返回 DeepSeek-Reasonix
tools.ts
根目录 / desktop / frontend / src / lib / tools.ts
1 // Per-tool presentation helpers. The kernel forwards every tool call the same way
2 // (name + raw-JSON args + output); these turn that generic payload into the
3 // recognizable one-liner, inline diff, and collapsed outcome each tool deserves —
4 // the recognizable "card" vocabulary the desktop uses. Kept pure (no React, no
5 // highlight.js) so ToolCard stays a renderer and the main bundle stays light.
6
7 import { diffLines } from "./diff";
8 import { t } from "./i18n";
9 import { extToLang } from "./lang";
10 import { isShellToolName } from "./shellToolIdentity";
11 import type { DictKey } from "../locales/en";
12
13 export interface ToolDiff {
14 original: string;
15 modified: string;
16 lang: string;
17 label?: string; // multi_edit labels each step ("edit 1", …)
18 }
19
20 export interface ToolFileDiff {
21 diff: string;
22 added: number;
23 removed: number;
24 }
25
26 function parse(args: string): Record<string, unknown> {
27 try {
28 const value: unknown = JSON.parse(args);
29 return value !== null && typeof value === "object" && !Array.isArray(value)
30 ? value as Record<string, unknown> : {};
31 } catch {
32 return {};
33 }
34 }
35
36 function str(a: Record<string, unknown>, key: string): string {
37 return typeof a[key] === "string" ? (a[key] as string) : "";
38 }
39
40 function num(value: unknown): number {
41 return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
42 }
43
44 export function fileDiffFromWire(value?: { diff?: unknown; added?: unknown; removed?: unknown }): ToolFileDiff | undefined {
45 const diff = typeof value?.diff === "string" ? value.diff : "";
46 const added = num(value?.added);
47 const removed = num(value?.removed);
48 if (!diff && added === 0 && removed === 0) return undefined;
49 return { diff, added, removed };
50 }
51
52 export function summarizeFileDiff(fileDiff?: ToolFileDiff): string {
53 if (!fileDiff || (fileDiff.added === 0 && fileDiff.removed === 0)) return "";
54 return `+${fileDiff.added} -${fileDiff.removed}`;
55 }
56
57 // subjectOf pulls the most informative one-liner out of a call's args — the
58 // command for bash, the pattern for search, the path for file tools, the
59 // description for a sub-task — so the collapsed row reads at a glance.
60 export function subjectOf(name: string, args: string): string {
61 if (isShellToolName(name)) name = "bash";
62 const a = parse(args);
63 switch (name) {
64 case "bash":
65 return str(a, "command");
66 case "grep":
67 case "glob":
68 return str(a, "pattern") || str(a, "path");
69 case "web_fetch":
70 return str(a, "url");
71 case "web_search":
72 return str(a, "query");
73 case "task":
74 return str(a, "description") || str(a, "prompt");
75 case "run_skill":
76 return str(a, "name");
77 case "move_file": {
78 const src = str(a, "source_path");
79 const dst = str(a, "destination_path");
80 return src && dst ? `${src} -> ${dst}` : src || dst;
81 }
82 case "remember":
83 return str(a, "name") || str(a, "description");
84 case "todo_write":
85 case "exit_plan_mode":
86 return ""; // these get dedicated cards, not a subject line
87 case "use_capability":
88 // Stable MCP proxy: show the capability id as the collapsed subject so
89 // Planner/Delivery cards read as "MCP · mcp-tool:server/tool".
90 return str(a, "capability_id") || str(a, "action");
91 default:
92 return str(a, "path") || str(a, "file_path");
93 }
94 }
95
96 // diffsFor returns the before/after pairs a writer tool's card renders inline:
97 // edit_file is one pair, write_file is an all-add (empty original), multi_edit is
98 // one pair per step. Returns [] for non-writers, so the card folds args/output
99 // away instead.
100 export function diffsFor(name: string, args: string): ToolDiff[] {
101 const a = parse(args);
102 const lang = extToLang(str(a, "path") || str(a, "file_path"));
103 if (name === "edit_file") {
104 if (typeof a.old_string === "string" && typeof a.new_string === "string") {
105 return [{ original: a.old_string, modified: a.new_string, lang }];
106 }
107 }
108 if (name === "write_file" && typeof a.content === "string") {
109 return [{ original: "", modified: a.content, lang }];
110 }
111 if (name === "multi_edit" && Array.isArray(a.edits)) {
112 const out: ToolDiff[] = [];
113 (a.edits as unknown[]).forEach((e, i) => {
114 const step = e as Record<string, unknown>;
115 if (typeof step?.old_string === "string" && typeof step?.new_string === "string") {
116 out.push({ original: step.old_string, modified: step.new_string, lang, label: `edit ${i + 1}` });
117 }
118 });
119 return out;
120 }
121 return [];
122 }
123
124 export function languageForToolArgs(args: string): string {
125 const a = parse(args);
126 return extToLang(str(a, "path") || str(a, "file_path"));
127 }
128
129 export type TodoStatus = "pending" | "in_progress" | "completed";
130
131 export interface Todo {
132 content: string;
133 status: TodoStatus | string;
134 }
135
136 function plusMinus(original: string, modified: string): { add: number; del: number } {
137 let add = 0;
138 let del = 0;
139 for (const r of diffLines(original, modified)) {
140 if (r.type === "add") add++;
141 else if (r.type === "del") del++;
142 }
143 return { add, del };
144 }
145
146 // lineCount counts lines, ignoring a single trailing newline so "a\n" reads as 1.
147 function lineCount(s: string): number {
148 if (!s) return 0;
149 const t = s.endsWith("\n") ? s.slice(0, -1) : s;
150 return t === "" ? 0 : t.split("\n").length;
151 }
152
153 function nonEmptyLines(s: string): number {
154 return s.split("\n").filter((l) => l.trim() !== "").length;
155 }
156
157 // countOf renders a localized "N <noun>" using the singular/plural key pair (zh
158 // collapses both to one form). Lives here, not the dict, so the counted phrasing
159 // stays a translation concern.
160 function countOf(n: number, one: DictKey, other: DictKey): string {
161 return t(n === 1 ? one : other, { n });
162 }
163
164 function hasReplaceAllEdit(edits: Record<string, unknown>[]): boolean {
165 return edits.some((e) => e?.replace_all === true);
166 }
167
168 function multiEditAppliedSummary(output: string): string {
169 const match = output.match(/:\s*(\d+)\s+edits applied \((\d+)\s+total replacements\)/);
170 if (!match) return "";
171 const edits = Number(match[1]);
172 const replacements = Number(match[2]);
173 if (!Number.isFinite(edits) || !Number.isFinite(replacements)) return "";
174 return `${countOf(edits, "tool.editOne", "tool.editOther")} · ${countOf(replacements, "tool.replacementOne", "tool.replacementOther")}`;
175 }
176
177 // summarize derives the one-line outcome shown under a finished card (the "⎿"
178 // secondary line) — counts from the args for writers, from the output for
179 // readers. "" means there's nothing worth a summary line.
180 export function summarize(name: string, args: string, output?: string, error?: string): string {
181 if (isShellToolName(name)) name = "bash";
182 if (error) return "";
183 const a = parse(args);
184 switch (name) {
185 case "write_file":
186 return typeof a.content === "string" ? countOf(lineCount(a.content), "tool.lineOne", "tool.lineOther") : "";
187 case "edit_file": {
188 if (typeof a.old_string === "string" && typeof a.new_string === "string") {
189 const { add, del } = plusMinus(a.old_string, a.new_string);
190 return `+${add} -${del}`;
191 }
192 return "";
193 }
194 case "multi_edit": {
195 const edits = Array.isArray(a.edits) ? (a.edits as Record<string, unknown>[]) : [];
196 if (hasReplaceAllEdit(edits)) {
197 return output ? multiEditAppliedSummary(output) : "";
198 }
199 let add = 0;
200 let del = 0;
201 for (const e of edits) {
202 if (typeof e?.old_string === "string" && typeof e?.new_string === "string") {
203 const pm = plusMinus(e.old_string, e.new_string);
204 add += pm.add;
205 del += pm.del;
206 }
207 }
208 return `${countOf(edits.length, "tool.editOne", "tool.editOther")} · +${add} -${del}`;
209 }
210 }
211
212 if (!output) return "";
213 switch (name) {
214 case "read_file": {
215 if (output.startsWith("(empty file)")) return t("tool.emptyFile");
216 const arrows = (output.match(/→/g) || []).length;
217 return countOf(arrows || lineCount(output), "tool.lineOne", "tool.lineOther");
218 }
219 case "grep":
220 return countOf(nonEmptyLines(output), "tool.matchOne", "tool.matchOther");
221 case "glob":
222 return countOf(nonEmptyLines(output), "tool.fileOne", "tool.fileOther");
223 case "ls":
224 return countOf(nonEmptyLines(output), "tool.entryOne", "tool.entryOther");
225 case "web_fetch":
226 return output.split("\n", 1)[0].slice(0, 80);
227 case "bash":
228 return output.trim() === "" ? t("tool.noOutput") : countOf(lineCount(output), "tool.lineOne", "tool.lineOther");
229 default:
230 return "";
231 }
232 }
233
233 lines TYPESCRIPT