pi-web-ui 0.68.2 → 0.70.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.
Files changed (48) hide show
  1. package/dist/server/agent-service.js +170 -54
  2. package/dist/server/attachments.js +7 -2
  3. package/dist/server/client-state.js +71 -17
  4. package/dist/server/dsh/dsh-agent-service.js +142 -38
  5. package/dist/server/dsh/dsh-client.js +9 -8
  6. package/dist/server/dsh/dsh-sessions.js +4 -3
  7. package/dist/server/edit-soft-tool.js +33 -26
  8. package/dist/server/files-service.js +12 -7
  9. package/dist/server/goal-service.js +85 -24
  10. package/dist/server/i18n.js +157 -0
  11. package/dist/server/index.js +77 -18
  12. package/dist/server/locales.js +55 -1
  13. package/dist/server/managed.js +61 -0
  14. package/dist/server/marker-service.js +37 -20
  15. package/dist/server/markers/builtins/notify.js +19 -6
  16. package/dist/server/markers/builtins/rename.js +41 -8
  17. package/dist/server/markers/builtins/todo.js +107 -30
  18. package/dist/server/markers/registry.js +2 -2
  19. package/dist/server/mcp-bridge.js +3 -1
  20. package/dist/server/model-admin.js +25 -14
  21. package/dist/server/plugin-catalog.js +7 -3
  22. package/dist/server/plugin-updater.js +6 -2
  23. package/dist/server/plugins.js +40 -17
  24. package/dist/server/prompt-composer.js +42 -16
  25. package/dist/server/protocol-version.js +1 -1
  26. package/dist/server/scm.js +18 -25
  27. package/dist/server/serialize.js +1 -0
  28. package/dist/server/settings-service.js +21 -1
  29. package/dist/server/subagent-templates.js +105 -0
  30. package/dist/server/subagents.js +164 -56
  31. package/dist/server/tabs.js +87 -0
  32. package/dist/server/terminals.js +99 -48
  33. package/dist/server/update-check.js +7 -2
  34. package/dist/server/vision-bridge.js +34 -12
  35. package/dist/server/webui-context.js +9 -0
  36. package/package.json +2 -1
  37. package/themes/cyberpunk.css +88 -81
  38. package/themes/dazzle.css +88 -81
  39. package/themes/md-preview.css +105 -98
  40. package/themes/white.css +167 -160
  41. package/web/dist/assets/{TerminalPanel-BQ5NTB9Y.js → TerminalPanel-UxgczH4c.js} +1 -1
  42. package/web/dist/assets/index-BH5QutUc.css +10 -0
  43. package/web/dist/assets/index-CaiIOwy7.js +335 -0
  44. package/web/dist/assets/{markdown-DOsihKaR.js → markdown-Cpo0pNcR.js} +1 -1
  45. package/web/dist/assets/{react-DIP6JKYk.js → react-CtudoG1_.js} +1 -1
  46. package/web/dist/index.html +4 -4
  47. package/web/dist/assets/index-C_I-6Zul.css +0 -10
  48. package/web/dist/assets/index-Ck5pa3XK.js +0 -333
@@ -24,6 +24,7 @@ import "./patch-node-pty.js";
24
24
  import { spawn } from "node-pty";
25
25
  import { defineTool } from "@earendil-works/pi-coding-agent";
26
26
  import { Type } from "typebox";
27
+ import { bilingual, pick } from "./i18n.js";
27
28
  /** Location of the command list for a project: <workspaceRoot>/.pi/commands.json */
28
29
  export function commandsFilePath(workspaceRoot) {
29
30
  return join(workspaceRoot, ".pi", "commands.json");
@@ -642,6 +643,7 @@ export function encodeTerminalKey(key, modifiers = {}) {
642
643
  export class TerminalManager {
643
644
  emit;
644
645
  workspaceRoot;
646
+ lang;
645
647
  /** Live PTYs only. Exited entries move to history so they no longer consume
646
648
  * the live-terminal limit while their output remains readable/replayable. */
647
649
  terms = new Map();
@@ -651,9 +653,12 @@ export class TerminalManager {
651
653
  /** 宿主回调:AI 触碰过的终端静默 ≥ 阈值时触发(一次性/纪元语义见
652
654
  * noteAgentActivity)。宿主自行判断会话是否在运行并决定是否注入。 */
653
655
  onAgentIdle = null;
654
- constructor(emit, workspaceRoot) {
656
+ constructor(emit, workspaceRoot,
657
+ // issue #91:按键/输入校验错误按客户端 UI 语言出中英(英文默认)。
658
+ lang) {
655
659
  this.emit = emit;
656
660
  this.workspaceRoot = workspaceRoot;
661
+ this.lang = lang;
657
662
  }
658
663
  /** Start a plain interactive shell in the given directory. */
659
664
  create(id, cwd, cols, rows, fallbackCwd, title, opts) {
@@ -1004,6 +1009,17 @@ export class TerminalManager {
1004
1009
  list() {
1005
1010
  return [...this.terms.values(), ...this.history.values()].map((entry) => this.info(entry));
1006
1011
  }
1012
+ /** Count of LIVE PTYs only — exited terminals that merely retain their
1013
+ * output for review (history) do NOT count. Conversations are kept in the
1014
+ * running list while a live terminal exists; exited leftovers must not
1015
+ * pin an idle conversation forever. */
1016
+ countLive() {
1017
+ let n = 0;
1018
+ for (const entry of this.terms.values())
1019
+ if (!entry.exited)
1020
+ n++;
1021
+ return n;
1022
+ }
1007
1023
  emitList() {
1008
1024
  this.emit({ type: "terminal_list", terminals: this.list() });
1009
1025
  }
@@ -1053,7 +1069,7 @@ export class TerminalManager {
1053
1069
  return `输入过长(上限 ${MAX_INPUT} 字符) Input too long (max ${MAX_INPUT} chars)`;
1054
1070
  const entry = this.terms.get(id);
1055
1071
  if (!entry || entry.exited)
1056
- return "终端不存在或进程已退出";
1072
+ return pick(this.lang?.() ?? "en", "终端不存在或进程已退出", "Terminal not found or its process has exited", "terminals.not.found.exited");
1057
1073
  // 已武装的纪元里任何人(含用户手动敲键盘)写了输入都算新活动,重置倒计时。
1058
1074
  entry.lastActivityAt = Date.now();
1059
1075
  if (entry.idleTimer)
@@ -1377,29 +1393,33 @@ export function makeTerminalBashTool(terminals, opts) {
1377
1393
  return defineTool({
1378
1394
  name: "bash",
1379
1395
  label: "Run bash command",
1380
- description: "Run a shell command and return its full output plus exit code. Commands run in a visible terminal.\n" +
1396
+ description: bilingual("Run a shell command and return its full output plus exit code. Commands run in a visible terminal.\n" +
1381
1397
  "persist=false (default, one-shot): a fresh terminal is created per call, run to completion, then the shell exits (the process ends) while its output stays in the terminal list for later review — like a normal bash call, but each command also leaves a viewable terminal record.\n" +
1382
1398
  "persist=true: commands run in the PERSISTENT visible terminal 'ai-bash' — shell state such as cd, venv activation or ssh sessions is retained across calls; you can use terminal_wait to re-block on a backgrounded command, or terminal_read / terminal_input / terminal_key on 'ai-bash' to observe or interact anytime.\n" +
1383
- "Run the bare command — do NOT pipe through head/tail/more/less (output is returned complete anyway, and pipes hide live progress in the terminal). Use the head/tail parameters instead to trim the returned output. For interactive commands (REPLs, prompts, installers asking y/n) set persist=true and drive them with terminal_input / terminal_key.",
1384
- promptSnippet: "run shell commands (persist=true keeps the terminal alive across calls)",
1399
+ "Run the bare command — do NOT pipe through head/tail/more/less (output is returned complete anyway, and pipes hide live progress in the terminal). Use the head/tail parameters instead to trim the returned output. For interactive commands (REPLs, prompts, installers asking y/n) set persist=true and drive them with terminal_input / terminal_key.", "运行 shell 命令并返回完整输出与退出码。命令在可见终端中运行。\n" +
1400
+ "persist=false(默认,一次性):每次调用新建一个终端,运行至结束,然后 shell 退出(进程结束),输出保留在终端列表中供稍后查看——如同普通 bash 调用,但每条命令都会留下一条可查看的终端记录。\n" +
1401
+ "persist=true:命令在常驻可见终端 'ai-bash' 中运行——cd、venv 激活、ssh 会话等 shell 状态跨调用保留;可用 terminal_wait 重新阻塞等待后台命令,或随时用 terminal_read / terminal_input / terminal_key 观察或交互。\n" +
1402
+ "直接运行裸命令——不要经 head/tail/more/less 管道(反正会返回完整输出,管道还会挡住终端里的实时进度)。用 head/tail 参数截断返回的输出。交互式命令(REPL、提示符、问 y/n 的安装程序)请设 persist=true 并用 terminal_input / terminal_key 驱动。"),
1403
+ promptSnippet: bilingual("run shell commands (persist=true keeps the terminal alive across calls)", "运行 shell 命令(persist=true 让终端跨调用保持存活)"),
1385
1404
  parameters: Type.Object({
1386
- command: Type.String({ description: "The shell command to run" }),
1387
- timeout: Type.Optional(Type.Number({ description: "Optional timeout in seconds" })),
1405
+ command: Type.String({ description: bilingual("The shell command to run", "要运行的 shell 命令") }),
1406
+ timeout: Type.Optional(Type.Number({ description: bilingual("Optional timeout in seconds", "可选的超时秒数") })),
1388
1407
  persist: Type.Optional(Type.Boolean({
1389
- description: "Keep the terminal alive after the command (default: false → a one-shot terminal that exits when the command finishes while its output is retained for review). true runs in the persistent 'ai-bash' terminal so shell state (cd/venv/ssh) is retained across calls and the terminal stays interactive.",
1408
+ description: bilingual("Keep the terminal alive after the command (default: false → a one-shot terminal that exits when the command finishes while its output is retained for review). true runs in the persistent 'ai-bash' terminal so shell state (cd/venv/ssh) is retained across calls and the terminal stays interactive.", "运行后保持终端存活(默认 false → 命令结束时退出的、输出保留供查看的一次性终端)。true 则在常驻 'ai-bash' 终端中运行,shell 状态(cd/venv/ssh)跨调用保留、终端保持可交互。"),
1390
1409
  })),
1391
1410
  head: Type.Optional(Type.Integer({
1392
1411
  minimum: 1,
1393
1412
  maximum: 5000,
1394
- description: "Only return the FIRST N lines of output (like `| head -N`). Use this for verbose commands instead of piping through head.",
1413
+ description: bilingual("Only return the FIRST N lines of output (like `| head -N`). Use this for verbose commands instead of piping through head.", "只返回输出的前 N 行(如 `| head -N`)。输出冗长的命令请用它,而不要经 head 管道。"),
1395
1414
  })),
1396
1415
  tail: Type.Optional(Type.Integer({
1397
1416
  minimum: 1,
1398
1417
  maximum: 5000,
1399
- description: "Only return the LAST N lines of output (like `| tail -N`). Use this for verbose commands instead of piping through tail.",
1418
+ description: bilingual("Only return the LAST N lines of output (like `| tail -N`). Use this for verbose commands instead of piping through tail.", "只返回输出的后 N 行(如 `| tail -N`)。输出冗长的命令请用它,而不要经 tail 管道。"),
1400
1419
  })),
1401
1420
  }),
1402
1421
  execute: async (_id, p, signal) => {
1422
+ const lang = opts.lang?.() ?? "en";
1403
1423
  const persist = p.persist ?? opts.defaultPersist();
1404
1424
  // create() 对已存活的同名终端原样返回、对已退出的原地重启。
1405
1425
  // forceBash:该终端永远跑 bash(而非用户登录 shell),模型写的
@@ -1412,7 +1432,7 @@ export function makeTerminalBashTool(terminals, opts) {
1412
1432
  forceBash: true,
1413
1433
  agentBash: true,
1414
1434
  }) === null) {
1415
- throw new Error(`无法打开 AI bash 终端(${termId})`);
1435
+ throw new Error(pick(lang, `无法打开 AI bash 终端(${termId})`, `Failed to open the AI bash terminal (${termId})`, "terminals.bash.open.failed", { termId }));
1416
1436
  }
1417
1437
  // 阻塞等待期间挂起活力提醒(我们自己在检测静默,避免双重通知)。
1418
1438
  terminals.suspendIdleWatch(termId);
@@ -1439,12 +1459,15 @@ export function makeTerminalBashTool(terminals, opts) {
1439
1459
  // tail 文件让模型看到日志尾部与真实退出码(否则输出为空)。
1440
1460
  const redirect = stripped && limiter.kind === "tail" ? detectStdoutRedirect(runCommand) : null;
1441
1461
  const tailFile = redirect ? { file: redirect.file, lines: limiter.lines ?? 10 } : undefined;
1462
+ // 复杂子表达式先 hoist 成干净 const(issue #91 v2:vars key 不写复杂表达式)。
1463
+ const limiterSegment = limiter.segment;
1464
+ const limiterTailLines = limiter.lines ?? 10;
1465
+ const limiterTailZh = limiter.kind === "tail" ? `本次返回末尾 ${limiterTailLines} 行。` : "本次返回全部输出。";
1466
+ const limiterTailEn = limiter.kind === "tail"
1467
+ ? `Returning the last ${limiterTailLines} lines this time.`
1468
+ : "Returning the full output this time.";
1442
1469
  const limiterNote = stripped
1443
- ? "\n[注:检测到你带了「" +
1444
- limiter.segment +
1445
- "」这类限输出/过滤管道——已在终端里直跑底层命令(实时可见 + 真实退出码),只按参数返回片段。" +
1446
- (limiter.kind === "tail" ? "本次返回末尾 " + (limiter.lines ?? 10) + " 行。" : "本次返回全部输出。") +
1447
- " 后续直接用 bash(command, tail=N) 参数限输出。]"
1470
+ ? pick(lang, `\n[注:检测到你带了「${limiterSegment}」这类限输出/过滤管道——已在终端里直跑底层命令(实时可见 + 真实退出码),只按参数返回片段。${limiterTailZh} 后续直接用 bash(command, tail=N) 参数限输出。]`, `\n[Note: detected a trailing output-limiting pipe "${limiterSegment}" — ran the underlying command directly in the terminal (live output + real exit code) and trimmed only the returned slice. ${limiterTailEn} Next time use the bash(command, tail=N) parameter to limit output.]`, "terminals.bash.limiter.note", { limiterSegment, limiterTailZh, limiterTailEn })
1448
1471
  : "";
1449
1472
  try {
1450
1473
  let collected = "";
@@ -1461,7 +1484,7 @@ export function makeTerminalBashTool(terminals, opts) {
1461
1484
  terminals.setSentinelPending(termId, false);
1462
1485
  terminals.inputChecked(termId, "\x03");
1463
1486
  closeOneShot();
1464
- throw new Error("Command aborted");
1487
+ throw new Error(pick(lang, "命令已中止", "Command aborted", "terminals.bash.aborted"));
1465
1488
  }
1466
1489
  await sleep(60);
1467
1490
  const read = terminals.read(termId, cursor);
@@ -1489,11 +1512,12 @@ export function makeTerminalBashTool(terminals, opts) {
1489
1512
  terminals.setSentinelPending(termId, false);
1490
1513
  terminals.inputChecked(termId, "\x03");
1491
1514
  closeOneShot();
1492
- throw new Error(`Command timed out after ${p.timeout}s(已发 Ctrl+C;已有输出:${truncateMiddle(stripAnsi(collected), 4000)})`);
1515
+ const timeoutPartial = truncateMiddle(stripAnsi(collected), 4000);
1516
+ throw new Error(pick(lang, `Command timed out after ${p.timeout}s(已发 Ctrl+C;已有输出:${timeoutPartial})`, `Command timed out after ${p.timeout}s (sent Ctrl+C; partial output: ${timeoutPartial})`, "terminals.bash.timeout", { "p.timeout": p.timeout, timeoutPartial }));
1493
1517
  }
1494
1518
  // 静默解阻(仅持久终端):转后台 + 注册完成观察器,立即把控制权还给模型。
1495
1519
  if (persist && idleMs > 0 && Date.now() - lastDataAt >= idleMs) {
1496
- return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail), Math.round((Date.now() - lastDataAt) / 1000));
1520
+ return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail), Math.round((Date.now() - lastDataAt) / 1000), lang);
1497
1521
  }
1498
1522
  }
1499
1523
  }
@@ -1504,7 +1528,7 @@ export function makeTerminalBashTool(terminals, opts) {
1504
1528
  });
1505
1529
  }
1506
1530
  /** 静默解阻路径:注册完成观察器后立即返回「仍在后台运行」。 */
1507
- function backgroundResult(terminals, opts, command, partialText, silentSeconds) {
1531
+ function backgroundResult(terminals, opts, command, partialText, silentSeconds, lang) {
1508
1532
  terminals.watchOutput("ai-bash", BASH_SENTINEL_RE, (m) => {
1509
1533
  // 后台命令最终结束(或终端被关)→ 清除待决标记,terminal_wait 不再适用。
1510
1534
  terminals.setSentinelPending("ai-bash", false);
@@ -1516,14 +1540,20 @@ function backgroundResult(terminals, opts, command, partialText, silentSeconds)
1516
1540
  });
1517
1541
  // partialText 已在调用方做过 cleanBashOutput + applyTail。
1518
1542
  const partial = truncateMiddle(partialText, 6000);
1543
+ // 空输出占位按语言预渲染(issue #91 v2:vars 只收干净标识)。
1544
+ const partialZh = partial || "(暂无输出)";
1545
+ const partialEn = partial || "(no output yet)";
1519
1546
  return {
1520
1547
  content: [
1521
1548
  {
1522
1549
  type: "text",
1523
- text: `命令仍在持久终端 ai-bash 中运行(已连续 ${silentSeconds} 秒无输出,未结束)。` +
1550
+ text: pick(lang, `命令仍在持久终端 ai-bash 中运行(已连续 ${silentSeconds} 秒无输出,未结束)。` +
1524
1551
  `本次调用不阻塞——命令继续在后台执行,结束时你会收到自动通知。\n` +
1525
- `已有输出:\n${partial || "(暂无输出)"}\n` +
1526
- `要重新阻塞等它结束就用 terminal_wait(terminalId="ai-bash")(无需反复轮询);需要交互用 terminal_input / terminal_key(Ctrl+C 可终止)。`,
1552
+ `已有输出:\n${partialZh}\n` +
1553
+ `要重新阻塞等它结束就用 terminal_wait(terminalId="ai-bash")(无需反复轮询);需要交互用 terminal_input / terminal_key(Ctrl+C 可终止)。`, `Command still running in the persistent terminal ai-bash (no output for ${silentSeconds}s, not finished). ` +
1554
+ `This call does not block — the command keeps running in the background and you will be notified automatically when it finishes.\n` +
1555
+ `Partial output:\n${partialEn}\n` +
1556
+ `To block until it finishes, use terminal_wait(terminalId="ai-bash") (no polling needed); use terminal_input / terminal_key to interact (Ctrl+C aborts).`, "terminals.bash.background.running", { silentSeconds, partialZh, partialEn }),
1527
1557
  },
1528
1558
  ],
1529
1559
  details: { running: true, terminalId: "ai-bash", silentSeconds },
@@ -1548,7 +1578,10 @@ export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools ar
1548
1578
  - The user explicitly asks you to work in the visible terminal panel.
1549
1579
  Liveness watchdog: terminals you touched (create/input/key) are monitored - if one goes silent with no new output while you are working (default 15s), an automatic system reminder is injected into the conversation. Treat it as a prompt to check that terminal (terminal_read), respond to an input prompt (terminal_input / terminal_key), or close it (terminal_close) if it is no longer needed.`;
1550
1580
  /** Build the agent-facing persistent terminal tools for one conversation. */
1551
- export function makePersistentTerminalTools(terminals, cwd) {
1581
+ export function makePersistentTerminalTools(terminals, cwd,
1582
+ /** per-call 返回文本的服务端语言(默认英文);工具 definition 走 bilingual 内联双语。 */
1583
+ lang) {
1584
+ const getLang = lang ?? (() => "en");
1552
1585
  const result = (text, details = {}) => ({ content: [{ type: "text", text }], details });
1553
1586
  const failIf = (error) => {
1554
1587
  if (error)
@@ -1558,62 +1591,70 @@ export function makePersistentTerminalTools(terminals, cwd) {
1558
1591
  defineTool({
1559
1592
  name: "terminal_create",
1560
1593
  label: "Create terminal",
1561
- 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. Prefer this over bash when the program is interactive/TUI-based (REPLs, vim/htop, y/n prompts), when starting a long-running server you want to keep observing or interrupt, or when the user asks to work in the visible terminal. For simple one-shot commands use bash instead.",
1562
- promptSnippet: "run interactive programs or long-running servers in a persistent visible PTY (multi-step: create → input/key → read)",
1594
+ description: bilingual("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. Prefer this over bash when the program is interactive/TUI-based (REPLs, vim/htop, y/n prompts), when starting a long-running server you want to keep observing or interrupt, or when the user asks to work in the visible terminal. For simple one-shot commands use bash instead.", "在当前工作区创建具名常驻交互式 PTY。用 terminal_input 或 terminal_key 与之交互,用 terminal_read 查看增量输出。程序是交互式/TUI(REPL、vim/htop、y/n 提示)、要启动长驻服务并持续观察或中断、或用户明确要求在可见终端里操作时,优先用它而非 bash。简单的一次性命令请用 bash。"),
1595
+ promptSnippet: bilingual("run interactive programs or long-running servers in a persistent visible PTY (multi-step: create → input/key → read)", "在常驻可见 PTY 中运行交互式程序或长驻服务(多步:create → input/key → read)"),
1563
1596
  parameters: Type.Object({
1564
- terminalId: Type.String({ description: "Stable terminal name" }),
1565
- cwd: Type.Optional(Type.String({ description: "Workspace-relative directory" })),
1597
+ terminalId: Type.String({ description: bilingual("Stable terminal name", "稳定的终端名称") }),
1598
+ cwd: Type.Optional(Type.String({ description: bilingual("Workspace-relative directory", "工作区相对目录") })),
1566
1599
  cols: Type.Optional(Type.Integer({ minimum: 2, maximum: 500 })),
1567
1600
  rows: Type.Optional(Type.Integer({ minimum: 2, maximum: 200 })),
1568
1601
  }),
1569
1602
  execute: async (_id, p) => {
1603
+ const lang = getLang();
1570
1604
  const info = terminals.create(p.terminalId, p.cwd ?? cwd, p.cols ?? 120, p.rows ?? 40, cwd, p.terminalId);
1605
+ const infoJson = JSON.stringify(info);
1571
1606
  if (!info)
1572
- throw new Error(`创建终端失败:${p.terminalId}`);
1607
+ throw new Error(pick(lang, `创建终端失败:${p.terminalId}`, `Failed to create terminal: ${p.terminalId}`, "terminals.create.failed", { "p.terminalId": p.terminalId }));
1573
1608
  // AI 创建 → 启动活力检测纪元(静默提醒只针对 agent 触碰过的终端)。
1574
1609
  terminals.noteAgentActivity(p.terminalId);
1575
- return result(`终端已创建:${JSON.stringify(info)}`, info);
1610
+ return result(pick(lang, `终端已创建:${infoJson}`, `Terminal created: ${infoJson}`, "terminals.create.done", { infoJson }), info);
1576
1611
  },
1577
1612
  }),
1578
1613
  defineTool({
1579
1614
  name: "terminal_list",
1580
1615
  label: "List terminals",
1581
- description: "List all persistent PTY terminals owned by this conversation.",
1582
- promptSnippet: "list persistent terminals",
1616
+ description: bilingual("List all persistent PTY terminals owned by this conversation.", "列出本对话拥有的全部常驻 PTY 终端。"),
1617
+ promptSnippet: bilingual("list persistent terminals", "列出常驻终端"),
1583
1618
  parameters: Type.Object({}),
1584
1619
  execute: async () => result(JSON.stringify(terminals.list()), terminals.list()),
1585
1620
  }),
1586
1621
  defineTool({
1587
1622
  name: "terminal_close",
1588
1623
  label: "Close terminal",
1589
- description: "Close a persistent PTY and terminate its process tree.",
1624
+ description: bilingual("Close a persistent PTY and terminate its process tree.", "关闭常驻 PTY 并终止其进程树。"),
1590
1625
  parameters: Type.Object({ terminalId: Type.String() }),
1591
1626
  execute: async (_id, p) => {
1627
+ const lang = getLang();
1592
1628
  if (!terminals.has(p.terminalId))
1593
- throw new Error(`终端不存在:${p.terminalId}`);
1629
+ throw new Error(pick(lang, `终端不存在:${p.terminalId}`, `Terminal not found: ${p.terminalId}`, "terminals.close.not.found", { "p.terminalId": p.terminalId }));
1594
1630
  terminals.kill(p.terminalId);
1595
- return result(`终端已关闭:${p.terminalId}`);
1631
+ return result(pick(lang, `终端已关闭:${p.terminalId}`, `Terminal closed: ${p.terminalId}`, "terminals.close.done", {
1632
+ "p.terminalId": p.terminalId,
1633
+ }));
1596
1634
  },
1597
1635
  }),
1598
1636
  defineTool({
1599
1637
  name: "terminal_input",
1600
1638
  label: "Send terminal input",
1601
- description: "Send arbitrary text to a persistent PTY. Include newline when a command should be submitted.",
1639
+ description: bilingual("Send arbitrary text to a persistent PTY. Include newline when a command should be submitted.", "向常驻 PTY 发送任意文本。需要提交命令时带上换行。"),
1602
1640
  parameters: Type.Object({ terminalId: Type.String(), data: Type.String() }),
1603
1641
  execute: async (_id, p) => {
1642
+ const lang = getLang();
1604
1643
  failIf(terminals.inputChecked(p.terminalId, p.data));
1605
1644
  // AI 发了输入 = 在等结果,重开一个静默纪元。
1606
1645
  terminals.noteAgentActivity(p.terminalId);
1607
- return result(`已发送 ${p.data.length} 个字符到 ${p.terminalId}`);
1646
+ return result(pick(lang, `已发送 ${p.data.length} 个字符到 ${p.terminalId}`, `Sent ${p.data.length} chars to ${p.terminalId}`, "terminals.input.sent", { "p.data.length": p.data.length, "p.terminalId": p.terminalId }));
1608
1647
  },
1609
1648
  }),
1610
1649
  defineTool({
1611
1650
  name: "terminal_key",
1612
1651
  label: "Send terminal key",
1613
- description: "Send Enter, Tab, arrows, function keys, or Ctrl/Alt combinations to a persistent PTY.",
1652
+ description: bilingual("Send Enter, Tab, arrows, function keys, or Ctrl/Alt combinations to a persistent PTY.", "向常驻 PTY 发送 Enter、Tab、方向键、功能键或 Ctrl/Alt 组合键。"),
1614
1653
  parameters: Type.Object({
1615
1654
  terminalId: Type.String(),
1616
- key: Type.String({ description: "Enter, Tab, ArrowUp, c, etc." }),
1655
+ key: Type.String({
1656
+ description: bilingual("Enter, Tab, ArrowUp, c, etc.", "按键名,如 Enter、Tab、ArrowUp、c 等"),
1657
+ }),
1617
1658
  modifiers: Type.Optional(Type.Object({
1618
1659
  ctrl: Type.Optional(Type.Boolean()),
1619
1660
  alt: Type.Optional(Type.Boolean()),
@@ -1621,16 +1662,17 @@ export function makePersistentTerminalTools(terminals, cwd) {
1621
1662
  })),
1622
1663
  }),
1623
1664
  execute: async (_id, p) => {
1665
+ const lang = getLang();
1624
1666
  failIf(terminals.key(p.terminalId, p.key, p.modifiers));
1625
1667
  // 同 terminal_input:AI 主动交互后重新计时。
1626
1668
  terminals.noteAgentActivity(p.terminalId);
1627
- return result(`已发送按键 ${p.key} 到 ${p.terminalId}`);
1669
+ return result(pick(lang, `已发送按键 ${p.key} 到 ${p.terminalId}`, `Sent key ${p.key} to ${p.terminalId}`, "terminals.key.sent", { "p.key": p.key, "p.terminalId": p.terminalId }));
1628
1670
  },
1629
1671
  }),
1630
1672
  defineTool({
1631
1673
  name: "terminal_read",
1632
1674
  label: "Read terminal output",
1633
- 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.",
1675
+ description: bilingual("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.", "从常驻 PTY 读取增量输出。保留返回的 cursor,下次读取时传回;可选择等待新输出或进程退出。"),
1634
1676
  parameters: Type.Object({
1635
1677
  terminalId: Type.String(),
1636
1678
  cursor: Type.Optional(Type.Integer({ minimum: 0 })),
@@ -1638,35 +1680,44 @@ export function makePersistentTerminalTools(terminals, cwd) {
1638
1680
  waitMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 120000 })),
1639
1681
  }),
1640
1682
  execute: async (_id, p, signal) => {
1683
+ const lang = getLang();
1641
1684
  const cursor = p.cursor ?? 0;
1642
1685
  if (p.waitMs)
1643
1686
  await terminals.waitForOutput(p.terminalId, cursor, p.waitMs, signal);
1644
1687
  const read = terminals.read(p.terminalId, cursor, p.maxBytes ?? 20000);
1645
1688
  if (!read)
1646
- throw new Error(`终端不存在:${p.terminalId}`);
1689
+ throw new Error(pick(lang, `终端不存在:${p.terminalId}`, `Terminal not found: ${p.terminalId}`, "terminals.read.not.found", { "p.terminalId": p.terminalId }));
1647
1690
  return result(JSON.stringify(read), read);
1648
1691
  },
1649
1692
  }),
1650
1693
  defineTool({
1651
1694
  name: "terminal_wait",
1652
1695
  label: "Wait for terminal command",
1653
- description: "Block until a command started THROUGH THE BASH TOOL finishes (its exit marker appears) or the timeout expires — no polling needed. Only applies to terminals with a pending bash-tool command; terminals driven manually via terminal_input (e.g. interactive programs) have no completion marker — use terminal_read(waitMs=…) to observe those instead. Returns {finished, exitCode} plus the output produced while waiting; finished=false means it is STILL running (call again to keep waiting).",
1654
- promptSnippet: "block until a terminal's current command finishes (no polling)",
1696
+ description: bilingual("Block until a command started THROUGH THE BASH TOOL finishes (its exit marker appears) or the timeout expires — no polling needed. Only applies to terminals with a pending bash-tool command; terminals driven manually via terminal_input (e.g. interactive programs) have no completion marker — use terminal_read(waitMs=…) to observe those instead. Returns {finished, exitCode} plus the output produced while waiting; finished=false means it is STILL running (call again to keep waiting).", "阻塞等待经 BASH 工具启动的命令结束(出现退出标记)或超时——无需轮询。仅适用于有待决 bash 工具命令的终端;经 terminal_input 手动驱动的终端(如交互式程序)没有完成标记——观察它们请用 terminal_read(waitMs=…)。返回 {finished, exitCode} 及等待期间产生的输出;finished=false 表示仍在运行(可再次调用继续等)。"),
1697
+ promptSnippet: bilingual("block until a terminal's current command finishes (no polling)", "阻塞等待终端当前命令结束(无需轮询)"),
1655
1698
  parameters: Type.Object({
1656
1699
  terminalId: Type.String(),
1657
- cursor: Type.Optional(Type.Integer({ minimum: 0, description: "Ignore exit markers before this absolute offset (default: now)" })),
1658
- maxWaitMs: Type.Optional(Type.Integer({ minimum: 100, maximum: 600000, description: "Max wait in ms (default 300000)" })),
1700
+ cursor: Type.Optional(Type.Integer({
1701
+ minimum: 0,
1702
+ description: bilingual("Ignore exit markers before this absolute offset (default: now)", "忽略该绝对偏移之前的退出标记(默认:现在)"),
1703
+ })),
1704
+ maxWaitMs: Type.Optional(Type.Integer({
1705
+ minimum: 100,
1706
+ maximum: 600000,
1707
+ description: bilingual("Max wait in ms (default 300000)", "最长等待毫秒数(默认 300000)"),
1708
+ })),
1659
1709
  }),
1660
1710
  execute: async (_id, p, signal) => {
1711
+ const lang = getLang();
1661
1712
  if (!terminals.has(p.terminalId)) {
1662
- throw new Error(`终端不存在:${p.terminalId}(可能已被关闭或会话重置,请先 terminal_create)`);
1713
+ throw new Error(pick(lang, `终端不存在:${p.terminalId}(可能已被关闭或会话重置,请先 terminal_create)`, `Terminal not found: ${p.terminalId} (it may have been closed or the session was reset — run terminal_create first)`, "terminals.wait.not.found", { "p.terminalId": p.terminalId }));
1663
1714
  }
1664
1715
  // 没有带哨兵的待决命令:shell 空闲在提示符,或该终端的命令是经
1665
1716
  // terminal_input 手动发的(无完成标记)——等哨兵永远等不到,直接
1666
1717
  // 说明并引导改用 terminal_read,避免 AI 无限重试。(显式传 cursor
1667
1718
  // 的调用是有目的的追溯查询,不拦。)
1668
1719
  if (p.cursor === undefined && !terminals.isSentinelPending(p.terminalId)) {
1669
- const why = `终端 ${p.terminalId} 当前没有正在等待完成的 bash 工具命令(shell 空闲,或该命令是通过 terminal_input 发出的、没有完成标记)。terminal_wait 不适用;要观察输出请用 terminal_read(terminalId="${p.terminalId}", waitMs=…)。`;
1720
+ const why = pick(lang, `终端 ${p.terminalId} 当前没有正在等待完成的 bash 工具命令(shell 空闲,或该命令是通过 terminal_input 发出的、没有完成标记)。terminal_wait 不适用;要观察输出请用 terminal_read(terminalId="${p.terminalId}", waitMs=…)。`, `Terminal ${p.terminalId} has no pending bash-tool command to wait for (the shell is idle, or the command was sent via terminal_input and has no completion marker). terminal_wait does not apply; use terminal_read(terminalId="${p.terminalId}", waitMs=…) to observe output.`, "terminals.wait.no.pending", { "p.terminalId": p.terminalId });
1670
1721
  return result(JSON.stringify({ applicable: false, reason: why }), { applicable: false });
1671
1722
  }
1672
1723
  const cursor = p.cursor ?? terminals.endCursor(p.terminalId) ?? 0;
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { readdirSync, readFileSync, realpathSync, existsSync } from "node:fs";
11
11
  import { delimiter, dirname, join } from "node:path";
12
+ import { pick } from "./i18n.js";
12
13
  const PI_CORE_PACKAGE = "@earendil-works/pi-coding-agent";
13
14
  const REGISTRY = "https://registry.npmjs.org";
14
15
  const FETCH_TIMEOUT_MS = 8_000;
@@ -274,7 +275,10 @@ export async function fetchLatest(fetcher, name) {
274
275
  * error item (upToDate: false) without failing the rest. Results keep the
275
276
  * input order. Bounded concurrency (CONCURRENCY) keeps registry load polite.
276
277
  */
277
- export async function checkAll(targets, fetcher = defaultFetcher) {
278
+ export async function checkAll(targets, fetcher = defaultFetcher,
279
+ /** 单项 registry 查询失败时的 error 文案语言(默认英文)。 */
280
+ lang) {
281
+ const l = lang?.() ?? "en";
278
282
  const results = Array.from({ length: targets.length });
279
283
  let cursor = 0;
280
284
  async function worker() {
@@ -293,6 +297,7 @@ export async function checkAll(targets, fetcher = defaultFetcher) {
293
297
  };
294
298
  }
295
299
  catch (err) {
300
+ const errMessage = err.message;
296
301
  results[i] = {
297
302
  name: t.name,
298
303
  kind: t.kind,
@@ -300,7 +305,7 @@ export async function checkAll(targets, fetcher = defaultFetcher) {
300
305
  latest: null,
301
306
  latestPublishedAt: null,
302
307
  upToDate: false,
303
- error: `检查更新失败:${err.message}`,
308
+ error: pick(l, `检查更新失败:${errMessage}`, `Failed to check for updates: ${errMessage}`, "updatecheck.check.failed", { errMessage }),
304
309
  };
305
310
  }
306
311
  }
@@ -1,3 +1,4 @@
1
+ import { getServerBlock, pick } from "./i18n.js";
1
2
  /** Per-batch timeout; a slow vision provider shouldn't stall a prompt forever. */
2
3
  const TRANSCRIBE_TIMEOUT_MS = Number(process.env.PI_WEB_VISION_TIMEOUT_MS ?? 90_000);
3
4
  /** Cap the transcript length so it doesn't blow up the main context. */
@@ -45,6 +46,24 @@ Follow these rules:
45
46
  5. If part of the image is too blurry/low-resolution to read, say "(读不清)" or "unclear" for that part — NEVER invent or guess content you cannot see.
46
47
  6. If there are multiple images, address them in order (图 1 / Image 1, 图 2 / Image 2, ...).
47
48
  7. Output only the transcript. No preamble, no commentary about the image itself.`;
49
+ /**
50
+ * 中文版内置转写提示词(调用方按 lang 选用,默认英文)。与 SYSTEM_PROMPT 同义,
51
+ * 仅语言不同——英文版是默认行为,保持原有转写质量不变。
52
+ */
53
+ export const SYSTEM_PROMPT_ZH = `你是文本模型的视觉桥。你会收到一张或多张图片,必须把它们转写成精确的结构化文字证据,让看不到图片的模型也能准确回答相关问题。
54
+
55
+ 规则:
56
+ 1. 逐字转写所有可见文本,保留措辞、拼写、标点与换行。这是最重要的部分——阅读者依赖你的转写,而非图片本身。
57
+ 2. 按阅读顺序描述版式:标题、段落、列表、表格、按钮、面板——说明它们出现的位置。
58
+ 3. 表格/图表/示意图:读出坐标轴、刻度(注意对数轴)、图例项、系列名、高亮点及其坐标,以及能辨认的数据值。
59
+ 4. 点名实体:人物、产品、公司、颜色、风格、物体、动作。
60
+ 5. 图片局部太模糊/分辨率太低读不清时,对该部分写“(读不清)”或 "unclear”——绝不编造或猜测看不见的内容。
61
+ 6. 多张图片时按顺序逐张处理(图 1 / Image 1,图 2 / Image 2,……)。
62
+ 7. 只输出转写结果,不加开场白,不评论图片本身。`;
63
+ /** 按服务端语言选用内置转写提示词(默认英文)。 */
64
+ export function getVisionSystemPrompt(lang = "en") {
65
+ return getServerBlock(lang, "vision.system", SYSTEM_PROMPT_ZH.split("\n"), SYSTEM_PROMPT.split("\n")).join("\n");
66
+ }
48
67
  /**
49
68
  * Assemble the final vision-model system prompt from the settings-panel prefs.
50
69
  * mode "append": custom text appended after the default prompt (empty custom =
@@ -52,36 +71,38 @@ Follow these rules:
52
71
  * an empty custom text still falls back to the default (never send an empty
53
72
  * system prompt to the vision model).
54
73
  */
55
- export function buildVisionBridgePrompt(mode, custom) {
74
+ export function buildVisionBridgePrompt(mode, custom, lang = "en") {
56
75
  const text = custom?.trim() ?? "";
76
+ const base = getVisionSystemPrompt(lang);
57
77
  if (mode === "replace" && text)
58
78
  return text;
59
79
  if (text)
60
- return `${SYSTEM_PROMPT}\n\n${text}`;
61
- return SYSTEM_PROMPT;
80
+ return `${base}\n\n${text}`;
81
+ return base;
62
82
  }
63
83
  /** Per-batch user instruction appended after the images. */
64
- function buildUserPrompt(count) {
84
+ function buildUserPrompt(count, lang = "en") {
65
85
  if (count <= 1) {
66
- return "请逐字转写这张图片的内容,并按上述规则输出结构化文字证据。";
86
+ return pick(lang, "请逐字转写这张图片的内容,并按上述规则输出结构化文字证据。", "Transcribe this image verbatim and output structured text evidence following the rules above.", "vision.transcribe.single");
67
87
  }
68
- return `请按图片顺序(图 1 到 图 ${count})逐张转写每张图片的内容,并按上述规则输出结构化文字证据。`;
88
+ return pick(lang, `请按图片顺序(图 1 到 图 ${count})逐张转写每张图片的内容,并按上述规则输出结构化文字证据。`, `Transcribe each image in order (Image 1 to Image ${count}) and output structured text evidence following the rules above.`, "vision.transcribe.batch", { count });
69
89
  }
70
90
  /**
71
91
  * Send one batch of images to a vision model and return its transcript.
72
92
  * Throws on timeout, abort, provider error or an empty response.
73
93
  */
74
94
  export async function transcribeImages(runtime, images, options = {}) {
95
+ const lang = options.lang ?? "en";
75
96
  const model = options.model ??
76
97
  (() => {
77
98
  const found = findVisionModels(runtime);
78
99
  if (found.length === 0) {
79
- throw new Error("未找到可用的视觉模型(models.json 中没有任何 input 含 image 的模型)");
100
+ throw new Error(pick(lang, "未找到可用的视觉模型(models.json 中没有任何 input 含 image 的模型)", "No vision-capable model found (no model with image input in models.json)", "vision.model.not.found"));
80
101
  }
81
102
  return runtime.getModel(found[0].provider, found[0].id);
82
103
  })();
83
104
  if (!model)
84
- throw new Error("视觉模型不可用(ModelRuntime.getModel 返回空)");
105
+ throw new Error(pick(lang, "视觉模型不可用(ModelRuntime.getModel 返回空)", "Vision model unavailable (ModelRuntime.getModel returned empty)", "vision.model.unavailable"));
85
106
  const ac = new AbortController();
86
107
  const timer = setTimeout(() => ac.abort(), TRANSCRIBE_TIMEOUT_MS);
87
108
  const onOuterAbort = () => ac.abort();
@@ -93,12 +114,12 @@ export async function transcribeImages(runtime, images, options = {}) {
93
114
  mimeType: img.mimeType?.startsWith("image/") ? img.mimeType : "image/png",
94
115
  }));
95
116
  const context = {
96
- systemPrompt: options.systemPrompt ?? SYSTEM_PROMPT,
117
+ systemPrompt: options.systemPrompt ?? getVisionSystemPrompt(lang),
97
118
  messages: [
98
119
  {
99
120
  role: "user",
100
121
  timestamp: Date.now(),
101
- content: [...imageBlocks, { type: "text", text: buildUserPrompt(images.length) }],
122
+ content: [...imageBlocks, { type: "text", text: buildUserPrompt(images.length, lang) }],
102
123
  },
103
124
  ],
104
125
  };
@@ -107,7 +128,8 @@ export async function transcribeImages(runtime, images, options = {}) {
107
128
  maxTokens: MAX_TRANSCRIBE_TOKENS,
108
129
  });
109
130
  if (msg.stopReason === "error" || msg.stopReason === "aborted") {
110
- throw new Error(msg.errorMessage || `视觉模型异常终止(${msg.stopReason})`);
131
+ throw new Error(msg.errorMessage ||
132
+ pick(lang, `视觉模型异常终止(${msg.stopReason})`, `Vision model terminated abnormally (${msg.stopReason})`, "vision.model.terminated", { "msg.stopReason": msg.stopReason }));
111
133
  }
112
134
  const text = msg.content
113
135
  .filter((b) => b.type === "text")
@@ -115,7 +137,7 @@ export async function transcribeImages(runtime, images, options = {}) {
115
137
  .join("\n")
116
138
  .trim();
117
139
  if (!text) {
118
- throw new Error("视觉模型返回了空的转写结果");
140
+ throw new Error(pick(lang, "视觉模型返回了空的转写结果", "Vision model returned an empty transcript", "vision.transcript.empty"));
119
141
  }
120
142
  return text;
121
143
  }
@@ -45,6 +45,15 @@ export class WebUIContext {
45
45
  this.emit = emit;
46
46
  }
47
47
  // -- widgets -------------------------------------------------------------
48
+ /** Register a widget whose lines come from a plain getter, re-evaluated on
49
+ * every render/refresh (no SDK Component required). Used for
50
+ * conversation-scoped overlays (e.g. markers) so switching conversations
51
+ * re-renders them without re-registering. */
52
+ setDynamicWidget(key, lines) {
53
+ this.widgets.set(key, { render: () => lines() });
54
+ this.lastLines.delete(key);
55
+ this.push();
56
+ }
48
57
  /** Matches ExtensionUIContext's overloaded setWidget exactly. */
49
58
  setWidget = (key, content, options) => {
50
59
  void options;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.68.2",
3
+ "version": "0.70.0",
4
4
  "description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -85,6 +85,7 @@
85
85
  "react-markdown": "^9.0.1",
86
86
  "rehype-highlight": "^7.0.1",
87
87
  "rehype-raw": "^7.0.0",
88
+ "remark-breaks": "^4.0.0",
88
89
  "remark-gfm": "^4.0.0",
89
90
  "typebox": "^1.3.14",
90
91
  "ws": "^8.18.0"