@liguoshuai/pi-web-chat 2.5.1 → 2.9.0
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/docs/CHANGELOG.md +68 -0
- package/package.json +4 -4
- package/public/app.js +239 -29
- package/public/icon.svg +11 -0
- package/public/index.html +6 -1
- package/public/manifest.webmanifest +17 -0
- package/public/style.css +68 -0
package/docs/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [2.9.0] - 2026-09-02
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **思考耗时与执行耗时多端展示特性 (Thinking & Execution Duration Display)**:
|
|
14
|
+
- **Web 端 (`clients/web`)**:
|
|
15
|
+
- 思考过程块 (`.thinking-block`):流式思考进行中实时显示动态耗时(例如 `3.2s`),思考完成显示完整用时徽章(例如 `用时 3.2s`)。
|
|
16
|
+
- 工具执行块 (`.tool-block`):执行中显示动态计时(例如 `执行中 · 1.2s`),执行完毕显示最终耗时(例如 `完成 · 1.2s` 或 `错误 · 0.8s`)。
|
|
17
|
+
- 助手回复消息头部 (`.role-tag`):实时显示本次回答生成总耗时,并在生成完成后展示总耗时(例如 `耗时 4.5s`)。
|
|
18
|
+
- 历史会话加载 (`reconstructFromEntries`):支持自动从历史日志计算各工具调用耗时与助手生成总耗时并渲染。
|
|
19
|
+
- **Android 原生端 (`clients/android`)**:
|
|
20
|
+
- `ChatMessage` 与 `ToolCall` 模型扩充思考用时 (`thinkingDurationMs`) 与执行耗时 (`durationMs`, `turnDurationMs`) 属性。
|
|
21
|
+
- Compose 界面中的 `ThinkingBlock`、`ToolCallBlock` 以及 `MessageBubble` 均支持清晰展示思考耗时、工具执行耗时与回答生成总用时。
|
|
22
|
+
- 历史会话加载支持还原思考与工具执行耗时。
|
|
23
|
+
- **HarmonyOS 鸿蒙原生端 (`clients/harmony`)**:
|
|
24
|
+
- `ChatMessage` 增加 `thinkingDuration` 与 `turnDuration` 属性支持。
|
|
25
|
+
- `Index.ets` 在思考过程与助手回复头部优雅展示思考耗时与执行总耗时。
|
|
26
|
+
- **跨端协议与公共库 (`@liguoshuai/pi-chat-protocol`)**:
|
|
27
|
+
- 新增跨端标准耗时格式化函数 `formatDuration(ms)` 与单元测试。
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
- 全端版本号统一升级至 2.9.0(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## [2.8.0] - 2026-09-02
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
- 前端思考过程与工具调用块时间戳显示:在思考过程块和工具调用块中添加时间戳,格式为当天 HH:mm,跨天 MM-dd HH:mm。
|
|
38
|
+
|
|
39
|
+
## [2.7.0] - 2026-09-01
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
- **网关 REST CORS 跨域支持与预检支持 (`server`)**:
|
|
43
|
+
- 在 Express 网关服务中增加标准 CORS 拦截器,支持自定义 `ALLOWED_ORIGINS` 配置与通配放行,支持 `OPTIONS` preflight 预检请求与相关请求头(`Authorization`, `Content-Type`, `x-api-token`)。
|
|
44
|
+
- **服务端环境变量自动检测与多宿主兼容 (`server`)**:
|
|
45
|
+
- 服务端配置模块增加自动检测并读取根目录或包内 `.env` 文件的能力。
|
|
46
|
+
- 完善 Windows 宿主机上的 `pi` 执行文件解析(`.cmd` / `.exe`)与 `spawn` 平台兼容性。
|
|
47
|
+
- **Web 端 PWA (Progressive Web App) 支持 (`clients/web`)**:
|
|
48
|
+
- 新增 `manifest.webmanifest` 与高清矢量 `icon.svg`,支持在手机与桌面浏览器中一键“添加到主屏幕”,享受沉浸式应用体验。
|
|
49
|
+
- **移动端指数退避断线重连 (`clients/android` & `clients/harmony`)**:
|
|
50
|
+
- Android 原生端 (`WebSocketClient.kt`) 与 HarmonyOS 鸿蒙原生端 (`WebSocketManager.ets`) 统一引入 Exponential Backoff + Jitter(指数退避与随机抖动重连算法),有效防止服务端重启或网络抖动时的瞬时并发重连风暴。
|
|
51
|
+
- **CI 工作流强化 (`.github/workflows/ci.yml`)**:
|
|
52
|
+
- 在 GitHub Actions 中增加 Android 原生端自动化构建与依赖检查(`gradlew assembleDebug`)。
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
- 全端版本号统一升级至 2.7.0(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## [2.6.0] - 2026-09-01
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
- **跨端协议增强与强类型声明 (`@liguoshuai/pi-chat-protocol`)**:
|
|
63
|
+
- 新增 `createBackfillStartMessage` 与 `createBackfillEndMessage` 协议消息构造器,支持在断线重连回放中透传 `overflowed` 标志。
|
|
64
|
+
- 新增 `ToolCallPayload` 与 `ToolResultPayload` 强类型接口,增强跨端工具调用的类型安全性。
|
|
65
|
+
- 在 Android (`PiChatProtocol.kt`) 与 HarmonyOS (`PiProtocol.ets`) 客户端协议模型中同步补齐 `overflowed` 等字段。
|
|
66
|
+
- **网关断线溢出检测与客户端自愈补偿 (`server` & `clients/web`)**:
|
|
67
|
+
- 网关 `PiAgent` 增加事件环形缓冲区溢出状态跟踪(`hasBufferOverflowed`),在客户端重连事件回放结束(`backfill_end`)时明确通知客户端。
|
|
68
|
+
- Web 客户端侦测到 `overflowed: true` 时,自动在后台触发增量/完整历史记录无感同步(`syncSessionHistory`),彻底杜绝网络严重抖动或长时间离线导致的消息截断。
|
|
69
|
+
- **Web 前端流式渲染性能与快捷键体验优化 (`clients/web`)**:
|
|
70
|
+
- 流式更新渲染全面统一采用 `requestAnimationFrame` 防抖批量刷新(`refreshStreamingContentDebounced`),消除高频 token 吐出时的页面重排重绘瓶颈。
|
|
71
|
+
- 新增 `Cmd/Ctrl + Shift + O` 快捷新建会话别名,适配主流 AI 交互习惯。
|
|
72
|
+
|
|
73
|
+
### Changed
|
|
74
|
+
- 全端版本号统一升级至 2.6.0(Monorepo Lockstep:Root / Protocol / Server / Web / Android / HarmonyOS)。
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
10
78
|
## [2.5.1] - 2026-09-01
|
|
11
79
|
|
|
12
80
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liguoshuai/pi-web-chat",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.0",
|
|
4
4
|
"description": "A ChatGPT/Gemini-style web UI for the pi coding agent, powered by pi's RPC mode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|
|
@@ -40,10 +40,10 @@
|
|
|
40
40
|
"node": ">=18"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@liguoshuai/pi-chat-protocol": "2.5.1",
|
|
44
|
-
"@liguoshuai/pi-chat-server": "2.5.1",
|
|
45
43
|
"express": "^4.21.2",
|
|
46
|
-
"ws": "^8.18.0"
|
|
44
|
+
"ws": "^8.18.0",
|
|
45
|
+
"@liguoshuai/pi-chat-protocol": "2.9.0",
|
|
46
|
+
"@liguoshuai/pi-chat-server": "2.9.0"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
49
|
"build": "node --check server.js && node --check bin/pi-web-chat.js && node --check public/app.js",
|
package/public/app.js
CHANGED
|
@@ -75,6 +75,8 @@ const state = {
|
|
|
75
75
|
isBackfilling: false,
|
|
76
76
|
aborting: false,
|
|
77
77
|
attachedImages: [], // Array of { data: string (base64), mimeType: string, url: string }
|
|
78
|
+
turnStartedAt: null,
|
|
79
|
+
streamingMsgDurationEl: null,
|
|
78
80
|
};
|
|
79
81
|
|
|
80
82
|
let toastTimer = null;
|
|
@@ -547,6 +549,52 @@ function formatMessageTime(ts) {
|
|
|
547
549
|
return `${year}-${month}-${date} ${timeStr}`;
|
|
548
550
|
}
|
|
549
551
|
|
|
552
|
+
// ---- Duration Formatter ----
|
|
553
|
+
function formatDuration(ms) {
|
|
554
|
+
if (ms == null || isNaN(ms) || ms < 0) return "";
|
|
555
|
+
if (ms < 1000) {
|
|
556
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
557
|
+
}
|
|
558
|
+
if (ms < 60000) {
|
|
559
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
560
|
+
}
|
|
561
|
+
const mins = Math.floor(ms / 60000);
|
|
562
|
+
const secs = Math.round((ms % 60000) / 1000);
|
|
563
|
+
return `${mins}m ${secs}s`;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
let liveTimerInterval = null;
|
|
567
|
+
function startStreamingTimer() {
|
|
568
|
+
if (liveTimerInterval) return;
|
|
569
|
+
liveTimerInterval = setInterval(() => {
|
|
570
|
+
const now = Date.now();
|
|
571
|
+
if (state.turnStartedAt && state.streamingMsgDurationEl) {
|
|
572
|
+
state.streamingMsgDurationEl.textContent = formatDuration(now - state.turnStartedAt);
|
|
573
|
+
}
|
|
574
|
+
const liveThinking = document.querySelectorAll(".thinking-duration.live");
|
|
575
|
+
liveThinking.forEach((el) => {
|
|
576
|
+
if (el._startedAt) {
|
|
577
|
+
el.textContent = formatDuration(now - el._startedAt);
|
|
578
|
+
el.style.display = "";
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
const liveTools = document.querySelectorAll(".tool-duration.live");
|
|
582
|
+
liveTools.forEach((el) => {
|
|
583
|
+
if (el._startedAt) {
|
|
584
|
+
el.textContent = formatDuration(now - el._startedAt);
|
|
585
|
+
el.style.display = "";
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
}, 100);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
function stopStreamingTimer() {
|
|
592
|
+
if (liveTimerInterval) {
|
|
593
|
+
clearInterval(liveTimerInterval);
|
|
594
|
+
liveTimerInterval = null;
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
|
|
550
598
|
function sameSession(a, b) {
|
|
551
599
|
if (!a || !b) return false;
|
|
552
600
|
if (a === b) return true;
|
|
@@ -822,6 +870,13 @@ async function loadSession(file) {
|
|
|
822
870
|
}
|
|
823
871
|
}
|
|
824
872
|
|
|
873
|
+
function parseEntryTimestamp(ts) {
|
|
874
|
+
if (!ts) return null;
|
|
875
|
+
if (typeof ts === "number") return ts;
|
|
876
|
+
const parsed = new Date(ts).getTime();
|
|
877
|
+
return isNaN(parsed) ? null : parsed;
|
|
878
|
+
}
|
|
879
|
+
|
|
825
880
|
function reconstructFromEntries(entries) {
|
|
826
881
|
// Map toolResults by toolCallId so we can attach them to their toolCall in the assistant message
|
|
827
882
|
const toolResults = new Map();
|
|
@@ -829,17 +884,23 @@ function reconstructFromEntries(entries) {
|
|
|
829
884
|
if (e.type === "message" && e.message?.role === "toolResult") {
|
|
830
885
|
const m = e.message;
|
|
831
886
|
if (m.toolCallId) {
|
|
832
|
-
toolResults.set(m.toolCallId,
|
|
887
|
+
toolResults.set(m.toolCallId, {
|
|
888
|
+
...m,
|
|
889
|
+
entryTs: parseEntryTimestamp(m.timestamp || e.timestamp)
|
|
890
|
+
});
|
|
833
891
|
}
|
|
834
892
|
}
|
|
835
893
|
}
|
|
836
894
|
|
|
837
895
|
const out = [];
|
|
896
|
+
let lastUserTs = null;
|
|
838
897
|
for (const e of entries) {
|
|
839
898
|
if (e.type !== "message") continue;
|
|
840
899
|
const m = e.message;
|
|
841
900
|
if (!m || m.role === "bashExecution") continue;
|
|
901
|
+
const msgTs = parseEntryTimestamp(m.timestamp || e.timestamp);
|
|
842
902
|
if (m.role === "user") {
|
|
903
|
+
lastUserTs = msgTs;
|
|
843
904
|
// Extract optional images from user message content array
|
|
844
905
|
let images = [];
|
|
845
906
|
if (Array.isArray(m.content)) {
|
|
@@ -851,13 +912,28 @@ function reconstructFromEntries(entries) {
|
|
|
851
912
|
url: c.data ? `data:${c.mimeType || "image/png"};base64,${c.data}` : (c.url || "")
|
|
852
913
|
}));
|
|
853
914
|
}
|
|
854
|
-
out.push({ role: "user", text: extractContentText(m.content), images, ts:
|
|
915
|
+
out.push({ role: "user", text: extractContentText(m.content), images, ts: msgTs });
|
|
855
916
|
} else if (m.role === "assistant") {
|
|
917
|
+
let turnDurationMs = null;
|
|
918
|
+
if (lastUserTs && msgTs && msgTs >= lastUserTs) {
|
|
919
|
+
const diff = msgTs - lastUserTs;
|
|
920
|
+
if (diff > 0 && diff < 15 * 60 * 1000) {
|
|
921
|
+
turnDurationMs = diff;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
856
924
|
const rawContent = Array.isArray(m.content) ? m.content : (m.content ? [{ type: "text", text: String(m.content) }] : []);
|
|
857
925
|
const content = rawContent.map(part => {
|
|
858
926
|
if (part && part.type === "toolCall") {
|
|
859
927
|
const res = toolResults.get(part.id);
|
|
860
|
-
|
|
928
|
+
let durationMs = null;
|
|
929
|
+
if (res) {
|
|
930
|
+
const startTs = parseEntryTimestamp(part.timestamp || m.timestamp || e.timestamp);
|
|
931
|
+
const endTs = res.entryTs || parseEntryTimestamp(res.timestamp);
|
|
932
|
+
if (startTs && endTs && endTs >= startTs) {
|
|
933
|
+
durationMs = endTs - startTs;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
return { ...part, result: res || null, durationMs };
|
|
861
937
|
}
|
|
862
938
|
return part;
|
|
863
939
|
});
|
|
@@ -869,7 +945,7 @@ function reconstructFromEntries(entries) {
|
|
|
869
945
|
} catch {}
|
|
870
946
|
content.push({ type: "text", text: `⚠️ **生成失败**: ${errMsg}` });
|
|
871
947
|
}
|
|
872
|
-
out.push({ role: "assistant", content, ts:
|
|
948
|
+
out.push({ role: "assistant", content, ts: msgTs, turnDurationMs, usage: m.usage });
|
|
873
949
|
}
|
|
874
950
|
// toolResult entries are attached directly to assistant toolCall parts, so they don't produce standalone messages
|
|
875
951
|
}
|
|
@@ -1274,6 +1350,9 @@ function renderAssistantBlock(m) {
|
|
|
1274
1350
|
if (timeStr) {
|
|
1275
1351
|
roleChildren.push(el("span", { class: "msg-time", text: timeStr, title: fullTimeStr }));
|
|
1276
1352
|
}
|
|
1353
|
+
if (m.turnDurationMs != null && m.turnDurationMs > 0) {
|
|
1354
|
+
roleChildren.push(el("span", { class: "msg-duration", text: `耗时 ${formatDuration(m.turnDurationMs)}`, title: "生成总耗时" }));
|
|
1355
|
+
}
|
|
1277
1356
|
roleChildren.push(copyMsgBtn);
|
|
1278
1357
|
|
|
1279
1358
|
const node = el("div", { class: "msg assistant" }, [
|
|
@@ -1288,7 +1367,7 @@ function renderAssistantBlock(m) {
|
|
|
1288
1367
|
const div = el("div", { html: renderMarkdown(c.text) });
|
|
1289
1368
|
content.appendChild(div);
|
|
1290
1369
|
} else if (c.type === "thinking") {
|
|
1291
|
-
content.appendChild(makeThinkingBlock(c.thinking));
|
|
1370
|
+
content.appendChild(makeThinkingBlock(c.thinking, false, m.ts, c.durationMs));
|
|
1292
1371
|
} else if (c.type === "toolCall") {
|
|
1293
1372
|
content.appendChild(makeToolBlockFromCall(c));
|
|
1294
1373
|
}
|
|
@@ -1300,8 +1379,24 @@ function renderAssistantBlock(m) {
|
|
|
1300
1379
|
return node;
|
|
1301
1380
|
}
|
|
1302
1381
|
|
|
1303
|
-
function makeThinkingBlock(thinkingText, isActivelyThinking = false) {
|
|
1382
|
+
function makeThinkingBlock(thinkingText, isActivelyThinking = false, ts = null, durationMs = null, startedAt = null) {
|
|
1304
1383
|
const block = el("div", { class: "thinking-block" + (isActivelyThinking ? " active" : "") });
|
|
1384
|
+
|
|
1385
|
+
const curDuration = durationMs != null
|
|
1386
|
+
? durationMs
|
|
1387
|
+
: (isActivelyThinking && startedAt ? Math.max(0, Date.now() - startedAt) : null);
|
|
1388
|
+
|
|
1389
|
+
const durationText = curDuration != null ? (isActivelyThinking ? formatDuration(curDuration) : `用时 ${formatDuration(curDuration)}`) : "";
|
|
1390
|
+
|
|
1391
|
+
const durationEl = el("span", {
|
|
1392
|
+
class: "thinking-duration" + (isActivelyThinking ? " live" : ""),
|
|
1393
|
+
text: durationText,
|
|
1394
|
+
style: durationText ? "" : "display: none;"
|
|
1395
|
+
});
|
|
1396
|
+
if (isActivelyThinking && startedAt) {
|
|
1397
|
+
durationEl._startedAt = startedAt;
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1305
1400
|
const head = el("div", {
|
|
1306
1401
|
class: "thinking-head",
|
|
1307
1402
|
onclick: () => {
|
|
@@ -1312,20 +1407,23 @@ function makeThinkingBlock(thinkingText, isActivelyThinking = false) {
|
|
|
1312
1407
|
}
|
|
1313
1408
|
}, [
|
|
1314
1409
|
el("span", { class: "thinking-title", text: isActivelyThinking ? "💭 正在思考中…" : "💭 思考过程" }),
|
|
1410
|
+
durationEl,
|
|
1411
|
+
ts ? el("span", { class: "thinking-time", text: formatMessageTime(ts) }) : null,
|
|
1315
1412
|
isActivelyThinking ? el("span", { class: "thinking-pulse" }) : null,
|
|
1316
1413
|
el("span", { class: "thinking-toggle-hint", text: "(点击展开/收起)" }),
|
|
1317
1414
|
]);
|
|
1318
|
-
|
|
1415
|
+
|
|
1319
1416
|
const body = el("div", { class: "thinking-body", html: escapeHtml(thinkingText) });
|
|
1320
|
-
|
|
1417
|
+
|
|
1321
1418
|
if (state.thinkingUserToggled) {
|
|
1322
1419
|
body.style.display = state.thinkingOpen ? "block" : "none";
|
|
1323
1420
|
} else {
|
|
1324
1421
|
body.style.display = isActivelyThinking ? "block" : "none";
|
|
1325
1422
|
}
|
|
1326
|
-
|
|
1423
|
+
|
|
1327
1424
|
block.appendChild(head);
|
|
1328
1425
|
block.appendChild(body);
|
|
1426
|
+
block._durationEl = durationEl;
|
|
1329
1427
|
return block;
|
|
1330
1428
|
}
|
|
1331
1429
|
|
|
@@ -1375,7 +1473,7 @@ function updateToolBlockCopyBtn(tc, call) {
|
|
|
1375
1473
|
tc.head._cmdToCopy = cmd;
|
|
1376
1474
|
}
|
|
1377
1475
|
|
|
1378
|
-
function makeToolBlockFromCall(call) {
|
|
1476
|
+
function makeToolBlockFromCall(call, ts = null) {
|
|
1379
1477
|
const block = el("div", { class: "tool-block" });
|
|
1380
1478
|
const hasResult = Boolean(call.result);
|
|
1381
1479
|
const isError = call.result ? Boolean(call.result.isError) : false;
|
|
@@ -1385,8 +1483,24 @@ function makeToolBlockFromCall(call) {
|
|
|
1385
1483
|
resultText = extractContentText(call.result.content);
|
|
1386
1484
|
}
|
|
1387
1485
|
|
|
1388
|
-
const stateText = hasResult ? (isError ? "错误" : "完成") : "
|
|
1389
|
-
const stateClass = "state" + (hasResult && isError ? " error" : "");
|
|
1486
|
+
const stateText = hasResult ? (isError ? "错误" : "完成") : "执行中…";
|
|
1487
|
+
const stateClass = "state" + (hasResult && isError ? " error" : "") + (!hasResult ? " running" : "");
|
|
1488
|
+
|
|
1489
|
+
const durationMs = call.durationMs != null
|
|
1490
|
+
? call.durationMs
|
|
1491
|
+
: (call.startedAt && call.endedAt ? Math.max(0, call.endedAt - call.startedAt) : null);
|
|
1492
|
+
const durationText = durationMs != null
|
|
1493
|
+
? `耗时 ${formatDuration(durationMs)}`
|
|
1494
|
+
: (!hasResult && call.startedAt ? formatDuration(Date.now() - call.startedAt) : "");
|
|
1495
|
+
|
|
1496
|
+
const durationEl = el("span", {
|
|
1497
|
+
class: "tool-duration" + (!hasResult ? " live" : ""),
|
|
1498
|
+
text: durationText,
|
|
1499
|
+
style: durationText ? "" : "display: none;"
|
|
1500
|
+
});
|
|
1501
|
+
if (!hasResult && call.startedAt) {
|
|
1502
|
+
durationEl._startedAt = call.startedAt;
|
|
1503
|
+
}
|
|
1390
1504
|
|
|
1391
1505
|
const cmdToCopy = getToolCommandToCopy(call);
|
|
1392
1506
|
const copyBtn = cmdToCopy ? el("button", {
|
|
@@ -1414,8 +1528,10 @@ function makeToolBlockFromCall(call) {
|
|
|
1414
1528
|
const head = el("div", { class: "tool-head" }, [
|
|
1415
1529
|
el("span", { class: "ic", text: "⚙" }),
|
|
1416
1530
|
el("span", { class: "name", text: call.name }),
|
|
1531
|
+
ts ? el("span", { class: "tool-time", text: formatMessageTime(ts) }) : null,
|
|
1417
1532
|
el("span", { class: "args", text: summaryArgs(call.name, call.arguments) }),
|
|
1418
1533
|
copyBtn,
|
|
1534
|
+
durationEl,
|
|
1419
1535
|
el("span", { class: stateClass, text: stateText }),
|
|
1420
1536
|
]);
|
|
1421
1537
|
head._cmdToCopy = cmdToCopy;
|
|
@@ -1433,9 +1549,10 @@ function makeToolBlockFromCall(call) {
|
|
|
1433
1549
|
block._head = head;
|
|
1434
1550
|
block._body = body;
|
|
1435
1551
|
block._callId = call.id;
|
|
1552
|
+
block._durationEl = durationEl;
|
|
1436
1553
|
|
|
1437
1554
|
if (!hasResult && call.id) {
|
|
1438
|
-
state.activeToolCalls.set(call.id, { block, body, head });
|
|
1555
|
+
state.activeToolCalls.set(call.id, { block, body, head, durationEl, startedAt: call.startedAt || Date.now() });
|
|
1439
1556
|
}
|
|
1440
1557
|
|
|
1441
1558
|
return block;
|
|
@@ -1481,6 +1598,8 @@ function getStreamingFullText() {
|
|
|
1481
1598
|
function ensureStreamingMsg(ts = Date.now()) {
|
|
1482
1599
|
if (state.streamingMsg) return state.streamingMsg;
|
|
1483
1600
|
showEmptyState(false);
|
|
1601
|
+
state.turnStartedAt = ts || Date.now();
|
|
1602
|
+
startStreamingTimer();
|
|
1484
1603
|
const timeStr = formatMessageTime(ts);
|
|
1485
1604
|
const fullTimeStr = (ts && !isNaN(new Date(ts).getTime())) ? new Date(ts).toLocaleString("zh-CN") : "";
|
|
1486
1605
|
|
|
@@ -1500,12 +1619,15 @@ function ensureStreamingMsg(ts = Date.now()) {
|
|
|
1500
1619
|
el("span", { text: "复制全文" })
|
|
1501
1620
|
]);
|
|
1502
1621
|
|
|
1622
|
+
const durationEl = el("span", { class: "msg-duration live", text: "0.0s", title: "生成耗时" });
|
|
1623
|
+
|
|
1503
1624
|
const roleChildren = [
|
|
1504
1625
|
el("span", { class: "role-name", text: "pi" }),
|
|
1505
1626
|
];
|
|
1506
1627
|
if (timeStr) {
|
|
1507
1628
|
roleChildren.push(el("span", { class: "msg-time", text: timeStr, title: fullTimeStr }));
|
|
1508
1629
|
}
|
|
1630
|
+
roleChildren.push(durationEl);
|
|
1509
1631
|
roleChildren.push(copyBtn);
|
|
1510
1632
|
|
|
1511
1633
|
const node = el("div", { class: "msg assistant" }, [
|
|
@@ -1513,6 +1635,7 @@ function ensureStreamingMsg(ts = Date.now()) {
|
|
|
1513
1635
|
el("div", { class: "content" }),
|
|
1514
1636
|
]);
|
|
1515
1637
|
state.streamingMsg = node;
|
|
1638
|
+
state.streamingMsgDurationEl = durationEl;
|
|
1516
1639
|
state.streamingItems = [];
|
|
1517
1640
|
state.thinkingOpen = true;
|
|
1518
1641
|
state.thinkingUserToggled = false;
|
|
@@ -1554,8 +1677,8 @@ function refreshStreamingContent() {
|
|
|
1554
1677
|
const item = state.streamingItems[i];
|
|
1555
1678
|
const isLast = (i === lastIdx);
|
|
1556
1679
|
if (item.type === "thinking") {
|
|
1557
|
-
const isActivelyThinking = state.streaming && isLast;
|
|
1558
|
-
content.appendChild(makeThinkingBlock(item.text, isActivelyThinking));
|
|
1680
|
+
const isActivelyThinking = state.streaming && isLast && item.isStreaming !== false;
|
|
1681
|
+
content.appendChild(makeThinkingBlock(item.text, isActivelyThinking, item.ts, item.durationMs, item.startedAt));
|
|
1559
1682
|
} else if (item.type === "tool") {
|
|
1560
1683
|
if (item.tc?.block) content.appendChild(item.tc.block);
|
|
1561
1684
|
} else if (item.type === "text") {
|
|
@@ -1568,8 +1691,25 @@ function refreshStreamingContent() {
|
|
|
1568
1691
|
|
|
1569
1692
|
function finalizeStreamingMsg() {
|
|
1570
1693
|
state.streaming = false;
|
|
1694
|
+
stopStreamingTimer();
|
|
1571
1695
|
if (state.streamingMsg) {
|
|
1696
|
+
for (const item of state.streamingItems) {
|
|
1697
|
+
if (item.type === "thinking" && item.isStreaming) {
|
|
1698
|
+
item.isStreaming = false;
|
|
1699
|
+
item.endedAt = Date.now();
|
|
1700
|
+
item.durationMs = Math.max(0, item.endedAt - (item.startedAt || item.ts || Date.now()));
|
|
1701
|
+
}
|
|
1702
|
+
}
|
|
1572
1703
|
refreshStreamingContent();
|
|
1704
|
+
|
|
1705
|
+
if (state.turnStartedAt) {
|
|
1706
|
+
const turnDuration = Math.max(0, Date.now() - state.turnStartedAt);
|
|
1707
|
+
if (state.streamingMsgDurationEl) {
|
|
1708
|
+
state.streamingMsgDurationEl.className = "msg-duration";
|
|
1709
|
+
state.streamingMsgDurationEl.textContent = `耗时 ${formatDuration(turnDuration)}`;
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1573
1713
|
const finalFullText = getStreamingFullText();
|
|
1574
1714
|
const btn = state.streamingMsg.querySelector(".btn-copy-msg");
|
|
1575
1715
|
if (btn) {
|
|
@@ -1581,6 +1721,8 @@ function finalizeStreamingMsg() {
|
|
|
1581
1721
|
};
|
|
1582
1722
|
}
|
|
1583
1723
|
}
|
|
1724
|
+
state.turnStartedAt = null;
|
|
1725
|
+
state.streamingMsgDurationEl = null;
|
|
1584
1726
|
state.streamingMsg = null;
|
|
1585
1727
|
state.streamingItems = [];
|
|
1586
1728
|
state.thinkingOpen = true;
|
|
@@ -1833,6 +1975,10 @@ function handlePiMessage(obj) {
|
|
|
1833
1975
|
setComposerAborting(true);
|
|
1834
1976
|
ensureStreamingMsg();
|
|
1835
1977
|
refreshStreamingContent();
|
|
1978
|
+
// If ring buffer overflowed while client was away, sync full session history in background
|
|
1979
|
+
if (obj.overflowed && state.currentSessionFile) {
|
|
1980
|
+
syncSessionHistory(state.currentSessionFile, false);
|
|
1981
|
+
}
|
|
1836
1982
|
} else {
|
|
1837
1983
|
finalizeStreamingMsg();
|
|
1838
1984
|
state.streaming = false;
|
|
@@ -1972,6 +2118,13 @@ function handlePiMessage(obj) {
|
|
|
1972
2118
|
const ev = obj.assistantMessageEvent;
|
|
1973
2119
|
if (!ev) break;
|
|
1974
2120
|
if (ev.type === "text_delta") {
|
|
2121
|
+
// Finalize active thinking duration if transitioning to text
|
|
2122
|
+
const activeThinking = state.streamingItems.find(it => it.type === "thinking" && it.isStreaming);
|
|
2123
|
+
if (activeThinking) {
|
|
2124
|
+
activeThinking.isStreaming = false;
|
|
2125
|
+
activeThinking.endedAt = Date.now();
|
|
2126
|
+
activeThinking.durationMs = Math.max(0, activeThinking.endedAt - (activeThinking.startedAt || activeThinking.ts || Date.now()));
|
|
2127
|
+
}
|
|
1975
2128
|
const last = state.streamingItems[state.streamingItems.length - 1];
|
|
1976
2129
|
if (last && last.type === "text") {
|
|
1977
2130
|
last.text += ev.delta;
|
|
@@ -1981,6 +2134,12 @@ function handlePiMessage(obj) {
|
|
|
1981
2134
|
refreshStreamingContentDebounced();
|
|
1982
2135
|
} else if (ev.type === "text_end") {
|
|
1983
2136
|
// Authoritative final text for this content slot.
|
|
2137
|
+
const activeThinking = state.streamingItems.find(it => it.type === "thinking" && it.isStreaming);
|
|
2138
|
+
if (activeThinking) {
|
|
2139
|
+
activeThinking.isStreaming = false;
|
|
2140
|
+
activeThinking.endedAt = Date.now();
|
|
2141
|
+
activeThinking.durationMs = Math.max(0, activeThinking.endedAt - (activeThinking.startedAt || activeThinking.ts || Date.now()));
|
|
2142
|
+
}
|
|
1984
2143
|
if (typeof ev.content === "string") {
|
|
1985
2144
|
const last = state.streamingItems[state.streamingItems.length - 1];
|
|
1986
2145
|
if (last && last.type === "text") {
|
|
@@ -1989,30 +2148,49 @@ function handlePiMessage(obj) {
|
|
|
1989
2148
|
state.streamingItems.push({ type: "text", text: ev.content });
|
|
1990
2149
|
}
|
|
1991
2150
|
}
|
|
1992
|
-
|
|
2151
|
+
refreshStreamingContentDebounced();
|
|
1993
2152
|
} else if (ev.type === "thinking_delta" || ev.type === "thinking_start" || ev.type === "thinking_end") {
|
|
1994
2153
|
// For thinking we accumulate deltas; thinking_delta carries .delta
|
|
1995
2154
|
if (ev.type === "thinking_delta" && ev.delta) {
|
|
1996
|
-
|
|
1997
|
-
if (last && last.type === "thinking") {
|
|
2155
|
+
let last = state.streamingItems[state.streamingItems.length - 1];
|
|
2156
|
+
if (last && last.type === "thinking" && last.isStreaming !== false) {
|
|
1998
2157
|
last.text += ev.delta;
|
|
2158
|
+
if (!last.startedAt) last.startedAt = Date.now();
|
|
1999
2159
|
} else {
|
|
2000
|
-
|
|
2160
|
+
last = { type: "thinking", text: ev.delta, ts: Date.now(), startedAt: Date.now(), isStreaming: true };
|
|
2161
|
+
state.streamingItems.push(last);
|
|
2162
|
+
}
|
|
2163
|
+
refreshStreamingContentDebounced();
|
|
2164
|
+
} else if (ev.type === "thinking_start") {
|
|
2165
|
+
state.streamingItems.push({ type: "thinking", text: "", ts: Date.now(), startedAt: Date.now(), isStreaming: true });
|
|
2166
|
+
refreshStreamingContentDebounced();
|
|
2167
|
+
} else if (ev.type === "thinking_end") {
|
|
2168
|
+
const last = state.streamingItems[state.streamingItems.length - 1];
|
|
2169
|
+
if (last && last.type === "thinking") {
|
|
2170
|
+
last.isStreaming = false;
|
|
2171
|
+
last.endedAt = Date.now();
|
|
2172
|
+
last.durationMs = Math.max(0, last.endedAt - (last.startedAt || last.ts || Date.now()));
|
|
2001
2173
|
}
|
|
2002
2174
|
refreshStreamingContentDebounced();
|
|
2003
2175
|
}
|
|
2004
2176
|
} else if (ev.type === "toolcall_start") {
|
|
2177
|
+
const activeThinking = state.streamingItems.find(it => it.type === "thinking" && it.isStreaming);
|
|
2178
|
+
if (activeThinking) {
|
|
2179
|
+
activeThinking.isStreaming = false;
|
|
2180
|
+
activeThinking.endedAt = Date.now();
|
|
2181
|
+
activeThinking.durationMs = Math.max(0, activeThinking.endedAt - (activeThinking.startedAt || activeThinking.ts || Date.now()));
|
|
2182
|
+
}
|
|
2005
2183
|
ensureStreamingMsg();
|
|
2006
2184
|
const call = ev.toolCall || { id: obj.toolCallId || ev.id, name: obj.toolName, arguments: obj.args };
|
|
2007
2185
|
// args may be incomplete until toolcall_end; we fill what we have now
|
|
2008
2186
|
// and patch the head display on toolcall_end.
|
|
2009
|
-
ensureToolBlock(call.id, call.name, call.arguments);
|
|
2187
|
+
ensureToolBlock(call.id, call.name, call.arguments, Date.now());
|
|
2010
2188
|
} else if (ev.type === "toolcall_delta") {
|
|
2011
2189
|
// Streaming function-call argument JSON. We don't render it live
|
|
2012
2190
|
// (JSON fragments are not useful UX), but make sure the tool block
|
|
2013
2191
|
// exists so toolcall_end has somewhere to write into.
|
|
2014
2192
|
const id = obj.toolCallId || ev.id;
|
|
2015
|
-
ensureToolBlock(id, obj.toolName || ev.toolCall?.name, obj.args);
|
|
2193
|
+
ensureToolBlock(id, obj.toolName || ev.toolCall?.name, obj.args, Date.now());
|
|
2016
2194
|
} else if (ev.type === "toolcall_end") {
|
|
2017
2195
|
// Authoritative final toolCall object (with full arguments). Patch
|
|
2018
2196
|
// the block head so the displayed args are the final ones, not the
|
|
@@ -2029,9 +2207,29 @@ function handlePiMessage(obj) {
|
|
|
2029
2207
|
break;
|
|
2030
2208
|
}
|
|
2031
2209
|
case "tool_execution_start": {
|
|
2210
|
+
const activeThinking = state.streamingItems.find(it => it.type === "thinking" && it.isStreaming);
|
|
2211
|
+
if (activeThinking) {
|
|
2212
|
+
activeThinking.isStreaming = false;
|
|
2213
|
+
activeThinking.endedAt = Date.now();
|
|
2214
|
+
activeThinking.durationMs = Math.max(0, activeThinking.endedAt - (activeThinking.startedAt || activeThinking.ts || Date.now()));
|
|
2215
|
+
}
|
|
2032
2216
|
ensureStreamingMsg();
|
|
2033
|
-
const tc = ensureToolBlock(obj.toolCallId, obj.toolName, obj.args);
|
|
2034
|
-
if (tc)
|
|
2217
|
+
const tc = ensureToolBlock(obj.toolCallId, obj.toolName, obj.args, Date.now());
|
|
2218
|
+
if (tc) {
|
|
2219
|
+
tc.startedAt = Date.now();
|
|
2220
|
+
if (tc.durationEl) {
|
|
2221
|
+
tc.durationEl.className = "tool-duration live";
|
|
2222
|
+
tc.durationEl._startedAt = tc.startedAt;
|
|
2223
|
+
tc.durationEl.textContent = "0.0s";
|
|
2224
|
+
tc.durationEl.style.display = "";
|
|
2225
|
+
}
|
|
2226
|
+
const stateEl = tc.head.querySelector(".state");
|
|
2227
|
+
if (stateEl) {
|
|
2228
|
+
stateEl.className = "state running";
|
|
2229
|
+
stateEl.textContent = "执行中…";
|
|
2230
|
+
}
|
|
2231
|
+
updateToolBlockCopyBtn(tc, { name: obj.toolName, arguments: obj.args });
|
|
2232
|
+
}
|
|
2035
2233
|
break;
|
|
2036
2234
|
}
|
|
2037
2235
|
case "tool_execution_update": {
|
|
@@ -2047,8 +2245,20 @@ function handlePiMessage(obj) {
|
|
|
2047
2245
|
if (tc) {
|
|
2048
2246
|
const text = extractContentText(obj.result?.content);
|
|
2049
2247
|
tc.body.innerHTML = escapeHtml(text) || "(无输出)";
|
|
2050
|
-
tc.
|
|
2051
|
-
tc.
|
|
2248
|
+
tc.endedAt = Date.now();
|
|
2249
|
+
const dur = Math.max(0, tc.endedAt - (tc.startedAt || tc.endedAt));
|
|
2250
|
+
tc.durationMs = dur;
|
|
2251
|
+
if (tc.durationEl) {
|
|
2252
|
+
tc.durationEl.className = "tool-duration";
|
|
2253
|
+
tc.durationEl._startedAt = null;
|
|
2254
|
+
tc.durationEl.textContent = `耗时 ${formatDuration(dur)}`;
|
|
2255
|
+
tc.durationEl.style.display = "";
|
|
2256
|
+
}
|
|
2257
|
+
const stateEl = tc.head.querySelector(".state");
|
|
2258
|
+
if (stateEl) {
|
|
2259
|
+
stateEl.textContent = obj.isError ? "错误" : "完成";
|
|
2260
|
+
stateEl.className = "state" + (obj.isError ? " error" : "");
|
|
2261
|
+
}
|
|
2052
2262
|
}
|
|
2053
2263
|
break;
|
|
2054
2264
|
}
|
|
@@ -2112,10 +2322,10 @@ function handleExtensionUiRequest(req) {
|
|
|
2112
2322
|
}
|
|
2113
2323
|
}
|
|
2114
2324
|
|
|
2115
|
-
function ensureToolBlock(toolCallId, name, args) {
|
|
2325
|
+
function ensureToolBlock(toolCallId, name, args, ts = Date.now()) {
|
|
2116
2326
|
if (state.activeToolCalls.has(toolCallId)) return state.activeToolCalls.get(toolCallId);
|
|
2117
|
-
const block = makeToolBlockFromCall({ id: toolCallId, name, arguments: args });
|
|
2118
|
-
const entry = state.activeToolCalls.get(toolCallId) || { block, body: block._body, head: block._head };
|
|
2327
|
+
const block = makeToolBlockFromCall({ id: toolCallId, name, arguments: args, startedAt: ts }, ts);
|
|
2328
|
+
const entry = state.activeToolCalls.get(toolCallId) || { block, body: block._body, head: block._head, durationEl: block._durationEl, startedAt: ts };
|
|
2119
2329
|
state.activeToolCalls.set(toolCallId, entry);
|
|
2120
2330
|
state.streamingItems.push({ type: "tool", id: toolCallId, tc: entry });
|
|
2121
2331
|
refreshStreamingContentDebounced();
|
|
@@ -2996,7 +3206,7 @@ async function init() {
|
|
|
2996
3206
|
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
|
2997
3207
|
const isCmdOrCtrl = isMac ? e.metaKey : e.ctrlKey;
|
|
2998
3208
|
|
|
2999
|
-
if (isCmdOrCtrl && e.shiftKey && e.key.toLowerCase() === "n") {
|
|
3209
|
+
if (isCmdOrCtrl && e.shiftKey && (e.key.toLowerCase() === "n" || e.key.toLowerCase() === "o")) {
|
|
3000
3210
|
e.preventDefault();
|
|
3001
3211
|
startNewSession(true);
|
|
3002
3212
|
return;
|
package/public/icon.svg
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="piGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
4
|
+
<stop offset="0%" stop-color="#3b82f6"/>
|
|
5
|
+
<stop offset="100%" stop-color="#8b5cf6"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect width="512" height="512" rx="128" fill="#18181b"/>
|
|
9
|
+
<rect x="16" y="16" width="480" height="480" rx="112" fill="none" stroke="url(#piGrad)" stroke-width="8" opacity="0.4"/>
|
|
10
|
+
<text x="256" y="340" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, sans-serif" font-size="280" font-weight="bold" fill="url(#piGrad)" text-anchor="middle">π</text>
|
|
11
|
+
</svg>
|
package/public/index.html
CHANGED
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<meta name="theme-color" content="#18181b">
|
|
7
|
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
8
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
|
6
9
|
<title>pi · Chat</title>
|
|
10
|
+
<link rel="manifest" href="/manifest.webmanifest">
|
|
11
|
+
<link rel="apple-touch-icon" href="/icon.svg">
|
|
7
12
|
<link rel="stylesheet" href="/style.css">
|
|
8
|
-
<link rel="icon"
|
|
13
|
+
<link rel="icon" type="image/svg+xml" href="/icon.svg">
|
|
9
14
|
</head>
|
|
10
15
|
<body data-cwd="">
|
|
11
16
|
<div class="app sidebar-open">
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-chat",
|
|
3
|
+
"short_name": "pi-chat",
|
|
4
|
+
"description": "ChatGPT/Gemini style web UI for the pi coding agent",
|
|
5
|
+
"start_url": "/",
|
|
6
|
+
"display": "standalone",
|
|
7
|
+
"background_color": "#18181b",
|
|
8
|
+
"theme_color": "#18181b",
|
|
9
|
+
"icons": [
|
|
10
|
+
{
|
|
11
|
+
"src": "/icon.svg",
|
|
12
|
+
"sizes": "any",
|
|
13
|
+
"type": "image/svg+xml",
|
|
14
|
+
"purpose": "any maskable"
|
|
15
|
+
}
|
|
16
|
+
]
|
|
17
|
+
}
|
package/public/style.css
CHANGED
|
@@ -578,6 +578,74 @@ body {
|
|
|
578
578
|
letter-spacing: 0;
|
|
579
579
|
user-select: none;
|
|
580
580
|
}
|
|
581
|
+
.msg-duration {
|
|
582
|
+
font-size: 11px;
|
|
583
|
+
font-weight: 400;
|
|
584
|
+
color: var(--text-muted);
|
|
585
|
+
background: rgba(255, 255, 255, 0.05);
|
|
586
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
587
|
+
padding: 1px 7px;
|
|
588
|
+
border-radius: 999px;
|
|
589
|
+
user-select: none;
|
|
590
|
+
}
|
|
591
|
+
.msg-duration.live {
|
|
592
|
+
color: var(--accent);
|
|
593
|
+
background: rgba(16, 163, 127, 0.12);
|
|
594
|
+
border-color: rgba(16, 163, 127, 0.25);
|
|
595
|
+
animation: pulse-live-tag 1.5s infinite ease-in-out;
|
|
596
|
+
}
|
|
597
|
+
.thinking-time {
|
|
598
|
+
font-size: 11px;
|
|
599
|
+
color: var(--text-muted);
|
|
600
|
+
}
|
|
601
|
+
.thinking-duration {
|
|
602
|
+
font-size: 11px;
|
|
603
|
+
font-weight: 500;
|
|
604
|
+
color: #c4b5fd;
|
|
605
|
+
background: rgba(167, 139, 250, 0.12);
|
|
606
|
+
border: 1px solid rgba(167, 139, 250, 0.25);
|
|
607
|
+
padding: 1px 8px;
|
|
608
|
+
border-radius: 999px;
|
|
609
|
+
display: inline-flex;
|
|
610
|
+
align-items: center;
|
|
611
|
+
gap: 3px;
|
|
612
|
+
user-select: none;
|
|
613
|
+
}
|
|
614
|
+
.thinking-duration.live {
|
|
615
|
+
color: var(--accent);
|
|
616
|
+
background: rgba(16, 163, 127, 0.12);
|
|
617
|
+
border-color: rgba(16, 163, 127, 0.3);
|
|
618
|
+
animation: pulse-live-tag 1.5s infinite ease-in-out;
|
|
619
|
+
}
|
|
620
|
+
.tool-time {
|
|
621
|
+
font-size: 11px;
|
|
622
|
+
color: var(--text-muted);
|
|
623
|
+
}
|
|
624
|
+
.tool-duration {
|
|
625
|
+
font-size: 11px;
|
|
626
|
+
font-weight: 400;
|
|
627
|
+
color: var(--text-dim);
|
|
628
|
+
background: #252525;
|
|
629
|
+
border: 1px solid var(--border);
|
|
630
|
+
padding: 1px 7px;
|
|
631
|
+
border-radius: 999px;
|
|
632
|
+
display: inline-flex;
|
|
633
|
+
align-items: center;
|
|
634
|
+
gap: 3px;
|
|
635
|
+
user-select: none;
|
|
636
|
+
}
|
|
637
|
+
.tool-duration.live {
|
|
638
|
+
color: #fbbf24;
|
|
639
|
+
background: rgba(234, 179, 8, 0.12);
|
|
640
|
+
border-color: rgba(234, 179, 8, 0.25);
|
|
641
|
+
animation: pulse-live-tag 1.5s infinite ease-in-out;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
@keyframes pulse-live-tag {
|
|
645
|
+
0%, 100% { opacity: 0.85; }
|
|
646
|
+
50% { opacity: 1; }
|
|
647
|
+
}
|
|
648
|
+
|
|
581
649
|
.msg.assistant .content {
|
|
582
650
|
font-size: 15px; line-height: 1.65; color: var(--text);
|
|
583
651
|
max-width: 100%; min-width: 0;
|