pi-web-ui 0.68.2 → 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 (42) hide show
  1. package/dist/server/agent-service.js +157 -50
  2. package/dist/server/attachments.js +7 -2
  3. package/dist/server/client-state.js +27 -0
  4. package/dist/server/dsh/dsh-agent-service.js +139 -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 +76 -18
  12. package/dist/server/locales.js +55 -1
  13. package/dist/server/managed.js +61 -0
  14. package/dist/server/marker-service.js +20 -7
  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 +20 -1
  29. package/dist/server/subagent-templates.js +105 -0
  30. package/dist/server/subagents.js +155 -54
  31. package/dist/server/tabs.js +87 -0
  32. package/dist/server/terminals.js +88 -48
  33. package/dist/server/update-check.js +7 -2
  34. package/dist/server/vision-bridge.js +34 -12
  35. package/package.json +2 -1
  36. package/web/dist/assets/{TerminalPanel-BQ5NTB9Y.js → TerminalPanel-Cj8zsjx-.js} +1 -1
  37. package/web/dist/assets/index-DCOcsPFm.js +334 -0
  38. package/web/dist/assets/{index-C_I-6Zul.css → index-jH2Bb-0X.css} +1 -1
  39. package/web/dist/assets/{markdown-DOsihKaR.js → markdown-Cpo0pNcR.js} +1 -1
  40. package/web/dist/assets/{react-DIP6JKYk.js → react-CtudoG1_.js} +1 -1
  41. package/web/dist/index.html +4 -4
  42. package/web/dist/assets/index-Ck5pa3XK.js +0 -333
@@ -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
+ }
@@ -36,7 +36,9 @@ import { startControlServer } from "./control-socket.js";
36
36
  import { scheduleUploadCleanup } from "./uploads.js";
37
37
  import { ensureWindowsBash, windowsBashDir } from "./ensure-bash.js";
38
38
  import { listThemes, resolveThemeFile } from "./themes.js";
39
- import { installPack, isKnownPack, listPacks, readPackFile, removePack } from "./locales.js";
39
+ import { isManaged, managedRefusal } from "./managed.js";
40
+ import { parseTabs, tabsRefusal } from "./tabs.js";
41
+ import { installPack, isKnownPack, listPacks, loadServerStrings, readPackFile, removePack, unloadServerStrings, } from "./locales.js";
40
42
  import { PluginManager, resolvePluginClientFile, } from "./plugins.js";
41
43
  import { McpBridge } from "./mcp-bridge.js";
42
44
  /** 从 CLI 参数中取 flag 值:支持 --flag value 与 --flag=value 两种写法。
@@ -79,17 +81,29 @@ const ALLOW_ORIGINS = (process.env.PI_WEB_ALLOW_ORIGINS ?? "")
79
81
  const AUTH_TOKEN = process.env.PI_WEB_TOKEN?.trim() ?? "";
80
82
  /** 语言包下载根(语言包仓库的 raw 文件地址;版本 tag 优先、main 兜底,见 locales.ts)。 */
81
83
  const LOCALE_BASE_URL = process.env.PI_WEB_LOCALE_BASE_URL?.trim() || "https://raw.githubusercontent.com/xing-shuyin/pi-web-ui";
82
- /** 本包版本 —— 下载语言包时优先取同版本 tag,保证 key 对齐。 */
83
- const APP_VERSION = (() => {
84
- try {
85
- // 注意:此处不能用下面的 pkgRoot 常量(TDZ)——直接调函数声明(已提升)。
86
- const pkg = JSON.parse(readFileSync(join(resolvePkgRoot(), "package.json"), "utf8"));
87
- return pkg.version ?? "";
88
- }
89
- catch {
90
- return "";
84
+ /**
85
+ * 本包版本 —— 下载语言包时优先取同版本 tag,保证 key 对齐。
86
+ *
87
+ * Read on first use, not here. `resolvePkgRoot()` is hoisted, but it reads
88
+ * `here`, which is a `const` declared further down: calling it at module-init
89
+ * time throws on the temporal dead zone, the catch swallows it, and the
90
+ * version was silently "" — so the language packs never used the version tag
91
+ * and always fell back to `main`. Reading it lazily costs one branch and
92
+ * gives the real number.
93
+ */
94
+ let appVersionCache = null;
95
+ function appVersion() {
96
+ if (appVersionCache === null) {
97
+ try {
98
+ const pkg = JSON.parse(readFileSync(join(resolvePkgRoot(), "package.json"), "utf8"));
99
+ appVersionCache = pkg.version ?? "";
100
+ }
101
+ catch {
102
+ appVersionCache = "";
103
+ }
91
104
  }
92
- })();
105
+ return appVersionCache;
106
+ }
93
107
  // Root of the SDK default per-project session dirs — chat transcripts live in
94
108
  // <SESSION_DIR_ROOT>/--<cwd>--/, shared with the pi CLI/TUI (getAgentDir
95
109
  // honors PI_CODING_AGENT_DIR).
@@ -178,6 +192,10 @@ if (AUTH_TOKEN) {
178
192
  }
179
193
  /** 引擎选择:PI_WEB_ENGINE=pi|dsh(默认 pi)。重启生效。 */
180
194
  const ENGINE = process.env.PI_WEB_ENGINE === "dsh" ? "dsh" : "pi";
195
+ /** PI_WEB_MANAGED=1: this instance is updated by whoever deploys it. */
196
+ const MANAGED = isManaged();
197
+ /** PI_WEB_TABS: the tabs this instance offers. null = all of them, as before. */
198
+ const TABS = parseTabs();
181
199
  app.get("/api/health", (_req, res) => {
182
200
  res.json({ ok: true, piVersion: VERSION, cwd: CWD, pid: process.pid, engine: ENGINE });
183
201
  });
@@ -268,8 +286,17 @@ app.get("/api/themes", (_req, res) => {
268
286
  });
269
287
  // 语言包:核心只随包发布中英,其余按需下载到 <dataDir>/locales/<code>.json。
270
288
  // 手工放进去的同名 JSON 也会被识别(离线安装)。PI_WEB_TOKEN 鉴权自动覆盖。
289
+ /**
290
+ * PI_WEB_LOCALE — the language a first visit falls back to.
291
+ *
292
+ * It is a fallback, not an override: an explicit choice, and then the
293
+ * browser's own languages, come first (web/src/pick-locale.ts). It rides on
294
+ * /api/locales because the client already asks for that at boot, so naming a
295
+ * default costs no extra request.
296
+ */
297
+ const DEFAULT_LOCALE = (process.env.PI_WEB_LOCALE ?? "").trim().toLowerCase() || null;
271
298
  app.get("/api/locales", (_req, res) => {
272
- res.json({ packs: listPacks(DATA_DIR) });
299
+ res.json({ packs: listPacks(DATA_DIR), defaultLocale: DEFAULT_LOCALE });
273
300
  });
274
301
  app.get("/api/locales/:code", (req, res) => {
275
302
  const code = String(req.params.code ?? "");
@@ -292,7 +319,9 @@ app.post("/api/locales/:code/install", async (req, res) => {
292
319
  return;
293
320
  }
294
321
  try {
295
- const meta = await installPack(DATA_DIR, code, { baseUrl: LOCALE_BASE_URL, version: APP_VERSION });
322
+ const meta = await installPack(DATA_DIR, code, { baseUrl: LOCALE_BASE_URL, version: appVersion() });
323
+ // 新包可能自带 serverStrings(issue #91 v2)——重扫注册,无表则跳过。
324
+ loadServerStrings(DATA_DIR);
296
325
  res.json({ ok: true, ...meta });
297
326
  }
298
327
  catch (e) {
@@ -309,6 +338,7 @@ app.delete("/api/locales/:code", (req, res) => {
309
338
  res.status(404).end("locale not installed");
310
339
  return;
311
340
  }
341
+ unloadServerStrings(code);
312
342
  res.json({ ok: true });
313
343
  });
314
344
  // Serve a theme's full CSS file so the frontend can swap the whole stylesheet.
@@ -494,6 +524,9 @@ const service = ENGINE === "dsh"
494
524
  : new AgentService(CWD,
495
525
  // Per-client persisted UI state: last-used workspace + recent projects.
496
526
  join(DATA_DIR, "client-state.json"));
527
+ // Server-string tables (issue #91 v2): packs' `serverStrings` sections feed
528
+ // pick() lookup for non-zh/en UI languages (missing key → English fallback).
529
+ loadServerStrings(DATA_DIR);
497
530
  // Optional UI plugins (<dataDir>/plugins/<id>/): scanned on every client
498
531
  // attach so freshly dropped plugins appear without a server restart.
499
532
  const pluginMgr = new PluginManager(DATA_DIR, CWD, join(pkgRoot, "plugins", "catalog.json"));
@@ -650,6 +683,15 @@ wss.on("connection", (ws) => {
650
683
  pending.push(msg);
651
684
  return;
652
685
  }
686
+ // Managed instances do not install software on themselves, and tabs this
687
+ // instance does not offer stay closed. Both refusals live here, on the
688
+ // server, because hiding them in the client would still leave the message
689
+ // reachable to anything that can open the socket. See managed.ts / tabs.ts.
690
+ const refusal = managedRefusal(msg.type, MANAGED) ?? tabsRefusal(msg.type, TABS);
691
+ if (refusal) {
692
+ send({ type: "notice", level: "error", text: refusal });
693
+ return;
694
+ }
653
695
  switch (msg.type) {
654
696
  case "prompt":
655
697
  void cs.prompt(msg.text, msg.attachments, msg.queue);
@@ -761,6 +803,11 @@ wss.on("connection", (ws) => {
761
803
  case "set_cwd":
762
804
  void cs.setCwd(msg.path);
763
805
  break;
806
+ case "set_locale":
807
+ // UI language report — per-client persist + lang-aware prompt
808
+ // refresh (streaming-safe). Engine-agnostic via DispatchSession.
809
+ void service.setLocale(clientId, msg.locale);
810
+ break;
764
811
  case "complete_path":
765
812
  void cs.completePath(msg.path);
766
813
  break;
@@ -886,6 +933,7 @@ wss.on("connection", (ws) => {
886
933
  terminalBashIdleMs: msg.terminalBashIdleMs,
887
934
  editSoftEnabled: msg.editSoftEnabled,
888
935
  questionnaireEnabled: msg.questionnaireEnabled,
936
+ goalModeEnabled: msg.goalModeEnabled,
889
937
  thinkingWrap: msg.thinkingWrap,
890
938
  toolsWrap: msg.toolsWrap,
891
939
  visionBridgeEnabled: msg.visionBridgeEnabled,
@@ -908,7 +956,7 @@ wss.on("connection", (ws) => {
908
956
  pluginMgr.handleMessage(msg.pluginId, msg.payload, clientId ?? undefined);
909
957
  break;
910
958
  case "plugin_settings": {
911
- const r = pluginMgr.savePluginSettings(msg.pluginId, msg.values ?? {});
959
+ const r = pluginMgr.savePluginSettings(msg.pluginId, msg.values ?? {}, () => cs?.getLang() ?? "en");
912
960
  if (r.error) {
913
961
  cs?.emitNotice("error", `插件设置保存失败:${r.error}`, `Failed to save plugin settings: ${r.error}`);
914
962
  }
@@ -918,10 +966,10 @@ wss.on("connection", (ws) => {
918
966
  break;
919
967
  }
920
968
  case "plugins_reload":
921
- void pluginMgr.reload().then(() => pluginMgr.pushToAll());
969
+ void pluginMgr.reload(() => cs?.getLang() ?? "en").then(() => pluginMgr.pushToAll());
922
970
  break;
923
971
  case "plugin_catalog_add": {
924
- const r = pluginMgr.addCatalogEntry(msg.entry ?? {});
972
+ const r = pluginMgr.addCatalogEntry(msg.entry ?? {}, () => cs?.getLang() ?? "en");
925
973
  if (r.error) {
926
974
  cs?.emitNotice("error", `添加到插件列表失败:${r.error}`, `Failed to add to plugin list: ${r.error}`);
927
975
  }
@@ -931,7 +979,7 @@ wss.on("connection", (ws) => {
931
979
  break;
932
980
  }
933
981
  case "plugin_catalog_remove": {
934
- const r = pluginMgr.removeCatalogEntry(msg.id);
982
+ const r = pluginMgr.removeCatalogEntry(msg.id, () => cs?.getLang() ?? "en");
935
983
  if (r.error) {
936
984
  cs?.emitNotice("error", `从插件列表移除失败:${r.error}`, `Failed to remove from plugin list: ${r.error}`);
937
985
  }
@@ -990,11 +1038,17 @@ wss.on("connection", (ws) => {
990
1038
  serverVersion: VERSION,
991
1039
  protocolVersion: PROTOCOL_VERSION,
992
1040
  engine: ENGINE,
1041
+ // This package's own version. `serverVersion` is the pi SDK's,
1042
+ // and the client used to learn ours from the update check —
1043
+ // which a managed instance never runs.
1044
+ appVersion: appVersion(),
1045
+ managed: MANAGED,
1046
+ tabs: TABS ? [...TABS] : undefined,
993
1047
  });
994
1048
  // Plugin catalog: re-scan + activate new dirs on every attach so
995
1049
  // freshly dropped plugins show up without a server restart.
996
1050
  pluginMgr
997
- .ensureLoaded()
1051
+ .ensureLoaded(() => service.get(cid)?.getLang() ?? "en")
998
1052
  .then((plugins) => {
999
1053
  if (closed)
1000
1054
  return;
@@ -1023,6 +1077,10 @@ wss.on("connection", (ws) => {
1023
1077
  // 会重连,重连又失败会陷入循环。至少把状态推下去。
1024
1078
  cs.flushSnapshot();
1025
1079
  });
1080
+ // hello may carry the UI locale — persist it before replaying
1081
+ // anything queued during startup (issue #91).
1082
+ if (msg.locale)
1083
+ void service.setLocale(cid, msg.locale);
1026
1084
  // Replay anything that arrived while the session was starting.
1027
1085
  const queued = pending;
1028
1086
  pending = [];
@@ -13,8 +13,9 @@
13
13
  * tolerable. Manually dropped <dataDir>/locales/*.json files work too
14
14
  * (offline installs) — the list is read from disk.
15
15
  */
16
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
16
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
17
17
  import { join } from "node:path";
18
+ import { extractServerStrings, registerServerStrings, unregisterServerStrings, } from "./i18n.js";
18
19
  /** Packs available for download (alphabetical by code). */
19
20
  export const LOCALE_PACKS = [
20
21
  { code: "de", nativeName: "Deutsch" },
@@ -55,6 +56,23 @@ export function validatePack(data, code) {
55
56
  }
56
57
  if (Object.keys(strings).length === 0)
57
58
  return { ok: false, error: "empty strings" };
59
+ // Optional translator table for server-authored strings (issue #91 v2).
60
+ // Non-string values are rejected (same strictness as `strings`).
61
+ let serverStrings;
62
+ if (d["serverStrings"] !== undefined) {
63
+ if (!d["serverStrings"] || typeof d["serverStrings"] !== "object" || Array.isArray(d["serverStrings"])) {
64
+ return { ok: false, error: "invalid serverStrings" };
65
+ }
66
+ serverStrings = {};
67
+ for (const [k, v] of Object.entries(d["serverStrings"])) {
68
+ if (typeof v !== "string")
69
+ return { ok: false, error: `non-string server value for ${k}` };
70
+ if (v.length > 0)
71
+ serverStrings[k] = v;
72
+ }
73
+ if (Object.keys(serverStrings).length === 0)
74
+ serverStrings = undefined;
75
+ }
58
76
  return {
59
77
  ok: true,
60
78
  pack: {
@@ -62,6 +80,7 @@ export function validatePack(data, code) {
62
80
  nativeName: d["nativeName"].trim(),
63
81
  version: typeof d["version"] === "string" ? d["version"] : "unknown",
64
82
  strings,
83
+ serverStrings,
65
84
  },
66
85
  };
67
86
  }
@@ -87,6 +106,41 @@ export function listPacks(dataDir) {
87
106
  return { ...meta, installed: installed !== null, version: installed?.version ?? null };
88
107
  });
89
108
  }
109
+ /**
110
+ * Server-string tables (issue #91 v2): scan `<dataDir>/locales/*.json` pack
111
+ * files and register each pack's `serverStrings` section for pick() lookup.
112
+ * Called once at startup and after every pack install/remove. Files that are
113
+ * missing/corrupt/have no table are skipped (best-effort, never throws).
114
+ * Returns the language codes that actually contributed a table.
115
+ */
116
+ export function loadServerStrings(dataDir) {
117
+ const loaded = [];
118
+ let files = [];
119
+ try {
120
+ files = readdirSync(join(dataDir, "locales")).filter((f) => f.endsWith(".json"));
121
+ }
122
+ catch {
123
+ return loaded;
124
+ }
125
+ for (const file of files) {
126
+ try {
127
+ const data = JSON.parse(readFileSync(join(dataDir, "locales", file), "utf8"));
128
+ const extracted = extractServerStrings(data);
129
+ if (extracted) {
130
+ registerServerStrings(extracted.code, extracted.table);
131
+ loaded.push(extracted.code);
132
+ }
133
+ }
134
+ catch {
135
+ // corrupt pack — UI serving already ignores it; skip here too
136
+ }
137
+ }
138
+ return loaded;
139
+ }
140
+ /** Drop one language's server-string table (pack removed). */
141
+ export function unloadServerStrings(code) {
142
+ unregisterServerStrings(code);
143
+ }
90
144
  export function removePack(dataDir, code) {
91
145
  if (!isKnownPack(code))
92
146
  return false;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * managed — instances whose updates come from outside.
3
+ *
4
+ * pi-web-ui can update itself: the top bar shows an updates badge and a panel
5
+ * whose buttons run `npm i -g pi-web-ui@latest` in a visible terminal and
6
+ * restart the server. The plugin market installs plugins from the network the
7
+ * same way. On a developer's laptop both are conveniences.
8
+ *
9
+ * Behind a deploy pipeline they are not. A Docker image, a distribution
10
+ * package, or a release script that also checks the reverse proxy, the service
11
+ * unit and the environment variables a new version needs — for all of those,
12
+ * an in-app self-update leaves the machine in a state nothing describes, until
13
+ * the next real deploy silently undoes it. `PI_WEB_MANAGED=1` says "this
14
+ * instance is managed from outside": the update paths refuse, and the client
15
+ * stops offering them.
16
+ *
17
+ * Two rules kept this small:
18
+ *
19
+ * - **The server refuses, the client only hides.** Hiding a button while the
20
+ * socket still accepts the message is hiding, not disabling: anything that
21
+ * can open the WebSocket can still send it.
22
+ * - **Nothing else changes.** Chatting, plugins already installed, git,
23
+ * terminal, settings are untouched — this switch is about who installs
24
+ * software on the machine, not about what the interface can do.
25
+ */
26
+ /** The client messages that install or replace software on the machine. */
27
+ export const MANAGED_MESSAGES = [
28
+ /** Update check behind the top-bar badge. */
29
+ "check_update",
30
+ /** "Re-check all" in the UPDATE panel (app + components). */
31
+ "check_updates_all",
32
+ /** Installs the pi agent itself. */
33
+ "install_pi_agent",
34
+ /** Plugin market: fetches and installs a plugin from the network. */
35
+ "plugin_catalog_add",
36
+ ];
37
+ /**
38
+ * Whether this instance is managed from outside.
39
+ *
40
+ * Accepts the spellings people actually put in a systemd unit or a
41
+ * docker-compose file; anything else — including an empty variable — means no,
42
+ * so the default stays exactly what it is today.
43
+ */
44
+ export function isManaged(env = process.env) {
45
+ const v = (env.PI_WEB_MANAGED ?? "").trim().toLowerCase();
46
+ return v === "1" || v === "true" || v === "yes" || v === "on";
47
+ }
48
+ /**
49
+ * The reason to send back, or null when the message may proceed.
50
+ *
51
+ * Returning the text rather than a boolean keeps the caller a single line and
52
+ * puts the explanation next to the rule it comes from — the user gets a
53
+ * sentence, not a silently dropped message.
54
+ */
55
+ export function managedRefusal(type, managed) {
56
+ if (!managed)
57
+ return null;
58
+ if (!MANAGED_MESSAGES.includes(type))
59
+ return null;
60
+ return "This instance is managed: updates and plugin installs are handled by whoever deploys it (PI_WEB_MANAGED=1).";
61
+ }
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * marker-service.ts — 标记服务(内置版 pi-marker-tools)。
3
3
  */
4
+ import { pick } from "./i18n.js";
4
5
  import { ensureMarkersRegistered, parseMarkers, getMarker, allMarkers, collectGuidance, listMarkerNames, } from "./markers/index.js";
5
6
  import { loadStateFromBranch, appendSnapshot } from "./markers/store.js";
6
- import { TODO_NAMESPACE, initTodoState, describeTodos } from "./markers/builtins/todo.js";
7
+ import { TODO_NAMESPACE, initTodoState } from "./markers/builtins/todo.js";
7
8
  ensureMarkersRegistered();
8
9
  const DEFAULT_MARKERS = {
9
10
  markersEnabled: true,
@@ -32,18 +33,26 @@ export class MarkerService {
32
33
  return false;
33
34
  return !this.settings.disabledMarkers.includes(name);
34
35
  }
36
+ /** 当前服务端语言(英文默认,未接线前保持原有英文行为)。 */
37
+ lang() {
38
+ return this.host.lang?.() ?? "en";
39
+ }
35
40
  buildGuidance() {
36
41
  if (!this.settings.markersEnabled)
37
42
  return "";
38
43
  const disabled = new Set(this.settings.disabledMarkers);
39
- const lines = collectGuidance(disabled);
44
+ const lines = collectGuidance(disabled, this.lang());
40
45
  if (lines.length === 0)
41
46
  return "";
42
- return ("\n\n────────── 内联标记工具(重要)──────────\n" +
47
+ return pick(this.lang(), "\n\n────────── 内联标记工具(重要)──────────\n" +
43
48
  "状态类操作(任务、标记位、通知)请直接写在回答正文里,采用内联标记语法,而不要调用对应工具。\n" +
44
49
  "插件会替你执行并把标记从展示文本中移除,不会中断你的回答。\n\n" +
45
50
  lines.join("\n") +
46
- "\n──────────\n");
51
+ "\n──────────\n", "\n\n────────── Inline marker tools (important) ──────────\n" +
52
+ "Write status operations (tasks, marker bits, notifications) directly in your reply text using inline marker syntax — do not call a tool for them.\n" +
53
+ "The plugin executes them and strips the markers from the displayed text without interrupting your reply.\n\n" +
54
+ lines.join("\n") +
55
+ "\n──────────\n", "markers.service.guidance.frame");
47
56
  }
48
57
  setEnabled(enabled) {
49
58
  this.settings.markersEnabled = !!enabled;
@@ -164,10 +173,14 @@ export class MarkerService {
164
173
  };
165
174
  let result;
166
175
  try {
167
- result = await marker.apply(token, ctx, state);
176
+ result = await marker.apply(token, ctx, state, this.lang());
168
177
  }
169
178
  catch (e) {
170
- result = { applied: false, error: `执行异常: ${e?.message ?? String(e)}` };
179
+ const errMsg = e?.message ?? String(e);
180
+ result = {
181
+ applied: false,
182
+ error: pick(this.lang(), `执行异常: ${errMsg}`, `Execution failed: ${errMsg}`, "markers.service.execution.failed", { errMsg: errMsg }),
183
+ };
171
184
  }
172
185
  if (result.applied) {
173
186
  // todo 落库;notify/conv 即时生效(通知已发 / 对话已重命名),无需快照。
@@ -234,7 +247,7 @@ export class MarkerService {
234
247
  const st = this.getState(conversationId, TODO_NAMESPACE, initTodoState);
235
248
  const visible = st.tasks.filter((t) => includeDeleted || t.status !== "deleted");
236
249
  if (visible.length === 0)
237
- return "No todos";
250
+ return pick(this.lang(), "[todo] (空)", "[todo] (empty)", "markers.service.describe.empty");
238
251
  return visible.map((t) => `[${t.status}] #${t.id}: ${t.subject}`).join("\n");
239
252
  }
240
253
  getRawState(conversationId, namespace) {
@@ -1,19 +1,32 @@
1
1
  /**
2
2
  * builtins/notify.ts — 纯提醒标记。
3
3
  */
4
+ import { getServerBlock, pick } from "../../i18n.js";
5
+ const NOTIFY_GUIDANCE_ZH = [
6
+ "- [[notify:<级别>:<内容>]] 仅向用户显示一个非打断性提醒,不会进入正文。级别为 info|warning|success|error。",
7
+ ];
8
+ const NOTIFY_GUIDANCE_EN = [
9
+ "- [[notify:<level>:<message>]] shows a non-interruptive notice to the user only; it never enters the reply text. Levels: info|warning|success|error.",
10
+ ];
11
+ /** 语言感知的 notify guidance(issue #91):en 用英译、zh 用中文,默认英文。 */
12
+ export function getNotifyGuidance(lang = "en") {
13
+ return getServerBlock(lang, "markers.notify.guidance", NOTIFY_GUIDANCE_ZH, NOTIFY_GUIDANCE_EN);
14
+ }
4
15
  export const notifyMarker = {
5
16
  name: "notify",
6
- guidance: [
7
- "- [[notify:<级别>:<内容>]] 仅向用户显示一个非打断性提醒,不会进入正文。级别为 info|warning|success|error。",
8
- ],
9
- async apply(token, ctx) {
17
+ guidance: NOTIFY_GUIDANCE_ZH,
18
+ getGuidance: getNotifyGuidance,
19
+ async apply(token, ctx, _state, lang = "en") {
10
20
  const level = token.op || token.kwargs["level"] || "info";
11
21
  const text = token.kwargs["text"] || token.args.join(" ") || "";
12
22
  if (!text)
13
- return { applied: false, error: "notify 需要内容" };
23
+ return {
24
+ applied: false,
25
+ error: pick(lang, "notify 需要内容", "notify requires a message", "markers.notify.requires.message"),
26
+ };
14
27
  const safe = (level === "warning" || level === "error" ? level : "info");
15
28
  ctx.notify(text, safe);
16
- return { applied: true, feedback: "notified" };
29
+ return { applied: true, feedback: pick(lang, "已发送提醒", "notified", "markers.notify.notified") };
17
30
  },
18
31
  overlay: undefined,
19
32
  init: () => undefined,