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
@@ -28,12 +28,13 @@ import { MarkerService } from "./marker-service.js";
28
28
  import { SlashCommandsService, parseSlash } from "./slash-commands.js";
29
29
  import { ModelAdminService } from "./model-admin.js";
30
30
  import { FilesService, MACHINE_ROOT, workspacePath } from "./files-service.js";
31
- import { isExtensionDisabled, isExtensionEnabled, ClientStateStore } from "./client-state.js";
32
- import { SubagentTemplatesStore } from "./subagent-templates.js";
31
+ import { isExtensionDisabled, isExtensionEnabled, normalizeRetryMaxAttempts, ClientStateStore, } from "./client-state.js";
32
+ import { pick, resolveServerLang } from "./i18n.js";
33
+ import { SubagentTemplatesStore, pickTemplatePrompt } from "./subagent-templates.js";
33
34
  import { applyHeadTail, makePersistentTerminalTools, makeTerminalBashTool, stripAnsi, TERMINAL_TOOLS_GUIDANCE, TERMINAL_TOOL_NAMES, } from "./terminals.js";
34
35
  import { WebUIContext, mockThemeProxy } from "./webui-context.js";
35
36
  import { makeEditSoftTool, SOFT_EDIT_TOOL_NAME } from "./edit-soft-tool.js";
36
- import { makeSubagentTools, subagentTitle, } from "./subagents.js";
37
+ import { makeSubagentTools, subagentTitle, withSubagentOwner, } from "./subagents.js";
37
38
  import { buildAttachmentMessages } from "./attachments.js";
38
39
  import { BUILTIN_SOUL, DEFAULT_PROMPT_TEMPLATE, buildToolsSchemaText, renderPromptTemplate, resolveSectionTexts, } from "./prompt-composer.js";
39
40
  import { serializeMessage, serializeStreamingMessage, stripTransientRetryErrors, } from "./serialize.js";
@@ -196,11 +197,13 @@ function makeMarkersListTool(getActiveId, markerSvc) {
196
197
  return {
197
198
  name: "markers_list",
198
199
  label: "List marker state",
199
- description: "只读查询内联标记状态。状态【写】操作请一律用内联标记([[todo:new:...]] 等)写在回答正文里,不要调用本工具做写操作。",
200
+ description: "Read-only query of inline marker state. All WRITE operations must use inline markers ([[todo:new:...]] etc.) in the reply body — never use this tool for writes.\n只读查询内联标记状态。状态【写】操作请一律用内联标记([[todo:new:...]] 等)写在回答正文里,不要调用本工具做写操作。",
200
201
  parameters: Type.Object({
201
202
  action: Type.Unsafe({ enum: ["list"] }),
202
203
  tool: Type.Optional(Type.Literal("todo")),
203
- includeDeleted: Type.Optional(Type.Boolean({ description: "是否包含已删除任务(tombstone,仅 todo)" })),
204
+ includeDeleted: Type.Optional(Type.Boolean({
205
+ description: "Whether to include deleted tasks (tombstones, todo only).\n是否包含已删除任务(tombstone,仅 todo)。",
206
+ })),
204
207
  }),
205
208
  execute: async (_id, params) => {
206
209
  const p = params;
@@ -260,7 +263,7 @@ export function makeAskUserQuestionTool(clientSession) {
260
263
  aborted: signal?.aborted,
261
264
  });
262
265
  if (answers === null) {
263
- throw new Error("用户取消了提问");
266
+ throw new Error("User cancelled the question.\n用户取消了提问。");
264
267
  }
265
268
  // 工具结果:把每道题的回答拼成简洁文本给模型,同时留 details 供 UI 展示。
266
269
  const lines = answers.map((a) => {
@@ -575,6 +578,8 @@ export class ClientSession {
575
578
  isDisposed: () => this.disposed,
576
579
  getCwd: () => this.cwd,
577
580
  getActiveCwd: () => this.conv?.cwd ?? this.cwd,
581
+ // issue #91:文件服务错误文案按客户端 UI 语言出中英(英文默认)。
582
+ getLang: () => this.getLang(),
578
583
  });
579
584
  bg = new BgServerTracker({
580
585
  emit: (msg) => this.emit(msg),
@@ -629,7 +634,9 @@ export class ClientSession {
629
634
  return (conversationId ? this.convs.get(conversationId) : this.conv)?.cwd ?? this.cwd;
630
635
  }
631
636
  makeTerminalManager(conversationId, cwd) {
632
- const mgr = new TerminalManager((msg) => this.emitTerminal(conversationId, msg), cwd);
637
+ const mgr = new TerminalManager((msg) => this.emitTerminal(conversationId, msg), cwd,
638
+ // issue #91:终端输入错误按客户端 UI 语言出中英(英文默认)。
639
+ () => this.getLang());
633
640
  // 终端活力检测:AI 触碰过的终端静默 ≥ 阈值(PI_WEB_TERMINAL_IDLE_MS,
634
641
  // 默认 15s)且该对话正在运行时,注入一条 steer 消息唤醒 AI 去检查。
635
642
  mgr.onAgentIdle = (terminalId, idleMs, title, lastLines) => this.notifyTerminalIdle(conversationId, terminalId, idleMs, title, lastLines);
@@ -699,18 +706,20 @@ export class ClientSession {
699
706
  * `model`(可选):"provider/id",显式指定子代理模型。不传时由调用方决定是否
700
707
  * 回退到模板模型 / 设置面板默认模型;null = 跟随主对话当前模型(默认行为,
701
708
  * runtime 重建时会继承共享 ModelRuntime 的当前默认)。 */
702
- async spawnSubagentConversation(prompt, type, cwd, apply, model) {
709
+ async spawnSubagentConversation(prompt, type, cwd, apply, model, parentId) {
703
710
  const conversationId = `sa-${randomUUID().slice(0, 8)}`;
704
711
  const terminals = this.makeTerminalManager(conversationId, cwd);
705
- const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, apply), {
712
+ const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, apply, conversationId), {
706
713
  cwd,
707
714
  agentDir: this.agentDir,
708
715
  sessionManager: SessionManager.inMemory(cwd),
709
716
  });
710
717
  const conv = this.makeConversation(runtime, conversationId, terminals);
711
718
  conv.isSubagent = true;
712
- // 派发时刻的 active 即父对话(子代理也可再派发,自然嵌套)。
713
- conv.parentId = this.activeId || undefined;
719
+ // 父对话 = 真正派发它的会话(按会话归属的 host 包装填入)。直接用 active
720
+ // 会错:后台对话运行时用户可能正看着别的项目对话,孩子会被记到无关
721
+ // 对话名下、沉到别的项目组底部(issue #95)。缺省才回退到 active。
722
+ conv.parentId = parentId ?? this.activeId ?? undefined;
714
723
  conv.subagentType = type;
715
724
  conv.listed = true;
716
725
  conv.title = subagentTitle(prompt);
@@ -903,6 +912,8 @@ export class ClientSession {
903
912
  windowsPersona: process.platform === "win32" ? WINDOWS_PERSONA : "",
904
913
  terminalGuidance: this.settingsSvc.current.terminalToolsEnabled !== false ? TERMINAL_TOOLS_GUIDANCE : "",
905
914
  markersGuidance: this.markerSvc.buildGuidance(),
915
+ // issue #91:组合模板各来源段按客户端 UI 语言渲染(英文默认)。
916
+ lang: this.getLang(),
906
917
  contextFiles: src.contextFiles,
907
918
  skills: src.skills,
908
919
  };
@@ -983,14 +994,14 @@ export class ClientSession {
983
994
  * 切换查看 / 输入补充(steer)/ 中止(abort)/ 移出全部复用现有对话机制。
984
995
  */
985
996
  subagentHost = {
986
- spawnSubagent: (prompt, type, cwd, templateName, model) => {
997
+ spawnSubagent: (prompt, type, cwd, templateName, model, parentId) => {
987
998
  // 模板:存在且启用时应用;传了名字但不可用 → 抛错让工具转给 AI。
988
999
  const tpl = templateName ? this.subagentTemplates.get(templateName) : undefined;
989
1000
  if (templateName && (!tpl || !tpl.enabled)) {
990
- throw new Error(`子代理模板不可用:${templateName}(不存在或已停用)`);
1001
+ throw new Error(pick(this.getLang(), `子代理模板不可用:${templateName}(不存在或已停用)`, `Subagent template unavailable: ${templateName} (missing or disabled)`, "agent.subagent.template.unavailable", { templateName: templateName }));
991
1002
  }
992
1003
  // 模型优先级:显式 model 参数 > 模板自带模型 > 设置面板默认模型;都不给 = 跟随主对话。
993
- return this.spawnSubagentConversation(prompt, type, cwd, tpl, model);
1004
+ return this.spawnSubagentConversation(prompt, type, cwd, tpl, model, parentId);
994
1005
  },
995
1006
  getSubagent: (convId) => this.getSubagentSnapshot(convId),
996
1007
  listSubagents: () => this.listSubagentSnapshots(),
@@ -1003,14 +1014,16 @@ export class ClientSession {
1003
1014
  stopSubagent: async (convId) => {
1004
1015
  const conv = this.convs.get(convId);
1005
1016
  if (conv && (conv.session.isStreaming || !conv.session.isIdle)) {
1006
- await this.interruptRun(conv, "用户停止子代理");
1017
+ await this.interruptRun(conv, pick(this.getLang(), "用户停止子代理", "User stopped the subagent", "agent.subagent.stop.user"));
1007
1018
  }
1008
1019
  },
1020
+ // issue #91:子代理工具返回按客户端 UI 语言出中英(英文默认)。
1021
+ lang: () => this.getLang(),
1009
1022
  // 只向 AI 暴露 enabled 的模板(停用的对 AI 不可见)。
1010
1023
  listTemplates: () => this.subagentTemplates
1011
1024
  .list()
1012
1025
  .filter((t) => t.enabled)
1013
- .map((t) => ({ name: t.name, description: t.description, model: t.model })),
1026
+ .map((t) => ({ name: t.name, description: t.description, descriptionEn: t.descriptionEn, model: t.model })),
1014
1027
  isTemplateUsable: (name) => {
1015
1028
  const t = this.subagentTemplates.get(name);
1016
1029
  return !!t && t.enabled;
@@ -1097,7 +1110,15 @@ export class ClientSession {
1097
1110
  // 复用现有重命名路径(内存标题 + 磁盘 session_info)
1098
1111
  void this.renameConversation(convId, title);
1099
1112
  },
1113
+ // 标记 widget 合并进扩展 widget 里,跟随当前活动会话渲染(切换会话即刷新)。
1114
+ refreshMarkers: () => this.webUi.refresh(),
1115
+ // issue #91:标记引导/错误按客户端 UI 语言出中英(英文默认)。
1116
+ lang: () => this.getLang(),
1100
1117
  });
1118
+ // 标记 widget 动态渲染「当前活动会话」的 todo/overlay:切换会话时只要刷新
1119
+ // webUi(见 switchConversation/setCwd/newChat)就会显示对应会话的标记,
1120
+ // 且与扩展 widget 合并下发、不会互相覆盖。
1121
+ this.webUi.setDynamicWidget("markers", () => this.markerSvc.overlayLines(this.activeId));
1101
1122
  this.settingsSvc = new SettingsService({
1102
1123
  clientId,
1103
1124
  stateStore,
@@ -1110,10 +1131,14 @@ export class ClientSession {
1110
1131
  isStreaming: () => this.session.isStreaming,
1111
1132
  reloadSession: async () => {
1112
1133
  await this.session.reload();
1134
+ // reload() 重读磁盘 settings.json,会丢掉内存 applyOverrides
1135
+ // (含重试次数覆盖)——依次重放:重试覆盖 → 终端门控。
1136
+ this.applyRetryOverrides();
1113
1137
  // reload() 会把 custom 工具重新加回活跃集——重放终端开关。
1114
1138
  this.applyToolGating(this.session);
1115
1139
  await this.pushSlashCommands();
1116
1140
  },
1141
+ applyRetryOverrides: () => this.applyRetryOverrides(),
1117
1142
  promptSnapshot: () => this.promptSnapshot(),
1118
1143
  getMarkerState: () => ({
1119
1144
  markersEnabled: this.markerSvc.current.markersEnabled,
@@ -1130,6 +1155,10 @@ export class ClientSession {
1130
1155
  flushSnapshot: () => this.flushSnapshot(),
1131
1156
  isDisposed: () => this.disposed,
1132
1157
  quiesceBlocked: () => this.quiesceBlocked(),
1158
+ // issue #91:目标/审查文案按客户端 UI 语言出中英(英文默认)。
1159
+ lang: () => this.getLang(),
1160
+ // 目标模式总开关(设置面板「目标审查」页):关 → 目标入口一律拒绝。
1161
+ goalModeEnabled: () => this.settingsSvc.current.goalModeEnabled !== false,
1133
1162
  activeConvId: () => this.activeId,
1134
1163
  activeConv: () => this.conv,
1135
1164
  getConv: (id) => this.convs.get(id),
@@ -1157,7 +1186,7 @@ export class ClientSession {
1157
1186
  const cs = new ClientSession(clientId, cwd, agentDir, stateStore);
1158
1187
  const conversationId = cs.nextConversationId();
1159
1188
  const terminals = cs.makeTerminalManager(conversationId, cwd);
1160
- const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(terminals), {
1189
+ const runtime = await createAgentSessionRuntime(cs.makeRuntimeFactory(terminals, undefined, conversationId), {
1161
1190
  cwd,
1162
1191
  agentDir,
1163
1192
  // Resume the most recent session for this project — the SDK default
@@ -1195,7 +1224,7 @@ export class ClientSession {
1195
1224
  * 应用(prompt replace/append + 白名单),其余(终端接管、Windows persona
1196
1225
  * 等)仍跟随主会话设置。undefined = 按主会话设置(普通对话/不选模板的子代理)。
1197
1226
  */
1198
- makeRuntimeFactory(terminals, apply) {
1227
+ makeRuntimeFactory(terminals, apply, ownerId) {
1199
1228
  return async ({ cwd: effectiveCwd, sessionManager }) => {
1200
1229
  const services = await createAgentSessionServices({
1201
1230
  cwd: effectiveCwd,
@@ -1218,8 +1247,8 @@ export class ClientSession {
1218
1247
  systemPromptOverride: (base) => {
1219
1248
  if (typeof base === "string" && base) {
1220
1249
  this.lastBaseSystemPrompt = base;
1221
- if (apply && apply.promptMode === "replace" && apply.systemPrompt.trim()) {
1222
- return apply.systemPrompt;
1250
+ if (apply && apply.promptMode === "replace" && pickTemplatePrompt(apply, this.getLang()).trim()) {
1251
+ return pickTemplatePrompt(apply, this.getLang());
1223
1252
  }
1224
1253
  }
1225
1254
  return undefined;
@@ -1229,8 +1258,8 @@ export class ClientSession {
1229
1258
  if (!apply)
1230
1259
  this.lastSdkAppendFiles = base.slice();
1231
1260
  const out = [...base];
1232
- if (apply && apply.promptMode === "append" && apply.systemPrompt.trim()) {
1233
- out.push(apply.systemPrompt);
1261
+ if (apply && apply.promptMode === "append" && pickTemplatePrompt(apply, this.getLang()).trim()) {
1262
+ out.push(pickTemplatePrompt(apply, this.getLang()));
1234
1263
  }
1235
1264
  // 主会话自定义「追加」已并入组合模板的 {{append}} 覆盖,不再在此注入。
1236
1265
  if (process.platform === "win32") {
@@ -1294,12 +1323,12 @@ export class ClientSession {
1294
1323
  // 把灵魂段换成模板提示词,自动段保留;SYSTEM.md 情形已在
1295
1324
  // systemPromptOverride 整体替换,此处边界不存在会自然跳过。
1296
1325
  if (apply) {
1297
- if (apply.promptMode !== "replace" || !apply.systemPrompt.trim())
1326
+ if (apply.promptMode !== "replace" || !pickTemplatePrompt(apply, this.getLang()).trim())
1298
1327
  return undefined;
1299
1328
  const boundary = event.systemPrompt.indexOf("\n\nAvailable tools:");
1300
1329
  if (boundary === -1)
1301
1330
  return undefined;
1302
- const swapped = apply.systemPrompt.trimEnd() + event.systemPrompt.slice(boundary);
1331
+ const swapped = pickTemplatePrompt(apply, this.getLang()).trimEnd() + event.systemPrompt.slice(boundary);
1303
1332
  return swapped === event.systemPrompt ? undefined : { systemPrompt: swapped };
1304
1333
  }
1305
1334
  // 主会话:组合模板渲染(模板为空且无覆盖时返回 undefined = 用 SDK 默认)。
@@ -1337,18 +1366,26 @@ export class ClientSession {
1337
1366
  idleMs: () => Math.max(0, Math.floor(this.settingsSvc.current.terminalBashIdleMs) || 0),
1338
1367
  kills: this.bashKills,
1339
1368
  notifyBackgroundDone: (info) => this.notifyTerminalBashDone(terminals, info),
1369
+ // issue #91:bash 返回按客户端 UI 语言出中英(英文默认)。
1370
+ lang: () => this.getLang(),
1340
1371
  }),
1341
1372
  // 设置关 → 原生 bash;开 → 终端 bash。
1342
1373
  () => this.settingsSvc.current.terminalBash),
1343
- ...makePersistentTerminalTools(terminals, effectiveCwd),
1374
+ ...makePersistentTerminalTools(terminals, effectiveCwd, () => this.getLang()),
1344
1375
  // 不覆盖内置 edit 的独立宽松编辑工具(缩进不敏感匹配;开关看设置)。
1345
- makeEditSoftTool(effectiveCwd),
1376
+ makeEditSoftTool(effectiveCwd, () => this.getLang()),
1346
1377
  // 插件注册的 AI 工具(创建时刻的实时快照;后续注册经
1347
1378
  // refreshPluginTools 动态补入已有会话)。
1348
1379
  ...(this.pluginToolsProvider?.() ?? []).map(pluginToolToDefinition),
1349
1380
  // 第一方子代理工具(spawn/get_result/steer/list/stop)。子代理会话
1350
- // 也注册了它们,因此可自然嵌套派发。
1351
- ...makeSubagentTools(this.subagentHost),
1381
+ // 也注册了它们,因此可自然嵌套派发。host 按 ownerId 包装:子代理的
1382
+ // 父对话 = 真正调用 spawn 的那个会话(本 runtime 所属会话),而不是
1383
+ // 派发瞬间的 active——后台对话继续产出时用户可能已切到别的项目,用
1384
+ // activeId 会把孩子记到无关会话名下、沉到别的组/底部(issue #95)。
1385
+ // ownerId 即本 runtime 所属会话(创建时就已知,见各调用点)。
1386
+ ...(ownerId
1387
+ ? makeSubagentTools(withSubagentOwner(this.subagentHost, ownerId))
1388
+ : makeSubagentTools(this.subagentHost)),
1352
1389
  // 内置标记只读查询工具(todo/svc 状态查询,写操作走内联标记)。
1353
1390
  makeMarkersListTool(() => this.activeId, this.markerSvc),
1354
1391
  // 标准引擎的 ask_user_question:模型调用 → 浏览器富渲染问卷(复用 DSH
@@ -1776,18 +1813,17 @@ export class ClientSession {
1776
1813
  conv.queueSteering = [...event.steering];
1777
1814
  conv.queueFollowUp = [...event.followUp];
1778
1815
  break;
1779
- // 手动 /compact 或阈值/溢出自动压缩开始——立即反馈,避免「没反应」
1780
- // (此前 compaction_start/end 事件被 switch 静默丢弃,issue #33)。
1816
+ // 手动 /compact 或阈值/溢出自动压缩开始——常驻进度条(快照 compaction
1817
+ // 字段),而不是一次性 toast(toast 几秒就消失,而摘要生成可能持续
1818
+ // 数十秒,用户会以为「没反应」)。立即 flush 让进度条第一时间出现。
1781
1819
  case "compaction_start": {
1782
- this.emit({
1783
- type: "notice",
1784
- level: "info",
1785
- text: "正在压缩上下文…(压缩摘要将显示在消息区)",
1786
- textEn: "Compacting context… (the summary will appear in the message list)",
1787
- });
1820
+ conv.compactionState = { reason: event.reason, startedAt: Date.now() };
1821
+ conv.lastCompactionTokens = null;
1822
+ this.flushSnapshot();
1788
1823
  break;
1789
1824
  }
1790
1825
  case "compaction_end": {
1826
+ conv.compactionState = null;
1791
1827
  if (event.errorMessage) {
1792
1828
  this.emit({
1793
1829
  type: "notice",
@@ -1807,6 +1843,8 @@ export class ClientSession {
1807
1843
  else if (event.result) {
1808
1844
  const { tokensBefore, estimatedTokensAfter } = event.result;
1809
1845
  const after = estimatedTokensAfter ?? tokensBefore;
1846
+ // 记住压缩后大小:SDK 在下轮响应前报 null,快照用此回填底栏。
1847
+ conv.lastCompactionTokens = estimatedTokensAfter ?? null;
1810
1848
  this.emit({
1811
1849
  type: "notice",
1812
1850
  level: "info",
@@ -2144,13 +2182,25 @@ export class ClientSession {
2144
2182
  totalMessages: s.totalMessages,
2145
2183
  tokens: s.tokens,
2146
2184
  cost: s.cost,
2147
- contextUsage: s.contextUsage
2148
- ? {
2149
- tokens: s.contextUsage.tokens,
2150
- contextWindow: s.contextUsage.contextWindow,
2151
- percent: s.contextUsage.percent,
2185
+ contextUsage: (() => {
2186
+ const cu = s.contextUsage;
2187
+ if (!cu)
2188
+ return stats.contextUsage;
2189
+ // 压缩刚结束、下轮响应未到:SDK 报 null,用压缩结果回填约数。
2190
+ if (cu.tokens == null && conv.lastCompactionTokens != null && cu.contextWindow > 0) {
2191
+ return {
2192
+ tokens: conv.lastCompactionTokens,
2193
+ contextWindow: cu.contextWindow,
2194
+ percent: (conv.lastCompactionTokens / cu.contextWindow) * 100,
2195
+ estimated: true,
2196
+ };
2152
2197
  }
2153
- : stats.contextUsage,
2198
+ return {
2199
+ tokens: cu.tokens,
2200
+ contextWindow: cu.contextWindow,
2201
+ percent: cu.percent,
2202
+ };
2203
+ })(),
2154
2204
  };
2155
2205
  }
2156
2206
  catch {
@@ -2187,6 +2237,7 @@ export class ClientSession {
2187
2237
  queue: { steering: conv.queueSteering, followUp: conv.queueFollowUp },
2188
2238
  errorMessage: state.errorMessage,
2189
2239
  retry: conv.retryState ?? null,
2240
+ compaction: conv.compactionState ?? null,
2190
2241
  tools: state.tools.map((t) => t.name),
2191
2242
  version: ++this.version,
2192
2243
  piConfigured: this.isPiConfigured(),
@@ -2466,7 +2517,7 @@ export class ClientSession {
2466
2517
  }
2467
2518
  try {
2468
2519
  const targets = collectTargets(this.agentDir, ClientSession.currentAppVersion());
2469
- const items = await checkAllUpdates(targets);
2520
+ const items = await checkAllUpdates(targets, undefined, () => this.getLang());
2470
2521
  this.updatesAllCache = { at: Date.now(), items };
2471
2522
  this.emit({ type: "update_status_all", items });
2472
2523
  }
@@ -2581,7 +2632,11 @@ export class ClientSession {
2581
2632
  this.flushSnapshot();
2582
2633
  },
2583
2634
  refreshSessions: () => this.refreshSessions(),
2584
- afterReload: () => this.applyToolGating(this.session),
2635
+ afterReload: () => {
2636
+ // /reload 同样重读磁盘 settings.json——重放重试覆盖 + 终端门控。
2637
+ this.applyRetryOverrides();
2638
+ this.applyToolGating(this.session);
2639
+ },
2585
2640
  pluginCommands: () => this.pluginCommandsProvider?.() ?? [],
2586
2641
  execPluginCommand: async (name, args) => {
2587
2642
  const def = this.pluginCommandsProvider?.().find((c) => c.name === name);
@@ -2626,10 +2681,10 @@ export class ClientSession {
2626
2681
  return this.modelAdmin.listModelsConfig();
2627
2682
  }
2628
2683
  fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
2629
- return this.modelAdmin.fetchModelsList(reqId, baseUrl, apiKey, authHeader, api);
2684
+ return this.modelAdmin.fetchModelsList(reqId, baseUrl, apiKey, authHeader, api, () => this.getLang());
2630
2685
  }
2631
2686
  refreshProviderModels(providerId, reqId) {
2632
- return this.modelAdmin.refreshProviderModels(providerId, reqId);
2687
+ return this.modelAdmin.refreshProviderModels(providerId, reqId, () => this.getLang());
2633
2688
  }
2634
2689
  /** Copy a built-in provider into an editable custom-provider draft
2635
2690
  * (clone_provider_result) — lets the user run a second API key without
@@ -2766,6 +2821,22 @@ export class ClientSession {
2766
2821
  pushSettings() {
2767
2822
  this.settingsSvc.push();
2768
2823
  }
2824
+ /** 把设置面板的出错重试次数注入全部存活会话的 SDK SettingsManager。
2825
+ * applyOverrides 只改内存合并视图(不碰 ~/.pi/agent/settings.json),
2826
+ * 且 SDK 每次退避前都重读 getRetrySettings()——即时生效、无需 reload。
2827
+ * 但 session.reload() 会重读磁盘丢掉覆盖,每次 reload 后必须重放
2828
+ * (reloadSession / afterReload / 标记开关直载路径均已接)。 */
2829
+ applyRetryOverrides() {
2830
+ const n = normalizeRetryMaxAttempts(this.settingsSvc.current.retryMaxAttempts);
2831
+ for (const c of this.convs.values()) {
2832
+ try {
2833
+ c.session.settingsManager.applyOverrides({ retry: { maxRetries: n } });
2834
+ }
2835
+ catch {
2836
+ // 会话未就绪或已释放 → 其 runtime 创建时统一注入。
2837
+ }
2838
+ }
2839
+ }
2769
2840
  /** Extensions/skills changed externally (e.g. `pi remove` finished in the
2770
2841
  * terminal): re-run session.reload() and re-push state. Streaming-safe —
2771
2842
  * deferred to agent_end, same as settings reloads. */
@@ -2774,7 +2845,10 @@ export class ClientSession {
2774
2845
  }
2775
2846
  /** Persist + apply a partial settings update (prompt text/mode, toggles). */
2776
2847
  async setSettings(partial) {
2777
- const { markersEnabled, disabledMarkers, ...rest } = partial;
2848
+ const { markersEnabled, disabledMarkers, quickPhrasesSeeded, ...rest } = partial;
2849
+ // 快捷短语「已 seed」是全局标记(非 per-clientId):置位一次后永久生效。
2850
+ if (quickPhrasesSeeded)
2851
+ this.stateStore.markQuickPhrasesSeeded();
2778
2852
  let markerChanged = false;
2779
2853
  if (markersEnabled !== undefined || disabledMarkers !== undefined) {
2780
2854
  this.markerSvc.setAll({
@@ -2792,6 +2866,7 @@ export class ClientSession {
2792
2866
  if (!this.session.isStreaming) {
2793
2867
  try {
2794
2868
  await this.session.reload();
2869
+ this.applyRetryOverrides();
2795
2870
  this.applyToolGating(this.session);
2796
2871
  await this.pushSlashCommands();
2797
2872
  this.pushSettings();
@@ -2870,6 +2945,26 @@ export class ClientSession {
2870
2945
  // 兼容旧入口:reload + 刷目录在宿主回调里完成
2871
2946
  return this.settingsSvc.applyRuntime();
2872
2947
  }
2948
+ /** Server language for this client (issue #91): resolved LIVE from the
2949
+ * persisted UI locale — "zh" only for zh*; everything else (including
2950
+ * never-reported) is English. Per-call tool return values read this on
2951
+ * every invocation, so they follow language switches with no rebuild. */
2952
+ getLang() {
2953
+ return resolveServerLang(this.stateStore.get(this.clientId).locale);
2954
+ }
2955
+ /** Persist the browser UI locale (hello.locale / set_locale) and refresh
2956
+ * lang-aware prompt segments. Reuses the settings reload path, so it is
2957
+ * streaming-safe (deferred to agent_end mid-run, same as settings). */
2958
+ async setLocale(locale) {
2959
+ const code = locale.trim().slice(0, 16);
2960
+ if (!code)
2961
+ return;
2962
+ const prev = this.getLang();
2963
+ this.stateStore.saveLocale(this.clientId, code);
2964
+ if (this.getLang() === prev)
2965
+ return; // same server language — nothing to re-render
2966
+ await this.applySettingsReload();
2967
+ }
2873
2968
  // ---------------------------------------------------------------------------
2874
2969
  // Commands
2875
2970
  // ---------------------------------------------------------------------------
@@ -2963,6 +3058,8 @@ export class ClientSession {
2963
3058
  emit: (msg) => this.emit(msg),
2964
3059
  settings: this.settingsSvc.current,
2965
3060
  session: this.session,
3061
+ // issue #91:附件/视觉桥文案按客户端 UI 语言出中英(英文默认)。
3062
+ getLang: () => this.getLang(),
2966
3063
  }, attachments);
2967
3064
  for (const aside of asides) {
2968
3065
  await s.sendCustomMessage(aside.message, { deliverAs: "nextTurn" });
@@ -3224,7 +3321,7 @@ export class ClientSession {
3224
3321
  this.clearAllToolWatchdogs(conv);
3225
3322
  conv.toolStartTimes.clear();
3226
3323
  await conv.runtime.dispose();
3227
- const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(conv.terminals), {
3324
+ const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(conv.terminals, undefined, conv.id), {
3228
3325
  cwd: conv.cwd,
3229
3326
  agentDir: this.agentDir,
3230
3327
  sessionManager: SessionManager.continueRecent(conv.cwd),
@@ -3303,7 +3400,7 @@ export class ClientSession {
3303
3400
  try {
3304
3401
  const conversationId = this.nextConversationId();
3305
3402
  const terminals = this.makeTerminalManager(conversationId, this.cwd);
3306
- const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals), {
3403
+ const runtime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId), {
3307
3404
  cwd: this.cwd,
3308
3405
  agentDir: this.agentDir,
3309
3406
  sessionManager: SessionManager.create(this.cwd),
@@ -3387,7 +3484,7 @@ export class ClientSession {
3387
3484
  reviewing: conv.goal.reviewing,
3388
3485
  wizardRunning: conv.wizardRunning,
3389
3486
  streaming: conv.session.isStreaming,
3390
- openTerminals: conv.terminals.list().length,
3487
+ openTerminals: conv.terminals.countLive(),
3391
3488
  listed: conv.listed,
3392
3489
  promptedSinceActive: conv.promptedSinceActive,
3393
3490
  hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
@@ -3784,7 +3881,7 @@ export class ClientSession {
3784
3881
  reviewing: conv.goal.reviewing,
3785
3882
  wizardRunning: conv.wizardRunning,
3786
3883
  streaming: conv.session.isStreaming,
3787
- openTerminals: conv.terminals.list().length,
3884
+ openTerminals: conv.terminals.countLive(),
3788
3885
  listed: conv.listed,
3789
3886
  promptedSinceActive: conv.promptedSinceActive,
3790
3887
  hasActiveSubagentRun: () => hasActiveSubagentRun({ sessionId: conv.session.sessionFile }),
@@ -3799,7 +3896,7 @@ export class ClientSession {
3799
3896
  textEn: `Conversation "${conv.title}" is still running — wait for it to finish or press Stop before removing`,
3800
3897
  });
3801
3898
  }
3802
- else if (conv.terminals.list().length > 0) {
3899
+ else if (conv.terminals.countLive() > 0) {
3803
3900
  this.emit({
3804
3901
  type: "notice",
3805
3902
  level: "warning",
@@ -3857,7 +3954,7 @@ export class ClientSession {
3857
3954
  const targetCwd = sessionManager.getCwd();
3858
3955
  const conversationId = this.nextConversationId();
3859
3956
  openedTerminals = this.makeTerminalManager(conversationId, targetCwd);
3860
- openedRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(openedTerminals), {
3957
+ openedRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(openedTerminals, undefined, conversationId), {
3861
3958
  cwd: targetCwd,
3862
3959
  agentDir: this.agentDir,
3863
3960
  sessionManager,
@@ -4209,7 +4306,7 @@ export class ClientSession {
4209
4306
  // First visit to this project: resume its most recent session.
4210
4307
  const conversationId = this.nextConversationId();
4211
4308
  const terminals = this.makeTerminalManager(conversationId, abs);
4212
- const newRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals), {
4309
+ const newRuntime = await createAgentSessionRuntime(this.makeRuntimeFactory(terminals, undefined, conversationId), {
4213
4310
  cwd: abs,
4214
4311
  agentDir: this.agentDir,
4215
4312
  sessionManager: SessionManager.continueRecent(abs),
@@ -4615,6 +4712,25 @@ export class AgentService {
4615
4712
  for (const cs of this.clients.values())
4616
4713
  cs.refreshPluginTools();
4617
4714
  }
4715
+ /** Browser UI locale report (hello.locale / set_locale): persist per client
4716
+ * and refresh lang-aware prompts (streaming-safe via ClientSession). */
4717
+ async setLocale(clientId, locale) {
4718
+ const cs = this.clients.get(clientId);
4719
+ if (cs) {
4720
+ await cs.setLocale(locale);
4721
+ return;
4722
+ }
4723
+ // hello race: session still being created — wait for it, then apply.
4724
+ const inflight = this.pending.get(clientId);
4725
+ if (inflight) {
4726
+ try {
4727
+ await (await inflight).setLocale(locale);
4728
+ }
4729
+ catch {
4730
+ /* attach failed — nothing to apply to */
4731
+ }
4732
+ }
4733
+ }
4618
4734
  /** 插件斜杠命令集合变化时由 index.ts 触发:重推各客户端的命令目录。 */
4619
4735
  applyPluginCommandCatalog() {
4620
4736
  for (const cs of this.clients.values())
@@ -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({