pi-web-ui 0.2.15 → 0.4.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/README.md +39 -6
- package/README.zh-CN.md +355 -0
- package/dist/server/agent-service.js +407 -20
- package/dist/server/index.js +44 -3
- package/package.json +1 -1
- package/web/dist/assets/index-8tKVAow0.css +41 -0
- package/web/dist/assets/index-C5atNZnA.js +119 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-Cjz-c1Wr.css +0 -41
- package/web/dist/assets/index-Tt1wcNur.js +0 -117
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* so reconnects just re-request a snapshot.
|
|
11
11
|
*/
|
|
12
12
|
import { spawn } from "node:child_process";
|
|
13
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
14
|
-
import { join } from "node:path";
|
|
13
|
+
import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync, } from "node:fs";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
15
|
import { createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, getAgentDir, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { serializeMessage, serializeStreamingMessage, } from "./serialize.js";
|
|
17
17
|
import { loadCommands, saveCommandsFile, TerminalManager, } from "./terminals.js";
|
|
@@ -20,6 +20,163 @@ const WIDGET_REFRESH_MS = 2000;
|
|
|
20
20
|
const WIDGET_WIDTH = 80;
|
|
21
21
|
/** Preview panel cap: only the first 512KB of a file is ever read/sent. */
|
|
22
22
|
const MAX_PREVIEW_BYTES = 512 * 1024;
|
|
23
|
+
const PREVIEW_IMAGE_EXTS = new Set([
|
|
24
|
+
"png",
|
|
25
|
+
"jpg",
|
|
26
|
+
"jpeg",
|
|
27
|
+
"gif",
|
|
28
|
+
"webp",
|
|
29
|
+
"svg",
|
|
30
|
+
"bmp",
|
|
31
|
+
"ico",
|
|
32
|
+
"avif",
|
|
33
|
+
"jfif",
|
|
34
|
+
"tif",
|
|
35
|
+
"tiff",
|
|
36
|
+
]);
|
|
37
|
+
const PREVIEW_VIDEO_EXTS = new Set([
|
|
38
|
+
"mp4",
|
|
39
|
+
"webm",
|
|
40
|
+
"mov",
|
|
41
|
+
"mkv",
|
|
42
|
+
"avi",
|
|
43
|
+
"m4v",
|
|
44
|
+
"ogv",
|
|
45
|
+
"mpg",
|
|
46
|
+
"mpeg",
|
|
47
|
+
"wmv",
|
|
48
|
+
"flv",
|
|
49
|
+
]);
|
|
50
|
+
const PREVIEW_TEXT_EXTS = new Set([
|
|
51
|
+
// code
|
|
52
|
+
"ts",
|
|
53
|
+
"tsx",
|
|
54
|
+
"js",
|
|
55
|
+
"jsx",
|
|
56
|
+
"mjs",
|
|
57
|
+
"cjs",
|
|
58
|
+
"jsm",
|
|
59
|
+
"es6",
|
|
60
|
+
"vue",
|
|
61
|
+
"svelte",
|
|
62
|
+
"py",
|
|
63
|
+
"pyw",
|
|
64
|
+
"ipynb",
|
|
65
|
+
"go",
|
|
66
|
+
"rs",
|
|
67
|
+
"c",
|
|
68
|
+
"h",
|
|
69
|
+
"cpp",
|
|
70
|
+
"hpp",
|
|
71
|
+
"cc",
|
|
72
|
+
"cxx",
|
|
73
|
+
"hh",
|
|
74
|
+
"csh",
|
|
75
|
+
"java",
|
|
76
|
+
"kt",
|
|
77
|
+
"kts",
|
|
78
|
+
"scala",
|
|
79
|
+
"sc",
|
|
80
|
+
"cs",
|
|
81
|
+
"fs",
|
|
82
|
+
"fsx",
|
|
83
|
+
"fsi",
|
|
84
|
+
"sh",
|
|
85
|
+
"bash",
|
|
86
|
+
"zsh",
|
|
87
|
+
"fish",
|
|
88
|
+
"bat",
|
|
89
|
+
"cmd",
|
|
90
|
+
"ps1",
|
|
91
|
+
"psd1",
|
|
92
|
+
"psm1",
|
|
93
|
+
"rb",
|
|
94
|
+
"php",
|
|
95
|
+
"pl",
|
|
96
|
+
"pm",
|
|
97
|
+
"tcl",
|
|
98
|
+
"lua",
|
|
99
|
+
"r",
|
|
100
|
+
"rmd",
|
|
101
|
+
"sql",
|
|
102
|
+
"swift",
|
|
103
|
+
"dart",
|
|
104
|
+
"groovy",
|
|
105
|
+
"gradle",
|
|
106
|
+
"tf",
|
|
107
|
+
"tfvars",
|
|
108
|
+
"hcl",
|
|
109
|
+
"nim",
|
|
110
|
+
"zig",
|
|
111
|
+
"v",
|
|
112
|
+
"vala",
|
|
113
|
+
"d",
|
|
114
|
+
"clj",
|
|
115
|
+
"cljs",
|
|
116
|
+
"cljc",
|
|
117
|
+
"edn",
|
|
118
|
+
"ex",
|
|
119
|
+
"exs",
|
|
120
|
+
"erl",
|
|
121
|
+
"hrl",
|
|
122
|
+
"ml",
|
|
123
|
+
"mli",
|
|
124
|
+
// markup / config / data
|
|
125
|
+
"json",
|
|
126
|
+
"jsonc",
|
|
127
|
+
"json5",
|
|
128
|
+
"md",
|
|
129
|
+
"mdx",
|
|
130
|
+
"markdown",
|
|
131
|
+
"html",
|
|
132
|
+
"htm",
|
|
133
|
+
"xhtml",
|
|
134
|
+
"css",
|
|
135
|
+
"scss",
|
|
136
|
+
"sass",
|
|
137
|
+
"less",
|
|
138
|
+
"styl",
|
|
139
|
+
"xml",
|
|
140
|
+
"dtd",
|
|
141
|
+
"yaml",
|
|
142
|
+
"yml",
|
|
143
|
+
"toml",
|
|
144
|
+
"ini",
|
|
145
|
+
"cfg",
|
|
146
|
+
"conf",
|
|
147
|
+
"properties",
|
|
148
|
+
"env",
|
|
149
|
+
"log",
|
|
150
|
+
"txt",
|
|
151
|
+
"text",
|
|
152
|
+
"csv",
|
|
153
|
+
"tsv",
|
|
154
|
+
"lock",
|
|
155
|
+
"sqlite",
|
|
156
|
+
"graphql",
|
|
157
|
+
"gql",
|
|
158
|
+
"proto",
|
|
159
|
+
"prisma",
|
|
160
|
+
"asm",
|
|
161
|
+
"s",
|
|
162
|
+
]);
|
|
163
|
+
/**
|
|
164
|
+
* Classify a file name into its preview category. Files with no extension
|
|
165
|
+
* (README, Makefile, .gitignore, …) are treated as text. Everything not in an
|
|
166
|
+
* allowlist (exe, jar, dll, zip, …) is "none" — never previewed.
|
|
167
|
+
*/
|
|
168
|
+
export function previewKind(name) {
|
|
169
|
+
const dot = name.lastIndexOf(".");
|
|
170
|
+
// A leading dot with nothing after it (.gitignore, .env) counts as no ext.
|
|
171
|
+
const ext = dot > 0 ? name.slice(dot + 1).toLowerCase() : "";
|
|
172
|
+
if (PREVIEW_IMAGE_EXTS.has(ext))
|
|
173
|
+
return "image";
|
|
174
|
+
if (PREVIEW_VIDEO_EXTS.has(ext))
|
|
175
|
+
return "video";
|
|
176
|
+
if (ext === "" || PREVIEW_TEXT_EXTS.has(ext))
|
|
177
|
+
return "text";
|
|
178
|
+
return "none";
|
|
179
|
+
}
|
|
23
180
|
// ---------------------------------------------------------------------------
|
|
24
181
|
// Web UI context adapter — bridges extension UI calls (setWidget/notify) to the
|
|
25
182
|
// browser. Extensions like rpiv-todo render a TUI widget via
|
|
@@ -271,15 +428,65 @@ function extractPartialText(partial) {
|
|
|
271
428
|
}
|
|
272
429
|
return null;
|
|
273
430
|
}
|
|
431
|
+
/**
|
|
432
|
+
* Persists which workspace each browser client last used + which workspaces it
|
|
433
|
+
* has opened, so a server restart / page reload restores the same project and
|
|
434
|
+
* the UI can offer a one-click recent-project list. File I/O is best-effort:
|
|
435
|
+
* persistence problems must never crash the server or block a session.
|
|
436
|
+
*/
|
|
437
|
+
class ClientStateStore {
|
|
438
|
+
filePath;
|
|
439
|
+
cache = null;
|
|
440
|
+
constructor(filePath) {
|
|
441
|
+
this.filePath = filePath;
|
|
442
|
+
}
|
|
443
|
+
load() {
|
|
444
|
+
if (this.cache)
|
|
445
|
+
return this.cache;
|
|
446
|
+
try {
|
|
447
|
+
const parsed = JSON.parse(readFileSync(this.filePath, "utf8"));
|
|
448
|
+
this.cache = parsed && typeof parsed === "object" ? parsed : {};
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
this.cache = {};
|
|
452
|
+
}
|
|
453
|
+
return this.cache;
|
|
454
|
+
}
|
|
455
|
+
save() {
|
|
456
|
+
try {
|
|
457
|
+
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
458
|
+
writeFileSync(this.filePath, JSON.stringify(this.cache, null, 2) + "\n");
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
// best effort
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
get(clientId) {
|
|
465
|
+
return this.load()[clientId] ?? { projects: [] };
|
|
466
|
+
}
|
|
467
|
+
/** Remember which workspace a client last used; bumps its project entry. */
|
|
468
|
+
remember(clientId, cwd) {
|
|
469
|
+
const all = this.load();
|
|
470
|
+
const state = (all[clientId] ??= { projects: [] });
|
|
471
|
+
state.lastCwd = cwd;
|
|
472
|
+
const now = Date.now();
|
|
473
|
+
state.projects = [
|
|
474
|
+
{ path: cwd, lastUsed: now },
|
|
475
|
+
...state.projects.filter((p) => p.path !== cwd),
|
|
476
|
+
].slice(0, 30);
|
|
477
|
+
this.save();
|
|
478
|
+
}
|
|
479
|
+
}
|
|
274
480
|
export class ClientSession {
|
|
275
481
|
clientId;
|
|
276
482
|
cwd;
|
|
277
|
-
/**
|
|
278
|
-
workspaceRoot;
|
|
483
|
+
/** Absolute per-client session directory.
|
|
279
484
|
/** Absolute per-client session directory. */
|
|
280
485
|
sessionDir;
|
|
281
486
|
/** pi config dir (auth/models/skills). */
|
|
282
487
|
agentDir;
|
|
488
|
+
/** Persisted per-client UI state (last workspace + recent projects). */
|
|
489
|
+
stateStore;
|
|
283
490
|
runtime;
|
|
284
491
|
session;
|
|
285
492
|
/** PTY terminals for this client (killed when the last socket detaches). */
|
|
@@ -313,16 +520,16 @@ export class ClientSession {
|
|
|
313
520
|
disposed = false;
|
|
314
521
|
/** pi-config readiness check, cached briefly so 60ms snapshots don't hit disk. */
|
|
315
522
|
piCheckCache = null;
|
|
316
|
-
constructor(clientId, cwd, sessionDir, agentDir, runtime) {
|
|
523
|
+
constructor(clientId, cwd, sessionDir, agentDir, runtime, stateStore) {
|
|
317
524
|
this.clientId = clientId;
|
|
318
525
|
this.cwd = cwd;
|
|
319
|
-
this.workspaceRoot = cwd;
|
|
320
526
|
this.sessionDir = sessionDir;
|
|
321
527
|
this.agentDir = agentDir;
|
|
322
528
|
this.runtime = runtime;
|
|
323
529
|
this.session = runtime.session;
|
|
530
|
+
this.stateStore = stateStore;
|
|
324
531
|
}
|
|
325
|
-
static async create(clientId, cwd, sessionDir) {
|
|
532
|
+
static async create(clientId, cwd, sessionDir, stateStore) {
|
|
326
533
|
const agentDir = process.env.PI_CODING_AGENT_DIR ?? getAgentDir();
|
|
327
534
|
const runtime = await createAgentSessionRuntime(ClientSession.runtimeFactory, {
|
|
328
535
|
cwd,
|
|
@@ -331,7 +538,7 @@ export class ClientSession {
|
|
|
331
538
|
// or start a fresh one on first visit.
|
|
332
539
|
sessionManager: SessionManager.continueRecent(cwd, sessionDir),
|
|
333
540
|
});
|
|
334
|
-
const cs = new ClientSession(clientId, cwd, sessionDir, agentDir, runtime);
|
|
541
|
+
const cs = new ClientSession(clientId, cwd, sessionDir, agentDir, runtime, stateStore);
|
|
335
542
|
for (const d of runtime.diagnostics) {
|
|
336
543
|
if (d.type !== "info") {
|
|
337
544
|
cs.pendingNotices.push({
|
|
@@ -1322,6 +1529,118 @@ export class ClientSession {
|
|
|
1322
1529
|
}
|
|
1323
1530
|
this.flushSnapshot();
|
|
1324
1531
|
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Map a rendered user-message id (`u-<timestamp>-<seq>`, assigned in
|
|
1534
|
+
* serialize.ts) back to its append-only session entry id. The seq handles
|
|
1535
|
+
* two user messages sharing the same millisecond timestamp.
|
|
1536
|
+
*/
|
|
1537
|
+
resolveUserMessageEntryId(messageId) {
|
|
1538
|
+
const m = /^u-(\d+)(?:-(\d+))?$/.exec(messageId);
|
|
1539
|
+
if (!m)
|
|
1540
|
+
return null;
|
|
1541
|
+
const ts = Number(m[1]);
|
|
1542
|
+
const seq = m[2] ? Number(m[2]) : 1;
|
|
1543
|
+
let count = 0;
|
|
1544
|
+
for (const entry of this.session.sessionManager.getEntries()) {
|
|
1545
|
+
if (entry.type !== "message")
|
|
1546
|
+
continue;
|
|
1547
|
+
const msg = entry.message;
|
|
1548
|
+
if (!msg || msg.role !== "user" || msg.timestamp !== ts)
|
|
1549
|
+
continue;
|
|
1550
|
+
count += 1;
|
|
1551
|
+
if (count === seq)
|
|
1552
|
+
return entry.id;
|
|
1553
|
+
}
|
|
1554
|
+
return null;
|
|
1555
|
+
}
|
|
1556
|
+
/**
|
|
1557
|
+
* Edit a past user question and re-ask it: forks a NEW session file that
|
|
1558
|
+
* keeps everything up to (but not including) that question, then sends the
|
|
1559
|
+
* edited text there. The original thread is untouched and stays in the
|
|
1560
|
+
* session list, so nothing is ever lost.
|
|
1561
|
+
*/
|
|
1562
|
+
async editMessage(messageId, text) {
|
|
1563
|
+
const trimmed = text.trim();
|
|
1564
|
+
if (!trimmed) {
|
|
1565
|
+
this.emit({
|
|
1566
|
+
type: "notice",
|
|
1567
|
+
level: "warning",
|
|
1568
|
+
text: "编辑内容为空,已取消",
|
|
1569
|
+
});
|
|
1570
|
+
this.flushSnapshot();
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
const entryId = this.resolveUserMessageEntryId(messageId);
|
|
1574
|
+
if (!entryId) {
|
|
1575
|
+
this.emit({
|
|
1576
|
+
type: "notice",
|
|
1577
|
+
level: "error",
|
|
1578
|
+
text: "找不到要编辑的消息(可能已被压缩或不在当前分支)",
|
|
1579
|
+
});
|
|
1580
|
+
this.flushSnapshot();
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
try {
|
|
1584
|
+
const result = await this.runtime.fork(entryId);
|
|
1585
|
+
if (result.cancelled) {
|
|
1586
|
+
this.emit({
|
|
1587
|
+
type: "notice",
|
|
1588
|
+
level: "info",
|
|
1589
|
+
text: "已取消编辑重问",
|
|
1590
|
+
});
|
|
1591
|
+
this.flushSnapshot();
|
|
1592
|
+
return;
|
|
1593
|
+
}
|
|
1594
|
+
await this.bindSession();
|
|
1595
|
+
await this.prompt(trimmed);
|
|
1596
|
+
this.emit({
|
|
1597
|
+
type: "notice",
|
|
1598
|
+
level: "info",
|
|
1599
|
+
text: "已从该问题重新提问(原对话保留在会话列表中)",
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
catch (err) {
|
|
1603
|
+
this.emit({
|
|
1604
|
+
type: "notice",
|
|
1605
|
+
level: "error",
|
|
1606
|
+
text: `编辑重问失败:${err.message}`,
|
|
1607
|
+
});
|
|
1608
|
+
}
|
|
1609
|
+
this.flushSnapshot();
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* Push the recent-project list (persisted per client, merged with every cwd
|
|
1613
|
+
* that has persisted sessions in this client's session store — so workspaces
|
|
1614
|
+
* opened before the recent-list feature existed still show up).
|
|
1615
|
+
*/
|
|
1616
|
+
async pushProjects() {
|
|
1617
|
+
try {
|
|
1618
|
+
const saved = this.stateStore.get(this.clientId);
|
|
1619
|
+
const map = new Map();
|
|
1620
|
+
for (const p of saved.projects)
|
|
1621
|
+
map.set(p.path, p.lastUsed);
|
|
1622
|
+
const all = await SessionManager.listAll(this.sessionDir);
|
|
1623
|
+
for (const s of all) {
|
|
1624
|
+
if (s.cwd) {
|
|
1625
|
+
const t = s.modified.getTime();
|
|
1626
|
+
const prev = map.get(s.cwd);
|
|
1627
|
+
if (prev === undefined || t > prev)
|
|
1628
|
+
map.set(s.cwd, t);
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
// Only keep directories that still exist — a deleted/unmounted workspace
|
|
1632
|
+
// is useless in the picker.
|
|
1633
|
+
const projects = [...map.entries()]
|
|
1634
|
+
.filter(([path]) => existsSync(path))
|
|
1635
|
+
.map(([path, lastUsed]) => ({ path, lastUsed }))
|
|
1636
|
+
.sort((a, b) => b.lastUsed - a.lastUsed)
|
|
1637
|
+
.slice(0, 20);
|
|
1638
|
+
this.emit({ type: "projects", projects });
|
|
1639
|
+
}
|
|
1640
|
+
catch {
|
|
1641
|
+
this.emit({ type: "projects", projects: [] });
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1325
1644
|
/** List a workspace directory (relative to the configured cwd). */
|
|
1326
1645
|
async listFiles(relPath) {
|
|
1327
1646
|
try {
|
|
@@ -1341,11 +1660,16 @@ export class ClientSession {
|
|
|
1341
1660
|
const dirents = await fs.readdir(target, { withFileTypes: true });
|
|
1342
1661
|
const entries = dirents
|
|
1343
1662
|
.filter((d) => !IGNORED_ENTRIES.has(d.name))
|
|
1344
|
-
.map((d) =>
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1663
|
+
.map((d) => {
|
|
1664
|
+
const entry = {
|
|
1665
|
+
name: d.name,
|
|
1666
|
+
path: rel === "" ? d.name : `${rel}/${d.name}`,
|
|
1667
|
+
type: (d.isDirectory() ? "dir" : "file"),
|
|
1668
|
+
};
|
|
1669
|
+
if (!d.isDirectory())
|
|
1670
|
+
entry.kind = previewKind(d.name);
|
|
1671
|
+
return entry;
|
|
1672
|
+
})
|
|
1349
1673
|
.sort((a, b) => a.type === b.type
|
|
1350
1674
|
? a.name.localeCompare(b.name)
|
|
1351
1675
|
: a.type === "dir"
|
|
@@ -1396,14 +1720,46 @@ export class ClientSession {
|
|
|
1396
1720
|
});
|
|
1397
1721
|
return;
|
|
1398
1722
|
}
|
|
1399
|
-
|
|
1400
|
-
|
|
1723
|
+
const name = relPath.split("/").pop() ?? relPath;
|
|
1724
|
+
const kind = previewKind(name);
|
|
1725
|
+
// Not previewable (exe, jar, archives, …) — never read or sent.
|
|
1726
|
+
if (kind === "none") {
|
|
1727
|
+
this.emit({
|
|
1728
|
+
type: "file_content",
|
|
1729
|
+
path: rel,
|
|
1730
|
+
name,
|
|
1731
|
+
text: "",
|
|
1732
|
+
truncated: false,
|
|
1733
|
+
binary: true,
|
|
1734
|
+
kind: "none",
|
|
1735
|
+
lines: 0,
|
|
1736
|
+
size: stat.size,
|
|
1737
|
+
});
|
|
1738
|
+
return;
|
|
1739
|
+
}
|
|
1740
|
+
// Media previews stream over the /api/file HTTP endpoint, so only
|
|
1741
|
+
// metadata is sent here — the raw bytes never touch the socket.
|
|
1742
|
+
if (kind === "image" || kind === "video") {
|
|
1743
|
+
this.emit({
|
|
1744
|
+
type: "file_content",
|
|
1745
|
+
path: rel,
|
|
1746
|
+
name,
|
|
1747
|
+
text: "",
|
|
1748
|
+
truncated: false,
|
|
1749
|
+
binary: true,
|
|
1750
|
+
kind,
|
|
1751
|
+
lines: 0,
|
|
1752
|
+
size: stat.size,
|
|
1753
|
+
});
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
// Text: read only the first MAX_PREVIEW_BYTES so huge files can't
|
|
1757
|
+
// exhaust memory or flood the socket.
|
|
1401
1758
|
const handle = await fs.open(abs, "r");
|
|
1402
1759
|
try {
|
|
1403
1760
|
const buf = Buffer.alloc(Math.min(stat.size, MAX_PREVIEW_BYTES));
|
|
1404
1761
|
const { bytesRead } = await handle.read(buf, 0, buf.length, 0);
|
|
1405
1762
|
const data = buf.subarray(0, bytesRead);
|
|
1406
|
-
const name = relPath.split("/").pop() ?? relPath;
|
|
1407
1763
|
if (data.includes(0)) {
|
|
1408
1764
|
this.emit({
|
|
1409
1765
|
type: "file_content",
|
|
@@ -1412,6 +1768,7 @@ export class ClientSession {
|
|
|
1412
1768
|
text: "",
|
|
1413
1769
|
truncated: false,
|
|
1414
1770
|
binary: true,
|
|
1771
|
+
kind: "text",
|
|
1415
1772
|
lines: 0,
|
|
1416
1773
|
size: stat.size,
|
|
1417
1774
|
});
|
|
@@ -1424,6 +1781,7 @@ export class ClientSession {
|
|
|
1424
1781
|
text: data.toString("utf8"),
|
|
1425
1782
|
truncated: bytesRead < stat.size,
|
|
1426
1783
|
binary: false,
|
|
1784
|
+
kind: "text",
|
|
1427
1785
|
lines: countLines(data),
|
|
1428
1786
|
size: stat.size,
|
|
1429
1787
|
});
|
|
@@ -1547,6 +1905,9 @@ export class ClientSession {
|
|
|
1547
1905
|
const oldRuntime = this.runtime;
|
|
1548
1906
|
this.runtime = newRuntime;
|
|
1549
1907
|
this.cwd = abs;
|
|
1908
|
+
// Remember the new workspace (restore target + recent-project entry).
|
|
1909
|
+
this.stateStore.remember(this.clientId, abs);
|
|
1910
|
+
void this.pushProjects();
|
|
1550
1911
|
this.unsubscribe?.();
|
|
1551
1912
|
this.unsubscribe = undefined;
|
|
1552
1913
|
await this.bindSession();
|
|
@@ -1563,6 +1924,8 @@ export class ClientSession {
|
|
|
1563
1924
|
});
|
|
1564
1925
|
void this.refreshSessions();
|
|
1565
1926
|
void this.listFiles(undefined);
|
|
1927
|
+
// Commands are per-project (.pi/commands.json in the current cwd).
|
|
1928
|
+
void this.listCommands();
|
|
1566
1929
|
}
|
|
1567
1930
|
catch (err) {
|
|
1568
1931
|
this.emit({
|
|
@@ -1647,7 +2010,7 @@ export class ClientSession {
|
|
|
1647
2010
|
}
|
|
1648
2011
|
/** Push the user command list (.pi/commands.json) to the client. */
|
|
1649
2012
|
async listCommands() {
|
|
1650
|
-
const { commands, path, warning } = await loadCommands(this.
|
|
2013
|
+
const { commands, path, warning } = await loadCommands(this.cwd);
|
|
1651
2014
|
if (warning) {
|
|
1652
2015
|
this.emit({ type: "notice", level: "warning", text: warning });
|
|
1653
2016
|
}
|
|
@@ -1655,7 +2018,7 @@ export class ClientSession {
|
|
|
1655
2018
|
}
|
|
1656
2019
|
/** Persist the user command list (.pi/commands.json). */
|
|
1657
2020
|
async saveCommands(commands) {
|
|
1658
|
-
const { path, error } = await saveCommandsFile(this.
|
|
2021
|
+
const { path, error } = await saveCommandsFile(this.cwd, commands);
|
|
1659
2022
|
if (error) {
|
|
1660
2023
|
this.emit({ type: "notice", level: "error", text: error });
|
|
1661
2024
|
return;
|
|
@@ -1694,9 +2057,11 @@ export class AgentService {
|
|
|
1694
2057
|
sessionDirRoot;
|
|
1695
2058
|
clients = new Map();
|
|
1696
2059
|
pending = new Map();
|
|
1697
|
-
|
|
2060
|
+
stateStore;
|
|
2061
|
+
constructor(cwd, sessionDirRoot, stateFile) {
|
|
1698
2062
|
this.cwd = cwd;
|
|
1699
2063
|
this.sessionDirRoot = sessionDirRoot;
|
|
2064
|
+
this.stateStore = new ClientStateStore(stateFile);
|
|
1700
2065
|
}
|
|
1701
2066
|
/** Get or create the session for a client, racing attach calls safely. */
|
|
1702
2067
|
async attach(clientId, send) {
|
|
@@ -1707,12 +2072,34 @@ export class AgentService {
|
|
|
1707
2072
|
cs = await inflight;
|
|
1708
2073
|
}
|
|
1709
2074
|
else {
|
|
1710
|
-
|
|
2075
|
+
// Restore this client's last-used workspace when it still exists;
|
|
2076
|
+
// otherwise fall back to the server's configured default cwd.
|
|
2077
|
+
let cwd = this.cwd;
|
|
2078
|
+
const saved = this.stateStore.get(clientId);
|
|
2079
|
+
if (saved.lastCwd && saved.lastCwd !== this.cwd) {
|
|
2080
|
+
try {
|
|
2081
|
+
if (statSync(saved.lastCwd).isDirectory())
|
|
2082
|
+
cwd = saved.lastCwd;
|
|
2083
|
+
}
|
|
2084
|
+
catch {
|
|
2085
|
+
// gone (unmounted drive / deleted) — fall back to the default
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
const creating = ClientSession.create(clientId, cwd, join(this.sessionDirRoot, sanitizeId(clientId)), this.stateStore).finally(() => {
|
|
1711
2089
|
this.pending.delete(clientId);
|
|
1712
2090
|
});
|
|
1713
2091
|
this.pending.set(clientId, creating);
|
|
1714
2092
|
cs = await creating;
|
|
1715
2093
|
this.clients.set(clientId, cs);
|
|
2094
|
+
// Make sure the restored/default workspace appears in the project list.
|
|
2095
|
+
this.stateStore.remember(clientId, cwd);
|
|
2096
|
+
if (cwd !== this.cwd) {
|
|
2097
|
+
send({
|
|
2098
|
+
type: "notice",
|
|
2099
|
+
level: "info",
|
|
2100
|
+
text: `已恢复上次的工作目录:${cwd}`,
|
|
2101
|
+
});
|
|
2102
|
+
}
|
|
1716
2103
|
}
|
|
1717
2104
|
}
|
|
1718
2105
|
cs.attachSink(send);
|
package/dist/server/index.js
CHANGED
|
@@ -13,14 +13,15 @@
|
|
|
13
13
|
* PI_CODING_AGENT_DIR pi config dir (auth/models/skills) — passed to the SDK
|
|
14
14
|
*/
|
|
15
15
|
import { existsSync } from "node:fs";
|
|
16
|
+
import { stat } from "node:fs/promises";
|
|
16
17
|
import { createServer } from "node:http";
|
|
17
|
-
import { dirname, join, resolve } from "node:path";
|
|
18
|
+
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
18
19
|
import { fileURLToPath } from "node:url";
|
|
19
20
|
import { randomUUID } from "node:crypto";
|
|
20
21
|
import express from "express";
|
|
21
22
|
import { WebSocket, WebSocketServer } from "ws";
|
|
22
23
|
import { VERSION } from "@earendil-works/pi-coding-agent";
|
|
23
|
-
import { AgentService } from "./agent-service.js";
|
|
24
|
+
import { AgentService, previewKind } from "./agent-service.js";
|
|
24
25
|
const PORT = Number(process.env.PORT ?? 8787);
|
|
25
26
|
const CWD = resolve(process.env.PI_WEB_CWD ?? process.cwd());
|
|
26
27
|
const DATA_DIR = resolve(process.env.PI_WEB_DATA_DIR ?? join(CWD, ".pi-web"));
|
|
@@ -30,6 +31,38 @@ app.use(express.json({ limit: "10mb" }));
|
|
|
30
31
|
app.get("/api/health", (_req, res) => {
|
|
31
32
|
res.json({ ok: true, piVersion: VERSION, cwd: CWD, pid: process.pid });
|
|
32
33
|
});
|
|
34
|
+
/**
|
|
35
|
+
* Stream a workspace file for the media preview (image/video). Path is
|
|
36
|
+
* validated against the workspace root and only image/video kinds are served —
|
|
37
|
+
* text goes over the WebSocket, and exe/jar/etc. are never exposed here.
|
|
38
|
+
* express's sendFile handles Range requests, so video seeking works.
|
|
39
|
+
*/
|
|
40
|
+
app.get("/api/file", async (req, res) => {
|
|
41
|
+
try {
|
|
42
|
+
const raw = typeof req.query.path === "string" ? req.query.path : "";
|
|
43
|
+
const abs = resolve(CWD, raw);
|
|
44
|
+
const rel = relative(CWD, abs);
|
|
45
|
+
if (rel.startsWith("..") || rel.includes(`${sep}..`)) {
|
|
46
|
+
res.status(400).end("path outside workspace");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const name = basename(abs);
|
|
50
|
+
const kind = previewKind(name);
|
|
51
|
+
if (kind !== "image" && kind !== "video") {
|
|
52
|
+
res.status(400).end("not a previewable media file");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const st = await stat(abs);
|
|
56
|
+
if (!st.isFile()) {
|
|
57
|
+
res.status(400).end("not a file");
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
res.sendFile(abs);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
res.status(404).end("not found");
|
|
64
|
+
}
|
|
65
|
+
});
|
|
33
66
|
// Production: serve the built frontend from web/dist. Resolve relative to this
|
|
34
67
|
// module so it works when installed as a package (global/npx/Docker), not just
|
|
35
68
|
// from the repo root. In dev, Vite serves the UI on :5173 and proxies /ws.
|
|
@@ -54,7 +87,9 @@ const heartbeatTimer = setInterval(() => {
|
|
|
54
87
|
}
|
|
55
88
|
}
|
|
56
89
|
}, 10_000);
|
|
57
|
-
const service = new AgentService(CWD, SESSION_DIR_ROOT
|
|
90
|
+
const service = new AgentService(CWD, SESSION_DIR_ROOT,
|
|
91
|
+
// Per-client persisted UI state: last-used workspace + recent projects.
|
|
92
|
+
join(DATA_DIR, "client-state.json"));
|
|
58
93
|
wss.on("connection", (ws) => {
|
|
59
94
|
let clientId = null;
|
|
60
95
|
let closed = false;
|
|
@@ -86,6 +121,9 @@ wss.on("connection", (ws) => {
|
|
|
86
121
|
case "new_chat":
|
|
87
122
|
void cs.newChat();
|
|
88
123
|
break;
|
|
124
|
+
case "edit_message":
|
|
125
|
+
void cs.editMessage(msg.messageId, msg.text);
|
|
126
|
+
break;
|
|
89
127
|
case "cycle_model":
|
|
90
128
|
void cs.cycleModel();
|
|
91
129
|
break;
|
|
@@ -98,6 +136,9 @@ wss.on("connection", (ws) => {
|
|
|
98
136
|
case "list_sessions":
|
|
99
137
|
void cs.refreshSessions();
|
|
100
138
|
break;
|
|
139
|
+
case "list_projects":
|
|
140
|
+
void cs.pushProjects();
|
|
141
|
+
break;
|
|
101
142
|
case "switch_session":
|
|
102
143
|
void cs.switchSession(msg.path);
|
|
103
144
|
break;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-web-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|