pi-web-ui 0.8.1 → 0.8.3

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.
@@ -1701,8 +1701,18 @@ export class ClientSession {
1701
1701
  async switchConversation(id) {
1702
1702
  if (!this.convs.has(id) || id === this.activeId)
1703
1703
  return;
1704
+ const prevCwd = this.cwd;
1704
1705
  this.activeId = id;
1705
1706
  this.cwd = this.conv.cwd;
1707
+ // Cross-directory jump: make the workspace switch explicit so the user
1708
+ // isn't surprised when the file tree / footer / history all change.
1709
+ if (this.cwd !== prevCwd) {
1710
+ this.emit({
1711
+ type: "notice",
1712
+ level: "info",
1713
+ text: `已切换到工作目录:${this.cwd}`,
1714
+ });
1715
+ }
1706
1716
  this.webUi.refresh();
1707
1717
  this.emitConversations();
1708
1718
  // Workspace-bound panels (session list / file tree / commands) follow
@@ -12,13 +12,12 @@
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 { createWriteStream, existsSync } from "node:fs";
16
- import { mkdir, stat } from "node:fs/promises";
15
+ import { existsSync } from "node:fs";
16
+ import { stat } from "node:fs/promises";
17
17
  import { createServer } from "node:http";
18
- import { basename, dirname, extname, join, resolve } from "node:path";
18
+ import { basename, dirname, 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";
22
21
  import express from "express";
23
22
  import { WebSocket, WebSocketServer } from "ws";
24
23
  import { VERSION } from "@earendil-works/pi-coding-agent";
@@ -83,83 +82,6 @@ app.get("/api/file", async (req, res) => {
83
82
  res.status(404).end("not found");
84
83
  }
85
84
  });
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
- });
163
85
  // Production: serve the built frontend from web/dist. Resolve relative to this
164
86
  // module so it works when installed as a package (global/npx/Docker), not just
165
87
  // from the repo root. In dev, Vite serves the UI on :5173 and proxies /ws.
@@ -137,7 +137,10 @@ function shellEnv() {
137
137
  // and node-pty throws the generic "posix_spawnp failed". Locally-built
138
138
  // copies (build/Release) are fine; every `npm install` that picks the prebuild
139
139
  // — e.g. `npm i -g pi-web-ui`, which is what system-service installs run — is
140
- // broken until the bit is restored. Self-heal at startup, best-effort.
140
+ // broken until the bit is restored. Self-heal at startup AND lazily before
141
+ // every spawn (an `npm i -g` while the server is running replaces the helper
142
+ // under the running process, so the startup-only repair misses it).
143
+ // Best-effort: a read-only node_modules just keeps the old failure.
141
144
  const require = createRequire(import.meta.url);
142
145
  /** Absolute paths of every node-pty spawn-helper this install can exec. */
143
146
  function spawnHelperPaths() {
@@ -294,6 +297,9 @@ export class TerminalManager {
294
297
  this.fail(id, `目录不存在:${abs}`);
295
298
  return false;
296
299
  }
300
+ // node-pty's spawn-helper may have lost its +x bit since the last repair
301
+ // (e.g. a global npm install replaced the helper while this server runs).
302
+ repairSpawnHelperPermissions();
297
303
  let pty;
298
304
  try {
299
305
  pty = spawn(SHELL, SHELL_ARGS, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-web-ui",
3
- "version": "0.8.1",
3
+ "version": "0.8.3",
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",