@rexvane/piweb 0.3.2 → 0.3.4

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/README.md CHANGED
@@ -39,27 +39,21 @@ npm install -g @rexvane/piweb
39
39
  piweb
40
40
  ```
41
41
 
42
- A global install prepares the production bundle once (during install, or on the first run if the script was skipped) and then starts in production mode. If that build fails, `piweb` reports it instead of falling back to a development server that cannot work without dev dependencies; retry with `npm rebuild -g @rexvane/piweb`. The package registers both `pi` and `piweb` (`pi web` starts the UI, any other argument is forwarded to the official pi CLI); if the official `pi` package is also installed globally, the two compete for the same `pi` command and the most recent install wins.
42
+ A global install prepares the production bundle once (during install, or on the first run if the script was skipped) and then starts in production mode. If that build fails, `piweb` reports it instead of falling back to a development server that cannot work without dev dependencies; retry with `npm rebuild -g @rexvane/piweb`. The package registers only the `piweb` command, so it never conflicts with the official `pi` CLI (`npm i -g @earendil-works/pi-coding-agent`) if you have both.
43
43
 
44
44
  ### Inside this repository
45
45
 
46
- You can launch PiWeb directly from your terminal using `pi web` (case-insensitive: `pi web`, `PI WEB`, `Pi Web`):
47
-
48
46
  ```bash
49
- # Link commands globally (one-time setup)
50
- npm link
51
-
52
- # Launch anywhere in your terminal (case-insensitive)
53
- pi web
47
+ npm install
48
+ npm run dev # development server with hot reload
54
49
  ```
55
50
 
56
- You can also run directly with npm:
51
+ Or run a production build locally:
57
52
 
58
53
  ```bash
59
- # Start PiWeb (defaults to http://127.0.0.1:30141 and auto-opens browser)
60
- npm run web
61
- # or
62
- npm start
54
+ npm install
55
+ npm run build # or: npm run build:release (staged, does not touch a running build)
56
+ npm start # serves on http://127.0.0.1:30141 (auto-increments if busy)
63
57
  ```
64
58
 
65
59
  ### CLI Options (`bin/piweb.js`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rexvane/piweb",
3
- "version": "0.3.2",
3
+ "version": "0.3.4",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Modern web UI for the pi coding agent, driven by the official pi SDK without modifying pi",
@@ -21,7 +21,6 @@
21
21
  "cli"
22
22
  ],
23
23
  "bin": {
24
- "pi": "bin/pi.js",
25
24
  "piweb": "bin/piweb.js"
26
25
  },
27
26
  "files": [
@@ -54,8 +54,9 @@ export function assertInstallation(root) {
54
54
  const realRoot = fs.realpathSync(root);
55
55
  const manifestPath = assertLocalPath(realRoot, "package.json");
56
56
  const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
57
- // Accept the historical name so installations made before the npm rename keep working.
58
- if (manifest?.name !== "piweb" && manifest?.name !== "pi-web") throw new Error(`refusing to update outside the PiWeb installation: ${realRoot}`);
57
+ // Accept the historical names so installations made before the npm rename keep working.
58
+ const validNames = ["piweb", "pi-web", "@rexvane/piweb"];
59
+ if (!validNames.includes(manifest?.name)) throw new Error(`refusing to update outside the PiWeb installation: ${realRoot}`);
59
60
  return realRoot;
60
61
  }
61
62
 
@@ -72,6 +72,7 @@ export async function POST(req: Request) {
72
72
  revision?: string;
73
73
  text?: string;
74
74
  force?: boolean;
75
+ allowPrivate?: boolean;
75
76
  };
76
77
  if (body.action === "setKey") {
77
78
  if (!body.providerId || !body.apiKey) return NextResponse.json({ success: false, error: "missing fields" }, { status: 400 });
@@ -93,7 +94,7 @@ export async function POST(req: Request) {
93
94
  }
94
95
  if (body.action === "discoverModels") {
95
96
  if (!body.baseUrl) return NextResponse.json({ success: false, error: "missing baseUrl" }, { status: 400 });
96
- const models = await discoverModels({ baseUrl: body.baseUrl, api: body.api, apiKey: body.apiKey, providerId: body.providerId });
97
+ const models = await discoverModels({ baseUrl: body.baseUrl, api: body.api, apiKey: body.apiKey, providerId: body.providerId, allowPrivate: body.allowPrivate === true });
97
98
  return NextResponse.json({ success: true, data: { models } });
98
99
  }
99
100
  if (body.action === "providerUsage") {
@@ -1,5 +1,5 @@
1
1
  import { NextResponse } from "next/server";
2
- import { listSkills, readSkillFile, setSkillDisabled } from "@/lib/skills-service";
2
+ import { deleteSkill, listSkills, readSkillFile, setSkillDisabled } from "@/lib/skills-service";
3
3
  import { BoundaryError, resolveWorkspacePath } from "@/lib/path-security";
4
4
 
5
5
  export const dynamic = "force-dynamic";
@@ -17,7 +17,7 @@ export async function GET(req: Request) {
17
17
 
18
18
  export async function POST(req: Request) {
19
19
  try {
20
- const body = (await req.json()) as { action: "toggle" | "read"; filePath?: string; disabled?: boolean; cwd?: string };
20
+ const body = (await req.json()) as { action: "toggle" | "read" | "delete"; filePath?: string; disabled?: boolean; cwd?: string };
21
21
  const cwd = await resolveWorkspacePath(body.cwd || process.cwd());
22
22
  if (body.action === "toggle") {
23
23
  if (!body.filePath || typeof body.disabled !== "boolean")
@@ -29,6 +29,11 @@ export async function POST(req: Request) {
29
29
  if (!body.filePath) return NextResponse.json({ success: false, error: "missing filePath" }, { status: 400 });
30
30
  return NextResponse.json({ success: true, data: { content: await readSkillFile(body.filePath, cwd) } });
31
31
  }
32
+ if (body.action === "delete") {
33
+ if (!body.filePath) return NextResponse.json({ success: false, error: "missing filePath" }, { status: 400 });
34
+ const r = await deleteSkill(body.filePath, cwd);
35
+ return NextResponse.json({ success: true, data: r });
36
+ }
32
37
  return NextResponse.json({ success: false, error: "unknown action" }, { status: 400 });
33
38
  } catch (error) {
34
39
  return NextResponse.json(
@@ -81,6 +81,7 @@ export function AppShell() {
81
81
  setGroupBy,
82
82
  setOrderBy,
83
83
  addWorkspaceByPicker,
84
+ addWorkspaceByPath,
84
85
  removeWorkspace,
85
86
  refreshModels,
86
87
  sendCommand,
@@ -201,11 +202,17 @@ export function AppShell() {
201
202
  }, [currentPath]);
202
203
 
203
204
  // 已知工作区列表(给 Hero 建议);启动不预选任何工作区,
204
- // 未选择时输入框仍可用,发送会提示先选择工作区
205
- const knownCwds = useMemo(
206
- () => Array.from(new Set([...addedWorkspaces, ...sessions.map((s) => s.cwd)])).filter(Boolean),
207
- [addedWorkspaces, sessions],
208
- );
205
+ // 未选择时输入框仍可用,发送会提示先选择工作区。
206
+ // 已删除工作区要过滤(与会话栏分组同口径):否则它下面残留的会话 cwd
207
+ // 会把刚删掉的工作区重新喂回顶部下拉。
208
+ const knownCwds = useMemo(() => {
209
+ const removed = new Set(removedWorkspaces.map((w) => w.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()));
210
+ const keep = (cwd: string) => {
211
+ const norm = cwd.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
212
+ return Boolean(norm) && !removed.has(norm);
213
+ };
214
+ return Array.from(new Set([...addedWorkspaces, ...sessions.map((s) => s.cwd)].filter(keep)));
215
+ }, [addedWorkspaces, sessions, removedWorkspaces]);
209
216
 
210
217
  // 模型目录派生值:数百个模型对象的映射只在目录变化时重算,
211
218
  // 不能跟着每次 token 增量 / 3 秒轮询重跑(usePiWeb 的 setState 都会触发本组件渲染)
@@ -695,6 +702,7 @@ export function AppShell() {
695
702
  providerNames={providerNames}
696
703
  authByProvider={authByProvider}
697
704
  addWorkspaceByPicker={addWorkspaceByPicker}
705
+ addWorkspaceByPath={addWorkspaceByPath}
698
706
  heroModel={heroModel}
699
707
  onSelectHeroModel={(provider, id) => {
700
708
  setHeroModel({ provider, id });
@@ -825,7 +833,7 @@ export function AppShell() {
825
833
  contextPercent={snapshot?.contextUsage?.percent ?? null}
826
834
  contextTokens={snapshot?.contextUsage?.tokens ?? null}
827
835
  contextWindow={snapshot?.contextUsage?.contextWindow ?? null}
828
- contextSource={{ systemChars: contextSystemChars, messages: state.messages }}
836
+ contextSource={{ systemChars: contextSystemChars, messages: state.messages, breakdown: snapshot?.contextBreakdown }}
829
837
  contextVisible={(snapshot?.messages?.length ?? 0) > 0}
830
838
  model={snapshot?.model}
831
839
  thinkingLevel={snapshot?.thinkingLevel}
@@ -1013,6 +1021,7 @@ function Hero({
1013
1021
  providerNames,
1014
1022
  authByProvider,
1015
1023
  addWorkspaceByPicker,
1024
+ addWorkspaceByPath,
1016
1025
  heroModel,
1017
1026
  onSelectHeroModel,
1018
1027
  defaultModel,
@@ -1039,6 +1048,7 @@ function Hero({
1039
1048
  providerNames: Record<string, string>;
1040
1049
  authByProvider: Record<string, boolean>;
1041
1050
  addWorkspaceByPicker: () => Promise<string | null>;
1051
+ addWorkspaceByPath: (dir: string) => Promise<string | null>;
1042
1052
  heroModel: { provider: string; id: string } | null;
1043
1053
  onSelectHeroModel: (provider: string, id: string) => void;
1044
1054
  defaultModel?: ModelChoice;
@@ -1048,6 +1058,8 @@ function Hero({
1048
1058
  }) {
1049
1059
  const { t } = useI18n();
1050
1060
  const [wsMenu, setWsMenu] = useState(false);
1061
+ const [wsPathDraft, setWsPathDraft] = useState("");
1062
+ const [wsPathError, setWsPathError] = useState<string | null>(null);
1051
1063
  const menuRef = useRef<HTMLDivElement>(null);
1052
1064
 
1053
1065
  useEffect(() => {
@@ -1069,6 +1081,19 @@ function Hero({
1069
1081
  }
1070
1082
  };
1071
1083
 
1084
+ // 手动输入路径添加(原生选择器不可用 / 无桌面环境兜底)
1085
+ const addByPath = async () => {
1086
+ const p = await addWorkspaceByPath(wsPathDraft);
1087
+ if (p) {
1088
+ setWsPathDraft("");
1089
+ setWsPathError(null);
1090
+ setCwd(p);
1091
+ setWsMenu(false);
1092
+ } else {
1093
+ setWsPathError(t.workspacePathInvalid);
1094
+ }
1095
+ };
1096
+
1072
1097
  const currentLabel = cwd ? (getWorkspaceName ? getWorkspaceName(cwd) : basename(cwd)) : t.startWith;
1073
1098
 
1074
1099
  return (
@@ -1130,6 +1155,24 @@ function Hero({
1130
1155
  <IconProjectAddOutline16 size={14} style={{ flex: "none", color: "var(--dsw-label-tertiary)" }} />
1131
1156
  {t.addWorkspace}
1132
1157
  </button>
1158
+ <div className="flex items-center gap-1.5 px-2 py-1.5" style={{ fontSize: 12.5 }}>
1159
+ <input
1160
+ className="min-w-0 flex-1 rounded-lg px-2 py-1"
1161
+ style={{ border: "0.5px solid var(--dsw-border-l3)", background: "var(--dsw-input-bg, transparent)", color: "inherit" }}
1162
+ placeholder={t.workspacePathPlaceholder}
1163
+ value={wsPathDraft}
1164
+ onChange={(e) => { setWsPathDraft(e.target.value); setWsPathError(null); }}
1165
+ onKeyDown={(e) => {
1166
+ if (e.key === "Enter") { e.preventDefault(); void addByPath(); }
1167
+ }}
1168
+ />
1169
+ <button type="button" className="pw-chip" style={{ flex: "none" }} disabled={!wsPathDraft.trim()} onClick={() => void addByPath()}>
1170
+ {t.workspacePathAdd}
1171
+ </button>
1172
+ </div>
1173
+ {wsPathError && (
1174
+ <div className="px-3.5 pb-1.5" style={{ fontSize: 11, color: "var(--dsw-danger)" }}>{wsPathError}</div>
1175
+ )}
1133
1176
  </div>
1134
1177
  )}
1135
1178
  </div>
@@ -84,7 +84,11 @@ export function ChatInput({
84
84
  contextTokens: number | null;
85
85
  contextWindow: number | null;
86
86
  /** 上下文分段数据源:ContextMeter 弹窗打开时才做字符统计 */
87
- contextSource?: { systemChars: number; messages: { content: Array<{ type: string; text?: string; thinking?: string }> }[] };
87
+ contextSource?: {
88
+ systemChars: number;
89
+ messages: { role?: string; content: Array<{ type: string; text?: string; thinking?: string }> }[];
90
+ breakdown?: import("@/lib/types").ContextBreakdown;
91
+ };
88
92
  /** 还没有对话时隐藏上下文计量 */
89
93
  contextVisible?: boolean;
90
94
  model?: { provider: string; id: string; name: string };
@@ -35,8 +35,10 @@ import type { ContextResource, TrajEntry, TrajTokens, WebContent, WebMessage, We
35
35
  import {
36
36
  classifyModelError,
37
37
  cleanCommand,
38
+ deleteTargetOf,
38
39
  displayToolName,
39
40
  firstSentence,
41
+ isDeleteCommand,
40
42
  langOfPath,
41
43
  previewSide,
42
44
  relativizeInText,
@@ -457,10 +459,10 @@ const ToolStep = memo(function ToolStep({
457
459
  if (kind !== "write" || !state || state.state !== "done" || state.isError) return null;
458
460
  const parsed = parseUnifiedDiff(state.patch || output);
459
461
  if (parsed) return parsed;
460
- // pi 的 write 工具不带 patch:新文件按带行号的正文展示(中性色,不标 +)
462
+ // pi 的 write 工具不带 patch:新文件按新增行展示(全绿 +),与 edit 的 diff 口径一致
461
463
  if (isWriteTool) {
462
464
  const content = str((args as { content?: unknown } | undefined)?.content);
463
- if (content) return content.replace(/\r/g, "").replace(/\n$/, "").split("\n").map((text, i): DiffLine => ({ kind: "ctx", old: i + 1, new: i + 1, text }));
465
+ if (content) return content.replace(/\r/g, "").replace(/\n$/, "").split("\n").map((text, i): DiffLine => ({ kind: "add", new: i + 1, text }));
464
466
  }
465
467
  return null;
466
468
  }, [args, isWriteTool, kind, output, state]);
@@ -478,6 +480,8 @@ const ToolStep = memo(function ToolStep({
478
480
  return str(a?.path ?? a?.file_path) ?? "";
479
481
  })();
480
482
  const fullCommand = kind === "cmd" ? (str((args as Record<string, unknown>)?.command) ?? str((args as Record<string, unknown>)?.cmd) ?? str((args as Record<string, unknown>)?.script) ?? "") : "";
483
+ // 删除文件的命令:完成后给红色标记(内容级 diff 不强求)
484
+ const deletedTarget = kind === "cmd" && !running && !failed && isDeleteCommand(fullCommand) ? deleteTargetOf(fullCommand) : "";
481
485
  const preview = state ? toolPreview(kind, state, tt, cwd) : { lines: [], hidden: 0 };
482
486
  const hasDiff = Boolean(diff && diff.length) && !failed;
483
487
  const hasOutput = Boolean(output || state?.encodingLoss);
@@ -488,7 +492,7 @@ const ToolStep = memo(function ToolStep({
488
492
  const diffHidden = diff && diffLines && !open ? diff.length - diffLines.length : 0;
489
493
  const diffStat = hasDiff && diff
490
494
  ? isWriteTool && !state?.patch
491
- ? t.resultWrote.replace("{n}", String(diff.length))
495
+ ? t.diffSummary.replace("{add}", String(diff.length)).replace("{del}", "0")
492
496
  : t.diffSummary.replace("{add}", String(diff.filter((l) => l.kind === "add").length)).replace("{del}", String(diff.filter((l) => l.kind === "del").length))
493
497
  : "";
494
498
  const toggle = () => setOpen((o) => !o);
@@ -508,6 +512,11 @@ const ToolStep = memo(function ToolStep({
508
512
  <span className="pw-arg" style={{ color: "var(--dsw-label-caption)" }}>{displayToolName(name)}</span>
509
513
  )}
510
514
  {diffStat && <span className="pw-meta">{diffStat}</span>}
515
+ {deletedTarget && (
516
+ <span className="pw-meta" style={{ color: "var(--dsw-danger)" }} title={deletedTarget}>
517
+ {t.deletedFile.replace("{path}", relativizeInText(deletedTarget, cwd || ""))}
518
+ </span>
519
+ )}
511
520
  {summary.scriptLines > 1 && <span className="pw-meta">{t.commandLines.replace("{n}", String(summary.scriptLines))}</span>}
512
521
  {running && <span className="pw-meta">{fmtSpan(elapsed)}</span>}
513
522
  {!running && duration >= 1000 && <span className="pw-meta">{fmtSpan(duration)}</span>}
@@ -638,6 +647,7 @@ const ERROR_HINT_KEY: Record<ModelErrorKind, string> = {
638
647
  timeout: "modelErrorTimeout",
639
648
  server: "modelErrorServer",
640
649
  network: "modelErrorNetwork",
650
+ moderation: "modelErrorModeration",
641
651
  };
642
652
 
643
653
  function ErrorStep({ message, count, model }: { message: string; count: number; model?: string }) {
@@ -1,18 +1,31 @@
1
1
  "use client";
2
2
 
3
3
  /**
4
- * 上下文占用(对齐 dsh ContextMeter):
5
- * 圆环常驻按钮 + 弹窗显示「百分比 / ~已用 / 窗口」与分段占用条
6
- * (系统提示词 / 对话消息 / 工具,mac 磁盘存储条式的多色分段)。
4
+ * 上下文占用:
5
+ * 圆环常驻按钮 + 弹窗显示「百分比 / ~已用 / 窗口」与 13 类分段占用条
6
+ * (mac 磁盘存储条式的多色分段,含 Free Space 段)。
7
+ * 分段口径:全部为字符/4 启发式估算(与 SDK estimateTokens 一致),
8
+ * 服务端分项(System Prompt / Tools / Memory / Skills / Compacted / Buffer)来自快照,
9
+ * 消息分项(User / Agent Text / Thinking / Tool Call / Tool Output)弹窗打开时前端统计。
7
10
  * 无对话时不渲染(由父组件经 contextVisible 控制)。
8
11
  */
9
12
  import { useEffect, useMemo, useRef, useState } from "react";
10
13
  import { useI18n } from "@/i18n";
14
+ import { classifyMessageChars } from "@/lib/process-format";
15
+ import type { ContextBreakdown } from "@/lib/types";
11
16
 
12
17
  function fmtTok(n: number): string {
13
18
  return n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(Math.round(n));
14
19
  }
15
20
 
21
+ /** 13 类分段的定义(顺序即渲染顺序) */
22
+ interface Segment {
23
+ key: string;
24
+ label: string;
25
+ tokens: number;
26
+ color: string;
27
+ }
28
+
16
29
  export function ContextMeter({
17
30
  percent,
18
31
  tokens,
@@ -22,8 +35,12 @@ export function ContextMeter({
22
35
  percent: number | null;
23
36
  tokens: number | null;
24
37
  contextWindow: number | null;
25
- /** 分段数据源:字符统计只在弹窗打开时执行(流式期间消息每 token 都变,不能常驻扫描) */
26
- source?: { systemChars: number; messages: { content: Array<{ type: string; text?: string; thinking?: string }> }[] };
38
+ /** 分段数据源:消息字符统计只在弹窗打开时执行(流式期间消息每 token 都变,不能常驻扫描) */
39
+ source?: {
40
+ systemChars: number;
41
+ messages: { role?: string; content: Array<{ type: string; text?: string; thinking?: string; arguments?: unknown }> }[];
42
+ breakdown?: ContextBreakdown;
43
+ };
27
44
  }) {
28
45
  const [open, setOpen] = useState(false);
29
46
  const ref = useRef<HTMLDivElement>(null);
@@ -38,16 +55,21 @@ export function ContextMeter({
38
55
  return () => document.removeEventListener("mousedown", h);
39
56
  }, [open]);
40
57
 
41
- // 分段估算只在弹窗打开时计算(≈3.5 字符/token)
42
- const segments = useMemo(() => {
43
- if (!open) return { system: 0, messages: 0 };
44
- let chars = 0;
45
- for (const message of source?.messages ?? []) {
46
- for (const content of message.content) {
47
- chars += content.type === "text" ? (content.text?.length ?? 0) : content.type === "thinking" ? (content.thinking?.length ?? 0) : 0;
48
- }
49
- }
50
- return { system: Math.round((source?.systemChars ?? 0) / 3.5), messages: Math.round(chars / 3.5) };
58
+ const used = tokens ?? 0;
59
+ const window_ = contextWindow ?? 0;
60
+ const b = source?.breakdown;
61
+
62
+ // 消息分项只在弹窗打开时统计(≈4 字符/token,与 SDK 一致)
63
+ const messageSegments = useMemo(() => {
64
+ if (!open) return { user: 0, agentText: 0, agentThinking: 0, agentToolCall: 0, toolOutput: 0 };
65
+ const chars = classifyMessageChars(source?.messages ?? []);
66
+ return {
67
+ user: Math.max(0, Math.round(chars.user / 4)),
68
+ agentText: Math.max(0, Math.round(chars.agentText / 4)),
69
+ agentThinking: Math.max(0, Math.round(chars.agentThinking / 4)),
70
+ agentToolCall: Math.max(0, Math.round(chars.agentToolCall / 4)),
71
+ toolOutput: Math.max(0, Math.round(chars.toolOutput / 4)),
72
+ };
51
73
  }, [open, source]);
52
74
 
53
75
  const p = percent ?? 0;
@@ -55,15 +77,34 @@ export function ContextMeter({
55
77
  const r = 5.5;
56
78
  const c = 2 * Math.PI * r;
57
79
  const shown = Math.max(0, Math.min(100, p));
58
- const used = tokens ?? 0;
59
- const window_ = contextWindow ?? 0;
60
80
 
61
- // 分段估算:系统提示词 / 对话消息;工具与基底取余量,保证各段之和等于总量
62
- const systemTokens = Math.max(0, Math.round(segments?.system ?? 0));
63
- const messageTokens = Math.max(0, Math.round(segments?.messages ?? 0));
64
- const toolTokens = Math.max(0, used - systemTokens - messageTokens);
65
- const segTotal = systemTokens + messageTokens + toolTokens || 1;
66
- const bar = (n: number) => `${(n / segTotal) * 100}%`;
81
+ const segments: Segment[] = b
82
+ ? [
83
+ { key: "systemPrompt", label: t.contextSystemPrompt, tokens: b.systemPrompt, color: "var(--dsw-accent)" },
84
+ { key: "systemTools", label: t.contextSystemTools, tokens: b.systemTools, color: "#8B7EC8" },
85
+ { key: "customTools", label: t.contextCustomTools, tokens: b.customTools, color: "#B39DDB" },
86
+ { key: "memory", label: t.contextMemory, tokens: b.memory, color: "#5FA8D3" },
87
+ { key: "skills", label: t.contextSkills, tokens: b.skills, color: "#4FB286" },
88
+ { key: "user", label: t.contextUserMessages, tokens: messageSegments.user, color: "var(--dsw-success)" },
89
+ { key: "agentText", label: t.contextAgentText, tokens: messageSegments.agentText, color: "#7BC96F" },
90
+ { key: "agentThinking", label: t.contextAgentThinking, tokens: messageSegments.agentThinking, color: "#C9A66B" },
91
+ { key: "agentToolCall", label: t.contextAgentToolCall, tokens: messageSegments.agentToolCall, color: "#D3A15F" },
92
+ { key: "toolOutput", label: t.contextToolOutput, tokens: messageSegments.toolOutput, color: "var(--dsw-warn)" },
93
+ { key: "compacted", label: t.contextCompacted, tokens: b.compacted, color: "#A05A78" },
94
+ { key: "buffer", label: t.contextAutoCompactBuffer, tokens: b.autoCompactBuffer, color: "var(--dsw-border-l3)" },
95
+ ]
96
+ : // 服务端分项缺失(旧快照):退回旧三段口径
97
+ [
98
+ { key: "system", label: t.contextSystem, tokens: Math.max(0, Math.round((source?.systemChars ?? 0) / 4)), color: "var(--dsw-accent)" },
99
+ { key: "messages", label: t.contextMessages, tokens: messageSegments.user + messageSegments.agentText + messageSegments.agentThinking, color: "var(--dsw-success)" },
100
+ { key: "tools", label: t.contextTools, tokens: Math.max(0, used - Math.max(0, Math.round((source?.systemChars ?? 0) / 4)) - messageSegments.user - messageSegments.agentText - messageSegments.agentThinking), color: "var(--dsw-warn)" },
101
+ ];
102
+
103
+ // Free Space:窗口余量(窗口未知则不显示);分段条不归一化到已用之和
104
+ const usedSum = segments.reduce((sum, s) => sum + s.tokens, 0);
105
+ const free = window_ > 0 ? Math.max(0, window_ - usedSum) : 0;
106
+ const barTotal = window_ > 0 ? window_ : usedSum || 1;
107
+ const bar = (n: number) => `${(n / barTotal) * 100}%`;
67
108
 
68
109
  return (
69
110
  <div ref={ref} className="relative">
@@ -98,18 +139,20 @@ export function ContextMeter({
98
139
  {tokens === null ? "—" : `~${fmtTok(used)}`} / {window_ > 0 ? fmtTok(window_) : "—"}
99
140
  </div>
100
141
 
101
- {/* 分段占用条(mac 磁盘存储条式多色分段) */}
102
- {(systemTokens > 0 || messageTokens > 0 || toolTokens > 0) && (
142
+ {/* 分段占用条(mac 磁盘存储条式多色分段,含 Free Space) */}
143
+ {usedSum > 0 && (
103
144
  <div className="mt-3 flex h-2 w-full overflow-hidden rounded-full" style={{ background: "var(--dsw-selector)" }} aria-hidden>
104
- {systemTokens > 0 && <span style={{ width: bar(systemTokens), background: "var(--dsw-accent)" }} />}
105
- {messageTokens > 0 && <span style={{ width: bar(messageTokens), background: "var(--dsw-success)" }} />}
106
- {toolTokens > 0 && <span style={{ width: bar(toolTokens), background: "var(--dsw-warn)" }} />}
145
+ {segments.filter((s) => s.tokens > 0).map((s) => (
146
+ <span key={s.key} style={{ width: bar(s.tokens), background: s.color }} />
147
+ ))}
148
+ {free > 0 && <span style={{ width: bar(free), background: "transparent" }} />}
107
149
  </div>
108
150
  )}
109
151
  <div className="mt-2 flex flex-col gap-1" style={{ color: "var(--dsw-label-tertiary)" }}>
110
- <SegmentRow label={t.contextSystem} tokens={systemTokens} color="var(--dsw-accent)" />
111
- <SegmentRow label={t.contextMessages} tokens={messageTokens} color="var(--dsw-success)" />
112
- <SegmentRow label={t.contextTools} tokens={toolTokens} color="var(--dsw-warn)" />
152
+ {segments.filter((s) => s.tokens > 0).map((s) => (
153
+ <SegmentRow key={s.key} label={s.label} tokens={s.tokens} color={s.color} />
154
+ ))}
155
+ {window_ > 0 && <SegmentRow label={t.contextFreeSpace} tokens={free} color="var(--dsw-selector)" />}
113
156
  </div>
114
157
  </div>
115
158
  )}
@@ -161,6 +161,12 @@ export function ProjectPanel({
161
161
  <IconCloseOutline14 size={13} />
162
162
  </button>
163
163
  </div>
164
+ {/* 生长快照不可用(找不到 git 等):明确提示 + 已退化为当前目录结构 */}
165
+ {cwd && hasSession && !growth.available && !growth.loading && (
166
+ <div className="mx-3 mb-2 rounded-lg px-2 py-1" style={{ fontSize: 11.5, color: "var(--dsw-warn)", background: "var(--dsw-hover)" }} role="status">
167
+ {t.growthUnavailableFallback}
168
+ </div>
169
+ )}
164
170
  {gitError && (
165
171
  <div className="mx-3 mb-2 rounded-lg px-2 py-1" style={{ fontSize: 11.5, color: "var(--dsw-danger)" }} title={gitError} role="alert" data-testid="growth-git-error">
166
172
  {t.gitStatusUnknown}
@@ -271,6 +271,24 @@
271
271
  opacity: 0.48;
272
272
  }
273
273
 
274
+ .discoverControls {
275
+ display: flex;
276
+ flex: none;
277
+ align-items: center;
278
+ gap: 12px;
279
+ }
280
+
281
+ .allowPrivate {
282
+ display: flex;
283
+ align-items: center;
284
+ gap: 4px;
285
+ font-size: 11.5px;
286
+ color: var(--dsw-label-caption);
287
+ cursor: pointer;
288
+ user-select: none;
289
+ white-space: nowrap;
290
+ }
291
+
274
292
  .modelEmpty {
275
293
  margin-top: 13px;
276
294
  padding: 17px;
@@ -145,13 +145,13 @@ export function ProviderSetupModal({
145
145
  };
146
146
 
147
147
  /** 供 ModelCatalog 的候选对话框调用:用表单当前值(含未保存的 key)探测远端目录 */
148
- const discoverAvailableModels = async (): Promise<ModelDraft[]> => {
148
+ const discoverAvailableModels = async (allowPrivate: boolean): Promise<ModelDraft[]> => {
149
149
  if (!baseUrl.trim()) throw new Error(t.apiAddress);
150
150
  const response = await fetch("/api/models", {
151
151
  method: "POST",
152
152
  headers: { "Content-Type": "application/json" },
153
153
  // builtin 模式带 providerId:表单 key 为空时后端回退到已存密钥 / OAuth 令牌
154
- body: JSON.stringify({ action: "discoverModels", baseUrl: baseUrl.trim(), api: mode === "builtin" ? selected?.apis[0] : api, apiKey: apiKey.trim(), providerId: mode === "builtin" ? selected?.id : undefined }),
154
+ body: JSON.stringify({ action: "discoverModels", baseUrl: baseUrl.trim(), api: mode === "builtin" ? selected?.apis[0] : api, apiKey: apiKey.trim(), providerId: mode === "builtin" ? selected?.id : undefined, allowPrivate }),
155
155
  });
156
156
  const result = await response.json();
157
157
  if (!response.ok || !result.success) throw new Error(result.error || t.toastError);
@@ -332,14 +332,16 @@ export function ModelCatalog({
332
332
  setModels: React.Dispatch<React.SetStateAction<ModelDraft[]>>;
333
333
  updateModel: (index: number, patch: Partial<ModelDraft>) => void;
334
334
  inheritedCount?: number;
335
- /** 用表单当前值探测远端目录;抛错时错误显示在目录内 */
336
- discover?: () => Promise<ModelDraft[]>;
335
+ /** 用表单当前值探测远端目录(allowPrivate:用户勾选了允许访问本机/私网地址);抛错时错误显示在目录内 */
336
+ discover?: (allowPrivate: boolean) => Promise<ModelDraft[]>;
337
337
  discoverDisabled?: boolean;
338
338
  }) {
339
339
  const { t } = useI18n();
340
340
  const [expanded, setExpanded] = useState<ReadonlySet<number>>(new Set());
341
341
  const [discovering, setDiscovering] = useState(false);
342
342
  const [discoverError, setDiscoverError] = useState("");
343
+ // 本地网关(Ollama 等)在私网/回环地址上,探测需要用户显式授权
344
+ const [allowPrivate, setAllowPrivate] = useState(false);
343
345
  // 候选对话框:null = 关闭
344
346
  const [candidates, setCandidates] = useState<ModelDraft[] | null>(null);
345
347
  const [picked, setPicked] = useState<ReadonlySet<string>>(new Set());
@@ -384,7 +386,7 @@ export function ModelCatalog({
384
386
  setDiscovering(true);
385
387
  setDiscoverError("");
386
388
  try {
387
- const found = await discover();
389
+ const found = await discover(allowPrivate);
388
390
  if (!found.length) {
389
391
  setDiscoverError(t.discoverEmpty);
390
392
  return;
@@ -441,9 +443,19 @@ export function ModelCatalog({
441
443
  </div>
442
444
  </div>
443
445
  {discover ? (
444
- <button className={styles.discoverButton} disabled={discoverDisabled || discovering} onClick={() => void startDiscover()}>
445
- {discovering ? t.discoveringModels : t.discoverModels}
446
- </button>
446
+ <div className={styles.discoverControls}>
447
+ <label className={styles.allowPrivate} title={t.allowPrivateHint}>
448
+ <input
449
+ type="checkbox"
450
+ checked={allowPrivate}
451
+ onChange={(event) => setAllowPrivate(event.target.checked)}
452
+ />
453
+ {t.allowPrivateDiscovery}
454
+ </label>
455
+ <button className={styles.discoverButton} disabled={discoverDisabled || discovering} onClick={() => void startDiscover()}>
456
+ {discovering ? t.discoveringModels : t.discoverModels}
457
+ </button>
458
+ </div>
447
459
  ) : null}
448
460
  </div>
449
461
  {discoverError ? <div className={styles.catalogError}>{discoverError}</div> : null}