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.
- package/dist/server/agent-service.js +157 -50
- package/dist/server/attachments.js +7 -2
- package/dist/server/client-state.js +27 -0
- package/dist/server/dsh/dsh-agent-service.js +139 -38
- package/dist/server/dsh/dsh-client.js +9 -8
- package/dist/server/dsh/dsh-sessions.js +4 -3
- 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 +76 -18
- package/dist/server/locales.js +55 -1
- 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 +42 -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 +20 -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 +7 -2
- package/dist/server/vision-bridge.js +34 -12
- package/package.json +2 -1
- package/web/dist/assets/{TerminalPanel-BQ5NTB9Y.js → TerminalPanel-Cj8zsjx-.js} +1 -1
- package/web/dist/assets/index-DCOcsPFm.js +334 -0
- package/web/dist/assets/{index-C_I-6Zul.css → index-jH2Bb-0X.css} +1 -1
- package/web/dist/assets/{markdown-DOsihKaR.js → markdown-Cpo0pNcR.js} +1 -1
- package/web/dist/assets/{react-DIP6JKYk.js → react-CtudoG1_.js} +1 -1
- package/web/dist/index.html +4 -4
- 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
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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:
|
|
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 = [];
|
package/dist/server/locales.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 "
|
|
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
|
-
|
|
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 {
|
|
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,
|