@co0ontty/wand 1.5.2 → 1.5.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 +67 -14
- package/dist/server.js +0 -49
- package/dist/structured-session-manager.js +17 -0
- package/dist/web-ui/content/scripts.js +105 -88
- package/dist/web-ui/content/styles.css +20 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,22 +1,18 @@
|
|
|
1
1
|
# wand
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
通过浏览器远程访问和管理本地 CLI 工具的 Web 控制台。专为 Claude Code 设计,支持终端和结构化对话双视图、会话持久化与恢复、权限管控、文件浏览等功能。
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## 安装
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- 会话持久化与恢复
|
|
9
|
-
- Claude Code 集成
|
|
10
|
-
- 文件浏览器
|
|
11
|
-
- HTTPS 安全连接(可选,配置 `https: true` 启用)
|
|
7
|
+
### 一键安装
|
|
12
8
|
|
|
13
|
-
|
|
9
|
+
自动检测并安装 Node.js(需要 v22+),然后安装 wand:
|
|
14
10
|
|
|
15
11
|
```bash
|
|
16
12
|
bash <(curl -Ls https://raw.githubusercontent.com/co0ontty/wand/master/install.sh)
|
|
17
13
|
```
|
|
18
14
|
|
|
19
|
-
|
|
15
|
+
### 手动安装
|
|
20
16
|
|
|
21
17
|
```bash
|
|
22
18
|
npm install -g @co0ontty/wand
|
|
@@ -24,16 +20,73 @@ wand init
|
|
|
24
20
|
wand web
|
|
25
21
|
```
|
|
26
22
|
|
|
27
|
-
|
|
23
|
+
安装完成后打开浏览器访问终端中提示的地址即可。
|
|
24
|
+
|
|
25
|
+
## 配置
|
|
26
|
+
|
|
27
|
+
配置文件位于 `~/.wand/config.json`,首次 `wand init` 时自动生成。
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
wand config:path # 查看配置文件路径
|
|
31
|
+
wand config:show # 查看当前配置
|
|
32
|
+
wand config:set host 0.0.0.0 # 修改配置项
|
|
33
|
+
wand config:set port 9443
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
常用配置项:
|
|
37
|
+
|
|
38
|
+
| 字段 | 默认值 | 说明 |
|
|
39
|
+
|------|--------|------|
|
|
40
|
+
| `host` | `127.0.0.1` | 监听地址,`0.0.0.0` 允许远程访问 |
|
|
41
|
+
| `port` | `8443` | 监听端口 |
|
|
42
|
+
| `https` | `false` | 启用 HTTPS(自签证书自动生成) |
|
|
43
|
+
| `password` | (随机生成) | 登录密码 |
|
|
44
|
+
| `replyLanguage` | `""` | Claude 回复语言偏好 |
|
|
45
|
+
|
|
46
|
+
## 功能
|
|
47
|
+
|
|
48
|
+
- **双视图模式** — 终端原始输出和结构化对话视图可随时切换
|
|
49
|
+
- **会话管理** — 创建、归档、恢复会话;支持从 Claude 原生历史记录恢复
|
|
50
|
+
- **权限控制** — 可视化权限提示,支持逐次确认、单次批准、本轮记忆等策略
|
|
51
|
+
- **文件浏览器** — 内置路径浏览、搜索和收藏功能
|
|
52
|
+
- **多种运行模式** — full-access / default / auto-edit 等 Claude 运行模式
|
|
53
|
+
- **PWA 支持** — 可添加到主屏幕作为独立应用使用
|
|
54
|
+
- **HTTPS** — 可选自签证书,适合远程或移动端访问
|
|
28
55
|
|
|
29
56
|
## 开发
|
|
30
57
|
|
|
31
58
|
```bash
|
|
32
|
-
npm install
|
|
33
|
-
npm run
|
|
34
|
-
npm run
|
|
59
|
+
npm install # 安装依赖
|
|
60
|
+
npm run dev # 从源码直接启动开发服务器
|
|
61
|
+
npm run check # TypeScript 类型检查
|
|
62
|
+
npm run build # 编译 + 复制静态资源到 dist/
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
隔离测试环境(不影响生产实例):
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npm run dev -- -c /tmp/wand-test/config.json
|
|
35
69
|
```
|
|
36
70
|
|
|
71
|
+
## 项目结构
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
src/
|
|
75
|
+
cli.ts # CLI 入口,解析命令和参数
|
|
76
|
+
server.ts # Express 服务器、REST API、WebSocket
|
|
77
|
+
server-session-routes.ts # 会话/恢复/历史相关路由
|
|
78
|
+
process-manager.ts # PTY 会话编排、输入输出路由、权限处理
|
|
79
|
+
claude-pty-bridge.ts # PTY 输出解析为结构化对话数据
|
|
80
|
+
storage.ts # SQLite 持久化
|
|
81
|
+
config.ts # 配置加载与合并
|
|
82
|
+
session-lifecycle.ts # 会话状态机(idle/thinking/waiting/archived)
|
|
83
|
+
session-logger.ts # 文件日志 ~/.wand/sessions/
|
|
84
|
+
resume-policy.ts # Claude 历史绑定与恢复策略
|
|
85
|
+
web-ui/ # 服务端渲染的前端 HTML/CSS/JS
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
数据存储在 `~/.wand/` 下:`config.json`(配置)、`wand.db`(SQLite)、`sessions/`(日志)。
|
|
89
|
+
|
|
37
90
|
## License
|
|
38
91
|
|
|
39
|
-
MIT
|
|
92
|
+
MIT
|
package/dist/server.js
CHANGED
|
@@ -604,55 +604,6 @@ export async function startServer(config, configPath) {
|
|
|
604
604
|
{ path: "/", name: "根目录", icon: "📁" },
|
|
605
605
|
]);
|
|
606
606
|
});
|
|
607
|
-
app.get("/api/favorite-paths", (_req, res) => {
|
|
608
|
-
const stored = storage.getConfigValue("favorite_paths");
|
|
609
|
-
const favorites = parseStoredPathList(stored);
|
|
610
|
-
res.json(favorites.filter((f) => !isBlockedFolderPath(normalizeFolderPath(f.path))));
|
|
611
|
-
});
|
|
612
|
-
app.post("/api/favorite-paths", (req, res) => {
|
|
613
|
-
const { path: favPath, name, icon } = req.body;
|
|
614
|
-
if (!favPath) {
|
|
615
|
-
res.status(400).json({ error: "路径不能为空。" });
|
|
616
|
-
return;
|
|
617
|
-
}
|
|
618
|
-
const resolvedFavoritePath = normalizeFolderPath(favPath);
|
|
619
|
-
if (isBlockedFolderPath(resolvedFavoritePath)) {
|
|
620
|
-
res.status(403).json({ error: "访问被拒绝:无法收藏系统敏感目录。" });
|
|
621
|
-
return;
|
|
622
|
-
}
|
|
623
|
-
const stored = storage.getConfigValue("favorite_paths");
|
|
624
|
-
const favorites = parseStoredPathList(stored);
|
|
625
|
-
if (favorites.some((f) => normalizeFolderPath(f.path) === resolvedFavoritePath)) {
|
|
626
|
-
res.status(400).json({ error: "该路径已在收藏列表中。" });
|
|
627
|
-
return;
|
|
628
|
-
}
|
|
629
|
-
const newFavorite = {
|
|
630
|
-
path: resolvedFavoritePath,
|
|
631
|
-
name: name || path.basename(resolvedFavoritePath),
|
|
632
|
-
icon: icon || "⭐",
|
|
633
|
-
addedAt: new Date().toISOString(),
|
|
634
|
-
};
|
|
635
|
-
favorites.push(newFavorite);
|
|
636
|
-
storage.setConfigValue("favorite_paths", JSON.stringify(favorites));
|
|
637
|
-
res.status(201).json(newFavorite);
|
|
638
|
-
});
|
|
639
|
-
app.delete("/api/favorite-paths", (req, res) => {
|
|
640
|
-
const { path: delPath } = req.body;
|
|
641
|
-
if (!delPath) {
|
|
642
|
-
res.status(400).json({ error: "路径不能为空。" });
|
|
643
|
-
return;
|
|
644
|
-
}
|
|
645
|
-
const stored = storage.getConfigValue("favorite_paths");
|
|
646
|
-
const favorites = parseStoredPathList(stored);
|
|
647
|
-
const index = favorites.findIndex((f) => f.path === delPath);
|
|
648
|
-
if (index === -1) {
|
|
649
|
-
res.status(404).json({ error: "未找到该收藏路径。" });
|
|
650
|
-
return;
|
|
651
|
-
}
|
|
652
|
-
favorites.splice(index, 1);
|
|
653
|
-
storage.setConfigValue("favorite_paths", JSON.stringify(favorites));
|
|
654
|
-
res.json({ ok: true });
|
|
655
|
-
});
|
|
656
607
|
app.get("/api/recent-paths", (_req, res) => {
|
|
657
608
|
const stored = storage.getConfigValue("recent_paths");
|
|
658
609
|
const recent = parseStoredPathList(stored);
|
|
@@ -627,6 +627,23 @@ export class StructuredSessionManager {
|
|
|
627
627
|
sessionId,
|
|
628
628
|
data: { status: finished.status, exitCode: 0, messages: finished.messages, sessionKind: "structured", structuredState: finished.structuredState },
|
|
629
629
|
});
|
|
630
|
+
// Auto-continue after plan mode exit: when Claude calls ExitPlanMode,
|
|
631
|
+
// the `-p` process exits because stdin is "ignore" and it cannot get
|
|
632
|
+
// user confirmation. Detect this and automatically resume execution
|
|
633
|
+
// so the plan is actually carried out.
|
|
634
|
+
const lastToolUse = [...turnState.blocks].reverse().find((b) => b.type === "tool_use");
|
|
635
|
+
if (lastToolUse && lastToolUse.name === "ExitPlanMode" && turnState.sessionId) {
|
|
636
|
+
console.log("[WAND] ExitPlanMode detected – auto-continuing plan execution for session:", sessionId);
|
|
637
|
+
resolve();
|
|
638
|
+
// Schedule the continuation outside the current promise chain so it
|
|
639
|
+
// does not block the close handler.
|
|
640
|
+
setImmediate(() => {
|
|
641
|
+
this.sendMessage(sessionId, "Plan approved. Proceed with the implementation.").catch((err) => {
|
|
642
|
+
console.error("[WAND] Auto-continue after ExitPlanMode failed:", err);
|
|
643
|
+
});
|
|
644
|
+
});
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
630
647
|
resolve();
|
|
631
648
|
});
|
|
632
649
|
});
|
|
@@ -1030,14 +1030,13 @@
|
|
|
1030
1030
|
|
|
1031
1031
|
function selectAllVisibleItems() {
|
|
1032
1032
|
var nextSessionIds = {};
|
|
1033
|
-
state.sessions.
|
|
1033
|
+
state.sessions.filter(function(session) {
|
|
1034
|
+
return !session.resumedToSessionId;
|
|
1035
|
+
}).forEach(function(session) {
|
|
1034
1036
|
nextSessionIds[session.id] = true;
|
|
1035
1037
|
});
|
|
1036
|
-
var cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
1037
1038
|
var nextHistoryIds = {};
|
|
1038
|
-
getVisibleClaudeHistorySessions().
|
|
1039
|
-
return !session.timestamp || new Date(session.timestamp).getTime() <= cutoff;
|
|
1040
|
-
}).forEach(function(session) {
|
|
1039
|
+
getVisibleClaudeHistorySessions().forEach(function(session) {
|
|
1041
1040
|
nextHistoryIds[session.claudeSessionId] = true;
|
|
1042
1041
|
});
|
|
1043
1042
|
state.selectedSessionIds = nextSessionIds;
|
|
@@ -2349,69 +2348,58 @@
|
|
|
2349
2348
|
}
|
|
2350
2349
|
}
|
|
2351
2350
|
|
|
2352
|
-
// Helper functions for recent paths
|
|
2353
|
-
function
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
return [];
|
|
2359
|
-
}
|
|
2351
|
+
// Helper functions for recent paths (single source: backend API)
|
|
2352
|
+
function fetchRecentPaths(callback) {
|
|
2353
|
+
fetch("/api/recent-paths", { credentials: "same-origin" })
|
|
2354
|
+
.then(function(res) { return res.json(); })
|
|
2355
|
+
.then(function(items) { callback(items || []); })
|
|
2356
|
+
.catch(function() { callback([]); });
|
|
2360
2357
|
}
|
|
2361
2358
|
|
|
2362
2359
|
function addRecentPath(path) {
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
recent = recent.slice(0, 5);
|
|
2370
|
-
try {
|
|
2371
|
-
localStorage.setItem("wand-recent-paths", JSON.stringify(recent));
|
|
2372
|
-
} catch (e) {
|
|
2373
|
-
// Ignore localStorage errors
|
|
2374
|
-
}
|
|
2360
|
+
return fetch("/api/recent-paths", {
|
|
2361
|
+
method: "POST",
|
|
2362
|
+
headers: { "Content-Type": "application/json" },
|
|
2363
|
+
credentials: "same-origin",
|
|
2364
|
+
body: JSON.stringify({ path: path })
|
|
2365
|
+
}).catch(function() {});
|
|
2375
2366
|
}
|
|
2376
2367
|
|
|
2377
|
-
function
|
|
2378
|
-
|
|
2379
|
-
if (recent.length === 0) return "";
|
|
2380
|
-
|
|
2368
|
+
function renderRecentPathsHtml(items) {
|
|
2369
|
+
if (!items.length) return "";
|
|
2381
2370
|
var html = '<div class="folder-recent-section">' +
|
|
2382
2371
|
'<div class="folder-recent-title">最近使用</div>';
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
html += '<div class="folder-recent-item" data-path="' + escapeHtml(
|
|
2386
|
-
'<span class="folder-recent-item-
|
|
2387
|
-
'<span class="folder-recent-item-path">' + escapeHtml(path) + '</span>' +
|
|
2372
|
+
items.forEach(function(item) {
|
|
2373
|
+
var p = item.path || item;
|
|
2374
|
+
html += '<div class="folder-recent-item" data-path="' + escapeHtml(p) + '">' +
|
|
2375
|
+
'<span class="folder-recent-item-path">' + escapeHtml(p) + '</span>' +
|
|
2388
2376
|
'</div>';
|
|
2389
2377
|
});
|
|
2390
|
-
|
|
2391
2378
|
html += '</div>';
|
|
2392
2379
|
return html;
|
|
2393
2380
|
}
|
|
2394
2381
|
|
|
2395
2382
|
function showRecentPathsDropdown() {
|
|
2396
2383
|
if (!folderPickerDropdown) return;
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2384
|
+
fetchRecentPaths(function(items) {
|
|
2385
|
+
var recentHtml = renderRecentPathsHtml(items);
|
|
2386
|
+
if (recentHtml) {
|
|
2387
|
+
folderPickerDropdown.innerHTML = recentHtml;
|
|
2388
|
+
folderPickerDropdown.classList.remove("hidden");
|
|
2389
|
+
folderPickerDropdown.querySelectorAll(".folder-recent-item").forEach(function(item) {
|
|
2390
|
+
item.addEventListener("click", function() {
|
|
2391
|
+
var path = this.dataset.path;
|
|
2392
|
+
if (folderPickerInput) {
|
|
2393
|
+
folderPickerInput.value = path;
|
|
2394
|
+
saveWorkingDir(path);
|
|
2395
|
+
loadFolderSuggestions(path);
|
|
2396
|
+
}
|
|
2397
|
+
});
|
|
2410
2398
|
});
|
|
2411
|
-
}
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
}
|
|
2399
|
+
} else {
|
|
2400
|
+
hideFolderDropdown();
|
|
2401
|
+
}
|
|
2402
|
+
});
|
|
2415
2403
|
}
|
|
2416
2404
|
|
|
2417
2405
|
// Working directory indicator click handler for active sessions
|
|
@@ -2472,7 +2460,6 @@
|
|
|
2472
2460
|
var path = entry.fullPath;
|
|
2473
2461
|
folderPickerInput.value = path;
|
|
2474
2462
|
saveWorkingDir(path);
|
|
2475
|
-
addRecentPath(path);
|
|
2476
2463
|
loadFolderSuggestions(path);
|
|
2477
2464
|
break;
|
|
2478
2465
|
}
|
|
@@ -4560,6 +4547,7 @@
|
|
|
4560
4547
|
syncComposerModeSelect();
|
|
4561
4548
|
return createStructuredSession(undefined, cwd, mode)
|
|
4562
4549
|
.then(function(data) {
|
|
4550
|
+
saveWorkingDir(cwd);
|
|
4563
4551
|
closeSessionModal();
|
|
4564
4552
|
closeSessionsDrawer();
|
|
4565
4553
|
return data;
|
|
@@ -4597,6 +4585,7 @@
|
|
|
4597
4585
|
state.selectedId = data.id;
|
|
4598
4586
|
console.log("[WAND] runPtyCommandFromModal created session:", data.id, "sessionKind:", data.sessionKind, "runner:", data.runner);
|
|
4599
4587
|
persistSelectedId();
|
|
4588
|
+
saveWorkingDir(cwd);
|
|
4600
4589
|
state.drafts[data.id] = "";
|
|
4601
4590
|
resetChatRenderCache();
|
|
4602
4591
|
closeSessionModal();
|
|
@@ -4655,18 +4644,14 @@
|
|
|
4655
4644
|
function loadBlankChatCwdDropdown(dropdown) {
|
|
4656
4645
|
var defaultCwd = getConfigCwd();
|
|
4657
4646
|
dropdown.innerHTML = '<div class="blank-chat-cwd-loading">加载中...</div>';
|
|
4658
|
-
|
|
4659
|
-
.then(function(res) { return res.json(); })
|
|
4660
|
-
.then(function(items) {
|
|
4647
|
+
fetchRecentPaths(function(items) {
|
|
4661
4648
|
var html = "";
|
|
4662
|
-
// Default directory always first
|
|
4663
4649
|
var currentDir = state.workingDir || defaultCwd;
|
|
4664
4650
|
html += '<div class="blank-chat-cwd-item' + (currentDir === defaultCwd ? " active" : "") + '" data-path="' + escapeHtml(defaultCwd) + '">' +
|
|
4665
4651
|
'<span class="blank-chat-cwd-item-label">默认</span>' +
|
|
4666
4652
|
'<span class="blank-chat-cwd-item-path">' + escapeHtml(defaultCwd) + '</span>' +
|
|
4667
4653
|
'</div>';
|
|
4668
|
-
|
|
4669
|
-
if (items && items.length) {
|
|
4654
|
+
if (items.length) {
|
|
4670
4655
|
var seen = {};
|
|
4671
4656
|
seen[defaultCwd] = true;
|
|
4672
4657
|
items.forEach(function(item) {
|
|
@@ -4689,26 +4674,18 @@
|
|
|
4689
4674
|
dropdown.classList.add("hidden");
|
|
4690
4675
|
var arrow = document.getElementById("blank-chat-cwd-arrow");
|
|
4691
4676
|
if (arrow) arrow.textContent = "▼";
|
|
4692
|
-
// Update folder picker input if exists
|
|
4693
4677
|
var fpInput = document.getElementById("folder-picker-input");
|
|
4694
4678
|
if (fpInput) fpInput.value = path;
|
|
4695
4679
|
});
|
|
4696
4680
|
});
|
|
4697
|
-
|
|
4698
|
-
.catch(function() {
|
|
4699
|
-
dropdown.innerHTML = '<div class="blank-chat-cwd-item" data-path="' + escapeHtml(defaultCwd) + '">' +
|
|
4700
|
-
'<span class="blank-chat-cwd-item-path">' + escapeHtml(defaultCwd) + '</span>' +
|
|
4701
|
-
'</div>';
|
|
4702
|
-
});
|
|
4681
|
+
});
|
|
4703
4682
|
}
|
|
4704
4683
|
|
|
4705
4684
|
function loadRecentPathBubbles() {
|
|
4706
4685
|
var container = document.getElementById("recent-paths-bubbles");
|
|
4707
4686
|
if (!container) return;
|
|
4708
|
-
|
|
4709
|
-
|
|
4710
|
-
.then(function(items) {
|
|
4711
|
-
if (!items || !items.length) {
|
|
4687
|
+
fetchRecentPaths(function(items) {
|
|
4688
|
+
if (!items.length) {
|
|
4712
4689
|
container.innerHTML = "";
|
|
4713
4690
|
return;
|
|
4714
4691
|
}
|
|
@@ -4726,10 +4703,7 @@
|
|
|
4726
4703
|
}
|
|
4727
4704
|
});
|
|
4728
4705
|
});
|
|
4729
|
-
|
|
4730
|
-
.catch(function() {
|
|
4731
|
-
if (container) container.innerHTML = "";
|
|
4732
|
-
});
|
|
4706
|
+
});
|
|
4733
4707
|
}
|
|
4734
4708
|
|
|
4735
4709
|
function schedulePathSuggestions() {
|
|
@@ -8040,12 +8014,12 @@
|
|
|
8040
8014
|
function fullRenderChat() {
|
|
8041
8015
|
// Extract system info from PTY output
|
|
8042
8016
|
var systemInfo = extractPtySystemInfo(selectedSession.output, messages);
|
|
8043
|
-
|
|
8017
|
+
|
|
8044
8018
|
// Build HTML with system info cards interleaved
|
|
8045
8019
|
var html = '';
|
|
8046
8020
|
var reversedMessages = messages.slice().reverse();
|
|
8047
8021
|
var msgCount = messages.length;
|
|
8048
|
-
|
|
8022
|
+
|
|
8049
8023
|
for (var i = 0; i < reversedMessages.length; i++) {
|
|
8050
8024
|
var msg = reversedMessages[i];
|
|
8051
8025
|
var originalIndex = msgCount - 1 - i; // Original index in messages array
|
|
@@ -8070,7 +8044,7 @@
|
|
|
8070
8044
|
}
|
|
8071
8045
|
|
|
8072
8046
|
// Render message
|
|
8073
|
-
html += renderChatMessage(msg);
|
|
8047
|
+
html += renderChatMessage(msg, roundUsageByIndex[originalIndex] || null);
|
|
8074
8048
|
}
|
|
8075
8049
|
|
|
8076
8050
|
chatMessages.innerHTML = html;
|
|
@@ -8107,6 +8081,47 @@
|
|
|
8107
8081
|
});
|
|
8108
8082
|
}
|
|
8109
8083
|
|
|
8084
|
+
// Pre-compute per-round cumulative usage.
|
|
8085
|
+
// A "round" starts at a user message and includes all subsequent assistant turns
|
|
8086
|
+
// until the next user message. Only the last assistant in each round shows the total.
|
|
8087
|
+
var roundUsageByIndex = {};
|
|
8088
|
+
(function() {
|
|
8089
|
+
var acc = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, totalCostUsd: 0 };
|
|
8090
|
+
var lastAssistantIdx = -1;
|
|
8091
|
+
for (var mi = 0; mi < messages.length; mi++) {
|
|
8092
|
+
var m = messages[mi];
|
|
8093
|
+
if (m.role === "user") {
|
|
8094
|
+
if (lastAssistantIdx >= 0 && (acc.inputTokens > 0 || acc.outputTokens > 0 || acc.totalCostUsd > 0)) {
|
|
8095
|
+
roundUsageByIndex[lastAssistantIdx] = {
|
|
8096
|
+
inputTokens: acc.inputTokens,
|
|
8097
|
+
outputTokens: acc.outputTokens,
|
|
8098
|
+
cacheReadInputTokens: acc.cacheReadInputTokens,
|
|
8099
|
+
totalCostUsd: acc.totalCostUsd
|
|
8100
|
+
};
|
|
8101
|
+
}
|
|
8102
|
+
acc = { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, totalCostUsd: 0 };
|
|
8103
|
+
lastAssistantIdx = -1;
|
|
8104
|
+
} else if (m.role === "assistant" && m.usage) {
|
|
8105
|
+
var u = m.usage;
|
|
8106
|
+
acc.inputTokens += (u.inputTokens || 0);
|
|
8107
|
+
acc.outputTokens += (u.outputTokens || 0);
|
|
8108
|
+
acc.cacheReadInputTokens += (u.cacheReadInputTokens || 0);
|
|
8109
|
+
acc.totalCostUsd += (u.totalCostUsd || 0);
|
|
8110
|
+
lastAssistantIdx = mi;
|
|
8111
|
+
} else if (m.role === "assistant") {
|
|
8112
|
+
lastAssistantIdx = mi;
|
|
8113
|
+
}
|
|
8114
|
+
}
|
|
8115
|
+
if (lastAssistantIdx >= 0 && (acc.inputTokens > 0 || acc.outputTokens > 0 || acc.totalCostUsd > 0)) {
|
|
8116
|
+
roundUsageByIndex[lastAssistantIdx] = {
|
|
8117
|
+
inputTokens: acc.inputTokens,
|
|
8118
|
+
outputTokens: acc.outputTokens,
|
|
8119
|
+
cacheReadInputTokens: acc.cacheReadInputTokens,
|
|
8120
|
+
totalCostUsd: acc.totalCostUsd
|
|
8121
|
+
};
|
|
8122
|
+
}
|
|
8123
|
+
})();
|
|
8124
|
+
|
|
8110
8125
|
if (needsFullRender) {
|
|
8111
8126
|
fullRenderChat();
|
|
8112
8127
|
} else if (msgCount > existingCount) {
|
|
@@ -8118,7 +8133,8 @@
|
|
|
8118
8133
|
var insertedEls = [];
|
|
8119
8134
|
for (var i = 0; i < newMessages.length; i++) {
|
|
8120
8135
|
var div = document.createElement("div");
|
|
8121
|
-
|
|
8136
|
+
var nmOrigIdx = existingCount + (newMessages.length - 1 - i);
|
|
8137
|
+
div.innerHTML = renderChatMessage(newMessages[i], roundUsageByIndex[nmOrigIdx] || null);
|
|
8122
8138
|
var el = div.firstElementChild;
|
|
8123
8139
|
if (el) {
|
|
8124
8140
|
el.classList.add("animate-in");
|
|
@@ -8143,7 +8159,8 @@
|
|
|
8143
8159
|
for (var mi = 0; mi < reversedMessages.length && mi < existingEls.length; mi++) {
|
|
8144
8160
|
var currentEl = existingEls[mi];
|
|
8145
8161
|
var tmpWrap = document.createElement("div");
|
|
8146
|
-
|
|
8162
|
+
var srOrigIdx = reversedMessages.length - 1 - mi;
|
|
8163
|
+
tmpWrap.innerHTML = renderChatMessage(reversedMessages[mi], roundUsageByIndex[srOrigIdx] || null);
|
|
8147
8164
|
var replacementEl = tmpWrap.firstElementChild;
|
|
8148
8165
|
if (!replacementEl) continue;
|
|
8149
8166
|
if (currentEl.innerHTML !== replacementEl.innerHTML || currentEl.className !== replacementEl.className) {
|
|
@@ -8859,7 +8876,7 @@
|
|
|
8859
8876
|
return messages;
|
|
8860
8877
|
}
|
|
8861
8878
|
|
|
8862
|
-
function renderChatMessage(msg) {
|
|
8879
|
+
function renderChatMessage(msg, roundUsage) {
|
|
8863
8880
|
// Thinking card (deep thought) — from PTY parsing
|
|
8864
8881
|
if (msg.role === "thinking") {
|
|
8865
8882
|
return '<div class="chat-message thinking">' +
|
|
@@ -8883,7 +8900,7 @@
|
|
|
8883
8900
|
|
|
8884
8901
|
// Structured content blocks (from JSON chat mode)
|
|
8885
8902
|
if (Array.isArray(msg.content)) {
|
|
8886
|
-
return renderStructuredMessage(msg);
|
|
8903
|
+
return renderStructuredMessage(msg, roundUsage);
|
|
8887
8904
|
}
|
|
8888
8905
|
|
|
8889
8906
|
// Legacy string content (from PTY parsing)
|
|
@@ -8938,7 +8955,7 @@
|
|
|
8938
8955
|
return '<div class="structured-tool-hint">已自动恢复一次 ' + escapeHtml(getToolDisplayName(toolName)) + ' 参数问题</div>';
|
|
8939
8956
|
}
|
|
8940
8957
|
|
|
8941
|
-
function renderStructuredMessage(msg) {
|
|
8958
|
+
function renderStructuredMessage(msg, roundUsage) {
|
|
8942
8959
|
var role = msg.role;
|
|
8943
8960
|
var avatar = role === "assistant" ? '<div class="chat-message-avatar">赛博虎妞</div>' : "";
|
|
8944
8961
|
|
|
@@ -8972,13 +8989,13 @@
|
|
|
8972
8989
|
}
|
|
8973
8990
|
|
|
8974
8991
|
var usageHtml = "";
|
|
8975
|
-
if (role === "assistant" &&
|
|
8976
|
-
var u =
|
|
8992
|
+
if (role === "assistant" && roundUsage) {
|
|
8993
|
+
var u = roundUsage;
|
|
8977
8994
|
var parts = [];
|
|
8978
|
-
if (u.inputTokens
|
|
8979
|
-
if (u.outputTokens
|
|
8980
|
-
if (u.cacheReadInputTokens
|
|
8981
|
-
if (u.totalCostUsd
|
|
8995
|
+
if (u.inputTokens > 0) parts.push("输入 " + u.inputTokens);
|
|
8996
|
+
if (u.outputTokens > 0) parts.push("输出 " + u.outputTokens);
|
|
8997
|
+
if (u.cacheReadInputTokens > 0) parts.push("缓存 " + u.cacheReadInputTokens);
|
|
8998
|
+
if (u.totalCostUsd > 0) parts.push("$" + u.totalCostUsd.toFixed(4));
|
|
8982
8999
|
if (parts.length > 0) {
|
|
8983
9000
|
usageHtml = '<div class="message-usage">' + parts.join(" · ") + '</div>';
|
|
8984
9001
|
}
|
|
@@ -7525,29 +7525,29 @@
|
|
|
7525
7525
|
.structured-status-bar {
|
|
7526
7526
|
display: flex;
|
|
7527
7527
|
align-items: center;
|
|
7528
|
-
gap:
|
|
7529
|
-
margin:
|
|
7530
|
-
padding:
|
|
7528
|
+
gap: 8px;
|
|
7529
|
+
margin: 0;
|
|
7530
|
+
padding: 4px 10px;
|
|
7531
7531
|
border-radius: var(--radius-sm);
|
|
7532
|
-
background:
|
|
7533
|
-
border:
|
|
7534
|
-
font-size: 0.
|
|
7535
|
-
color: var(--text-
|
|
7532
|
+
background: transparent;
|
|
7533
|
+
border: none;
|
|
7534
|
+
font-size: 0.6875rem;
|
|
7535
|
+
color: var(--text-muted);
|
|
7536
7536
|
transition: all 0.3s ease;
|
|
7537
7537
|
overflow: hidden;
|
|
7538
7538
|
}
|
|
7539
7539
|
|
|
7540
7540
|
.structured-status-bar .status-bar-label {
|
|
7541
7541
|
flex-shrink: 0;
|
|
7542
|
-
font-weight:
|
|
7543
|
-
color: var(--
|
|
7542
|
+
font-weight: 500;
|
|
7543
|
+
color: var(--text-muted);
|
|
7544
7544
|
}
|
|
7545
7545
|
|
|
7546
7546
|
.structured-status-bar .status-bar-track {
|
|
7547
7547
|
flex: 1;
|
|
7548
|
-
height:
|
|
7549
|
-
border-radius:
|
|
7550
|
-
background: rgba(var(--accent-rgb, 99, 102, 241), 0.
|
|
7548
|
+
height: 2px;
|
|
7549
|
+
border-radius: 1px;
|
|
7550
|
+
background: rgba(var(--accent-rgb, 99, 102, 241), 0.08);
|
|
7551
7551
|
overflow: hidden;
|
|
7552
7552
|
position: relative;
|
|
7553
7553
|
}
|
|
@@ -7556,9 +7556,9 @@
|
|
|
7556
7556
|
position: absolute;
|
|
7557
7557
|
top: 0;
|
|
7558
7558
|
left: 0;
|
|
7559
|
-
width:
|
|
7559
|
+
width: 30%;
|
|
7560
7560
|
height: 100%;
|
|
7561
|
-
border-radius:
|
|
7561
|
+
border-radius: 1px;
|
|
7562
7562
|
background: linear-gradient(90deg, transparent, var(--accent-soft), transparent);
|
|
7563
7563
|
animation: marqueeScroll 1.5s ease-in-out infinite;
|
|
7564
7564
|
}
|
|
@@ -7572,14 +7572,14 @@
|
|
|
7572
7572
|
flex-shrink: 0;
|
|
7573
7573
|
font-variant-numeric: tabular-nums;
|
|
7574
7574
|
font-family: var(--font-mono);
|
|
7575
|
-
font-size: 0.
|
|
7575
|
+
font-size: 0.625rem;
|
|
7576
7576
|
color: var(--text-muted);
|
|
7577
7577
|
}
|
|
7578
7578
|
|
|
7579
7579
|
/* 完成态 */
|
|
7580
7580
|
.structured-status-bar.completed {
|
|
7581
|
-
background:
|
|
7582
|
-
border-color:
|
|
7581
|
+
background: transparent;
|
|
7582
|
+
border-color: transparent;
|
|
7583
7583
|
animation: statusBarFadeOut 2s ease-out 1s forwards;
|
|
7584
7584
|
}
|
|
7585
7585
|
|
|
@@ -7600,9 +7600,9 @@
|
|
|
7600
7600
|
}
|
|
7601
7601
|
|
|
7602
7602
|
@keyframes statusBarFadeOut {
|
|
7603
|
-
0% { opacity: 1; max-height:
|
|
7604
|
-
70% { opacity: 0; max-height:
|
|
7605
|
-
100% { opacity: 0; max-height: 0; margin: 0; padding: 0
|
|
7603
|
+
0% { opacity: 1; max-height: 30px; margin: 0; padding: 4px 10px; }
|
|
7604
|
+
70% { opacity: 0; max-height: 30px; margin: 0; padding: 4px 10px; }
|
|
7605
|
+
100% { opacity: 0; max-height: 0; margin: 0; padding: 0 10px; border-width: 0; }
|
|
7606
7606
|
}
|
|
7607
7607
|
|
|
7608
7608
|
/* 结束标记 */
|