Files
hunjing/frontend/src/components/chat/ToolCallBlock.tsx
T
2026-08-21 16:00:13 +08:00

391 lines
13 KiB
TypeScript

"use client";
import { useCallback, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { ToolConfirmationCard } from "@/components/tools/ToolConfirmationCard";
import { Badge, type BadgeTone } from "@/components/ui/Badge";
import { cn } from "@/lib/utils/cn";
import { useChatStore } from "@/lib/stores/chat-store";
import type { ToolCallDisplay } from "@/lib/stores/chat-store";
/** 结果文本展示截断长度(超过则默认折叠) */
const RESULT_COLLAPSE_LENGTH = 280;
/** 工具调用状态 → 徽章色调 + 中文文案 */
const STATUS_META: Record<
ToolCallDisplay["status"],
{ label: string; tone: BadgeTone; pulse?: boolean }
> = {
running: { label: "执行中", tone: "warning", pulse: true },
confirming: { label: "待确认", tone: "warning", pulse: true },
success: { label: "已完成", tone: "success" },
error: { label: "失败", tone: "danger" },
denied: { label: "已拒绝", tone: "danger" },
skipped: { label: "已跳过", tone: "neutral" },
};
export interface ToolCallBlockProps {
toolCall: ToolCallDisplay;
/** 消息所属 run_id(确认端点调用需要) */
runId?: string;
onApprove?: (
runId: string,
confirmationId: string,
toolCallId: string,
) => void;
onDeny?: (runId: string, confirmationId: string, toolCallId: string) => void;
}
/**
* 工具调用块(对话流 tool.* 事件可视化)。结构与 ThoughtBlock 对齐:
* 可折叠头(工具名 + 状态徽章)+ 展开态结果文本(截断 + 展开)。
*
* 状态为 confirming 时,在结果下方嵌入 ToolConfirmationCard(复用)。
* 样式遵循琉璃镜(玻璃 + 矿物色),与 ThoughtBlock 视觉一致。
*
* 状态机:
* running(金,脉冲)→ success(铜绿)/ error(朱砂)/ denied(朱砂)/ skipped(灰)
* 中间可能进入 confirming(金,脉冲)展示确认卡片,用户操作后回 running。
*/
export function ToolCallBlock({
toolCall,
runId,
onApprove,
onDeny,
}: ToolCallBlockProps) {
const meta = STATUS_META[toolCall.status] ?? STATUS_META.running;
// running / confirming 时默认展开让用户看到实时状态;终态默认折叠
const isActive =
toolCall.status === "running" || toolCall.status === "confirming";
const [open, setOpen] = useState(isActive);
// 确认卡片需要 confirmation_id;同时需要本地 loading 态(用户点击后等待后端事件)
const [resolving, setResolving] = useState(false);
const handleApprove = () => {
if (!runId || !toolCall.confirmationId || !onApprove) return;
setResolving(true);
onApprove(runId, toolCall.confirmationId, toolCall.toolCallId);
// 不在此处清 resolving——后端推 tool.completed 时 toolCall.status 会变化,
// 此组件卸载或重渲染。若后端迟迟不推(如 410/503),用户可在错误提示后重试。
// 保守起见,等几秒自动解除 loading,避免按钮永久转圈。
setTimeout(() => setResolving(false), 4000);
};
const handleDeny = () => {
if (!runId || !toolCall.confirmationId || !onDeny) return;
setResolving(true);
onDeny(runId, toolCall.confirmationId, toolCall.toolCallId);
setTimeout(() => setResolving(false), 4000);
};
const hasResult = toolCall.result !== undefined && toolCall.result !== "";
const resultLong = (toolCall.result?.length ?? 0) > RESULT_COLLAPSE_LENGTH;
const [resultExpanded, setResultExpanded] = useState(false);
return (
<div className="mb-2 overflow-hidden rounded-xl border border-border-glass bg-fg/[0.03]">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-fg/[0.02]"
aria-expanded={open}
>
<span
className={cn(
"text-gold transition-opacity",
meta.pulse && "animate-pulse",
)}
aria-hidden
>
<WrenchIcon />
</span>
<span className="text-xs font-medium text-fg/80">
{toolCall.toolName || "工具调用"}
</span>
<Badge tone={meta.tone} className="ml-0.5">
{meta.label}
</Badge>
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
aria-hidden
className={cn(
"ml-auto text-muted transition-transform duration-200",
open && "rotate-180",
)}
>
<path
d="M3 4.5l3 3 3-3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
initial={{ scaleY: 0, opacity: 0 }}
animate={{ scaleY: 1, opacity: 1 }}
exit={{ scaleY: 0, opacity: 0 }}
transition={{ duration: 0.2, ease: "easeInOut" }}
className="origin-top overflow-hidden"
>
<div className="space-y-2 border-t border-border-glass px-3 py-2">
{/* 调用参数:confirming 态由确认卡片承载 args,避免重复 */}
{toolCall.args && toolCall.status !== "confirming" && (
<ToolCallArgs
toolName={toolCall.toolName}
args={toolCall.args}
/>
)}
{/* 结果文本(success/error/denied/skipped 才有) */}
{hasResult && (
<div>
<pre
className={cn(
"whitespace-pre-wrap break-words font-mono text-xs leading-relaxed text-muted",
resultLong && !resultExpanded && "line-clamp-6",
)}
>
{toolCall.result}
</pre>
{resultLong && (
<button
type="button"
onClick={() => setResultExpanded((v) => !v)}
className="mt-1 text-xs text-patina transition-opacity hover:opacity-80"
>
{resultExpanded ? "收起结果" : "展开结果"}
</button>
)}
</div>
)}
{/* 文件预览:write_file 成功时提供独立面板预览入口 */}
{toolCall.status === "success" &&
toolCall.toolName === "write_file" && (
<FilePreviewButton args={toolCall.args} runId={runId} />
)}
{/* 执行中无结果时显示轻量提示 */}
{!hasResult && toolCall.status === "running" && (
<p className="text-xs text-muted/70">正在执行…</p>
)}
{/* 确认卡片:仅在 confirming 态且有 confirmationId 时展示 */}
{toolCall.status === "confirming" &&
toolCall.confirmationId &&
toolCall.riskLevel && (
<ToolConfirmationCard
tool={toolCall.toolName}
riskLevel={toolCall.riskLevel}
reason={toolCall.reason ?? ""}
args={toolCall.args}
onApprove={handleApprove}
onDeny={handleDeny}
loading={resolving}
/>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function WrenchIcon() {
return (
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M11.5 2.5a2.5 2.5 0 0 0-3.4 2.9L3 10.5a1.4 1.4 0 0 0 2 2l5.1-5.1a2.5 2.5 0 0 0 2.9-3.4l-1.7 1.7-1.4-.2-.2-1.4 1.8-1.6Z"
stroke="currentColor"
strokeWidth="1.2"
strokeLinejoin="round"
/>
</svg>
);
}
/**
* 打开全局面板预览沙箱文件。
*/
function FilePreviewButton({
args,
runId,
}: {
args: string | undefined;
runId: string | undefined;
}) {
const openPreviewFile = useChatStore((s) => s.openPreviewFile);
const filePath = extractFilePath(args);
const handleOpen = useCallback(() => {
openPreviewFile({ path: filePath, runId });
}, [filePath, runId, openPreviewFile]);
if (!filePath) return null;
return (
<button
type="button"
onClick={handleOpen}
className="inline-flex items-center gap-1.5 rounded border border-border-glass px-2.5 py-1 text-[11px] text-patina transition-colors hover:bg-patina/10"
>
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden>
<rect
x="2"
y="3"
width="12"
height="10"
rx="1.5"
stroke="currentColor"
strokeWidth="1.2"
/>
<circle cx="6" cy="7.5" r="1" fill="currentColor" />
<path
d="M2 10l3.5-2.5L9 10l2-1.5 3 2"
stroke="currentColor"
strokeWidth="1"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
预览文件
</button>
);
}
/**
* 从 write_file 的 args JSON 中提取文件路径。
* args 格式:``{"path": "output/report.html", "content": "..."}``
*/
function extractFilePath(args: string | undefined): string {
if (!args) return "";
try {
const parsed = JSON.parse(args) as Record<string, unknown>;
const p = parsed.path;
return typeof p === "string" && p.trim() ? p.trim() : "";
} catch {
return "";
}
}
/** 单个入参值的截断长度:超过则折叠为摘要(write_file 的 content 可能极长) */
const ARG_VALUE_PREVIEW = 120;
/** 解析工具调用入参 JSON 字符串;失败或非对象时回退到原始文本展示。 */
function parseToolArgs(
args: string | undefined,
): Record<string, unknown> | null {
if (!args) return null;
try {
const parsed = JSON.parse(args);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
/** 把入参值格式化为可读字符串;过长的字符串折叠为「前缀…(共 N 字符)」。 */
function previewToolArgValue(value: unknown): string {
if (typeof value === "string") {
return value.length > ARG_VALUE_PREVIEW
? `${value.slice(0, ARG_VALUE_PREVIEW)}…(共 ${value.length} 字符)`
: value;
}
if (value === null) return "null";
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value);
}
/**
* 入参键值对展示(dl/dt/dd)。长值经 previewToolArgValue 折叠。
*/
function ToolArgKeyValue({ entries }: { entries: [string, unknown][] }) {
return (
<dl className="mt-1 space-y-0.5 rounded-lg bg-fg/[0.04] p-2 font-mono text-xs leading-relaxed">
{entries.map(([k, v]) => (
<div key={k} className="flex gap-2">
<dt className="shrink-0 text-muted">{k}:</dt>
<dd className="break-all text-fg/80">{previewToolArgValue(v)}</dd>
</div>
))}
</dl>
);
}
/**
* 工具调用入参展示。所有状态(只要有 args)都显示,让用户能判断 AI 实际调用了什么——
* 尤其 shell 工具的 command,否则仅看执行结果无法判断命令是否正确。
*
* shell:终端样式突出 command(用户最关心的「命令」),其余键(timeout_ms/stdin)附在下方;
* 其它工具:键值对形式,长值折叠。
*/
function ToolCallArgs({
toolName,
args,
}: {
toolName: string;
args: string | undefined;
}) {
const parsed = parseToolArgs(args);
// 解析失败:回退为原始文本(args 为空则不渲染)
if (!parsed) {
if (!args) return null;
return (
<div>
<span className="text-[11px] font-medium tracking-wide text-muted/60">
调用参数
</span>
<pre className="mt-1 whitespace-pre-wrap break-words rounded-lg bg-fg/[0.04] p-2 font-mono text-xs leading-relaxed text-fg/80">
{args}
</pre>
</div>
);
}
const entries = Object.entries(parsed);
if (entries.length === 0) return null;
// shell:突出命令行,其余参数作为附属键值对
if (toolName === "shell" && typeof parsed.command === "string") {
const extras = entries.filter(([k]) => k !== "command");
return (
<div>
<span className="text-[11px] font-medium tracking-wide text-muted/60">
命令
</span>
<pre className="mt-1 whitespace-pre-wrap break-words rounded-lg border-l-2 border-patina/50 bg-fg/[0.05] p-2 font-mono text-xs leading-relaxed text-fg">
<span className="select-none text-patina">$ </span>
{parsed.command}
</pre>
{extras.length > 0 && <ToolArgKeyValue entries={extras} />}
</div>
);
}
return (
<div>
<span className="text-[11px] font-medium tracking-wide text-muted/60">
调用参数
</span>
<ToolArgKeyValue entries={entries} />
</div>
);
}