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
@@ -16,7 +16,7 @@
16
16
  * all share one conversation list per project.
17
17
  * PI_CODING_AGENT_DIR pi config dir (auth/models/skills) — passed to the SDK
18
18
  */
19
- import { existsSync } from "node:fs";
19
+ import { existsSync, readFileSync } from "node:fs";
20
20
  import { stat } from "node:fs/promises";
21
21
  import { createServer } from "node:http";
22
22
  import { createConnection } from "node:net";
@@ -36,6 +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 { isManaged, managedRefusal } from "./managed.js";
40
+ import { parseTabs, tabsRefusal } from "./tabs.js";
41
+ import { installPack, isKnownPack, listPacks, loadServerStrings, readPackFile, removePack, unloadServerStrings, } from "./locales.js";
39
42
  import { PluginManager, resolvePluginClientFile, } from "./plugins.js";
40
43
  import { McpBridge } from "./mcp-bridge.js";
41
44
  /** 从 CLI 参数中取 flag 值:支持 --flag value 与 --flag=value 两种写法。
@@ -76,6 +79,31 @@ const ALLOW_ORIGINS = (process.env.PI_WEB_ALLOW_ORIGINS ?? "")
76
79
  * Authorization: Bearer / X-PI-Token 头、?token= 查询参数或 pi_web_token cookie
77
80
  * 任一匹配即可;供 0.0.0.0 / 反代等暴露场景兜底,未设置则行为不变。 */
78
81
  const AUTH_TOKEN = process.env.PI_WEB_TOKEN?.trim() ?? "";
82
+ /** 语言包下载根(语言包仓库的 raw 文件地址;版本 tag 优先、main 兜底,见 locales.ts)。 */
83
+ const LOCALE_BASE_URL = process.env.PI_WEB_LOCALE_BASE_URL?.trim() || "https://raw.githubusercontent.com/xing-shuyin/pi-web-ui";
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
+ }
104
+ }
105
+ return appVersionCache;
106
+ }
79
107
  // Root of the SDK default per-project session dirs — chat transcripts live in
80
108
  // <SESSION_DIR_ROOT>/--<cwd>--/, shared with the pi CLI/TUI (getAgentDir
81
109
  // honors PI_CODING_AGENT_DIR).
@@ -164,6 +192,10 @@ if (AUTH_TOKEN) {
164
192
  }
165
193
  /** 引擎选择:PI_WEB_ENGINE=pi|dsh(默认 pi)。重启生效。 */
166
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();
167
199
  app.get("/api/health", (_req, res) => {
168
200
  res.json({ ok: true, piVersion: VERSION, cwd: CWD, pid: process.pid, engine: ENGINE });
169
201
  });
@@ -252,6 +284,63 @@ const USER_THEMES_DIR = join(DATA_DIR, "themes");
252
284
  app.get("/api/themes", (_req, res) => {
253
285
  res.json({ themes: listThemes(BUILTIN_THEMES_DIR, USER_THEMES_DIR) });
254
286
  });
287
+ // 语言包:核心只随包发布中英,其余按需下载到 <dataDir>/locales/<code>.json。
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;
298
+ app.get("/api/locales", (_req, res) => {
299
+ res.json({ packs: listPacks(DATA_DIR), defaultLocale: DEFAULT_LOCALE });
300
+ });
301
+ app.get("/api/locales/:code", (req, res) => {
302
+ const code = String(req.params.code ?? "");
303
+ if (!isKnownPack(code)) {
304
+ res.status(404).end("unknown locale");
305
+ return;
306
+ }
307
+ const pack = readPackFile(DATA_DIR, code);
308
+ if (!pack) {
309
+ res.status(404).end("locale not installed");
310
+ return;
311
+ }
312
+ res.setHeader("Cache-Control", "no-cache");
313
+ res.json(pack);
314
+ });
315
+ app.post("/api/locales/:code/install", async (req, res) => {
316
+ const code = String(req.params.code ?? "");
317
+ if (!isKnownPack(code)) {
318
+ res.status(400).json({ error: `unknown locale: ${code}` });
319
+ return;
320
+ }
321
+ try {
322
+ const meta = await installPack(DATA_DIR, code, { baseUrl: LOCALE_BASE_URL, version: appVersion() });
323
+ // 新包可能自带 serverStrings(issue #91 v2)——重扫注册,无表则跳过。
324
+ loadServerStrings(DATA_DIR);
325
+ res.json({ ok: true, ...meta });
326
+ }
327
+ catch (e) {
328
+ res.status(502).json({ error: e instanceof Error ? e.message : String(e) });
329
+ }
330
+ });
331
+ app.delete("/api/locales/:code", (req, res) => {
332
+ const code = String(req.params.code ?? "");
333
+ if (!isKnownPack(code)) {
334
+ res.status(404).end("unknown locale");
335
+ return;
336
+ }
337
+ if (!removePack(DATA_DIR, code)) {
338
+ res.status(404).end("locale not installed");
339
+ return;
340
+ }
341
+ unloadServerStrings(code);
342
+ res.json({ ok: true });
343
+ });
255
344
  // Serve a theme's full CSS file so the frontend can swap the whole stylesheet.
256
345
  // Registered before the SPA catch-all below (otherwise it'd return index.html).
257
346
  app.get("/themes/:id.css", (req, res) => {
@@ -435,6 +524,9 @@ const service = ENGINE === "dsh"
435
524
  : new AgentService(CWD,
436
525
  // Per-client persisted UI state: last-used workspace + recent projects.
437
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);
438
530
  // Optional UI plugins (<dataDir>/plugins/<id>/): scanned on every client
439
531
  // attach so freshly dropped plugins appear without a server restart.
440
532
  const pluginMgr = new PluginManager(DATA_DIR, CWD, join(pkgRoot, "plugins", "catalog.json"));
@@ -591,6 +683,15 @@ wss.on("connection", (ws) => {
591
683
  pending.push(msg);
592
684
  return;
593
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
+ }
594
695
  switch (msg.type) {
595
696
  case "prompt":
596
697
  void cs.prompt(msg.text, msg.attachments, msg.queue);
@@ -702,6 +803,11 @@ wss.on("connection", (ws) => {
702
803
  case "set_cwd":
703
804
  void cs.setCwd(msg.path);
704
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;
705
811
  case "complete_path":
706
812
  void cs.completePath(msg.path);
707
813
  break;
@@ -826,6 +932,8 @@ wss.on("connection", (ws) => {
826
932
  terminalBash: msg.terminalBash,
827
933
  terminalBashIdleMs: msg.terminalBashIdleMs,
828
934
  editSoftEnabled: msg.editSoftEnabled,
935
+ questionnaireEnabled: msg.questionnaireEnabled,
936
+ goalModeEnabled: msg.goalModeEnabled,
829
937
  thinkingWrap: msg.thinkingWrap,
830
938
  toolsWrap: msg.toolsWrap,
831
939
  visionBridgeEnabled: msg.visionBridgeEnabled,
@@ -848,7 +956,7 @@ wss.on("connection", (ws) => {
848
956
  pluginMgr.handleMessage(msg.pluginId, msg.payload, clientId ?? undefined);
849
957
  break;
850
958
  case "plugin_settings": {
851
- const r = pluginMgr.savePluginSettings(msg.pluginId, msg.values ?? {});
959
+ const r = pluginMgr.savePluginSettings(msg.pluginId, msg.values ?? {}, () => cs?.getLang() ?? "en");
852
960
  if (r.error) {
853
961
  cs?.emitNotice("error", `插件设置保存失败:${r.error}`, `Failed to save plugin settings: ${r.error}`);
854
962
  }
@@ -858,10 +966,10 @@ wss.on("connection", (ws) => {
858
966
  break;
859
967
  }
860
968
  case "plugins_reload":
861
- void pluginMgr.reload().then(() => pluginMgr.pushToAll());
969
+ void pluginMgr.reload(() => cs?.getLang() ?? "en").then(() => pluginMgr.pushToAll());
862
970
  break;
863
971
  case "plugin_catalog_add": {
864
- const r = pluginMgr.addCatalogEntry(msg.entry ?? {});
972
+ const r = pluginMgr.addCatalogEntry(msg.entry ?? {}, () => cs?.getLang() ?? "en");
865
973
  if (r.error) {
866
974
  cs?.emitNotice("error", `添加到插件列表失败:${r.error}`, `Failed to add to plugin list: ${r.error}`);
867
975
  }
@@ -871,7 +979,7 @@ wss.on("connection", (ws) => {
871
979
  break;
872
980
  }
873
981
  case "plugin_catalog_remove": {
874
- const r = pluginMgr.removeCatalogEntry(msg.id);
982
+ const r = pluginMgr.removeCatalogEntry(msg.id, () => cs?.getLang() ?? "en");
875
983
  if (r.error) {
876
984
  cs?.emitNotice("error", `从插件列表移除失败:${r.error}`, `Failed to remove from plugin list: ${r.error}`);
877
985
  }
@@ -930,11 +1038,17 @@ wss.on("connection", (ws) => {
930
1038
  serverVersion: VERSION,
931
1039
  protocolVersion: PROTOCOL_VERSION,
932
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,
933
1047
  });
934
1048
  // Plugin catalog: re-scan + activate new dirs on every attach so
935
1049
  // freshly dropped plugins show up without a server restart.
936
1050
  pluginMgr
937
- .ensureLoaded()
1051
+ .ensureLoaded(() => service.get(cid)?.getLang() ?? "en")
938
1052
  .then((plugins) => {
939
1053
  if (closed)
940
1054
  return;
@@ -963,6 +1077,10 @@ wss.on("connection", (ws) => {
963
1077
  // 会重连,重连又失败会陷入循环。至少把状态推下去。
964
1078
  cs.flushSnapshot();
965
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);
966
1084
  // Replay anything that arrived while the session was starting.
967
1085
  const queued = pending;
968
1086
  pending = [];
@@ -0,0 +1,210 @@
1
+ /**
2
+ * locales — downloadable language packs.
3
+ *
4
+ * Core ships zh/en only (see web/src/i18n.tsx). Every other language lives in
5
+ * `locales/<code>.json` in the git repo (NOT in the npm `files` whitelist, so
6
+ * packs never ship with the package) with shape:
7
+ * { code, nativeName, version, strings: Record<string,string> }
8
+ *
9
+ * On demand the server downloads a pack from PI_WEB_LOCALE_BASE_URL
10
+ * (default: GitHub raw — version tag first, `main` as fallback) into
11
+ * <dataDir>/locales/<code>.json and serves it to the browser. Missing keys
12
+ * fall back to English client-side, so version skew between app and pack is
13
+ * tolerable. Manually dropped <dataDir>/locales/*.json files work too
14
+ * (offline installs) — the list is read from disk.
15
+ */
16
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ import { extractServerStrings, registerServerStrings, unregisterServerStrings, } from "./i18n.js";
19
+ /** Packs available for download (alphabetical by code). */
20
+ export const LOCALE_PACKS = [
21
+ { code: "de", nativeName: "Deutsch" },
22
+ { code: "es", nativeName: "Español" },
23
+ { code: "fr", nativeName: "Français" },
24
+ { code: "it", nativeName: "Italiano" },
25
+ { code: "ja", nativeName: "日本語" },
26
+ { code: "ko", nativeName: "한국어" },
27
+ { code: "pt", nativeName: "Português" },
28
+ { code: "ru", nativeName: "Русский" },
29
+ ];
30
+ export function isKnownPack(code) {
31
+ return LOCALE_PACKS.some((p) => p.code === code);
32
+ }
33
+ export function packMeta(code) {
34
+ return LOCALE_PACKS.find((p) => p.code === code) ?? null;
35
+ }
36
+ export function packPath(dataDir, code) {
37
+ return join(dataDir, "locales", `${code}.json`);
38
+ }
39
+ /** Downloaded packs must look like this (extra fields ignored). */
40
+ export function validatePack(data, code) {
41
+ if (!data || typeof data !== "object")
42
+ return { ok: false, error: "not an object" };
43
+ const d = data;
44
+ if (d["code"] !== code)
45
+ return { ok: false, error: `code mismatch (want ${code})` };
46
+ if (typeof d["nativeName"] !== "string" || !d["nativeName"].trim()) {
47
+ return { ok: false, error: "missing nativeName" };
48
+ }
49
+ if (!d["strings"] || typeof d["strings"] !== "object")
50
+ return { ok: false, error: "missing strings" };
51
+ const strings = {};
52
+ for (const [k, v] of Object.entries(d["strings"])) {
53
+ if (typeof v !== "string")
54
+ return { ok: false, error: `non-string value for ${k}` };
55
+ strings[k] = v;
56
+ }
57
+ if (Object.keys(strings).length === 0)
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
+ }
76
+ return {
77
+ ok: true,
78
+ pack: {
79
+ code,
80
+ nativeName: d["nativeName"].trim(),
81
+ version: typeof d["version"] === "string" ? d["version"] : "unknown",
82
+ strings,
83
+ serverStrings,
84
+ },
85
+ };
86
+ }
87
+ /** Read an installed pack (null when missing / corrupt — corrupt files are ignored, not deleted). */
88
+ export function readPackFile(dataDir, code) {
89
+ if (!isKnownPack(code))
90
+ return null;
91
+ const file = packPath(dataDir, code);
92
+ if (!existsSync(file))
93
+ return null;
94
+ try {
95
+ const data = JSON.parse(readFileSync(file, "utf8"));
96
+ const v = validatePack(data, code);
97
+ return v.ok ? v.pack : null;
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ export function listPacks(dataDir) {
104
+ return LOCALE_PACKS.map((meta) => {
105
+ const installed = readPackFile(dataDir, meta.code);
106
+ return { ...meta, installed: installed !== null, version: installed?.version ?? null };
107
+ });
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
+ }
144
+ export function removePack(dataDir, code) {
145
+ if (!isKnownPack(code))
146
+ return false;
147
+ const file = packPath(dataDir, code);
148
+ if (!existsSync(file))
149
+ return false;
150
+ rmSync(file);
151
+ return true;
152
+ }
153
+ const MAX_PACK_BYTES = 4 * 1024 * 1024;
154
+ /**
155
+ * Download a pack and persist it under <dataDir>/locales/<code>.json
156
+ * (atomic write via tmp + rename). Throws on network / validation errors.
157
+ */
158
+ export async function installPack(dataDir, code, opts = {}) {
159
+ const meta = packMeta(code);
160
+ if (!meta)
161
+ throw new Error(`unknown locale: ${code}`);
162
+ const base = (opts.baseUrl ?? "https://raw.githubusercontent.com/xing-shuyin/pi-web-ui").replace(/\/+$/, "");
163
+ const urls = opts.version
164
+ ? [`${base}/v${opts.version}/locales/${code}.json`, `${base}/main/locales/${code}.json`]
165
+ : [`${base}/main/locales/${code}.json`];
166
+ const fetchFn = opts.fetchFn ?? fetch;
167
+ const timeoutMs = opts.timeoutMs ?? 30000;
168
+ let lastError = "";
169
+ for (const url of urls) {
170
+ const ctrl = new AbortController();
171
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
172
+ try {
173
+ const res = await fetchFn(url, { signal: ctrl.signal });
174
+ if (!res.ok) {
175
+ lastError = `HTTP ${res.status} for ${url}`;
176
+ continue;
177
+ }
178
+ const text = await res.text();
179
+ if (text.length > MAX_PACK_BYTES)
180
+ throw new Error(`pack too large (${text.length} bytes)`);
181
+ let data;
182
+ try {
183
+ data = JSON.parse(text);
184
+ }
185
+ catch {
186
+ throw new Error(`invalid JSON from ${url}`);
187
+ }
188
+ const v = validatePack(data, code);
189
+ if (!v.ok)
190
+ throw new Error(`invalid pack ${code}: ${v.error}`);
191
+ mkdirSync(join(dataDir, "locales"), { recursive: true });
192
+ const file = packPath(dataDir, code);
193
+ const tmp = `${file}.${process.pid}.tmp`;
194
+ writeFileSync(tmp, JSON.stringify(v.pack));
195
+ renameSync(tmp, file);
196
+ return { code, nativeName: v.pack.nativeName, version: v.pack.version };
197
+ }
198
+ catch (e) {
199
+ lastError = e instanceof Error ? e.message : String(e);
200
+ // A validation error means the URL answered with wrong content —
201
+ // don't silently retry the fallback for it, surface it directly.
202
+ if (lastError.startsWith("invalid "))
203
+ throw e;
204
+ }
205
+ finally {
206
+ clearTimeout(timer);
207
+ }
208
+ }
209
+ throw new Error(lastError || `download failed for ${code}`);
210
+ }
@@ -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,