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