pi-web-ui 0.68.1 → 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 (50) hide show
  1. package/dist/server/agent-service.js +306 -102
  2. package/dist/server/attachments.js +7 -2
  3. package/dist/server/client-state.js +31 -2
  4. package/dist/server/dsh/dsh-agent-service.js +154 -39
  5. package/dist/server/dsh/dsh-client.js +9 -8
  6. package/dist/server/dsh/dsh-sessions.js +4 -3
  7. package/dist/server/dsh/runtime/runtime-root.mjs +17 -11
  8. package/dist/server/edit-soft-tool.js +33 -26
  9. package/dist/server/files-service.js +12 -7
  10. package/dist/server/goal-service.js +85 -24
  11. package/dist/server/i18n.js +157 -0
  12. package/dist/server/index.js +124 -6
  13. package/dist/server/locales.js +210 -0
  14. package/dist/server/managed.js +61 -0
  15. package/dist/server/marker-service.js +20 -7
  16. package/dist/server/markers/builtins/notify.js +19 -6
  17. package/dist/server/markers/builtins/rename.js +41 -8
  18. package/dist/server/markers/builtins/todo.js +107 -30
  19. package/dist/server/markers/registry.js +2 -2
  20. package/dist/server/mcp-bridge.js +3 -1
  21. package/dist/server/model-admin.js +25 -14
  22. package/dist/server/plugin-catalog.js +7 -3
  23. package/dist/server/plugin-updater.js +6 -2
  24. package/dist/server/plugins.js +40 -17
  25. package/dist/server/prompt-composer.js +76 -16
  26. package/dist/server/protocol-version.js +1 -1
  27. package/dist/server/scm.js +18 -25
  28. package/dist/server/serialize.js +1 -0
  29. package/dist/server/settings-service.js +29 -1
  30. package/dist/server/subagent-templates.js +105 -0
  31. package/dist/server/subagents.js +155 -54
  32. package/dist/server/tabs.js +87 -0
  33. package/dist/server/terminals.js +88 -48
  34. package/dist/server/update-check.js +56 -52
  35. package/dist/server/vision-bridge.js +34 -12
  36. package/package.json +4 -1
  37. package/web/dist/assets/TerminalPanel-Cj8zsjx-.js +6 -0
  38. package/web/dist/assets/TerminalPanel-DOrYoP_4.css +32 -0
  39. package/web/dist/assets/index-DCOcsPFm.js +334 -0
  40. package/web/dist/assets/index-jH2Bb-0X.css +10 -0
  41. package/web/dist/assets/markdown-Cpo0pNcR.js +51 -0
  42. package/web/dist/assets/{react-C9ovnpIm.js → react-CtudoG1_.js} +2 -2
  43. package/web/dist/assets/xterm-B96xOxS9.js +38 -0
  44. package/web/dist/index.html +4 -4
  45. package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +0 -32
  46. package/web/dist/assets/TerminalPanel-IJF_fssI.js +0 -6
  47. package/web/dist/assets/index-BmiyyjKp.css +0 -10
  48. package/web/dist/assets/index-qoTr5KXy.js +0 -332
  49. package/web/dist/assets/markdown-DRBrS2Nf.js +0 -51
  50. package/web/dist/assets/xterm-D1D2FVe3.js +0 -38
@@ -188,12 +188,16 @@ export async function buildAttachmentMessages(ctx, attachments) {
188
188
  else {
189
189
  // Batch hash so re-sending identical images (edit & re-ask) reuses
190
190
  // the transcript instead of re-burning tokens on the vision API.
191
+ // issue #91:转写提示词按客户端 UI 语言选用(英文默认),语言进缓存键。
192
+ const vLang = ctx.getLang?.() ?? "en";
191
193
  // The active transcription prompt is part of the key: changing
192
194
  // the custom prompt must invalidate cached transcripts made with
193
195
  // the old prompt.
194
196
  const batchHash = bridgedImages.map((b) => `${b.att.name ?? "img"}:${b.raw.slice(0, 48)}`).join("|") +
195
197
  "::" +
196
- buildVisionBridgePrompt(ctx.settings.visionBridgePromptMode, ctx.settings.visionBridgePrompt);
198
+ buildVisionBridgePrompt(ctx.settings.visionBridgePromptMode, ctx.settings.visionBridgePrompt, vLang) +
199
+ "::" +
200
+ vLang;
197
201
  let transcript = visionBridgeCache.get(batchHash);
198
202
  if (transcript === undefined) {
199
203
  ctx.emit({
@@ -210,7 +214,8 @@ export async function buildAttachmentMessages(ctx, attachments) {
210
214
  name: b.att.name,
211
215
  })), {
212
216
  model: chosenModel ?? undefined,
213
- systemPrompt: buildVisionBridgePrompt(ctx.settings.visionBridgePromptMode, ctx.settings.visionBridgePrompt),
217
+ systemPrompt: buildVisionBridgePrompt(ctx.settings.visionBridgePromptMode, ctx.settings.visionBridgePrompt, vLang),
218
+ lang: vLang,
214
219
  });
215
220
  visionBridgeCache.set(batchHash, transcript);
216
221
  ctx.emit({
@@ -8,6 +8,15 @@
8
8
  */
9
9
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
10
  import { dirname } from "node:path";
11
+ /** 大模型 API 出错自动重试次数的默认值(SDK 默认 3)。 */
12
+ export const DEFAULT_RETRY_MAX_ATTEMPTS = 6;
13
+ /** 归一化重试次数:非数值回落默认,钳制到 [0, 100] 整数。 */
14
+ export function normalizeRetryMaxAttempts(v) {
15
+ const n = Math.floor(Number(v));
16
+ if (!Number.isFinite(n))
17
+ return DEFAULT_RETRY_MAX_ATTEMPTS;
18
+ return Math.min(100, Math.max(0, n));
19
+ }
11
20
  /** Stable identity of an extension for the enable/disable toggle: the npm
12
21
  * spec for packages (survives version bumps), the resolved entry path
13
22
  * otherwise. */
@@ -143,6 +152,18 @@ export class ClientStateStore {
143
152
  locked: s.goalPrefs.locked ?? true,
144
153
  };
145
154
  }
155
+ /** Persist the client's UI locale code (hello/set_locale; best-effort). */
156
+ saveLocale(clientId, locale) {
157
+ const code = locale.trim().slice(0, 16);
158
+ if (!code)
159
+ return;
160
+ const all = this.load();
161
+ const state = (all[clientId] ??= { projects: [] });
162
+ if (state.locale === code)
163
+ return;
164
+ state.locale = code;
165
+ this.save();
166
+ }
146
167
  /** Persist the client's goal/review preferences (model choice, rounds, lock). */
147
168
  saveGoalPrefs(clientId, prefs) {
148
169
  const all = this.load();
@@ -200,10 +221,12 @@ export class ClientStateStore {
200
221
  promptOverrides,
201
222
  disabledSkills: stored?.disabledSkills ?? [],
202
223
  disabledExtensions: stored?.disabledExtensions ?? [],
203
- terminalToolsEnabled: stored?.terminalToolsEnabled ?? true,
224
+ terminalToolsEnabled: stored?.terminalToolsEnabled ?? false,
204
225
  terminalBash: stored?.terminalBash ?? false,
205
226
  terminalBashIdleMs: stored?.terminalBashIdleMs ?? 15_000,
206
227
  editSoftEnabled: stored?.editSoftEnabled ?? false,
228
+ questionnaireEnabled: stored?.questionnaireEnabled ?? true,
229
+ goalModeEnabled: stored?.goalModeEnabled ?? true,
207
230
  thinkingWrap: stored?.thinkingWrap ?? false,
208
231
  toolsWrap: stored?.toolsWrap ?? true,
209
232
  visionBridgeEnabled: stored?.visionBridgeEnabled ?? true,
@@ -211,6 +234,7 @@ export class ClientStateStore {
211
234
  visionBridgePromptMode: stored?.visionBridgePromptMode === "replace" ? "replace" : "append",
212
235
  visionBridgePrompt: stored?.visionBridgePrompt ?? "",
213
236
  subagentDefaultModel: stored?.subagentDefaultModel ?? null,
237
+ retryMaxAttempts: normalizeRetryMaxAttempts(stored?.retryMaxAttempts),
214
238
  quickPhrases: stored?.quickPhrases ?? [],
215
239
  quickPhrasesEnabled: stored?.quickPhrasesEnabled ?? true,
216
240
  reviewPrompt: stored?.reviewPrompt ?? "",
@@ -230,15 +254,18 @@ export class ClientStateStore {
230
254
  promptOverrides: { ...(settings.promptOverrides ?? cur.promptOverrides) },
231
255
  disabledSkills: settings.disabledSkills ?? cur.disabledSkills ?? [],
232
256
  disabledExtensions: settings.disabledExtensions ?? cur.disabledExtensions ?? [],
233
- terminalToolsEnabled: settings.terminalToolsEnabled ?? cur.terminalToolsEnabled ?? true,
257
+ terminalToolsEnabled: settings.terminalToolsEnabled ?? cur.terminalToolsEnabled ?? false,
234
258
  terminalBash: settings.terminalBash ?? cur.terminalBash ?? false,
235
259
  terminalBashIdleMs: settings.terminalBashIdleMs ?? cur.terminalBashIdleMs ?? 15_000,
236
260
  editSoftEnabled: settings.editSoftEnabled ?? cur.editSoftEnabled ?? false,
261
+ questionnaireEnabled: settings.questionnaireEnabled ?? cur.questionnaireEnabled ?? true,
262
+ goalModeEnabled: settings.goalModeEnabled ?? cur.goalModeEnabled ?? true,
237
263
  thinkingWrap: settings.thinkingWrap ?? cur.thinkingWrap ?? false,
238
264
  toolsWrap: settings.toolsWrap ?? cur.toolsWrap ?? true,
239
265
  visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
240
266
  visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
241
267
  subagentDefaultModel: settings.subagentDefaultModel ?? cur.subagentDefaultModel ?? null,
268
+ retryMaxAttempts: normalizeRetryMaxAttempts(settings.retryMaxAttempts ?? cur.retryMaxAttempts ?? DEFAULT_RETRY_MAX_ATTEMPTS),
242
269
  visionBridgePromptMode: settings.visionBridgePromptMode ?? cur.visionBridgePromptMode ?? "append",
243
270
  visionBridgePrompt: settings.visionBridgePrompt ?? cur.visionBridgePrompt ?? "",
244
271
  reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
@@ -256,6 +283,8 @@ export class ClientStateStore {
256
283
  // Older client-state files predate review settings.
257
284
  reviewPrompt: p.reviewPrompt ?? "",
258
285
  reviewDisabledSkills: p.reviewDisabledSkills ?? [],
286
+ // Older presets predate the configurable retry count.
287
+ retryMaxAttempts: normalizeRetryMaxAttempts(p.retryMaxAttempts),
259
288
  }));
260
289
  }
261
290
  /** Persist the client's named settings presets. */
@@ -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。 */
@@ -104,10 +106,12 @@ const DEFAULT_SETTINGS = {
104
106
  customSystemPrompt: "",
105
107
  disabledSkills: [],
106
108
  disabledExtensions: [],
107
- terminalToolsEnabled: true,
109
+ terminalToolsEnabled: false,
108
110
  terminalBash: false,
109
111
  terminalBashIdleMs: 15_000,
110
112
  editSoftEnabled: false,
113
+ questionnaireEnabled: true,
114
+ goalModeEnabled: true,
111
115
  thinkingWrap: false,
112
116
  toolsWrap: true,
113
117
  disabledPlugins: [],
@@ -221,6 +225,8 @@ export class DshClientSession {
221
225
  terminalBash: savedSettings.terminalBash,
222
226
  terminalBashIdleMs: savedSettings.terminalBashIdleMs,
223
227
  editSoftEnabled: savedSettings.editSoftEnabled,
228
+ questionnaireEnabled: savedSettings.questionnaireEnabled ?? true,
229
+ goalModeEnabled: savedSettings.goalModeEnabled ?? true,
224
230
  thinkingWrap: savedSettings.thinkingWrap,
225
231
  toolsWrap: savedSettings.toolsWrap,
226
232
  disabledPlugins: savedSettings.disabledPlugins ?? [],
@@ -443,6 +449,11 @@ export class DshClientSession {
443
449
  else if (method === "question.pending") {
444
450
  // 模型 ask_user_question → 转发给浏览器对话框(deadline = 服务端超时时间戳)。
445
451
  const params0 = params;
452
+ // 问卷开关(默认开):关 → 不弹框,立即取消让模型得知已禁用。
453
+ if (this.settings.questionnaireEnabled === false) {
454
+ void this.answerQuestion(params0.id, [], true);
455
+ return;
456
+ }
446
457
  this.emit({
447
458
  type: "question_pending",
448
459
  id: params0.id,
@@ -461,6 +472,7 @@ export class DshClientSession {
461
472
  options: q.options.map((o) => ({
462
473
  label: String(o.label ?? ""),
463
474
  ...(typeof o.description === "string" ? { description: o.description } : {}),
475
+ ...(typeof o.preview === "string" ? { preview: o.preview } : {}),
464
476
  })),
465
477
  }
466
478
  : {}),
@@ -577,14 +589,17 @@ export class DshClientSession {
577
589
  const args = (params?.args && typeof params.args === "object" ? params.args : {});
578
590
  if (!id)
579
591
  return;
592
+ const lang = this.getLang();
580
593
  if (!name) {
581
- 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(() => { });
582
597
  return;
583
598
  }
584
599
  try {
585
600
  const tool = this.bridgedTool((this.pluginToolsProvider?.() ?? []).find((t) => t.name === name));
586
601
  if (!tool) {
587
- 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);
588
603
  return;
589
604
  }
590
605
  const ac = new AbortController();
@@ -614,6 +629,14 @@ export class DshClientSession {
614
629
  wizard: { active: false, draft: "", model: null, step: 0, maxSteps: 3, status: "" },
615
630
  };
616
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
+ }
617
640
  /** 新建(或切换)一个 conversation。existing 的 sessionId 续聊最近 JSONL。 */
618
641
  addConversation(sessionId, cwd, replay = true) {
619
642
  const id = this.nextConversationId();
@@ -622,7 +645,7 @@ export class DshClientSession {
622
645
  sessionId,
623
646
  dsGoal: null,
624
647
  goal: this.makeGoalStatus(),
625
- title: DEFAULT_CONV_TITLE,
648
+ title: this.defaultTitle(),
626
649
  cwd,
627
650
  createdAt: Date.now(),
628
651
  messages: [],
@@ -634,7 +657,9 @@ export class DshClientSession {
634
657
  lastEventAt: Date.now(),
635
658
  listed: false,
636
659
  promptedSinceActive: false,
637
- terminals: new TerminalManager((msg) => this.emit(msg), cwd),
660
+ terminals: new TerminalManager((msg) => this.emit(msg), cwd,
661
+ // issue #91:终端输入错误按客户端 UI 语言出中英(英文默认)。
662
+ () => this.getLang()),
638
663
  toolStartTimes: new Map(),
639
664
  tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
640
665
  };
@@ -647,7 +672,7 @@ export class DshClientSession {
647
672
  conv.messages = replayEventsToMessages(events);
648
673
  for (const m of conv.messages)
649
674
  conv.messageIds.add(m.id);
650
- conv.title = firstUserText(events);
675
+ conv.title = firstUserText(events, this.getLang());
651
676
  }
652
677
  }
653
678
  catch {
@@ -718,7 +743,7 @@ export class DshClientSession {
718
743
  if (imgRefs.length > 0) {
719
744
  void this.hydrateImageBlocks(conv, msg, imgRefs);
720
745
  }
721
- if (conv.title === DEFAULT_CONV_TITLE) {
746
+ if (DshClientSession.isDefaultTitle(conv.title)) {
722
747
  const t = conv.messages
723
748
  .find((m) => m.role === "user")
724
749
  ?.content?.map((c) => ("text" in c ? c.text : ""))
@@ -834,7 +859,8 @@ export class DshClientSession {
834
859
  if (reason.kind === "completed")
835
860
  w.resolve();
836
861
  else
837
- 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 })));
838
864
  }
839
865
  break;
840
866
  }
@@ -890,7 +916,7 @@ export class DshClientSession {
890
916
  conv.messages.push(msg);
891
917
  }
892
918
  refreshConversationTitle(conv) {
893
- if (conv.title !== DEFAULT_CONV_TITLE)
919
+ if (!DshClientSession.isDefaultTitle(conv.title))
894
920
  return;
895
921
  // 从消息列表取第一个用户文本。
896
922
  const t = conv.messages
@@ -1180,11 +1206,12 @@ export class DshClientSession {
1180
1206
  const histText = this.histToContext(conv);
1181
1207
  conv = this.forkConversation(conv);
1182
1208
  if (histText.trim()) {
1183
- 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}`;
1184
1211
  }
1185
1212
  }
1186
1213
  // 命名对话(首个 prompt)。
1187
- if (conv.title === DEFAULT_CONV_TITLE && text.trim()) {
1214
+ if (DshClientSession.isDefaultTitle(conv.title) && text.trim()) {
1188
1215
  const trimmed = text.trim().replace(/\s+/g, " ");
1189
1216
  conv.title = trimmed.length > 30 ? `${trimmed.slice(0, 30)}…` : trimmed;
1190
1217
  this.emitConversations();
@@ -1339,11 +1366,12 @@ export class DshClientSession {
1339
1366
  }
1340
1367
  const hist = this.histToContext(conv);
1341
1368
  this.forkConversation(conv);
1369
+ const lang = this.getLang();
1342
1370
  const text = lastUser
1343
1371
  ? hist.trim()
1344
- ? `${lastUser}\n\n(以下为原对话上下文,仅作参考):\n${hist}`
1372
+ ? `${lastUser}\n\n${pick(lang, "(以下为原对话上下文,仅作参考):", "(Previous conversation context below for reference only):", "dsh.prompt.context.short")}\n${hist}`
1345
1373
  : lastUser
1346
- : "请继续";
1374
+ : pick(lang, "请继续", "Please continue", "dsh.prompt.continue");
1347
1375
  this.emit({
1348
1376
  type: "notice",
1349
1377
  level: "info",
@@ -1362,6 +1390,7 @@ export class DshClientSession {
1362
1390
  }
1363
1391
  async buildContentBlocks(text, attachments) {
1364
1392
  const blocks = [{ type: "text", text }];
1393
+ const lang = this.getLang();
1365
1394
  if (!Array.isArray(attachments))
1366
1395
  return blocks;
1367
1396
  for (const a of attachments) {
@@ -1376,7 +1405,7 @@ export class DshClientSession {
1376
1405
  catch (err) {
1377
1406
  blocks.push({
1378
1407
  type: "text",
1379
- 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 }),
1380
1409
  });
1381
1410
  }
1382
1411
  }
@@ -1384,12 +1413,15 @@ export class DshClientSession {
1384
1413
  // 上传文件 → 落盘 + 路径引用。
1385
1414
  try {
1386
1415
  const saved = saveUpload(this.clientId, a.name ?? "upload", Buffer.from(a.fileData, "base64"), this.dataDir);
1387
- 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
+ });
1388
1420
  }
1389
1421
  catch (err) {
1390
1422
  blocks.push({
1391
1423
  type: "text",
1392
- 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 }),
1393
1425
  });
1394
1426
  }
1395
1427
  }
@@ -1416,7 +1448,10 @@ export class DshClientSession {
1416
1448
  blocks.push({ type: "image", attachment: saved.ref });
1417
1449
  }
1418
1450
  catch {
1419
- 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
+ });
1420
1455
  }
1421
1456
  }
1422
1457
  else {
@@ -1429,19 +1464,31 @@ export class DshClientSession {
1429
1464
  }
1430
1465
  }
1431
1466
  else {
1432
- 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
+ });
1433
1471
  }
1434
1472
  }
1435
1473
  catch {
1436
- 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
+ });
1437
1478
  }
1438
1479
  }
1439
1480
  else {
1440
- 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
+ });
1441
1485
  }
1442
1486
  }
1443
1487
  else if (a.name) {
1444
- 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
+ });
1445
1492
  }
1446
1493
  }
1447
1494
  return blocks;
@@ -1576,7 +1623,7 @@ export class DshClientSession {
1576
1623
  summaries.push({
1577
1624
  path: file,
1578
1625
  name: sessionId,
1579
- firstMessage: firstUserText(events),
1626
+ firstMessage: firstUserText(events, this.getLang()),
1580
1627
  messageCount: events.filter((e) => e.type === "user/message" || e.type === "assistant/message" || e.type === "tool/result").length,
1581
1628
  modified: statSync(file).mtimeMs,
1582
1629
  source: "web",
@@ -1888,11 +1935,11 @@ export class DshClientSession {
1888
1935
  continue;
1889
1936
  if (all.includes(q) ||
1890
1937
  sessionId.toLowerCase().includes(q) ||
1891
- firstUserText(events).toLowerCase().includes(q)) {
1938
+ firstUserText(events, this.getLang()).toLowerCase().includes(q)) {
1892
1939
  results.push({
1893
1940
  path: file,
1894
1941
  name: sessionId,
1895
- firstMessage: firstUserText(events),
1942
+ firstMessage: firstUserText(events, this.getLang()),
1896
1943
  messageCount: events.filter((e) => e.type === "user/message" || e.type === "assistant/message" || e.type === "tool/result").length,
1897
1944
  modified: statSync(file).mtimeMs,
1898
1945
  source: "web",
@@ -2060,6 +2107,10 @@ export class DshClientSession {
2060
2107
  terminalBash: this.settings.terminalBash,
2061
2108
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2062
2109
  editSoftEnabled: this.settings.editSoftEnabled,
2110
+ // DSH 无独立重试配置(pi 引擎才暴露),保持默认。
2111
+ retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
2112
+ questionnaireEnabled: this.settings.questionnaireEnabled,
2113
+ goalModeEnabled: this.settings.goalModeEnabled,
2063
2114
  thinkingWrap: this.settings.thinkingWrap,
2064
2115
  toolsWrap: this.settings.toolsWrap,
2065
2116
  visionBridgeEnabled: false,
@@ -2073,6 +2124,8 @@ export class DshClientSession {
2073
2124
  promptOverrides: {},
2074
2125
  effectiveSystemPrompt: this.settings.customSystemPrompt,
2075
2126
  promptSourceDefaults: {},
2127
+ // DSH 引擎不接标准 pi 的 customTool 工具 schema(走 goal-rpc),此处给空。
2128
+ toolsSchema: "",
2076
2129
  visionBridgeDefaultPrompt: "",
2077
2130
  visionModels: [],
2078
2131
  skills: this.skillsCache,
@@ -2108,6 +2161,10 @@ export class DshClientSession {
2108
2161
  this.settings.terminalBashIdleMs = partial.terminalBashIdleMs;
2109
2162
  if (partial.editSoftEnabled !== undefined)
2110
2163
  this.settings.editSoftEnabled = partial.editSoftEnabled;
2164
+ if (partial.questionnaireEnabled !== undefined)
2165
+ this.settings.questionnaireEnabled = partial.questionnaireEnabled;
2166
+ if (partial.goalModeEnabled !== undefined)
2167
+ this.settings.goalModeEnabled = partial.goalModeEnabled;
2111
2168
  if (partial.thinkingWrap !== undefined)
2112
2169
  this.settings.thinkingWrap = partial.thinkingWrap;
2113
2170
  if (partial.toolsWrap !== undefined)
@@ -2135,6 +2192,10 @@ export class DshClientSession {
2135
2192
  terminalBash: this.settings.terminalBash,
2136
2193
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2137
2194
  editSoftEnabled: this.settings.editSoftEnabled,
2195
+ // DSH 无独立重试配置(pi 引擎才暴露),保持默认。
2196
+ retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
2197
+ questionnaireEnabled: this.settings.questionnaireEnabled,
2198
+ goalModeEnabled: this.settings.goalModeEnabled,
2138
2199
  thinkingWrap: this.settings.thinkingWrap,
2139
2200
  toolsWrap: this.settings.toolsWrap,
2140
2201
  disabledPlugins: this.settings.disabledPlugins,
@@ -2194,6 +2255,8 @@ export class DshClientSession {
2194
2255
  terminalBash: this.settings.terminalBash,
2195
2256
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2196
2257
  editSoftEnabled: this.settings.editSoftEnabled,
2258
+ // DSH 无独立重试配置,预设沿用默认值。
2259
+ retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
2197
2260
  visionBridgePromptMode: "append",
2198
2261
  visionBridgePrompt: "",
2199
2262
  reviewPrompt: this.settings.reviewPrompt,
@@ -2334,7 +2397,9 @@ export class DshClientSession {
2334
2397
  else if (phase === "blocked") {
2335
2398
  g.reviewing = false;
2336
2399
  g.verdict = "fail";
2337
- g.feedback = data.goal.blockedReason ?? "(模型报告受阻)";
2400
+ g.feedback =
2401
+ data.goal.blockedReason ??
2402
+ pick(this.getLang(), "(模型报告受阻)", "(Model reported blocked)", "dsh.goal.blocked");
2338
2403
  g.status = "目标受阻";
2339
2404
  g.statusEn = "Goal blocked";
2340
2405
  }
@@ -2360,6 +2425,15 @@ export class DshClientSession {
2360
2425
  }
2361
2426
  if (this.quiesceBlocked())
2362
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
+ }
2363
2437
  const conv = this.conv;
2364
2438
  const text = goal.trim();
2365
2439
  const g = conv.goal;
@@ -2417,7 +2491,7 @@ export class DshClientSession {
2417
2491
  if (conv.turnWaiter) {
2418
2492
  const w = conv.turnWaiter;
2419
2493
  conv.turnWaiter = undefined;
2420
- w.reject(new Error("调研已取消"));
2494
+ w.reject(new Error(pick(this.getLang(), "调研已取消", "Survey cancelled", "dsh.survey.cancelled")));
2421
2495
  }
2422
2496
  if (conv.dsGoal) {
2423
2497
  try {
@@ -2444,6 +2518,15 @@ export class DshClientSession {
2444
2518
  // 提问(经提问桥 → 浏览器对话框)→ 收敛输出 GOAL: 行 → 自动设目标。
2445
2519
  if (this.quiesceBlocked())
2446
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
+ }
2447
2530
  const conv = this.conv;
2448
2531
  const draft = (text ?? "").trim();
2449
2532
  if (!draft)
@@ -2476,7 +2559,7 @@ export class DshClientSession {
2476
2559
  });
2477
2560
  try {
2478
2561
  const waiter = new Promise((resolve, reject) => {
2479
- 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);
2480
2563
  timer.unref?.();
2481
2564
  conv.turnWaiter = {
2482
2565
  resolve: () => {
@@ -2756,9 +2839,7 @@ export class DshClientSession {
2756
2839
  }
2757
2840
  async checkUpdate() {
2758
2841
  try {
2759
- const latest = await checkAllUpdates([
2760
- { name: "pi-web-ui", version: DshClientSession.currentAppVersion(), kind: "webui" },
2761
- ]);
2842
+ const latest = await checkAllUpdates([{ name: "pi-web-ui", version: DshClientSession.currentAppVersion(), kind: "webui" }], undefined, () => this.getLang());
2762
2843
  const item = latest[0];
2763
2844
  this.emit({
2764
2845
  type: "update_status",
@@ -2783,7 +2864,7 @@ export class DshClientSession {
2783
2864
  async checkUpdatesAll(force = false) {
2784
2865
  try {
2785
2866
  const targets = collectTargets(join(homedir(), ".pi", "agent"), DshClientSession.currentAppVersion());
2786
- const items = await checkAllUpdates(targets);
2867
+ const items = await checkAllUpdates(targets, undefined, () => this.getLang());
2787
2868
  if (force) {
2788
2869
  // 强制模式:忽略缓存(默认 Fetcher 带 TTL,直接再查一次即可)。
2789
2870
  void items;
@@ -2809,7 +2890,11 @@ export class DshClientSession {
2809
2890
  // pi 专属:DSH 引擎下的简化实现
2810
2891
  // -----------------------------------------------------------------------
2811
2892
  async installPiAgent() {
2812
- 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
+ });
2813
2898
  }
2814
2899
  async setProviderApiKey(provider, apiKey) {
2815
2900
  const key = apiKey.trim();
@@ -2904,7 +2989,7 @@ export class DshClientSession {
2904
2989
  providers: [
2905
2990
  {
2906
2991
  id: "deepseek-official",
2907
- name: "DeepSeek 官方",
2992
+ name: pick(this.getLang(), "DeepSeek 官方", "DeepSeek Official", "dsh.provider.deepseek.official"),
2908
2993
  configured: !!loadDeepSeekKey(),
2909
2994
  source: loadDeepSeekKey() ? "stored" : undefined,
2910
2995
  },
@@ -2939,13 +3024,23 @@ export class DshClientSession {
2939
3024
  });
2940
3025
  }
2941
3026
  async fetchModelsList(reqId, _baseUrl, _apiKey, _authHeader, _api) {
2942
- 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
+ });
2943
3033
  }
2944
3034
  async refreshProviderModels(_providerId, reqId) {
2945
- 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
+ });
2946
3041
  }
2947
3042
  async cloneProvider(_provider, reqId) {
2948
- const error = "DSH 引擎不支持自定义 provider";
3043
+ const error = pick(this.getLang(), "DSH 引擎不支持自定义 provider", "The DSH engine does not support custom providers", "dsh.provider.clone.unsupported");
2949
3044
  const errorEn = "DSH engine does not support custom providers";
2950
3045
  this.emit({ type: "notice", level: "error", text: error, textEn: errorEn });
2951
3046
  this.emit({ type: "clone_provider_result", reqId, ok: false, error });
@@ -2988,7 +3083,7 @@ export class DshClientSession {
2988
3083
  this.activeId = fresh.id;
2989
3084
  // 编辑后的提问本身在 prompt 里;历史作为附加上下文(首条 prompt)。
2990
3085
  const headText = contextNote.trim()
2991
- ? `${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}`
2992
3087
  : text;
2993
3088
  await this.prompt(headText, attachments);
2994
3089
  this.emitConversations();
@@ -3003,6 +3098,19 @@ export class DshClientSession {
3003
3098
  });
3004
3099
  }
3005
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
+ }
3006
3114
  async setCwd(newCwd) {
3007
3115
  try {
3008
3116
  const abs = resolve(newCwd);
@@ -3183,7 +3291,7 @@ export class DshAgentService {
3183
3291
  let cs = this.clients.get(clientId);
3184
3292
  if (!cs) {
3185
3293
  if (this.quiesced) {
3186
- throw new QuiesceRejectedError("新连接被拒绝,请等服务器恢复后重试");
3294
+ throw new QuiesceRejectedError(bilingual("New connections rejected; retry after the server resumes", "新连接被拒绝,请等服务器恢复后重试"));
3187
3295
  }
3188
3296
  let cwd = this.cwd;
3189
3297
  const saved = this.stateStore.get(clientId);
@@ -3221,6 +3329,13 @@ export class DshAgentService {
3221
3329
  this.onClientCwdChanged?.(cs.cwd);
3222
3330
  return cs;
3223
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
+ }
3224
3339
  applyPluginAgentTools() {
3225
3340
  // 工具桥(#15):插件工具列表变化 → 各客户端运行时重新注册。
3226
3341
  for (const cs of this.clients.values())