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
|
@@ -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:
|
|
24
|
-
|
|
32
|
+
guidance: RENAME_GUIDANCE_ZH,
|
|
33
|
+
getGuidance: getRenameGuidance,
|
|
34
|
+
async apply(token, ctx, _state, lang = "en") {
|
|
25
35
|
if (token.op !== "rename") {
|
|
26
|
-
return {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/dist/server/plugins.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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 {
|
|
249
|
-
|
|
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 {
|
|
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 },
|