@liguoshuai/pi-web-chat 1.8.5 → 1.8.7
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/package.json +1 -1
- package/public/app.js +3 -5
- package/public/style.css +5 -1
- package/scripts/install-service.sh +2 -1
- package/server.js +69 -21
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -1959,14 +1959,12 @@ function renderModelList(listContainer) {
|
|
|
1959
1959
|
const opt = el("div", {
|
|
1960
1960
|
class: "opt" + (active ? " active" : ""),
|
|
1961
1961
|
onclick: () => {
|
|
1962
|
-
const prev = state.currentModel;
|
|
1963
1962
|
$("#modelPillName").textContent = "切换中…";
|
|
1964
1963
|
sendWs({ type: "set_model", provider: m.provider, modelId: m.id });
|
|
1965
1964
|
saveRecentModel(m);
|
|
1966
1965
|
$("#modelMenu").classList.remove("open");
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
}
|
|
1966
|
+
// 成功后的“已切换模型至 …”提示统一由 set_model 的 response 处理逻辑弹出,
|
|
1967
|
+
// 避免在这里乐观提示一次、响应到达后 pi 再提示一次(重复提示)。
|
|
1970
1968
|
},
|
|
1971
1969
|
}, [
|
|
1972
1970
|
el("span", { class: "check", html: active ? "✓" : "" }),
|
|
@@ -2316,7 +2314,7 @@ async function init() {
|
|
|
2316
2314
|
toggleModelMenu();
|
|
2317
2315
|
} else if (e.key === "Escape" && state.streaming) {
|
|
2318
2316
|
const isMenuOpen = $("#modelMenu")?.classList.contains("open") || $("#thinkingMenu")?.classList.contains("open");
|
|
2319
|
-
const isModalOpen = $("#cwdModal")?.
|
|
2317
|
+
const isModalOpen = $("#cwdModal")?.classList.contains("open");
|
|
2320
2318
|
if (!isMenuOpen && !isModalOpen) {
|
|
2321
2319
|
abortGeneration();
|
|
2322
2320
|
}
|
package/public/style.css
CHANGED
|
@@ -356,7 +356,11 @@ body {
|
|
|
356
356
|
font-size: 15px; line-height: 1.65; color: var(--text);
|
|
357
357
|
max-width: 100%; min-width: 0;
|
|
358
358
|
}
|
|
359
|
-
.msg.assistant .content p {
|
|
359
|
+
.msg.assistant .content p {
|
|
360
|
+
margin-bottom: 12px;
|
|
361
|
+
white-space: pre-wrap;
|
|
362
|
+
word-break: break-word;
|
|
363
|
+
}
|
|
360
364
|
.msg.assistant .content p:last-child { margin-bottom: 0; }
|
|
361
365
|
.msg.assistant .content pre {
|
|
362
366
|
background: #0d0d0d; border: 1px solid var(--border);
|
|
@@ -22,11 +22,12 @@ SRC="$HERE/pi-web-chat.service"
|
|
|
22
22
|
DEST_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
|
|
23
23
|
DEST="$DEST_DIR/pi-web-chat.service"
|
|
24
24
|
|
|
25
|
-
PORT="
|
|
25
|
+
PORT="3000"
|
|
26
26
|
RESTART_NOW="no"
|
|
27
27
|
for arg in "$@"; do
|
|
28
28
|
case "$arg" in
|
|
29
29
|
--restart|--now) RESTART_NOW="yes" ;;
|
|
30
|
+
[0-9]*) PORT="$arg" ;;
|
|
30
31
|
esac
|
|
31
32
|
done
|
|
32
33
|
|
package/server.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// for listing sessions and reading session history from the JSONL store.
|
|
4
4
|
import { spawn } from "child_process";
|
|
5
5
|
import { randomUUID } from "crypto";
|
|
6
|
-
import { readFile, readdir, stat, writeFile, mkdir } from "fs/promises";
|
|
6
|
+
import { readFile, readdir, stat, writeFile, mkdir, realpath as fsRealpath } from "fs/promises";
|
|
7
7
|
import { readFileSync, existsSync } from "fs";
|
|
8
8
|
import { StringDecoder } from "string_decoder";
|
|
9
9
|
import express from "express";
|
|
@@ -213,14 +213,14 @@ class PiAgent {
|
|
|
213
213
|
maybeScheduleIdleKill() {
|
|
214
214
|
// Only arm the reclamation timer when: no client attached AND truly idle.
|
|
215
215
|
// A background task that is still running must never be killed here.
|
|
216
|
-
if (this.hasWs || this.isBusy) return;
|
|
216
|
+
if (!this.alive || this.hasWs || this.isBusy) return;
|
|
217
217
|
if (IDLE_TIMEOUT_MS === 0) return; // disabled
|
|
218
218
|
this.cancelIdleKill();
|
|
219
219
|
const ms = IDLE_TIMEOUT_MS;
|
|
220
220
|
this.idleTimer = setTimeout(() => {
|
|
221
221
|
this.idleTimer = null;
|
|
222
222
|
// re-check at fire time — a reconnect or new task may have started.
|
|
223
|
-
if (this.hasWs || this.isBusy) return;
|
|
223
|
+
if (!this.alive || this.hasWs || this.isBusy) return;
|
|
224
224
|
console.log(`Reclaiming truly-idle pi agent after ${Math.round(ms / 1000)}s (key=${this.sessionKey || "unkeyed"})`);
|
|
225
225
|
if (IDLE_DROP_HEAP) {
|
|
226
226
|
try { if (typeof global.gc === "function") global.gc(); } catch {}
|
|
@@ -271,6 +271,10 @@ class PiAgent {
|
|
|
271
271
|
if (this.sessionKey) activeAgents.delete(this.sessionKey);
|
|
272
272
|
this.cancelIdleKill();
|
|
273
273
|
if (this.lifetimeTimer) { clearTimeout(this.lifetimeTimer); this.lifetimeTimer = null; }
|
|
274
|
+
for (const [id, resolve] of this.pending) {
|
|
275
|
+
resolve({ type: "response", id, success: false, error: err.message });
|
|
276
|
+
}
|
|
277
|
+
this.pending.clear();
|
|
274
278
|
console.error(`[pi spawn error]`, err);
|
|
275
279
|
this.wsSend({ type: "pi_exit", error: err.message });
|
|
276
280
|
for (const s of this.sockets) { try { s.close(); } catch {} }
|
|
@@ -291,6 +295,10 @@ class PiAgent {
|
|
|
291
295
|
console.log(`pi exited (code=${code})`);
|
|
292
296
|
this.wsSend({ type: "pi_exit", code });
|
|
293
297
|
if (this.sessionKey) activeAgents.delete(this.sessionKey);
|
|
298
|
+
for (const [id, resolve] of this.pending) {
|
|
299
|
+
resolve({ type: "response", id, success: false, error: "pi exited" });
|
|
300
|
+
}
|
|
301
|
+
this.pending.clear();
|
|
294
302
|
for (const s of this.sockets) { try { s.close(); } catch {} }
|
|
295
303
|
this.sockets.clear();
|
|
296
304
|
this.cancelIdleKill();
|
|
@@ -326,6 +334,10 @@ class PiAgent {
|
|
|
326
334
|
this.setStreaming(false);
|
|
327
335
|
this.eventBuffer = [];
|
|
328
336
|
this.bufferHead = 0;
|
|
337
|
+
// If unkeyed, actively query state from pi so sessionKey is recorded
|
|
338
|
+
if (!this.sessionKey) {
|
|
339
|
+
this.send({ type: "get_state" });
|
|
340
|
+
}
|
|
329
341
|
break;
|
|
330
342
|
case "pi_exit": this.state = "idle"; break;
|
|
331
343
|
}
|
|
@@ -386,7 +398,22 @@ class PiAgent {
|
|
|
386
398
|
}
|
|
387
399
|
const id = String(++this.reqId);
|
|
388
400
|
const payload = { ...cmd, id };
|
|
389
|
-
|
|
401
|
+
// Long-running commands (prompt/steer) are tracked for their whole
|
|
402
|
+
// lifetime: pi's response may legitimately take many minutes, and the
|
|
403
|
+
// pending entry doubles as the isBusy guard that protects a working
|
|
404
|
+
// agent from idle-kill. Fire-and-forget queries (get_state etc.) keep a
|
|
405
|
+
// safety timeout so a dropped response doesn't leak the promise.
|
|
406
|
+
const longRunning = cmd.type === "prompt" || cmd.type === "steer";
|
|
407
|
+
let timeoutId = null;
|
|
408
|
+
const settled = { done: false };
|
|
409
|
+
const complete = (obj) => {
|
|
410
|
+
if (settled.done) return;
|
|
411
|
+
settled.done = true;
|
|
412
|
+
this.pending.delete(id);
|
|
413
|
+
if (timeoutId) { clearTimeout(timeoutId); timeoutId = null; }
|
|
414
|
+
resolve(obj);
|
|
415
|
+
};
|
|
416
|
+
this.pending.set(id, complete);
|
|
390
417
|
this.markActivity();
|
|
391
418
|
try {
|
|
392
419
|
this.proc.stdin.write(JSON.stringify(payload) + "\n");
|
|
@@ -394,14 +421,14 @@ class PiAgent {
|
|
|
394
421
|
this.pending.delete(id);
|
|
395
422
|
return resolve({ type: "response", id, success: false, error: err.message });
|
|
396
423
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
}
|
|
404
|
-
}
|
|
424
|
+
if (!longRunning) {
|
|
425
|
+
timeoutId = setTimeout(() => {
|
|
426
|
+
if (this.pending.has(id)) {
|
|
427
|
+
this.pending.delete(id);
|
|
428
|
+
resolve({ type: "response", id, success: false, error: "timeout" });
|
|
429
|
+
}
|
|
430
|
+
}, 60000);
|
|
431
|
+
}
|
|
405
432
|
});
|
|
406
433
|
}
|
|
407
434
|
|
|
@@ -459,6 +486,10 @@ class PiAgent {
|
|
|
459
486
|
if (this.sessionKey) {
|
|
460
487
|
activeAgents.delete(this.sessionKey);
|
|
461
488
|
}
|
|
489
|
+
for (const [id, resolve] of this.pending) {
|
|
490
|
+
resolve({ type: "response", id, success: false, error: "pi process stopped" });
|
|
491
|
+
}
|
|
492
|
+
this.pending.clear();
|
|
462
493
|
for (const s of this.sockets) { try { s.close(); } catch {} }
|
|
463
494
|
this.sockets.clear();
|
|
464
495
|
try { this.proc && this.proc.kill("SIGTERM"); } catch {}
|
|
@@ -727,13 +758,34 @@ app.get("/api/session", async (req, res) => {
|
|
|
727
758
|
const file = req.query.file;
|
|
728
759
|
if (!file || !file.endsWith(".jsonl")) return res.status(400).json({ error: "bad file" });
|
|
729
760
|
|
|
730
|
-
// Security check: ensure the file path is within SESSIONS_DIR
|
|
731
|
-
|
|
761
|
+
// Security check: ensure the file path is within SESSIONS_DIR. We resolve the
|
|
762
|
+
// canonical (real) path rather than just the lexical one, otherwise a
|
|
763
|
+
// symlink placed inside SESSIONS_DIR could point outside and let a caller
|
|
764
|
+
// read arbitrary .jsonl files via the traversal check above.
|
|
732
765
|
const resolvedSessionsDir = normalizePath(SESSIONS_DIR);
|
|
733
|
-
const
|
|
766
|
+
const requestedPath = normalizePath(file);
|
|
767
|
+
const relPath = path.relative(resolvedSessionsDir, requestedPath);
|
|
734
768
|
if (relPath.startsWith("..") || path.isAbsolute(relPath)) {
|
|
735
769
|
return res.status(403).json({ error: "Access denied" });
|
|
736
770
|
}
|
|
771
|
+
// Canonicalize both target file and sessions directory to verify the real
|
|
772
|
+
// target still lives under the real SESSIONS_DIR even when symlinks exist.
|
|
773
|
+
let resolvedFile;
|
|
774
|
+
try {
|
|
775
|
+
resolvedFile = await fsRealpath(requestedPath);
|
|
776
|
+
} catch {
|
|
777
|
+
// Fall back to the lexical path if realpath fails (e.g. missing file);
|
|
778
|
+
// the read below will surface the actual error.
|
|
779
|
+
resolvedFile = requestedPath;
|
|
780
|
+
}
|
|
781
|
+
let canonicalSessionsDir = resolvedSessionsDir;
|
|
782
|
+
try {
|
|
783
|
+
canonicalSessionsDir = await fsRealpath(resolvedSessionsDir);
|
|
784
|
+
} catch {}
|
|
785
|
+
const realRel = path.relative(canonicalSessionsDir, resolvedFile);
|
|
786
|
+
if (realRel.startsWith("..") || path.isAbsolute(realRel)) {
|
|
787
|
+
return res.status(403).json({ error: "Access denied" });
|
|
788
|
+
}
|
|
737
789
|
|
|
738
790
|
const content = await readFile(resolvedFile, "utf8");
|
|
739
791
|
const lines = content.split("\n").filter(Boolean);
|
|
@@ -751,11 +803,6 @@ app.get("/api/session", async (req, res) => {
|
|
|
751
803
|
// Build a map and reconstruct the active path from root -> leaf.
|
|
752
804
|
const byId = new Map();
|
|
753
805
|
for (const e of entries) if (e.id) byId.set(e.id, e);
|
|
754
|
-
let leaf = null;
|
|
755
|
-
for (const e of entries) {
|
|
756
|
-
// a leaf is one that nobody else has as parentId (and isn't a non-message like header)
|
|
757
|
-
if (e.type === "message" || e.type === "message_summary") leaf = e.id;
|
|
758
|
-
}
|
|
759
806
|
// find true leaf = last entry with no children
|
|
760
807
|
const childCount = new Map();
|
|
761
808
|
for (const e of entries) {
|
|
@@ -905,7 +952,8 @@ wss.on("connection", (ws, req) => {
|
|
|
905
952
|
agent.send({ type: "new_session" });
|
|
906
953
|
break;
|
|
907
954
|
case "switch_session":
|
|
908
|
-
if (msg.sessionPath)
|
|
955
|
+
if (!msg.sessionPath) break; // refuse malformed request; never forward an undefined path to pi
|
|
956
|
+
agent.setSessionKey(cwd, msg.sessionPath);
|
|
909
957
|
agent.send({ type: "switch_session", sessionPath: msg.sessionPath });
|
|
910
958
|
break;
|
|
911
959
|
case "steer":
|