pi-web-ui 0.64.0 → 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/LICENSE +21 -21
- package/README.md +504 -504
- package/README.zh-CN.md +427 -427
- package/bin/pi-web-ui.mjs +1809 -1809
- package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
- package/deploy/nginx-subpath.conf +88 -88
- package/deploy/pi-web-ui-task.xml +54 -54
- package/deploy/pi-web-ui.service +31 -31
- package/dist/server/agent-service.js +45 -22
- package/dist/server/attachments.js +16 -11
- package/dist/server/dsh/dsh-agent-service.js +27 -7
- package/dist/server/dsh/runtime/cordis.yml +1 -1
- package/dist/server/dsh/runtime/goal-rpc.mjs +645 -645
- package/dist/server/dsh/runtime/launcher.mjs +164 -164
- package/dist/server/dsh/runtime/override.patch.yml +71 -71
- package/dist/server/dsh/runtime/runtime-root.mjs +86 -86
- package/dist/server/files-service.js +235 -43
- package/dist/server/goal-service.js +4 -1
- package/dist/server/index.js +48 -13
- package/dist/server/marker-service.js +8 -3
- package/dist/server/markers/builtins/rename.js +3 -3
- package/dist/server/model-admin.js +12 -6
- package/dist/server/plugins.js +4 -4
- package/dist/server/slash-commands.js +3 -0
- package/dist/server/terminals.js +25 -15
- package/dist/server/vision-bridge.js +9 -9
- package/dist/server/webui-context.js +2 -2
- package/extensions/webui.ts +190 -190
- package/package.json +109 -109
- package/themes/cyberpunk.css +81 -81
- package/themes/dazzle.css +81 -81
- package/themes/md-preview.css +98 -98
- package/themes/white.css +160 -160
- package/web/dist/assets/TerminalPanel-BXISCcrQ.js +6 -0
- package/web/dist/assets/index-BxuvIAJQ.js +326 -0
- package/web/dist/assets/{index-CMgDryBL.css → index-DVnuQrK5.css} +1 -1
- package/web/dist/favicon.svg +8 -8
- package/web/dist/index.html +21 -21
- package/web/dist/manifest.webmanifest +50 -50
- package/web/dist/sw.js +126 -126
- package/web/public/favicon.svg +8 -8
- package/web/public/manifest.webmanifest +50 -50
- package/web/public/sw.js +126 -126
- package/web/dist/assets/TerminalPanel-DNkAT34Z.js +0 -2
- package/web/dist/assets/index-_1vyEugI.js +0 -326
|
@@ -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({
|
|
@@ -657,17 +782,22 @@ export class FilesService {
|
|
|
657
782
|
* for the target dir (the recursive watcher may not cover it on posix).
|
|
658
783
|
*/
|
|
659
784
|
async uploadFile(relDir, name, data) {
|
|
660
|
-
const emitErr = (text) => this.host.emit({ type: "notice", level: "error", text });
|
|
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
|
-
emitErr(`路径超出工作区:${relDir}`);
|
|
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,18 +806,26 @@ 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) {
|
|
686
|
-
emitErr(`空文件:${name}`);
|
|
824
|
+
emitErr(`空文件:${name}`, `Empty file: ${name}`);
|
|
687
825
|
return;
|
|
688
826
|
}
|
|
689
827
|
if (buf.length > MAX_UPLOAD_BYTES) {
|
|
690
|
-
emitErr(`文件过大:${name}(上限 ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB
|
|
828
|
+
emitErr(`文件过大:${name}(上限 ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB)`, `File too large: ${name} (max ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)}MB)`);
|
|
691
829
|
return;
|
|
692
830
|
}
|
|
693
831
|
mkdirSync(wp.abs, { recursive: true });
|
|
@@ -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
|
|
@@ -707,7 +845,7 @@ export class FilesService {
|
|
|
707
845
|
});
|
|
708
846
|
}
|
|
709
847
|
catch (err) {
|
|
710
|
-
emitErr(`上传文件失败:${err.message}`);
|
|
848
|
+
emitErr(`上传文件失败:${err.message}`, `Upload failed: ${err.message}`);
|
|
711
849
|
}
|
|
712
850
|
}
|
|
713
851
|
/**
|
|
@@ -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
|
}
|
|
@@ -611,7 +611,10 @@ export class GoalService {
|
|
|
611
611
|
g.status = "已手动停止,目标审查已中止";
|
|
612
612
|
g.statusEn = "Stopped manually, goal review aborted";
|
|
613
613
|
this.emitGoalStatus();
|
|
614
|
-
return
|
|
614
|
+
return {
|
|
615
|
+
text: "⏹ 已手动停止,目标审查已中止(想继续可重新设定目标)",
|
|
616
|
+
textEn: "⏹ Stopped manually, goal review aborted (set a new goal to continue)",
|
|
617
|
+
};
|
|
615
618
|
}
|
|
616
619
|
return null;
|
|
617
620
|
}
|
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";
|
|
@@ -803,10 +838,10 @@ wss.on("connection", (ws) => {
|
|
|
803
838
|
case "plugin_settings": {
|
|
804
839
|
const r = pluginMgr.savePluginSettings(msg.pluginId, msg.values ?? {});
|
|
805
840
|
if (r.error) {
|
|
806
|
-
cs?.emitNotice("error", `插件设置保存失败:${r.error}`);
|
|
841
|
+
cs?.emitNotice("error", `插件设置保存失败:${r.error}`, `Failed to save plugin settings: ${r.error}`);
|
|
807
842
|
}
|
|
808
843
|
else {
|
|
809
|
-
cs?.emitNotice("info", "插件设置已保存");
|
|
844
|
+
cs?.emitNotice("info", "插件设置已保存", "Plugin settings saved");
|
|
810
845
|
}
|
|
811
846
|
break;
|
|
812
847
|
}
|
|
@@ -155,8 +155,8 @@ export class MarkerService {
|
|
|
155
155
|
const state = getOrInit(token.tool);
|
|
156
156
|
const ctx = {
|
|
157
157
|
conversationId,
|
|
158
|
-
notify: (msg, level) => {
|
|
159
|
-
this.host.emit({ type: "notice", level: level ?? "info", text: msg });
|
|
158
|
+
notify: (msg, level, msgEn) => {
|
|
159
|
+
this.host.emit({ type: "notice", level: level ?? "info", text: msg, textEn: msgEn });
|
|
160
160
|
},
|
|
161
161
|
renameConversation: (title) => {
|
|
162
162
|
this.host.renameConversation(conversationId, title);
|
|
@@ -183,7 +183,12 @@ export class MarkerService {
|
|
|
183
183
|
dirty.add(token.tool);
|
|
184
184
|
}
|
|
185
185
|
else if (result.error) {
|
|
186
|
-
this.host.emit({
|
|
186
|
+
this.host.emit({
|
|
187
|
+
type: "notice",
|
|
188
|
+
level: "warning",
|
|
189
|
+
text: `[${token.tool}] ${result.error}`,
|
|
190
|
+
textEn: `[${token.tool}] ${result.error}`,
|
|
191
|
+
});
|
|
187
192
|
}
|
|
188
193
|
}
|
|
189
194
|
for (const ns of dirty) {
|
|
@@ -43,7 +43,7 @@ export const renameMarker = {
|
|
|
43
43
|
return { applied: false, error: "当前环境不支持重命名" };
|
|
44
44
|
try {
|
|
45
45
|
ctx.renameConversation(title);
|
|
46
|
-
ctx.notify(`已重命名为:${title}`, "info");
|
|
46
|
+
ctx.notify(`已重命名为:${title}`, "info", `Renamed to: ${title}`);
|
|
47
47
|
return { applied: true, feedback: `renamed to "${title}"` };
|
|
48
48
|
}
|
|
49
49
|
catch (e) {
|
|
@@ -69,7 +69,7 @@ export const renameAliasMarker = {
|
|
|
69
69
|
return { applied: false, error: "当前环境不支持重命名" };
|
|
70
70
|
try {
|
|
71
71
|
ctx.renameConversation(trimmed);
|
|
72
|
-
ctx.notify(`已重命名为:${trimmed}`, "info");
|
|
72
|
+
ctx.notify(`已重命名为:${trimmed}`, "info", `Renamed to: ${trimmed}`);
|
|
73
73
|
return { applied: true, feedback: `renamed to "${trimmed}"` };
|
|
74
74
|
}
|
|
75
75
|
catch (e) {
|
|
@@ -92,7 +92,7 @@ export const titleAliasMarker = {
|
|
|
92
92
|
if (!ctx.renameConversation)
|
|
93
93
|
return { applied: false, error: "当前环境不支持重命名" };
|
|
94
94
|
ctx.renameConversation(title.slice(0, 80));
|
|
95
|
-
ctx.notify(`已重命名为:${title.slice(0, 80)}`, "info");
|
|
95
|
+
ctx.notify(`已重命名为:${title.slice(0, 80)}`, "info", `Renamed to: ${title.slice(0, 80)}`);
|
|
96
96
|
return { applied: true, feedback: `renamed` };
|
|
97
97
|
},
|
|
98
98
|
overlay: undefined,
|
|
@@ -575,19 +575,19 @@ export class ModelAdminService {
|
|
|
575
575
|
*/
|
|
576
576
|
async cloneProvider(providerId, reqId) {
|
|
577
577
|
const pid = providerId.trim();
|
|
578
|
-
const fail = (error) => {
|
|
579
|
-
this.host.emit({ type: "notice", level: "error", text: error });
|
|
578
|
+
const fail = (error, errorEn) => {
|
|
579
|
+
this.host.emit({ type: "notice", level: "error", text: error, textEn: errorEn });
|
|
580
580
|
this.host.emit({ type: "clone_provider_result", reqId, ok: false, error });
|
|
581
581
|
};
|
|
582
582
|
try {
|
|
583
583
|
if (!pid) {
|
|
584
|
-
fail("请填写服务商 ID");
|
|
584
|
+
fail("请填写服务商 ID", "Enter a provider ID");
|
|
585
585
|
return;
|
|
586
586
|
}
|
|
587
587
|
const mr = this.host.modelRuntime();
|
|
588
588
|
const p = mr.getProvider(pid);
|
|
589
589
|
if (!p) {
|
|
590
|
-
fail(`供应商 ${pid}
|
|
590
|
+
fail(`供应商 ${pid} 不存在`, `Provider ${pid} does not exist`);
|
|
591
591
|
return;
|
|
592
592
|
}
|
|
593
593
|
const noBaseUrl = !p.baseUrl;
|
|
@@ -617,7 +617,7 @@ export class ModelAdminService {
|
|
|
617
617
|
models = readModels();
|
|
618
618
|
}
|
|
619
619
|
if (models.length === 0) {
|
|
620
|
-
fail(`${pid}
|
|
620
|
+
fail(`${pid} 的模型列表为空,无法复制(请稍后重试)`, `Model list for ${pid} is empty, cannot clone (retry later)`);
|
|
621
621
|
return;
|
|
622
622
|
}
|
|
623
623
|
// 供应商级 api 取占比最高,模型保留全量去重(避免 muse-spark 被过滤)
|
|
@@ -652,11 +652,14 @@ export class ModelAdminService {
|
|
|
652
652
|
text: noBaseUrl
|
|
653
653
|
? `📋 已复制 ${pid} → ${newId}(${kept.length} 个模型),该供应商无远程 baseUrl,已生成模板请手动填写 baseUrl 和新的 API 密钥后保存`
|
|
654
654
|
: `📋 已复制 ${pid} → ${newId}(${kept.length} 个模型),请填入新的 API 密钥后保存`,
|
|
655
|
+
textEn: noBaseUrl
|
|
656
|
+
? `📋 Cloned ${pid} → ${newId} (${kept.length} models); this provider has no remote baseUrl — template generated, fill in baseUrl and a new API key, then save`
|
|
657
|
+
: `📋 Cloned ${pid} → ${newId} (${kept.length} models); fill in the new API key, then save`,
|
|
655
658
|
});
|
|
656
659
|
this.host.emit({ type: "clone_provider_result", reqId, ok: true, config, configs: [config] });
|
|
657
660
|
}
|
|
658
661
|
catch (err) {
|
|
659
|
-
fail(`复制服务商失败:${err.message}`);
|
|
662
|
+
fail(`复制服务商失败:${err.message}`, `Failed to clone provider: ${err.message}`);
|
|
660
663
|
}
|
|
661
664
|
this.host.flushSnapshot();
|
|
662
665
|
}
|
|
@@ -1015,6 +1018,9 @@ export class ModelAdminService {
|
|
|
1015
1018
|
text: added > 0
|
|
1016
1019
|
? `🔄 已刷新 ${pid}:新增 ${added} 个模型,共 ${merged.length} 个`
|
|
1017
1020
|
: `🔄 已刷新 ${pid}:无新增模型(共 ${merged.length} 个)`,
|
|
1021
|
+
textEn: added > 0
|
|
1022
|
+
? `🔄 Refreshed ${pid}: ${added} new models, ${merged.length} total`
|
|
1023
|
+
: `🔄 Refreshed ${pid}: no new models (${merged.length} total)`,
|
|
1018
1024
|
});
|
|
1019
1025
|
return done(true, { added, total: merged.length });
|
|
1020
1026
|
}
|
package/dist/server/plugins.js
CHANGED
|
@@ -308,7 +308,7 @@ export class PluginManager {
|
|
|
308
308
|
if (prev === key)
|
|
309
309
|
return; // 同版本能力清单,不再打扰
|
|
310
310
|
const list = perms.length ? perms.join(", ") : "无";
|
|
311
|
-
this.notifyAll(perms.length ? "warning" : "info", `插件「${info.name}」已激活(${prev ? "能力清单变更" : "首次安装"};声明能力:${list}
|
|
311
|
+
this.notifyAll(perms.length ? "warning" : "info", `插件「${info.name}」已激活(${prev ? "能力清单变更" : "首次安装"};声明能力:${list})——请确认来源可信`, `Plugin "${info.name}" activated (${prev ? "capability list changed" : "first install"}; declared: ${list}) — verify the source is trusted`);
|
|
312
312
|
writeFileSync(markerFile, JSON.stringify({ v: 1, key, perms }), "utf8");
|
|
313
313
|
}
|
|
314
314
|
catch (err) {
|
|
@@ -344,8 +344,8 @@ export class PluginManager {
|
|
|
344
344
|
this.deliverAll({ type: "plugin_data", pluginId, payload });
|
|
345
345
|
}
|
|
346
346
|
/** 系统通知:发给所有 socket(复用 notice 消息,前端 toast 展示)。 */
|
|
347
|
-
notifyAll(level, text) {
|
|
348
|
-
this.deliverAll({ type: "notice", level, text });
|
|
347
|
+
notifyAll(level, text, textEn) {
|
|
348
|
+
this.deliverAll({ type: "notice", level, text, textEn });
|
|
349
349
|
}
|
|
350
350
|
/** 给指定客户端定向发一条插件消息;找不到该 socket 时静默忽略。 */
|
|
351
351
|
sendTo(clientId, pluginId, payload) {
|
|
@@ -708,7 +708,7 @@ export class PluginManager {
|
|
|
708
708
|
const self = this; // 对象字面量 getter 里不能用插件宿主的 this (oxlint no-this-alias: 誤報, getter closure 需要 host)
|
|
709
709
|
const host = {
|
|
710
710
|
broadcast: (payload) => this.broadcast(info.id, payload),
|
|
711
|
-
notify: (level, text) => this.notifyAll(level, text),
|
|
711
|
+
notify: (level, text, textEn) => this.notifyAll(level, text, textEn),
|
|
712
712
|
sendTo: (clientId, payload) => this.sendTo(clientId, info.id, payload),
|
|
713
713
|
onMessage: (h) => {
|
|
714
714
|
handlers.add(h);
|
|
@@ -174,6 +174,9 @@ export class SlashCommandsService {
|
|
|
174
174
|
text: current
|
|
175
175
|
? `当前模型:${current.name}(${current.provider}/${current.id})。用法:/model <名称>`
|
|
176
176
|
: `用法:/model <名称>`,
|
|
177
|
+
textEn: current
|
|
178
|
+
? `Current model: ${current.name} (${current.provider}/${current.id}). Usage: /model <name>`
|
|
179
|
+
: `Usage: /model <name>`,
|
|
177
180
|
});
|
|
178
181
|
return true;
|
|
179
182
|
}
|