pi-web-ui 0.31.0 → 0.32.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/LICENSE +21 -21
- package/README.md +295 -295
- package/README.zh-CN.md +279 -279
- package/bin/pi-web-ui.mjs +0 -0
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
- package/deploy/nginx-subpath.conf +88 -88
- package/deploy/pi-web-ui-task.xml +71 -71
- package/deploy/pi-web-ui.service +31 -31
- package/dist/server/agent-service.js +14 -127
- package/dist/server/attachments.js +3 -3
- package/dist/server/index.js +35 -48
- package/dist/server/model-admin.js +94 -0
- package/dist/server/terminals.js +4 -4
- package/extensions/webui.ts +192 -192
- package/package.json +1 -1
- package/web/dist/assets/{TerminalPanel-CVOwLwwF.js → TerminalPanel-D9N7NOQl.js} +1 -1
- package/web/dist/assets/index-vcrKbPaa.js +19 -0
- package/web/dist/favicon.svg +8 -8
- package/web/dist/index.html +14 -14
- package/web/public/favicon.svg +8 -8
- package/web/dist/assets/index-BYW5b0ZE.js +0 -19
|
@@ -79,14 +79,14 @@ export class QuiesceRejectedError extends Error {
|
|
|
79
79
|
* programs wait for input that never comes. Legacy Chinese files are often
|
|
80
80
|
* GBK/GB2312 — read them with the right encoding, never paste mojibake into
|
|
81
81
|
* reasoning/answers. */
|
|
82
|
-
const WINDOWS_PERSONA = `You are a coding agent running on Windows. The bash tool runs Git Bash (bash.exe), not PowerShell. Follow these rules to avoid hanging the session:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
- ALWAYS pass a timeout parameter to the bash tool (in seconds). There is NO default timeout — a command that never finishes (servers, watchers, infinite loops, slow downloads/installs) will hang the entire conversation indefinitely. Pick a generous timeout for long-running work, but never omit it.
|
|
87
|
-
- NEVER run interactive or foreground long-running commands through the bash tool (vi, less, top, python -, node -, npm run dev, sleep 10000). For servers/daemons use background execution with output redirected to a log file, then poll the log; stop them when done.
|
|
88
|
-
- In the interactive terminal (TTY) — which is Git Bash too, not PowerShell — NEVER use heredocs (<<'EOF' ... EOF) or here-strings, and NEVER start interactive programs (vi, less, python -, node -, npm init): they wait for keyboard input that never arrives and hang the terminal forever. Prefer writing a temp script file (e.g. .pi-tmp.sh) and running it non-interactively. ALWAYS pass a timeout to long-running commands (e.g. \`timeout 120 npm run dev\`).
|
|
89
|
-
|
|
82
|
+
const WINDOWS_PERSONA = `You are a coding agent running on Windows. The bash tool runs Git Bash (bash.exe), not PowerShell. Follow these rules to avoid hanging the session:
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
- ALWAYS pass a timeout parameter to the bash tool (in seconds). There is NO default timeout — a command that never finishes (servers, watchers, infinite loops, slow downloads/installs) will hang the entire conversation indefinitely. Pick a generous timeout for long-running work, but never omit it.
|
|
87
|
+
- NEVER run interactive or foreground long-running commands through the bash tool (vi, less, top, python -, node -, npm run dev, sleep 10000). For servers/daemons use background execution with output redirected to a log file, then poll the log; stop them when done.
|
|
88
|
+
- In the interactive terminal (TTY) — which is Git Bash too, not PowerShell — NEVER use heredocs (<<'EOF' ... EOF) or here-strings, and NEVER start interactive programs (vi, less, python -, node -, npm init): they wait for keyboard input that never arrives and hang the terminal forever. Prefer writing a temp script file (e.g. .pi-tmp.sh) and running it non-interactively. ALWAYS pass a timeout to long-running commands (e.g. \`timeout 120 npm run dev\`).
|
|
89
|
+
|
|
90
90
|
Many legacy Chinese text files (.html/.txt/.md/.log, exported documents) are GBK/GB2312 encoded: the read tool decodes UTF-8 only and will show mojibake (乱码) for them. If a file's content looks garbled, read it through the terminal instead: in Git Bash use \`cat file | iconv -f GBK -t UTF-8\` (or \`iconv -f GBK -t UTF-8 file\`); in cmd use \`chcp 65001 && type file\`; in PowerShell use \`Get-Content -Encoding Default file\`. Never paste mojibake into your reasoning or answer — describe the decoded content instead.`;
|
|
91
91
|
/**
|
|
92
92
|
* Killable bash tool: wraps the SDK bash tool with operations that register
|
|
@@ -1238,13 +1238,6 @@ export class ClientSession {
|
|
|
1238
1238
|
}
|
|
1239
1239
|
return 0;
|
|
1240
1240
|
}
|
|
1241
|
-
/** True once updateApp succeeded — the process must restart to run new code. */
|
|
1242
|
-
pendingRestart = false;
|
|
1243
|
-
/**
|
|
1244
|
-
* Set by index.ts: called after a successful self-update; returns whether
|
|
1245
|
-
* the process is going to restart itself (so the notice can say so).
|
|
1246
|
-
*/
|
|
1247
|
-
onUpdateReady = undefined;
|
|
1248
1241
|
/** Set by index.ts: called when /pi-web-ui:quit is invoked. */
|
|
1249
1242
|
onQuit = undefined;
|
|
1250
1243
|
/** Ask the npm registry for the latest pi-web-ui version and report it. */
|
|
@@ -1269,7 +1262,6 @@ export class ClientSession {
|
|
|
1269
1262
|
latest,
|
|
1270
1263
|
latestPublishedAt,
|
|
1271
1264
|
upToDate,
|
|
1272
|
-
pendingRestart: this.pendingRestart,
|
|
1273
1265
|
});
|
|
1274
1266
|
}
|
|
1275
1267
|
catch (err) {
|
|
@@ -1279,115 +1271,10 @@ export class ClientSession {
|
|
|
1279
1271
|
latest: null,
|
|
1280
1272
|
latestPublishedAt: null,
|
|
1281
1273
|
upToDate: false,
|
|
1282
|
-
pendingRestart: this.pendingRestart,
|
|
1283
1274
|
error: `检查更新失败:${err.message}`,
|
|
1284
1275
|
});
|
|
1285
1276
|
}
|
|
1286
1277
|
}
|
|
1287
|
-
/**
|
|
1288
|
-
* After `npm i -g`, confirm the on-disk package this process serves from
|
|
1289
|
-
* actually changed to the new version and is complete. Windows npm updates
|
|
1290
|
-
* can fail partway (locked files / Defender / npm rollback) and leave the
|
|
1291
|
-
* global install without its bin links — restarting into that is a silent
|
|
1292
|
-
* crash (web/dist missing + `pi-web-ui` no longer on PATH). Returns null
|
|
1293
|
-
* when OK, else a human-readable problem description.
|
|
1294
|
-
*/
|
|
1295
|
-
static verifyGlobalInstall() {
|
|
1296
|
-
try {
|
|
1297
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
1298
|
-
const pkgRoot = resolve(here, "..", "..");
|
|
1299
|
-
const pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8"));
|
|
1300
|
-
if (!pkg.version || pkg.version === ClientSession.currentAppVersion()) {
|
|
1301
|
-
return `安装目录版本未变化(${pkg.version ?? "未知"})`;
|
|
1302
|
-
}
|
|
1303
|
-
if (!existsSync(join(pkgRoot, "web", "dist", "index.html"))) {
|
|
1304
|
-
return "web/dist/index.html 缺失(前端产物未安装完整)";
|
|
1305
|
-
}
|
|
1306
|
-
if (!existsSync(join(pkgRoot, "bin", "pi-web-ui.mjs"))) {
|
|
1307
|
-
return "bin/pi-web-ui.mjs 缺失";
|
|
1308
|
-
}
|
|
1309
|
-
if (process.platform === "win32") {
|
|
1310
|
-
const prefix = dirname(process.execPath);
|
|
1311
|
-
const hasShim = existsSync(join(prefix, "pi-web-ui.cmd")) ||
|
|
1312
|
-
existsSync(join(prefix, "pi-web-ui.ps1"));
|
|
1313
|
-
if (!hasShim)
|
|
1314
|
-
return "pi-web-ui 命令入口(bin 链接)未生成";
|
|
1315
|
-
}
|
|
1316
|
-
return null;
|
|
1317
|
-
}
|
|
1318
|
-
catch (err) {
|
|
1319
|
-
return `读取安装目录失败:${err.message}`;
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
/** npm i -g pi-web-ui@latest — the new code only takes effect after a restart. */
|
|
1323
|
-
async updateApp() {
|
|
1324
|
-
try {
|
|
1325
|
-
this.emit({
|
|
1326
|
-
type: "notice",
|
|
1327
|
-
level: "info",
|
|
1328
|
-
text: "正在更新 pi-web-ui(npm i -g pi-web-ui@latest)…",
|
|
1329
|
-
});
|
|
1330
|
-
const { code, out } = await this.runAsync("npm", ["i", "-g", "pi-web-ui@latest"], 180_000);
|
|
1331
|
-
if (code !== 0) {
|
|
1332
|
-
this.emit({
|
|
1333
|
-
type: "update_result",
|
|
1334
|
-
ok: false,
|
|
1335
|
-
detail: `npm i 失败(${code ?? "timeout"}):${out.slice(0, 400)}`,
|
|
1336
|
-
});
|
|
1337
|
-
this.emit({
|
|
1338
|
-
type: "notice",
|
|
1339
|
-
level: "error",
|
|
1340
|
-
text: `更新 pi-web-ui 失败(${code ?? "timeout"}):${out.slice(0, 300)}`,
|
|
1341
|
-
});
|
|
1342
|
-
return;
|
|
1343
|
-
}
|
|
1344
|
-
// npm reported success, but on Windows the replacement can be partial
|
|
1345
|
-
// (locked files, rollback) — restarting into a broken install is a
|
|
1346
|
-
// crash with no hint. Verify before handing over.
|
|
1347
|
-
const problem = ClientSession.verifyGlobalInstall();
|
|
1348
|
-
if (problem) {
|
|
1349
|
-
this.emit({
|
|
1350
|
-
type: "update_result",
|
|
1351
|
-
ok: false,
|
|
1352
|
-
detail: `npm i 成功但安装不完整(${problem})。请手动执行 npm i -g pi-web-ui@latest 修复后再重启服务。`,
|
|
1353
|
-
});
|
|
1354
|
-
this.emit({
|
|
1355
|
-
type: "notice",
|
|
1356
|
-
level: "error",
|
|
1357
|
-
text: `更新未完整生效(${problem})。请手动执行 npm i -g pi-web-ui@latest 修复`,
|
|
1358
|
-
});
|
|
1359
|
-
return;
|
|
1360
|
-
}
|
|
1361
|
-
this.pendingRestart = true;
|
|
1362
|
-
this.emit({
|
|
1363
|
-
type: "update_result",
|
|
1364
|
-
ok: true,
|
|
1365
|
-
detail: out.slice(0, 400),
|
|
1366
|
-
});
|
|
1367
|
-
const autoRestart = this.onUpdateReady?.() ?? false;
|
|
1368
|
-
this.emit({
|
|
1369
|
-
type: "notice",
|
|
1370
|
-
level: "info",
|
|
1371
|
-
text: autoRestart
|
|
1372
|
-
? "✅ 已更新 pi-web-ui,正在自动重启…"
|
|
1373
|
-
: "✅ 已更新 pi-web-ui,重启服务后生效(pi-web-ui server restart)",
|
|
1374
|
-
});
|
|
1375
|
-
}
|
|
1376
|
-
catch (err) {
|
|
1377
|
-
this.emit({
|
|
1378
|
-
type: "update_result",
|
|
1379
|
-
ok: false,
|
|
1380
|
-
detail: String(err),
|
|
1381
|
-
});
|
|
1382
|
-
this.emit({
|
|
1383
|
-
type: "notice",
|
|
1384
|
-
level: "error",
|
|
1385
|
-
text: `更新 pi-web-ui 失败:${err.message}`,
|
|
1386
|
-
});
|
|
1387
|
-
}
|
|
1388
|
-
// Re-check so the UI reflects the new state (pendingRestart included).
|
|
1389
|
-
void this.checkUpdate();
|
|
1390
|
-
}
|
|
1391
1278
|
async installPiAgent() {
|
|
1392
1279
|
try {
|
|
1393
1280
|
mkdirSync(this.agentDir, { recursive: true });
|
|
@@ -1491,6 +1378,12 @@ export class ClientSession {
|
|
|
1491
1378
|
refreshProviderModels(providerId, reqId) {
|
|
1492
1379
|
return this.modelAdmin.refreshProviderModels(providerId, reqId);
|
|
1493
1380
|
}
|
|
1381
|
+
/** Copy a built-in provider into an editable custom-provider draft
|
|
1382
|
+
* (clone_provider_result) — lets the user run a second API key without
|
|
1383
|
+
* overwriting the built-in one. */
|
|
1384
|
+
cloneProvider(providerId, reqId) {
|
|
1385
|
+
return this.modelAdmin.cloneProvider(providerId, reqId);
|
|
1386
|
+
}
|
|
1494
1387
|
saveModelConfig(providerId, config) {
|
|
1495
1388
|
return this.modelAdmin.saveModelConfig(providerId, config);
|
|
1496
1389
|
}
|
|
@@ -2485,11 +2378,6 @@ export class AgentService {
|
|
|
2485
2378
|
socketCount = 0;
|
|
2486
2379
|
pending = new Map();
|
|
2487
2380
|
stateStore;
|
|
2488
|
-
/**
|
|
2489
|
-
* Set by index.ts: called by a client session after a successful
|
|
2490
|
-
* self-update; returns whether the process will restart itself.
|
|
2491
|
-
*/
|
|
2492
|
-
onUpdateReady = undefined;
|
|
2493
2381
|
/** Set by index.ts: called when /pi-web-ui:quit is invoked. */
|
|
2494
2382
|
onQuit = undefined;
|
|
2495
2383
|
constructor(cwd, stateFile) {
|
|
@@ -2603,7 +2491,6 @@ export class AgentService {
|
|
|
2603
2491
|
cs.notifyInterrupted(this.stateStore.takeInterrupted(clientId));
|
|
2604
2492
|
cs.attachSink(send);
|
|
2605
2493
|
// Forward hooks (set once by index.ts) to every session.
|
|
2606
|
-
cs.onUpdateReady = this.onUpdateReady;
|
|
2607
2494
|
cs.onQuit = this.onQuit;
|
|
2608
2495
|
cs.isQuiesced = () => this.quiesced;
|
|
2609
2496
|
return cs;
|
|
@@ -397,9 +397,9 @@ export async function buildAttachmentMessages(ctx, attachments) {
|
|
|
397
397
|
content: [
|
|
398
398
|
{
|
|
399
399
|
type: "text",
|
|
400
|
-
text: `
|
|
401
|
-
<vision-bridge>
|
|
402
|
-
${transcript}
|
|
400
|
+
text: `
|
|
401
|
+
<vision-bridge>
|
|
402
|
+
${transcript}
|
|
403
403
|
</vision-bridge>`,
|
|
404
404
|
},
|
|
405
405
|
...(pathImg
|
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";
|
|
@@ -342,49 +341,12 @@ const service = new AgentService(CWD,
|
|
|
342
341
|
// Per-client persisted UI state: last-used workspace + recent projects.
|
|
343
342
|
join(DATA_DIR, "client-state.json"));
|
|
344
343
|
// ---------------------------------------------------------------------------
|
|
345
|
-
// Self-update
|
|
344
|
+
// Self-update
|
|
346
345
|
// ---------------------------------------------------------------------------
|
|
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;
|
|
346
|
+
// In-app updates now run `npm i -g pi-web-ui@latest` in a visible terminal
|
|
347
|
+
// tab (frontend-initiated); after it finishes the user restarts via
|
|
348
|
+
// `pi-web-ui server restart`. The PI_WEB_RESTART_CHILD port-wait handshake
|
|
349
|
+
// below stays: an externally orchestrated replacement child still needs it.
|
|
388
350
|
function scheduleQuit() {
|
|
389
351
|
const isLaunchd = process.platform === "darwin" && process.ppid === 1;
|
|
390
352
|
const isSystemd = process.platform === "linux" && !!process.env.INVOCATION_ID;
|
|
@@ -410,6 +372,11 @@ service.onQuit = scheduleQuit;
|
|
|
410
372
|
* ~10MB——连半份都没发完就丢,前端频繁跳帧;短会话又太迟钝。相对阈值语义稳定在
|
|
411
373
|
* 「缓冲堆了约 N 份快照」,不随会话长短漂移。 */
|
|
412
374
|
const SNAPSHOT_BACKPRESSURE_FACTOR = 3;
|
|
375
|
+
/** 背压绝对下限:低于此积压永不丢快照(小会话的相对阈值只有几 KB,会被
|
|
376
|
+
* 正常的消息突发误伤,见 send() 内注释)。 */
|
|
377
|
+
const SNAPSHOT_BACKPRESSURE_MIN_BYTES = 262_144;
|
|
378
|
+
/** 背压丢弃后的延迟重发间隔。 */
|
|
379
|
+
const SNAPSHOT_RETRY_MS = 250;
|
|
413
380
|
/**
|
|
414
381
|
* Multi-tab serialization sharing: emit() hands the SAME message object to
|
|
415
382
|
* every socket of a client, but each send() used to JSON.stringify it
|
|
@@ -436,6 +403,8 @@ wss.on("connection", (ws) => {
|
|
|
436
403
|
let lastSnapshotBytes = 0;
|
|
437
404
|
/** Commands received while the session is still being created — replayed after attach. */
|
|
438
405
|
let pending = [];
|
|
406
|
+
/** 背压丢快照后的延迟重发定时器(去重:一次只排一个)。 */
|
|
407
|
+
let snapshotRetryTimer = null;
|
|
439
408
|
// 协议层错误(非法帧/未 masked 帧等):不注册 handler 会作为 uncaught
|
|
440
409
|
// exception 打崩整个进程(issue #11 附带发现)。记日志并按坏连接关闭。
|
|
441
410
|
ws.on("error", (err) => {
|
|
@@ -452,13 +421,27 @@ wss.on("connection", (ws) => {
|
|
|
452
421
|
return;
|
|
453
422
|
// 发送背压(issue #11):socket 消费不过来时(前端慢/网络差),堆里会堆积
|
|
454
423
|
// 每份可达 ~10MB 的全量 snapshot 字符串,低内存主机直接 OOM。snapshot 是全量
|
|
455
|
-
//
|
|
424
|
+
// 幂等的且稍后必有更新的一份,可以安全丢弃——在序列化之前丢,连
|
|
456
425
|
// stringify 的分配都省掉。ready/notice/error/tool_delta 等消息必须送达。
|
|
457
426
|
// 阈值相对化(评论区建议):用「最近一份 snapshot 的字节数 × 倍数」做基准,
|
|
458
427
|
// 首份无基准不丢(首次必达)。wire.length 是 UTF-16 字符数,×2 估算字节。
|
|
428
|
+
// 下限保护(小会话误伤修复):小会话一份 snapshot 才 ~1KB,相对阈值只有几
|
|
429
|
+
// KB——前面一批 settings_state/slash_commands 的正常突发就能把 bufferedAmount
|
|
430
|
+
// 抬过阈值,把紧随其后的 snapshot_delta 静默丢掉;而丢弃后若无后续事件就
|
|
431
|
+
// 再也没有快照,客户端永远停在旧状态(前端靠 rev 缺口 get_state 自愈,
|
|
432
|
+
// 协议测试则直接卡死)。绝对下限保证小会话永不触发背压。
|
|
459
433
|
if ((msg.type === "snapshot" || msg.type === "snapshot_delta") &&
|
|
460
434
|
lastSnapshotBytes > 0 &&
|
|
461
|
-
ws.bufferedAmount > SNAPSHOT_BACKPRESSURE_FACTOR * lastSnapshotBytes) {
|
|
435
|
+
ws.bufferedAmount > Math.max(SNAPSHOT_BACKPRESSURE_MIN_BYTES, SNAPSHOT_BACKPRESSURE_FACTOR * lastSnapshotBytes)) {
|
|
436
|
+
// 真正的慢客户端:丢弃是安全的,但不能「丢完就没了」——安排一次延迟
|
|
437
|
+
// 重发,等缓冲排空后快照最终必达(否则若此后再无事件,客户端将永久
|
|
438
|
+
// 停留在旧快照)。重发仍走 flushSnapshot:缓冲未排空则再次顺延。
|
|
439
|
+
if (!snapshotRetryTimer) {
|
|
440
|
+
snapshotRetryTimer = setTimeout(() => {
|
|
441
|
+
snapshotRetryTimer = null;
|
|
442
|
+
service.get(clientId ?? "")?.flushSnapshot();
|
|
443
|
+
}, SNAPSHOT_RETRY_MS);
|
|
444
|
+
}
|
|
462
445
|
return;
|
|
463
446
|
}
|
|
464
447
|
const wire = serializeShared(msg);
|
|
@@ -570,9 +553,6 @@ wss.on("connection", (ws) => {
|
|
|
570
553
|
case "check_update":
|
|
571
554
|
void cs.checkUpdate();
|
|
572
555
|
break;
|
|
573
|
-
case "update_app":
|
|
574
|
-
void cs.updateApp();
|
|
575
|
-
break;
|
|
576
556
|
case "dialog_response":
|
|
577
557
|
cs.resolveDialog(msg.id, msg.value);
|
|
578
558
|
break;
|
|
@@ -603,6 +583,9 @@ wss.on("connection", (ws) => {
|
|
|
603
583
|
case "refresh_provider_models":
|
|
604
584
|
void cs.refreshProviderModels(msg.providerId, msg.reqId);
|
|
605
585
|
break;
|
|
586
|
+
case "clone_provider":
|
|
587
|
+
void cs.cloneProvider(msg.provider, msg.reqId);
|
|
588
|
+
break;
|
|
606
589
|
case "terminal_create": {
|
|
607
590
|
const tm = cs.getTerminalManager(msg.conversationId);
|
|
608
591
|
if (tm)
|
|
@@ -742,6 +725,10 @@ wss.on("connection", (ws) => {
|
|
|
742
725
|
service.noteSocketClose();
|
|
743
726
|
closed = true;
|
|
744
727
|
pending = [];
|
|
728
|
+
if (snapshotRetryTimer) {
|
|
729
|
+
clearTimeout(snapshotRetryTimer);
|
|
730
|
+
snapshotRetryTimer = null;
|
|
731
|
+
}
|
|
745
732
|
if (clientId)
|
|
746
733
|
service.detach(clientId, send);
|
|
747
734
|
});
|
|
@@ -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();
|
package/dist/server/terminals.js
CHANGED
|
@@ -790,10 +790,10 @@ export const TERMINAL_TOOL_NAMES = [
|
|
|
790
790
|
/** System-prompt guidance teaching the model WHEN to prefer the terminal tools
|
|
791
791
|
* over one-shot bash. Without it models almost never pick them — bash returns
|
|
792
792
|
* complete output in a single call, so it always wins on convenience. */
|
|
793
|
-
export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read). The one-shot bash tool stays the DEFAULT for ordinary commands - it runs once and returns the full output. Switch to the terminal tools only when:
|
|
794
|
-
- The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin).
|
|
795
|
-
- You start a long-running server or watcher and want to keep watching its output (terminal_read with waitMs) or send keys to it later (e.g. interrupt via terminal_key with Ctrl+c).
|
|
796
|
-
- The user explicitly asks you to work in the visible terminal panel.
|
|
793
|
+
export const TERMINAL_TOOLS_GUIDANCE = `Persistent interactive terminal tools are available (terminal_create / terminal_list / terminal_close / terminal_input / terminal_key / terminal_read). The one-shot bash tool stays the DEFAULT for ordinary commands - it runs once and returns the full output. Switch to the terminal tools only when:
|
|
794
|
+
- The program is interactive or TUI-based (REPLs like python/node, vim/htop, installers asking y/n, anything waiting on stdin).
|
|
795
|
+
- You start a long-running server or watcher and want to keep watching its output (terminal_read with waitMs) or send keys to it later (e.g. interrupt via terminal_key with Ctrl+c).
|
|
796
|
+
- The user explicitly asks you to work in the visible terminal panel.
|
|
797
797
|
Do NOT use them for simple one-shot commands; bash remains cheaper and simpler there.`;
|
|
798
798
|
/** Build the agent-facing persistent terminal tools for one conversation. */
|
|
799
799
|
export function makePersistentTerminalTools(terminals, cwd) {
|