pi-web-ui 0.90.1 → 0.92.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 +81 -1
- package/bin/pi-web-ui.mjs +21 -2
- package/dist/server/agent-service.js +1206 -106
- package/dist/server/claim-files-tool.js +116 -0
- package/dist/server/claim-store.js +301 -0
- package/dist/server/client-state.js +119 -5
- package/dist/server/compaction-markers.js +287 -0
- package/dist/server/conversation-read-tool.js +369 -27
- package/dist/server/conversation-touches.js +357 -0
- package/dist/server/delegate-task.js +12 -1
- package/dist/server/dsh/dsh-agent-service.js +28 -1
- package/dist/server/file-transfer-routes.js +4 -1
- package/dist/server/icon-svg.js +35 -0
- package/dist/server/index.js +152 -34
- package/dist/server/model-admin.js +124 -2
- package/dist/server/model-enrich.js +478 -0
- package/dist/server/plugin-catalog.js +5 -0
- package/dist/server/plugin-facilities.js +42 -11
- package/dist/server/plugin-schedule.js +27 -2
- package/dist/server/plugins.js +112 -18
- package/dist/server/present-files-tool.js +303 -0
- package/dist/server/prompt-composer.js +1 -1
- package/dist/server/protocol-version.js +1 -1
- package/dist/server/read-tool.js +91 -0
- package/dist/server/schedule-agent-tool.js +23 -5
- package/dist/server/scheduler-tasks.js +60 -1
- package/dist/server/scm-commitmsg.js +140 -0
- package/dist/server/scm.js +42 -0
- package/dist/server/serialize.js +21 -1
- package/dist/server/settings-service.js +47 -0
- package/dist/server/skill-tool.js +120 -0
- package/dist/server/soft-cap.js +68 -0
- package/dist/server/subagent-templates.js +6 -0
- package/dist/server/subagents.js +157 -16
- package/dist/server/text-sniff.js +15 -0
- package/dist/server/themes.js +17 -2
- package/dist/server/tool-manager.js +13 -1
- package/package.json +2 -1
- package/plugin-sdk/README.md +1 -0
- package/plugins/catalog.json +20 -0
- package/themes/catppuccin-latte.css +136 -0
- package/themes/catppuccin.css +140 -0
- package/themes/nord.css +130 -0
- package/themes/one-dark.css +131 -0
- package/themes/paper.css +12 -12
- package/themes/solarized-light.css +131 -0
- package/themes/tokyo-night.css +140 -0
- package/web/dist/assets/{TerminalPanel-CkxM4Byl.js → TerminalPanel-RACpk6AX.js} +1 -1
- package/web/dist/assets/es-DIDZppIF.js +2 -0
- package/web/dist/assets/index-BgrnP9Jz.js +364 -0
- package/web/dist/assets/index-Cy8dSTZt.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DJIV4A-t.js +0 -355
- package/web/dist/assets/index-DLdcRBs_.css +0 -1
package/dist/server/index.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* all share one conversation list per project.
|
|
17
17
|
* PI_CODING_AGENT_DIR pi config dir (auth/models/skills) — passed to the SDK
|
|
18
18
|
*/
|
|
19
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
20
20
|
import { stat } from "node:fs/promises";
|
|
21
21
|
import { createServer, request as proxyRequest } from "node:http";
|
|
22
22
|
import { createConnection } from "node:net";
|
|
@@ -32,7 +32,7 @@ import { PROTOCOL_VERSION } from "./protocol-version.js";
|
|
|
32
32
|
import { AgentService, workspacePath, QuiesceRejectedError } from "./agent-service.js";
|
|
33
33
|
import { WS_MAX_PAYLOAD_BYTES, isAbsoluteWirePath, wireToAbs } from "./files-service.js";
|
|
34
34
|
import { registerFileTransferRoutes } from "./file-transfer-routes.js";
|
|
35
|
-
import { previewKind } from "./text-sniff.js";
|
|
35
|
+
import { isAudioFile, previewKind } from "./text-sniff.js";
|
|
36
36
|
import { startControlServer } from "./control-socket.js";
|
|
37
37
|
import { scheduleUploadCleanup } from "./uploads.js";
|
|
38
38
|
import { ensureWindowsBash, windowsBashDir } from "./ensure-bash.js";
|
|
@@ -81,6 +81,18 @@ function cliFlag(name) {
|
|
|
81
81
|
const PORT = Number(cliFlag("--port") ?? process.env.PI_WEB_PORT ?? 8787);
|
|
82
82
|
const CWD = resolve(cliFlag("--cwd") ?? process.env.PI_WEB_CWD ?? process.cwd());
|
|
83
83
|
const DATA_DIR = resolve(cliFlag("--data-dir") ?? process.env.PI_WEB_DATA_DIR ?? join(homedir(), ".pi-web"));
|
|
84
|
+
// The data dir is where the control socket, client state, plugins, themes and
|
|
85
|
+
// uploads live, but nothing guarantees it exists on a first run (a fresh
|
|
86
|
+
// `server install` never creates it). The control socket binds at startup —
|
|
87
|
+
// before any store gets a chance to mkdir its own subdirectory — and bind()
|
|
88
|
+
// into a missing directory fails with EACCES, silently disabling the whole
|
|
89
|
+
// control channel (status/quiesce) for that process. Create it up front.
|
|
90
|
+
try {
|
|
91
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
92
|
+
}
|
|
93
|
+
catch (err) {
|
|
94
|
+
console.warn(`[data] 无法创建数据目录 ${DATA_DIR}: ${err.message}`);
|
|
95
|
+
}
|
|
84
96
|
// Dev-no-cache setting read without a ClientStateStore instance (the index.html
|
|
85
97
|
// route runs before any client attaches). Reads the same global settings blob.
|
|
86
98
|
function readDevNoCacheSetting() {
|
|
@@ -212,6 +224,16 @@ function cookieToken(req) {
|
|
|
212
224
|
}
|
|
213
225
|
return "";
|
|
214
226
|
}
|
|
227
|
+
/** Express 5 命名通配 `*splat` 的取值:单段是字符串,多段是字符串数组
|
|
228
|
+
* (issue #225:直接 String() 会把多段用逗号拼成 "a,b.mjs",插件 vendor 子
|
|
229
|
+
* 目录、嵌套文件预览、插件子路径 API 全 404)。统一拼回 "/" 即得 Express 4
|
|
230
|
+
* 语义;下游既有的越界/包含校验(workspacePath / resolvePluginClientFile)不变。 */
|
|
231
|
+
function splatParam(req) {
|
|
232
|
+
const v = req.params.splat;
|
|
233
|
+
if (Array.isArray(v))
|
|
234
|
+
return v.join("/");
|
|
235
|
+
return String(v ?? "");
|
|
236
|
+
}
|
|
215
237
|
if (AUTH_TOKEN) {
|
|
216
238
|
// /api/health 保持开放:无敏感信息,容器/监控探针需要它。
|
|
217
239
|
// 但绝不能因命中 /api/health 就反射下发真实 token cookie(安全漏洞:issue #45)。
|
|
@@ -269,7 +291,9 @@ app.get("/api/health", (_req, res) => {
|
|
|
269
291
|
*
|
|
270
292
|
* Media preview (no download param): only image/video kinds are served —
|
|
271
293
|
* text goes over the WebSocket, and exe/jar/etc. are never exposed here.
|
|
272
|
-
*
|
|
294
|
+
* Audio files (isAudioFile: browser-playable containers only) are allowed too
|
|
295
|
+
* so present_files cards and the preview dialog can inline-play them.
|
|
296
|
+
* express's sendFile handles Range requests, so video/audio seeking works.
|
|
273
297
|
*
|
|
274
298
|
* Download (?download=1): any file kind is served with
|
|
275
299
|
* Content-Disposition: attachment so the browser saves it instead of
|
|
@@ -306,7 +330,8 @@ app.get("/api/file", async (req, res) => {
|
|
|
306
330
|
// allowlist them explicitly here.
|
|
307
331
|
const lower = name.toLowerCase();
|
|
308
332
|
const isHtmlPreview = lower.endsWith(".html") || lower.endsWith(".htm") || lower.endsWith(".xhtml");
|
|
309
|
-
|
|
333
|
+
const isAudioPreview = isAudioFile(name);
|
|
334
|
+
if (!isDownload && kind !== "image" && kind !== "video" && !isHtmlPreview && !isAudioPreview) {
|
|
310
335
|
res.status(400).end("not a previewable media file");
|
|
311
336
|
return;
|
|
312
337
|
}
|
|
@@ -318,7 +343,10 @@ app.get("/api/file", async (req, res) => {
|
|
|
318
343
|
if (isDownload) {
|
|
319
344
|
// res.download sets Content-Disposition: attachment and RFC 5987
|
|
320
345
|
// filename* encoding for non-ASCII names.
|
|
321
|
-
|
|
346
|
+
// dotfiles: allow — issue #223:Express 5 的 send 默认 dotfiles=ignore,
|
|
347
|
+
// 工作区/数据目录常位于隐藏目录下(如 ~/.pi-web),绝对路径含点号段会被判 404。
|
|
348
|
+
// 路径已由上方的 workspacePath/isAbsoluteWirePath 做工作区 containment 校验,放行安全。
|
|
349
|
+
res.download(abs, name, { dotfiles: "allow" });
|
|
322
350
|
}
|
|
323
351
|
else {
|
|
324
352
|
if (isHtmlPreview) {
|
|
@@ -335,7 +363,7 @@ app.get("/api/file", async (req, res) => {
|
|
|
335
363
|
res.setHeader("Content-Security-Policy", allowJs ? "sandbox allow-scripts" : "sandbox");
|
|
336
364
|
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
337
365
|
}
|
|
338
|
-
res.sendFile(abs);
|
|
366
|
+
res.sendFile(abs, { dotfiles: "allow" });
|
|
339
367
|
}
|
|
340
368
|
}
|
|
341
369
|
catch {
|
|
@@ -360,8 +388,8 @@ app.get("/api/file", async (req, res) => {
|
|
|
360
388
|
*/
|
|
361
389
|
app.get("/api/preview/*splat", async (req, res) => {
|
|
362
390
|
try {
|
|
363
|
-
//
|
|
364
|
-
const captured =
|
|
391
|
+
// 多段路径的 splat 是数组(见 splatParam),拼回 "/" 后才是线形路径。
|
|
392
|
+
const captured = splatParam(req);
|
|
365
393
|
const ABS_MARKER = "__abs__/";
|
|
366
394
|
const cid = typeof req.query.clientId === "string" ? req.query.clientId : "";
|
|
367
395
|
const cs = cid ? service.get(cid) : undefined;
|
|
@@ -399,7 +427,7 @@ app.get("/api/preview/*splat", async (req, res) => {
|
|
|
399
427
|
const allowJs = req.query.allowJs === "1";
|
|
400
428
|
res.setHeader("Content-Security-Policy", allowJs ? "sandbox allow-scripts" : "sandbox");
|
|
401
429
|
}
|
|
402
|
-
res.sendFile(abs);
|
|
430
|
+
res.sendFile(abs, { dotfiles: "allow" });
|
|
403
431
|
}
|
|
404
432
|
catch {
|
|
405
433
|
res.status(404).end("not found");
|
|
@@ -498,7 +526,10 @@ app.get("/themes/:id.css", (req, res) => {
|
|
|
498
526
|
}
|
|
499
527
|
res.setHeader("Content-Type", "text/css; charset=utf-8");
|
|
500
528
|
res.setHeader("Cache-Control", "no-cache");
|
|
501
|
-
|
|
529
|
+
// dotfiles: allow — issue #223:Express 5 的 send 默认 dotfiles=ignore,主题文件位于
|
|
530
|
+
// 隐藏目录下(如 ~/.pi-web/themes、npm 全局目录 ~/.local/…)时会被判 404。路径已由
|
|
531
|
+
// resolveThemeFile 校验(id 白名单 + 仅已知目录 + isFile),放行安全。
|
|
532
|
+
res.sendFile(file, { dotfiles: "allow" });
|
|
502
533
|
});
|
|
503
534
|
// Plugin client bundles: <dataDir>/plugins/<id>/client/* served at
|
|
504
535
|
// /plugins/<id>/client/* so the frontend can import() plugin views. Only the
|
|
@@ -510,8 +541,8 @@ const PLUGINS_DIR = join(DATA_DIR, "plugins");
|
|
|
510
541
|
// /plugins-api/<id>/inbox。PI_WEB_TOKEN 鉴权(上方 app.use)自动覆盖;
|
|
511
542
|
// 响应已在前面过了 express.json。注意不要在此 catch-all 里消费 body。
|
|
512
543
|
app.all(["/plugins-api/:id/*splat", "/plugins-api/:id"], (req, res) => {
|
|
513
|
-
//
|
|
514
|
-
const rest =
|
|
544
|
+
// 多段子路径的 splat 是数组(见 splatParam),拼回 "/" 后再交插件路由。
|
|
545
|
+
const rest = splatParam(req);
|
|
515
546
|
pluginMgr.handleHttp(String(req.params.id ?? ""), req.method, rest, req, res);
|
|
516
547
|
});
|
|
517
548
|
function proxyForwardPath(hit, url) {
|
|
@@ -584,8 +615,8 @@ app.use((req, res, next) => {
|
|
|
584
615
|
proxyHttp(hit, req, res);
|
|
585
616
|
});
|
|
586
617
|
app.get("/plugins/:id/client/*splat", (req, res) => {
|
|
587
|
-
//
|
|
588
|
-
const rest =
|
|
618
|
+
// 多段子路径(插件 vendor/分包)的 splat 是数组(见 splatParam),拼回 "/"。
|
|
619
|
+
const rest = splatParam(req);
|
|
589
620
|
// 特权 DOM 门禁:声明了 dom 能力的插件,其 bundle 需用户逐个授权后才下发
|
|
590
621
|
// (同源 bundle 技术上拦不住 DOM 访问,门只能放在这里;见 server/plugin-dom.ts)。
|
|
591
622
|
if (pluginMgr.isDomBundleBlocked(String(req.params.id ?? ""))) {
|
|
@@ -602,7 +633,8 @@ app.get("/plugins/:id/client/*splat", (req, res) => {
|
|
|
602
633
|
res.setHeader("Content-Type", "text/javascript; charset=utf-8");
|
|
603
634
|
}
|
|
604
635
|
res.setHeader("Cache-Control", "no-cache"); // 开发期改文件即生效
|
|
605
|
-
|
|
636
|
+
// dotfiles: allow — issue #223:插件目录默认在 ~/.pi-web/plugins(隐藏目录段),同上需放行。
|
|
637
|
+
res.sendFile(abs, { dotfiles: "allow" }, (err) => {
|
|
606
638
|
if (err && !res.headersSent)
|
|
607
639
|
res
|
|
608
640
|
.status(err.statusCode === 404 ? 404 : 500)
|
|
@@ -659,7 +691,8 @@ if (existsSync(webDist)) {
|
|
|
659
691
|
const stored = readDevNoCacheSetting();
|
|
660
692
|
const noStore = stored ?? envDefault;
|
|
661
693
|
res.setHeader("Cache-Control", noStore ? "no-store" : "public, max-age=0");
|
|
662
|
-
|
|
694
|
+
// dotfiles: allow — issue #223:nvm 等安装路径本身在隐藏目录下(如 ~/.nvm/…),同上需放行。
|
|
695
|
+
res.sendFile(join(webDist, "index.html"), { dotfiles: "allow" }, (err) => {
|
|
663
696
|
if (err && !res.headersSent) {
|
|
664
697
|
res.status(503).send("正在更新 pi-web-ui,请稍后刷新…");
|
|
665
698
|
}
|
|
@@ -967,7 +1000,8 @@ pluginMgr.permissionRequester = (pluginId, req) => new Promise((resolve) => {
|
|
|
967
1000
|
pluginMgr.onPermGrantsChanged = () => pushPluginPermissions();
|
|
968
1001
|
// 内置定时任务(issue #184):全局 <dataDir>/scheduler-tasks.json,TTL 与
|
|
969
1002
|
// client-state 同级;Agent 工具建的任务优先唤醒发起对话(issue #193:
|
|
970
|
-
// wakeConversation steer
|
|
1003
|
+
// wakeConversation steer 投递,不切用户当前对话),原对话不在先回落同项目
|
|
1004
|
+
// 活跃对话(issue #231:视口兜底+自动重绑定+明确降级提示),都没有才无头伪
|
|
971
1005
|
// 客户端(chatFromScheduler);单次任务触发后自动删除。DSH 引擎无这俩方法时
|
|
972
1006
|
// executor 回 not-supported(历史里记失败,不炸进程)。
|
|
973
1007
|
const scheduler = new SchedulerStore(DATA_DIR, {
|
|
@@ -979,31 +1013,83 @@ const scheduler = new SchedulerStore(DATA_DIR, {
|
|
|
979
1013
|
result = { ok: false, error: "当前引擎不支持定时任务(仅标准 pi 引擎)" };
|
|
980
1014
|
}
|
|
981
1015
|
else {
|
|
982
|
-
|
|
1016
|
+
const runHeadless = () => svc.chatFromScheduler({
|
|
1017
|
+
id: task.id,
|
|
1018
|
+
cwd: task.cwd,
|
|
1019
|
+
prompt: task.prompt,
|
|
1020
|
+
model: task.model,
|
|
1021
|
+
thinkingLevel: task.thinkingLevel,
|
|
1022
|
+
});
|
|
983
1023
|
const target = String(task.conversationId ?? "").trim();
|
|
984
|
-
|
|
985
|
-
|
|
1024
|
+
const taskFile = String(task.sessionFile ?? "").trim();
|
|
1025
|
+
const text = `[定时任务 ${task.name}] ${task.prompt}`;
|
|
1026
|
+
if (!target && !taskFile) {
|
|
1027
|
+
// 面板建的任务:创建时就没绑对话,保持无头语义(不抢占用户视口)。
|
|
1028
|
+
result = await runHeadless();
|
|
1029
|
+
}
|
|
1030
|
+
else if (typeof svc.wakeConversation === "function") {
|
|
1031
|
+
// 原绑定对话还在(含压缩/重启后按会话文件重认)→ steer 唤醒,报告落原对话。
|
|
1032
|
+
let w;
|
|
1033
|
+
try {
|
|
1034
|
+
w = await svc.wakeConversation(target, text, { sessionFile: taskFile, cwd: task.cwd });
|
|
1035
|
+
}
|
|
1036
|
+
catch (err) {
|
|
1037
|
+
w = { ok: false, error: err.message };
|
|
1038
|
+
}
|
|
986
1039
|
if (w.ok) {
|
|
1040
|
+
// 会话继承(issue #231):压缩/重启后同文件对话换了新 id → 任务跟过去,
|
|
1041
|
+
// 下次触发直达,不再误判 closed/gone。单次任务随后自删,免一次写盘。
|
|
1042
|
+
if (!task.oneShot) {
|
|
1043
|
+
try {
|
|
1044
|
+
if (w.conversationId && w.conversationId !== target)
|
|
1045
|
+
scheduler.rebind(task.id, { conversationId: w.conversationId });
|
|
1046
|
+
if (w.sessionFile && w.sessionFile !== taskFile)
|
|
1047
|
+
scheduler.rebind(task.id, { sessionFile: w.sessionFile });
|
|
1048
|
+
}
|
|
1049
|
+
catch {
|
|
1050
|
+
// 重绑失败不影响本次已投递的唤醒
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
987
1053
|
result = { ok: true, conversationId: w.conversationId };
|
|
988
1054
|
}
|
|
1055
|
+
else if (typeof svc.wakeViewportInCwd === "function") {
|
|
1056
|
+
// 活跃视口兜底(issue #231):原句柄断开(切走释放/过户改名/重启),
|
|
1057
|
+
// 只要同项目还有用户正看着的对话,报告落那里 —— 不静默吞结果。
|
|
1058
|
+
const fallbackText = `[定时任务 ${task.name}|原对话不在,已转到本窗口继续] ${task.prompt}`;
|
|
1059
|
+
let f;
|
|
1060
|
+
try {
|
|
1061
|
+
f = await svc.wakeViewportInCwd(task.cwd, fallbackText);
|
|
1062
|
+
}
|
|
1063
|
+
catch (err) {
|
|
1064
|
+
f = { ok: false, error: err.message };
|
|
1065
|
+
}
|
|
1066
|
+
if (f.ok) {
|
|
1067
|
+
if (!task.oneShot) {
|
|
1068
|
+
try {
|
|
1069
|
+
scheduler.rebind(task.id, {
|
|
1070
|
+
conversationId: f.conversationId ?? "",
|
|
1071
|
+
sessionFile: f.sessionFile ?? "",
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
catch {
|
|
1075
|
+
// 重绑失败不影响本次已投递的唤醒
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
pushNoticeToAll("info", `定时任务「${task.name}」原对话不在,已转到同项目的活跃对话继续(原绑定 ${target || "(未知)"})。报告直接落在当前对话。`, `Scheduled task "${task.name}" moved to the project's active conversation (was ${target || "unknown"}). The report lands in the current chat.`);
|
|
1079
|
+
result = { ok: true, conversationId: f.conversationId };
|
|
1080
|
+
}
|
|
1081
|
+
else {
|
|
1082
|
+
// 降级可见性(issue #231):必须无头时明确广播去向,不静默。
|
|
1083
|
+
pushNoticeToAll("warning", `定时任务「${task.name}」原对话不在、同项目也无存活对话,已转后台执行(无头)。报告在后台任务面板的调度历史中查看。`, `Scheduled task "${task.name}" found no live conversation and runs headless; see its report in the background-tasks panel history.`);
|
|
1084
|
+
result = await runHeadless();
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
989
1087
|
else {
|
|
990
|
-
result = await
|
|
991
|
-
id: task.id,
|
|
992
|
-
cwd: task.cwd,
|
|
993
|
-
prompt: task.prompt,
|
|
994
|
-
model: task.model,
|
|
995
|
-
thinkingLevel: task.thinkingLevel,
|
|
996
|
-
});
|
|
1088
|
+
result = await runHeadless();
|
|
997
1089
|
}
|
|
998
1090
|
}
|
|
999
1091
|
else {
|
|
1000
|
-
result = await
|
|
1001
|
-
id: task.id,
|
|
1002
|
-
cwd: task.cwd,
|
|
1003
|
-
prompt: task.prompt,
|
|
1004
|
-
model: task.model,
|
|
1005
|
-
thinkingLevel: task.thinkingLevel,
|
|
1006
|
-
});
|
|
1092
|
+
result = await runHeadless();
|
|
1007
1093
|
}
|
|
1008
1094
|
}
|
|
1009
1095
|
}
|
|
@@ -1489,6 +1575,22 @@ wss.on("connection", (ws) => {
|
|
|
1489
1575
|
case "scm_commit":
|
|
1490
1576
|
void cs.scmQuery("commit", msg.reqId, { hash: msg.hash });
|
|
1491
1577
|
break;
|
|
1578
|
+
case "scm_commitmsg":
|
|
1579
|
+
if (typeof cs.scmGenCommitMessage === "function") {
|
|
1580
|
+
void cs.scmGenCommitMessage(msg.reqId);
|
|
1581
|
+
}
|
|
1582
|
+
else {
|
|
1583
|
+
// DSH 等引擎没有 pi 的 ModelRuntime——照样应答一次(ok:false),
|
|
1584
|
+
// 前端按钮不能因引擎差异卡在转圈。
|
|
1585
|
+
send({
|
|
1586
|
+
type: "scm_data",
|
|
1587
|
+
reqId: msg.reqId,
|
|
1588
|
+
kind: "commitmsg",
|
|
1589
|
+
ok: false,
|
|
1590
|
+
error: "当前引擎不支持 AI 生成提交信息(请用 pi 引擎)/ AI commit messages need the pi engine",
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
break;
|
|
1492
1594
|
case "read_file":
|
|
1493
1595
|
void cs.readFile(msg.path);
|
|
1494
1596
|
break;
|
|
@@ -1522,6 +1624,12 @@ wss.on("connection", (ws) => {
|
|
|
1522
1624
|
case "set_model":
|
|
1523
1625
|
void cs.setModel(msg.modelId);
|
|
1524
1626
|
break;
|
|
1627
|
+
case "set_default_model":
|
|
1628
|
+
void cs.setDefaultModel?.(msg.modelId);
|
|
1629
|
+
break;
|
|
1630
|
+
case "clear_default_model":
|
|
1631
|
+
cs.clearDefaultModel?.();
|
|
1632
|
+
break;
|
|
1525
1633
|
case "set_thinking":
|
|
1526
1634
|
cs.setThinking(msg.level);
|
|
1527
1635
|
break;
|
|
@@ -1640,6 +1748,12 @@ wss.on("connection", (ws) => {
|
|
|
1640
1748
|
case "clone_provider":
|
|
1641
1749
|
void cs.cloneProvider(msg.provider, msg.reqId);
|
|
1642
1750
|
break;
|
|
1751
|
+
case "enrich_models":
|
|
1752
|
+
void cs.enrichModels(msg.reqId, msg.ids, msg.hints);
|
|
1753
|
+
break;
|
|
1754
|
+
case "abort_enrich_models":
|
|
1755
|
+
cs.abortEnrichModels(msg.reqId);
|
|
1756
|
+
break;
|
|
1643
1757
|
case "list_provider_keys":
|
|
1644
1758
|
cs.listProviderKeys();
|
|
1645
1759
|
break;
|
|
@@ -1727,8 +1841,10 @@ wss.on("connection", (ws) => {
|
|
|
1727
1841
|
terminalToolsEnabled: msg.terminalToolsEnabled,
|
|
1728
1842
|
terminalBash: msg.terminalBash,
|
|
1729
1843
|
terminalBashIdleMs: msg.terminalBashIdleMs,
|
|
1844
|
+
readDirEnabled: msg.readDirEnabled,
|
|
1730
1845
|
editSoftEnabled: msg.editSoftEnabled,
|
|
1731
1846
|
questionnaireEnabled: msg.questionnaireEnabled,
|
|
1847
|
+
parallelReminderEnabled: msg.parallelReminderEnabled,
|
|
1732
1848
|
goalModeEnabled: msg.goalModeEnabled,
|
|
1733
1849
|
thinkingWrap: msg.thinkingWrap,
|
|
1734
1850
|
toolsWrap: msg.toolsWrap,
|
|
@@ -1741,6 +1857,8 @@ wss.on("connection", (ws) => {
|
|
|
1741
1857
|
visionBridgePrompt: msg.visionBridgePrompt,
|
|
1742
1858
|
subagentDefaultModel: msg.subagentDefaultModel,
|
|
1743
1859
|
retryMaxAttempts: msg.retryMaxAttempts,
|
|
1860
|
+
softCapTokens: msg.softCapTokens,
|
|
1861
|
+
softCapByModel: msg.softCapByModel,
|
|
1744
1862
|
reviewPrompt: msg.reviewPrompt,
|
|
1745
1863
|
reviewDisabledSkills: msg.reviewDisabledSkills,
|
|
1746
1864
|
markersEnabled: msg.markersEnabled,
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { dirname, join } from "node:path";
|
|
16
|
+
import { enrichBatch } from "./model-enrich.js";
|
|
16
17
|
import { pick } from "./i18n.js";
|
|
17
18
|
import { ProviderOAuthFlowManager } from "./provider-oauth-flow.js";
|
|
18
19
|
/** Drop provisional overlay rows once the official catalog lists the same id.
|
|
@@ -196,9 +197,13 @@ function parseOpenAiModel(m) {
|
|
|
196
197
|
boolMeta(r.supports_vision) === true ||
|
|
197
198
|
boolMeta(r.vision) === true ||
|
|
198
199
|
strArrMeta(r.input)?.includes("image") === true;
|
|
200
|
+
// 名字兜底:很多端点(如 Antigravity-Manager 的 /v1/models)只给 id 不给
|
|
201
|
+
// 元数据;id 含 thinking 即视为思考模型(与反代自身的 is_thinking_model
|
|
202
|
+
// 同口径)。只增不减:取消勾选靠手填(编辑页/刷新合并都只补缺)。
|
|
199
203
|
const reasoning = boolMeta(r.reasoning) === true ||
|
|
200
204
|
boolMeta(r.supports_reasoning) === true ||
|
|
201
|
-
modalities?.includes("reasoning") === true
|
|
205
|
+
modalities?.includes("reasoning") === true ||
|
|
206
|
+
id.toLowerCase().includes("thinking");
|
|
202
207
|
const contextWindow = numMeta(r.context_window) ?? numMeta(r.context_length) ?? numMeta(r.max_model_len) ?? numMeta(r.max_context_length);
|
|
203
208
|
const maxTokens = numMeta(r.max_tokens) ?? numMeta(r.max_output_tokens) ?? numMeta(r.max_completion_tokens);
|
|
204
209
|
return {
|
|
@@ -218,9 +223,12 @@ function parseGoogleModel(m) {
|
|
|
218
223
|
const rawName = typeof r.name === "string" ? r.name : "";
|
|
219
224
|
const id = rawName.replace(/^models\//, "");
|
|
220
225
|
const displayName = typeof r.displayName === "string" ? r.displayName : undefined;
|
|
226
|
+
// 与 OpenAI 形状同口径的名字兜底(见 parseOpenAiModel)。
|
|
227
|
+
const reasoning = id.toLowerCase().includes("thinking");
|
|
221
228
|
return {
|
|
222
229
|
id,
|
|
223
230
|
...(displayName && displayName !== id ? { name: displayName } : {}),
|
|
231
|
+
...(reasoning ? { reasoning: true } : {}),
|
|
224
232
|
...(numMeta(r.inputTokenLimit) ? { contextWindow: numMeta(r.inputTokenLimit) } : {}),
|
|
225
233
|
...(numMeta(r.outputTokenLimit) ? { maxTokens: numMeta(r.outputTokenLimit) } : {}),
|
|
226
234
|
};
|
|
@@ -228,6 +236,7 @@ function parseGoogleModel(m) {
|
|
|
228
236
|
export class ModelAdminService {
|
|
229
237
|
host;
|
|
230
238
|
oauthFlows;
|
|
239
|
+
activeEnrichAbort = new Map();
|
|
231
240
|
constructor(host) {
|
|
232
241
|
this.host = host;
|
|
233
242
|
this.oauthFlows = new ProviderOAuthFlowManager({
|
|
@@ -251,6 +260,25 @@ export class ModelAdminService {
|
|
|
251
260
|
}
|
|
252
261
|
dispose() {
|
|
253
262
|
this.oauthFlows.dispose();
|
|
263
|
+
for (const ac of this.activeEnrichAbort.values()) {
|
|
264
|
+
ac.abort();
|
|
265
|
+
}
|
|
266
|
+
this.activeEnrichAbort.clear();
|
|
267
|
+
}
|
|
268
|
+
abortEnrichModels(reqId) {
|
|
269
|
+
if (reqId !== undefined) {
|
|
270
|
+
const ac = this.activeEnrichAbort.get(reqId);
|
|
271
|
+
if (ac) {
|
|
272
|
+
ac.abort();
|
|
273
|
+
this.activeEnrichAbort.delete(reqId);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
for (const ac of this.activeEnrichAbort.values()) {
|
|
278
|
+
ac.abort();
|
|
279
|
+
}
|
|
280
|
+
this.activeEnrichAbort.clear();
|
|
281
|
+
}
|
|
254
282
|
}
|
|
255
283
|
async logoutProviderOAuth(provider) {
|
|
256
284
|
const providerId = provider.trim();
|
|
@@ -880,6 +908,94 @@ export class ModelAdminService {
|
|
|
880
908
|
}
|
|
881
909
|
this.host.flushSnapshot();
|
|
882
910
|
}
|
|
911
|
+
/**
|
|
912
|
+
* Enrich custom-provider DRAFT rows with public catalog params
|
|
913
|
+
* (OpenRouter primary, models.dev secondary) — enrich_models_result.
|
|
914
|
+
* Nothing is saved here; the UI fills blanks from the result.
|
|
915
|
+
*/
|
|
916
|
+
async enrichModels(reqId, ids, hints, lang) {
|
|
917
|
+
const l = lang?.() ?? "en";
|
|
918
|
+
const ac = new AbortController();
|
|
919
|
+
this.activeEnrichAbort.set(reqId, ac);
|
|
920
|
+
try {
|
|
921
|
+
const cleanIds = [...new Set((ids ?? []).map((s) => (s ?? "").trim()).filter(Boolean))].slice(0, 100);
|
|
922
|
+
if (cleanIds.length === 0) {
|
|
923
|
+
throw new Error(pick(l, "没有可补的模型 id", "No model ids to enrich", "models.enrich.empty"));
|
|
924
|
+
}
|
|
925
|
+
const cleanHints = {};
|
|
926
|
+
for (const [k, v] of Object.entries(hints ?? {})) {
|
|
927
|
+
if (k.trim() && (v ?? "").trim())
|
|
928
|
+
cleanHints[k.trim()] = (v ?? "").trim();
|
|
929
|
+
}
|
|
930
|
+
const results = await enrichBatch(cleanIds, cleanHints, {
|
|
931
|
+
lang: (l === "zh" ? "zh" : "en"),
|
|
932
|
+
signal: ac.signal,
|
|
933
|
+
onProgress: (p) => {
|
|
934
|
+
this.host.emit({
|
|
935
|
+
type: "enrich_models_progress",
|
|
936
|
+
reqId,
|
|
937
|
+
phase: p.phase,
|
|
938
|
+
current: p.current,
|
|
939
|
+
total: p.total,
|
|
940
|
+
message: p.message,
|
|
941
|
+
});
|
|
942
|
+
},
|
|
943
|
+
});
|
|
944
|
+
const matched = results.filter((r) => r.status === "matched").length;
|
|
945
|
+
const suggested = results.filter((r) => r.status === "suggested").length;
|
|
946
|
+
this.host.emit({ type: "enrich_models_result", reqId, ok: true, results });
|
|
947
|
+
this.host.emit({
|
|
948
|
+
type: "notice",
|
|
949
|
+
level: "info",
|
|
950
|
+
text: `🔍 已补 ${matched} 个${suggested ? `,另有 ${suggested} 个只找到相近家族(见建议)` : ""},共 ${results.length} 个`,
|
|
951
|
+
textEn: `🔍 Enriched ${matched}${suggested ? `, ${suggested} with family suggestions` : ""} of ${results.length}`,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
catch (err) {
|
|
955
|
+
const isAbort = err.message === "aborted" || ac.signal.aborted;
|
|
956
|
+
const partial = err.partialResults;
|
|
957
|
+
if (isAbort) {
|
|
958
|
+
const hasPartial = Array.isArray(partial) && partial.length > 0;
|
|
959
|
+
if (hasPartial) {
|
|
960
|
+
const matched = partial.filter((r) => r.status === "matched").length;
|
|
961
|
+
this.host.emit({ type: "enrich_models_result", reqId, ok: true, results: partial });
|
|
962
|
+
this.host.emit({
|
|
963
|
+
type: "notice",
|
|
964
|
+
level: "warning",
|
|
965
|
+
text: `⏹ 已中断补参数(已保留中断前匹配的 ${matched} 个模型)`,
|
|
966
|
+
textEn: `⏹ Model enrichment aborted (kept ${matched} models matched before abort)`,
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
else {
|
|
970
|
+
this.host.emit({
|
|
971
|
+
type: "enrich_models_result",
|
|
972
|
+
reqId,
|
|
973
|
+
ok: false,
|
|
974
|
+
error: pick(l, "已取消补参数", "Model enrichment cancelled", "models.enrich.cancelled"),
|
|
975
|
+
});
|
|
976
|
+
this.host.emit({
|
|
977
|
+
type: "notice",
|
|
978
|
+
level: "warning",
|
|
979
|
+
text: pick(l, "已取消补参数", "Model enrichment cancelled", "models.enrich.cancelled"),
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
else {
|
|
984
|
+
const error = err.message;
|
|
985
|
+
this.host.emit({ type: "enrich_models_result", reqId, ok: false, error });
|
|
986
|
+
this.host.emit({
|
|
987
|
+
type: "notice",
|
|
988
|
+
level: "error",
|
|
989
|
+
text: `补参数失败:${error}`,
|
|
990
|
+
textEn: `Enrich failed: ${error}`,
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
finally {
|
|
995
|
+
this.activeEnrichAbort.delete(reqId);
|
|
996
|
+
}
|
|
997
|
+
this.host.flushSnapshot();
|
|
998
|
+
}
|
|
883
999
|
/** Enumerate pi's built-in providers with auth capabilities and status. */
|
|
884
1000
|
async listProviders() {
|
|
885
1001
|
const mr = this.host.modelRuntime();
|
|
@@ -1074,9 +1190,12 @@ export class ModelAdminService {
|
|
|
1074
1190
|
boolMeta(r.supports_vision) === true ||
|
|
1075
1191
|
boolMeta(r.vision) === true ||
|
|
1076
1192
|
strArrMeta(r.input)?.includes("image") === true;
|
|
1193
|
+
// 名字兜底(同模块级 parseOpenAiModel):只给 id 的端点靠 id 含 thinking
|
|
1194
|
+
// 视为思考模型,只增不减,手填合并只补缺。
|
|
1077
1195
|
const reasoning = boolMeta(r.reasoning) === true ||
|
|
1078
1196
|
boolMeta(r.supports_reasoning) === true ||
|
|
1079
|
-
modalities?.includes("reasoning") === true
|
|
1197
|
+
modalities?.includes("reasoning") === true ||
|
|
1198
|
+
id.toLowerCase().includes("thinking");
|
|
1080
1199
|
const contextWindow = numMeta(r.context_window) ??
|
|
1081
1200
|
numMeta(r.context_length) ??
|
|
1082
1201
|
numMeta(r.max_model_len) ??
|
|
@@ -1099,9 +1218,12 @@ export class ModelAdminService {
|
|
|
1099
1218
|
const rawName = typeof r.name === "string" ? r.name : "";
|
|
1100
1219
|
const id = rawName.replace(/^models\//, "");
|
|
1101
1220
|
const displayName = typeof r.displayName === "string" ? r.displayName : undefined;
|
|
1221
|
+
// 与 OpenAI 形状同口径的名字兜底(见模块级 parseOpenAiModel)。
|
|
1222
|
+
const reasoning = id.toLowerCase().includes("thinking");
|
|
1102
1223
|
return {
|
|
1103
1224
|
id,
|
|
1104
1225
|
...(displayName && displayName !== id ? { name: displayName } : {}),
|
|
1226
|
+
...(reasoning ? { reasoning: true } : {}),
|
|
1105
1227
|
...(numMeta(r.inputTokenLimit) ? { contextWindow: numMeta(r.inputTokenLimit) } : {}),
|
|
1106
1228
|
...(numMeta(r.outputTokenLimit) ? { maxTokens: numMeta(r.outputTokenLimit) } : {}),
|
|
1107
1229
|
};
|