pi-web-ui 0.95.0 → 0.96.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/CHANGELOG.md +42 -1
- package/bin/pi-web-ui.mjs +159 -31
- package/dist/server/agent-service.js +880 -240
- package/dist/server/approval-rules.js +56 -10
- package/dist/server/attachment-store.js +21 -4
- package/dist/server/attachments.js +30 -3
- package/dist/server/bg-servers.js +45 -7
- package/dist/server/claim-files-tool.js +4 -8
- package/dist/server/claim-store.js +4 -2
- package/dist/server/client-state.js +63 -14
- package/dist/server/compact-context-tool.js +6 -5
- package/dist/server/composer-drafts.js +9 -0
- package/dist/server/control-socket.js +57 -28
- package/dist/server/conversation-read-tool.js +7 -17
- package/dist/server/dangling-tools.js +72 -5
- package/dist/server/delegate-task.js +25 -28
- package/dist/server/dsh/dsh-agent-service.js +19 -6
- package/dist/server/edit-soft-tool.js +14 -11
- package/dist/server/eval-tool.js +67 -14
- package/dist/server/file-archives.js +16 -5
- package/dist/server/files-service.js +30 -11
- package/dist/server/goal-service.js +141 -19
- package/dist/server/hashline-engine.js +59 -21
- package/dist/server/host-guard.js +94 -0
- package/dist/server/i18n.js +3 -3
- package/dist/server/index.js +306 -78
- package/dist/server/lsp-tool.js +752 -60
- package/dist/server/mcp-bridge.js +47 -3
- package/dist/server/model-admin.js +94 -34
- package/dist/server/office-parse.js +96 -30
- package/dist/server/patch-tool.js +9 -15
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +11 -1
- package/dist/server/plugin-catalog-sync.js +36 -19
- package/dist/server/plugin-catalog.js +11 -4
- package/dist/server/plugin-facilities.js +92 -21
- package/dist/server/plugin-llm.js +71 -65
- package/dist/server/plugin-project.js +70 -2
- package/dist/server/plugin-updater.js +115 -12
- package/dist/server/plugins.js +205 -44
- package/dist/server/present-files-tool.js +9 -12
- package/dist/server/process-utils.js +4 -0
- package/dist/server/prompt-composer.js +9 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +69 -30
- package/dist/server/resolve-global-sdk.js +30 -16
- package/dist/server/schedule-agent-tool.js +12 -15
- package/dist/server/sdk-origin.js +18 -2
- package/dist/server/serialize.js +108 -21
- package/dist/server/settings-service.js +4 -2
- package/dist/server/skill-tool.js +5 -7
- package/dist/server/subagent-templates.js +51 -0
- package/dist/server/subagents.js +48 -72
- package/dist/server/terminals.js +181 -69
- package/dist/server/tool-manager.js +24 -7
- package/dist/server/tool-overrides.js +59 -0
- package/dist/server/update-check.js +28 -1
- package/dist/server/uploads.js +17 -2
- package/dist/server/wait-subscription-scan.js +18 -21
- package/dist/server/ws-client-id.js +29 -0
- package/dist/server/ws-pending-queue.js +60 -0
- package/extensions/webui.ts +60 -2
- package/package.json +4 -2
- package/plugins/catalog.json +20 -0
- package/themes/aetheris.css +457 -0
- package/themes/zhupi-dark.css +658 -0
- package/themes/zhupi.css +711 -0
- package/web/dist/assets/{TerminalPanel-DlGBF2MD.js → TerminalPanel-DDChcYOh.js} +1 -1
- package/web/dist/assets/index-Do9RgJC3.js +366 -0
- package/web/dist/assets/index-DryOsILO.css +1 -0
- package/web/dist/assets/{markdown-eUQn_o9D.js → markdown-DXwnfD9T.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/dist/assets/index-BXnCQL9p.js +0 -364
- package/web/dist/assets/index-RpIGFyi_.css +0 -1
|
@@ -16,8 +16,12 @@
|
|
|
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";
|
|
20
19
|
const PROTOCOL_VERSION = "2025-03-26"; // 广泛支持的工具版本
|
|
20
|
+
/** 单行(一条 JSON-RPC 消息)的长度上限(1MB):失控服务器一行永不换行会顶爆内存。 */
|
|
21
|
+
const MCP_MAX_LINE_CHARS = 1024 * 1024;
|
|
22
|
+
/** 未换行数据的总缓冲上限(4MB):服务器只吐垃圾不吐换行时按协议错误杀进程。
|
|
23
|
+
* 上限按 JS 字符串长度计(UTF-16 码元 ≤ UTF-8 字节),内存放大有界。 */
|
|
24
|
+
const MCP_MAX_BUFFER_CHARS = 4 * 1024 * 1024;
|
|
21
25
|
let rpcSeq = 0;
|
|
22
26
|
/**
|
|
23
27
|
* 单个 MCP 服务器的客户端:管理子进程、请求/响应按 id 关联、握手与工具调用。
|
|
@@ -245,12 +249,22 @@ export class McpClient {
|
|
|
245
249
|
}
|
|
246
250
|
onData(chunk) {
|
|
247
251
|
this.buffer += chunk;
|
|
252
|
+
// 总缓冲上限:服务器一直输出却不换行(或极慢地换行)时,缓冲会无限涨,
|
|
253
|
+
// 宿主内存被一个失控子进程吃光。超限按协议错误处理:杀进程 + 拒绝在途请求。
|
|
254
|
+
if (this.buffer.length > MCP_MAX_BUFFER_CHARS) {
|
|
255
|
+
this.killForProtocolError(new Error(`[mcp:${this.name}] stdout 缓冲超过 ${Math.round(MCP_MAX_BUFFER_CHARS / 1024 / 1024)}MB 上限,按协议错误关闭`));
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
248
258
|
let nl;
|
|
249
259
|
while ((nl = this.buffer.indexOf("\n")) >= 0) {
|
|
250
260
|
const line = this.buffer.slice(0, nl).trim();
|
|
251
261
|
this.buffer = this.buffer.slice(nl + 1);
|
|
252
262
|
if (!line)
|
|
253
263
|
continue;
|
|
264
|
+
if (line.length > MCP_MAX_LINE_CHARS) {
|
|
265
|
+
this.killForProtocolError(new Error(`[mcp:${this.name}] 单行超过 ${Math.round(MCP_MAX_LINE_CHARS / 1024 / 1024)}MB 上限,按协议错误关闭`));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
254
268
|
let msg;
|
|
255
269
|
try {
|
|
256
270
|
msg = JSON.parse(line);
|
|
@@ -262,6 +276,24 @@ export class McpClient {
|
|
|
262
276
|
this.handleMessage(msg);
|
|
263
277
|
}
|
|
264
278
|
}
|
|
279
|
+
/** 协议错误(stdout 缓冲超限):杀掉子进程、拒绝全部在途请求、清空缓冲。
|
|
280
|
+
* 与进程自然退出共用自愈语义 —— 下一次工具调用会重新拉起并重新握手
|
|
281
|
+
* (失控服务器重启后再次超限就再次杀,不会永久占用宿主内存)。 */
|
|
282
|
+
killForProtocolError(err) {
|
|
283
|
+
this.log(err.message);
|
|
284
|
+
this.buffer = "";
|
|
285
|
+
const child = this.child;
|
|
286
|
+
this.child = null;
|
|
287
|
+
this.rejectAll(err);
|
|
288
|
+
if (child) {
|
|
289
|
+
try {
|
|
290
|
+
child.kill();
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
/* 已退出 */
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
265
297
|
handleMessage(msg) {
|
|
266
298
|
if (msg.id !== undefined) {
|
|
267
299
|
const pending = this.pending.get(String(msg.id));
|
|
@@ -360,8 +392,7 @@ function adaptMcpTool(serverName, mcpTool, client) {
|
|
|
360
392
|
return {
|
|
361
393
|
name,
|
|
362
394
|
label: `${serverName} · ${mcpTool.name}`,
|
|
363
|
-
description: mcpTool.description ??
|
|
364
|
-
bilingual(`Tool ${mcpTool.name} provided by MCP server "${serverName}"`, `从 MCP 服务器「${serverName}」提供的工具 ${mcpTool.name}`),
|
|
395
|
+
description: mcpTool.description ?? `Tool ${mcpTool.name} provided by MCP server "${serverName}"`,
|
|
365
396
|
parameters: mcpTool.inputSchema ?? {},
|
|
366
397
|
execute: async (_toolCallId, params, _signal) => {
|
|
367
398
|
return client.call(mcpTool.name, params ?? {});
|
|
@@ -380,6 +411,8 @@ export class McpBridge {
|
|
|
380
411
|
opts;
|
|
381
412
|
clients = [];
|
|
382
413
|
tools = [];
|
|
414
|
+
/** reload 串行化链:重入的 reload 排在前一个之后,杜绝交叠留下的孤儿进程。 */
|
|
415
|
+
reloadChain = Promise.resolve();
|
|
383
416
|
constructor(dataDir, log = () => { }, opts = {}) {
|
|
384
417
|
this.dataDir = dataDir;
|
|
385
418
|
this.log = log;
|
|
@@ -411,6 +444,12 @@ export class McpBridge {
|
|
|
411
444
|
}
|
|
412
445
|
/**
|
|
413
446
|
* 按磁盘上的最新配置**整体换入**服务器集合(`mcp.json` 热加载用),可重复调用。
|
|
447
|
+
*
|
|
448
|
+
* 并发互斥:watch + 轮询 + 手动 apply 可能同时触发 reload,两个 reload 交叠跑
|
|
449
|
+
* 会各自 startOne/close —— 同一服务器起两个子进程、或把别人刚换入的实例当
|
|
450
|
+
* stale 关掉(孤儿/误杀)。这里用一条 promise 链把重入排成队,每个 reload
|
|
451
|
+
* 看到的是前一个完成后的最新状态;热加载的防抖窗口之外再兜一道。
|
|
452
|
+
*
|
|
414
453
|
* 三步的顺序都有讲究:
|
|
415
454
|
* 1. 规格没变的服务器**沿用原实例** —— 改一个服务器不该连带重启其它服务器(子进程、
|
|
416
455
|
* 浏览器会话、在途调用全都不动);
|
|
@@ -419,6 +458,11 @@ export class McpBridge {
|
|
|
419
458
|
* 3. 最后才 close 掉被移除/被替换的旧实例,并按新集合重建工具表。
|
|
420
459
|
*/
|
|
421
460
|
async reload() {
|
|
461
|
+
const run = this.reloadChain.then(() => this.reloadOnce(), () => this.reloadOnce());
|
|
462
|
+
this.reloadChain = run.then(() => undefined, () => undefined);
|
|
463
|
+
return run;
|
|
464
|
+
}
|
|
465
|
+
async reloadOnce() {
|
|
422
466
|
const cfg = optsOverrideOrRead(this.opts.specOverride, this.dataDir);
|
|
423
467
|
const next = new Map();
|
|
424
468
|
let kept = 0;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* 经 ModelAdminHost 与 ClientSession 解耦(同 settings/goal/slash 服务模式)。
|
|
12
12
|
* UI 文案直接中文(服务端 notice 约定)。apiKey/headers 绝不下发浏览器。
|
|
13
13
|
*/
|
|
14
|
-
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { dirname, join } from "node:path";
|
|
16
16
|
import { enrichBatch } from "./model-enrich.js";
|
|
17
17
|
import { pick } from "./i18n.js";
|
|
@@ -65,6 +65,13 @@ export function graduateOverlayModels(providers, official) {
|
|
|
65
65
|
}
|
|
66
66
|
return graduated;
|
|
67
67
|
}
|
|
68
|
+
/** 原子写 JSON(tmp + rename):进程崩溃不留半截文件——裸 writeFileSync 中途被
|
|
69
|
+
* 杀会把整份配置截断,下次读盘即空表。tmp 名带 pid,避免同机多进程互踩。 */
|
|
70
|
+
function atomicWriteJson(path, data) {
|
|
71
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
72
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n");
|
|
73
|
+
renameSync(tmp, path);
|
|
74
|
+
}
|
|
68
75
|
/** Strip // and /* *\/ comments without touching string literals (URLs contain //). */
|
|
69
76
|
function stripJsonComments(src) {
|
|
70
77
|
let out = "";
|
|
@@ -111,7 +118,7 @@ function stripJsonComments(src) {
|
|
|
111
118
|
/** Merge a UI-submitted provider config into the existing models.json entry.
|
|
112
119
|
*
|
|
113
120
|
* 表单(`UiProviderConfig`)只承载 UI 认识的字段:provider 级 name/api/baseUrl/
|
|
114
|
-
*
|
|
121
|
+
* hasApiKey/authHeader,模型级 id/name/reasoning/input/contextWindow/maxTokens。
|
|
115
122
|
* models.json 里还可能有 UI 不认识的字段——provider 级 headers(浏览器拿不到,
|
|
116
123
|
* 见 listModelsConfig)、模型级 api/baseUrl/cost/compat/thinkingLevelMap(手写或
|
|
117
124
|
* 脚本写入,pi-ai 靠它们决定请求地址与推理格式)。按表单整体重建条目会把这些字段
|
|
@@ -120,6 +127,10 @@ function stripJsonComments(src) {
|
|
|
120
127
|
*
|
|
121
128
|
* 所以这里以已有条目为底、表单字段覆盖:表单没提到的字段原样保留,表单清空的可选
|
|
122
129
|
* 字段才真正删除;`models` 仍是「表单即全集」——表单里删掉的 id 会被移除。
|
|
130
|
+
*
|
|
131
|
+
* apiKey 例外:浏览器不再持有明文(只见 hasApiKey),**缺字段 = 保留旧值**;显式
|
|
132
|
+
* 空串才清除(协议级"空=清除"语义保留,但 Web 表单留空时整字段不传)。这样
|
|
133
|
+
* refresh_provider_models 等不带 apiKey 的内部保存路径也绝不会把密钥抹掉。
|
|
123
134
|
*/
|
|
124
135
|
export function mergeProviderConfigEntry(prevEntry, config, models) {
|
|
125
136
|
const prevModels = new Map();
|
|
@@ -134,7 +145,7 @@ export function mergeProviderConfigEntry(prevEntry, config, models) {
|
|
|
134
145
|
}
|
|
135
146
|
const mergedModels = models.map((model) => {
|
|
136
147
|
// 旧条目同 id 的字段(api/baseUrl/cost/compat/…)先铺底,表单字段覆盖。
|
|
137
|
-
const merged = { ...
|
|
148
|
+
const merged = { ...prevModels.get(model.id), id: model.id };
|
|
138
149
|
if (model.name?.trim())
|
|
139
150
|
merged.name = model.name.trim();
|
|
140
151
|
else
|
|
@@ -167,7 +178,12 @@ export function mergeProviderConfigEntry(prevEntry, config, models) {
|
|
|
167
178
|
apply("name", config.name?.trim() || undefined);
|
|
168
179
|
apply("api", config.api?.trim() || undefined);
|
|
169
180
|
apply("baseUrl", config.baseUrl?.trim() || undefined);
|
|
170
|
-
|
|
181
|
+
if (config.apiKey === undefined) {
|
|
182
|
+
// 表单没带 apiKey(留空/内部路径)→ 保留旧值,绝不清除。
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
apply("apiKey", config.apiKey.trim() || undefined);
|
|
186
|
+
}
|
|
171
187
|
apply("authHeader", config.authHeader ? true : undefined);
|
|
172
188
|
mergedEntry.models = mergedModels;
|
|
173
189
|
return mergedEntry;
|
|
@@ -331,29 +347,55 @@ export class ModelAdminService {
|
|
|
331
347
|
}
|
|
332
348
|
/** Read + parse provider-keys.json. */
|
|
333
349
|
readProviderKeys() {
|
|
350
|
+
let raw;
|
|
334
351
|
try {
|
|
335
|
-
|
|
336
|
-
const out = {};
|
|
337
|
-
for (const [pid, entry] of Object.entries(parsed)) {
|
|
338
|
-
const keys = Array.isArray(entry?.keys) ? entry.keys.filter((k) => k?.name && k?.apiKey) : [];
|
|
339
|
-
if (!pid || keys.length === 0)
|
|
340
|
-
continue;
|
|
341
|
-
const activeKeyName = entry.activeKeyName === null
|
|
342
|
-
? null
|
|
343
|
-
: entry.activeKeyName && keys.some((k) => k.name === entry.activeKeyName)
|
|
344
|
-
? entry.activeKeyName
|
|
345
|
-
: keys[0].name;
|
|
346
|
-
out[pid] = { activeKeyName, keys };
|
|
347
|
-
}
|
|
348
|
-
return out;
|
|
352
|
+
raw = readFileSync(this.providerKeysPath(), "utf8");
|
|
349
353
|
}
|
|
350
354
|
catch {
|
|
355
|
+
return {}; // 文件不存在 = 首次引导
|
|
356
|
+
}
|
|
357
|
+
let parsed;
|
|
358
|
+
try {
|
|
359
|
+
parsed = JSON.parse(raw);
|
|
360
|
+
}
|
|
361
|
+
catch (err) {
|
|
362
|
+
// 坏文件改名留存:里面可能有全部备用密钥,绝不能被后续种子/空表整份覆盖。
|
|
363
|
+
this.quarantineCorruptFile(this.providerKeysPath(), err);
|
|
351
364
|
return {};
|
|
352
365
|
}
|
|
366
|
+
const out = {};
|
|
367
|
+
for (const [pid, entry] of Object.entries(parsed)) {
|
|
368
|
+
const keys = Array.isArray(entry?.keys) ? entry.keys.filter((k) => k?.name && k?.apiKey) : [];
|
|
369
|
+
if (!pid || keys.length === 0)
|
|
370
|
+
continue;
|
|
371
|
+
const activeKeyName = entry.activeKeyName === null
|
|
372
|
+
? null
|
|
373
|
+
: entry.activeKeyName && keys.some((k) => k.name === entry.activeKeyName)
|
|
374
|
+
? entry.activeKeyName
|
|
375
|
+
: keys[0].name;
|
|
376
|
+
out[pid] = { activeKeyName, keys };
|
|
377
|
+
}
|
|
378
|
+
return out;
|
|
353
379
|
}
|
|
354
380
|
writeProviderKeys(data) {
|
|
355
381
|
mkdirSync(this.host.agentDir, { recursive: true });
|
|
356
|
-
|
|
382
|
+
// 原子写(tmp+rename):崩溃不留半截 JSON;0o600 限权防本机其他用户读密钥
|
|
383
|
+
//(Windows 忽略 mode 无害,POSIX 生效)。
|
|
384
|
+
const path = this.providerKeysPath();
|
|
385
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
386
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
|
|
387
|
+
renameSync(tmp, path);
|
|
388
|
+
}
|
|
389
|
+
/** 解析失败的配置文件改名留存为 <name>.corrupt-<timestamp>。改名失败(占用/
|
|
390
|
+
* 跨盘等)也不抛——留存是尽力而为,绝不能阻塞主流程。 */
|
|
391
|
+
quarantineCorruptFile(path, err) {
|
|
392
|
+
try {
|
|
393
|
+
renameSync(path, `${path}.corrupt-${Date.now()}`);
|
|
394
|
+
console.warn(`[model-admin] 配置文件解析失败,已改名留存:${path}(${err.message})`);
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
console.warn(`[model-admin] 配置文件解析失败且无法留存:${path}(${err.message})`);
|
|
398
|
+
}
|
|
357
399
|
}
|
|
358
400
|
/** Default name "密钥 N" for a provider's Nth key. */
|
|
359
401
|
defaultKeyName(keys) {
|
|
@@ -421,12 +463,17 @@ export class ModelAdminService {
|
|
|
421
463
|
}
|
|
422
464
|
}
|
|
423
465
|
/** Push the masked provider-keys map to the client. Seeds the store from any
|
|
424
|
-
* auth.json credentials so legacy single-key setups show up immediately.
|
|
466
|
+
* auth.json credentials so legacy single-key setups show up immediately.
|
|
467
|
+
* 只在文件不存在(首次引导)时才落盘种子;文件存在(含空表)绝不整份写回
|
|
468
|
+
* ——写回的来源永远是读盘结果,避免任何竞态把用户密钥覆盖掉。 */
|
|
425
469
|
listProviderKeys() {
|
|
470
|
+
const path = this.providerKeysPath();
|
|
426
471
|
const data = this.readProviderKeys();
|
|
427
|
-
|
|
428
|
-
this.
|
|
429
|
-
|
|
472
|
+
if (!existsSync(path)) {
|
|
473
|
+
for (const pid of this.builtinProviderIds())
|
|
474
|
+
this.seedProviderKeysFromAuth(pid, data);
|
|
475
|
+
this.writeProviderKeys(data);
|
|
476
|
+
}
|
|
430
477
|
this.host.emit({ type: "provider_keys", ...this.providerKeysInfo(data) });
|
|
431
478
|
this.host.flushSnapshot();
|
|
432
479
|
}
|
|
@@ -458,7 +505,7 @@ export class ModelAdminService {
|
|
|
458
505
|
// no file yet / unparsable — start fresh
|
|
459
506
|
}
|
|
460
507
|
data[pid] = { type: "api_key", key: apiKey };
|
|
461
|
-
|
|
508
|
+
atomicWriteJson(authPath, data);
|
|
462
509
|
const mr = this.host.modelRuntime();
|
|
463
510
|
await mr.setRuntimeApiKey(pid, apiKey);
|
|
464
511
|
await mr.refresh({ allowNetwork: true, providers: [pid] });
|
|
@@ -689,7 +736,7 @@ export class ModelAdminService {
|
|
|
689
736
|
// no file yet — nothing to clean
|
|
690
737
|
}
|
|
691
738
|
delete auth[pid];
|
|
692
|
-
|
|
739
|
+
atomicWriteJson(authPath, auth);
|
|
693
740
|
const mr = this.host.modelRuntime();
|
|
694
741
|
await mr.removeRuntimeApiKey(pid);
|
|
695
742
|
await mr.refresh({ providers: [pid] });
|
|
@@ -780,7 +827,7 @@ export class ModelAdminService {
|
|
|
780
827
|
return;
|
|
781
828
|
}
|
|
782
829
|
delete data[pid];
|
|
783
|
-
|
|
830
|
+
atomicWriteJson(authPath, data);
|
|
784
831
|
// Clear every stored key so the provider returns to unconfigured.
|
|
785
832
|
delete keyData[pid];
|
|
786
833
|
this.writeProviderKeys(keyData);
|
|
@@ -1131,7 +1178,8 @@ export class ModelAdminService {
|
|
|
1131
1178
|
name: p.name,
|
|
1132
1179
|
api: p.api,
|
|
1133
1180
|
baseUrl: p.baseUrl,
|
|
1134
|
-
apiKey
|
|
1181
|
+
// apiKey 明文绝不下发浏览器,只报有无;表单留空 = 保持不变。
|
|
1182
|
+
hasApiKey: typeof p.apiKey === "string" && p.apiKey.trim() !== "",
|
|
1135
1183
|
authHeader: p.authHeader,
|
|
1136
1184
|
// headers are intentionally NOT sent to the browser — they may
|
|
1137
1185
|
// contain Authorization / API-key values; kept server-side only.
|
|
@@ -1232,12 +1280,23 @@ export class ModelAdminService {
|
|
|
1232
1280
|
* because the baseUrl is often a LAN/loopback host the browser can't reach
|
|
1233
1281
|
* cross-origin) and return the advertised models. reqId is echoed back
|
|
1234
1282
|
* in fetch_models_result so the UI can match concurrent requests. */
|
|
1283
|
+
/** 编辑表单的探测(fetch_models / test_model_connection)没带明文 apiKey 时,
|
|
1284
|
+
* 按 providerId 回落到 models.json 里已保存的密钥——编辑存量服务商时表单
|
|
1285
|
+
* 一律留空(明文不再回传浏览器),没有这个回落「测试连接」就会 401。 */
|
|
1286
|
+
savedProviderApiKey(providerId) {
|
|
1287
|
+
const pid = providerId?.trim();
|
|
1288
|
+
if (!pid)
|
|
1289
|
+
return undefined;
|
|
1290
|
+
const saved = this.readModelsConfig().providers[pid];
|
|
1291
|
+
return typeof saved?.apiKey === "string" && saved.apiKey.trim() ? saved.apiKey : undefined;
|
|
1292
|
+
}
|
|
1235
1293
|
async fetchModelsList(reqId, baseUrl, apiKey, authHeader, api,
|
|
1236
1294
|
/** 探测抛错文案语言(默认英文);调用方可传 () => getLang() 实现跟随。 */
|
|
1237
|
-
lang) {
|
|
1295
|
+
lang, providerId) {
|
|
1238
1296
|
const emitError = (error) => this.host.emit({ type: "fetch_models_result", reqId, ok: false, error });
|
|
1239
1297
|
try {
|
|
1240
|
-
const
|
|
1298
|
+
const key = apiKey?.trim() || this.savedProviderApiKey(providerId);
|
|
1299
|
+
const models = await ModelAdminService.probeModelsEndpoint(baseUrl, key, authHeader, api, undefined, lang);
|
|
1241
1300
|
this.host.emit({ type: "fetch_models_result", reqId, ok: true, models });
|
|
1242
1301
|
}
|
|
1243
1302
|
catch (err) {
|
|
@@ -1248,10 +1307,11 @@ export class ModelAdminService {
|
|
|
1248
1307
|
* Lightweight connectivity and auth probe for a provider endpoint.
|
|
1249
1308
|
* Measures latency in ms and returns ok/error.
|
|
1250
1309
|
*/
|
|
1251
|
-
async testConnection(reqId, baseUrl, apiKey, authHeader, api, lang) {
|
|
1310
|
+
async testConnection(reqId, baseUrl, apiKey, authHeader, api, lang, providerId) {
|
|
1252
1311
|
const start = Date.now();
|
|
1253
1312
|
try {
|
|
1254
|
-
|
|
1313
|
+
const key = apiKey?.trim() || this.savedProviderApiKey(providerId);
|
|
1314
|
+
await ModelAdminService.probeModelsEndpoint(baseUrl, key, authHeader, api, undefined, lang);
|
|
1255
1315
|
const latencyMs = Date.now() - start;
|
|
1256
1316
|
this.host.emit({ type: "test_model_connection_result", reqId, ok: true, latencyMs });
|
|
1257
1317
|
}
|
|
@@ -1545,7 +1605,7 @@ export class ModelAdminService {
|
|
|
1545
1605
|
if (graduated.length === 0)
|
|
1546
1606
|
return graduated;
|
|
1547
1607
|
try {
|
|
1548
|
-
|
|
1608
|
+
atomicWriteJson(this.modelsConfigPath(), { providers });
|
|
1549
1609
|
}
|
|
1550
1610
|
catch {
|
|
1551
1611
|
return [];
|
|
@@ -1582,7 +1642,7 @@ export class ModelAdminService {
|
|
|
1582
1642
|
* config list + the picker. Callers emit their own notice. */
|
|
1583
1643
|
async writeModelsConfigAndReload(providers, pid) {
|
|
1584
1644
|
mkdirSync(this.host.agentDir, { recursive: true });
|
|
1585
|
-
|
|
1645
|
+
atomicWriteJson(this.modelsConfigPath(), { providers });
|
|
1586
1646
|
// Allow a models.json entry to reuse the provider credential already
|
|
1587
1647
|
// stored in auth.json. Seed the shared runtime too, because older pi-ai
|
|
1588
1648
|
// versions did not always fall back to stored credentials for a
|
|
@@ -1739,7 +1799,7 @@ export class ModelAdminService {
|
|
|
1739
1799
|
return;
|
|
1740
1800
|
}
|
|
1741
1801
|
delete providers[providerId];
|
|
1742
|
-
|
|
1802
|
+
atomicWriteJson(this.modelsConfigPath(), { providers });
|
|
1743
1803
|
await this.host.modelRuntime().refresh();
|
|
1744
1804
|
this.host.invalidatePiConfig();
|
|
1745
1805
|
await this.listModelsConfig();
|
|
@@ -67,9 +67,9 @@ export function unzipFiles(buf, wanted) {
|
|
|
67
67
|
let totalUncomp = 0;
|
|
68
68
|
const out = new Map();
|
|
69
69
|
for (const [name, meta] of files) {
|
|
70
|
-
|
|
71
|
-
if (totalUncomp > OFFICE_MAX_UNCOMPRESSED_BYTES)
|
|
70
|
+
if (meta.uncompSize > OFFICE_MAX_UNCOMPRESSED_BYTES) {
|
|
72
71
|
throw new Error("解包后内容过大(疑似 zip 炸弹),拒绝预览");
|
|
72
|
+
}
|
|
73
73
|
const lp = meta.localOffset;
|
|
74
74
|
if (buf.readUInt32LE(lp) !== 0x04034b50)
|
|
75
75
|
throw new Error(`zip 局部头损坏:${name}`);
|
|
@@ -81,42 +81,88 @@ export function unzipFiles(buf, wanted) {
|
|
|
81
81
|
if (meta.flag & 0x1)
|
|
82
82
|
throw new Error(`不支持加密 zip 条目:${name}`);
|
|
83
83
|
const method = lMethod || meta.method;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
else
|
|
84
|
+
let decompressed;
|
|
85
|
+
if (method === 0) {
|
|
86
|
+
decompressed = Buffer.from(raw);
|
|
87
|
+
}
|
|
88
|
+
else if (method === 8) {
|
|
89
|
+
const remainingQuota = OFFICE_MAX_UNCOMPRESSED_BYTES - totalUncomp;
|
|
90
|
+
if (remainingQuota <= 0)
|
|
91
|
+
throw new Error("解包后内容过大(疑似 zip 炸弹),拒绝预览");
|
|
92
|
+
try {
|
|
93
|
+
decompressed = Buffer.from(inflateRawSync(raw, { maxOutputLength: remainingQuota }));
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
if (err.message?.includes("maxOutputLength") ||
|
|
97
|
+
err.code === "ERR_BUFFER_TOO_LARGE") {
|
|
98
|
+
throw new Error("解包后内容过大(疑似 zip 炸弹),拒绝预览");
|
|
99
|
+
}
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
89
104
|
throw new Error(`不支持的压缩方式 ${method}:${name}`);
|
|
105
|
+
}
|
|
106
|
+
totalUncomp += decompressed.length;
|
|
107
|
+
if (totalUncomp > OFFICE_MAX_UNCOMPRESSED_BYTES) {
|
|
108
|
+
throw new Error("解包后内容过大(疑似 zip 炸弹),拒绝预览");
|
|
109
|
+
}
|
|
110
|
+
out.set(name, decompressed);
|
|
90
111
|
}
|
|
91
112
|
return out;
|
|
92
113
|
}
|
|
114
|
+
const NAMED_ENTITIES = { lt: "<", gt: ">", amp: "&", quot: '"', apos: "'" };
|
|
115
|
+
/**
|
|
116
|
+
* 单轮解码命名实体与十/十六进制数字实体(一次扫描同时匹配三类)。
|
|
117
|
+
*
|
|
118
|
+
* 旧实现分三段 replace:`&#60;` 会先被命名段解码成 `<`、再被数字段
|
|
119
|
+
* 解码成 `<`——双重解码让转义文本"逃出"字面量(可注入标签)。单轮扫描把
|
|
120
|
+
* `&…;` 整体消费、解码产物不参与后续匹配:`&#60;` 正确地得到 `<`。
|
|
121
|
+
*/
|
|
93
122
|
function decodeEntities(s) {
|
|
94
|
-
return String(s ?? "")
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
103
|
-
})
|
|
104
|
-
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => {
|
|
105
|
-
try {
|
|
106
|
-
return String.fromCodePoint(Number.parseInt(h, 16));
|
|
123
|
+
return String(s ?? "").replace(/&(?:#([0-9]+);|#x([0-9a-fA-F]+);|([a-zA-Z]+);)/g, (_whole, dec, hex, name) => {
|
|
124
|
+
if (dec !== undefined) {
|
|
125
|
+
try {
|
|
126
|
+
return String.fromCodePoint(Number(dec));
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return "";
|
|
130
|
+
}
|
|
107
131
|
}
|
|
108
|
-
|
|
109
|
-
|
|
132
|
+
if (hex !== undefined) {
|
|
133
|
+
try {
|
|
134
|
+
return String.fromCodePoint(Number.parseInt(hex, 16));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return "";
|
|
138
|
+
}
|
|
110
139
|
}
|
|
140
|
+
// 未知的命名实体保留原文(与旧实现一致,不臆造映射)
|
|
141
|
+
return NAMED_ENTITIES[name ?? ""] ?? _whole;
|
|
111
142
|
});
|
|
112
143
|
}
|
|
113
144
|
const stripTags = (s) => decodeEntities(String(s ?? "").replace(/<[^>]+>/g, ""));
|
|
145
|
+
/** document.xml 解压后的字节上限:超大 XML 会让段落正则扫描退化成秒级卡顿(同步事件循环被挂死)。 */
|
|
146
|
+
const DOCX_MAX_XML_BYTES = 20 * 1024 * 1024;
|
|
114
147
|
/** docx → 段落数组。 */
|
|
115
148
|
export function parseDocxParagraphs(buf) {
|
|
116
149
|
const files = unzipFiles(buf, ["word/document.xml"]);
|
|
117
150
|
const xml = files.get("word/document.xml")?.toString("utf8");
|
|
118
151
|
if (!xml)
|
|
119
152
|
throw new Error("docx 里找不到 word/document.xml");
|
|
153
|
+
// 预检 1:解压后过大的 document.xml 在下面的正则扫描里代价爆炸(一次性物化
|
|
154
|
+
// 全部段落、非贪婪匹配最坏回溯到文本末尾),直接友好报错而不是挂住进程。
|
|
155
|
+
if (xml.length > DOCX_MAX_XML_BYTES) {
|
|
156
|
+
throw new Error(`文档内容过大(document.xml 解压后 ${(xml.length / 1048576).toFixed(1)} MB,上限 20 MB),拒绝预览`);
|
|
157
|
+
}
|
|
158
|
+
// 预检 2:正常文档的段落闭合标签与开标签同量级。"只有开标签、没有闭标签"
|
|
159
|
+
// 的恶意结构会让非贪婪正则在每个候选起点都回溯扫描到文本末尾(O(n²)),
|
|
160
|
+
// 同步挂死事件循环——开标签远多于闭标签(>2 倍)且闭标签为 0 时直接报错。
|
|
161
|
+
const opens = xml.match(/<w:p[\s>]/g)?.length ?? 0;
|
|
162
|
+
const closes = xml.match(/<\/w:p>/g)?.length ?? 0;
|
|
163
|
+
if (opens > 0 && closes === 0) {
|
|
164
|
+
throw new Error("文档结构异常(段落标签大量未闭合),疑似恶意文档,拒绝预览");
|
|
165
|
+
}
|
|
120
166
|
const paragraphs = [];
|
|
121
167
|
for (const m of xml.matchAll(/<w:p[\s>][\s\S]*?<\/w:p>/g)) {
|
|
122
168
|
const pXml = m[0];
|
|
@@ -160,11 +206,22 @@ function parseSharedStrings(xml) {
|
|
|
160
206
|
}
|
|
161
207
|
return out;
|
|
162
208
|
}
|
|
209
|
+
const MAX_PARSE_ROWS = 1000;
|
|
210
|
+
const MAX_PARSE_COLS = 100;
|
|
163
211
|
function parseSheet(xml, shared) {
|
|
164
212
|
const rows = [];
|
|
213
|
+
let maxRowSeen = 0;
|
|
214
|
+
let maxColSeen = 0;
|
|
165
215
|
for (const m of xml.matchAll(/<row[^>]*>([\s\S]*?)<\/row>/g)) {
|
|
166
216
|
const rowAttr = m[0].slice(0, m[0].indexOf(">"));
|
|
167
|
-
const
|
|
217
|
+
const rawRNum = /r="(\d+)"/.exec(rowAttr)?.[1];
|
|
218
|
+
const rNum = rawRNum ? Number(rawRNum) - 1 : rows.length;
|
|
219
|
+
if (!Number.isFinite(rNum) || rNum < 0)
|
|
220
|
+
continue;
|
|
221
|
+
maxRowSeen = Math.max(maxRowSeen, rNum + 1);
|
|
222
|
+
// 防 OOM:巨大行号不进行无边界预分配,仅计入总量
|
|
223
|
+
if (rNum >= MAX_PARSE_ROWS)
|
|
224
|
+
continue;
|
|
168
225
|
while (rows.length <= rNum)
|
|
169
226
|
rows.push([]);
|
|
170
227
|
const row = rows[rNum];
|
|
@@ -173,7 +230,11 @@ function parseSheet(xml, shared) {
|
|
|
173
230
|
const ref = /r="([^"]+)"/.exec(attrs)?.[1];
|
|
174
231
|
const t = /t="([^"]+)"/.exec(attrs)?.[1];
|
|
175
232
|
const pos = splitCellRef(ref ?? "");
|
|
176
|
-
if (!pos)
|
|
233
|
+
if (!pos || pos.col < 0)
|
|
234
|
+
continue;
|
|
235
|
+
maxColSeen = Math.max(maxColSeen, pos.col + 1);
|
|
236
|
+
// 防 OOM:巨大列号不进行无边界空字符串 push
|
|
237
|
+
if (pos.col >= MAX_PARSE_COLS)
|
|
177
238
|
continue;
|
|
178
239
|
const inner = c[2];
|
|
179
240
|
let val = "";
|
|
@@ -197,7 +258,11 @@ function parseSheet(xml, shared) {
|
|
|
197
258
|
row[pos.col] = val;
|
|
198
259
|
}
|
|
199
260
|
}
|
|
200
|
-
return
|
|
261
|
+
return {
|
|
262
|
+
rows,
|
|
263
|
+
nRows: Math.max(rows.length, maxRowSeen),
|
|
264
|
+
nCols: Math.max(Math.max(0, ...rows.map((r) => r.length)), maxColSeen),
|
|
265
|
+
};
|
|
201
266
|
}
|
|
202
267
|
/** xlsx/xlsm → sheet 数组(名按 workbook 还原,取不到时回落 sheetN)。 */
|
|
203
268
|
export function parseXlsxSheets(buf) {
|
|
@@ -227,10 +292,11 @@ export function parseXlsxSheets(buf) {
|
|
|
227
292
|
const shared = parseSharedStrings(files.get("xl/sharedStrings.xml")?.toString("utf8"));
|
|
228
293
|
return targets.map((t) => {
|
|
229
294
|
const xml = files.get(t.file)?.toString("utf8");
|
|
230
|
-
const
|
|
231
|
-
const fullCols =
|
|
295
|
+
const parsed = xml ? parseSheet(xml, shared) : { rows: [], nRows: 0, nCols: 0 };
|
|
296
|
+
const fullCols = parsed.nCols;
|
|
297
|
+
const fullRows = parsed.nRows;
|
|
232
298
|
const nCols = Math.min(MAX_COLS, fullCols);
|
|
233
|
-
const cut =
|
|
299
|
+
const cut = parsed.rows.slice(0, MAX_ROWS).map((r) => {
|
|
234
300
|
const row = r.slice(0, MAX_COLS);
|
|
235
301
|
while (row.length < nCols)
|
|
236
302
|
row.push("");
|
|
@@ -238,10 +304,10 @@ export function parseXlsxSheets(buf) {
|
|
|
238
304
|
});
|
|
239
305
|
return {
|
|
240
306
|
name: t.name,
|
|
241
|
-
nRows:
|
|
307
|
+
nRows: fullRows,
|
|
242
308
|
nCols: fullCols,
|
|
243
309
|
rows: cut,
|
|
244
|
-
truncated:
|
|
310
|
+
truncated: fullRows > MAX_ROWS || fullCols > MAX_COLS,
|
|
245
311
|
};
|
|
246
312
|
});
|
|
247
313
|
}
|
|
@@ -19,21 +19,15 @@ export function makePatchTool(options) {
|
|
|
19
19
|
return defineTool({
|
|
20
20
|
name: PATCH_TOOL_NAME,
|
|
21
21
|
label: "Apply Hashline patch",
|
|
22
|
-
description: `Apply
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
- \`PUT N
|
|
27
|
-
- \`
|
|
28
|
-
- \`
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
- \`PUT <N @name\` / \`PUT >N @name\` paste register.
|
|
32
|
-
- \`REM\` delete file.
|
|
33
|
-
- \`MV dest/path\` move/rename file.
|
|
34
|
-
- Body rows under \`:\` headers MUST start with \`+\` (\`+TEXT\`, \`+\` for blank line).
|
|
35
|
-
If file content diverged, the engine attempts automatic 3-way merge recovery.
|
|
36
|
-
After successful patch, the tool returns the next anchor tag and live LSP diagnostics for subsequent edits.`,
|
|
22
|
+
description: `Apply content-hashed, line-anchored patches to workspace files — prevents stale edits, line drift, and indentation hallucination.
|
|
23
|
+
Each file section starts with \`[path#TAG]\` (or \`[path]\` if the hash is unknown yet). Operations:
|
|
24
|
+
- \`PUT N.=M:\` replace lines N..M (inclusive) with the following \`+TEXT\` lines.
|
|
25
|
+
- \`PUT N*:\` replace the whole syntactic block starting at line N (closing brace/indent auto-resolved).
|
|
26
|
+
- \`PUT <N:\` insert before line N (\`<1\` = head of file); \`PUT >N:\` insert after (\`>$\` = end).
|
|
27
|
+
- \`CUT N.=M [@name]\` / \`CUT N* [@name]\` delete lines and save to register; \`PUT <N @name\` / \`PUT >N @name\` paste it.
|
|
28
|
+
- \`REM\` delete file; \`MV dest/path\` move/rename file.
|
|
29
|
+
- Body rows under \`:\` headers MUST start with \`+\` (\`+\` alone = blank line).
|
|
30
|
+
On divergence the engine attempts a 3-way merge. After success the tool returns the next anchor tag and live LSP diagnostics.`,
|
|
37
31
|
parameters: Type.Object({
|
|
38
32
|
patch: Type.String({
|
|
39
33
|
description: "The complete Hashline patch text containing one or more [path#TAG] sections with PUT/CUT/REM/MV operations.",
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 权限预设相关辅助逻辑与会话回放。
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* 从会话 sessionManager entries 回放恢复最后设定的权限预设(若从未设定则返回 undefined)。
|
|
6
|
+
* customType 为 "permission/preset",载荷结构为 { preset: string }。
|
|
7
|
+
*/
|
|
8
|
+
export function readPermissionFromSession(sm) {
|
|
9
|
+
try {
|
|
10
|
+
const mgr = sm;
|
|
11
|
+
if (typeof mgr?.getEntries !== "function")
|
|
12
|
+
return undefined;
|
|
13
|
+
const entries = mgr.getEntries();
|
|
14
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
15
|
+
const e = entries[i];
|
|
16
|
+
if (e?.type === "custom" && e.customType === "permission/preset" && typeof e.data?.preset === "string") {
|
|
17
|
+
return e.data.preset;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// ignore
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
@@ -54,11 +54,21 @@ export class PlanManager {
|
|
|
54
54
|
return null;
|
|
55
55
|
const currentStep = current.steps[idx];
|
|
56
56
|
const nextStatus = patch.status && VALID_STATUSES.has(patch.status) ? patch.status : currentStep.status;
|
|
57
|
+
// patch 里剥掉 id:步骤 id 是 activeStepId / first-match 推进的锚点,
|
|
58
|
+
// 被改掉会让 activeStepId 悬空(指向不存在的步骤)。
|
|
59
|
+
const { id: _ignored, ...rest } = patch;
|
|
57
60
|
const nextStep = {
|
|
58
61
|
...currentStep,
|
|
59
|
-
...
|
|
62
|
+
...rest,
|
|
60
63
|
status: nextStatus,
|
|
64
|
+
// title/description 与 setPlan 同口径截断(200/1000),防超长内容撑爆快照。
|
|
65
|
+
title: String(rest.title ?? currentStep.title ?? "").slice(0, 200),
|
|
61
66
|
};
|
|
67
|
+
const description = String(rest.description ?? currentStep.description ?? "").slice(0, 1000);
|
|
68
|
+
if (description)
|
|
69
|
+
nextStep.description = description;
|
|
70
|
+
else
|
|
71
|
+
delete nextStep.description;
|
|
62
72
|
const nextSteps = [...current.steps];
|
|
63
73
|
nextSteps[idx] = nextStep;
|
|
64
74
|
let nextActive = current.activeStepId;
|