diff --git a/langgraph/src/agent/searcher/nodes/tool-executor.ts b/langgraph/src/agent/searcher/nodes/tool-executor.ts index f816cd0..342a12d 100644 --- a/langgraph/src/agent/searcher/nodes/tool-executor.ts +++ b/langgraph/src/agent/searcher/nodes/tool-executor.ts @@ -59,6 +59,7 @@ export async function toolExecutorNode( switch (name) { case "google_search": { const parsed = googleSearchSchema.parse(args); + const googleCardId = `google-search-${tc.id}`; let results: Array<{ title: string; url: string; snippet: string }>; let knowledgeGraph: { title: string; description: string } | undefined; let fallbackUsed = false; @@ -103,6 +104,7 @@ export async function toolExecutorNode( { name: "search-result", props: { + card_id: googleCardId, query: parsed.query, total: results.length, results, @@ -139,6 +141,7 @@ export async function toolExecutorNode( case "web_search_deep": { const parsed = webSearchDeepSchema.parse(args); + const deepCardId = `web-search-deep-${tc.id}`; let enriched: Array<{ title: string; url: string; snippet: string }>; let fallbackUsed = false; let readerAllFailed = false; @@ -202,6 +205,7 @@ export async function toolExecutorNode( { name: "search-result", props: { + card_id: deepCardId, query: parsed.query, total: enriched.length, results: enriched, diff --git a/langgraph/src/components/ToolCallStatus.tsx b/langgraph/src/components/ToolCallStatus.tsx index 51cb223..9f07de6 100644 --- a/langgraph/src/components/ToolCallStatus.tsx +++ b/langgraph/src/components/ToolCallStatus.tsx @@ -325,10 +325,25 @@ export default function ToolCallStatus({ let uiItem: UIMsgLocal | undefined; if (tc.name && UI_NAME_MAP[tc.name]) { const uiName = UI_NAME_MAP[tc.name]; - const matchIdx = matchCounters[uiName] ?? 0; - matchCounters[uiName] = matchIdx + 1; - const candidates = uiItems.filter((ui) => ui.name === uiName); - uiItem = candidates[matchIdx]; + // First try exact match: card_id contains the tool call id (e.g. "google-search-") + if (tc.id) { + uiItem = uiItems.find( + (ui) => ui.name === uiName && typeof ui.props?.card_id === "string" && (ui.props.card_id as string).includes(tc.id!), + ); + } + // Fallback: match by index (for cards without card_id or where id is not embedded) + if (!uiItem) { + const matchIdx = matchCounters[uiName] ?? 0; + const candidates = uiItems.filter( + (ui) => ui.name === uiName && ( + !tc.id || + typeof ui.props?.card_id !== "string" || + !(ui.props.card_id as string).includes(tc.id) + ), + ); + uiItem = candidates[matchIdx]; + } + matchCounters[uiName] = (matchCounters[uiName] ?? 0) + 1; } // 优先按 toolCallId 精确匹配,降级按 index diff --git a/langgraph/src/main.tsx b/langgraph/src/main.tsx index 7c60f65..7e2481e 100644 --- a/langgraph/src/main.tsx +++ b/langgraph/src/main.tsx @@ -49,18 +49,27 @@ class CardErrorBoundary extends Component< // ─── Card deduplication by card_id ────────────────────────────────────────── // When the backend pushes loading then complete cards with the same card_id, -// keep only the last (most recent) card per card_id. Cards without card_id pass through. +// keep only the terminal-state card per card_id. Cards without card_id pass through. +// Priority: done/error > reading/any-other (prevents race where parallel tool +// executions push reading and done out of order, retaining the skeleton instead +// of the completed content). function deduplicateUiItems(items: UIMsgLocal[]): UIMsgLocal[] { - const seen = new Map(); - // Walk forward to find the last index for each card_id + const seen = new Map(); items.forEach((item, idx) => { const cardId = item.props?.card_id as string | undefined; - if (cardId) seen.set(cardId, idx); + if (!cardId) return; + const status = item.props?.status as string | undefined; + const prev = seen.get(cardId); + // Upgrade to done/error unconditionally; for other statuses keep the last occurrence + if (!prev || status === "done" || status === "error" || + (prev.status !== "done" && prev.status !== "error")) { + seen.set(cardId, { idx, status: status ?? "" }); + } }); return items.filter((item, idx) => { const cardId = item.props?.card_id as string | undefined; if (!cardId) return true; - return seen.get(cardId) === idx; + return seen.get(cardId)?.idx === idx; }); } @@ -714,16 +723,17 @@ function App() { ); // Sort by card type priority first, then by sort_key for deterministic order const CARD_TYPE_PRIORITY: Record = { - "error-result": 100, - "chart-result": 200, - "knowledge-result": 300, - "ticket-summary": 400, - "ticket-detail": 450, - "search-result": 500, - "sandbox-result": 600, - "canvas-doc": 700, - "reply-draft": 750, - "next-actions": 900, + "error-result": 100, + "chart-result": 200, + "knowledge-result": 300, + "ticket-summary": 400, + "ticket-detail": 450, + "search-result": 500, + "web-read-progress": 550, + "sandbox-result": 600, + "canvas-doc": 700, + "reply-draft": 750, + "next-actions": 900, }; const uiItems = [...uiItemsRaw].sort((a, b) => { const priorityA = CARD_TYPE_PRIORITY[a.name] ?? 500; @@ -842,6 +852,7 @@ function App() { google_search: "search-result", web_search_deep: "search-result", sandbox_run: "sandbox-result", + web_read: "web-read-progress", }; return tc.name && ui.name === nameMap[tc.name]; }));