pi-web-ui 0.64.1 → 0.64.2
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/attachments.js +13 -8
- package/dist/server/files-service.js +230 -38
- package/dist/server/index.js +46 -11
- package/package.json +1 -1
- package/web/dist/assets/{TerminalPanel-C5VIdPmn.js → TerminalPanel-BXISCcrQ.js} +1 -1
- package/web/dist/assets/index-BxuvIAJQ.js +326 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/index-BmDs6ukf.js +0 -326
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { countLines, decodeText, looksLikeText, sniffImageMime } from "./text-sniff.js";
|
|
2
2
|
import { saveUpload, uploadsRoot } from "./uploads.js";
|
|
3
|
+
import { isAbsoluteWirePath, wireToAbs } from "./files-service.js";
|
|
3
4
|
import { buildVisionBridgePrompt, findVisionModels, transcribeImages } from "./vision-bridge.js";
|
|
4
5
|
/** 跨快照的视觉转写缓存:批次 hash(名称 + base64 头 + 提示词)→ 转写文本。
|
|
5
6
|
* 编辑重问重发相同图片不再重复耗视觉 token。进程级共享即可。 */
|
|
@@ -120,9 +121,11 @@ export async function buildAttachmentMessages(ctx, attachments) {
|
|
|
120
121
|
const ext = extname(att.path).toLowerCase();
|
|
121
122
|
if (!IMAGE_EXT.has(ext) || ext === ".svg")
|
|
122
123
|
continue;
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
124
|
+
// 机器浏览的绝对路径(盘符 / posix "/")允许在工作区之外。
|
|
125
|
+
const absPath = isAbsoluteWirePath(att.path);
|
|
126
|
+
const abs = absPath ? wireToAbs(att.path) : resolve(root, att.path);
|
|
127
|
+
const rawRel = absPath ? null : relative(root, abs);
|
|
128
|
+
if (!absPath && (rawRel.startsWith("..") || rawRel.includes(`${sep}..`)))
|
|
126
129
|
continue;
|
|
127
130
|
let st;
|
|
128
131
|
try {
|
|
@@ -381,9 +384,10 @@ export async function buildAttachmentMessages(ctx, attachments) {
|
|
|
381
384
|
pushUploadAside(att.name ?? basename(abs), abs.split(sep).join("/"), buf);
|
|
382
385
|
continue;
|
|
383
386
|
}
|
|
384
|
-
const
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
+
const absPath = isAbsoluteWirePath(att.path);
|
|
388
|
+
const abs = absPath ? wireToAbs(att.path) : resolve(root, att.path);
|
|
389
|
+
const rawRel = absPath ? null : relative(root, abs);
|
|
390
|
+
if (!absPath && (rawRel.startsWith("..") || rawRel.includes(`${sep}..`))) {
|
|
387
391
|
ctx.emit({
|
|
388
392
|
type: "notice",
|
|
389
393
|
level: "warning",
|
|
@@ -393,8 +397,9 @@ export async function buildAttachmentMessages(ctx, attachments) {
|
|
|
393
397
|
continue;
|
|
394
398
|
}
|
|
395
399
|
// Normalize to forward slashes (relative() returns "\\" on Windows);
|
|
396
|
-
// <file path> and details.path must use the wire format.
|
|
397
|
-
|
|
400
|
+
// <file path> and details.path must use the wire format. 机器浏览的绝对
|
|
401
|
+
// 路径直接按绝对路径引用(SDK 读文件工具接受绝对路径,与上传文件一致)。
|
|
402
|
+
const rel = absPath ? abs.split(sep).join("/") : rawRel.split(sep).join("/");
|
|
398
403
|
let stat;
|
|
399
404
|
try {
|
|
400
405
|
stat = await fs.stat(abs);
|
|
@@ -10,6 +10,47 @@ import { resolve, relative, sep } from "node:path";
|
|
|
10
10
|
import { previewKind, looksLikeText, decodeText, hexDump, countLines } from "./text-sniff.js";
|
|
11
11
|
import { gitDirOf, isNotRepoError, scmStatus, scmHistory, scmFileDiff, scmCommitDetail } from "./scm.js";
|
|
12
12
|
export const IS_WIN32 = process.platform === "win32";
|
|
13
|
+
/** 机器根虚拟路径:工作区「上一级」到达此处,列出所有盘符(Windows)/ "/"(posix)。
|
|
14
|
+
* 这是 wire 字面量,前端 web/src/components/{RightPanel,FooterBar}.tsx 里同值使用。 */
|
|
15
|
+
export const MACHINE_ROOT = "@root";
|
|
16
|
+
/** wire 路径统一用 "/"。绝对 = posix "/...";win32 还有 "C:/..." / 裸 "C:"。
|
|
17
|
+
* 机器浏览(越过工作区根换盘符)发送这些路径;工作区相对树不会产生它们(Windows
|
|
18
|
+
* 文件名不能含 ":",相对路径经 relative() 归一化后也不以 "/" 开头)。 */
|
|
19
|
+
export function isAbsoluteWirePath(p) {
|
|
20
|
+
if (p === MACHINE_ROOT || p.startsWith("/"))
|
|
21
|
+
return true;
|
|
22
|
+
return IS_WIN32 && /^[A-Za-z]:([\\/]|$)/.test(p);
|
|
23
|
+
}
|
|
24
|
+
/** 去掉结尾 "/"(保留 posix 根 "/" 本身),归一成规范的 wire 形式。前端面包屑
|
|
25
|
+
* 不产生结尾斜杠,这里只对补全/直接输入做防御性清理。 */
|
|
26
|
+
export function normWirePath(p) {
|
|
27
|
+
if (p.endsWith("/") && p !== "/")
|
|
28
|
+
return p.slice(0, -1);
|
|
29
|
+
return p;
|
|
30
|
+
}
|
|
31
|
+
/** wire 绝对路径("C:/Users/x" / "/Users/x" / "C:")→ 原生绝对路径。
|
|
32
|
+
* 裸盘符根("C:")在 win32 的 resolve 里会落到「C: 上的当前目录」,必须显式转 "C:\\"。 */
|
|
33
|
+
export function wireToAbs(wire) {
|
|
34
|
+
const w = normWirePath(wire);
|
|
35
|
+
if (IS_WIN32) {
|
|
36
|
+
const m = /^([A-Za-z]):$/.exec(w);
|
|
37
|
+
if (m)
|
|
38
|
+
return `${m[1].toUpperCase()}:\\`;
|
|
39
|
+
}
|
|
40
|
+
return resolve(w);
|
|
41
|
+
}
|
|
42
|
+
/** 机器浏览模式下某目录的父级 wire 路径:盘符根("C:")→ 机器根;posix "/" 无父级。 */
|
|
43
|
+
export function absoluteParent(wire) {
|
|
44
|
+
const s = normWirePath(wire);
|
|
45
|
+
if (s === "" || s === MACHINE_ROOT)
|
|
46
|
+
return null;
|
|
47
|
+
const i = s.lastIndexOf("/");
|
|
48
|
+
if (i < 0)
|
|
49
|
+
return IS_WIN32 && /^[A-Za-z]:$/.test(s) ? MACHINE_ROOT : null;
|
|
50
|
+
if (i === 0)
|
|
51
|
+
return "/"; // posix "/a" → "/"
|
|
52
|
+
return s.slice(0, i);
|
|
53
|
+
}
|
|
13
54
|
/** 预览只读文件前 512KB。 */
|
|
14
55
|
export const MAX_PREVIEW_BYTES = 512 * 1024;
|
|
15
56
|
/** 右键上传单文件上限(内存中转 base64 → Buffer)。 */
|
|
@@ -106,9 +147,11 @@ async function readDirForUI(abs, rel) {
|
|
|
106
147
|
else {
|
|
107
148
|
type = d.isDirectory() ? "dir" : "file";
|
|
108
149
|
}
|
|
150
|
+
// 机器浏览(绝对路径)下 rel 是绝对 wire 路径;posix 根 "/" 特殊处理避免 "//name"。
|
|
151
|
+
const entryPath = rel === "" ? d.name : rel.endsWith("/") ? `${rel.slice(0, -1)}/${d.name}` : `${rel}/${d.name}`;
|
|
109
152
|
const entry = {
|
|
110
153
|
name: d.name,
|
|
111
|
-
path:
|
|
154
|
+
path: entryPath,
|
|
112
155
|
type,
|
|
113
156
|
};
|
|
114
157
|
if (type === "file")
|
|
@@ -141,10 +184,69 @@ export class FilesService {
|
|
|
141
184
|
constructor(host) {
|
|
142
185
|
this.host = host;
|
|
143
186
|
}
|
|
187
|
+
/** 机器根列目录(此电脑/盘符列表);posix 上就是根 "/"。 */
|
|
188
|
+
async machineRootEntries() {
|
|
189
|
+
const fsp = await import("node:fs/promises");
|
|
190
|
+
if (IS_WIN32) {
|
|
191
|
+
const out = [];
|
|
192
|
+
for (let c = 65; c <= 90; c++) {
|
|
193
|
+
const drive = `${String.fromCharCode(c)}:`;
|
|
194
|
+
try {
|
|
195
|
+
const st = await fsp.stat(`${drive}\\`);
|
|
196
|
+
if (st.isDirectory())
|
|
197
|
+
out.push({ name: drive, path: drive, type: "dir" });
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// 空口/未挂载盘符 —— 跳过
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
return [{ name: "/", path: "/", type: "dir" }];
|
|
206
|
+
}
|
|
144
207
|
async listFiles(relPath) {
|
|
145
208
|
const { resolve, sep, relative } = await import("node:path");
|
|
146
209
|
const root = resolve(this.host.getCwd());
|
|
147
|
-
const
|
|
210
|
+
const raw = relPath ?? "";
|
|
211
|
+
// ---- 机器根(此电脑:盘符列表)—— 工作区之上的虚拟层 ----
|
|
212
|
+
if (raw === MACHINE_ROOT || raw === MACHINE_ROOT + "/") {
|
|
213
|
+
const entries = await this.machineRootEntries();
|
|
214
|
+
this.host.emit({
|
|
215
|
+
type: "files",
|
|
216
|
+
path: MACHINE_ROOT,
|
|
217
|
+
parent: null,
|
|
218
|
+
entries,
|
|
219
|
+
truncated: false,
|
|
220
|
+
absolute: true,
|
|
221
|
+
});
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// ---- 绝对路径浏览(Windows 盘符 / posix "/"):允许越过工作区根 ----
|
|
225
|
+
// 机器模式不设 watcher(工作区递归 watch 不覆盖别的盘),文件变动靠 10s 轮询。
|
|
226
|
+
if (isAbsoluteWirePath(raw)) {
|
|
227
|
+
const wire = normWirePath(raw);
|
|
228
|
+
const abs = wireToAbs(wire);
|
|
229
|
+
const { entries, truncated, error } = await readDirForUI(abs, wire);
|
|
230
|
+
this.host.emit({
|
|
231
|
+
type: "files",
|
|
232
|
+
path: wire,
|
|
233
|
+
parent: absoluteParent(wire),
|
|
234
|
+
entries,
|
|
235
|
+
truncated,
|
|
236
|
+
absolute: true,
|
|
237
|
+
});
|
|
238
|
+
if (error) {
|
|
239
|
+
this.host.emit({
|
|
240
|
+
type: "notice",
|
|
241
|
+
level: "warning",
|
|
242
|
+
text: `目录不可读:${error}`,
|
|
243
|
+
textEn: `Directory is not readable: ${error}`,
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
// ---- 工作区相对视图(原有行为) ----
|
|
249
|
+
const target = raw ? resolve(root, raw) : root;
|
|
148
250
|
const rawRel = relative(root, target);
|
|
149
251
|
if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`)) {
|
|
150
252
|
this.host.emit({
|
|
@@ -175,7 +277,8 @@ export class FilesService {
|
|
|
175
277
|
this.host.emit({
|
|
176
278
|
type: "files",
|
|
177
279
|
path: rel === "" ? "" : rel,
|
|
178
|
-
|
|
280
|
+
// 工作区根也允许「上一级」→ 机器根(Windows 换盘符 / posix 到 /)。
|
|
281
|
+
parent: rel === "" ? MACHINE_ROOT : rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "",
|
|
179
282
|
entries,
|
|
180
283
|
truncated,
|
|
181
284
|
});
|
|
@@ -508,17 +611,28 @@ export class FilesService {
|
|
|
508
611
|
try {
|
|
509
612
|
const fs = await import("node:fs/promises");
|
|
510
613
|
const root = this.host.getCwd();
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
614
|
+
const absWire = isAbsoluteWirePath(relPath);
|
|
615
|
+
let abs;
|
|
616
|
+
let rel;
|
|
617
|
+
if (absWire) {
|
|
618
|
+
// 机器浏览:绝对路径直接读;回显用绝对 wire 形式(前端按 path 匹配)。
|
|
619
|
+
abs = wireToAbs(relPath);
|
|
620
|
+
rel = abs.split(sep).join("/");
|
|
621
|
+
}
|
|
622
|
+
else {
|
|
623
|
+
const w = workspacePath(resolve(root), relPath);
|
|
624
|
+
if (!w) {
|
|
625
|
+
this.host.emit({
|
|
626
|
+
type: "notice",
|
|
627
|
+
level: "warning",
|
|
628
|
+
text: `路径超出工作区:${relPath}`,
|
|
629
|
+
textEn: `Path is outside the workspace: ${relPath}`,
|
|
630
|
+
});
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
abs = w.abs;
|
|
634
|
+
rel = w.rel;
|
|
520
635
|
}
|
|
521
|
-
const { abs, rel } = wp;
|
|
522
636
|
const stat = await fs.stat(abs);
|
|
523
637
|
if (!stat.isFile()) {
|
|
524
638
|
this.host.emit({
|
|
@@ -600,15 +714,26 @@ export class FilesService {
|
|
|
600
714
|
async writeFile(relPath, text) {
|
|
601
715
|
try {
|
|
602
716
|
const root = this.host.getCwd();
|
|
603
|
-
const
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
717
|
+
const absWire = isAbsoluteWirePath(relPath);
|
|
718
|
+
let abs;
|
|
719
|
+
let rel;
|
|
720
|
+
if (absWire) {
|
|
721
|
+
abs = wireToAbs(relPath);
|
|
722
|
+
rel = abs.split(sep).join("/");
|
|
723
|
+
}
|
|
724
|
+
else {
|
|
725
|
+
const w = workspacePath(resolve(root), relPath);
|
|
726
|
+
if (!w) {
|
|
727
|
+
this.host.emit({
|
|
728
|
+
type: "notice",
|
|
729
|
+
level: "warning",
|
|
730
|
+
text: `路径超出工作区:${relPath}`,
|
|
731
|
+
textEn: `Path is outside the workspace: ${relPath}`,
|
|
732
|
+
});
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
abs = w.abs;
|
|
736
|
+
rel = w.rel;
|
|
612
737
|
}
|
|
613
738
|
if (Buffer.byteLength(text, "utf8") > 2 * 1024 * 1024) {
|
|
614
739
|
this.host.emit({
|
|
@@ -619,7 +744,7 @@ export class FilesService {
|
|
|
619
744
|
});
|
|
620
745
|
return;
|
|
621
746
|
}
|
|
622
|
-
const stat = statSync(
|
|
747
|
+
const stat = statSync(abs);
|
|
623
748
|
if (!stat.isFile()) {
|
|
624
749
|
this.host.emit({
|
|
625
750
|
type: "notice",
|
|
@@ -629,16 +754,16 @@ export class FilesService {
|
|
|
629
754
|
});
|
|
630
755
|
return;
|
|
631
756
|
}
|
|
632
|
-
writeFileSync(
|
|
757
|
+
writeFileSync(abs, text, "utf8");
|
|
633
758
|
this.host.emit({
|
|
634
759
|
type: "notice",
|
|
635
760
|
level: "info",
|
|
636
|
-
text: `已保存:${
|
|
637
|
-
textEn: `Saved: ${
|
|
761
|
+
text: `已保存:${rel}`,
|
|
762
|
+
textEn: `Saved: ${rel}`,
|
|
638
763
|
});
|
|
639
764
|
// Re-read through the same path as the preview request so the client
|
|
640
765
|
// gets the canonical content, line count and file size after saving.
|
|
641
|
-
await this.readFile(
|
|
766
|
+
await this.readFile(rel);
|
|
642
767
|
}
|
|
643
768
|
catch (err) {
|
|
644
769
|
this.host.emit({
|
|
@@ -660,14 +785,19 @@ export class FilesService {
|
|
|
660
785
|
const emitErr = (text, textEn) => this.host.emit({ type: "notice", level: "error", text, textEn });
|
|
661
786
|
try {
|
|
662
787
|
const root = this.host.getCwd();
|
|
788
|
+
const absDir = relDir ? isAbsoluteWirePath(relDir) : false;
|
|
663
789
|
let wp;
|
|
664
|
-
if (relDir) {
|
|
790
|
+
if (relDir && !absDir) {
|
|
665
791
|
wp = workspacePath(resolve(root), relDir);
|
|
666
792
|
if (!wp) {
|
|
667
793
|
emitErr(`路径超出工作区:${relDir}`, `Path outside workspace: ${relDir}`);
|
|
668
794
|
return;
|
|
669
795
|
}
|
|
670
796
|
}
|
|
797
|
+
else if (relDir) {
|
|
798
|
+
// 机器浏览的目录(可能是盘符根 "C:"):按绝对路径解析。
|
|
799
|
+
wp = { abs: wireToAbs(relDir), rel: wireToAbs(relDir).split(sep).join("/") };
|
|
800
|
+
}
|
|
671
801
|
else {
|
|
672
802
|
wp = { abs: root, rel: "" };
|
|
673
803
|
}
|
|
@@ -676,10 +806,18 @@ export class FilesService {
|
|
|
676
806
|
const base = name.split(/[\\/]/).pop() ?? "";
|
|
677
807
|
const safe = (base.replace(/[/:*?"<>|\x00-\x1f]/g, "_").trim() || "file").slice(0, 200);
|
|
678
808
|
const abs = resolve(wp.abs, safe);
|
|
679
|
-
|
|
680
|
-
if (
|
|
681
|
-
|
|
682
|
-
|
|
809
|
+
let uploadRel;
|
|
810
|
+
if (absDir) {
|
|
811
|
+
// 绝对目录模式不再校验工作区归属(机器浏览)。
|
|
812
|
+
uploadRel = abs.split(sep).join("/");
|
|
813
|
+
}
|
|
814
|
+
else {
|
|
815
|
+
const rawRel = relative(root, abs);
|
|
816
|
+
if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`)) {
|
|
817
|
+
emitErr(`文件名不合法:${name}`, `Invalid file name: ${name}`);
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
uploadRel = rawRel.split(sep).join("/");
|
|
683
821
|
}
|
|
684
822
|
const buf = Buffer.from(data, "base64");
|
|
685
823
|
if (buf.length === 0) {
|
|
@@ -695,8 +833,8 @@ export class FilesService {
|
|
|
695
833
|
this.host.emit({
|
|
696
834
|
type: "notice",
|
|
697
835
|
level: "info",
|
|
698
|
-
text: `已上传:${
|
|
699
|
-
textEn: `Uploaded: ${
|
|
836
|
+
text: `已上传:${uploadRel}`,
|
|
837
|
+
textEn: `Uploaded: ${uploadRel}`,
|
|
700
838
|
});
|
|
701
839
|
// Emit for the target directory itself so the panel refreshes even
|
|
702
840
|
// when the active listing/preview isn't that dir (posix watcher only
|
|
@@ -764,13 +902,67 @@ export class FilesService {
|
|
|
764
902
|
const { homedir } = await import("node:os");
|
|
765
903
|
const home = homedir();
|
|
766
904
|
const cwd = this.host.getCwd();
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
if (expanded === "") {
|
|
905
|
+
const isWin = IS_WIN32;
|
|
906
|
+
const rawInput = input.trim();
|
|
907
|
+
if (rawInput === "") {
|
|
771
908
|
empty();
|
|
772
909
|
return;
|
|
773
910
|
}
|
|
911
|
+
// ---- 机器根(此电脑/盘符列表)----
|
|
912
|
+
if (rawInput === MACHINE_ROOT || rawInput === MACHINE_ROOT + "/") {
|
|
913
|
+
this.host.emit({ type: "path_completions", completions: await this.machineRootEntries() });
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
// ---- Windows 盘符输入:"D"(补全到盘符,Tab 即换盘)/ "D:"(列盘根)----
|
|
917
|
+
if (isWin && /^[A-Za-z]:?$/.test(rawInput)) {
|
|
918
|
+
const letter = rawInput[0].toUpperCase();
|
|
919
|
+
const drive = `${letter}:`;
|
|
920
|
+
let st;
|
|
921
|
+
try {
|
|
922
|
+
st = await fs.stat(`${drive}\\`);
|
|
923
|
+
}
|
|
924
|
+
catch {
|
|
925
|
+
empty();
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (!st.isDirectory()) {
|
|
929
|
+
empty();
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
if (rawInput.length === 2) {
|
|
933
|
+
// 已带冒号:直接列出盘根条目。
|
|
934
|
+
const dirents = await fs.readdir(`${drive}\\`, { withFileTypes: true }).catch(() => null);
|
|
935
|
+
if (!dirents) {
|
|
936
|
+
empty();
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
const items = dirents
|
|
940
|
+
.filter((d) => !ignoredEntries().has(d.name))
|
|
941
|
+
.map((d) => ({
|
|
942
|
+
name: d.name,
|
|
943
|
+
path: `${drive}/${d.name}`,
|
|
944
|
+
type: (d.isDirectory() ? "dir" : "file"),
|
|
945
|
+
}))
|
|
946
|
+
.sort((a, b) => {
|
|
947
|
+
const aHidden = a.name.startsWith(".");
|
|
948
|
+
const bHidden = b.name.startsWith(".");
|
|
949
|
+
if (aHidden !== bHidden)
|
|
950
|
+
return aHidden ? 1 : -1;
|
|
951
|
+
if (a.type !== b.type)
|
|
952
|
+
return a.type === "dir" ? -1 : 1;
|
|
953
|
+
return a.name.localeCompare(b.name);
|
|
954
|
+
})
|
|
955
|
+
.slice(0, 100);
|
|
956
|
+
this.host.emit({ type: "path_completions", completions: items });
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
// 只有字母:补全到盘符本身。
|
|
960
|
+
this.host.emit({ type: "path_completions", completions: [{ name: drive, path: drive, type: "dir" }] });
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
// Expand ~ and relative inputs to an absolute path. Windows users type
|
|
964
|
+
// backslashes (P:\agent) and ~\ — handle both separator styles.
|
|
965
|
+
let expanded = rawInput;
|
|
774
966
|
if (expanded === "~" || expanded === "~\\") {
|
|
775
967
|
expanded = home;
|
|
776
968
|
}
|
package/dist/server/index.js
CHANGED
|
@@ -30,6 +30,7 @@ import { WebSocket, WebSocketServer } from "ws";
|
|
|
30
30
|
import { VERSION, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
31
31
|
import { PROTOCOL_VERSION } from "./protocol-version.js";
|
|
32
32
|
import { AgentService, workspacePath, QuiesceRejectedError } from "./agent-service.js";
|
|
33
|
+
import { isAbsoluteWirePath, wireToAbs } from "./files-service.js";
|
|
33
34
|
import { previewKind } from "./text-sniff.js";
|
|
34
35
|
import { startControlServer } from "./control-socket.js";
|
|
35
36
|
import { scheduleUploadCleanup } from "./uploads.js";
|
|
@@ -118,21 +119,47 @@ function requestTokens(req) {
|
|
|
118
119
|
function tokenOk(req) {
|
|
119
120
|
return requestTokens(req).includes(AUTH_TOKEN);
|
|
120
121
|
}
|
|
122
|
+
/** 请求携带的 pi_web_token cookie 值(未带/损坏时为空串)。 */
|
|
123
|
+
function cookieToken(req) {
|
|
124
|
+
const cookie = req.headers.cookie;
|
|
125
|
+
if (typeof cookie !== "string")
|
|
126
|
+
return "";
|
|
127
|
+
for (const part of cookie.split(";")) {
|
|
128
|
+
const [k, ...rest] = part.trim().split("=");
|
|
129
|
+
if (k === "pi_web_token")
|
|
130
|
+
return rest.join("=").trim();
|
|
131
|
+
}
|
|
132
|
+
return "";
|
|
133
|
+
}
|
|
121
134
|
if (AUTH_TOKEN) {
|
|
122
135
|
// /api/health 保持开放:无敏感信息,容器/监控探针需要它。
|
|
123
|
-
// 但绝不能因命中 /api/health 就反射下发真实 token cookie
|
|
136
|
+
// 但绝不能因命中 /api/health 就反射下发真实 token cookie(安全漏洞:issue #45)。
|
|
124
137
|
app.use((req, res, next) => {
|
|
125
138
|
const ok = tokenOk(req);
|
|
126
|
-
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
139
|
+
const cookie = cookieToken(req);
|
|
140
|
+
// 浏览器经 ?token= 首次进入后下发 HttpOnly cookie,后续导航/资源请求免带参数。
|
|
141
|
+
// 重要:只要请求携带着有效 token(query/header/cookie 任一匹配)就把 cookie 刷新为
|
|
142
|
+
// 当前 AUTH_TOKEN——服务端重启改了 PI_WEB_TOKEN 后,旧 cookie 经一次正确的
|
|
143
|
+
// ?token= 进入即被重新同步,无需用户清缓存(issue #71)。
|
|
144
|
+
if (ok) {
|
|
145
|
+
if (cookie !== encodeURIComponent(AUTH_TOKEN)) {
|
|
146
|
+
res.setHeader("Set-Cookie", `pi_web_token=${encodeURIComponent(AUTH_TOKEN)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else if (cookie) {
|
|
150
|
+
// 请求带的 cookie 已是失效旧值(服务端口令已更换)——立即让其过期,
|
|
151
|
+
// 避免浏览器被残留 cookie 卡死一年(本来也不该再信任它鉴权)。
|
|
152
|
+
res.setHeader("Set-Cookie", "pi_web_token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0");
|
|
130
153
|
}
|
|
131
154
|
if (req.path === "/api/health" || ok) {
|
|
132
155
|
next();
|
|
133
156
|
return;
|
|
134
157
|
}
|
|
135
|
-
res
|
|
158
|
+
res
|
|
159
|
+
.status(401)
|
|
160
|
+
.send(cookie
|
|
161
|
+
? "unauthorized: PI_WEB_TOKEN required — 服务端口令已变更?已清除旧 token cookie,请用当前 ?token= 重新进入"
|
|
162
|
+
: "unauthorized: PI_WEB_TOKEN required (?token=…)");
|
|
136
163
|
});
|
|
137
164
|
}
|
|
138
165
|
/** 引擎选择:PI_WEB_ENGINE=pi|dsh(默认 pi)。重启生效。 */
|
|
@@ -160,12 +187,20 @@ app.get("/api/file", async (req, res) => {
|
|
|
160
187
|
// back to the server cwd for requests without a known client.
|
|
161
188
|
const cid = typeof req.query.clientId === "string" ? req.query.clientId : "";
|
|
162
189
|
const cs = cid ? service.get(cid) : undefined;
|
|
163
|
-
const
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
190
|
+
const root = cs?.cwd ?? CWD;
|
|
191
|
+
const absWire = isAbsoluteWirePath(raw);
|
|
192
|
+
let abs;
|
|
193
|
+
if (absWire) {
|
|
194
|
+
abs = wireToAbs(raw);
|
|
195
|
+
}
|
|
196
|
+
else {
|
|
197
|
+
const wp = workspacePath(root, raw);
|
|
198
|
+
if (!wp) {
|
|
199
|
+
res.status(400).end("path outside workspace");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
abs = wp.abs;
|
|
167
203
|
}
|
|
168
|
-
const abs = wp.abs;
|
|
169
204
|
const name = basename(abs);
|
|
170
205
|
const kind = previewKind(name);
|
|
171
206
|
const isDownload = req.query.download === "1";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-web-ui",
|
|
3
|
-
"version": "0.64.
|
|
3
|
+
"version": "0.64.2",
|
|
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
|
"author": {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import{a as l,j as n}from"./markdown-DRBrS2Nf.js";import{u as O,b as M,T as z,a as W,F as Y,c as H,d as Z,e as q,f as ee,g as ne,h as te,i as se,r as ae}from"./index-
|
|
1
|
+
import{a as l,j as n}from"./markdown-DRBrS2Nf.js";import{u as O,b as M,T as z,a as W,F as Y,c as H,d as Z,e as q,f as ee,g as ne,h as te,i as se,r as ae}from"./index-BxuvIAJQ.js";import{D as re,o as ie}from"./xterm-D1D2FVe3.js";import"./react-C9ovnpIm.js";function le(t){let c=null;return{clean:t.replace(/\r?\n?\[pi-term-exit:(-?\d+)\]\r?\n?/g,(a,h)=>(c=Number(h),`\r
|
|
2
2
|
`)).replace(/\r?\n?\x1b\[90m\[(?:进程已退出,退出码 |Process exited with code )-?\d+\]\x1b\[0m\r?\n?/g,`\r
|
|
3
3
|
`),exitCode:c}}function ce({conversationId:t,terminalId:c,command:f,cwd:a,title:h,active:u,running:b,exitCode:y,send:j,register:T}){const k=l.useRef(null),N=l.useRef(null),{locale:o}=O(),w=l.useRef(o);w.current=o;const F=f?JSON.stringify(f):"";l.useEffect(()=>{const m=k.current;if(!m)return;const r=new re({theme:M(),fontFamily:'"SF Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace',fontSize:13,cursorBlink:!0,scrollback:8e3}),g=new ie;r.loadAddon(g),r.open(m),N.current={term:r,fit:g},u&&r.focus();const C=()=>{r.options.theme=M()};window.addEventListener(z,C),r.attachCustomKeyEventHandler(d=>{var A;if(d.type!=="keydown")return!0;const D=(A=d.key)==null?void 0:A.toLowerCase();if((d.ctrlKey||d.metaKey)&&D==="v")return!1;if(d.ctrlKey&&!d.shiftKey&&!d.altKey&&D==="c"&&r.hasSelection()){const E=r.textarea;return E&&(E.value=r.getSelection(),E.select()),!1}return!0});const _=T(t,c,{write:d=>r.write(le(d).clean),dispose:()=>r.dispose()}),$=()=>{try{g.fit(),j({type:"terminal_resize",terminalId:c,conversationId:t,cols:r.cols,rows:r.rows})}catch{}},B=requestAnimationFrame(()=>{try{g.fit()}catch{}j(f?{type:"run_command",terminalId:c,conversationId:t,command:f,cols:r.cols,rows:r.rows}:{type:"terminal_create",terminalId:c,title:h,locale:w.current,conversationId:t,cwd:a,cols:r.cols,rows:r.rows})}),S=r.onData(d=>{j({type:"terminal_input",terminalId:c,conversationId:t,data:d})});let v=null;return typeof ResizeObserver<"u"&&(v=new ResizeObserver(()=>{m.offsetWidth>0&&m.offsetHeight>0&&$()}),v.observe(m)),()=>{cancelAnimationFrame(B),S.dispose(),window.removeEventListener(z,C),v==null||v.disconnect(),_(),r.dispose(),N.current=null}},[t,c,F,j,T]),l.useEffect(()=>{if(!u)return;const m=requestAnimationFrame(()=>{const r=N.current;if(r){try{r.fit.fit(),j({type:"terminal_resize",terminalId:c,conversationId:t,cols:r.term.cols,rows:r.term.rows})}catch{}r.term.focus()}});return()=>cancelAnimationFrame(m)},[u]);const{t:R}=O(),x=l.useRef(void 0);return l.useEffect(()=>{if(b===x.current||(x.current=b,b!==!1))return;const m=N.current;m&&m.term.write(`\r
|
|
4
4
|
\x1B[90m${R("exitBanner",{code:y??""})}\x1B[0m\r
|