pi-web-ui 0.68.2 → 0.69.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 (42) hide show
  1. package/dist/server/agent-service.js +157 -50
  2. package/dist/server/attachments.js +7 -2
  3. package/dist/server/client-state.js +27 -0
  4. package/dist/server/dsh/dsh-agent-service.js +139 -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 +76 -18
  12. package/dist/server/locales.js +55 -1
  13. package/dist/server/managed.js +61 -0
  14. package/dist/server/marker-service.js +20 -7
  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 +20 -1
  29. package/dist/server/subagent-templates.js +105 -0
  30. package/dist/server/subagents.js +155 -54
  31. package/dist/server/tabs.js +87 -0
  32. package/dist/server/terminals.js +88 -48
  33. package/dist/server/update-check.js +7 -2
  34. package/dist/server/vision-bridge.js +34 -12
  35. package/package.json +2 -1
  36. package/web/dist/assets/{TerminalPanel-BQ5NTB9Y.js → TerminalPanel-Cj8zsjx-.js} +1 -1
  37. package/web/dist/assets/index-DCOcsPFm.js +334 -0
  38. package/web/dist/assets/{index-C_I-6Zul.css → index-jH2Bb-0X.css} +1 -1
  39. package/web/dist/assets/{markdown-DOsihKaR.js → markdown-Cpo0pNcR.js} +1 -1
  40. package/web/dist/assets/{react-DIP6JKYk.js → react-CtudoG1_.js} +1 -1
  41. package/web/dist/index.html +4 -4
  42. 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) {
@@ -1053,7 +1058,7 @@ export class TerminalManager {
1053
1058
  return `输入过长(上限 ${MAX_INPUT} 字符) Input too long (max ${MAX_INPUT} chars)`;
1054
1059
  const entry = this.terms.get(id);
1055
1060
  if (!entry || entry.exited)
1056
- return "终端不存在或进程已退出";
1061
+ return pick(this.lang?.() ?? "en", "终端不存在或进程已退出", "Terminal not found or its process has exited", "terminals.not.found.exited");
1057
1062
  // 已武装的纪元里任何人(含用户手动敲键盘)写了输入都算新活动,重置倒计时。
1058
1063
  entry.lastActivityAt = Date.now();
1059
1064
  if (entry.idleTimer)
@@ -1377,29 +1382,33 @@ export function makeTerminalBashTool(terminals, opts) {
1377
1382
  return defineTool({
1378
1383
  name: "bash",
1379
1384
  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" +
1385
+ description: bilingual("Run a shell command and return its full output plus exit code. Commands run in a visible terminal.\n" +
1381
1386
  "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
1387
  "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)",
1388
+ "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" +
1389
+ "persist=false(默认,一次性):每次调用新建一个终端,运行至结束,然后 shell 退出(进程结束),输出保留在终端列表中供稍后查看——如同普通 bash 调用,但每条命令都会留下一条可查看的终端记录。\n" +
1390
+ "persist=true:命令在常驻可见终端 'ai-bash' 中运行——cd、venv 激活、ssh 会话等 shell 状态跨调用保留;可用 terminal_wait 重新阻塞等待后台命令,或随时用 terminal_read / terminal_input / terminal_key 观察或交互。\n" +
1391
+ "直接运行裸命令——不要经 head/tail/more/less 管道(反正会返回完整输出,管道还会挡住终端里的实时进度)。用 head/tail 参数截断返回的输出。交互式命令(REPL、提示符、问 y/n 的安装程序)请设 persist=true 并用 terminal_input / terminal_key 驱动。"),
1392
+ promptSnippet: bilingual("run shell commands (persist=true keeps the terminal alive across calls)", "运行 shell 命令(persist=true 让终端跨调用保持存活)"),
1385
1393
  parameters: Type.Object({
1386
- command: Type.String({ description: "The shell command to run" }),
1387
- timeout: Type.Optional(Type.Number({ description: "Optional timeout in seconds" })),
1394
+ command: Type.String({ description: bilingual("The shell command to run", "要运行的 shell 命令") }),
1395
+ timeout: Type.Optional(Type.Number({ description: bilingual("Optional timeout in seconds", "可选的超时秒数") })),
1388
1396
  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.",
1397
+ 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
1398
  })),
1391
1399
  head: Type.Optional(Type.Integer({
1392
1400
  minimum: 1,
1393
1401
  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.",
1402
+ 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
1403
  })),
1396
1404
  tail: Type.Optional(Type.Integer({
1397
1405
  minimum: 1,
1398
1406
  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.",
1407
+ 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
1408
  })),
1401
1409
  }),
1402
1410
  execute: async (_id, p, signal) => {
1411
+ const lang = opts.lang?.() ?? "en";
1403
1412
  const persist = p.persist ?? opts.defaultPersist();
1404
1413
  // create() 对已存活的同名终端原样返回、对已退出的原地重启。
1405
1414
  // forceBash:该终端永远跑 bash(而非用户登录 shell),模型写的
@@ -1412,7 +1421,7 @@ export function makeTerminalBashTool(terminals, opts) {
1412
1421
  forceBash: true,
1413
1422
  agentBash: true,
1414
1423
  }) === null) {
1415
- throw new Error(`无法打开 AI bash 终端(${termId})`);
1424
+ throw new Error(pick(lang, `无法打开 AI bash 终端(${termId})`, `Failed to open the AI bash terminal (${termId})`, "terminals.bash.open.failed", { termId }));
1416
1425
  }
1417
1426
  // 阻塞等待期间挂起活力提醒(我们自己在检测静默,避免双重通知)。
1418
1427
  terminals.suspendIdleWatch(termId);
@@ -1439,12 +1448,15 @@ export function makeTerminalBashTool(terminals, opts) {
1439
1448
  // tail 文件让模型看到日志尾部与真实退出码(否则输出为空)。
1440
1449
  const redirect = stripped && limiter.kind === "tail" ? detectStdoutRedirect(runCommand) : null;
1441
1450
  const tailFile = redirect ? { file: redirect.file, lines: limiter.lines ?? 10 } : undefined;
1451
+ // 复杂子表达式先 hoist 成干净 const(issue #91 v2:vars key 不写复杂表达式)。
1452
+ const limiterSegment = limiter.segment;
1453
+ const limiterTailLines = limiter.lines ?? 10;
1454
+ const limiterTailZh = limiter.kind === "tail" ? `本次返回末尾 ${limiterTailLines} 行。` : "本次返回全部输出。";
1455
+ const limiterTailEn = limiter.kind === "tail"
1456
+ ? `Returning the last ${limiterTailLines} lines this time.`
1457
+ : "Returning the full output this time.";
1442
1458
  const limiterNote = stripped
1443
- ? "\n[注:检测到你带了「" +
1444
- limiter.segment +
1445
- "」这类限输出/过滤管道——已在终端里直跑底层命令(实时可见 + 真实退出码),只按参数返回片段。" +
1446
- (limiter.kind === "tail" ? "本次返回末尾 " + (limiter.lines ?? 10) + " 行。" : "本次返回全部输出。") +
1447
- " 后续直接用 bash(command, tail=N) 参数限输出。]"
1459
+ ? 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
1460
  : "";
1449
1461
  try {
1450
1462
  let collected = "";
@@ -1461,7 +1473,7 @@ export function makeTerminalBashTool(terminals, opts) {
1461
1473
  terminals.setSentinelPending(termId, false);
1462
1474
  terminals.inputChecked(termId, "\x03");
1463
1475
  closeOneShot();
1464
- throw new Error("Command aborted");
1476
+ throw new Error(pick(lang, "命令已中止", "Command aborted", "terminals.bash.aborted"));
1465
1477
  }
1466
1478
  await sleep(60);
1467
1479
  const read = terminals.read(termId, cursor);
@@ -1489,11 +1501,12 @@ export function makeTerminalBashTool(terminals, opts) {
1489
1501
  terminals.setSentinelPending(termId, false);
1490
1502
  terminals.inputChecked(termId, "\x03");
1491
1503
  closeOneShot();
1492
- throw new Error(`Command timed out after ${p.timeout}s(已发 Ctrl+C;已有输出:${truncateMiddle(stripAnsi(collected), 4000)})`);
1504
+ const timeoutPartial = truncateMiddle(stripAnsi(collected), 4000);
1505
+ 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
1506
  }
1494
1507
  // 静默解阻(仅持久终端):转后台 + 注册完成观察器,立即把控制权还给模型。
1495
1508
  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));
1509
+ return backgroundResult(terminals, opts, runCommand, applyHeadTail(cleanBashOutput(collected), p.head, effectiveTail), Math.round((Date.now() - lastDataAt) / 1000), lang);
1497
1510
  }
1498
1511
  }
1499
1512
  }
@@ -1504,7 +1517,7 @@ export function makeTerminalBashTool(terminals, opts) {
1504
1517
  });
1505
1518
  }
1506
1519
  /** 静默解阻路径:注册完成观察器后立即返回「仍在后台运行」。 */
1507
- function backgroundResult(terminals, opts, command, partialText, silentSeconds) {
1520
+ function backgroundResult(terminals, opts, command, partialText, silentSeconds, lang) {
1508
1521
  terminals.watchOutput("ai-bash", BASH_SENTINEL_RE, (m) => {
1509
1522
  // 后台命令最终结束(或终端被关)→ 清除待决标记,terminal_wait 不再适用。
1510
1523
  terminals.setSentinelPending("ai-bash", false);
@@ -1516,14 +1529,20 @@ function backgroundResult(terminals, opts, command, partialText, silentSeconds)
1516
1529
  });
1517
1530
  // partialText 已在调用方做过 cleanBashOutput + applyTail。
1518
1531
  const partial = truncateMiddle(partialText, 6000);
1532
+ // 空输出占位按语言预渲染(issue #91 v2:vars 只收干净标识)。
1533
+ const partialZh = partial || "(暂无输出)";
1534
+ const partialEn = partial || "(no output yet)";
1519
1535
  return {
1520
1536
  content: [
1521
1537
  {
1522
1538
  type: "text",
1523
- text: `命令仍在持久终端 ai-bash 中运行(已连续 ${silentSeconds} 秒无输出,未结束)。` +
1539
+ text: pick(lang, `命令仍在持久终端 ai-bash 中运行(已连续 ${silentSeconds} 秒无输出,未结束)。` +
1524
1540
  `本次调用不阻塞——命令继续在后台执行,结束时你会收到自动通知。\n` +
1525
- `已有输出:\n${partial || "(暂无输出)"}\n` +
1526
- `要重新阻塞等它结束就用 terminal_wait(terminalId="ai-bash")(无需反复轮询);需要交互用 terminal_input / terminal_key(Ctrl+C 可终止)。`,
1541
+ `已有输出:\n${partialZh}\n` +
1542
+ `要重新阻塞等它结束就用 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).` +
1543
+ `This call does not block — the command keeps running in the background and you will be notified automatically when it finishes.\n` +
1544
+ `Partial output:\n${partialEn}\n` +
1545
+ `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
1546
  },
1528
1547
  ],
1529
1548
  details: { running: true, terminalId: "ai-bash", silentSeconds },
@@ -1548,7 +1567,10 @@ export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools ar
1548
1567
  - The user explicitly asks you to work in the visible terminal panel.
1549
1568
  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
1569
  /** Build the agent-facing persistent terminal tools for one conversation. */
1551
- export function makePersistentTerminalTools(terminals, cwd) {
1570
+ export function makePersistentTerminalTools(terminals, cwd,
1571
+ /** per-call 返回文本的服务端语言(默认英文);工具 definition 走 bilingual 内联双语。 */
1572
+ lang) {
1573
+ const getLang = lang ?? (() => "en");
1552
1574
  const result = (text, details = {}) => ({ content: [{ type: "text", text }], details });
1553
1575
  const failIf = (error) => {
1554
1576
  if (error)
@@ -1558,62 +1580,70 @@ export function makePersistentTerminalTools(terminals, cwd) {
1558
1580
  defineTool({
1559
1581
  name: "terminal_create",
1560
1582
  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)",
1583
+ 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。"),
1584
+ 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
1585
  parameters: Type.Object({
1564
- terminalId: Type.String({ description: "Stable terminal name" }),
1565
- cwd: Type.Optional(Type.String({ description: "Workspace-relative directory" })),
1586
+ terminalId: Type.String({ description: bilingual("Stable terminal name", "稳定的终端名称") }),
1587
+ cwd: Type.Optional(Type.String({ description: bilingual("Workspace-relative directory", "工作区相对目录") })),
1566
1588
  cols: Type.Optional(Type.Integer({ minimum: 2, maximum: 500 })),
1567
1589
  rows: Type.Optional(Type.Integer({ minimum: 2, maximum: 200 })),
1568
1590
  }),
1569
1591
  execute: async (_id, p) => {
1592
+ const lang = getLang();
1570
1593
  const info = terminals.create(p.terminalId, p.cwd ?? cwd, p.cols ?? 120, p.rows ?? 40, cwd, p.terminalId);
1594
+ const infoJson = JSON.stringify(info);
1571
1595
  if (!info)
1572
- throw new Error(`创建终端失败:${p.terminalId}`);
1596
+ throw new Error(pick(lang, `创建终端失败:${p.terminalId}`, `Failed to create terminal: ${p.terminalId}`, "terminals.create.failed", { "p.terminalId": p.terminalId }));
1573
1597
  // AI 创建 → 启动活力检测纪元(静默提醒只针对 agent 触碰过的终端)。
1574
1598
  terminals.noteAgentActivity(p.terminalId);
1575
- return result(`终端已创建:${JSON.stringify(info)}`, info);
1599
+ return result(pick(lang, `终端已创建:${infoJson}`, `Terminal created: ${infoJson}`, "terminals.create.done", { infoJson }), info);
1576
1600
  },
1577
1601
  }),
1578
1602
  defineTool({
1579
1603
  name: "terminal_list",
1580
1604
  label: "List terminals",
1581
- description: "List all persistent PTY terminals owned by this conversation.",
1582
- promptSnippet: "list persistent terminals",
1605
+ description: bilingual("List all persistent PTY terminals owned by this conversation.", "列出本对话拥有的全部常驻 PTY 终端。"),
1606
+ promptSnippet: bilingual("list persistent terminals", "列出常驻终端"),
1583
1607
  parameters: Type.Object({}),
1584
1608
  execute: async () => result(JSON.stringify(terminals.list()), terminals.list()),
1585
1609
  }),
1586
1610
  defineTool({
1587
1611
  name: "terminal_close",
1588
1612
  label: "Close terminal",
1589
- description: "Close a persistent PTY and terminate its process tree.",
1613
+ description: bilingual("Close a persistent PTY and terminate its process tree.", "关闭常驻 PTY 并终止其进程树。"),
1590
1614
  parameters: Type.Object({ terminalId: Type.String() }),
1591
1615
  execute: async (_id, p) => {
1616
+ const lang = getLang();
1592
1617
  if (!terminals.has(p.terminalId))
1593
- throw new Error(`终端不存在:${p.terminalId}`);
1618
+ throw new Error(pick(lang, `终端不存在:${p.terminalId}`, `Terminal not found: ${p.terminalId}`, "terminals.close.not.found", { "p.terminalId": p.terminalId }));
1594
1619
  terminals.kill(p.terminalId);
1595
- return result(`终端已关闭:${p.terminalId}`);
1620
+ return result(pick(lang, `终端已关闭:${p.terminalId}`, `Terminal closed: ${p.terminalId}`, "terminals.close.done", {
1621
+ "p.terminalId": p.terminalId,
1622
+ }));
1596
1623
  },
1597
1624
  }),
1598
1625
  defineTool({
1599
1626
  name: "terminal_input",
1600
1627
  label: "Send terminal input",
1601
- description: "Send arbitrary text to a persistent PTY. Include newline when a command should be submitted.",
1628
+ description: bilingual("Send arbitrary text to a persistent PTY. Include newline when a command should be submitted.", "向常驻 PTY 发送任意文本。需要提交命令时带上换行。"),
1602
1629
  parameters: Type.Object({ terminalId: Type.String(), data: Type.String() }),
1603
1630
  execute: async (_id, p) => {
1631
+ const lang = getLang();
1604
1632
  failIf(terminals.inputChecked(p.terminalId, p.data));
1605
1633
  // AI 发了输入 = 在等结果,重开一个静默纪元。
1606
1634
  terminals.noteAgentActivity(p.terminalId);
1607
- return result(`已发送 ${p.data.length} 个字符到 ${p.terminalId}`);
1635
+ 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
1636
  },
1609
1637
  }),
1610
1638
  defineTool({
1611
1639
  name: "terminal_key",
1612
1640
  label: "Send terminal key",
1613
- description: "Send Enter, Tab, arrows, function keys, or Ctrl/Alt combinations to a persistent PTY.",
1641
+ description: bilingual("Send Enter, Tab, arrows, function keys, or Ctrl/Alt combinations to a persistent PTY.", "向常驻 PTY 发送 Enter、Tab、方向键、功能键或 Ctrl/Alt 组合键。"),
1614
1642
  parameters: Type.Object({
1615
1643
  terminalId: Type.String(),
1616
- key: Type.String({ description: "Enter, Tab, ArrowUp, c, etc." }),
1644
+ key: Type.String({
1645
+ description: bilingual("Enter, Tab, ArrowUp, c, etc.", "按键名,如 Enter、Tab、ArrowUp、c 等"),
1646
+ }),
1617
1647
  modifiers: Type.Optional(Type.Object({
1618
1648
  ctrl: Type.Optional(Type.Boolean()),
1619
1649
  alt: Type.Optional(Type.Boolean()),
@@ -1621,16 +1651,17 @@ export function makePersistentTerminalTools(terminals, cwd) {
1621
1651
  })),
1622
1652
  }),
1623
1653
  execute: async (_id, p) => {
1654
+ const lang = getLang();
1624
1655
  failIf(terminals.key(p.terminalId, p.key, p.modifiers));
1625
1656
  // 同 terminal_input:AI 主动交互后重新计时。
1626
1657
  terminals.noteAgentActivity(p.terminalId);
1627
- return result(`已发送按键 ${p.key} 到 ${p.terminalId}`);
1658
+ 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
1659
  },
1629
1660
  }),
1630
1661
  defineTool({
1631
1662
  name: "terminal_read",
1632
1663
  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.",
1664
+ 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
1665
  parameters: Type.Object({
1635
1666
  terminalId: Type.String(),
1636
1667
  cursor: Type.Optional(Type.Integer({ minimum: 0 })),
@@ -1638,35 +1669,44 @@ export function makePersistentTerminalTools(terminals, cwd) {
1638
1669
  waitMs: Type.Optional(Type.Integer({ minimum: 0, maximum: 120000 })),
1639
1670
  }),
1640
1671
  execute: async (_id, p, signal) => {
1672
+ const lang = getLang();
1641
1673
  const cursor = p.cursor ?? 0;
1642
1674
  if (p.waitMs)
1643
1675
  await terminals.waitForOutput(p.terminalId, cursor, p.waitMs, signal);
1644
1676
  const read = terminals.read(p.terminalId, cursor, p.maxBytes ?? 20000);
1645
1677
  if (!read)
1646
- throw new Error(`终端不存在:${p.terminalId}`);
1678
+ throw new Error(pick(lang, `终端不存在:${p.terminalId}`, `Terminal not found: ${p.terminalId}`, "terminals.read.not.found", { "p.terminalId": p.terminalId }));
1647
1679
  return result(JSON.stringify(read), read);
1648
1680
  },
1649
1681
  }),
1650
1682
  defineTool({
1651
1683
  name: "terminal_wait",
1652
1684
  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)",
1685
+ 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 表示仍在运行(可再次调用继续等)。"),
1686
+ promptSnippet: bilingual("block until a terminal's current command finishes (no polling)", "阻塞等待终端当前命令结束(无需轮询)"),
1655
1687
  parameters: Type.Object({
1656
1688
  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)" })),
1689
+ cursor: Type.Optional(Type.Integer({
1690
+ minimum: 0,
1691
+ description: bilingual("Ignore exit markers before this absolute offset (default: now)", "忽略该绝对偏移之前的退出标记(默认:现在)"),
1692
+ })),
1693
+ maxWaitMs: Type.Optional(Type.Integer({
1694
+ minimum: 100,
1695
+ maximum: 600000,
1696
+ description: bilingual("Max wait in ms (default 300000)", "最长等待毫秒数(默认 300000)"),
1697
+ })),
1659
1698
  }),
1660
1699
  execute: async (_id, p, signal) => {
1700
+ const lang = getLang();
1661
1701
  if (!terminals.has(p.terminalId)) {
1662
- throw new Error(`终端不存在:${p.terminalId}(可能已被关闭或会话重置,请先 terminal_create)`);
1702
+ 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
1703
  }
1664
1704
  // 没有带哨兵的待决命令:shell 空闲在提示符,或该终端的命令是经
1665
1705
  // terminal_input 手动发的(无完成标记)——等哨兵永远等不到,直接
1666
1706
  // 说明并引导改用 terminal_read,避免 AI 无限重试。(显式传 cursor
1667
1707
  // 的调用是有目的的追溯查询,不拦。)
1668
1708
  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=…)。`;
1709
+ 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
1710
  return result(JSON.stringify({ applicable: false, reason: why }), { applicable: false });
1671
1711
  }
1672
1712
  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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.68.2",
3
+ "version": "0.69.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"
@@ -1,4 +1,4 @@
1
- import{a as l,j as n}from"./markdown-DOsihKaR.js";import{u as P,b as O,T as M,a as W,F as Y,c as z,d as Z,e as H,f as ee,g as ne,h as te,i as se,r as ae}from"./index-Ck5pa3XK.js";import{D as re,o as ie}from"./xterm-B96xOxS9.js";import"./react-DIP6JKYk.js";function le(t){let c=null;return{clean:t.replace(/\r?\n?\[pi-term-exit:(-?\d+)\]\r?\n?/g,(a,x)=>(c=Number(x),`\r
1
+ import{a as l,j as n}from"./markdown-Cpo0pNcR.js";import{u as P,b as O,T as M,a as W,F as Y,c as z,d as Z,e as H,f as ee,g as ne,h as te,i as se,r as ae}from"./index-DCOcsPFm.js";import{D as re,o as ie}from"./xterm-B96xOxS9.js";import"./react-CtudoG1_.js";function le(t){let c=null;return{clean:t.replace(/\r?\n?\[pi-term-exit:(-?\d+)\]\r?\n?/g,(a,x)=>(c=Number(x),`\r
2
2
  `)).replace(/\r?\n?\x1b\[90m\[(?:进程已退出,退出码 |Process exited with code )-?\d+\]\x1b\[0m\r?\n?/g,`\r
3
3
  `),exitCode:c}}function ce({conversationId:t,terminalId:c,command:p,cwd:a,title:x,active:u,running:v,exitCode:j,send:h,register:C}){const T=l.useRef(null),y=l.useRef(null),{locale:o}=P(),N=l.useRef(o);N.current=o;const E=p?JSON.stringify(p):"";l.useEffect(()=>{const m=T.current;if(!m)return;const r=new re({theme:O(),fontFamily:'"SF Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace',fontSize:13,cursorBlink:!0,scrollback:8e3}),b=new ie;r.loadAddon(b),r.open(m),y.current={term:r,fit:b},u&&r.focus();const g=()=>{r.options.theme=O()};window.addEventListener(M,g),r.attachCustomKeyEventHandler(d=>{if(d.type!=="keydown")return!0;const S=d.key?.toLowerCase();if((d.ctrlKey||d.metaKey)&&S==="v")return!1;if(d.ctrlKey&&!d.shiftKey&&!d.altKey&&S==="c"&&r.hasSelection()){const D=r.textarea;return D&&(D.value=r.getSelection(),D.select()),!1}return!0});const _=C(t,c,{write:d=>r.write(le(d).clean),dispose:()=>r.dispose()}),$=()=>{try{b.fit(),h({type:"terminal_resize",terminalId:c,conversationId:t,cols:r.cols,rows:r.rows})}catch{}},B=requestAnimationFrame(()=>{try{b.fit()}catch{}h(p?{type:"run_command",terminalId:c,conversationId:t,command:p,cols:r.cols,rows:r.rows}:{type:"terminal_create",terminalId:c,title:x,locale:N.current,conversationId:t,cwd:a,cols:r.cols,rows:r.rows})}),R=r.onData(d=>{h({type:"terminal_input",terminalId:c,conversationId:t,data:d})});let k=null;return typeof ResizeObserver<"u"&&(k=new ResizeObserver(()=>{m.offsetWidth>0&&m.offsetHeight>0&&$()}),k.observe(m)),()=>{cancelAnimationFrame(B),R.dispose(),window.removeEventListener(M,g),k?.disconnect(),_(),r.dispose(),y.current=null}},[t,c,E,h,C]),l.useEffect(()=>{if(!u)return;const m=requestAnimationFrame(()=>{const r=y.current;if(r){try{r.fit.fit(),h({type:"terminal_resize",terminalId:c,conversationId:t,cols:r.term.cols,rows:r.term.rows})}catch{}r.term.focus()}});return()=>cancelAnimationFrame(m)},[u]);const{t:F}=P(),f=l.useRef(void 0);return l.useEffect(()=>{if(v===f.current||(f.current=v,v!==!1))return;const m=y.current;m&&m.term.write(`\r
4
4
  \x1B[90m${F("exitBanner",{code:j??""})}\x1B[0m\r