pi-web-ui 0.31.0 → 0.34.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/bin/pi-web-ui.mjs +291 -2
- package/dist/server/agent-service.js +245 -126
- package/dist/server/client-state.js +29 -0
- package/dist/server/index.js +96 -48
- package/dist/server/model-admin.js +94 -0
- package/dist/server/plugins.js +368 -0
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/settings-service.js +19 -0
- package/dist/server/terminals.js +485 -6
- package/package.json +96 -96
- package/themes/md-preview.css +7026 -6911
- package/themes/white.css +7087 -6972
- package/web/dist/assets/{TerminalPanel-CVOwLwwF.js → TerminalPanel-DXxlbXLf.js} +1 -1
- package/web/dist/assets/index-8y0w43os.css +10 -0
- package/web/dist/assets/index-CNrY4Azz.js +19 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BYW5b0ZE.js +0 -19
- package/web/dist/assets/index-C9LigvHH.css +0 -10
package/dist/server/index.js
CHANGED
|
@@ -20,7 +20,6 @@ import { existsSync } from "node:fs";
|
|
|
20
20
|
import { stat } from "node:fs/promises";
|
|
21
21
|
import { createServer } from "node:http";
|
|
22
22
|
import { createConnection } from "node:net";
|
|
23
|
-
import { spawn } from "node:child_process";
|
|
24
23
|
import { basename, delimiter, dirname, join, resolve, sep } from "node:path";
|
|
25
24
|
import { homedir } from "node:os";
|
|
26
25
|
import { fileURLToPath } from "node:url";
|
|
@@ -36,6 +35,7 @@ import { startControlServer } from "./control-socket.js";
|
|
|
36
35
|
import { scheduleUploadCleanup } from "./uploads.js";
|
|
37
36
|
import { ensureWindowsBash, windowsBashDir } from "./ensure-bash.js";
|
|
38
37
|
import { listThemes, resolveThemeFile } from "./themes.js";
|
|
38
|
+
import { PluginManager, resolvePluginClientFile } from "./plugins.js";
|
|
39
39
|
const PORT = Number(process.env.PORT ?? 8787);
|
|
40
40
|
const CWD = resolve(process.env.PI_WEB_CWD ?? process.cwd());
|
|
41
41
|
const DATA_DIR = resolve(process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
|
|
@@ -210,6 +210,30 @@ app.get("/themes/:id.css", (req, res) => {
|
|
|
210
210
|
res.setHeader("Cache-Control", "no-cache");
|
|
211
211
|
res.sendFile(file);
|
|
212
212
|
});
|
|
213
|
+
// Plugin client bundles: <dataDir>/plugins/<id>/client/* served at
|
|
214
|
+
// /plugins/<id>/client/* so the frontend can import() plugin views. Only the
|
|
215
|
+
// client/ subtree is exposed — manifest.json and the server-side index.mjs
|
|
216
|
+
// (which may hold credentials) never leave the machine. Registered BEFORE the
|
|
217
|
+
// SPA catch-all below.
|
|
218
|
+
const PLUGINS_DIR = join(DATA_DIR, "plugins");
|
|
219
|
+
app.get("/plugins/:id/client/*", (req, res) => {
|
|
220
|
+
// express 4 的通配参数在运行时落在 params[0],但类型声明里没有 —— 显式取
|
|
221
|
+
const rest = String(req.params[0] ?? "");
|
|
222
|
+
const abs = resolvePluginClientFile(PLUGINS_DIR, req.params.id, rest);
|
|
223
|
+
if (!abs) {
|
|
224
|
+
res.status(404).end("plugin not found");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// .mjs 常不在老 mime 表里,手动定 Content-Type 保证 import() 可用
|
|
228
|
+
if (/\.(mjs|js)$/.test(abs)) {
|
|
229
|
+
res.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
|
230
|
+
}
|
|
231
|
+
res.setHeader("Cache-Control", "no-cache"); // 开发期改文件即生效
|
|
232
|
+
res.sendFile(abs, (err) => {
|
|
233
|
+
if (err && !res.headersSent)
|
|
234
|
+
res.status(err.statusCode === 404 ? 404 : 500).end("not found");
|
|
235
|
+
});
|
|
236
|
+
});
|
|
213
237
|
/** Set in the env of the replacement child spawned by a self-update restart. */
|
|
214
238
|
const RESTART_CHILD_ENV = "PI_WEB_RESTART_CHILD";
|
|
215
239
|
const webDist = join(pkgRoot, "web", "dist");
|
|
@@ -341,50 +365,22 @@ const heartbeatTimer = setInterval(() => {
|
|
|
341
365
|
const service = new AgentService(CWD,
|
|
342
366
|
// Per-client persisted UI state: last-used workspace + recent projects.
|
|
343
367
|
join(DATA_DIR, "client-state.json"));
|
|
368
|
+
// Optional UI plugins (<dataDir>/plugins/<id>/): scanned on every client
|
|
369
|
+
// attach so freshly dropped plugins appear without a server restart.
|
|
370
|
+
const pluginMgr = new PluginManager(DATA_DIR, CWD);
|
|
371
|
+
// 插件扩展点:SDK 工具执行事件(bash/读文件等 start+end)转发给已注册的插件。
|
|
372
|
+
service.onToolEvent = (ev) => pluginMgr.emitToolEvent(ev);
|
|
373
|
+
// 插件扩展点:插件注册的 AI 工具(registerAgentTool)→ 会话创建时带上 +
|
|
374
|
+
// 变化时动态注入/移除已有会话。
|
|
375
|
+
service.pluginToolsProvider = () => pluginMgr.getAgentTools();
|
|
376
|
+
pluginMgr.onAgentToolsChanged = () => service.applyPluginAgentTools();
|
|
344
377
|
// ---------------------------------------------------------------------------
|
|
345
|
-
// Self-update
|
|
378
|
+
// Self-update
|
|
346
379
|
// ---------------------------------------------------------------------------
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
// Docker containers can't self-restart (the orchestrator owns that), so they
|
|
352
|
-
// keep the manual-restart notice.
|
|
353
|
-
function scheduleUpdateRestart() {
|
|
354
|
-
const isLaunchd = process.platform === "darwin" && process.ppid === 1;
|
|
355
|
-
const isSystemd = process.platform === "linux" && !!process.env.INVOCATION_ID;
|
|
356
|
-
const inDocker = existsSync("/.dockerenv");
|
|
357
|
-
if (isLaunchd || isSystemd || inDocker) {
|
|
358
|
-
// Supervisors relaunch on exit; Docker restarts externally. Nothing to
|
|
359
|
-
// spawn — just exit after the notice has flushed.
|
|
360
|
-
if (isLaunchd || isSystemd) {
|
|
361
|
-
setTimeout(() => {
|
|
362
|
-
console.log("update applied — auto-restarting…");
|
|
363
|
-
if (isSystemd) {
|
|
364
|
-
// Non-zero exit: legacy units use Restart=on-failure.
|
|
365
|
-
process.exit(3);
|
|
366
|
-
}
|
|
367
|
-
void shutdown();
|
|
368
|
-
}, 1500);
|
|
369
|
-
return true;
|
|
370
|
-
}
|
|
371
|
-
return false;
|
|
372
|
-
}
|
|
373
|
-
// Foreground / Windows: spawn a replacement from the updated install and
|
|
374
|
-
// exit. Same stdio (logs keep flowing), same args/env (port, cwd, data
|
|
375
|
-
// dir…); the child waits for this port to free before binding.
|
|
376
|
-
setTimeout(() => {
|
|
377
|
-
console.log("update applied — spawning replacement…");
|
|
378
|
-
spawn(process.execPath, process.argv.slice(1), {
|
|
379
|
-
stdio: "inherit",
|
|
380
|
-
env: { ...process.env, [RESTART_CHILD_ENV]: "1" },
|
|
381
|
-
...(process.platform === "win32" ? { windowsHide: true } : {}),
|
|
382
|
-
});
|
|
383
|
-
void shutdown();
|
|
384
|
-
}, 1500);
|
|
385
|
-
return true;
|
|
386
|
-
}
|
|
387
|
-
service.onUpdateReady = scheduleUpdateRestart;
|
|
380
|
+
// In-app updates now run `npm i -g pi-web-ui@latest` in a visible terminal
|
|
381
|
+
// tab (frontend-initiated); after it finishes the user restarts via
|
|
382
|
+
// `pi-web-ui server restart`. The PI_WEB_RESTART_CHILD port-wait handshake
|
|
383
|
+
// below stays: an externally orchestrated replacement child still needs it.
|
|
388
384
|
function scheduleQuit() {
|
|
389
385
|
const isLaunchd = process.platform === "darwin" && process.ppid === 1;
|
|
390
386
|
const isSystemd = process.platform === "linux" && !!process.env.INVOCATION_ID;
|
|
@@ -410,6 +406,11 @@ service.onQuit = scheduleQuit;
|
|
|
410
406
|
* ~10MB——连半份都没发完就丢,前端频繁跳帧;短会话又太迟钝。相对阈值语义稳定在
|
|
411
407
|
* 「缓冲堆了约 N 份快照」,不随会话长短漂移。 */
|
|
412
408
|
const SNAPSHOT_BACKPRESSURE_FACTOR = 3;
|
|
409
|
+
/** 背压绝对下限:低于此积压永不丢快照(小会话的相对阈值只有几 KB,会被
|
|
410
|
+
* 正常的消息突发误伤,见 send() 内注释)。 */
|
|
411
|
+
const SNAPSHOT_BACKPRESSURE_MIN_BYTES = 262_144;
|
|
412
|
+
/** 背压丢弃后的延迟重发间隔。 */
|
|
413
|
+
const SNAPSHOT_RETRY_MS = 250;
|
|
413
414
|
/**
|
|
414
415
|
* Multi-tab serialization sharing: emit() hands the SAME message object to
|
|
415
416
|
* every socket of a client, but each send() used to JSON.stringify it
|
|
@@ -436,6 +437,8 @@ wss.on("connection", (ws) => {
|
|
|
436
437
|
let lastSnapshotBytes = 0;
|
|
437
438
|
/** Commands received while the session is still being created — replayed after attach. */
|
|
438
439
|
let pending = [];
|
|
440
|
+
/** 背压丢快照后的延迟重发定时器(去重:一次只排一个)。 */
|
|
441
|
+
let snapshotRetryTimer = null;
|
|
439
442
|
// 协议层错误(非法帧/未 masked 帧等):不注册 handler 会作为 uncaught
|
|
440
443
|
// exception 打崩整个进程(issue #11 附带发现)。记日志并按坏连接关闭。
|
|
441
444
|
ws.on("error", (err) => {
|
|
@@ -452,13 +455,27 @@ wss.on("connection", (ws) => {
|
|
|
452
455
|
return;
|
|
453
456
|
// 发送背压(issue #11):socket 消费不过来时(前端慢/网络差),堆里会堆积
|
|
454
457
|
// 每份可达 ~10MB 的全量 snapshot 字符串,低内存主机直接 OOM。snapshot 是全量
|
|
455
|
-
//
|
|
458
|
+
// 幂等的且稍后必有更新的一份,可以安全丢弃——在序列化之前丢,连
|
|
456
459
|
// stringify 的分配都省掉。ready/notice/error/tool_delta 等消息必须送达。
|
|
457
460
|
// 阈值相对化(评论区建议):用「最近一份 snapshot 的字节数 × 倍数」做基准,
|
|
458
461
|
// 首份无基准不丢(首次必达)。wire.length 是 UTF-16 字符数,×2 估算字节。
|
|
462
|
+
// 下限保护(小会话误伤修复):小会话一份 snapshot 才 ~1KB,相对阈值只有几
|
|
463
|
+
// KB——前面一批 settings_state/slash_commands 的正常突发就能把 bufferedAmount
|
|
464
|
+
// 抬过阈值,把紧随其后的 snapshot_delta 静默丢掉;而丢弃后若无后续事件就
|
|
465
|
+
// 再也没有快照,客户端永远停在旧状态(前端靠 rev 缺口 get_state 自愈,
|
|
466
|
+
// 协议测试则直接卡死)。绝对下限保证小会话永不触发背压。
|
|
459
467
|
if ((msg.type === "snapshot" || msg.type === "snapshot_delta") &&
|
|
460
468
|
lastSnapshotBytes > 0 &&
|
|
461
|
-
ws.bufferedAmount > SNAPSHOT_BACKPRESSURE_FACTOR * lastSnapshotBytes) {
|
|
469
|
+
ws.bufferedAmount > Math.max(SNAPSHOT_BACKPRESSURE_MIN_BYTES, SNAPSHOT_BACKPRESSURE_FACTOR * lastSnapshotBytes)) {
|
|
470
|
+
// 真正的慢客户端:丢弃是安全的,但不能「丢完就没了」——安排一次延迟
|
|
471
|
+
// 重发,等缓冲排空后快照最终必达(否则若此后再无事件,客户端将永久
|
|
472
|
+
// 停留在旧快照)。重发仍走 flushSnapshot:缓冲未排空则再次顺延。
|
|
473
|
+
if (!snapshotRetryTimer) {
|
|
474
|
+
snapshotRetryTimer = setTimeout(() => {
|
|
475
|
+
snapshotRetryTimer = null;
|
|
476
|
+
service.get(clientId ?? "")?.flushSnapshot();
|
|
477
|
+
}, SNAPSHOT_RETRY_MS);
|
|
478
|
+
}
|
|
462
479
|
return;
|
|
463
480
|
}
|
|
464
481
|
const wire = serializeShared(msg);
|
|
@@ -466,6 +483,10 @@ wss.on("connection", (ws) => {
|
|
|
466
483
|
lastSnapshotBytes = wire.length * 2;
|
|
467
484
|
ws.send(wire);
|
|
468
485
|
};
|
|
486
|
+
// Plugins broadcast to every open socket; unregister on close below.
|
|
487
|
+
// Plugins broadcast to every open socket; unregister on close below. The
|
|
488
|
+
// cid getter lets plugins target THIS socket via host.sendTo(clientId).
|
|
489
|
+
const removePluginSender = pluginMgr.addSender(send, () => clientId);
|
|
469
490
|
const dispatch = (msg) => {
|
|
470
491
|
if (!clientId) {
|
|
471
492
|
pending.push(msg);
|
|
@@ -522,6 +543,12 @@ wss.on("connection", (ws) => {
|
|
|
522
543
|
case "list_projects":
|
|
523
544
|
void cs.pushProjects();
|
|
524
545
|
break;
|
|
546
|
+
case "remove_project":
|
|
547
|
+
void cs.removeProject(msg.path);
|
|
548
|
+
break;
|
|
549
|
+
case "delete_session":
|
|
550
|
+
void cs.deleteSession(msg.path);
|
|
551
|
+
break;
|
|
525
552
|
case "switch_session":
|
|
526
553
|
void cs.switchSession(msg.path);
|
|
527
554
|
break;
|
|
@@ -570,9 +597,6 @@ wss.on("connection", (ws) => {
|
|
|
570
597
|
case "check_update":
|
|
571
598
|
void cs.checkUpdate();
|
|
572
599
|
break;
|
|
573
|
-
case "update_app":
|
|
574
|
-
void cs.updateApp();
|
|
575
|
-
break;
|
|
576
600
|
case "dialog_response":
|
|
577
601
|
cs.resolveDialog(msg.id, msg.value);
|
|
578
602
|
break;
|
|
@@ -603,6 +627,9 @@ wss.on("connection", (ws) => {
|
|
|
603
627
|
case "refresh_provider_models":
|
|
604
628
|
void cs.refreshProviderModels(msg.providerId, msg.reqId);
|
|
605
629
|
break;
|
|
630
|
+
case "clone_provider":
|
|
631
|
+
void cs.cloneProvider(msg.provider, msg.reqId);
|
|
632
|
+
break;
|
|
606
633
|
case "terminal_create": {
|
|
607
634
|
const tm = cs.getTerminalManager(msg.conversationId);
|
|
608
635
|
if (tm)
|
|
@@ -660,7 +687,10 @@ wss.on("connection", (ws) => {
|
|
|
660
687
|
customSystemPrompt: msg.customSystemPrompt,
|
|
661
688
|
disabledSkills: msg.disabledSkills,
|
|
662
689
|
disabledExtensions: msg.disabledExtensions,
|
|
690
|
+
disabledPlugins: msg.disabledPlugins,
|
|
663
691
|
terminalToolsEnabled: msg.terminalToolsEnabled,
|
|
692
|
+
terminalBash: msg.terminalBash,
|
|
693
|
+
terminalBashIdleMs: msg.terminalBashIdleMs,
|
|
664
694
|
visionBridgeEnabled: msg.visionBridgeEnabled,
|
|
665
695
|
visionBridgeModel: msg.visionBridgeModel,
|
|
666
696
|
visionBridgePromptMode: msg.visionBridgePromptMode,
|
|
@@ -672,6 +702,12 @@ wss.on("connection", (ws) => {
|
|
|
672
702
|
case "extensions_reload":
|
|
673
703
|
void cs.reloadExtensions();
|
|
674
704
|
break;
|
|
705
|
+
case "plugin_message":
|
|
706
|
+
pluginMgr.handleMessage(msg.pluginId, msg.payload, clientId ?? undefined);
|
|
707
|
+
break;
|
|
708
|
+
case "plugins_reload":
|
|
709
|
+
void pluginMgr.reload().then(() => pluginMgr.pushToAll());
|
|
710
|
+
break;
|
|
675
711
|
case "save_preset":
|
|
676
712
|
void cs.savePreset(msg.name);
|
|
677
713
|
break;
|
|
@@ -708,6 +744,12 @@ wss.on("connection", (ws) => {
|
|
|
708
744
|
protocolVersion: PROTOCOL_VERSION,
|
|
709
745
|
});
|
|
710
746
|
cs.flushSnapshot();
|
|
747
|
+
// Plugin catalog: re-scan + activate new dirs on every attach so
|
|
748
|
+
// freshly dropped plugins show up without a server restart.
|
|
749
|
+
pluginMgr
|
|
750
|
+
.ensureLoaded()
|
|
751
|
+
.then((plugins) => send({ type: "plugins", plugins, epoch: pluginMgr.epoch }))
|
|
752
|
+
.catch(() => { });
|
|
711
753
|
// Replay anything that arrived while the session was starting.
|
|
712
754
|
const queued = pending;
|
|
713
755
|
pending = [];
|
|
@@ -742,6 +784,11 @@ wss.on("connection", (ws) => {
|
|
|
742
784
|
service.noteSocketClose();
|
|
743
785
|
closed = true;
|
|
744
786
|
pending = [];
|
|
787
|
+
removePluginSender();
|
|
788
|
+
if (snapshotRetryTimer) {
|
|
789
|
+
clearTimeout(snapshotRetryTimer);
|
|
790
|
+
snapshotRetryTimer = null;
|
|
791
|
+
}
|
|
745
792
|
if (clientId)
|
|
746
793
|
service.detach(clientId, send);
|
|
747
794
|
});
|
|
@@ -792,6 +839,7 @@ async function shutdown() {
|
|
|
792
839
|
console.log("\nshutting down…");
|
|
793
840
|
clearInterval(heartbeatTimer);
|
|
794
841
|
stopControl();
|
|
842
|
+
pluginMgr.dispose();
|
|
795
843
|
await service.disposeAll();
|
|
796
844
|
wss.close();
|
|
797
845
|
httpServer.close();
|
|
@@ -233,6 +233,100 @@ export class ModelAdminService {
|
|
|
233
233
|
}
|
|
234
234
|
this.host.flushSnapshot();
|
|
235
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* Copy a BUILT-IN provider (baseUrl + current model catalog) into an
|
|
238
|
+
* editable custom-provider draft and return it via clone_provider_result.
|
|
239
|
+
* Nothing is persisted — the user renames the draft, pastes a DIFFERENT
|
|
240
|
+
* API key in the form, then saves via save_model_config. Credentials are
|
|
241
|
+
* never copied: the whole point is running a second key alongside the
|
|
242
|
+
* built-in one without touching it.
|
|
243
|
+
*/
|
|
244
|
+
async cloneProvider(providerId, reqId) {
|
|
245
|
+
const pid = providerId.trim();
|
|
246
|
+
const fail = (error) => this.host.emit({ type: "clone_provider_result", reqId, ok: false, error });
|
|
247
|
+
try {
|
|
248
|
+
if (!pid) {
|
|
249
|
+
fail("请填写服务商 ID");
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const mr = this.host.modelRuntime();
|
|
253
|
+
const p = mr.getProvider(pid);
|
|
254
|
+
if (!p) {
|
|
255
|
+
fail(`供应商 ${pid} 不存在`);
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (!p.baseUrl) {
|
|
259
|
+
fail(`${pid} 没有 baseUrl(OAuth/环境变量型供应商),无法复制为自定义服务商`);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
// Map runtime models → models.json rows; dynamic providers ship an
|
|
263
|
+
// empty catalog until refreshed over the network.
|
|
264
|
+
const readModels = () => {
|
|
265
|
+
try {
|
|
266
|
+
return mr.getModels(pid).map((m) => ({
|
|
267
|
+
api: m.api,
|
|
268
|
+
entry: {
|
|
269
|
+
id: m.id,
|
|
270
|
+
...(m.name && m.name !== m.id ? { name: m.name } : {}),
|
|
271
|
+
...(m.reasoning ? { reasoning: true } : {}),
|
|
272
|
+
...(m.input?.includes("image")
|
|
273
|
+
? { input: ["text", "image"] }
|
|
274
|
+
: {}),
|
|
275
|
+
...(m.contextWindow ? { contextWindow: m.contextWindow } : {}),
|
|
276
|
+
...(m.maxTokens ? { maxTokens: m.maxTokens } : {}),
|
|
277
|
+
},
|
|
278
|
+
}));
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
let models = readModels();
|
|
285
|
+
if (models.length === 0) {
|
|
286
|
+
await mr.refresh({ allowNetwork: true });
|
|
287
|
+
models = readModels();
|
|
288
|
+
}
|
|
289
|
+
if (models.length === 0) {
|
|
290
|
+
fail(`${pid} 的模型列表为空,无法复制(请稍后重试)`);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
// models.json 的 api 是 provider 级:取占比最高的 api,只复制该 api 的模型。
|
|
294
|
+
const counts = new Map();
|
|
295
|
+
for (const m of models)
|
|
296
|
+
counts.set(m.api, (counts.get(m.api) ?? 0) + 1);
|
|
297
|
+
let api = models[0].api;
|
|
298
|
+
for (const [k, v] of counts)
|
|
299
|
+
if (v > (counts.get(api) ?? 0))
|
|
300
|
+
api = k;
|
|
301
|
+
const kept = models.filter((m) => m.api === api).map((m) => m.entry);
|
|
302
|
+
// Suggest a free id (<pid>-2, -3, …) — save_model_config would silently
|
|
303
|
+
// overwrite an existing custom entry with the same id.
|
|
304
|
+
const taken = new Set([
|
|
305
|
+
...Object.keys(this.readModelsConfig().providers),
|
|
306
|
+
...mr.getRegisteredProviderIds(),
|
|
307
|
+
]);
|
|
308
|
+
let newId = `${pid}-2`;
|
|
309
|
+
for (let n = 2; taken.has(newId); n++)
|
|
310
|
+
newId = `${pid}-${n}`;
|
|
311
|
+
const config = {
|
|
312
|
+
providerId: newId,
|
|
313
|
+
name: p.name,
|
|
314
|
+
api,
|
|
315
|
+
baseUrl: p.baseUrl,
|
|
316
|
+
models: kept,
|
|
317
|
+
};
|
|
318
|
+
this.host.emit({
|
|
319
|
+
type: "notice",
|
|
320
|
+
level: "info",
|
|
321
|
+
text: `📋 已复制 ${pid} → ${newId}(${kept.length} 个模型),请填入新的 API 密钥后保存`,
|
|
322
|
+
});
|
|
323
|
+
this.host.emit({ type: "clone_provider_result", reqId, ok: true, config });
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
fail(`复制服务商失败:${err.message}`);
|
|
327
|
+
}
|
|
328
|
+
this.host.flushSnapshot();
|
|
329
|
+
}
|
|
236
330
|
/** Enumerate pi's built-in providers with auth status (key-only config). */
|
|
237
331
|
async listProviders() {
|
|
238
332
|
const mr = this.host.modelRuntime();
|