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.
- package/CHANGELOG.md +34 -1
- package/bin/pi-web-ui.mjs +21 -2
- package/dist/server/agent-service.js +443 -45
- package/dist/server/claim-files-tool.js +116 -0
- package/dist/server/claim-store.js +301 -0
- package/dist/server/client-state.js +49 -0
- package/dist/server/conversation-read-tool.js +369 -27
- package/dist/server/conversation-touches.js +357 -0
- package/dist/server/dsh/dsh-agent-service.js +7 -0
- package/dist/server/index.js +32 -4
- package/dist/server/model-admin.js +124 -2
- package/dist/server/model-enrich.js +478 -0
- package/dist/server/plugin-schedule.js +27 -2
- package/dist/server/plugins.js +65 -10
- package/dist/server/present-files-tool.js +303 -0
- package/dist/server/read-tool.js +91 -0
- package/dist/server/serialize.js +21 -1
- package/dist/server/settings-service.js +7 -0
- package/dist/server/text-sniff.js +15 -0
- package/dist/server/themes.js +17 -2
- package/dist/server/tool-manager.js +7 -1
- package/package.json +1 -1
- package/plugin-sdk/README.md +1 -0
- package/plugins/catalog.json +10 -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-GtDQ1h85.js → TerminalPanel-RACpk6AX.js} +1 -1
- 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-CdsKltOS.css +0 -1
- 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
|
// -----------------------------------------------------------------------
|
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() {
|
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
};
|