Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
334 lines
12 KiB
TypeScript
334 lines
12 KiB
TypeScript
import { Loader2, CheckCircle2, ChevronRight, ChevronDown, XCircle, ChevronsUpDown, AlertCircle, CornerDownRight } from "lucide-react";
|
|
import { useState, useCallback } 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: "正在运行沙盒...",
|
|
chart_generate: "正在生成图表...",
|
|
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"];
|
|
executionLog?: Array<{
|
|
tool: string;
|
|
toolCallId?: string;
|
|
status: string;
|
|
inputSummary?: string;
|
|
durationMs?: number;
|
|
resultCount?: number;
|
|
retryCount?: number;
|
|
errorCode?: string;
|
|
errorMessage?: string;
|
|
fallbackFrom?: string;
|
|
}>;
|
|
}
|
|
|
|
interface ToolCallRowProps {
|
|
tc: ToolCall;
|
|
isDone: boolean;
|
|
isFailed: boolean;
|
|
isPartial?: boolean;
|
|
isFallback?: boolean;
|
|
uiItem?: UIMsgLocal;
|
|
stream?: ToolCallStatusProps["stream"];
|
|
components?: ToolCallStatusProps["components"];
|
|
logEntry?: {
|
|
durationMs?: number;
|
|
resultCount?: number;
|
|
retryCount?: number;
|
|
errorCode?: string;
|
|
errorMessage?: string;
|
|
fallbackFrom?: string;
|
|
};
|
|
// When globalExpanded is not undefined, the parent controls expand state
|
|
globalExpanded?: boolean;
|
|
}
|
|
|
|
function ToolCallRow({
|
|
tc,
|
|
isDone,
|
|
isFailed,
|
|
isPartial,
|
|
isFallback,
|
|
uiItem,
|
|
stream,
|
|
components,
|
|
logEntry,
|
|
globalExpanded,
|
|
}: ToolCallRowProps) {
|
|
// Local state used only when globalExpanded is undefined (single tool or no global toggle)
|
|
const [localExpanded, setLocalExpanded] = useState(isFailed);
|
|
const expanded = globalExpanded !== undefined ? globalExpanded : localExpanded;
|
|
const setExpanded = useCallback((val: boolean | ((v: boolean) => boolean)) => {
|
|
if (globalExpanded === undefined) {
|
|
setLocalExpanded(val);
|
|
}
|
|
}, [globalExpanded]);
|
|
const label = tc.name ? (TOOL_NAME_MAP[tc.name] ?? tc.name) : "工具调用";
|
|
const canExpand = isDone && (!!uiItem || isFailed || isPartial || isFallback);
|
|
|
|
// Determine icon and color by priority: isFailed > isFallback > isPartial > isDone > loading
|
|
function renderIcon() {
|
|
if (isFailed) return <XCircle className="size-3.5 text-red-500 shrink-0" />;
|
|
if (isFallback) return <CornerDownRight className="size-3.5 text-orange-500 shrink-0" />;
|
|
if (isPartial) return <AlertCircle className="size-3.5 text-yellow-500 shrink-0" />;
|
|
if (isDone) return <CheckCircle2 className="size-3.5 text-green-500 shrink-0" />;
|
|
return <Loader2 className="size-3.5 text-muted-foreground animate-spin shrink-0" />;
|
|
}
|
|
|
|
function renderLabel() {
|
|
const durationStr = logEntry?.durationMs != null
|
|
? ` · ${logEntry.durationMs < 1000 ? `${logEntry.durationMs}ms` : `${(logEntry.durationMs / 1000).toFixed(1)}s`}`
|
|
: "";
|
|
if (isFailed) return `${label} · 失败${durationStr}`;
|
|
if (isFallback) {
|
|
if (canExpand) return `${label} · 已切换到备用${durationStr} · ${expanded ? "收起" : "查看结果"}`;
|
|
return `${label} · 已切换到备用${durationStr}`;
|
|
}
|
|
if (isPartial) {
|
|
if (canExpand) return `${label} · 结果不完整${durationStr} · ${expanded ? "收起" : "查看结果"}`;
|
|
return `${label} · 结果不完整${durationStr}`;
|
|
}
|
|
if (isDone) {
|
|
if (canExpand) return `${label}${durationStr} · ${expanded ? "收起" : "查看结果"}`;
|
|
return `${label} · 已完成${durationStr}`;
|
|
}
|
|
return tc.name ? (TOOL_LOADING_MAP[tc.name] ?? `正在${label}...`) : "思考中...";
|
|
}
|
|
|
|
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" />
|
|
)}
|
|
{renderIcon()}
|
|
<span>{renderLabel()}</span>
|
|
</button>
|
|
{expanded && isFailed && (
|
|
<div className="mt-2 ml-7 rounded-md border border-red-200 bg-red-50 dark:bg-red-950/20 dark:border-red-900 px-3 py-2 animate-in fade-in duration-300">
|
|
<p className="text-xs text-red-600 dark:text-red-400">
|
|
{(uiItem?.props?.errorMessage as string) ?? "工具执行失败,请稍后重试。"}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{expanded && isPartial && !isFailed && (
|
|
<div className="mt-2 ml-7 bg-yellow-50 border border-yellow-200 rounded p-2 text-xs text-yellow-700 animate-in fade-in duration-300">
|
|
查询完成,但未找到完整匹配结果
|
|
</div>
|
|
)}
|
|
{expanded && isFallback && !isFailed && (
|
|
<div className="mt-2 ml-7 bg-orange-50 border border-orange-200 rounded p-2 text-xs text-orange-700 animate-in fade-in duration-300">
|
|
主工具执行失败,已自动切换至备选方案
|
|
</div>
|
|
)}
|
|
{expanded && logEntry && (
|
|
<div className="mt-2 ml-7 flex flex-wrap gap-x-4 gap-y-1">
|
|
{logEntry.resultCount != null && (
|
|
<span className="text-xs text-muted-foreground">
|
|
结果数:<span className="text-foreground font-medium">{logEntry.resultCount}</span>
|
|
</span>
|
|
)}
|
|
{(logEntry.retryCount ?? 0) > 0 && (
|
|
<span className="text-xs text-muted-foreground">
|
|
重试:<span className="text-yellow-600 font-medium">{logEntry.retryCount} 次</span>
|
|
</span>
|
|
)}
|
|
{logEntry.fallbackFrom && (
|
|
<span className="text-xs text-muted-foreground">
|
|
主工具:<span className="text-foreground font-medium">{logEntry.fallbackFrom}</span>
|
|
</span>
|
|
)}
|
|
{logEntry.errorCode && (
|
|
<span className="text-xs text-muted-foreground">
|
|
错误码:<span className="text-red-500 font-medium font-mono">{logEntry.errorCode}</span>
|
|
</span>
|
|
)}
|
|
{logEntry.errorMessage && (
|
|
<span className="text-xs text-muted-foreground w-full">
|
|
原因:<span className="text-red-600">{logEntry.errorMessage}</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
{expanded && !isFailed && 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 && !isFailed && !uiItem && (isPartial || isFallback) && (
|
|
<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>
|
|
)}
|
|
{expanded && !isFailed && !uiItem && !isPartial && !isFallback && (
|
|
<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,
|
|
executionLog = [],
|
|
}: ToolCallStatusProps) {
|
|
if (!toolCalls.length) return null;
|
|
|
|
// Global expand/collapse state — only active when there are >= 2 tool calls
|
|
const [globalExpanded, setGlobalExpanded] = useState<boolean | undefined>(undefined);
|
|
|
|
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> = {};
|
|
|
|
const showGlobalToggle = toolCalls.length >= 2;
|
|
|
|
function handleGlobalToggle() {
|
|
setGlobalExpanded((prev) => {
|
|
// If currently collapsed (false) → expand all; otherwise → collapse all
|
|
return prev === false ? true : false;
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-1.5 mb-1">
|
|
{/* Global collapse/expand button — only shown when >= 2 tool calls */}
|
|
{showGlobalToggle && (
|
|
<div className="flex justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={handleGlobalToggle}
|
|
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors px-1.5 py-0.5 rounded hover:bg-accent"
|
|
title={globalExpanded === false ? "展开全部" : "折叠全部"}
|
|
>
|
|
<ChevronsUpDown className="size-3 shrink-0" />
|
|
{globalExpanded === false ? "展开全部" : "折叠全部"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{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];
|
|
}
|
|
|
|
// 优先按 toolCallId 精确匹配,降级按 index
|
|
const logEntry = executionLog
|
|
? (tc.id && executionLog.find(e => e.toolCallId === tc.id))
|
|
|| (i < executionLog.length ? executionLog[i] : undefined)
|
|
: undefined;
|
|
const isPartial = logEntry?.status === "partial_success";
|
|
const isFallbackStatus = logEntry?.status === "fallback_success";
|
|
|
|
return (
|
|
<ToolCallRow
|
|
key={tc.id ?? i}
|
|
tc={tc}
|
|
isDone={!!isDone}
|
|
isFailed={isFailed}
|
|
isPartial={isPartial}
|
|
isFallback={isFallbackStatus}
|
|
uiItem={uiItem}
|
|
stream={stream}
|
|
components={components}
|
|
logEntry={logEntry ?? undefined}
|
|
globalExpanded={showGlobalToggle ? globalExpanded : undefined}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|