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);