pi-web-ui 0.30.0 → 0.32.0

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.
@@ -24,7 +24,7 @@ import { ModelAdminService } from "./model-admin.js";
24
24
  import { FilesService, workspacePath } from "./files-service.js";
25
25
  import { isExtensionDisabled, ClientStateStore, } from "./client-state.js";
26
26
  import { saveUpload } from "./uploads.js";
27
- import { makePersistentTerminalTools } from "./terminals.js";
27
+ import { makePersistentTerminalTools, TERMINAL_TOOLS_GUIDANCE, TERMINAL_TOOL_NAMES, } from "./terminals.js";
28
28
  import { WebUIContext } from "./webui-context.js";
29
29
  import { buildAttachmentMessages, parseModelSpec, } from "./attachments.js";
30
30
  import { serializeMessage, serializeStreamingMessage, } from "./serialize.js";
@@ -79,14 +79,14 @@ export class QuiesceRejectedError extends Error {
79
79
  * programs wait for input that never comes. Legacy Chinese files are often
80
80
  * GBK/GB2312 — read them with the right encoding, never paste mojibake into
81
81
  * reasoning/answers. */
82
- const WINDOWS_PERSONA = `You are a coding agent running on Windows. The bash tool runs Git Bash (bash.exe), not PowerShell. Follow these rules to avoid hanging the session:
83
-
84
-
85
-
86
- - ALWAYS pass a timeout parameter to the bash tool (in seconds). There is NO default timeout — a command that never finishes (servers, watchers, infinite loops, slow downloads/installs) will hang the entire conversation indefinitely. Pick a generous timeout for long-running work, but never omit it.
87
- - NEVER run interactive or foreground long-running commands through the bash tool (vi, less, top, python -, node -, npm run dev, sleep 10000). For servers/daemons use background execution with output redirected to a log file, then poll the log; stop them when done.
88
- - In the interactive terminal (TTY) — which is Git Bash too, not PowerShell — NEVER use heredocs (<<'EOF' ... EOF) or here-strings, and NEVER start interactive programs (vi, less, python -, node -, npm init): they wait for keyboard input that never arrives and hang the terminal forever. Prefer writing a temp script file (e.g. .pi-tmp.sh) and running it non-interactively. ALWAYS pass a timeout to long-running commands (e.g. \`timeout 120 npm run dev\`).
89
-
82
+ const WINDOWS_PERSONA = `You are a coding agent running on Windows. The bash tool runs Git Bash (bash.exe), not PowerShell. Follow these rules to avoid hanging the session:
83
+
84
+
85
+
86
+ - ALWAYS pass a timeout parameter to the bash tool (in seconds). There is NO default timeout — a command that never finishes (servers, watchers, infinite loops, slow downloads/installs) will hang the entire conversation indefinitely. Pick a generous timeout for long-running work, but never omit it.
87
+ - NEVER run interactive or foreground long-running commands through the bash tool (vi, less, top, python -, node -, npm run dev, sleep 10000). For servers/daemons use background execution with output redirected to a log file, then poll the log; stop them when done.
88
+ - In the interactive terminal (TTY) — which is Git Bash too, not PowerShell — NEVER use heredocs (<<'EOF' ... EOF) or here-strings, and NEVER start interactive programs (vi, less, python -, node -, npm init): they wait for keyboard input that never arrives and hang the terminal forever. Prefer writing a temp script file (e.g. .pi-tmp.sh) and running it non-interactively. ALWAYS pass a timeout to long-running commands (e.g. \`timeout 120 npm run dev\`).
89
+
90
90
  Many legacy Chinese text files (.html/.txt/.md/.log, exported documents) are GBK/GB2312 encoded: the read tool decodes UTF-8 only and will show mojibake (乱码) for them. If a file's content looks garbled, read it through the terminal instead: in Git Bash use \`cat file | iconv -f GBK -t UTF-8\` (or \`iconv -f GBK -t UTF-8 file\`); in cmd use \`chcp 65001 && type file\`; in PowerShell use \`Get-Content -Encoding Default file\`. Never paste mojibake into your reasoning or answer — describe the decoded content instead.`;
91
91
  /**
92
92
  * Killable bash tool: wraps the SDK bash tool with operations that register
@@ -415,6 +415,8 @@ export class ClientSession {
415
415
  isStreaming: () => this.session.isStreaming,
416
416
  reloadSession: async () => {
417
417
  await this.session.reload();
418
+ // reload() 会把 custom 工具重新加回活跃集——重放终端开关。
419
+ this.applyTerminalToolGating(this.session);
418
420
  await this.pushSlashCommands();
419
421
  },
420
422
  effectiveDefaultSystemPrompt: () => this.effectiveDefaultSystemPrompt(),
@@ -520,6 +522,11 @@ export class ClientSession {
520
522
  // GBK 老中文文件让模型改用终端按正确编码读(iconv/chcp/Get-Content)。
521
523
  out.push(WINDOWS_PERSONA);
522
524
  }
525
+ if (this.settingsSvc.current.terminalToolsEnabled !== false) {
526
+ // 终端工具使用引导(全平台):告诉模型什么场景该用持久终端
527
+ // 而不是一次性 bash——没有这段模型几乎从不主动选终端工具。
528
+ out.push(TERMINAL_TOOLS_GUIDANCE);
529
+ }
523
530
  return out;
524
531
  },
525
532
  // 技能开关:禁用的技能从系统提示词和 /skill: 目录中剔除。
@@ -536,18 +543,21 @@ export class ClientSession {
536
543
  }),
537
544
  },
538
545
  });
546
+ const created = await createAgentSessionFromServices({
547
+ services,
548
+ sessionManager,
549
+ // 可手动停止的 bash 工具:覆盖 SDK 内置 bash(customTools 按 name
550
+ // 覆盖),执行时把自己的 AbortController 注册进客户端集合——
551
+ // abortBash() 只杀这些命令,agent run 与对话继续。
552
+ customTools: [
553
+ makeKillableBashTool(effectiveCwd, this.bashKills),
554
+ ...makePersistentTerminalTools(terminals, effectiveCwd),
555
+ ],
556
+ });
557
+ // 终端工具开关从创建起就生效(工具始终注册进注册表,只调活跃集)。
558
+ this.applyTerminalToolGating(created.session);
539
559
  return {
540
- ...(await createAgentSessionFromServices({
541
- services,
542
- sessionManager,
543
- // 可手动停止的 bash 工具:覆盖 SDK 内置 bash(customTools 按 name
544
- // 覆盖),执行时把自己的 AbortController 注册进客户端集合——
545
- // abortBash() 只杀这些命令,agent run 与对话继续。
546
- customTools: [
547
- makeKillableBashTool(effectiveCwd, this.bashKills),
548
- ...makePersistentTerminalTools(terminals, effectiveCwd),
549
- ],
550
- })),
560
+ ...created,
551
561
  services,
552
562
  diagnostics: services.diagnostics,
553
563
  };
@@ -590,8 +600,8 @@ export class ClientSession {
590
600
  uiMessageCache: new Map(),
591
601
  lastMessagesSig: "",
592
602
  lastMessagesArray: [],
593
- queueSteering: 0,
594
- queueFollowUp: 0,
603
+ queueSteering: [],
604
+ queueFollowUp: [],
595
605
  toolStartTimes: new Map(),
596
606
  toolWatchdogs: new Map(),
597
607
  };
@@ -849,8 +859,8 @@ export class ClientSession {
849
859
  break;
850
860
  }
851
861
  case "queue_update":
852
- conv.queueSteering = event.steering.length;
853
- conv.queueFollowUp = event.followUp.length;
862
+ conv.queueSteering = [...event.steering];
863
+ conv.queueFollowUp = [...event.followUp];
854
864
  break;
855
865
  // A run finished or a new entry was persisted — keep the session list fresh
856
866
  // (new chat + first message, completed turns, compaction, etc.).
@@ -1228,13 +1238,6 @@ export class ClientSession {
1228
1238
  }
1229
1239
  return 0;
1230
1240
  }
1231
- /** True once updateApp succeeded — the process must restart to run new code. */
1232
- pendingRestart = false;
1233
- /**
1234
- * Set by index.ts: called after a successful self-update; returns whether
1235
- * the process is going to restart itself (so the notice can say so).
1236
- */
1237
- onUpdateReady = undefined;
1238
1241
  /** Set by index.ts: called when /pi-web-ui:quit is invoked. */
1239
1242
  onQuit = undefined;
1240
1243
  /** Ask the npm registry for the latest pi-web-ui version and report it. */
@@ -1259,7 +1262,6 @@ export class ClientSession {
1259
1262
  latest,
1260
1263
  latestPublishedAt,
1261
1264
  upToDate,
1262
- pendingRestart: this.pendingRestart,
1263
1265
  });
1264
1266
  }
1265
1267
  catch (err) {
@@ -1269,115 +1271,10 @@ export class ClientSession {
1269
1271
  latest: null,
1270
1272
  latestPublishedAt: null,
1271
1273
  upToDate: false,
1272
- pendingRestart: this.pendingRestart,
1273
1274
  error: `检查更新失败:${err.message}`,
1274
1275
  });
1275
1276
  }
1276
1277
  }
1277
- /**
1278
- * After `npm i -g`, confirm the on-disk package this process serves from
1279
- * actually changed to the new version and is complete. Windows npm updates
1280
- * can fail partway (locked files / Defender / npm rollback) and leave the
1281
- * global install without its bin links — restarting into that is a silent
1282
- * crash (web/dist missing + `pi-web-ui` no longer on PATH). Returns null
1283
- * when OK, else a human-readable problem description.
1284
- */
1285
- static verifyGlobalInstall() {
1286
- try {
1287
- const here = dirname(fileURLToPath(import.meta.url));
1288
- const pkgRoot = resolve(here, "..", "..");
1289
- const pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8"));
1290
- if (!pkg.version || pkg.version === ClientSession.currentAppVersion()) {
1291
- return `安装目录版本未变化(${pkg.version ?? "未知"})`;
1292
- }
1293
- if (!existsSync(join(pkgRoot, "web", "dist", "index.html"))) {
1294
- return "web/dist/index.html 缺失(前端产物未安装完整)";
1295
- }
1296
- if (!existsSync(join(pkgRoot, "bin", "pi-web-ui.mjs"))) {
1297
- return "bin/pi-web-ui.mjs 缺失";
1298
- }
1299
- if (process.platform === "win32") {
1300
- const prefix = dirname(process.execPath);
1301
- const hasShim = existsSync(join(prefix, "pi-web-ui.cmd")) ||
1302
- existsSync(join(prefix, "pi-web-ui.ps1"));
1303
- if (!hasShim)
1304
- return "pi-web-ui 命令入口(bin 链接)未生成";
1305
- }
1306
- return null;
1307
- }
1308
- catch (err) {
1309
- return `读取安装目录失败:${err.message}`;
1310
- }
1311
- }
1312
- /** npm i -g pi-web-ui@latest — the new code only takes effect after a restart. */
1313
- async updateApp() {
1314
- try {
1315
- this.emit({
1316
- type: "notice",
1317
- level: "info",
1318
- text: "正在更新 pi-web-ui(npm i -g pi-web-ui@latest)…",
1319
- });
1320
- const { code, out } = await this.runAsync("npm", ["i", "-g", "pi-web-ui@latest"], 180_000);
1321
- if (code !== 0) {
1322
- this.emit({
1323
- type: "update_result",
1324
- ok: false,
1325
- detail: `npm i 失败(${code ?? "timeout"}):${out.slice(0, 400)}`,
1326
- });
1327
- this.emit({
1328
- type: "notice",
1329
- level: "error",
1330
- text: `更新 pi-web-ui 失败(${code ?? "timeout"}):${out.slice(0, 300)}`,
1331
- });
1332
- return;
1333
- }
1334
- // npm reported success, but on Windows the replacement can be partial
1335
- // (locked files, rollback) — restarting into a broken install is a
1336
- // crash with no hint. Verify before handing over.
1337
- const problem = ClientSession.verifyGlobalInstall();
1338
- if (problem) {
1339
- this.emit({
1340
- type: "update_result",
1341
- ok: false,
1342
- detail: `npm i 成功但安装不完整(${problem})。请手动执行 npm i -g pi-web-ui@latest 修复后再重启服务。`,
1343
- });
1344
- this.emit({
1345
- type: "notice",
1346
- level: "error",
1347
- text: `更新未完整生效(${problem})。请手动执行 npm i -g pi-web-ui@latest 修复`,
1348
- });
1349
- return;
1350
- }
1351
- this.pendingRestart = true;
1352
- this.emit({
1353
- type: "update_result",
1354
- ok: true,
1355
- detail: out.slice(0, 400),
1356
- });
1357
- const autoRestart = this.onUpdateReady?.() ?? false;
1358
- this.emit({
1359
- type: "notice",
1360
- level: "info",
1361
- text: autoRestart
1362
- ? "✅ 已更新 pi-web-ui,正在自动重启…"
1363
- : "✅ 已更新 pi-web-ui,重启服务后生效(pi-web-ui server restart)",
1364
- });
1365
- }
1366
- catch (err) {
1367
- this.emit({
1368
- type: "update_result",
1369
- ok: false,
1370
- detail: String(err),
1371
- });
1372
- this.emit({
1373
- type: "notice",
1374
- level: "error",
1375
- text: `更新 pi-web-ui 失败:${err.message}`,
1376
- });
1377
- }
1378
- // Re-check so the UI reflects the new state (pendingRestart included).
1379
- void this.checkUpdate();
1380
- }
1381
1278
  async installPiAgent() {
1382
1279
  try {
1383
1280
  mkdirSync(this.agentDir, { recursive: true });
@@ -1453,6 +1350,7 @@ export class ClientSession {
1453
1350
  setCwd: (path) => this.setCwd(path),
1454
1351
  setThinking: (level) => this.setThinking(level),
1455
1352
  refreshSessions: () => this.refreshSessions(),
1353
+ afterReload: () => this.applyTerminalToolGating(this.session),
1456
1354
  onQuit: () => this.onQuit?.() ?? false,
1457
1355
  });
1458
1356
  /** Catalog push — index.ts get_commands / attach / cwd 切换等都会调用。 */
@@ -1480,6 +1378,12 @@ export class ClientSession {
1480
1378
  refreshProviderModels(providerId, reqId) {
1481
1379
  return this.modelAdmin.refreshProviderModels(providerId, reqId);
1482
1380
  }
1381
+ /** Copy a built-in provider into an editable custom-provider draft
1382
+ * (clone_provider_result) — lets the user run a second API key without
1383
+ * overwriting the built-in one. */
1384
+ cloneProvider(providerId, reqId) {
1385
+ return this.modelAdmin.cloneProvider(providerId, reqId);
1386
+ }
1483
1387
  saveModelConfig(providerId, config) {
1484
1388
  return this.modelAdmin.saveModelConfig(providerId, config);
1485
1389
  }
@@ -1495,6 +1399,12 @@ export class ClientSession {
1495
1399
  pushSettings() {
1496
1400
  this.settingsSvc.push();
1497
1401
  }
1402
+ /** Extensions/skills changed externally (e.g. `pi remove` finished in the
1403
+ * terminal): re-run session.reload() and re-push state. Streaming-safe —
1404
+ * deferred to agent_end, same as settings reloads. */
1405
+ async reloadExtensions() {
1406
+ return this.settingsSvc.applyRuntime();
1407
+ }
1498
1408
  /** Persist + apply a partial settings update (prompt text/mode, toggles). */
1499
1409
  async setSettings(partial) {
1500
1410
  await this.settingsSvc.set(partial);
@@ -1515,6 +1425,25 @@ export class ClientSession {
1515
1425
  async applyRuntimeSettings() {
1516
1426
  return this.settingsSvc.applyRuntime();
1517
1427
  }
1428
+ /** 把终端工具开关应用到 session 的活跃工具集:关闭时从活跃集中剔除
1429
+ * terminal_*(工具仍留在注册表,重开时可直接加回)。session.reload() 与新
1430
+ * 会话创建都会把 custom 工具加回活跃集,所以这两条路径之后都要重放本方法。 */
1431
+ applyTerminalToolGating(session) {
1432
+ try {
1433
+ const enabled = this.settingsSvc.current.terminalToolsEnabled !== false;
1434
+ const names = new Set(session.getActiveToolNames());
1435
+ for (const n of TERMINAL_TOOL_NAMES) {
1436
+ if (enabled)
1437
+ names.add(n);
1438
+ else
1439
+ names.delete(n);
1440
+ }
1441
+ session.setActiveToolsByName([...names]);
1442
+ }
1443
+ catch {
1444
+ // Session 未就绪——下次创建/reload 会再应用。
1445
+ }
1446
+ }
1518
1447
  async applySettingsReload() {
1519
1448
  // 兼容旧入口:reload + 刷目录在宿主回调里完成
1520
1449
  return this.settingsSvc.applyRuntime();
@@ -1557,7 +1486,7 @@ export class ClientSession {
1557
1486
  pendingMessages() {
1558
1487
  let n = 0;
1559
1488
  for (const c of this.convs.values())
1560
- n += c.queueFollowUp + c.queueSteering;
1489
+ n += c.queueFollowUp.length + c.queueSteering.length;
1561
1490
  return n;
1562
1491
  }
1563
1492
  async prompt(text, attachments,
@@ -2449,11 +2378,6 @@ export class AgentService {
2449
2378
  socketCount = 0;
2450
2379
  pending = new Map();
2451
2380
  stateStore;
2452
- /**
2453
- * Set by index.ts: called by a client session after a successful
2454
- * self-update; returns whether the process will restart itself.
2455
- */
2456
- onUpdateReady = undefined;
2457
2381
  /** Set by index.ts: called when /pi-web-ui:quit is invoked. */
2458
2382
  onQuit = undefined;
2459
2383
  constructor(cwd, stateFile) {
@@ -2567,7 +2491,6 @@ export class AgentService {
2567
2491
  cs.notifyInterrupted(this.stateStore.takeInterrupted(clientId));
2568
2492
  cs.attachSink(send);
2569
2493
  // Forward hooks (set once by index.ts) to every session.
2570
- cs.onUpdateReady = this.onUpdateReady;
2571
2494
  cs.onQuit = this.onQuit;
2572
2495
  cs.isQuiesced = () => this.quiesced;
2573
2496
  return cs;
@@ -397,9 +397,9 @@ export async function buildAttachmentMessages(ctx, attachments) {
397
397
  content: [
398
398
  {
399
399
  type: "text",
400
- text: `
401
- <vision-bridge>
402
- ${transcript}
400
+ text: `
401
+ <vision-bridge>
402
+ ${transcript}
403
403
  </vision-bridge>`,
404
404
  },
405
405
  ...(pathImg
@@ -151,6 +151,7 @@ export class ClientStateStore {
151
151
  customSystemPrompt: s?.settings?.customSystemPrompt ?? "",
152
152
  disabledSkills: s?.settings?.disabledSkills ?? [],
153
153
  disabledExtensions: s?.settings?.disabledExtensions ?? [],
154
+ terminalToolsEnabled: s?.settings?.terminalToolsEnabled ?? true,
154
155
  visionBridgeEnabled: s?.settings?.visionBridgeEnabled ?? true,
155
156
  visionBridgeModel: s?.settings?.visionBridgeModel ?? null,
156
157
  visionBridgePromptMode: s?.settings?.visionBridgePromptMode === "replace" ? "replace" : "append",
@@ -169,6 +170,7 @@ export class ClientStateStore {
169
170
  customSystemPrompt: settings.customSystemPrompt ?? cur.customSystemPrompt ?? "",
170
171
  disabledSkills: settings.disabledSkills ?? cur.disabledSkills ?? [],
171
172
  disabledExtensions: settings.disabledExtensions ?? cur.disabledExtensions ?? [],
173
+ terminalToolsEnabled: settings.terminalToolsEnabled ?? cur.terminalToolsEnabled ?? true,
172
174
  visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
173
175
  visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
174
176
  visionBridgePromptMode: settings.visionBridgePromptMode ??
@@ -20,7 +20,6 @@ import { existsSync } from "node:fs";
20
20
  import { stat } from "node:fs/promises";
21
21
  import { createServer } from "node:http";
22
22
  import { createConnection } from "node:net";
23
- import { spawn } from "node:child_process";
24
23
  import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
25
24
  import { homedir } from "node:os";
26
25
  import { fileURLToPath } from "node:url";
@@ -342,49 +341,12 @@ const service = new AgentService(CWD,
342
341
  // Per-client persisted UI state: last-used workspace + recent projects.
343
342
  join(DATA_DIR, "client-state.json"));
344
343
  // ---------------------------------------------------------------------------
345
- // Self-update auto-restart
344
+ // Self-update
346
345
  // ---------------------------------------------------------------------------
347
- // npm i -g writes new code to disk but the running process keeps the old
348
- // code in memory so a successful in-app update hands the process over:
349
- // macOS launchd (KeepAlive) and systemd (Restart) relaunch us on exit;
350
- // foreground runs get a replacement child that waits for our port to free.
351
- // Docker containers can't self-restart (the orchestrator owns that), so they
352
- // keep the manual-restart notice.
353
- function scheduleUpdateRestart() {
354
- const isLaunchd = process.platform === "darwin" && process.ppid === 1;
355
- const isSystemd = process.platform === "linux" && !!process.env.INVOCATION_ID;
356
- const inDocker = existsSync("/.dockerenv");
357
- if (isLaunchd || isSystemd || inDocker) {
358
- // Supervisors relaunch on exit; Docker restarts externally. Nothing to
359
- // spawn — just exit after the notice has flushed.
360
- if (isLaunchd || isSystemd) {
361
- setTimeout(() => {
362
- console.log("update applied — auto-restarting…");
363
- if (isSystemd) {
364
- // Non-zero exit: legacy units use Restart=on-failure.
365
- process.exit(3);
366
- }
367
- void shutdown();
368
- }, 1500);
369
- return true;
370
- }
371
- return false;
372
- }
373
- // Foreground / Windows: spawn a replacement from the updated install and
374
- // exit. Same stdio (logs keep flowing), same args/env (port, cwd, data
375
- // dir…); the child waits for this port to free before binding.
376
- setTimeout(() => {
377
- console.log("update applied — spawning replacement…");
378
- spawn(process.execPath, process.argv.slice(1), {
379
- stdio: "inherit",
380
- env: { ...process.env, [RESTART_CHILD_ENV]: "1" },
381
- ...(process.platform === "win32" ? { windowsHide: true } : {}),
382
- });
383
- void shutdown();
384
- }, 1500);
385
- return true;
386
- }
387
- service.onUpdateReady = scheduleUpdateRestart;
346
+ // In-app updates now run `npm i -g pi-web-ui@latest` in a visible terminal
347
+ // tab (frontend-initiated); after it finishes the user restarts via
348
+ // `pi-web-ui server restart`. The PI_WEB_RESTART_CHILD port-wait handshake
349
+ // below stays: an externally orchestrated replacement child still needs it.
388
350
  function scheduleQuit() {
389
351
  const isLaunchd = process.platform === "darwin" && process.ppid === 1;
390
352
  const isSystemd = process.platform === "linux" && !!process.env.INVOCATION_ID;
@@ -410,6 +372,11 @@ service.onQuit = scheduleQuit;
410
372
  * ~10MB——连半份都没发完就丢,前端频繁跳帧;短会话又太迟钝。相对阈值语义稳定在
411
373
  * 「缓冲堆了约 N 份快照」,不随会话长短漂移。 */
412
374
  const SNAPSHOT_BACKPRESSURE_FACTOR = 3;
375
+ /** 背压绝对下限:低于此积压永不丢快照(小会话的相对阈值只有几 KB,会被
376
+ * 正常的消息突发误伤,见 send() 内注释)。 */
377
+ const SNAPSHOT_BACKPRESSURE_MIN_BYTES = 262_144;
378
+ /** 背压丢弃后的延迟重发间隔。 */
379
+ const SNAPSHOT_RETRY_MS = 250;
413
380
  /**
414
381
  * Multi-tab serialization sharing: emit() hands the SAME message object to
415
382
  * every socket of a client, but each send() used to JSON.stringify it
@@ -436,6 +403,8 @@ wss.on("connection", (ws) => {
436
403
  let lastSnapshotBytes = 0;
437
404
  /** Commands received while the session is still being created — replayed after attach. */
438
405
  let pending = [];
406
+ /** 背压丢快照后的延迟重发定时器(去重:一次只排一个)。 */
407
+ let snapshotRetryTimer = null;
439
408
  // 协议层错误(非法帧/未 masked 帧等):不注册 handler 会作为 uncaught
440
409
  // exception 打崩整个进程(issue #11 附带发现)。记日志并按坏连接关闭。
441
410
  ws.on("error", (err) => {
@@ -452,13 +421,27 @@ wss.on("connection", (ws) => {
452
421
  return;
453
422
  // 发送背压(issue #11):socket 消费不过来时(前端慢/网络差),堆里会堆积
454
423
  // 每份可达 ~10MB 的全量 snapshot 字符串,低内存主机直接 OOM。snapshot 是全量
455
- // 幂等的且 60ms 后必有更新的一份,可以安全丢弃——在序列化之前丢,连
424
+ // 幂等的且稍后必有更新的一份,可以安全丢弃——在序列化之前丢,连
456
425
  // stringify 的分配都省掉。ready/notice/error/tool_delta 等消息必须送达。
457
426
  // 阈值相对化(评论区建议):用「最近一份 snapshot 的字节数 × 倍数」做基准,
458
427
  // 首份无基准不丢(首次必达)。wire.length 是 UTF-16 字符数,×2 估算字节。
428
+ // 下限保护(小会话误伤修复):小会话一份 snapshot 才 ~1KB,相对阈值只有几
429
+ // KB——前面一批 settings_state/slash_commands 的正常突发就能把 bufferedAmount
430
+ // 抬过阈值,把紧随其后的 snapshot_delta 静默丢掉;而丢弃后若无后续事件就
431
+ // 再也没有快照,客户端永远停在旧状态(前端靠 rev 缺口 get_state 自愈,
432
+ // 协议测试则直接卡死)。绝对下限保证小会话永不触发背压。
459
433
  if ((msg.type === "snapshot" || msg.type === "snapshot_delta") &&
460
434
  lastSnapshotBytes > 0 &&
461
- ws.bufferedAmount > SNAPSHOT_BACKPRESSURE_FACTOR * lastSnapshotBytes) {
435
+ ws.bufferedAmount > Math.max(SNAPSHOT_BACKPRESSURE_MIN_BYTES, SNAPSHOT_BACKPRESSURE_FACTOR * lastSnapshotBytes)) {
436
+ // 真正的慢客户端:丢弃是安全的,但不能「丢完就没了」——安排一次延迟
437
+ // 重发,等缓冲排空后快照最终必达(否则若此后再无事件,客户端将永久
438
+ // 停留在旧快照)。重发仍走 flushSnapshot:缓冲未排空则再次顺延。
439
+ if (!snapshotRetryTimer) {
440
+ snapshotRetryTimer = setTimeout(() => {
441
+ snapshotRetryTimer = null;
442
+ service.get(clientId ?? "")?.flushSnapshot();
443
+ }, SNAPSHOT_RETRY_MS);
444
+ }
462
445
  return;
463
446
  }
464
447
  const wire = serializeShared(msg);
@@ -570,9 +553,6 @@ wss.on("connection", (ws) => {
570
553
  case "check_update":
571
554
  void cs.checkUpdate();
572
555
  break;
573
- case "update_app":
574
- void cs.updateApp();
575
- break;
576
556
  case "dialog_response":
577
557
  cs.resolveDialog(msg.id, msg.value);
578
558
  break;
@@ -603,6 +583,9 @@ wss.on("connection", (ws) => {
603
583
  case "refresh_provider_models":
604
584
  void cs.refreshProviderModels(msg.providerId, msg.reqId);
605
585
  break;
586
+ case "clone_provider":
587
+ void cs.cloneProvider(msg.provider, msg.reqId);
588
+ break;
606
589
  case "terminal_create": {
607
590
  const tm = cs.getTerminalManager(msg.conversationId);
608
591
  if (tm)
@@ -660,6 +643,7 @@ wss.on("connection", (ws) => {
660
643
  customSystemPrompt: msg.customSystemPrompt,
661
644
  disabledSkills: msg.disabledSkills,
662
645
  disabledExtensions: msg.disabledExtensions,
646
+ terminalToolsEnabled: msg.terminalToolsEnabled,
663
647
  visionBridgeEnabled: msg.visionBridgeEnabled,
664
648
  visionBridgeModel: msg.visionBridgeModel,
665
649
  visionBridgePromptMode: msg.visionBridgePromptMode,
@@ -668,6 +652,9 @@ wss.on("connection", (ws) => {
668
652
  reviewDisabledSkills: msg.reviewDisabledSkills,
669
653
  });
670
654
  break;
655
+ case "extensions_reload":
656
+ void cs.reloadExtensions();
657
+ break;
671
658
  case "save_preset":
672
659
  void cs.savePreset(msg.name);
673
660
  break;
@@ -738,6 +725,10 @@ wss.on("connection", (ws) => {
738
725
  service.noteSocketClose();
739
726
  closed = true;
740
727
  pending = [];
728
+ if (snapshotRetryTimer) {
729
+ clearTimeout(snapshotRetryTimer);
730
+ snapshotRetryTimer = null;
731
+ }
741
732
  if (clientId)
742
733
  service.detach(clientId, send);
743
734
  });
@@ -233,6 +233,100 @@ export class ModelAdminService {
233
233
  }
234
234
  this.host.flushSnapshot();
235
235
  }
236
+ /**
237
+ * Copy a BUILT-IN provider (baseUrl + current model catalog) into an
238
+ * editable custom-provider draft and return it via clone_provider_result.
239
+ * Nothing is persisted — the user renames the draft, pastes a DIFFERENT
240
+ * API key in the form, then saves via save_model_config. Credentials are
241
+ * never copied: the whole point is running a second key alongside the
242
+ * built-in one without touching it.
243
+ */
244
+ async cloneProvider(providerId, reqId) {
245
+ const pid = providerId.trim();
246
+ const fail = (error) => this.host.emit({ type: "clone_provider_result", reqId, ok: false, error });
247
+ try {
248
+ if (!pid) {
249
+ fail("请填写服务商 ID");
250
+ return;
251
+ }
252
+ const mr = this.host.modelRuntime();
253
+ const p = mr.getProvider(pid);
254
+ if (!p) {
255
+ fail(`供应商 ${pid} 不存在`);
256
+ return;
257
+ }
258
+ if (!p.baseUrl) {
259
+ fail(`${pid} 没有 baseUrl(OAuth/环境变量型供应商),无法复制为自定义服务商`);
260
+ return;
261
+ }
262
+ // Map runtime models → models.json rows; dynamic providers ship an
263
+ // empty catalog until refreshed over the network.
264
+ const readModels = () => {
265
+ try {
266
+ return mr.getModels(pid).map((m) => ({
267
+ api: m.api,
268
+ entry: {
269
+ id: m.id,
270
+ ...(m.name && m.name !== m.id ? { name: m.name } : {}),
271
+ ...(m.reasoning ? { reasoning: true } : {}),
272
+ ...(m.input?.includes("image")
273
+ ? { input: ["text", "image"] }
274
+ : {}),
275
+ ...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
276
+ ...(m.maxTokens ? { maxTokens: m.maxTokens } : {}),
277
+ },
278
+ }));
279
+ }
280
+ catch {
281
+ return [];
282
+ }
283
+ };
284
+ let models = readModels();
285
+ if (models.length === 0) {
286
+ await mr.refresh({ allowNetwork: true });
287
+ models = readModels();
288
+ }
289
+ if (models.length === 0) {
290
+ fail(`${pid} 的模型列表为空,无法复制(请稍后重试)`);
291
+ return;
292
+ }
293
+ // models.json 的 api 是 provider 级:取占比最高的 api,只复制该 api 的模型。
294
+ const counts = new Map();
295
+ for (const m of models)
296
+ counts.set(m.api, (counts.get(m.api) ?? 0) + 1);
297
+ let api = models[0].api;
298
+ for (const [k, v] of counts)
299
+ if (v > (counts.get(api) ?? 0))
300
+ api = k;
301
+ const kept = models.filter((m) => m.api === api).map((m) => m.entry);
302
+ // Suggest a free id (<pid>-2, -3, …) — save_model_config would silently
303
+ // overwrite an existing custom entry with the same id.
304
+ const taken = new Set([
305
+ ...Object.keys(this.readModelsConfig().providers),
306
+ ...mr.getRegisteredProviderIds(),
307
+ ]);
308
+ let newId = `${pid}-2`;
309
+ for (let n = 2; taken.has(newId); n++)
310
+ newId = `${pid}-${n}`;
311
+ const config = {
312
+ providerId: newId,
313
+ name: p.name,
314
+ api,
315
+ baseUrl: p.baseUrl,
316
+ models: kept,
317
+ };
318
+ this.host.emit({
319
+ type: "notice",
320
+ level: "info",
321
+ text: `📋 已复制 ${pid} → ${newId}(${kept.length} 个模型),请填入新的 API 密钥后保存`,
322
+ });
323
+ this.host.emit({ type: "clone_provider_result", reqId, ok: true, config });
324
+ }
325
+ catch (err) {
326
+ fail(`复制服务商失败:${err.message}`);
327
+ }
328
+ this.host.flushSnapshot();
329
+ }
236
330
  /** Enumerate pi's built-in providers with auth status (key-only config). */
237
331
  async listProviders() {
238
332
  const mr = this.host.modelRuntime();
@@ -8,4 +8,4 @@
8
8
  * its own copy in web/src/protocol-version.ts; scripts/check-protocol-sync.mjs
9
9
  * verifies the two never drift.
10
10
  */
11
- export const PROTOCOL_VERSION = 3;
11
+ export const PROTOCOL_VERSION = 5;