fix: remove agent progress bubble and add dialogue memory

This commit is contained in:
134 2026-07-07 16:30:03 +08:00
parent 6a2b928080
commit c17de9def5
4 changed files with 268 additions and 153 deletions

View File

@ -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,

View File

@ -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 (
<div className="flex w-full justify-start">
<div
aria-label={title}
className={cn(
"w-full max-w-[min(92%,860px)] rounded-[22px] border px-4 py-3 shadow-[0_16px_44px_-32px_rgba(15,23,42,0.65)] backdrop-blur-sm",
isFailed
? "border-rose-300/40 bg-rose-50/70 dark:border-rose-400/20 dark:bg-rose-950/20"
: isClarify
? "border-amber-300/40 bg-amber-50/70 dark:border-amber-400/20 dark:bg-amber-950/20"
: "border-[#d8deea]/70 bg-white/72 dark:border-white/10 dark:bg-white/6"
)}
>
<div className="flex items-center gap-2.5">
<span
className={cn(
"inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full",
isFailed
? "bg-rose-500/12 text-rose-600 dark:text-rose-300"
: isClarify
? "bg-amber-500/12 text-amber-600 dark:text-amber-300"
: isCompleted
? "bg-emerald-500/12 text-emerald-600 dark:text-emerald-300"
: "bg-sky-500/12 text-sky-600 dark:text-sky-300"
)}
>
{isFailed ? (
<AlertCircle className="h-4 w-4" />
) : isCompleted ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Loader2 className="h-4 w-4 animate-spin" />
)}
</span>
<span className="text-[15px] font-medium text-[#5d6675] dark:text-zinc-300">
{title}
</span>
</div>
<div className="mt-4 overflow-x-auto pb-1 scrollbar-hide">
<div className="flex min-w-max items-center">
{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 (
<div key={step.key} className="flex items-center">
<div className="flex min-w-max items-center gap-2">
<span
className={cn(
"relative inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 transition-colors",
isStepFailed
? "border-rose-500 bg-rose-500 text-white"
: isStepCompleted
? "border-emerald-500 bg-emerald-500 text-white"
: isStepCurrent
? "border-sky-500 bg-white text-sky-500 dark:bg-[#111318]"
: "border-[#cfd6e2] bg-white text-transparent dark:border-white/18 dark:bg-white/8"
)}
>
{isStepCompleted ? (
<Check className="h-2.5 w-2.5" />
) : isStepFailed ? (
<span className="h-1.5 w-1.5 rounded-full bg-current" />
) : isStepCurrent ? (
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-current" />
) : (
<span className="h-1.5 w-1.5 rounded-full bg-current" />
)}
{isStepCurrent ? (
<span className="absolute inset-[-5px] rounded-full border border-sky-400/35" />
) : null}
</span>
<span
className={cn(
"whitespace-nowrap text-[14px] font-medium leading-5",
isStepFailed
? "text-rose-600 dark:text-rose-300"
: isStepCompleted
? "text-emerald-600 dark:text-emerald-300"
: isStepCurrent
? "text-sky-600 dark:text-sky-300"
: "text-[#9aa3b2] dark:text-zinc-500"
)}
>
{step.label}
</span>
</div>
{index < steps.length - 1 ? (
<span
className={cn(
"mx-3 h-px w-9 shrink-0 transition-colors",
lineIsActive
? isStepFailed
? "bg-rose-300 dark:bg-rose-400/35"
: "bg-sky-300 dark:bg-sky-400/35"
: "bg-[#d8dee9] dark:bg-white/10"
)}
/>
) : null}
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
function CanvasComposerUploadProgressDialog({
progress,
}: {
@ -13672,10 +13548,6 @@ export function CanvasChatPanel({
<CanvasAssistantTypingPlaceholder hint={clawdProcessingHint} />
) : null}
{isLoading && !shouldRenderImageLoading && (
<CanvasAgentProgressTimeline progress={activeAgentProgress} />
)}
<div ref={messagesEndRef} />
</div>
</div>

View File

@ -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(/<!--[\s\S]*?-->/g, "")
.replace(/!\[[^\]]*]\([^)]+\)/g, "")
.replace(/\[[^\]]*]\((?:https?:\/\/|\/)[^)]+\)/g, "")
.replace(/<video[^>]*>[\s\S]*?<\/video>/gi, "")
.replace(/<video[^>]+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<Record<string, unknown>>)
: [];
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,
};
}

View File

@ -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",
"<!-- IMAGE_GENERATION_START -->\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);