pi-web-ui 0.68.2 → 0.70.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 (48) hide show
  1. package/dist/server/agent-service.js +170 -54
  2. package/dist/server/attachments.js +7 -2
  3. package/dist/server/client-state.js +71 -17
  4. package/dist/server/dsh/dsh-agent-service.js +142 -38
  5. package/dist/server/dsh/dsh-client.js +9 -8
  6. package/dist/server/dsh/dsh-sessions.js +4 -3
  7. package/dist/server/edit-soft-tool.js +33 -26
  8. package/dist/server/files-service.js +12 -7
  9. package/dist/server/goal-service.js +85 -24
  10. package/dist/server/i18n.js +157 -0
  11. package/dist/server/index.js +77 -18
  12. package/dist/server/locales.js +55 -1
  13. package/dist/server/managed.js +61 -0
  14. package/dist/server/marker-service.js +37 -20
  15. package/dist/server/markers/builtins/notify.js +19 -6
  16. package/dist/server/markers/builtins/rename.js +41 -8
  17. package/dist/server/markers/builtins/todo.js +107 -30
  18. package/dist/server/markers/registry.js +2 -2
  19. package/dist/server/mcp-bridge.js +3 -1
  20. package/dist/server/model-admin.js +25 -14
  21. package/dist/server/plugin-catalog.js +7 -3
  22. package/dist/server/plugin-updater.js +6 -2
  23. package/dist/server/plugins.js +40 -17
  24. package/dist/server/prompt-composer.js +42 -16
  25. package/dist/server/protocol-version.js +1 -1
  26. package/dist/server/scm.js +18 -25
  27. package/dist/server/serialize.js +1 -0
  28. package/dist/server/settings-service.js +21 -1
  29. package/dist/server/subagent-templates.js +105 -0
  30. package/dist/server/subagents.js +164 -56
  31. package/dist/server/tabs.js +87 -0
  32. package/dist/server/terminals.js +99 -48
  33. package/dist/server/update-check.js +7 -2
  34. package/dist/server/vision-bridge.js +34 -12
  35. package/dist/server/webui-context.js +9 -0
  36. package/package.json +2 -1
  37. package/themes/cyberpunk.css +88 -81
  38. package/themes/dazzle.css +88 -81
  39. package/themes/md-preview.css +105 -98
  40. package/themes/white.css +167 -160
  41. package/web/dist/assets/{TerminalPanel-BQ5NTB9Y.js → TerminalPanel-UxgczH4c.js} +1 -1
  42. package/web/dist/assets/index-BH5QutUc.css +10 -0
  43. package/web/dist/assets/index-CaiIOwy7.js +335 -0
  44. package/web/dist/assets/{markdown-DOsihKaR.js → markdown-Cpo0pNcR.js} +1 -1
  45. package/web/dist/assets/{react-DIP6JKYk.js → react-CtudoG1_.js} +1 -1
  46. package/web/dist/index.html +4 -4
  47. package/web/dist/assets/index-C_I-6Zul.css +0 -10
  48. package/web/dist/assets/index-Ck5pa3XK.js +0 -333
@@ -9,6 +9,7 @@
9
9
  * 持久化:通过宿主回调直接改对话标题(内存 + 磁盘 transcript session_info),
10
10
  * 不需要额外状态。
11
11
  */
12
+ import { getServerBlock, pick } from "../../i18n.js";
12
13
  export const RENAME_NAMESPACE = "conv";
13
14
  function extractTitle(token) {
14
15
  // args[0] 是主标题;kwargs 兼容 text/name/title
@@ -18,27 +19,59 @@ function extractTitle(token) {
18
19
  return `${fromArgs} ${fromKw}`.trim();
19
20
  return fromArgs || fromKw;
20
21
  }
22
+ const RENAME_GUIDANCE_ZH = ["- 重命名当前对话:[[conv:rename:<新标题>]](在了解了用户需求后尽早重命名对话)"];
23
+ const RENAME_GUIDANCE_EN = [
24
+ "- Rename the current conversation: [[conv:rename:<new title>]] (do it early, once you understand what the user needs)",
25
+ ];
26
+ /** 语言感知的 conv guidance(issue #91):en 用英译、zh 用中文,默认英文。 */
27
+ export function getRenameGuidance(lang = "en") {
28
+ return getServerBlock(lang, "markers.rename.guidance", RENAME_GUIDANCE_ZH, RENAME_GUIDANCE_EN);
29
+ }
21
30
  export const renameMarker = {
22
31
  name: "conv",
23
- guidance: ["- 重命名当前对话:[[conv:rename:<新标题>]](在了解了用户需求后尽早重命名对话)"],
24
- async apply(token, ctx) {
32
+ guidance: RENAME_GUIDANCE_ZH,
33
+ getGuidance: getRenameGuidance,
34
+ async apply(token, ctx, _state, lang = "en") {
25
35
  if (token.op !== "rename") {
26
- return { applied: false, error: `conv 未知操作: ${token.op}(当前仅支持 conv:rename)` };
36
+ return {
37
+ applied: false,
38
+ error: pick(lang, `conv 未知操作: ${token.op}(当前仅支持 conv:rename)`, `conv unknown operation: ${token.op} (only conv:rename is supported)`, "markers.rename.unknown.operation", { "token.op": token.op }),
39
+ };
27
40
  }
28
41
  const title = extractTitle(token);
29
42
  if (!title)
30
- return { applied: false, error: "conv:rename 需要一个标题参数 [[conv:rename:<新标题>]]" };
43
+ return {
44
+ applied: false,
45
+ error: pick(lang, "conv:rename 需要一个标题参数 [[conv:rename:<新标题>]]", "conv:rename requires a title argument [[conv:rename:<new title>]]", "markers.rename.requires.title"),
46
+ };
31
47
  if (title.length > 80)
32
- return { applied: false, error: "标题过长(最多 80 字)" };
48
+ return {
49
+ applied: false,
50
+ error: pick(lang, "标题过长(最多 80 字)", "Title too long (max 80 characters)", "markers.rename.title.too.long"),
51
+ };
33
52
  if (!ctx.renameConversation)
34
- return { applied: false, error: "当前环境不支持重命名" };
53
+ return {
54
+ applied: false,
55
+ error: pick(lang, "当前环境不支持重命名", "Renaming is not supported in this environment", "markers.rename.not.supported"),
56
+ };
35
57
  try {
36
58
  ctx.renameConversation(title);
37
59
  ctx.notify(`已重命名为:${title}`, "info", `Renamed to: ${title}`);
38
- return { applied: true, feedback: `renamed to "${title}"` };
60
+ return {
61
+ applied: true,
62
+ feedback: pick(lang, `已重命名为“${title}”`, `renamed to "${title}"`, "markers.rename.renamed.to", {
63
+ title: title,
64
+ }),
65
+ };
39
66
  }
40
67
  catch (e) {
41
- return { applied: false, error: `重命名失败: ${e.message ?? String(e)}` };
68
+ const errMsg = e.message ?? String(e);
69
+ return {
70
+ applied: false,
71
+ error: pick(lang, `重命名失败: ${errMsg}`, `Rename failed: ${errMsg}`, "markers.rename.rename.failed", {
72
+ errMsg: errMsg,
73
+ }),
74
+ };
42
75
  }
43
76
  },
44
77
  overlay: undefined,
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * builtins/todo.ts — 内置任务标记(复刻 pi-marker-tools)。
3
3
  */
4
+ import { getServerBlock, pick } from "../../i18n.js";
4
5
  export const TODO_NAMESPACE = "todo";
5
6
  export function initTodoState() {
6
7
  return { tasks: [], nextId: 1 };
@@ -26,10 +27,10 @@ function formatStatus(s) {
26
27
  return s;
27
28
  }
28
29
  }
29
- export function describeTodos(state, includeDeleted = false) {
30
+ export function describeTodos(state, includeDeleted = false, lang = "en") {
30
31
  const visible = state.tasks.filter((t) => includeDeleted || t.status !== "deleted");
31
32
  if (visible.length === 0)
32
- return "[todo] ()";
33
+ return pick(lang, "[todo] (空)", "[todo] (empty)", "markers.todo.list.empty");
33
34
  return visible
34
35
  .map((t) => {
35
36
  const form = t.status === "in_progress" && t.activeForm ? ` (${t.activeForm})` : "";
@@ -38,82 +39,158 @@ export function describeTodos(state, includeDeleted = false) {
38
39
  })
39
40
  .join("\n");
40
41
  }
42
+ const TODO_GUIDANCE_ZH = [
43
+ "# 内联标记工具(状态类操作请写在回答正文,不要调用工具)",
44
+ "- 标记语法:[[todo:new:<主题>]] 新建;[[todo:set:<id>,completed|in_progress|pending]] 状态;[[todo:remove:<id>]] 删除;[[todo:dep:<id>,blocks=<依赖id,逗号分隔>]] 设依赖。",
45
+ "- 状态变化全部用上面的 [[todo:...]] 内联标记表达,不会中断回答,无需等待返回。",
46
+ "- 想查看/list 当前任务列表时,才用 `markers_list` 工具(读操作走工具)。",
47
+ "- 不要编造不存在的任务 id;id 由 [[todo:new:...]] 分配,首次分配是自增整数。",
48
+ ];
49
+ const TODO_GUIDANCE_EN = [
50
+ "# Inline marker tools (express state changes inline in your reply text — never call a tool for them)",
51
+ "- Marker syntax: [[todo:new:<subject>]] to create; [[todo:set:<id>,completed|in_progress|pending]] for status; [[todo:remove:<id>]] to delete; [[todo:dep:<id>,blocks=<dep ids, comma-separated>]] to set dependencies.",
52
+ "- Express all status changes with the [[todo:...]] inline markers above; they never interrupt your reply and need no waiting for a result.",
53
+ "- Only use the `markers_list` tool (the read path goes through the tool) when you want to list the current tasks.",
54
+ "- Never invent task ids; ids are assigned by [[todo:new:...]], starting from incrementing integers.",
55
+ ];
56
+ /** 语言感知的 todo guidance(issue #91):en 用英译、zh 用中文,默认英文。 */
57
+ export function getTodoGuidance(lang = "en") {
58
+ return getServerBlock(lang, "markers.todo.guidance", TODO_GUIDANCE_ZH, TODO_GUIDANCE_EN);
59
+ }
41
60
  export const todoMarker = {
42
61
  name: "todo",
43
- guidance: [
44
- "# 内联标记工具(状态类操作请写在回答正文,不要调用工具)",
45
- "- 标记语法:[[todo:new:<主题>]] 新建;[[todo:set:<id>,completed|in_progress|pending]] 状态;[[todo:remove:<id>]] 删除;[[todo:dep:<id>,blocks=<依赖id,逗号分隔>]] 设依赖。",
46
- "- 状态变化全部用上面的 [[todo:...]] 内联标记表达,不会中断回答,无需等待返回。",
47
- "- 想查看/list 当前任务列表时,才用 `markers_list` 工具(读操作走工具)。",
48
- "- 不要编造不存在的任务 id;id 由 [[todo:new:...]] 分配,首次分配是自增整数。",
49
- ],
50
- async apply(token, _ctx, _state) {
62
+ guidance: TODO_GUIDANCE_ZH,
63
+ getGuidance: getTodoGuidance,
64
+ async apply(token, _ctx, _state, lang = "en") {
51
65
  const state = _state;
52
66
  const op = token.op;
53
67
  switch (op) {
54
68
  case "new": {
55
69
  const subject = token.args[0]?.trim();
56
70
  if (!subject)
57
- return { applied: false, error: "todo:new 需要一个主题参数 [[todo:new:<主题>]]" };
71
+ return {
72
+ applied: false,
73
+ error: pick(lang, "todo:new 需要一个主题参数 [[todo:new:<主题>]]", "todo:new requires a subject argument [[todo:new:<subject>]]", "markers.todo.new.requires.subject"),
74
+ };
58
75
  const id = state.nextId++;
59
76
  state.tasks.push({ id, subject, status: "pending", blockedBy: [], createdAt: Date.now() });
60
- return { applied: true, feedback: `Created #${id}: ${subject} (pending)` };
77
+ return {
78
+ applied: true,
79
+ feedback: pick(lang, `已创建 #${id}:${subject}(pending)`, `Created #${id}: ${subject} (pending)`, "markers.todo.new.created", { id: id, subject: subject }),
80
+ };
61
81
  }
62
82
  case "set": {
63
83
  const id = parseId(token.args[0]);
64
- if (id === null)
65
- return { applied: false, error: `todo:set 的 id 无效: "${token.args[0] ?? ""}"` };
84
+ if (id === null) {
85
+ const rawId = token.args[0] ?? "";
86
+ return {
87
+ applied: false,
88
+ error: pick(lang, `todo:set 的 id 无效: "${rawId}"`, `todo:set has an invalid id: "${rawId}"`, "markers.todo.set.invalid.id", { rawId: rawId }),
89
+ };
90
+ }
66
91
  const status = token.args[1]?.trim();
67
92
  if (!status || !(status === "pending" || status === "in_progress" || status === "completed")) {
68
- return { applied: false, error: `todo:set 状态无效: "${status ?? ""}",应为 pending|in_progress|completed` };
93
+ const statusText = status ?? "";
94
+ return {
95
+ applied: false,
96
+ error: pick(lang, `todo:set 状态无效: "${statusText}",应为 pending|in_progress|completed`, `todo:set has an invalid status: "${statusText}", expected pending|in_progress|completed`, "markers.todo.set.invalid.status", { statusText: statusText }),
97
+ };
69
98
  }
70
99
  const task = findTask(state, id);
71
100
  if (!task)
72
- return { applied: false, error: `todo:set 任务 #${id} 不存在` };
101
+ return {
102
+ applied: false,
103
+ error: pick(lang, `todo:set 任务 #${id} 不存在`, `todo:set task #${id} does not exist`, "markers.todo.set.task.missing", { id: id }),
104
+ };
73
105
  const activeForm = token.kwargs["activeForm"];
74
106
  const from = task.status;
75
107
  if (status === "pending" && from === "completed") {
76
- return { applied: false, error: `任务 #${id} 已完成,不能置回 pending` };
108
+ return {
109
+ applied: false,
110
+ error: pick(lang, `任务 #${id} 已完成,不能置回 pending`, `Task #${id} is completed and cannot be set back to pending`, "markers.todo.set.completed.no.pending", { id: id }),
111
+ };
77
112
  }
78
113
  if (status === "in_progress" && from === "completed") {
79
- return { applied: false, error: `任务 #${id} 已完成,不能重新进行中` };
114
+ return {
115
+ applied: false,
116
+ error: pick(lang, `任务 #${id} 已完成,不能重新进行中`, `Task #${id} is completed and cannot be set back to in_progress`, "markers.todo.set.completed.no.inprogress", { id: id }),
117
+ };
80
118
  }
81
119
  task.status = status;
82
120
  if (status === "in_progress" && activeForm)
83
121
  task.activeForm = activeForm;
84
122
  const change = from !== status ? ` (${from} → ${status})` : "";
85
- return { applied: true, feedback: `Updated #${id}${change}` };
123
+ return {
124
+ applied: true,
125
+ feedback: pick(lang, `已更新 #${id}${change}`, `Updated #${id}${change}`, "markers.todo.set.updated", {
126
+ id: id,
127
+ change: change,
128
+ }),
129
+ };
86
130
  }
87
131
  case "remove": {
88
132
  const id = parseId(token.args[0]);
89
- if (id === null)
90
- return { applied: false, error: `todo:remove 的 id 无效: "${token.args[0] ?? ""}"` };
133
+ if (id === null) {
134
+ const rawId = token.args[0] ?? "";
135
+ return {
136
+ applied: false,
137
+ error: pick(lang, `todo:remove 的 id 无效: "${rawId}"`, `todo:remove has an invalid id: "${rawId}"`, "markers.todo.remove.invalid.id", { rawId: rawId }),
138
+ };
139
+ }
91
140
  const task = findTask(state, id);
92
141
  if (!task)
93
- return { applied: false, error: `todo:remove 任务 #${id} 不存在` };
142
+ return {
143
+ applied: false,
144
+ error: pick(lang, `todo:remove 任务 #${id} 不存在`, `todo:remove task #${id} does not exist`, "markers.todo.remove.task.missing", { id: id }),
145
+ };
94
146
  task.status = "deleted";
95
- return { applied: true, feedback: `Deleted #${id}: ${task.subject}` };
147
+ return {
148
+ applied: true,
149
+ feedback: pick(lang, `已删除 #${id}:${task.subject}`, `Deleted #${id}: ${task.subject}`, "markers.todo.remove.deleted", { id: id, "task.subject": task.subject }),
150
+ };
96
151
  }
97
152
  case "dep": {
98
153
  const id = parseId(token.args[0]);
99
- if (id === null)
100
- return { applied: false, error: `todo:dep 的 id 无效: "${token.args[0] ?? ""}"` };
154
+ if (id === null) {
155
+ const rawId = token.args[0] ?? "";
156
+ return {
157
+ applied: false,
158
+ error: pick(lang, `todo:dep 的 id 无效: "${rawId}"`, `todo:dep has an invalid id: "${rawId}"`, "markers.todo.dep.invalid.id", { rawId: rawId }),
159
+ };
160
+ }
101
161
  const task = findTask(state, id);
102
162
  if (!task)
103
- return { applied: false, error: `todo:dep 任务 #${id} 不存在` };
163
+ return {
164
+ applied: false,
165
+ error: pick(lang, `todo:dep 任务 #${id} 不存在`, `todo:dep task #${id} does not exist`, "markers.todo.dep.task.missing", { id: id }),
166
+ };
104
167
  const depRaw = token.kwargs["blocks"] ?? token.args[1] ?? "";
105
168
  const deps = depRaw
106
169
  .split(",")
107
170
  .map((x) => parseId(x.trim()))
108
171
  .filter((x) => x !== null);
109
172
  const bad = deps.filter((d) => d === id || !findTask(state, d));
110
- if (bad.length)
111
- return { applied: false, error: `todo:dep 检测到非法依赖 ${bad.join(",")}(不存在或自环)` };
173
+ if (bad.length) {
174
+ const badList = bad.join(",");
175
+ return {
176
+ applied: false,
177
+ error: pick(lang, `todo:dep 检测到非法依赖 ${badList}(不存在或自环)`, `todo:dep detected invalid dependencies ${badList} (missing or self-referencing)`, "markers.todo.dep.invalid.dependencies", { badList: badList }),
178
+ };
179
+ }
112
180
  task.blockedBy = deps;
113
- return { applied: true, feedback: `#${id} blocks: ${deps.length ? deps.join(",") : "(none)"}` };
181
+ const depsText = deps.length ? deps.join(",") : lang === "zh" ? "(无)" : "(none)";
182
+ return {
183
+ applied: true,
184
+ feedback: pick(lang, `#${id} 依赖:${depsText}`, `#${id} blocks: ${depsText}`, "markers.todo.dep.blocks.updated", { id: id, depsText: depsText }),
185
+ };
114
186
  }
115
187
  default:
116
- return { applied: false, error: `todo 未知操作: ${op}` };
188
+ return {
189
+ applied: false,
190
+ error: pick(lang, `todo 未知操作: ${op}`, `todo unknown operation: ${op}`, "markers.todo.unknown.operation", {
191
+ op: op,
192
+ }),
193
+ };
117
194
  }
118
195
  },
119
196
  overlay(state) {
@@ -14,12 +14,12 @@ export function allMarkers() {
14
14
  export function lookupToken(name) {
15
15
  return registry.get(name);
16
16
  }
17
- export function collectGuidance(disabled = new Set()) {
17
+ export function collectGuidance(disabled = new Set(), lang = "en") {
18
18
  const out = [];
19
19
  for (const m of allMarkers()) {
20
20
  if (disabled.has(m.name))
21
21
  continue;
22
- out.push(...m.guidance);
22
+ out.push(...(m.getGuidance?.(lang) ?? m.guidance));
23
23
  }
24
24
  return out;
25
25
  }
@@ -16,6 +16,7 @@
16
16
  import { spawn } from "node:child_process";
17
17
  import { readFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
+ import { bilingual } from "./i18n.js";
19
20
  const PROTOCOL_VERSION = "2025-03-26"; // 广泛支持的工具版本
20
21
  let rpcSeq = 0;
21
22
  /**
@@ -210,7 +211,8 @@ function adaptMcpTool(serverName, mcpTool, client) {
210
211
  return {
211
212
  name,
212
213
  label: `${serverName} · ${mcpTool.name}`,
213
- description: mcpTool.description ?? `从 MCP 服务器「${serverName}」提供的工具 ${mcpTool.name}`,
214
+ description: mcpTool.description ??
215
+ bilingual(`Tool ${mcpTool.name} provided by MCP server "${serverName}"`, `从 MCP 服务器「${serverName}」提供的工具 ${mcpTool.name}`),
214
216
  parameters: mcpTool.inputSchema ?? {},
215
217
  execute: async (_toolCallId, params, _signal) => {
216
218
  return client.call(mcpTool.name, params ?? {});
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
15
  import { join } from "node:path";
16
+ import { pick } from "./i18n.js";
16
17
  /** Strip // and /* *\/ comments without touching string literals (URLs contain //). */
17
18
  function stripJsonComments(src) {
18
19
  let out = "";
@@ -853,10 +854,12 @@ export class ModelAdminService {
853
854
  * because the baseUrl is often a LAN/loopback host the browser can't reach
854
855
  * cross-origin) and return the advertised models. reqId is echoed back
855
856
  * in fetch_models_result so the UI can match concurrent requests. */
856
- async fetchModelsList(reqId, baseUrl, apiKey, authHeader, api) {
857
+ async fetchModelsList(reqId, baseUrl, apiKey, authHeader, api,
858
+ /** 探测抛错文案语言(默认英文);调用方可传 () => getLang() 实现跟随。 */
859
+ lang) {
857
860
  const emitError = (error) => this.host.emit({ type: "fetch_models_result", reqId, ok: false, error });
858
861
  try {
859
- const models = await ModelAdminService.probeModelsEndpoint(baseUrl, apiKey, authHeader, api);
862
+ const models = await ModelAdminService.probeModelsEndpoint(baseUrl, apiKey, authHeader, api, undefined, lang);
860
863
  this.host.emit({ type: "fetch_models_result", reqId, ok: true, models });
861
864
  }
862
865
  catch (err) {
@@ -869,19 +872,22 @@ export class ModelAdminService {
869
872
  * a user-facing message on any failure; returns deduped+sorted entries.
870
873
  * Shared by the edit-form "auto fetch" and the saved-provider refresh.
871
874
  */
872
- static async probeModelsEndpoint(baseUrl, apiKey, authHeader, api, extraHeaders) {
875
+ static async probeModelsEndpoint(baseUrl, apiKey, authHeader, api, extraHeaders,
876
+ /** 抛错文案语言(默认英文);调用方可传 () => getLang() 实现跟随。 */
877
+ lang) {
878
+ const l = lang?.() ?? "en";
873
879
  const base = (baseUrl ?? "").trim().replace(/\/+$/, "");
874
880
  if (!base)
875
- throw new Error("请先填写 baseUrl");
881
+ throw new Error(pick(l, "请先填写 baseUrl", "Enter the baseUrl first", "models.fetch.baseurl.missing"));
876
882
  let url;
877
883
  try {
878
884
  url = new URL(base);
879
885
  }
880
886
  catch {
881
- throw new Error(`baseUrl 无效:${base}`);
887
+ throw new Error(pick(l, `baseUrl 无效:${base}`, `Invalid baseUrl: ${base}`, "models.fetch.baseurl.invalid", { base }));
882
888
  }
883
889
  if (url.protocol !== "http:" && url.protocol !== "https:") {
884
- throw new Error("baseUrl 仅支持 http/https");
890
+ throw new Error(pick(l, "baseUrl 仅支持 http/https", "baseUrl supports http/https only", "models.fetch.baseurl.protocol"));
885
891
  }
886
892
  const headers = {
887
893
  ...extraHeaders,
@@ -912,9 +918,12 @@ export class ModelAdminService {
912
918
  }
913
919
  catch (err) {
914
920
  if (err.name === "AbortError") {
915
- throw new Error("请求超时(15 秒)");
921
+ throw new Error(pick(l, "请求超时(15 秒)", "Request timed out (15s)", "models.fetch.timeout"));
916
922
  }
917
- throw new Error(`请求失败:${err.message}`);
923
+ const errMessage = err.message;
924
+ throw new Error(pick(l, `请求失败:${errMessage}`, `Request failed: ${errMessage}`, "models.fetch.request.error", {
925
+ errMessage,
926
+ }));
918
927
  }
919
928
  finally {
920
929
  clearTimeout(timer);
@@ -927,7 +936,7 @@ export class ModelAdminService {
927
936
  res = await tryFetch(`${base}/v1/models`);
928
937
  }
929
938
  if (!res)
930
- throw new Error("请求失败");
939
+ throw new Error(pick(l, "请求失败", "Request failed", "models.fetch.request.failed"));
931
940
  if (!res.ok) {
932
941
  let detail = "";
933
942
  try {
@@ -936,7 +945,9 @@ export class ModelAdminService {
936
945
  catch {
937
946
  // response body already consumed / not text — ignore
938
947
  }
939
- throw new Error(`接口返回 HTTP ${res.status}${detail ? `:${detail}` : ""}`);
948
+ const detailSuffixZh = detail ? `:${detail}` : "";
949
+ const detailSuffixEn = detail ? `: ${detail}` : "";
950
+ throw new Error(pick(l, `接口返回 HTTP ${res.status}${detailSuffixZh}`, `Upstream returned HTTP ${res.status}${detailSuffixEn}`, "models.fetch.upstream.http", { "res.status": res.status, detailSuffixZh, detailSuffixEn }));
940
951
  }
941
952
  let models = [];
942
953
  try {
@@ -952,7 +963,7 @@ export class ModelAdminService {
952
963
  }
953
964
  }
954
965
  catch {
955
- throw new Error("响应不是有效的 JSON");
966
+ throw new Error(pick(l, "响应不是有效的 JSON", "Response is not valid JSON", "models.fetch.invalid.json"));
956
967
  }
957
968
  // Dedupe by id (keep the first, most complete entry) and sort by id.
958
969
  const seen = new Set();
@@ -960,7 +971,7 @@ export class ModelAdminService {
960
971
  .filter((m) => (seen.has(m.id) ? false : (seen.add(m.id), true)))
961
972
  .sort((a, b) => a.id.localeCompare(b.id));
962
973
  if (models.length === 0)
963
- throw new Error("接口未返回任何模型");
974
+ throw new Error(pick(l, "接口未返回任何模型", "The endpoint returned no models", "models.fetch.no.models"));
964
975
  return models;
965
976
  }
966
977
  /**
@@ -970,7 +981,7 @@ export class ModelAdminService {
970
981
  * existing ids keep all manually-entered fields and only gain metadata
971
982
  * they were missing; brand-new ids are appended. Hot-reloads the runtime.
972
983
  */
973
- async refreshProviderModels(providerId, reqId) {
984
+ async refreshProviderModels(providerId, reqId, lang) {
974
985
  const done = (ok, extra = {}) => this.host.emit({ type: "refresh_provider_result", reqId, ok, ...extra });
975
986
  try {
976
987
  const pid = providerId.trim();
@@ -986,7 +997,7 @@ export class ModelAdminService {
986
997
  });
987
998
  return done(false, { error: "provider missing or no baseUrl" });
988
999
  }
989
- const fetched = await ModelAdminService.probeModelsEndpoint(saved.baseUrl, saved.apiKey, saved.authHeader === true ? true : undefined, saved.api, saved.headers);
1000
+ const fetched = await ModelAdminService.probeModelsEndpoint(saved.baseUrl, saved.apiKey, saved.authHeader === true ? true : undefined, saved.api, saved.headers, lang);
990
1001
  // Merge: manual values win; fetched fills blanks and appends new ids.
991
1002
  const prev = new Map((saved.models ?? []).map((m) => [m.id, m]));
992
1003
  let added = 0;
@@ -16,6 +16,7 @@
16
16
  */
17
17
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
18
18
  import { dirname } from "node:path";
19
+ import { pick } from "./i18n.js";
19
20
  /** 合法插件 id(与 server/plugins.ts 的 ID_RE 一致,防路径穿越)。 */
20
21
  const ID_RE = /^[A-Za-z0-9_-]+$/;
21
22
  /** source 校验:owner/repo 或 owner/repo/子路径[#ref]。宽松校验,实际解析
@@ -121,14 +122,17 @@ export function readCatalog(builtinPath, customPath) {
121
122
  }
122
123
  /** 把用户填的条目追加进 custom 文件(同 id 覆盖旧条目);返回规范化后的条目。
123
124
  * 非法来源/条目抛 Error。 */
124
- export function addCustomEntry(customPath, input) {
125
+ export function addCustomEntry(customPath, input,
126
+ /** 面向用户的抛错文案语言(默认英文);调用方可传 () => getLang() 实现跟随。 */
127
+ lang) {
128
+ const l = lang?.() ?? "en";
125
129
  const source = String(input?.source ?? "").trim();
126
130
  if (!isValidSource(source)) {
127
- throw new Error("来源需为 owner/repo 或 owner/repo/子目录(不支持本地路径)");
131
+ throw new Error(pick(l, "来源需为 owner/repo 或 owner/repo/子目录(不支持本地路径)", "Source must be owner/repo or owner/repo/subdir (local paths are not supported)", "plugincatalog.source.invalid"));
128
132
  }
129
133
  const id = deriveCatalogId(typeof input?.id === "string" ? input.id.trim() : undefined, source);
130
134
  if (!ID_RE.test(id))
131
- throw new Error(`非法 id "${id}"(仅限字母数字-_)`);
135
+ throw new Error(pick(l, `非法 id "${id}"(仅限字母数字-_)`, `Invalid id "${id}" (letters/digits/-/_ only)`, "plugincatalog.id.invalid", { id }));
132
136
  const raw = readJsonSafe(customPath, {});
133
137
  const entries = Array.isArray(raw.entries) ? raw.entries : [];
134
138
  const next = entries.filter((x) => !(x && typeof x === "object" && x.id === id));
@@ -10,6 +10,7 @@
10
10
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, cpSync, writeFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { execFile } from "node:child_process";
13
+ import { pick } from "./i18n.js";
13
14
  const PLUGIN_ID_RE = /^[A-Za-z0-9_-]+$/;
14
15
  /** 保留的备份份数(超出删除最旧的)。 */
15
16
  export const BACKUP_KEEP = 3;
@@ -150,7 +151,10 @@ export async function resolveRemoteSha(spec, exec = execGit) {
150
151
  return sha ? sha.slice(0, 12) : null;
151
152
  }
152
153
  /** 扫描全部已装插件,对比本地 sha 与远端 sha,报告更新状态。 */
153
- export async function checkPluginUpdates(dataDir, exec = execGit) {
154
+ export async function checkPluginUpdates(dataDir, exec = execGit,
155
+ /** error 字段文案语言(默认英文);调用方可传 () => getLang() 实现跟随。 */
156
+ lang) {
157
+ const l = lang?.() ?? "en";
154
158
  const pluginsDir = join(dataDir, "plugins");
155
159
  let names = [];
156
160
  try {
@@ -186,7 +190,7 @@ export async function checkPluginUpdates(dataDir, exec = execGit) {
186
190
  remoteSha = null;
187
191
  }
188
192
  if (!remoteSha && !error)
189
- error = "无法检查(非 git 源或 git 不可用)";
193
+ error = pick(l, "无法检查(非 git 源或 git 不可用)", "Cannot check (non-git source or git unavailable)", "pluginupdate.cannot.check");
190
194
  let name;
191
195
  let version;
192
196
  try {
@@ -20,6 +20,7 @@ import { readdir, readFile, stat } from "node:fs/promises";
20
20
  import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
21
21
  import { join, resolve, sep } from "node:path";
22
22
  import { pathToFileURL } from "node:url";
23
+ import { pick } from "./i18n.js";
23
24
  import { PluginStorage, PluginSecrets, ensurePluginDeps, WorkspaceFS } from "./plugin-facilities.js";
24
25
  import { readCatalog, addCustomEntry, removeCustomEntry } from "./plugin-catalog.js";
25
26
  import { createHash } from "node:crypto";
@@ -84,14 +85,22 @@ function storedSettingsValues(dir, schema) {
84
85
  return out;
85
86
  }
86
87
  /** 校验并写回 settings(storage.json 的 settings 键,原子写);返回错误信息或 null。 */
87
- function saveSettingsValues(dir, schema, values) {
88
+ function saveSettingsValues(dir, schema, values,
89
+ /** 错误文案语言(默认英文);调用方可传 () => getLang() 实现跟随。 */
90
+ lang) {
91
+ const l = lang?.() ?? "en";
88
92
  const clean = {};
89
93
  for (const f of schema) {
90
94
  const v = values?.[f.key];
91
95
  if (f.type === "number") {
92
96
  const n = v === undefined ? Number(f.default ?? 0) : Number(v);
93
97
  if (!Number.isFinite(n) || (f.min !== undefined && n < f.min) || (f.max !== undefined && n > f.max)) {
94
- return { error: `${f.label} 超出范围`, clean };
98
+ return {
99
+ error: pick(l, `${f.label} 超出范围`, `${f.label} out of range`, "plugins.settings.out.of.range", {
100
+ "f.label": f.label,
101
+ }),
102
+ clean,
103
+ };
95
104
  }
96
105
  clean[f.key] = n;
97
106
  }
@@ -100,7 +109,12 @@ function saveSettingsValues(dir, schema, values) {
100
109
  }
101
110
  else if (f.type === "select") {
102
111
  if (v !== undefined && !f.options?.includes(String(v)))
103
- return { error: `${f.label} 值非法`, clean };
112
+ return {
113
+ error: pick(l, `${f.label} 值非法`, `Invalid value for ${f.label}`, "plugins.settings.invalid.value", {
114
+ "f.label": f.label,
115
+ }),
116
+ clean,
117
+ };
104
118
  clean[f.key] = v === undefined ? f.default : String(v);
105
119
  }
106
120
  else {
@@ -238,15 +252,20 @@ export class PluginManager {
238
252
  /** 保存某插件的声明式设置(⚙ 面板 → plugin_settings 消息):按 schema 校验、
239
253
  * 原子写 storage.json 的 settings 键、通知插件 onSettingsChanged、重推清单
240
254
  * 让前端回显。返回错误信息或 null(成功)。 */
241
- savePluginSettings(pluginId, values) {
255
+ savePluginSettings(pluginId, values,
256
+ /** 错误文案语言(默认英文);调用方可传 () => getLang() 实现跟随。 */
257
+ lang) {
258
+ const l = lang?.() ?? "en";
242
259
  if (!ID_RE.test(pluginId))
243
- return { error: "非法的插件 id" };
260
+ return { error: pick(l, "非法的插件 id", "Invalid plugin id", "plugins.id.invalid") };
244
261
  const dir = join(this.pluginsDir, pluginId);
245
262
  const info = this.loaded.get(pluginId)?.info;
246
263
  const schema = info?.settingsSchema ?? [];
247
264
  if (!schema.length)
248
- return { error: "该插件没有声明式设置(manifest 未声明 settings)" };
249
- const { error, clean } = saveSettingsValues(dir, schema, values);
265
+ return {
266
+ error: pick(l, "该插件没有声明式设置(manifest 未声明 settings)", "This plugin has no declarative settings (manifest declares no settings)", "plugins.settings.no.declarative"),
267
+ };
268
+ const { error, clean } = saveSettingsValues(dir, schema, values, lang);
250
269
  if (error)
251
270
  return { error };
252
271
  // 通知插件(异常隔离)
@@ -284,9 +303,9 @@ export class PluginManager {
284
303
  }
285
304
  /** 往用户自定义列表加一条(同 id 覆盖);返回错误信息或 null(成功)。
286
305
  * 成功后 epoch+1 并重推列表。 */
287
- addCatalogEntry(input) {
306
+ addCatalogEntry(input, lang) {
288
307
  try {
289
- addCustomEntry(this.customCatalogPath, input);
308
+ addCustomEntry(this.customCatalogPath, input, lang);
290
309
  this.catalogEpoch += 1;
291
310
  void this.pushCatalog();
292
311
  return {};
@@ -296,11 +315,14 @@ export class PluginManager {
296
315
  }
297
316
  }
298
317
  /** 移除一条用户自定义条目(builtin 不可经此删除);返回错误信息或 null。 */
299
- removeCatalogEntry(id) {
318
+ removeCatalogEntry(id, lang) {
319
+ const l = lang?.() ?? "en";
300
320
  try {
301
321
  const ok = removeCustomEntry(this.customCatalogPath, id);
302
322
  if (!ok)
303
- return { error: "未找到该条目,或它是内置条目(不可移除)" };
323
+ return {
324
+ error: pick(l, "未找到该条目,或它是内置条目(不可移除)", "Entry not found, or it is a built-in entry (cannot be removed)", "plugins.catalog.entry.cannot.remove"),
325
+ };
304
326
  this.catalogEpoch += 1;
305
327
  void this.pushCatalog();
306
328
  return {};
@@ -418,11 +440,11 @@ export class PluginManager {
418
440
  /** 服务端热重载:反激活全部 → 清缓存 → 重扫重激活 → epoch+1。
419
441
  * 返回新目录清单(含激活结果)。重激活后的插件实例是新模块,
420
442
  * 内存状态为初始值——逐个客户端触发 onAttach 让它们重推自身状态。 */
421
- async reload() {
443
+ async reload(lang) {
422
444
  this.dispose();
423
445
  this.attempted.clear();
424
446
  this.epochCounter += 1;
425
- const list = await this.ensureLoaded();
447
+ const list = await this.ensureLoaded(lang);
426
448
  for (const s of this.senders) {
427
449
  const cid = s.cid();
428
450
  if (cid)
@@ -606,14 +628,14 @@ export class PluginManager {
606
628
  * attach 时调用:重扫目录 + 激活尚未加载的新插件。
607
629
  * 返回给浏览器的目录(含激活失败的条目,前端显示为不可用)。
608
630
  */
609
- async ensureLoaded() {
631
+ async ensureLoaded(lang) {
610
632
  const found = await this.scan();
611
633
  for (const info of found) {
612
634
  if (this.loaded.has(info.id) || this.attempted.has(info.id))
613
635
  continue;
614
636
  if (!existsSync(join(this.pluginsDir, info.id, "index.mjs")))
615
637
  continue; // 纯前端插件
616
- await this.activate(info);
638
+ await this.activate(info, lang);
617
639
  }
618
640
  // 已被删除的插件:调用 deactivate 并移出缓存
619
641
  // eslint-disable-next-line unicorn/no-useless-spread -- snapshot: handlers may unsubscribe mid-emit
@@ -735,7 +757,8 @@ export class PluginManager {
735
757
  }
736
758
  return out;
737
759
  }
738
- async activate(info) {
760
+ async activate(info, lang) {
761
+ const l = lang?.() ?? "en";
739
762
  this.attempted.add(info.id);
740
763
  const dir = join(this.pluginsDir, info.id);
741
764
  const handlers = new Set();
@@ -758,7 +781,7 @@ export class PluginManager {
758
781
  }
759
782
  catch { }
760
783
  if (apiVersion > PLUGIN_API_VERSION) {
761
- const msg = `插件要求宿主 API v${apiVersion},当前宿主 v${PLUGIN_API_VERSION} —— 请升级 pi-web-ui`;
784
+ const msg = pick(l, `插件要求宿主 API v${apiVersion},当前宿主 v${PLUGIN_API_VERSION} —— 请升级 pi-web-ui`, `Plugin requires host API v${apiVersion} but the host is v${PLUGIN_API_VERSION} — please upgrade pi-web-ui`, "plugins.host.api.mismatch", { apiVersion, PLUGIN_API_VERSION });
762
785
  console.error(`[plugin:${info.id}] ${msg}`);
763
786
  this.loaded.set(info.id, {
764
787
  info: { ...info, error: msg },