- Fix ToolCallStatus label duplication: label span was shown twice when failed/done; consolidate into a single span with correct copy - Add animate-in fade-in duration-300 wrapper to standalone Gen-UI cards and streaming orphan cards in main.tsx (already present in ToolCallStatus expanded view) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
182 lines
5.4 KiB
TypeScript
182 lines
5.4 KiB
TypeScript
import { Loader2, CheckCircle2, ChevronRight, ChevronDown, XCircle } from "lucide-react";
|
|
import { useState } from "react";
|
|
import { LoadExternalComponent } from "@langchain/langgraph-sdk/react-ui";
|
|
|
|
const TOOL_NAME_MAP: Record<string, string> = {
|
|
kb_search: "知识库检索",
|
|
ticket_list: "工单查询",
|
|
ticket_detail: "工单详情",
|
|
web_search: "网络搜索",
|
|
google_search: "网络搜索",
|
|
web_search_deep: "深度搜索",
|
|
web_read: "网页阅读",
|
|
code_execute: "代码执行",
|
|
code_install: "依赖安装",
|
|
doc_create: "文档创建",
|
|
doc_edit: "文档编辑",
|
|
doc_translate: "文档翻译",
|
|
sandbox_run: "代码沙盒",
|
|
};
|
|
|
|
// 按工具类型显示 loading 文案
|
|
const TOOL_LOADING_MAP: Record<string, string> = {
|
|
kb_search: "正在检索知识库...",
|
|
ticket_list: "正在查询工单...",
|
|
ticket_detail: "正在获取工单详情...",
|
|
web_search: "正在搜索网络...",
|
|
google_search: "正在搜索网络...",
|
|
web_search_deep: "正在深度搜索...",
|
|
web_read: "正在阅读网页...",
|
|
code_execute: "正在执行代码...",
|
|
code_install: "正在安装依赖...",
|
|
sandbox_run: "正在运行沙盒...",
|
|
doc_create: "正在创建文档...",
|
|
doc_edit: "正在编辑文档...",
|
|
doc_translate: "正在翻译文档...",
|
|
};
|
|
|
|
interface ToolCall {
|
|
name?: string;
|
|
id?: string;
|
|
}
|
|
|
|
interface UIMsgLocal {
|
|
id: string;
|
|
type: string;
|
|
name: string;
|
|
props: Record<string, unknown>;
|
|
metadata?: { message_id?: string };
|
|
}
|
|
|
|
interface ToolCallStatusProps {
|
|
toolCalls: ToolCall[];
|
|
isLoading: boolean;
|
|
completedToolIds?: Set<string>;
|
|
failedToolIds?: Set<string>;
|
|
uiItems?: UIMsgLocal[];
|
|
stream?: Parameters<typeof LoadExternalComponent>[0]["stream"];
|
|
components?: Parameters<typeof LoadExternalComponent>[0]["components"];
|
|
}
|
|
|
|
function ToolCallRow({
|
|
tc,
|
|
isDone,
|
|
isFailed,
|
|
uiItem,
|
|
stream,
|
|
components,
|
|
}: {
|
|
tc: ToolCall;
|
|
isDone: boolean;
|
|
isFailed: boolean;
|
|
uiItem?: UIMsgLocal;
|
|
stream?: ToolCallStatusProps["stream"];
|
|
components?: ToolCallStatusProps["components"];
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const label = tc.name ? (TOOL_NAME_MAP[tc.name] ?? tc.name) : "工具调用";
|
|
const canExpand = isDone && !!uiItem;
|
|
|
|
return (
|
|
<div>
|
|
<button
|
|
type="button"
|
|
disabled={!canExpand}
|
|
onClick={() => canExpand && setExpanded((v) => !v)}
|
|
className="flex items-center gap-1.5 text-xs text-muted-foreground w-full text-left"
|
|
>
|
|
{canExpand ? (
|
|
expanded ? (
|
|
<ChevronDown className="size-3.5 shrink-0" />
|
|
) : (
|
|
<ChevronRight className="size-3.5 shrink-0" />
|
|
)
|
|
) : (
|
|
<span className="size-3.5 shrink-0" />
|
|
)}
|
|
{isFailed ? (
|
|
<XCircle className="size-3.5 text-red-500 shrink-0" />
|
|
) : isDone ? (
|
|
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
|
|
) : (
|
|
<Loader2 className="size-3.5 animate-spin shrink-0" />
|
|
)}
|
|
<span>{isFailed ? `${label} · 失败` : isDone ? (canExpand ? `${label} · ${expanded ? "收起" : "查看结果"}` : `${label} · 已完成`) : (tc.name ? (TOOL_LOADING_MAP[tc.name] ?? `正在${label}...`) : "思考中...")}</span>
|
|
</button>
|
|
{expanded && uiItem && stream && components && (
|
|
<div className="mt-2 ml-7">
|
|
<div className="animate-in fade-in duration-300">
|
|
<LoadExternalComponent
|
|
stream={stream}
|
|
message={uiItem as any}
|
|
components={components as any}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{expanded && !uiItem && (
|
|
<div className="mt-2 ml-7 space-y-2 animate-pulse">
|
|
<div className="h-3 bg-muted rounded w-3/4" />
|
|
<div className="h-3 bg-muted rounded w-1/2" />
|
|
<div className="h-3 bg-muted rounded w-5/6" />
|
|
<div className="h-8 bg-muted rounded w-full mt-3" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ToolCallStatus({
|
|
toolCalls,
|
|
isLoading,
|
|
completedToolIds,
|
|
failedToolIds,
|
|
uiItems = [],
|
|
stream,
|
|
components,
|
|
}: ToolCallStatusProps) {
|
|
if (!toolCalls.length) return null;
|
|
|
|
const UI_NAME_MAP: Record<string, string> = {
|
|
kb_search: "knowledge-result",
|
|
ticket_list: "ticket-summary",
|
|
ticket_detail: "ticket-detail",
|
|
web_search: "search-result",
|
|
google_search: "search-result",
|
|
sandbox_run: "sandbox-result",
|
|
};
|
|
|
|
// Track how many times each ui component name has been matched, so multiple
|
|
// calls of the same tool type pick different UI cards in order.
|
|
const matchCounters: Record<string, number> = {};
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1.5 mb-1">
|
|
{toolCalls.map((tc, i) => {
|
|
const isDone = !isLoading || (tc.id && completedToolIds?.has(tc.id));
|
|
const isFailed = !!(tc.id && failedToolIds?.has(tc.id));
|
|
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];
|
|
}
|
|
|
|
return (
|
|
<ToolCallRow
|
|
key={tc.id ?? i}
|
|
tc={tc}
|
|
isDone={!!isDone}
|
|
isFailed={isFailed}
|
|
uiItem={uiItem}
|
|
stream={stream}
|
|
components={components}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|