pi-web-ui 0.94.0 → 0.95.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 +62 -0
- package/README.md +5 -6
- package/README.zh-CN.md +4 -5
- package/bin/pi-web-ui.mjs +751 -206
- package/dist/server/agent-service.js +1869 -85
- package/dist/server/approval-rules.js +550 -0
- package/dist/server/attachment-store.js +112 -0
- package/dist/server/attachments.js +62 -171
- package/dist/server/client-state.js +4 -0
- package/dist/server/compact-context-tool.js +125 -0
- package/dist/server/context-budget.js +317 -0
- package/dist/server/dangling-tools.js +162 -0
- package/dist/server/dsh/dsh-agent-service.js +50 -60
- package/dist/server/edit-soft-tool.js +70 -5
- package/dist/server/eval-tool.js +535 -0
- package/dist/server/files-service.js +35 -7
- package/dist/server/goal-service.js +240 -68
- package/dist/server/hashline-engine.js +665 -0
- package/dist/server/host-metrics.js +26 -2
- package/dist/server/http-proxy.js +11 -1
- package/dist/server/index.js +157 -9
- package/dist/server/lsp-tool.js +679 -0
- package/dist/server/model-admin.js +20 -0
- package/dist/server/office-parse.js +309 -0
- package/dist/server/patch-tool.js +91 -0
- package/dist/server/plan-manager.js +104 -0
- package/dist/server/plugin-api-catalog.js +296 -0
- package/dist/server/plugin-install-spec.js +196 -0
- package/dist/server/plugin-installer.js +73 -0
- package/dist/server/plugin-manifest-validate.js +305 -0
- package/dist/server/plugin-project.js +47 -11
- package/dist/server/plugin-tool-guard.js +120 -0
- package/dist/server/plugins.js +745 -161
- package/dist/server/process-utils.js +12 -6
- package/dist/server/scheduler-tasks.js +6 -0
- package/dist/server/serialize.js +13 -3
- package/dist/server/settings-service.js +108 -1
- package/dist/server/subagents.js +41 -0
- package/dist/server/terminals.js +69 -14
- package/dist/server/tool-approval.js +84 -0
- package/dist/server/tool-manager.js +214 -6
- package/dist/server/workspace-snapshot.js +113 -0
- package/package.json +3 -2
- package/plugin-sdk/README.md +25 -0
- package/plugin-sdk/index.d.ts +31 -38
- package/plugin-sdk/index.mjs +35 -19
- package/plugins/catalog.json +20 -0
- package/themes/ayu-light.css +6 -6
- package/themes/catppuccin-latte.css +6 -6
- package/themes/claude-code-dark.css +144 -0
- package/themes/codex.css +6 -6
- package/themes/dark-teal.css +20 -6
- package/themes/everforest-light.css +6 -6
- package/themes/geist.css +6 -6
- package/themes/gruvbox-light.css +6 -6
- package/themes/kanagawa-lotus.css +6 -6
- package/themes/rose-pine-dawn.css +6 -6
- package/themes/solarized-light.css +6 -6
- package/themes/vs-code-dark.css +146 -0
- package/web/dist/assets/{TerminalPanel-B-CMQuzm.js → TerminalPanel-DlGBF2MD.js} +1 -1
- package/web/dist/assets/index-BXnCQL9p.js +364 -0
- package/web/dist/assets/index-RpIGFyi_.css +1 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-18jK6al2.js +0 -364
- package/web/dist/assets/index-B3S9MxnN.css +0 -1
|
@@ -1244,6 +1244,26 @@ export class ModelAdminService {
|
|
|
1244
1244
|
emitError(err.message);
|
|
1245
1245
|
}
|
|
1246
1246
|
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Lightweight connectivity and auth probe for a provider endpoint.
|
|
1249
|
+
* Measures latency in ms and returns ok/error.
|
|
1250
|
+
*/
|
|
1251
|
+
async testConnection(reqId, baseUrl, apiKey, authHeader, api, lang) {
|
|
1252
|
+
const start = Date.now();
|
|
1253
|
+
try {
|
|
1254
|
+
await ModelAdminService.probeModelsEndpoint(baseUrl, apiKey, authHeader, api, undefined, lang);
|
|
1255
|
+
const latencyMs = Date.now() - start;
|
|
1256
|
+
this.host.emit({ type: "test_model_connection_result", reqId, ok: true, latencyMs });
|
|
1257
|
+
}
|
|
1258
|
+
catch (err) {
|
|
1259
|
+
this.host.emit({
|
|
1260
|
+
type: "test_model_connection_result",
|
|
1261
|
+
reqId,
|
|
1262
|
+
ok: false,
|
|
1263
|
+
error: err.message,
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1247
1267
|
/**
|
|
1248
1268
|
* Probe a custom provider's model-list endpoint (OpenAI-compatible /models
|
|
1249
1269
|
* with a /v1 retry; Google {models:[…]} shape supported). Throws Error with
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Office 文档纯文本提取(docx / xlsx / xlsm,csv 由调用方按普通文本处理)。
|
|
3
|
+
*
|
|
4
|
+
* 给「随处可看」用的:文件预览(files-service readFile)、附件等走 file_content
|
|
5
|
+
* 下发 Markdown 文本,前端 FilePreview 按 Markdown 渲染表格/段落,无需改协议。
|
|
6
|
+
*
|
|
7
|
+
* 零依赖:docx / xlsx 本质是 zip 包,这里用 node:zlib + 手写 zip 中央目录解析
|
|
8
|
+
* 做最小解包(只支持 stored / deflate,Office 写出的文件都是这两种)。
|
|
9
|
+
*
|
|
10
|
+
* 注意:plugins/office-preview/index.mjs 里有一份同逻辑的 JS 拷贝(插件必须
|
|
11
|
+
* 自包含、不能 import 服务端 TS,见架构文档插件章)。两边改解析时请同步另一份。
|
|
12
|
+
*/
|
|
13
|
+
import { inflateRawSync } from "node:zlib";
|
|
14
|
+
const OFFICE_EXTS = new Set([".docx", ".xlsx", ".xlsm"]);
|
|
15
|
+
/** 单个文件上限(与 office-preview 插件一致)。 */
|
|
16
|
+
export const OFFICE_MAX_FILE_BYTES = 15 * 1024 * 1024;
|
|
17
|
+
/** 解包后总量上限(防 zip 炸弹:小包解出巨量内容)。 */
|
|
18
|
+
export const OFFICE_MAX_UNCOMPRESSED_BYTES = 64 * 1024 * 1024;
|
|
19
|
+
/** 下发文本上限(字符;超出截断并置 truncated)。 */
|
|
20
|
+
export const OFFICE_MAX_TEXT_CHARS = 200_000;
|
|
21
|
+
/** 表格上限:每表行数 × 列数(预览首屏口径)。 */
|
|
22
|
+
const MAX_ROWS = 500;
|
|
23
|
+
const MAX_COLS = 20;
|
|
24
|
+
export function isOfficeFile(name) {
|
|
25
|
+
const dot = String(name ?? "")
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.lastIndexOf(".");
|
|
28
|
+
const ext = dot > 0 ? String(name).toLowerCase().slice(dot) : "";
|
|
29
|
+
return OFFICE_EXTS.has(ext);
|
|
30
|
+
}
|
|
31
|
+
function findEocd(buf) {
|
|
32
|
+
const sig = 0x06054b50;
|
|
33
|
+
const minLen = 22;
|
|
34
|
+
if (buf.length < minLen)
|
|
35
|
+
throw new Error("不是有效的 zip 文件(太小)");
|
|
36
|
+
const start = Math.max(0, buf.length - 65536 - minLen);
|
|
37
|
+
for (let i = buf.length - minLen; i >= start; i--) {
|
|
38
|
+
if (buf.readUInt32LE(i) === sig)
|
|
39
|
+
return i;
|
|
40
|
+
}
|
|
41
|
+
throw new Error("不是有效的 zip 文件(找不到 EOCD)");
|
|
42
|
+
}
|
|
43
|
+
/** 返回 Map<文件名, Buffer>(只解要的文件,其余跳过)。 */
|
|
44
|
+
export function unzipFiles(buf, wanted) {
|
|
45
|
+
const want = new Set(wanted);
|
|
46
|
+
const eocd = findEocd(buf);
|
|
47
|
+
const cdCount = buf.readUInt16LE(eocd + 10);
|
|
48
|
+
const cdOffset = buf.readUInt32LE(eocd + 16);
|
|
49
|
+
const files = new Map();
|
|
50
|
+
let p = cdOffset;
|
|
51
|
+
for (let i = 0; i < cdCount; i++) {
|
|
52
|
+
if (buf.readUInt32LE(p) !== 0x02014b50)
|
|
53
|
+
throw new Error("zip 中央目录损坏");
|
|
54
|
+
const flag = buf.readUInt16LE(p + 8);
|
|
55
|
+
const method = buf.readUInt16LE(p + 10);
|
|
56
|
+
const compSize = buf.readUInt32LE(p + 20);
|
|
57
|
+
const uncompSize = buf.readUInt32LE(p + 24);
|
|
58
|
+
const nameLen = buf.readUInt16LE(p + 28);
|
|
59
|
+
const extraLen = buf.readUInt16LE(p + 30);
|
|
60
|
+
const commentLen = buf.readUInt16LE(p + 32);
|
|
61
|
+
const localOffset = buf.readUInt32LE(p + 42);
|
|
62
|
+
const name = buf.toString("utf8", p + 46, p + 46 + nameLen);
|
|
63
|
+
if (want.has(name))
|
|
64
|
+
files.set(name, { method, compSize, flag, localOffset, uncompSize });
|
|
65
|
+
p += 46 + nameLen + extraLen + commentLen;
|
|
66
|
+
}
|
|
67
|
+
let totalUncomp = 0;
|
|
68
|
+
const out = new Map();
|
|
69
|
+
for (const [name, meta] of files) {
|
|
70
|
+
totalUncomp += meta.uncompSize;
|
|
71
|
+
if (totalUncomp > OFFICE_MAX_UNCOMPRESSED_BYTES)
|
|
72
|
+
throw new Error("解包后内容过大(疑似 zip 炸弹),拒绝预览");
|
|
73
|
+
const lp = meta.localOffset;
|
|
74
|
+
if (buf.readUInt32LE(lp) !== 0x04034b50)
|
|
75
|
+
throw new Error(`zip 局部头损坏:${name}`);
|
|
76
|
+
const lMethod = buf.readUInt16LE(lp + 8);
|
|
77
|
+
const lNameLen = buf.readUInt16LE(lp + 26);
|
|
78
|
+
const lExtraLen = buf.readUInt16LE(lp + 28);
|
|
79
|
+
const dataStart = lp + 30 + lNameLen + lExtraLen;
|
|
80
|
+
const raw = buf.subarray(dataStart, dataStart + meta.compSize);
|
|
81
|
+
if (meta.flag & 0x1)
|
|
82
|
+
throw new Error(`不支持加密 zip 条目:${name}`);
|
|
83
|
+
const method = lMethod || meta.method;
|
|
84
|
+
if (method === 0)
|
|
85
|
+
out.set(name, Buffer.from(raw));
|
|
86
|
+
else if (method === 8)
|
|
87
|
+
out.set(name, Buffer.from(inflateRawSync(raw)));
|
|
88
|
+
else
|
|
89
|
+
throw new Error(`不支持的压缩方式 ${method}:${name}`);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
function decodeEntities(s) {
|
|
94
|
+
return String(s ?? "")
|
|
95
|
+
.replace(/&(lt|gt|amp|quot|apos);/g, (_, e) => ({ lt: "<", gt: ">", amp: "&", quot: '"', apos: "'" })[e])
|
|
96
|
+
.replace(/&#(\d+);/g, (_, n) => {
|
|
97
|
+
try {
|
|
98
|
+
return String.fromCodePoint(Number(n));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return "";
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => {
|
|
105
|
+
try {
|
|
106
|
+
return String.fromCodePoint(Number.parseInt(h, 16));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return "";
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const stripTags = (s) => decodeEntities(String(s ?? "").replace(/<[^>]+>/g, ""));
|
|
114
|
+
/** docx → 段落数组。 */
|
|
115
|
+
export function parseDocxParagraphs(buf) {
|
|
116
|
+
const files = unzipFiles(buf, ["word/document.xml"]);
|
|
117
|
+
const xml = files.get("word/document.xml")?.toString("utf8");
|
|
118
|
+
if (!xml)
|
|
119
|
+
throw new Error("docx 里找不到 word/document.xml");
|
|
120
|
+
const paragraphs = [];
|
|
121
|
+
for (const m of xml.matchAll(/<w:p[\s>][\s\S]*?<\/w:p>/g)) {
|
|
122
|
+
const pXml = m[0];
|
|
123
|
+
let text = "";
|
|
124
|
+
for (const n of pXml.matchAll(/<w:(t|tab|br)[^>]*\/?>([^<]*)(?:<\/w:t>)?/g)) {
|
|
125
|
+
if (n[1] === "tab")
|
|
126
|
+
text += "\t";
|
|
127
|
+
else if (n[1] === "br")
|
|
128
|
+
text += "\n";
|
|
129
|
+
else
|
|
130
|
+
text += decodeEntities(n[2]);
|
|
131
|
+
}
|
|
132
|
+
paragraphs.push(text);
|
|
133
|
+
}
|
|
134
|
+
while (paragraphs.length > 1 &&
|
|
135
|
+
paragraphs[paragraphs.length - 1] === "" &&
|
|
136
|
+
paragraphs[paragraphs.length - 2] === "") {
|
|
137
|
+
paragraphs.pop();
|
|
138
|
+
}
|
|
139
|
+
return paragraphs;
|
|
140
|
+
}
|
|
141
|
+
function colLettersToIndex(letters) {
|
|
142
|
+
let n = 0;
|
|
143
|
+
for (const ch of letters.toUpperCase())
|
|
144
|
+
n = n * 26 + (ch.charCodeAt(0) - 64);
|
|
145
|
+
return n - 1;
|
|
146
|
+
}
|
|
147
|
+
function splitCellRef(ref) {
|
|
148
|
+
const m = /^([A-Za-z]+)(\d+)$/.exec(String(ref ?? "").trim());
|
|
149
|
+
if (!m)
|
|
150
|
+
return null;
|
|
151
|
+
return { col: colLettersToIndex(m[1]), row: Number(m[2]) - 1 };
|
|
152
|
+
}
|
|
153
|
+
function parseSharedStrings(xml) {
|
|
154
|
+
if (!xml)
|
|
155
|
+
return [];
|
|
156
|
+
const out = [];
|
|
157
|
+
for (const m of xml.matchAll(/<si>([\s\S]*?)<\/si>/g)) {
|
|
158
|
+
const texts = [...m[1].matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((x) => decodeEntities(x[1]));
|
|
159
|
+
out.push(texts.join(""));
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
function parseSheet(xml, shared) {
|
|
164
|
+
const rows = [];
|
|
165
|
+
for (const m of xml.matchAll(/<row[^>]*>([\s\S]*?)<\/row>/g)) {
|
|
166
|
+
const rowAttr = m[0].slice(0, m[0].indexOf(">"));
|
|
167
|
+
const rNum = Number(/r="(\d+)"/.exec(rowAttr)?.[1] ?? rows.length + 1) - 1;
|
|
168
|
+
while (rows.length <= rNum)
|
|
169
|
+
rows.push([]);
|
|
170
|
+
const row = rows[rNum];
|
|
171
|
+
for (const c of m[1].matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/g)) {
|
|
172
|
+
const attrs = c[1];
|
|
173
|
+
const ref = /r="([^"]+)"/.exec(attrs)?.[1];
|
|
174
|
+
const t = /t="([^"]+)"/.exec(attrs)?.[1];
|
|
175
|
+
const pos = splitCellRef(ref ?? "");
|
|
176
|
+
if (!pos)
|
|
177
|
+
continue;
|
|
178
|
+
const inner = c[2];
|
|
179
|
+
let val = "";
|
|
180
|
+
if (t === "inlineStr") {
|
|
181
|
+
val = [...inner.matchAll(/<t[^>]*>([\s\S]*?)<\/t>/g)].map((x) => decodeEntities(x[1])).join("");
|
|
182
|
+
}
|
|
183
|
+
else if (t === "s") {
|
|
184
|
+
const idx = Number(stripTags(/<v>([\s\S]*?)<\/v>/.exec(inner)?.[1] ?? ""));
|
|
185
|
+
val = Number.isFinite(idx) && shared[idx] !== undefined ? shared[idx] : "";
|
|
186
|
+
}
|
|
187
|
+
else if (t === "b") {
|
|
188
|
+
val = stripTags(/<v>([\s\S]*?)<\/v>/.exec(inner)?.[1] ?? "") === "1" ? "TRUE" : "FALSE";
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
val = stripTags(/<v>([\s\S]*?)<\/v>/.exec(inner)?.[1] ?? "");
|
|
192
|
+
if (!val)
|
|
193
|
+
val = stripTags(inner);
|
|
194
|
+
}
|
|
195
|
+
while (row.length <= pos.col)
|
|
196
|
+
row.push("");
|
|
197
|
+
row[pos.col] = val;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return rows;
|
|
201
|
+
}
|
|
202
|
+
/** xlsx/xlsm → sheet 数组(名按 workbook 还原,取不到时回落 sheetN)。 */
|
|
203
|
+
export function parseXlsxSheets(buf) {
|
|
204
|
+
const probe = unzipFiles(buf, ["xl/workbook.xml", "xl/_rels/workbook.xml.rels"]);
|
|
205
|
+
const wbXml = probe.get("xl/workbook.xml")?.toString("utf8") ?? "";
|
|
206
|
+
const relsXml = probe.get("xl/_rels/workbook.xml.rels")?.toString("utf8") ?? "";
|
|
207
|
+
const relTarget = new Map();
|
|
208
|
+
for (const m of relsXml.matchAll(/<Relationship\b[^>]*>/g)) {
|
|
209
|
+
const id = /Id="([^"]+)"/.exec(m[0])?.[1];
|
|
210
|
+
const target = /Target="([^"]+)"/.exec(m[0])?.[1];
|
|
211
|
+
if (id && target)
|
|
212
|
+
relTarget.set(id, target.replace(/^\/+/, "").replace(/^xl\//, ""));
|
|
213
|
+
}
|
|
214
|
+
const sheetDefs = [];
|
|
215
|
+
for (const m of wbXml.matchAll(/<sheet\b[^>]*\/>/g)) {
|
|
216
|
+
const name = /name="([^"]*)"/.exec(m[0])?.[1] ?? `Sheet${sheetDefs.length + 1}`;
|
|
217
|
+
const rid = /r:id="([^"]+)"/.exec(m[0])?.[1];
|
|
218
|
+
sheetDefs.push({ name: decodeEntities(name), rid });
|
|
219
|
+
}
|
|
220
|
+
let targets = sheetDefs.map((d, i) => {
|
|
221
|
+
const t = d.rid && relTarget.get(d.rid);
|
|
222
|
+
return { name: d.name, file: t ? `xl/${t}` : `xl/worksheets/sheet${i + 1}.xml` };
|
|
223
|
+
});
|
|
224
|
+
if (targets.length === 0)
|
|
225
|
+
targets = [{ name: "Sheet1", file: "xl/worksheets/sheet1.xml" }];
|
|
226
|
+
const files = unzipFiles(buf, ["xl/sharedStrings.xml", ...targets.map((t) => t.file)]);
|
|
227
|
+
const shared = parseSharedStrings(files.get("xl/sharedStrings.xml")?.toString("utf8"));
|
|
228
|
+
return targets.map((t) => {
|
|
229
|
+
const xml = files.get(t.file)?.toString("utf8");
|
|
230
|
+
const all = xml ? parseSheet(xml, shared) : [];
|
|
231
|
+
const fullCols = Math.max(0, ...all.map((r) => r.length));
|
|
232
|
+
const nCols = Math.min(MAX_COLS, fullCols);
|
|
233
|
+
const cut = all.slice(0, MAX_ROWS).map((r) => {
|
|
234
|
+
const row = r.slice(0, MAX_COLS);
|
|
235
|
+
while (row.length < nCols)
|
|
236
|
+
row.push("");
|
|
237
|
+
return row;
|
|
238
|
+
});
|
|
239
|
+
return {
|
|
240
|
+
name: t.name,
|
|
241
|
+
nRows: all.length,
|
|
242
|
+
nCols: fullCols,
|
|
243
|
+
rows: cut,
|
|
244
|
+
truncated: all.length > MAX_ROWS || fullCols > MAX_COLS,
|
|
245
|
+
};
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
const mdCell = (s) => String(s ?? "")
|
|
249
|
+
.replace(/\|/g, "\\|")
|
|
250
|
+
.replace(/\r?\n/g, " ");
|
|
251
|
+
/** 列号 → Excel 式列名(0→A,25→Z,26→AA)。 */
|
|
252
|
+
function colName(i) {
|
|
253
|
+
let s = "";
|
|
254
|
+
let n = i;
|
|
255
|
+
do {
|
|
256
|
+
s = String.fromCharCode(65 + (n % 26)) + s;
|
|
257
|
+
n = Math.floor(n / 26) - 1;
|
|
258
|
+
} while (n >= 0);
|
|
259
|
+
return s;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* 表头固定用 A/B/C…(与 Excel 列标一致),而不拿第一行数据冒充表头——
|
|
263
|
+
* 很多表的第一行是通知/标题文字,染成紫色表头又怪又误导。
|
|
264
|
+
*/
|
|
265
|
+
function sheetToMarkdown(sheet) {
|
|
266
|
+
const head = `## ${sheet.name}(${sheet.nRows} 行 × ${sheet.nCols} 列${sheet.truncated ? ",只看前一部分" : ""})`;
|
|
267
|
+
if (sheet.rows.length === 0)
|
|
268
|
+
return `${head}\n\n(空表)`;
|
|
269
|
+
const width = Math.max(1, ...sheet.rows.map((r) => r.length));
|
|
270
|
+
const pad = (r) => {
|
|
271
|
+
const row = r.slice(0, width);
|
|
272
|
+
while (row.length < width)
|
|
273
|
+
row.push("");
|
|
274
|
+
return row;
|
|
275
|
+
};
|
|
276
|
+
const lines = [Array.from({ length: width }, (_, i) => colName(i)), ...sheet.rows.map(pad)].map((r) => `| ${r.map(mdCell).join(" | ")} |`);
|
|
277
|
+
lines.splice(1, 0, `| ${Array(width).fill("---").join(" | ")} |`);
|
|
278
|
+
return [head, "", ...lines].join("\n");
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Office 文件 → Markdown 预览文本(前端 FilePreview 按 Markdown 渲染)。
|
|
282
|
+
* 非 office 扩展名返回 null;zip 损坏/加密等抛错(调用方回落旧的二进制嗅探)。
|
|
283
|
+
*/
|
|
284
|
+
export function extractOfficeText(filename, buf) {
|
|
285
|
+
const dot = String(filename ?? "")
|
|
286
|
+
.toLowerCase()
|
|
287
|
+
.lastIndexOf(".");
|
|
288
|
+
const ext = dot > 0 ? String(filename).toLowerCase().slice(dot) : "";
|
|
289
|
+
if (!OFFICE_EXTS.has(ext))
|
|
290
|
+
return null;
|
|
291
|
+
if (buf.length > OFFICE_MAX_FILE_BYTES) {
|
|
292
|
+
throw new Error(`文件太大(${(buf.length / 1048576).toFixed(1)} MB),Office 预览上限 15 MB`);
|
|
293
|
+
}
|
|
294
|
+
let text;
|
|
295
|
+
let truncated = false;
|
|
296
|
+
if (ext === ".docx") {
|
|
297
|
+
const paras = parseDocxParagraphs(buf);
|
|
298
|
+
text = paras.join("\n");
|
|
299
|
+
truncated = text.length > OFFICE_MAX_TEXT_CHARS;
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
const sheets = parseXlsxSheets(buf);
|
|
303
|
+
text = sheets.map(sheetToMarkdown).join("\n\n");
|
|
304
|
+
truncated = text.length > OFFICE_MAX_TEXT_CHARS || sheets.some((s) => s.truncated);
|
|
305
|
+
}
|
|
306
|
+
if (text.length > OFFICE_MAX_TEXT_CHARS)
|
|
307
|
+
text = text.slice(0, OFFICE_MAX_TEXT_CHARS);
|
|
308
|
+
return { text, truncated };
|
|
309
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* patch-tool.ts — 导出给 AI Agent 的结构化补丁工具(Hashline Patch Tool)。
|
|
3
|
+
*
|
|
4
|
+
* 核心设计:
|
|
5
|
+
* - 解决标准 edit 工具容易因缩进幻觉、空白字符或行号漂移导致编辑失败的问题。
|
|
6
|
+
* - 补丁语言基于 [path#TAG] 内容哈希锚点,支持精确行替换(PUT N.=M:)、
|
|
7
|
+
* 语法块级替换(PUT N*:,自动匹配闭合括号/缩进)、行前/后插入(PUT <N: / PUT >N:)、
|
|
8
|
+
* 剪切与寄存器粘贴(CUT / PUT @reg)、文件删除(REM)与重命名(MV)。
|
|
9
|
+
* - 当文件被外部改动导致哈希不一致时,自动尝试 3-Way Merge 冲突自愈。
|
|
10
|
+
*/
|
|
11
|
+
import { resolve } from "node:path";
|
|
12
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { Type } from "typebox";
|
|
14
|
+
import { applyHashlinePatch, computeFileHash, formatHashlineHeader, formatNumberedLines, globalSnapshotStore, } from "./hashline-engine.js";
|
|
15
|
+
import { getLiveLspDiagnostics } from "./lsp-tool.js";
|
|
16
|
+
export const PATCH_TOOL_NAME = "patch";
|
|
17
|
+
export function makePatchTool(options) {
|
|
18
|
+
const cwd = options.cwd;
|
|
19
|
+
return defineTool({
|
|
20
|
+
name: PATCH_TOOL_NAME,
|
|
21
|
+
label: "Apply Hashline patch",
|
|
22
|
+
description: `Apply high-reliability, content-hashed, line-anchored patches to files in the workspace.
|
|
23
|
+
Designed to prevent stale edits, line-drift, and indentation hallucination.
|
|
24
|
+
Each file section starts with \`[path#TAG]\` (or \`[path]\` if hash is not yet known).
|
|
25
|
+
Supports:
|
|
26
|
+
- \`PUT N.=M:\` replace original inclusive lines N to M with following \`+TEXT\` lines.
|
|
27
|
+
- \`PUT N*:\` replace entire syntactic block starting at line N (closing brace/indentation resolved automatically).
|
|
28
|
+
- \`PUT <N:\` insert lines before line N (\`PUT <1:\` = head of file).
|
|
29
|
+
- \`PUT >N:\` insert lines after line N (\`PUT >$:\` = end of file).
|
|
30
|
+
- \`CUT N.=M [@name]\` / \`CUT N* [@name]\` delete lines and save to register.
|
|
31
|
+
- \`PUT <N @name\` / \`PUT >N @name\` paste register.
|
|
32
|
+
- \`REM\` delete file.
|
|
33
|
+
- \`MV dest/path\` move/rename file.
|
|
34
|
+
- Body rows under \`:\` headers MUST start with \`+\` (\`+TEXT\`, \`+\` for blank line).
|
|
35
|
+
If file content diverged, the engine attempts automatic 3-way merge recovery.
|
|
36
|
+
After successful patch, the tool returns the next anchor tag and live LSP diagnostics for subsequent edits.`,
|
|
37
|
+
parameters: Type.Object({
|
|
38
|
+
patch: Type.String({
|
|
39
|
+
description: "The complete Hashline patch text containing one or more [path#TAG] sections with PUT/CUT/REM/MV operations.",
|
|
40
|
+
}),
|
|
41
|
+
timeout: Type.Optional(Type.Number({
|
|
42
|
+
description: "Optional execution timeout in seconds.",
|
|
43
|
+
})),
|
|
44
|
+
}),
|
|
45
|
+
async execute(_callId, params, _signal, _onUpdate, _ctx) {
|
|
46
|
+
const patchText = typeof params?.patch === "string" ? params.patch : "";
|
|
47
|
+
if (!patchText.trim()) {
|
|
48
|
+
const emptyReport = {
|
|
49
|
+
ok: false,
|
|
50
|
+
summary: "Empty patch text provided.",
|
|
51
|
+
results: [],
|
|
52
|
+
error: "Empty patch",
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
content: [{ type: "text", text: "Error: No patch content provided." }],
|
|
56
|
+
details: emptyReport,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const report = applyHashlinePatch(patchText, {
|
|
60
|
+
cwd,
|
|
61
|
+
snapshotStore: globalSnapshotStore,
|
|
62
|
+
});
|
|
63
|
+
if (!report.ok) {
|
|
64
|
+
return {
|
|
65
|
+
content: [{ type: "text", text: `Patch Failed:\n${report.summary}` }],
|
|
66
|
+
details: report,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const textOutput = [report.summary];
|
|
70
|
+
// 为成功修改的文件输出最新的头部标签,方便 Agent 连续进行下一步修改
|
|
71
|
+
for (const r of report.results) {
|
|
72
|
+
if (r.op !== "deleted" && r.newHash) {
|
|
73
|
+
const targetPath = r.newPath || r.filePath;
|
|
74
|
+
textOutput.push(`\nNext edit anchor for ${targetPath}: \`${formatHashlineHeader(targetPath, r.newHash)}\``);
|
|
75
|
+
try {
|
|
76
|
+
const fullPath = resolve(cwd, targetPath);
|
|
77
|
+
const diags = await getLiveLspDiagnostics(fullPath, cwd);
|
|
78
|
+
if (diags) {
|
|
79
|
+
textOutput.push(`\nLive LSP diagnostics for ${targetPath}:\n${diags}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch { }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
content: [{ type: "text", text: textOutput.join("\n") }],
|
|
87
|
+
details: report,
|
|
88
|
+
};
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* server/plan-manager.ts
|
|
3
|
+
*
|
|
4
|
+
* 结构化任务计划看板与步骤状态机(Plan Mode / Step State Machine)。
|
|
5
|
+
*
|
|
6
|
+
* 借鉴 DeepSeek Harness (DSH) 的 dsh-plan-mode:
|
|
7
|
+
* 1. 任务步骤状态机(Step State Machine):
|
|
8
|
+
* - 步骤字段:id, title, status ("pending" | "in_progress" | "done" | "failed"), description
|
|
9
|
+
* - 总体进度与当前执行中步骤
|
|
10
|
+
* 2. 计划管理与更新(PlanManager):
|
|
11
|
+
* - 模型可通过 customTool `plan_update` 更新
|
|
12
|
+
* - 客户端也可通过协议消息 `plan_update` 调整
|
|
13
|
+
* - 状态自动同步到快照 `UiState.plan`
|
|
14
|
+
*/
|
|
15
|
+
const VALID_STATUSES = new Set(["pending", "in_progress", "done", "failed"]);
|
|
16
|
+
export class PlanManager {
|
|
17
|
+
plans = new Map();
|
|
18
|
+
/** 获取指定会话的计划状态。 */
|
|
19
|
+
getPlan(conversationId) {
|
|
20
|
+
return this.plans.get(conversationId) ?? null;
|
|
21
|
+
}
|
|
22
|
+
/** 设置/全量更新指定会话的计划。 */
|
|
23
|
+
setPlan(conversationId, steps, activeStepId) {
|
|
24
|
+
const normalizedSteps = (Array.isArray(steps) ? steps : []).map((s, idx) => {
|
|
25
|
+
const status = (VALID_STATUSES.has(s.status) ? s.status : "pending");
|
|
26
|
+
return {
|
|
27
|
+
id: String(s.id || `step-${idx + 1}`),
|
|
28
|
+
title: String(s.title || "").slice(0, 200),
|
|
29
|
+
status,
|
|
30
|
+
...(s.description ? { description: String(s.description).slice(0, 1000) } : {}),
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
// 自动推断 activeStepId:优先显式传参;否则推断第一个 in_progress 的步骤
|
|
34
|
+
let effectiveActiveId = activeStepId;
|
|
35
|
+
if (effectiveActiveId === undefined) {
|
|
36
|
+
const inProg = normalizedSteps.find((s) => s.status === "in_progress");
|
|
37
|
+
effectiveActiveId = inProg ? inProg.id : null;
|
|
38
|
+
}
|
|
39
|
+
const state = {
|
|
40
|
+
steps: normalizedSteps,
|
|
41
|
+
activeStepId: effectiveActiveId,
|
|
42
|
+
updatedAt: Date.now(),
|
|
43
|
+
};
|
|
44
|
+
this.plans.set(conversationId, state);
|
|
45
|
+
return state;
|
|
46
|
+
}
|
|
47
|
+
/** 增量更新单个步骤的状态或内容。 */
|
|
48
|
+
updateStep(conversationId, stepId, patch) {
|
|
49
|
+
const current = this.plans.get(conversationId);
|
|
50
|
+
if (!current)
|
|
51
|
+
return null;
|
|
52
|
+
const idx = current.steps.findIndex((s) => s.id === stepId);
|
|
53
|
+
if (idx === -1)
|
|
54
|
+
return null;
|
|
55
|
+
const currentStep = current.steps[idx];
|
|
56
|
+
const nextStatus = patch.status && VALID_STATUSES.has(patch.status) ? patch.status : currentStep.status;
|
|
57
|
+
const nextStep = {
|
|
58
|
+
...currentStep,
|
|
59
|
+
...patch,
|
|
60
|
+
status: nextStatus,
|
|
61
|
+
};
|
|
62
|
+
const nextSteps = [...current.steps];
|
|
63
|
+
nextSteps[idx] = nextStep;
|
|
64
|
+
let nextActive = current.activeStepId;
|
|
65
|
+
if (patch.status === "in_progress") {
|
|
66
|
+
nextActive = stepId;
|
|
67
|
+
}
|
|
68
|
+
else if (current.activeStepId === stepId && patch.status) {
|
|
69
|
+
// 当前活动步骤已完成或失败,自动推进到下一个待执行步骤
|
|
70
|
+
const nextPending = nextSteps.find((s) => s.status === "pending" || s.status === "in_progress");
|
|
71
|
+
nextActive = nextPending ? nextPending.id : null;
|
|
72
|
+
}
|
|
73
|
+
const nextState = {
|
|
74
|
+
steps: nextSteps,
|
|
75
|
+
activeStepId: nextActive,
|
|
76
|
+
updatedAt: Date.now(),
|
|
77
|
+
};
|
|
78
|
+
this.plans.set(conversationId, nextState);
|
|
79
|
+
return nextState;
|
|
80
|
+
}
|
|
81
|
+
/** 清除指定会话的计划。 */
|
|
82
|
+
clearPlan(conversationId) {
|
|
83
|
+
this.plans.delete(conversationId);
|
|
84
|
+
}
|
|
85
|
+
/** 格式化计划为简洁文本,供模型上下文或诊断使用。 */
|
|
86
|
+
describePlan(conversationId) {
|
|
87
|
+
const plan = this.getPlan(conversationId);
|
|
88
|
+
if (!plan || plan.steps.length === 0)
|
|
89
|
+
return "No active plan.";
|
|
90
|
+
const doneCount = plan.steps.filter((s) => s.status === "done").length;
|
|
91
|
+
const total = plan.steps.length;
|
|
92
|
+
const lines = [`Plan Progress: ${doneCount}/${total} completed`];
|
|
93
|
+
for (let i = 0; i < plan.steps.length; i++) {
|
|
94
|
+
const s = plan.steps[i];
|
|
95
|
+
const icon = s.status === "done" ? "[x]" : s.status === "in_progress" ? "[>]" : s.status === "failed" ? "[!]" : "[ ]";
|
|
96
|
+
const activeTag = s.id === plan.activeStepId ? " (current)" : "";
|
|
97
|
+
lines.push(`${icon} ${i + 1}. ${s.title}${activeTag}`);
|
|
98
|
+
if (s.description) {
|
|
99
|
+
lines.push(` ${s.description}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
|
104
|
+
}
|