From 77174f4e2191af81002bb6085d87ea968b0348c1 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 7 Jul 2026 14:12:18 +0800 Subject: [PATCH 01/16] feat: add downloads for style pro and ai wash results --- components/canvas/AiWashWorkspace.tsx | 53 +++++++++++++++ components/style-pro/StyleProWorkspace.tsx | 76 +++++++++++++++++++++- 2 files changed, 128 insertions(+), 1 deletion(-) diff --git a/components/canvas/AiWashWorkspace.tsx b/components/canvas/AiWashWorkspace.tsx index 5e878afe9..10690b970 100644 --- a/components/canvas/AiWashWorkspace.tsx +++ b/components/canvas/AiWashWorkspace.tsx @@ -3,6 +3,7 @@ import { ChevronDown, Clock3, + Download, History as HistoryIcon, ImageIcon, Loader2, @@ -45,6 +46,7 @@ import { type AiWashControlLevel, type AiWashPreset, } from "@/lib/ai-wash/config"; +import { downloadCanvasImageAsPng } from "@/lib/client/canvas-image-download"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import { cn } from "@/lib/utils"; import { imageGenerationService } from "@/services/canvas"; @@ -368,6 +370,11 @@ function getAiWashStatusLabel(status?: AiWashResultStatus) { } } +function buildAiWashDownloadName(result: AiWashResultCard) { + const createdAt = result.createdAt || Date.now(); + return `ai-wash-${result.title || result.presetId || "result"}-${createdAt}`; +} + function composePresetPrompt(params: { preset: AiWashPreset; selectedLevelIndexByControl: Record; @@ -456,6 +463,9 @@ export function AiWashWorkspace({ useState>({}); const [historySourceOverride, setHistorySourceOverride] = useState(""); const [isSubmitting, setIsSubmitting] = useState(false); + const [downloadingResultId, setDownloadingResultId] = useState( + null + ); const [isHistoryExpanded, setIsHistoryExpanded] = useState(false); const [lastErrorMessage, setLastErrorMessage] = useState(""); const [comparePositionById, setComparePositionById] = useState< @@ -977,6 +987,30 @@ export function AiWashWorkspace({ }); }, [compareSourceUrl, onApplyResult, selectedResult]); + const handleDownloadSelectedResult = useCallback(async () => { + if (!selectedResult?.imageUrl) { + toast.error("请先选择一张洗图结果"); + return; + } + + const downloadId = selectedResult.presetId || selectedResult.imageUrl; + setDownloadingResultId(downloadId); + try { + await downloadCanvasImageAsPng({ + url: selectedResult.imageUrl, + name: buildAiWashDownloadName(selectedResult), + candidates: [selectedResult.imageUrl], + }); + toast.success("图片已开始下载"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "下载失败"); + } finally { + setDownloadingResultId((current) => + current === downloadId ? null : current + ); + } + }, [selectedResult]); + const handleLoadHistoryBatch = useCallback((entry: AiWashHistoryBatch) => { const loadedPresetId = (entry.items.find((item) => item.isDefault) || entry.items[0])?.presetId || ""; @@ -1430,6 +1464,25 @@ export function AiWashWorkspace({ + {selectedResult?.imageUrl ? ( + + ) : null} + + ) : null} ); } @@ -1021,6 +1055,9 @@ export function StyleProWorkspace({ useState("style"); const [selectedModelId, setSelectedModelId] = useState(""); const [uploading, setUploading] = useState(false); + const [downloadingImageId, setDownloadingImageId] = useState( + null + ); const [generationTaskState, setGenerationTaskState] = useState(null); const generating = @@ -1458,6 +1495,34 @@ export function StyleProWorkspace({ [config] ); + const handleDownloadResult = useCallback( + async (item?: StyleProHistoryItem | null) => { + const target = item || currentResult; + if (!target?.resultImageUrl) { + toast.error("暂无可下载的结果图"); + return; + } + + const downloadId = target.id || target.resultImageUrl; + setDownloadingImageId(downloadId); + try { + await downloadCanvasImageAsPng({ + url: target.resultImageUrl, + name: buildStyleProDownloadName(target), + candidates: [target.resultImageUrl], + }); + toast.success("图片已开始下载"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "下载失败"); + } finally { + setDownloadingImageId((current) => + current === downloadId ? null : current + ); + } + }, + [currentResult] + ); + const disabledReason = !sourceImageUrl ? "请先上传图片" : generationMode === "realistic" && config?.realisticModeAvailable === false @@ -1875,6 +1940,15 @@ export function StyleProWorkspace({ { + void handleDownloadResult(currentResult); + }} taskState={generationTaskState} /> From 1781977486866958a01e0fbdf441966bded04e01 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 7 Jul 2026 14:27:48 +0800 Subject: [PATCH 02/16] fix: keep canvas chat messages ordered --- .../canvas/chat-panel/canvas-chat-media.ts | 12 +++-- tests/routes/canvas-chat-helpers.test.ts | 49 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/components/canvas/chat-panel/canvas-chat-media.ts b/components/canvas/chat-panel/canvas-chat-media.ts index 0d022f2d6..88d2e2269 100644 --- a/components/canvas/chat-panel/canvas-chat-media.ts +++ b/components/canvas/chat-panel/canvas-chat-media.ts @@ -381,11 +381,15 @@ export function sortCanvasMessagesByCreatedAt(messages: ChatMessage[]): ChatMess .sort((a, b) => { const messageA = a.message; const messageB = b.message; - const timeA = getMessageTimestamp(messageA) ?? 0; - const timeB = getMessageTimestamp(messageB) ?? 0; - if (timeA !== timeB) return timeA - timeB; + const timeA = getMessageTimestamp(messageA); + const timeB = getMessageTimestamp(messageB); + const hasTimeA = timeA !== null; + const hasTimeB = timeB !== null; - if (messageA.role !== messageB.role) { + if (hasTimeA && hasTimeB && timeA !== timeB) return timeA - timeB; + if (hasTimeA !== hasTimeB) return hasTimeA ? -1 : 1; + + if (hasTimeA && hasTimeB && messageA.role !== messageB.role) { if (messageA.role === "user") return -1; if (messageB.role === "user") return 1; } diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index d51b0caec..6edd96b14 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -4,6 +4,7 @@ import { extractAssistantImageUrls, extractVideoUrls, normalizeMediaUrl, + sortCanvasMessagesByCreatedAt, stripMediaMarkup, } from "@/components/canvas/chat-panel/canvas-chat-media"; import { @@ -211,6 +212,54 @@ import type { CanvasAgentContext } from "@/types/canvas/agent-protocol"; import type { ChatMessage } from "@/lib/types"; test.describe("canvas chat pure helpers", () => { + test("keeps optimistic messages without timestamps after persisted history", () => { + const messages = [ + { + id: "optimistic-latest-user", + role: "user", + parts: [{ type: "text", text: "你能为我做什么?列出表给我" }], + }, + { + id: "persisted-first-user", + role: "user", + createdAt: "2026-07-07T01:00:00.000Z", + parts: [{ type: "text", text: "你好" }], + }, + { + id: "persisted-first-assistant", + role: "assistant", + createdAt: "2026-07-07T01:00:01.000Z", + parts: [{ type: "text", text: "你好,我在。" }], + }, + ] as ChatMessage[]; + + expect(sortCanvasMessagesByCreatedAt(messages).map((message) => message.id)).toEqual([ + "persisted-first-user", + "persisted-first-assistant", + "optimistic-latest-user", + ]); + }); + + test("preserves relative order among messages without timestamps", () => { + const messages = [ + { + id: "pending-user", + role: "user", + parts: [{ type: "text", text: "生成一张图" }], + }, + { + id: "pending-assistant", + role: "assistant", + parts: [{ type: "text", text: "分析中" }], + }, + ] as ChatMessage[]; + + expect(sortCanvasMessagesByCreatedAt(messages).map((message) => message.id)).toEqual([ + "pending-user", + "pending-assistant", + ]); + }); + test("formats uploaded spreadsheet rows as safe markdown tables for OpenClaw", () => { const markdown = rowsToOpenClawMarkdownTable([ ["名称", "备注"], From 2d6289f0fa55eae0598e34c13098e5fae550b205 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 7 Jul 2026 14:38:26 +0800 Subject: [PATCH 03/16] fix: add agent preface before generation --- app/(chat)/api/chat/route.ts | 86 +++++++++++++++++++++++++++ components/canvas/CanvasChatPanel.tsx | 5 +- 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 3dd0b80fc..df38dbc43 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -541,6 +541,65 @@ function writeCanvasOperationShadowChunk(params: { ); } +const AGENT_EXECUTION_PREFACE_INTERNAL_PATTERN = + /(?:^|\n)\s*(?:#{1,6}\s*)?(?:需求理解|图片分析|执行计划|输出设置|优化后提示词|使用建议)(?:\s|[::]|$)|【\s*(?:需求理解|图片分析|执行计划|输出设置|优化后提示词|使用建议)\s*】|提示词|自动优化|模型|提交生成|回写|流程|步骤/; + +function buildAgentExecutionPrefaceText(params: { + friendlyMessage?: string | null; + fallback: string; +}) { + const fallback = String(params.fallback || "").replace(/\s+/g, " ").trim(); + const rawMessage = String(params.friendlyMessage || "") + .replace(//g, "") + .trim(); + + if (!rawMessage || AGENT_EXECUTION_PREFACE_INTERNAL_PATTERN.test(rawMessage)) { + return fallback; + } + + const candidate = rawMessage + .split(/\r?\n/) + .map((line) => line.replace(/^[-*]\s*/, "").trim()) + .find(Boolean); + + if (!candidate || AGENT_EXECUTION_PREFACE_INTERNAL_PATTERN.test(candidate)) { + return fallback; + } + + const firstSentence = candidate.match(/^(.{1,80}?[。!?!?~~])/u)?.[1] || candidate; + const compact = firstSentence.replace(/\s+/g, " ").trim().slice(0, 80); + return /[。!?!?~~]$/.test(compact) ? compact : `${compact}。`; +} + +async function writeAgentExecutionPrefaceMessage(params: { + writer: StreamWriter; + chatId: string; + label: string; + friendlyMessage?: string | null; + fallback: string; +}) { + const text = buildAgentExecutionPrefaceText({ + friendlyMessage: params.friendlyMessage, + fallback: params.fallback, + }); + if (!text) return null; + + const messageId = generateUUID(); + writeAssistantTextChunk(params.writer, text, params.label, messageId); + + try { + await saveAssistantTextOnlyMessage({ + chatId: params.chatId, + messageId, + text, + }); + } catch (error) { + chatApiLogger.warn("[Chat API] Failed to persist agent execution preface:", error); + } + + return messageId; +} + async function replayAssistantMessagesForDuplicateRequest({ chatId, userMessageId, @@ -7715,6 +7774,16 @@ export async function POST(request: Request) { ) { const directImageStream = createUIMessageStream({ execute: async ({ writer }) => { + await writeAgentExecutionPrefaceMessage({ + writer, + chatId: id, + label: "agent:generate_image:preface", + friendlyMessage: smartAutoFriendlyMessage, + fallback: + directAgentToolTargetImageUrls.length > 0 + ? "收到,我会参考你选的图片来生成新画面,稍等一下。" + : "好的,我按你的描述来做一版画面,稍等我处理一下。", + }); writeAgentProgressChunk( writer, buildDirectAgentImageContextProgress({ @@ -7917,6 +7986,16 @@ export async function POST(request: Request) { ) { const directVideoStream = createUIMessageStream({ execute: async ({ writer }) => { + await writeAgentExecutionPrefaceMessage({ + writer, + chatId: id, + label: "agent:generate_video:preface", + friendlyMessage: smartAutoFriendlyMessage, + fallback: + directAgentToolTargetImageUrls.length > 0 + ? "收到,我会参考你选的画面来生成视频,稍等一下。" + : "好的,我按你的需求来做这段视频,稍等我处理一下。", + }); const assistantMessageId = generateUUID(); writeAgentProgressChunk( writer, @@ -8123,6 +8202,13 @@ export async function POST(request: Request) { const directToolLabel = getDirectAgentToolLabel(directAgentToolCapability); const directToolStream = createUIMessageStream({ execute: async ({ writer }) => { + await writeAgentExecutionPrefaceMessage({ + writer, + chatId: id, + label: `agent:${directAgentToolCapability}:preface`, + friendlyMessage: smartAutoFriendlyMessage, + fallback: `好的,我来帮你${directToolLabel},稍等我处理一下。`, + }); writeAgentProgressChunk( writer, buildDirectAgentImageContextProgress({ diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 2e48a2353..345fef046 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -12225,10 +12225,7 @@ export function CanvasChatPanel({ const isAgentTargetClarificationActive = shouldShowAgentTargetLockCard && activeAgentTargetLock?.stage === "clarify"; const shouldShowAgentProgressCard = - activeComposerMode === "agent" && - Boolean(activeAgentProgress) && - activeAgentProgress?.stage !== "completed" && - activeAgentProgress?.stage !== "failed"; + false; const agentTargetLockPreviewItems = useMemo(() => { if (!activeAgentTargetLock) return [] as AgentTargetLockRefPreview[]; const targets = Array.isArray(activeAgentTargetLock.targets) ? activeAgentTargetLock.targets : []; From 7e1fd15ebdc09ee43a7166656e0c63eda3f9ffb9 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 7 Jul 2026 14:54:58 +0800 Subject: [PATCH 04/16] fix: stabilize canvas agent chat feedback --- components/canvas/CanvasChatPanel.tsx | 72 ++++++++++++-- .../runtime/direct-agent-response-builders.ts | 94 ++++++++++++++----- tests/routes/canvas-chat-helpers.test.ts | 26 +++++ 3 files changed, 162 insertions(+), 30 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 345fef046..e4516c616 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -792,6 +792,8 @@ const CANVAS_AGENT_SECTION_TITLES = new Set([ "需求理解", CANVAS_IMAGE_ANALYSIS_TITLE, "执行计划", + "执行结果", + "后续操作", "优化后提示词", "输出设置", ]); @@ -2303,6 +2305,7 @@ export function CanvasChatPanel({ const [analysisCollapseState, setAnalysisCollapseState] = useState>({}); const [typedAssistantSections, setTypedAssistantSections] = useState>({}); const [activeTypingSections, setActiveTypingSections] = useState>({}); + const [agentTypewriterMessageIds, setAgentTypewriterMessageIds] = useState>({}); const [clawdTypewriterMessageIds, setClawdTypewriterMessageIds] = useState>({}); const [insufficientPointsState, setInsufficientPointsState] = useState<{ balance: number; @@ -6466,6 +6469,18 @@ export function CanvasChatPanel({ const normalizedAttachments = Array.isArray(messageData.attachments) ? messageData.attachments : []; + const appendedMessageId = String(messageData.id || "").trim(); + const appendedAssistantText = normalizedParts + .filter( + (part: any) => + part?.type === "text" && typeof part?.text === "string" + ) + .map((part: any) => String(part.text || "")) + .join("\n\n"); + const appendedAssistantVisibleText = normalizeCanvasAgentText( + stripCanvasModelRoutingLines(stripMediaMarkup(appendedAssistantText)) + .replace(//g, "") + ); const emptyNearestUserImageKeys = new Set(); const incomingImageUrls = normalizedParts.flatMap((part: any) => part?.type === "text" && typeof part?.text === "string" @@ -6525,6 +6540,18 @@ export function CanvasChatPanel({ if (!isEventForActiveChat) { return; } + + if ( + messageData.role === "assistant" && + appendedMessageId && + appendedAssistantVisibleText + ) { + setAgentTypewriterMessageIds((previousIds) => + previousIds[appendedMessageId] + ? previousIds + : { ...previousIds, [appendedMessageId]: true } + ); + } setMessages((prevMessages) => { const existingIndex = prevMessages.findIndex( @@ -6993,11 +7020,20 @@ export function CanvasChatPanel({ currentModelIdRef.current = selectedModelId; inFlightChatIdRef.current = activeChatId; inFlightRawChatIdRef.current = rawChatId; + const launchUserMessageId = generateUUID(); + const launchCreatedAt = new Date(); sendMessage({ + id: launchUserMessageId, role: "user", parts, - }); + createdAt: launchCreatedAt, + metadata: { + createdAt: launchCreatedAt.toISOString(), + composerMode: activeComposerModeRef.current || activeComposerMode, + modelId: selectedModelId, + }, + } as any); hasHydratedHistoryRef.current = true; pendingLaunchRef.current = null; @@ -7010,7 +7046,7 @@ export function CanvasChatPanel({ if (editableDiv) { editableDiv.innerHTML = ""; } - }, [activeChatId, rawChatId, selectedModelId, status, sendMessage, clearSelection, launchAutoSendTick]); + }, [activeChatId, rawChatId, selectedModelId, status, sendMessage, clearSelection, launchAutoSendTick, activeComposerMode]); // 当历史消息加载完成后,更新消息列表(支持同一会话的增量刷新) const loadedHistorySignatureRef = useRef(""); @@ -10821,6 +10857,20 @@ export function CanvasChatPanel({ !shouldTreatAsCanvasAgentControlIntent && effectiveComposerMode === "image" && Boolean(requestModel && isCanvasImageModel(requestModel)); + const userMessageId = generateUUID(); + const latestMessageTimestamp = messages.reduce( + (latest, message) => Math.max(latest, getMessageTimestamp(message) ?? 0), + 0 + ); + const optimisticCreatedAt = new Date( + Math.max(Date.now(), latestMessageTimestamp + 1) + ); + const optimisticUserMetadata = { + createdAt: optimisticCreatedAt.toISOString(), + composerMode: effectiveComposerMode, + agentEntryMode: effectiveAgentEntryMode, + modelId: effectiveModelId, + }; // 🆕 隐藏提示词(不在 UI 中展示) const hiddenSegments: string[] = []; @@ -10933,9 +10983,12 @@ export function CanvasChatPanel({ // 使用与 multimodal-input 相同的格式发送消息 try { sendMessage({ + id: userMessageId, role: "user", parts: parts, - }); + createdAt: optimisticCreatedAt, + metadata: optimisticUserMetadata, + } as any); } catch (error) { releaseSendGuard(activeChatId); if (shouldShowOptimisticImageGeneration) { @@ -11019,6 +11072,7 @@ export function CanvasChatPanel({ markSessionClawdSkillTagPromptApplied, buildHistoryMessagesUrl, workflowContext, + messages, ]); const handleOpenClawQuickAction = useCallback( @@ -12953,6 +13007,11 @@ export function CanvasChatPanel({ const shouldTypeClawAssistantMessage = message.role === "assistant" && Boolean(clawdTypewriterMessageIds[message.id]); + const shouldTypeAgentAssistantMessage = + message.role === "assistant" && + Boolean(agentTypewriterMessageIds[message.id]); + const shouldForceTypeAssistantMessage = + shouldTypeClawAssistantMessage || shouldTypeAgentAssistantMessage; const isClawAssistantMessage = message.role === "assistant" && (isClawdMode || isClawdChatMessage(message)); @@ -13161,16 +13220,16 @@ export function CanvasChatPanel({ !typedAssistantSections[analysisTypingKey] && ((shouldTypeAssistantMessage && !hasCanvasRenderableMarkdown(analysisSection?.analysis || "")) || - shouldTypeClawAssistantMessage); + shouldForceTypeAssistantMessage); const shouldAnimatePlainText = !analysisSection && Boolean(plainText) && !typedAssistantSections[plainTextTypingKey] && ((shouldTypeAssistantMessage && !hasCanvasRenderableMarkdown(plainText)) || - shouldTypeClawAssistantMessage); + shouldForceTypeAssistantMessage); const shouldDeferAssistantMedia = - isClawAssistantMessage && + (isClawAssistantMessage || shouldTypeAgentAssistantMessage) && (shouldAnimateAnalysis || shouldAnimatePlainText); const visibleImageUrls = shouldDeferAssistantMedia ? [] @@ -13182,6 +13241,7 @@ export function CanvasChatPanel({ !isDetailGenerationMode && !isClawAssistantMessage && visibleImageUrls.length > 0 && + !plainText.trim() && !plainText.includes(CANVAS_IMAGE_DONE_SUMMARY) && !isLatestAssistantDuringGeneration; const renderPlainText = (text: string, key: string) => ( diff --git a/lib/ai/runtime/direct-agent-response-builders.ts b/lib/ai/runtime/direct-agent-response-builders.ts index c1b349c86..0576f56d1 100644 --- a/lib/ai/runtime/direct-agent-response-builders.ts +++ b/lib/ai/runtime/direct-agent-response-builders.ts @@ -10,6 +10,56 @@ import { type DirectAgentToolCapability, } from "@/lib/ai/runtime/direct-agent-execution"; +function normalizeDirectAgentResultSentence(value: string) { + const normalized = String(value || "") + .replace(/结果如下[::]?\s*$/u, "") + .replace(/[,,。::]\s*$/u, "") + .trim(); + return normalized || "已完成处理"; +} + +function buildDirectAgentResultCard(params: { + result: string; + mediaMarkdown?: string | null; + followUp: string; +}) { + return [ + "### 执行结果", + `- ${normalizeDirectAgentResultSentence(params.result)},结果如下。`, + params.mediaMarkdown, + "### 后续操作", + `- ${params.followUp}`, + ] + .filter(Boolean) + .join("\n\n"); +} + +function getDirectAgentFollowUpMessage(capability: DirectAgentToolCapability) { + switch (capability) { + case "remove_background": + return "如需微调边缘、替换背景或继续合成,可以直接告诉我。"; + case "remove_watermark": + return "如需继续修补局部痕迹或统一画面质感,可以直接告诉我。"; + case "enhance": + return "如需继续调整清晰度、色彩或细节强度,可以直接告诉我。"; + case "resize": + return "如需继续改尺寸、比例或输出规格,可以直接告诉我。"; + case "extend": + return "如需继续扩展画面、调整构图或补充边缘内容,可以直接告诉我。"; + case "angle_variant": + return "如需继续换角度、换机位或统一系列视角,可以直接告诉我。"; + case "local_edit": + case "box_cutout": + return "如需继续修改局部、替换元素或细化边缘,可以直接告诉我。"; + case "generate_video": + return "如需继续调整镜头、节奏、比例或时长,可以直接告诉我。"; + case "generate_image": + case "generic_edit": + default: + return "如需继续调整风格、构图、主体细节或尺寸,可以直接告诉我。"; + } +} + export function buildDirectAgentAnalysisAssistantText(params: { summary: string; answer?: string | null; @@ -32,12 +82,11 @@ export function buildDirectAgentImageGenerationAssistantText(params: { modelDisplayName?: string | null; }) { return prependImageModelBadge( - [ - getDirectAgentToolSuccessMessage("generate_image"), - params.imageMarkdown, - ] - .filter(Boolean) - .join("\n\n"), + buildDirectAgentResultCard({ + result: getDirectAgentToolSuccessMessage("generate_image"), + mediaMarkdown: params.imageMarkdown, + followUp: getDirectAgentFollowUpMessage("generate_image"), + }), params.modelDisplayName, ); } @@ -47,12 +96,11 @@ export function buildDirectAgentVideoGenerationAssistantText(params: { modelDisplayName?: string | null; }) { return prependImageModelBadge( - [ - getDirectAgentToolSuccessMessage("generate_video"), - buildAgentToolVideoMarkdown(params.videoUrl), - ] - .filter(Boolean) - .join("\n\n"), + buildDirectAgentResultCard({ + result: getDirectAgentToolSuccessMessage("generate_video"), + mediaMarkdown: buildAgentToolVideoMarkdown(params.videoUrl), + followUp: getDirectAgentFollowUpMessage("generate_video"), + }), params.modelDisplayName, ); } @@ -64,16 +112,15 @@ export function buildDirectAgentImageEditAssistantText(params: { imageSize?: string; }) { return prependImageModelBadge( - [ - getDirectAgentToolSuccessMessage(params.capability), - buildAgentToolImageMarkdown({ + buildDirectAgentResultCard({ + result: getDirectAgentToolSuccessMessage(params.capability), + mediaMarkdown: buildAgentToolImageMarkdown({ originalUrl: params.originalUrl, thumbnailUrl: params.thumbnailUrl, imageSize: params.imageSize, }), - ] - .filter(Boolean) - .join("\n\n"), + followUp: getDirectAgentFollowUpMessage(params.capability), + }), getDirectAgentToolLabel(params.capability), ); } @@ -87,12 +134,11 @@ export function buildDirectAgentBatchImageEditAssistantText(params: { }>; }) { return prependImageModelBadge( - [ - getDirectAgentToolSuccessMessage(params.capability), - buildAgentToolImagesMarkdown(params.results), - ] - .filter(Boolean) - .join("\n\n"), + buildDirectAgentResultCard({ + result: getDirectAgentToolSuccessMessage(params.capability), + mediaMarkdown: buildAgentToolImagesMarkdown(params.results), + followUp: getDirectAgentFollowUpMessage(params.capability), + }), getDirectAgentToolLabel(params.capability), ); } diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 6edd96b14..c4d257b53 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -131,6 +131,10 @@ import { resolveDirectAgentToolExecution, shouldRunDirectAgentBatchImageEdit, } from "@/lib/ai/runtime/direct-agent-execution"; +import { + buildDirectAgentImageEditAssistantText, + buildDirectAgentImageGenerationAssistantText, +} from "@/lib/ai/runtime/direct-agent-response-builders"; import { inferAngleEditOptionsFromText } from "@/lib/ai/runtime/angle-edit-options"; import { hasDirectAgentLocalEditIntent, @@ -1179,6 +1183,28 @@ test.describe("canvas chat pure helpers", () => { ); }); + test("formats direct agent image results as structured markdown cards", () => { + const generatedText = buildDirectAgentImageGenerationAssistantText({ + imageMarkdown: "![生成结果](https://cdn.example.com/generated.png)", + modelDisplayName: "Test Image Model", + }); + + expect(generatedText).toContain("### 执行结果"); + expect(generatedText).toContain("- 已完成图片生成,结果如下。"); + expect(generatedText).toContain("![生成结果](https://cdn.example.com/generated.png)"); + expect(generatedText).toContain("### 后续操作"); + + const editText = buildDirectAgentImageEditAssistantText({ + capability: "remove_background", + originalUrl: "https://cdn.example.com/cutout.png", + }); + + expect(editText).toContain("### 执行结果"); + expect(editText).toContain("- 已完成抠图处理,结果如下。"); + expect(editText).toContain("https://cdn.example.com/cutout.png"); + expect(editText).toContain("替换背景"); + }); + test("only runs direct image edit batches for explicit safe batch requests", () => { expect( shouldRunDirectAgentBatchImageEdit({ From 8f679a100c096d760cf4965fcec036e8ace57bc0 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 7 Jul 2026 15:17:43 +0800 Subject: [PATCH 05/16] feat: add canvas agent orchestration kernel --- app/(chat)/api/chat/route.ts | 14 +- components/canvas/CanvasChatPanel.tsx | 28 ++-- lib/ai/context-memory/target-resolution.ts | 53 +++++-- lib/ai/runtime/canvas-agent-kernel.ts | 156 +++++++++++++++++++++ lib/ai/runtime/intent-arbiter.ts | 28 ++-- tests/routes/canvas-chat-helpers.test.ts | 94 +++++++++++++ 6 files changed, 338 insertions(+), 35 deletions(-) create mode 100644 lib/ai/runtime/canvas-agent-kernel.ts diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index df38dbc43..c3be7a608 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -262,6 +262,7 @@ import { recordProviderHealthSuccess, shouldAvoidProvider, } from "@/lib/ai/runtime/provider-health"; +import { resolveCanvasAgentRequestPolicy } from "@/lib/ai/runtime/canvas-agent-kernel"; import { arbitrateAgentIntent } from "@/lib/ai/runtime/intent-arbiter"; import { buildAgentRoleIdentityInstruction } from "@/lib/ai/runtime/agent-role-profile"; import { getVideoStoryboardConfig } from "@/lib/video-agent/config"; @@ -1095,8 +1096,19 @@ export async function POST(request: Request) { const shouldRespectDirectComposerMode = composerMode === "image" || composerMode === "video" || composerMode === "detail"; const shouldIsolateDirectComposerContext = shouldRespectDirectComposerMode; - let shouldRunAgentConversation = finalModelId === "auto"; + const requestPolicy = resolveCanvasAgentRequestPolicy({ + composerMode, + agentEntryMode: agentPresetId ? "preset" : "auto", + selectedChatModel: finalModelId, + hasCanvasControlIntentFastPath, + }); + finalModelId = requestPolicy.effectiveSelectedChatModel; + let shouldRunAgentConversation = + finalModelId === "auto" || requestPolicy.shouldForceToolOrchestration; let shouldKeepSelectedModel = false; + if (requestPolicy.reason === "agent_auto_composer") { + chatApiLogger.info("[Smart Auto Mode] 🧭 Agent composer forces tool orchestration, ignoring stale selected model"); + } if (shouldRespectDirectComposerMode && shouldRunAgentConversation) { shouldRunAgentConversation = false; chatApiLogger.info(`[Smart Auto Mode] ⛳ Direct composer mode (${composerMode}) disables initial auto-agent routing`); diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index e4516c616..8fef5c262 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -77,6 +77,7 @@ import { getImageCreditCostFromConfig, getImageModelCreditDiscountLabel, } from "@/lib/ai/image-model-pricing"; +import { resolveCanvasAgentRequestPolicy } from "@/lib/ai/runtime/canvas-agent-kernel"; import { getVideoToolOptionsForMode } from "@/lib/ai/video-tool-capability"; import type { ChatMessage } from "@/lib/types"; import type { @@ -5352,14 +5353,8 @@ export function CanvasChatPanel({ } if (mode === "agent") { - const preferredAgentModelId = - allModels.find( - (model) => - model.id === lastConversationModelId && - isCanvasConversationOnlyModel(model) - )?.id || "auto"; - currentModelIdRef.current = preferredAgentModelId; - setSelectedModelId(preferredAgentModelId); + currentModelIdRef.current = "auto"; + setSelectedModelId("auto"); setIsDetailGenerationMode(false); return targetChatSessionId; } @@ -6160,7 +6155,15 @@ export function CanvasChatPanel({ const hiddenPrompt = hiddenPromptRef.current || undefined; const autoContext = autoContextRef.current || undefined; const contextPayload = capturedContextPayload; - const requestModel = allModels.find((model) => model.id === currentModelIdRef.current); + const requestPolicy = resolveCanvasAgentRequestPolicy({ + composerMode: effectiveComposerMode, + agentEntryMode: effectiveAgentEntryMode, + selectedChatModel: currentModelIdRef.current, + }); + const effectiveSelectedChatModel = requestPolicy.effectiveSelectedChatModel; + const requestModel = allModels.find( + (model) => model.id === effectiveSelectedChatModel + ); const requestIsVideoModel = isCanvasVideoModel(requestModel); const requestIsConversationModel = isCanvasConversationOnlyModel(requestModel); hiddenPromptRef.current = null; @@ -6177,10 +6180,13 @@ export function CanvasChatPanel({ selectedAgentPreset?.id ? selectedAgentPreset.id : undefined, - selectedChatModel: currentModelIdRef.current, + selectedChatModel: effectiveSelectedChatModel, composerMode: effectiveComposerMode, selectedVisibilityType: "private", - deepThinkingEnabled: requestIsConversationModel + deepThinkingEnabled: + effectiveComposerMode === "agent" + ? undefined + : requestIsConversationModel ? false : requestIsVideoModel || currentModelIdRef.current === "auto" ? undefined diff --git a/lib/ai/context-memory/target-resolution.ts b/lib/ai/context-memory/target-resolution.ts index e0d8f8064..6c69dd7ef 100644 --- a/lib/ai/context-memory/target-resolution.ts +++ b/lib/ai/context-memory/target-resolution.ts @@ -5,16 +5,17 @@ import type { ResolvedImageRef, ResolvedTargetAssets, } from "@/lib/ai/context-memory/types"; +import { + hasCanvasAgentActionableImageEditIntent, + hasCanvasAgentDeicticTarget, + resolveCanvasAgentTargetPreference, +} from "@/lib/ai/runtime/canvas-agent-kernel"; const ORIGINAL_IMAGE_REGEX = /(原图|上传图|参考图|最初那张|第一张图|素材图|源图|原始图片|原始那张|我发的那张|上传的那张|原来的图)/i; const LATEST_RESULT_REGEX = /(上一张|上一轮|刚生成|最新生成|这个结果|该结果|按这个结果|继续在这张|基于这张结果|延续上一次结果|上一版|刚才那张结果|生成的图|生成图|结果图|出图结果|刚出的图|上一张生成图|上一轮结果图|现在这张生成图)/i; -const DEICTIC_TARGET_REGEX = - /(这张图|这只|这个|这款|这套|当前这张|当前这个|当前这只|这一张|这一只|这个产品|这个物体|这只猫|这只狗)/i; const SELECTION_REGEX = /(选中的|当前这张|我选的|这几张|这些图|选中图)/i; -const ACTIONABLE_EDIT_REGEX = - /(改|调整|优化|换|替换|去掉|抠|重绘|增强|修|处理|按照|按|保留|统一|色差|背景|清晰|风格|亮一点|暗一点|白底|透明底)/i; const ACTIONABLE_GENERATION_REGEX = /(生图|生成|画一张|出图|来一张|再来一张|做一张|做图|做视频|视频生成|生成视频|动起来)/i; const IMPLICIT_HISTORY_TARGET_REGEX = @@ -255,6 +256,7 @@ export function resolveTargetAssets( const focusPolicy = params.contextPayload?.focusPolicy || "auto"; const workingStateGeneratedUrls = normalizeStringArray(workingState?.lastGeneratedImageUrls); const workingStateUploadedUrls = normalizeStringArray(workingState?.lastUploadedImageUrls); + const workingStateResolvedUrls = normalizeStringArray(workingState?.lastResolvedTargetImageUrls); const generatedRefs = workingStateGeneratedUrls.length > 0 ? toWorkingMemoryRefs(workingStateGeneratedUrls) : assistantRefs; const originalRefs = @@ -384,7 +386,39 @@ export function resolveTargetAssets( }); } - if (DEICTIC_TARGET_REGEX.test(text)) { + const canvasAgentTargetPreference = resolveCanvasAgentTargetPreference({ + text, + selectedImageCount: selectedRefs.length, + latestResultImageCount: generatedRefs.length, + workingMemoryTargetImageCount: workingStateResolvedUrls.length, + }); + if (canvasAgentTargetPreference.shouldPreferLatestResult) { + return buildResolvedTargetAssets({ + targetRefs: generatedRefs, + candidateRefs, + resolutionMode: + workingStateGeneratedUrls.length > 0 ? "working_memory" : "latest_result", + confidence: canvasAgentTargetPreference.confidence, + needsClarification: false, + reason: "用户使用指代表达并提出图片编辑意图,默认续接最近生成结果", + fromHistory: true, + }); + } + + if (canvasAgentTargetPreference.shouldPreferWorkingMemoryTarget) { + const refs = toWorkingMemoryRefs(workingStateResolvedUrls); + return buildResolvedTargetAssets({ + targetRefs: refs, + candidateRefs, + resolutionMode: "working_memory", + confidence: canvasAgentTargetPreference.confidence, + needsClarification: false, + reason: "用户使用指代表达并提出图片编辑意图,使用工作记忆中的上一轮目标图", + fromHistory: true, + }); + } + + if (hasCanvasAgentDeicticTarget(text)) { if (selectedRefs.length > 0) { return buildResolvedTargetAssets({ targetRefs: selectedRefs, @@ -421,7 +455,7 @@ export function resolveTargetAssets( } if ( - ACTIONABLE_EDIT_REGEX.test(text) && + hasCanvasAgentActionableImageEditIntent(text) && currentRefs.length === 0 && generatedRefs.length > 0 && selectedLooksLikeOriginal && @@ -463,14 +497,13 @@ export function resolveTargetAssets( }); } - const workingStateResolvedUrls = normalizeStringArray(workingState?.lastResolvedTargetImageUrls); const allowsImplicitHistoryTarget = IMPLICIT_HISTORY_TARGET_REGEX.test(text) && - (ACTIONABLE_EDIT_REGEX.test(text) || + (hasCanvasAgentActionableImageEditIntent(text) || ACTIONABLE_GENERATION_REGEX.test(text) || LATEST_RESULT_REGEX.test(text) || ORIGINAL_IMAGE_REGEX.test(text) || - DEICTIC_TARGET_REGEX.test(text) || + hasCanvasAgentDeicticTarget(text) || SELECTION_REGEX.test(text)); if (allowsImplicitHistoryTarget) { @@ -525,7 +558,7 @@ export function resolveTargetAssets( } } - if (ACTIONABLE_EDIT_REGEX.test(text)) { + if (hasCanvasAgentActionableImageEditIntent(text)) { return buildResolvedTargetAssets({ targetRefs: [], candidateRefs, diff --git a/lib/ai/runtime/canvas-agent-kernel.ts b/lib/ai/runtime/canvas-agent-kernel.ts new file mode 100644 index 000000000..68467384f --- /dev/null +++ b/lib/ai/runtime/canvas-agent-kernel.ts @@ -0,0 +1,156 @@ +import type { DirectAgentToolCapability } from "@/lib/ai/runtime/direct-agent-execution"; + +export type CanvasAgentComposerMode = "agent" | "image" | "video" | "detail" | "clawd"; +export type CanvasAgentEntryMode = "auto" | "preset"; + +export interface CanvasAgentRequestPolicy { + effectiveSelectedChatModel: string; + shouldForceToolOrchestration: boolean; + reason: string | null; +} + +export interface CanvasAgentTargetPreference { + shouldPreferLatestResult: boolean; + shouldPreferWorkingMemoryTarget: boolean; + confidence: number; + reason: string | null; +} + +const DEICTIC_TARGET_REGEX = + /(这张图|这只|这个|这款|这套|当前这张|当前这个|当前这只|这一张|这一只|这个产品|这个物体|这只猫|这只狗)/i; + +const ACTIONABLE_IMAGE_EDIT_REGEX = + /(抠图|去背景|透明背景|去背|扣图|移除背景|去水印|水印|去logo|去\s*logo|移除logo|去除logo|换背景|改背景|背景换成|背景改成|白底|纯色背景|替换.*背景|变清晰|高清|锐化|降噪|提升质量|修复|清晰化|扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|多视角|换视角|改视角|视角变体|新角度|换角度|改角度|去除|消除|擦掉|删除|移除|p掉|重绘|风格化|卡通化|动漫风|油画风|调色|调整颜色|亮度|对比度|饱和度|缩放|放大|缩小|调整尺寸|裁剪|resize|crop|修图|p图|改图|调图|编辑.*图|优化.*图|重做|再调整|继续改|继续调|处理)/i; + +export function normalizeCanvasAgentRequestText(text: string) { + return String(text || "").toLowerCase().replace(/\s+/g, "").trim(); +} + +export function hasCanvasAgentDeicticTarget(text: string) { + return DEICTIC_TARGET_REGEX.test(String(text || "")); +} + +export function hasCanvasAgentActionableImageEditIntent(text: string) { + return ACTIONABLE_IMAGE_EDIT_REGEX.test(String(text || "")); +} + +export function inferCanvasAgentImageEditCapability( + text: string, +): DirectAgentToolCapability | null { + const normalizedText = normalizeCanvasAgentRequestText(text); + if (!normalizedText) return null; + if (/去水印|水印|去logo|移除logo|去除logo/.test(normalizedText)) { + return "remove_watermark"; + } + if (/抠图|去背景|透明背景|去背|扣图|移除背景/.test(normalizedText)) { + return "remove_background"; + } + if (/多视角|换视角|改视角|视角变体|新角度|换角度|改角度|侧面|侧视|背面|背后|俯视|俯拍|仰视|仰拍|低机位|高机位/.test(normalizedText)) { + return "angle_variant"; + } + if (/扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|比例|画幅|竖版|横版|竖图|横图|9:16|16:9|1:1/.test(normalizedText)) { + return "extend"; + } + if (/调整尺寸|裁剪|resize|crop|缩放|放大|缩小/.test(normalizedText)) { + return "resize"; + } + if (/局部|框选|选区|区域|涂抹|蒙版/.test(normalizedText)) { + return "local_edit"; + } + if (/高清|清晰|锐化|降噪|提升质量|修复|清晰化/.test(normalizedText)) { + return "enhance"; + } + if (hasCanvasAgentActionableImageEditIntent(text)) { + return "generic_edit"; + } + return null; +} + +export function resolveCanvasAgentRequestPolicy(params: { + composerMode?: string | null; + agentEntryMode?: string | null; + selectedChatModel?: string | null; + hasCanvasControlIntentFastPath?: boolean; +}): CanvasAgentRequestPolicy { + const composerMode = String(params.composerMode || "").trim(); + const agentEntryMode = String(params.agentEntryMode || "").trim(); + const selectedChatModel = String(params.selectedChatModel || "").trim() || "auto"; + const isAgentAutoComposer = composerMode === "agent" && agentEntryMode !== "preset"; + const shouldForceToolOrchestration = + isAgentAutoComposer || Boolean(params.hasCanvasControlIntentFastPath); + + if (isAgentAutoComposer) { + return { + effectiveSelectedChatModel: "auto", + shouldForceToolOrchestration: true, + reason: "agent_auto_composer", + }; + } + + if (params.hasCanvasControlIntentFastPath) { + return { + effectiveSelectedChatModel: selectedChatModel, + shouldForceToolOrchestration: true, + reason: "canvas_control_fast_path", + }; + } + + return { + effectiveSelectedChatModel: selectedChatModel, + shouldForceToolOrchestration, + reason: null, + }; +} + +export function resolveCanvasAgentTargetPreference(params: { + text: string; + selectedImageCount: number; + latestResultImageCount: number; + workingMemoryTargetImageCount: number; +}): CanvasAgentTargetPreference { + const hasDeicticTarget = hasCanvasAgentDeicticTarget(params.text); + const hasEditIntent = hasCanvasAgentActionableImageEditIntent(params.text); + const selectedImageCount = Math.max(0, Math.round(params.selectedImageCount || 0)); + const latestResultImageCount = Math.max( + 0, + Math.round(params.latestResultImageCount || 0), + ); + const workingMemoryTargetImageCount = Math.max( + 0, + Math.round(params.workingMemoryTargetImageCount || 0), + ); + + if (!hasDeicticTarget || !hasEditIntent || selectedImageCount > 0) { + return { + shouldPreferLatestResult: false, + shouldPreferWorkingMemoryTarget: false, + confidence: 0, + reason: null, + }; + } + + if (latestResultImageCount > 0) { + return { + shouldPreferLatestResult: true, + shouldPreferWorkingMemoryTarget: false, + confidence: 90, + reason: "deictic_edit_prefers_latest_result", + }; + } + + if (workingMemoryTargetImageCount > 0) { + return { + shouldPreferLatestResult: false, + shouldPreferWorkingMemoryTarget: true, + confidence: 86, + reason: "deictic_edit_uses_working_memory_target", + }; + } + + return { + shouldPreferLatestResult: false, + shouldPreferWorkingMemoryTarget: false, + confidence: 0, + reason: null, + }; +} diff --git a/lib/ai/runtime/intent-arbiter.ts b/lib/ai/runtime/intent-arbiter.ts index cfd463d3b..ce1081ef1 100644 --- a/lib/ai/runtime/intent-arbiter.ts +++ b/lib/ai/runtime/intent-arbiter.ts @@ -5,6 +5,10 @@ import type { ResolvedTargetAssets } from "@/lib/ai/context-memory/types"; import type { AnalysisResult } from "@/lib/ai/smart-analyzer"; import type { TaskBoundaryDecision } from "@/lib/ai/runtime/task-boundary-classifier"; import { hasDirectAgentLocalEditIntent } from "@/lib/ai/runtime/local-edit-options"; +import { + hasCanvasAgentActionableImageEditIntent, + inferCanvasAgentImageEditCapability, +} from "@/lib/ai/runtime/canvas-agent-kernel"; export type IntentArbiterMode = | "respond" @@ -116,23 +120,21 @@ function isExplicitImageGenerationRequest(text: string): boolean { } function isLikelyImageEditRequest(text: string): boolean { - const normalizedText = String(text || "").toLowerCase(); - return hasDirectAgentLocalEditIntent(text) || /抠图|去背景|透明背景|去背|扣图|移除背景|去水印|水印|去logo|去\s*logo|移除logo|去除logo|换背景|改背景|背景换成|背景改成|白底|纯色背景|替换.*背景|变清晰|高清|锐化|降噪|提升质量|修复|清晰化|扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|换成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|改成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|比例|画幅|竖版|横版|多视角|换视角|改视角|视角变体|新角度|换角度|改角度|侧面|侧视|背面|背后|俯视|俯拍|仰视|仰拍|低机位|高机位|去除|消除|擦掉|删除|移除|p掉|重绘|风格化|卡通化|动漫风|油画风|转.*风格|变.*风格|改.*风格|调色|调整颜色|亮度|对比度|饱和度|色调|曝光|滤镜|改成.*色|换成.*色|变成.*色|颜色改成|颜色换成|颜色变成|缩放|放大|缩小|调整尺寸|裁剪|resize|crop|修图|p图|改图|调图|编辑.*图|优化.*图|重做|再调整|继续改|继续调/.test( - normalizedText, - ); + return hasDirectAgentLocalEditIntent(text) || hasCanvasAgentActionableImageEditIntent(text); } function inferDefaultImageEditSubIntent(text: string): string { - const lowerText = String(text || "").toLowerCase(); - if (/去水印|水印|去logo|去\s*logo|移除logo|去除logo/.test(lowerText)) return "remove_watermark"; - if (/抠图|去背景|透明背景|去背|扣图|移除背景/.test(lowerText)) return "remove_background"; - if (/换背景|改背景|背景换成|背景改成|白底|透明底/.test(lowerText)) return "replace_background"; - if (/多视角|换视角|改视角|视角变体|新角度|换角度|改角度|侧面|侧视|背面|背后|俯视|俯拍|仰视|仰拍|低机位|高机位/.test(lowerText)) return "angle_variant"; + const inferredCapability = inferCanvasAgentImageEditCapability(text); + if ( + inferredCapability && + inferredCapability !== "generic_edit" && + inferredCapability !== "generate_image" && + inferredCapability !== "generate_video" && + inferredCapability !== "analyze" + ) { + return inferredCapability; + } if (hasDirectAgentLocalEditIntent(text)) return "local_edit"; - if (/扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|比例|画幅|竖版|横版|竖图|横图|9:16|16:9|1:1/.test(lowerText)) return "extend"; - if (/风格|卡通|动漫|油画|像素风|写实|插画/.test(lowerText)) return "style_transfer"; - if (/颜色|调色|亮度|对比度|饱和度|改成.*色|换成.*色/.test(lowerText)) return "color_adjust"; - if (/去除|消除|擦掉|删除|移除|p掉|重绘/.test(lowerText)) return "inpaint"; return "enhance"; } diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index c4d257b53..1ed8f6a5a 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -45,6 +45,10 @@ import { } from "@/lib/canvas-toolbar-config"; import { shouldBypassCanvasAgentMissingTargetClarification } from "@/lib/ai/runtime/canvas-agent-clarification"; import { hasCanvasAgentRoutingContext } from "@/lib/ai/canvas-agent-routing"; +import { + resolveCanvasAgentRequestPolicy, + resolveCanvasAgentTargetPreference, +} from "@/lib/ai/runtime/canvas-agent-kernel"; import { inferAgentImageCountFromText } from "@/lib/ai/runtime/image-count-intent"; import { isAgentModeScopedChatId, @@ -128,6 +132,7 @@ import { getDirectAgentToolSuccessMessage, hasExplicitDirectAgentBatchImageEditIntent, isDirectAgentBatchImageEditCapability, + resolveDirectAgentToolContext, resolveDirectAgentToolExecution, shouldRunDirectAgentBatchImageEdit, } from "@/lib/ai/runtime/direct-agent-execution"; @@ -1205,6 +1210,95 @@ test.describe("canvas chat pure helpers", () => { expect(editText).toContain("替换背景"); }); + test("resolves deictic canvas edit requests to latest results and direct tools", () => { + const latestResultUrl = "https://cdn.example.com/latest-cat-result.png"; + const requestPolicy = resolveCanvasAgentRequestPolicy({ + composerMode: "agent", + selectedChatModel: "conversation-model-id", + }); + + expect(requestPolicy.effectiveSelectedChatModel).toBe("auto"); + expect(requestPolicy.shouldForceToolOrchestration).toBe(true); + + const targetPreference = resolveCanvasAgentTargetPreference({ + text: "将这只猫从图中扣图扣出来", + selectedImageCount: 0, + latestResultImageCount: 1, + workingMemoryTargetImageCount: 1, + }); + + expect(targetPreference.shouldPreferLatestResult).toBe(true); + + const mediaInput = { + imageRefs: [], + currentMessageImageRefs: [], + selectedImageRefs: [], + canvasImageRefs: [], + historyUserImageRefs: [], + historyAssistantImageRefs: [ + { + url: latestResultUrl, + normalizedUrl: latestResultUrl, + source: "history_assistant_result", + role: "history_result", + }, + ], + resolvedImageRefs: [], + currentMessageImageUrls: [], + selectedImageUrls: [], + historyUserImageUrls: [], + historyAssistantImageUrls: [latestResultUrl], + resolvedImageUrls: [latestResultUrl], + selectedAssetIds: [], + candidateAssetIds: [], + uploadedAssetIds: [], + hasImage: true, + source: "history", + fromHistory: true, + } as any; + const workingState = { + lastGeneratedImageUrls: [latestResultUrl], + lastResolvedTargetImageUrls: [latestResultUrl], + }; + const targetResolution = { + targetRefs: [], + candidateRefs: [], + targetImageUrls: [latestResultUrl], + targetAssetIds: [], + candidateImageUrls: [], + candidateAssetIds: [], + resolutionMode: "working_memory", + confidence: targetPreference.confidence, + needsClarification: false, + reason: targetPreference.reason, + fromHistory: true, + } as any; + + const directContext = resolveDirectAgentToolContext({ + execution: { + mode: "edit_image", + routeIntent: "image_edit", + routeSubIntent: "remove_background", + targetSource: targetResolution.resolutionMode, + preferredCapability: "remove_background", + shouldUseStructuredReply: true, + shouldOptimizePrompt: true, + shouldApplyImagePipeline: true, + shouldApplyVideoPipeline: false, + shouldStayConversational: false, + shouldPublishToCanvas: false, + toolCalls: [], + availableTools: ["removeBackground"], + } as any, + requestResolvedMediaInput: mediaInput, + requestTargetResolution: targetResolution, + requestWorkingState: workingState, + }); + + expect(directContext?.execution.toolName).toBe("removeBackground"); + expect(directContext?.targetImageUrl).toBe(latestResultUrl); + }); + test("only runs direct image edit batches for explicit safe batch requests", () => { expect( shouldRunDirectAgentBatchImageEdit({ From eab3f0372f5815387194a08c3061975d06d89d56 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 7 Jul 2026 15:42:54 +0800 Subject: [PATCH 06/16] feat: add canvas agent progress timeline --- components/canvas/CanvasChatPanel.tsx | 165 ++++++++++++++---- .../canvas/chat-panel/canvas-agent-stream.ts | 111 +++++++++++- tests/routes/canvas-chat-helpers.test.ts | 40 +++++ 3 files changed, 282 insertions(+), 34 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 8fef5c262..29afa01fa 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -144,15 +144,16 @@ import { type CanvasToolbarItemKey, } from "@/lib/canvas-toolbar-config"; import { - AGENT_PROGRESS_STEPS, type AgentProgressStreamPayload, type AgentTargetLockRefPreview, type AgentTargetLockStreamPayload, + buildAgentProgressNodeSteps, type CanvasOperationStreamPayload, type CanvasViewOperationStreamPayload, type CanvasMediaStreamPayload, filterCanvasMediaPayloadForUnhandledItems, getAgentProgressFallbackTitle, + getAgentProgressStepLabel, getAgentTargetLockTitle, getAgentTargetResolutionLabel, getCanvasMediaPayloadHandledItemIds, @@ -1623,6 +1624,130 @@ function CanvasAssistantTypingPlaceholder({ hint }: { hint?: string | null }) { ); } +function CanvasAgentProgressTimeline({ + progress, +}: { + progress?: AgentProgressStreamPayload | null; +}) { + const steps = buildAgentProgressNodeSteps(progress); + const title = progress?.title?.trim() || getAgentProgressFallbackTitle(progress); + const isFailed = progress?.stage === "failed"; + const isCompleted = progress?.stage === "completed"; + const isClarify = progress?.stage === "clarify"; + + return ( +
+
+
+ + {isFailed ? ( + + ) : isCompleted ? ( + + ) : ( + + )} + + + {title} + +
+ +
+
+ {steps.map((step, index) => { + const isStepCompleted = step.state === "completed"; + const isStepCurrent = step.state === "current"; + const isStepFailed = step.state === "failed"; + const lineIsActive = + isStepCompleted || isStepCurrent || isStepFailed; + + return ( +
+
+ + {isStepCompleted ? ( + + ) : isStepFailed ? ( + + ) : isStepCurrent ? ( + + ) : ( + + )} + {isStepCurrent ? ( + + ) : null} + + + {step.label} + +
+ {index < steps.length - 1 ? ( + + ) : null} +
+ ); + })} +
+
+
+
+ ); +} + function CanvasComposerUploadProgressDialog({ progress, }: { @@ -12318,26 +12443,10 @@ export function CanvasChatPanel({ activeAgentProgress?.title?.trim() || getAgentProgressFallbackTitle(activeAgentProgress); const agentProgressMessage = activeAgentProgress?.message?.trim() || "我正在根据当前上下文持续推进这一步。"; - const agentProgressSteps = useMemo(() => { - const currentStepIndex = Math.max( - 0, - AGENT_PROGRESS_STEPS.findIndex((step) => step.key === activeAgentProgress?.currentStep) - ); - return AGENT_PROGRESS_STEPS.map((step, index) => { - let state: "upcoming" | "current" | "completed" | "failed" = "upcoming"; - if (activeAgentProgress?.stage === "failed") { - state = index < currentStepIndex ? "completed" : index === currentStepIndex ? "failed" : "upcoming"; - } else if (activeAgentProgress?.stage === "completed") { - state = "completed"; - } else { - state = index < currentStepIndex ? "completed" : index === currentStepIndex ? "current" : "upcoming"; - } - return { - ...step, - state, - }; - }); - }, [activeAgentProgress]); + const agentProgressSteps = useMemo( + () => buildAgentProgressNodeSteps(activeAgentProgress), + [activeAgentProgress] + ); const renderModelSelectContent = () => ( -
- - 分析中 -
- + )}
@@ -14660,9 +14764,10 @@ export function CanvasChatPanel({ {agentProgressTitle} - {AGENT_PROGRESS_STEPS.find( - (step) => step.key === activeAgentProgress.currentStep - )?.label || "处理中"} + {getAgentProgressStepLabel( + activeAgentProgress.currentStep || "lock_target", + activeAgentProgress + )}
diff --git a/components/canvas/chat-panel/canvas-agent-stream.ts b/components/canvas/chat-panel/canvas-agent-stream.ts index 3e2480894..e7a6246a2 100644 --- a/components/canvas/chat-panel/canvas-agent-stream.ts +++ b/components/canvas/chat-panel/canvas-agent-stream.ts @@ -206,14 +206,26 @@ export type AgentProgressStreamPayload = { capability?: string; }; +export type AgentProgressStepState = + | "upcoming" + | "current" + | "completed" + | "failed"; + +export type AgentProgressNodeStep = { + key: NonNullable; + label: string; + state: AgentProgressStepState; +}; + export const AGENT_PROGRESS_STEPS: Array<{ key: NonNullable; label: string; }> = [ - { key: "lock_target", label: "整理素材" }, - { key: "analyze", label: "理解需求" }, - { key: "select_tool", label: "优化提示" }, - { key: "execute", label: "提交生成" }, + { key: "lock_target", label: "理解用户需求" }, + { key: "analyze", label: "分析使用工具" }, + { key: "select_tool", label: "确认执行工具" }, + { key: "execute", label: "执行中" }, { key: "finalize", label: "整理结果" }, ]; @@ -260,3 +272,94 @@ export function getAgentProgressFallbackTitle(payload?: AgentProgressStreamPaylo return "正在理解需求"; } } + +export function getAgentProgressActionLabel( + payload?: AgentProgressStreamPayload | null +) { + switch (payload?.capability) { + case "analyze": + return "图片分析"; + case "generic_edit": + return "修图"; + case "remove_background": + return "抠图"; + case "remove_watermark": + return "去水印"; + case "enhance": + return "图像增强"; + case "resize": + return "尺寸调整"; + case "extend": + return "扩图"; + case "angle_variant": + return "多视角"; + case "local_edit": + return "局部编辑"; + case "box_cutout": + return "框选抠图"; + case "generate_image": + return "生图"; + case "generate_video": + return "视频生成"; + default: + return "工具"; + } +} + +export function getAgentProgressStepLabel( + stepKey: NonNullable, + payload?: AgentProgressStreamPayload | null +) { + const actionLabel = getAgentProgressActionLabel(payload); + switch (stepKey) { + case "lock_target": + return "理解用户需求"; + case "analyze": + return payload?.capability === "analyze" ? "分析图片" : "分析使用工具"; + case "select_tool": + return actionLabel === "工具" ? "确认执行工具" : `确认${actionLabel}工具`; + case "execute": + return actionLabel === "工具" ? "执行中" : `${actionLabel}中`; + case "finalize": + return "整理结果"; + default: + return "处理中"; + } +} + +export function buildAgentProgressNodeSteps( + payload?: AgentProgressStreamPayload | null +): AgentProgressNodeStep[] { + const currentStep = payload?.currentStep || "lock_target"; + const currentStepIndex = Math.max( + 0, + AGENT_PROGRESS_STEPS.findIndex((step) => step.key === currentStep) + ); + + return AGENT_PROGRESS_STEPS.map((step, index) => { + let state: AgentProgressStepState = "upcoming"; + if (payload?.stage === "failed") { + state = + index < currentStepIndex + ? "completed" + : index === currentStepIndex + ? "failed" + : "upcoming"; + } else if (payload?.stage === "completed") { + state = "completed"; + } else { + state = + index < currentStepIndex + ? "completed" + : index === currentStepIndex + ? "current" + : "upcoming"; + } + + return { + key: step.key, + label: getAgentProgressStepLabel(step.key, payload), + state, + }; + }); +} diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 1ed8f6a5a..2504c581c 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -9,8 +9,10 @@ import { } from "@/components/canvas/chat-panel/canvas-chat-media"; import { filterCanvasMediaPayloadForUnhandledItems, + buildAgentProgressNodeSteps, getCanvasMediaPayloadHandledItemIds, getCanvasOperationHandledMediaItemIds, + getAgentProgressStepLabel, isCanvasAgentViewOperationClientEnabled, normalizeCanvasAgentClientBoolean, shouldRetryCanvasViewOperationAfterRefresh, @@ -1210,6 +1212,44 @@ test.describe("canvas chat pure helpers", () => { expect(editText).toContain("替换背景"); }); + test("builds capability-aware agent progress node labels", () => { + const selectingSteps = buildAgentProgressNodeSteps({ + stage: "planning", + currentStep: "select_tool", + capability: "remove_background", + }); + + expect(selectingSteps.map((step) => step.label)).toEqual([ + "理解用户需求", + "分析使用工具", + "确认抠图工具", + "抠图中", + "整理结果", + ]); + expect(selectingSteps.map((step) => step.state)).toEqual([ + "completed", + "completed", + "current", + "upcoming", + "upcoming", + ]); + + const executingSteps = buildAgentProgressNodeSteps({ + stage: "executing", + currentStep: "execute", + capability: "remove_background", + }); + + expect(executingSteps[2]?.state).toBe("completed"); + expect(executingSteps[3]).toMatchObject({ + label: "抠图中", + state: "current", + }); + expect(getAgentProgressStepLabel("execute", { capability: "generate_video" })).toBe( + "视频生成中" + ); + }); + test("resolves deictic canvas edit requests to latest results and direct tools", () => { const latestResultUrl = "https://cdn.example.com/latest-cat-result.png"; const requestPolicy = resolveCanvasAgentRequestPolicy({ From 6a2b9280802fe67a75b60af77a95656d2a83fedf Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 7 Jul 2026 16:02:33 +0800 Subject: [PATCH 07/16] fix: resolve unselected canvas image targets --- components/canvas/CanvasChatPanel.tsx | 22 ++++++-- lib/ai/context-memory/media-input.ts | 1 + lib/ai/context-memory/target-resolution.ts | 27 ++++++++++ lib/ai/context-memory/types.ts | 1 + lib/ai/runtime/direct-agent-execution.ts | 1 + lib/canvas/local-upload-state.ts | 9 +++- tests/routes/canvas-chat-helpers.test.ts | 60 ++++++++++++++++++++++ 7 files changed, 115 insertions(+), 6 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 29afa01fa..dcc86638f 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -857,6 +857,18 @@ function isInternalCanvasImageGenerationFailureText(parts: Array) { ); } +function getCanvasImageNodeUrl(node: CanvasNode | null | undefined) { + if (!node || node.type !== "image") return ""; + const data = (node.data || {}) as Record; + return String( + data.src || + data.previewImage || + data.imageUrl || + data.renderArtifacts?.screenshotUrl || + "" + ).trim(); +} + function findCanvasImageAnchorNode( nodes: CanvasNode[], params: { @@ -873,7 +885,7 @@ function findCanvasImageAnchorNode( if (sourceKeys.size > 0) { const matchedNode = nodes.find((node) => { if (node.type !== "image") return false; - const src = String((node.data as any)?.src || "").trim(); + const src = getCanvasImageNodeUrl(node); return sourceKeys.has(normalizeImageIdentityUrl(src)); }); if (matchedNode) return matchedNode; @@ -7871,7 +7883,7 @@ export function CanvasChatPanel({ pendingTarget: pendingCanvasAgentAnnotationEditTargetRef.current, isEligibleNode: (node) => node.type === "image" && - isValidChatHttpUrl(String((node.data as any)?.src || "").trim()), + isValidChatHttpUrl(getCanvasImageNodeUrl(node)), }); if ( pendingCanvasAgentAnnotationEditTargetRef.current && @@ -7888,7 +7900,7 @@ export function CanvasChatPanel({ const imageNodes = nodes.filter( (node) => node.type === "image" && - isValidChatHttpUrl(String((node.data as any)?.src || "").trim()), + isValidChatHttpUrl(getCanvasImageNodeUrl(node)), ); const effectiveComposerMode = activeComposerModeRef.current || activeComposerMode; @@ -7923,7 +7935,7 @@ export function CanvasChatPanel({ new Map( [ ...selectedImageNodes.filter((node) => - isValidChatHttpUrl(String((node.data as any)?.src || "").trim()) + isValidChatHttpUrl(getCanvasImageNodeUrl(node)) ), ...(pendingAnnotationTargetNode ? [pendingAnnotationTargetNode] : []), ].map((node) => [node.id, node]) @@ -8201,7 +8213,7 @@ export function CanvasChatPanel({ assetId: isValidChatUuid((node.data as any)?.assetId) ? String((node.data as any).assetId).trim() : undefined, - url: String((node.data as any)?.src || "").trim(), + url: getCanvasImageNodeUrl(node), title: typeof (node.data as any)?.title === "string" ? String((node.data as any).title).slice(0, 256) diff --git a/lib/ai/context-memory/media-input.ts b/lib/ai/context-memory/media-input.ts index 6d06e1697..228c24391 100644 --- a/lib/ai/context-memory/media-input.ts +++ b/lib/ai/context-memory/media-input.ts @@ -308,6 +308,7 @@ export function resolveChatMediaInput( resolvedImageRefs, currentMessageImageUrls: currentMessageImageRefs.map((ref) => ref.url), selectedImageUrls: selectedImageRefs.map((ref) => ref.url), + canvasImageUrls: canvasImageRefs.map((ref) => ref.url), historyUserImageUrls: history.latestUser.map((ref) => ref.url), historyAssistantImageUrls: history.latestAssistant.map((ref) => ref.url), resolvedImageUrls: resolvedImageRefs.map((ref) => ref.url), diff --git a/lib/ai/context-memory/target-resolution.ts b/lib/ai/context-memory/target-resolution.ts index 6c69dd7ef..fb253758b 100644 --- a/lib/ai/context-memory/target-resolution.ts +++ b/lib/ai/context-memory/target-resolution.ts @@ -497,6 +497,21 @@ export function resolveTargetAssets( }); } + if ( + canvasRefs.length === 1 && + (hasCanvasAgentActionableImageEditIntent(text) || focusPolicy === "auto") + ) { + return buildResolvedTargetAssets({ + targetRefs: [canvasRefs[0]], + candidateRefs, + resolutionMode: "text_reference", + confidence: 84, + needsClarification: false, + reason: "用户使用画布/图片指代表达,且当前画布仅有一张可处理图片", + fromHistory: true, + }); + } + const allowsImplicitHistoryTarget = IMPLICIT_HISTORY_TARGET_REGEX.test(text) && (hasCanvasAgentActionableImageEditIntent(text) || @@ -559,6 +574,18 @@ export function resolveTargetAssets( } if (hasCanvasAgentActionableImageEditIntent(text)) { + if (canvasRefs.length === 1) { + return buildResolvedTargetAssets({ + targetRefs: [canvasRefs[0]], + candidateRefs, + resolutionMode: "text_reference", + confidence: 82, + needsClarification: false, + reason: "用户表达了图片编辑意图,且当前画布仅有一张可处理图片", + fromHistory: true, + }); + } + return buildResolvedTargetAssets({ targetRefs: [], candidateRefs, diff --git a/lib/ai/context-memory/types.ts b/lib/ai/context-memory/types.ts index d183534fe..bc6cafdb4 100644 --- a/lib/ai/context-memory/types.ts +++ b/lib/ai/context-memory/types.ts @@ -57,6 +57,7 @@ export interface ResolvedChatMediaInput { resolvedImageRefs: ResolvedImageRef[]; currentMessageImageUrls: string[]; selectedImageUrls: string[]; + canvasImageUrls: string[]; historyUserImageUrls: string[]; historyAssistantImageUrls: string[]; resolvedImageUrls: string[]; diff --git a/lib/ai/runtime/direct-agent-execution.ts b/lib/ai/runtime/direct-agent-execution.ts index bd507cb55..79a6f6642 100644 --- a/lib/ai/runtime/direct-agent-execution.ts +++ b/lib/ai/runtime/direct-agent-execution.ts @@ -653,6 +653,7 @@ export function pickAgentSourceImageUrls(params: { params.requestTargetResolution?.targetImageUrls || [], params.requestResolvedMediaInput?.selectedImageUrls || [], params.requestResolvedMediaInput?.currentMessageImageUrls || [], + params.requestResolvedMediaInput?.canvasImageUrls || [], params.requestWorkingState?.lastResolvedTargetImageUrls || [], params.requestWorkingState?.lastGeneratedImageUrls || [], params.requestResolvedMediaInput?.historyAssistantImageUrls || [], diff --git a/lib/canvas/local-upload-state.ts b/lib/canvas/local-upload-state.ts index 7845f4766..0f1cf4b6c 100644 --- a/lib/canvas/local-upload-state.ts +++ b/lib/canvas/local-upload-state.ts @@ -29,6 +29,13 @@ export function isCanvasImageReadyForAi(node: CanvasNode | null | undefined) { return false; } - const src = String(((node.data as Record)?.src || "")).trim(); + const data = (node.data || {}) as Record; + const src = String( + data.src || + data.previewImage || + data.imageUrl || + data.renderArtifacts?.screenshotUrl || + "" + ).trim(); return /^https?:\/\//i.test(src); } diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 2504c581c..1888fa834 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -1339,6 +1339,66 @@ test.describe("canvas chat pure helpers", () => { expect(directContext?.targetImageUrl).toBe(latestResultUrl); }); + test("falls back to canvas images for direct agent edit tools", () => { + const canvasImageUrl = "https://cdn.example.com/canvas-cat.png"; + const mediaInput = { + imageRefs: [], + currentMessageImageRefs: [], + selectedImageRefs: [], + canvasImageRefs: [ + { + url: canvasImageUrl, + normalizedUrl: canvasImageUrl, + nodeId: "cat-node", + source: "canvas_context", + role: "reference", + x: 120, + y: 80, + width: 640, + height: 640, + isSelected: false, + }, + ], + historyUserImageRefs: [], + historyAssistantImageRefs: [], + resolvedImageRefs: [], + currentMessageImageUrls: [], + selectedImageUrls: [], + canvasImageUrls: [canvasImageUrl], + historyUserImageUrls: [], + historyAssistantImageUrls: [], + resolvedImageUrls: [], + selectedAssetIds: [], + candidateAssetIds: [], + uploadedAssetIds: [], + hasImage: false, + source: "none", + fromHistory: false, + } as any; + + const directContext = resolveDirectAgentToolContext({ + execution: { + mode: "edit_image", + routeIntent: "image_edit", + routeSubIntent: "remove_background", + targetSource: "text_reference", + preferredCapability: "remove_background", + shouldUseStructuredReply: true, + shouldOptimizePrompt: true, + shouldApplyImagePipeline: true, + shouldApplyVideoPipeline: false, + shouldStayConversational: false, + shouldPublishToCanvas: false, + toolCalls: [], + availableTools: ["removeBackground"], + } as any, + requestResolvedMediaInput: mediaInput, + }); + + expect(directContext?.execution.toolName).toBe("removeBackground"); + expect(directContext?.targetImageUrl).toBe(canvasImageUrl); + }); + test("only runs direct image edit batches for explicit safe batch requests", () => { expect( shouldRunDirectAgentBatchImageEdit({ From c17de9def57bb59fb70204376d11679eff4a40dc Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 7 Jul 2026 16:30:03 +0800 Subject: [PATCH 08/16] fix: remove agent progress bubble and add dialogue memory --- app/(chat)/api/chat/route.ts | 91 ++++++++++---- components/canvas/CanvasChatPanel.tsx | 128 -------------------- lib/ai/runtime/recent-dialogue-memory.ts | 146 +++++++++++++++++++++++ tests/routes/canvas-chat-helpers.test.ts | 56 +++++++++ 4 files changed, 268 insertions(+), 153 deletions(-) create mode 100644 lib/ai/runtime/recent-dialogue-memory.ts diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index c3be7a608..5b901905b 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -250,6 +250,7 @@ import { pruneTaskBoundaryContext, pruneTaskBoundaryHistoryMessages, } from "@/lib/ai/runtime/task-boundary-context-pruner"; +import { buildRecentDialogueMemory } from "@/lib/ai/runtime/recent-dialogue-memory"; import { resolveAgentModelSlotDescriptor, upsertAgentModelSlot, @@ -1085,6 +1086,10 @@ export async function POST(request: Request) { let confirmedPromptForConversationState: string | undefined; let smartAutoHistoryMessagesFromDb: DBMessage[] = []; let smartAutoMediaHistoryMessagesFromDb: DBMessage[] = []; + let recentDialogueMemory = buildRecentDialogueMemory({ + messages: [], + currentText: requestTextContent, + }); let preferredAgentModelId: string | undefined; let configuredAgentConversationModelId: string | undefined; let agentModelSlots: AgentModelSlots = {}; @@ -1489,6 +1494,17 @@ export async function POST(request: Request) { taskType: taskBoundaryDecision.taskType, taskMode: taskBoundaryDecision.taskMode, }); + recentDialogueMemory = buildRecentDialogueMemory({ + messages: loadedHistoryMessages, + currentText: smartAutoInputText, + maxEntries: taskBoundaryDecision.taskType === "chat" ? 6 : 4, + maxTotalChars: taskBoundaryDecision.taskType === "chat" ? 1000 : 700, + }); + chatApiLogger.info("[RecentDialogueMemory] Smart auto recall:", { + entries: recentDialogueMemory.entries.length, + isRecallRequest: recentDialogueMemory.isRecallRequest, + taskType: taskBoundaryDecision.taskType, + }); autoTextForFallback = smartAutoInputText; if (!requestWorkingState && !shouldIsolateDirectComposerContext) { @@ -1563,23 +1579,12 @@ export async function POST(request: Request) { resolutionConfidence: requestTargetResolution.confidence, }); - // 🆕 从历史消息中提取对话文本(用于上下文理解) - const historyMessages: { role: "user" | "assistant"; content: string }[] = []; - for (const msg of smartAutoHistoryMessagesFromDb.slice(-10)) { // 最多取最近10条 - const parts = msg.parts as any[]; - if (parts && parts.length > 0) { - // 提取文本内容 - const textParts = parts.filter((p: any) => p.type === 'text'); - const content = textParts.map((p: any) => p.text || '').join(' ').trim(); - if (content) { - historyMessages.push({ - role: msg.role as "user" | "assistant", - content: content.substring(0, 300), // 限制长度 - }); - } - } - } - chatApiLogger.info("[Smart Auto Mode] History context:", historyMessages.length, "messages"); + const historyMessages: { role: "user" | "assistant"; content: string }[] = + recentDialogueMemory.entries.map((entry) => ({ + role: entry.role, + content: entry.content, + })); + chatApiLogger.info("[Smart Auto Mode] History context:", historyMessages.length, "memory entries"); // 2. 使用 LangGraph 驱动的自动模式工作流 const { runAutoAgentGraph } = await import("@/lib/ai/auto-agent-graph"); @@ -1612,8 +1617,15 @@ export async function POST(request: Request) { workflowState: composedConversationState.threadState.workflowState, memoryHints: composedConversationState.threadState.memoryHints, contextSummary: composedConversationState.threadState.contextSummary, + recentDialogueMemory: recentDialogueMemory.contextSummary, }, }); + const agentGraphContext = [ + composedConversationState.composedConversationMemory.contextSummary, + recentDialogueMemory.contextSummary, + ] + .filter(Boolean) + .join("\n"); const autoGraphResult = await withOperationTimeout( runAutoAgentGraph({ text: smartAutoInputText, @@ -1641,10 +1653,7 @@ export async function POST(request: Request) { requestResolvedMediaInput.historyUserImageUrls.length, currentUploadImageCount: requestResolvedMediaInput.currentMessageImageUrls.length, historyMessages, - context: - composedConversationState.composedConversationMemory.contextSummary || - effectiveAutoContextText || - undefined, + context: agentGraphContext || effectiveAutoContextText || undefined, preferredModelId: preferredAgentModelId, threadState: composedConversationState.threadState, }), @@ -2747,6 +2756,21 @@ export async function POST(request: Request) { purpose: "media", hasCurrentImageInput: hasImageInputForConversationAction, }); + recentDialogueMemory = shouldIsolateDirectComposerContext + ? recentDialogueMemory + : buildRecentDialogueMemory({ + messages: loadedMessagesForBoundary, + currentText: requestTextContent, + maxEntries: taskBoundaryDecision.taskType === "chat" ? 6 : 4, + maxTotalChars: taskBoundaryDecision.taskType === "chat" ? 1000 : 700, + }); + if (recentDialogueMemory.entries.length > 0) { + chatApiLogger.info("[RecentDialogueMemory] Request recall:", { + entries: recentDialogueMemory.entries.length, + isRecallRequest: recentDialogueMemory.isRecallRequest, + taskType: taskBoundaryDecision.taskType, + }); + } if (!requestWorkingState && !shouldIsolateDirectComposerContext) { requestWorkingState = await getConversationWorkingStateByChatId({ @@ -8591,16 +8615,33 @@ export async function POST(request: Request) { resolvedRuntimeModelLookupId = actualModelId; } - const effectiveConversationSystemPrompt = shouldRunAgentConversation - ? `${await buildAgentRoleIdentityInstruction({ + const recentDialogueSystemContext = recentDialogueMemory.contextSummary + ? [ + recentDialogueMemory.contextSummary, + ...recentDialogueMemory.memoryHints, + ].join("\n") + : ""; + const baseConversationSystemPrompt = systemPrompt({ + selectedChatModel: finalModelId, + requestHints, + }); + const agentIdentityInstruction = shouldRunAgentConversation + ? await buildAgentRoleIdentityInstruction({ allowVideoGeneration: allowAgentVideoGeneration, hasCanvasContext: Boolean( effectiveContextPayload?.source === "canvas" || effectiveContextPayload?.selectedNodeIds?.length || effectiveContextPayload?.canvasImageNodes?.length, ), - })}\n\n${systemPrompt({ selectedChatModel: finalModelId, requestHints })}` - : systemPrompt({ selectedChatModel: finalModelId, requestHints }); + }) + : ""; + const effectiveConversationSystemPrompt = [ + agentIdentityInstruction, + recentDialogueSystemContext, + baseConversationSystemPrompt, + ] + .filter(Boolean) + .join("\n\n"); const activeRuntimeModelDescriptor = await resolveAgentModelSlotDescriptor( resolvedRuntimeModelLookupId, diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index dcc86638f..939931879 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -1636,130 +1636,6 @@ function CanvasAssistantTypingPlaceholder({ hint }: { hint?: string | null }) { ); } -function CanvasAgentProgressTimeline({ - progress, -}: { - progress?: AgentProgressStreamPayload | null; -}) { - const steps = buildAgentProgressNodeSteps(progress); - const title = progress?.title?.trim() || getAgentProgressFallbackTitle(progress); - const isFailed = progress?.stage === "failed"; - const isCompleted = progress?.stage === "completed"; - const isClarify = progress?.stage === "clarify"; - - return ( -
-
-
- - {isFailed ? ( - - ) : isCompleted ? ( - - ) : ( - - )} - - - {title} - -
- -
-
- {steps.map((step, index) => { - const isStepCompleted = step.state === "completed"; - const isStepCurrent = step.state === "current"; - const isStepFailed = step.state === "failed"; - const lineIsActive = - isStepCompleted || isStepCurrent || isStepFailed; - - return ( -
-
- - {isStepCompleted ? ( - - ) : isStepFailed ? ( - - ) : isStepCurrent ? ( - - ) : ( - - )} - {isStepCurrent ? ( - - ) : null} - - - {step.label} - -
- {index < steps.length - 1 ? ( - - ) : null} -
- ); - })} -
-
-
-
- ); -} - function CanvasComposerUploadProgressDialog({ progress, }: { @@ -13672,10 +13548,6 @@ export function CanvasChatPanel({ ) : null} - {isLoading && !shouldRenderImageLoading && ( - - )} -
diff --git a/lib/ai/runtime/recent-dialogue-memory.ts b/lib/ai/runtime/recent-dialogue-memory.ts new file mode 100644 index 000000000..fa8dbd113 --- /dev/null +++ b/lib/ai/runtime/recent-dialogue-memory.ts @@ -0,0 +1,146 @@ +import type { DBMessage } from "@/lib/db/schema"; + +export interface RecentDialogueMemoryEntry { + role: "user" | "assistant"; + content: string; + messageId?: string; +} + +export interface RecentDialogueMemory { + entries: RecentDialogueMemoryEntry[]; + contextSummary: string; + memoryHints: string[]; + isRecallRequest: boolean; +} + +const USER_RECALL_PATTERN = + /(?:我|用户)?(?:刚才|刚刚|前面|之前|上面|上一(?:句|条|轮)|最近).{0,12}(?:说|发|问|讲|提|输入|发送).{0,12}(?:什么|啥|哪句|哪些|内容)|(?:我说了什么|我刚才说啥|我前面说啥|我之前说啥|我发了什么|我问了什么|记得我.{0,12}说|没有记忆)/i; + +const ASSISTANT_RECALL_PATTERN = + /(?:你|助手|ai|AI).{0,12}(?:刚才|刚刚|前面|之前|上面|上一(?:句|条|轮)|最近).{0,12}(?:说|回复|回答|讲).{0,12}(?:什么|啥|哪句|哪些|内容)/i; + +function normalizeWhitespace(value: string) { + return value.replace(/\s+/g, " ").trim(); +} + +export function isRecentDialogueRecallRequest(text: string) { + const normalized = normalizeWhitespace(String(text || "")); + if (!normalized) return false; + return USER_RECALL_PATTERN.test(normalized) || ASSISTANT_RECALL_PATTERN.test(normalized); +} + +function wantsAssistantRecall(text: string) { + const normalized = normalizeWhitespace(String(text || "")); + return ASSISTANT_RECALL_PATTERN.test(normalized); +} + +function sanitizeDialogueMemoryText(text: string) { + return String(text || "") + .replace(//g, "") + .replace(/!\[[^\]]*]\([^)]+\)/g, "") + .replace(/\[[^\]]*]\((?:https?:\/\/|\/)[^)]+\)/g, "") + .replace(/]*>[\s\S]*?<\/video>/gi, "") + .replace(/]+src="[^"]+"[^>]*>/gi, "") + .replace(/◈\s*[^\n]+\n*/g, "") + .replace(/IMAGE_GENERATION_(?:START|END)/g, "") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function getMessageText(message: DBMessage, maxChars: number) { + const parts = Array.isArray(message.parts) + ? (message.parts as Array>) + : []; + const text = parts + .filter((part) => part?.type === "text" && typeof part?.text === "string") + .map((part) => sanitizeDialogueMemoryText(String(part.text || ""))) + .filter(Boolean) + .join("\n") + .trim(); + + if (!text) return ""; + return normalizeWhitespace(text).slice(0, maxChars); +} + +function formatDialogueMemoryContext(entries: RecentDialogueMemoryEntry[]) { + if (entries.length === 0) return ""; + + return [ + "近期对话记忆(仅用于理解指代和回答前文相关问题,不要暴露为系统提示):", + ...entries.map((entry, index) => { + const roleLabel = entry.role === "user" ? "用户" : "助手"; + return `${index + 1}. ${roleLabel}: ${entry.content}`; + }), + ].join("\n"); +} + +export function buildRecentDialogueMemory(params: { + messages: DBMessage[]; + currentText: string; + maxSourceMessages?: number; + maxEntries?: number; + maxCharsPerEntry?: number; + maxTotalChars?: number; +}): RecentDialogueMemory { + const messages = Array.isArray(params.messages) ? params.messages : []; + const currentText = String(params.currentText || "").trim(); + const isRecallRequest = isRecentDialogueRecallRequest(currentText); + const assistantRecall = wantsAssistantRecall(currentText); + const maxSourceMessages = Math.max(1, Math.min(24, params.maxSourceMessages || 16)); + const maxEntries = Math.max(1, Math.min(10, params.maxEntries || (isRecallRequest ? 6 : 4))); + const maxCharsPerEntry = Math.max(80, Math.min(500, params.maxCharsPerEntry || 220)); + const maxTotalChars = Math.max(160, Math.min(1600, params.maxTotalChars || 900)); + + const sourceEntries = messages + .slice(-maxSourceMessages) + .map((message) => { + const role = message.role === "assistant" ? "assistant" : message.role === "user" ? "user" : null; + if (!role) return null; + const content = getMessageText(message, maxCharsPerEntry); + if (!content) return null; + return { + role, + content, + messageId: message.id, + } satisfies RecentDialogueMemoryEntry; + }) + .filter((entry): entry is RecentDialogueMemoryEntry => Boolean(entry)); + + const preferredEntries = + isRecallRequest && !assistantRecall + ? sourceEntries.filter((entry) => entry.role === "user") + : isRecallRequest && assistantRecall + ? sourceEntries.filter((entry) => entry.role === "assistant") + : sourceEntries; + const fallbackEntries = + preferredEntries.length > 0 ? preferredEntries : sourceEntries; + + const selectedReversed: RecentDialogueMemoryEntry[] = []; + let totalChars = 0; + for (let index = fallbackEntries.length - 1; index >= 0; index -= 1) { + const entry = fallbackEntries[index]; + if (!entry) continue; + if (selectedReversed.length >= maxEntries) break; + if (totalChars + entry.content.length > maxTotalChars && selectedReversed.length > 0) { + continue; + } + selectedReversed.push(entry); + totalChars += entry.content.length; + } + + const entries = selectedReversed.reverse(); + const memoryHints = entries.length + ? [ + isRecallRequest + ? "用户正在询问前文或记忆,应优先根据近期对话记忆回答,不要声称没有上下文。" + : "近期对话记忆只用于理解指代和延续语境,不要逐字复述,除非用户明确询问前文。", + ] + : []; + + return { + entries, + contextSummary: formatDialogueMemoryContext(entries), + memoryHints, + isRecallRequest, + }; +} diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index 1888fa834..6b0027ab4 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -52,6 +52,10 @@ import { resolveCanvasAgentTargetPreference, } from "@/lib/ai/runtime/canvas-agent-kernel"; import { inferAgentImageCountFromText } from "@/lib/ai/runtime/image-count-intent"; +import { + buildRecentDialogueMemory, + isRecentDialogueRecallRequest, +} from "@/lib/ai/runtime/recent-dialogue-memory"; import { isAgentModeScopedChatId, isScopedEmptyHistoryRequest, @@ -221,6 +225,19 @@ import { import type { PickedObjectAttachment } from "@/components/canvas/chat-panel/canvas-chat-session"; import type { CanvasAgentContext } from "@/types/canvas/agent-protocol"; import type { ChatMessage } from "@/lib/types"; +import type { DBMessage } from "@/lib/db/schema"; + +function dbTextMessage( + id: string, + role: "user" | "assistant", + text: string, +): DBMessage { + return { + id, + role, + parts: [{ type: "text", text }], + } as unknown as DBMessage; +} test.describe("canvas chat pure helpers", () => { test("keeps optimistic messages without timestamps after persisted history", () => { @@ -309,6 +326,45 @@ test.describe("canvas chat pure helpers", () => { expect(upstream).toContain("| SKU | 销量 |"); }); + test("builds bounded recent dialogue memory for user recall requests", () => { + const memory = buildRecentDialogueMemory({ + messages: [ + dbTextMessage("m1", "user", "你好"), + dbTextMessage("m2", "assistant", "你好,我在。"), + dbTextMessage("m3", "user", "你是一个小可爱"), + dbTextMessage("m4", "assistant", "嘿嘿,谢谢你的夸奖。"), + ], + currentText: "我前面说了什么?", + }); + + expect(isRecentDialogueRecallRequest("我前面说了什么?")).toBe(true); + expect(memory.isRecallRequest).toBe(true); + expect(memory.entries.map((entry) => entry.role)).toEqual(["user", "user"]); + expect(memory.entries.at(-1)?.content).toBe("你是一个小可爱"); + expect(memory.contextSummary).toContain("用户: 你是一个小可爱"); + expect(memory.memoryHints.join(" ")).toContain("不要声称没有上下文"); + }); + + test("sanitizes media markup from recent dialogue memory", () => { + const memory = buildRecentDialogueMemory({ + messages: [ + dbTextMessage("m1", "user", "生成一只小猫"), + dbTextMessage( + "m2", + "assistant", + "\n![原图](https://example.com/cat.png)\n已完成本次生成" + ), + ], + currentText: "你刚才回复了什么?", + }); + + expect(memory.entries).toHaveLength(1); + expect(memory.entries[0].role).toBe("assistant"); + expect(memory.entries[0].content).toBe("已完成本次生成"); + expect(memory.contextSummary).not.toContain("https://example.com"); + expect(memory.contextSummary).not.toContain("IMAGE_GENERATION_START"); + }); + test("infers explicit agent image counts from user text", () => { expect(inferAgentImageCountFromText("再出三版")).toBe(3); expect(inferAgentImageCountFromText("基于这张图再来 3 张")).toBe(3); From b17779e0347a2a2d98293dc632dac4f72f739065 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 7 Jul 2026 16:33:03 +0800 Subject: [PATCH 09/16] fix: stabilize recent dialogue memory typing --- lib/ai/runtime/recent-dialogue-memory.ts | 28 ++++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/ai/runtime/recent-dialogue-memory.ts b/lib/ai/runtime/recent-dialogue-memory.ts index fa8dbd113..b6b5640fd 100644 --- a/lib/ai/runtime/recent-dialogue-memory.ts +++ b/lib/ai/runtime/recent-dialogue-memory.ts @@ -91,20 +91,20 @@ export function buildRecentDialogueMemory(params: { const maxCharsPerEntry = Math.max(80, Math.min(500, params.maxCharsPerEntry || 220)); const maxTotalChars = Math.max(160, Math.min(1600, params.maxTotalChars || 900)); - const sourceEntries = messages - .slice(-maxSourceMessages) - .map((message) => { - const role = message.role === "assistant" ? "assistant" : message.role === "user" ? "user" : null; - if (!role) return null; - const content = getMessageText(message, maxCharsPerEntry); - if (!content) return null; - return { - role, - content, - messageId: message.id, - } satisfies RecentDialogueMemoryEntry; - }) - .filter((entry): entry is RecentDialogueMemoryEntry => Boolean(entry)); + const sourceEntries: RecentDialogueMemoryEntry[] = []; + for (const message of messages.slice(-maxSourceMessages)) { + const role = message.role === "assistant" ? "assistant" : message.role === "user" ? "user" : null; + if (!role) continue; + + const content = getMessageText(message, maxCharsPerEntry); + if (!content) continue; + + sourceEntries.push({ + role, + content, + messageId: message.id, + }); + } const preferredEntries = isRecallRequest && !assistantRecall From f7676716bba8194eab04a8de8da402b9df474a8f Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 7 Jul 2026 16:54:43 +0800 Subject: [PATCH 10/16] fix: keep canvas user message visible while pending --- components/canvas/CanvasChatPanel.tsx | 49 +++++++++++++++++++-------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 939931879..19e17793d 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -7041,8 +7041,7 @@ export function CanvasChatPanel({ inFlightRawChatIdRef.current = rawChatId; const launchUserMessageId = generateUUID(); const launchCreatedAt = new Date(); - - sendMessage({ + const launchUserMessage = { id: launchUserMessageId, role: "user", parts, @@ -7052,7 +7051,15 @@ export function CanvasChatPanel({ composerMode: activeComposerModeRef.current || activeComposerMode, modelId: selectedModelId, }, - } as any); + } as ChatMessage; + + sendMessage(launchUserMessage as any); + setMessages((previousMessages) => + sortCanvasMessagesByCreatedAt([ + ...previousMessages.filter((message) => message.id !== launchUserMessageId), + launchUserMessage, + ]) + ); hasHydratedHistoryRef.current = true; pendingLaunchRef.current = null; @@ -7065,7 +7072,7 @@ export function CanvasChatPanel({ if (editableDiv) { editableDiv.innerHTML = ""; } - }, [activeChatId, rawChatId, selectedModelId, status, sendMessage, clearSelection, launchAutoSendTick, activeComposerMode]); + }, [activeChatId, rawChatId, selectedModelId, status, sendMessage, setMessages, clearSelection, launchAutoSendTick, activeComposerMode]); // 当历史消息加载完成后,更新消息列表(支持同一会话的增量刷新) const loadedHistorySignatureRef = useRef(""); @@ -7083,6 +7090,12 @@ export function CanvasChatPanel({ ) { return; } + const hasActiveRegularSubmission = + inFlightChatIdRef.current === activeChatId || + inFlightRawChatIdRef.current === rawChatId; + if (hasActiveRegularSubmission && !shouldForceHydrate) { + return; + } const historyMessages = historyData.messages || []; const signature = buildCanvasHistoryMessagesSignature(rawChatId, historyMessages); @@ -7112,7 +7125,7 @@ export function CanvasChatPanel({ } }; } - }, [historyData, isClawdSubmitting, rawChatId, setMessages, status]); + }, [activeChatId, historyData, isClawdSubmitting, rawChatId, setMessages, status]); useEffect(() => { const streamState = streamImageGenerationStateByChatId[activeChatId]; @@ -10999,15 +11012,23 @@ export function CanvasChatPanel({ }); } - // 使用与 multimodal-input 相同的格式发送消息 - try { - sendMessage({ - id: userMessageId, - role: "user", - parts: parts, - createdAt: optimisticCreatedAt, - metadata: optimisticUserMetadata, - } as any); + const optimisticUserMessage: ChatMessage = { + id: userMessageId, + role: "user", + parts, + createdAt: optimisticCreatedAt, + metadata: optimisticUserMetadata, + } as ChatMessage; + + // 使用与 multimodal-input 相同的格式发送消息 + try { + sendMessage(optimisticUserMessage as any); + setMessages((previousMessages) => + sortCanvasMessagesByCreatedAt([ + ...previousMessages.filter((message) => message.id !== userMessageId), + optimisticUserMessage, + ]) + ); } catch (error) { releaseSendGuard(activeChatId); if (shouldShowOptimisticImageGeneration) { From 654829c4fd6fdde01f0c79e5b6d0bbc16ea180c2 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 7 Jul 2026 20:36:18 +0800 Subject: [PATCH 11/16] 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: [ From 574c259812478ba8e797632893b845efbef032b4 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 7 Jul 2026 21:07:18 +0800 Subject: [PATCH 12/16] fix canvas agent image generation status --- app/(chat)/api/chat/route.ts | 31 +++++++++++++++++++++++++++ components/canvas/CanvasChatPanel.tsx | 22 ++++++++++++------- 2 files changed, 45 insertions(+), 8 deletions(-) diff --git a/app/(chat)/api/chat/route.ts b/app/(chat)/api/chat/route.ts index 67e892f9b..fcbfd0b3d 100644 --- a/app/(chat)/api/chat/route.ts +++ b/app/(chat)/api/chat/route.ts @@ -7896,6 +7896,32 @@ export async function POST(request: Request) { operationLabel: "agent-generate-image", resultType: "image", }); + const directImageStatusPayload = { + providerType: requestedModelProviderType, + modelName: finalModelId, + imageCount: + effectiveImageParams?.imageCount || + defaultImageQualityParams.imageCount || + 1, + }; + const stopDirectImageKeepalive = startStreamKeepalive( + writer, + "agent:generate_image", + DEFAULT_STREAM_KEEPALIVE_INTERVAL_MS, + ); + const stopDirectImageStatus = + contextPayload?.source === "canvas" + ? setInterval(() => { + writeImageGenerationStatusChunk( + writer, + { + phase: "processing", + ...directImageStatusPayload, + }, + "agent:generate_image:status-processing-keepalive", + ); + }, DEFAULT_STREAM_KEEPALIVE_INTERVAL_MS) + : null; try { const directImageResult = await executeDirectAgentImageGeneration({ requestTextContent, @@ -8037,6 +8063,11 @@ export async function POST(request: Request) { "", "agent:generate_image:end-error", ); + } finally { + stopDirectImageKeepalive(); + if (stopDirectImageStatus) { + clearInterval(stopDirectImageStatus); + } } }, }); diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 7a894a236..d9eadd00f 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -11006,16 +11006,20 @@ export function CanvasChatPanel({ metadata: optimisticUserMetadata, } as ChatMessage; + setMessages((previousMessages) => + sortCanvasMessagesByCreatedAt([ + ...previousMessages.filter((message) => message.id !== userMessageId), + optimisticUserMessage, + ]) + ); + // 使用与 multimodal-input 相同的格式发送消息 try { sendMessage(optimisticUserMessage as any); - setMessages((previousMessages) => - sortCanvasMessagesByCreatedAt([ - ...previousMessages.filter((message) => message.id !== userMessageId), - optimisticUserMessage, - ]) - ); } catch (error) { + setMessages((previousMessages) => + previousMessages.filter((message) => message.id !== userMessageId) + ); releaseSendGuard(activeChatId); if (shouldShowOptimisticImageGeneration) { clearStreamImageGenerationStateForChat(activeChatId); @@ -12010,8 +12014,10 @@ export function CanvasChatPanel({ ) ); const shouldShowImageLoading = - ((hasGenerationStart && !hasGenerationEnd) || Boolean(streamImageGenerationState)) && - !!currentModel; + Boolean(streamImageGenerationState) || + (hasGenerationStart && + !hasGenerationEnd && + Boolean(currentModel || activeComposerMode === "agent")); const hasActiveTypewriter = Object.keys(activeTypingSections).length > 0; const shouldRenderImageLoading = shouldShowImageLoading && !hasActiveTypewriter; const loadingImageCount = streamImageGenerationState?.imageCount || imageParams.imageCount || 1; From dab44f38255591896528ed5a707b843803ff61d1 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 7 Jul 2026 21:21:12 +0800 Subject: [PATCH 13/16] fix canvas agent duplicate user message --- components/canvas/CanvasChatPanel.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index d9eadd00f..96e36c683 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -11006,20 +11006,16 @@ export function CanvasChatPanel({ metadata: optimisticUserMetadata, } as ChatMessage; - setMessages((previousMessages) => - sortCanvasMessagesByCreatedAt([ - ...previousMessages.filter((message) => message.id !== userMessageId), - optimisticUserMessage, - ]) - ); - // 使用与 multimodal-input 相同的格式发送消息 try { sendMessage(optimisticUserMessage as any); - } catch (error) { setMessages((previousMessages) => - previousMessages.filter((message) => message.id !== userMessageId) + sortCanvasMessagesByCreatedAt([ + ...previousMessages.filter((message) => message.id !== userMessageId), + optimisticUserMessage, + ]) ); + } catch (error) { releaseSendGuard(activeChatId); if (shouldShowOptimisticImageGeneration) { clearStreamImageGenerationStateForChat(activeChatId); From 16f23b3f7ca19341c95044a167dcd912870dbf5d Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 7 Jul 2026 21:38:24 +0800 Subject: [PATCH 14/16] fix canvas cutout intent routing --- lib/ai/runtime/canvas-agent-kernel.ts | 4 +- lib/ai/runtime/intent-arbiter.ts | 32 ++++++------- lib/ai/smart-analyzer.ts | 10 ++-- lib/ai/specialists/index.ts | 2 +- tests/routes/canvas-chat-helpers.test.ts | 60 ++++++++++++++++++++++++ 5 files changed, 82 insertions(+), 26 deletions(-) diff --git a/lib/ai/runtime/canvas-agent-kernel.ts b/lib/ai/runtime/canvas-agent-kernel.ts index 68467384f..6c5ae36f2 100644 --- a/lib/ai/runtime/canvas-agent-kernel.ts +++ b/lib/ai/runtime/canvas-agent-kernel.ts @@ -20,7 +20,7 @@ const DEICTIC_TARGET_REGEX = /(这张图|这只|这个|这款|这套|当前这张|当前这个|当前这只|这一张|这一只|这个产品|这个物体|这只猫|这只狗)/i; const ACTIONABLE_IMAGE_EDIT_REGEX = - /(抠图|去背景|透明背景|去背|扣图|移除背景|去水印|水印|去logo|去\s*logo|移除logo|去除logo|换背景|改背景|背景换成|背景改成|白底|纯色背景|替换.*背景|变清晰|高清|锐化|降噪|提升质量|修复|清晰化|扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|多视角|换视角|改视角|视角变体|新角度|换角度|改角度|去除|消除|擦掉|删除|移除|p掉|重绘|风格化|卡通化|动漫风|油画风|调色|调整颜色|亮度|对比度|饱和度|缩放|放大|缩小|调整尺寸|裁剪|resize|crop|修图|p图|改图|调图|编辑.*图|优化.*图|重做|再调整|继续改|继续调|处理)/i; + /(抠图|去背景|透明背景|去背|扣图|抠出|扣出|抠出来|扣出来|移除背景|去水印|水印|去logo|去\s*logo|移除logo|去除logo|换背景|改背景|背景换成|背景改成|白底|纯色背景|替换.*背景|变清晰|高清|锐化|降噪|提升质量|修复|清晰化|扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|多视角|换视角|改视角|视角变体|新角度|换角度|改角度|去除|消除|擦掉|删除|移除|p掉|重绘|风格化|卡通化|动漫风|油画风|调色|调整颜色|亮度|对比度|饱和度|缩放|放大|缩小|调整尺寸|裁剪|resize|crop|修图|p图|改图|调图|编辑.*图|优化.*图|重做|再调整|继续改|继续调|处理)/i; export function normalizeCanvasAgentRequestText(text: string) { return String(text || "").toLowerCase().replace(/\s+/g, "").trim(); @@ -42,7 +42,7 @@ export function inferCanvasAgentImageEditCapability( if (/去水印|水印|去logo|移除logo|去除logo/.test(normalizedText)) { return "remove_watermark"; } - if (/抠图|去背景|透明背景|去背|扣图|移除背景/.test(normalizedText)) { + if (/抠图|去背景|透明背景|去背|扣图|抠出|扣出|抠出来|扣出来|移除背景/.test(normalizedText)) { return "remove_background"; } if (/多视角|换视角|改视角|视角变体|新角度|换角度|改角度|侧面|侧视|背面|背后|俯视|俯拍|仰视|仰拍|低机位|高机位/.test(normalizedText)) { diff --git a/lib/ai/runtime/intent-arbiter.ts b/lib/ai/runtime/intent-arbiter.ts index ce1081ef1..4cb6141b0 100644 --- a/lib/ai/runtime/intent-arbiter.ts +++ b/lib/ai/runtime/intent-arbiter.ts @@ -94,10 +94,10 @@ function isExplicitImageGenerationRequest(text: string): boolean { const visualTarget = "(?:图|图片|海报|头像|插画|场景|主图|产品图|商品图|电商图|壁纸|封面|logo|图标)"; const createVisualTarget = new RegExp( - `(?:生成|创作|创建|制作|设计|做|出|画|绘制)${countToken}?张?.{0,32}${visualTarget}`, + `(?:生成|创作|创建|制作|设计|做|出|画(?!布)|绘制)${countToken}?张?.{0,32}${visualTarget}`, ); const drawSubject = new RegExp( - `(?:画|绘制|生成|创作|创建)${countToken}?(?:只|个|位|幅|张)?.{0,24}(?:小猫|猫咪|猫|狗|人物|角色|ip|头像|logo|图标|插画)`, + `(?:画(?!布)|绘制|生成|创作|创建)${countToken}?(?:只|个|位|幅|张)?.{0,24}(?:小猫|猫咪|猫|狗|人物|角色|ip|头像|logo|图标|插画)`, ); const continueVariants = new RegExp( `(?:再来|再出|再做|再给我|补|多来|继续生成|继续出)${countToken}?张`, @@ -197,6 +197,9 @@ function buildExecutionFromAnalysis(params: { targetSource: ResolvedTargetAssets["resolutionMode"]; }): IntentArbiterExecution { const mode = mapIntentToMode(params.analysis.intent, params.analysis.needsClarification); + const shouldKeepPlannedToolCalls = + params.execution?.mode === mode && + params.execution?.routeIntent === params.analysis.intent; return { mode, @@ -218,7 +221,7 @@ function buildExecutionFromAnalysis(params: { shouldStayConversational: mode === "respond" || mode === "analyze_image" || mode === "clarify", shouldPublishToCanvas: params.execution?.shouldPublishToCanvas || false, - toolCalls: params.execution?.toolCalls || [], + toolCalls: shouldKeepPlannedToolCalls ? params.execution?.toolCalls || [] : [], availableTools: params.execution?.availableTools || [], }; } @@ -262,6 +265,14 @@ function resolvePolicyIntent(params: { }; } + if (hasUsableImage && likelyEdit) { + return { + intent: "image_edit", + subIntent: params.analysis.subIntent || inferDefaultImageEditSubIntent(params.text), + reason: "image_edit_policy_override", + }; + } + switch (params.actionResolution.kind) { case "image_edit_followup": return { @@ -292,13 +303,6 @@ function resolvePolicyIntent(params: { reason: "task_boundary_execution_video", }; case "execution_image": - if (hasUsableImage && likelyEdit && !explicitImageGen) { - return { - intent: "image_edit", - subIntent: params.analysis.subIntent || inferDefaultImageEditSubIntent(params.text), - reason: "task_boundary_execution_image_edit", - }; - } return { intent: "image_gen", reason: "task_boundary_execution_image_generate", @@ -329,14 +333,6 @@ function resolvePolicyIntent(params: { }; } - if (hasUsableImage && likelyEdit && params.analysis.intent === "chat") { - return { - intent: "image_edit", - subIntent: params.analysis.subIntent || inferDefaultImageEditSubIntent(params.text), - reason: "image_edit_policy_override", - }; - } - return { intent: params.analysis.intent, subIntent: diff --git a/lib/ai/smart-analyzer.ts b/lib/ai/smart-analyzer.ts index 3bde4b6ee..c22451481 100644 --- a/lib/ai/smart-analyzer.ts +++ b/lib/ai/smart-analyzer.ts @@ -33,7 +33,7 @@ export interface AnalysisResult { function inferDefaultImageEditSubIntent(text: string): AnalysisResult["subIntent"] { const lowerText = text.toLowerCase(); if (/去水印|水印|去logo|去\s*logo|移除logo|去除logo/.test(lowerText)) return "remove_watermark"; - if (/抠图|去背景|透明背景|去背|扣图|移除背景/.test(lowerText)) return "remove_background"; + if (/抠图|去背景|透明背景|去背|扣图|抠出|扣出|抠出来|扣出来|移除背景/.test(lowerText)) return "remove_background"; if (/换背景|改背景|背景换成|背景改成|白底|透明底/.test(lowerText)) return "replace_background"; if (/多视角|换视角|改视角|视角变体|新角度|换角度|改角度|侧面|侧视|背面|背后|俯视|俯拍|仰视|仰拍|低机位|高机位/.test(lowerText)) return "angle_variant"; if (hasDirectAgentLocalEditIntent(text)) return "local_edit"; @@ -112,10 +112,10 @@ export function isExplicitImageGenerationRequest(text: string): boolean { const visualTarget = "(?:图|图片|海报|头像|插画|场景|主图|产品图|商品图|电商图|壁纸|封面|logo|图标)"; const createVisualTarget = new RegExp( - `(?:生成|创作|创建|制作|设计|做|出|画|绘制)${countToken}?张?.{0,32}${visualTarget}`, + `(?:生成|创作|创建|制作|设计|做|出|画(?!布)|绘制)${countToken}?张?.{0,32}${visualTarget}`, ); const drawSubject = new RegExp( - `(?:画|绘制|生成|创作|创建)${countToken}?(?:只|个|位|幅|张)?.{0,24}(?:小猫|猫咪|猫|狗|人物|角色|ip|头像|logo|图标|插画)`, + `(?:画(?!布)|绘制|生成|创作|创建)${countToken}?(?:只|个|位|幅|张)?.{0,24}(?:小猫|猫咪|猫|狗|人物|角色|ip|头像|logo|图标|插画)`, ); const continueVariants = new RegExp( `(?:再来|再出|再做|再给我|补|多来|继续生成|继续出)${countToken}?张`, @@ -141,7 +141,7 @@ export function isExplicitImageGenerationRequest(text: string): boolean { function isLikelyImageEditRequest(text: string): boolean { const normalizedText = text.toLowerCase(); - return hasDirectAgentLocalEditIntent(text) || /抠图|去背景|透明背景|去背|扣图|移除背景|去水印|水印|去logo|去\s*logo|移除logo|去除logo|换背景|改背景|背景换成|背景改成|白底|纯色背景|替换.*背景|变清晰|高清|锐化|降噪|提升质量|修复|清晰化|扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|换成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|改成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|比例|画幅|竖版|横版|多视角|换视角|改视角|视角变体|新角度|换角度|改角度|侧面|侧视|背面|背后|俯视|俯拍|仰视|仰拍|低机位|高机位|去除|消除|擦掉|删除|移除|p掉|重绘|风格化|卡通化|动漫风|油画风|转.*风格|变.*风格|改.*风格|调色|调整颜色|亮度|对比度|饱和度|色调|曝光|滤镜|改成.*色|换成.*色|变成.*色|颜色改成|颜色换成|颜色变成|缩放|放大|缩小|调整尺寸|裁剪|resize|crop|修图|p图|改图|调图|编辑.*图|优化.*图|重做|再调整|继续改|继续调/.test( + return hasDirectAgentLocalEditIntent(text) || /抠图|去背景|透明背景|去背|扣图|抠出|扣出|抠出来|扣出来|移除背景|去水印|水印|去logo|去\s*logo|移除logo|去除logo|换背景|改背景|背景换成|背景改成|白底|纯色背景|替换.*背景|变清晰|高清|锐化|降噪|提升质量|修复|清晰化|扩展|拓展|延伸|扩图|outpainting|拓宽|扩大|换成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|改成.*(?:比例|竖版|横版|竖图|横图|9:16|16:9|1:1)|比例|画幅|竖版|横版|多视角|换视角|改视角|视角变体|新角度|换角度|改角度|侧面|侧视|背面|背后|俯视|俯拍|仰视|仰拍|低机位|高机位|去除|消除|擦掉|删除|移除|p掉|重绘|风格化|卡通化|动漫风|油画风|转.*风格|变.*风格|改.*风格|调色|调整颜色|亮度|对比度|饱和度|色调|曝光|滤镜|改成.*色|换成.*色|变成.*色|颜色改成|颜色换成|颜色变成|缩放|放大|缩小|调整尺寸|裁剪|resize|crop|修图|p图|改图|调图|编辑.*图|优化.*图|重做|再调整|继续改|继续调/.test( normalizedText, ); } @@ -2331,7 +2331,7 @@ export function fallbackKeywordAnalysis( } // 抠图/去背景 - if (/抠图|去背景|透明背景|去背|扣图|移除背景/.test(lowerText)) { + if (/抠图|去背景|透明背景|去背|扣图|抠出|扣出|抠出来|扣出来|移除背景/.test(lowerText)) { return { intent: "image_edit", subIntent: "remove_background", diff --git a/lib/ai/specialists/index.ts b/lib/ai/specialists/index.ts index f91793f61..75827ad67 100644 --- a/lib/ai/specialists/index.ts +++ b/lib/ai/specialists/index.ts @@ -250,7 +250,7 @@ function normalizeEditCapability( subIntent: "remove_watermark", }; } - if (subIntent === "remove_background" || /抠图|去背|去背景|透明底/.test(normalizedText)) { + if (subIntent === "remove_background" || /抠图|去背|去背景|透明底|抠出|扣出|抠出来|扣出来/.test(normalizedText)) { return { capability: "remove_background", toolName: "removeBackground", diff --git a/tests/routes/canvas-chat-helpers.test.ts b/tests/routes/canvas-chat-helpers.test.ts index bb5d878e7..ee23944a0 100644 --- a/tests/routes/canvas-chat-helpers.test.ts +++ b/tests/routes/canvas-chat-helpers.test.ts @@ -48,9 +48,11 @@ import { import { shouldBypassCanvasAgentMissingTargetClarification } from "@/lib/ai/runtime/canvas-agent-clarification"; import { hasCanvasAgentRoutingContext } from "@/lib/ai/canvas-agent-routing"; import { + inferCanvasAgentImageEditCapability, resolveCanvasAgentRequestPolicy, resolveCanvasAgentTargetPreference, } from "@/lib/ai/runtime/canvas-agent-kernel"; +import { isExplicitImageGenerationRequest } from "@/lib/ai/smart-analyzer"; import { inferAgentImageCountFromText } from "@/lib/ai/runtime/image-count-intent"; import { buildRecentDialogueMemory, @@ -1463,6 +1465,64 @@ test.describe("canvas chat pure helpers", () => { expect(directContext?.targetImageUrl).toBe(canvasImageUrl); }); + test("routes canvas subject cutout wording to remove background instead of image generation", () => { + const canvasImageUrl = "https://cdn.example.com/canvas-dog.png"; + const userText = "帮我将画布中的狗的图扣出来"; + + expect(isExplicitImageGenerationRequest(userText)).toBe(false); + expect(inferCanvasAgentImageEditCapability(userText)).toBe("remove_background"); + + const directContext = resolveDirectAgentToolContext({ + execution: { + mode: "edit_image", + routeIntent: "image_edit", + routeSubIntent: "remove_background", + targetSource: "text_reference", + preferredCapability: "remove_background", + shouldUseStructuredReply: true, + shouldOptimizePrompt: true, + shouldApplyImagePipeline: true, + shouldApplyVideoPipeline: false, + shouldStayConversational: false, + shouldPublishToCanvas: false, + toolCalls: [], + availableTools: ["resolveTargetImage", "removeBackground"], + } as any, + requestResolvedMediaInput: { + imageRefs: [], + currentMessageImageRefs: [], + selectedImageRefs: [], + canvasImageRefs: [ + { + url: canvasImageUrl, + normalizedUrl: canvasImageUrl, + nodeId: "dog-node", + source: "canvas_context", + role: "reference", + }, + ], + historyUserImageRefs: [], + historyAssistantImageRefs: [], + resolvedImageRefs: [], + currentMessageImageUrls: [], + selectedImageUrls: [], + canvasImageUrls: [canvasImageUrl], + historyUserImageUrls: [], + historyAssistantImageUrls: [], + resolvedImageUrls: [], + selectedAssetIds: [], + candidateAssetIds: [], + uploadedAssetIds: [], + hasImage: true, + source: "canvas_context", + fromHistory: false, + } as any, + }); + + expect(directContext?.execution.toolName).toBe("removeBackground"); + expect(directContext?.targetImageUrl).toBe(canvasImageUrl); + }); + test("only runs direct image edit batches for explicit safe batch requests", () => { expect( shouldRunDirectAgentBatchImageEdit({ From f692425607006835977bfabd5820ce8dd3d3c87d Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 7 Jul 2026 22:13:39 +0800 Subject: [PATCH 15/16] fix: persist claw chat sessions --- components/canvas/CanvasChatPanel.tsx | 137 +++++++++++++++++++++++--- 1 file changed, 123 insertions(+), 14 deletions(-) diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index 96e36c683..bb3c104b3 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -2432,6 +2432,33 @@ export function CanvasChatPanel({ ); }, [getSendGuard]); + const persistChatSessions = useCallback( + (sessions: CanvasChatSession[]) => { + const sessionStorageKey = getSessionStorageKey(canvasId, chatScope); + try { + localStorage.setItem(sessionStorageKey, JSON.stringify(sessions)); + setLoadedChatSessionStorageKey(sessionStorageKey); + } catch (error) { + console.error("[CanvasChat] Failed to save chat sessions:", error); + } + }, + [canvasId, chatScope] + ); + + const updateChatSessions = useCallback( + (updater: (previousSessions: CanvasChatSession[]) => CanvasChatSession[]) => { + setChatSessions((previousSessions) => { + const nextSessions = updater(previousSessions); + if (nextSessions === previousSessions) { + return previousSessions; + } + persistChatSessions(nextSessions); + return nextSessions; + }); + }, + [persistChatSessions] + ); + const startNewChatSession = useCallback( (options?: { mode?: CanvasComposerMode; @@ -2462,13 +2489,19 @@ export function CanvasChatPanel({ forceHistoryHydrationRef.current = null; releaseSendGuard(activeChatId); - setChatSessions((prevSessions) => [newSession, ...prevSessions]); + updateChatSessions((prevSessions) => [newSession, ...prevSessions]); setActiveChatId(newChatSessionId); replaceChatSessionQuery(newChatSessionId); toast.success(options?.toastMessage || "已新建聊天窗口"); return newChatSessionId; }, - [activeChatId, chatSessions, releaseSendGuard, replaceChatSessionQuery] + [ + activeChatId, + chatSessions, + releaseSendGuard, + replaceChatSessionQuery, + updateChatSessions, + ] ); const setSessionClawdSkillTag = useCallback( @@ -2479,11 +2512,12 @@ export function CanvasChatPanel({ ) => { if (!chatSessionId) return; const normalizedTagId = String(tagId || "").trim() || null; - setChatSessions((prevSessions) => + updateChatSessions((prevSessions) => prevSessions.map((session) => session.id === chatSessionId ? { ...session, + mode: "clawd", clawdSkillTagId: normalizedTagId, clawdSkillTagPromptApplied: normalizedTagId === null @@ -2495,17 +2529,18 @@ export function CanvasChatPanel({ ) ); }, - [] + [updateChatSessions] ); const markSessionClawdSkillTagPromptApplied = useCallback( (chatSessionId: string, tagId: string) => { if (!chatSessionId || !tagId) return; - setChatSessions((prevSessions) => + updateChatSessions((prevSessions) => prevSessions.map((session) => session.id === chatSessionId && session.clawdSkillTagId === tagId ? { ...session, + mode: "clawd", clawdSkillTagPromptApplied: true, updatedAt: Date.now(), } @@ -2513,7 +2548,7 @@ export function CanvasChatPanel({ ) ); }, - [] + [updateChatSessions] ); const handleTypewriterActiveChange = useCallback((typingKey: string, active: boolean) => { @@ -4510,12 +4545,84 @@ export function CanvasChatPanel({ activeChatSession, activeComposerMode ); + useEffect(() => { + if (activeChatSessionMode !== "clawd") return; + if (!isClawdModeEnabled) return; + if (activeComposerMode !== "clawd") { + setComposerModeOverride("clawd"); + } + if (isDetailGenerationMode) { + setIsDetailGenerationMode(false); + } + }, [ + activeChatSessionMode, + activeComposerMode, + isClawdModeEnabled, + isDetailGenerationMode, + ]); + + useEffect(() => { + if (!activeChatId) return; + const historyMessages = historyData?.messages || []; + if (historyMessages.length === 0) return; + if (!historyMessages.some(isClawdChatMessage)) return; + + updateChatSessions((prevSessions) => { + const currentIndex = prevSessions.findIndex( + (session) => session.id === activeChatId + ); + const now = Date.now(); + + if (currentIndex < 0) { + const title = + deriveSessionTitleFromMessages(historyMessages) || `${CLAW_MODE_LABEL} 对话`; + return [ + { + id: activeChatId, + title, + mode: "clawd", + createdAt: now, + updatedAt: now, + }, + ...prevSessions, + ]; + } + + const current = prevSessions[currentIndex]; + if (getCanvasChatSessionMode(current) === "clawd") { + return prevSessions; + } + + const nextSessions = [...prevSessions]; + nextSessions[currentIndex] = { + ...current, + mode: "clawd", + updatedAt: Math.max(Number(current.updatedAt) || 0, now), + }; + nextSessions.sort((a, b) => b.updatedAt - a.updatedAt); + return nextSessions; + }); + + if (isClawdModeEnabled && activeComposerMode !== "clawd") { + setComposerModeOverride("clawd"); + if (isDetailGenerationMode) { + setIsDetailGenerationMode(false); + } + } + }, [ + activeChatId, + activeComposerMode, + historyData?.messages, + isClawdModeEnabled, + isDetailGenerationMode, + updateChatSessions, + ]); const currentModeChatSessions = useMemo( () => chatSessions.filter( - (session) => getCanvasChatSessionMode(session) === activeComposerMode + (session) => getCanvasChatSessionMode(session) === activeChatSessionMode ), - [activeComposerMode, chatSessions] + [activeChatSessionMode, chatSessions] ); const agentSelectableModels = useMemo( () => @@ -5319,7 +5426,7 @@ export function CanvasChatPanel({ if (canReuseActiveEmptySession) { forceHistoryHydrationRef.current = null; targetChatSessionId = activeChatId; - setChatSessions((prevSessions) => + updateChatSessions((prevSessions) => prevSessions.map((session) => session.id === activeChatId ? { @@ -5404,6 +5511,7 @@ export function CanvasChatPanel({ releaseSendGuard, replaceChatSessionQuery, startNewChatSession, + updateChatSessions, ] ); @@ -7216,7 +7324,7 @@ export function CanvasChatPanel({ const derivedTitle = deriveSessionTitleFromMessages(messages); const now = Date.now(); - setChatSessions((prevSessions) => { + updateChatSessions((prevSessions) => { const currentIndex = prevSessions.findIndex( (session) => session.id === activeChatId ); @@ -7252,7 +7360,7 @@ export function CanvasChatPanel({ nextSessions.sort((a, b) => b.updatedAt - a.updatedAt); return nextSessions; }); - }, [activeChatId, messages]); + }, [activeChatId, messages, updateChatSessions]); useEffect(() => { if (activeChatSessionMode === "clawd") return; @@ -10550,7 +10658,7 @@ export function CanvasChatPanel({ }, } as ChatMessage; - setChatSessions((prevSessions) => + updateChatSessions((prevSessions) => prevSessions.map((session) => session.id === activeChatId ? { @@ -11096,6 +11204,7 @@ export function CanvasChatPanel({ status, mutate, markSessionClawdSkillTagPromptApplied, + updateChatSessions, buildHistoryMessagesUrl, workflowContext, messages, @@ -12473,8 +12582,8 @@ export function CanvasChatPanel({ ); const createNewChatSession = useCallback(() => { - startNewChatSession({ mode: activeComposerMode }); - }, [activeComposerMode, startNewChatSession]); + startNewChatSession({ mode: activeChatSessionMode }); + }, [activeChatSessionMode, startNewChatSession]); const switchToSession = useCallback((sessionId: string) => { if (!sessionId || sessionId === activeChatId) { From 6c89df0f8bf84937fb91a3482fa1488264764b93 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 7 Jul 2026 22:26:04 +0800 Subject: [PATCH 16/16] feat: add global claw chat history --- app/api/canvas/clawd-chat/history/route.ts | 42 +++ components/canvas/CanvasChatPanel.tsx | 321 +++++++++++++++++---- lib/db/queries.ts | 38 +++ 3 files changed, 341 insertions(+), 60 deletions(-) create mode 100644 app/api/canvas/clawd-chat/history/route.ts diff --git a/app/api/canvas/clawd-chat/history/route.ts b/app/api/canvas/clawd-chat/history/route.ts new file mode 100644 index 000000000..822587749 --- /dev/null +++ b/app/api/canvas/clawd-chat/history/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server"; +import { auth } from "@/app/(auth)/auth"; +import { getClawdChatsByUserId } from "@/lib/db/queries"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +export async function GET(request: Request) { + const session = await auth().catch(() => null); + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const url = new URL(request.url); + const limit = Math.max( + 1, + Math.min(100, Math.round(Number(url.searchParams.get("limit") || 50))) + ); + + const chats = await getClawdChatsByUserId({ + userId: session.user.id, + limit, + }); + + return NextResponse.json( + { + chats: chats.map((chat) => ({ + id: chat.id, + title: chat.title, + createdAt: chat.createdAt?.toISOString?.() || chat.createdAt, + updatedAt: chat.updatedAt?.toISOString?.() || chat.updatedAt, + messageCount: Number(chat.messageCount || 0), + })), + }, + { + headers: { + "Cache-Control": "no-store, max-age=0", + }, + } + ); +} diff --git a/components/canvas/CanvasChatPanel.tsx b/components/canvas/CanvasChatPanel.tsx index bb3c104b3..cc450b929 100644 --- a/components/canvas/CanvasChatPanel.tsx +++ b/components/canvas/CanvasChatPanel.tsx @@ -1971,6 +1971,14 @@ interface CanvasChatSession { clawdSkillTagPromptApplied?: boolean; } +interface ClawdHistoryItem { + id: string; + title: string; + createdAt: string | Date; + updatedAt: string | Date; + messageCount: number; +} + const CANVAS_CHAT_QUERY_PARAM = "chat"; const CANVAS_IMAGE_NODE_MANUAL_UPLOAD_EVENT = "canvas:image-node-manual-upload"; @@ -2194,6 +2202,7 @@ export function CanvasChatPanel({ const inFlightRawChatIdRef = useRef(null); const [chatSessions, setChatSessions] = useState([]); const [isSessionMenuOpen, setIsSessionMenuOpen] = useState(false); + const [isClawdHistoryMenuOpen, setIsClawdHistoryMenuOpen] = useState(false); const [isComposerModeMenuOpen, setIsComposerModeMenuOpen] = useState(false); const [selectedSkillTagId, setSelectedSkillTagId] = useState(null); const [areCanvasSkillTagsExpanded, setAreCanvasSkillTagsExpanded] = useState(false); @@ -2239,6 +2248,7 @@ export function CanvasChatPanel({ ); const hasInitializedVideoWorkspaceRef = useRef(false); const sessionMenuRef = useRef(null); + const clawdHistoryMenuRef = useRef(null); const historyScrollTimerRef = useRef | null>(null); const forceHistoryHydrationRef = useRef(null); const copiedUserMessageRef = useRef(null); @@ -2963,6 +2973,17 @@ export function CanvasChatPanel({ return () => document.removeEventListener("mousedown", handleClickOutside); }, [isSessionMenuOpen]); + useEffect(() => { + if (!isClawdHistoryMenuOpen) return; + const handleClickOutside = (event: MouseEvent) => { + if (!clawdHistoryMenuRef.current?.contains(event.target as Node)) { + setIsClawdHistoryMenuOpen(false); + } + }; + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isClawdHistoryMenuOpen]); + useEffect(() => { const sessionStorageKey = getSessionStorageKey(canvasId, chatScope); if (loadedChatSessionStorageKey !== sessionStorageKey) return; @@ -4545,6 +4566,19 @@ export function CanvasChatPanel({ activeChatSession, activeComposerMode ); + const clawdHistorySWRKey = + isClawdMode && isClawdHistoryMenuOpen + ? "/api/canvas/clawd-chat/history?limit=80" + : null; + const { + data: clawdHistoryData, + isLoading: isClawdHistoryLoading, + mutate: mutateClawdHistory, + } = useSWR<{ chats: ClawdHistoryItem[] }>(clawdHistorySWRKey, fetcher, { + revalidateOnFocus: false, + revalidateOnReconnect: false, + dedupingInterval: 5000, + }); useEffect(() => { if (activeChatSessionMode !== "clawd") return; if (!isClawdModeEnabled) return; @@ -10785,6 +10819,7 @@ export function CanvasChatPanel({ if (rawChatId) { void mutate(buildHistoryMessagesUrl(rawChatId, { cacheBust: true })); } + void mutateClawdHistory(); hasHydratedHistoryRef.current = true; } catch (error) { console.error("[CanvasChat] Claw request failed:", error); @@ -11203,6 +11238,7 @@ export function CanvasChatPanel({ shouldBlockDuplicateSend, status, mutate, + mutateClawdHistory, markSessionClawdSkillTagPromptApplied, updateChatSessions, buildHistoryMessagesUrl, @@ -12598,6 +12634,70 @@ export function CanvasChatPanel({ toast.success("已切换聊天窗口"); }, [activeChatId, releaseSendGuard, replaceChatSessionQuery]); + const switchToClawdHistorySession = useCallback( + (historyItem: ClawdHistoryItem) => { + const sessionId = normalizeCanvasChatSessionId(historyItem.id); + if (!sessionId) return; + + const updatedAt = Date.parse(String(historyItem.updatedAt || "")) || Date.now(); + const createdAt = Date.parse(String(historyItem.createdAt || "")) || updatedAt; + const title = String(historyItem.title || "").trim() || `${CLAW_MODE_LABEL} 对话`; + + updateChatSessions((prevSessions) => { + const currentIndex = prevSessions.findIndex( + (session) => session.id === sessionId + ); + + if (currentIndex < 0) { + return [ + { + id: sessionId, + title, + mode: "clawd", + createdAt, + updatedAt, + }, + ...prevSessions, + ]; + } + + const nextSessions = [...prevSessions]; + nextSessions[currentIndex] = { + ...nextSessions[currentIndex], + title, + mode: "clawd", + updatedAt: Math.max( + Number(nextSessions[currentIndex]?.updatedAt) || 0, + updatedAt + ), + }; + nextSessions.sort((a, b) => b.updatedAt - a.updatedAt); + return nextSessions; + }); + + forceHistoryHydrationRef.current = toRawCanvasChatId(sessionId); + releaseSendGuard(activeChatId); + setActiveChatId(sessionId); + setComposerModeOverride("clawd"); + setIsDetailGenerationMode(false); + replaceChatSessionQuery(sessionId); + setIsClawdHistoryMenuOpen(false); + toast.success("已打开 Claw 历史对话"); + }, + [activeChatId, releaseSendGuard, replaceChatSessionQuery, updateChatSessions] + ); + + const formatClawdHistoryTime = useCallback((value: string | Date) => { + const timestamp = Date.parse(String(value || "")); + if (!Number.isFinite(timestamp)) return ""; + return new Date(timestamp).toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + }, []); + const activeSessionTitle = useMemo(() => { const current = chatSessions.find((session) => session.id === activeChatId); return String(current?.title || "").trim(); @@ -12726,72 +12826,173 @@ export function CanvasChatPanel({ > -
- - {isSessionMenuOpen && ( -
+ - ); - }) + + Claw + + {isClawdHistoryMenuOpen && ( +
+
+
+ Claw 对话历史 +
+ +
+
+ {isClawdHistoryLoading ? ( +
+ + 正在加载 Claw 历史 +
+ ) : (clawdHistoryData?.chats || []).length === 0 ? ( +
+ 暂无 Claw 历史 +
+ ) : ( + (clawdHistoryData?.chats || []).map((item) => { + const sessionId = normalizeCanvasChatSessionId(item.id); + const isActive = sessionId === activeChatId; + const timeLabel = formatClawdHistoryTime(item.updatedAt); + return ( + + ); + }) + )} +
-
- )} -
+ )} +
+ ) : ( +
+ + {isSessionMenuOpen && ( +
+
聊天窗口记录
+
+ {currentModeChatSessions.length === 0 ? ( +
暂无记录
+ ) : ( + currentModeChatSessions.map((session, index) => { + const isActive = session.id === activeChatId; + const timeLabel = new Date(session.updatedAt).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + return ( + + ); + }) + )} +
+
+ )} +
+ )}