391 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|