pi-web-ui 0.68.1 → 0.69.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/agent-service.js +306 -102
- package/dist/server/attachments.js +7 -2
- package/dist/server/client-state.js +31 -2
- package/dist/server/dsh/dsh-agent-service.js +154 -39
- package/dist/server/dsh/dsh-client.js +9 -8
- package/dist/server/dsh/dsh-sessions.js +4 -3
- package/dist/server/dsh/runtime/runtime-root.mjs +17 -11
- package/dist/server/edit-soft-tool.js +33 -26
- package/dist/server/files-service.js +12 -7
- package/dist/server/goal-service.js +85 -24
- package/dist/server/i18n.js +157 -0
- package/dist/server/index.js +124 -6
- package/dist/server/locales.js +210 -0
- package/dist/server/managed.js +61 -0
- package/dist/server/marker-service.js +20 -7
- package/dist/server/markers/builtins/notify.js +19 -6
- package/dist/server/markers/builtins/rename.js +41 -8
- package/dist/server/markers/builtins/todo.js +107 -30
- package/dist/server/markers/registry.js +2 -2
- package/dist/server/mcp-bridge.js +3 -1
- package/dist/server/model-admin.js +25 -14
- package/dist/server/plugin-catalog.js +7 -3
- package/dist/server/plugin-updater.js +6 -2
- package/dist/server/plugins.js +40 -17
- package/dist/server/prompt-composer.js +76 -16
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/scm.js +18 -25
- package/dist/server/serialize.js +1 -0
- package/dist/server/settings-service.js +29 -1
- package/dist/server/subagent-templates.js +105 -0
- package/dist/server/subagents.js +155 -54
- package/dist/server/tabs.js +87 -0
- package/dist/server/terminals.js +88 -48
- package/dist/server/update-check.js +56 -52
- package/dist/server/vision-bridge.js +34 -12
- package/package.json +4 -1
- package/web/dist/assets/TerminalPanel-Cj8zsjx-.js +6 -0
- package/web/dist/assets/TerminalPanel-DOrYoP_4.css +32 -0
- package/web/dist/assets/index-DCOcsPFm.js +334 -0
- package/web/dist/assets/index-jH2Bb-0X.css +10 -0
- package/web/dist/assets/markdown-Cpo0pNcR.js +51 -0
- package/web/dist/assets/{react-C9ovnpIm.js → react-CtudoG1_.js} +2 -2
- package/web/dist/assets/xterm-B96xOxS9.js +38 -0
- package/web/dist/index.html +4 -4
- package/web/dist/assets/TerminalPanel-6GBZ9nXN.css +0 -32
- package/web/dist/assets/TerminalPanel-IJF_fssI.js +0 -6
- package/web/dist/assets/index-BmiyyjKp.css +0 -10
- package/web/dist/assets/index-qoTr5KXy.js +0 -332
- package/web/dist/assets/markdown-DRBrS2Nf.js +0 -51
- package/web/dist/assets/xterm-D1D2FVe3.js +0 -38
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
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,
|