@liguoshuai/pi-web-chat 2.3.0 → 2.5.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/ARCHITECTURE.md +2 -2
- package/docs/CHANGELOG.md +26 -1
- package/package.json +3 -3
- package/public/app.js +189 -54
- package/public/style.css +27 -1
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 架构设计文档
|
|
2
2
|
|
|
3
|
-
> pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应
|
|
3
|
+
> pi-web-chat 的技术架构、数据流、关键设计决策与扩展点说明(对应 v2.3.0 版本)。
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
**核心设计原则**:
|
|
40
40
|
- **按 Session Key 池化进程**:以 `${cwd}:${resolvedSessionPath}` 作为唯一 Key,多标签页或多设备连接同一个 Session 时共享同一个后台 `PiAgent` 进程。
|
|
41
41
|
- **后台持久化运行(Headless Persistence)**:网页断开(如关闭标签页)不会立即终止进程。只要 Agent 处于流式生成或有未响应请求状态,后台进程会坚持跑完当前任务。
|
|
42
|
-
- **离线事件环形缓存与回放(Ring Buffer & Backfill)**:无客户端连接时,pi 输出的事件自动存入环形 Buffer(默认
|
|
42
|
+
- **离线事件环形缓存与回放(Ring Buffer & Backfill)**:无客户端连接时,pi 输出的事件自动存入环形 Buffer(默认 5000 条)。客户端重连时通过 `backfill_start` → 离线事件 → `backfill_end` 进行增量补齐。
|
|
43
43
|
- **真正空闲回收(True-Idle Timeout)**:仅在“无 WebSocket 连接”且“进程彻底进入 idle(非 streaming、无挂起 RPC)”时,才启动 `IDLE_TIMEOUT_MS` 空闲回收倒计时。
|
|
44
44
|
- **磁盘 JSONL 为唯一真理(Single Source of Truth)**:会话历史与树状分支全由 pi 本身维护在磁盘 `.jsonl` 文件中,Web 端通过 REST 接口与 RPC 消息与文件保持同步。
|
|
45
45
|
- **`pi` 可执行文件自动探查与派生**:服务端通过 `resolvePiBin()` 自动定位全局安装的 `pi` 可执行文件(优先使用 `PI_BIN` 环境变量 -> 检查 `~/.npm-global/bin/pi` -> `/usr/local/bin/pi` -> `/usr/bin/pi` -> 系统 `PATH`),派生 `pi --mode rpc --session-dir ...` 子进程。
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
-
## [2.
|
|
10
|
+
## [2.5.0] - 2026-09-01
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Android 端聊天消息、思考过程与工具执行块均显示时间戳(当天 `HH:mm`,跨天 `MM-dd HH:mm`)。
|
|
14
|
+
- Android 端新增工具执行渲染(`tool_execution_start/update/end`):展示工具名、参数、结果与执行状态(执行中/完成/错误)。
|
|
15
|
+
- 用户消息头部显示 `user`,助手消息头部显示 `pi`。
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- Android 端会话列表在新建会话、`agent_start`/`agent_settled`、切换会话及服务端返回 `sessionFile` 时即时刷新,新会话更快出现在侧边栏。
|
|
19
|
+
- 全端版本号统一升级至 2.5.0。
|
|
20
|
+
|
|
21
|
+
## [2.4.1] - 2026-09-01
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- **彻底解决新建对话中断已有后台任务的问题**:
|
|
25
|
+
- 修复了 `startNewSession` 在已有活动 WebSocket 连接上直接发送 `new_session` RPC 指令导致原有会话子进程被重置与中断的问题。新建会话时安全断开当前连接(原会话保持后台运行),并为新会话连接独立的无状态 Agent。
|
|
26
|
+
- 服务端网关 `NEW_SESSION` 与 `SWITCH_SESSION` 消息处理器增加安全解绑(detach)逻辑,避免跨会话指令误触或覆盖已有活跃 Agent 进程。
|
|
27
|
+
- **精准修复老会话误判为“运行中”的状态异常**:
|
|
28
|
+
- 严格区分进程资源防回收判定(`isBusy`,包含 pending 异步请求)与真实 AI 流式生成状态(`isStreaming`,严格对应 `state === 'streaming'`)。
|
|
29
|
+
- 服务端 `/api/sessions` 接口与 `backfill_end` 状态同步均修正为读取 `isStreaming`,彻底杜绝老会话连接时因 `get_state`/`get_available_models` 临时 pending 导致误判为“运行中”的问题。
|
|
30
|
+
- 优化了事件环形缓存机制:会话生成结束收敛(`agent_settled`)后彻底清空缓冲区,且仅在流式生成期间缓存事件,避免非流式 RPC 响应污染回放缓冲区导致前端误进入流式状态。
|
|
31
|
+
- 前端 `updateState` 及侧边栏会话路径比对增加统一路径规范化匹配(`sameSession`),避免路径形式差异导致草稿会话残留或状态错乱。
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## [2.4.0] - 2026-08-31
|
|
11
36
|
|
|
12
37
|
### Fixed & Improved
|
|
13
38
|
- **后台异步长任务稳定性与持久化机制全面强化 (Background Async Persistence & Lifetime Fix)**:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liguoshuai/pi-web-chat",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.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,8 +40,8 @@
|
|
|
40
40
|
"node": ">=18"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@liguoshuai/pi-chat-protocol": "2.
|
|
44
|
-
"@liguoshuai/pi-chat-server": "2.
|
|
43
|
+
"@liguoshuai/pi-chat-protocol": "2.5.0",
|
|
44
|
+
"@liguoshuai/pi-chat-server": "2.5.0",
|
|
45
45
|
"express": "^4.21.2",
|
|
46
46
|
"ws": "^8.18.0"
|
|
47
47
|
},
|
package/public/app.js
CHANGED
|
@@ -448,7 +448,10 @@ function renderInlineMd(text) {
|
|
|
448
448
|
flushList();
|
|
449
449
|
flushPara();
|
|
450
450
|
// restore inline code
|
|
451
|
-
outHtml = outHtml.replace(/\u0000CODE(\d+)\u0000/g, (_, n) =>
|
|
451
|
+
outHtml = outHtml.replace(/\u0000CODE(\d+)\u0000/g, (_, n) => {
|
|
452
|
+
const chunk = codeChunks[+n];
|
|
453
|
+
return chunk ? `<code>${escapeHtml(chunk.slice(1, -1))}</code>` : "";
|
|
454
|
+
});
|
|
452
455
|
return outHtml;
|
|
453
456
|
}
|
|
454
457
|
|
|
@@ -517,22 +520,96 @@ const el = (tag, props = {}, children = []) => {
|
|
|
517
520
|
return n;
|
|
518
521
|
};
|
|
519
522
|
|
|
523
|
+
// ---- Message Time Formatter ----
|
|
524
|
+
function formatMessageTime(ts) {
|
|
525
|
+
if (!ts) return "";
|
|
526
|
+
const d = (typeof ts === "number" || typeof ts === "string") ? new Date(ts) : ts;
|
|
527
|
+
if (!d || isNaN(d.getTime())) return "";
|
|
528
|
+
|
|
529
|
+
const now = new Date();
|
|
530
|
+
const isToday = d.getFullYear() === now.getFullYear() &&
|
|
531
|
+
d.getMonth() === now.getMonth() &&
|
|
532
|
+
d.getDate() === now.getDate();
|
|
533
|
+
|
|
534
|
+
const timeStr = d.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false });
|
|
535
|
+
if (isToday) {
|
|
536
|
+
return timeStr;
|
|
537
|
+
}
|
|
538
|
+
const isThisYear = d.getFullYear() === now.getFullYear();
|
|
539
|
+
if (isThisYear) {
|
|
540
|
+
const month = d.getMonth() + 1;
|
|
541
|
+
const date = d.getDate();
|
|
542
|
+
return `${month}月${date}日 ${timeStr}`;
|
|
543
|
+
}
|
|
544
|
+
const year = d.getFullYear();
|
|
545
|
+
const month = String(d.getMonth() + 1).padStart(2, "0");
|
|
546
|
+
const date = String(d.getDate()).padStart(2, "0");
|
|
547
|
+
return `${year}-${month}-${date} ${timeStr}`;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function sameSession(a, b) {
|
|
551
|
+
if (!a || !b) return false;
|
|
552
|
+
if (a === b) return true;
|
|
553
|
+
return a.replace(/^[.~]\//, "") === b.replace(/^[.~]\//, "");
|
|
554
|
+
}
|
|
555
|
+
|
|
520
556
|
// ---- Sidebar / sessions ----
|
|
521
557
|
async function refreshSessions() {
|
|
522
558
|
const cwd = state.cwd || "";
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
559
|
+
try {
|
|
560
|
+
const res = await authFetch(`${API}/api/sessions?cwd=${encodeURIComponent(cwd)}`);
|
|
561
|
+
const data = await res.json();
|
|
562
|
+
renderSidebar(data.sessions || []);
|
|
563
|
+
} catch (err) {
|
|
564
|
+
console.warn("refreshSessions error:", err);
|
|
565
|
+
renderSidebar([]);
|
|
566
|
+
}
|
|
526
567
|
}
|
|
527
568
|
|
|
528
569
|
function renderSidebar(sessions) {
|
|
529
570
|
const list = $("#sessionList");
|
|
571
|
+
if (!list) return;
|
|
530
572
|
list.innerHTML = "";
|
|
531
|
-
|
|
573
|
+
|
|
574
|
+
const hasCurrentInSessions = Boolean(state.currentSessionFile && sessions.some(s => sameSession(s.file, state.currentSessionFile)));
|
|
575
|
+
|
|
576
|
+
// If current session is a new/draft session not yet in the session list from REST API:
|
|
577
|
+
if (!hasCurrentInSessions && (!state.currentSessionFile || $("#emptyState")?.style.display !== "none" || state.streaming)) {
|
|
578
|
+
const draftTitle = $("#topSessionName")?.textContent || "新对话";
|
|
579
|
+
const isRunning = Boolean(state.streaming);
|
|
580
|
+
const runningBadge = isRunning ? el("span", { class: "session-running-badge", title: "任务正在后台生成中…" }, [
|
|
581
|
+
el("span", { class: "spinner-dot" }),
|
|
582
|
+
el("span", { text: "运行中" }),
|
|
583
|
+
]) : null;
|
|
584
|
+
|
|
585
|
+
const titleEl = el("div", { class: "session-item-name" }, [
|
|
586
|
+
el("span", { text: draftTitle }),
|
|
587
|
+
...(runningBadge ? [runningBadge] : []),
|
|
588
|
+
]);
|
|
589
|
+
|
|
590
|
+
const draftItem = el("div", {
|
|
591
|
+
class: "session-item active draft-session",
|
|
592
|
+
dataset: { file: state.currentSessionFile || "" },
|
|
593
|
+
title: state.currentSessionFile || "新对话",
|
|
594
|
+
onclick: () => {
|
|
595
|
+
if (state.currentSessionFile) loadSession(state.currentSessionFile);
|
|
596
|
+
},
|
|
597
|
+
}, [
|
|
598
|
+
el("div", { class: "title" }, [
|
|
599
|
+
titleEl,
|
|
600
|
+
el("div", { class: "meta", text: `刚刚 · ${state.streaming ? 1 : 0} 条` }),
|
|
601
|
+
]),
|
|
602
|
+
]);
|
|
603
|
+
list.appendChild(draftItem);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (sessions.length === 0 && list.children.length === 0) {
|
|
532
607
|
list.appendChild(el("div", { class: "sidebar-empty", text: "没有会话记录" }));
|
|
533
608
|
return;
|
|
534
609
|
}
|
|
610
|
+
|
|
535
611
|
sessions.forEach((s) => {
|
|
612
|
+
if (sameSession(s.file, state.currentSessionFile) && !hasCurrentInSessions) return;
|
|
536
613
|
const title = s.sessionName || s.firstUser || "新对话";
|
|
537
614
|
const when = s.timestamp ? new Date(s.timestamp).toLocaleString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "";
|
|
538
615
|
|
|
@@ -546,7 +623,7 @@ function renderSidebar(sessions) {
|
|
|
546
623
|
},
|
|
547
624
|
});
|
|
548
625
|
|
|
549
|
-
const isRunning = Boolean(s.isStreaming || (s.file
|
|
626
|
+
const isRunning = Boolean(s.isStreaming || (sameSession(s.file, state.currentSessionFile) && state.streaming));
|
|
550
627
|
const runningBadge = isRunning ? el("span", { class: "session-running-badge", title: "任务正在后台生成中…" }, [
|
|
551
628
|
el("span", { class: "spinner-dot" }),
|
|
552
629
|
el("span", { text: "运行中" }),
|
|
@@ -558,7 +635,7 @@ function renderSidebar(sessions) {
|
|
|
558
635
|
]);
|
|
559
636
|
|
|
560
637
|
const item = el("div", {
|
|
561
|
-
class: "session-item" + (s.file
|
|
638
|
+
class: "session-item" + (sameSession(s.file, state.currentSessionFile) ? " active" : ""),
|
|
562
639
|
dataset: { file: s.file },
|
|
563
640
|
title: s.file, // hover tooltip = raw jsonl path
|
|
564
641
|
onclick: () => loadSession(s.file),
|
|
@@ -612,13 +689,16 @@ function startNewSession() {
|
|
|
612
689
|
try {
|
|
613
690
|
window.history.replaceState({}, "", window.location.pathname);
|
|
614
691
|
} catch {}
|
|
615
|
-
connectWs({ explicitNewSession: true }); // no session -> pi creates a new one
|
|
616
692
|
$("#topSessionName").textContent = "新对话";
|
|
617
693
|
if (wasStreaming) {
|
|
618
694
|
showToast("前一个会话已转入后台继续运行");
|
|
619
695
|
}
|
|
620
696
|
// Mobile: close sidebar on new session
|
|
621
697
|
if (window.innerWidth <= 768) closeSidebar();
|
|
698
|
+
|
|
699
|
+
connectWs({ explicitNewSession: true }); // no session -> pi creates a new one
|
|
700
|
+
// Instantly render optimistic new session at the top of left sidebar
|
|
701
|
+
renderSidebar([]);
|
|
622
702
|
refreshSessions();
|
|
623
703
|
}
|
|
624
704
|
|
|
@@ -717,7 +797,7 @@ async function syncSessionHistory(file, force = false) {
|
|
|
717
797
|
}
|
|
718
798
|
|
|
719
799
|
async function loadSession(file) {
|
|
720
|
-
if (file
|
|
800
|
+
if (sameSession(file, state.currentSessionFile)) return;
|
|
721
801
|
const wasStreaming = state.streaming;
|
|
722
802
|
state.currentSessionFile = file;
|
|
723
803
|
state.streaming = false;
|
|
@@ -771,7 +851,7 @@ function reconstructFromEntries(entries) {
|
|
|
771
851
|
url: c.data ? `data:${c.mimeType || "image/png"};base64,${c.data}` : (c.url || "")
|
|
772
852
|
}));
|
|
773
853
|
}
|
|
774
|
-
out.push({ role: "user", text: extractContentText(m.content), images, ts: m.timestamp });
|
|
854
|
+
out.push({ role: "user", text: extractContentText(m.content), images, ts: m.timestamp || e.timestamp });
|
|
775
855
|
} else if (m.role === "assistant") {
|
|
776
856
|
const rawContent = Array.isArray(m.content) ? m.content : (m.content ? [{ type: "text", text: String(m.content) }] : []);
|
|
777
857
|
const content = rawContent.map(part => {
|
|
@@ -789,7 +869,7 @@ function reconstructFromEntries(entries) {
|
|
|
789
869
|
} catch {}
|
|
790
870
|
content.push({ type: "text", text: `⚠️ **生成失败**: ${errMsg}` });
|
|
791
871
|
}
|
|
792
|
-
out.push({ role: "assistant", content, ts: m.timestamp, usage: m.usage });
|
|
872
|
+
out.push({ role: "assistant", content, ts: m.timestamp || e.timestamp, usage: m.usage });
|
|
793
873
|
}
|
|
794
874
|
// toolResult entries are attached directly to assistant toolCall parts, so they don't produce standalone messages
|
|
795
875
|
}
|
|
@@ -1080,13 +1160,15 @@ function exportCurrentSession() {
|
|
|
1080
1160
|
|
|
1081
1161
|
const msgs = chatInner.querySelectorAll(".msg");
|
|
1082
1162
|
msgs.forEach(msg => {
|
|
1163
|
+
const time = msg.querySelector(".msg-time")?.innerText || "";
|
|
1164
|
+
const timeInfo = time ? ` (${time})` : "";
|
|
1083
1165
|
if (msg.classList.contains("user")) {
|
|
1084
1166
|
const isSteer = !!msg.querySelector(".steer-badge");
|
|
1085
1167
|
const bubble = msg.querySelector(".bubble");
|
|
1086
1168
|
const text = bubble ? bubble.innerText.replace("🧭 指导指令", "").trim() : "";
|
|
1087
|
-
md += `### 👤
|
|
1169
|
+
md += `### 👤 user${timeInfo}${isSteer ? " (🧭 指导指令)" : ""}\n\n${text}\n\n`;
|
|
1088
1170
|
} else if (msg.classList.contains("assistant")) {
|
|
1089
|
-
md += `### 🤖 pi\n\n`;
|
|
1171
|
+
md += `### 🤖 pi${timeInfo}\n\n`;
|
|
1090
1172
|
const thinking = msg.querySelector(".thinking-body");
|
|
1091
1173
|
if (thinking && thinking.innerText.trim()) {
|
|
1092
1174
|
md += `<details><summary>💭 思考过程</summary>\n\n${thinking.innerText.trim()}\n\n</details>\n\n`;
|
|
@@ -1121,6 +1203,10 @@ function exportCurrentSession() {
|
|
|
1121
1203
|
function appendMessageNode(role, m) {
|
|
1122
1204
|
if (role === "user") {
|
|
1123
1205
|
const isSteer = m.isSteer || false;
|
|
1206
|
+
const ts = m.ts || m.timestamp || Date.now();
|
|
1207
|
+
const timeStr = formatMessageTime(ts);
|
|
1208
|
+
const fullTimeStr = (ts && !isNaN(new Date(ts).getTime())) ? new Date(ts).toLocaleString("zh-CN") : "";
|
|
1209
|
+
|
|
1124
1210
|
const steerBadge = isSteer ? el("span", { class: "steer-badge", text: "🧭 指导指令" }) : null;
|
|
1125
1211
|
const bubbleChildren = [steerBadge];
|
|
1126
1212
|
if (m.text) {
|
|
@@ -1143,7 +1229,16 @@ function appendMessageNode(role, m) {
|
|
|
1143
1229
|
bubbleChildren.push(imgsContainer);
|
|
1144
1230
|
}
|
|
1145
1231
|
const bubble = el("div", { class: "bubble" + (isSteer ? " steer" : "") }, bubbleChildren);
|
|
1146
|
-
|
|
1232
|
+
|
|
1233
|
+
const roleChildren = [
|
|
1234
|
+
el("span", { class: "role-name", text: "user" }),
|
|
1235
|
+
];
|
|
1236
|
+
if (timeStr) {
|
|
1237
|
+
roleChildren.push(el("span", { class: "msg-time", text: timeStr, title: fullTimeStr }));
|
|
1238
|
+
}
|
|
1239
|
+
const roleTag = el("div", { class: "role-tag user-role-tag" }, roleChildren);
|
|
1240
|
+
|
|
1241
|
+
const node = el("div", { class: "msg user" }, [roleTag, bubble]);
|
|
1147
1242
|
$("#chat-inner").appendChild(node);
|
|
1148
1243
|
scrollBottom();
|
|
1149
1244
|
return node;
|
|
@@ -1152,6 +1247,10 @@ function appendMessageNode(role, m) {
|
|
|
1152
1247
|
}
|
|
1153
1248
|
|
|
1154
1249
|
function renderAssistantBlock(m) {
|
|
1250
|
+
const ts = m.ts || m.timestamp || null;
|
|
1251
|
+
const timeStr = formatMessageTime(ts);
|
|
1252
|
+
const fullTimeStr = (ts && !isNaN(new Date(ts).getTime())) ? new Date(ts).toLocaleString("zh-CN") : "";
|
|
1253
|
+
|
|
1155
1254
|
// m.content is array of {type:text|thinking|toolCall}
|
|
1156
1255
|
const fullText = extractContentText(m.content);
|
|
1157
1256
|
const copyMsgBtn = el("button", {
|
|
@@ -1169,11 +1268,16 @@ function renderAssistantBlock(m) {
|
|
|
1169
1268
|
el("span", { text: "复制全文" })
|
|
1170
1269
|
]);
|
|
1171
1270
|
|
|
1271
|
+
const roleChildren = [
|
|
1272
|
+
el("span", { class: "role-name", text: "pi" }),
|
|
1273
|
+
];
|
|
1274
|
+
if (timeStr) {
|
|
1275
|
+
roleChildren.push(el("span", { class: "msg-time", text: timeStr, title: fullTimeStr }));
|
|
1276
|
+
}
|
|
1277
|
+
roleChildren.push(copyMsgBtn);
|
|
1278
|
+
|
|
1172
1279
|
const node = el("div", { class: "msg assistant" }, [
|
|
1173
|
-
el("div", { class: "role-tag" },
|
|
1174
|
-
el("span", { text: "pi" }),
|
|
1175
|
-
copyMsgBtn
|
|
1176
|
-
]),
|
|
1280
|
+
el("div", { class: "role-tag" }, roleChildren),
|
|
1177
1281
|
el("div", { class: "content" }),
|
|
1178
1282
|
]);
|
|
1179
1283
|
const content = node.querySelector(".content");
|
|
@@ -1374,28 +1478,38 @@ function getStreamingFullText() {
|
|
|
1374
1478
|
}
|
|
1375
1479
|
|
|
1376
1480
|
// ---- Streaming: handle live assistant message ----
|
|
1377
|
-
function ensureStreamingMsg() {
|
|
1481
|
+
function ensureStreamingMsg(ts = Date.now()) {
|
|
1378
1482
|
if (state.streamingMsg) return state.streamingMsg;
|
|
1379
1483
|
showEmptyState(false);
|
|
1484
|
+
const timeStr = formatMessageTime(ts);
|
|
1485
|
+
const fullTimeStr = (ts && !isNaN(new Date(ts).getTime())) ? new Date(ts).toLocaleString("zh-CN") : "";
|
|
1486
|
+
|
|
1487
|
+
const copyBtn = el("button", {
|
|
1488
|
+
class: "btn-copy-msg",
|
|
1489
|
+
type: "button",
|
|
1490
|
+
title: "复制回答全文",
|
|
1491
|
+
onclick: async (e) => {
|
|
1492
|
+
e.stopPropagation();
|
|
1493
|
+
const text = getStreamingFullText();
|
|
1494
|
+
if (await copyToClipboard(text)) {
|
|
1495
|
+
showToast("已复制回答全文");
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
}, [
|
|
1499
|
+
el("svg", { html: '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>' }),
|
|
1500
|
+
el("span", { text: "复制全文" })
|
|
1501
|
+
]);
|
|
1502
|
+
|
|
1503
|
+
const roleChildren = [
|
|
1504
|
+
el("span", { class: "role-name", text: "pi" }),
|
|
1505
|
+
];
|
|
1506
|
+
if (timeStr) {
|
|
1507
|
+
roleChildren.push(el("span", { class: "msg-time", text: timeStr, title: fullTimeStr }));
|
|
1508
|
+
}
|
|
1509
|
+
roleChildren.push(copyBtn);
|
|
1510
|
+
|
|
1380
1511
|
const node = el("div", { class: "msg assistant" }, [
|
|
1381
|
-
el("div", { class: "role-tag" },
|
|
1382
|
-
el("span", { text: "pi" }),
|
|
1383
|
-
el("button", {
|
|
1384
|
-
class: "btn-copy-msg",
|
|
1385
|
-
type: "button",
|
|
1386
|
-
title: "复制回答全文",
|
|
1387
|
-
onclick: async (e) => {
|
|
1388
|
-
e.stopPropagation();
|
|
1389
|
-
const text = getStreamingFullText();
|
|
1390
|
-
if (await copyToClipboard(text)) {
|
|
1391
|
-
showToast("已复制回答全文");
|
|
1392
|
-
}
|
|
1393
|
-
}
|
|
1394
|
-
}, [
|
|
1395
|
-
el("svg", { html: '<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>' }),
|
|
1396
|
-
el("span", { text: "复制全文" })
|
|
1397
|
-
])
|
|
1398
|
-
]),
|
|
1512
|
+
el("div", { class: "role-tag" }, roleChildren),
|
|
1399
1513
|
el("div", { class: "content" }),
|
|
1400
1514
|
]);
|
|
1401
1515
|
state.streamingMsg = node;
|
|
@@ -1640,8 +1754,8 @@ function connectWs(opts = {}) {
|
|
|
1640
1754
|
wasDisconnected = false;
|
|
1641
1755
|
reconnectAttempts = 0;
|
|
1642
1756
|
|
|
1643
|
-
|
|
1644
|
-
|
|
1757
|
+
sendWs({ type: "get_state" });
|
|
1758
|
+
sendWs({ type: "get_available_models" });
|
|
1645
1759
|
};
|
|
1646
1760
|
|
|
1647
1761
|
ws.onclose = () => {
|
|
@@ -1690,7 +1804,7 @@ function sendWs(obj) {
|
|
|
1690
1804
|
function handlePiMessage(obj) {
|
|
1691
1805
|
// Automatically bind to the session file as soon as pi allocates it on disk
|
|
1692
1806
|
const sessionFile = obj.data?.sessionFile || obj.sessionFile || obj.data?.sessionPath || obj.sessionPath;
|
|
1693
|
-
if (sessionFile && sessionFile
|
|
1807
|
+
if (sessionFile && !sameSession(sessionFile, state.currentSessionFile)) {
|
|
1694
1808
|
state.currentSessionFile = sessionFile;
|
|
1695
1809
|
try {
|
|
1696
1810
|
const newUrl = window.location.pathname + "?session=" + encodeURIComponent(sessionFile);
|
|
@@ -1706,8 +1820,6 @@ function handlePiMessage(obj) {
|
|
|
1706
1820
|
// that happened in the background while no browser was attached.
|
|
1707
1821
|
if (obj.type === "backfill_start") {
|
|
1708
1822
|
state.isBackfilling = true;
|
|
1709
|
-
state.streaming = true;
|
|
1710
|
-
setComposerAborting(true);
|
|
1711
1823
|
state.streamingItems = [];
|
|
1712
1824
|
state.streamingMsg = null;
|
|
1713
1825
|
state.activeToolCalls.clear();
|
|
@@ -1724,11 +1836,15 @@ function handlePiMessage(obj) {
|
|
|
1724
1836
|
} else {
|
|
1725
1837
|
finalizeStreamingMsg();
|
|
1726
1838
|
state.streaming = false;
|
|
1839
|
+
state.aborting = false;
|
|
1727
1840
|
setComposerAborting(false);
|
|
1728
|
-
|
|
1841
|
+
if (state.currentSessionFile) {
|
|
1842
|
+
syncSessionHistory(state.currentSessionFile, true);
|
|
1843
|
+
}
|
|
1729
1844
|
}
|
|
1730
1845
|
// jump to the latest content once the replay is done
|
|
1731
1846
|
requestAnimationFrame(scrollBottom);
|
|
1847
|
+
refreshSessions();
|
|
1732
1848
|
return;
|
|
1733
1849
|
}
|
|
1734
1850
|
// Responses to commands we issued (get_state etc.) come back with success+data.
|
|
@@ -1800,7 +1916,7 @@ function handlePiMessage(obj) {
|
|
|
1800
1916
|
}
|
|
1801
1917
|
break;
|
|
1802
1918
|
case "remote_user_prompt":
|
|
1803
|
-
appendMessageNode("user", { text: obj.message, images: obj.images, isSteer: obj.isSteer });
|
|
1919
|
+
appendMessageNode("user", { text: obj.message, images: obj.images, isSteer: obj.isSteer, ts: obj.timestamp || Date.now() });
|
|
1804
1920
|
break;
|
|
1805
1921
|
case "agent_start":
|
|
1806
1922
|
state.streaming = true;
|
|
@@ -2035,16 +2151,12 @@ function updateState(d) {
|
|
|
2035
2151
|
setComposerAborting(true);
|
|
2036
2152
|
ensureStreamingMsg();
|
|
2037
2153
|
}
|
|
2038
|
-
} else if (state.streaming) {
|
|
2154
|
+
} else if (state.streaming && !state.isBackfilling) {
|
|
2039
2155
|
finalizeStreamingMsg();
|
|
2040
2156
|
state.streaming = false;
|
|
2041
2157
|
state.aborting = false;
|
|
2042
2158
|
setComposerAborting(false);
|
|
2043
|
-
|
|
2044
|
-
syncSessionHistory(state.currentSessionFile, true);
|
|
2045
|
-
} else {
|
|
2046
|
-
refreshSessions();
|
|
2047
|
-
}
|
|
2159
|
+
refreshSessions();
|
|
2048
2160
|
}
|
|
2049
2161
|
}
|
|
2050
2162
|
}
|
|
@@ -2644,7 +2756,7 @@ function submitSteer() {
|
|
|
2644
2756
|
return;
|
|
2645
2757
|
}
|
|
2646
2758
|
|
|
2647
|
-
appendMessageNode("user", { text, isSteer: true });
|
|
2759
|
+
appendMessageNode("user", { text, isSteer: true, ts: Date.now() });
|
|
2648
2760
|
ta.value = "";
|
|
2649
2761
|
autoResize();
|
|
2650
2762
|
updateComposerUI();
|
|
@@ -2690,8 +2802,9 @@ function submitPrompt() {
|
|
|
2690
2802
|
}
|
|
2691
2803
|
|
|
2692
2804
|
if (hint) hint.textContent = "pi 会执行命令与读写你的文件 —— 请注意操作内容。"; // restore default
|
|
2805
|
+
const now = Date.now();
|
|
2693
2806
|
// Render the user's message locally for instant feedback.
|
|
2694
|
-
appendMessageNode("user", { text, images: state.attachedImages ? [...state.attachedImages] : [] });
|
|
2807
|
+
appendMessageNode("user", { text, images: state.attachedImages ? [...state.attachedImages] : [], ts: now });
|
|
2695
2808
|
|
|
2696
2809
|
ta.value = "";
|
|
2697
2810
|
state.attachedImages = [];
|
|
@@ -2701,12 +2814,34 @@ function submitPrompt() {
|
|
|
2701
2814
|
state.streaming = true;
|
|
2702
2815
|
state.aborting = false;
|
|
2703
2816
|
setComposerAborting(true);
|
|
2704
|
-
ensureStreamingMsg();
|
|
2817
|
+
ensureStreamingMsg(now);
|
|
2705
2818
|
refreshStreamingContent();
|
|
2706
2819
|
|
|
2707
2820
|
// Set session name from the first prompt of a brand-new session.
|
|
2708
2821
|
if (state.currentSessionFile == null && text) {
|
|
2709
|
-
|
|
2822
|
+
const promptTitle = text.slice(0, 60).replace(/\s+/g, " ");
|
|
2823
|
+
$("#topSessionName").textContent = promptTitle;
|
|
2824
|
+
sendWs({ type: "set_session_name", name: promptTitle });
|
|
2825
|
+
|
|
2826
|
+
// Update draft session item in sidebar immediately
|
|
2827
|
+
const list = $("#sessionList");
|
|
2828
|
+
if (list) {
|
|
2829
|
+
const draftItem = list.querySelector(".session-item.draft-session") || list.querySelector(".session-item.active");
|
|
2830
|
+
if (draftItem) {
|
|
2831
|
+
const nameSpan = draftItem.querySelector(".session-item-name span");
|
|
2832
|
+
if (nameSpan) nameSpan.textContent = promptTitle;
|
|
2833
|
+
let badge = draftItem.querySelector(".session-running-badge");
|
|
2834
|
+
if (!badge) {
|
|
2835
|
+
badge = el("span", { class: "session-running-badge", title: "任务正在后台生成中…" }, [
|
|
2836
|
+
el("span", { class: "spinner-dot" }),
|
|
2837
|
+
el("span", { text: "运行中" }),
|
|
2838
|
+
]);
|
|
2839
|
+
draftItem.querySelector(".session-item-name")?.appendChild(badge);
|
|
2840
|
+
}
|
|
2841
|
+
const meta = draftItem.querySelector(".meta");
|
|
2842
|
+
if (meta) meta.textContent = "刚刚 · 1 条";
|
|
2843
|
+
}
|
|
2844
|
+
}
|
|
2710
2845
|
}
|
|
2711
2846
|
sendWs({ type: "prompt", message: text, images: imagesToSend });
|
|
2712
2847
|
}
|
package/public/style.css
CHANGED
|
@@ -529,6 +529,17 @@ body {
|
|
|
529
529
|
.msg.user {
|
|
530
530
|
align-items: flex-end;
|
|
531
531
|
}
|
|
532
|
+
.msg.user .role-tag {
|
|
533
|
+
font-size: 12px;
|
|
534
|
+
color: var(--text-dim);
|
|
535
|
+
font-weight: 600;
|
|
536
|
+
letter-spacing: .3px;
|
|
537
|
+
display: flex;
|
|
538
|
+
align-items: center;
|
|
539
|
+
gap: 8px;
|
|
540
|
+
margin-bottom: 2px;
|
|
541
|
+
padding-right: 4px;
|
|
542
|
+
}
|
|
532
543
|
.msg.user .bubble {
|
|
533
544
|
background: var(--bg-user-bubble);
|
|
534
545
|
color: var(--user-text);
|
|
@@ -554,9 +565,19 @@ body {
|
|
|
554
565
|
.msg.assistant { align-items: stretch; }
|
|
555
566
|
.msg.assistant .role-tag {
|
|
556
567
|
font-size: 12px; color: var(--text-dim); font-weight: 600; letter-spacing: .3px;
|
|
557
|
-
display: flex; align-items: center; gap:
|
|
568
|
+
display: flex; align-items: center; gap: 8px;
|
|
558
569
|
margin-bottom: 2px;
|
|
559
570
|
}
|
|
571
|
+
.role-name {
|
|
572
|
+
font-weight: 600;
|
|
573
|
+
}
|
|
574
|
+
.msg-time {
|
|
575
|
+
font-size: 11px;
|
|
576
|
+
font-weight: 400;
|
|
577
|
+
color: var(--text-muted);
|
|
578
|
+
letter-spacing: 0;
|
|
579
|
+
user-select: none;
|
|
580
|
+
}
|
|
560
581
|
.msg.assistant .content {
|
|
561
582
|
font-size: 15px; line-height: 1.65; color: var(--text);
|
|
562
583
|
max-width: 100%; min-width: 0;
|
|
@@ -1795,10 +1816,15 @@ body {
|
|
|
1795
1816
|
border-radius: 16px;
|
|
1796
1817
|
}
|
|
1797
1818
|
|
|
1819
|
+
.msg.user .role-tag,
|
|
1798
1820
|
.msg.assistant .role-tag {
|
|
1799
1821
|
font-size: 11px;
|
|
1800
1822
|
}
|
|
1801
1823
|
|
|
1824
|
+
.msg-time {
|
|
1825
|
+
font-size: 10px;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1802
1828
|
.msg.assistant .content {
|
|
1803
1829
|
font-size: 14px;
|
|
1804
1830
|
line-height: 1.6;
|