@liguoshuai/pi-web-chat 1.0.1
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 +88 -0
- package/LICENSE +21 -0
- package/README.md +118 -0
- package/bin/pi-web-chat.js +67 -0
- package/package.json +47 -0
- package/public/app.js +860 -0
- package/public/index.html +68 -0
- package/public/style.css +349 -0
- package/server.js +322 -0
package/server.js
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
// server.js — pi-web-chat backend
|
|
2
|
+
// Bridges a browser WebSocket to a `pi --mode rpc` subprocess, and REST APIs
|
|
3
|
+
// for listing sessions and reading session history from the JSONL store.
|
|
4
|
+
import { spawn } from "child_process";
|
|
5
|
+
import { randomUUID } from "crypto";
|
|
6
|
+
import { readFile, readdir } from "fs/promises";
|
|
7
|
+
import { existsSync } from "fs";
|
|
8
|
+
import express from "express";
|
|
9
|
+
import { WebSocketServer } from "ws";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import os from "os";
|
|
12
|
+
import { fileURLToPath } from "url";
|
|
13
|
+
import { dirname } from "path";
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
// Resolve pi binary: prefer PI_BIN env, else search PATH, else fall back to ~/.npm-global/bin/pi
|
|
18
|
+
function resolvePiBin() {
|
|
19
|
+
if (process.env.PI_BIN && existsSync(process.env.PI_BIN)) return process.env.PI_BIN;
|
|
20
|
+
const home = os.homedir();
|
|
21
|
+
const candidates = [
|
|
22
|
+
path.join(home, ".npm-global/bin/pi"),
|
|
23
|
+
"/usr/local/bin/pi",
|
|
24
|
+
"/usr/bin/pi",
|
|
25
|
+
];
|
|
26
|
+
for (const c of candidates) if (existsSync(c)) return c;
|
|
27
|
+
return "pi"; // hope it's on PATH of the spawned shell
|
|
28
|
+
}
|
|
29
|
+
const PI_BIN = resolvePiBin();
|
|
30
|
+
// Where pi stores sessions, organized by cwd-encoded subdirectory.
|
|
31
|
+
const SESSIONS_DIR = process.env.PI_SESSIONS_DIR || path.join(home(), ".pi", "agent", "sessions");
|
|
32
|
+
const PORT = process.env.PORT || 3000;
|
|
33
|
+
|
|
34
|
+
// One pi RPC process per browser WebSocket connection.
|
|
35
|
+
class PiAgent {
|
|
36
|
+
constructor(ws, cwd) {
|
|
37
|
+
this.ws = ws;
|
|
38
|
+
this.cwd = cwd || home();
|
|
39
|
+
this.reqId = 0;
|
|
40
|
+
this.pending = new Map(); // reqId -> resolve()
|
|
41
|
+
this.proc = null;
|
|
42
|
+
this.buffer = "";
|
|
43
|
+
this.alive = false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
start() {
|
|
47
|
+
const args = [PI_BIN, "--mode", "rpc", "--session-dir", SESSIONS_DIR];
|
|
48
|
+
this.proc = spawn(args[0], args.slice(1), {
|
|
49
|
+
cwd: this.cwd,
|
|
50
|
+
env: { ...process.env, PI_SKIP_VERSION_CHECK: "1" },
|
|
51
|
+
});
|
|
52
|
+
this.alive = true;
|
|
53
|
+
this.proc.stdout.on("data", (d) => this.onStdout(d));
|
|
54
|
+
this.proc.stderr.on("data", (d) => {
|
|
55
|
+
process.stderr.write(`[pi stderr] ${d}`);
|
|
56
|
+
});
|
|
57
|
+
this.proc.on("exit", (code) => {
|
|
58
|
+
this.alive = false;
|
|
59
|
+
console.log(`pi exited (code=${code})`);
|
|
60
|
+
this.wsSend({ type: "pi_exit", code });
|
|
61
|
+
try { this.ws.close(); } catch {}
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
onStdout(chunk) {
|
|
66
|
+
this.buffer += chunk.toString("utf8");
|
|
67
|
+
while (true) {
|
|
68
|
+
const nl = this.buffer.indexOf("\n");
|
|
69
|
+
if (nl === -1) break;
|
|
70
|
+
let line = this.buffer.slice(0, nl);
|
|
71
|
+
this.buffer = this.buffer.slice(nl + 1);
|
|
72
|
+
if (line.endsWith("\r")) line = line.slice(0, -1);
|
|
73
|
+
if (!line.trim()) continue;
|
|
74
|
+
let obj;
|
|
75
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
76
|
+
this.onPiMessage(obj);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
onPiMessage(obj) {
|
|
81
|
+
// RPC responses carry `id`; events do not.
|
|
82
|
+
if (obj.type === "response" && obj.id) {
|
|
83
|
+
const res = this.pending.get(obj.id);
|
|
84
|
+
if (res) { this.pending.delete(obj.id); res(obj); }
|
|
85
|
+
}
|
|
86
|
+
// Forward every event / response to the browser as-is.
|
|
87
|
+
this.wsSend(obj);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
send(cmd) {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
if (!this.alive) return reject(new Error("pi process not alive"));
|
|
93
|
+
const id = String(++this.reqId);
|
|
94
|
+
const payload = { ...cmd, id };
|
|
95
|
+
this.pending.set(id, resolve);
|
|
96
|
+
this.proc.stdin.write(JSON.stringify(payload) + "\n");
|
|
97
|
+
// Safety: timeout so a dropped response doesn't leak the promise.
|
|
98
|
+
setTimeout(() => {
|
|
99
|
+
if (this.pending.has(id)) {
|
|
100
|
+
this.pending.delete(id);
|
|
101
|
+
resolve({ type: "response", id, success: false, error: "timeout" });
|
|
102
|
+
}
|
|
103
|
+
}, 60000);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
sendNoReply(cmd) {
|
|
108
|
+
if (!this.alive) throw new Error("pi process not alive");
|
|
109
|
+
this.proc.stdin.write(JSON.stringify(cmd) + "\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
wsSend(obj) {
|
|
113
|
+
if (this.ws.readyState === 1) this.ws.send(JSON.stringify(obj));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
stop() {
|
|
117
|
+
this.alive = false;
|
|
118
|
+
try { this.proc && this.proc.kill("SIGTERM"); } catch {}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---- REST: list sessions + read one session's messages ----
|
|
123
|
+
const app = express();
|
|
124
|
+
app.use(express.json({ limit: "50mb" }));
|
|
125
|
+
app.use(express.static(path.join(__dirname, "public")));
|
|
126
|
+
|
|
127
|
+
// Endpoint to catch front-end errors for deep diagnostics
|
|
128
|
+
app.post("/api/log-error", (req, res) => {
|
|
129
|
+
const { message, source, lineno, colno, error, userAgent } = req.body;
|
|
130
|
+
const logStr = `\n[CLIENT ERROR] ${new Date().toISOString()}\nMessage: ${message}\nSource: ${source}:${lineno}:${colno}\nError: ${JSON.stringify(error)}\nUA: ${userAgent}\n`;
|
|
131
|
+
process.stderr.write(logStr);
|
|
132
|
+
res.json({ ok: true });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Scan SESSIONS_DIR for .jsonl files in BOTH the root AND every subdirectory.
|
|
136
|
+
// Why both? Because pi stores sessions under a cwd-encoded subdir (e.g.
|
|
137
|
+
// `--home-zrlgs--`) when left to its own device, but our server passes
|
|
138
|
+
// `--session-dir` directly — in that mode pi drops new session files straight
|
|
139
|
+
// into the directory root (no cwd subdir). So to robustly list every session
|
|
140
|
+
// regardless of how it got there, we walk the whole tree: root + subdirs.
|
|
141
|
+
async function listAllSessionFiles() {
|
|
142
|
+
if (!existsSync(SESSIONS_DIR)) return [];
|
|
143
|
+
const files = [];
|
|
144
|
+
|
|
145
|
+
// Root-level .jsonl files (created when we pass --session-dir to pi).
|
|
146
|
+
const top = await readdir(SESSIONS_DIR, { withFileTypes: true }).catch(() => []);
|
|
147
|
+
for (const e of top) {
|
|
148
|
+
if (e.isFile() && e.name.endsWith(".jsonl")) files.push(path.join(SESSIONS_DIR, e.name));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Subdirectory .jsonl files (created by pi itself when cwd is encoded).
|
|
152
|
+
const subdirs = top.filter(d => d.isDirectory());
|
|
153
|
+
for (const d of subdirs) {
|
|
154
|
+
const dp = path.join(SESSIONS_DIR, d.name);
|
|
155
|
+
const names = (await readdir(dp).catch(() => [])).filter(f => f.endsWith(".jsonl"));
|
|
156
|
+
for (const n of names) files.push(path.join(dp, n));
|
|
157
|
+
}
|
|
158
|
+
return files;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
app.get("/api/sessions", async (req, res) => {
|
|
162
|
+
try {
|
|
163
|
+
const cwd = req.query.cwd || home();
|
|
164
|
+
const all = await listAllSessionFiles();
|
|
165
|
+
const sessions = [];
|
|
166
|
+
for (const full of all) {
|
|
167
|
+
try {
|
|
168
|
+
const content = await readFile(full, "utf8");
|
|
169
|
+
const lines = content.split("\n").filter(Boolean);
|
|
170
|
+
let header = null, title = null, msgCount = 0;
|
|
171
|
+
for (const line of lines) {
|
|
172
|
+
let o;
|
|
173
|
+
try { o = JSON.parse(line); } catch { continue; }
|
|
174
|
+
if (o.type === "session") header = o;
|
|
175
|
+
if (o.type === "message" && o.message && o.message.role === "user" && !title) {
|
|
176
|
+
title = extractText(o.message.content).slice(0, 80);
|
|
177
|
+
}
|
|
178
|
+
if (o.type === "message") msgCount++;
|
|
179
|
+
}
|
|
180
|
+
if (!header || header.cwd !== cwd) continue;
|
|
181
|
+
sessions.push({
|
|
182
|
+
file: full,
|
|
183
|
+
name: path.basename(full),
|
|
184
|
+
id: header.id,
|
|
185
|
+
timestamp: header.timestamp,
|
|
186
|
+
firstUser: title,
|
|
187
|
+
messageCount: msgCount,
|
|
188
|
+
});
|
|
189
|
+
} catch {}
|
|
190
|
+
}
|
|
191
|
+
sessions.sort((a, b) => (b.timestamp || "").localeCompare(a.timestamp || ""));
|
|
192
|
+
res.json({ cwd, sessions });
|
|
193
|
+
} catch (e) {
|
|
194
|
+
console.error(e);
|
|
195
|
+
res.status(500).json({ error: String(e) });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
function extractText(content) {
|
|
200
|
+
if (typeof content === "string") return content;
|
|
201
|
+
if (!Array.isArray(content)) return "";
|
|
202
|
+
return content
|
|
203
|
+
.filter(c => c.type === "text" || typeof c === "string")
|
|
204
|
+
.map(c => typeof c === "string" ? c : c.text)
|
|
205
|
+
.join("");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Return a session as a linear chat transcript (walking the parent chain to the leaf).
|
|
209
|
+
app.get("/api/session", async (req, res) => {
|
|
210
|
+
try {
|
|
211
|
+
const file = req.query.file;
|
|
212
|
+
if (!file || !file.endsWith(".jsonl")) return res.status(400).json({ error: "bad file" });
|
|
213
|
+
const content = await readFile(file, "utf8");
|
|
214
|
+
const lines = content.split("\n").filter(Boolean);
|
|
215
|
+
const entries = [];
|
|
216
|
+
let header = null;
|
|
217
|
+
for (const line of lines) {
|
|
218
|
+
let o; try { o = JSON.parse(line); } catch { continue; }
|
|
219
|
+
if (o.type === "session") header = o;
|
|
220
|
+
entries.push(o);
|
|
221
|
+
}
|
|
222
|
+
// Build a map and reconstruct the active path from root -> leaf.
|
|
223
|
+
const byId = new Map();
|
|
224
|
+
for (const e of entries) if (e.id) byId.set(e.id, e);
|
|
225
|
+
let leaf = null;
|
|
226
|
+
for (const e of entries) {
|
|
227
|
+
// a leaf is one that nobody else has as parentId (and isn't a non-message like header)
|
|
228
|
+
if (e.type === "message" || e.type === "message_summary") leaf = e.id;
|
|
229
|
+
}
|
|
230
|
+
// find true leaf = last entry with no children
|
|
231
|
+
const childCount = new Map();
|
|
232
|
+
for (const e of entries) {
|
|
233
|
+
if (e.parentId) childCount.set(e.parentId, (childCount.get(e.parentId) || 0) + 1);
|
|
234
|
+
}
|
|
235
|
+
let leafId = null;
|
|
236
|
+
for (const e of entries) {
|
|
237
|
+
if (e.id && !childCount.has(e.id)) leafId = e.id;
|
|
238
|
+
}
|
|
239
|
+
// Walk parent chain from leaf to root.
|
|
240
|
+
const path = [];
|
|
241
|
+
let cur = leafId;
|
|
242
|
+
const guard = new Set();
|
|
243
|
+
while (cur && !guard.has(cur)) {
|
|
244
|
+
guard.add(cur);
|
|
245
|
+
const e = byId.get(cur);
|
|
246
|
+
if (!e) break;
|
|
247
|
+
path.unshift(e);
|
|
248
|
+
cur = e.parentId;
|
|
249
|
+
}
|
|
250
|
+
res.json({ header, entries: path });
|
|
251
|
+
} catch (e) {
|
|
252
|
+
console.error(e);
|
|
253
|
+
res.status(500).json({ error: String(e) });
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// ---- WebSocket: 1 browser conn = 1 pi RPC conn ----
|
|
258
|
+
const httpServer = app.listen(PORT, () => {
|
|
259
|
+
console.log(`pi-web-chat on http://localhost:${PORT}`);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const wss = new WebSocketServer({ server: httpServer, path: "/ws" });
|
|
263
|
+
wss.on("connection", (ws, req) => {
|
|
264
|
+
const url = new URL(req.url, "http://x");
|
|
265
|
+
const cwd = url.searchParams.get("cwd") || home();
|
|
266
|
+
const session = url.searchParams.get("session") || null;
|
|
267
|
+
const agent = new PiAgent(ws, cwd);
|
|
268
|
+
agent.start();
|
|
269
|
+
ws.piAgent = agent;
|
|
270
|
+
console.log(`ws connected (cwd=${cwd}, session=${session || "new"})`);
|
|
271
|
+
|
|
272
|
+
// Open a specific session, or start fresh.
|
|
273
|
+
if (session) agent.sendNoReply({ type: "switch_session", sessionPath: session });
|
|
274
|
+
|
|
275
|
+
ws.on("message", (raw) => {
|
|
276
|
+
let msg;
|
|
277
|
+
try { msg = JSON.parse(raw.toString()); } catch { return; }
|
|
278
|
+
switch (msg.type) {
|
|
279
|
+
case "prompt":
|
|
280
|
+
agent.send({ type: "prompt", message: msg.message, images: msg.images });
|
|
281
|
+
break;
|
|
282
|
+
case "abort":
|
|
283
|
+
agent.sendNoReply({ type: "abort" });
|
|
284
|
+
break;
|
|
285
|
+
case "new_session":
|
|
286
|
+
agent.send({ type: "new_session" });
|
|
287
|
+
break;
|
|
288
|
+
case "switch_session":
|
|
289
|
+
agent.send({ type: "switch_session", sessionPath: msg.sessionPath });
|
|
290
|
+
break;
|
|
291
|
+
case "steer":
|
|
292
|
+
agent.send({ type: "steer", message: msg.message });
|
|
293
|
+
break;
|
|
294
|
+
case "set_session_name":
|
|
295
|
+
agent.send({ type: "set_session_name", name: msg.name });
|
|
296
|
+
break;
|
|
297
|
+
case "get_entries":
|
|
298
|
+
agent.send({ type: "get_entries", since: msg.since });
|
|
299
|
+
break;
|
|
300
|
+
case "get_state":
|
|
301
|
+
agent.send({ type: "get_state" });
|
|
302
|
+
break;
|
|
303
|
+
case "get_available_models":
|
|
304
|
+
agent.send({ type: "get_available_models" });
|
|
305
|
+
break;
|
|
306
|
+
case "set_model":
|
|
307
|
+
agent.send({ type: "set_model", provider: msg.provider, modelId: msg.modelId });
|
|
308
|
+
break;
|
|
309
|
+
default:
|
|
310
|
+
// Unknown — just forward, might be a raw RPC command.
|
|
311
|
+
agent.send(msg);
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
ws.on("close", () => {
|
|
316
|
+
console.log("ws closed, stopping pi");
|
|
317
|
+
agent.stop();
|
|
318
|
+
});
|
|
319
|
+
ws.on("error", () => agent.stop());
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
function home() { return os.homedir(); }
|