pi-web-ui 0.68.2 → 0.70.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/server/agent-service.js +170 -54
  2. package/dist/server/attachments.js +7 -2
  3. package/dist/server/client-state.js +71 -17
  4. package/dist/server/dsh/dsh-agent-service.js +142 -38
  5. package/dist/server/dsh/dsh-client.js +9 -8
  6. package/dist/server/dsh/dsh-sessions.js +4 -3
  7. package/dist/server/edit-soft-tool.js +33 -26
  8. package/dist/server/files-service.js +12 -7
  9. package/dist/server/goal-service.js +85 -24
  10. package/dist/server/i18n.js +157 -0
  11. package/dist/server/index.js +77 -18
  12. package/dist/server/locales.js +55 -1
  13. package/dist/server/managed.js +61 -0
  14. package/dist/server/marker-service.js +37 -20
  15. package/dist/server/markers/builtins/notify.js +19 -6
  16. package/dist/server/markers/builtins/rename.js +41 -8
  17. package/dist/server/markers/builtins/todo.js +107 -30
  18. package/dist/server/markers/registry.js +2 -2
  19. package/dist/server/mcp-bridge.js +3 -1
  20. package/dist/server/model-admin.js +25 -14
  21. package/dist/server/plugin-catalog.js +7 -3
  22. package/dist/server/plugin-updater.js +6 -2
  23. package/dist/server/plugins.js +40 -17
  24. package/dist/server/prompt-composer.js +42 -16
  25. package/dist/server/protocol-version.js +1 -1
  26. package/dist/server/scm.js +18 -25
  27. package/dist/server/serialize.js +1 -0
  28. package/dist/server/settings-service.js +21 -1
  29. package/dist/server/subagent-templates.js +105 -0
  30. package/dist/server/subagents.js +164 -56
  31. package/dist/server/tabs.js +87 -0
  32. package/dist/server/terminals.js +99 -48
  33. package/dist/server/update-check.js +7 -2
  34. package/dist/server/vision-bridge.js +34 -12
  35. package/dist/server/webui-context.js +9 -0
  36. package/package.json +2 -1
  37. package/themes/cyberpunk.css +88 -81
  38. package/themes/dazzle.css +88 -81
  39. package/themes/md-preview.css +105 -98
  40. package/themes/white.css +167 -160
  41. package/web/dist/assets/{TerminalPanel-BQ5NTB9Y.js → TerminalPanel-UxgczH4c.js} +1 -1
  42. package/web/dist/assets/index-BH5QutUc.css +10 -0
  43. package/web/dist/assets/index-CaiIOwy7.js +335 -0
  44. package/web/dist/assets/{markdown-DOsihKaR.js → markdown-Cpo0pNcR.js} +1 -1
  45. package/web/dist/assets/{react-DIP6JKYk.js → react-CtudoG1_.js} +1 -1
  46. package/web/dist/index.html +4 -4
  47. package/web/dist/assets/index-C_I-6Zul.css +0 -10
  48. package/web/dist/assets/index-Ck5pa3XK.js +0 -333
@@ -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. */
@@ -67,6 +76,14 @@ export class ClientStateStore {
67
76
  constructor(filePath) {
68
77
  this.filePath = filePath;
69
78
  }
79
+ /** 长期设置(设置面板 config + 预设 + 标记开关)的固定存储键。
80
+ *
81
+ * 为什么用固定全局键而非 per-clientId:clientId 存 sessionStorage(每标签页独立、
82
+ * 关浏览器即失),按 clientId 存设置会在每次新会话/重启后生成新 id → 设置全部重置、
83
+ * 且各标签页/浏览器各有一套互不同步。改为全局共享后:所有客户端(标签页/浏览器)
84
+ * 使用同一套配置,且持久化在服务端,重启不丢(「同一套配置」)。会话级状态
85
+ * (最近项目 / lastCwd / 项目模型与密钥等)仍按 clientId 各自保留。 */
86
+ static GLOBAL_SETTINGS_KEY = "__settings__";
70
87
  /** <dataDir>(client-state.json 的上一级)——共享配置(子代理模板库等)落在这里。 */
71
88
  get dataDir() {
72
89
  return dirname(this.filePath);
@@ -143,6 +160,18 @@ export class ClientStateStore {
143
160
  locked: s.goalPrefs.locked ?? true,
144
161
  };
145
162
  }
163
+ /** Persist the client's UI locale code (hello/set_locale; best-effort). */
164
+ saveLocale(clientId, locale) {
165
+ const code = locale.trim().slice(0, 16);
166
+ if (!code)
167
+ return;
168
+ const all = this.load();
169
+ const state = (all[clientId] ??= { projects: [] });
170
+ if (state.locale === code)
171
+ return;
172
+ state.locale = code;
173
+ this.save();
174
+ }
146
175
  /** Persist the client's goal/review preferences (model choice, rounds, lock). */
147
176
  saveGoalPrefs(clientId, prefs) {
148
177
  const all = this.load();
@@ -176,9 +205,9 @@ export class ClientStateStore {
176
205
  }
177
206
  return list;
178
207
  }
179
- /** Last-used settings-panel state for a client, or defaults. */
180
- getSettings(clientId) {
181
- const s = this.load()[clientId];
208
+ /** 设置面板状态(系统提示词模式/文字 + 禁用技能/扩展)——全局共享同一套配置。 */
209
+ getSettings(_clientId) {
210
+ const s = this.load()[ClientStateStore.GLOBAL_SETTINGS_KEY];
182
211
  const stored = s?.settings;
183
212
  // 旧存档(promptMode/customSystemPrompt)迁移到 compose:追加文字成为独立
184
213
  // {{append}} 覆盖、替换文字成为 {{soul}} 覆盖;无自定义则用默认模板。
@@ -205,6 +234,7 @@ export class ClientStateStore {
205
234
  terminalBashIdleMs: stored?.terminalBashIdleMs ?? 15_000,
206
235
  editSoftEnabled: stored?.editSoftEnabled ?? false,
207
236
  questionnaireEnabled: stored?.questionnaireEnabled ?? true,
237
+ goalModeEnabled: stored?.goalModeEnabled ?? true,
208
238
  thinkingWrap: stored?.thinkingWrap ?? false,
209
239
  toolsWrap: stored?.toolsWrap ?? true,
210
240
  visionBridgeEnabled: stored?.visionBridgeEnabled ?? true,
@@ -212,6 +242,7 @@ export class ClientStateStore {
212
242
  visionBridgePromptMode: stored?.visionBridgePromptMode === "replace" ? "replace" : "append",
213
243
  visionBridgePrompt: stored?.visionBridgePrompt ?? "",
214
244
  subagentDefaultModel: stored?.subagentDefaultModel ?? null,
245
+ retryMaxAttempts: normalizeRetryMaxAttempts(stored?.retryMaxAttempts),
215
246
  quickPhrases: stored?.quickPhrases ?? [],
216
247
  quickPhrasesEnabled: stored?.quickPhrasesEnabled ?? true,
217
248
  reviewPrompt: stored?.reviewPrompt ?? "",
@@ -219,10 +250,10 @@ export class ClientStateStore {
219
250
  disabledPlugins: stored?.disabledPlugins ?? [],
220
251
  };
221
252
  }
222
- /** Persist the client's settings-panel state (partial merge). */
223
- saveSettings(clientId, settings) {
253
+ /** Persist the settings-panel state (partial merge) — global shared config. */
254
+ saveSettings(_clientId, settings) {
224
255
  const all = this.load();
225
- const state = (all[clientId] ??= { projects: [] });
256
+ const state = (all[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
226
257
  const cur = state.settings ?? {};
227
258
  state.settings = {
228
259
  promptMode: settings.promptMode ?? cur.promptMode ?? "append",
@@ -236,11 +267,13 @@ export class ClientStateStore {
236
267
  terminalBashIdleMs: settings.terminalBashIdleMs ?? cur.terminalBashIdleMs ?? 15_000,
237
268
  editSoftEnabled: settings.editSoftEnabled ?? cur.editSoftEnabled ?? false,
238
269
  questionnaireEnabled: settings.questionnaireEnabled ?? cur.questionnaireEnabled ?? true,
270
+ goalModeEnabled: settings.goalModeEnabled ?? cur.goalModeEnabled ?? true,
239
271
  thinkingWrap: settings.thinkingWrap ?? cur.thinkingWrap ?? false,
240
272
  toolsWrap: settings.toolsWrap ?? cur.toolsWrap ?? true,
241
273
  visionBridgeEnabled: settings.visionBridgeEnabled ?? cur.visionBridgeEnabled ?? true,
242
274
  visionBridgeModel: settings.visionBridgeModel ?? cur.visionBridgeModel ?? null,
243
275
  subagentDefaultModel: settings.subagentDefaultModel ?? cur.subagentDefaultModel ?? null,
276
+ retryMaxAttempts: normalizeRetryMaxAttempts(settings.retryMaxAttempts ?? cur.retryMaxAttempts ?? DEFAULT_RETRY_MAX_ATTEMPTS),
244
277
  visionBridgePromptMode: settings.visionBridgePromptMode ?? cur.visionBridgePromptMode ?? "append",
245
278
  visionBridgePrompt: settings.visionBridgePrompt ?? cur.visionBridgePrompt ?? "",
246
279
  reviewPrompt: settings.reviewPrompt ?? cur.reviewPrompt ?? "",
@@ -251,19 +284,21 @@ export class ClientStateStore {
251
284
  };
252
285
  this.save();
253
286
  }
254
- /** Named settings presets for a client (empty if never saved). */
255
- getPresets(clientId) {
256
- return (this.load()[clientId]?.presets ?? []).map((p) => ({
287
+ /** Named settings presets for a client (empty if never saved) — global shared. */
288
+ getPresets(_clientId) {
289
+ return (this.load()[ClientStateStore.GLOBAL_SETTINGS_KEY]?.presets ?? []).map((p) => ({
257
290
  ...p,
258
291
  // Older client-state files predate review settings.
259
292
  reviewPrompt: p.reviewPrompt ?? "",
260
293
  reviewDisabledSkills: p.reviewDisabledSkills ?? [],
294
+ // Older presets predate the configurable retry count.
295
+ retryMaxAttempts: normalizeRetryMaxAttempts(p.retryMaxAttempts),
261
296
  }));
262
297
  }
263
- /** Persist the client's named settings presets. */
264
- savePresets(clientId, presets) {
298
+ /** Persist the named settings presets — global shared config. */
299
+ savePresets(_clientId, presets) {
265
300
  const all = this.load();
266
- const state = (all[clientId] ??= { projects: [] });
301
+ const state = (all[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
267
302
  state.presets = presets;
268
303
  this.save();
269
304
  }
@@ -319,17 +354,36 @@ export class ClientStateStore {
319
354
  delete all[clientId].projectModels;
320
355
  this.save();
321
356
  }
322
- /** 内置标记工具开关(全局 + 按 marker 禁用)。 */
323
- getMarkerSettings(clientId) {
324
- const s = this.load()[clientId]?.markers;
357
+ /** 全局「快捷短语已 seed」标记(非 per-clientId)。
358
+ *
359
+ * 为什么全局:clientId sessionStorage(每标签页独立、关浏览器即失),按
360
+ * clientId 记 seed 会在每次新会话生成新 clientId 时误判为「从未 seed」,导致
361
+ * 用户删掉的默认短语又被填回默认。seed 只需一次(首次见空列表),之后即为用户
362
+ * 数据,增删改/恢复默认/关闭都走设置面板。存服务端而非浏览器 localStorage,
363
+ * 任何浏览器/标签页/清缓存都不受影响。 */
364
+ getQuickPhrasesSeeded() {
365
+ const meta = this.load()[ClientStateStore.GLOBAL_SETTINGS_KEY];
366
+ return !!meta?.quickPhrasesSeeded;
367
+ }
368
+ markQuickPhrasesSeeded() {
369
+ const all = this.load();
370
+ const meta = (all[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
371
+ if (meta.quickPhrasesSeeded)
372
+ return;
373
+ meta.quickPhrasesSeeded = true;
374
+ this.save();
375
+ }
376
+ /** 内置标记工具开关(全局共享同一套 + 按 marker 禁用)。 */
377
+ getMarkerSettings(_clientId) {
378
+ const s = this.load()[ClientStateStore.GLOBAL_SETTINGS_KEY]?.markers;
325
379
  return {
326
380
  markersEnabled: s?.markersEnabled ?? true,
327
381
  disabledMarkers: s?.disabledMarkers ?? [],
328
382
  };
329
383
  }
330
- saveMarkerSettings(clientId, settings) {
384
+ saveMarkerSettings(_clientId, settings) {
331
385
  const all = this.load();
332
- const state = (all[clientId] ??= { projects: [] });
386
+ const state = (all[ClientStateStore.GLOBAL_SETTINGS_KEY] ??= { projects: [] });
333
387
  const cur = state.markers ?? { markersEnabled: true, disabledMarkers: [] };
334
388
  state.markers = {
335
389
  markersEnabled: settings.markersEnabled ?? cur.markersEnabled ?? true,
@@ -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,
@@ -2099,10 +2141,13 @@ export class DshClientSession {
2099
2141
  subagentModels: [],
2100
2142
  quickPhrases: [...this.settings.quickPhrases],
2101
2143
  quickPhrasesEnabled: this.settings.quickPhrasesEnabled,
2144
+ quickPhrasesSeeded: this.stateStore.getQuickPhrasesSeeded(),
2102
2145
  };
2103
2146
  this.emit({ type: "settings_state", settings });
2104
2147
  }
2105
2148
  async setSettings(partial) {
2149
+ if (partial.quickPhrasesSeeded)
2150
+ this.stateStore.markQuickPhrasesSeeded();
2106
2151
  if (partial.promptMode !== undefined)
2107
2152
  this.settings.promptMode = partial.promptMode;
2108
2153
  if (partial.customSystemPrompt !== undefined)
@@ -2121,6 +2166,8 @@ export class DshClientSession {
2121
2166
  this.settings.editSoftEnabled = partial.editSoftEnabled;
2122
2167
  if (partial.questionnaireEnabled !== undefined)
2123
2168
  this.settings.questionnaireEnabled = partial.questionnaireEnabled;
2169
+ if (partial.goalModeEnabled !== undefined)
2170
+ this.settings.goalModeEnabled = partial.goalModeEnabled;
2124
2171
  if (partial.thinkingWrap !== undefined)
2125
2172
  this.settings.thinkingWrap = partial.thinkingWrap;
2126
2173
  if (partial.toolsWrap !== undefined)
@@ -2148,7 +2195,10 @@ export class DshClientSession {
2148
2195
  terminalBash: this.settings.terminalBash,
2149
2196
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2150
2197
  editSoftEnabled: this.settings.editSoftEnabled,
2198
+ // DSH 无独立重试配置(pi 引擎才暴露),保持默认。
2199
+ retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
2151
2200
  questionnaireEnabled: this.settings.questionnaireEnabled,
2201
+ goalModeEnabled: this.settings.goalModeEnabled,
2152
2202
  thinkingWrap: this.settings.thinkingWrap,
2153
2203
  toolsWrap: this.settings.toolsWrap,
2154
2204
  disabledPlugins: this.settings.disabledPlugins,
@@ -2208,6 +2258,8 @@ export class DshClientSession {
2208
2258
  terminalBash: this.settings.terminalBash,
2209
2259
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2210
2260
  editSoftEnabled: this.settings.editSoftEnabled,
2261
+ // DSH 无独立重试配置,预设沿用默认值。
2262
+ retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
2211
2263
  visionBridgePromptMode: "append",
2212
2264
  visionBridgePrompt: "",
2213
2265
  reviewPrompt: this.settings.reviewPrompt,
@@ -2348,7 +2400,9 @@ export class DshClientSession {
2348
2400
  else if (phase === "blocked") {
2349
2401
  g.reviewing = false;
2350
2402
  g.verdict = "fail";
2351
- g.feedback = data.goal.blockedReason ?? "(模型报告受阻)";
2403
+ g.feedback =
2404
+ data.goal.blockedReason ??
2405
+ pick(this.getLang(), "(模型报告受阻)", "(Model reported blocked)", "dsh.goal.blocked");
2352
2406
  g.status = "目标受阻";
2353
2407
  g.statusEn = "Goal blocked";
2354
2408
  }
@@ -2374,6 +2428,15 @@ export class DshClientSession {
2374
2428
  }
2375
2429
  if (this.quiesceBlocked())
2376
2430
  return;
2431
+ if (this.settings.goalModeEnabled === false) {
2432
+ this.emit({
2433
+ type: "notice",
2434
+ level: "warning",
2435
+ text: "目标模式已关闭:请先在设置「目标审查」中启用目标模式。",
2436
+ textEn: "Goal mode is off: enable it under Settings → Goal review first.",
2437
+ });
2438
+ return;
2439
+ }
2377
2440
  const conv = this.conv;
2378
2441
  const text = goal.trim();
2379
2442
  const g = conv.goal;
@@ -2431,7 +2494,7 @@ export class DshClientSession {
2431
2494
  if (conv.turnWaiter) {
2432
2495
  const w = conv.turnWaiter;
2433
2496
  conv.turnWaiter = undefined;
2434
- w.reject(new Error("调研已取消"));
2497
+ w.reject(new Error(pick(this.getLang(), "调研已取消", "Survey cancelled", "dsh.survey.cancelled")));
2435
2498
  }
2436
2499
  if (conv.dsGoal) {
2437
2500
  try {
@@ -2458,6 +2521,15 @@ export class DshClientSession {
2458
2521
  // 提问(经提问桥 → 浏览器对话框)→ 收敛输出 GOAL: 行 → 自动设目标。
2459
2522
  if (this.quiesceBlocked())
2460
2523
  return;
2524
+ if (this.settings.goalModeEnabled === false) {
2525
+ this.emit({
2526
+ type: "notice",
2527
+ level: "warning",
2528
+ text: "目标模式已关闭:请先在设置「目标审查」中启用目标模式。",
2529
+ textEn: "Goal mode is off: enable it under Settings → Goal review first.",
2530
+ });
2531
+ return;
2532
+ }
2461
2533
  const conv = this.conv;
2462
2534
  const draft = (text ?? "").trim();
2463
2535
  if (!draft)
@@ -2490,7 +2562,7 @@ export class DshClientSession {
2490
2562
  });
2491
2563
  try {
2492
2564
  const waiter = new Promise((resolve, reject) => {
2493
- const timer = setTimeout(() => reject(new Error("调研超时(10 分钟)")), 10 * 60_000);
2565
+ const timer = setTimeout(() => reject(new Error(pick(this.getLang(), "调研超时(10 分钟)", "Survey timed out (10 minutes)", "dsh.survey.timeout"))), 10 * 60_000);
2494
2566
  timer.unref?.();
2495
2567
  conv.turnWaiter = {
2496
2568
  resolve: () => {
@@ -2770,9 +2842,7 @@ export class DshClientSession {
2770
2842
  }
2771
2843
  async checkUpdate() {
2772
2844
  try {
2773
- const latest = await checkAllUpdates([
2774
- { name: "pi-web-ui", version: DshClientSession.currentAppVersion(), kind: "webui" },
2775
- ]);
2845
+ const latest = await checkAllUpdates([{ name: "pi-web-ui", version: DshClientSession.currentAppVersion(), kind: "webui" }], undefined, () => this.getLang());
2776
2846
  const item = latest[0];
2777
2847
  this.emit({
2778
2848
  type: "update_status",
@@ -2797,7 +2867,7 @@ export class DshClientSession {
2797
2867
  async checkUpdatesAll(force = false) {
2798
2868
  try {
2799
2869
  const targets = collectTargets(join(homedir(), ".pi", "agent"), DshClientSession.currentAppVersion());
2800
- const items = await checkAllUpdates(targets);
2870
+ const items = await checkAllUpdates(targets, undefined, () => this.getLang());
2801
2871
  if (force) {
2802
2872
  // 强制模式:忽略缓存(默认 Fetcher 带 TTL,直接再查一次即可)。
2803
2873
  void items;
@@ -2823,7 +2893,11 @@ export class DshClientSession {
2823
2893
  // pi 专属:DSH 引擎下的简化实现
2824
2894
  // -----------------------------------------------------------------------
2825
2895
  async installPiAgent() {
2826
- this.emit({ type: "install_result", ok: true, detail: "DSH 引擎不需要 pi CLI" });
2896
+ this.emit({
2897
+ type: "install_result",
2898
+ ok: true,
2899
+ detail: pick(this.getLang(), "DSH 引擎不需要 pi CLI", "The DSH engine does not need the pi CLI", "dsh.engine.no.cli"),
2900
+ });
2827
2901
  }
2828
2902
  async setProviderApiKey(provider, apiKey) {
2829
2903
  const key = apiKey.trim();
@@ -2918,7 +2992,7 @@ export class DshClientSession {
2918
2992
  providers: [
2919
2993
  {
2920
2994
  id: "deepseek-official",
2921
- name: "DeepSeek 官方",
2995
+ name: pick(this.getLang(), "DeepSeek 官方", "DeepSeek Official", "dsh.provider.deepseek.official"),
2922
2996
  configured: !!loadDeepSeekKey(),
2923
2997
  source: loadDeepSeekKey() ? "stored" : undefined,
2924
2998
  },
@@ -2953,13 +3027,23 @@ export class DshClientSession {
2953
3027
  });
2954
3028
  }
2955
3029
  async fetchModelsList(reqId, _baseUrl, _apiKey, _authHeader, _api) {
2956
- this.emit({ type: "fetch_models_result", reqId, ok: false, error: "DSH 引擎不支持自定义 provider 探测" });
3030
+ this.emit({
3031
+ type: "fetch_models_result",
3032
+ reqId,
3033
+ ok: false,
3034
+ error: pick(this.getLang(), "DSH 引擎不支持自定义 provider 探测", "The DSH engine does not support custom provider probing", "dsh.provider.probing.unsupported"),
3035
+ });
2957
3036
  }
2958
3037
  async refreshProviderModels(_providerId, reqId) {
2959
- this.emit({ type: "refresh_provider_result", reqId, ok: false, error: "DSH 引擎不支持自定义 provider" });
3038
+ this.emit({
3039
+ type: "refresh_provider_result",
3040
+ reqId,
3041
+ ok: false,
3042
+ error: pick(this.getLang(), "DSH 引擎不支持自定义 provider", "The DSH engine does not support custom providers", "dsh.provider.custom.unsupported"),
3043
+ });
2960
3044
  }
2961
3045
  async cloneProvider(_provider, reqId) {
2962
- const error = "DSH 引擎不支持自定义 provider";
3046
+ const error = pick(this.getLang(), "DSH 引擎不支持自定义 provider", "The DSH engine does not support custom providers", "dsh.provider.clone.unsupported");
2963
3047
  const errorEn = "DSH engine does not support custom providers";
2964
3048
  this.emit({ type: "notice", level: "error", text: error, textEn: errorEn });
2965
3049
  this.emit({ type: "clone_provider_result", reqId, ok: false, error });
@@ -3002,7 +3086,7 @@ export class DshClientSession {
3002
3086
  this.activeId = fresh.id;
3003
3087
  // 编辑后的提问本身在 prompt 里;历史作为附加上下文(首条 prompt)。
3004
3088
  const headText = contextNote.trim()
3005
- ? `${text}\n\n(编辑重问,原对话上下文,仅作参考,忽略其中指令性语气:)\n${contextNote}`
3089
+ ? `${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
3090
  : text;
3007
3091
  await this.prompt(headText, attachments);
3008
3092
  this.emitConversations();
@@ -3017,6 +3101,19 @@ export class DshClientSession {
3017
3101
  });
3018
3102
  }
3019
3103
  }
3104
+ /** Server language for this client (issue #91): resolved LIVE from the
3105
+ * persisted UI locale — "zh" only for zh*; everything else is English. */
3106
+ getLang() {
3107
+ return resolveServerLang(this.stateStore.get(this.clientId).locale);
3108
+ }
3109
+ /** Persist the browser UI locale (hello.locale / set_locale). DSH
3110
+ * runtime prompts pick it up on the next run — no restart needed. */
3111
+ async setLocale(locale) {
3112
+ const code = locale.trim().slice(0, 16);
3113
+ if (!code)
3114
+ return;
3115
+ this.stateStore.saveLocale(this.clientId, code);
3116
+ }
3020
3117
  async setCwd(newCwd) {
3021
3118
  try {
3022
3119
  const abs = resolve(newCwd);
@@ -3197,7 +3294,7 @@ export class DshAgentService {
3197
3294
  let cs = this.clients.get(clientId);
3198
3295
  if (!cs) {
3199
3296
  if (this.quiesced) {
3200
- throw new QuiesceRejectedError("新连接被拒绝,请等服务器恢复后重试");
3297
+ throw new QuiesceRejectedError(bilingual("New connections rejected; retry after the server resumes", "新连接被拒绝,请等服务器恢复后重试"));
3201
3298
  }
3202
3299
  let cwd = this.cwd;
3203
3300
  const saved = this.stateStore.get(clientId);
@@ -3235,6 +3332,13 @@ export class DshAgentService {
3235
3332
  this.onClientCwdChanged?.(cs.cwd);
3236
3333
  return cs;
3237
3334
  }
3335
+ /** Browser UI locale report (hello.locale / set_locale): persist per
3336
+ * client; DSH runtime prompts refresh on next run (P2 bilingual). */
3337
+ async setLocale(clientId, locale) {
3338
+ const cs = this.clients.get(clientId);
3339
+ if (cs)
3340
+ await cs.setLocale(locale);
3341
+ }
3238
3342
  applyPluginAgentTools() {
3239
3343
  // 工具桥(#15):插件工具列表变化 → 各客户端运行时重新注册。
3240
3344
  for (const cs of this.clients.values())