pi-web-ui 0.2.12 → 0.2.13
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/README.md +7 -0
- package/dist/server/agent-service.js +155 -3
- package/dist/server/index.js +3 -0
- package/dist/server/terminals.js +4 -1
- package/package.json +1 -1
- package/web/dist/assets/index-B0PtdZE3.js +117 -0
- package/web/dist/assets/index-Cjz-c1Wr.css +41 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-BBwex8TS.js +0 -116
- package/web/dist/assets/index-OfhL4xu-.css +0 -41
package/README.md
CHANGED
|
@@ -207,6 +207,13 @@ Key design points:
|
|
|
207
207
|
reference and the model reads them on demand with its `read` tool, so attaching
|
|
208
208
|
a 5 MB file costs only a few tokens until the model actually looks at it.
|
|
209
209
|
Images are always attached as image content.
|
|
210
|
+
- **File preview with line selection.** Click a file name (or its 👁 button) in
|
|
211
|
+
the right panel to open a preview modal with line numbers. Click / drag /
|
|
212
|
+
Shift+click to select a line range, then “添加到对话” to queue it as a `lines`
|
|
213
|
+
attachment — the server inlines only the selected range
|
|
214
|
+
(`<file path=... lines="2-3">`), so you can point the agent at exactly the
|
|
215
|
+
code you mean without dumping the whole file. Preview reads are capped at
|
|
216
|
+
512 KB and binary files are detected and refused.
|
|
210
217
|
- **Live tool output.** `bash_execution_update` / `tool_execution_update` events
|
|
211
218
|
are forwarded as lightweight `tool_delta` messages so terminal output streams
|
|
212
219
|
in real time; the final output arrives in the toolResult message on the next
|
|
@@ -18,6 +18,8 @@ import { loadCommands, saveCommandsFile, TerminalManager, } from "./terminals.js
|
|
|
18
18
|
const SNAPSHOT_INTERVAL_MS = 60;
|
|
19
19
|
const WIDGET_REFRESH_MS = 2000;
|
|
20
20
|
const WIDGET_WIDTH = 80;
|
|
21
|
+
/** Preview panel cap: only the first 512KB of a file is ever read/sent. */
|
|
22
|
+
const MAX_PREVIEW_BYTES = 512 * 1024;
|
|
21
23
|
// ---------------------------------------------------------------------------
|
|
22
24
|
// Web UI context adapter — bridges extension UI calls (setWidget/notify) to the
|
|
23
25
|
// browser. Extensions like rpiv-todo render a TUI widget via
|
|
@@ -244,12 +246,17 @@ const IGNORED_ENTRIES = new Set([
|
|
|
244
246
|
"Thumbs.db",
|
|
245
247
|
]);
|
|
246
248
|
function countLines(buf) {
|
|
249
|
+
if (buf.length === 0)
|
|
250
|
+
return 0;
|
|
251
|
+
const hasTrailingNewline = buf[buf.length - 1] === 10; /* \n */
|
|
247
252
|
let lines = 0;
|
|
248
253
|
for (let i = 0; i < buf.length; i++) {
|
|
249
|
-
if (buf[i] === 10
|
|
254
|
+
if (buf[i] === 10)
|
|
250
255
|
lines++;
|
|
251
256
|
}
|
|
252
|
-
|
|
257
|
+
// A trailing newline terminates the last line instead of starting an empty
|
|
258
|
+
// one — matches the client preview's split-based line numbering.
|
|
259
|
+
return hasTrailingNewline ? lines : lines + 1;
|
|
253
260
|
}
|
|
254
261
|
function extractPartialText(partial) {
|
|
255
262
|
const content = partial
|
|
@@ -959,7 +966,8 @@ export class ClientSession {
|
|
|
959
966
|
* Text files are size-aware: small files are inlined into the message so the
|
|
960
967
|
* model sees them immediately; large files are passed as a <file path="...">
|
|
961
968
|
* reference and the model reads them on demand with its read tool (which has
|
|
962
|
-
* built-in truncation). Images are always passed as image content.
|
|
969
|
+
* built-in truncation). Images are always passed as image content. Mode
|
|
970
|
+
* "lines" inlines only a 1-based inclusive line range of the file.
|
|
963
971
|
*/
|
|
964
972
|
async buildAttachmentMessages(attachments) {
|
|
965
973
|
if (!attachments || attachments.length === 0)
|
|
@@ -990,6 +998,8 @@ export class ClientSession {
|
|
|
990
998
|
".svg": "image/svg+xml",
|
|
991
999
|
};
|
|
992
1000
|
const out = [];
|
|
1001
|
+
/** Cap for reading a file in "lines" mode (selected slice is inlined). */
|
|
1002
|
+
const MAX_LINES_READ_BYTES = 2 * 1024 * 1024;
|
|
993
1003
|
for (const att of attachments) {
|
|
994
1004
|
const abs = resolve(root, att.path);
|
|
995
1005
|
const rel = relative(root, abs);
|
|
@@ -1104,6 +1114,79 @@ export class ClientSession {
|
|
|
1104
1114
|
out.push(makeReference());
|
|
1105
1115
|
continue;
|
|
1106
1116
|
}
|
|
1117
|
+
// Line-range mode: inline only the selected 1-based inclusive range.
|
|
1118
|
+
// Reading is capped so a huge file can't exhaust memory even though
|
|
1119
|
+
// the selected slice is small.
|
|
1120
|
+
if (att.mode === "lines") {
|
|
1121
|
+
const range = att.lines;
|
|
1122
|
+
if (!range || range.start < 1 || range.end < range.start) {
|
|
1123
|
+
this.emit({
|
|
1124
|
+
type: "notice",
|
|
1125
|
+
level: "warning",
|
|
1126
|
+
text: `行范围无效,已改为仅引用:${att.path}`,
|
|
1127
|
+
});
|
|
1128
|
+
out.push(makeReference());
|
|
1129
|
+
continue;
|
|
1130
|
+
}
|
|
1131
|
+
if (stat.size > MAX_LINES_READ_BYTES) {
|
|
1132
|
+
this.emit({
|
|
1133
|
+
type: "notice",
|
|
1134
|
+
level: "warning",
|
|
1135
|
+
text: `文件过大,已改为仅引用:${att.path}`,
|
|
1136
|
+
});
|
|
1137
|
+
out.push(makeReference());
|
|
1138
|
+
continue;
|
|
1139
|
+
}
|
|
1140
|
+
const buf = await fs.readFile(abs);
|
|
1141
|
+
if (buf.includes(0)) {
|
|
1142
|
+
this.emit({
|
|
1143
|
+
type: "notice",
|
|
1144
|
+
level: "warning",
|
|
1145
|
+
text: `二进制文件已改为仅引用:${att.path}`,
|
|
1146
|
+
});
|
|
1147
|
+
out.push(makeReference());
|
|
1148
|
+
continue;
|
|
1149
|
+
}
|
|
1150
|
+
const parts = buf.toString("utf8").split("\n");
|
|
1151
|
+
// A trailing newline yields an empty phantom line — drop it so line
|
|
1152
|
+
// numbers match the preview panel.
|
|
1153
|
+
if (parts.length > 0 && parts[parts.length - 1] === "")
|
|
1154
|
+
parts.pop();
|
|
1155
|
+
const start = Math.min(range.start, parts.length);
|
|
1156
|
+
const end = Math.min(range.end, parts.length);
|
|
1157
|
+
if (start < 1 || end < start) {
|
|
1158
|
+
this.emit({
|
|
1159
|
+
type: "notice",
|
|
1160
|
+
level: "warning",
|
|
1161
|
+
text: `选中行超出文件范围,已改为仅引用:${att.path}`,
|
|
1162
|
+
});
|
|
1163
|
+
out.push(makeReference());
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
const selected = parts.slice(start - 1, end).join("\n");
|
|
1167
|
+
out.push({
|
|
1168
|
+
message: {
|
|
1169
|
+
customType: "file",
|
|
1170
|
+
content: [
|
|
1171
|
+
{
|
|
1172
|
+
type: "text",
|
|
1173
|
+
text: `\n<file path="${rel}" lines="${start}-${end}">\n\`\`\`\n${selected}\n\`\`\`\n</file>`,
|
|
1174
|
+
},
|
|
1175
|
+
],
|
|
1176
|
+
display: true,
|
|
1177
|
+
details: {
|
|
1178
|
+
name,
|
|
1179
|
+
path: rel,
|
|
1180
|
+
mode: "lines",
|
|
1181
|
+
size: stat.size,
|
|
1182
|
+
lines: end - start + 1,
|
|
1183
|
+
startLine: start,
|
|
1184
|
+
endLine: end,
|
|
1185
|
+
},
|
|
1186
|
+
},
|
|
1187
|
+
});
|
|
1188
|
+
continue;
|
|
1189
|
+
}
|
|
1107
1190
|
// Forced inline has a hard cap to protect the model context.
|
|
1108
1191
|
if (att.mode === "inline") {
|
|
1109
1192
|
if (stat.size > MAX_INLINE_BYTES) {
|
|
@@ -1288,6 +1371,75 @@ export class ClientSession {
|
|
|
1288
1371
|
});
|
|
1289
1372
|
}
|
|
1290
1373
|
}
|
|
1374
|
+
/** Read a workspace file for the preview panel (size-capped, binary-safe). */
|
|
1375
|
+
async readFile(relPath) {
|
|
1376
|
+
try {
|
|
1377
|
+
const fs = await import("node:fs/promises");
|
|
1378
|
+
const { resolve, sep, relative } = await import("node:path");
|
|
1379
|
+
const root = resolve(this.cwd);
|
|
1380
|
+
const abs = resolve(root, relPath);
|
|
1381
|
+
const rel = relative(root, abs);
|
|
1382
|
+
if (rel.startsWith("..") || rel.includes(`${sep}..`)) {
|
|
1383
|
+
this.emit({
|
|
1384
|
+
type: "notice",
|
|
1385
|
+
level: "warning",
|
|
1386
|
+
text: `路径超出工作区:${relPath}`,
|
|
1387
|
+
});
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
const stat = await fs.stat(abs);
|
|
1391
|
+
if (!stat.isFile()) {
|
|
1392
|
+
this.emit({
|
|
1393
|
+
type: "notice",
|
|
1394
|
+
level: "warning",
|
|
1395
|
+
text: `不是文件:${relPath}`,
|
|
1396
|
+
});
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
// Read only the first MAX_PREVIEW_BYTES so huge files can't exhaust
|
|
1400
|
+
// memory or flood the socket.
|
|
1401
|
+
const handle = await fs.open(abs, "r");
|
|
1402
|
+
try {
|
|
1403
|
+
const buf = Buffer.alloc(Math.min(stat.size, MAX_PREVIEW_BYTES));
|
|
1404
|
+
const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
|
|
1405
|
+
const data = buf.subarray(0, bytesRead);
|
|
1406
|
+
const name = relPath.split("/").pop() ?? relPath;
|
|
1407
|
+
if (data.includes(0)) {
|
|
1408
|
+
this.emit({
|
|
1409
|
+
type: "file_content",
|
|
1410
|
+
path: rel,
|
|
1411
|
+
name,
|
|
1412
|
+
text: "",
|
|
1413
|
+
truncated: false,
|
|
1414
|
+
binary: true,
|
|
1415
|
+
lines: 0,
|
|
1416
|
+
size: stat.size,
|
|
1417
|
+
});
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
this.emit({
|
|
1421
|
+
type: "file_content",
|
|
1422
|
+
path: rel,
|
|
1423
|
+
name,
|
|
1424
|
+
text: data.toString("utf8"),
|
|
1425
|
+
truncated: bytesRead < stat.size,
|
|
1426
|
+
binary: false,
|
|
1427
|
+
lines: countLines(data),
|
|
1428
|
+
size: stat.size,
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
finally {
|
|
1432
|
+
await handle.close();
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
catch (err) {
|
|
1436
|
+
this.emit({
|
|
1437
|
+
type: "notice",
|
|
1438
|
+
level: "error",
|
|
1439
|
+
text: `读取文件失败:${err.message}`,
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1291
1443
|
async cycleModel() {
|
|
1292
1444
|
try {
|
|
1293
1445
|
await this.session.cycleModel();
|
package/dist/server/index.js
CHANGED
package/dist/server/terminals.js
CHANGED
|
@@ -121,7 +121,10 @@ const SHELL_ARGS = isWindows ? [] : ["-i"];
|
|
|
121
121
|
* Default a UTF-8 locale so multibyte text round-trips.
|
|
122
122
|
*/
|
|
123
123
|
function shellEnv() {
|
|
124
|
-
const env = {
|
|
124
|
+
const env = {
|
|
125
|
+
...process.env,
|
|
126
|
+
TERM: "xterm-256color",
|
|
127
|
+
};
|
|
125
128
|
if (!env.LANG && !env.LC_ALL)
|
|
126
129
|
env.LANG = "en_US.UTF-8";
|
|
127
130
|
return env;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-web-ui",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13",
|
|
4
4
|
"description": "Web chat interface for the pi coding agent, powered by the pi SDK (@earendil-works/pi-coding-agent) — one-command run, Docker/systemd/launchd deployable",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|