pi-web-ui 0.28.2 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -21
- package/README.md +295 -295
- package/README.zh-CN.md +279 -279
- package/bin/pi-web-ui.mjs +0 -0
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
- package/deploy/nginx-subpath.conf +88 -88
- package/deploy/pi-web-ui-task.xml +71 -71
- package/deploy/pi-web-ui.service +31 -31
- package/dist/server/agent-service.js +408 -3851
- package/dist/server/attachments.js +621 -0
- package/dist/server/bg-servers.js +138 -0
- package/dist/server/client-state.js +148 -0
- package/dist/server/files-service.js +633 -0
- package/dist/server/goal-service.js +869 -0
- package/dist/server/index.js +144 -8
- package/dist/server/model-admin.js +727 -0
- package/dist/server/process-utils.js +86 -0
- package/dist/server/protocol-version.js +11 -0
- package/dist/server/scm.js +298 -0
- package/dist/server/settings-service.js +268 -0
- package/dist/server/slash-commands.js +245 -0
- package/dist/server/terminals.js +98 -0
- package/dist/server/text-sniff.js +268 -0
- package/dist/server/uploads.js +107 -0
- package/dist/server/webui-context.js +208 -0
- package/extensions/webui.ts +192 -192
- package/package.json +12 -5
- package/themes/light.css +6318 -6318
- package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +32 -0
- package/web/dist/assets/TerminalPanel-B5TqtKa7.js +2 -0
- package/web/dist/assets/index-CU7j-jm3.js +13 -0
- package/web/dist/assets/index-Dsb8Bak1.css +10 -0
- package/web/dist/assets/markdown-DRBrS2Nf.js +51 -0
- package/web/dist/assets/react-C9ovnpIm.js +24 -0
- package/web/dist/assets/xterm-D1D2FVe3.js +38 -0
- package/web/dist/favicon.svg +8 -8
- package/web/dist/index.html +17 -15
- package/web/public/favicon.svg +8 -8
- package/web/dist/assets/index-BnDkdKFN.css +0 -41
- package/web/dist/assets/index-DmmSVSzk.js +0 -129
|
@@ -16,14 +16,30 @@ import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, createBashTool, createLocalBashOperations, defineTool, getAgentDir, ModelRuntime, SessionManager, VERSION, } from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import { Type } from "typebox";
|
|
19
|
+
import { BgServerTracker } from "./bg-servers.js";
|
|
20
|
+
import { SettingsService } from "./settings-service.js";
|
|
21
|
+
import { GoalService } from "./goal-service.js";
|
|
22
|
+
import { SlashCommandsService, parseSlash } from "./slash-commands.js";
|
|
23
|
+
import { ModelAdminService } from "./model-admin.js";
|
|
24
|
+
import { FilesService, workspacePath } from "./files-service.js";
|
|
25
|
+
import { extensionKey, ClientStateStore, } from "./client-state.js";
|
|
26
|
+
import { saveUpload } from "./uploads.js";
|
|
27
|
+
import { makePersistentTerminalTools } from "./terminals.js";
|
|
28
|
+
import { WebUIContext } from "./webui-context.js";
|
|
29
|
+
import { buildAttachmentMessages, parseModelSpec, } from "./attachments.js";
|
|
19
30
|
import { serializeMessage, serializeStreamingMessage, } from "./serialize.js";
|
|
20
31
|
import { loadCommands, saveCommandsFile, TerminalManager, } from "./terminals.js";
|
|
21
|
-
import { buildVisionBridgePrompt,
|
|
32
|
+
import { buildVisionBridgePrompt, SYSTEM_PROMPT, transcribeImages, } from "./vision-bridge.js";
|
|
22
33
|
const SNAPSHOT_INTERVAL_MS = 60;
|
|
34
|
+
/** While assistant deltas are flowing, live rendering is carried by
|
|
35
|
+
* message_delta — full snapshots become pure reconciliation checkpoints, so
|
|
36
|
+
* send them on a slow event-driven cadence (see flushSnapshot call-sites:
|
|
37
|
+
* agent_end / tool_execution_end always checkpoint immediately). */
|
|
38
|
+
const STREAMING_SNAPSHOT_INTERVAL_MS = 2000;
|
|
39
|
+
/** Deltas newer than this keep the streaming (low-frequency) snapshot cadence. */
|
|
40
|
+
const DELTA_ACTIVE_WINDOW_MS = 1500;
|
|
23
41
|
const WIDGET_REFRESH_MS = 2000;
|
|
24
|
-
const WIDGET_WIDTH = 80;
|
|
25
42
|
/** Preview panel cap: only the first 512KB of a file is ever read/sent. */
|
|
26
|
-
const MAX_PREVIEW_BYTES = 512 * 1024;
|
|
27
43
|
/** Thrown when the service is quiesced (draining) and the request is NEW work
|
|
28
44
|
* the admission controller refuses: a brand-new client attach, a prompt,
|
|
29
45
|
* a fork, a session resume, or a goal wizard start. index.ts closes the
|
|
@@ -36,237 +52,13 @@ export class QuiesceRejectedError extends Error {
|
|
|
36
52
|
this.name = "QuiesceRejectedError";
|
|
37
53
|
}
|
|
38
54
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"bmp",
|
|
47
|
-
"ico",
|
|
48
|
-
"avif",
|
|
49
|
-
"jfif",
|
|
50
|
-
"tif",
|
|
51
|
-
"tiff",
|
|
52
|
-
]);
|
|
53
|
-
const PREVIEW_VIDEO_EXTS = new Set([
|
|
54
|
-
"mp4",
|
|
55
|
-
"webm",
|
|
56
|
-
"mov",
|
|
57
|
-
"mkv",
|
|
58
|
-
"avi",
|
|
59
|
-
"m4v",
|
|
60
|
-
"ogv",
|
|
61
|
-
"mpg",
|
|
62
|
-
"mpeg",
|
|
63
|
-
"wmv",
|
|
64
|
-
"flv",
|
|
65
|
-
]);
|
|
66
|
-
const PREVIEW_TEXT_EXTS = new Set([
|
|
67
|
-
// code
|
|
68
|
-
"ts",
|
|
69
|
-
"tsx",
|
|
70
|
-
"js",
|
|
71
|
-
"jsx",
|
|
72
|
-
"mjs",
|
|
73
|
-
"cjs",
|
|
74
|
-
"jsm",
|
|
75
|
-
"es6",
|
|
76
|
-
"vue",
|
|
77
|
-
"svelte",
|
|
78
|
-
"py",
|
|
79
|
-
"pyw",
|
|
80
|
-
"ipynb",
|
|
81
|
-
"go",
|
|
82
|
-
"rs",
|
|
83
|
-
"c",
|
|
84
|
-
"h",
|
|
85
|
-
"cpp",
|
|
86
|
-
"hpp",
|
|
87
|
-
"cc",
|
|
88
|
-
"cxx",
|
|
89
|
-
"hh",
|
|
90
|
-
"csh",
|
|
91
|
-
"java",
|
|
92
|
-
"kt",
|
|
93
|
-
"kts",
|
|
94
|
-
"scala",
|
|
95
|
-
"sc",
|
|
96
|
-
"cs",
|
|
97
|
-
"fs",
|
|
98
|
-
"fsx",
|
|
99
|
-
"fsi",
|
|
100
|
-
"sh",
|
|
101
|
-
"bash",
|
|
102
|
-
"zsh",
|
|
103
|
-
"fish",
|
|
104
|
-
"bat",
|
|
105
|
-
"cmd",
|
|
106
|
-
"ps1",
|
|
107
|
-
"psd1",
|
|
108
|
-
"psm1",
|
|
109
|
-
"rb",
|
|
110
|
-
"php",
|
|
111
|
-
"pl",
|
|
112
|
-
"pm",
|
|
113
|
-
"tcl",
|
|
114
|
-
"lua",
|
|
115
|
-
"r",
|
|
116
|
-
"rmd",
|
|
117
|
-
"sql",
|
|
118
|
-
"swift",
|
|
119
|
-
"dart",
|
|
120
|
-
"groovy",
|
|
121
|
-
"gradle",
|
|
122
|
-
"tf",
|
|
123
|
-
"tfvars",
|
|
124
|
-
"hcl",
|
|
125
|
-
"nim",
|
|
126
|
-
"zig",
|
|
127
|
-
"v",
|
|
128
|
-
"vala",
|
|
129
|
-
"d",
|
|
130
|
-
"clj",
|
|
131
|
-
"cljs",
|
|
132
|
-
"cljc",
|
|
133
|
-
"edn",
|
|
134
|
-
"ex",
|
|
135
|
-
"exs",
|
|
136
|
-
"erl",
|
|
137
|
-
"hrl",
|
|
138
|
-
"ml",
|
|
139
|
-
"mli",
|
|
140
|
-
// markup / config / data
|
|
141
|
-
"json",
|
|
142
|
-
"jsonc",
|
|
143
|
-
"json5",
|
|
144
|
-
"jsonl",
|
|
145
|
-
"md",
|
|
146
|
-
"mdx",
|
|
147
|
-
"markdown",
|
|
148
|
-
"html",
|
|
149
|
-
"htm",
|
|
150
|
-
"xhtml",
|
|
151
|
-
"css",
|
|
152
|
-
"scss",
|
|
153
|
-
"sass",
|
|
154
|
-
"less",
|
|
155
|
-
"styl",
|
|
156
|
-
"xml",
|
|
157
|
-
"dtd",
|
|
158
|
-
"yaml",
|
|
159
|
-
"yml",
|
|
160
|
-
"toml",
|
|
161
|
-
"ini",
|
|
162
|
-
"cfg",
|
|
163
|
-
"conf",
|
|
164
|
-
"properties",
|
|
165
|
-
"env",
|
|
166
|
-
"log",
|
|
167
|
-
"txt",
|
|
168
|
-
"text",
|
|
169
|
-
"csv",
|
|
170
|
-
"tsv",
|
|
171
|
-
"lock",
|
|
172
|
-
"sqlite",
|
|
173
|
-
"graphql",
|
|
174
|
-
"gql",
|
|
175
|
-
"proto",
|
|
176
|
-
"prisma",
|
|
177
|
-
"asm",
|
|
178
|
-
"s",
|
|
179
|
-
]);
|
|
180
|
-
/**
|
|
181
|
-
* Classify a file name into its preview category. Files with no extension
|
|
182
|
-
* (README, Makefile, .gitignore, …) are treated as text. Everything not in an
|
|
183
|
-
* allowlist (exe, jar, dll, zip, …) is "none" — never previewed.
|
|
184
|
-
*/
|
|
185
|
-
export function previewKind(name) {
|
|
186
|
-
const dot = name.lastIndexOf(".");
|
|
187
|
-
// A leading dot with nothing after it (.gitignore, .env) counts as no ext.
|
|
188
|
-
const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
|
|
189
|
-
if (PREVIEW_IMAGE_EXTS.has(ext))
|
|
190
|
-
return "image";
|
|
191
|
-
if (PREVIEW_VIDEO_EXTS.has(ext))
|
|
192
|
-
return "video";
|
|
193
|
-
if (ext === "" || PREVIEW_TEXT_EXTS.has(ext))
|
|
194
|
-
return "text";
|
|
195
|
-
return "none";
|
|
196
|
-
}
|
|
197
|
-
/**
|
|
198
|
-
* Content sniff for the preview: any data that has no NUL bytes and no
|
|
199
|
-
* meaningful control-char ratio is treated as text — so files with unknown
|
|
200
|
-
* or absent extensions (jsonl, .log.1, …) still open as text. NULs catch
|
|
201
|
-
* zip/sqlite/png/… even when the extension claims text.
|
|
202
|
-
*/
|
|
203
|
-
function looksLikeText(buf) {
|
|
204
|
-
if (buf.length === 0)
|
|
205
|
-
return true;
|
|
206
|
-
if (buf.includes(0))
|
|
207
|
-
return false;
|
|
208
|
-
const text = buf.toString("utf8");
|
|
209
|
-
let control = 0;
|
|
210
|
-
for (const ch of text) {
|
|
211
|
-
const c = ch.charCodeAt(0);
|
|
212
|
-
// Keep \t \n \r \f (and \b); everything else < 0x20 is binary-ish.
|
|
213
|
-
if (c < 0x20 && c !== 9 && c !== 10 && c !== 12 && c !== 13)
|
|
214
|
-
control++;
|
|
215
|
-
}
|
|
216
|
-
return control / Math.max(text.length, 1) < 0.02;
|
|
217
|
-
}
|
|
218
|
-
/** Decode bytes: strict UTF-8 first, falling back to GBK (Windows legacy
|
|
219
|
-
* Chinese files), then latin1 as a last resort — so previews and inline
|
|
220
|
-
* attachments never show mojibake for GBK/GB2312 encoded files. */
|
|
221
|
-
function decodeText(buf) {
|
|
222
|
-
try {
|
|
223
|
-
return new TextDecoder("utf-8", { fatal: true }).decode(buf);
|
|
224
|
-
}
|
|
225
|
-
catch {
|
|
226
|
-
try {
|
|
227
|
-
return new TextDecoder("gbk").decode(buf);
|
|
228
|
-
}
|
|
229
|
-
catch {
|
|
230
|
-
return buf.toString("latin1");
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
/** Sniff an image MIME type from magic bytes (extension is only a hint).
|
|
235
|
-
* Returns null when the bytes don't look like a known raster format —
|
|
236
|
-
* callers keep such files as plain path references. */
|
|
237
|
-
function sniffImageMime(buf, ext) {
|
|
238
|
-
if (buf.length >= 8 &&
|
|
239
|
-
buf[0] === 0x89 &&
|
|
240
|
-
buf[1] === 0x50 &&
|
|
241
|
-
buf[2] === 0x4e &&
|
|
242
|
-
buf[3] === 0x47) {
|
|
243
|
-
return "image/png";
|
|
244
|
-
}
|
|
245
|
-
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) {
|
|
246
|
-
return "image/jpeg";
|
|
247
|
-
}
|
|
248
|
-
const head = buf.slice(0, 6).toString("ascii");
|
|
249
|
-
if (head === "GIF87a" || head === "GIF89a")
|
|
250
|
-
return "image/gif";
|
|
251
|
-
if (buf.length >= 12 &&
|
|
252
|
-
buf.slice(0, 4).toString("ascii") === "RIFF" &&
|
|
253
|
-
buf.slice(8, 12).toString("ascii") === "WEBP") {
|
|
254
|
-
return "image/webp";
|
|
255
|
-
}
|
|
256
|
-
if (buf.length >= 2 && buf[0] === 0x42 && buf[1] === 0x4d)
|
|
257
|
-
return "image/bmp";
|
|
258
|
-
// Unknown but raster-looking extension — trust the extension so existing
|
|
259
|
-
// image attachments keep working.
|
|
260
|
-
const known = {
|
|
261
|
-
".png": "image/png",
|
|
262
|
-
".jpg": "image/jpeg",
|
|
263
|
-
".jpeg": "image/jpeg",
|
|
264
|
-
".gif": "image/gif",
|
|
265
|
-
".webp": "image/webp",
|
|
266
|
-
".bmp": "image/bmp",
|
|
267
|
-
};
|
|
268
|
-
return known[ext] ?? null;
|
|
269
|
-
}
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Preview kind classification. The preview panel only opens image / video /
|
|
57
|
+
// text-editable files; everything else (exe, jar, archives, …) is refused so
|
|
58
|
+
// it is never read or sent to the browser. Media files are served over the
|
|
59
|
+
// /api/file HTTP endpoint instead of the WebSocket, so they are classified
|
|
60
|
+
// here but never read into the snapshot path.
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
270
62
|
/** Windows persona appendix — appended to the SDK system prompt on win32 only.
|
|
271
63
|
* Two failure modes it guards against: (1) the SDK bash tool has NO default
|
|
272
64
|
* timeout, so a long-running command hangs the whole conversation forever;
|
|
@@ -321,87 +113,6 @@ function makeKillableBashTool(cwd, kills) {
|
|
|
321
113
|
execute: (toolCallId, params, signal, onUpdate) => tool.execute(toolCallId, params, signal, onUpdate),
|
|
322
114
|
};
|
|
323
115
|
}
|
|
324
|
-
/**
|
|
325
|
-
* Snapshot currently LISTENING TCP ports → owning pid. Windows: netstat;
|
|
326
|
-
* POSIX: lsof. Used to detect servers the agent started in the background
|
|
327
|
-
* (the bash tool itself exits, leaving e.g. `npm run dev &` listening).
|
|
328
|
-
*/
|
|
329
|
-
async function snapshotListeningPorts() {
|
|
330
|
-
const m = new Map();
|
|
331
|
-
try {
|
|
332
|
-
const { execFile } = await import("node:child_process");
|
|
333
|
-
if (process.platform === "win32") {
|
|
334
|
-
const out = await new Promise((resolve, reject) => execFile("netstat", ["-ano", "-p", "tcp"], { windowsHide: true, timeout: 8000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
|
|
335
|
-
for (const line of out.split(/\r?\n/)) {
|
|
336
|
-
const p = line.trim().split(/\s+/);
|
|
337
|
-
// TCP 0.0.0.0:5173 0.0.0.0:0 LISTENING 12345
|
|
338
|
-
if (p.length >= 5 && p[0] === "TCP" && p[3] === "LISTENING") {
|
|
339
|
-
const port = Number(p[1].split(":").pop());
|
|
340
|
-
const pid = Number(p[4]);
|
|
341
|
-
if (Number.isFinite(port) && Number.isFinite(pid))
|
|
342
|
-
m.set(port, pid);
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
else {
|
|
347
|
-
const out = await new Promise((resolve, reject) => execFile("lsof", ["-iTCP", "-sTCP:LISTEN", "-P", "-n"], { timeout: 8000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
|
|
348
|
-
for (const line of out.split(/\r?\n/).slice(1)) {
|
|
349
|
-
const p = line.trim().split(/\s+/);
|
|
350
|
-
if (p.length >= 9) {
|
|
351
|
-
// NAME column tail: "*:5173 (LISTEN)" or "[::1]:5173 (LISTEN)"
|
|
352
|
-
const mm = (p[p.length - 1] ?? "").match(/(\d+)\)?\s*$/);
|
|
353
|
-
const port = mm ? Number(mm[1]) : NaN;
|
|
354
|
-
const pid = Number(p[1]);
|
|
355
|
-
if (Number.isFinite(port) && Number.isFinite(pid))
|
|
356
|
-
m.set(port, pid);
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
catch {
|
|
362
|
-
// best effort — snapshot failure just means no tracking this round
|
|
363
|
-
}
|
|
364
|
-
return m;
|
|
365
|
-
}
|
|
366
|
-
/** Kill a pid and its whole process tree (cross-platform). */
|
|
367
|
-
function killPidTree(pid) {
|
|
368
|
-
try {
|
|
369
|
-
if (process.platform === "win32") {
|
|
370
|
-
void import("node:child_process").then(({ spawn }) => {
|
|
371
|
-
spawn("taskkill", ["/F", "/T", "/PID", String(pid)], {
|
|
372
|
-
stdio: "ignore",
|
|
373
|
-
detached: true,
|
|
374
|
-
windowsHide: true,
|
|
375
|
-
}).unref();
|
|
376
|
-
});
|
|
377
|
-
}
|
|
378
|
-
else {
|
|
379
|
-
process.kill(-pid, "SIGKILL");
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
catch {
|
|
383
|
-
// already dead
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
/** Best-effort process name for a pid (tasklist on win32, ps on POSIX).
|
|
387
|
-
* Returns undefined when the process is gone or the lookup fails. */
|
|
388
|
-
async function lookupProcessName(pid) {
|
|
389
|
-
try {
|
|
390
|
-
const { execFile } = await import("node:child_process");
|
|
391
|
-
if (process.platform === "win32") {
|
|
392
|
-
const out = await new Promise((resolve, reject) => execFile("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { windowsHide: true, timeout: 4000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
|
|
393
|
-
// CSV: "node.exe","12345",...
|
|
394
|
-
const m = out.match(/"([^"]+)"/);
|
|
395
|
-
return m ? m[1] : undefined;
|
|
396
|
-
}
|
|
397
|
-
const out = await new Promise((resolve, reject) => execFile("ps", ["-o", "comm=", "-p", String(pid)], { timeout: 4000 }, (err, stdout) => (err ? reject(err) : resolve(stdout))));
|
|
398
|
-
const name = out.trim();
|
|
399
|
-
return name || undefined;
|
|
400
|
-
}
|
|
401
|
-
catch {
|
|
402
|
-
return undefined;
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
116
|
/**
|
|
406
117
|
* Cheap per-message discriminator for the serialization cache key. Persisted
|
|
407
118
|
* message content never changes, so this is stable across snapshots, while
|
|
@@ -412,23 +123,6 @@ async function lookupProcessName(pid) {
|
|
|
412
123
|
* payloads by data length (identical lengths within the same ms are far too
|
|
413
124
|
* unlikely to matter).
|
|
414
125
|
*/
|
|
415
|
-
/** System prompt for the goal-wizard session. The wizard asks the user a few
|
|
416
|
-
* questions (via its goal_ask tool) to scope a raw requirement into a precise,
|
|
417
|
-
* reviewable goal, then emits ONLY the final goal text as its last message. */
|
|
418
|
-
function wizardPrompt(draft) {
|
|
419
|
-
return [
|
|
420
|
-
`You are a goal-clarification wizard. The user has stated a raw requirement. Your job is to turn it into ONE precise, actionable goal that a coding agent can fully satisfy and that can be strictly reviewed.`, // eslint-disable-line max-len
|
|
421
|
-
``,
|
|
422
|
-
`# User's raw requirement`, // eslint-disable-line no-regex-spaces
|
|
423
|
-
draft,
|
|
424
|
-
``,
|
|
425
|
-
`Use your goal_ask tool to ask the user focused questions to pin down the essential, ambiguous details. Keep it concise — usually 2 to 4 questions: what exactly to build/do, scope boundaries (what NOT to do), acceptance criteria / done-definition, and any constraints (style, performance, environment).`, // eslint-disable-line max-len
|
|
426
|
-
`Prefer multiple-choice (goal_ask with options) when you can offer clear choices; use open questions only for things that genuinely need free text.`, // eslint-disable-line max-len
|
|
427
|
-
`Once you have enough to write an unambiguous, reviewable goal, STOP asking and reply with EXACTLY this format and nothing else (no preamble, no bullets):`, // eslint-disable-line max-len
|
|
428
|
-
`GOAL: <one concrete, verifiable sentence describing the deliverable and its acceptance criteria>`, // eslint-disable-line max-len
|
|
429
|
-
`If the user cancels or stops answering (the tool reports a cancellation), still produce a sensible best-effort goal from what you already know.`, // eslint-disable-line max-len
|
|
430
|
-
].join("\n");
|
|
431
|
-
}
|
|
432
126
|
function contentFingerprint(m) {
|
|
433
127
|
const content = m.content;
|
|
434
128
|
if (!Array.isArray(content) || content.length === 0)
|
|
@@ -445,283 +139,12 @@ function contentFingerprint(m) {
|
|
|
445
139
|
}
|
|
446
140
|
return `txt:${h.toString(36)}:${text.length}`;
|
|
447
141
|
}
|
|
448
|
-
/** First few KB of binary data as a classic hex + ASCII dump (preview only). */
|
|
449
|
-
function hexDump(buf, maxBytes = 4096) {
|
|
450
|
-
const data = buf.subarray(0, Math.min(buf.length, maxBytes));
|
|
451
|
-
const rows = [];
|
|
452
|
-
for (let off = 0; off < data.length; off += 16) {
|
|
453
|
-
const chunk = data.subarray(off, off + 16);
|
|
454
|
-
const hex = [...chunk]
|
|
455
|
-
.map((b) => b.toString(16).padStart(2, "0"))
|
|
456
|
-
.join(" ");
|
|
457
|
-
const ascii = [...chunk]
|
|
458
|
-
.map((b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : "."))
|
|
459
|
-
.join("");
|
|
460
|
-
rows.push(`${off.toString(16).padStart(8, "0")} ${hex.padEnd(47, " ")} ${ascii}`);
|
|
461
|
-
}
|
|
462
|
-
return rows.join("\n");
|
|
463
|
-
}
|
|
464
142
|
// ---------------------------------------------------------------------------
|
|
465
143
|
// Web UI context adapter — bridges extension UI calls (setWidget/notify) to the
|
|
466
144
|
// browser. Extensions like rpiv-todo render a TUI widget via
|
|
467
145
|
// `ui.setWidget(key, (tui, theme) => comp)`; we capture the component, render it
|
|
468
146
|
// with a mock theme to plain text lines, and push them to the client.
|
|
469
147
|
// ---------------------------------------------------------------------------
|
|
470
|
-
/** Mock theme: TUI color functions degrade to identity so widget text survives. */
|
|
471
|
-
const mockTheme = new Proxy({
|
|
472
|
-
fg: (_color, text) => text,
|
|
473
|
-
bold: (text) => text,
|
|
474
|
-
strikethrough: (text) => text,
|
|
475
|
-
dim: (text) => text,
|
|
476
|
-
}, {
|
|
477
|
-
get(target, prop) {
|
|
478
|
-
if (prop in target)
|
|
479
|
-
return target[prop];
|
|
480
|
-
// Unknown theme methods → no-op passthrough.
|
|
481
|
-
return (_arg, text) => text !== undefined ? text : "";
|
|
482
|
-
},
|
|
483
|
-
});
|
|
484
|
-
/** Mock TUI: any method call is a safe no-op. */
|
|
485
|
-
const mockTui = new Proxy({
|
|
486
|
-
requestRender: () => { },
|
|
487
|
-
render: () => { },
|
|
488
|
-
}, {
|
|
489
|
-
get(target, prop) {
|
|
490
|
-
if (prop in target)
|
|
491
|
-
return target[prop];
|
|
492
|
-
return () => { };
|
|
493
|
-
},
|
|
494
|
-
});
|
|
495
|
-
/**
|
|
496
|
-
* Implements the subset of ExtensionUIContext that makes sense for a web UI.
|
|
497
|
-
* TUI-only affordances (select/confirm/input dialogs, terminal input, custom
|
|
498
|
-
* footer) are inert: dialogs resolve to cancellation instead of blocking.
|
|
499
|
-
*/
|
|
500
|
-
export class WebUIContext {
|
|
501
|
-
theme = mockTheme;
|
|
502
|
-
widgets = new Map();
|
|
503
|
-
lastLines = new Map();
|
|
504
|
-
emit;
|
|
505
|
-
constructor(emit) {
|
|
506
|
-
this.emit = emit;
|
|
507
|
-
}
|
|
508
|
-
// -- widgets -------------------------------------------------------------
|
|
509
|
-
/** Matches ExtensionUIContext's overloaded setWidget exactly. */
|
|
510
|
-
setWidget = (key, content, options) => {
|
|
511
|
-
void options;
|
|
512
|
-
if (content === undefined) {
|
|
513
|
-
this.widgets.delete(key);
|
|
514
|
-
this.lastLines.delete(key);
|
|
515
|
-
this.push();
|
|
516
|
-
return;
|
|
517
|
-
}
|
|
518
|
-
if (typeof content === "function") {
|
|
519
|
-
let comp;
|
|
520
|
-
try {
|
|
521
|
-
// Mock TUI/theme: extensions only read a handful of theme helpers;
|
|
522
|
-
// everything else is a no-op, so the widget renders to plain text.
|
|
523
|
-
comp = content(mockTui, mockTheme);
|
|
524
|
-
}
|
|
525
|
-
catch {
|
|
526
|
-
comp = undefined;
|
|
527
|
-
}
|
|
528
|
-
this.widgets.set(key, {
|
|
529
|
-
render: (w) => comp?.render?.(w),
|
|
530
|
-
dispose: comp?.dispose,
|
|
531
|
-
});
|
|
532
|
-
}
|
|
533
|
-
else {
|
|
534
|
-
this.widgets.set(key, { render: () => content });
|
|
535
|
-
}
|
|
536
|
-
this.push();
|
|
537
|
-
};
|
|
538
|
-
/** Re-render all widgets and push when content changed (polled + on demand). */
|
|
539
|
-
refresh() {
|
|
540
|
-
let changed = false;
|
|
541
|
-
for (const [key, w] of this.widgets) {
|
|
542
|
-
let lines;
|
|
543
|
-
try {
|
|
544
|
-
lines = w.render(WIDGET_WIDTH);
|
|
545
|
-
}
|
|
546
|
-
catch {
|
|
547
|
-
lines = undefined;
|
|
548
|
-
}
|
|
549
|
-
const prev = this.lastLines.get(key);
|
|
550
|
-
if (JSON.stringify(lines ?? null) !== JSON.stringify(prev ?? null)) {
|
|
551
|
-
this.lastLines.set(key, lines ?? []);
|
|
552
|
-
changed = true;
|
|
553
|
-
}
|
|
554
|
-
}
|
|
555
|
-
if (changed)
|
|
556
|
-
this.push();
|
|
557
|
-
}
|
|
558
|
-
push() {
|
|
559
|
-
const widgets = this.snapshot();
|
|
560
|
-
this.emit({ type: "widgets", widgets });
|
|
561
|
-
}
|
|
562
|
-
/** Render all widgets to their current text lines (without emitting). */
|
|
563
|
-
snapshot() {
|
|
564
|
-
return [...this.widgets.entries()].map(([key, w]) => {
|
|
565
|
-
let lines;
|
|
566
|
-
try {
|
|
567
|
-
lines = w.render(WIDGET_WIDTH);
|
|
568
|
-
}
|
|
569
|
-
catch {
|
|
570
|
-
lines = undefined;
|
|
571
|
-
}
|
|
572
|
-
this.lastLines.set(key, lines ?? []);
|
|
573
|
-
return { key, lines: lines ?? [] };
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
// -- notifications --------------------------------------------------------
|
|
577
|
-
notify(message, type) {
|
|
578
|
-
this.emit({ type: "notice", level: type ?? "info", text: message });
|
|
579
|
-
}
|
|
580
|
-
// -- footer status (pi-lens "LSP Inactive", pi-cache-optimizer cache stats) --
|
|
581
|
-
statuses = new Map();
|
|
582
|
-
setStatus(key, text) {
|
|
583
|
-
if (text === undefined || text === "") {
|
|
584
|
-
this.statuses.delete(key);
|
|
585
|
-
}
|
|
586
|
-
else {
|
|
587
|
-
this.statuses.set(key, text);
|
|
588
|
-
}
|
|
589
|
-
this.pushStatuses();
|
|
590
|
-
}
|
|
591
|
-
pushStatuses() {
|
|
592
|
-
this.emit({
|
|
593
|
-
type: "statuses",
|
|
594
|
-
statuses: [...this.statuses.entries()].map(([k, v]) => ({
|
|
595
|
-
key: k,
|
|
596
|
-
text: v,
|
|
597
|
-
})),
|
|
598
|
-
});
|
|
599
|
-
}
|
|
600
|
-
/** Current footer status entries (for replay on socket attach). */
|
|
601
|
-
statusSnapshot() {
|
|
602
|
-
return [...this.statuses.entries()].map(([k, v]) => ({ key: k, text: v }));
|
|
603
|
-
}
|
|
604
|
-
// -- dialogs (select/confirm/input bridged to the browser) ---------------
|
|
605
|
-
dialogSeq = 0;
|
|
606
|
-
pendingDialogs = new Map();
|
|
607
|
-
select = (title, options) => this.openDialog("select", title, [options]);
|
|
608
|
-
confirm = (title, message) => this.openDialog("confirm", title, [message]);
|
|
609
|
-
input = (title, placeholder) => this.openDialog("input", title, [placeholder ?? ""]);
|
|
610
|
-
openDialog(kind, title, args) {
|
|
611
|
-
return new Promise((resolve) => {
|
|
612
|
-
const id = ++this.dialogSeq;
|
|
613
|
-
this.pendingDialogs.set(id, resolve);
|
|
614
|
-
this.emit({ type: "dialog", id, kind, title, args });
|
|
615
|
-
});
|
|
616
|
-
}
|
|
617
|
-
/** Resolve a pending dialog with the user's choice (called from the client). */
|
|
618
|
-
resolveDialog(id, value) {
|
|
619
|
-
const resolve = this.pendingDialogs.get(id);
|
|
620
|
-
if (resolve) {
|
|
621
|
-
this.pendingDialogs.delete(id);
|
|
622
|
-
resolve(value);
|
|
623
|
-
this.emit({ type: "dialog_closed", id });
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
/** Close every pending dialog as cancelled (used when a goal wizard aborts —
|
|
627
|
-
* its unanswered browser dialogs must vanish, not linger). */
|
|
628
|
-
cancelPendingDialogs() {
|
|
629
|
-
for (const [id, resolve] of this.pendingDialogs) {
|
|
630
|
-
this.pendingDialogs.delete(id);
|
|
631
|
-
resolve(null);
|
|
632
|
-
this.emit({ type: "dialog_closed", id });
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
// -- inert TUI-only affordances ------------------------------------------
|
|
636
|
-
onTerminalInput = () => () => { };
|
|
637
|
-
setWorkingMessage = () => { };
|
|
638
|
-
setWorkingVisible = () => { };
|
|
639
|
-
setWorkingIndicator = () => { };
|
|
640
|
-
setHiddenThinkingLabel = () => { };
|
|
641
|
-
setFooter = () => { };
|
|
642
|
-
setHeader = () => { };
|
|
643
|
-
setTitle = () => { };
|
|
644
|
-
custom = (_factory, _done) => new Promise(() => { });
|
|
645
|
-
pasteToEditor = () => { };
|
|
646
|
-
setEditorText = () => { };
|
|
647
|
-
getEditorText = () => "";
|
|
648
|
-
editor = async () => undefined;
|
|
649
|
-
addAutocompleteProvider = () => { };
|
|
650
|
-
setEditorComponent = () => { };
|
|
651
|
-
getEditorComponent = () => undefined;
|
|
652
|
-
getAllThemes = () => [];
|
|
653
|
-
getTheme = () => undefined;
|
|
654
|
-
setTheme = () => ({ success: false });
|
|
655
|
-
getToolsExpanded = () => false;
|
|
656
|
-
setToolsExpanded = () => { };
|
|
657
|
-
/** Dispose all widgets (extension reload / session teardown). */
|
|
658
|
-
dispose() {
|
|
659
|
-
for (const w of this.widgets.values()) {
|
|
660
|
-
try {
|
|
661
|
-
w.dispose?.();
|
|
662
|
-
}
|
|
663
|
-
catch {
|
|
664
|
-
// best effort
|
|
665
|
-
}
|
|
666
|
-
}
|
|
667
|
-
this.widgets.clear();
|
|
668
|
-
this.lastLines.clear();
|
|
669
|
-
// Cancel any pending dialogs.
|
|
670
|
-
for (const [id, resolve] of this.pendingDialogs) {
|
|
671
|
-
resolve(null);
|
|
672
|
-
this.emit({ type: "dialog_closed", id });
|
|
673
|
-
}
|
|
674
|
-
this.pendingDialogs.clear();
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
const IS_WIN32 = process.platform === "win32";
|
|
678
|
-
// mac/linux: hide build & dependency noise (original behavior).
|
|
679
|
-
const IGNORED_ENTRIES = new Set([
|
|
680
|
-
"node_modules",
|
|
681
|
-
".git",
|
|
682
|
-
".svn",
|
|
683
|
-
".hg",
|
|
684
|
-
"dist",
|
|
685
|
-
".next",
|
|
686
|
-
".nuxt",
|
|
687
|
-
".cache",
|
|
688
|
-
".venv",
|
|
689
|
-
"venv",
|
|
690
|
-
"__pycache__",
|
|
691
|
-
"coverage",
|
|
692
|
-
".pi-web",
|
|
693
|
-
".DS_Store",
|
|
694
|
-
"Thumbs.db",
|
|
695
|
-
]);
|
|
696
|
-
// Windows: the file tree is the primary way to navigate a project, so only
|
|
697
|
-
// hide what would flood or destabilize the panel (dependency trees, VCS
|
|
698
|
-
// internals, session data) plus pure junk. Build output (dist/.next/…) and
|
|
699
|
-
// local env dirs (venv/__pycache__/…) stay visible — "所有文件可查看".
|
|
700
|
-
const IGNORED_ENTRIES_WIN = new Set([
|
|
701
|
-
"node_modules",
|
|
702
|
-
".git",
|
|
703
|
-
".pi-web",
|
|
704
|
-
".DS_Store",
|
|
705
|
-
"Thumbs.db",
|
|
706
|
-
"desktop.ini",
|
|
707
|
-
]);
|
|
708
|
-
/** The ignore set for the current platform — keeps win/posix lists separate. */
|
|
709
|
-
function ignoredEntries() {
|
|
710
|
-
return IS_WIN32 ? IGNORED_ENTRIES_WIN : IGNORED_ENTRIES;
|
|
711
|
-
}
|
|
712
|
-
function countLines(buf) {
|
|
713
|
-
if (buf.length === 0)
|
|
714
|
-
return 0;
|
|
715
|
-
const hasTrailingNewline = buf[buf.length - 1] === 10; /* \n */
|
|
716
|
-
let lines = 0;
|
|
717
|
-
for (let i = 0; i < buf.length; i++) {
|
|
718
|
-
if (buf[i] === 10)
|
|
719
|
-
lines++;
|
|
720
|
-
}
|
|
721
|
-
// A trailing newline terminates the last line instead of starting an empty
|
|
722
|
-
// one — matches the client preview's split-based line numbering.
|
|
723
|
-
return hasTrailingNewline ? lines : lines + 1;
|
|
724
|
-
}
|
|
725
148
|
function extractPartialText(partial) {
|
|
726
149
|
const content = partial
|
|
727
150
|
?.content;
|
|
@@ -735,222 +158,7 @@ function extractPartialText(partial) {
|
|
|
735
158
|
}
|
|
736
159
|
return null;
|
|
737
160
|
}
|
|
738
|
-
|
|
739
|
-
* Resolve a workspace-relative path against a root, refusing traversal
|
|
740
|
-
* (".." escapes). Returns { abs, rel } — rel is normalized and slash-
|
|
741
|
-
* separated — or null when the path leaves the workspace.
|
|
742
|
-
*/
|
|
743
|
-
export function workspacePath(root, raw) {
|
|
744
|
-
const abs = resolve(root, raw);
|
|
745
|
-
const rawRel = relative(root, abs);
|
|
746
|
-
if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`))
|
|
747
|
-
return null;
|
|
748
|
-
// Normalize to forward slashes: the wire protocol and the frontend always
|
|
749
|
-
// use "/", but relative() returns "\\" on Windows.
|
|
750
|
-
return { abs, rel: rawRel.split(sep).join("/") };
|
|
751
|
-
}
|
|
752
|
-
/**
|
|
753
|
-
* Read a directory for the file panel. The two platforms intentionally use
|
|
754
|
-
* different strategies — do NOT unify them:
|
|
755
|
-
*
|
|
756
|
-
* darwin/linux (posix): original behavior — hide build/dependency noise,
|
|
757
|
-
* small cap, hard error notice when the directory itself is unreadable.
|
|
758
|
-
*
|
|
759
|
-
* win32: stability and completeness first, preview second. ACL-protected
|
|
760
|
-
* system dirs (C:\$Recycle.Bin, Program Files internals, OneDrive placeholders)
|
|
761
|
-
* throw EPERM/EACCES on open — that must not kill the panel, so it degrades
|
|
762
|
-
* to an empty listing plus a warning. Directory symlinks/junctions are
|
|
763
|
-
* followed so mklink /D folders stay navigable; broken links still show as
|
|
764
|
-
* files instead of vanishing. The cap is 4x posix and truncation is reported
|
|
765
|
-
* via `truncated` instead of happening silently.
|
|
766
|
-
*/
|
|
767
|
-
async function readDirForUI(abs, rel) {
|
|
768
|
-
const fs = await import("node:fs/promises");
|
|
769
|
-
const ignored = ignoredEntries();
|
|
770
|
-
const MAX = IS_WIN32 ? 2000 : 500;
|
|
771
|
-
let dirents;
|
|
772
|
-
try {
|
|
773
|
-
dirents = await fs.readdir(abs, { withFileTypes: true });
|
|
774
|
-
}
|
|
775
|
-
catch (err) {
|
|
776
|
-
if (!IS_WIN32)
|
|
777
|
-
throw err;
|
|
778
|
-
// Windows ACL-protected/system dirs throw EPERM/EACCES on open —
|
|
779
|
-
// degrade to an empty listing; listFiles turns this into a warning.
|
|
780
|
-
return { entries: [], truncated: false, error: err.message };
|
|
781
|
-
}
|
|
782
|
-
const out = [];
|
|
783
|
-
for (const d of dirents) {
|
|
784
|
-
if (ignored.has(d.name))
|
|
785
|
-
continue;
|
|
786
|
-
let type;
|
|
787
|
-
if (IS_WIN32 && d.isSymbolicLink()) {
|
|
788
|
-
// mklink /D symlinks and junctions are reparse points — libuv
|
|
789
|
-
// classifies them as links, so isDirectory() is false. Follow the
|
|
790
|
-
// target so folder links stay navigable; broken links still show.
|
|
791
|
-
try {
|
|
792
|
-
const st = await fs.stat(join(abs, d.name));
|
|
793
|
-
type = st.isDirectory() ? "dir" : "file";
|
|
794
|
-
}
|
|
795
|
-
catch {
|
|
796
|
-
type = "file";
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
else {
|
|
800
|
-
type = d.isDirectory() ? "dir" : "file";
|
|
801
|
-
}
|
|
802
|
-
const entry = {
|
|
803
|
-
name: d.name,
|
|
804
|
-
path: rel === "" ? d.name : `${rel}/${d.name}`,
|
|
805
|
-
type,
|
|
806
|
-
};
|
|
807
|
-
if (type === "file")
|
|
808
|
-
entry.kind = previewKind(d.name);
|
|
809
|
-
out.push(entry);
|
|
810
|
-
}
|
|
811
|
-
out.sort((a, b) => a.type === b.type
|
|
812
|
-
? a.name.localeCompare(b.name)
|
|
813
|
-
: a.type === "dir"
|
|
814
|
-
? -1
|
|
815
|
-
: 1);
|
|
816
|
-
const truncated = out.length > MAX;
|
|
817
|
-
if (truncated)
|
|
818
|
-
out.length = MAX;
|
|
819
|
-
return { entries: out, truncated };
|
|
820
|
-
}
|
|
821
|
-
/** Stable identity of an extension for the enable/disable toggle: the npm
|
|
822
|
-
* spec for packages (survives version bumps), the resolved entry path
|
|
823
|
-
* otherwise. */
|
|
824
|
-
function extensionKey(e) {
|
|
825
|
-
const src = e.sourceInfo;
|
|
826
|
-
if (src?.origin === "package" && src.source)
|
|
827
|
-
return src.source;
|
|
828
|
-
return src?.path ?? e.path;
|
|
829
|
-
}
|
|
830
|
-
/**
|
|
831
|
-
* Persists which workspace each browser client last used + which workspaces it
|
|
832
|
-
* has opened, so a server restart / page reload restores the same project and
|
|
833
|
-
* the UI can offer a one-click recent-project list. File I/O is best-effort:
|
|
834
|
-
* persistence problems must never crash the server or block a session.
|
|
835
|
-
*/
|
|
836
|
-
class ClientStateStore {
|
|
837
|
-
filePath;
|
|
838
|
-
cache = null;
|
|
839
|
-
constructor(filePath) {
|
|
840
|
-
this.filePath = filePath;
|
|
841
|
-
}
|
|
842
|
-
load() {
|
|
843
|
-
if (this.cache)
|
|
844
|
-
return this.cache;
|
|
845
|
-
try {
|
|
846
|
-
const parsed = JSON.parse(readFileSync(this.filePath, "utf8"));
|
|
847
|
-
this.cache = parsed && typeof parsed === "object" ? parsed : {};
|
|
848
|
-
}
|
|
849
|
-
catch {
|
|
850
|
-
this.cache = {};
|
|
851
|
-
}
|
|
852
|
-
return this.cache;
|
|
853
|
-
}
|
|
854
|
-
save() {
|
|
855
|
-
try {
|
|
856
|
-
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
857
|
-
writeFileSync(this.filePath, JSON.stringify(this.cache, null, 2) + "\n");
|
|
858
|
-
}
|
|
859
|
-
catch {
|
|
860
|
-
// best effort
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
get(clientId) {
|
|
864
|
-
return this.load()[clientId] ?? { projects: [] };
|
|
865
|
-
}
|
|
866
|
-
/** Remember which workspace a client last used; bumps its project entry. */
|
|
867
|
-
remember(clientId, cwd) {
|
|
868
|
-
const all = this.load();
|
|
869
|
-
const state = (all[clientId] ??= { projects: [] });
|
|
870
|
-
state.lastCwd = cwd;
|
|
871
|
-
const now = Date.now();
|
|
872
|
-
state.projects = [
|
|
873
|
-
{ path: cwd, lastUsed: now },
|
|
874
|
-
...state.projects.filter((p) => p.path !== cwd),
|
|
875
|
-
].slice(0, 30);
|
|
876
|
-
this.save();
|
|
877
|
-
}
|
|
878
|
-
/** Last-used goal/review prefs for a client, or undefined if never set. */
|
|
879
|
-
getGoalPrefs(clientId) {
|
|
880
|
-
const s = this.load()[clientId];
|
|
881
|
-
if (!s?.goalPrefs)
|
|
882
|
-
return undefined;
|
|
883
|
-
return {
|
|
884
|
-
reviewModel: s.goalPrefs.reviewModel ?? null,
|
|
885
|
-
maxRounds: s.goalPrefs.maxRounds ?? 0,
|
|
886
|
-
locked: s.goalPrefs.locked ?? true,
|
|
887
|
-
};
|
|
888
|
-
}
|
|
889
|
-
/** Persist the client's goal/review preferences (model choice, rounds, lock). */
|
|
890
|
-
saveGoalPrefs(clientId, prefs) {
|
|
891
|
-
const all = this.load();
|
|
892
|
-
const state = (all[clientId] ??= { projects: [] });
|
|
893
|
-
state.goalPrefs = {
|
|
894
|
-
reviewModel: prefs?.reviewModel ?? null,
|
|
895
|
-
maxRounds: prefs?.maxRounds ?? 0,
|
|
896
|
-
locked: prefs?.locked ?? true,
|
|
897
|
-
};
|
|
898
|
-
this.save();
|
|
899
|
-
}
|
|
900
|
-
/** Last-used settings-panel state for a client, or defaults. */
|
|
901
|
-
getSettings(clientId) {
|
|
902
|
-
const s = this.load()[clientId];
|
|
903
|
-
return {
|
|
904
|
-
promptMode: s?.settings?.promptMode === "replace" ? "replace" : "append",
|
|
905
|
-
customSystemPrompt: s?.settings?.customSystemPrompt ?? "",
|
|
906
|
-
disabledSkills: s?.settings?.disabledSkills ?? [],
|
|
907
|
-
disabledExtensions: s?.settings?.disabledExtensions ?? [],
|
|
908
|
-
visionBridgeEnabled: s?.settings?.visionBridgeEnabled ?? true,
|
|
909
|
-
visionBridgeModel: s?.settings?.visionBridgeModel ?? null,
|
|
910
|
-
visionBridgePromptMode: s?.settings?.visionBridgePromptMode === "replace" ? "replace" : "append",
|
|
911
|
-
visionBridgePrompt: s?.settings?.visionBridgePrompt ?? "",
|
|
912
|
-
reviewPrompt: s?.settings?.reviewPrompt ?? "",
|
|
913
|
-
reviewDisabledSkills: s?.settings?.reviewDisabledSkills ?? [],
|
|
914
|
-
};
|
|
915
|
-
}
|
|
916
|
-
/** Persist the client's settings-panel state (partial merge). */
|
|
917
|
-
saveSettings(clientId, settings) {
|
|
918
|
-
const all = this.load();
|
|
919
|
-
const state = (all[clientId] ??= { projects: [] });
|
|
920
|
-
const cur = state.settings ?? {};
|
|
921
|
-
state.settings = {
|
|
922
|
-
promptMode: settings.promptMode ?? cur.promptMode ?? "append",
|
|
923
|
-
customSystemPrompt: settings.customSystemPrompt ?? cur.customSystemPrompt ?? "",
|
|
924
|
-
disabledSkills: settings.disabledSkills ?? cur.disabledSkills ?? [],
|
|
925
|
-
disabledExtensions: settings.disabledExtensions ?? cur.disabledExtensions ?? [],
|
|
926
|
-
visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
|
|
927
|
-
visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
|
|
928
|
-
visionBridgePromptMode: settings.visionBridgePromptMode ??
|
|
929
|
-
cur.visionBridgePromptMode ??
|
|
930
|
-
"append",
|
|
931
|
-
visionBridgePrompt: settings.visionBridgePrompt ?? cur.visionBridgePrompt ?? "",
|
|
932
|
-
reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
|
|
933
|
-
reviewDisabledSkills: settings.reviewDisabledSkills ?? cur.reviewDisabledSkills ?? [],
|
|
934
|
-
};
|
|
935
|
-
this.save();
|
|
936
|
-
}
|
|
937
|
-
/** Named settings presets for a client (empty if never saved). */
|
|
938
|
-
getPresets(clientId) {
|
|
939
|
-
return (this.load()[clientId]?.presets ?? []).map((p) => ({
|
|
940
|
-
...p,
|
|
941
|
-
// Older client-state files predate review settings.
|
|
942
|
-
reviewPrompt: p.reviewPrompt ?? "",
|
|
943
|
-
reviewDisabledSkills: p.reviewDisabledSkills ?? [],
|
|
944
|
-
}));
|
|
945
|
-
}
|
|
946
|
-
/** Persist the client's named settings presets. */
|
|
947
|
-
savePresets(clientId, presets) {
|
|
948
|
-
const all = this.load();
|
|
949
|
-
const state = (all[clientId] ??= { projects: [] });
|
|
950
|
-
state.presets = presets;
|
|
951
|
-
this.save();
|
|
952
|
-
}
|
|
953
|
-
}
|
|
161
|
+
export { workspacePath };
|
|
954
162
|
/** Hard cap on how long ONE tool call may run before the watchdog aborts the
|
|
955
163
|
* session. The SDK bash tool has NO default timeout, so a command that never
|
|
956
164
|
* finishes (servers, watchers, infinite loops) would otherwise hang the whole
|
|
@@ -964,102 +172,6 @@ const TOOL_WATCHDOG_TIMEOUT_MS = (() => {
|
|
|
964
172
|
* runtime alive; conversations of other projects keep their own lists). */
|
|
965
173
|
const MAX_OPEN_CONVERSATIONS = 8;
|
|
966
174
|
const DEFAULT_CONV_TITLE = "新对话";
|
|
967
|
-
/** Build the agent-facing persistent terminal tools for one conversation. */
|
|
968
|
-
export function makePersistentTerminalTools(terminals, cwd) {
|
|
969
|
-
const result = (text, details = {}) => ({ content: [{ type: "text", text }], details });
|
|
970
|
-
const failIf = (error) => {
|
|
971
|
-
if (error)
|
|
972
|
-
throw new Error(error);
|
|
973
|
-
};
|
|
974
|
-
return [
|
|
975
|
-
defineTool({
|
|
976
|
-
name: "terminal_create",
|
|
977
|
-
label: "Create terminal",
|
|
978
|
-
description: "Create a named persistent interactive PTY in the current workspace. Use terminal_input or terminal_key to interact with it and terminal_read to inspect incremental output.",
|
|
979
|
-
promptSnippet: "create persistent interactive PTY terminals",
|
|
980
|
-
parameters: Type.Object({
|
|
981
|
-
terminalId: Type.String({ description: "Stable terminal name" }),
|
|
982
|
-
cwd: Type.Optional(Type.String({ description: "Workspace-relative directory" })),
|
|
983
|
-
cols: Type.Optional(Type.Integer({ minimum: 2, maximum: 500 })),
|
|
984
|
-
rows: Type.Optional(Type.Integer({ minimum: 2, maximum: 200 })),
|
|
985
|
-
}),
|
|
986
|
-
execute: async (_id, p) => {
|
|
987
|
-
const info = terminals.create(p.terminalId, p.cwd ?? cwd, p.cols ?? 120, p.rows ?? 40, cwd, p.terminalId);
|
|
988
|
-
if (!info)
|
|
989
|
-
throw new Error(`创建终端失败:${p.terminalId}`);
|
|
990
|
-
return result(`终端已创建:${JSON.stringify(info)}`, info);
|
|
991
|
-
},
|
|
992
|
-
}),
|
|
993
|
-
defineTool({
|
|
994
|
-
name: "terminal_list",
|
|
995
|
-
label: "List terminals",
|
|
996
|
-
description: "List all persistent PTY terminals owned by this conversation.",
|
|
997
|
-
promptSnippet: "list persistent terminals",
|
|
998
|
-
parameters: Type.Object({}),
|
|
999
|
-
execute: async () => result(JSON.stringify(terminals.list()), terminals.list()),
|
|
1000
|
-
}),
|
|
1001
|
-
defineTool({
|
|
1002
|
-
name: "terminal_close",
|
|
1003
|
-
label: "Close terminal",
|
|
1004
|
-
description: "Close a persistent PTY and terminate its process tree.",
|
|
1005
|
-
parameters: Type.Object({ terminalId: Type.String() }),
|
|
1006
|
-
execute: async (_id, p) => {
|
|
1007
|
-
if (!terminals.has(p.terminalId))
|
|
1008
|
-
throw new Error(`终端不存在:${p.terminalId}`);
|
|
1009
|
-
terminals.kill(p.terminalId);
|
|
1010
|
-
return result(`终端已关闭:${p.terminalId}`);
|
|
1011
|
-
},
|
|
1012
|
-
}),
|
|
1013
|
-
defineTool({
|
|
1014
|
-
name: "terminal_input",
|
|
1015
|
-
label: "Send terminal input",
|
|
1016
|
-
description: "Send arbitrary text to a persistent PTY. Include newline when a command should be submitted.",
|
|
1017
|
-
parameters: Type.Object({ terminalId: Type.String(), data: Type.String() }),
|
|
1018
|
-
execute: async (_id, p) => {
|
|
1019
|
-
failIf(terminals.inputChecked(p.terminalId, p.data));
|
|
1020
|
-
return result(`已发送 ${p.data.length} 个字符到 ${p.terminalId}`);
|
|
1021
|
-
},
|
|
1022
|
-
}),
|
|
1023
|
-
defineTool({
|
|
1024
|
-
name: "terminal_key",
|
|
1025
|
-
label: "Send terminal key",
|
|
1026
|
-
description: "Send Enter, Tab, arrows, function keys, or Ctrl/Alt combinations to a persistent PTY.",
|
|
1027
|
-
parameters: Type.Object({
|
|
1028
|
-
terminalId: Type.String(),
|
|
1029
|
-
key: Type.String({ description: "Enter, Tab, ArrowUp, c, etc." }),
|
|
1030
|
-
modifiers: Type.Optional(Type.Object({
|
|
1031
|
-
ctrl: Type.Optional(Type.Boolean()),
|
|
1032
|
-
alt: Type.Optional(Type.Boolean()),
|
|
1033
|
-
shift: Type.Optional(Type.Boolean()),
|
|
1034
|
-
})),
|
|
1035
|
-
}),
|
|
1036
|
-
execute: async (_id, p) => {
|
|
1037
|
-
failIf(terminals.key(p.terminalId, p.key, p.modifiers));
|
|
1038
|
-
return result(`已发送按键 ${p.key} 到 ${p.terminalId}`);
|
|
1039
|
-
},
|
|
1040
|
-
}),
|
|
1041
|
-
defineTool({
|
|
1042
|
-
name: "terminal_read",
|
|
1043
|
-
label: "Read terminal output",
|
|
1044
|
-
description: "Read incremental output from a persistent PTY. Keep the returned cursor and pass it on the next read; optionally wait for new output or process exit.",
|
|
1045
|
-
parameters: Type.Object({
|
|
1046
|
-
terminalId: Type.String(),
|
|
1047
|
-
cursor: Type.Optional(Type.Integer({ minimum: 0 })),
|
|
1048
|
-
maxBytes: Type.Optional(Type.Integer({ minimum: 1, maximum: 100000 })),
|
|
1049
|
-
waitMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 120000 })),
|
|
1050
|
-
}),
|
|
1051
|
-
execute: async (_id, p, signal) => {
|
|
1052
|
-
const cursor = p.cursor ?? 0;
|
|
1053
|
-
if (p.waitMs)
|
|
1054
|
-
await terminals.waitForOutput(p.terminalId, cursor, p.waitMs, signal);
|
|
1055
|
-
const read = terminals.read(p.terminalId, cursor, p.maxBytes ?? 20000);
|
|
1056
|
-
if (!read)
|
|
1057
|
-
throw new Error(`终端不存在:${p.terminalId}`);
|
|
1058
|
-
return result(JSON.stringify(read), read);
|
|
1059
|
-
},
|
|
1060
|
-
}),
|
|
1061
|
-
];
|
|
1062
|
-
}
|
|
1063
175
|
/** First user text in a session, truncated for the conversation list. */
|
|
1064
176
|
function conversationTitle(session) {
|
|
1065
177
|
try {
|
|
@@ -1114,55 +226,14 @@ export class ClientSession {
|
|
|
1114
226
|
* the first conversation and reused by later ones. */
|
|
1115
227
|
sharedModelRuntime;
|
|
1116
228
|
// -----------------------------------------------------------------------
|
|
1117
|
-
// Goal / review
|
|
1118
|
-
//
|
|
1119
|
-
// injects its feedback back into the main session to revise. All goal
|
|
1120
|
-
// mutation goes through setGoal/clearGoal so UI state stays consistent.
|
|
229
|
+
// Goal / review / wizard —— 自包含模块,见 goal-service.ts。每个对话有独立
|
|
230
|
+
// 的 GoalStatus,审查可并发;宿主回调在构造函数里接入。
|
|
1121
231
|
// -----------------------------------------------------------------------
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
locked: true,
|
|
1128
|
-
};
|
|
1129
|
-
/** Goal state exposed by the goal bar for the ACTIVE conversation. */
|
|
1130
|
-
get goal() {
|
|
1131
|
-
return this.conv.goal;
|
|
1132
|
-
}
|
|
1133
|
-
/** The browser has one dialog at a time, so wizard UI plumbing remains
|
|
1134
|
-
* client-wide; review execution itself is per conversation. */
|
|
1135
|
-
/** Settings-panel state (system prompt + disabled skills/extensions). The
|
|
1136
|
-
* resource-loader overrides in makeRuntimeFactory() read this at every
|
|
1137
|
-
* reload(), so session.reload() applies changes to the running runtime. */
|
|
1138
|
-
settings;
|
|
1139
|
-
/** Named settings presets (saved combos the user can re-apply). */
|
|
1140
|
-
presets = [];
|
|
1141
|
-
/** Settings changed while the run was streaming — the runtime reload is
|
|
1142
|
-
* deferred to the next agent_end so an in-flight run is never torn down. */
|
|
1143
|
-
pendingSettingsReload = false;
|
|
1144
|
-
/** Full (incl. disabled) skill/extension lists seen so far — disabled
|
|
1145
|
-
* entries disappear from the loader after reload, so this cache keeps them
|
|
1146
|
-
* visible (and re-enableable) in the settings panel. */
|
|
1147
|
-
knownSkills = new Map();
|
|
1148
|
-
knownExtensions = new Map();
|
|
1149
|
-
/** Aborts the currently-running goal wizard (user clicked ✗ / timed out). Drives
|
|
1150
|
-
* the in-flight goal_ask dialog to resolve as cancelled and (via the run
|
|
1151
|
-
* signal) stops the wizard session's agent run. Recreated per wizard. */
|
|
1152
|
-
wizardAbort = null;
|
|
1153
|
-
/** The wizard's AgentSession while it runs — lets clearGoal truly terminate it
|
|
1154
|
-
* (abort the run), not just flip a flag. */
|
|
1155
|
-
wizardSession = null;
|
|
1156
|
-
/** Conversation that owns the one browser wizard currently in flight. */
|
|
1157
|
-
wizardOwnerId = null;
|
|
1158
|
-
/** True when the wizard was cancelled externally (✗ / clear_goal / timeout) —
|
|
1159
|
-
* startGoalWizard reads this after the run to avoid setting a goal. */
|
|
1160
|
-
wizardCancelled = false;
|
|
1161
|
-
/** Idle-timeout for the wizard: if no answer arrives within this window (a
|
|
1162
|
-
* dialog is up but the user doesn't respond), the wizard is auto-cancelled. */
|
|
1163
|
-
static WIZARD_IDLE_TIMEOUT_MS = 5 * 60_000;
|
|
1164
|
-
/** Absolute deadline for the whole wizard session (model latency guard). */
|
|
1165
|
-
static WIZARD_MAX_TOTAL_MS = 20 * 60_000;
|
|
232
|
+
goalSvc;
|
|
233
|
+
/** Settings-panel state (system prompt + disabled skills/extensions) —
|
|
234
|
+
* 自包含模块,见 settings-service.ts。resource-loader overrides 在每次
|
|
235
|
+
* reload() 时读 current 的最新值,session.reload() 即可应用到运行中 runtime。 */
|
|
236
|
+
settingsSvc; // 构造函数里创建(需要 clientId/stateStore)
|
|
1166
237
|
/** How long a hard abort waits for session.abort() to make the run idle
|
|
1167
238
|
* before force-resetting the conversation (model streams that ignore the
|
|
1168
239
|
* abort signal would otherwise leave the chat stuck forever). */
|
|
@@ -1174,19 +245,20 @@ export class ClientSession {
|
|
|
1174
245
|
/** Live AbortControllers of THIS client's running bash tool calls — aborting
|
|
1175
246
|
* them kills only the command (agent run and conversation continue). */
|
|
1176
247
|
bashKills = new Set();
|
|
1177
|
-
/**
|
|
1178
|
-
*
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
248
|
+
/** Background-server tracking (port snapshots + 后台任务 panel state) —
|
|
249
|
+
* 自包含模块,见 bg-servers.ts。列表按 CLIENT 存活,不随对话切换/结束消失。 */
|
|
250
|
+
/** 文件树 / 预览读写 / SCM 查询 / watcher —— 自包含模块,见 files-service.ts。 */
|
|
251
|
+
files = new FilesService({
|
|
252
|
+
emit: (msg) => this.emit(msg),
|
|
253
|
+
isDisposed: () => this.disposed,
|
|
254
|
+
getCwd: () => this.cwd,
|
|
255
|
+
getActiveCwd: () => this.conv?.cwd ?? this.cwd,
|
|
256
|
+
});
|
|
257
|
+
bg = new BgServerTracker({
|
|
258
|
+
emit: (msg) => this.emit(msg),
|
|
259
|
+
flushSnapshot: () => this.flushSnapshot(),
|
|
260
|
+
isDisposed: () => this.disposed,
|
|
261
|
+
});
|
|
1190
262
|
/** The active conversation (all session operations target it). */
|
|
1191
263
|
get conv() {
|
|
1192
264
|
const conv = this.convs.get(this.activeId);
|
|
@@ -1247,7 +319,6 @@ export class ClientSession {
|
|
|
1247
319
|
* prompt with the same images skips the vision API call entirely — editing
|
|
1248
320
|
* a question doesn't re-burn tokens on re-transcribing identical screenshots.
|
|
1249
321
|
*/
|
|
1250
|
-
visionBridgeCache = new Map();
|
|
1251
322
|
/** Most recent built-in (default) system prompt observed by the
|
|
1252
323
|
* resource-loader override — surfaced via settings_state so the
|
|
1253
324
|
* replace-mode editor can show the prompt it would otherwise replace.
|
|
@@ -1279,6 +350,9 @@ export class ClientSession {
|
|
|
1279
350
|
sinks = new Set();
|
|
1280
351
|
pendingNotices = [];
|
|
1281
352
|
snapshotTimer = null;
|
|
353
|
+
/** Timestamp of the most recent message_delta push — while fresh, snapshots
|
|
354
|
+
* use the slower STREAMING_SNAPSHOT_INTERVAL_MS cadence. */
|
|
355
|
+
lastDeltaAt = 0;
|
|
1282
356
|
sessionsTimer = null;
|
|
1283
357
|
version = 0;
|
|
1284
358
|
/**
|
|
@@ -1296,31 +370,67 @@ export class ClientSession {
|
|
|
1296
370
|
* filesystem — failures silently fall back to the poll. */
|
|
1297
371
|
fsWatcher = null;
|
|
1298
372
|
watchPath = null;
|
|
373
|
+
/** fs.watch on the active repo's git dir — external changes (CLI commit,
|
|
374
|
+
* IDE branch switch) push `scm_changed` so the panel refreshes itself.
|
|
375
|
+
* One watcher per client session, re-targeted when the queried cwd
|
|
376
|
+
* changes; failures (bare repo, unsupported fs) silently disable it. */
|
|
377
|
+
gitWatcher = null;
|
|
378
|
+
gitWatchCwd = null;
|
|
379
|
+
gitDirtyTimer = null;
|
|
1299
380
|
watchTimer = null;
|
|
1300
381
|
constructor(clientId, cwd, agentDir, stateStore) {
|
|
1301
382
|
this.clientId = clientId;
|
|
1302
383
|
this.cwd = cwd;
|
|
1303
384
|
this.agentDir = agentDir;
|
|
1304
385
|
this.stateStore = stateStore;
|
|
1305
|
-
this.
|
|
1306
|
-
|
|
386
|
+
this.settingsSvc = new SettingsService({
|
|
387
|
+
clientId,
|
|
388
|
+
stateStore,
|
|
389
|
+
emit: (msg) => this.emit(msg),
|
|
390
|
+
flushSnapshot: () => this.flushSnapshot(),
|
|
391
|
+
isDisposed: () => this.disposed,
|
|
392
|
+
getSession: () => this.session,
|
|
393
|
+
isStreaming: () => this.session.isStreaming,
|
|
394
|
+
reloadSession: async () => {
|
|
395
|
+
await this.session.reload();
|
|
396
|
+
await this.pushSlashCommands();
|
|
397
|
+
},
|
|
398
|
+
effectiveDefaultSystemPrompt: () => this.effectiveDefaultSystemPrompt(),
|
|
399
|
+
});
|
|
400
|
+
this.goalSvc = new GoalService({
|
|
401
|
+
clientId,
|
|
402
|
+
agentDir,
|
|
403
|
+
stateStore,
|
|
404
|
+
webUi: this.webUi,
|
|
405
|
+
emit: (msg) => this.emit(msg),
|
|
406
|
+
flushSnapshot: () => this.flushSnapshot(),
|
|
407
|
+
isDisposed: () => this.disposed,
|
|
408
|
+
quiesceBlocked: () => this.quiesceBlocked(),
|
|
409
|
+
activeConvId: () => this.activeId,
|
|
410
|
+
activeConv: () => this.conv,
|
|
411
|
+
getConv: (id) => this.convs.get(id),
|
|
412
|
+
cwd: () => this.cwd,
|
|
413
|
+
reviewSettings: () => this.settingsSvc.reviewPrefs,
|
|
414
|
+
gitDiff: (dir) => this.gitDiff(dir),
|
|
415
|
+
});
|
|
416
|
+
this.modelAdmin = new ModelAdminService({
|
|
417
|
+
agentDir,
|
|
418
|
+
emit: (msg) => this.emit(msg),
|
|
419
|
+
flushSnapshot: () => this.flushSnapshot(),
|
|
420
|
+
isDisposed: () => this.disposed,
|
|
421
|
+
modelRuntime: () => this.runtime.services.modelRuntime,
|
|
422
|
+
invalidatePiConfig: () => {
|
|
423
|
+
this.piCheckCache = null;
|
|
424
|
+
},
|
|
425
|
+
pushModels: async () => this.listModels(),
|
|
426
|
+
});
|
|
1307
427
|
// Prune dead background tasks every 30s (only spawns netstat/lsof while
|
|
1308
428
|
// the list is non-empty). unref: must not keep the process alive.
|
|
1309
|
-
this.
|
|
1310
|
-
this.bgTimer.unref?.();
|
|
429
|
+
this.bg.start();
|
|
1311
430
|
}
|
|
1312
431
|
static async create(clientId, cwd, stateStore) {
|
|
1313
432
|
const agentDir = process.env.PI_CODING_AGENT_DIR ?? getAgentDir();
|
|
1314
433
|
const cs = new ClientSession(clientId, cwd, agentDir, stateStore);
|
|
1315
|
-
// Restore last-used goal/review preferences so model & rounds survive reload.
|
|
1316
|
-
const gPrefs = stateStore.getGoalPrefs(clientId);
|
|
1317
|
-
if (gPrefs) {
|
|
1318
|
-
cs.goalReviewPrefs = {
|
|
1319
|
-
reviewModel: gPrefs.reviewModel,
|
|
1320
|
-
maxRounds: gPrefs.maxRounds,
|
|
1321
|
-
locked: gPrefs.locked,
|
|
1322
|
-
};
|
|
1323
|
-
}
|
|
1324
434
|
const conversationId = cs.nextConversationId();
|
|
1325
435
|
const terminals = cs.makeTerminalManager(conversationId, cwd);
|
|
1326
436
|
const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(terminals), {
|
|
@@ -1371,15 +481,15 @@ export class ClientSession {
|
|
|
1371
481
|
if (typeof base === "string" && base) {
|
|
1372
482
|
this.lastBaseSystemPrompt = base;
|
|
1373
483
|
}
|
|
1374
|
-
return this.
|
|
1375
|
-
this.
|
|
1376
|
-
? this.
|
|
484
|
+
return this.settingsSvc.current.promptMode === "replace" &&
|
|
485
|
+
this.settingsSvc.current.customSystemPrompt.trim()
|
|
486
|
+
? this.settingsSvc.current.customSystemPrompt
|
|
1377
487
|
: base;
|
|
1378
488
|
},
|
|
1379
489
|
appendSystemPromptOverride: (base) => {
|
|
1380
490
|
const out = [...base];
|
|
1381
|
-
const custom = this.
|
|
1382
|
-
if (this.
|
|
491
|
+
const custom = this.settingsSvc.current.customSystemPrompt.trim();
|
|
492
|
+
if (this.settingsSvc.current.promptMode === "append" && custom) {
|
|
1383
493
|
out.push(custom);
|
|
1384
494
|
}
|
|
1385
495
|
if (process.platform === "win32") {
|
|
@@ -1393,12 +503,12 @@ export class ClientSession {
|
|
|
1393
503
|
// 技能开关:禁用的技能从系统提示词和 /skill: 目录中剔除。
|
|
1394
504
|
skillsOverride: (res) => ({
|
|
1395
505
|
...res,
|
|
1396
|
-
skills: res.skills.filter((s) => !this.
|
|
506
|
+
skills: res.skills.filter((s) => !this.settingsSvc.current.disabledSkills.includes(s.name)),
|
|
1397
507
|
}),
|
|
1398
508
|
// 插件开关:禁用的扩展整个卸载(工具 / 命令随之消失)。
|
|
1399
509
|
extensionsOverride: (res) => ({
|
|
1400
510
|
...res,
|
|
1401
|
-
extensions: res.extensions.filter((e) => !this.
|
|
511
|
+
extensions: res.extensions.filter((e) => !this.settingsSvc.current.disabledExtensions.includes(extensionKey(e))),
|
|
1402
512
|
}),
|
|
1403
513
|
},
|
|
1404
514
|
});
|
|
@@ -1422,25 +532,7 @@ export class ClientSession {
|
|
|
1422
532
|
/** Create independent goal state for one conversation. Preferences are
|
|
1423
533
|
* client-wide defaults, while goal text/review progress is not shared. */
|
|
1424
534
|
makeGoalStatus() {
|
|
1425
|
-
return
|
|
1426
|
-
conversationId: null,
|
|
1427
|
-
goal: null,
|
|
1428
|
-
reviewModel: this.goalReviewPrefs.reviewModel,
|
|
1429
|
-
maxRounds: this.goalReviewPrefs.maxRounds,
|
|
1430
|
-
locked: this.goalReviewPrefs.locked,
|
|
1431
|
-
reviewing: false,
|
|
1432
|
-
round: 0,
|
|
1433
|
-
status: "",
|
|
1434
|
-
verdict: "pending",
|
|
1435
|
-
wizard: {
|
|
1436
|
-
active: false,
|
|
1437
|
-
draft: "",
|
|
1438
|
-
model: null,
|
|
1439
|
-
step: 0,
|
|
1440
|
-
maxSteps: 6,
|
|
1441
|
-
status: "",
|
|
1442
|
-
},
|
|
1443
|
-
};
|
|
535
|
+
return this.goalSvc.makeGoalStatus();
|
|
1444
536
|
}
|
|
1445
537
|
/** Allocate a stable conversation id before constructing its runtime/tools. */
|
|
1446
538
|
nextConversationId() {
|
|
@@ -1464,6 +556,7 @@ export class ClientSession {
|
|
|
1464
556
|
goalGeneration: 0,
|
|
1465
557
|
goalReviewGeneration: 0,
|
|
1466
558
|
wizardRunning: false,
|
|
559
|
+
deltaSeq: 0,
|
|
1467
560
|
terminals,
|
|
1468
561
|
msgIds: new Map(),
|
|
1469
562
|
nextMsgId: 1,
|
|
@@ -1500,13 +593,13 @@ export class ClientSession {
|
|
|
1500
593
|
void this.pushSlashCommands();
|
|
1501
594
|
// Reconnect: push the remembered goal prefs (model choice, rounds cap,
|
|
1502
595
|
// locked) so the goal bar restores them on reload — "全局记忆".
|
|
1503
|
-
this.emitGoalStatus();
|
|
596
|
+
this.goalSvc.emitGoalStatus();
|
|
1504
597
|
// Reconnect: push the settings panel state (prompt text/mode, skill &
|
|
1505
598
|
// extension toggles, saved presets).
|
|
1506
599
|
this.pushSettings();
|
|
1507
600
|
// Reconnect: push the background-task list — it must survive reconnects
|
|
1508
601
|
// and outlive the conversation that started the tasks.
|
|
1509
|
-
this.
|
|
602
|
+
this.bg.push();
|
|
1510
603
|
// PTYs are conversation-owned and survive a socket reconnect.
|
|
1511
604
|
this.pushTerminals();
|
|
1512
605
|
}
|
|
@@ -1516,7 +609,7 @@ export class ClientSession {
|
|
|
1516
609
|
// conversation and can be inspected after reconnecting. Only conversation
|
|
1517
610
|
// disposal or server shutdown kills them.
|
|
1518
611
|
if (this.sinks.size === 0) {
|
|
1519
|
-
this.unwatchDir();
|
|
612
|
+
this.files.unwatchDir();
|
|
1520
613
|
}
|
|
1521
614
|
}
|
|
1522
615
|
/** Broadcast to every connected socket of this client. */
|
|
@@ -1596,6 +689,8 @@ export class ClientSession {
|
|
|
1596
689
|
if (event.id) {
|
|
1597
690
|
this.emit({
|
|
1598
691
|
type: "tool_delta",
|
|
692
|
+
conversationId: conv.id,
|
|
693
|
+
seq: ++conv.deltaSeq,
|
|
1599
694
|
toolCallId: event.id,
|
|
1600
695
|
toolName: "bash",
|
|
1601
696
|
delta: event.delta,
|
|
@@ -1610,9 +705,7 @@ export class ClientSession {
|
|
|
1610
705
|
// Snapshot listeners before a bash run — the post-run diff catches
|
|
1611
706
|
// servers the agent started in the background.
|
|
1612
707
|
if (event.toolName === "bash") {
|
|
1613
|
-
|
|
1614
|
-
this.bashListenBefore = m;
|
|
1615
|
-
});
|
|
708
|
+
this.bg.snapshotBefore();
|
|
1616
709
|
}
|
|
1617
710
|
this.armToolWatchdog(conv, event.toolCallId);
|
|
1618
711
|
break;
|
|
@@ -1624,7 +717,7 @@ export class ClientSession {
|
|
|
1624
717
|
// Bash finished — wait briefly for background servers to bind their
|
|
1625
718
|
// ports, then diff against the pre-run snapshot and record them.
|
|
1626
719
|
if (event.toolName === "bash")
|
|
1627
|
-
void this.
|
|
720
|
+
void this.bg.trackAfterBash();
|
|
1628
721
|
const durationMs = startedAt !== undefined ? Date.now() - startedAt : undefined;
|
|
1629
722
|
// The bash tool does not put its exit code in result.details — on
|
|
1630
723
|
// failure it throws "Command exited with code N" and the agent
|
|
@@ -1667,6 +760,8 @@ export class ClientSession {
|
|
|
1667
760
|
if (text) {
|
|
1668
761
|
this.emit({
|
|
1669
762
|
type: "tool_delta",
|
|
763
|
+
conversationId: conv.id,
|
|
764
|
+
seq: ++conv.deltaSeq,
|
|
1670
765
|
toolCallId: event.toolCallId,
|
|
1671
766
|
toolName: event.toolName,
|
|
1672
767
|
delta: text,
|
|
@@ -1682,7 +777,6 @@ export class ClientSession {
|
|
|
1682
777
|
// (new chat + first message, completed turns, compaction, etc.).
|
|
1683
778
|
case "agent_end": {
|
|
1684
779
|
this.scheduleSessionsRefresh();
|
|
1685
|
-
const g = conv.goal;
|
|
1686
780
|
// Manual interrupt (Stop button / abort): the last assistant message
|
|
1687
781
|
// carries stopReason "aborted". A half-finished run should NOT be
|
|
1688
782
|
// reviewed (it would fail and inject a revision, only to be stopped
|
|
@@ -1693,38 +787,19 @@ export class ClientSession {
|
|
|
1693
787
|
return a.role === "assistant" && a.stopReason === "aborted";
|
|
1694
788
|
});
|
|
1695
789
|
if (aborted) {
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
g.goal = null;
|
|
1700
|
-
g.reviewing = false;
|
|
1701
|
-
g.verdict = "pending";
|
|
1702
|
-
g.feedback = undefined;
|
|
1703
|
-
g.status = "已手动停止,目标审查已中止";
|
|
1704
|
-
this.emitGoalStatus();
|
|
1705
|
-
this.emit({
|
|
1706
|
-
type: "notice",
|
|
1707
|
-
level: "warning",
|
|
1708
|
-
text: "⏹ 已手动停止,目标审查已中止(想继续可重新设定目标)",
|
|
1709
|
-
});
|
|
790
|
+
const stopNotice = this.goalSvc.onAgentEnd(conv, true);
|
|
791
|
+
if (stopNotice) {
|
|
792
|
+
this.emit({ type: "notice", level: "warning", text: stopNotice });
|
|
1710
793
|
}
|
|
1711
794
|
break;
|
|
1712
795
|
}
|
|
1713
|
-
// Goal review hook
|
|
1714
|
-
|
|
1715
|
-
// already mid-review, spawn the isolated reviewer.
|
|
1716
|
-
if (g.goal &&
|
|
1717
|
-
g.conversationId === conv.id &&
|
|
1718
|
-
!g.reviewing &&
|
|
1719
|
-
!conv.wizardRunning &&
|
|
1720
|
-
!this.disposed) {
|
|
1721
|
-
void this.runGoalReview(conv);
|
|
1722
|
-
}
|
|
796
|
+
// Goal review hook lives in GoalService.onAgentEnd(conv, false).
|
|
797
|
+
this.goalSvc.onAgentEnd(conv, false);
|
|
1723
798
|
// Deferred settings reload: settings (system prompt / skills /
|
|
1724
799
|
// extensions) changed while the run was streaming — applying now
|
|
1725
800
|
// would have torn down the in-flight run.
|
|
1726
|
-
if (this.
|
|
1727
|
-
this.
|
|
801
|
+
if (this.settingsSvc.hasPendingReload() && !this.disposed) {
|
|
802
|
+
this.settingsSvc.consumePendingReload();
|
|
1728
803
|
void this.applySettingsReload();
|
|
1729
804
|
}
|
|
1730
805
|
break;
|
|
@@ -1732,10 +807,57 @@ export class ClientSession {
|
|
|
1732
807
|
case "entry_appended":
|
|
1733
808
|
this.scheduleSessionsRefresh();
|
|
1734
809
|
break;
|
|
810
|
+
case "message_update": {
|
|
811
|
+
// Live assistant-message increment, deliberately OUTSIDE the snapshot
|
|
812
|
+
// channel: send() drops snapshots under backpressure (big sessions),
|
|
813
|
+
// but this small message must always get through or the UI freezes on
|
|
814
|
+
// stale state. Only the ACTIVE conversation streams to the browser —
|
|
815
|
+
// background conversations would clobber the streaming view; their
|
|
816
|
+
// state arrives via snapshot when switched to.
|
|
817
|
+
if (conv.id !== this.conv.id)
|
|
818
|
+
break;
|
|
819
|
+
const ame = event.assistantMessageEvent;
|
|
820
|
+
const m = event.message;
|
|
821
|
+
this.lastDeltaAt = Date.now();
|
|
822
|
+
this.emit({
|
|
823
|
+
type: "message_delta",
|
|
824
|
+
conversationId: conv.id,
|
|
825
|
+
seq: ++conv.deltaSeq,
|
|
826
|
+
// Must match serializeStreamingMessage()'s stable id so deltas
|
|
827
|
+
// patch onto the snapshot's streamingMessage and reconcile.
|
|
828
|
+
messageId: `stream-${m?.timestamp ?? 0}`,
|
|
829
|
+
usage: (() => {
|
|
830
|
+
try {
|
|
831
|
+
const t = this.session.getSessionStats().tokens;
|
|
832
|
+
return t ? { input: t.input, output: t.output, total: t.total } : null;
|
|
833
|
+
}
|
|
834
|
+
catch {
|
|
835
|
+
return null;
|
|
836
|
+
}
|
|
837
|
+
})(),
|
|
838
|
+
// Strip `partial` (the cumulative message): re-serializing it per
|
|
839
|
+
// token is exactly what we're trying to avoid. The next snapshot
|
|
840
|
+
// carries the authoritative full message anyway.
|
|
841
|
+
assistantMessageEvent: {
|
|
842
|
+
type: ame.type,
|
|
843
|
+
contentIndex: "contentIndex" in ame ? ame.contentIndex : undefined,
|
|
844
|
+
delta: "delta" in ame ? ame.delta : undefined,
|
|
845
|
+
},
|
|
846
|
+
});
|
|
847
|
+
break;
|
|
848
|
+
}
|
|
1735
849
|
default:
|
|
1736
850
|
break;
|
|
1737
851
|
}
|
|
1738
|
-
|
|
852
|
+
// Snapshot checkpoint policy: deltas carry live rendering during streaming;
|
|
853
|
+
// full snapshots are reconciliation checkpoints taken immediately at
|
|
854
|
+
// run/tool boundaries and on a slow timer otherwise.
|
|
855
|
+
if (event.type === "agent_end" || event.type === "tool_execution_end") {
|
|
856
|
+
this.flushSnapshot();
|
|
857
|
+
}
|
|
858
|
+
else {
|
|
859
|
+
this.scheduleSnapshot();
|
|
860
|
+
}
|
|
1739
861
|
}
|
|
1740
862
|
/** Debounced push of the persisted session list + open conversations. */
|
|
1741
863
|
scheduleSessionsRefresh() {
|
|
@@ -1748,6 +870,7 @@ export class ClientSession {
|
|
|
1748
870
|
this.emitConversations();
|
|
1749
871
|
void this.pushSessions();
|
|
1750
872
|
}, 800);
|
|
873
|
+
// pushSessions no-ops unless the client opted in via list_sessions.
|
|
1751
874
|
}
|
|
1752
875
|
/** Serialize a persisted message with a STABLE id + cached object reference. */
|
|
1753
876
|
serializeCached(m) {
|
|
@@ -2147,727 +1270,73 @@ export class ClientSession {
|
|
|
2147
1270
|
}
|
|
2148
1271
|
this.flushSnapshot();
|
|
2149
1272
|
}
|
|
2150
|
-
/**
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
this.
|
|
2155
|
-
return;
|
|
2156
|
-
}
|
|
2157
|
-
if (!key) {
|
|
2158
|
-
this.emit({ type: "notice", level: "error", text: "请填写 API 密钥" });
|
|
2159
|
-
return;
|
|
2160
|
-
}
|
|
2161
|
-
try {
|
|
2162
|
-
// Persist to auth.json (auth.json shape: { <provider>: { type: "api_key", key } }).
|
|
2163
|
-
const authPath = join(this.agentDir, "auth.json");
|
|
2164
|
-
mkdirSync(this.agentDir, { recursive: true });
|
|
2165
|
-
let data = {};
|
|
2166
|
-
try {
|
|
2167
|
-
data = JSON.parse(readFileSync(authPath, "utf8"));
|
|
2168
|
-
}
|
|
2169
|
-
catch {
|
|
2170
|
-
// no file yet / unparsable — start fresh
|
|
2171
|
-
}
|
|
2172
|
-
data[provider.trim()] = { type: "api_key", key };
|
|
2173
|
-
writeFileSync(authPath, JSON.stringify(data, null, 2) + "\n");
|
|
2174
|
-
// Apply immediately for this session (runtime credentials are cached), then
|
|
2175
|
-
// refresh models. allowNetwork downloads the provider's official model
|
|
2176
|
-
// catalog (openai/anthropic/… are dynamic providers with no built-in list).
|
|
2177
|
-
const mr = this.runtime.services.modelRuntime;
|
|
2178
|
-
await mr.setRuntimeApiKey(provider.trim(), key);
|
|
2179
|
-
await mr.refresh({ allowNetwork: true });
|
|
2180
|
-
this.piCheckCache = null;
|
|
2181
|
-
this.emit({
|
|
2182
|
-
type: "notice",
|
|
2183
|
-
level: "info",
|
|
2184
|
-
text: `✅ 已保存 ${provider.trim()} 的 API 密钥并刷新模型列表`,
|
|
2185
|
-
});
|
|
2186
|
-
await this.listModels();
|
|
2187
|
-
await this.listProviders();
|
|
2188
|
-
}
|
|
2189
|
-
catch (err) {
|
|
2190
|
-
this.emit({
|
|
2191
|
-
type: "notice",
|
|
2192
|
-
level: "error",
|
|
2193
|
-
text: `保存 API 密钥失败:${err.message}`,
|
|
2194
|
-
});
|
|
1273
|
+
/** Send a snapshot immediately (cancels any pending throttled one). */
|
|
1274
|
+
flushSnapshot() {
|
|
1275
|
+
if (this.snapshotTimer) {
|
|
1276
|
+
clearTimeout(this.snapshotTimer);
|
|
1277
|
+
this.snapshotTimer = null;
|
|
2195
1278
|
}
|
|
2196
|
-
this.
|
|
1279
|
+
if (!this.disposed)
|
|
1280
|
+
this.emit({ type: "snapshot", state: this.snapshot() });
|
|
2197
1281
|
}
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
const mr = this.runtime.services.modelRuntime;
|
|
2201
|
-
let providers;
|
|
2202
|
-
try {
|
|
2203
|
-
providers = mr.getProviders().map((p) => {
|
|
2204
|
-
try {
|
|
2205
|
-
const st = mr.getProviderAuthStatus(p.id);
|
|
2206
|
-
return {
|
|
2207
|
-
id: p.id,
|
|
2208
|
-
name: p.name,
|
|
2209
|
-
configured: st?.configured ?? false,
|
|
2210
|
-
source: st?.source,
|
|
2211
|
-
};
|
|
2212
|
-
}
|
|
2213
|
-
catch {
|
|
2214
|
-
// One odd provider must not blank the whole list.
|
|
2215
|
-
return { id: p.id, name: p.name, configured: false };
|
|
2216
|
-
}
|
|
2217
|
-
});
|
|
2218
|
-
}
|
|
2219
|
-
catch (err) {
|
|
2220
|
-
this.emit({
|
|
2221
|
-
type: "notice",
|
|
2222
|
-
level: "error",
|
|
2223
|
-
text: `获取服务商列表失败:${err.message}`,
|
|
2224
|
-
});
|
|
1282
|
+
scheduleSnapshot() {
|
|
1283
|
+
if (this.snapshotTimer || this.disposed)
|
|
2225
1284
|
return;
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
i += 2;
|
|
2255
|
-
continue;
|
|
2256
|
-
}
|
|
2257
|
-
if (c === '"')
|
|
2258
|
-
inString = false;
|
|
2259
|
-
i++;
|
|
2260
|
-
continue;
|
|
2261
|
-
}
|
|
2262
|
-
if (c === '"') {
|
|
2263
|
-
inString = true;
|
|
2264
|
-
out += c;
|
|
2265
|
-
i++;
|
|
2266
|
-
continue;
|
|
2267
|
-
}
|
|
2268
|
-
if (c === "/" && next === "/") {
|
|
2269
|
-
while (i < src.length && src[i] !== "\n")
|
|
2270
|
-
i++;
|
|
2271
|
-
continue;
|
|
2272
|
-
}
|
|
2273
|
-
if (c === "/" && next === "*") {
|
|
2274
|
-
i += 2;
|
|
2275
|
-
while (i < src.length && !(src[i] === "*" && src[i + 1] === "/"))
|
|
2276
|
-
i++;
|
|
2277
|
-
i += 2;
|
|
2278
|
-
continue;
|
|
2279
|
-
}
|
|
2280
|
-
out += c;
|
|
2281
|
-
i++;
|
|
2282
|
-
}
|
|
2283
|
-
return out;
|
|
2284
|
-
}
|
|
2285
|
-
/** Read + parse models.json (tolerating // and /* *\/ comments like the SDK). */
|
|
2286
|
-
readModelsConfig() {
|
|
2287
|
-
const path = this.modelsConfigPath();
|
|
2288
|
-
try {
|
|
2289
|
-
const raw = readFileSync(path, "utf8");
|
|
2290
|
-
const parsed = JSON.parse(ClientSession.stripJsonComments(raw));
|
|
2291
|
-
return { providers: parsed?.providers ?? {} };
|
|
2292
|
-
}
|
|
2293
|
-
catch {
|
|
2294
|
-
return { providers: {} };
|
|
2295
|
-
}
|
|
2296
|
-
}
|
|
2297
|
-
/** Send the current models.json custom providers to the client. */
|
|
2298
|
-
async listModelsConfig() {
|
|
2299
|
-
const { providers } = this.readModelsConfig();
|
|
2300
|
-
const list = Object.entries(providers).map(([providerId, p]) => {
|
|
2301
|
-
const models = Array.isArray(p.models)
|
|
2302
|
-
? p.models.map((m) => ({
|
|
2303
|
-
id: String(m.id ?? ""),
|
|
2304
|
-
name: m.name,
|
|
2305
|
-
reasoning: m.reasoning,
|
|
2306
|
-
input: Array.isArray(m.input) ? m.input : undefined,
|
|
2307
|
-
contextWindow: m.contextWindow,
|
|
2308
|
-
maxTokens: m.maxTokens,
|
|
2309
|
-
}))
|
|
2310
|
-
: [];
|
|
2311
|
-
return {
|
|
2312
|
-
providerId,
|
|
2313
|
-
name: p.name,
|
|
2314
|
-
api: p.api,
|
|
2315
|
-
baseUrl: p.baseUrl,
|
|
2316
|
-
apiKey: p.apiKey,
|
|
2317
|
-
authHeader: p.authHeader,
|
|
2318
|
-
// headers are intentionally NOT sent to the browser — they may
|
|
2319
|
-
// contain Authorization / API-key values; kept server-side only.
|
|
2320
|
-
models,
|
|
2321
|
-
};
|
|
2322
|
-
});
|
|
2323
|
-
this.emit({ type: "models_config", providers: list });
|
|
2324
|
-
}
|
|
2325
|
-
/** Numeric metadata value (NaN/string "unknown" → undefined). */
|
|
2326
|
-
static numMeta(v) {
|
|
2327
|
-
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
2328
|
-
}
|
|
2329
|
-
static boolMeta(v) {
|
|
2330
|
-
return typeof v === "boolean" ? v : undefined;
|
|
2331
|
-
}
|
|
2332
|
-
static strArrMeta(v) {
|
|
2333
|
-
return Array.isArray(v)
|
|
2334
|
-
? v.filter((x) => typeof x === "string")
|
|
2335
|
-
: undefined;
|
|
2336
|
-
}
|
|
2337
|
-
/** Best-effort extraction of model metadata from an OpenAI-compatible
|
|
2338
|
-
* /models `data[]` item. Most endpoints only return `{ id }` — the extra
|
|
2339
|
-
* fields (context_window / max_model_len / modalities / supports_vision /
|
|
2340
|
-
* reasoning / display_name) come from vLLM and other extended
|
|
2341
|
-
* implementations, and are filled into the form when present. */
|
|
2342
|
-
static parseOpenAiModel(m) {
|
|
2343
|
-
const r = (m ?? {});
|
|
2344
|
-
const id = typeof r.id === "string" ? r.id : "";
|
|
2345
|
-
const name = (typeof r.name === "string" && r.name.trim() ? r.name : undefined) ??
|
|
2346
|
-
(typeof r.display_name === "string" && r.display_name.trim()
|
|
2347
|
-
? r.display_name
|
|
2348
|
-
: undefined);
|
|
2349
|
-
const modalities = ClientSession.strArrMeta(r.modalities) ??
|
|
2350
|
-
ClientSession.strArrMeta(r.input_modalities);
|
|
2351
|
-
const vision = modalities?.includes("image") === true ||
|
|
2352
|
-
ClientSession.boolMeta(r.supports_vision) === true ||
|
|
2353
|
-
ClientSession.boolMeta(r.vision) === true ||
|
|
2354
|
-
ClientSession.strArrMeta(r.input)?.includes("image") === true;
|
|
2355
|
-
const reasoning = ClientSession.boolMeta(r.reasoning) === true ||
|
|
2356
|
-
ClientSession.boolMeta(r.supports_reasoning) === true ||
|
|
2357
|
-
modalities?.includes("reasoning") === true;
|
|
2358
|
-
const contextWindow = ClientSession.numMeta(r.context_window) ??
|
|
2359
|
-
ClientSession.numMeta(r.context_length) ??
|
|
2360
|
-
ClientSession.numMeta(r.max_model_len) ??
|
|
2361
|
-
ClientSession.numMeta(r.max_context_length);
|
|
2362
|
-
const maxTokens = ClientSession.numMeta(r.max_tokens) ??
|
|
2363
|
-
ClientSession.numMeta(r.max_output_tokens) ??
|
|
2364
|
-
ClientSession.numMeta(r.max_completion_tokens);
|
|
2365
|
-
return {
|
|
2366
|
-
id,
|
|
2367
|
-
...(name ? { name } : {}),
|
|
2368
|
-
...(reasoning ? { reasoning: true } : {}),
|
|
2369
|
-
...(vision ? { input: ["text", "image"] } : {}),
|
|
2370
|
-
...(contextWindow ? { contextWindow } : {}),
|
|
2371
|
-
...(maxTokens ? { maxTokens } : {}),
|
|
2372
|
-
};
|
|
2373
|
-
}
|
|
2374
|
-
/** google-generative-ai /models shape:
|
|
2375
|
-
* { models: [{ name: "models/gemini-flash", displayName, inputTokenLimit,
|
|
2376
|
-
* outputTokenLimit, supportedGenerationMethods }] } */
|
|
2377
|
-
static parseGoogleModel(m) {
|
|
2378
|
-
const r = (m ?? {});
|
|
2379
|
-
const rawName = typeof r.name === "string" ? r.name : "";
|
|
2380
|
-
const id = rawName.replace(/^models\//, "");
|
|
2381
|
-
const displayName = typeof r.displayName === "string" ? r.displayName : undefined;
|
|
2382
|
-
return {
|
|
2383
|
-
id,
|
|
2384
|
-
...(displayName && displayName !== id ? { name: displayName } : {}),
|
|
2385
|
-
...(ClientSession.numMeta(r.inputTokenLimit)
|
|
2386
|
-
? { contextWindow: ClientSession.numMeta(r.inputTokenLimit) }
|
|
2387
|
-
: {}),
|
|
2388
|
-
...(ClientSession.numMeta(r.outputTokenLimit)
|
|
2389
|
-
? { maxTokens: ClientSession.numMeta(r.outputTokenLimit) }
|
|
2390
|
-
: {}),
|
|
2391
|
-
};
|
|
1285
|
+
// During active streaming the deltas carry live rendering — full snapshots
|
|
1286
|
+
// are just a periodic reconciliation checkpoint, so send them far less
|
|
1287
|
+
// often (they serialize the whole session; big sessions made this path OOM).
|
|
1288
|
+
const interval = Date.now() - this.lastDeltaAt < DELTA_ACTIVE_WINDOW_MS
|
|
1289
|
+
? STREAMING_SNAPSHOT_INTERVAL_MS
|
|
1290
|
+
: SNAPSHOT_INTERVAL_MS;
|
|
1291
|
+
this.snapshotTimer = setTimeout(() => {
|
|
1292
|
+
this.snapshotTimer = null;
|
|
1293
|
+
if (!this.disposed)
|
|
1294
|
+
this.emit({ type: "snapshot", state: this.snapshot() });
|
|
1295
|
+
}, interval);
|
|
1296
|
+
}
|
|
1297
|
+
/** Slash-command catalog + native command execution — 自包含模块,见
|
|
1298
|
+
* slash-commands.ts(内置命令拦截 + 扩展/模板/技能目录推送)。 */
|
|
1299
|
+
slash = new SlashCommandsService({
|
|
1300
|
+
emit: (msg) => this.emit(msg),
|
|
1301
|
+
cwd: () => this.cwd,
|
|
1302
|
+
getSession: () => this.session,
|
|
1303
|
+
newChat: () => this.newChat(),
|
|
1304
|
+
setModel: (id) => this.setModel(id),
|
|
1305
|
+
setCwd: (path) => this.setCwd(path),
|
|
1306
|
+
setThinking: (level) => this.setThinking(level),
|
|
1307
|
+
refreshSessions: () => this.refreshSessions(),
|
|
1308
|
+
onQuit: () => this.onQuit?.() ?? false,
|
|
1309
|
+
});
|
|
1310
|
+
/** Catalog push — index.ts get_commands / attach / cwd 切换等都会调用。 */
|
|
1311
|
+
pushSlashCommands() {
|
|
1312
|
+
return this.slash.push();
|
|
2392
1313
|
}
|
|
2393
|
-
/**
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
const emitError = (error) => this.emit({ type: "fetch_models_result", reqId, ok: false, error });
|
|
2399
|
-
const base = (baseUrl ?? "").trim().replace(/\/+$/, "");
|
|
2400
|
-
if (!base)
|
|
2401
|
-
return emitError("请先填写 baseUrl");
|
|
2402
|
-
let url;
|
|
2403
|
-
try {
|
|
2404
|
-
url = new URL(base);
|
|
2405
|
-
}
|
|
2406
|
-
catch {
|
|
2407
|
-
return emitError(`baseUrl 无效:${base}`);
|
|
2408
|
-
}
|
|
2409
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
2410
|
-
return emitError("baseUrl 仅支持 http/https");
|
|
2411
|
-
}
|
|
2412
|
-
const headers = {};
|
|
2413
|
-
// Per-api auth conventions (mirror pi's built-in provider configs):
|
|
2414
|
-
// openai-*: Authorization: Bearer <key>
|
|
2415
|
-
// anthropic: x-api-key + anthropic-version
|
|
2416
|
-
// google: x-goog-api-key
|
|
2417
|
-
// authHeader=false → no auth header at all (custom gateways).
|
|
2418
|
-
if (apiKey?.trim() && authHeader !== false) {
|
|
2419
|
-
const key = apiKey.trim();
|
|
2420
|
-
if (api === "anthropic-messages") {
|
|
2421
|
-
headers["x-api-key"] = key;
|
|
2422
|
-
headers["anthropic-version"] = "2023-06-01";
|
|
2423
|
-
}
|
|
2424
|
-
else if (api === "google-generative-ai") {
|
|
2425
|
-
headers["x-goog-api-key"] = key;
|
|
2426
|
-
}
|
|
2427
|
-
else {
|
|
2428
|
-
headers["Authorization"] = `Bearer ${key}`;
|
|
2429
|
-
}
|
|
2430
|
-
}
|
|
2431
|
-
const tryFetch = async (u) => {
|
|
2432
|
-
const ac = new AbortController();
|
|
2433
|
-
const timer = setTimeout(() => ac.abort(), 15000);
|
|
2434
|
-
try {
|
|
2435
|
-
return await fetch(u, { headers, signal: ac.signal });
|
|
2436
|
-
}
|
|
2437
|
-
catch (err) {
|
|
2438
|
-
if (err.name === "AbortError") {
|
|
2439
|
-
emitError("请求超时(15 秒)");
|
|
2440
|
-
}
|
|
2441
|
-
else {
|
|
2442
|
-
emitError(`请求失败:${err.message}`);
|
|
2443
|
-
}
|
|
2444
|
-
return null;
|
|
2445
|
-
}
|
|
2446
|
-
finally {
|
|
2447
|
-
clearTimeout(timer);
|
|
2448
|
-
}
|
|
2449
|
-
};
|
|
2450
|
-
let res = await tryFetch(`${base}/models`);
|
|
2451
|
-
// BaseUrls that omit the /v1 prefix (e.g. https://api.openai.com) 404 on
|
|
2452
|
-
// the bare path — retry under /v1.
|
|
2453
|
-
if (res && res.status === 404 && !/\/v\d+[a-z-]*$/.test(base)) {
|
|
2454
|
-
res = await tryFetch(`${base}/v1/models`);
|
|
2455
|
-
}
|
|
2456
|
-
if (!res)
|
|
2457
|
-
return;
|
|
2458
|
-
if (!res.ok) {
|
|
2459
|
-
let detail = "";
|
|
2460
|
-
try {
|
|
2461
|
-
detail = (await res.text()).slice(0, 200);
|
|
2462
|
-
}
|
|
2463
|
-
catch {
|
|
2464
|
-
// response body already consumed / not text — ignore
|
|
2465
|
-
}
|
|
2466
|
-
return emitError(`接口返回 HTTP ${res.status}${detail ? `:${detail}` : ""}`);
|
|
2467
|
-
}
|
|
2468
|
-
let models = [];
|
|
2469
|
-
try {
|
|
2470
|
-
const json = (await res.json());
|
|
2471
|
-
const data = Array.isArray(json.data) ? json.data : null;
|
|
2472
|
-
if (data) {
|
|
2473
|
-
// OpenAI-compatible: { data: [{ id, context_window, modalities, … }] }
|
|
2474
|
-
models = data
|
|
2475
|
-
.map((m) => ClientSession.parseOpenAiModel(m))
|
|
2476
|
-
.filter((m) => m.id);
|
|
2477
|
-
}
|
|
2478
|
-
else if (Array.isArray(json.models)) {
|
|
2479
|
-
// Google: { models: [{ name: "models/…", displayName, … }] }
|
|
2480
|
-
models = json.models
|
|
2481
|
-
.map((m) => ClientSession.parseGoogleModel(m))
|
|
2482
|
-
.filter((m) => m.id);
|
|
2483
|
-
}
|
|
2484
|
-
}
|
|
2485
|
-
catch {
|
|
2486
|
-
return emitError("响应不是有效的 JSON");
|
|
2487
|
-
}
|
|
2488
|
-
// Dedupe by id (keep the first, most complete entry) and sort by id.
|
|
2489
|
-
const seen = new Set();
|
|
2490
|
-
models = models
|
|
2491
|
-
.filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true)))
|
|
2492
|
-
.sort((a, b) => a.id.localeCompare(b.id));
|
|
2493
|
-
if (models.length === 0)
|
|
2494
|
-
return emitError("接口未返回任何模型");
|
|
2495
|
-
this.emit({ type: "fetch_models_result", reqId, ok: true, models });
|
|
1314
|
+
/** 模型/服务商配置管理 —— 自包含模块,见 model-admin.ts。 */
|
|
1315
|
+
modelAdmin;
|
|
1316
|
+
/** Persist an api-key credential for a provider (auth.json). */
|
|
1317
|
+
setProviderApiKey(provider, apiKey) {
|
|
1318
|
+
return this.modelAdmin.setProviderApiKey(provider, apiKey);
|
|
2496
1319
|
}
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
const pid = providerId.trim();
|
|
2500
|
-
if (!pid || !/^[\w.-]+$/.test(pid)) {
|
|
2501
|
-
this.emit({
|
|
2502
|
-
type: "notice",
|
|
2503
|
-
level: "error",
|
|
2504
|
-
text: "服务商 ID 无效(仅字母/数字/._-)",
|
|
2505
|
-
});
|
|
2506
|
-
return;
|
|
2507
|
-
}
|
|
2508
|
-
const models = (config.models ?? [])
|
|
2509
|
-
.filter((m) => m.id && m.id.trim())
|
|
2510
|
-
.map((m) => ({
|
|
2511
|
-
id: m.id.trim(),
|
|
2512
|
-
...(m.name?.trim() ? { name: m.name.trim() } : {}),
|
|
2513
|
-
...(m.reasoning ? { reasoning: true } : {}),
|
|
2514
|
-
...(m.input?.length ? { input: m.input } : {}),
|
|
2515
|
-
...(m.contextWindow ? { contextWindow: Number(m.contextWindow) } : {}),
|
|
2516
|
-
...(m.maxTokens ? { maxTokens: Number(m.maxTokens) } : {}),
|
|
2517
|
-
}));
|
|
2518
|
-
if (models.length === 0) {
|
|
2519
|
-
this.emit({ type: "notice", level: "error", text: "至少需要一个模型" });
|
|
2520
|
-
return;
|
|
2521
|
-
}
|
|
2522
|
-
try {
|
|
2523
|
-
const { providers } = this.readModelsConfig();
|
|
2524
|
-
// headers never reach the browser, so the incoming config can't carry
|
|
2525
|
-
// them — preserve the previously stored values when they are absent.
|
|
2526
|
-
const prevHeaders = providers[pid]?.headers;
|
|
2527
|
-
providers[pid] = {
|
|
2528
|
-
...(config.name?.trim() ? { name: config.name.trim() } : {}),
|
|
2529
|
-
...(config.api?.trim() ? { api: config.api.trim() } : {}),
|
|
2530
|
-
...(config.baseUrl?.trim() ? { baseUrl: config.baseUrl.trim() } : {}),
|
|
2531
|
-
...(config.apiKey?.trim() ? { apiKey: config.apiKey.trim() } : {}),
|
|
2532
|
-
...(config.authHeader ? { authHeader: true } : {}),
|
|
2533
|
-
...(prevHeaders && Object.keys(prevHeaders).length > 0
|
|
2534
|
-
? { headers: prevHeaders }
|
|
2535
|
-
: {}),
|
|
2536
|
-
models,
|
|
2537
|
-
};
|
|
2538
|
-
mkdirSync(this.agentDir, { recursive: true });
|
|
2539
|
-
writeFileSync(this.modelsConfigPath(), JSON.stringify({ providers }, null, 2) + "\n");
|
|
2540
|
-
// Allow a custom models.json entry to reuse the provider credential
|
|
2541
|
-
// already stored in auth.json. Seed the shared runtime too, because
|
|
2542
|
-
// older pi-ai versions did not always fall back to stored credentials
|
|
2543
|
-
// for a newly-created custom provider. Never copy the secret into
|
|
2544
|
-
// models.json.
|
|
2545
|
-
try {
|
|
2546
|
-
const auth = JSON.parse(readFileSync(join(this.agentDir, "auth.json"), "utf8"));
|
|
2547
|
-
const credential = auth[pid];
|
|
2548
|
-
if (credential &&
|
|
2549
|
-
typeof credential === "object" &&
|
|
2550
|
-
"key" in credential &&
|
|
2551
|
-
typeof credential.key === "string" &&
|
|
2552
|
-
credential.key.trim()) {
|
|
2553
|
-
await this.runtime.services.modelRuntime.setRuntimeApiKey(pid, credential.key);
|
|
2554
|
-
}
|
|
2555
|
-
}
|
|
2556
|
-
catch {
|
|
2557
|
-
// auth.json is optional; models.json can still use its own apiKey.
|
|
2558
|
-
}
|
|
2559
|
-
await this.runtime.services.modelRuntime.refresh();
|
|
2560
|
-
await this.listModelsConfig();
|
|
2561
|
-
await this.listModels();
|
|
2562
|
-
this.emit({
|
|
2563
|
-
type: "notice",
|
|
2564
|
-
level: "info",
|
|
2565
|
-
text: `✅ 已保存服务商 ${pid}(${models.length} 个模型)并刷新模型列表`,
|
|
2566
|
-
});
|
|
2567
|
-
}
|
|
2568
|
-
catch (err) {
|
|
2569
|
-
this.emit({
|
|
2570
|
-
type: "notice",
|
|
2571
|
-
level: "error",
|
|
2572
|
-
text: `保存模型配置失败:${err.message}`,
|
|
2573
|
-
});
|
|
2574
|
-
}
|
|
2575
|
-
this.flushSnapshot();
|
|
1320
|
+
clearProviderApiKey(provider) {
|
|
1321
|
+
return this.modelAdmin.clearProviderApiKey(provider);
|
|
2576
1322
|
}
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
try {
|
|
2580
|
-
const { providers } = this.readModelsConfig();
|
|
2581
|
-
if (!(providerId in providers)) {
|
|
2582
|
-
this.emit({
|
|
2583
|
-
type: "notice",
|
|
2584
|
-
level: "info",
|
|
2585
|
-
text: `服务商 ${providerId} 不存在`,
|
|
2586
|
-
});
|
|
2587
|
-
return;
|
|
2588
|
-
}
|
|
2589
|
-
delete providers[providerId];
|
|
2590
|
-
writeFileSync(this.modelsConfigPath(), JSON.stringify({ providers }, null, 2) + "\n");
|
|
2591
|
-
await this.runtime.services.modelRuntime.refresh();
|
|
2592
|
-
await this.listModelsConfig();
|
|
2593
|
-
await this.listModels();
|
|
2594
|
-
this.emit({
|
|
2595
|
-
type: "notice",
|
|
2596
|
-
level: "info",
|
|
2597
|
-
text: `🗑 已删除服务商 ${providerId}`,
|
|
2598
|
-
});
|
|
2599
|
-
}
|
|
2600
|
-
catch (err) {
|
|
2601
|
-
this.emit({
|
|
2602
|
-
type: "notice",
|
|
2603
|
-
level: "error",
|
|
2604
|
-
text: `删除模型配置失败:${err.message}`,
|
|
2605
|
-
});
|
|
2606
|
-
}
|
|
2607
|
-
this.flushSnapshot();
|
|
1323
|
+
listProviders() {
|
|
1324
|
+
return this.modelAdmin.listProviders();
|
|
2608
1325
|
}
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
if (this.snapshotTimer) {
|
|
2612
|
-
clearTimeout(this.snapshotTimer);
|
|
2613
|
-
this.snapshotTimer = null;
|
|
2614
|
-
}
|
|
2615
|
-
if (!this.disposed)
|
|
2616
|
-
this.emit({ type: "snapshot", state: this.snapshot() });
|
|
1326
|
+
listModelsConfig() {
|
|
1327
|
+
return this.modelAdmin.listModelsConfig();
|
|
2617
1328
|
}
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
return;
|
|
2621
|
-
this.snapshotTimer = setTimeout(() => {
|
|
2622
|
-
this.snapshotTimer = null;
|
|
2623
|
-
if (!this.disposed)
|
|
2624
|
-
this.emit({ type: "snapshot", state: this.snapshot() });
|
|
2625
|
-
}, SNAPSHOT_INTERVAL_MS);
|
|
1329
|
+
fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
|
|
1330
|
+
return this.modelAdmin.fetchModelsList(reqId, baseUrl, apiKey, authHeader, api);
|
|
2626
1331
|
}
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
// ---------------------------------------------------------------------------
|
|
2630
|
-
/**
|
|
2631
|
-
* Slash commands implemented natively by the web server (the pi CLI's built-in
|
|
2632
|
-
* interactive commands like /model and /new are NOT handled by the SDK's
|
|
2633
|
-
* prompt() — without this they'd be sent to the model as plain text). Keep in
|
|
2634
|
-
* sync with execNativeCommand(). /help and /copy are client-side UI actions
|
|
2635
|
-
* (they never reach the server) but stay listed so the picker shows them.
|
|
2636
|
-
*/
|
|
2637
|
-
static NATIVE_COMMANDS = [
|
|
2638
|
-
{ name: "new", description: "新建对话", descriptionEn: "New chat" },
|
|
2639
|
-
{ name: "model", description: "切换模型", descriptionEn: "Switch model", argumentHint: "[名称]", argumentHintEn: "[name]" },
|
|
2640
|
-
{ name: "compact", description: "压缩上下文", descriptionEn: "Compact context", argumentHint: "[说明]", argumentHintEn: "[instructions]" },
|
|
2641
|
-
{ name: "cwd", description: "切换工作目录", descriptionEn: "Switch workspace", argumentHint: "<路径>", argumentHintEn: "<path>" },
|
|
2642
|
-
{
|
|
2643
|
-
name: "thinking",
|
|
2644
|
-
description: "设置思考强度",
|
|
2645
|
-
descriptionEn: "Set thinking level",
|
|
2646
|
-
argumentHint: "<off|low|medium|high|xhigh|max>",
|
|
2647
|
-
argumentHintEn: "<off|low|medium|high|xhigh|max>",
|
|
2648
|
-
},
|
|
2649
|
-
{ name: "resume", description: "刷新会话列表", descriptionEn: "Refresh session list" },
|
|
2650
|
-
{ name: "reload", description: "重新加载扩展、技能与模板", descriptionEn: "Reload extensions, skills & templates" },
|
|
2651
|
-
{ name: "help", description: "显示全部命令", descriptionEn: "Show all commands" },
|
|
2652
|
-
{ name: "copy", description: "复制上一条助手回复", descriptionEn: "Copy last assistant reply" },
|
|
2653
|
-
{ name: "pi-web-ui:quit", description: "退出服务", descriptionEn: "Quit server (supervisor will restart)" },
|
|
2654
|
-
];
|
|
2655
|
-
/** Parse a prompt into "/command args" — returns null when it isn't one. */
|
|
2656
|
-
parseSlash(text) {
|
|
2657
|
-
const trimmed = text.trim();
|
|
2658
|
-
if (!trimmed.startsWith("/"))
|
|
2659
|
-
return null;
|
|
2660
|
-
const m = trimmed.match(/^\/([^\s]+)\s*([\s\S]*)$/);
|
|
2661
|
-
if (!m || !m[1])
|
|
2662
|
-
return null;
|
|
2663
|
-
return { name: m[1], args: m[2].trim() };
|
|
1332
|
+
refreshProviderModels(providerId, reqId) {
|
|
1333
|
+
return this.modelAdmin.refreshProviderModels(providerId, reqId);
|
|
2664
1334
|
}
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
async execNativeCommand(name, args) {
|
|
2668
|
-
switch (name) {
|
|
2669
|
-
case "new":
|
|
2670
|
-
await this.newChat();
|
|
2671
|
-
return true;
|
|
2672
|
-
case "model": {
|
|
2673
|
-
if (!args) {
|
|
2674
|
-
const current = this.session.model;
|
|
2675
|
-
this.emit({
|
|
2676
|
-
type: "notice",
|
|
2677
|
-
level: "info",
|
|
2678
|
-
text: current
|
|
2679
|
-
? `当前模型:${current.name}(${current.provider}/${current.id})。用法:/model <名称>`
|
|
2680
|
-
: `用法:/model <名称>`,
|
|
2681
|
-
});
|
|
2682
|
-
return true;
|
|
2683
|
-
}
|
|
2684
|
-
const query = args.toLowerCase();
|
|
2685
|
-
const available = await this.session.modelRuntime.getAvailable();
|
|
2686
|
-
// Prefer an exact "provider/id" match, else id/name substring.
|
|
2687
|
-
const exact = available.find((m) => m.provider + "/" + m.id === args.trim());
|
|
2688
|
-
const matches = exact
|
|
2689
|
-
? [exact]
|
|
2690
|
-
: available.filter((m) => m.id.toLowerCase().includes(query) ||
|
|
2691
|
-
m.name.toLowerCase().includes(query) ||
|
|
2692
|
-
m.provider.toLowerCase().includes(query));
|
|
2693
|
-
if (matches.length === 0) {
|
|
2694
|
-
this.emit({
|
|
2695
|
-
type: "notice",
|
|
2696
|
-
level: "error",
|
|
2697
|
-
text: `没有匹配到模型:${args}(可用模型见顶栏模型列表)`,
|
|
2698
|
-
});
|
|
2699
|
-
return true;
|
|
2700
|
-
}
|
|
2701
|
-
const pick = matches[0];
|
|
2702
|
-
if (matches.length > 1) {
|
|
2703
|
-
this.emit({
|
|
2704
|
-
type: "notice",
|
|
2705
|
-
level: "warning",
|
|
2706
|
-
text: `找到 ${matches.length} 个匹配模型,已选用:${pick.name}(精确匹配请用 provider/id)`,
|
|
2707
|
-
});
|
|
2708
|
-
}
|
|
2709
|
-
await this.setModel(`${pick.provider}/${pick.id}`);
|
|
2710
|
-
return true;
|
|
2711
|
-
}
|
|
2712
|
-
case "compact":
|
|
2713
|
-
try {
|
|
2714
|
-
await this.session.compact(args || undefined);
|
|
2715
|
-
}
|
|
2716
|
-
catch (err) {
|
|
2717
|
-
this.emit({
|
|
2718
|
-
type: "notice",
|
|
2719
|
-
level: "error",
|
|
2720
|
-
text: `压缩上下文失败:${err.message}`,
|
|
2721
|
-
});
|
|
2722
|
-
}
|
|
2723
|
-
return true;
|
|
2724
|
-
case "cwd":
|
|
2725
|
-
if (!args) {
|
|
2726
|
-
this.emit({
|
|
2727
|
-
type: "notice",
|
|
2728
|
-
level: "info",
|
|
2729
|
-
text: `当前工作目录:${this.cwd}。用法:/cwd <路径>`,
|
|
2730
|
-
});
|
|
2731
|
-
}
|
|
2732
|
-
else {
|
|
2733
|
-
await this.setCwd(args);
|
|
2734
|
-
}
|
|
2735
|
-
return true;
|
|
2736
|
-
case "thinking": {
|
|
2737
|
-
const ALIAS = {
|
|
2738
|
-
off: "off",
|
|
2739
|
-
minimal: "minimal",
|
|
2740
|
-
low: "low",
|
|
2741
|
-
medium: "medium",
|
|
2742
|
-
high: "high",
|
|
2743
|
-
xhigh: "xhigh",
|
|
2744
|
-
max: "max",
|
|
2745
|
-
关闭: "off",
|
|
2746
|
-
极简: "minimal",
|
|
2747
|
-
低: "low",
|
|
2748
|
-
中: "medium",
|
|
2749
|
-
高: "high",
|
|
2750
|
-
极高: "xhigh",
|
|
2751
|
-
最大: "max",
|
|
2752
|
-
};
|
|
2753
|
-
const level = ALIAS[args.trim().toLowerCase()];
|
|
2754
|
-
if (!level) {
|
|
2755
|
-
this.emit({
|
|
2756
|
-
type: "notice",
|
|
2757
|
-
level: "error",
|
|
2758
|
-
text: `无效的思考强度:${args || "(空)"}。可用:off / minimal / low / medium / high / xhigh / max`,
|
|
2759
|
-
});
|
|
2760
|
-
return true;
|
|
2761
|
-
}
|
|
2762
|
-
this.setThinking(level);
|
|
2763
|
-
return true;
|
|
2764
|
-
}
|
|
2765
|
-
case "resume":
|
|
2766
|
-
await this.refreshSessions();
|
|
2767
|
-
this.emit({
|
|
2768
|
-
type: "notice",
|
|
2769
|
-
level: "info",
|
|
2770
|
-
text: "会话列表已刷新,请在左侧「历史对话」中选择",
|
|
2771
|
-
});
|
|
2772
|
-
return true;
|
|
2773
|
-
case "reload":
|
|
2774
|
-
try {
|
|
2775
|
-
// Re-discovers extensions / skills / prompt templates from disk and
|
|
2776
|
-
// re-pushes the picker catalog (the CLI's /reload semantics).
|
|
2777
|
-
await this.session.reload();
|
|
2778
|
-
await this.pushSlashCommands();
|
|
2779
|
-
this.emit({
|
|
2780
|
-
type: "notice",
|
|
2781
|
-
level: "info",
|
|
2782
|
-
text: "已重新加载扩展、技能与提示模板",
|
|
2783
|
-
});
|
|
2784
|
-
}
|
|
2785
|
-
catch (err) {
|
|
2786
|
-
this.emit({
|
|
2787
|
-
type: "notice",
|
|
2788
|
-
level: "error",
|
|
2789
|
-
text: `重新加载失败:${err.message}`,
|
|
2790
|
-
});
|
|
2791
|
-
}
|
|
2792
|
-
return true;
|
|
2793
|
-
case "pi-web-ui:quit": {
|
|
2794
|
-
this.emit({
|
|
2795
|
-
type: "notice",
|
|
2796
|
-
level: "info",
|
|
2797
|
-
text: "正在退出 pi-web-ui… supervisor 将自动重启服务",
|
|
2798
|
-
});
|
|
2799
|
-
setTimeout(() => {
|
|
2800
|
-
const didSchedule = this.onQuit?.() ?? false;
|
|
2801
|
-
if (!didSchedule) {
|
|
2802
|
-
setTimeout(() => process.exit(0), 100);
|
|
2803
|
-
}
|
|
2804
|
-
}, 300);
|
|
2805
|
-
return true;
|
|
2806
|
-
}
|
|
2807
|
-
case "help":
|
|
2808
|
-
case "copy":
|
|
2809
|
-
// Client-side UI actions — the client handles them before sending;
|
|
2810
|
-
// swallow here so the SDK never sees them as plain prompt text.
|
|
2811
|
-
return true;
|
|
2812
|
-
default:
|
|
2813
|
-
return false;
|
|
2814
|
-
}
|
|
1335
|
+
saveModelConfig(providerId, config) {
|
|
1336
|
+
return this.modelAdmin.saveModelConfig(providerId, config);
|
|
2815
1337
|
}
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
* then the SDK's invokable commands for the ACTIVE conversation (extension
|
|
2819
|
-
* commands, prompt templates, skills) — the same set the SDK expands when a
|
|
2820
|
-
* prompt text starts with "/" (see AgentSession.prompt).
|
|
2821
|
-
*/
|
|
2822
|
-
async pushSlashCommands() {
|
|
2823
|
-
const commands = [];
|
|
2824
|
-
const seen = new Set();
|
|
2825
|
-
for (const c of ClientSession.NATIVE_COMMANDS) {
|
|
2826
|
-
commands.push({ ...c, source: "builtin" });
|
|
2827
|
-
seen.add(c.name);
|
|
2828
|
-
}
|
|
2829
|
-
try {
|
|
2830
|
-
const s = this.session;
|
|
2831
|
-
// Extension commands — the SDK already suffixes collisions with builtin
|
|
2832
|
-
// names ("new:2"), and those still reach the SDK since execNativeCommand
|
|
2833
|
-
// only intercepts the exact native names.
|
|
2834
|
-
for (const cmd of s.extensionRunner.getRegisteredCommands()) {
|
|
2835
|
-
if (seen.has(cmd.invocationName))
|
|
2836
|
-
continue;
|
|
2837
|
-
commands.push({
|
|
2838
|
-
name: cmd.invocationName,
|
|
2839
|
-
description: cmd.description,
|
|
2840
|
-
source: "extension",
|
|
2841
|
-
});
|
|
2842
|
-
seen.add(cmd.invocationName);
|
|
2843
|
-
}
|
|
2844
|
-
// Prompt templates: /templatename args
|
|
2845
|
-
for (const t of s.promptTemplates) {
|
|
2846
|
-
if (seen.has(t.name))
|
|
2847
|
-
continue;
|
|
2848
|
-
commands.push({
|
|
2849
|
-
name: t.name,
|
|
2850
|
-
description: t.description,
|
|
2851
|
-
source: "prompt",
|
|
2852
|
-
});
|
|
2853
|
-
seen.add(t.name);
|
|
2854
|
-
}
|
|
2855
|
-
// Skills: /skill:name args
|
|
2856
|
-
for (const skill of s.resourceLoader.getSkills().skills) {
|
|
2857
|
-
const name = `skill:${skill.name}`;
|
|
2858
|
-
if (seen.has(name))
|
|
2859
|
-
continue;
|
|
2860
|
-
commands.push({
|
|
2861
|
-
name,
|
|
2862
|
-
description: skill.description,
|
|
2863
|
-
source: "skill",
|
|
2864
|
-
});
|
|
2865
|
-
}
|
|
2866
|
-
}
|
|
2867
|
-
catch {
|
|
2868
|
-
// Session not ready yet — native-only catalog still serves the picker.
|
|
2869
|
-
}
|
|
2870
|
-
this.emit({ type: "slash_commands", commands });
|
|
1338
|
+
deleteModelConfig(providerId) {
|
|
1339
|
+
return this.modelAdmin.deleteModelConfig(providerId);
|
|
2871
1340
|
}
|
|
2872
1341
|
// ---------------------------------------------------------------------------
|
|
2873
1342
|
// Settings (system prompt / skills / extensions / presets)
|
|
@@ -2876,231 +1345,31 @@ export class ClientSession {
|
|
|
2876
1345
|
* with enabled flags + saved presets). Pushed on attach and after every
|
|
2877
1346
|
* settings change. */
|
|
2878
1347
|
pushSettings() {
|
|
2879
|
-
|
|
2880
|
-
const reviewDisabledSkills = new Set(this.settings.reviewDisabledSkills);
|
|
2881
|
-
const disabledExts = new Set(this.settings.disabledExtensions);
|
|
2882
|
-
try {
|
|
2883
|
-
// Refresh the cache with the CURRENTLY loaded set (post-filter).
|
|
2884
|
-
for (const s of this.session.resourceLoader.getSkills().skills) {
|
|
2885
|
-
this.knownSkills.set(s.name, {
|
|
2886
|
-
name: s.name,
|
|
2887
|
-
description: s.description,
|
|
2888
|
-
enabled: true,
|
|
2889
|
-
});
|
|
2890
|
-
}
|
|
2891
|
-
for (const e of this.session.resourceLoader.getExtensions().extensions) {
|
|
2892
|
-
const id = extensionKey(e);
|
|
2893
|
-
const p = e.sourceInfo?.path ?? e.path;
|
|
2894
|
-
this.knownExtensions.set(id, {
|
|
2895
|
-
id,
|
|
2896
|
-
name: e.sourceInfo?.origin === "package" && e.sourceInfo.source
|
|
2897
|
-
? e.sourceInfo.source
|
|
2898
|
-
: basename(p),
|
|
2899
|
-
path: p,
|
|
2900
|
-
enabled: true,
|
|
2901
|
-
});
|
|
2902
|
-
}
|
|
2903
|
-
}
|
|
2904
|
-
catch {
|
|
2905
|
-
// Session not ready yet — keep whatever we already know.
|
|
2906
|
-
}
|
|
2907
|
-
// Disabled entries are filtered out of the loader — keep them in the
|
|
2908
|
-
// panel (with the last-known description) so they can be re-enabled.
|
|
2909
|
-
for (const name of this.settings.disabledSkills) {
|
|
2910
|
-
if (!this.knownSkills.has(name)) {
|
|
2911
|
-
this.knownSkills.set(name, { name, description: "", enabled: false });
|
|
2912
|
-
}
|
|
2913
|
-
}
|
|
2914
|
-
for (const id of this.settings.disabledExtensions) {
|
|
2915
|
-
if (!this.knownExtensions.has(id)) {
|
|
2916
|
-
this.knownExtensions.set(id, {
|
|
2917
|
-
id,
|
|
2918
|
-
name: id.startsWith("npm:") ? id : basename(id),
|
|
2919
|
-
path: "",
|
|
2920
|
-
enabled: false,
|
|
2921
|
-
});
|
|
2922
|
-
}
|
|
2923
|
-
}
|
|
2924
|
-
const skills = [...this.knownSkills.values()]
|
|
2925
|
-
.map((s) => ({ ...s, enabled: !disabledSkills.has(s.name) }))
|
|
2926
|
-
.sort((a, b) => a.name.localeCompare(b.name));
|
|
2927
|
-
const reviewSkills = [...this.knownSkills.values()]
|
|
2928
|
-
.map((s) => ({ ...s, enabled: !reviewDisabledSkills.has(s.name) }))
|
|
2929
|
-
.sort((a, b) => a.name.localeCompare(b.name));
|
|
2930
|
-
const extensions = [...this.knownExtensions.values()]
|
|
2931
|
-
.map((e) => ({ ...e, enabled: !disabledExts.has(e.id) }))
|
|
2932
|
-
.sort((a, b) => a.name.localeCompare(b.name));
|
|
2933
|
-
this.emit({
|
|
2934
|
-
type: "settings_state",
|
|
2935
|
-
settings: {
|
|
2936
|
-
promptMode: this.settings.promptMode,
|
|
2937
|
-
customSystemPrompt: this.settings.customSystemPrompt,
|
|
2938
|
-
visionBridgeEnabled: this.settings.visionBridgeEnabled,
|
|
2939
|
-
visionBridgeModel: this.settings.visionBridgeModel,
|
|
2940
|
-
visionBridgePromptMode: this.settings.visionBridgePromptMode,
|
|
2941
|
-
visionBridgePrompt: this.settings.visionBridgePrompt,
|
|
2942
|
-
reviewPrompt: this.settings.reviewPrompt,
|
|
2943
|
-
reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
|
|
2944
|
-
// The built-in prompts, so the replace-mode editors can prefill the
|
|
2945
|
-
// text they would otherwise replace (empty until the resource-loader
|
|
2946
|
-
// has run once for the system prompt).
|
|
2947
|
-
defaultSystemPrompt: this.effectiveDefaultSystemPrompt(),
|
|
2948
|
-
visionBridgeDefaultPrompt: SYSTEM_PROMPT,
|
|
2949
|
-
visionModels: this.collectVisionModels(),
|
|
2950
|
-
disabledSkills: [...this.settings.disabledSkills],
|
|
2951
|
-
disabledExtensions: [...this.settings.disabledExtensions],
|
|
2952
|
-
skills,
|
|
2953
|
-
reviewSkills,
|
|
2954
|
-
extensions,
|
|
2955
|
-
presets: this.presets.map((p) => ({ ...p })),
|
|
2956
|
-
},
|
|
2957
|
-
});
|
|
2958
|
-
}
|
|
2959
|
-
/** Vision-capable configured models, for the settings-panel picker. */
|
|
2960
|
-
collectVisionModels() {
|
|
2961
|
-
try {
|
|
2962
|
-
return findVisionModels(this.session.modelRuntime).map((m) => ({
|
|
2963
|
-
provider: m.provider,
|
|
2964
|
-
id: m.id,
|
|
2965
|
-
label: m.label,
|
|
2966
|
-
}));
|
|
2967
|
-
}
|
|
2968
|
-
catch {
|
|
2969
|
-
// Session not ready yet — the picker stays empty until next push.
|
|
2970
|
-
return [];
|
|
2971
|
-
}
|
|
1348
|
+
this.settingsSvc.push();
|
|
2972
1349
|
}
|
|
2973
1350
|
/** Persist + apply a partial settings update (prompt text/mode, toggles). */
|
|
2974
1351
|
async setSettings(partial) {
|
|
2975
|
-
|
|
2976
|
-
partial.customSystemPrompt !== undefined ||
|
|
2977
|
-
partial.disabledSkills !== undefined ||
|
|
2978
|
-
partial.disabledExtensions !== undefined;
|
|
2979
|
-
if (partial.promptMode !== undefined)
|
|
2980
|
-
this.settings.promptMode = partial.promptMode;
|
|
2981
|
-
if (partial.customSystemPrompt !== undefined) {
|
|
2982
|
-
this.settings.customSystemPrompt = partial.customSystemPrompt;
|
|
2983
|
-
}
|
|
2984
|
-
if (partial.disabledSkills !== undefined) {
|
|
2985
|
-
this.settings.disabledSkills = partial.disabledSkills;
|
|
2986
|
-
}
|
|
2987
|
-
if (partial.disabledExtensions !== undefined) {
|
|
2988
|
-
this.settings.disabledExtensions = partial.disabledExtensions;
|
|
2989
|
-
}
|
|
2990
|
-
if (partial.visionBridgeEnabled !== undefined) {
|
|
2991
|
-
this.settings.visionBridgeEnabled = partial.visionBridgeEnabled;
|
|
2992
|
-
}
|
|
2993
|
-
if (partial.visionBridgeModel !== undefined) {
|
|
2994
|
-
this.settings.visionBridgeModel = partial.visionBridgeModel ?? null;
|
|
2995
|
-
}
|
|
2996
|
-
if (partial.visionBridgePromptMode !== undefined) {
|
|
2997
|
-
this.settings.visionBridgePromptMode = partial.visionBridgePromptMode;
|
|
2998
|
-
}
|
|
2999
|
-
if (partial.visionBridgePrompt !== undefined) {
|
|
3000
|
-
this.settings.visionBridgePrompt = partial.visionBridgePrompt;
|
|
3001
|
-
}
|
|
3002
|
-
if (partial.reviewPrompt !== undefined) {
|
|
3003
|
-
this.settings.reviewPrompt = partial.reviewPrompt;
|
|
3004
|
-
}
|
|
3005
|
-
if (partial.reviewDisabledSkills !== undefined) {
|
|
3006
|
-
this.settings.reviewDisabledSkills = partial.reviewDisabledSkills;
|
|
3007
|
-
}
|
|
3008
|
-
this.stateStore.saveSettings(this.clientId, this.settings);
|
|
3009
|
-
this.pushSettings();
|
|
3010
|
-
if (needsReload)
|
|
3011
|
-
await this.applyRuntimeSettings();
|
|
1352
|
+
await this.settingsSvc.set(partial);
|
|
3012
1353
|
}
|
|
3013
1354
|
/** Save the CURRENT settings as a named preset (overwrites if exists). */
|
|
3014
1355
|
async savePreset(name) {
|
|
3015
|
-
|
|
3016
|
-
if (!n) {
|
|
3017
|
-
this.emit({ type: "notice", level: "error", text: "预设名称不能为空" });
|
|
3018
|
-
return;
|
|
3019
|
-
}
|
|
3020
|
-
const preset = {
|
|
3021
|
-
name: n,
|
|
3022
|
-
promptMode: this.settings.promptMode,
|
|
3023
|
-
customSystemPrompt: this.settings.customSystemPrompt,
|
|
3024
|
-
disabledSkills: [...this.settings.disabledSkills],
|
|
3025
|
-
disabledExtensions: [...this.settings.disabledExtensions],
|
|
3026
|
-
reviewPrompt: this.settings.reviewPrompt,
|
|
3027
|
-
reviewDisabledSkills: [...this.settings.reviewDisabledSkills],
|
|
3028
|
-
};
|
|
3029
|
-
const existing = this.presets.findIndex((p) => p.name === n);
|
|
3030
|
-
if (existing >= 0)
|
|
3031
|
-
this.presets[existing] = preset;
|
|
3032
|
-
else
|
|
3033
|
-
this.presets.push(preset);
|
|
3034
|
-
this.stateStore.savePresets(this.clientId, this.presets);
|
|
3035
|
-
this.pushSettings();
|
|
1356
|
+
return this.settingsSvc.savePreset(name);
|
|
3036
1357
|
}
|
|
3037
1358
|
/** Replace the current settings with the named preset and apply it. */
|
|
3038
1359
|
async applyPreset(name) {
|
|
3039
|
-
|
|
3040
|
-
if (!p) {
|
|
3041
|
-
this.emit({ type: "notice", level: "error", text: `预设不存在:${name}` });
|
|
3042
|
-
return;
|
|
3043
|
-
}
|
|
3044
|
-
this.settings = {
|
|
3045
|
-
promptMode: p.promptMode,
|
|
3046
|
-
customSystemPrompt: p.customSystemPrompt,
|
|
3047
|
-
disabledSkills: [...p.disabledSkills],
|
|
3048
|
-
disabledExtensions: [...p.disabledExtensions],
|
|
3049
|
-
reviewPrompt: p.reviewPrompt ?? this.settings.reviewPrompt,
|
|
3050
|
-
reviewDisabledSkills: [
|
|
3051
|
-
...(p.reviewDisabledSkills ?? this.settings.reviewDisabledSkills),
|
|
3052
|
-
],
|
|
3053
|
-
// Presets don't capture vision-bridge prefs — keep the current ones.
|
|
3054
|
-
visionBridgeEnabled: this.settings.visionBridgeEnabled,
|
|
3055
|
-
visionBridgeModel: this.settings.visionBridgeModel,
|
|
3056
|
-
visionBridgePromptMode: this.settings.visionBridgePromptMode,
|
|
3057
|
-
visionBridgePrompt: this.settings.visionBridgePrompt,
|
|
3058
|
-
};
|
|
3059
|
-
this.stateStore.saveSettings(this.clientId, this.settings);
|
|
3060
|
-
this.pushSettings();
|
|
3061
|
-
await this.applyRuntimeSettings();
|
|
1360
|
+
return this.settingsSvc.applyPreset(name);
|
|
3062
1361
|
}
|
|
3063
1362
|
/** Remove a named preset. */
|
|
3064
1363
|
async deletePreset(name) {
|
|
3065
|
-
|
|
3066
|
-
this.stateStore.savePresets(this.clientId, this.presets);
|
|
3067
|
-
this.pushSettings();
|
|
1364
|
+
return this.settingsSvc.deletePreset(name);
|
|
3068
1365
|
}
|
|
3069
|
-
/**
|
|
3070
|
-
* Make settings changes effective in the running runtime. The resource-loader
|
|
3071
|
-
* overrides read this.settings at call time, so a reload re-applies them.
|
|
3072
|
-
* Reloading mid-stream would tear down the in-flight run — defer instead.
|
|
3073
|
-
*/
|
|
1366
|
+
/** Make settings effective in the running runtime(流式中则延迟到 agent_end)。 */
|
|
3074
1367
|
async applyRuntimeSettings() {
|
|
3075
|
-
|
|
3076
|
-
return;
|
|
3077
|
-
if (this.session.isStreaming) {
|
|
3078
|
-
this.pendingSettingsReload = true;
|
|
3079
|
-
this.emit({
|
|
3080
|
-
type: "notice",
|
|
3081
|
-
level: "info",
|
|
3082
|
-
text: "当前回复进行中,设置将在回复结束后自动应用",
|
|
3083
|
-
});
|
|
3084
|
-
return;
|
|
3085
|
-
}
|
|
3086
|
-
await this.applySettingsReload();
|
|
1368
|
+
return this.settingsSvc.applyRuntime();
|
|
3087
1369
|
}
|
|
3088
|
-
/** session.reload() + refresh the slash-command catalog + push state. */
|
|
3089
1370
|
async applySettingsReload() {
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
await this.pushSlashCommands();
|
|
3093
|
-
this.pushSettings();
|
|
3094
|
-
this.flushSnapshot();
|
|
3095
|
-
this.emit({ type: "notice", level: "info", text: "设置已应用" });
|
|
3096
|
-
}
|
|
3097
|
-
catch (err) {
|
|
3098
|
-
this.emit({
|
|
3099
|
-
type: "notice",
|
|
3100
|
-
level: "error",
|
|
3101
|
-
text: `设置应用失败:${err.message}`,
|
|
3102
|
-
});
|
|
3103
|
-
}
|
|
1371
|
+
// 兼容旧入口:reload + 刷目录在宿主回调里完成
|
|
1372
|
+
return this.settingsSvc.applyRuntime();
|
|
3104
1373
|
}
|
|
3105
1374
|
// ---------------------------------------------------------------------------
|
|
3106
1375
|
// Commands
|
|
@@ -3149,695 +1418,88 @@ export class ClientSession {
|
|
|
3149
1418
|
* after the WHOLE run finishes (补充 button — "AI 生成结束才发送").
|
|
3150
1419
|
* false/undefined = steer: the pi CLI Enter semantic — injected right
|
|
3151
1420
|
* after the current turn settles, skipping remaining planned tool calls.
|
|
3152
|
-
*/
|
|
3153
|
-
queue = false) {
|
|
3154
|
-
try {
|
|
3155
|
-
const s = this.session;
|
|
3156
|
-
// Native slash commands (see NATIVE_COMMANDS) are executed here and
|
|
3157
|
-
// never reach the SDK. Extension / skill / template commands fall
|
|
3158
|
-
// through — AgentSession.prompt() handles those itself.
|
|
3159
|
-
const slash =
|
|
3160
|
-
if (slash && (await this.
|
|
3161
|
-
this.flushSnapshot();
|
|
3162
|
-
return;
|
|
3163
|
-
}
|
|
3164
|
-
// Native commands above are pure config tweaks (no tokens) — allow them
|
|
3165
|
-
// even while quiesced. Everything that reaches the SDK is NEW work and
|
|
3166
|
-
// is refused until admission reopens.
|
|
3167
|
-
if (this.quiesceBlocked())
|
|
3168
|
-
return;
|
|
3169
|
-
// Attach files as independent nextTurn context messages (asides) so the
|
|
3170
|
-
// user message stays clean; they render as separate attachment cards.
|
|
3171
|
-
const asides = await this.buildAttachmentMessages(attachments);
|
|
3172
|
-
for (const aside of asides) {
|
|
3173
|
-
await s.sendCustomMessage(aside.message, { deliverAs: "nextTurn" });
|
|
3174
|
-
}
|
|
3175
|
-
if (s.isStreaming) {
|
|
3176
|
-
// queue=true (补充 button) → followUp: the message is delivered only
|
|
3177
|
-
// after the whole run finishes — the agent finishes what it started,
|
|
3178
|
-
// then responds to the queued message. queue=false/undefined
|
|
3179
|
-
// (plain Enter) → steer: interrupts the current run — the message
|
|
3180
|
-
// is delivered right after the current assistant turn settles
|
|
3181
|
-
// (remaining planned tool calls are skipped) and the agent
|
|
3182
|
-
// immediately responds to it. This is the pi CLI
|
|
3183
|
-
// Enter-during-streaming semantic (docs/usage: Enter queues a
|
|
3184
|
-
// steering message); followUp would wait for the whole run
|
|
3185
|
-
// to finish, which users perceive as ordinary queueing.
|
|
3186
|
-
await s.prompt(text, {
|
|
3187
|
-
streamingBehavior: queue ? "followUp" : "steer",
|
|
3188
|
-
});
|
|
3189
|
-
}
|
|
3190
|
-
else {
|
|
3191
|
-
await s.prompt(text);
|
|
3192
|
-
}
|
|
3193
|
-
}
|
|
3194
|
-
catch (err) {
|
|
3195
|
-
this.emit({
|
|
3196
|
-
type: "notice",
|
|
3197
|
-
level: "error",
|
|
3198
|
-
text: `提示发送失败:${err.message}`,
|
|
3199
|
-
});
|
|
3200
|
-
}
|
|
3201
|
-
// Name the conversation after its first user prompt.
|
|
3202
|
-
const conv = this.conv;
|
|
3203
|
-
if (conv.title === DEFAULT_CONV_TITLE && text.trim()) {
|
|
3204
|
-
const trimmed = text.trim().replace(/\s+/g, " ");
|
|
3205
|
-
conv.title = trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
|
|
3206
|
-
this.emitConversations();
|
|
3207
|
-
}
|
|
3208
|
-
// The active conversation has been continued since it was opened — it
|
|
3209
|
-
// must not be dismissed when the user switches away. (Also bumps the
|
|
3210
|
-
// per-project "most recently active" order used by set_cwd.)
|
|
3211
|
-
conv.promptedSinceActive = true;
|
|
3212
|
-
conv.lastActiveAt = Date.now();
|
|
3213
|
-
this.flushSnapshot();
|
|
3214
|
-
}
|
|
3215
|
-
/**
|
|
3216
|
-
* Turn attached files into custom-message payloads.
|
|
3217
|
-
*
|
|
3218
|
-
* Text files are size-aware: small files are inlined into the message so the
|
|
3219
|
-
* model sees them immediately; large files are passed as a <file path="...">
|
|
3220
|
-
* reference and the model reads them on demand with its read tool (which has
|
|
3221
|
-
* built-in truncation). Images are always passed as image content. Mode
|
|
3222
|
-
* "lines" inlines only a 1-based inclusive line range of the file. Raw
|
|
3223
|
-
* pasted/dropped/uploaded images (attachment.imageData) skip the workspace
|
|
3224
|
-
* path entirely and go straight to the model as image content. Raw uploaded
|
|
3225
|
-
* files (attachment.fileData) are persisted under <dataDir>/uploads/ and
|
|
3226
|
-
* attached as absolute-path references (small text ones are inlined).
|
|
3227
|
-
*/
|
|
3228
|
-
async buildAttachmentMessages(attachments) {
|
|
3229
|
-
if (!attachments || attachments.length === 0)
|
|
3230
|
-
return [];
|
|
3231
|
-
const fs = await import("node:fs/promises");
|
|
3232
|
-
const { resolve, sep, relative, extname, join } = await import("node:path");
|
|
3233
|
-
const root = resolve(this.cwd);
|
|
3234
|
-
const MAX_ATTACHMENT_BYTES = 200 * 1024;
|
|
3235
|
-
// Files at or below this size are inlined; larger files are referenced by
|
|
3236
|
-
// path only (the model reads them on demand — saves tokens for small edits).
|
|
3237
|
-
const MAX_INLINE_BYTES = Number(process.env.PI_WEB_INLINE_FILE_MAX ?? 12 * 1024);
|
|
3238
|
-
const IMAGE_EXT = new Set([
|
|
3239
|
-
".png",
|
|
3240
|
-
".jpg",
|
|
3241
|
-
".jpeg",
|
|
3242
|
-
".gif",
|
|
3243
|
-
".webp",
|
|
3244
|
-
".bmp",
|
|
3245
|
-
".svg",
|
|
3246
|
-
]);
|
|
3247
|
-
const MIME = {
|
|
3248
|
-
".png": "image/png",
|
|
3249
|
-
".jpg": "image/jpeg",
|
|
3250
|
-
".jpeg": "image/jpeg",
|
|
3251
|
-
".gif": "image/gif",
|
|
3252
|
-
".webp": "image/webp",
|
|
3253
|
-
".bmp": "image/bmp",
|
|
3254
|
-
".svg": "image/svg+xml",
|
|
3255
|
-
};
|
|
3256
|
-
const out = [];
|
|
3257
|
-
// -- Vision bridge ------------------------------------------------------
|
|
3258
|
-
// When the active model can't accept images (DeepSeek, GLM, …), pasted
|
|
3259
|
-
// images are transcribed by a configured vision model first and the
|
|
3260
|
-
// transcript is fed to the text-only model as text evidence (see
|
|
3261
|
-
// vision-bridge.ts — any model in models.json whose input includes
|
|
3262
|
-
// "image" works, zero extra config). Vision-capable main models keep
|
|
3263
|
-
// the raw image-content path untouched.
|
|
3264
|
-
const mainModel = this.session.model;
|
|
3265
|
-
const mainSupportsVision = mainModel?.input?.includes("image") ?? false;
|
|
3266
|
-
const bridgedImages = [];
|
|
3267
|
-
/** Raw image bytes for path-referenced image files (idx → info), pre-read
|
|
3268
|
-
* so the loop below doesn't re-read them. SVG stays a plain text file —
|
|
3269
|
-
* the model reads its source, far more useful than a rasterized blob. */
|
|
3270
|
-
const pathImageData = new Map();
|
|
3271
|
-
/** Cap for path images (fully read + base64'd); larger ones fall back to
|
|
3272
|
-
* a plain path reference (the model can still attempt to read them). */
|
|
3273
|
-
const MAX_PATH_IMAGE_BYTES = 5 * 1024 * 1024;
|
|
3274
|
-
for (const [idx, att] of attachments.entries()) {
|
|
3275
|
-
if (att.imageData) {
|
|
3276
|
-
const raw = att.imageData.replace(/^data:[^;]*;base64,/, "");
|
|
3277
|
-
const mimeType = att.mimeType?.startsWith("image/")
|
|
3278
|
-
? att.mimeType
|
|
3279
|
-
: "image/png";
|
|
3280
|
-
const bytes = Buffer.byteLength(raw, "base64");
|
|
3281
|
-
// Only images that would actually be sent (non-empty, under the cap).
|
|
3282
|
-
if (bytes > 0 && bytes <= 2 * 1024 * 1024) {
|
|
3283
|
-
if (!mainSupportsVision) {
|
|
3284
|
-
bridgedImages.push({ idx, att, raw, mimeType, bytes });
|
|
3285
|
-
}
|
|
3286
|
-
}
|
|
3287
|
-
continue;
|
|
3288
|
-
}
|
|
3289
|
-
if (att.fileData || !att.path)
|
|
3290
|
-
continue;
|
|
3291
|
-
const ext = extname(att.path).toLowerCase();
|
|
3292
|
-
if (!IMAGE_EXT.has(ext) || ext === ".svg")
|
|
3293
|
-
continue;
|
|
3294
|
-
const abs = resolve(root, att.path);
|
|
3295
|
-
const rawRel = relative(root, abs);
|
|
3296
|
-
if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`))
|
|
3297
|
-
continue;
|
|
3298
|
-
let st;
|
|
3299
|
-
try {
|
|
3300
|
-
st = await fs.stat(abs);
|
|
3301
|
-
}
|
|
3302
|
-
catch {
|
|
3303
|
-
continue;
|
|
3304
|
-
}
|
|
3305
|
-
if (!st.isFile() || st.size === 0 || st.size > MAX_PATH_IMAGE_BYTES) {
|
|
3306
|
-
continue;
|
|
3307
|
-
}
|
|
3308
|
-
const buf = await fs.readFile(abs);
|
|
3309
|
-
const mime = sniffImageMime(buf, ext);
|
|
3310
|
-
if (!mime)
|
|
3311
|
-
continue;
|
|
3312
|
-
const raw = buf.toString("base64");
|
|
3313
|
-
pathImageData.set(idx, { raw, mimeType: mime, bytes: st.size });
|
|
3314
|
-
if (!mainSupportsVision) {
|
|
3315
|
-
bridgedImages.push({ idx, att, raw, mimeType: mime, bytes: st.size });
|
|
3316
|
-
}
|
|
3317
|
-
}
|
|
3318
|
-
/** Transcript per attachment index (filled below, keyed by bridgedImages idx). */
|
|
3319
|
-
const bridgeTranscripts = new Map();
|
|
3320
|
-
if (bridgedImages.length > 0) {
|
|
3321
|
-
if (!this.settings.visionBridgeEnabled) {
|
|
3322
|
-
this.emit({
|
|
3323
|
-
type: "notice",
|
|
3324
|
-
level: "warning",
|
|
3325
|
-
text: `当前模型(${mainModel?.name ?? mainModel?.id ?? "未知"})不支持识图,且视觉桥已在设置中关闭:图片将原样发送、可能被忽略。`,
|
|
3326
|
-
});
|
|
3327
|
-
}
|
|
3328
|
-
else {
|
|
3329
|
-
const visionModels = findVisionModels(this.session.modelRuntime);
|
|
3330
|
-
// Preferred model from settings ("provider/id") — validated to exist
|
|
3331
|
-
// and actually accept images; falls back to the first auto-detected.
|
|
3332
|
-
let chosen = visionModels[0] ?? null;
|
|
3333
|
-
const pref = this.settings.visionBridgeModel;
|
|
3334
|
-
if (pref) {
|
|
3335
|
-
const spec = this.resolveReviewModel(pref);
|
|
3336
|
-
if (spec) {
|
|
3337
|
-
const pm = this.session.modelRuntime.getModel(spec.provider, spec.id);
|
|
3338
|
-
if (pm?.input?.includes("image")) {
|
|
3339
|
-
chosen = {
|
|
3340
|
-
provider: spec.provider,
|
|
3341
|
-
id: spec.id,
|
|
3342
|
-
label: `${pm.name ?? pm.id} (${spec.provider})`,
|
|
3343
|
-
};
|
|
3344
|
-
}
|
|
3345
|
-
}
|
|
3346
|
-
}
|
|
3347
|
-
if (!chosen) {
|
|
3348
|
-
this.emit({
|
|
3349
|
-
type: "notice",
|
|
3350
|
-
level: "warning",
|
|
3351
|
-
text: `当前模型(${mainModel?.name ?? mainModel?.id ?? "未知"})不支持识图,且未找到可用的视觉模型:图片将原样发送、可能被忽略。在模型配置里添加任意支持图片的模型(如 qwen-vl、GLM-4V、Gemini)即可自动启用视觉桥转写。`,
|
|
3352
|
-
});
|
|
3353
|
-
}
|
|
3354
|
-
else {
|
|
3355
|
-
// Batch hash so re-sending identical images (edit & re-ask) reuses
|
|
3356
|
-
// the transcript instead of re-burning tokens on the vision API.
|
|
3357
|
-
// The active transcription prompt is part of the key: changing
|
|
3358
|
-
// the custom prompt must invalidate cached transcripts made with
|
|
3359
|
-
// the old prompt.
|
|
3360
|
-
const batchHash = bridgedImages
|
|
3361
|
-
.map((b) => `${b.att.name ?? "img"}:${b.raw.slice(0, 48)}`)
|
|
3362
|
-
.join("|") +
|
|
3363
|
-
"::" +
|
|
3364
|
-
buildVisionBridgePrompt(this.settings.visionBridgePromptMode, this.settings.visionBridgePrompt);
|
|
3365
|
-
let transcript = this.visionBridgeCache.get(batchHash);
|
|
3366
|
-
if (transcript === undefined) {
|
|
3367
|
-
this.emit({
|
|
3368
|
-
type: "notice",
|
|
3369
|
-
level: "info",
|
|
3370
|
-
text: `当前模型不支持识图,正在用视觉桥(${chosen.label})转写 ${bridgedImages.length} 张图片…`,
|
|
3371
|
-
});
|
|
3372
|
-
try {
|
|
3373
|
-
const chosenModel = this.session.modelRuntime.getModel(chosen.provider, chosen.id);
|
|
3374
|
-
transcript = await transcribeImages(this.session.modelRuntime, bridgedImages.map((b) => ({
|
|
3375
|
-
data: b.raw,
|
|
3376
|
-
mimeType: b.mimeType,
|
|
3377
|
-
name: b.att.name,
|
|
3378
|
-
})), {
|
|
3379
|
-
model: chosenModel ?? undefined,
|
|
3380
|
-
systemPrompt: buildVisionBridgePrompt(this.settings.visionBridgePromptMode, this.settings.visionBridgePrompt),
|
|
3381
|
-
});
|
|
3382
|
-
this.visionBridgeCache.set(batchHash, transcript);
|
|
3383
|
-
this.emit({
|
|
3384
|
-
type: "notice",
|
|
3385
|
-
level: "info",
|
|
3386
|
-
text: `✅ 图片已由视觉桥转写完成(${chosen.label})`,
|
|
3387
|
-
});
|
|
3388
|
-
}
|
|
3389
|
-
catch (err) {
|
|
3390
|
-
transcript = "";
|
|
3391
|
-
this.emit({
|
|
3392
|
-
type: "notice",
|
|
3393
|
-
level: "error",
|
|
3394
|
-
text: `图片转写失败(${chosen.label}):${err.message}。图片将原样发送、可能被忽略。`,
|
|
3395
|
-
});
|
|
3396
|
-
}
|
|
3397
|
-
}
|
|
3398
|
-
for (const b of bridgedImages)
|
|
3399
|
-
bridgeTranscripts.set(b.idx, transcript ?? "");
|
|
3400
|
-
}
|
|
3401
|
-
}
|
|
3402
|
-
}
|
|
3403
|
-
/** Cap for reading a file in "lines" mode (selected slice is inlined). */
|
|
3404
|
-
const MAX_LINES_READ_BYTES = 2 * 1024 * 1024;
|
|
3405
|
-
for (const [idx, att] of attachments.entries()) {
|
|
3406
|
-
// Raw pasted/dropped/uploaded image — no workspace path involved (the
|
|
3407
|
-
// browser downscales client-side; this guard only prevents abuse).
|
|
3408
|
-
if (att.imageData) {
|
|
3409
|
-
const raw = att.imageData.replace(/^data:[^;]*;base64,/, "");
|
|
3410
|
-
const mimeType = att.mimeType?.startsWith("image/") ? att.mimeType : "image/png";
|
|
3411
|
-
const bytes = Buffer.byteLength(raw, "base64");
|
|
3412
|
-
const MAX_PASTED_IMAGE_BYTES = 2 * 1024 * 1024;
|
|
3413
|
-
if (bytes === 0) {
|
|
3414
|
-
this.emit({
|
|
3415
|
-
type: "notice",
|
|
3416
|
-
level: "error",
|
|
3417
|
-
text: `图片数据为空,已跳过`,
|
|
3418
|
-
});
|
|
3419
|
-
continue;
|
|
3420
|
-
}
|
|
3421
|
-
if (bytes > MAX_PASTED_IMAGE_BYTES) {
|
|
3422
|
-
this.emit({
|
|
3423
|
-
type: "notice",
|
|
3424
|
-
level: "warning",
|
|
3425
|
-
text: `图片过大已跳过(>2MB):${att.name ?? "粘贴图片"}`,
|
|
3426
|
-
});
|
|
3427
|
-
continue;
|
|
3428
|
-
}
|
|
3429
|
-
const transcript = bridgeTranscripts.get(idx);
|
|
3430
|
-
if (transcript) {
|
|
3431
|
-
// Bridged: the text-only main model can't see images, so it gets the
|
|
3432
|
-
// vision model's transcript as text evidence; the image block is
|
|
3433
|
-
// kept so the card still shows the original thumbnail.
|
|
3434
|
-
out.push({
|
|
3435
|
-
message: {
|
|
3436
|
-
customType: "file",
|
|
3437
|
-
content: [
|
|
3438
|
-
{
|
|
3439
|
-
type: "text",
|
|
3440
|
-
text: `\n<vision-bridge>\n${transcript}\n</vision-bridge>`,
|
|
3441
|
-
},
|
|
3442
|
-
{ type: "image", data: raw, mimeType },
|
|
3443
|
-
],
|
|
3444
|
-
display: true,
|
|
3445
|
-
details: {
|
|
3446
|
-
name: att.name ?? "image.png",
|
|
3447
|
-
path: undefined,
|
|
3448
|
-
mode: "bridged",
|
|
3449
|
-
size: bytes,
|
|
3450
|
-
},
|
|
3451
|
-
},
|
|
3452
|
-
});
|
|
3453
|
-
continue;
|
|
3454
|
-
}
|
|
3455
|
-
out.push({
|
|
3456
|
-
message: {
|
|
3457
|
-
customType: "file",
|
|
3458
|
-
content: [{ type: "image", data: raw, mimeType }],
|
|
3459
|
-
display: true,
|
|
3460
|
-
details: {
|
|
3461
|
-
name: att.name ?? "image.png",
|
|
3462
|
-
// No workspace path — the card renders without the path line.
|
|
3463
|
-
path: undefined,
|
|
3464
|
-
mode: "image",
|
|
3465
|
-
size: bytes,
|
|
3466
|
-
},
|
|
3467
|
-
},
|
|
3468
|
-
});
|
|
3469
|
-
continue;
|
|
3470
|
-
}
|
|
3471
|
-
// Raw uploaded file (base64) — no workspace path involved. The bytes are
|
|
3472
|
-
// persisted under <dataDir>/uploads/<clientId>/ so the model can read
|
|
3473
|
-
// them on demand with its read tool (absolute path, no traversal guard
|
|
3474
|
-
// needed — the path is server-generated). Small text uploads are inlined
|
|
3475
|
-
// so the model sees them immediately; everything else becomes a path
|
|
3476
|
-
// reference.
|
|
3477
|
-
if (att.fileData) {
|
|
3478
|
-
const buf = Buffer.from(att.fileData, "base64");
|
|
3479
|
-
const MAX_UPLOAD_BYTES = 20 * 1024 * 1024;
|
|
3480
|
-
if (buf.length === 0) {
|
|
3481
|
-
this.emit({
|
|
3482
|
-
type: "notice",
|
|
3483
|
-
level: "error",
|
|
3484
|
-
text: `文件数据为空,已跳过`,
|
|
3485
|
-
});
|
|
3486
|
-
continue;
|
|
3487
|
-
}
|
|
3488
|
-
if (buf.length > MAX_UPLOAD_BYTES) {
|
|
3489
|
-
this.emit({
|
|
3490
|
-
type: "notice",
|
|
3491
|
-
level: "warning",
|
|
3492
|
-
text: `文件过大已跳过(>20MB):${att.name ?? "上传文件"}`,
|
|
3493
|
-
});
|
|
3494
|
-
continue;
|
|
3495
|
-
}
|
|
3496
|
-
// Uploaded files live in a GLOBAL per-user dir (not inside the project
|
|
3497
|
-
// or the per-client session store) so browsing a repo never picks up
|
|
3498
|
-
// uploaded junk: <home>/.pi-web/uploads/<clientId>/.
|
|
3499
|
-
const { homedir } = await import("node:os");
|
|
3500
|
-
const uploadsDir = join(homedir(), ".pi-web", "uploads", this.clientId);
|
|
3501
|
-
const safeName = (att.name ?? "file")
|
|
3502
|
-
.replace(/[\\/:*?"<>|\x00-\x1f]/g, "_")
|
|
3503
|
-
.slice(0, 80);
|
|
3504
|
-
const abs = join(uploadsDir, `${Date.now()}-${safeName}`);
|
|
3505
|
-
await fs.mkdir(uploadsDir, { recursive: true });
|
|
3506
|
-
await fs.writeFile(abs, buf);
|
|
3507
|
-
// Wire format: forward-slash absolute path (the read tool accepts
|
|
3508
|
-
// absolute paths; Windows uses "C:/..." — safe inside the XML-ish tag).
|
|
3509
|
-
const wirePath = abs.split(sep).join("/");
|
|
3510
|
-
if (buf.length <= MAX_INLINE_BYTES && looksLikeText(buf)) {
|
|
3511
|
-
const lines = countLines(buf);
|
|
3512
|
-
out.push({
|
|
3513
|
-
message: {
|
|
3514
|
-
customType: "file",
|
|
3515
|
-
content: [
|
|
3516
|
-
{
|
|
3517
|
-
type: "text",
|
|
3518
|
-
text: `\n<file path="${wirePath}">\n\`\`\`\n${decodeText(buf)}\n\`\`\`\n</file>`,
|
|
3519
|
-
},
|
|
3520
|
-
],
|
|
3521
|
-
display: true,
|
|
3522
|
-
details: {
|
|
3523
|
-
name: safeName,
|
|
3524
|
-
path: wirePath,
|
|
3525
|
-
mode: "inline",
|
|
3526
|
-
size: buf.length,
|
|
3527
|
-
lines,
|
|
3528
|
-
},
|
|
3529
|
-
},
|
|
3530
|
-
});
|
|
3531
|
-
}
|
|
3532
|
-
else {
|
|
3533
|
-
out.push({
|
|
3534
|
-
message: {
|
|
3535
|
-
customType: "file",
|
|
3536
|
-
content: [
|
|
3537
|
-
{
|
|
3538
|
-
type: "text",
|
|
3539
|
-
text: `<file path="${wirePath}" size="${buf.length}" />`,
|
|
3540
|
-
},
|
|
3541
|
-
],
|
|
3542
|
-
display: true,
|
|
3543
|
-
details: {
|
|
3544
|
-
name: safeName,
|
|
3545
|
-
path: wirePath,
|
|
3546
|
-
mode: "reference",
|
|
3547
|
-
size: buf.length,
|
|
3548
|
-
},
|
|
3549
|
-
},
|
|
3550
|
-
});
|
|
3551
|
-
}
|
|
3552
|
-
continue;
|
|
3553
|
-
}
|
|
3554
|
-
const abs = resolve(root, att.path);
|
|
3555
|
-
const rawRel = relative(root, abs);
|
|
3556
|
-
if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`)) {
|
|
3557
|
-
this.emit({
|
|
3558
|
-
type: "notice",
|
|
3559
|
-
level: "warning",
|
|
3560
|
-
text: `附件路径超出工作区:${att.path}`,
|
|
3561
|
-
});
|
|
3562
|
-
continue;
|
|
3563
|
-
}
|
|
3564
|
-
// Normalize to forward slashes (relative() returns "\\" on Windows);
|
|
3565
|
-
// <file path> and details.path must use the wire format.
|
|
3566
|
-
const rel = rawRel.split(sep).join("/");
|
|
3567
|
-
let stat;
|
|
3568
|
-
try {
|
|
3569
|
-
stat = await fs.stat(abs);
|
|
3570
|
-
}
|
|
3571
|
-
catch {
|
|
3572
|
-
this.emit({
|
|
3573
|
-
type: "notice",
|
|
3574
|
-
level: "error",
|
|
3575
|
-
text: `附件不存在:${att.path}`,
|
|
3576
|
-
});
|
|
3577
|
-
continue;
|
|
3578
|
-
}
|
|
3579
|
-
const name = att.path.split(/[\\/]/).pop() ?? att.path;
|
|
3580
|
-
// Folders can't be inlined — always a path reference the model browses
|
|
3581
|
-
// on demand with its own tools (ls/read).
|
|
3582
|
-
if (stat.isDirectory()) {
|
|
3583
|
-
out.push({
|
|
3584
|
-
message: {
|
|
3585
|
-
customType: "file",
|
|
3586
|
-
content: [{ type: "text", text: `<folder path="${rel}" />` }],
|
|
3587
|
-
display: true,
|
|
3588
|
-
details: {
|
|
3589
|
-
name,
|
|
3590
|
-
path: rel,
|
|
3591
|
-
mode: "reference",
|
|
3592
|
-
type: "folder",
|
|
3593
|
-
},
|
|
3594
|
-
},
|
|
3595
|
-
});
|
|
3596
|
-
continue;
|
|
3597
|
-
}
|
|
3598
|
-
if (!stat.isFile()) {
|
|
3599
|
-
this.emit({
|
|
3600
|
-
type: "notice",
|
|
3601
|
-
level: "warning",
|
|
3602
|
-
text: `跳过非文件附件:${att.path}`,
|
|
3603
|
-
});
|
|
3604
|
-
continue;
|
|
3605
|
-
}
|
|
3606
|
-
const ext = extname(att.path).toLowerCase();
|
|
3607
|
-
if (IMAGE_EXT.has(ext) && ext !== ".svg") {
|
|
3608
|
-
const pathImg = pathImageData.get(idx);
|
|
3609
|
-
const transcript = bridgeTranscripts.get(idx);
|
|
3610
|
-
if (transcript) {
|
|
3611
|
-
// Text-only main model: the vision bridge transcribed this image —
|
|
3612
|
-
// the model gets the transcript as text evidence (+ thumbnail).
|
|
3613
|
-
out.push({
|
|
3614
|
-
message: {
|
|
3615
|
-
customType: "file",
|
|
3616
|
-
content: [
|
|
3617
|
-
{
|
|
3618
|
-
type: "text",
|
|
3619
|
-
text: `
|
|
3620
|
-
<vision-bridge>
|
|
3621
|
-
${transcript}
|
|
3622
|
-
</vision-bridge>`,
|
|
3623
|
-
},
|
|
3624
|
-
...(pathImg
|
|
3625
|
-
? ([{
|
|
3626
|
-
type: "image",
|
|
3627
|
-
data: pathImg.raw,
|
|
3628
|
-
mimeType: pathImg.mimeType,
|
|
3629
|
-
}])
|
|
3630
|
-
: []),
|
|
3631
|
-
],
|
|
3632
|
-
display: true,
|
|
3633
|
-
details: {
|
|
3634
|
-
name,
|
|
3635
|
-
path: rel,
|
|
3636
|
-
mode: "bridged",
|
|
3637
|
-
size: stat.size,
|
|
3638
|
-
},
|
|
3639
|
-
},
|
|
3640
|
-
});
|
|
3641
|
-
continue;
|
|
3642
|
-
}
|
|
3643
|
-
if (pathImg) {
|
|
3644
|
-
// Vision-capable main model (or bridge failed): send the raw image
|
|
3645
|
-
// content straight from the pre-read bytes.
|
|
3646
|
-
out.push({
|
|
3647
|
-
message: {
|
|
3648
|
-
customType: "file",
|
|
3649
|
-
content: [
|
|
3650
|
-
{
|
|
3651
|
-
type: "image",
|
|
3652
|
-
data: pathImg.raw,
|
|
3653
|
-
mimeType: pathImg.mimeType,
|
|
3654
|
-
},
|
|
3655
|
-
],
|
|
3656
|
-
display: true,
|
|
3657
|
-
details: { name, path: rel, mode: "image", size: stat.size },
|
|
3658
|
-
},
|
|
3659
|
-
});
|
|
3660
|
-
continue;
|
|
3661
|
-
}
|
|
3662
|
-
// Pre-read failed (unsupported sniff / too large): fall back to the
|
|
3663
|
-
// legacy inline-cap behavior.
|
|
3664
|
-
if (stat.size > MAX_ATTACHMENT_BYTES) {
|
|
3665
|
-
this.emit({
|
|
3666
|
-
type: "notice",
|
|
3667
|
-
level: "warning",
|
|
3668
|
-
text: `图片附件过大已跳过(>200KB):${att.path}`,
|
|
3669
|
-
});
|
|
3670
|
-
continue;
|
|
3671
|
-
}
|
|
3672
|
-
const data = await fs.readFile(abs, "base64");
|
|
3673
|
-
out.push({
|
|
3674
|
-
message: {
|
|
3675
|
-
customType: "file",
|
|
3676
|
-
content: [
|
|
3677
|
-
{ type: "image", data, mimeType: MIME[ext] ?? "image/png" },
|
|
3678
|
-
],
|
|
3679
|
-
display: true,
|
|
3680
|
-
details: { name, path: rel, mode: "image", size: stat.size },
|
|
3681
|
-
},
|
|
3682
|
-
});
|
|
3683
|
-
continue;
|
|
3684
|
-
}
|
|
3685
|
-
const makeReference = () => ({
|
|
3686
|
-
message: {
|
|
3687
|
-
customType: "file",
|
|
3688
|
-
content: [
|
|
3689
|
-
{
|
|
3690
|
-
type: "text",
|
|
3691
|
-
text: `<file path="${rel}" size="${stat.size}" />`,
|
|
3692
|
-
},
|
|
3693
|
-
],
|
|
3694
|
-
display: true,
|
|
3695
|
-
details: { name, path: rel, mode: "reference", size: stat.size },
|
|
3696
|
-
},
|
|
3697
|
-
});
|
|
3698
|
-
const makeInline = (buf) => {
|
|
3699
|
-
const lines = countLines(buf);
|
|
3700
|
-
return {
|
|
3701
|
-
message: {
|
|
3702
|
-
customType: "file",
|
|
3703
|
-
content: [
|
|
3704
|
-
{
|
|
3705
|
-
type: "text",
|
|
3706
|
-
text: `\n<file path="${rel}">\n\`\`\`\n${decodeText(buf)}\n\`\`\`\n</file>`,
|
|
3707
|
-
},
|
|
3708
|
-
],
|
|
3709
|
-
display: true,
|
|
3710
|
-
details: {
|
|
3711
|
-
name,
|
|
3712
|
-
path: rel,
|
|
3713
|
-
mode: "inline",
|
|
3714
|
-
size: stat.size,
|
|
3715
|
-
lines,
|
|
3716
|
-
},
|
|
3717
|
-
},
|
|
3718
|
-
};
|
|
3719
|
-
};
|
|
3720
|
-
// Reference mode is always honored and never reads the file.
|
|
3721
|
-
if (att.mode === "reference") {
|
|
3722
|
-
out.push(makeReference());
|
|
3723
|
-
continue;
|
|
3724
|
-
}
|
|
3725
|
-
// Line-range mode: inline only the selected 1-based inclusive range.
|
|
3726
|
-
// Reading is capped so a huge file can't exhaust memory even though
|
|
3727
|
-
// the selected slice is small.
|
|
3728
|
-
if (att.mode === "lines") {
|
|
3729
|
-
const range = att.lines;
|
|
3730
|
-
if (!range || range.start < 1 || range.end < range.start) {
|
|
3731
|
-
this.emit({
|
|
3732
|
-
type: "notice",
|
|
3733
|
-
level: "warning",
|
|
3734
|
-
text: `行范围无效,已改为仅引用:${att.path}`,
|
|
3735
|
-
});
|
|
3736
|
-
out.push(makeReference());
|
|
3737
|
-
continue;
|
|
3738
|
-
}
|
|
3739
|
-
if (stat.size > MAX_LINES_READ_BYTES) {
|
|
3740
|
-
this.emit({
|
|
3741
|
-
type: "notice",
|
|
3742
|
-
level: "warning",
|
|
3743
|
-
text: `文件过大,已改为仅引用:${att.path}`,
|
|
3744
|
-
});
|
|
3745
|
-
out.push(makeReference());
|
|
3746
|
-
continue;
|
|
3747
|
-
}
|
|
3748
|
-
const buf = await fs.readFile(abs);
|
|
3749
|
-
if (buf.includes(0)) {
|
|
3750
|
-
this.emit({
|
|
3751
|
-
type: "notice",
|
|
3752
|
-
level: "warning",
|
|
3753
|
-
text: `二进制文件已改为仅引用:${att.path}`,
|
|
3754
|
-
});
|
|
3755
|
-
out.push(makeReference());
|
|
3756
|
-
continue;
|
|
3757
|
-
}
|
|
3758
|
-
const parts = decodeText(buf).split("\n");
|
|
3759
|
-
// A trailing newline yields an empty phantom line — drop it so line
|
|
3760
|
-
// numbers match the preview panel.
|
|
3761
|
-
if (parts.length > 0 && parts[parts.length - 1] === "")
|
|
3762
|
-
parts.pop();
|
|
3763
|
-
const start = Math.min(range.start, parts.length);
|
|
3764
|
-
const end = Math.min(range.end, parts.length);
|
|
3765
|
-
if (start < 1 || end < start) {
|
|
3766
|
-
this.emit({
|
|
3767
|
-
type: "notice",
|
|
3768
|
-
level: "warning",
|
|
3769
|
-
text: `选中行超出文件范围,已改为仅引用:${att.path}`,
|
|
3770
|
-
});
|
|
3771
|
-
out.push(makeReference());
|
|
3772
|
-
continue;
|
|
3773
|
-
}
|
|
3774
|
-
const selected = parts.slice(start - 1, end).join("\n");
|
|
3775
|
-
out.push({
|
|
3776
|
-
message: {
|
|
3777
|
-
customType: "file",
|
|
3778
|
-
content: [
|
|
3779
|
-
{
|
|
3780
|
-
type: "text",
|
|
3781
|
-
text: `\n<file path="${rel}" lines="${start}-${end}">\n\`\`\`\n${selected}\n\`\`\`\n</file>`,
|
|
3782
|
-
},
|
|
3783
|
-
],
|
|
3784
|
-
display: true,
|
|
3785
|
-
details: {
|
|
3786
|
-
name,
|
|
3787
|
-
path: rel,
|
|
3788
|
-
mode: "lines",
|
|
3789
|
-
size: stat.size,
|
|
3790
|
-
lines: end - start + 1,
|
|
3791
|
-
startLine: start,
|
|
3792
|
-
endLine: end,
|
|
3793
|
-
},
|
|
3794
|
-
},
|
|
3795
|
-
});
|
|
3796
|
-
continue;
|
|
3797
|
-
}
|
|
3798
|
-
// Forced inline has a hard cap to protect the model context.
|
|
3799
|
-
if (att.mode === "inline") {
|
|
3800
|
-
if (stat.size > MAX_INLINE_BYTES) {
|
|
3801
|
-
this.emit({
|
|
3802
|
-
type: "notice",
|
|
3803
|
-
level: "warning",
|
|
3804
|
-
text: `文件过大,已改为仅引用:${att.path}`,
|
|
3805
|
-
});
|
|
3806
|
-
out.push(makeReference());
|
|
3807
|
-
continue;
|
|
3808
|
-
}
|
|
3809
|
-
const buf = await fs.readFile(abs);
|
|
3810
|
-
if (buf.includes(0)) {
|
|
3811
|
-
this.emit({
|
|
3812
|
-
type: "notice",
|
|
3813
|
-
level: "warning",
|
|
3814
|
-
text: `二进制文件已改为仅引用:${att.path}`,
|
|
3815
|
-
});
|
|
3816
|
-
out.push(makeReference());
|
|
3817
|
-
continue;
|
|
3818
|
-
}
|
|
3819
|
-
out.push(makeInline(buf));
|
|
3820
|
-
continue;
|
|
1421
|
+
*/
|
|
1422
|
+
queue = false) {
|
|
1423
|
+
try {
|
|
1424
|
+
const s = this.session;
|
|
1425
|
+
// Native slash commands (see NATIVE_COMMANDS) are executed here and
|
|
1426
|
+
// never reach the SDK. Extension / skill / template commands fall
|
|
1427
|
+
// through — AgentSession.prompt() handles those itself.
|
|
1428
|
+
const slash = parseSlash(text);
|
|
1429
|
+
if (slash && (await this.slash.exec(slash.name, slash.args))) {
|
|
1430
|
+
this.flushSnapshot();
|
|
1431
|
+
return;
|
|
3821
1432
|
}
|
|
3822
|
-
//
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
1433
|
+
// Native commands above are pure config tweaks (no tokens) — allow them
|
|
1434
|
+
// even while quiesced. Everything that reaches the SDK is NEW work and
|
|
1435
|
+
// is refused until admission reopens.
|
|
1436
|
+
if (this.quiesceBlocked())
|
|
1437
|
+
return;
|
|
1438
|
+
// Attach files as independent nextTurn context messages (asides) so the
|
|
1439
|
+
// user message stays clean; they render as separate attachment cards.
|
|
1440
|
+
const asides = await buildAttachmentMessages({
|
|
1441
|
+
cwd: this.cwd,
|
|
1442
|
+
clientId: this.clientId,
|
|
1443
|
+
emit: (msg) => this.emit(msg),
|
|
1444
|
+
settings: this.settingsSvc.current,
|
|
1445
|
+
session: this.session,
|
|
1446
|
+
}, attachments);
|
|
1447
|
+
for (const aside of asides) {
|
|
1448
|
+
await s.sendCustomMessage(aside.message, { deliverAs: "nextTurn" });
|
|
3826
1449
|
}
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
1450
|
+
if (s.isStreaming) {
|
|
1451
|
+
// queue=true (补充 button) → followUp: the message is delivered only
|
|
1452
|
+
// after the whole run finishes — the agent finishes what it started,
|
|
1453
|
+
// then responds to the queued message. queue=false/undefined
|
|
1454
|
+
// (plain Enter) → steer: interrupts the current run — the message
|
|
1455
|
+
// is delivered right after the current assistant turn settles
|
|
1456
|
+
// (remaining planned tool calls are skipped) and the agent
|
|
1457
|
+
// immediately responds to it. This is the pi CLI
|
|
1458
|
+
// Enter-during-streaming semantic (docs/usage: Enter queues a
|
|
1459
|
+
// steering message); followUp would wait for the whole run
|
|
1460
|
+
// to finish, which users perceive as ordinary queueing.
|
|
1461
|
+
await s.prompt(text, {
|
|
1462
|
+
streamingBehavior: queue ? "followUp" : "steer",
|
|
3833
1463
|
});
|
|
3834
|
-
out.push(makeReference());
|
|
3835
|
-
continue;
|
|
3836
1464
|
}
|
|
3837
|
-
|
|
1465
|
+
else {
|
|
1466
|
+
await s.prompt(text);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
catch (err) {
|
|
1470
|
+
this.emit({
|
|
1471
|
+
type: "notice",
|
|
1472
|
+
level: "error",
|
|
1473
|
+
text: `提示发送失败:${err.message}`,
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
// Name the conversation after its first user prompt.
|
|
1477
|
+
const conv = this.conv;
|
|
1478
|
+
if (conv.title === DEFAULT_CONV_TITLE && text.trim()) {
|
|
1479
|
+
const trimmed = text.trim().replace(/\s+/g, " ");
|
|
1480
|
+
conv.title = trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
|
|
1481
|
+
this.emitConversations();
|
|
3838
1482
|
}
|
|
3839
|
-
|
|
1483
|
+
// The active conversation has been continued since it was opened — it
|
|
1484
|
+
// must not be dismissed when the user switches away. (Also bumps the
|
|
1485
|
+
// per-project "most recently active" order used by set_cwd.)
|
|
1486
|
+
conv.promptedSinceActive = true;
|
|
1487
|
+
conv.lastActiveAt = Date.now();
|
|
1488
|
+
this.flushSnapshot();
|
|
3840
1489
|
}
|
|
1490
|
+
/**
|
|
1491
|
+
* Turn attached files into custom-message payloads.
|
|
1492
|
+
*
|
|
1493
|
+
* Text files are size-aware: small files are inlined into the message so the
|
|
1494
|
+
* model sees them immediately; large files are passed as a <file path="...">
|
|
1495
|
+
* reference and the model reads them on demand with its read tool (which has
|
|
1496
|
+
* built-in truncation). Images are always passed as image content. Mode
|
|
1497
|
+
* "lines" inlines only a 1-based inclusive line range of the file. Raw
|
|
1498
|
+
* pasted/dropped/uploaded images (attachment.imageData) skip the workspace
|
|
1499
|
+
* path entirely and go straight to the model as image content. Raw uploaded
|
|
1500
|
+
* files (attachment.fileData) are persisted under <dataDir>/uploads/ and
|
|
1501
|
+
* attached as absolute-path references (small text ones are inlined).
|
|
1502
|
+
*/
|
|
3841
1503
|
/**
|
|
3842
1504
|
* Hard-abort the running agent (Stop button / global 中断). Tries
|
|
3843
1505
|
* session.abort() first; if the run is not idle within
|
|
@@ -3852,112 +1514,17 @@ ${transcript}
|
|
|
3852
1514
|
await this.interruptRun(this.conv, "已停止");
|
|
3853
1515
|
this.flushSnapshot();
|
|
3854
1516
|
}
|
|
3855
|
-
/** After a bash tool run, wait briefly for background servers to bind,
|
|
3856
|
-
* then diff the listening-port snapshot against the pre-run one and
|
|
3857
|
-
* remember anything new — those are servers the agent left running. */
|
|
3858
|
-
async trackBackgroundServers() {
|
|
3859
|
-
const before = this.bashListenBefore;
|
|
3860
|
-
this.bashListenBefore = null;
|
|
3861
|
-
if (!before)
|
|
3862
|
-
return;
|
|
3863
|
-
await new Promise((r) => setTimeout(r, 1500));
|
|
3864
|
-
const after = await snapshotListeningPorts();
|
|
3865
|
-
let added = false;
|
|
3866
|
-
for (const [port, pid] of after) {
|
|
3867
|
-
if (!before.has(port) && !this.bgServers.has(port)) {
|
|
3868
|
-
this.bgServers.set(port, { pid, since: Date.now() });
|
|
3869
|
-
added = true;
|
|
3870
|
-
// Best-effort process name so the panel shows something readable.
|
|
3871
|
-
void lookupProcessName(pid).then((name) => {
|
|
3872
|
-
const cur = this.bgServers.get(port);
|
|
3873
|
-
if (cur && cur.pid === pid && name) {
|
|
3874
|
-
cur.name = name;
|
|
3875
|
-
this.emitBgServers();
|
|
3876
|
-
}
|
|
3877
|
-
});
|
|
3878
|
-
this.emit({
|
|
3879
|
-
type: "notice",
|
|
3880
|
-
level: "info",
|
|
3881
|
-
text: `检测到 AI 启动的后台服务:端口 ${port}(pid ${pid})——可在顶栏「后台任务」里单独停止或全部关闭`,
|
|
3882
|
-
});
|
|
3883
|
-
}
|
|
3884
|
-
}
|
|
3885
|
-
if (added)
|
|
3886
|
-
this.emitBgServers();
|
|
3887
|
-
}
|
|
3888
|
-
/** The current background-server list, oldest first. */
|
|
3889
|
-
bgServerList() {
|
|
3890
|
-
return [...this.bgServers.entries()]
|
|
3891
|
-
.map(([port, v]) => ({
|
|
3892
|
-
port,
|
|
3893
|
-
pid: v.pid,
|
|
3894
|
-
since: v.since,
|
|
3895
|
-
...(v.name ? { name: v.name } : {}),
|
|
3896
|
-
}))
|
|
3897
|
-
.sort((a, b) => a.since - b.since);
|
|
3898
|
-
}
|
|
3899
|
-
/** Push the current background-task list to every connected socket. */
|
|
3900
|
-
emitBgServers() {
|
|
3901
|
-
this.emit({ type: "bg_servers", servers: this.bgServerList() });
|
|
3902
|
-
}
|
|
3903
|
-
/** Re-snapshot listening ports and drop tracked entries that are no longer
|
|
3904
|
-
* listening — the process exited on its own, so it must leave the panel.
|
|
3905
|
-
* Port AND pid must both match: a port reused by an unrelated process is
|
|
3906
|
-
* not our server anymore. Silent (the list just updates). */
|
|
3907
|
-
async refreshBgServers() {
|
|
3908
|
-
if (this.disposed || this.bgServers.size === 0)
|
|
3909
|
-
return;
|
|
3910
|
-
const now = await snapshotListeningPorts();
|
|
3911
|
-
let changed = false;
|
|
3912
|
-
for (const [port, v] of [...this.bgServers]) {
|
|
3913
|
-
if (now.get(port) !== v.pid) {
|
|
3914
|
-
this.bgServers.delete(port);
|
|
3915
|
-
changed = true;
|
|
3916
|
-
}
|
|
3917
|
-
}
|
|
3918
|
-
if (changed)
|
|
3919
|
-
this.emitBgServers();
|
|
3920
|
-
}
|
|
3921
1517
|
/** Re-push the current list on request (panel opened); prunes dead entries first. */
|
|
3922
1518
|
async listBgServers() {
|
|
3923
|
-
await this.
|
|
3924
|
-
this.emitBgServers();
|
|
1519
|
+
await this.bg.listAndPush();
|
|
3925
1520
|
}
|
|
3926
1521
|
/** Kill ONE background server (by port); returns whether anything was killed. */
|
|
3927
1522
|
async killBackgroundServer(port) {
|
|
3928
|
-
|
|
3929
|
-
if (!entry) {
|
|
3930
|
-
this.emit({
|
|
3931
|
-
type: "notice",
|
|
3932
|
-
level: "info",
|
|
3933
|
-
text: `端口 ${port} 不在后台任务列表中`,
|
|
3934
|
-
});
|
|
3935
|
-
this.flushSnapshot();
|
|
3936
|
-
return false;
|
|
3937
|
-
}
|
|
3938
|
-
killPidTree(entry.pid);
|
|
3939
|
-
this.bgServers.delete(port);
|
|
3940
|
-
this.emitBgServers();
|
|
3941
|
-
this.emit({
|
|
3942
|
-
type: "notice",
|
|
3943
|
-
level: "info",
|
|
3944
|
-
text: `已停止后台任务:端口 ${port}(pid ${entry.pid})`,
|
|
3945
|
-
});
|
|
3946
|
-
this.flushSnapshot();
|
|
3947
|
-
return true;
|
|
1523
|
+
return this.bg.killOne(port);
|
|
3948
1524
|
}
|
|
3949
1525
|
/** Kill every background server the agent started; returns the freed ports. */
|
|
3950
1526
|
async killAllBackgroundServers() {
|
|
3951
|
-
|
|
3952
|
-
return [];
|
|
3953
|
-
const killed = [];
|
|
3954
|
-
for (const [port, { pid }] of [...this.bgServers]) {
|
|
3955
|
-
killPidTree(pid);
|
|
3956
|
-
killed.push(String(port));
|
|
3957
|
-
}
|
|
3958
|
-
this.bgServers.clear();
|
|
3959
|
-
this.emitBgServers();
|
|
3960
|
-
return killed;
|
|
1527
|
+
return this.bg.killAll();
|
|
3961
1528
|
}
|
|
3962
1529
|
/** Kill only the running bash command(s) — the agent run itself continues
|
|
3963
1530
|
* (the bash tool returns an aborted error and the model moves on). Uses
|
|
@@ -4131,7 +1698,7 @@ ${transcript}
|
|
|
4131
1698
|
this.removeConversation(displaced.id);
|
|
4132
1699
|
await this.bindSession();
|
|
4133
1700
|
this.emitConversations();
|
|
4134
|
-
this.emitGoalStatus();
|
|
1701
|
+
this.goalSvc.emitGoalStatus();
|
|
4135
1702
|
this.pushTerminals();
|
|
4136
1703
|
// The new runtime re-discovered skills/templates — refresh the catalog
|
|
4137
1704
|
// so the picker stops showing the previous runtime's list.
|
|
@@ -4209,7 +1776,7 @@ ${transcript}
|
|
|
4209
1776
|
this.conv.lastActiveAt = Date.now();
|
|
4210
1777
|
this.webUi.refresh();
|
|
4211
1778
|
this.emitConversations();
|
|
4212
|
-
this.emitGoalStatus();
|
|
1779
|
+
this.goalSvc.emitGoalStatus();
|
|
4213
1780
|
this.pushTerminals();
|
|
4214
1781
|
// The switched-to conversation has its own runtime (own resource cache).
|
|
4215
1782
|
void this.pushSlashCommands();
|
|
@@ -4247,11 +1814,20 @@ ${transcript}
|
|
|
4247
1814
|
});
|
|
4248
1815
|
}
|
|
4249
1816
|
/** List persisted sessions for this client, newest first. */
|
|
1817
|
+
/** The client asked for the session list at least once (lazy loading) —
|
|
1818
|
+
* background refreshes only re-push when this is true, so a mobile
|
|
1819
|
+
* client that never opened the panel never pays the disk scan. */
|
|
1820
|
+
sessionsRequested = false;
|
|
4250
1821
|
/** Push the persisted session list to the client (client-requested). */
|
|
4251
1822
|
async refreshSessions() {
|
|
1823
|
+
this.sessionsRequested = true;
|
|
4252
1824
|
await this.pushSessions();
|
|
4253
1825
|
}
|
|
4254
1826
|
async pushSessions() {
|
|
1827
|
+
if (!this.sessionsRequested)
|
|
1828
|
+
return;
|
|
1829
|
+
if (!this.sessionsRequested)
|
|
1830
|
+
return;
|
|
4255
1831
|
try {
|
|
4256
1832
|
// Sessions live in the SDK default per-project dir
|
|
4257
1833
|
// (<agentDir>/sessions/--<cwd>--/), the same files the pi CLI/TUI
|
|
@@ -4268,7 +1844,9 @@ ${transcript}
|
|
|
4268
1844
|
source: "web",
|
|
4269
1845
|
});
|
|
4270
1846
|
}
|
|
4271
|
-
const sorted = [...sessions.values()]
|
|
1847
|
+
const sorted = [...sessions.values()]
|
|
1848
|
+
.sort((a, b) => b.modified - a.modified)
|
|
1849
|
+
.slice(0, 200); // newest first — the panel shows recent history
|
|
4272
1850
|
this.emit({ type: "sessions", sessions: sorted });
|
|
4273
1851
|
}
|
|
4274
1852
|
catch {
|
|
@@ -4354,310 +1932,87 @@ ${transcript}
|
|
|
4354
1932
|
this.emit({
|
|
4355
1933
|
type: "notice",
|
|
4356
1934
|
level: "error",
|
|
4357
|
-
text: "找不到要编辑的消息(可能已被压缩或不在当前分支)",
|
|
4358
|
-
});
|
|
4359
|
-
this.flushSnapshot();
|
|
4360
|
-
return;
|
|
4361
|
-
}
|
|
4362
|
-
try {
|
|
4363
|
-
const result = await this.runtime.fork(entryId);
|
|
4364
|
-
if (result.cancelled) {
|
|
4365
|
-
this.emit({
|
|
4366
|
-
type: "notice",
|
|
4367
|
-
level: "info",
|
|
4368
|
-
text: "已取消编辑重问",
|
|
4369
|
-
});
|
|
4370
|
-
this.flushSnapshot();
|
|
4371
|
-
return;
|
|
4372
|
-
}
|
|
4373
|
-
await this.bindSession();
|
|
4374
|
-
await this.prompt(trimmed);
|
|
4375
|
-
this.emit({
|
|
4376
|
-
type: "notice",
|
|
4377
|
-
level: "info",
|
|
4378
|
-
text: "已从该问题重新提问(原对话保留在会话列表中)",
|
|
4379
|
-
});
|
|
4380
|
-
}
|
|
4381
|
-
catch (err) {
|
|
4382
|
-
this.emit({
|
|
4383
|
-
type: "notice",
|
|
4384
|
-
level: "error",
|
|
4385
|
-
text: `编辑重问失败:${err.message}`,
|
|
4386
|
-
});
|
|
4387
|
-
}
|
|
4388
|
-
this.flushSnapshot();
|
|
4389
|
-
}
|
|
4390
|
-
/**
|
|
4391
|
-
* Push the recent-project list (persisted per client, merged with every cwd
|
|
4392
|
-
* that has persisted sessions in this client's session store — so workspaces
|
|
4393
|
-
* opened before the recent-list feature existed still show up).
|
|
4394
|
-
*/
|
|
4395
|
-
async pushProjects() {
|
|
4396
|
-
try {
|
|
4397
|
-
const saved = this.stateStore.get(this.clientId);
|
|
4398
|
-
const map = new Map();
|
|
4399
|
-
for (const p of saved.projects)
|
|
4400
|
-
map.set(p.path, p.lastUsed);
|
|
4401
|
-
const all = await SessionManager.listAll();
|
|
4402
|
-
for (const s of all) {
|
|
4403
|
-
if (s.cwd) {
|
|
4404
|
-
const t = s.modified.getTime();
|
|
4405
|
-
const prev = map.get(s.cwd);
|
|
4406
|
-
if (prev === undefined || t > prev)
|
|
4407
|
-
map.set(s.cwd, t);
|
|
4408
|
-
}
|
|
4409
|
-
}
|
|
4410
|
-
// Only keep directories that still exist — a deleted/unmounted workspace
|
|
4411
|
-
// is useless in the picker.
|
|
4412
|
-
const projects = [...map.entries()]
|
|
4413
|
-
.filter(([path]) => existsSync(path))
|
|
4414
|
-
.map(([path, lastUsed]) => ({ path, lastUsed }))
|
|
4415
|
-
.sort((a, b) => b.lastUsed - a.lastUsed)
|
|
4416
|
-
.slice(0, 20);
|
|
4417
|
-
this.emit({ type: "projects", projects });
|
|
4418
|
-
}
|
|
4419
|
-
catch {
|
|
4420
|
-
this.emit({ type: "projects", projects: [] });
|
|
4421
|
-
}
|
|
4422
|
-
}
|
|
4423
|
-
/** List a workspace directory (relative to the configured cwd). */
|
|
4424
|
-
async listFiles(relPath) {
|
|
4425
|
-
try {
|
|
4426
|
-
const { resolve, sep, relative } = await import("node:path");
|
|
4427
|
-
const root = resolve(this.cwd);
|
|
4428
|
-
const target = relPath ? resolve(root, relPath) : root;
|
|
4429
|
-
const rawRel = relative(root, target);
|
|
4430
|
-
if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`)) {
|
|
4431
|
-
this.emit({
|
|
4432
|
-
type: "notice",
|
|
4433
|
-
level: "warning",
|
|
4434
|
-
text: `路径超出工作区:${relPath ?? ""}`,
|
|
4435
|
-
});
|
|
4436
|
-
return;
|
|
4437
|
-
}
|
|
4438
|
-
// Normalize to forward slashes: the wire protocol and the frontend
|
|
4439
|
-
// always use "/", but relative() returns "\\" on Windows.
|
|
4440
|
-
const rel = rawRel.split(sep).join("/");
|
|
4441
|
-
const { entries, truncated, error } = await readDirForUI(target, rel);
|
|
4442
|
-
// Watch the listed directory (only after a successful read — a missing
|
|
4443
|
-
// dir throws above and must not create a watcher on a phantom path).
|
|
4444
|
-
this.watchDir(target, rel);
|
|
4445
|
-
if (error) {
|
|
4446
|
-
// Windows-only: unreadable system dirs degrade to an empty list
|
|
4447
|
-
// with a warning instead of a hard error — the panel stays usable.
|
|
4448
|
-
this.emit({
|
|
4449
|
-
type: "notice",
|
|
4450
|
-
level: "warning",
|
|
4451
|
-
text: `目录不可读:${error}`,
|
|
4452
|
-
});
|
|
4453
|
-
}
|
|
4454
|
-
this.emit({
|
|
4455
|
-
type: "files",
|
|
4456
|
-
path: rel === "" ? "" : rel,
|
|
4457
|
-
parent: rel === ""
|
|
4458
|
-
? null
|
|
4459
|
-
: rel.includes("/")
|
|
4460
|
-
? rel.slice(0, rel.lastIndexOf("/"))
|
|
4461
|
-
: "",
|
|
4462
|
-
entries,
|
|
4463
|
-
truncated,
|
|
4464
|
-
});
|
|
4465
|
-
}
|
|
4466
|
-
catch (err) {
|
|
4467
|
-
this.emit({
|
|
4468
|
-
type: "notice",
|
|
4469
|
-
level: "error",
|
|
4470
|
-
text: `读取目录失败:${err.message}`,
|
|
4471
|
-
});
|
|
4472
|
-
}
|
|
4473
|
-
}
|
|
4474
|
-
/** Watch a directory for changes so the file panel refreshes instantly
|
|
4475
|
-
* instead of waiting for the 10s poll. Watches the directory exactly as
|
|
4476
|
-
* listed (one level); navigating re-watches the new target. fs.watch is
|
|
4477
|
-
* unavailable on some platforms/filesystems — failures silently fall back
|
|
4478
|
-
* to the poll. */
|
|
4479
|
-
watchDir(absPath, rel) {
|
|
4480
|
-
if (this.disposed || this.watchPath === rel)
|
|
4481
|
-
return;
|
|
4482
|
-
this.unwatchDir();
|
|
4483
|
-
this.watchPath = rel;
|
|
4484
|
-
try {
|
|
4485
|
-
// persistent: false — the watcher must not keep the process alive.
|
|
4486
|
-
this.fsWatcher = watch(absPath, { persistent: false }, () => {
|
|
4487
|
-
// Burst events (npm install, git ops, editor save→rename) are
|
|
4488
|
-
// debounced into a single refresh.
|
|
4489
|
-
if (this.watchTimer)
|
|
4490
|
-
return;
|
|
4491
|
-
this.watchTimer = setTimeout(() => {
|
|
4492
|
-
this.watchTimer = null;
|
|
4493
|
-
this.emit({ type: "file_changed", path: this.watchPath ?? "" });
|
|
4494
|
-
}, 400);
|
|
4495
|
-
});
|
|
4496
|
-
this.fsWatcher.on("error", () => {
|
|
4497
|
-
// Directory deleted / unsupported fs — stop watching; the poll (or
|
|
4498
|
-
// the next navigation) restores things.
|
|
4499
|
-
this.unwatchDir();
|
|
4500
|
-
});
|
|
4501
|
-
}
|
|
4502
|
-
catch {
|
|
4503
|
-
// fs.watch unsupported (some network mounts, containers) — poll covers it.
|
|
4504
|
-
this.fsWatcher = null;
|
|
4505
|
-
this.watchPath = null;
|
|
4506
|
-
}
|
|
4507
|
-
}
|
|
4508
|
-
unwatchDir() {
|
|
4509
|
-
if (this.watchTimer) {
|
|
4510
|
-
clearTimeout(this.watchTimer);
|
|
4511
|
-
this.watchTimer = null;
|
|
4512
|
-
}
|
|
4513
|
-
if (this.fsWatcher) {
|
|
4514
|
-
try {
|
|
4515
|
-
this.fsWatcher.close();
|
|
4516
|
-
}
|
|
4517
|
-
catch {
|
|
4518
|
-
// already closed
|
|
4519
|
-
}
|
|
4520
|
-
this.fsWatcher = null;
|
|
4521
|
-
}
|
|
4522
|
-
this.watchPath = null;
|
|
4523
|
-
}
|
|
4524
|
-
/** Read a workspace file for the preview panel (size-capped, binary-safe). */
|
|
4525
|
-
async readFile(relPath) {
|
|
4526
|
-
try {
|
|
4527
|
-
const fs = await import("node:fs/promises");
|
|
4528
|
-
const root = resolve(this.cwd);
|
|
4529
|
-
const wp = workspacePath(root, relPath);
|
|
4530
|
-
if (!wp) {
|
|
4531
|
-
this.emit({
|
|
4532
|
-
type: "notice",
|
|
4533
|
-
level: "warning",
|
|
4534
|
-
text: `路径超出工作区:${relPath}`,
|
|
4535
|
-
});
|
|
4536
|
-
return;
|
|
4537
|
-
}
|
|
4538
|
-
const { abs, rel } = wp;
|
|
4539
|
-
const stat = await fs.stat(abs);
|
|
4540
|
-
if (!stat.isFile()) {
|
|
4541
|
-
this.emit({
|
|
4542
|
-
type: "notice",
|
|
4543
|
-
level: "warning",
|
|
4544
|
-
text: `不是文件:${relPath}`,
|
|
4545
|
-
});
|
|
4546
|
-
return;
|
|
4547
|
-
}
|
|
4548
|
-
const name = relPath.split(/[\\/]/).pop() ?? relPath;
|
|
4549
|
-
const kind = previewKind(name);
|
|
4550
|
-
// Media previews stream over the /api/file HTTP endpoint, so only
|
|
4551
|
-
// metadata is sent here — the raw bytes never touch the socket.
|
|
4552
|
-
if (kind === "image" || kind === "video") {
|
|
4553
|
-
this.emit({
|
|
4554
|
-
type: "file_content",
|
|
4555
|
-
path: rel,
|
|
4556
|
-
name,
|
|
4557
|
-
text: "",
|
|
4558
|
-
truncated: false,
|
|
4559
|
-
binary: true,
|
|
4560
|
-
kind,
|
|
4561
|
-
lines: 0,
|
|
4562
|
-
size: stat.size,
|
|
4563
|
-
});
|
|
4564
|
-
return;
|
|
4565
|
-
}
|
|
4566
|
-
// Everything else: read a capped prefix and sniff the content.
|
|
4567
|
-
// Anything that looks like text previews as text regardless of its
|
|
4568
|
-
// extension (jsonl, .log.1, weird suffixes, …); binary content gets
|
|
4569
|
-
// a hex dump of the first few KB instead of being refused.
|
|
4570
|
-
const handle = await fs.open(abs, "r");
|
|
4571
|
-
try {
|
|
4572
|
-
const buf = Buffer.alloc(Math.min(stat.size, MAX_PREVIEW_BYTES));
|
|
4573
|
-
const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
|
|
4574
|
-
const data = buf.subarray(0, bytesRead);
|
|
4575
|
-
if (looksLikeText(data)) {
|
|
4576
|
-
this.emit({
|
|
4577
|
-
type: "file_content",
|
|
4578
|
-
path: rel,
|
|
4579
|
-
name,
|
|
4580
|
-
text: decodeText(data),
|
|
4581
|
-
truncated: bytesRead < stat.size,
|
|
4582
|
-
binary: false,
|
|
4583
|
-
kind: "text",
|
|
4584
|
-
lines: countLines(data),
|
|
4585
|
-
size: stat.size,
|
|
4586
|
-
});
|
|
4587
|
-
}
|
|
4588
|
-
else {
|
|
4589
|
-
this.emit({
|
|
4590
|
-
type: "file_content",
|
|
4591
|
-
path: rel,
|
|
4592
|
-
name,
|
|
4593
|
-
text: hexDump(data),
|
|
4594
|
-
truncated: bytesRead < stat.size,
|
|
4595
|
-
binary: true,
|
|
4596
|
-
kind: kind === "text" ? "text" : "none",
|
|
4597
|
-
lines: 0,
|
|
4598
|
-
size: stat.size,
|
|
4599
|
-
});
|
|
4600
|
-
}
|
|
4601
|
-
}
|
|
4602
|
-
finally {
|
|
4603
|
-
await handle.close();
|
|
4604
|
-
}
|
|
4605
|
-
}
|
|
4606
|
-
catch (err) {
|
|
4607
|
-
this.emit({
|
|
4608
|
-
type: "notice",
|
|
4609
|
-
level: "error",
|
|
4610
|
-
text: `读取文件失败:${err.message}`,
|
|
4611
|
-
});
|
|
4612
|
-
}
|
|
4613
|
-
}
|
|
4614
|
-
/** Save text from the file preview panel within the active workspace. */
|
|
4615
|
-
async writeFile(relPath, text) {
|
|
4616
|
-
try {
|
|
4617
|
-
const root = resolve(this.cwd);
|
|
4618
|
-
const wp = workspacePath(root, relPath);
|
|
4619
|
-
if (!wp) {
|
|
4620
|
-
this.emit({
|
|
4621
|
-
type: "notice",
|
|
4622
|
-
level: "warning",
|
|
4623
|
-
text: `路径超出工作区:${relPath}`,
|
|
4624
|
-
});
|
|
4625
|
-
return;
|
|
4626
|
-
}
|
|
4627
|
-
if (Buffer.byteLength(text, "utf8") > 2 * 1024 * 1024) {
|
|
4628
|
-
this.emit({
|
|
4629
|
-
type: "notice",
|
|
4630
|
-
level: "warning",
|
|
4631
|
-
text: "文件内容过大,无法保存(上限 2MB)",
|
|
4632
|
-
});
|
|
4633
|
-
return;
|
|
4634
|
-
}
|
|
4635
|
-
const stat = statSync(wp.abs);
|
|
4636
|
-
if (!stat.isFile()) {
|
|
1935
|
+
text: "找不到要编辑的消息(可能已被压缩或不在当前分支)",
|
|
1936
|
+
});
|
|
1937
|
+
this.flushSnapshot();
|
|
1938
|
+
return;
|
|
1939
|
+
}
|
|
1940
|
+
try {
|
|
1941
|
+
const result = await this.runtime.fork(entryId);
|
|
1942
|
+
if (result.cancelled) {
|
|
4637
1943
|
this.emit({
|
|
4638
1944
|
type: "notice",
|
|
4639
|
-
level: "
|
|
4640
|
-
text:
|
|
1945
|
+
level: "info",
|
|
1946
|
+
text: "已取消编辑重问",
|
|
4641
1947
|
});
|
|
1948
|
+
this.flushSnapshot();
|
|
4642
1949
|
return;
|
|
4643
1950
|
}
|
|
4644
|
-
|
|
1951
|
+
await this.bindSession();
|
|
1952
|
+
await this.prompt(trimmed);
|
|
4645
1953
|
this.emit({
|
|
4646
1954
|
type: "notice",
|
|
4647
1955
|
level: "info",
|
|
4648
|
-
text:
|
|
1956
|
+
text: "已从该问题重新提问(原对话保留在会话列表中)",
|
|
4649
1957
|
});
|
|
4650
|
-
// Re-read through the same path as the preview request so the client
|
|
4651
|
-
// gets the canonical content, line count and file size after saving.
|
|
4652
|
-
await this.readFile(wp.rel);
|
|
4653
1958
|
}
|
|
4654
1959
|
catch (err) {
|
|
4655
1960
|
this.emit({
|
|
4656
1961
|
type: "notice",
|
|
4657
1962
|
level: "error",
|
|
4658
|
-
text:
|
|
1963
|
+
text: `编辑重问失败:${err.message}`,
|
|
4659
1964
|
});
|
|
4660
1965
|
}
|
|
1966
|
+
this.flushSnapshot();
|
|
1967
|
+
}
|
|
1968
|
+
/**
|
|
1969
|
+
* Push the recent-project list (persisted per client, merged with every cwd
|
|
1970
|
+
* that has persisted sessions in this client's session store — so workspaces
|
|
1971
|
+
* opened before the recent-list feature existed still show up).
|
|
1972
|
+
*/
|
|
1973
|
+
async pushProjects() {
|
|
1974
|
+
try {
|
|
1975
|
+
const saved = this.stateStore.get(this.clientId);
|
|
1976
|
+
const map = new Map();
|
|
1977
|
+
for (const p of saved.projects)
|
|
1978
|
+
map.set(p.path, p.lastUsed);
|
|
1979
|
+
const all = await SessionManager.listAll();
|
|
1980
|
+
for (const s of all) {
|
|
1981
|
+
if (s.cwd) {
|
|
1982
|
+
const t = s.modified.getTime();
|
|
1983
|
+
const prev = map.get(s.cwd);
|
|
1984
|
+
if (prev === undefined || t > prev)
|
|
1985
|
+
map.set(s.cwd, t);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
// Only keep directories that still exist — a deleted/unmounted workspace
|
|
1989
|
+
// is useless in the picker.
|
|
1990
|
+
const projects = [...map.entries()]
|
|
1991
|
+
.filter(([path]) => existsSync(path))
|
|
1992
|
+
.map(([path, lastUsed]) => ({ path, lastUsed }))
|
|
1993
|
+
.sort((a, b) => b.lastUsed - a.lastUsed)
|
|
1994
|
+
.slice(0, 20);
|
|
1995
|
+
this.emit({ type: "projects", projects });
|
|
1996
|
+
}
|
|
1997
|
+
catch {
|
|
1998
|
+
this.emit({ type: "projects", projects: [] });
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
/** List a workspace directory (relative to the configured cwd). */
|
|
2002
|
+
async listFiles(relPath) {
|
|
2003
|
+
return this.files.listFiles(relPath);
|
|
2004
|
+
}
|
|
2005
|
+
/** SCM 只读查询(结构化 JSON,reqId 匹配)。 */
|
|
2006
|
+
async scmQuery(kind, reqId, arg) {
|
|
2007
|
+
return this.files.scmQuery(kind, reqId, arg);
|
|
2008
|
+
}
|
|
2009
|
+
/** Read a workspace file for the preview panel (size-capped, binary-safe). */
|
|
2010
|
+
async readFile(relPath) {
|
|
2011
|
+
return this.files.readFile(relPath);
|
|
2012
|
+
}
|
|
2013
|
+
/** Save text from the file preview panel within the active workspace. */
|
|
2014
|
+
async writeFile(relPath, text) {
|
|
2015
|
+
return this.files.writeFile(relPath, text);
|
|
4661
2016
|
}
|
|
4662
2017
|
async cycleModel() {
|
|
4663
2018
|
try {
|
|
@@ -4677,78 +2032,12 @@ ${transcript}
|
|
|
4677
2032
|
* directory, and return prefix matches (dirs first, capped).
|
|
4678
2033
|
*/
|
|
4679
2034
|
async completePath(input) {
|
|
4680
|
-
|
|
4681
|
-
try {
|
|
4682
|
-
const fs = await import("node:fs/promises");
|
|
4683
|
-
const { resolve, sep, isAbsolute } = await import("node:path");
|
|
4684
|
-
const { homedir } = await import("node:os");
|
|
4685
|
-
const home = homedir();
|
|
4686
|
-
// Expand ~ and relative inputs to an absolute path. Windows users type
|
|
4687
|
-
// backslashes (P:\agent) and ~\ — handle both separator styles.
|
|
4688
|
-
let expanded = input.trim();
|
|
4689
|
-
if (expanded === "") {
|
|
4690
|
-
empty();
|
|
4691
|
-
return;
|
|
4692
|
-
}
|
|
4693
|
-
if (expanded === "~" || expanded === "~\\") {
|
|
4694
|
-
expanded = home;
|
|
4695
|
-
}
|
|
4696
|
-
else if (expanded.startsWith("~/") || expanded.startsWith("~\\")) {
|
|
4697
|
-
expanded = home + sep + expanded.slice(2);
|
|
4698
|
-
}
|
|
4699
|
-
else if (!isAbsolute(expanded)) {
|
|
4700
|
-
expanded = resolve(this.cwd, expanded);
|
|
4701
|
-
}
|
|
4702
|
-
// Split into parent dir + prefix on the LAST separator of either style
|
|
4703
|
-
// (Windows accepts both / and \, so P:\agent/de must work too).
|
|
4704
|
-
const lastSlash = Math.max(expanded.lastIndexOf("/"), expanded.lastIndexOf("\\"));
|
|
4705
|
-
const dirPart = lastSlash >= 0 ? expanded.slice(0, lastSlash + 1) : "";
|
|
4706
|
-
const prefix = lastSlash >= 0 ? expanded.slice(lastSlash + 1) : expanded;
|
|
4707
|
-
const dirents = await fs
|
|
4708
|
-
.readdir(dirPart, { withFileTypes: true })
|
|
4709
|
-
.catch(() => null);
|
|
4710
|
-
if (!dirents) {
|
|
4711
|
-
empty();
|
|
4712
|
-
return;
|
|
4713
|
-
}
|
|
4714
|
-
const completions = dirents
|
|
4715
|
-
.filter((d) => d.name.startsWith(prefix) && !ignoredEntries().has(d.name))
|
|
4716
|
-
.map((d) => ({
|
|
4717
|
-
name: d.name,
|
|
4718
|
-
// Windows users type backslashes — normalize the completion to the
|
|
4719
|
-
// wire format ("/") so the picked path round-trips cleanly.
|
|
4720
|
-
path: IS_WIN32
|
|
4721
|
-
? join(dirPart, d.name).split(sep).join("/")
|
|
4722
|
-
: dirPart + d.name,
|
|
4723
|
-
type: (d.isDirectory() ? "dir" : "file"),
|
|
4724
|
-
}))
|
|
4725
|
-
.sort((a, b) => {
|
|
4726
|
-
const aHidden = a.name.startsWith(".");
|
|
4727
|
-
const bHidden = b.name.startsWith(".");
|
|
4728
|
-
if (aHidden !== bHidden)
|
|
4729
|
-
return aHidden ? 1 : -1;
|
|
4730
|
-
if (a.type !== b.type)
|
|
4731
|
-
return a.type === "dir" ? -1 : 1;
|
|
4732
|
-
return a.name.localeCompare(b.name);
|
|
4733
|
-
})
|
|
4734
|
-
.slice(0, 30);
|
|
4735
|
-
this.emit({ type: "path_completions", completions });
|
|
4736
|
-
}
|
|
4737
|
-
catch {
|
|
4738
|
-
empty();
|
|
4739
|
-
}
|
|
2035
|
+
return this.files.completePath(input);
|
|
4740
2036
|
}
|
|
4741
|
-
/**
|
|
4742
|
-
* Switch the agent's working directory by switching the ACTIVE conversation
|
|
4743
|
-
* to the target project's own most recently active conversation (creating a
|
|
4744
|
-
* fresh one that resumes that project's most recent session on first
|
|
4745
|
-
* visit). Conversations of other projects keep running untouched in their
|
|
4746
|
-
* own per-project lists — nothing is rebuilt, so titles/cwds never leak
|
|
4747
|
-
* between projects.
|
|
4748
|
-
*/
|
|
4749
2037
|
async setCwd(newCwd) {
|
|
4750
2038
|
try {
|
|
4751
2039
|
const { resolve } = await import("node:path");
|
|
2040
|
+
this.files.unwatchGit(); // stale repo's watcher must not fire across projects
|
|
4752
2041
|
const fs = await import("node:fs/promises");
|
|
4753
2042
|
const abs = resolve(newCwd);
|
|
4754
2043
|
const st = await fs.stat(abs);
|
|
@@ -4813,7 +2102,7 @@ ${transcript}
|
|
|
4813
2102
|
void this.pushProjects();
|
|
4814
2103
|
this.webUi.refresh();
|
|
4815
2104
|
this.emitConversations();
|
|
4816
|
-
this.emitGoalStatus();
|
|
2105
|
+
this.goalSvc.emitGoalStatus();
|
|
4817
2106
|
// Skills / prompt templates are project-bound — refresh the catalog.
|
|
4818
2107
|
void this.pushSlashCommands();
|
|
4819
2108
|
this.emit({
|
|
@@ -4860,526 +2149,18 @@ ${transcript}
|
|
|
4860
2149
|
// ---------------------------------------------------------------------------
|
|
4861
2150
|
// Goal / review
|
|
4862
2151
|
// ---------------------------------------------------------------------------
|
|
4863
|
-
/**
|
|
4864
|
-
* UI). Conversations without an active goal reflect the client's remembered
|
|
4865
|
-
* defaults; an existing goal keeps its own review settings. */
|
|
4866
|
-
emitGoalStatus() {
|
|
4867
|
-
const goal = this.goal;
|
|
4868
|
-
if (!goal.goal && !goal.reviewing && !goal.wizard.active) {
|
|
4869
|
-
goal.reviewModel = this.goalReviewPrefs.reviewModel;
|
|
4870
|
-
goal.maxRounds = this.goalReviewPrefs.maxRounds;
|
|
4871
|
-
goal.locked = this.goalReviewPrefs.locked;
|
|
4872
|
-
}
|
|
4873
|
-
this.emit({ type: "goal_status", status: { ...goal } });
|
|
4874
|
-
}
|
|
4875
|
-
/**
|
|
4876
|
-
* Set (or clear) the active goal. `goal === ""` clears it. The goal is
|
|
4877
|
-
* applied to the CURRENT active conversation of this project; reviews check
|
|
4878
|
-
* whatever run finishes next (agent_end).
|
|
4879
|
-
*/
|
|
2152
|
+
/** Goal family delegates to GoalService (see goal-service.ts). */
|
|
4880
2153
|
async setGoal(goalText, opts) {
|
|
4881
|
-
|
|
4882
|
-
if (!text) {
|
|
4883
|
-
await this.clearGoal();
|
|
4884
|
-
return;
|
|
4885
|
-
}
|
|
4886
|
-
// A goal is scoped to the conversation that is active when it is set.
|
|
4887
|
-
// This prevents an agent_end from a newly-created/switched conversation
|
|
4888
|
-
// from consuming the previous conversation's goal.
|
|
4889
|
-
const goalConversationId = this.activeId;
|
|
4890
|
-
this.conv.goalGeneration += 1;
|
|
4891
|
-
this.goal.reviewing = false;
|
|
4892
|
-
this.goal.conversationId = goalConversationId;
|
|
4893
|
-
this.goal.goal = text;
|
|
4894
|
-
// Model & rounds preference semantics ("全局记忆"):
|
|
4895
|
-
// - reviewModel undefined → keep the remembered choice; empty → main model.
|
|
4896
|
-
// - maxRounds 0 = unlimited (default); >0 = finite cap (clamped to 50).
|
|
4897
|
-
if (opts?.reviewModel !== undefined)
|
|
4898
|
-
this.goal.reviewModel = opts.reviewModel || null;
|
|
4899
|
-
if (typeof opts?.maxRounds === "number") {
|
|
4900
|
-
const mr = Math.round(opts.maxRounds);
|
|
4901
|
-
this.goal.maxRounds = mr >= 1 ? Math.min(mr, 50) : 0;
|
|
4902
|
-
}
|
|
4903
|
-
if (opts?.locked !== undefined)
|
|
4904
|
-
this.goal.locked = opts.locked;
|
|
4905
|
-
this.goalReviewPrefs = {
|
|
4906
|
-
reviewModel: this.goal.reviewModel,
|
|
4907
|
-
maxRounds: this.goal.maxRounds,
|
|
4908
|
-
locked: this.goal.locked,
|
|
4909
|
-
};
|
|
4910
|
-
// Persist the chosen preferences so they survive reload.
|
|
4911
|
-
this.stateStore.saveGoalPrefs(this.clientId, {
|
|
4912
|
-
reviewModel: this.goal.reviewModel,
|
|
4913
|
-
maxRounds: this.goal.maxRounds,
|
|
4914
|
-
locked: this.goal.locked,
|
|
4915
|
-
});
|
|
4916
|
-
// Reset the loop for a freshly-set goal (single-shot goals start at 0).
|
|
4917
|
-
this.goal.round = 0;
|
|
4918
|
-
this.goal.reviewing = false;
|
|
4919
|
-
this.goal.verdict = "pending";
|
|
4920
|
-
this.goal.feedback = undefined;
|
|
4921
|
-
this.goal.wizard.active = false;
|
|
4922
|
-
this.goal.wizard.status = "";
|
|
4923
|
-
this.goal.status = "目标已设,等待生成…";
|
|
4924
|
-
this.emitGoalStatus();
|
|
4925
|
-
this.emit({
|
|
4926
|
-
type: "notice",
|
|
4927
|
-
level: "info",
|
|
4928
|
-
text: `🎯 已设目标:${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`,
|
|
4929
|
-
});
|
|
4930
|
-
// Auto-start generation right after setting the goal (unless this setGoal is
|
|
4931
|
-
// the wizard's internal one, which kicks off itself). This makes the direct
|
|
4932
|
-
// goal-bar path behave like the AI-提炼 path: set a target → agent begins.
|
|
4933
|
-
if (opts?.autoStart !== false) {
|
|
4934
|
-
try {
|
|
4935
|
-
const s = this.conv.session;
|
|
4936
|
-
await s.sendUserMessage(`【目标已设定】\n\n${text}\n\n请现在开始实现这个目标。`, { deliverAs: s.isStreaming ? "steer" : "followUp" });
|
|
4937
|
-
}
|
|
4938
|
-
catch {
|
|
4939
|
-
// Best-effort; the user can still prompt manually.
|
|
4940
|
-
}
|
|
4941
|
-
this.flushSnapshot();
|
|
4942
|
-
}
|
|
2154
|
+
return this.goalSvc.setGoal(goalText, opts);
|
|
4943
2155
|
}
|
|
4944
|
-
/**
|
|
4945
|
-
* Collaborative target wizard. Turns a raw user requirement into a refined
|
|
4946
|
-
* goal by spinning up an ISOLATED wizard session (own fresh ModelRuntime +
|
|
4947
|
-
* in-memory session, so its model choice is its own) that questions the user
|
|
4948
|
-
* via `goal_ask` (multiple-choice + free-text, bridged to the browser through
|
|
4949
|
-
* the existing select/input dialog), converging on a goal, then auto-sets it.
|
|
4950
|
-
* Mutually exclusive with the review loop of the same conversation.
|
|
4951
|
-
*/
|
|
4952
2156
|
async startGoalWizard(text, opts) {
|
|
4953
|
-
|
|
4954
|
-
return;
|
|
4955
|
-
const draft = (text ?? "").trim();
|
|
4956
|
-
if (!draft)
|
|
4957
|
-
return;
|
|
4958
|
-
// The wizard and its progress cards belong to the conversation that
|
|
4959
|
-
// launched it. If the user switches away, do not later set a goal on the
|
|
4960
|
-
// new active conversation while the wizard is still finishing.
|
|
4961
|
-
const wizardConversationId = this.activeId;
|
|
4962
|
-
const wizardConversation = this.conv;
|
|
4963
|
-
if (wizardConversation.wizardRunning || this.wizardOwnerId !== null) {
|
|
4964
|
-
this.emit({
|
|
4965
|
-
type: "notice",
|
|
4966
|
-
level: "warning",
|
|
4967
|
-
text: "已有目标调研进行中,请等它完成…",
|
|
4968
|
-
});
|
|
4969
|
-
return;
|
|
4970
|
-
}
|
|
4971
|
-
if (wizardConversation.goal.reviewing) {
|
|
4972
|
-
this.emit({
|
|
4973
|
-
type: "notice",
|
|
4974
|
-
level: "warning",
|
|
4975
|
-
text: "正在审查中,无法开始目标调研,请稍等…",
|
|
4976
|
-
});
|
|
4977
|
-
return;
|
|
4978
|
-
}
|
|
4979
|
-
// Questions are NOT capped (调研不限制) — the wizard converges on its own;
|
|
4980
|
-
// the idle- and total-timeouts are the only guards. maxSteps is purely a
|
|
4981
|
-
// soft UI indicator, not a hard stop.
|
|
4982
|
-
const maxSteps = 20;
|
|
4983
|
-
wizardConversation.wizardRunning = true;
|
|
4984
|
-
this.wizardOwnerId = wizardConversationId;
|
|
4985
|
-
this.wizardCancelled = false;
|
|
4986
|
-
this.wizardAbort = new AbortController();
|
|
4987
|
-
this.wizardSession = null;
|
|
4988
|
-
wizardConversation.goal.wizard.active = true;
|
|
4989
|
-
wizardConversation.goal.wizard.draft = draft;
|
|
4990
|
-
wizardConversation.goal.wizard.model = opts?.wizardModel ?? null;
|
|
4991
|
-
// Remember the model choice (and persist rounds/lock) — global memory.
|
|
4992
|
-
if (opts?.wizardModel !== undefined && opts.wizardModel !== null)
|
|
4993
|
-
wizardConversation.goal.reviewModel = opts.wizardModel || null;
|
|
4994
|
-
if (typeof opts?.maxRounds === "number") {
|
|
4995
|
-
const mr = Math.round(opts.maxRounds);
|
|
4996
|
-
wizardConversation.goal.maxRounds = mr >= 1 ? Math.min(mr, 50) : 0;
|
|
4997
|
-
}
|
|
4998
|
-
if (opts?.locked !== undefined)
|
|
4999
|
-
wizardConversation.goal.locked = opts.locked;
|
|
5000
|
-
this.goalReviewPrefs = {
|
|
5001
|
-
reviewModel: wizardConversation.goal.reviewModel,
|
|
5002
|
-
maxRounds: wizardConversation.goal.maxRounds,
|
|
5003
|
-
locked: wizardConversation.goal.locked,
|
|
5004
|
-
};
|
|
5005
|
-
this.stateStore.saveGoalPrefs(this.clientId, {
|
|
5006
|
-
reviewModel: wizardConversation.goal.reviewModel,
|
|
5007
|
-
maxRounds: wizardConversation.goal.maxRounds,
|
|
5008
|
-
locked: wizardConversation.goal.locked,
|
|
5009
|
-
});
|
|
5010
|
-
wizardConversation.goal.wizard.step = 0;
|
|
5011
|
-
wizardConversation.goal.wizard.maxSteps = maxSteps;
|
|
5012
|
-
wizardConversation.goal.wizard.status = "调研中…";
|
|
5013
|
-
wizardConversation.goal.status = "目标调研中…";
|
|
5014
|
-
this.emitGoalStatus();
|
|
5015
|
-
// Idle-timeout: cancel the wizard if no question is answered within the
|
|
5016
|
-
// window (a stale dialog with no user response must not run forever). A
|
|
5017
|
-
// fresh timer is armed for each question; cleared once the run ends.
|
|
5018
|
-
const ac = this.wizardAbort;
|
|
5019
|
-
let idleTimer = null;
|
|
5020
|
-
const armIdle = () => {
|
|
5021
|
-
if (idleTimer)
|
|
5022
|
-
clearTimeout(idleTimer);
|
|
5023
|
-
idleTimer = setTimeout(() => {
|
|
5024
|
-
if (!ac.signal.aborted) {
|
|
5025
|
-
this.wizardCancelled = true;
|
|
5026
|
-
ac.abort(new Error("目标调研超时(等待回答过久)"));
|
|
5027
|
-
}
|
|
5028
|
-
}, ClientSession.WIZARD_IDLE_TIMEOUT_MS);
|
|
5029
|
-
idleTimer.unref?.();
|
|
5030
|
-
};
|
|
5031
|
-
const clearIdle = () => {
|
|
5032
|
-
if (idleTimer) {
|
|
5033
|
-
clearTimeout(idleTimer);
|
|
5034
|
-
idleTimer = null;
|
|
5035
|
-
}
|
|
5036
|
-
};
|
|
5037
|
-
armIdle();
|
|
5038
|
-
// Total-duration guard: hard cap on the whole wizard session (model
|
|
5039
|
-
// latency / unexpected loops must not run forever).
|
|
5040
|
-
const totalTimer = setTimeout(() => {
|
|
5041
|
-
if (!ac.signal.aborted) {
|
|
5042
|
-
this.wizardCancelled = true;
|
|
5043
|
-
ac.abort(new Error("目标调研超过总时长上限"));
|
|
5044
|
-
}
|
|
5045
|
-
}, ClientSession.WIZARD_MAX_TOTAL_MS);
|
|
5046
|
-
totalTimer.unref?.();
|
|
5047
|
-
this.emit({
|
|
5048
|
-
type: "notice",
|
|
5049
|
-
level: "info",
|
|
5050
|
-
text: `🔍 正在围绕需求展开调研:${draft.slice(0, 60)}${draft.length > 60 ? "…" : ""}`,
|
|
5051
|
-
});
|
|
5052
|
-
// The main conversation to show wizard progress cards in.
|
|
5053
|
-
const mainSession = wizardConversation.session;
|
|
5054
|
-
let refinedGoal = "";
|
|
5055
|
-
try {
|
|
5056
|
-
const wmSpec = opts?.wizardModel
|
|
5057
|
-
? this.resolveReviewModel(opts.wizardModel)
|
|
5058
|
-
: null; // reuse the honest "provider/id" parser
|
|
5059
|
-
const services = await createAgentSessionServices({
|
|
5060
|
-
cwd: wizardConversation.cwd,
|
|
5061
|
-
agentDir: this.agentDir,
|
|
5062
|
-
modelRuntime: await ModelRuntime.create({
|
|
5063
|
-
authPath: join(this.agentDir, "auth.json"),
|
|
5064
|
-
modelsPath: join(this.agentDir, "models.json"),
|
|
5065
|
-
}),
|
|
5066
|
-
});
|
|
5067
|
-
let model;
|
|
5068
|
-
if (wmSpec)
|
|
5069
|
-
model = services.modelRuntime.getModel(wmSpec.provider, wmSpec.id);
|
|
5070
|
-
if (!model) {
|
|
5071
|
-
const mainModel = mainSession.model;
|
|
5072
|
-
if (mainModel?.provider && mainModel.id)
|
|
5073
|
-
model = services.modelRuntime.getModel(mainModel.provider, mainModel.id);
|
|
5074
|
-
}
|
|
5075
|
-
// The wizard asks the user questions via this tool; each call bridges one
|
|
5076
|
-
// select/input dialog to the browser and returns the user's answer.
|
|
5077
|
-
let qStep = 0;
|
|
5078
|
-
const goalAsk = defineTool({
|
|
5079
|
-
name: "goal_ask",
|
|
5080
|
-
label: "Ask the user",
|
|
5081
|
-
description: "Ask the user ONE question at a time to scope down the goal. Provide a clear question and 2-4 concise options; or ask an open question. Returns the user's chosen answer.",
|
|
5082
|
-
parameters: Type.Object({
|
|
5083
|
-
question: Type.String({ description: "The question to ask" }),
|
|
5084
|
-
options: Type.Optional(Type.Array(Type.String())),
|
|
5085
|
-
}),
|
|
5086
|
-
// ONE question at a time. Sequential execution prevents the agent from
|
|
5087
|
-
// firing parallel goal_ask calls whose dialogs would overwrite each other
|
|
5088
|
-
// in the single browser modal (leaving earlier ones deadlocked — the
|
|
5089
|
-
// reported "调研卡住").
|
|
5090
|
-
executionMode: "sequential",
|
|
5091
|
-
execute: async (_id, params, _sig, _onUpdate, ctx) => {
|
|
5092
|
-
qStep += 1;
|
|
5093
|
-
if (qStep > maxSteps) {
|
|
5094
|
-
return {
|
|
5095
|
-
content: [
|
|
5096
|
-
{
|
|
5097
|
-
type: "text",
|
|
5098
|
-
text: "(达到最大提问数,请直接给出收敛后的目标文本作为最终答案)",
|
|
5099
|
-
},
|
|
5100
|
-
],
|
|
5101
|
-
details: {},
|
|
5102
|
-
};
|
|
5103
|
-
}
|
|
5104
|
-
// Show the question in the main flow BEFORE blocking on the dialog, so
|
|
5105
|
-
// the user sees the wizard working even before answering.
|
|
5106
|
-
wizardConversation.goal.wizard.step = qStep;
|
|
5107
|
-
wizardConversation.goal.wizard.status = `调研中:请回答第 ${qStep} 题`;
|
|
5108
|
-
this.emitGoalStatus();
|
|
5109
|
-
try {
|
|
5110
|
-
armIdle();
|
|
5111
|
-
const isChoice = !!(params.options && params.options.length > 0);
|
|
5112
|
-
await this.pushWizardCard(mainSession, `🔍 第 ${qStep} 题:${params.question}${isChoice ? `【${params.options.join(" / ")}】` : ""}`, { question: params.question });
|
|
5113
|
-
// Resolve the pending dialog as cancelled if the wizard is aborted.
|
|
5114
|
-
let aborted = false;
|
|
5115
|
-
const onAbort = () => {
|
|
5116
|
-
aborted = true;
|
|
5117
|
-
};
|
|
5118
|
-
ac.signal.addEventListener("abort", onAbort, { once: true });
|
|
5119
|
-
const choose = isChoice
|
|
5120
|
-
? ctx.ui.select(`🔍 第 ${qStep} 题:${params.question}`, params.options)
|
|
5121
|
-
: ctx.ui.input(`🔍 第 ${qStep} 题:${params.question}`);
|
|
5122
|
-
const ans = (await choose);
|
|
5123
|
-
ac.signal.removeEventListener("abort", onAbort);
|
|
5124
|
-
if (aborted || ac.signal.aborted) {
|
|
5125
|
-
return {
|
|
5126
|
-
content: [
|
|
5127
|
-
{
|
|
5128
|
-
type: "text",
|
|
5129
|
-
text: "(调研已取消,请不要继续提问,直接结束对话)",
|
|
5130
|
-
},
|
|
5131
|
-
],
|
|
5132
|
-
details: {},
|
|
5133
|
-
};
|
|
5134
|
-
}
|
|
5135
|
-
if (ans === undefined || ans === null || ans === false || ans === "") {
|
|
5136
|
-
return {
|
|
5137
|
-
content: [
|
|
5138
|
-
{
|
|
5139
|
-
type: "text",
|
|
5140
|
-
text: "(用户已取消调研,请直接给出你当前收敛的目标文本作为最终答案)",
|
|
5141
|
-
},
|
|
5142
|
-
],
|
|
5143
|
-
details: {},
|
|
5144
|
-
};
|
|
5145
|
-
}
|
|
5146
|
-
// Record the answer in the flow too (instant append, main session idle).
|
|
5147
|
-
await this.pushWizardCard(mainSession, `↳ 您的回答:${ans}`, { question: params.question, answer: String(ans) });
|
|
5148
|
-
return {
|
|
5149
|
-
content: [{ type: "text", text: `用户回答:${ans}` }],
|
|
5150
|
-
details: {},
|
|
5151
|
-
};
|
|
5152
|
-
}
|
|
5153
|
-
catch (err) {
|
|
5154
|
-
return {
|
|
5155
|
-
content: [
|
|
5156
|
-
{
|
|
5157
|
-
type: "text",
|
|
5158
|
-
text: ac.signal.aborted
|
|
5159
|
-
? "(调研已取消,请不要继续提问,直接结束对话)"
|
|
5160
|
-
: `提问失败:${err.message}`,
|
|
5161
|
-
},
|
|
5162
|
-
],
|
|
5163
|
-
details: {},
|
|
5164
|
-
};
|
|
5165
|
-
}
|
|
5166
|
-
},
|
|
5167
|
-
});
|
|
5168
|
-
const srv = await createAgentSessionFromServices({
|
|
5169
|
-
services,
|
|
5170
|
-
sessionManager: SessionManager.inMemory(this.cwd),
|
|
5171
|
-
customTools: [goalAsk],
|
|
5172
|
-
...(model ? { model } : {}),
|
|
5173
|
-
});
|
|
5174
|
-
const wizard = srv.session;
|
|
5175
|
-
this.wizardSession = wizard;
|
|
5176
|
-
await wizard.bindExtensions({ mode: "rpc", uiContext: this.webUi });
|
|
5177
|
-
// Cancel watcher: when the user ✗s / idle-timeout fires, truly stop the
|
|
5178
|
-
// wizard's agent run (not just mark it).
|
|
5179
|
-
if (!ac.signal.aborted) {
|
|
5180
|
-
ac.signal.addEventListener("abort", () => {
|
|
5181
|
-
void wizard.abort().catch(() => { });
|
|
5182
|
-
// Close the unanswered browser dialog(s) the wizard may have up.
|
|
5183
|
-
this.webUi.cancelPendingDialogs();
|
|
5184
|
-
}, { once: true });
|
|
5185
|
-
}
|
|
5186
|
-
await wizard.prompt(wizardPrompt(draft));
|
|
5187
|
-
refinedGoal = wizard.getLastAssistantText()?.trim() ?? "";
|
|
5188
|
-
// The wizard is prompted to emit "GOAL: <text>". Parse past the marker;
|
|
5189
|
-
// if it didn't follow, strip a leading preamble line and keep the rest.
|
|
5190
|
-
const goalMatch = refinedGoal.match(/GOAL\s*[::]\s*([\s\S]*)/i);
|
|
5191
|
-
if (goalMatch) {
|
|
5192
|
-
refinedGoal = goalMatch[1].trim();
|
|
5193
|
-
}
|
|
5194
|
-
else {
|
|
5195
|
-
const lines = refinedGoal.split("\n").filter((l) => l.trim());
|
|
5196
|
-
if (lines.length > 1 && !/[。.!??]\s*$/.test(lines[0])) {
|
|
5197
|
-
// First line looks like preamble (no sentence-ending punctuation).
|
|
5198
|
-
refinedGoal = lines.slice(1).join(" ").trim();
|
|
5199
|
-
}
|
|
5200
|
-
}
|
|
5201
|
-
await srv.session.dispose();
|
|
5202
|
-
}
|
|
5203
|
-
catch (err) {
|
|
5204
|
-
this.emit({
|
|
5205
|
-
type: "notice",
|
|
5206
|
-
level: "error",
|
|
5207
|
-
text: `目标调研失败:${err.message}`,
|
|
5208
|
-
});
|
|
5209
|
-
}
|
|
5210
|
-
finally {
|
|
5211
|
-
clearIdle();
|
|
5212
|
-
clearTimeout(totalTimer);
|
|
5213
|
-
wizardConversation.wizardRunning = false;
|
|
5214
|
-
if (this.wizardOwnerId === wizardConversationId)
|
|
5215
|
-
this.wizardOwnerId = null;
|
|
5216
|
-
wizardConversation.goal.wizard.active = false;
|
|
5217
|
-
wizardConversation.goal.wizard.step = 0;
|
|
5218
|
-
wizardConversation.goal.wizard.status = "";
|
|
5219
|
-
this.wizardSession = null;
|
|
5220
|
-
this.emitGoalStatus();
|
|
5221
|
-
}
|
|
5222
|
-
// Aborted externally (✗ / clear_goal / idle-timeout): do NOT set a goal.
|
|
5223
|
-
if (ac.signal.aborted || this.wizardCancelled) {
|
|
5224
|
-
this.emit({
|
|
5225
|
-
type: "notice",
|
|
5226
|
-
level: "info",
|
|
5227
|
-
text: `目标调研已取消${ac.signal.reason ? `:${String(ac.signal.reason?.message ?? ac.signal.reason)}` : ""}`,
|
|
5228
|
-
});
|
|
5229
|
-
this.wizardAbort = null;
|
|
5230
|
-
return;
|
|
5231
|
-
}
|
|
5232
|
-
if (!refinedGoal.trim()) {
|
|
5233
|
-
this.emit({
|
|
5234
|
-
type: "notice",
|
|
5235
|
-
level: "warning",
|
|
5236
|
-
text: "调研未产出有效目标,请重试",
|
|
5237
|
-
});
|
|
5238
|
-
return;
|
|
5239
|
-
}
|
|
5240
|
-
if (this.activeId !== wizardConversationId) {
|
|
5241
|
-
this.emit({
|
|
5242
|
-
type: "notice",
|
|
5243
|
-
level: "info",
|
|
5244
|
-
text: "已切换对话,目标调研结果已丢弃",
|
|
5245
|
-
});
|
|
5246
|
-
return;
|
|
5247
|
-
}
|
|
5248
|
-
// Auto-set the refined goal. The wizard workflow implies "set a goal and
|
|
5249
|
-
// work until it passes", so default LOCKED=true unless the user explicitly
|
|
5250
|
-
// turned the lock off (a lock lets the review loop keep revising to pass;
|
|
5251
|
-
// without it the review is single-shot).
|
|
5252
|
-
const wantLocked = opts?.locked === undefined ? true : opts.locked;
|
|
5253
|
-
await this.setGoal(refinedGoal, {
|
|
5254
|
-
reviewModel: wizardConversation.goal.reviewModel ?? undefined,
|
|
5255
|
-
maxRounds: opts?.maxRounds,
|
|
5256
|
-
locked: wantLocked,
|
|
5257
|
-
// The wizard kicks off generation itself below — avoid a double kick.
|
|
5258
|
-
autoStart: false,
|
|
5259
|
-
});
|
|
5260
|
-
const g2 = wizardConversation.goal;
|
|
5261
|
-
this.wizardCancelled = false;
|
|
5262
|
-
this.wizardAbort = null;
|
|
5263
|
-
this.emit({
|
|
5264
|
-
type: "notice",
|
|
5265
|
-
level: "info",
|
|
5266
|
-
text: `🎯 调研完成,目标已设为:${refinedGoal.slice(0, 80)}${refinedGoal.length > 80 ? "…" : ""}`,
|
|
5267
|
-
});
|
|
5268
|
-
// Kick the main agent into generating right away (no manual "开始吧").
|
|
5269
|
-
// The kick-off is a user message so it appears in the flow and triggers a
|
|
5270
|
-
// normal turn; the finishing agent_end then runs the review loop.
|
|
5271
|
-
try {
|
|
5272
|
-
await mainSession.sendUserMessage(`【目标已设定】\n\n${g2.goal}\n\n请现在开始实现这个目标。`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
|
|
5273
|
-
}
|
|
5274
|
-
catch {
|
|
5275
|
-
// Generation kick-off is best-effort; the user can still prompt manually.
|
|
5276
|
-
}
|
|
2157
|
+
return this.goalSvc.startGoalWizard(text, opts);
|
|
5277
2158
|
}
|
|
5278
|
-
/** Persist goal/review preference defaults (model, rounds cap, locked) without
|
|
5279
|
-
* touching the active goal — so changes in the goal bar are remembered across
|
|
5280
|
-
* reloads. maxRounds 0 = unlimited. Emits goal_status so the UI stays synced. */
|
|
5281
2159
|
async setGoalPrefs(opts) {
|
|
5282
|
-
|
|
5283
|
-
this.goal.reviewModel = opts.reviewModel || null;
|
|
5284
|
-
if (typeof opts?.maxRounds === "number") {
|
|
5285
|
-
const mr = Math.round(opts.maxRounds);
|
|
5286
|
-
this.goal.maxRounds = mr >= 1 ? Math.min(mr, 50) : 0;
|
|
5287
|
-
}
|
|
5288
|
-
if (opts?.locked !== undefined)
|
|
5289
|
-
this.goal.locked = opts.locked;
|
|
5290
|
-
this.goalReviewPrefs = {
|
|
5291
|
-
reviewModel: this.goal.reviewModel,
|
|
5292
|
-
maxRounds: this.goal.maxRounds,
|
|
5293
|
-
locked: this.goal.locked,
|
|
5294
|
-
};
|
|
5295
|
-
this.stateStore.saveGoalPrefs(this.clientId, {
|
|
5296
|
-
reviewModel: this.goal.reviewModel,
|
|
5297
|
-
maxRounds: this.goal.maxRounds,
|
|
5298
|
-
locked: this.goal.locked,
|
|
5299
|
-
});
|
|
5300
|
-
this.emitGoalStatus();
|
|
2160
|
+
return this.goalSvc.setGoalPrefs(opts);
|
|
5301
2161
|
}
|
|
5302
|
-
/** Clear the active goal (cancels the review loop AND aborts a running
|
|
5303
|
-
* goal wizard — truly terminating its in-flight dialog + agent run). */
|
|
5304
2162
|
async clearGoal() {
|
|
5305
|
-
this.
|
|
5306
|
-
this.goal.reviewing = false;
|
|
5307
|
-
this.goal.conversationId = null;
|
|
5308
|
-
this.goal.goal = null;
|
|
5309
|
-
this.goal.reviewing = false;
|
|
5310
|
-
this.goal.verdict = "pending";
|
|
5311
|
-
this.goal.feedback = undefined;
|
|
5312
|
-
this.goal.wizard.active = false;
|
|
5313
|
-
this.goal.wizard.status = "";
|
|
5314
|
-
this.goal.status = "";
|
|
5315
|
-
this.emitGoalStatus();
|
|
5316
|
-
// Abort a running wizard for real (✗ in the goal bar while scoping).
|
|
5317
|
-
if (this.wizardOwnerId === this.activeId) {
|
|
5318
|
-
this.wizardCancelled = true;
|
|
5319
|
-
this.webUi.cancelPendingDialogs();
|
|
5320
|
-
this.wizardAbort?.abort();
|
|
5321
|
-
const ws2 = this.wizardSession;
|
|
5322
|
-
this.wizardSession = null;
|
|
5323
|
-
if (ws2) {
|
|
5324
|
-
await ws2.abort().catch(() => { });
|
|
5325
|
-
ws2.dispose();
|
|
5326
|
-
}
|
|
5327
|
-
this.wizardAbort = null;
|
|
5328
|
-
}
|
|
5329
|
-
}
|
|
5330
|
-
/** Build a "provider/id" or null for the reviewer model, validating it exists. */
|
|
5331
|
-
resolveReviewModel(spec) {
|
|
5332
|
-
if (!spec)
|
|
5333
|
-
return null;
|
|
5334
|
-
const slash = spec.indexOf("/");
|
|
5335
|
-
if (slash <= 0 || slash === spec.length - 1)
|
|
5336
|
-
return null;
|
|
5337
|
-
return { provider: spec.slice(0, slash), id: spec.slice(slash + 1), spec };
|
|
5338
|
-
}
|
|
5339
|
-
/**
|
|
5340
|
-
* The whitelisted reviewer plan — tell the reviewer what to decide and how
|
|
5341
|
-
* to report, regardless of which model it runs on.
|
|
5342
|
-
*/
|
|
5343
|
-
reviewerPrompt(goal, round, maxRounds, output, gitDiff, customPrompt = "") {
|
|
5344
|
-
return [
|
|
5345
|
-
`You are a strict, independent goal-reviewer. Your ONLY job is to judge whether the agent's work fully satisfies the stated goal, by checking the agent's final output and, when present, its git diff.`, // eslint-disable-line max-len
|
|
5346
|
-
``,
|
|
5347
|
-
`# Goal`, // eslint-disable-line no-regex-spaces
|
|
5348
|
-
goal,
|
|
5349
|
-
``,
|
|
5350
|
-
`# Agent's final output`, // eslint-disable-line no-regex-spaces
|
|
5351
|
-
output.length > 0 ? output : "(the agent produced no text — inspect the diff)", // eslint-disable-line max-len
|
|
5352
|
-
``,
|
|
5353
|
-
`# Git diff (if any)`, // eslint-disable-line no-regex-spaces
|
|
5354
|
-
gitDiff.length > 0 ? gitDiff : "(no staged/committed changes detected)", // eslint-disable-line max-len
|
|
5355
|
-
``,
|
|
5356
|
-
`This is review round ${round}${maxRounds > 0 ? ` of up to ${maxRounds}` : " (no round cap — keep revising until it passes)"}.`, // eslint-disable-line max-len
|
|
5357
|
-
...(customPrompt.trim()
|
|
5358
|
-
? [``, `# Additional reviewer instructions`, customPrompt.trim()]
|
|
5359
|
-
: []),
|
|
5360
|
-
``,
|
|
5361
|
-
`Decide: does the work satisfy the goal? If yes, respond with ONLY a JSON object with this exact shape (no markdown fences, no extra text):`, // eslint-disable-line max-len
|
|
5362
|
-
`{"verdict":"pass","feedback":"<one short sentence: what was satisfied>"}`, // eslint-disable-line max-len
|
|
5363
|
-
`If NO, respond with ONLY: {"verdict":"fail","feedback":"<concise, actionable list of what the agent must fix to satisfy the goal>"}`, // eslint-disable-line max-len
|
|
5364
|
-
`The feedback for a fail must be specific enough that the agent can act on it directly.`, // eslint-disable-line max-len
|
|
5365
|
-
].join("\n");
|
|
5366
|
-
}
|
|
5367
|
-
/** Insert a wizard progress card into the MAIN conversation flow and render it
|
|
5368
|
-
* IMMEDIATELY (the main session is idle while the wizard runs in its own
|
|
5369
|
-
* session, so — unlike nextTurn, which queues until the next user prompt —
|
|
5370
|
-
* sending without a delivery option appends + persists + emits at once). */
|
|
5371
|
-
async pushWizardCard(sess, text, details) {
|
|
5372
|
-
try {
|
|
5373
|
-
await sess.sendCustomMessage({
|
|
5374
|
-
customType: "goal-wizard",
|
|
5375
|
-
content: [{ type: "text", text }],
|
|
5376
|
-
display: true,
|
|
5377
|
-
details: { type: "goal-wizard", ...details },
|
|
5378
|
-
});
|
|
5379
|
-
}
|
|
5380
|
-
catch {
|
|
5381
|
-
// Non-fatal
|
|
5382
|
-
}
|
|
2163
|
+
return this.goalSvc.clearGoal();
|
|
5383
2164
|
}
|
|
5384
2165
|
/** Run a git diff (unstaged + staged) in a conversation's workspace, or
|
|
5385
2166
|
* "" when not a repo. */
|
|
@@ -5394,228 +2175,6 @@ ${transcript}
|
|
|
5394
2175
|
return "";
|
|
5395
2176
|
}
|
|
5396
2177
|
}
|
|
5397
|
-
/**
|
|
5398
|
-
* The review loop: build an ISOLATED reviewer session (own fresh
|
|
5399
|
-
* AgentSession + own ModelRuntime so the reviewer truly runs on a different
|
|
5400
|
-
* model without touching the main session), ask it to judge the goal, then:
|
|
5401
|
-
* - pass → set status "已通过", insert a verdict card, end the loop;
|
|
5402
|
-
* - fail → inject the feedback as a user message into the main session
|
|
5403
|
-
* to steer a revision; the next agent_end re-reviews with the
|
|
5404
|
-
* same round budget.
|
|
5405
|
-
* Guarded per conversation so separate conversations can review concurrently.
|
|
5406
|
-
*/
|
|
5407
|
-
isCurrentGoalReview(conv, goalGeneration, reviewGeneration) {
|
|
5408
|
-
return (!this.disposed &&
|
|
5409
|
-
this.convs.get(conv.id) === conv &&
|
|
5410
|
-
conv.goal.conversationId === conv.id &&
|
|
5411
|
-
conv.goalGeneration === goalGeneration &&
|
|
5412
|
-
conv.goalReviewGeneration === reviewGeneration &&
|
|
5413
|
-
!!conv.goal.goal);
|
|
5414
|
-
}
|
|
5415
|
-
/** Drop the result of a review that became stale while it was awaiting the
|
|
5416
|
-
* reviewer model (most commonly because the user switched conversations). */
|
|
5417
|
-
discardStaleGoalReview(conv, goalGeneration, reviewGeneration) {
|
|
5418
|
-
if (conv.goalReviewGeneration !== reviewGeneration)
|
|
5419
|
-
return;
|
|
5420
|
-
if (conv.goalGeneration === goalGeneration &&
|
|
5421
|
-
conv.goal.conversationId === conv.id) {
|
|
5422
|
-
conv.goal.reviewing = false;
|
|
5423
|
-
conv.goal.status = "审查已中止,目标已更新或取消";
|
|
5424
|
-
this.emitGoalStatus();
|
|
5425
|
-
}
|
|
5426
|
-
}
|
|
5427
|
-
async runGoalReview(conv) {
|
|
5428
|
-
// The review is bound to the conversation that just ran. Capture both the
|
|
5429
|
-
// owner and a generation so a later switch/set/clear cannot let an old,
|
|
5430
|
-
// asynchronous reviewer mutate the new conversation's goal state.
|
|
5431
|
-
const mainConv = this.convs.get(conv.id) ?? conv;
|
|
5432
|
-
const mainSession = mainConv.session;
|
|
5433
|
-
const g = conv.goal;
|
|
5434
|
-
if (!g.goal ||
|
|
5435
|
-
g.conversationId !== conv.id ||
|
|
5436
|
-
g.reviewing ||
|
|
5437
|
-
conv.wizardRunning ||
|
|
5438
|
-
this.disposed)
|
|
5439
|
-
return;
|
|
5440
|
-
const goalGeneration = conv.goalGeneration;
|
|
5441
|
-
const reviewGeneration = ++conv.goalReviewGeneration;
|
|
5442
|
-
// Narrowed copy — TS control-flow can't narrow `g.goal` (a mutable shared
|
|
5443
|
-
// object field) through the entire async body, so capture it here.
|
|
5444
|
-
const goalText = g.goal;
|
|
5445
|
-
// Capture review-only settings for this run. Changing settings while a
|
|
5446
|
-
// review is in flight affects the next review, never this one.
|
|
5447
|
-
const reviewPrompt = this.settings.reviewPrompt;
|
|
5448
|
-
const reviewDisabledSkills = new Set(this.settings.reviewDisabledSkills);
|
|
5449
|
-
// Cap rounds: single-shot (locked=false) always exactly one review.
|
|
5450
|
-
// For locked goals, maxRounds 0 = unlimited (keep revising until pass).
|
|
5451
|
-
const budget = g.locked ? (g.maxRounds > 0 ? g.maxRounds : Infinity) : 1;
|
|
5452
|
-
if (g.locked && g.maxRounds > 0 && g.round >= budget) {
|
|
5453
|
-
g.status = `已达最大轮数(${budget}),停止审查`;
|
|
5454
|
-
g.reviewing = false;
|
|
5455
|
-
this.emitGoalStatus();
|
|
5456
|
-
return;
|
|
5457
|
-
}
|
|
5458
|
-
g.reviewing = true;
|
|
5459
|
-
g.round += 1;
|
|
5460
|
-
g.verdict = "pending";
|
|
5461
|
-
g.feedback = undefined;
|
|
5462
|
-
g.status = `审查中(第 ${g.round} 轮)…`;
|
|
5463
|
-
this.emitGoalStatus();
|
|
5464
|
-
// Collect the review inputs.
|
|
5465
|
-
let finalText = "";
|
|
5466
|
-
try {
|
|
5467
|
-
finalText = mainSession.getLastAssistantText() ?? "";
|
|
5468
|
-
}
|
|
5469
|
-
catch {
|
|
5470
|
-
finalText = "";
|
|
5471
|
-
}
|
|
5472
|
-
const diff = await this.gitDiff(mainConv.cwd);
|
|
5473
|
-
if (!this.isCurrentGoalReview(conv, goalGeneration, reviewGeneration)) {
|
|
5474
|
-
this.discardStaleGoalReview(conv, goalGeneration, reviewGeneration);
|
|
5475
|
-
return;
|
|
5476
|
-
}
|
|
5477
|
-
let reviewerVerdict = "fail";
|
|
5478
|
-
let reviewerFeedback = "(审查无法完成)";
|
|
5479
|
-
try {
|
|
5480
|
-
const rmSpec = this.resolveReviewModel(g.reviewModel);
|
|
5481
|
-
const services = await createAgentSessionServices({
|
|
5482
|
-
cwd: mainConv.cwd,
|
|
5483
|
-
agentDir: this.agentDir,
|
|
5484
|
-
// The reviewer has its own skill allow/deny list. It deliberately does
|
|
5485
|
-
// not reuse the main session's disabledSkills setting.
|
|
5486
|
-
resourceLoaderOptions: {
|
|
5487
|
-
skillsOverride: (res) => ({
|
|
5488
|
-
...res,
|
|
5489
|
-
skills: res.skills.filter((s) => !reviewDisabledSkills.has(s.name)),
|
|
5490
|
-
}),
|
|
5491
|
-
},
|
|
5492
|
-
// A FRESH ModelRuntime for the reviewer — isolated from the shared
|
|
5493
|
-
// one used by the main conversations, so its model choice is its own.
|
|
5494
|
-
modelRuntime: await ModelRuntime.create({
|
|
5495
|
-
authPath: join(this.agentDir, "auth.json"),
|
|
5496
|
-
modelsPath: join(this.agentDir, "models.json"),
|
|
5497
|
-
}),
|
|
5498
|
-
});
|
|
5499
|
-
// Model resolution: explicit reviewer model, else the main session's
|
|
5500
|
-
// current model (so a goal works even when no reviewer model is given).
|
|
5501
|
-
let model;
|
|
5502
|
-
if (rmSpec) {
|
|
5503
|
-
model = services.modelRuntime.getModel(rmSpec.provider, rmSpec.id);
|
|
5504
|
-
}
|
|
5505
|
-
if (!model) {
|
|
5506
|
-
const mainModel = mainSession.model;
|
|
5507
|
-
if (mainModel?.provider && mainModel.id) {
|
|
5508
|
-
model = services.modelRuntime.getModel(mainModel.provider, mainModel.id);
|
|
5509
|
-
}
|
|
5510
|
-
}
|
|
5511
|
-
const srv = await createAgentSessionFromServices({
|
|
5512
|
-
services,
|
|
5513
|
-
sessionManager: SessionManager.inMemory(mainConv.cwd),
|
|
5514
|
-
...(model ? { model } : {}),
|
|
5515
|
-
});
|
|
5516
|
-
const reviewCap = g.locked && g.maxRounds > 0 ? g.maxRounds : 0; // 0 = no cap
|
|
5517
|
-
const reviewer = srv.session;
|
|
5518
|
-
await reviewer.prompt(this.reviewerPrompt(goalText, g.round, reviewCap, finalText, diff, reviewPrompt));
|
|
5519
|
-
// Parse the reviewer's final output (expected to be a JSON object).
|
|
5520
|
-
const raw = reviewer.getLastAssistantText() ?? "";
|
|
5521
|
-
const m = raw.match(/\{\s*"verdict"\s*:\s*"(pass|fail)"[^}]*\}/);
|
|
5522
|
-
if (m) {
|
|
5523
|
-
reviewerVerdict = m[1];
|
|
5524
|
-
const fm = raw.match(/"feedback"\s*:\s*"([^"]*)"/);
|
|
5525
|
-
reviewerFeedback = fm?.[1] ?? "";
|
|
5526
|
-
}
|
|
5527
|
-
else {
|
|
5528
|
-
// No JSON — assume fail with the raw output as feedback.
|
|
5529
|
-
reviewerVerdict = "fail";
|
|
5530
|
-
reviewerFeedback = raw.slice(0, 2000);
|
|
5531
|
-
}
|
|
5532
|
-
await srv.session.dispose();
|
|
5533
|
-
}
|
|
5534
|
-
catch (err) {
|
|
5535
|
-
reviewerVerdict = "fail";
|
|
5536
|
-
reviewerFeedback = `审查过程中出错:${err.message}`;
|
|
5537
|
-
}
|
|
5538
|
-
// The user may have switched chats or replaced/cleared the goal while the
|
|
5539
|
-
// isolated reviewer was running. Never apply a stale verdict or inject it
|
|
5540
|
-
// into the old session after that point.
|
|
5541
|
-
if (!this.isCurrentGoalReview(conv, goalGeneration, reviewGeneration)) {
|
|
5542
|
-
this.discardStaleGoalReview(conv, goalGeneration, reviewGeneration);
|
|
5543
|
-
return;
|
|
5544
|
-
}
|
|
5545
|
-
g.reviewing = false;
|
|
5546
|
-
g.verdict = reviewerVerdict;
|
|
5547
|
-
g.feedback = reviewerFeedback;
|
|
5548
|
-
const round = g.round;
|
|
5549
|
-
// Display cap: 0 means "unlimited" (keep revising until pass).
|
|
5550
|
-
const budgetForCard = g.locked ? (Number.isFinite(budget) ? budget : 0) : 1;
|
|
5551
|
-
const verdict = reviewerVerdict;
|
|
5552
|
-
const feedback = reviewerFeedback;
|
|
5553
|
-
/** Format "round/cap" for user-facing strings; cap 0 → 不限. */
|
|
5554
|
-
const capFmt = (cap) => cap > 0 ? `第 ${round}/${cap} 轮` : `第 ${round} 轮(不限)`;
|
|
5555
|
-
if (verdict === "pass") {
|
|
5556
|
-
g.status = "✅ 已通过目标审查";
|
|
5557
|
-
this.emit({ type: "notice", level: "info", text: "✅ 目标已通过审查" });
|
|
5558
|
-
g.conversationId = null;
|
|
5559
|
-
g.goal = null; // a passed goal is done and cleared
|
|
5560
|
-
this.emitGoalStatus();
|
|
5561
|
-
// Pass = the review result goes straight into the conversation as an
|
|
5562
|
-
// ordinary user message (NO separate goal-review card). It both tells the
|
|
5563
|
-
// USER the outcome and hands the main agent back out of "goal mode", so a
|
|
5564
|
-
// follow-up instruction like "发布" is a normal request — not a confirm echo.
|
|
5565
|
-
try {
|
|
5566
|
-
await mainSession.sendUserMessage(`✅ 目标已达成并通过审查(第 ${round} 轮)。\n\n目标:${goalText}\n\n${feedback}\n\n(目标模式已解除,接下来按你的普通指令响应。)`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
|
|
5567
|
-
}
|
|
5568
|
-
catch {
|
|
5569
|
-
// Best-effort.
|
|
5570
|
-
}
|
|
5571
|
-
this.flushSnapshot();
|
|
5572
|
-
return;
|
|
5573
|
-
}
|
|
5574
|
-
// Failure: if rounds remain, steer a revision; else report the loop done.
|
|
5575
|
-
// For unlimited (budget=0) isLastRound is always false → keeps revising.
|
|
5576
|
-
const isLastRound = !g.locked ? true : g.maxRounds > 0 && g.round >= g.maxRounds;
|
|
5577
|
-
if (!isLastRound) {
|
|
5578
|
-
g.status = `本轮不通过,正在把意见交给 agent 修改(${capFmt(budgetForCard)})…`;
|
|
5579
|
-
this.emit({
|
|
5580
|
-
type: "notice",
|
|
5581
|
-
level: "warning",
|
|
5582
|
-
text: `目标审查第 ${g.round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮未通过,把意见交给 agent 修改…`,
|
|
5583
|
-
});
|
|
5584
|
-
// Inject the reviewer's feedback into the main session to revise (this IS
|
|
5585
|
-
// the fail review result, as an ordinary user message — no separate card).
|
|
5586
|
-
try {
|
|
5587
|
-
const steerText = `【目标审查:第 ${g.round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮未通过】\n\n目标:${goalText}\n\n` +
|
|
5588
|
-
`审查意见:${feedback}\n\n请根据以上意见修改你的成果,使其完全满足目标。`;
|
|
5589
|
-
await mainSession.sendUserMessage(steerText, {
|
|
5590
|
-
deliverAs: mainSession.isStreaming ? "steer" : "followUp",
|
|
5591
|
-
});
|
|
5592
|
-
}
|
|
5593
|
-
catch (err) {
|
|
5594
|
-
g.status = `意见注入失败:${err.message}`;
|
|
5595
|
-
}
|
|
5596
|
-
this.emitGoalStatus();
|
|
5597
|
-
this.flushSnapshot();
|
|
5598
|
-
return;
|
|
5599
|
-
}
|
|
5600
|
-
// Rounds exhausted (finite cap reached / single-shot failed). Deliver the
|
|
5601
|
-
// fail result as an ordinary user message (no separate card), like the pass
|
|
5602
|
-
// and revise paths — the review result always lands in the conversation.
|
|
5603
|
-
g.status =
|
|
5604
|
-
g.locked && g.maxRounds > 0
|
|
5605
|
-
? `已达最大轮数(${g.maxRounds}),目标仍未通过`
|
|
5606
|
-
: `目标未通过(${capFmt(budgetForCard)})`;
|
|
5607
|
-
try {
|
|
5608
|
-
await mainSession.sendUserMessage(`❌ 目标未通过审查(第 ${round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮)。\n\n目标:${goalText}\n\n审查意见:${feedback}`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
|
|
5609
|
-
}
|
|
5610
|
-
catch {
|
|
5611
|
-
// Best-effort.
|
|
5612
|
-
}
|
|
5613
|
-
this.emit({ type: "notice", level: "warning", text: "目标未通过审查(已达最大轮数)" });
|
|
5614
|
-
g.conversationId = null;
|
|
5615
|
-
g.goal = null; // loop exhausted — clear the active goal
|
|
5616
|
-
this.emitGoalStatus();
|
|
5617
|
-
this.flushSnapshot();
|
|
5618
|
-
}
|
|
5619
2178
|
/** Switch to a specific model by "provider/id" (e.g. "anthropic/claude-sonnet-5"). */
|
|
5620
2179
|
async setModel(modelId) {
|
|
5621
2180
|
try {
|
|
@@ -5701,12 +2260,10 @@ ${transcript}
|
|
|
5701
2260
|
clearInterval(this.widgetsTimer);
|
|
5702
2261
|
this.widgetsTimer = null;
|
|
5703
2262
|
}
|
|
5704
|
-
this.unwatchDir();
|
|
2263
|
+
this.files.unwatchDir();
|
|
2264
|
+
this.files.unwatchGit();
|
|
5705
2265
|
this.webUi.dispose();
|
|
5706
|
-
|
|
5707
|
-
clearInterval(this.bgTimer);
|
|
5708
|
-
this.bgTimer = null;
|
|
5709
|
-
}
|
|
2266
|
+
this.bg.stop();
|
|
5710
2267
|
for (const conv of this.convs.values()) {
|
|
5711
2268
|
this.clearAllToolWatchdogs(conv);
|
|
5712
2269
|
conv.unsubscribe?.();
|