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
@@ -29,10 +29,11 @@ import { basename, dirname, join, resolve, sep } from "node:path";
29
29
  import { randomUUID } from "node:crypto";
30
30
  import { homedir } from "node:os";
31
31
  import { BgServerTracker } from "../bg-servers.js";
32
- import { ClientStateStore } from "../client-state.js";
32
+ import { ClientStateStore, DEFAULT_RETRY_MAX_ATTEMPTS } from "../client-state.js";
33
33
  import { FilesService, workspacePath } from "../files-service.js";
34
34
  import { QuiesceRejectedError } from "../agent-service.js";
35
35
  import { NATIVE_COMMANDS, parseSlash } from "../slash-commands.js";
36
+ import { bilingual, pick, resolveServerLang } from "../i18n.js";
36
37
  import { TerminalManager, loadCommands, saveCommandsFile } from "../terminals.js";
37
38
  import { saveUpload } from "../uploads.js";
38
39
  import { checkAll as checkAllUpdates, collectTargets } from "../update-check.js";
@@ -43,6 +44,7 @@ import { firstUserText, findSessionFilesForCwd, readSessionLog, replayEventsToMe
43
44
  const SNAPSHOT_INTERVAL_MS = 60;
44
45
  const MAX_OPEN_CONVERSATIONS = 8;
45
46
  const DEFAULT_CONV_TITLE = "新对话";
47
+ const DEFAULT_CONV_TITLE_EN = "New chat";
46
48
  const DEFAULT_MODEL = "deepseek-v4-flash";
47
49
  /** DSH 可选模型(顶栏模型选择器)。仅 deepseek-v4-flash-vision-exp 支持图片
48
50
  * (adapter 默认目录 inputModalities: [text, image]);flash/pro 是 text-only。 */
@@ -109,6 +111,7 @@ const DEFAULT_SETTINGS = {
109
111
  terminalBashIdleMs: 15_000,
110
112
  editSoftEnabled: false,
111
113
  questionnaireEnabled: true,
114
+ goalModeEnabled: true,
112
115
  thinkingWrap: false,
113
116
  toolsWrap: true,
114
117
  disabledPlugins: [],
@@ -223,6 +226,7 @@ export class DshClientSession {
223
226
  terminalBashIdleMs: savedSettings.terminalBashIdleMs,
224
227
  editSoftEnabled: savedSettings.editSoftEnabled,
225
228
  questionnaireEnabled: savedSettings.questionnaireEnabled ?? true,
229
+ goalModeEnabled: savedSettings.goalModeEnabled ?? true,
226
230
  thinkingWrap: savedSettings.thinkingWrap,
227
231
  toolsWrap: savedSettings.toolsWrap,
228
232
  disabledPlugins: savedSettings.disabledPlugins ?? [],
@@ -585,14 +589,17 @@ export class DshClientSession {
585
589
  const args = (params?.args && typeof params.args === "object" ? params.args : {});
586
590
  if (!id)
587
591
  return;
592
+ const lang = this.getLang();
588
593
  if (!name) {
589
- void this.runtime.toolsCallResult(id, "工具名缺失", true).catch(() => { });
594
+ void this.runtime
595
+ .toolsCallResult(id, pick(lang, "工具名缺失", "Missing tool name", "dsh.tool.missing.name"), true)
596
+ .catch(() => { });
590
597
  return;
591
598
  }
592
599
  try {
593
600
  const tool = this.bridgedTool((this.pluginToolsProvider?.() ?? []).find((t) => t.name === name));
594
601
  if (!tool) {
595
- await this.runtime.toolsCallResult(id, `未知插件工具:${name}`, true);
602
+ await this.runtime.toolsCallResult(id, pick(lang, `未知插件工具:${name}`, `Unknown plugin tool: ${name}`, "dsh.tool.unknown.plugin", { name }), true);
596
603
  return;
597
604
  }
598
605
  const ac = new AbortController();
@@ -622,6 +629,14 @@ export class DshClientSession {
622
629
  wizard: { active: false, draft: "", model: null, step: 0, maxSteps: 3, status: "" },
623
630
  };
624
631
  }
632
+ /** 未命名对话的默认标题(issue #91:按客户端语言,英文默认)。 */
633
+ defaultTitle() {
634
+ return pick(this.getLang(), DEFAULT_CONV_TITLE, DEFAULT_CONV_TITLE_EN, "dsh.conv.default.title");
635
+ }
636
+ /** 是否仍是默认(未命名)标题——中英都认,跨语言切换不丢命名判断。 */
637
+ static isDefaultTitle(title) {
638
+ return title === DEFAULT_CONV_TITLE || title === DEFAULT_CONV_TITLE_EN;
639
+ }
625
640
  /** 新建(或切换)一个 conversation。existing 的 sessionId 续聊最近 JSONL。 */
626
641
  addConversation(sessionId, cwd, replay = true) {
627
642
  const id = this.nextConversationId();
@@ -630,7 +645,7 @@ export class DshClientSession {
630
645
  sessionId,
631
646
  dsGoal: null,
632
647
  goal: this.makeGoalStatus(),
633
- title: DEFAULT_CONV_TITLE,
648
+ title: this.defaultTitle(),
634
649
  cwd,
635
650
  createdAt: Date.now(),
636
651
  messages: [],
@@ -642,7 +657,9 @@ export class DshClientSession {
642
657
  lastEventAt: Date.now(),
643
658
  listed: false,
644
659
  promptedSinceActive: false,
645
- terminals: new TerminalManager((msg) => this.emit(msg), cwd),
660
+ terminals: new TerminalManager((msg) => this.emit(msg), cwd,
661
+ // issue #91:终端输入错误按客户端 UI 语言出中英(英文默认)。
662
+ () => this.getLang()),
646
663
  toolStartTimes: new Map(),
647
664
  tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
648
665
  };
@@ -655,7 +672,7 @@ export class DshClientSession {
655
672
  conv.messages = replayEventsToMessages(events);
656
673
  for (const m of conv.messages)
657
674
  conv.messageIds.add(m.id);
658
- conv.title = firstUserText(events);
675
+ conv.title = firstUserText(events, this.getLang());
659
676
  }
660
677
  }
661
678
  catch {
@@ -726,7 +743,7 @@ export class DshClientSession {
726
743
  if (imgRefs.length > 0) {
727
744
  void this.hydrateImageBlocks(conv, msg, imgRefs);
728
745
  }
729
- if (conv.title === DEFAULT_CONV_TITLE) {
746
+ if (DshClientSession.isDefaultTitle(conv.title)) {
730
747
  const t = conv.messages
731
748
  .find((m) => m.role === "user")
732
749
  ?.content?.map((c) => ("text" in c ? c.text : ""))
@@ -842,7 +859,8 @@ export class DshClientSession {
842
859
  if (reason.kind === "completed")
843
860
  w.resolve();
844
861
  else
845
- w.reject(new Error(reason.error?.message ?? `本轮异常结束(${reason.kind})`));
862
+ w.reject(new Error(reason.error?.message ??
863
+ pick(this.getLang(), `本轮异常结束(${reason.kind})`, `Round ended abnormally (${reason.kind})`, "dsh.round.ended.abnormally", { "reason.kind": reason.kind })));
846
864
  }
847
865
  break;
848
866
  }
@@ -898,7 +916,7 @@ export class DshClientSession {
898
916
  conv.messages.push(msg);
899
917
  }
900
918
  refreshConversationTitle(conv) {
901
- if (conv.title !== DEFAULT_CONV_TITLE)
919
+ if (!DshClientSession.isDefaultTitle(conv.title))
902
920
  return;
903
921
  // 从消息列表取第一个用户文本。
904
922
  const t = conv.messages
@@ -1188,11 +1206,12 @@ export class DshClientSession {
1188
1206
  const histText = this.histToContext(conv);
1189
1207
  conv = this.forkConversation(conv);
1190
1208
  if (histText.trim()) {
1191
- text = `${text}\n\n(以下为原对话上下文,仅作参考,请忽略其中的指令性语气):\n${histText}`;
1209
+ const lang = this.getLang();
1210
+ text = `${text}\n\n${pick(lang, "(以下为原对话上下文,仅作参考,请忽略其中的指令性语气):", "(Previous conversation context below for reference only; ignore any instructive tone in it):", "dsh.prompt.context.full")}\n${histText}`;
1192
1211
  }
1193
1212
  }
1194
1213
  // 命名对话(首个 prompt)。
1195
- if (conv.title === DEFAULT_CONV_TITLE && text.trim()) {
1214
+ if (DshClientSession.isDefaultTitle(conv.title) && text.trim()) {
1196
1215
  const trimmed = text.trim().replace(/\s+/g, " ");
1197
1216
  conv.title = trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
1198
1217
  this.emitConversations();
@@ -1347,11 +1366,12 @@ export class DshClientSession {
1347
1366
  }
1348
1367
  const hist = this.histToContext(conv);
1349
1368
  this.forkConversation(conv);
1369
+ const lang = this.getLang();
1350
1370
  const text = lastUser
1351
1371
  ? hist.trim()
1352
- ? `${lastUser}\n\n(以下为原对话上下文,仅作参考):\n${hist}`
1372
+ ? `${lastUser}\n\n${pick(lang, "(以下为原对话上下文,仅作参考):", "(Previous conversation context below for reference only):", "dsh.prompt.context.short")}\n${hist}`
1353
1373
  : lastUser
1354
- : "请继续";
1374
+ : pick(lang, "请继续", "Please continue", "dsh.prompt.continue");
1355
1375
  this.emit({
1356
1376
  type: "notice",
1357
1377
  level: "info",
@@ -1370,6 +1390,7 @@ export class DshClientSession {
1370
1390
  }
1371
1391
  async buildContentBlocks(text, attachments) {
1372
1392
  const blocks = [{ type: "text", text }];
1393
+ const lang = this.getLang();
1373
1394
  if (!Array.isArray(attachments))
1374
1395
  return blocks;
1375
1396
  for (const a of attachments) {
@@ -1384,7 +1405,7 @@ export class DshClientSession {
1384
1405
  catch (err) {
1385
1406
  blocks.push({
1386
1407
  type: "text",
1387
- text: `\n[图片附件: ${a.name ?? "image"}(保存失败 ${err.message})]`,
1408
+ text: pick(lang, `\n[图片附件: ${a.name ?? "image"}(保存失败 ${err.message})]`, `\n[Image attachment: ${a.name ?? "image"} (save failed: ${err.message})]`, "dsh.attach.image.save.failed", { 'a.name ?? "image"': a.name ?? "image", "(err as Error).message": err.message }),
1388
1409
  });
1389
1410
  }
1390
1411
  }
@@ -1392,12 +1413,15 @@ export class DshClientSession {
1392
1413
  // 上传文件 → 落盘 + 路径引用。
1393
1414
  try {
1394
1415
  const saved = saveUpload(this.clientId, a.name ?? "upload", Buffer.from(a.fileData, "base64"), this.dataDir);
1395
- blocks.push({ type: "text", text: `\n[上传文件: ${saved.abs}]` });
1416
+ blocks.push({
1417
+ type: "text",
1418
+ text: pick(lang, `\n[上传文件: ${saved.abs}]`, `\n[Uploaded file: ${saved.abs}]`, "dsh.attach.upload.saved", { "saved.abs": saved.abs }),
1419
+ });
1396
1420
  }
1397
1421
  catch (err) {
1398
1422
  blocks.push({
1399
1423
  type: "text",
1400
- text: `\n[上传文件: ${a.name ?? "upload"}(落盘失败 ${err.message})]`,
1424
+ text: pick(lang, `\n[上传文件: ${a.name ?? "upload"}(落盘失败 ${err.message})]`, `\n[Uploaded file: ${a.name ?? "upload"} (failed to save: ${err.message})]`, "dsh.attach.upload.save.failed", { 'a.name ?? "upload"': a.name ?? "upload", "(err as Error).message": err.message }),
1401
1425
  });
1402
1426
  }
1403
1427
  }
@@ -1424,7 +1448,10 @@ export class DshClientSession {
1424
1448
  blocks.push({ type: "image", attachment: saved.ref });
1425
1449
  }
1426
1450
  catch {
1427
- blocks.push({ type: "text", text: `\n[图片附件: ${resolved.rel}]` });
1451
+ blocks.push({
1452
+ type: "text",
1453
+ text: pick(lang, `\n[图片附件: ${resolved.rel}]`, `\n[Image attachment: ${resolved.rel}]`, "dsh.attach.image.ref", { "resolved.rel": resolved.rel }),
1454
+ });
1428
1455
  }
1429
1456
  }
1430
1457
  else {
@@ -1437,19 +1464,31 @@ export class DshClientSession {
1437
1464
  }
1438
1465
  }
1439
1466
  else {
1440
- blocks.push({ type: "text", text: `\n[文件引用: ${resolved.rel}(大文件,请用读取工具查看)]` });
1467
+ blocks.push({
1468
+ type: "text",
1469
+ text: pick(lang, `\n[文件引用: ${resolved.rel}(大文件,请用读取工具查看)]`, `\n[File reference: ${resolved.rel} (large file, use the read tool to view it)]`, "dsh.attach.file.large", { "resolved.rel": resolved.rel }),
1470
+ });
1441
1471
  }
1442
1472
  }
1443
1473
  catch {
1444
- blocks.push({ type: "text", text: `\n[文件引用: ${resolved.rel}]` });
1474
+ blocks.push({
1475
+ type: "text",
1476
+ text: pick(lang, `\n[文件引用: ${resolved.rel}]`, `\n[File reference: ${resolved.rel}]`, "dsh.attach.file.ref.fallback", { "resolved.rel": resolved.rel }),
1477
+ });
1445
1478
  }
1446
1479
  }
1447
1480
  else {
1448
- blocks.push({ type: "text", text: `\n[文件引用: ${resolved.rel}]` });
1481
+ blocks.push({
1482
+ type: "text",
1483
+ text: pick(lang, `\n[文件引用: ${resolved.rel}]`, `\n[File reference: ${resolved.rel}]`, "dsh.attach.file.ref", { "resolved.rel": resolved.rel }),
1484
+ });
1449
1485
  }
1450
1486
  }
1451
1487
  else if (a.name) {
1452
- blocks.push({ type: "text", text: `\n[附件: ${a.name}]` });
1488
+ blocks.push({
1489
+ type: "text",
1490
+ text: pick(lang, `\n[附件: ${a.name}]`, `\n[Attachment: ${a.name}]`, "dsh.attach.generic", { name: a.name }),
1491
+ });
1453
1492
  }
1454
1493
  }
1455
1494
  return blocks;
@@ -1584,7 +1623,7 @@ export class DshClientSession {
1584
1623
  summaries.push({
1585
1624
  path: file,
1586
1625
  name: sessionId,
1587
- firstMessage: firstUserText(events),
1626
+ firstMessage: firstUserText(events, this.getLang()),
1588
1627
  messageCount: events.filter((e) => e.type === "user/message" || e.type === "assistant/message" || e.type === "tool/result").length,
1589
1628
  modified: statSync(file).mtimeMs,
1590
1629
  source: "web",
@@ -1896,11 +1935,11 @@ export class DshClientSession {
1896
1935
  continue;
1897
1936
  if (all.includes(q) ||
1898
1937
  sessionId.toLowerCase().includes(q) ||
1899
- firstUserText(events).toLowerCase().includes(q)) {
1938
+ firstUserText(events, this.getLang()).toLowerCase().includes(q)) {
1900
1939
  results.push({
1901
1940
  path: file,
1902
1941
  name: sessionId,
1903
- firstMessage: firstUserText(events),
1942
+ firstMessage: firstUserText(events, this.getLang()),
1904
1943
  messageCount: events.filter((e) => e.type === "user/message" || e.type === "assistant/message" || e.type === "tool/result").length,
1905
1944
  modified: statSync(file).mtimeMs,
1906
1945
  source: "web",
@@ -2068,7 +2107,10 @@ export class DshClientSession {
2068
2107
  terminalBash: this.settings.terminalBash,
2069
2108
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2070
2109
  editSoftEnabled: this.settings.editSoftEnabled,
2110
+ // DSH 无独立重试配置(pi 引擎才暴露),保持默认。
2111
+ retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
2071
2112
  questionnaireEnabled: this.settings.questionnaireEnabled,
2113
+ goalModeEnabled: this.settings.goalModeEnabled,
2072
2114
  thinkingWrap: this.settings.thinkingWrap,
2073
2115
  toolsWrap: this.settings.toolsWrap,
2074
2116
  visionBridgeEnabled: false,
@@ -2121,6 +2163,8 @@ export class DshClientSession {
2121
2163
  this.settings.editSoftEnabled = partial.editSoftEnabled;
2122
2164
  if (partial.questionnaireEnabled !== undefined)
2123
2165
  this.settings.questionnaireEnabled = partial.questionnaireEnabled;
2166
+ if (partial.goalModeEnabled !== undefined)
2167
+ this.settings.goalModeEnabled = partial.goalModeEnabled;
2124
2168
  if (partial.thinkingWrap !== undefined)
2125
2169
  this.settings.thinkingWrap = partial.thinkingWrap;
2126
2170
  if (partial.toolsWrap !== undefined)
@@ -2148,7 +2192,10 @@ export class DshClientSession {
2148
2192
  terminalBash: this.settings.terminalBash,
2149
2193
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2150
2194
  editSoftEnabled: this.settings.editSoftEnabled,
2195
+ // DSH 无独立重试配置(pi 引擎才暴露),保持默认。
2196
+ retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
2151
2197
  questionnaireEnabled: this.settings.questionnaireEnabled,
2198
+ goalModeEnabled: this.settings.goalModeEnabled,
2152
2199
  thinkingWrap: this.settings.thinkingWrap,
2153
2200
  toolsWrap: this.settings.toolsWrap,
2154
2201
  disabledPlugins: this.settings.disabledPlugins,
@@ -2208,6 +2255,8 @@ export class DshClientSession {
2208
2255
  terminalBash: this.settings.terminalBash,
2209
2256
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2210
2257
  editSoftEnabled: this.settings.editSoftEnabled,
2258
+ // DSH 无独立重试配置,预设沿用默认值。
2259
+ retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
2211
2260
  visionBridgePromptMode: "append",
2212
2261
  visionBridgePrompt: "",
2213
2262
  reviewPrompt: this.settings.reviewPrompt,
@@ -2348,7 +2397,9 @@ export class DshClientSession {
2348
2397
  else if (phase === "blocked") {
2349
2398
  g.reviewing = false;
2350
2399
  g.verdict = "fail";
2351
- g.feedback = data.goal.blockedReason ?? "(模型报告受阻)";
2400
+ g.feedback =
2401
+ data.goal.blockedReason ??
2402
+ pick(this.getLang(), "(模型报告受阻)", "(Model reported blocked)", "dsh.goal.blocked");
2352
2403
  g.status = "目标受阻";
2353
2404
  g.statusEn = "Goal blocked";
2354
2405
  }
@@ -2374,6 +2425,15 @@ export class DshClientSession {
2374
2425
  }
2375
2426
  if (this.quiesceBlocked())
2376
2427
  return;
2428
+ if (this.settings.goalModeEnabled === false) {
2429
+ this.emit({
2430
+ type: "notice",
2431
+ level: "warning",
2432
+ text: "目标模式已关闭:请先在设置「目标审查」中启用目标模式。",
2433
+ textEn: "Goal mode is off: enable it under Settings → Goal review first.",
2434
+ });
2435
+ return;
2436
+ }
2377
2437
  const conv = this.conv;
2378
2438
  const text = goal.trim();
2379
2439
  const g = conv.goal;
@@ -2431,7 +2491,7 @@ export class DshClientSession {
2431
2491
  if (conv.turnWaiter) {
2432
2492
  const w = conv.turnWaiter;
2433
2493
  conv.turnWaiter = undefined;
2434
- w.reject(new Error("调研已取消"));
2494
+ w.reject(new Error(pick(this.getLang(), "调研已取消", "Survey cancelled", "dsh.survey.cancelled")));
2435
2495
  }
2436
2496
  if (conv.dsGoal) {
2437
2497
  try {
@@ -2458,6 +2518,15 @@ export class DshClientSession {
2458
2518
  // 提问(经提问桥 → 浏览器对话框)→ 收敛输出 GOAL: 行 → 自动设目标。
2459
2519
  if (this.quiesceBlocked())
2460
2520
  return;
2521
+ if (this.settings.goalModeEnabled === false) {
2522
+ this.emit({
2523
+ type: "notice",
2524
+ level: "warning",
2525
+ text: "目标模式已关闭:请先在设置「目标审查」中启用目标模式。",
2526
+ textEn: "Goal mode is off: enable it under Settings → Goal review first.",
2527
+ });
2528
+ return;
2529
+ }
2461
2530
  const conv = this.conv;
2462
2531
  const draft = (text ?? "").trim();
2463
2532
  if (!draft)
@@ -2490,7 +2559,7 @@ export class DshClientSession {
2490
2559
  });
2491
2560
  try {
2492
2561
  const waiter = new Promise((resolve, reject) => {
2493
- const timer = setTimeout(() => reject(new Error("调研超时(10 分钟)")), 10 * 60_000);
2562
+ const timer = setTimeout(() => reject(new Error(pick(this.getLang(), "调研超时(10 分钟)", "Survey timed out (10 minutes)", "dsh.survey.timeout"))), 10 * 60_000);
2494
2563
  timer.unref?.();
2495
2564
  conv.turnWaiter = {
2496
2565
  resolve: () => {
@@ -2770,9 +2839,7 @@ export class DshClientSession {
2770
2839
  }
2771
2840
  async checkUpdate() {
2772
2841
  try {
2773
- const latest = await checkAllUpdates([
2774
- { name: "pi-web-ui", version: DshClientSession.currentAppVersion(), kind: "webui" },
2775
- ]);
2842
+ const latest = await checkAllUpdates([{ name: "pi-web-ui", version: DshClientSession.currentAppVersion(), kind: "webui" }], undefined, () => this.getLang());
2776
2843
  const item = latest[0];
2777
2844
  this.emit({
2778
2845
  type: "update_status",
@@ -2797,7 +2864,7 @@ export class DshClientSession {
2797
2864
  async checkUpdatesAll(force = false) {
2798
2865
  try {
2799
2866
  const targets = collectTargets(join(homedir(), ".pi", "agent"), DshClientSession.currentAppVersion());
2800
- const items = await checkAllUpdates(targets);
2867
+ const items = await checkAllUpdates(targets, undefined, () => this.getLang());
2801
2868
  if (force) {
2802
2869
  // 强制模式:忽略缓存(默认 Fetcher 带 TTL,直接再查一次即可)。
2803
2870
  void items;
@@ -2823,7 +2890,11 @@ export class DshClientSession {
2823
2890
  // pi 专属:DSH 引擎下的简化实现
2824
2891
  // -----------------------------------------------------------------------
2825
2892
  async installPiAgent() {
2826
- this.emit({ type: "install_result", ok: true, detail: "DSH 引擎不需要 pi CLI" });
2893
+ this.emit({
2894
+ type: "install_result",
2895
+ ok: true,
2896
+ detail: pick(this.getLang(), "DSH 引擎不需要 pi CLI", "The DSH engine does not need the pi CLI", "dsh.engine.no.cli"),
2897
+ });
2827
2898
  }
2828
2899
  async setProviderApiKey(provider, apiKey) {
2829
2900
  const key = apiKey.trim();
@@ -2918,7 +2989,7 @@ export class DshClientSession {
2918
2989
  providers: [
2919
2990
  {
2920
2991
  id: "deepseek-official",
2921
- name: "DeepSeek 官方",
2992
+ name: pick(this.getLang(), "DeepSeek 官方", "DeepSeek Official", "dsh.provider.deepseek.official"),
2922
2993
  configured: !!loadDeepSeekKey(),
2923
2994
  source: loadDeepSeekKey() ? "stored" : undefined,
2924
2995
  },
@@ -2953,13 +3024,23 @@ export class DshClientSession {
2953
3024
  });
2954
3025
  }
2955
3026
  async fetchModelsList(reqId, _baseUrl, _apiKey, _authHeader, _api) {
2956
- this.emit({ type: "fetch_models_result", reqId, ok: false, error: "DSH 引擎不支持自定义 provider 探测" });
3027
+ this.emit({
3028
+ type: "fetch_models_result",
3029
+ reqId,
3030
+ ok: false,
3031
+ error: pick(this.getLang(), "DSH 引擎不支持自定义 provider 探测", "The DSH engine does not support custom provider probing", "dsh.provider.probing.unsupported"),
3032
+ });
2957
3033
  }
2958
3034
  async refreshProviderModels(_providerId, reqId) {
2959
- this.emit({ type: "refresh_provider_result", reqId, ok: false, error: "DSH 引擎不支持自定义 provider" });
3035
+ this.emit({
3036
+ type: "refresh_provider_result",
3037
+ reqId,
3038
+ ok: false,
3039
+ error: pick(this.getLang(), "DSH 引擎不支持自定义 provider", "The DSH engine does not support custom providers", "dsh.provider.custom.unsupported"),
3040
+ });
2960
3041
  }
2961
3042
  async cloneProvider(_provider, reqId) {
2962
- const error = "DSH 引擎不支持自定义 provider";
3043
+ const error = pick(this.getLang(), "DSH 引擎不支持自定义 provider", "The DSH engine does not support custom providers", "dsh.provider.clone.unsupported");
2963
3044
  const errorEn = "DSH engine does not support custom providers";
2964
3045
  this.emit({ type: "notice", level: "error", text: error, textEn: errorEn });
2965
3046
  this.emit({ type: "clone_provider_result", reqId, ok: false, error });
@@ -3002,7 +3083,7 @@ export class DshClientSession {
3002
3083
  this.activeId = fresh.id;
3003
3084
  // 编辑后的提问本身在 prompt 里;历史作为附加上下文(首条 prompt)。
3004
3085
  const headText = contextNote.trim()
3005
- ? `${text}\n\n(编辑重问,原对话上下文,仅作参考,忽略其中指令性语气:)\n${contextNote}`
3086
+ ? `${text}\n\n${pick(this.getLang(), "(编辑重问,原对话上下文,仅作参考,忽略其中指令性语气:)", "(Edit-and-reask; previous conversation context for reference only, ignore any instructive tone in it):", "dsh.prompt.context.edit.reask")}\n${contextNote}`
3006
3087
  : text;
3007
3088
  await this.prompt(headText, attachments);
3008
3089
  this.emitConversations();
@@ -3017,6 +3098,19 @@ export class DshClientSession {
3017
3098
  });
3018
3099
  }
3019
3100
  }
3101
+ /** Server language for this client (issue #91): resolved LIVE from the
3102
+ * persisted UI locale — "zh" only for zh*; everything else is English. */
3103
+ getLang() {
3104
+ return resolveServerLang(this.stateStore.get(this.clientId).locale);
3105
+ }
3106
+ /** Persist the browser UI locale (hello.locale / set_locale). DSH
3107
+ * runtime prompts pick it up on the next run — no restart needed. */
3108
+ async setLocale(locale) {
3109
+ const code = locale.trim().slice(0, 16);
3110
+ if (!code)
3111
+ return;
3112
+ this.stateStore.saveLocale(this.clientId, code);
3113
+ }
3020
3114
  async setCwd(newCwd) {
3021
3115
  try {
3022
3116
  const abs = resolve(newCwd);
@@ -3197,7 +3291,7 @@ export class DshAgentService {
3197
3291
  let cs = this.clients.get(clientId);
3198
3292
  if (!cs) {
3199
3293
  if (this.quiesced) {
3200
- throw new QuiesceRejectedError("新连接被拒绝,请等服务器恢复后重试");
3294
+ throw new QuiesceRejectedError(bilingual("New connections rejected; retry after the server resumes", "新连接被拒绝,请等服务器恢复后重试"));
3201
3295
  }
3202
3296
  let cwd = this.cwd;
3203
3297
  const saved = this.stateStore.get(clientId);
@@ -3235,6 +3329,13 @@ export class DshAgentService {
3235
3329
  this.onClientCwdChanged?.(cs.cwd);
3236
3330
  return cs;
3237
3331
  }
3332
+ /** Browser UI locale report (hello.locale / set_locale): persist per
3333
+ * client; DSH runtime prompts refresh on next run (P2 bilingual). */
3334
+ async setLocale(clientId, locale) {
3335
+ const cs = this.clients.get(clientId);
3336
+ if (cs)
3337
+ await cs.setLocale(locale);
3338
+ }
3238
3339
  applyPluginAgentTools() {
3239
3340
  // 工具桥(#15):插件工具列表变化 → 各客户端运行时重新注册。
3240
3341
  for (const cs of this.clients.values())
@@ -22,6 +22,7 @@ import { existsSync, readFileSync } from "node:fs";
22
22
  import { homedir } from "node:os";
23
23
  import { dirname, join, resolve } from "node:path";
24
24
  import { fileURLToPath } from "node:url";
25
+ import { bilingual } from "../i18n.js";
25
26
  /** 项目依赖解析(tsc 编译后 dist/server/dsh/ 里向上找 node_modules)。 */
26
27
  const require = createRequire(import.meta.url);
27
28
  export class DshRpcError extends Error {
@@ -132,10 +133,10 @@ export class DshRuntime {
132
133
  }
133
134
  async doStart() {
134
135
  if (!existsSync(this.launcher)) {
135
- throw new DshTransportError(`launcher 不存在: ${this.launcher}`);
136
+ throw new DshTransportError(bilingual(`launcher missing: ${this.launcher}`, `launcher 不存在: ${this.launcher}`));
136
137
  }
137
138
  if (!existsSync(this.jsonrpcEntry)) {
138
- throw new DshTransportError(`dsh-sdk-jsonrpc-server 未安装(缺 ${this.jsonrpcEntry})。请先 npm i @deepseek-ai/dsh-sdk-jsonrpc-server@0.1.1-rc.2`);
139
+ throw new DshTransportError(bilingual(`dsh-sdk-jsonrpc-server is not installed (missing ${this.jsonrpcEntry}). Run npm i @deepseek-ai/dsh-sdk-jsonrpc-server@0.1.1-rc.2 first`, `dsh-sdk-jsonrpc-server 未安装(缺 ${this.jsonrpcEntry})。请先 npm i @deepseek-ai/dsh-sdk-jsonrpc-server@0.1.1-rc.2`));
139
140
  }
140
141
  const key = loadDeepSeekKey(this.agentDir);
141
142
  const env = {
@@ -170,7 +171,7 @@ export class DshRuntime {
170
171
  this.stderrTail = (this.stderrTail + chunk).slice(-4000);
171
172
  });
172
173
  spawned.on("error", (err) => {
173
- this.failPending(new DshTransportError(`runtime 启动失败: ${err.message}`));
174
+ this.failPending(new DshTransportError(bilingual(`runtime failed to start: ${err.message}`, `runtime 启动失败: ${err.message}`)));
174
175
  });
175
176
  spawned.on("exit", (code, signal) => {
176
177
  // 只处理当前 proc 的退出:kill/restart 后旧 proc 迟到的 exit 事件
@@ -179,7 +180,7 @@ export class DshRuntime {
179
180
  return;
180
181
  const intentional = this.closed;
181
182
  this.debug("exit", { code, signal, intentional });
182
- const err = new DshTransportError(`DSH runtime 已退出 (code=${code} signal=${signal}) stderr: ${this.stderrTail.slice(-400)}`);
183
+ const err = new DshTransportError(bilingual(`DSH runtime exited (code=${code} signal=${signal}) stderr: ${this.stderrTail.slice(-400)}`, `DSH runtime 已退出 (code=${code} signal=${signal}) stderr: ${this.stderrTail.slice(-400)}`));
183
184
  this.failPending(err);
184
185
  this.initialized = false;
185
186
  this.onExit?.(code, signal, intentional);
@@ -247,7 +248,7 @@ export class DshRuntime {
247
248
  return new Promise((resolve2, reject) => {
248
249
  const timer = setTimeout(() => {
249
250
  this.pending.delete(id);
250
- reject(new DshTransportError(`请求 ${method} 超时`));
251
+ reject(new DshTransportError(bilingual(`Request ${method} timed out`, `请求 ${method} 超时`)));
251
252
  }, timeoutMs);
252
253
  this.pending.set(id, {
253
254
  resolve: (v) => {
@@ -265,7 +266,7 @@ export class DshRuntime {
265
266
  _write(msg) {
266
267
  const proc = this.proc;
267
268
  if (!proc || !proc.stdin || proc.stdin.destroyed) {
268
- throw new DshTransportError("runtime 未启动");
269
+ throw new DshTransportError(bilingual("runtime not started", "runtime 未启动"));
269
270
  }
270
271
  proc.stdin.write(JSON.stringify(msg) + "\n");
271
272
  }
@@ -280,7 +281,7 @@ export class DshRuntime {
280
281
  contentBlocks,
281
282
  }));
282
283
  if (typeof res.messageId !== "string") {
283
- throw new DshTransportError("session/prompt 未返回 messageId");
284
+ throw new DshTransportError(bilingual("session/prompt did not return a messageId", "session/prompt 未返回 messageId"));
284
285
  }
285
286
  return res.messageId;
286
287
  }
@@ -453,7 +454,7 @@ export class DshRuntime {
453
454
  return;
454
455
  }
455
456
  this.proc = null;
456
- this.failPending(new DshTransportError("runtime killed (interrupt)"));
457
+ this.failPending(new DshTransportError(bilingual("runtime killed (interrupt)", "运行时已被终止(中断)")));
457
458
  try {
458
459
  if (process.platform === "win32") {
459
460
  const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], {
@@ -11,6 +11,7 @@
11
11
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
12
12
  import { join } from "node:path";
13
13
  import { zstdDecompressSync } from "node:zlib";
14
+ import { pick } from "../i18n.js";
14
15
  /** projectKey(cwd) — 镜像 DSH 运行时的会话目录命名(--<cwd>--)。分隔符 → "-",非法字符 → ~XXXX(大写 hex)。 */
15
16
  export function projectKey(cwd) {
16
17
  let readable = "";
@@ -165,8 +166,8 @@ export function findSessionFilesForCwd(sessionRoot, cwd) {
165
166
  out.push(...findSessionFiles(d));
166
167
  return out.sort(sessionFileNewest);
167
168
  }
168
- /** 第一个用户文本(会话标题素材)。 */
169
- export function firstUserText(events) {
169
+ /** 第一个用户文本(会话标题素材)。无用户文本时回退默认标题(issue #91:lang 缺省英文)。 */
170
+ export function firstUserText(events, lang = "en") {
170
171
  for (const ev of events) {
171
172
  if (ev.type === "user/message") {
172
173
  const blocks = ev.data?.content;
@@ -181,7 +182,7 @@ export function firstUserText(events) {
181
182
  }
182
183
  }
183
184
  }
184
- return "新对话";
185
+ return pick(lang, "新对话", "New chat", "dsh.sessions.untitled");
185
186
  }
186
187
  import { assistantMessageEventToUiMessage, toolResultEventToUiMessage, userMessageEventToUiMessage, } from "./dsh-serialize.js";
187
188
  export function replayEventsToMessages(events) {