Files
socaichat/langgraph/src/components/ToolCallStatus.tsx
T
gongzhiyongandClaude Sonnet 4.6 19cb2e42bc
Deploy LangGraph Server to Azure Web App / build-and-deploy (push) Failing after 15s
Deploy LangGraph UI to Azure Static Web Apps / build-and-deploy (push) Failing after 44s
fix(ui): fix ToolCallStatus duplicate label + add fade-in to standalone cards
- 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>
2026-04-11 22:34:59 +08:00

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