From e7dffeca37fb2d97329248e91c560331586188c6 Mon Sep 17 00:00:00 2001 From: Abeautifulsnow Date: Thu, 2 Jul 2026 18:49:50 +0800 Subject: [PATCH] fix(ai): tool call status cards, table overflow, streaming TextDelta * fix(ai): merge tool call status cards * fix(ai): constrain markdown table overflow * fix(ai): restore streaming of answer text via TextDelta events The on_chunk closure was missing live AgentEvent::TextDelta emission for text content, causing the AI answer text to appear all at once after the turn completed. Reasoning deltas were unaffected. Root cause: commit c30033628 rewrote the on_chunk closure and accidentally dropped the TextDelta emit line while keeping ReasoningDelta intact. Fix: restore TextDelta emit in on_chunk for incremental streaming, and remove the post-turn full-TextDelta workaround which would duplicate content. * test(ai): extract chunk_to_events and add streaming event tests Extract the event-generation logic from the on_chunk closure into a pure function chunk_to_events() to make it testable, and add 6 unit tests covering all chunk content combinations: text-only, reasoning-only, mixed, empty, and edge cases. This ensures a regression like the one fixed in the previous commit (where TextDelta emission was dropped while ReasoningDelta was kept) would be caught by tests. --- .../src/components/editor/AiAssistant.vue | 77 +++++++++++---- .../src/lib/__tests__/aiMarkdown.spec.ts | 56 +++++++++++ .../src/lib/aiAgentStepPresentation.ts | 29 ++++++ apps/desktop/src/lib/aiMarkdown.ts | 16 +++ crates/dbx-core/src/agent_loop.rs | 98 ++++++++++++++++++- .../app-tests/aiAgentStepPresentation.test.ts | 65 +++++++++++- 6 files changed, 314 insertions(+), 27 deletions(-) diff --git a/apps/desktop/src/components/editor/AiAssistant.vue b/apps/desktop/src/components/editor/AiAssistant.vue index 03fb71428..60fc7ea9d 100644 --- a/apps/desktop/src/components/editor/AiAssistant.vue +++ b/apps/desktop/src/components/editor/AiAssistant.vue @@ -22,7 +22,7 @@ import { buildAiContext, runAgentStream, isVectorDbType, type AiAction } from "@ import { formatAiModelOption } from "@/lib/aiModelPresentation"; import type { AgentEvent } from "@/lib/tauri"; import { buildAiAgentPlan } from "@/lib/aiAgentPlan"; -import { buildAiAgentStepItems, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/aiAgentStepPresentation"; +import { buildAiAgentStepItems, toolCallStepKey, upsertAgentStep, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/aiAgentStepPresentation"; import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/aiCodeHighlighter"; import { createAiMessageRenderer } from "@/lib/aiMessageRender"; import { formatAiInlineMarkdown, handleAiMarkdownLinkClick } from "@/lib/aiMarkdown"; @@ -401,17 +401,18 @@ function agentStepIcon(tone: AiAgentStepTone) { } function agentStepClass(tone: AiAgentStepTone): string { + const base = "transition-colors duration-200 ease-out motion-safe:transition-colors motion-reduce:transition-none"; switch (tone) { case "success": - return "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"; + return `border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300 ${base}`; case "active": - return "border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300"; + return `border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300 ${base}`; case "warning": - return "border-amber-500/35 bg-amber-500/10 text-amber-700 dark:text-amber-300"; + return `border-amber-500/35 bg-amber-500/10 text-amber-700 dark:text-amber-300 ${base}`; case "danger": - return "border-red-500/35 bg-red-500/10 text-red-700 dark:text-red-300"; + return `border-red-500/35 bg-red-500/10 text-red-700 dark:text-red-300 ${base}`; default: - return "border-border bg-background/60 text-muted-foreground"; + return `border-border bg-background/60 text-muted-foreground ${base}`; } } @@ -468,21 +469,32 @@ function agentEventToStep(event: AgentEvent, index: number): AiAgentStepItem | u if (event.type !== "tool_call_start" && event.type !== "tool_call_end") return undefined; + // Use a stable key based on tool_call_id so start and end events map to the same card. + const toolKey = toolCallStepKey(event.tool_call_id, index, event.type); + + if (event.type === "tool_call_start") { + return { + key: toolKey, + labelKey: "ai.agentSteps.callingTool", + tone: "active", + toolName: event.tool_name, + toolArgs: event.args as Record, + }; + } + + // tool_call_end: produce a final step; toolArgs will be merged from the start step by upsert if missing. const isExecuteQuery = event.tool_name === "execute_query" || event.tool_name === "dbx_execute_query"; - const labelKey = event.type === "tool_call_start" ? "ai.agentSteps.callingTool" : isExecuteQuery ? (event.is_error ? "ai.agentSteps.executeBlocked" : "ai.agentSteps.executeSafe") : event.is_error ? "ai.agentSteps.toolError" : "ai.agentSteps.toolDone"; - const tone = (event.type === "tool_call_start" ? "active" : event.is_error ? "danger" : "success") as AiAgentStepTone; + const labelKey = isExecuteQuery ? (event.is_error ? "ai.agentSteps.executeBlocked" : "ai.agentSteps.executeSafe") : event.is_error ? "ai.agentSteps.toolError" : "ai.agentSteps.toolDone"; + const tone: AiAgentStepTone = event.is_error ? "danger" : "success"; return { - key: `${event.tool_call_id || ""}-${event.type}`, + key: toolKey, labelKey, tone, - titleKey: undefined, - titleParams: { tool: event.tool_name || "" }, toolName: event.tool_name, - toolArgs: event.type === "tool_call_start" ? (event.args as Record) : undefined, - toolResult: event.type === "tool_call_end" ? extractToolResultContent(event.result) : undefined, - explainData: event.type === "tool_call_end" ? extractExplainData(event.result) : undefined, - isError: event.type === "tool_call_end" ? event.is_error : undefined, + toolResult: extractToolResultContent(event.result), + explainData: extractExplainData(event.result), + isError: event.is_error, }; } @@ -794,7 +806,7 @@ async function send() { if (msg) { if (!msg.agentSteps) msg.agentSteps = []; const step = agentEventToStep(event, agentEvents.length - 1); - if (step) msg.agentSteps.push(step); + if (step) upsertAgentStep(msg.agentSteps, step); } pendingCompaction.value = { summary: event.summary, compactedMessages: event.compacted_messages }; } @@ -804,7 +816,7 @@ async function send() { if (msg) { if (!msg.agentSteps) msg.agentSteps = []; const step = agentEventToStep(event, agentEvents.length - 1); - if (step) msg.agentSteps.push(step); + if (step) upsertAgentStep(msg.agentSteps, step); } } scrollToBottom(); @@ -818,9 +830,14 @@ async function send() { const msg = messages.value[assistantIdx]; if (msg) msg.isThinking = false; isGenerating.value = false; - // Render agent tool call steps from agent events + // Render agent tool call steps from agent events (fallback when no real-time steps) if (msg && agentEvents.length > 0 && !msg.agentSteps?.length) { - msg.agentSteps = agentEvents.map((e, index) => agentEventToStep(e, index)).filter((step): step is AiAgentStepItem => Boolean(step)); + const steps: AiAgentStepItem[] = []; + agentEvents.forEach((e, index) => { + const step = agentEventToStep(e, index); + if (step) upsertAgentStep(steps, step); + }); + if (steps.length) msg.agentSteps = steps; } // Fallback: use aiAgentPlan for backward compatibility if (msg && !msg.agentSteps?.length) { @@ -1117,7 +1134,7 @@ async function openExternalUrl(url: string) {
-
+