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.
Files changed (45) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +504 -504
  3. package/README.zh-CN.md +427 -427
  4. package/bin/pi-web-ui.mjs +1809 -1809
  5. package/deploy/com.xingshuyin.pi-web-ui.plist +48 -48
  6. package/deploy/nginx-subpath.conf +88 -88
  7. package/deploy/pi-web-ui-task.xml +54 -54
  8. package/deploy/pi-web-ui.service +31 -31
  9. package/dist/server/agent-service.js +45 -22
  10. package/dist/server/attachments.js +16 -11
  11. package/dist/server/dsh/dsh-agent-service.js +27 -7
  12. package/dist/server/dsh/runtime/cordis.yml +1 -1
  13. package/dist/server/dsh/runtime/goal-rpc.mjs +645 -645
  14. package/dist/server/dsh/runtime/launcher.mjs +164 -164
  15. package/dist/server/dsh/runtime/override.patch.yml +71 -71
  16. package/dist/server/dsh/runtime/runtime-root.mjs +86 -86
  17. package/dist/server/files-service.js +235 -43
  18. package/dist/server/goal-service.js +4 -1
  19. package/dist/server/index.js +48 -13
  20. package/dist/server/marker-service.js +8 -3
  21. package/dist/server/markers/builtins/rename.js +3 -3
  22. package/dist/server/model-admin.js +12 -6
  23. package/dist/server/plugins.js +4 -4
  24. package/dist/server/slash-commands.js +3 -0
  25. package/dist/server/terminals.js +25 -15
  26. package/dist/server/vision-bridge.js +9 -9
  27. package/dist/server/webui-context.js +2 -2
  28. package/extensions/webui.ts +190 -190
  29. package/package.json +109 -109
  30. package/themes/cyberpunk.css +81 -81
  31. package/themes/dazzle.css +81 -81
  32. package/themes/md-preview.css +98 -98
  33. package/themes/white.css +160 -160
  34. package/web/dist/assets/TerminalPanel-BXISCcrQ.js +6 -0
  35. package/web/dist/assets/index-BxuvIAJQ.js +326 -0
  36. package/web/dist/assets/{index-CMgDryBL.css → index-DVnuQrK5.css} +1 -1
  37. package/web/dist/favicon.svg +8 -8
  38. package/web/dist/index.html +21 -21
  39. package/web/dist/manifest.webmanifest +50 -50
  40. package/web/dist/sw.js +126 -126
  41. package/web/public/favicon.svg +8 -8
  42. package/web/public/manifest.webmanifest +50 -50
  43. package/web/public/sw.js +126 -126
  44. package/web/dist/assets/TerminalPanel-DNkAT34Z.js +0 -2
  45. 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: rel === "" ? d.name : `${rel}/${d.name}`,
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 target = relPath ? resolve(root, relPath) : root;
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
- parent: rel === "" ? null : rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "",
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 wp = workspacePath(resolve(root), relPath);
512
- if (!wp) {
513
- this.host.emit({
514
- type: "notice",
515
- level: "warning",
516
- text: `路径超出工作区:${relPath}`,
517
- textEn: `Path is outside the workspace: ${relPath}`,
518
- });
519
- return;
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 wp = workspacePath(resolve(root), relPath);
604
- if (!wp) {
605
- this.host.emit({
606
- type: "notice",
607
- level: "warning",
608
- text: `路径超出工作区:${relPath}`,
609
- textEn: `Path is outside the workspace: ${relPath}`,
610
- });
611
- return;
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(wp.abs);
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(wp.abs, text, "utf8");
757
+ writeFileSync(abs, text, "utf8");
633
758
  this.host.emit({
634
759
  type: "notice",
635
760
  level: "info",
636
- text: `已保存:${wp.rel}`,
637
- textEn: `Saved: ${wp.rel}`,
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(wp.rel);
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
- const rawRel = relative(root, abs);
680
- if (rawRel.startsWith("..") || rawRel.includes(`${sep}..`)) {
681
- emitErr(`文件名不合法:${name}`);
682
- return;
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: `已上传:${rawRel.split(sep).join("/")}`,
699
- textEn: `Uploaded: ${rawRel.split(sep).join("/")}`,
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
- // Expand ~ and relative inputs to an absolute path. Windows users type
768
- // backslashes (P:\agent) and ~\ — handle both separator styles.
769
- let expanded = input.trim();
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
  }
@@ -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
- // 浏览器经 ?token= 首次进入后下发 HttpOnly cookie,后续导航/资源请求免带参数;
127
- // 只有请求确实携带着有效 token 时才下发——匿名命中 /api/health 不触发。
128
- if (ok && !req.headers.cookie?.includes("pi_web_token=")) {
129
- res.setHeader("Set-Cookie", `pi_web_token=${encodeURIComponent(AUTH_TOKEN)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000`);
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.status(401).send("unauthorized: PI_WEB_TOKEN required (?token=…)");
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 wp = workspacePath(cs?.cwd ?? CWD, raw);
164
- if (!wp) {
165
- res.status(400).end("path outside workspace");
166
- return;
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({ type: "notice", level: "warning", text: `[${token.tool}] ${result.error}` });
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
  }
@@ -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
  }