@rexvane/piweb 0.3.3 → 0.3.5
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 +48 -73
- package/package.json +1 -1
- package/scripts/build-output.mjs +3 -2
- package/scripts/install-build.mjs +54 -6
- package/src/app/api/models/route.ts +2 -1
- package/src/app/api/skills/route.ts +7 -2
- package/src/components/AppShell.tsx +12 -6
- package/src/components/ChatInput.tsx +5 -1
- package/src/components/ChatWindow.tsx +13 -3
- package/src/components/ContextMeter.tsx +74 -31
- package/src/components/ProjectPanel.tsx +6 -0
- package/src/components/ProviderSetupModal.module.css +18 -0
- package/src/components/ProviderSetupModal.tsx +20 -8
- package/src/components/SettingsPanel.tsx +72 -16
- package/src/hooks/useGrowth.ts +21 -7
- package/src/hooks/usePiWeb.ts +1 -0
- package/src/i18n.tsx +48 -2
- package/src/lib/agent-manager.ts +104 -1
- package/src/lib/growth-service.ts +93 -49
- package/src/lib/models-service.ts +53 -13
- package/src/lib/process-format.ts +49 -1
- package/src/lib/skills-service.ts +23 -0
- package/src/lib/types.ts +24 -0
- package/src/lib/update-service.ts +1 -1
- package/src/lib/workspace-store.ts +81 -43
package/README.md
CHANGED
|
@@ -14,109 +14,84 @@
|
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
17
|
-
**PiWeb** brings a sleek, feature-rich browser interface to the [pi coding agent](https://github.com/earendil-works/pi) with zero modifications to the core Pi engine.
|
|
17
|
+
**PiWeb** brings a sleek, feature-rich browser interface to the [pi coding agent](https://github.com/earendil-works/pi) with zero modifications to the core Pi engine. Install with one command, and all your existing pi configuration, sessions, models, skills, and plugins work out of the box.
|
|
18
18
|
|
|
19
|
-
##
|
|
19
|
+
## Features
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
- **Composer & Input**: Image attachments (paste, pick, drag-and-drop), 2-level model selector, model cycling forward/backward, queue management (steering / follow-up), and `/` slash commands.
|
|
23
|
-
- **Decoupled Workspaces & Sessions**: Group sessions by workspace folders, native system folder picker on Windows, rename/delete workspaces with centered confirmation modals, preserve sessions under "Ungrouped", and maintain empty workspaces independently.
|
|
24
|
-
- **Session Exploration**: Session tree branch visualization & navigation, user message draft recovery, export session logs to JSONL / HTML, and trajectory views.
|
|
25
|
-
- **Comprehensive Settings**:
|
|
26
|
-
- **General**: Tool presets (Read Only / Workspace Write / Full Access), language (zh/en), appearance, enter key behaviors, and auto-compact.
|
|
27
|
-
- **Models**: Manage 30+ built-in providers, custom providers in `models.json`, and OAuth logins (Claude, Codex, Copilot, etc.).
|
|
28
|
-
- **Plugins & Skills**: View and manage extensions and skills directly via Pi's built-in package manager.
|
|
29
|
-
- **Security & Sandboxing**: Optional HTTP Basic Auth via `PI_WEB_PASSWORD`, origin validation on write actions, and strict path boundary checks.
|
|
21
|
+
### Real-Time Streaming Chat
|
|
30
22
|
|
|
31
|
-
|
|
23
|
+
Full SSE snapshots with ordered deltas and automatic replay via `Last-Event-ID`. The thinking process and inline tool execution cards stream live.
|
|
32
24
|
|
|
33
|
-
|
|
25
|
+
<p align="center"><img src="assets/showcase.png" alt="Streaming chat with tool cards" width="840" /></p>
|
|
34
26
|
|
|
35
|
-
###
|
|
27
|
+
### Workspaces & Sessions
|
|
36
28
|
|
|
37
|
-
|
|
38
|
-
npm install -g @rexvane/piweb
|
|
39
|
-
piweb
|
|
40
|
-
```
|
|
29
|
+
Sessions grouped by project folder, with a native folder picker (Windows / macOS / Linux), rename and archive. Session tree branches, draft recovery, and JSONL / HTML export.
|
|
41
30
|
|
|
42
|
-
|
|
31
|
+
### Project Growth Tree
|
|
43
32
|
|
|
44
|
-
|
|
33
|
+
Every step the agent takes on your codebase is snapshotted: navigate the timeline round by round, see added/modified/deleted files, and open line-level diffs for any file at any step — even if the workspace has no git history of its own.
|
|
45
34
|
|
|
46
|
-
|
|
47
|
-
npm install
|
|
48
|
-
npm run dev # development server with hot reload
|
|
49
|
-
```
|
|
35
|
+
### Context Meter
|
|
50
36
|
|
|
51
|
-
|
|
37
|
+
Click the ring next to the composer to see the context window broken down into 13 segments — System Prompt, System/Custom Tools, Memory (AGENTS.md), Skills, messages by type, Compacted Data, Auto-Compact Buffer, and Free Space.
|
|
52
38
|
|
|
53
|
-
|
|
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)
|
|
57
|
-
```
|
|
39
|
+
### Model & Provider Management
|
|
58
40
|
|
|
59
|
-
|
|
41
|
+
30+ built-in providers, OAuth logins (Claude, Codex, Copilot, etc.), custom providers written to `models.json`, one-click model discovery, and a placeholder key auto-filled for local gateways like Ollama.
|
|
60
42
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
--dev Start in development mode (`next dev`; env PI_WEB_DEV=1; also used when no production build exists)
|
|
65
|
-
--no-open Do not automatically open browser (env PI_WEB_NO_OPEN=1)
|
|
66
|
-
-h, --help Show help
|
|
67
|
-
```
|
|
43
|
+
### Plugins & Skills
|
|
44
|
+
|
|
45
|
+
Install, update, and uninstall pi packages directly from the UI. Enable/disable or delete skills; every change applies to running sessions immediately.
|
|
68
46
|
|
|
69
|
-
|
|
47
|
+
### Security
|
|
70
48
|
|
|
71
|
-
|
|
49
|
+
Optional login via `PI_WEB_PASSWORD` (required for non-loopback binds), origin validation on write actions, strict path boundary checks, and tool presets (Read Only / Workspace Write / Full Access).
|
|
72
50
|
|
|
73
|
-
|
|
51
|
+
## Usage
|
|
74
52
|
|
|
75
|
-
###
|
|
53
|
+
### Install
|
|
76
54
|
|
|
77
55
|
```bash
|
|
78
|
-
npm
|
|
79
|
-
|
|
80
|
-
npm test # Service, protocol and component regressions
|
|
81
|
-
npm run check # Full check (types + tests + build)
|
|
82
|
-
npm run build:release # Validate and stage a production build without replacing the running build
|
|
56
|
+
npm install -g @rexvane/piweb
|
|
57
|
+
piweb
|
|
83
58
|
```
|
|
84
59
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
The audit follow-up is merged into this branch and has been through an isolated release plus a local production deployment. It covers the production login build, upload integrity through the Next.js proxy, per-session extension isolation and tool-policy reloads, lossless model/settings writes, composer and pending-extension-UI lifecycle, Git/Growth/diff correctness, and isolated release preparation.
|
|
60
|
+
The package registers only the `piweb` command, so it never conflicts with the official `pi` CLI. Requires Node.js ≥ 22.19.0 (24 recommended); Git is needed for growth-history features.
|
|
88
61
|
|
|
89
|
-
|
|
62
|
+
### Update
|
|
90
63
|
|
|
91
|
-
|
|
64
|
+
```bash
|
|
65
|
+
npm update -g @rexvane/piweb
|
|
66
|
+
```
|
|
92
67
|
|
|
93
|
-
|
|
68
|
+
Or install a specific version: `npm install -g @rexvane/piweb@0.3.4`.
|
|
94
69
|
|
|
95
|
-
|
|
70
|
+
### Uninstall
|
|
96
71
|
|
|
97
|
-
|
|
72
|
+
```bash
|
|
73
|
+
npm uninstall -g @rexvane/piweb
|
|
74
|
+
```
|
|
98
75
|
|
|
99
|
-
|
|
76
|
+
This removes PiWeb only — your pi data lives in `~/.pi/agent/` and is left untouched.
|
|
100
77
|
|
|
101
|
-
|
|
78
|
+
### Run
|
|
102
79
|
|
|
80
|
+
```bash
|
|
81
|
+
piweb # http://127.0.0.1:30141 (auto-increments if busy)
|
|
82
|
+
piweb -p 30143 --no-open # custom port, don't open the browser
|
|
83
|
+
piweb -H 0.0.0.0 # LAN access (requires PI_WEB_PASSWORD)
|
|
103
84
|
```
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
├─ security-service Tool presets & project trust
|
|
114
|
-
├─ plugins-service Pi package management (DefaultPackageManager) & extension registry
|
|
115
|
-
├─ workspace-store Persistent workspaces registry & native folder picker
|
|
116
|
-
▼
|
|
117
|
-
@earendil-works/pi-coding-agent (Official Pi SDK, zero engine modifications)
|
|
118
|
-
```
|
|
85
|
+
|
|
86
|
+
| Option | Description |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| `-p, --port <port>` | Listen port (default 30141, env `PORT`) |
|
|
89
|
+
| `-H, --hostname <host>` | Bind address (default 127.0.0.1, env `PI_WEB_HOSTNAME`) |
|
|
90
|
+
| `--dev` | Development mode |
|
|
91
|
+
| `--no-open` | Do not auto-open the browser |
|
|
92
|
+
|
|
93
|
+
Set `PI_WEB_PASSWORD` to enable the login page and HTTP Basic Auth (user `pi`). Remote access over plain HTTP is not recommended; use HTTPS or a trusted VPN.
|
|
119
94
|
|
|
120
95
|
## License
|
|
121
96
|
|
|
122
|
-
[MIT
|
|
97
|
+
[MIT](LICENSE)
|
package/package.json
CHANGED
package/scripts/build-output.mjs
CHANGED
|
@@ -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
|
|
58
|
-
|
|
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
|
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* Repository checkouts always have dev dependencies and keep `npm run dev`.
|
|
10
10
|
*/
|
|
11
11
|
import fs from "node:fs";
|
|
12
|
+
import os from "node:os";
|
|
12
13
|
import path from "node:path";
|
|
13
14
|
import { createRequire } from "node:module";
|
|
14
15
|
import { spawnSync } from "node:child_process";
|
|
@@ -65,15 +66,64 @@ export function resolveNextBin(root) {
|
|
|
65
66
|
* neither compiled nor mapped through the `@/*` path alias. Build in a staging
|
|
66
67
|
* directory outside `node_modules` (same drive, so the dependency links resolve)
|
|
67
68
|
* and copy the finished output back into the package.
|
|
69
|
+
*
|
|
70
|
+
* The staging directory must NOT sit inside any `node_modules`: for a global
|
|
71
|
+
* install like ~/.local/lib/node_modules/@rexvane/piweb, the grandparent IS
|
|
72
|
+
* node_modules, and Next would exclude the staged sources exactly the same way.
|
|
73
|
+
* Walk up until we are outside every node_modules segment; give up and use the
|
|
74
|
+
* system temp dir (cross-device copy is still correct, just slower).
|
|
68
75
|
*/
|
|
76
|
+
function stagingParent(root) {
|
|
77
|
+
// Split on BOTH separators: a win32 Node can see POSIX paths (and vice versa)
|
|
78
|
+
// when the package is installed through a compatibility layer or the test
|
|
79
|
+
// suite simulates foreign layouts.
|
|
80
|
+
const segments = path.dirname(root).split(/[\\/]/).filter(Boolean);
|
|
81
|
+
// Drop trailing path components while the path still contains a node_modules segment.
|
|
82
|
+
while (segments.length > 1 && segments.includes("node_modules")) {
|
|
83
|
+
segments.pop();
|
|
84
|
+
}
|
|
85
|
+
const candidate = segments.length ? path.resolve(`/${segments.join(path.sep)}`) : path.sep;
|
|
86
|
+
try {
|
|
87
|
+
fs.mkdirSync(candidate, { recursive: true });
|
|
88
|
+
fs.accessSync(candidate, fs.constants.W_OK);
|
|
89
|
+
return candidate;
|
|
90
|
+
} catch {
|
|
91
|
+
return os.tmpdir();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Staging directory must live outside EVERY node_modules on the way up (Next excludes those paths). */
|
|
96
|
+
export function assertStagingOutsideNodeModules(root) {
|
|
97
|
+
const parent = stagingParent(root);
|
|
98
|
+
if (parent.split(/[\\/]/).includes("node_modules")) {
|
|
99
|
+
throw new Error(`staging parent is inside node_modules: ${parent}`);
|
|
100
|
+
}
|
|
101
|
+
return parent;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function tryLinkDependencies(root, staging) {
|
|
105
|
+
try {
|
|
106
|
+
fs.symlinkSync(path.join(root, "node_modules"), path.join(staging, "node_modules"), "junction");
|
|
107
|
+
return true;
|
|
108
|
+
} catch {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
69
113
|
function buildInStaging(root, nextBin, env, log, warn) {
|
|
70
|
-
const parent = path.dirname(path.dirname(root));
|
|
71
114
|
let staging;
|
|
72
115
|
try {
|
|
73
|
-
staging = fs.mkdtempSync(path.join(
|
|
116
|
+
staging = fs.mkdtempSync(path.join(stagingParent(root), ".piweb-build-"));
|
|
74
117
|
} catch {
|
|
75
118
|
staging = fs.mkdtempSync(path.join(os.tmpdir(), "piweb-build-"));
|
|
76
119
|
}
|
|
120
|
+
// junction 不能跨卷:staging 与包不同盘时链接会失败,退回系统临时目录再试一次
|
|
121
|
+
if (!tryLinkDependencies(root, staging) && path.parse(staging).root !== path.parse(os.tmpdir()).root) {
|
|
122
|
+
try {
|
|
123
|
+
fs.rmSync(staging, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
|
|
124
|
+
} catch { /* proceed to the tmpdir attempt */ }
|
|
125
|
+
staging = fs.mkdtempSync(path.join(os.tmpdir(), "piweb-build-"));
|
|
126
|
+
}
|
|
77
127
|
try {
|
|
78
128
|
for (const entry of ["src", "public", "assets", "scripts", "bin"]) {
|
|
79
129
|
const from = path.join(root, entry);
|
|
@@ -83,10 +133,8 @@ function buildInStaging(root, nextBin, env, log, warn) {
|
|
|
83
133
|
const from = path.join(root, file);
|
|
84
134
|
if (fs.existsSync(from)) fs.copyFileSync(from, path.join(staging, file));
|
|
85
135
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
} catch (error) {
|
|
89
|
-
warn(`[piweb] Could not link dependencies into the staging build: ${error.message}`);
|
|
136
|
+
if (!tryLinkDependencies(root, staging)) {
|
|
137
|
+
warn("[piweb] Could not link dependencies into the staging build (junction unavailable).");
|
|
90
138
|
return false;
|
|
91
139
|
}
|
|
92
140
|
log(`[piweb] Building in ${staging} ...`);
|
|
@@ -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(
|
|
@@ -201,11 +201,17 @@ export function AppShell() {
|
|
|
201
201
|
}, [currentPath]);
|
|
202
202
|
|
|
203
203
|
// 已知工作区列表(给 Hero 建议);启动不预选任何工作区,
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
204
|
+
// 未选择时输入框仍可用,发送会提示先选择工作区。
|
|
205
|
+
// 已删除工作区要过滤(与会话栏分组同口径):否则它下面残留的会话 cwd
|
|
206
|
+
// 会把刚删掉的工作区重新喂回顶部下拉。
|
|
207
|
+
const knownCwds = useMemo(() => {
|
|
208
|
+
const removed = new Set(removedWorkspaces.map((w) => w.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()));
|
|
209
|
+
const keep = (cwd: string) => {
|
|
210
|
+
const norm = cwd.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
211
|
+
return Boolean(norm) && !removed.has(norm);
|
|
212
|
+
};
|
|
213
|
+
return Array.from(new Set([...addedWorkspaces, ...sessions.map((s) => s.cwd)].filter(keep)));
|
|
214
|
+
}, [addedWorkspaces, sessions, removedWorkspaces]);
|
|
209
215
|
|
|
210
216
|
// 模型目录派生值:数百个模型对象的映射只在目录变化时重算,
|
|
211
217
|
// 不能跟着每次 token 增量 / 3 秒轮询重跑(usePiWeb 的 setState 都会触发本组件渲染)
|
|
@@ -825,7 +831,7 @@ export function AppShell() {
|
|
|
825
831
|
contextPercent={snapshot?.contextUsage?.percent ?? null}
|
|
826
832
|
contextTokens={snapshot?.contextUsage?.tokens ?? null}
|
|
827
833
|
contextWindow={snapshot?.contextUsage?.contextWindow ?? null}
|
|
828
|
-
contextSource={{ systemChars: contextSystemChars, messages: state.messages }}
|
|
834
|
+
contextSource={{ systemChars: contextSystemChars, messages: state.messages, breakdown: snapshot?.contextBreakdown }}
|
|
829
835
|
contextVisible={(snapshot?.messages?.length ?? 0) > 0}
|
|
830
836
|
model={snapshot?.model}
|
|
831
837
|
thinkingLevel={snapshot?.thinkingLevel}
|
|
@@ -84,7 +84,11 @@ export function ChatInput({
|
|
|
84
84
|
contextTokens: number | null;
|
|
85
85
|
contextWindow: number | null;
|
|
86
86
|
/** 上下文分段数据源:ContextMeter 弹窗打开时才做字符统计 */
|
|
87
|
-
contextSource?: {
|
|
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: "
|
|
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.
|
|
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
|
-
*
|
|
5
|
-
* 圆环常驻按钮 + 弹窗显示「百分比 / ~已用 /
|
|
6
|
-
*
|
|
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
|
-
/**
|
|
26
|
-
source?: {
|
|
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
|
-
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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;
|