返回 DeepSeek-Reasonix
ChatToolBody.tsx
根目录 / desktop / frontend / src / components / ChatToolBody.tsx
1 import { useEffect, useRef, useState } from "react";
2 import { ErrorMessage } from "./ErrorMessage";
3 import type { ChatNode } from "../lib/chatViewSource";
4 import type { ChatContentLoader } from "../lib/chatContentLoader";
5 import { useT } from "../lib/i18n";
6 import { diffsFor, subjectOf } from "../lib/tools";
7 import { TerminalBlock } from "./harness-chat/TerminalBlock";
8 import { DiffBlock } from "./harness-chat/DiffBlock";
9 import { WebBlock } from "./harness-chat/WebBlock";
10 import { normalizeSearchSources } from "../lib/searchSourcesPresentation";
11 import { parseSearchSources, searchOutputMetadata } from "../lib/searchSources";
12 import toolCss from "./harness-chat/ToolRow.styles";
13 import { classifyTool, toolPresentation } from "../lib/chatToolPresentation";
14 import { boundedPayloadSections, utf8Prefix } from "../lib/toolPayloadPreview";
15 import "./harness-chat/TerminalBlock.css";
16 import "./harness-chat/DiffBlock.css";
17 import "./harness-chat/Pill.css";
18 import "./harness-chat/ToolBody.css";
19 import "./harness-chat/WebBlock.css";
20
21 export default function ChatToolBody({ item, loader }: { item: Extract<ChatNode, { kind: "tool" }>["item"]; loader: ChatContentLoader }) {
22 const t = useT();
23 const [loaded, setLoaded] = useState<{ item: typeof item; text: string }>();
24 const [pendingItem, setPendingItem] = useState<typeof item>();
25 const [errorItem, setErrorItem] = useState<typeof item>();
26 const busy = pendingItem === item;
27 const error = errorItem === item;
28 const epoch = useRef(0);
29 useEffect(() => { return () => { epoch.current++; }; }, [item]);
30 const full = loaded && loaded.item.id === item.id && loaded.item.args === item.args &&
31 loaded.item.output === item.output && loaded.item.error === item.error && loaded.item.dataArchived === item.dataArchived
32 ? loaded.text : undefined;
33 const preview = JSON.stringify({ args: item.args, output: item.output, error: item.error, diff: item.fileDiff });
34 const limited = full === undefined && (loader.needsFullContent(item, "tool") || preview.length > 8000);
35 const load = async () => {
36 const ticket = ++epoch.current; setPendingItem(item); setErrorItem(undefined);
37 try {
38 const text = await loader.load(item, "tool");
39 if (ticket !== epoch.current) throw new Error("Tool changed or closed");
40 const payload: unknown = JSON.parse(text);
41 if (!payload || typeof payload !== "object" || Array.isArray(payload)) throw new Error("Invalid tool content");
42 for (const key of ["args", "output", "error"] as const) {
43 if (key in payload && (payload as Record<string, unknown>)[key] != null && typeof (payload as Record<string, unknown>)[key] !== "string") throw new Error("Invalid tool content");
44 }
45 setLoaded({ item, text });
46 } catch { if (ticket === epoch.current) setErrorItem(item); }
47 finally { if (ticket === epoch.current) setPendingItem(undefined); }
48 };
49 let value: { args?: string; output?: string; error?: string } = item;
50 if (full !== undefined) { try { value = JSON.parse(full); } catch { /* Keep the available preview. */ } }
51 let args: Record<string, unknown> = {};
52 try { args = JSON.parse(value.args || "{}") || {}; } catch { /* Streaming arguments. */ }
53 const labels = { copy: t("msg.copy"), copied: t("msg.copied"), collapse: t("common.collapse"), collapseAria: t("common.collapse"),
54 expand: (hidden: number) => t("chat.expandLines", { count: hidden }), expandAria: (hidden: number) => t("chat.expandLines", { count: hidden }) };
55 const diffs = !limited ? diffsFor(item.name, value.args || "{}") : [];
56 const kind = classifyTool(item);
57 const presentation = toolPresentation(item);
58 const search = kind === "search" && !limited ? normalizeSearchSources(item.searchSources ?? parseSearchSources(value.output || "")) : undefined;
59 const searchMeta = searchOutputMetadata(value.output);
60 return <>
61 {value.error && (kind === "shell" || diffs.length > 0) && <p role="alert"><ErrorMessage error={value.error} /></p>}
62 {!limited && kind === "shell" && (item.execution || item.status === "running" || item.status === "stopped") ? <TerminalBlock command={typeof args.command === "string" ? args.command : value.args || ""}
63 output={value.output} running={presentation.state === "running"} exitCode={presentation.exitCode}
64 presentation={{ state: presentation.dot, label: t(presentation.label) }}
65 maxLines={200} className={toolCss.terminalBody}
66 labels={{ ...labels, signal: signal => signal, exitCode: code => `${code}`, running: t("chat.running"), failed: t("chat.failed"), done: t("chat.done"), noOutput: t("chat.noOutput") }} />
67 : search && item.status === "done" ? <WebBlock kind="search" answer={searchMeta.summary ?? item.searchSummary}
68 sources={search.visible.map(source => ({ url: source.href, title: source.title }))} truncated={search.hiddenCount > 0}
69 labels={{ noResults: t("sources.notProvided"), sourcesTruncated: t("sources.hidden", { n: search.hiddenCount }), http: "HTTP", contentTruncated: t("chat.loadFull") }} className={toolCss.webBody} />
70 : diffs.length ? <DiffBlock diffs={diffs.map(diff => ({ path: subjectOf(item.name, value.args || "{}"), oldText: diff.original, newText: diff.modified }))}
71 maxLines={200} labels={{ ...labels, files: count => t("chat.files", { count }) }} className={toolCss.diffBody} />
72 : <div className={toolCss.ioCard}><ToolPayload text={full ?? preview} preview={limited} /></div>}
73 {limited && <button className="btn" disabled={busy} onClick={() => void load()}>{t(error ? "chat.loadFailed" : busy ? "chat.loading" : "chat.loadFull")}</button>}
74 </>;
75 }
76
77 export function ToolPayload({ text, preview }: { text: string; preview: boolean }) {
78 const t = useT();
79 let value: unknown;
80 try { value = JSON.parse(text); } catch { return <pre>{preview ? utf8Prefix(text, 16 * 1024) : text}</pre>; }
81 if (!value || typeof value !== "object" || Array.isArray(value)) return <pre>{preview ? utf8Prefix(text, 16 * 1024) : text}</pre>;
82 const entries = preview ? boundedPayloadSections(value as Record<string, unknown>)
83 : Object.entries(value).filter(([, content]) => content != null).map(([key, content]) => ({ key, body: typeof content === "string" ? content : JSON.stringify(content, null, 2) }));
84 return <>{entries.map(({ key, body }) => {
85 const label = key === "args" ? t("chat.tool.input") : key === "output" ? t("chat.tool.output") : key === "error" ? t("tool.error") : key;
86 if (key === "error") return <section key={key} className={toolCss.ioSection}><ErrorMessage error={body} /></section>;
87 return <section key={key} className={toolCss.ioSection}><span className={toolCss.ioLabel}>{label}</span><pre className={toolCss.ioText} data-error={key === "error" || undefined}>{body}</pre></section>;
88 })}</>;
89 }
90
90 lines Plain Text