@liguoshuai/pi-web-chat 2.5.0 → 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 CHANGED
@@ -7,6 +7,90 @@ 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
+
78
+ ## [2.5.1] - 2026-09-01
79
+
80
+ ### Added
81
+ - **AI Coding Agent 工作指南与工程规范 (`AGENTS.md` & `CLAUDE.md`)**:
82
+ - 确立三大强制铁律:工作前必拉取远端主干(`git pull origin main`)、任何修改必递增 SemVer 语义化版本号并在 Web 与各客户端正确呈现、任务完成必通过全量测试并提交推送至远端。
83
+ - 制定 Monorepo 全仓版本同步清单(Lockstep)与多端版本呈现规范。
84
+ - **客户端版本号呈现强化**:
85
+ - Android 原生端:启用 `buildConfig = true`,在侧边栏抽屉底部及后端配置对话框中通过 `BuildConfig.VERSION_NAME` 实时展示客户端版本号(`v2.5.1`)。
86
+ - HarmonyOS 鸿蒙原生端:在侧边栏底部展示应用版本号(`v2.5.1`)。
87
+ - Web 端:通过网关 `/api/config` 动态获取并在侧边栏底部 `#appVersion` 区域正确展示当前版本号(`v2.5.1`)。
88
+
89
+ ### Changed
90
+ - 全端版本号统一升级至 2.5.1(Root / Protocol / Server / Web / Android / HarmonyOS)。
91
+
92
+ ---
93
+
10
94
  ## [2.5.0] - 2026-09-01
11
95
 
12
96
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "2.5.0",
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.0",
44
- "@liguoshuai/pi-chat-server": "2.5.0",
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, m);
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: m.timestamp || e.timestamp });
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
- return { ...part, result: res || null };
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: m.timestamp || e.timestamp, usage: m.usage });
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
- refreshStreamingContent();
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
- const last = state.streamingItems[state.streamingItems.length - 1];
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
- state.streamingItems.push({ type: "thinking", text: ev.delta });
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) updateToolBlockCopyBtn(tc, { name: obj.toolName, arguments: obj.args });
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.head.querySelector(".state").textContent = obj.isError ? "错误" : "完成";
2051
- tc.head.querySelector(".state").classList.toggle("error", !!obj.isError);
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;
@@ -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" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ctext y='20' font-size='20'%3Eπ%3C/text%3E%3C/svg%3E">
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;