Files
socaichat/langgraph/src/components/ToolCallStatus.tsx
T

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>
);
}