pi-web-ui 0.7.0 → 0.8.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/dist/server/index.js
CHANGED
|
@@ -12,12 +12,13 @@
|
|
|
12
12
|
* PI_WEB_DATA_DIR where per-client session dirs are stored (default: <cwd>/.pi-web)
|
|
13
13
|
* PI_CODING_AGENT_DIR pi config dir (auth/models/skills) — passed to the SDK
|
|
14
14
|
*/
|
|
15
|
-
import { existsSync } from "node:fs";
|
|
16
|
-
import { stat } from "node:fs/promises";
|
|
15
|
+
import { createWriteStream, existsSync } from "node:fs";
|
|
16
|
+
import { mkdir, stat } from "node:fs/promises";
|
|
17
17
|
import { createServer } from "node:http";
|
|
18
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
18
|
+
import { basename, dirname, extname, join, resolve } from "node:path";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
import { randomUUID } from "node:crypto";
|
|
21
|
+
import { pipeline } from "node:stream/promises";
|
|
21
22
|
import express from "express";
|
|
22
23
|
import { WebSocket, WebSocketServer } from "ws";
|
|
23
24
|
import { VERSION } from "@earendil-works/pi-coding-agent";
|
|
@@ -32,10 +33,15 @@ app.get("/api/health", (_req, res) => {
|
|
|
32
33
|
res.json({ ok: true, piVersion: VERSION, cwd: CWD, pid: process.pid });
|
|
33
34
|
});
|
|
34
35
|
/**
|
|
35
|
-
* Stream a workspace file
|
|
36
|
-
*
|
|
36
|
+
* Stream a workspace file over HTTP.
|
|
37
|
+
*
|
|
38
|
+
* Media preview (no download param): only image/video kinds are served —
|
|
37
39
|
* text goes over the WebSocket, and exe/jar/etc. are never exposed here.
|
|
38
40
|
* express's sendFile handles Range requests, so video seeking works.
|
|
41
|
+
*
|
|
42
|
+
* Download (?download=1): any file kind is served with
|
|
43
|
+
* Content-Disposition: attachment so the browser saves it instead of
|
|
44
|
+
* rendering. Path is validated against the workspace root either way.
|
|
39
45
|
*/
|
|
40
46
|
app.get("/api/file", async (req, res) => {
|
|
41
47
|
try {
|
|
@@ -54,7 +60,8 @@ app.get("/api/file", async (req, res) => {
|
|
|
54
60
|
const abs = wp.abs;
|
|
55
61
|
const name = basename(abs);
|
|
56
62
|
const kind = previewKind(name);
|
|
57
|
-
|
|
63
|
+
const isDownload = req.query.download === "1";
|
|
64
|
+
if (!isDownload && kind !== "image" && kind !== "video") {
|
|
58
65
|
res.status(400).end("not a previewable media file");
|
|
59
66
|
return;
|
|
60
67
|
}
|
|
@@ -63,12 +70,96 @@ app.get("/api/file", async (req, res) => {
|
|
|
63
70
|
res.status(400).end("not a file");
|
|
64
71
|
return;
|
|
65
72
|
}
|
|
66
|
-
|
|
73
|
+
if (isDownload) {
|
|
74
|
+
// res.download sets Content-Disposition: attachment and RFC 5987
|
|
75
|
+
// filename* encoding for non-ASCII names.
|
|
76
|
+
res.download(abs, name);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
res.sendFile(abs);
|
|
80
|
+
}
|
|
67
81
|
}
|
|
68
82
|
catch {
|
|
69
83
|
res.status(404).end("not found");
|
|
70
84
|
}
|
|
71
85
|
});
|
|
86
|
+
/**
|
|
87
|
+
* Save an uploaded file into the client's workspace (drag & drop from the OS
|
|
88
|
+
* file manager). Query params:
|
|
89
|
+
* clientId client session — resolves the workspace root (falls back to CWD)
|
|
90
|
+
* destDir workspace-relative target directory ("" = workspace root)
|
|
91
|
+
* Request body is the raw file bytes (streamed to disk); headers:
|
|
92
|
+
* X-File-Name original file name (URI-encoded)
|
|
93
|
+
* X-File-Rel-Path optional path of the file within the drop (URI-encoded)
|
|
94
|
+
* — its directory part is recreated under destDir, so
|
|
95
|
+
* dropping a whole folder keeps its structure. The final
|
|
96
|
+
* segment is used as the file name.
|
|
97
|
+
* Paths are validated against the workspace root; existing files get a
|
|
98
|
+
* "name (1).ext" suffix instead of being overwritten.
|
|
99
|
+
*/
|
|
100
|
+
app.post("/api/upload", async (req, res) => {
|
|
101
|
+
const fail = (status, error) => res.status(status).json({ ok: false, error });
|
|
102
|
+
try {
|
|
103
|
+
const cid = typeof req.query.clientId === "string" ? req.query.clientId : "";
|
|
104
|
+
const rawDest = typeof req.query.destDir === "string" ? req.query.destDir : "";
|
|
105
|
+
const cs = cid ? service.get(cid) : undefined;
|
|
106
|
+
const wp = workspacePath(cs?.cwd ?? CWD, rawDest);
|
|
107
|
+
if (!wp) {
|
|
108
|
+
fail(400, "目标目录不在工作区内");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const nameRaw = typeof req.headers["x-file-name"] === "string"
|
|
112
|
+
? decodeURIComponent(req.headers["x-file-name"])
|
|
113
|
+
: "";
|
|
114
|
+
const relRaw = typeof req.headers["x-file-rel-path"] === "string"
|
|
115
|
+
? decodeURIComponent(req.headers["x-file-rel-path"])
|
|
116
|
+
: "";
|
|
117
|
+
// Sanitize every path segment: strip separators/traversal, drop empties.
|
|
118
|
+
const clean = (s) => {
|
|
119
|
+
const seg = basename(s).replace(/[\\/]/g, "");
|
|
120
|
+
return seg && seg !== "." && seg !== ".." ? seg : null;
|
|
121
|
+
};
|
|
122
|
+
const relDirs = relRaw
|
|
123
|
+
.split("/")
|
|
124
|
+
.map(clean)
|
|
125
|
+
.filter((s) => s !== null);
|
|
126
|
+
const name = clean(relDirs.length > 0 ? relDirs[relDirs.length - 1] : nameRaw);
|
|
127
|
+
if (!name) {
|
|
128
|
+
fail(400, "无效文件名");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// Ensure the destination directory exists (and is a directory).
|
|
132
|
+
const destAbs = join(wp.abs, ...relDirs.slice(0, -1));
|
|
133
|
+
const destSt = await stat(wp.abs).catch(() => null);
|
|
134
|
+
if (destSt && !destSt.isDirectory()) {
|
|
135
|
+
fail(400, "目标不是目录");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
await mkdir(destAbs, { recursive: true });
|
|
139
|
+
// Never overwrite: "name (1).ext", "name (2).ext", …
|
|
140
|
+
const ext = extname(name);
|
|
141
|
+
const stem = name.slice(0, name.length - ext.length);
|
|
142
|
+
let finalName = name;
|
|
143
|
+
for (let i = 1; existsSync(join(destAbs, finalName)); i++) {
|
|
144
|
+
finalName = `${stem} (${i})${ext}`;
|
|
145
|
+
}
|
|
146
|
+
const finalAbs = join(destAbs, finalName);
|
|
147
|
+
// Stream the request body straight to disk.
|
|
148
|
+
let size = 0;
|
|
149
|
+
const out = createWriteStream(finalAbs);
|
|
150
|
+
req.on("data", (chunk) => {
|
|
151
|
+
size += chunk.length;
|
|
152
|
+
});
|
|
153
|
+
await pipeline(req, out);
|
|
154
|
+
const relOut = [rawDest, ...relDirs.slice(0, -1), finalName]
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.join("/");
|
|
157
|
+
res.json({ ok: true, path: relOut, name: finalName, size });
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
fail(500, "上传失败");
|
|
161
|
+
}
|
|
162
|
+
});
|
|
72
163
|
// Production: serve the built frontend from web/dist. Resolve relative to this
|
|
73
164
|
// module so it works when installed as a package (global/npx/Docker), not just
|
|
74
165
|
// from the repo root. In dev, Vite serves the UI on :5173 and proxies /ws.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-web-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|