pi-web-ui 0.91.0 → 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.
Files changed (37) hide show
  1. package/CHANGELOG.md +34 -1
  2. package/bin/pi-web-ui.mjs +21 -2
  3. package/dist/server/agent-service.js +443 -45
  4. package/dist/server/claim-files-tool.js +116 -0
  5. package/dist/server/claim-store.js +301 -0
  6. package/dist/server/client-state.js +49 -0
  7. package/dist/server/conversation-read-tool.js +369 -27
  8. package/dist/server/conversation-touches.js +357 -0
  9. package/dist/server/dsh/dsh-agent-service.js +7 -0
  10. package/dist/server/index.js +32 -4
  11. package/dist/server/model-admin.js +124 -2
  12. package/dist/server/model-enrich.js +478 -0
  13. package/dist/server/plugin-schedule.js +27 -2
  14. package/dist/server/plugins.js +65 -10
  15. package/dist/server/present-files-tool.js +303 -0
  16. package/dist/server/read-tool.js +91 -0
  17. package/dist/server/serialize.js +21 -1
  18. package/dist/server/settings-service.js +7 -0
  19. package/dist/server/text-sniff.js +15 -0
  20. package/dist/server/themes.js +17 -2
  21. package/dist/server/tool-manager.js +7 -1
  22. package/package.json +1 -1
  23. package/plugin-sdk/README.md +1 -0
  24. package/plugins/catalog.json +10 -0
  25. package/themes/catppuccin-latte.css +136 -0
  26. package/themes/catppuccin.css +140 -0
  27. package/themes/nord.css +130 -0
  28. package/themes/one-dark.css +131 -0
  29. package/themes/paper.css +12 -12
  30. package/themes/solarized-light.css +131 -0
  31. package/themes/tokyo-night.css +140 -0
  32. package/web/dist/assets/{TerminalPanel-GtDQ1h85.js → TerminalPanel-RACpk6AX.js} +1 -1
  33. package/web/dist/assets/index-BgrnP9Jz.js +364 -0
  34. package/web/dist/assets/index-Cy8dSTZt.css +1 -0
  35. package/web/dist/index.html +2 -2
  36. package/web/dist/assets/index-CdsKltOS.css +0 -1
  37. package/web/dist/assets/index-OoULNF70.js +0 -363
@@ -0,0 +1,357 @@
1
+ // ---------------------------------------------------------------------------
2
+ // conversation-touches.ts — 会话「触碰文件集」提取(纯函数,零 node 依赖)
3
+ // ---------------------------------------------------------------------------
4
+ // 背景:同项目多对话并行时,服务端完全知道「谁碰过哪些文件」(实时消息与落盘
5
+ // 转录里都有工具调用记录),但既没算也没给 AI,导致 AI 只能靠猜冲突。本模块
6
+ // 只实现一次触碰集解析器,三处复用:并行提醒(agent-service.ts)、
7
+ // conversation_read 的 action:"files" 与 action:"status"。
8
+ //
9
+ // 输入形状(两条路都已核实,见下「数据来源」):
10
+ // - 实时路:session.agent.state.messages(SDK AgentMessage),assistant 消息
11
+ // 的 content 里是 {type:"toolCall", id, name, arguments} 块;
12
+ // - 历史路:转录 jsonl 的 message.content,原样保留同一形状(parseTranscript
13
+ // Lines 透传 content)。
14
+ // 注意:textOfContent 只在「渲染文本」里丢了参数(仍渲染 `[tool call: name]`),
15
+ // content 结构里的 arguments 一直都在。所以 toolCalls 字段是 additive 的便捷
16
+ // 访问点(一处解析、多处复用,并行 toolCall 也能表示),不是新数据源;取不到
17
+ // 时回落扫 content,再取不到就给空集 —— 绝不伪造。
18
+ //
19
+ // 提取规则(只算写,不算读 —— 这是准确性的关键):
20
+ // 写工具白名单:edit / write / edit_soft(参数 path;兼容 file_path/filePath
21
+ // 别名)。白名单之外的一切工具调用一律忽略,所以 read/ls/glob/grep 等读操作
22
+ // 天然不算 —— 不需要也不维护「读工具黑名单」。
23
+ // bash 高置信度写入迹象(拿不准就丢,宁可漏报不要假阳性):
24
+ // - 重定向 `>` / `>>` 的目标词(含 `2> file` 这类 fd 前缀;排除 `>&N`、
25
+ // /dev/null、NUL、&N;heredoc `<<`/`<<<` 不算写入);
26
+ // - rm / rmdir / mkdir / touch 的非 flag 参数;
27
+ // - cp / mv 只取最后一个非 flag 参数(目标端;源端是读/被删,不算写)。
28
+ // 解析是轻量分词器(引号/转义感知,`;`/`&&`/`||`/`|`/`&`/`(`/`)` 切分命令
29
+ // 段),不是完整 shell 解析器。
30
+ //
31
+ // 已知局限(如实记录,调用方展示时不要夸大):
32
+ // 1. 压缩(compaction)后旧消息被摘要替代,压缩前的触碰会丢失 —— 触碰集是
33
+ // 「当前可见转录」的下界,不是全集。
34
+ // 2. 相对路径按字面记录(edit 常给绝对路径、bash 常给相对路径),跨会话交集
35
+ // 可能因此漏报;但绝不做后缀模糊匹配(同名不同目录会假阳性,比漏报更糟)。
36
+ // 3. 路径归一化只做:去首尾空白、反斜杠转斜杠、去 `./` 前缀。Windows 大小写
37
+ // 不敏感不做归一。
38
+ // 4. powershell/cmd 风格写入(Set-Content/Out-File/del/copy)、git apply、
39
+ // tee 落盘、xargs/timeout/nice 等包装词之后的子命令 —— 一律不提取
40
+ // (sudo/env/command/time/nohup/doas 这几个常见包装词能跳过)。
41
+ // 5. 引号内的 `>`(如 echo "a > b")不提取;但 `eval`/`sh -c` 套娃串内的重定
42
+ // 向会被当成普通命令解析 —— 置信度下降,属已知噪声源。
43
+ // ---------------------------------------------------------------------------
44
+ /** 工具调用的参数键:SDK 用 arguments,兼容 input/args 两种历史写法。 */
45
+ function argsOfBlock(blk) {
46
+ if (typeof blk.arguments !== "undefined")
47
+ return blk.arguments;
48
+ if (typeof blk.input !== "undefined")
49
+ return blk.input;
50
+ return blk.args;
51
+ }
52
+ /** content 块数组里工具调用的 (name, args) 对(非 toolCall 块跳过)。 */
53
+ export function toolCallRefsOfContent(content) {
54
+ if (!Array.isArray(content))
55
+ return [];
56
+ const out = [];
57
+ for (const b of content) {
58
+ if (!b || typeof b !== "object")
59
+ continue;
60
+ const blk = b;
61
+ if (blk.type !== "toolCall" || typeof blk.name !== "string" || !blk.name)
62
+ continue;
63
+ const args = argsOfBlock(blk);
64
+ if (typeof args === "undefined")
65
+ out.push({ name: blk.name });
66
+ else
67
+ out.push({ name: blk.name, args });
68
+ }
69
+ return out;
70
+ }
71
+ /** 一条消息的全部工具调用:优先已解析的 toolCalls 字段,回落扫 content。 */
72
+ export function toolCallsOf(m) {
73
+ if (!m || typeof m !== "object")
74
+ return [];
75
+ if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
76
+ return m.toolCalls.filter((t) => !!t && typeof t === "object" && typeof t.name === "string");
77
+ }
78
+ return toolCallRefsOfContent(m.content);
79
+ }
80
+ /** 写工具白名单:只有这些工具的目标路径才算「写」。 */
81
+ const WRITE_TOOL_NAMES = new Set(["edit", "write", "edit_soft"]);
82
+ function pathOfWriteArgs(args) {
83
+ if (!args || typeof args !== "object")
84
+ return undefined;
85
+ const a = args;
86
+ for (const k of ["path", "file_path", "filePath"]) {
87
+ const v = a[k];
88
+ if (typeof v === "string" && v.trim())
89
+ return v.trim();
90
+ }
91
+ return undefined;
92
+ }
93
+ /** 路径轻归一(见文件头局限 3):只做无损、低风险的规范化。 */
94
+ export function normalizeTouchPath(p) {
95
+ let s = p.trim().replace(/\\/g, "/");
96
+ while (s.startsWith("./"))
97
+ s = s.slice(2);
98
+ return s;
99
+ }
100
+ // ---------------------------------------------------------------------------
101
+ // bash 命令解析(高置信度写入迹象,见文件头规则)
102
+ // ---------------------------------------------------------------------------
103
+ /** shell 分词:引号/反斜杠转义感知,引号剥掉(`echo "a > b"` 的 > 不会误报)。 */
104
+ function tokenizeShell(cmd) {
105
+ const toks = [];
106
+ let cur = "";
107
+ let quote = null;
108
+ let i = 0;
109
+ const push = () => {
110
+ if (cur !== "") {
111
+ toks.push(cur);
112
+ cur = "";
113
+ }
114
+ };
115
+ while (i < cmd.length) {
116
+ const ch = cmd[i];
117
+ if (quote) {
118
+ if (ch === "\\" && quote === '"' && i + 1 < cmd.length) {
119
+ cur += cmd[i + 1];
120
+ i += 2;
121
+ continue;
122
+ }
123
+ if (ch === quote)
124
+ quote = null;
125
+ else
126
+ cur += ch;
127
+ i++;
128
+ continue;
129
+ }
130
+ if (ch === "\\" && i + 1 < cmd.length) {
131
+ cur += cmd[i + 1];
132
+ i += 2;
133
+ continue;
134
+ }
135
+ if (ch === "'" || ch === '"') {
136
+ quote = ch;
137
+ i++;
138
+ continue;
139
+ }
140
+ if (ch === " " || ch === "\t" || ch === "\r" || ch === "\n") {
141
+ push();
142
+ i++;
143
+ continue;
144
+ }
145
+ // 多字符操作符先行:>>、<<、&&、||、2>、1>> 等。
146
+ const three = cmd.slice(i, i + 3);
147
+ const two = cmd.slice(i, i + 2);
148
+ if (/^\d>>/.test(three) || three === "<<<") {
149
+ push();
150
+ toks.push(three);
151
+ i += 3;
152
+ continue;
153
+ }
154
+ if (two === ">>" || two === "<<" || two === "&&" || two === "||" || /^\d>$/.test(two) || /^\d</.test(two)) {
155
+ push();
156
+ toks.push(two);
157
+ i += 2;
158
+ continue;
159
+ }
160
+ if (ch === ">" || ch === "<" || ch === ";" || ch === "|" || ch === "&" || ch === "(" || ch === ")") {
161
+ push();
162
+ toks.push(ch);
163
+ i++;
164
+ continue;
165
+ }
166
+ cur += ch;
167
+ i++;
168
+ }
169
+ push();
170
+ return toks;
171
+ }
172
+ const SEGMENT_SEPS = new Set([";", "&&", "||", "|", "&", "(", ")"]);
173
+ /** 命令段首可跳过的包装词(之后仍是命令位置)。 */
174
+ const WRAPPER_WORDS = new Set(["sudo", "env", "command", "time", "nohup", "doas"]);
175
+ const MULTI_TARGET_WRITES = new Set(["rm", "rmdir", "mkdir", "touch"]);
176
+ /** 只算目标端(最后一个非 flag 参数)的命令。 */
177
+ const DEST_ONLY_WRITES = new Set(["cp", "mv"]);
178
+ /** 重定向目标黑名单:不是文件。 */
179
+ function isNonFileTarget(t) {
180
+ if (t === "/dev/null" || t.toLowerCase() === "nul")
181
+ return true;
182
+ if (/^&\d*$/.test(t))
183
+ return true;
184
+ if (t === "-")
185
+ return true;
186
+ return false;
187
+ }
188
+ /** bash 命令 → 高置信度写入路径(去重前,调用方计数)。 */
189
+ export function bashWriteTargets(command) {
190
+ if (typeof command !== "string" || !command.trim())
191
+ return [];
192
+ const toks = tokenizeShell(command);
193
+ const out = [];
194
+ // 命令段内状态:cmd = 当前命令词(null = 还在找命令词),args = 该段非 flag 参数。
195
+ let cmd = null;
196
+ let pendingMulti = [];
197
+ let pendingDest = null;
198
+ let afterDoubleDash = false;
199
+ let skipNext = false;
200
+ const flush = () => {
201
+ out.push(...pendingMulti);
202
+ if (pendingDest !== null)
203
+ out.push(pendingDest);
204
+ pendingMulti = [];
205
+ pendingDest = null;
206
+ };
207
+ for (let i = 0; i < toks.length; i++) {
208
+ const t = toks[i];
209
+ if (skipNext) {
210
+ skipNext = false;
211
+ continue;
212
+ }
213
+ if (SEGMENT_SEPS.has(t)) {
214
+ flush();
215
+ cmd = null;
216
+ afterDoubleDash = false;
217
+ continue;
218
+ }
219
+ // 重定向:`[fd]>[>] 目标`;heredoc 一律跳过。分隔符不能当目标吃掉
220
+ // (`echo a > ; rm x` 里 `;` 必须保留,否则后半段命令会错位)。
221
+ if (/^(\d*)(>>?)$/.test(t) || t === "<<" || t === "<<<" || /^(\d*)<<<?$/.test(t)) {
222
+ const next = toks[i + 1];
223
+ if (typeof next !== "undefined" && !SEGMENT_SEPS.has(next)) {
224
+ if (t.includes(">") && !isNonFileTarget(next))
225
+ out.push(next);
226
+ skipNext = true;
227
+ }
228
+ continue;
229
+ }
230
+ if (t === "--") {
231
+ afterDoubleDash = true;
232
+ continue;
233
+ }
234
+ if (cmd === null) {
235
+ if (WRAPPER_WORDS.has(t))
236
+ continue;
237
+ if (t.includes("=") && !t.startsWith("-"))
238
+ continue; // VAR=val 前缀赋值
239
+ cmd = t;
240
+ if (cmd === "xargs")
241
+ cmd = "\0xargs-opaque"; // xargs 后的是子命令+参数,别当文件(局限 4)
242
+ continue;
243
+ }
244
+ if (cmd === "\0xargs-opaque")
245
+ continue;
246
+ if (!afterDoubleDash && t.startsWith("-") && t.length > 1)
247
+ continue; // flag
248
+ if (MULTI_TARGET_WRITES.has(cmd))
249
+ pendingMulti.push(t);
250
+ else if (DEST_ONLY_WRITES.has(cmd))
251
+ pendingDest = t; // 只留最后一个
252
+ }
253
+ flush();
254
+ return out.filter((p) => p !== "" && !isNonFileTarget(p));
255
+ }
256
+ /** 转录消息 → 触碰文件集(按次数降序,次数相同按最后时间降序)。 */
257
+ export function extractTouches(messages, opts) {
258
+ if (!Array.isArray(messages) || messages.length === 0)
259
+ return [];
260
+ const max = Math.max(1, Math.floor(opts?.maxMessages ?? 500));
261
+ const slice = messages.slice(Math.max(0, messages.length - max));
262
+ const acc = new Map();
263
+ const bump = (raw, ts) => {
264
+ const path = normalizeTouchPath(raw);
265
+ if (!path)
266
+ return;
267
+ const e = acc.get(path);
268
+ if (e) {
269
+ e.count++;
270
+ if (ts > e.lastTs)
271
+ e.lastTs = ts;
272
+ }
273
+ else {
274
+ acc.set(path, { count: 1, lastTs: ts });
275
+ }
276
+ };
277
+ for (const m of slice) {
278
+ if (!m || typeof m !== "object")
279
+ continue;
280
+ const ts = typeof m.timestamp === "number" && Number.isFinite(m.timestamp) ? m.timestamp : 0;
281
+ for (const call of toolCallsOf(m)) {
282
+ const name = call.name;
283
+ if (WRITE_TOOL_NAMES.has(name)) {
284
+ const p = pathOfWriteArgs(call.args);
285
+ if (p)
286
+ bump(p, ts);
287
+ continue;
288
+ }
289
+ if (name === "bash" && call.args && typeof call.args === "object") {
290
+ const c = call.args.command;
291
+ if (typeof c === "string") {
292
+ for (const p of bashWriteTargets(c))
293
+ bump(p, ts);
294
+ }
295
+ }
296
+ }
297
+ // `!` 命令 / bashExecution 转录消息:命令在顶层 command 字段。
298
+ if (m.role === "bashExecution" && typeof m.command === "string") {
299
+ for (const p of bashWriteTargets(m.command))
300
+ bump(p, ts);
301
+ }
302
+ }
303
+ return [...acc.entries()]
304
+ .map(([path, e]) => ({ path, count: e.count, lastTs: e.lastTs }))
305
+ .sort((a, b) => b.count - a.count || b.lastTs - a.lastTs);
306
+ }
307
+ /** 两个触碰集求并(sidecar 合并用):sidecar 是历史快照、live 是当前全量,
308
+ * 两者消息集重叠,所以 count 取大不相加 —— 相加会把同一批触碰算两遍。
309
+ * 压缩后次数只能保证是下界(presence 与 lastTs 精确,交集计算不受影响)。 */
310
+ export function unionTouchLists(a, b) {
311
+ const acc = new Map();
312
+ for (const f of [...(Array.isArray(a) ? a : []), ...(Array.isArray(b) ? b : [])]) {
313
+ if (!f || typeof f.path !== "string")
314
+ continue;
315
+ const e = acc.get(f.path);
316
+ const count = typeof f.count === "number" && Number.isFinite(f.count) ? f.count : 0;
317
+ const lastTs = typeof f.lastTs === "number" && Number.isFinite(f.lastTs) ? f.lastTs : 0;
318
+ if (e) {
319
+ if (count > e.count)
320
+ e.count = count;
321
+ if (lastTs > e.lastTs)
322
+ e.lastTs = lastTs;
323
+ }
324
+ else {
325
+ acc.set(f.path, { count, lastTs });
326
+ }
327
+ }
328
+ return [...acc.entries()]
329
+ .map(([path, e]) => ({ path, count: e.count, lastTs: e.lastTs }))
330
+ .sort((x, y) => y.count - x.count || y.lastTs - x.lastTs);
331
+ }
332
+ /** 两个触碰集的交集:取 a 中同时出现在 b 里的条目(计数/时间沿用 a 的)。 */
333
+ export function intersectTouches(a, b) {
334
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length === 0 || b.length === 0)
335
+ return [];
336
+ const inB = new Set(b.map((f) => f.path));
337
+ return a.filter((f) => inB.has(f.path));
338
+ }
339
+ /** 单条触碰格式化(供提醒用):条目超长时优先保路径完整 —— 路径永不截断
340
+ * (半截路径不可用,还可能误导 AI 去碰错文件),只丢 `×N` 后缀。 */
341
+ export function formatTouchEntry(f, perItemCap = 60) {
342
+ const full = f.count > 1 ? `${f.path} ×${f.count}` : f.path;
343
+ if (full.length <= perItemCap)
344
+ return full;
345
+ return f.path;
346
+ }
347
+ /** 触碰集紧凑格式化:前 N 条完整路径 + `… (+K)` 计数(静默丢弃是大忌)。 */
348
+ export function formatTouchesCompact(files, opts) {
349
+ if (!Array.isArray(files) || files.length === 0)
350
+ return "";
351
+ const maxItems = Math.max(1, Math.floor(opts?.maxItems ?? 3));
352
+ const cap = Math.max(10, Math.floor(opts?.perItemCap ?? 60));
353
+ const shown = files.slice(0, maxItems).map((f) => formatTouchEntry(f, cap));
354
+ if (files.length > maxItems)
355
+ shown.push(`… (+${files.length - maxItems})`);
356
+ return shown.join("、");
357
+ }
@@ -2590,6 +2590,8 @@ export class DshClientSession {
2590
2590
  terminalToolsEnabled: this.settings.terminalToolsEnabled,
2591
2591
  terminalBash: this.settings.terminalBash,
2592
2592
  terminalBashIdleMs: this.settings.terminalBashIdleMs,
2593
+ // DSH 引擎无 customTool 注册面(工具来自 shipped preset),read 目录覆盖面不存在。
2594
+ readDirEnabled: true,
2593
2595
  editSoftEnabled: this.settings.editSoftEnabled,
2594
2596
  // DSH 无独立重试配置(pi 引擎才暴露),保持默认。
2595
2597
  retryMaxAttempts: DEFAULT_RETRY_MAX_ATTEMPTS,
@@ -3880,6 +3882,11 @@ export class DshClientSession {
3880
3882
  this.emit({ type: "notice", level: "error", text: error, textEn: errorEn });
3881
3883
  this.emit({ type: "clone_provider_result", reqId, ok: false, error });
3882
3884
  }
3885
+ async enrichModels(reqId, _ids, _hints) {
3886
+ const error = pick(this.getLang(), "DSH 引擎不支持自定义 provider", "The DSH engine does not support custom providers", "dsh.provider.custom.unsupported");
3887
+ this.emit({ type: "enrich_models_result", reqId, ok: false, error });
3888
+ }
3889
+ abortEnrichModels(_reqId) { }
3883
3890
  // -----------------------------------------------------------------------
3884
3891
  // 其他
3885
3892
  // -----------------------------------------------------------------------
@@ -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() {
@@ -279,7 +291,9 @@ app.get("/api/health", (_req, res) => {
279
291
  *
280
292
  * Media preview (no download param): only image/video kinds are served —
281
293
  * text goes over the WebSocket, and exe/jar/etc. are never exposed here.
282
- * express's sendFile handles Range requests, so video seeking works.
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.
283
297
  *
284
298
  * Download (?download=1): any file kind is served with
285
299
  * Content-Disposition: attachment so the browser saves it instead of
@@ -316,7 +330,8 @@ app.get("/api/file", async (req, res) => {
316
330
  // allowlist them explicitly here.
317
331
  const lower = name.toLowerCase();
318
332
  const isHtmlPreview = lower.endsWith(".html") || lower.endsWith(".htm") || lower.endsWith(".xhtml");
319
- if (!isDownload && kind !== "image" && kind !== "video" && !isHtmlPreview) {
333
+ const isAudioPreview = isAudioFile(name);
334
+ if (!isDownload && kind !== "image" && kind !== "video" && !isHtmlPreview && !isAudioPreview) {
320
335
  res.status(400).end("not a previewable media file");
321
336
  return;
322
337
  }
@@ -1609,6 +1624,12 @@ wss.on("connection", (ws) => {
1609
1624
  case "set_model":
1610
1625
  void cs.setModel(msg.modelId);
1611
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;
1612
1633
  case "set_thinking":
1613
1634
  cs.setThinking(msg.level);
1614
1635
  break;
@@ -1727,6 +1748,12 @@ wss.on("connection", (ws) => {
1727
1748
  case "clone_provider":
1728
1749
  void cs.cloneProvider(msg.provider, msg.reqId);
1729
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;
1730
1757
  case "list_provider_keys":
1731
1758
  cs.listProviderKeys();
1732
1759
  break;
@@ -1814,6 +1841,7 @@ wss.on("connection", (ws) => {
1814
1841
  terminalToolsEnabled: msg.terminalToolsEnabled,
1815
1842
  terminalBash: msg.terminalBash,
1816
1843
  terminalBashIdleMs: msg.terminalBashIdleMs,
1844
+ readDirEnabled: msg.readDirEnabled,
1817
1845
  editSoftEnabled: msg.editSoftEnabled,
1818
1846
  questionnaireEnabled: msg.questionnaireEnabled,
1819
1847
  parallelReminderEnabled: msg.parallelReminderEnabled,
@@ -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
  };