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.
- package/dist/server/agent-service.js +306 -102
- package/dist/server/attachments.js +7 -2
- package/dist/server/client-state.js +31 -2
- package/dist/server/dsh/dsh-agent-service.js +154 -39
- package/dist/server/dsh/dsh-client.js +9 -8
- package/dist/server/dsh/dsh-sessions.js +4 -3
- package/dist/server/dsh/runtime/runtime-root.mjs +17 -11
- package/dist/server/edit-soft-tool.js +33 -26
- package/dist/server/files-service.js +12 -7
- package/dist/server/goal-service.js +85 -24
- package/dist/server/i18n.js +157 -0
- package/dist/server/index.js +124 -6
- package/dist/server/locales.js +210 -0
- package/dist/server/managed.js +61 -0
- package/dist/server/marker-service.js +20 -7
- package/dist/server/markers/builtins/notify.js +19 -6
- package/dist/server/markers/builtins/rename.js +41 -8
- package/dist/server/markers/builtins/todo.js +107 -30
- package/dist/server/markers/registry.js +2 -2
- package/dist/server/mcp-bridge.js +3 -1
- package/dist/server/model-admin.js +25 -14
- package/dist/server/plugin-catalog.js +7 -3
- package/dist/server/plugin-updater.js +6 -2
- package/dist/server/plugins.js +40 -17
- package/dist/server/prompt-composer.js +76 -16
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/scm.js +18 -25
- package/dist/server/serialize.js +1 -0
- package/dist/server/settings-service.js +29 -1
- package/dist/server/subagent-templates.js +105 -0
- package/dist/server/subagents.js +155 -54
- package/dist/server/tabs.js +87 -0
- package/dist/server/terminals.js +88 -48
- package/dist/server/update-check.js +56 -52
- package/dist/server/vision-bridge.js +34 -12
- package/package.json +4 -1
- package/web/dist/assets/TerminalPanel-Cj8zsjx-.js +6 -0
- package/web/dist/assets/TerminalPanel-DOrYoP_4.css +32 -0
- package/web/dist/assets/index-DCOcsPFm.js +334 -0
- package/web/dist/assets/index-jH2Bb-0X.css +10 -0
- package/web/dist/assets/markdown-Cpo0pNcR.js +51 -0
- package/web/dist/assets/{react-C9ovnpIm.js → react-CtudoG1_.js} +2 -2
- package/web/dist/assets/xterm-B96xOxS9.js +38 -0
- package/web/dist/index.html +4 -4
- package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +0 -32
- package/web/dist/assets/TerminalPanel-IJF_fssI.js +0 -6
- package/web/dist/assets/index-BmiyyjKp.css +0 -10
- package/web/dist/assets/index-qoTr5KXy.js +0 -332
- package/web/dist/assets/markdown-DRBrS2Nf.js +0 -51
- 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
|
-
|
|
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
|
-
|
|
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: [
|
|
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
|
-
: `提问失败:${
|
|
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
|
-
|
|
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 &&
|
|
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 = `审查过程中出错:${
|
|
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
|
-
|
|
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
|
|
860
|
-
|
|
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
|
-
|
|
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
|
+
}
|