From 654829c4fd6fdde01f0c79e5b6d0bbc16ea180c2 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 7 Jul 2026 20:36:18 +0800 Subject: [PATCH] fix: restore canvas pending feedback and scoped capabilities --- app/(chat)/api/chat/route.ts | 47 +++++++++++++--- components/canvas/CanvasChatPanel.tsx | 62 +++++++++++----------- lib/ai/runtime/task-boundary-classifier.ts | 5 +- lib/ai/runtime/task-boundary-text.ts | 12 +++++ tests/routes/canvas-chat-helpers.test.ts | 8 +++ 5 files changed, 93 insertions(+), 41 deletions(-) create mode 100644 lib/ai/runtime/task-boundary-text.ts diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 5b901905b..67e892f9b 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -257,6 +257,7 @@ import { type AgentModelSlots, } from "@/lib/ai/runtime/agent-model-slots"; import { isPureGreetingTurnRequest } from "@/lib/ai/runtime/task-boundary-classifier"; +import { isCapabilityQuestionTurnRequest } from "@/lib/ai/runtime/task-boundary-text"; import { getProviderHealthSnapshot, recordProviderHealthFailure, @@ -846,6 +847,19 @@ const DEFAULT_STREAM_KEEPALIVE_INTERVAL_MS = 12_000; const CHAT_CREATION_LOOKUP_RETRY_DELAYS_MS = [60, 180, 360] as const; const DETAIL_REPLACEMENT_PROMPT_PACK_REQUIRED_MESSAGE = "请先在「风格化提示词配置」里选择提示词组合,再执行详情场景替换;缺少提示词组时系统不会处理。"; +const CANVAS_AGENT_GREETING_MESSAGE = + "你好,我在。你可以直接告诉我想做什么:生图、修图、生成视频、分析图片都可以。"; +const CANVAS_AGENT_CAPABILITY_MESSAGE = [ + "我主要帮你完成画布里的视觉创作任务:", + "", + "- 生图:按文字、参考图或画布选中内容生成图片。", + "- 修图:换背景、去元素、局部重绘、扩图、增强、抠图、改尺寸。", + "- 视频:文生视频、图生视频、分镜/镜头视频需求整理与生成。", + "- 图片分析:识别主体、风格、构图、材质、光线,并整理可执行提示词。", + "- 画布协作:读取当前画布选择、槽位、最近结果,继续生成、修改或写回画布。", + "", + "你可以直接说:把这张图换成白底、参考左边图片生成 3 张主图、让这张图动起来。", +].join("\n"); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -1234,9 +1248,20 @@ export async function POST(request: Request) { const hasCurrentMessageMediaInput = message.parts.some((part: any) => part?.type === "file" || part?.type === "image" || part?.type === "image_url" ); + const isGreetingTurnRequest = isPureGreetingTurnRequest(requestTextContent); + const isCapabilityQuestionRequest = + isCapabilityQuestionTurnRequest(requestTextContent); + const canvasFastPathMessage = isCapabilityQuestionRequest + ? CANVAS_AGENT_CAPABILITY_MESSAGE + : isGreetingTurnRequest + ? CANVAS_AGENT_GREETING_MESSAGE + : ""; if ( - shouldRunAgentConversation && - isPureGreetingTurnRequest(requestTextContent) && + (shouldRunAgentConversation || + isCanvasPageRequest || + contextPayload?.source === "canvas" || + Boolean(requestCanvasAgentContext)) && + canvasFastPathMessage && !hasCurrentMessageMediaInput ) { const session = await auth(); @@ -1245,7 +1270,6 @@ export async function POST(request: Request) { return new ChatSDKError("unauthorized:chat").toResponse(); } - const greetingMessage = "你好,我在。你可以直接告诉我想做什么:生图、修图、生成视频、分析图片都可以。"; const fastPathTitle = requestTextContent.trim().slice(0, 80) || "新对话"; await saveChat({ id, @@ -1266,7 +1290,12 @@ export async function POST(request: Request) { const greetingStream = createUIMessageStream({ execute: async ({ writer }) => { const messageId = generateUUID(); - writeAssistantTextChunk(writer, greetingMessage, "greeting-fast-path", messageId); + writeAssistantTextChunk( + writer, + canvasFastPathMessage, + isCapabilityQuestionRequest ? "capability-fast-path" : "greeting-fast-path", + messageId + ); await saveMessages({ messages: [ @@ -1287,7 +1316,7 @@ export async function POST(request: Request) { id: messageId, chatId: id, role: "assistant", - parts: [{ type: "text", text: greetingMessage }], + parts: [{ type: "text", text: canvasFastPathMessage }], attachments: [], createdAt: new Date(), videoStorageStatus: null, @@ -8625,10 +8654,16 @@ export async function POST(request: Request) { selectedChatModel: finalModelId, requestHints, }); - const agentIdentityInstruction = shouldRunAgentConversation + const shouldUseProductIdentityInstruction = + shouldRunAgentConversation || + isCanvasPageRequest || + contextPayload?.source === "canvas" || + Boolean(requestCanvasAgentContext); + const agentIdentityInstruction = shouldUseProductIdentityInstruction ? await buildAgentRoleIdentityInstruction({ allowVideoGeneration: allowAgentVideoGeneration, hasCanvasContext: Boolean( + isCanvasPageRequest || effectiveContextPayload?.source === "canvas" || effectiveContextPayload?.selectedNodeIds?.length || effectiveContextPayload?.canvasImageNodes?.length, diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 19e17793d..7a894a236 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -1595,21 +1595,15 @@ function OpenClawQuickActions({ ); } -function CanvasAssistantTypingPlaceholder({ hint }: { hint?: string | null }) { +function CanvasAssistantTypingPlaceholder({ + hint, + label = "分析中", +}: { + hint?: string | null; + label?: string; +}) { return (
-
{hint ? (
@@ -1617,19 +1611,11 @@ function CanvasAssistantTypingPlaceholder({ hint }: { hint?: string | null }) {
) : null}
- {[0, 1, 2].map((index) => ( - - ))} + + {label}
@@ -11910,9 +11896,11 @@ export function CanvasChatPanel({ ); const isLoading = status === 'streaming' || status === 'submitted'; + const activeChatSendGuard = getSendGuard(activeChatId); + const isActiveChatPending = isLoading || activeChatSendGuard.inFlight; const isComposerUploading = Boolean(composerUploadProgress); const isSubmitting = - isLoading || isDetailGenerating || isClawdSubmitting || isComposerUploading; + isActiveChatPending || isDetailGenerating || isClawdSubmitting || isComposerUploading; const hasInputOrAttachments = Boolean( input.trim() || attachments.length > 0 || (!isDetailGenerationMode && selectedSkillTag) ); @@ -12287,15 +12275,23 @@ export function CanvasChatPanel({ const isDetailEmptyState = isDetailGenerationMode && renderableVisibleMessages.length === 0 && - !isLoading && + !isActiveChatPending && !shouldShowImageLoading; const isPlainCanvasEmptyState = !isWorkspacePresentation && !isDetailGenerationMode && !isVideoWorkspace && renderableVisibleMessages.length === 0 && - !isLoading && + !isActiveChatPending && !shouldShowImageLoading; + const latestRenderableMessage = renderableVisibleMessages.at(-1); + const shouldRenderAssistantTypingPlaceholder = + !isClawdMode && + !isDetailGenerationMode && + !shouldRenderImageLoading && + !hasActiveTypewriter && + isActiveChatPending && + latestRenderableMessage?.role === "user"; const workspaceEmptyTitle = isClawdMode ? `开始 ${CLAW_MODE_LABEL} 对话` @@ -13490,9 +13486,7 @@ export function CanvasChatPanel({ shouldAnimateAnalysis || shouldAnimatePlainText || isClawdSubmitting || - getSendGuard(activeChatId).inFlight || - status === "streaming" || - status === "submitted" + isActiveChatPending } onAction={handleOpenClawQuickAction} /> @@ -13564,9 +13558,13 @@ export function CanvasChatPanel({ estimatedWaitMs={loadingEstimatedWaitMs} /> )} + + {shouldRenderAssistantTypingPlaceholder ? ( + + ) : null} {isClawdMode && isClawdSubmitting ? ( - + ) : null}
diff --git a/lib/ai/runtime/task-boundary-classifier.ts b/lib/ai/runtime/task-boundary-classifier.ts index a4dddf4a2..fe6991ed2 100644 --- a/lib/ai/runtime/task-boundary-classifier.ts +++ b/lib/ai/runtime/task-boundary-classifier.ts @@ -5,6 +5,7 @@ import type { PendingGridConfirmation, } from "@/lib/ai/conversation-action-resolver"; import { isExplicitPromptOptimizationRequest } from "@/lib/ai/smart-analyzer"; +import { isCapabilityQuestionTurnRequest } from "@/lib/ai/runtime/task-boundary-text"; import { detectStoryboardCommandIntent } from "@/lib/video-agent/storyboard-command-interpreter"; export type TaskBoundaryType = @@ -88,9 +89,7 @@ function isLikelyChatOnlyRequest(text: string): boolean { const normalized = normalizeCompactText(text); if (!normalized) return false; - return /你能做什么|你会什么|你可以做什么|你能帮我做什么|你能干什么|你可以帮我做什么|介绍一下你自己|介绍一下自己|说说你的能力|你有哪些功能|你有什么功能|怎么用|怎么玩|如何使用|帮助说明|帮助手册|功能介绍/.test( - normalized, - ); + return isCapabilityQuestionTurnRequest(normalized); } function isLikelyAnalysisOnlyRequest(text: string, hasImageContext: boolean): boolean { diff --git a/lib/ai/runtime/task-boundary-text.ts b/lib/ai/runtime/task-boundary-text.ts new file mode 100644 index 000000000..dad761f7f --- /dev/null +++ b/lib/ai/runtime/task-boundary-text.ts @@ -0,0 +1,12 @@ +export function normalizeCompactTaskBoundaryText(text: string) { + return String(text || "").toLowerCase().replace(/\s+/g, "").trim(); +} + +export function isCapabilityQuestionTurnRequest(text: string): boolean { + const normalized = normalizeCompactTaskBoundaryText(text); + if (!normalized) return false; + + return /你.*(?:能|会|可以|可|嫩)?.*(?:为我做什么|帮我做什么|帮我什么|做什么|干什么)|你.*(?:有哪些|有什么).*(?:功能|能力)|(?:介绍|说说).*(?:功能|能力|你自己|自己)|怎么用|怎么玩|如何使用|帮助说明|帮助手册|功能介绍/.test( + normalized, + ); +} diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 6b0027ab4..bb5d878e7 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -56,6 +56,7 @@ import { buildRecentDialogueMemory, isRecentDialogueRecallRequest, } from "@/lib/ai/runtime/recent-dialogue-memory"; +import { isCapabilityQuestionTurnRequest } from "@/lib/ai/runtime/task-boundary-text"; import { isAgentModeScopedChatId, isScopedEmptyHistoryRequest, @@ -326,6 +327,13 @@ test.describe("canvas chat pure helpers", () => { expect(upstream).toContain("| SKU | 销量 |"); }); + test("recognizes canvas product capability questions with typo tolerance", () => { + expect(isCapabilityQuestionTurnRequest("你能为我做什么?")).toBe(true); + expect(isCapabilityQuestionTurnRequest("你嫩为我做什么?")).toBe(true); + expect(isCapabilityQuestionTurnRequest("你有哪些功能")).toBe(true); + expect(isCapabilityQuestionTurnRequest("帮我把这张图换成白底")).toBe(false); + }); + test("builds bounded recent dialogue memory for user recall requests", () => { const memory = buildRecentDialogueMemory({ messages: [