pi-web-ui 0.94.1 → 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 +91 -2
- package/README.md +5 -6
- package/README.zh-CN.md +4 -5
- package/bin/pi-web-ui.mjs +910 -237
- package/dist/server/agent-service.js +2655 -231
- package/dist/server/approval-rules.js +596 -0
- package/dist/server/attachment-store.js +129 -0
- package/dist/server/attachments.js +92 -174
- 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 +67 -14
- package/dist/server/compact-context-tool.js +126 -0
- package/dist/server/composer-drafts.js +9 -0
- package/dist/server/context-budget.js +317 -0
- package/dist/server/control-socket.js +57 -28
- package/dist/server/conversation-read-tool.js +7 -17
- package/dist/server/dangling-tools.js +229 -0
- package/dist/server/delegate-task.js +25 -28
- package/dist/server/dsh/dsh-agent-service.js +69 -66
- package/dist/server/edit-soft-tool.js +84 -16
- package/dist/server/eval-tool.js +588 -0
- package/dist/server/file-archives.js +16 -5
- package/dist/server/files-service.js +65 -18
- package/dist/server/goal-service.js +371 -77
- package/dist/server/hashline-engine.js +703 -0
- package/dist/server/host-guard.js +94 -0
- package/dist/server/host-metrics.js +26 -2
- package/dist/server/i18n.js +3 -3
- package/dist/server/index.js +456 -80
- package/dist/server/lsp-tool.js +1371 -0
- package/dist/server/mcp-bridge.js +47 -3
- package/dist/server/model-admin.js +112 -32
- package/dist/server/office-parse.js +375 -0
- package/dist/server/patch-tool.js +85 -0
- package/dist/server/permission-preset.js +25 -0
- package/dist/server/plan-manager.js +114 -0
- package/dist/server/plugin-api-catalog.js +296 -0
- 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-install-spec.js +196 -0
- package/dist/server/plugin-installer.js +73 -0
- package/dist/server/plugin-llm.js +71 -65
- package/dist/server/plugin-manifest-validate.js +305 -0
- package/dist/server/plugin-project.js +117 -13
- package/dist/server/plugin-tool-guard.js +120 -0
- package/dist/server/plugin-updater.js +115 -12
- package/dist/server/plugins.js +950 -205
- package/dist/server/present-files-tool.js +9 -12
- package/dist/server/process-utils.js +16 -6
- 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/scheduler-tasks.js +6 -0
- package/dist/server/sdk-origin.js +18 -2
- package/dist/server/serialize.js +111 -14
- package/dist/server/settings-service.js +112 -3
- package/dist/server/skill-tool.js +5 -7
- package/dist/server/subagent-templates.js +51 -0
- package/dist/server/subagents.js +83 -66
- package/dist/server/terminals.js +248 -81
- package/dist/server/tool-approval.js +84 -0
- package/dist/server/tool-manager.js +237 -12
- 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/workspace-snapshot.js +113 -0
- 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 +5 -3
- package/plugin-sdk/README.md +25 -0
- package/plugin-sdk/index.d.ts +31 -38
- package/plugin-sdk/index.mjs +35 -19
- package/plugins/catalog.json +40 -0
- package/themes/aetheris.css +457 -0
- package/themes/ayu-light.css +6 -6
- package/themes/catppuccin-latte.css +6 -6
- package/themes/claude-code-dark.css +144 -0
- package/themes/codex.css +6 -6
- package/themes/everforest-light.css +6 -6
- package/themes/geist.css +6 -6
- package/themes/gruvbox-light.css +6 -6
- package/themes/kanagawa-lotus.css +6 -6
- package/themes/rose-pine-dawn.css +6 -6
- package/themes/solarized-light.css +6 -6
- package/themes/vs-code-dark.css +146 -0
- package/themes/zhupi-dark.css +658 -0
- package/themes/zhupi.css +711 -0
- package/web/dist/assets/{TerminalPanel-MVoxpJOA.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-B3S9MxnN.css +0 -1
- package/web/dist/assets/index-DVLrHI2E.js +0 -364
|
@@ -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,18 +1280,50 @@ 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) {
|
|
1244
1303
|
emitError(err.message);
|
|
1245
1304
|
}
|
|
1246
1305
|
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Lightweight connectivity and auth probe for a provider endpoint.
|
|
1308
|
+
* Measures latency in ms and returns ok/error.
|
|
1309
|
+
*/
|
|
1310
|
+
async testConnection(reqId, baseUrl, apiKey, authHeader, api, lang, providerId) {
|
|
1311
|
+
const start = Date.now();
|
|
1312
|
+
try {
|
|
1313
|
+
const key = apiKey?.trim() || this.savedProviderApiKey(providerId);
|
|
1314
|
+
await ModelAdminService.probeModelsEndpoint(baseUrl, key, authHeader, api, undefined, lang);
|
|
1315
|
+
const latencyMs = Date.now() - start;
|
|
1316
|
+
this.host.emit({ type: "test_model_connection_result", reqId, ok: true, latencyMs });
|
|
1317
|
+
}
|
|
1318
|
+
catch (err) {
|
|
1319
|
+
this.host.emit({
|
|
1320
|
+
type: "test_model_connection_result",
|
|
1321
|
+
reqId,
|
|
1322
|
+
ok: false,
|
|
1323
|
+
error: err.message,
|
|
1324
|
+
});
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1247
1327
|
/**
|
|
1248
1328
|
* Probe a custom provider's model-list endpoint (OpenAI-compatible /models
|
|
1249
1329
|
* with a /v1 retry; Google {models:[…]} shape supported). Throws Error with
|
|
@@ -1525,7 +1605,7 @@ export class ModelAdminService {
|
|
|
1525
1605
|
if (graduated.length === 0)
|
|
1526
1606
|
return graduated;
|
|
1527
1607
|
try {
|
|
1528
|
-
|
|
1608
|
+
atomicWriteJson(this.modelsConfigPath(), { providers });
|
|
1529
1609
|
}
|
|
1530
1610
|
catch {
|
|
1531
1611
|
return [];
|
|
@@ -1562,7 +1642,7 @@ export class ModelAdminService {
|
|
|
1562
1642
|
* config list + the picker. Callers emit their own notice. */
|
|
1563
1643
|
async writeModelsConfigAndReload(providers, pid) {
|
|
1564
1644
|
mkdirSync(this.host.agentDir, { recursive: true });
|
|
1565
|
-
|
|
1645
|
+
atomicWriteJson(this.modelsConfigPath(), { providers });
|
|
1566
1646
|
// Allow a models.json entry to reuse the provider credential already
|
|
1567
1647
|
// stored in auth.json. Seed the shared runtime too, because older pi-ai
|
|
1568
1648
|
// versions did not always fall back to stored credentials for a
|
|
@@ -1719,7 +1799,7 @@ export class ModelAdminService {
|
|
|
1719
1799
|
return;
|
|
1720
1800
|
}
|
|
1721
1801
|
delete providers[providerId];
|
|
1722
|
-
|
|
1802
|
+
atomicWriteJson(this.modelsConfigPath(), { providers });
|
|
1723
1803
|
await this.host.modelRuntime().refresh();
|
|
1724
1804
|
this.host.invalidatePiConfig();
|
|
1725
1805
|
await this.listModelsConfig();
|