@liguoshuai/pi-web-chat 2.2.0 → 2.4.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 +17 -0
- package/package.json +4 -3
- package/public/app.js +227 -58
- package/public/style.css +62 -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,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [2.3.0] - 2026-08-31
|
|
11
|
+
|
|
12
|
+
### Fixed & Improved
|
|
13
|
+
- **后台异步长任务稳定性与持久化机制全面强化 (Background Async Persistence & Lifetime Fix)**:
|
|
14
|
+
- **移除 30 分钟无条件强杀定时器**:将 `MAX_AGENT_LIFETIME_MS` 默认设为 `0`(禁用硬超时杀进程)。若用户配置了正数生命周期上限,也会严格判断 `isBusy`(正在生成/执行工具中)与 `hasListeners`(仍有客户端监听),任务执行期间绝不强杀子进程。
|
|
15
|
+
- **扩大离线事件环形缓冲区**:将 `EVENT_BUFFER_SIZE` 默认容量从 2000 提升至 5000,保障长时间后台离线输出不丢帧。
|
|
16
|
+
- **Web 端平滑多任务切换与静默转入后台 (Seamless Background Session Switch)**:
|
|
17
|
+
- 移除了在生成过程中新建会话(`startNewSession`)或侧边栏切换会话(`loadSession`)时的强制中断(`abortGeneration()`)逻辑。
|
|
18
|
+
- 切换或新建会话时,原会话平滑转入后台静默继续执行,并弹出轻提示告知用户。
|
|
19
|
+
- 修复了断线重连(`ws.onopen`)与离线事件增量回放(Backfill)之间的 DOM 渲染竞态条件,避免流式内容被意外清除。
|
|
20
|
+
- **侧边栏会话后台执行状态感知 (Sidebar Background Execution Indicator)**:
|
|
21
|
+
- 服务端 `/api/sessions` 接口扩展返回 `isStreaming` 状态字段,精准反馈每个会话对应的后台 Agent 是否正在忙碌。
|
|
22
|
+
- Web 端侧边栏为正在后台生成中的会话实时展示 `⚡ 运行中` 呼吸动画状态徽标。
|
|
23
|
+
- 页面可见性恢复(`visibilitychange`)及定时后台状态轮询自动刷新各会话状态。
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
10
27
|
## [2.2.0] - 2026-08-30
|
|
11
28
|
|
|
12
29
|
### Fixed & Improved
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liguoshuai/pi-web-chat",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.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,12 +40,13 @@
|
|
|
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.4.0",
|
|
44
|
+
"@liguoshuai/pi-chat-server": "2.4.0",
|
|
45
45
|
"express": "^4.21.2",
|
|
46
46
|
"ws": "^8.18.0"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
|
+
"build": "node --check server.js && node --check bin/pi-web-chat.js && node --check public/app.js",
|
|
49
50
|
"start": "node server.js",
|
|
50
51
|
"dev": "node --watch server.js",
|
|
51
52
|
"test": "node --test"
|
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,90 @@ 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
|
+
|
|
520
550
|
// ---- Sidebar / sessions ----
|
|
521
551
|
async function refreshSessions() {
|
|
522
552
|
const cwd = state.cwd || "";
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
553
|
+
try {
|
|
554
|
+
const res = await authFetch(`${API}/api/sessions?cwd=${encodeURIComponent(cwd)}`);
|
|
555
|
+
const data = await res.json();
|
|
556
|
+
renderSidebar(data.sessions || []);
|
|
557
|
+
} catch (err) {
|
|
558
|
+
console.warn("refreshSessions error:", err);
|
|
559
|
+
renderSidebar([]);
|
|
560
|
+
}
|
|
526
561
|
}
|
|
527
562
|
|
|
528
563
|
function renderSidebar(sessions) {
|
|
529
564
|
const list = $("#sessionList");
|
|
565
|
+
if (!list) return;
|
|
530
566
|
list.innerHTML = "";
|
|
531
|
-
|
|
567
|
+
|
|
568
|
+
const hasCurrentInSessions = Boolean(state.currentSessionFile && sessions.some(s => s.file === state.currentSessionFile));
|
|
569
|
+
|
|
570
|
+
// If current session is a new/draft session not yet in the session list from REST API:
|
|
571
|
+
if (!hasCurrentInSessions && (!state.currentSessionFile || $("#emptyState")?.style.display !== "none" || state.streaming)) {
|
|
572
|
+
const draftTitle = $("#topSessionName")?.textContent || "新对话";
|
|
573
|
+
const isRunning = Boolean(state.streaming);
|
|
574
|
+
const runningBadge = isRunning ? el("span", { class: "session-running-badge", title: "任务正在后台生成中…" }, [
|
|
575
|
+
el("span", { class: "spinner-dot" }),
|
|
576
|
+
el("span", { text: "运行中" }),
|
|
577
|
+
]) : null;
|
|
578
|
+
|
|
579
|
+
const titleEl = el("div", { class: "session-item-name" }, [
|
|
580
|
+
el("span", { text: draftTitle }),
|
|
581
|
+
...(runningBadge ? [runningBadge] : []),
|
|
582
|
+
]);
|
|
583
|
+
|
|
584
|
+
const draftItem = el("div", {
|
|
585
|
+
class: "session-item active draft-session",
|
|
586
|
+
dataset: { file: state.currentSessionFile || "" },
|
|
587
|
+
title: state.currentSessionFile || "新对话",
|
|
588
|
+
onclick: () => {
|
|
589
|
+
if (state.currentSessionFile) loadSession(state.currentSessionFile);
|
|
590
|
+
},
|
|
591
|
+
}, [
|
|
592
|
+
el("div", { class: "title" }, [
|
|
593
|
+
titleEl,
|
|
594
|
+
el("div", { class: "meta", text: `刚刚 · ${state.streaming ? 1 : 0} 条` }),
|
|
595
|
+
]),
|
|
596
|
+
]);
|
|
597
|
+
list.appendChild(draftItem);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (sessions.length === 0 && list.children.length === 0) {
|
|
532
601
|
list.appendChild(el("div", { class: "sidebar-empty", text: "没有会话记录" }));
|
|
533
602
|
return;
|
|
534
603
|
}
|
|
604
|
+
|
|
535
605
|
sessions.forEach((s) => {
|
|
606
|
+
if (s.file === state.currentSessionFile && !hasCurrentInSessions) return;
|
|
536
607
|
const title = s.sessionName || s.firstUser || "新对话";
|
|
537
608
|
const when = s.timestamp ? new Date(s.timestamp).toLocaleString("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "";
|
|
538
609
|
|
|
@@ -546,6 +617,17 @@ function renderSidebar(sessions) {
|
|
|
546
617
|
},
|
|
547
618
|
});
|
|
548
619
|
|
|
620
|
+
const isRunning = Boolean(s.isStreaming || (s.file === state.currentSessionFile && state.streaming));
|
|
621
|
+
const runningBadge = isRunning ? el("span", { class: "session-running-badge", title: "任务正在后台生成中…" }, [
|
|
622
|
+
el("span", { class: "spinner-dot" }),
|
|
623
|
+
el("span", { text: "运行中" }),
|
|
624
|
+
]) : null;
|
|
625
|
+
|
|
626
|
+
const titleEl = el("div", { class: "session-item-name" }, [
|
|
627
|
+
el("span", { text: title }),
|
|
628
|
+
...(runningBadge ? [runningBadge] : []),
|
|
629
|
+
]);
|
|
630
|
+
|
|
549
631
|
const item = el("div", {
|
|
550
632
|
class: "session-item" + (s.file === state.currentSessionFile ? " active" : ""),
|
|
551
633
|
dataset: { file: s.file },
|
|
@@ -553,7 +635,7 @@ function renderSidebar(sessions) {
|
|
|
553
635
|
onclick: () => loadSession(s.file),
|
|
554
636
|
}, [
|
|
555
637
|
el("div", { class: "title" }, [
|
|
556
|
-
|
|
638
|
+
titleEl,
|
|
557
639
|
el("div", { class: "meta", text: `${when} · ${s.messageCount || 0} 条` }),
|
|
558
640
|
]),
|
|
559
641
|
btnDelete,
|
|
@@ -587,21 +669,36 @@ function filterSessions(query) {
|
|
|
587
669
|
}
|
|
588
670
|
}
|
|
589
671
|
|
|
590
|
-
function startNewSession(
|
|
591
|
-
|
|
592
|
-
if (askConfirm && !confirm("正在生成中,新建会话会终止当前操作,确定吗?")) return;
|
|
593
|
-
abortGeneration();
|
|
594
|
-
}
|
|
672
|
+
function startNewSession() {
|
|
673
|
+
const wasStreaming = state.streaming;
|
|
595
674
|
clearChat();
|
|
596
675
|
showEmptyState(true);
|
|
597
676
|
state.currentSessionFile = null;
|
|
677
|
+
state.streaming = false;
|
|
678
|
+
state.aborting = false;
|
|
679
|
+
state.streamingItems = [];
|
|
680
|
+
state.streamingMsg = null;
|
|
681
|
+
state.activeToolCalls.clear();
|
|
682
|
+
setComposerAborting(false);
|
|
598
683
|
try {
|
|
599
684
|
window.history.replaceState({}, "", window.location.pathname);
|
|
600
685
|
} catch {}
|
|
601
|
-
connectWs({ explicitNewSession: true }); // no session -> pi creates a new one
|
|
602
686
|
$("#topSessionName").textContent = "新对话";
|
|
687
|
+
if (wasStreaming) {
|
|
688
|
+
showToast("前一个会话已转入后台继续运行");
|
|
689
|
+
}
|
|
603
690
|
// Mobile: close sidebar on new session
|
|
604
691
|
if (window.innerWidth <= 768) closeSidebar();
|
|
692
|
+
|
|
693
|
+
// If WebSocket is already live, request new session directly without reconnect overhead
|
|
694
|
+
if (state.ws && state.ws.readyState === 1) {
|
|
695
|
+
sendWs({ type: "new_session" });
|
|
696
|
+
sendWs({ type: "get_state" });
|
|
697
|
+
} else {
|
|
698
|
+
connectWs({ explicitNewSession: true }); // no session -> pi creates a new one
|
|
699
|
+
}
|
|
700
|
+
// Instantly render optimistic new session at the top of left sidebar
|
|
701
|
+
renderSidebar([]);
|
|
605
702
|
refreshSessions();
|
|
606
703
|
}
|
|
607
704
|
|
|
@@ -683,8 +780,8 @@ async function syncSessionHistory(file, force = false) {
|
|
|
683
780
|
const topName = data.sessionName || (data.header?.id ? baseName(file) : "新对话");
|
|
684
781
|
$("#topSessionName").textContent = topName;
|
|
685
782
|
|
|
686
|
-
// Only overwrite chat if
|
|
687
|
-
if (force || !state.streaming) {
|
|
783
|
+
// Only overwrite chat if not actively backfilling
|
|
784
|
+
if (!state.isBackfilling && (force || !state.streaming)) {
|
|
688
785
|
clearChat();
|
|
689
786
|
const msgs = reconstructFromEntries(data.entries || []);
|
|
690
787
|
showEmptyState(msgs.length === 0);
|
|
@@ -700,9 +797,8 @@ async function syncSessionHistory(file, force = false) {
|
|
|
700
797
|
}
|
|
701
798
|
|
|
702
799
|
async function loadSession(file) {
|
|
703
|
-
if (state.
|
|
704
|
-
|
|
705
|
-
}
|
|
800
|
+
if (file === state.currentSessionFile) return;
|
|
801
|
+
const wasStreaming = state.streaming;
|
|
706
802
|
state.currentSessionFile = file;
|
|
707
803
|
state.streaming = false;
|
|
708
804
|
state.aborting = false;
|
|
@@ -721,6 +817,9 @@ async function loadSession(file) {
|
|
|
721
817
|
await syncSessionHistory(file, true);
|
|
722
818
|
// Reconnect websocket pointed at this session so new prompts continue history.
|
|
723
819
|
connectWs({ session: file });
|
|
820
|
+
if (wasStreaming) {
|
|
821
|
+
showToast("前一个会话已转入后台继续运行");
|
|
822
|
+
}
|
|
724
823
|
}
|
|
725
824
|
|
|
726
825
|
function reconstructFromEntries(entries) {
|
|
@@ -752,7 +851,7 @@ function reconstructFromEntries(entries) {
|
|
|
752
851
|
url: c.data ? `data:${c.mimeType || "image/png"};base64,${c.data}` : (c.url || "")
|
|
753
852
|
}));
|
|
754
853
|
}
|
|
755
|
-
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 });
|
|
756
855
|
} else if (m.role === "assistant") {
|
|
757
856
|
const rawContent = Array.isArray(m.content) ? m.content : (m.content ? [{ type: "text", text: String(m.content) }] : []);
|
|
758
857
|
const content = rawContent.map(part => {
|
|
@@ -770,7 +869,7 @@ function reconstructFromEntries(entries) {
|
|
|
770
869
|
} catch {}
|
|
771
870
|
content.push({ type: "text", text: `⚠️ **生成失败**: ${errMsg}` });
|
|
772
871
|
}
|
|
773
|
-
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 });
|
|
774
873
|
}
|
|
775
874
|
// toolResult entries are attached directly to assistant toolCall parts, so they don't produce standalone messages
|
|
776
875
|
}
|
|
@@ -1061,13 +1160,15 @@ function exportCurrentSession() {
|
|
|
1061
1160
|
|
|
1062
1161
|
const msgs = chatInner.querySelectorAll(".msg");
|
|
1063
1162
|
msgs.forEach(msg => {
|
|
1163
|
+
const time = msg.querySelector(".msg-time")?.innerText || "";
|
|
1164
|
+
const timeInfo = time ? ` (${time})` : "";
|
|
1064
1165
|
if (msg.classList.contains("user")) {
|
|
1065
1166
|
const isSteer = !!msg.querySelector(".steer-badge");
|
|
1066
1167
|
const bubble = msg.querySelector(".bubble");
|
|
1067
1168
|
const text = bubble ? bubble.innerText.replace("🧭 指导指令", "").trim() : "";
|
|
1068
|
-
md += `### 👤
|
|
1169
|
+
md += `### 👤 user${timeInfo}${isSteer ? " (🧭 指导指令)" : ""}\n\n${text}\n\n`;
|
|
1069
1170
|
} else if (msg.classList.contains("assistant")) {
|
|
1070
|
-
md += `### 🤖 pi\n\n`;
|
|
1171
|
+
md += `### 🤖 pi${timeInfo}\n\n`;
|
|
1071
1172
|
const thinking = msg.querySelector(".thinking-body");
|
|
1072
1173
|
if (thinking && thinking.innerText.trim()) {
|
|
1073
1174
|
md += `<details><summary>💭 思考过程</summary>\n\n${thinking.innerText.trim()}\n\n</details>\n\n`;
|
|
@@ -1102,6 +1203,10 @@ function exportCurrentSession() {
|
|
|
1102
1203
|
function appendMessageNode(role, m) {
|
|
1103
1204
|
if (role === "user") {
|
|
1104
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
|
+
|
|
1105
1210
|
const steerBadge = isSteer ? el("span", { class: "steer-badge", text: "🧭 指导指令" }) : null;
|
|
1106
1211
|
const bubbleChildren = [steerBadge];
|
|
1107
1212
|
if (m.text) {
|
|
@@ -1124,7 +1229,16 @@ function appendMessageNode(role, m) {
|
|
|
1124
1229
|
bubbleChildren.push(imgsContainer);
|
|
1125
1230
|
}
|
|
1126
1231
|
const bubble = el("div", { class: "bubble" + (isSteer ? " steer" : "") }, bubbleChildren);
|
|
1127
|
-
|
|
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]);
|
|
1128
1242
|
$("#chat-inner").appendChild(node);
|
|
1129
1243
|
scrollBottom();
|
|
1130
1244
|
return node;
|
|
@@ -1133,6 +1247,10 @@ function appendMessageNode(role, m) {
|
|
|
1133
1247
|
}
|
|
1134
1248
|
|
|
1135
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
|
+
|
|
1136
1254
|
// m.content is array of {type:text|thinking|toolCall}
|
|
1137
1255
|
const fullText = extractContentText(m.content);
|
|
1138
1256
|
const copyMsgBtn = el("button", {
|
|
@@ -1150,11 +1268,16 @@ function renderAssistantBlock(m) {
|
|
|
1150
1268
|
el("span", { text: "复制全文" })
|
|
1151
1269
|
]);
|
|
1152
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
|
+
|
|
1153
1279
|
const node = el("div", { class: "msg assistant" }, [
|
|
1154
|
-
el("div", { class: "role-tag" },
|
|
1155
|
-
el("span", { text: "pi" }),
|
|
1156
|
-
copyMsgBtn
|
|
1157
|
-
]),
|
|
1280
|
+
el("div", { class: "role-tag" }, roleChildren),
|
|
1158
1281
|
el("div", { class: "content" }),
|
|
1159
1282
|
]);
|
|
1160
1283
|
const content = node.querySelector(".content");
|
|
@@ -1355,28 +1478,38 @@ function getStreamingFullText() {
|
|
|
1355
1478
|
}
|
|
1356
1479
|
|
|
1357
1480
|
// ---- Streaming: handle live assistant message ----
|
|
1358
|
-
function ensureStreamingMsg() {
|
|
1481
|
+
function ensureStreamingMsg(ts = Date.now()) {
|
|
1359
1482
|
if (state.streamingMsg) return state.streamingMsg;
|
|
1360
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
|
+
|
|
1361
1511
|
const node = el("div", { class: "msg assistant" }, [
|
|
1362
|
-
el("div", { class: "role-tag" },
|
|
1363
|
-
el("span", { text: "pi" }),
|
|
1364
|
-
el("button", {
|
|
1365
|
-
class: "btn-copy-msg",
|
|
1366
|
-
type: "button",
|
|
1367
|
-
title: "复制回答全文",
|
|
1368
|
-
onclick: async (e) => {
|
|
1369
|
-
e.stopPropagation();
|
|
1370
|
-
const text = getStreamingFullText();
|
|
1371
|
-
if (await copyToClipboard(text)) {
|
|
1372
|
-
showToast("已复制回答全文");
|
|
1373
|
-
}
|
|
1374
|
-
}
|
|
1375
|
-
}, [
|
|
1376
|
-
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>' }),
|
|
1377
|
-
el("span", { text: "复制全文" })
|
|
1378
|
-
])
|
|
1379
|
-
]),
|
|
1512
|
+
el("div", { class: "role-tag" }, roleChildren),
|
|
1380
1513
|
el("div", { class: "content" }),
|
|
1381
1514
|
]);
|
|
1382
1515
|
state.streamingMsg = node;
|
|
@@ -1621,8 +1754,8 @@ function connectWs(opts = {}) {
|
|
|
1621
1754
|
wasDisconnected = false;
|
|
1622
1755
|
reconnectAttempts = 0;
|
|
1623
1756
|
|
|
1624
|
-
|
|
1625
|
-
|
|
1757
|
+
sendWs({ type: "get_state" });
|
|
1758
|
+
sendWs({ type: "get_available_models" });
|
|
1626
1759
|
};
|
|
1627
1760
|
|
|
1628
1761
|
ws.onclose = () => {
|
|
@@ -1670,14 +1803,15 @@ function sendWs(obj) {
|
|
|
1670
1803
|
|
|
1671
1804
|
function handlePiMessage(obj) {
|
|
1672
1805
|
// Automatically bind to the session file as soon as pi allocates it on disk
|
|
1673
|
-
|
|
1674
|
-
|
|
1806
|
+
const sessionFile = obj.data?.sessionFile || obj.sessionFile || obj.data?.sessionPath || obj.sessionPath;
|
|
1807
|
+
if (sessionFile && sessionFile !== state.currentSessionFile) {
|
|
1808
|
+
state.currentSessionFile = sessionFile;
|
|
1675
1809
|
try {
|
|
1676
|
-
const newUrl = window.location.pathname + "?session=" + encodeURIComponent(
|
|
1677
|
-
window.history.replaceState({ session:
|
|
1810
|
+
const newUrl = window.location.pathname + "?session=" + encodeURIComponent(sessionFile);
|
|
1811
|
+
window.history.replaceState({ session: sessionFile }, "", newUrl);
|
|
1678
1812
|
} catch {}
|
|
1679
1813
|
if ($("#topSessionName").textContent === "新对话") {
|
|
1680
|
-
$("#topSessionName").textContent = baseName(
|
|
1814
|
+
$("#topSessionName").textContent = baseName(sessionFile);
|
|
1681
1815
|
}
|
|
1682
1816
|
refreshSessions();
|
|
1683
1817
|
}
|
|
@@ -1780,7 +1914,7 @@ function handlePiMessage(obj) {
|
|
|
1780
1914
|
}
|
|
1781
1915
|
break;
|
|
1782
1916
|
case "remote_user_prompt":
|
|
1783
|
-
appendMessageNode("user", { text: obj.message, images: obj.images, isSteer: obj.isSteer });
|
|
1917
|
+
appendMessageNode("user", { text: obj.message, images: obj.images, isSteer: obj.isSteer, ts: obj.timestamp || Date.now() });
|
|
1784
1918
|
break;
|
|
1785
1919
|
case "agent_start":
|
|
1786
1920
|
state.streaming = true;
|
|
@@ -2624,7 +2758,7 @@ function submitSteer() {
|
|
|
2624
2758
|
return;
|
|
2625
2759
|
}
|
|
2626
2760
|
|
|
2627
|
-
appendMessageNode("user", { text, isSteer: true });
|
|
2761
|
+
appendMessageNode("user", { text, isSteer: true, ts: Date.now() });
|
|
2628
2762
|
ta.value = "";
|
|
2629
2763
|
autoResize();
|
|
2630
2764
|
updateComposerUI();
|
|
@@ -2670,8 +2804,9 @@ function submitPrompt() {
|
|
|
2670
2804
|
}
|
|
2671
2805
|
|
|
2672
2806
|
if (hint) hint.textContent = "pi 会执行命令与读写你的文件 —— 请注意操作内容。"; // restore default
|
|
2807
|
+
const now = Date.now();
|
|
2673
2808
|
// Render the user's message locally for instant feedback.
|
|
2674
|
-
appendMessageNode("user", { text, images: state.attachedImages ? [...state.attachedImages] : [] });
|
|
2809
|
+
appendMessageNode("user", { text, images: state.attachedImages ? [...state.attachedImages] : [], ts: now });
|
|
2675
2810
|
|
|
2676
2811
|
ta.value = "";
|
|
2677
2812
|
state.attachedImages = [];
|
|
@@ -2681,12 +2816,34 @@ function submitPrompt() {
|
|
|
2681
2816
|
state.streaming = true;
|
|
2682
2817
|
state.aborting = false;
|
|
2683
2818
|
setComposerAborting(true);
|
|
2684
|
-
ensureStreamingMsg();
|
|
2819
|
+
ensureStreamingMsg(now);
|
|
2685
2820
|
refreshStreamingContent();
|
|
2686
2821
|
|
|
2687
2822
|
// Set session name from the first prompt of a brand-new session.
|
|
2688
2823
|
if (state.currentSessionFile == null && text) {
|
|
2689
|
-
|
|
2824
|
+
const promptTitle = text.slice(0, 60).replace(/\s+/g, " ");
|
|
2825
|
+
$("#topSessionName").textContent = promptTitle;
|
|
2826
|
+
sendWs({ type: "set_session_name", name: promptTitle });
|
|
2827
|
+
|
|
2828
|
+
// Update draft session item in sidebar immediately
|
|
2829
|
+
const list = $("#sessionList");
|
|
2830
|
+
if (list) {
|
|
2831
|
+
const draftItem = list.querySelector(".session-item.draft-session") || list.querySelector(".session-item.active");
|
|
2832
|
+
if (draftItem) {
|
|
2833
|
+
const nameSpan = draftItem.querySelector(".session-item-name span");
|
|
2834
|
+
if (nameSpan) nameSpan.textContent = promptTitle;
|
|
2835
|
+
let badge = draftItem.querySelector(".session-running-badge");
|
|
2836
|
+
if (!badge) {
|
|
2837
|
+
badge = el("span", { class: "session-running-badge", title: "任务正在后台生成中…" }, [
|
|
2838
|
+
el("span", { class: "spinner-dot" }),
|
|
2839
|
+
el("span", { text: "运行中" }),
|
|
2840
|
+
]);
|
|
2841
|
+
draftItem.querySelector(".session-item-name")?.appendChild(badge);
|
|
2842
|
+
}
|
|
2843
|
+
const meta = draftItem.querySelector(".meta");
|
|
2844
|
+
if (meta) meta.textContent = "刚刚 · 1 条";
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2690
2847
|
}
|
|
2691
2848
|
sendWs({ type: "prompt", message: text, images: imagesToSend });
|
|
2692
2849
|
}
|
|
@@ -2786,12 +2943,15 @@ async function init() {
|
|
|
2786
2943
|
}
|
|
2787
2944
|
|
|
2788
2945
|
const ta = $("#composer");
|
|
2946
|
+
let isComposing = false;
|
|
2947
|
+
ta.addEventListener("compositionstart", () => { isComposing = true; });
|
|
2948
|
+
ta.addEventListener("compositionend", () => { isComposing = false; });
|
|
2789
2949
|
ta.addEventListener("input", () => {
|
|
2790
2950
|
autoResize();
|
|
2791
2951
|
updateComposerUI();
|
|
2792
2952
|
});
|
|
2793
2953
|
ta.addEventListener("keydown", (e) => {
|
|
2794
|
-
if (e.key === "Enter" && !e.shiftKey && !e.isComposing && e.keyCode !== 229) {
|
|
2954
|
+
if (e.key === "Enter" && !e.shiftKey && !e.isComposing && !isComposing && e.keyCode !== 229) {
|
|
2795
2955
|
e.preventDefault();
|
|
2796
2956
|
if (state.streaming) {
|
|
2797
2957
|
if (ta.value.trim()) {
|
|
@@ -3080,6 +3240,7 @@ async function init() {
|
|
|
3080
3240
|
|
|
3081
3241
|
document.addEventListener("visibilitychange", () => {
|
|
3082
3242
|
if (document.visibilityState === "visible") {
|
|
3243
|
+
refreshSessions();
|
|
3083
3244
|
if (!state.wsConnected) {
|
|
3084
3245
|
reconnectAttempts = 0;
|
|
3085
3246
|
scheduleReconnect(0);
|
|
@@ -3093,6 +3254,7 @@ async function init() {
|
|
|
3093
3254
|
});
|
|
3094
3255
|
|
|
3095
3256
|
window.addEventListener("pageshow", () => {
|
|
3257
|
+
refreshSessions();
|
|
3096
3258
|
if (!state.wsConnected) {
|
|
3097
3259
|
reconnectAttempts = 0;
|
|
3098
3260
|
scheduleReconnect(0);
|
|
@@ -3101,6 +3263,13 @@ async function init() {
|
|
|
3101
3263
|
}
|
|
3102
3264
|
});
|
|
3103
3265
|
|
|
3266
|
+
// Background session status polling
|
|
3267
|
+
setInterval(() => {
|
|
3268
|
+
if (document.visibilityState === "visible") {
|
|
3269
|
+
refreshSessions();
|
|
3270
|
+
}
|
|
3271
|
+
}, 10000);
|
|
3272
|
+
|
|
3104
3273
|
// Global event delegation for code block copy buttons
|
|
3105
3274
|
document.addEventListener("click", async (e) => {
|
|
3106
3275
|
const btn = e.target.closest(".btn-copy-code");
|
package/public/style.css
CHANGED
|
@@ -141,9 +141,44 @@ body {
|
|
|
141
141
|
line-height: 1.35;
|
|
142
142
|
}
|
|
143
143
|
.session-item .session-item-name {
|
|
144
|
+
display: flex;
|
|
145
|
+
align-items: center;
|
|
146
|
+
gap: 6px;
|
|
147
|
+
overflow: hidden;
|
|
148
|
+
text-overflow: ellipsis;
|
|
149
|
+
white-space: nowrap;
|
|
150
|
+
}
|
|
151
|
+
.session-item .session-item-name > span:first-child {
|
|
144
152
|
overflow: hidden;
|
|
145
153
|
text-overflow: ellipsis;
|
|
146
154
|
white-space: nowrap;
|
|
155
|
+
flex: 1;
|
|
156
|
+
min-width: 0;
|
|
157
|
+
}
|
|
158
|
+
.session-running-badge {
|
|
159
|
+
display: inline-flex;
|
|
160
|
+
align-items: center;
|
|
161
|
+
gap: 4px;
|
|
162
|
+
font-size: 10px;
|
|
163
|
+
line-height: 1;
|
|
164
|
+
color: var(--accent, #3b82f6);
|
|
165
|
+
background: rgba(59, 130, 246, 0.12);
|
|
166
|
+
border: 1px solid rgba(59, 130, 246, 0.25);
|
|
167
|
+
padding: 2px 5px;
|
|
168
|
+
border-radius: 9999px;
|
|
169
|
+
font-weight: 500;
|
|
170
|
+
flex-shrink: 0;
|
|
171
|
+
}
|
|
172
|
+
.session-running-badge .spinner-dot {
|
|
173
|
+
width: 6px;
|
|
174
|
+
height: 6px;
|
|
175
|
+
border-radius: 50%;
|
|
176
|
+
background-color: var(--accent, #3b82f6);
|
|
177
|
+
animation: pulse-dot 1.2s infinite ease-in-out;
|
|
178
|
+
}
|
|
179
|
+
@keyframes pulse-dot {
|
|
180
|
+
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
|
181
|
+
50% { opacity: 1; transform: scale(1.2); }
|
|
147
182
|
}
|
|
148
183
|
.session-item .meta {
|
|
149
184
|
font-size: 11px;
|
|
@@ -494,6 +529,17 @@ body {
|
|
|
494
529
|
.msg.user {
|
|
495
530
|
align-items: flex-end;
|
|
496
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
|
+
}
|
|
497
543
|
.msg.user .bubble {
|
|
498
544
|
background: var(--bg-user-bubble);
|
|
499
545
|
color: var(--user-text);
|
|
@@ -519,9 +565,19 @@ body {
|
|
|
519
565
|
.msg.assistant { align-items: stretch; }
|
|
520
566
|
.msg.assistant .role-tag {
|
|
521
567
|
font-size: 12px; color: var(--text-dim); font-weight: 600; letter-spacing: .3px;
|
|
522
|
-
display: flex; align-items: center; gap:
|
|
568
|
+
display: flex; align-items: center; gap: 8px;
|
|
523
569
|
margin-bottom: 2px;
|
|
524
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
|
+
}
|
|
525
581
|
.msg.assistant .content {
|
|
526
582
|
font-size: 15px; line-height: 1.65; color: var(--text);
|
|
527
583
|
max-width: 100%; min-width: 0;
|
|
@@ -1760,10 +1816,15 @@ body {
|
|
|
1760
1816
|
border-radius: 16px;
|
|
1761
1817
|
}
|
|
1762
1818
|
|
|
1819
|
+
.msg.user .role-tag,
|
|
1763
1820
|
.msg.assistant .role-tag {
|
|
1764
1821
|
font-size: 11px;
|
|
1765
1822
|
}
|
|
1766
1823
|
|
|
1824
|
+
.msg-time {
|
|
1825
|
+
font-size: 10px;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1767
1828
|
.msg.assistant .content {
|
|
1768
1829
|
font-size: 14px;
|
|
1769
1830
|
line-height: 1.6;
|