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
@@ -15,6 +15,7 @@
15
15
  import { join } from "node:path";
16
16
  import { Type } from "typebox";
17
17
  import { createAgentSessionFromServices, createAgentSessionServices, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
18
+ import { bilingual, pick } from "./i18n.js";
18
19
  import { parseModelSpec } from "./attachments.js";
19
20
  /** System prompt for the goal-wizard session. The wizard asks the user a few
20
21
  * questions (via its goal_ask tool) to scope a raw requirement into a precise,
@@ -75,6 +76,14 @@ export class GoalService {
75
76
  get reviewPrefs() {
76
77
  return this.prefs;
77
78
  }
79
+ /** 当前服务端语言(英文默认,未接线前保持原有英文行为)。 */
80
+ lang() {
81
+ return this.host.lang?.() ?? "en";
82
+ }
83
+ /** 目标模式总开关(设置面板可关)。关 → 所有目标入口拒绝、审查不再触发。 */
84
+ goalEnabled() {
85
+ return this.host.goalModeEnabled();
86
+ }
78
87
  /** Create independent goal state for one conversation. Preferences are
79
88
  * client-wide defaults, while goal text/review progress is not shared. */
80
89
  makeGoalStatus() {
@@ -120,6 +129,15 @@ export class GoalService {
120
129
  await this.clearGoal();
121
130
  return;
122
131
  }
132
+ if (!this.goalEnabled()) {
133
+ this.host.emit({
134
+ type: "notice",
135
+ level: "warning",
136
+ text: "目标模式已关闭:请先在设置「目标审查」中启用目标模式。",
137
+ textEn: "Goal mode is off: enable it under Settings → Goal review first.",
138
+ });
139
+ return;
140
+ }
123
141
  // A goal is scoped to the conversation that is active when it is set.
124
142
  // This prevents an agent_end from a newly-created/switched conversation
125
143
  // from consuming the previous conversation's goal.
@@ -175,7 +193,8 @@ export class GoalService {
175
193
  if (opts?.autoStart !== false) {
176
194
  try {
177
195
  const s = conv.session;
178
- await s.sendUserMessage(`【目标已设定】\n\n${text}\n\n请现在开始实现这个目标。`, {
196
+ const kick = pick(this.lang(), `【目标已设定】\n\n${text}\n\n请现在开始实现这个目标。`, `[Goal set]\n\n${text}\n\nStart implementing this goal now.`, "goal.set.kick", { text: text });
197
+ await s.sendUserMessage(kick, {
179
198
  deliverAs: s.isStreaming ? "steer" : "followUp",
180
199
  });
181
200
  }
@@ -196,6 +215,15 @@ export class GoalService {
196
215
  async startGoalWizard(text, opts) {
197
216
  if (this.host.quiesceBlocked())
198
217
  return;
218
+ if (!this.goalEnabled()) {
219
+ this.host.emit({
220
+ type: "notice",
221
+ level: "warning",
222
+ text: "目标模式已关闭:请先在设置「目标审查」中启用目标模式。",
223
+ textEn: "Goal mode is off: enable it under Settings → Goal review first.",
224
+ });
225
+ return;
226
+ }
199
227
  const draft = (text ?? "").trim();
200
228
  if (!draft)
201
229
  return;
@@ -272,7 +300,7 @@ export class GoalService {
272
300
  idleTimer = setTimeout(() => {
273
301
  if (!ac.signal.aborted) {
274
302
  this.wizardCancelled = true;
275
- ac.abort(new Error("目标调研超时(等待回答过久)"));
303
+ ac.abort(new Error(pick(this.lang(), "目标调研超时(等待回答过久)", "Goal survey timed out (waited too long for an answer)", "goal.wizard.idle.timeout")));
276
304
  }
277
305
  }, GoalService.WIZARD_IDLE_TIMEOUT_MS);
278
306
  idleTimer.unref?.();
@@ -289,7 +317,7 @@ export class GoalService {
289
317
  const totalTimer = setTimeout(() => {
290
318
  if (!ac.signal.aborted) {
291
319
  this.wizardCancelled = true;
292
- ac.abort(new Error("目标调研超过总时长上限"));
320
+ ac.abort(new Error(pick(this.lang(), "目标调研超过总时长上限", "Goal survey exceeded the total time limit", "goal.wizard.total.timeout")));
293
321
  }
294
322
  }, GoalService.WIZARD_MAX_TOTAL_MS);
295
323
  totalTimer.unref?.();
@@ -326,9 +354,9 @@ export class GoalService {
326
354
  const goalAsk = defineTool({
327
355
  name: "goal_ask",
328
356
  label: "Ask the user",
329
- description: "Ask the user ONE question at a time to scope down the goal. Provide a clear question and 2-4 concise options; or ask an open question. Returns the user's chosen answer.",
357
+ description: bilingual("Ask the user ONE question at a time to scope down the goal. Provide a clear question and 2-4 concise options; or ask an open question. Returns the user's chosen answer.", "一次只向用户提一个问题,以明确目标范围。给出清晰的问题和 2-4 个简洁选项;或提开放式问题。返回用户选择的答案。"),
330
358
  parameters: Type.Object({
331
- question: Type.String({ description: "The question to ask" }),
359
+ question: Type.String({ description: bilingual("The question to ask", "要问的问题") }),
332
360
  options: Type.Optional(Type.Array(Type.String())),
333
361
  }),
334
362
  // ONE question at a time. Sequential execution prevents the agent from
@@ -337,13 +365,14 @@ export class GoalService {
337
365
  // reported "调研卡住").
338
366
  executionMode: "sequential",
339
367
  execute: async (_id, params, _sig, _onUpdate, ctx) => {
368
+ const lang = this.lang();
340
369
  qStep += 1;
341
370
  if (qStep > maxSteps) {
342
371
  return {
343
372
  content: [
344
373
  {
345
374
  type: "text",
346
- text: "(达到最大提问数,请直接给出收敛后的目标文本作为最终答案)",
375
+ text: pick(lang, "(达到最大提问数,请直接给出收敛后的目标文本作为最终答案)", "(Max questions reached — stop asking and reply with the converged goal text as your final answer)", "goal.wizard.max.questions"),
347
376
  },
348
377
  ],
349
378
  details: {},
@@ -358,16 +387,23 @@ export class GoalService {
358
387
  try {
359
388
  armIdle();
360
389
  const isChoice = !!(params.options && params.options.length > 0);
361
- await this.pushWizardCard(mainSession, `🔍 第 ${qStep} 题:${params.question}${isChoice ? `【${params.options.join(" / ")}】` : ""}`, { question: params.question });
390
+ const qTitle = pick(lang, `🔍 第 ${qStep} 题:${params.question}`, `🔍 Question ${qStep}: ${params.question}`, "goal.wizard.question.title", { qStep: qStep, "params.question": params.question });
391
+ const optionsJoined = params.options.join(" / ");
392
+ const choiceSuffixZh = isChoice ? `【${optionsJoined}】` : "";
393
+ const choiceSuffixEn = isChoice ? ` [${optionsJoined}]` : "";
394
+ await this.pushWizardCard(mainSession, pick(lang, `🔍 第 ${qStep} 题:${params.question}${choiceSuffixZh}`, `🔍 Question ${qStep}: ${params.question}${choiceSuffixEn}`, "goal.wizard.question.card", {
395
+ qStep: qStep,
396
+ "params.question": params.question,
397
+ choiceSuffixZh: choiceSuffixZh,
398
+ choiceSuffixEn: choiceSuffixEn,
399
+ }), { question: params.question });
362
400
  // Resolve the pending dialog as cancelled if the wizard is aborted.
363
401
  let aborted = false;
364
402
  const onAbort = () => {
365
403
  aborted = true;
366
404
  };
367
405
  ac.signal.addEventListener("abort", onAbort, { once: true });
368
- const choose = isChoice
369
- ? ctx.ui.select(`🔍 第 ${qStep} 题:${params.question}`, params.options)
370
- : ctx.ui.input(`🔍 第 ${qStep} 题:${params.question}`);
406
+ const choose = isChoice ? ctx.ui.select(qTitle, params.options) : ctx.ui.input(qTitle);
371
407
  const ans = (await choose);
372
408
  ac.signal.removeEventListener("abort", onAbort);
373
409
  if (aborted || ac.signal.aborted) {
@@ -375,7 +411,7 @@ export class GoalService {
375
411
  content: [
376
412
  {
377
413
  type: "text",
378
- text: "(调研已取消,请不要继续提问,直接结束对话)",
414
+ text: pick(lang, "(调研已取消,请不要继续提问,直接结束对话)", "(The survey was cancelled — stop asking and end the conversation)", "goal.wizard.cancelled.stop"),
379
415
  },
380
416
  ],
381
417
  details: {},
@@ -386,30 +422,40 @@ export class GoalService {
386
422
  content: [
387
423
  {
388
424
  type: "text",
389
- text: "(用户已取消调研,请直接给出你当前收敛的目标文本作为最终答案)",
425
+ text: pick(lang, "(用户已取消调研,请直接给出你当前收敛的目标文本作为最终答案)", "(The user cancelled the survey — reply with your best-effort goal text as the final answer)", "goal.wizard.cancelled.best"),
390
426
  },
391
427
  ],
392
428
  details: {},
393
429
  };
394
430
  }
395
431
  // Record the answer in the flow too (instant append, main session idle).
396
- await this.pushWizardCard(mainSession, `↳ 您的回答:${ans}`, {
432
+ await this.pushWizardCard(mainSession, pick(lang, `↳ 您的回答:${ans}`, `↳ Your answer: ${ans}`, "goal.wizard.answer.card", { ans: ans }), {
397
433
  question: params.question,
398
434
  answer: String(ans),
399
435
  });
400
436
  return {
401
- content: [{ type: "text", text: `用户回答:${ans}` }],
437
+ content: [
438
+ {
439
+ type: "text",
440
+ text: pick(lang, `用户回答:${ans}`, `User answer: ${ans}`, "goal.wizard.answer.return", {
441
+ ans: ans,
442
+ }),
443
+ },
444
+ ],
402
445
  details: {},
403
446
  };
404
447
  }
405
448
  catch (err) {
449
+ const errMsg = err.message;
406
450
  return {
407
451
  content: [
408
452
  {
409
453
  type: "text",
410
454
  text: ac.signal.aborted
411
- ? "(调研已取消,请不要继续提问,直接结束对话)"
412
- : `提问失败:${err.message}`,
455
+ ? pick(lang, "(调研已取消,请不要继续提问,直接结束对话)", "(The survey was cancelled — stop asking and end the conversation)", "goal.wizard.cancelled.aborted")
456
+ : pick(lang, `提问失败:${errMsg}`, `Failed to ask: ${errMsg}`, "goal.wizard.ask.failed", {
457
+ errMsg: errMsg,
458
+ }),
413
459
  },
414
460
  ],
415
461
  details: {},
@@ -526,7 +572,8 @@ export class GoalService {
526
572
  // The kick-off is a user message so it appears in the flow and triggers a
527
573
  // normal turn; the finishing agent_end then runs the review loop.
528
574
  try {
529
- await mainSession.sendUserMessage(`【目标已设定】\n\n${wgoal.goal}\n\n请现在开始实现这个目标。`, {
575
+ const wizardKick = pick(this.lang(), `【目标已设定】\n\n${wgoal.goal}\n\n请现在开始实现这个目标。`, `[Goal set]\n\n${wgoal.goal}\n\nStart implementing this goal now.`, "goal.wizard.kick", { "wgoal.goal": wgoal.goal });
576
+ await mainSession.sendUserMessage(wizardKick, {
530
577
  deliverAs: mainSession.isStreaming ? "steer" : "followUp",
531
578
  });
532
579
  }
@@ -538,6 +585,8 @@ export class GoalService {
538
585
  * touching the active goal — so changes in the goal bar are remembered across
539
586
  * reloads. maxRounds 0 = unlimited. Emits goal_status so the UI stays synced. */
540
587
  async setGoalPrefs(opts) {
588
+ if (!this.goalEnabled())
589
+ return;
541
590
  const goal = this.host.activeConv().goal;
542
591
  if (opts?.reviewModel !== undefined)
543
592
  goal.reviewModel = opts.reviewModel || null;
@@ -621,7 +670,12 @@ export class GoalService {
621
670
  // Goal review hook: after the run finished normally, if a goal is
622
671
  // active (and it belonged to the ACTIVE conversation) and we're not
623
672
  // already mid-review, spawn the isolated reviewer.
624
- if (g.goal && g.conversationId === conv.id && !g.reviewing && !conv.wizardRunning && !this.host.isDisposed()) {
673
+ if (g.goal &&
674
+ g.conversationId === conv.id &&
675
+ !g.reviewing &&
676
+ !conv.wizardRunning &&
677
+ !this.host.isDisposed() &&
678
+ this.goalEnabled()) {
625
679
  void this.runGoalReview(conv);
626
680
  }
627
681
  return null;
@@ -743,7 +797,7 @@ export class GoalService {
743
797
  return;
744
798
  }
745
799
  let reviewerVerdict = "fail";
746
- let reviewerFeedback = "(审查无法完成)";
800
+ let reviewerFeedback = pick(this.lang(), "(审查无法完成)", "(The review could not be completed)", "goal.review.incomplete");
747
801
  try {
748
802
  const rmSpec = this.resolveReviewModel(g.reviewModel);
749
803
  const services = await createAgentSessionServices({
@@ -800,8 +854,9 @@ export class GoalService {
800
854
  await srv.session.dispose();
801
855
  }
802
856
  catch (err) {
857
+ const reviewErrMsg = err.message;
803
858
  reviewerVerdict = "fail";
804
- reviewerFeedback = `审查过程中出错:${err.message}`;
859
+ reviewerFeedback = pick(this.lang(), `审查过程中出错:${reviewErrMsg}`, `Error during review: ${reviewErrMsg}`, "goal.review.error", { reviewErrMsg: reviewErrMsg });
805
860
  }
806
861
  // The user may have switched chats or replaced/cleared the goal while the
807
862
  // isolated reviewer was running. Never apply a stale verdict or inject it
@@ -833,7 +888,8 @@ export class GoalService {
833
888
  // USER the outcome and hands the main agent back out of "goal mode", so a
834
889
  // follow-up instruction like "发布" is a normal request — not a confirm echo.
835
890
  try {
836
- await mainSession.sendUserMessage(`✅ 目标已达成并通过审查(第 ${round} 轮)。\n\n目标:${goalText}\n\n${feedback}\n\n(目标模式已解除,接下来按你的普通指令响应。)`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
891
+ const passText = pick(this.lang(), `✅ 目标已达成并通过审查(第 ${round} 轮)。\n\n目标:${goalText}\n\n${feedback}\n\n(目标模式已解除,接下来按你的普通指令响应。)`, `✅ Goal achieved and passed review (round ${round}).\n\nGoal: ${goalText}\n\n${feedback}\n\n(Goal mode is off — respond to further instructions normally.)`, "goal.review.pass", { round: round, goalText: goalText, feedback: feedback });
892
+ await mainSession.sendUserMessage(passText, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
837
893
  }
838
894
  catch {
839
895
  // Best-effort.
@@ -856,8 +912,11 @@ export class GoalService {
856
912
  // Inject the reviewer's feedback into the main session to revise (this IS
857
913
  // the fail review result, as an ordinary user message — no separate card).
858
914
  try {
859
- const steerText = `【目标审查:第 ${g.round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮未通过】\n\n目标:${goalText}\n\n` +
860
- `审查意见:${feedback}\n\n请根据以上意见修改你的成果,使其完全满足目标。`;
915
+ const capped = budgetForCard > 0 ? budgetForCard : "不限";
916
+ const cappedEn = budgetForCard > 0 ? budgetForCard : "unlimited";
917
+ const steerText = pick(this.lang(), `【目标审查:第 ${g.round}/${capped} 轮未通过】\n\n目标:${goalText}\n\n` +
918
+ `审查意见:${feedback}\n\n请根据以上意见修改你的成果,使其完全满足目标。`, `[Goal review: round ${g.round}/${cappedEn} failed]\n\nGoal: ${goalText}\n\n` +
919
+ `Feedback: ${feedback}\n\nRevise your work based on the feedback above so it fully satisfies the goal.`, "goal.review.revise", { "g.round": g.round, capped: capped, goalText: goalText, feedback: feedback, cappedEn: cappedEn });
861
920
  await mainSession.sendUserMessage(steerText, {
862
921
  deliverAs: mainSession.isStreaming ? "steer" : "followUp",
863
922
  });
@@ -882,7 +941,9 @@ export class GoalService {
882
941
  g.statusEn = `Goal failed (${roundsEn})`;
883
942
  }
884
943
  try {
885
- await mainSession.sendUserMessage(`❌ 目标未通过审查(第 ${round}/${budgetForCard > 0 ? budgetForCard : "不限"} 轮)。\n\n目标:${goalText}\n\n审查意见:${feedback}`, { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
944
+ const capped = budgetForCard > 0 ? budgetForCard : "不限";
945
+ const cappedEn = budgetForCard > 0 ? budgetForCard : "unlimited";
946
+ await mainSession.sendUserMessage(pick(this.lang(), `❌ 目标未通过审查(第 ${round}/${capped} 轮)。\n\n目标:${goalText}\n\n审查意见:${feedback}`, `❌ Goal failed review (round ${round}/${cappedEn}).\n\nGoal: ${goalText}\n\nFeedback: ${feedback}`, "goal.review.fail", { round: round, capped: capped, goalText: goalText, feedback: feedback, cappedEn: cappedEn }), { deliverAs: mainSession.isStreaming ? "steer" : "followUp" });
886
947
  }
887
948
  catch {
888
949
  // Best-effort.
@@ -0,0 +1,157 @@
1
+ /**
2
+ * i18n — server-side language negotiation (issue #91).
3
+ *
4
+ * Two tracks:
5
+ * - Browser UI strings live in `locales/<code>.json` packs (`strings`).
6
+ * - Server-authored strings the model relays to the user (tool return
7
+ * values, prompt segments, guidance blocks) are keyed through THIS module.
8
+ * zh/en stay inline at the call site (zero-cost, always available); every
9
+ * OTHER language resolves through a per-language table that translators
10
+ * ship inside the SAME pack file (`serverStrings` section — one download
11
+ * covers UI + server). Missing key → English fallback, so partial
12
+ * translations are safe and adding a language is just filling a table.
13
+ *
14
+ * Conventions for contributors:
15
+ * - `pick(lang, zh, en, key?)` — key format `<module>.<slug>`, e.g.
16
+ * `subagents.spawn.started`. Slugs derive from the English source text.
17
+ * Keys must be globally unique (module prefix guarantees it).
18
+ * - `getServerBlock(lang, key, zhLines, enLines)` — multi-line blocks
19
+ * (guidance arrays, prompt sections); tables store one `\n`-joined string.
20
+ * - `bilingual(en, zh)` — tool DEFINITIONS stay static en+zh (baked into the
21
+ * session at creation; the model works fine with English definitions
22
+ * under any UI language). No keys needed.
23
+ * - Template DATA (subagent_templates content, user overrides) stays zh/en
24
+ * fields — it is user-editable config, not code copy.
25
+ *
26
+ * Pure functions + a tiny in-memory registry (no node imports) — unit-tested.
27
+ * Tables are loaded from `<dataDir>/locales/*.json` at startup (see
28
+ * loadServerStringsFromDir + index.ts hooks); tests register synthetic tables.
29
+ */
30
+ /**
31
+ * Normalize a UI locale code to a server language code.
32
+ * "zh-CN"/"zh_TW" → "zh"; "pt-BR" → "pt"; "en-US" → "en"; "" → "en".
33
+ */
34
+ export function resolveServerLang(locale) {
35
+ if (typeof locale !== "string")
36
+ return "en";
37
+ const code = locale.trim().toLowerCase();
38
+ if (!code)
39
+ return "en";
40
+ const m = code.match(/^([a-z]{2,3})(?:[-_].*)?$/);
41
+ return m?.[1] ?? "en";
42
+ }
43
+ /** True when the server language is Chinese. */
44
+ export function isZh(lang) {
45
+ return lang === "zh";
46
+ }
47
+ /* ------------------------------------------------------------------ */
48
+ /* translator tables */
49
+ /* ------------------------------------------------------------------ */
50
+ const serverTables = new Map();
51
+ /** Normalize a table code the same way locales resolve ("PT-br" → "pt"). */
52
+ function tableCode(code) {
53
+ return resolveServerLang(code);
54
+ }
55
+ /** Register (or replace) a translator table, e.g. from a pack's
56
+ * `serverStrings` section. Empty tables are ignored. */
57
+ export function registerServerStrings(code, table) {
58
+ const entries = Object.entries(table ?? {}).filter(([k, v]) => typeof k === "string" && k.length > 0 && typeof v === "string");
59
+ if (entries.length === 0)
60
+ return;
61
+ serverTables.set(tableCode(code), Object.fromEntries(entries));
62
+ }
63
+ /** Drop a translator table (pack removed). */
64
+ export function unregisterServerStrings(code) {
65
+ serverTables.delete(tableCode(code));
66
+ }
67
+ /** Visible for tests / diagnostics. */
68
+ export function registeredServerLangs() {
69
+ return [...serverTables.keys()].sort();
70
+ }
71
+ /** Look up one key for a non-Chinese language (undefined = fall back). */
72
+ export function getServerString(lang, key) {
73
+ if (isZh(lang) || !key)
74
+ return undefined;
75
+ const v = serverTables.get(tableCode(lang))?.[key];
76
+ return typeof v === "string" && v.length > 0 ? v : undefined;
77
+ }
78
+ /**
79
+ * Pick the zh/en variant of a user-visible or model-facing string, with
80
+ * translator-table override for other languages:
81
+ * pick(lang, "当前没有子代理。", "No subagents running.", "subagents.list.empty")
82
+ * zh → inline Chinese; other → table hit or inline English.
83
+ *
84
+ * Interpolated strings pass `vars` (5th arg). Table values use `{expr}`
85
+ * slots where `expr` is the EXACT text inside the call site's `${...}`
86
+ * (translators copy it verbatim; complex sub-expressions should be hoisted
87
+ * to a named const at the call site first):
88
+ * pick(lang, `剩${n}个`, `${n} left`, "k.items.left", { n })
89
+ * → table: `"残り{n}件"`. Slots missing from `vars` stay literal.
90
+ */
91
+ export function pick(lang, zh, en, key, vars) {
92
+ if (isZh(lang))
93
+ return zh;
94
+ if (key) {
95
+ const hit = getServerString(lang, key);
96
+ if (hit !== undefined)
97
+ return formatTable(hit, vars);
98
+ }
99
+ return en;
100
+ }
101
+ /** Fill `{name}` slots from vars (unknown slots stay literal so a stale
102
+ * table never eats text silently; null/undefined render as empty). */
103
+ export function formatTable(template, vars) {
104
+ if (!vars)
105
+ return template;
106
+ let out = template;
107
+ for (const [k, v] of Object.entries(vars)) {
108
+ out = out.split(`{${k}}`).join(v === undefined || v === null ? "" : String(v));
109
+ }
110
+ return out;
111
+ }
112
+ /**
113
+ * Multi-line variant for guidance blocks / prompt sections. Tables store one
114
+ * `\n`-joined string per key; zh/en stay inline arrays at the call site:
115
+ * getServerBlock(lang, "markers.todo.guidance", TODO_GUIDANCE_ZH, TODO_GUIDANCE_EN)
116
+ */
117
+ export function getServerBlock(lang, key, zhLines, enLines) {
118
+ if (isZh(lang))
119
+ return zhLines;
120
+ const hit = getServerString(lang, key);
121
+ if (hit !== undefined)
122
+ return hit.split("\n");
123
+ return enLines;
124
+ }
125
+ /**
126
+ * Join English-first bilingual copy for tool *definitions* (baked into the
127
+ * session at creation, so they cannot be lang-switched without rebuilding
128
+ * the runtime — inline both instead). English leads per the English-default
129
+ * policy; the Chinese half keeps zh-UI behavior identical to before.
130
+ */
131
+ export function bilingual(en, zh) {
132
+ if (!en)
133
+ return zh;
134
+ if (!zh)
135
+ return en;
136
+ if (en === zh)
137
+ return en;
138
+ return `${en}\n${zh}`;
139
+ }
140
+ /** Pull a translator table out of a parsed pack file (null = none usable). */
141
+ export function extractServerStrings(data) {
142
+ if (!data || typeof data !== "object")
143
+ return null;
144
+ const d = data;
145
+ if (typeof d.code !== "string" || !d.code)
146
+ return null;
147
+ if (!d.serverStrings || typeof d.serverStrings !== "object")
148
+ return null;
149
+ const table = {};
150
+ for (const [k, v] of Object.entries(d.serverStrings)) {
151
+ if (typeof k === "string" && k.length > 0 && typeof v === "string" && v.length > 0)
152
+ table[k] = v;
153
+ }
154
+ if (Object.keys(table).length === 0)
155
+ return null;
156
+ return { code: d.code, table };
157
+ }