@liguoshuai/pi-web-chat 2.15.4 → 2.17.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/package.json +4 -4
- package/public/app.js +368 -85
- package/public/index.html +9 -1
- package/public/markdown.js +18 -3
- package/public/style.css +43 -2
package/README.md
CHANGED
|
@@ -181,7 +181,7 @@ Web 服务启动后,在浏览器中访问:
|
|
|
181
181
|
| `PORT` | `3000` | Web 服务监听端口 |
|
|
182
182
|
| `PI_BIN` | 自动探测(`~/.npm-global/bin/pi`、`/usr/local/bin/pi` 或 `PATH`) | 显式指定 pi 可执行文件绝对路径 |
|
|
183
183
|
| `PI_SESSIONS_DIR` | `~/.pi/agent/sessions` | pi 的 session 存储目录 |
|
|
184
|
-
| `IDLE_TIMEOUT_MS` | `
|
|
184
|
+
| `IDLE_TIMEOUT_MS` | `1800000` (30分钟) | 真正空闲(无连接+非流式)后的进程回收超时(`0` 为禁用回收) |
|
|
185
185
|
| `MAX_AGENT_LIFETIME_MS` | `0` (无上限) | 单个 Agent 进程后台生存硬上限(`0` 为禁用) |
|
|
186
186
|
| `EVENT_BUFFER_SIZE` | `5000` | 离线环形 Buffer 允许缓存的最大事件条数 |
|
|
187
187
|
| `MAX_CONCURRENT_AGENTS` | `0` (无限制) | 进程池最大并发 Agent 进程数量 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liguoshuai/pi-web-chat",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.17.8",
|
|
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",
|
|
@@ -42,13 +42,13 @@
|
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"express": "^4.21.2",
|
|
44
44
|
"ws": "^8.18.0",
|
|
45
|
-
"@liguoshuai/pi-chat-protocol": "2.
|
|
46
|
-
"@liguoshuai/pi-chat-server": "2.
|
|
45
|
+
"@liguoshuai/pi-chat-protocol": "2.17.8",
|
|
46
|
+
"@liguoshuai/pi-chat-server": "2.17.8"
|
|
47
47
|
},
|
|
48
48
|
"scripts": {
|
|
49
49
|
"build": "node --check server.js && node --check bin/pi-web-chat.js && node --check public/markdown.js && node --check public/app.js",
|
|
50
50
|
"start": "node server.js",
|
|
51
51
|
"dev": "node --watch server.js",
|
|
52
|
-
"test": "node --test"
|
|
52
|
+
"test": "node --test --test-timeout=8000"
|
|
53
53
|
}
|
|
54
54
|
}
|
package/public/app.js
CHANGED
|
@@ -81,6 +81,8 @@ const state = {
|
|
|
81
81
|
attachedImages: [], // Array of { data: string (base64), mimeType: string, url: string }
|
|
82
82
|
turnStartedAt: null,
|
|
83
83
|
streamingMsgDurationEl: null,
|
|
84
|
+
streamingWatchdog: null, // 5-min timeout for stuck streaming
|
|
85
|
+
lastSessions: [],
|
|
84
86
|
};
|
|
85
87
|
|
|
86
88
|
let toastTimer = null;
|
|
@@ -308,9 +310,11 @@ const makeCopyIconSvg = () => el("svg", {
|
|
|
308
310
|
stroke: "currentColor",
|
|
309
311
|
"stroke-width": "2",
|
|
310
312
|
"stroke-linecap": "round",
|
|
311
|
-
"stroke-linejoin": "round"
|
|
312
|
-
|
|
313
|
-
})
|
|
313
|
+
"stroke-linejoin": "round"
|
|
314
|
+
}, [
|
|
315
|
+
el("rect", { x: "9", y: "9", width: "13", height: "13", rx: "2", ry: "2" }),
|
|
316
|
+
el("path", { d: "M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" })
|
|
317
|
+
]);
|
|
314
318
|
|
|
315
319
|
// ---- Message Time Formatter ----
|
|
316
320
|
function formatMessageTime(ts) {
|
|
@@ -359,8 +363,10 @@ function startStreamingTimer() {
|
|
|
359
363
|
if (state.turnStartedAt && state.streamingMsgDurationEl) {
|
|
360
364
|
state.streamingMsgDurationEl.textContent = formatDuration(now - state.turnStartedAt);
|
|
361
365
|
}
|
|
362
|
-
|
|
363
|
-
|
|
366
|
+
// Single combined query instead of two separate querySelectorAll calls.
|
|
367
|
+
// Interval at 200ms (5 scans/sec) is still smooth for duration display.
|
|
368
|
+
const liveEls = document.querySelectorAll(".thinking-duration.live, .tool-duration.live");
|
|
369
|
+
liveEls.forEach((el) => {
|
|
364
370
|
if (el._startedAt) {
|
|
365
371
|
el.textContent = formatDuration(now - el._startedAt);
|
|
366
372
|
el.style.display = "";
|
|
@@ -373,6 +379,20 @@ function startStreamingTimer() {
|
|
|
373
379
|
el.style.display = "";
|
|
374
380
|
}
|
|
375
381
|
});
|
|
382
|
+
// Live update for the thinking placeholder spinner label before first delta arrives
|
|
383
|
+
const placeholder = document.querySelector(".thinking-placeholder");
|
|
384
|
+
if (placeholder && state.turnStartedAt) {
|
|
385
|
+
const elapsed = Math.max(0, now - state.turnStartedAt);
|
|
386
|
+
const labelEl = placeholder.querySelector(".thinking-label");
|
|
387
|
+
if (labelEl) {
|
|
388
|
+
const durStr = formatDuration(elapsed);
|
|
389
|
+
if (elapsed > 2500) {
|
|
390
|
+
labelEl.textContent = `正在深度推理中… (${durStr})`;
|
|
391
|
+
} else {
|
|
392
|
+
labelEl.textContent = `正在思考中… (${durStr})`;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
376
396
|
}, 100);
|
|
377
397
|
}
|
|
378
398
|
|
|
@@ -383,6 +403,20 @@ function stopStreamingTimer() {
|
|
|
383
403
|
}
|
|
384
404
|
}
|
|
385
405
|
|
|
406
|
+
function updateUrlSession(sessionFile) {
|
|
407
|
+
if (!sessionFile) return;
|
|
408
|
+
try {
|
|
409
|
+
const params = new URLSearchParams(window.location.search);
|
|
410
|
+
params.set("session", sessionFile);
|
|
411
|
+
if (state.cwd && !params.has("cwd")) {
|
|
412
|
+
params.set("cwd", state.cwd);
|
|
413
|
+
}
|
|
414
|
+
const query = params.toString();
|
|
415
|
+
const newUrl = query ? `${window.location.pathname}?${query}` : window.location.pathname;
|
|
416
|
+
window.history.replaceState({ session: sessionFile }, "", newUrl);
|
|
417
|
+
} catch {}
|
|
418
|
+
}
|
|
419
|
+
|
|
386
420
|
function sameSession(a, b) {
|
|
387
421
|
if (!a || !b) return false;
|
|
388
422
|
if (a === b) return true;
|
|
@@ -396,10 +430,14 @@ async function refreshSessions() {
|
|
|
396
430
|
try {
|
|
397
431
|
const res = await authFetch(`${API}/api/sessions?cwd=${encodeURIComponent(cwd)}`);
|
|
398
432
|
const data = await res.json();
|
|
399
|
-
|
|
433
|
+
state.lastSessions = data.sessions || [];
|
|
434
|
+
renderSidebar(state.lastSessions);
|
|
400
435
|
} catch (err) {
|
|
401
436
|
console.warn("refreshSessions error:", err);
|
|
402
|
-
|
|
437
|
+
// Preserve existing sessions list rather than blanking out the sidebar on error
|
|
438
|
+
if (state.lastSessions && state.lastSessions.length > 0) {
|
|
439
|
+
renderSidebar(state.lastSessions);
|
|
440
|
+
}
|
|
403
441
|
}
|
|
404
442
|
}
|
|
405
443
|
|
|
@@ -524,9 +562,15 @@ function startNewSession() {
|
|
|
524
562
|
state.activeToolCalls.clear();
|
|
525
563
|
setComposerStreaming(false);
|
|
526
564
|
try {
|
|
527
|
-
|
|
565
|
+
const params = new URLSearchParams(window.location.search);
|
|
566
|
+
params.delete("session");
|
|
567
|
+
params.delete("file");
|
|
568
|
+
const query = params.toString();
|
|
569
|
+
const newUrl = query ? `${window.location.pathname}?${query}` : window.location.pathname;
|
|
570
|
+
window.history.replaceState({}, "", newUrl);
|
|
528
571
|
} catch {}
|
|
529
572
|
$("#topSessionName").textContent = "新对话";
|
|
573
|
+
updatePageTitle(null);
|
|
530
574
|
if (wasStreaming) {
|
|
531
575
|
showToast("前一个会话已转入后台继续运行");
|
|
532
576
|
}
|
|
@@ -534,8 +578,8 @@ function startNewSession() {
|
|
|
534
578
|
if (window.innerWidth <= 768) closeSidebar();
|
|
535
579
|
|
|
536
580
|
connectWs({ explicitNewSession: true }); // no session -> pi creates a new one
|
|
537
|
-
//
|
|
538
|
-
renderSidebar([]);
|
|
581
|
+
// Keep existing sessions in sidebar and immediately display new draft item
|
|
582
|
+
renderSidebar(state.lastSessions || []);
|
|
539
583
|
refreshSessions();
|
|
540
584
|
}
|
|
541
585
|
|
|
@@ -553,7 +597,7 @@ async function deleteSession(file, title) {
|
|
|
553
597
|
return;
|
|
554
598
|
}
|
|
555
599
|
showToast("会话已删除");
|
|
556
|
-
if (state.currentSessionFile
|
|
600
|
+
if (sameSession(state.currentSessionFile, file)) {
|
|
557
601
|
startNewSession();
|
|
558
602
|
} else {
|
|
559
603
|
await refreshSessions();
|
|
@@ -586,6 +630,12 @@ function initMobileToolbarFab() {
|
|
|
586
630
|
} else {
|
|
587
631
|
fab.classList.remove("visible");
|
|
588
632
|
}
|
|
633
|
+
const distanceFromBottom = chat.scrollHeight - chat.scrollTop - chat.clientHeight;
|
|
634
|
+
if (distanceFromBottom <= 80) {
|
|
635
|
+
userScrolledUp = false;
|
|
636
|
+
} else if (distanceFromBottom > 150) {
|
|
637
|
+
userScrolledUp = true;
|
|
638
|
+
}
|
|
589
639
|
};
|
|
590
640
|
|
|
591
641
|
chat.addEventListener("scroll", onScroll);
|
|
@@ -616,6 +666,7 @@ async function syncSessionHistory(file, force = false) {
|
|
|
616
666
|
|
|
617
667
|
const topName = data.sessionName || data.firstUser || "新对话";
|
|
618
668
|
$("#topSessionName").textContent = topName;
|
|
669
|
+
updatePageTitle(topName);
|
|
619
670
|
|
|
620
671
|
// Only overwrite chat if not actively backfilling
|
|
621
672
|
if (!state.isBackfilling && (force || !state.streaming)) {
|
|
@@ -625,7 +676,8 @@ async function syncSessionHistory(file, force = false) {
|
|
|
625
676
|
for (const m of msgs) {
|
|
626
677
|
appendMessageNode(m.role, m);
|
|
627
678
|
}
|
|
628
|
-
|
|
679
|
+
userScrolledUp = false;
|
|
680
|
+
scrollBottom(true);
|
|
629
681
|
refreshSessions();
|
|
630
682
|
}
|
|
631
683
|
} catch (e) {
|
|
@@ -643,10 +695,7 @@ async function loadSession(file) {
|
|
|
643
695
|
state.streamingMsg = null;
|
|
644
696
|
state.activeToolCalls.clear();
|
|
645
697
|
setComposerStreaming(false);
|
|
646
|
-
|
|
647
|
-
const newUrl = window.location.pathname + "?session=" + encodeURIComponent(file);
|
|
648
|
-
window.history.replaceState({ session: file }, "", newUrl);
|
|
649
|
-
} catch {}
|
|
698
|
+
updateUrlSession(file);
|
|
650
699
|
// Mobile: close sidebar on selection
|
|
651
700
|
if (window.innerWidth <= 768) {
|
|
652
701
|
closeSidebar();
|
|
@@ -681,16 +730,39 @@ function reconstructFromEntries(entries, timing = null) {
|
|
|
681
730
|
}
|
|
682
731
|
}
|
|
683
732
|
|
|
733
|
+
// Precompute which entry indices are the last assistant message in their turn.
|
|
734
|
+
// A turn is delimited by user messages; the last assistant message before the
|
|
735
|
+
// next user message (or end of entries) is the last in its turn.
|
|
736
|
+
// We only show the total turn duration on this final message, not on every
|
|
737
|
+
// intermediate assistant message (e.g. tool-call messages within the same turn).
|
|
738
|
+
const lastAssistantInTurn = new Set();
|
|
739
|
+
let lastAssistantIdx = null;
|
|
740
|
+
for (let i = 0; i < entries.length; i++) {
|
|
741
|
+
const e = entries[i];
|
|
742
|
+
if (e.type !== "message" || !e.message) continue;
|
|
743
|
+
if (e.message.role === "assistant") {
|
|
744
|
+
lastAssistantIdx = i;
|
|
745
|
+
} else if (e.message.role === "user" && lastAssistantIdx != null) {
|
|
746
|
+
lastAssistantInTurn.add(lastAssistantIdx);
|
|
747
|
+
lastAssistantIdx = null;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
if (lastAssistantIdx != null) lastAssistantInTurn.add(lastAssistantIdx);
|
|
751
|
+
|
|
684
752
|
const out = [];
|
|
685
753
|
let lastUserTs = null;
|
|
686
|
-
let
|
|
687
|
-
|
|
754
|
+
let turnIndex = -1; // incremented per user message — aligns with timing[] (one entry per turn)
|
|
755
|
+
let thinkingIdx = 0; // accumulated across assistant messages within the same turn
|
|
756
|
+
for (let i = 0; i < entries.length; i++) {
|
|
757
|
+
const e = entries[i];
|
|
688
758
|
if (e.type !== "message") continue;
|
|
689
759
|
const m = e.message;
|
|
690
760
|
if (!m || m.role === "bashExecution") continue;
|
|
691
761
|
const msgTs = parseEntryTimestamp(m.timestamp || e.timestamp);
|
|
692
762
|
if (m.role === "user") {
|
|
693
763
|
lastUserTs = msgTs;
|
|
764
|
+
turnIndex++;
|
|
765
|
+
thinkingIdx = 0; // reset thinking index for the new turn
|
|
694
766
|
// Extract optional images from user message content array
|
|
695
767
|
let images = [];
|
|
696
768
|
if (Array.isArray(m.content)) {
|
|
@@ -705,17 +777,19 @@ function reconstructFromEntries(entries, timing = null) {
|
|
|
705
777
|
out.push({ role: "user", text: extractContentText(m.content), images, ts: msgTs });
|
|
706
778
|
} else if (m.role === "assistant") {
|
|
707
779
|
let turnDurationMs = null;
|
|
708
|
-
// Try timing data first, then fall back to timestamp heuristic
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
if (
|
|
715
|
-
|
|
780
|
+
// Try timing data first, then fall back to timestamp heuristic.
|
|
781
|
+
// Only show turn duration on the last assistant message of the turn.
|
|
782
|
+
const turnTiming = timing ? timing[turnIndex] : null;
|
|
783
|
+
if (lastAssistantInTurn.has(i)) {
|
|
784
|
+
if (turnTiming?.turnDuration != null) {
|
|
785
|
+
turnDurationMs = turnTiming.turnDuration;
|
|
786
|
+
} else if (lastUserTs && msgTs && msgTs >= lastUserTs) {
|
|
787
|
+
const diff = msgTs - lastUserTs;
|
|
788
|
+
if (diff > 0 && diff < 15 * 60 * 1000) {
|
|
789
|
+
turnDurationMs = diff;
|
|
790
|
+
}
|
|
716
791
|
}
|
|
717
792
|
}
|
|
718
|
-
let thinkingIdx = 0;
|
|
719
793
|
const rawContent = Array.isArray(m.content) ? m.content : (m.content ? [{ type: "text", text: String(m.content) }] : []);
|
|
720
794
|
const content = rawContent.map(part => {
|
|
721
795
|
if (part && part.type === "thinking") {
|
|
@@ -753,13 +827,11 @@ function reconstructFromEntries(entries, timing = null) {
|
|
|
753
827
|
content.push({ type: "text", text: `⚠️ **生成失败**: ${errMsg}` });
|
|
754
828
|
}
|
|
755
829
|
out.push({ role: "assistant", content, ts: msgTs, turnDurationMs, usage: m.usage });
|
|
756
|
-
assistantMsgCount++;
|
|
757
830
|
}
|
|
758
831
|
// toolResult entries are attached directly to assistant toolCall parts, so they don't produce standalone messages
|
|
759
832
|
}
|
|
760
833
|
return out;
|
|
761
834
|
}
|
|
762
|
-
|
|
763
835
|
function extractContentText(content) {
|
|
764
836
|
if (typeof content === "string") return content;
|
|
765
837
|
if (!Array.isArray(content)) return "";
|
|
@@ -787,6 +859,7 @@ function clearChat() {
|
|
|
787
859
|
chatInner.innerHTML = "";
|
|
788
860
|
state.streamingMsg = null;
|
|
789
861
|
state.streamingItems = [];
|
|
862
|
+
lastRenderedItemCount = 0;
|
|
790
863
|
state.thinkingOpen = true;
|
|
791
864
|
state.thinkingUserToggled = false;
|
|
792
865
|
state.activeToolCalls.clear();
|
|
@@ -1025,6 +1098,22 @@ async function handleIncomingFiles(files) {
|
|
|
1025
1098
|
|
|
1026
1099
|
const handleImageFiles = handleIncomingFiles;
|
|
1027
1100
|
|
|
1101
|
+
/**
|
|
1102
|
+
* 手动压缩当前会话上下文,释放长会话累积的 token,缓解越聊越慢。
|
|
1103
|
+
* 触发后由 pi 端执行 compaction,返回结果通过 response 事件回显。
|
|
1104
|
+
*/
|
|
1105
|
+
function compactContext() {
|
|
1106
|
+
if (state.streaming) {
|
|
1107
|
+
showToast("请等待当前任务结束后再压缩上下文");
|
|
1108
|
+
return;
|
|
1109
|
+
}
|
|
1110
|
+
if (!sendWs({ type: "compact" })) {
|
|
1111
|
+
showToast("连接不可用,无法压缩上下文");
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
showToast("正在压缩上下文…");
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1028
1117
|
function exportCurrentSession() {
|
|
1029
1118
|
const chatInner = $("#chat-inner");
|
|
1030
1119
|
if (!chatInner || chatInner.children.length === 0) {
|
|
@@ -1281,6 +1370,79 @@ function updateToolBlockCopyBtn(tc, call) {
|
|
|
1281
1370
|
tc.head._cmdToCopy = cmd;
|
|
1282
1371
|
}
|
|
1283
1372
|
|
|
1373
|
+
function formatToolCommandText(name, args) {
|
|
1374
|
+
if (!args) return "";
|
|
1375
|
+
let obj = args;
|
|
1376
|
+
if (typeof args === "string") {
|
|
1377
|
+
try { obj = JSON.parse(args); } catch { return args; }
|
|
1378
|
+
}
|
|
1379
|
+
if (!obj || typeof obj !== "object") return String(args);
|
|
1380
|
+
if (name === "bash" && obj.command) {
|
|
1381
|
+
return `$ ${obj.command}`;
|
|
1382
|
+
}
|
|
1383
|
+
if (name === "read" && obj.path) {
|
|
1384
|
+
let s = `read ${obj.path}`;
|
|
1385
|
+
if (obj.offset != null) s += ` (offset: ${obj.offset})`;
|
|
1386
|
+
if (obj.limit != null) s += ` (limit: ${obj.limit})`;
|
|
1387
|
+
return s;
|
|
1388
|
+
}
|
|
1389
|
+
if (name === "write" && obj.path) {
|
|
1390
|
+
let s = `write ${obj.path}`;
|
|
1391
|
+
if (obj.content) {
|
|
1392
|
+
const preview = obj.content.length > 500 ? obj.content.slice(0, 500) + "\n... (truncated)" : obj.content;
|
|
1393
|
+
s += `\n\n--- 写入内容预览 ---\n${preview}`;
|
|
1394
|
+
}
|
|
1395
|
+
return s;
|
|
1396
|
+
}
|
|
1397
|
+
if (name === "edit" && obj.path) {
|
|
1398
|
+
return `edit ${obj.path}`;
|
|
1399
|
+
}
|
|
1400
|
+
if (name === "ls" && obj.path) {
|
|
1401
|
+
return `ls ${obj.path}`;
|
|
1402
|
+
}
|
|
1403
|
+
if (name === "grep") {
|
|
1404
|
+
return `grep "${obj.pattern || ""}" ${obj.path || ""}`;
|
|
1405
|
+
}
|
|
1406
|
+
if (name === "find") {
|
|
1407
|
+
return `find ${obj.path || "."} -name "${obj.pattern || ""}"`;
|
|
1408
|
+
}
|
|
1409
|
+
try {
|
|
1410
|
+
return JSON.stringify(obj, null, 2);
|
|
1411
|
+
} catch {
|
|
1412
|
+
return String(args);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function renderToolBlockContent(bodyEl, call, resultText, isRunning, isError) {
|
|
1417
|
+
if (!bodyEl) return;
|
|
1418
|
+
bodyEl.innerHTML = "";
|
|
1419
|
+
const cmdText = formatToolCommandText(call?.name, call?.arguments ?? call?.args);
|
|
1420
|
+
|
|
1421
|
+
// 1. 指令 / 参数区域 (有命令就立即提前显示,无需等待输出)
|
|
1422
|
+
if (cmdText) {
|
|
1423
|
+
const cmdSec = el("div", { class: "tool-section tool-cmd-section" }, [
|
|
1424
|
+
el("div", { class: "tool-section-header" }, [
|
|
1425
|
+
el("span", { class: "tool-section-title", text: "执行指令" }),
|
|
1426
|
+
]),
|
|
1427
|
+
el("pre", { class: "tool-code-block", text: cmdText }),
|
|
1428
|
+
]);
|
|
1429
|
+
bodyEl.appendChild(cmdSec);
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// 2. 输出区域 (执行中显示实时状态/输出,执行完显示最终结果)
|
|
1433
|
+
const outSec = el("div", { class: "tool-section tool-out-section" });
|
|
1434
|
+
const outHeader = el("div", { class: "tool-section-header" }, [
|
|
1435
|
+
el("span", { class: "tool-section-title", text: isRunning ? "实时状态" : "输出结果" }),
|
|
1436
|
+
isRunning ? el("span", { class: "tool-running-pulse", text: "正在执行…" }) : null,
|
|
1437
|
+
]);
|
|
1438
|
+
outSec.appendChild(outHeader);
|
|
1439
|
+
|
|
1440
|
+
const displayOut = resultText || (isRunning ? "(命令正在执行中,请稍候…)" : "(无输出)");
|
|
1441
|
+
const outPre = el("pre", { class: "tool-output-block" + (isError ? " is-error" : ""), text: displayOut });
|
|
1442
|
+
outSec.appendChild(outPre);
|
|
1443
|
+
bodyEl.appendChild(outSec);
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1284
1446
|
function makeToolBlockFromCall(call, ts = null) {
|
|
1285
1447
|
const block = el("div", { class: "tool-block" });
|
|
1286
1448
|
const hasResult = Boolean(call.result);
|
|
@@ -1344,9 +1506,9 @@ function makeToolBlockFromCall(call, ts = null) {
|
|
|
1344
1506
|
]);
|
|
1345
1507
|
head._cmdToCopy = cmdToCopy;
|
|
1346
1508
|
|
|
1347
|
-
const
|
|
1348
|
-
const body = el("div", { class: "tool-body", html: escapeHtml(bodyText) });
|
|
1509
|
+
const body = el("div", { class: "tool-body" });
|
|
1349
1510
|
body.style.display = "none";
|
|
1511
|
+
renderToolBlockContent(body, call, resultText, !hasResult, isError);
|
|
1350
1512
|
|
|
1351
1513
|
head.addEventListener("click", () => {
|
|
1352
1514
|
body.style.display = body.style.display === "none" ? "block" : "none";
|
|
@@ -1360,7 +1522,7 @@ function makeToolBlockFromCall(call, ts = null) {
|
|
|
1360
1522
|
block._durationEl = durationEl;
|
|
1361
1523
|
|
|
1362
1524
|
if (!hasResult && call.id) {
|
|
1363
|
-
state.activeToolCalls.set(call.id, { block, body, head, durationEl, startedAt: call.startedAt || Date.now() });
|
|
1525
|
+
state.activeToolCalls.set(call.id, { block, body, head, durationEl, startedAt: call.startedAt || Date.now(), name: call.name, args: call.arguments });
|
|
1364
1526
|
}
|
|
1365
1527
|
|
|
1366
1528
|
return block;
|
|
@@ -1378,20 +1540,36 @@ function summaryArgs(name, args) {
|
|
|
1378
1540
|
if (name === "write" && obj.path) return obj.path;
|
|
1379
1541
|
if (name === "edit" && obj.path) return obj.path;
|
|
1380
1542
|
if (name === "ls" && obj.path) return obj.path;
|
|
1381
|
-
if (name === "grep") return obj.pattern || "";
|
|
1382
|
-
if (name === "find") return obj.pattern
|
|
1543
|
+
if (name === "grep") return obj.pattern ? `${obj.pattern}${obj.path ? ` · ${obj.path}` : ""}` : (obj.path || "");
|
|
1544
|
+
if (name === "find") return obj.pattern ? `${obj.pattern}${obj.path ? ` · ${obj.path}` : ""}` : (obj.path || "");
|
|
1383
1545
|
if (typeof obj === "object" && obj !== null) {
|
|
1384
1546
|
const keys = Object.keys(obj);
|
|
1385
1547
|
if (keys.length === 1 && typeof obj[keys[0]] === "string") return obj[keys[0]];
|
|
1548
|
+
return JSON.stringify(obj);
|
|
1386
1549
|
}
|
|
1387
|
-
return
|
|
1550
|
+
return String(obj);
|
|
1388
1551
|
} catch { return ""; }
|
|
1389
1552
|
}
|
|
1390
1553
|
|
|
1391
|
-
|
|
1554
|
+
let userScrolledUp = false;
|
|
1555
|
+
|
|
1556
|
+
function updatePageTitle(title) {
|
|
1557
|
+
if (!title || title === "新对话") {
|
|
1558
|
+
document.title = "pi-chat";
|
|
1559
|
+
} else {
|
|
1560
|
+
document.title = `${title} · pi-chat`;
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
function scrollBottom(force = false) {
|
|
1392
1565
|
// Don't fight the user during a background-event replay (backfill).
|
|
1393
1566
|
if (state.isBackfilling) return;
|
|
1394
1567
|
const chat = $("#chat");
|
|
1568
|
+
if (!chat) return;
|
|
1569
|
+
// If streaming and user manually scrolled up to read history, don't hijack scroll unless forced!
|
|
1570
|
+
if (!force && userScrolledUp && state.streaming) {
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1395
1573
|
chat.scrollTop = chat.scrollHeight;
|
|
1396
1574
|
}
|
|
1397
1575
|
|
|
@@ -1406,6 +1584,7 @@ function getStreamingFullText() {
|
|
|
1406
1584
|
function ensureStreamingMsg(ts = Date.now()) {
|
|
1407
1585
|
if (state.streamingMsg) return state.streamingMsg;
|
|
1408
1586
|
showEmptyState(false);
|
|
1587
|
+
lastRenderedItemCount = 0;
|
|
1409
1588
|
state.turnStartedAt = ts || Date.now();
|
|
1410
1589
|
startStreamingTimer();
|
|
1411
1590
|
const timeStr = formatMessageTime(ts);
|
|
@@ -1463,26 +1642,47 @@ function refreshStreamingContentDebounced() {
|
|
|
1463
1642
|
});
|
|
1464
1643
|
}
|
|
1465
1644
|
|
|
1645
|
+
let lastRenderedItemCount = 0;
|
|
1466
1646
|
function refreshStreamingContent() {
|
|
1467
1647
|
const node = state.streamingMsg;
|
|
1468
1648
|
if (!node) return;
|
|
1469
1649
|
const content = node.querySelector(".content");
|
|
1470
|
-
|
|
1650
|
+
const items = state.streamingItems;
|
|
1471
1651
|
|
|
1472
|
-
if (
|
|
1473
|
-
if (
|
|
1474
|
-
content.
|
|
1475
|
-
|
|
1476
|
-
el("
|
|
1477
|
-
|
|
1652
|
+
if (items.length === 0) {
|
|
1653
|
+
if (content.children.length === 0 || !content.querySelector(".thinking-placeholder")) {
|
|
1654
|
+
content.innerHTML = "";
|
|
1655
|
+
if (state.streaming) {
|
|
1656
|
+
content.appendChild(el("div", { class: "thinking-placeholder" }, [
|
|
1657
|
+
el("span", { class: "thinking-spinner" }),
|
|
1658
|
+
el("span", { class: "thinking-label", text: "正在思考中…" })
|
|
1659
|
+
]));
|
|
1660
|
+
}
|
|
1478
1661
|
}
|
|
1479
1662
|
scrollBottom();
|
|
1480
1663
|
return;
|
|
1481
1664
|
}
|
|
1482
1665
|
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1666
|
+
// Fast path: if item count unchanged and last item is text, only update its innerHTML.
|
|
1667
|
+
// This avoids O(n²) rebuilding on every text delta for long messages with many tool calls.
|
|
1668
|
+
if (items.length === lastRenderedItemCount && items.length > 0) {
|
|
1669
|
+
const lastItem = items[items.length - 1];
|
|
1670
|
+
if (lastItem.type === "text") {
|
|
1671
|
+
const lastEl = content.lastElementChild;
|
|
1672
|
+
if (lastEl && !lastEl.classList.contains("thinking-block") && !lastEl.classList.contains("tool-block")) {
|
|
1673
|
+
const showCursor = state.streaming;
|
|
1674
|
+
lastEl.innerHTML = renderMarkdown(lastItem.text) + (showCursor ? '<span class="typing-cursor"></span>' : "");
|
|
1675
|
+
scrollBottom();
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
// Full rebuild (new items added or structure changed)
|
|
1682
|
+
content.innerHTML = "";
|
|
1683
|
+
const lastIdx = items.length - 1;
|
|
1684
|
+
for (let i = 0; i < items.length; i++) {
|
|
1685
|
+
const item = items[i];
|
|
1486
1686
|
const isLast = (i === lastIdx);
|
|
1487
1687
|
if (item.type === "thinking") {
|
|
1488
1688
|
const isActivelyThinking = state.streaming && isLast && item.isStreaming !== false;
|
|
@@ -1494,11 +1694,14 @@ function refreshStreamingContent() {
|
|
|
1494
1694
|
content.appendChild(el("div", { html: renderMarkdown(item.text) + (showCursor ? '<span class="typing-cursor"></span>' : "") }));
|
|
1495
1695
|
}
|
|
1496
1696
|
}
|
|
1697
|
+
lastRenderedItemCount = items.length;
|
|
1497
1698
|
scrollBottom();
|
|
1498
1699
|
}
|
|
1499
1700
|
|
|
1500
1701
|
function finalizeStreamingMsg() {
|
|
1501
1702
|
state.streaming = false;
|
|
1703
|
+
if (state.streamingWatchdog) { clearTimeout(state.streamingWatchdog); state.streamingWatchdog = null; }
|
|
1704
|
+
lastRenderedItemCount = 0;
|
|
1502
1705
|
stopStreamingTimer();
|
|
1503
1706
|
if (state.streamingMsg) {
|
|
1504
1707
|
for (const item of state.streamingItems) {
|
|
@@ -1771,15 +1974,13 @@ function sendWs(obj) {
|
|
|
1771
1974
|
function handlePiMessage(obj) {
|
|
1772
1975
|
// Automatically bind to the session file as soon as pi allocates it on disk
|
|
1773
1976
|
const sessionFile = obj.data?.sessionFile || obj.sessionFile || obj.data?.sessionPath || obj.sessionPath;
|
|
1774
|
-
if (sessionFile
|
|
1977
|
+
if (sessionFile) {
|
|
1978
|
+
const isDifferent = !sameSession(sessionFile, state.currentSessionFile);
|
|
1775
1979
|
state.currentSessionFile = sessionFile;
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
}
|
|
1780
|
-
// Do not set baseName(sessionFile) as title — it's a session ID, not a user-friendly summary.
|
|
1781
|
-
// The proper title will be set by syncSessionHistory (via firstUser) or by the user prompt.
|
|
1782
|
-
refreshSessions();
|
|
1980
|
+
if (state.streaming || isDifferent) {
|
|
1981
|
+
updateUrlSession(sessionFile);
|
|
1982
|
+
refreshSessions();
|
|
1983
|
+
}
|
|
1783
1984
|
}
|
|
1784
1985
|
|
|
1785
1986
|
// Backfill markers emitted by the server when it replays buffered events
|
|
@@ -1873,6 +2074,19 @@ function handlePiMessage(obj) {
|
|
|
1873
2074
|
state.aborting = false;
|
|
1874
2075
|
setComposerStreaming(false);
|
|
1875
2076
|
}
|
|
2077
|
+
else if (obj.command === "compact") {
|
|
2078
|
+
if (obj.success) {
|
|
2079
|
+
const after = obj.data?.estimatedTokensAfter;
|
|
2080
|
+
const msg = typeof after === "number"
|
|
2081
|
+
? `上下文已压缩(预估剩余约 ${after} tokens)`
|
|
2082
|
+
: "上下文已压缩";
|
|
2083
|
+
showToast(msg);
|
|
2084
|
+
appendSystemNotice(msg);
|
|
2085
|
+
sendWs({ type: "get_state" });
|
|
2086
|
+
} else {
|
|
2087
|
+
showToast(`压缩上下文失败: ${obj.error || "未知错误"}`);
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
1876
2090
|
else if (obj.command === "switch_session" && obj.success) {
|
|
1877
2091
|
// ask pi for current state so we can get session id, name
|
|
1878
2092
|
sendWs({ type: "get_state" });
|
|
@@ -2021,27 +2235,36 @@ function handlePiMessage(obj) {
|
|
|
2021
2235
|
activeThinking.durationMs = Math.max(0, activeThinking.endedAt - (activeThinking.startedAt || activeThinking.ts || Date.now()));
|
|
2022
2236
|
}
|
|
2023
2237
|
ensureStreamingMsg();
|
|
2024
|
-
const
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2238
|
+
const callId = ev.toolCall?.id || ev.id || obj.toolCallId;
|
|
2239
|
+
const callName = ev.toolCall?.name || ev.toolName || obj.toolName;
|
|
2240
|
+
const callArgs = ev.toolCall?.arguments || obj.args;
|
|
2241
|
+
if (callId) {
|
|
2242
|
+
ensureToolBlock(callId, callName, callArgs, Date.now());
|
|
2243
|
+
}
|
|
2028
2244
|
} else if (ev.type === "toolcall_delta") {
|
|
2029
|
-
// Streaming function-call argument JSON. We
|
|
2030
|
-
//
|
|
2031
|
-
// exists so toolcall_end has somewhere to write into.
|
|
2032
|
-
const id = obj.toolCallId || ev.id;
|
|
2033
|
-
ensureToolBlock(id, obj.toolName || ev.toolCall?.name, obj.args, Date.now());
|
|
2245
|
+
// Streaming function-call argument JSON. We do not create dummy tool blocks
|
|
2246
|
+
// with undefined ID here to prevent empty tasks from appearing.
|
|
2034
2247
|
} else if (ev.type === "toolcall_end") {
|
|
2035
|
-
// Authoritative final toolCall object (with full arguments).
|
|
2036
|
-
// the block head so the displayed args are
|
|
2037
|
-
//
|
|
2038
|
-
const call = ev.toolCall || { id: obj.toolCallId, name: obj.toolName, arguments: obj.args };
|
|
2039
|
-
const id = call.id || obj.toolCallId;
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2248
|
+
// Authoritative final toolCall object (with full arguments).
|
|
2249
|
+
// Update the block head so the displayed args and copy button are final,
|
|
2250
|
+
// and pre-render the command in the block body immediately!
|
|
2251
|
+
const call = ev.toolCall || { id: obj.toolCallId || ev.id, name: obj.toolName || ev.toolName, arguments: obj.args };
|
|
2252
|
+
const id = call.id || obj.toolCallId || ev.id;
|
|
2253
|
+
if (id) {
|
|
2254
|
+
let tc = state.activeToolCalls.get(id);
|
|
2255
|
+
if (!tc) {
|
|
2256
|
+
tc = ensureToolBlock(id, call.name, call.arguments, Date.now());
|
|
2257
|
+
}
|
|
2258
|
+
if (tc) {
|
|
2259
|
+
tc.name = call.name || tc.name;
|
|
2260
|
+
tc.args = call.arguments || tc.args;
|
|
2261
|
+
const nameEl = tc.head.querySelector(".name");
|
|
2262
|
+
if (nameEl && tc.name) nameEl.textContent = tc.name;
|
|
2263
|
+
const argsEl = tc.head.querySelector(".args");
|
|
2264
|
+
if (argsEl) argsEl.textContent = summaryArgs(tc.name, tc.args);
|
|
2265
|
+
updateToolBlockCopyBtn(tc, { name: tc.name, arguments: tc.args });
|
|
2266
|
+
renderToolBlockContent(tc.body, { name: tc.name, arguments: tc.args }, "", true, false);
|
|
2267
|
+
}
|
|
2045
2268
|
}
|
|
2046
2269
|
}
|
|
2047
2270
|
break;
|
|
@@ -2057,18 +2280,26 @@ function handlePiMessage(obj) {
|
|
|
2057
2280
|
const tc = ensureToolBlock(obj.toolCallId, obj.toolName, obj.args, Date.now());
|
|
2058
2281
|
if (tc) {
|
|
2059
2282
|
tc.startedAt = Date.now();
|
|
2283
|
+
tc.name = obj.toolName || tc.name;
|
|
2284
|
+
tc.args = obj.args || tc.args;
|
|
2060
2285
|
if (tc.durationEl) {
|
|
2061
2286
|
tc.durationEl.className = "tool-duration live";
|
|
2062
2287
|
tc.durationEl._startedAt = tc.startedAt;
|
|
2063
2288
|
tc.durationEl.textContent = "0.0s";
|
|
2064
2289
|
tc.durationEl.style.display = "";
|
|
2065
2290
|
}
|
|
2291
|
+
const nameEl = tc.head.querySelector(".name");
|
|
2292
|
+
if (nameEl && tc.name) nameEl.textContent = tc.name;
|
|
2293
|
+
const argsEl = tc.head.querySelector(".args");
|
|
2294
|
+
if (argsEl) argsEl.textContent = summaryArgs(tc.name, tc.args);
|
|
2066
2295
|
const stateEl = tc.head.querySelector(".state");
|
|
2067
2296
|
if (stateEl) {
|
|
2068
2297
|
stateEl.className = "state running";
|
|
2069
2298
|
stateEl.textContent = "执行中…";
|
|
2070
2299
|
}
|
|
2071
|
-
updateToolBlockCopyBtn(tc, { name:
|
|
2300
|
+
updateToolBlockCopyBtn(tc, { name: tc.name, arguments: tc.args });
|
|
2301
|
+
// Immediately render the command code block in body so user sees it right away!
|
|
2302
|
+
renderToolBlockContent(tc.body, { name: tc.name, arguments: tc.args }, "", true, false);
|
|
2072
2303
|
}
|
|
2073
2304
|
break;
|
|
2074
2305
|
}
|
|
@@ -2076,7 +2307,12 @@ function handlePiMessage(obj) {
|
|
|
2076
2307
|
const tc = state.activeToolCalls.get(obj.toolCallId);
|
|
2077
2308
|
if (tc) {
|
|
2078
2309
|
const text = extractContentText(obj.partialResult?.content);
|
|
2079
|
-
tc.body.
|
|
2310
|
+
const outPre = tc.body.querySelector(".tool-output-block");
|
|
2311
|
+
if (outPre) {
|
|
2312
|
+
outPre.textContent = text || "(执行中…)";
|
|
2313
|
+
} else {
|
|
2314
|
+
renderToolBlockContent(tc.body, { name: tc.name, arguments: tc.args }, text, true, false);
|
|
2315
|
+
}
|
|
2080
2316
|
}
|
|
2081
2317
|
break;
|
|
2082
2318
|
}
|
|
@@ -2084,7 +2320,6 @@ function handlePiMessage(obj) {
|
|
|
2084
2320
|
const tc = state.activeToolCalls.get(obj.toolCallId);
|
|
2085
2321
|
if (tc) {
|
|
2086
2322
|
const text = extractContentText(obj.result?.content);
|
|
2087
|
-
tc.body.innerHTML = escapeHtml(text) || "(无输出)";
|
|
2088
2323
|
tc.endedAt = Date.now();
|
|
2089
2324
|
const dur = Math.max(0, tc.endedAt - (tc.startedAt || tc.endedAt));
|
|
2090
2325
|
tc.durationMs = dur;
|
|
@@ -2099,9 +2334,8 @@ function handlePiMessage(obj) {
|
|
|
2099
2334
|
stateEl.textContent = obj.isError ? "错误" : "完成";
|
|
2100
2335
|
stateEl.className = "state" + (obj.isError ? " error" : "");
|
|
2101
2336
|
}
|
|
2337
|
+
renderToolBlockContent(tc.body, { name: tc.name, arguments: tc.args }, text, false, Boolean(obj.isError));
|
|
2102
2338
|
// Remove from activeToolCalls so stale entries don't accumulate.
|
|
2103
|
-
// Previously only cleared wholesale in finalizeStreamingMsg/clearChat/etc,
|
|
2104
|
-
// which meant a missed tool_execution_end left tools stuck in "执行中…" forever.
|
|
2105
2339
|
state.activeToolCalls.delete(obj.toolCallId);
|
|
2106
2340
|
}
|
|
2107
2341
|
break;
|
|
@@ -2174,9 +2408,17 @@ function handleExtensionUiRequest(req) {
|
|
|
2174
2408
|
}
|
|
2175
2409
|
|
|
2176
2410
|
function ensureToolBlock(toolCallId, name, args, ts = Date.now()) {
|
|
2177
|
-
if (
|
|
2411
|
+
if (!toolCallId) return null;
|
|
2412
|
+
if (state.activeToolCalls.has(toolCallId)) {
|
|
2413
|
+
const existing = state.activeToolCalls.get(toolCallId);
|
|
2414
|
+
if (name && !existing.name) existing.name = name;
|
|
2415
|
+
if (args && (!existing.args || existing.args === "{}")) existing.args = args;
|
|
2416
|
+
return existing;
|
|
2417
|
+
}
|
|
2178
2418
|
const block = makeToolBlockFromCall({ id: toolCallId, name, arguments: args, startedAt: ts }, ts);
|
|
2179
|
-
const entry = state.activeToolCalls.get(toolCallId) || { block, body: block._body, head: block._head, durationEl: block._durationEl, startedAt: ts };
|
|
2419
|
+
const entry = state.activeToolCalls.get(toolCallId) || { block, body: block._body, head: block._head, durationEl: block._durationEl, startedAt: ts, name, args };
|
|
2420
|
+
entry.name = name;
|
|
2421
|
+
entry.args = args;
|
|
2180
2422
|
state.activeToolCalls.set(toolCallId, entry);
|
|
2181
2423
|
state.streamingItems.push({ type: "tool", id: toolCallId, tc: entry });
|
|
2182
2424
|
refreshStreamingContentDebounced();
|
|
@@ -2838,6 +3080,8 @@ function submitSteer() {
|
|
|
2838
3080
|
appendMessageNode("user", { text, isSteer: true, ts: Date.now() });
|
|
2839
3081
|
ta.value = "";
|
|
2840
3082
|
autoResize();
|
|
3083
|
+
userScrolledUp = false;
|
|
3084
|
+
scrollBottom(true);
|
|
2841
3085
|
updateComposerUI();
|
|
2842
3086
|
|
|
2843
3087
|
if (hint) hint.textContent = "已插入指导指令!pi 将在当前轮次中实时接收并调整方向。";
|
|
@@ -2882,6 +3126,8 @@ function submitPrompt() {
|
|
|
2882
3126
|
const now = Date.now();
|
|
2883
3127
|
// Render the user's message locally for instant feedback.
|
|
2884
3128
|
appendMessageNode("user", { text, images: state.attachedImages ? [...state.attachedImages] : [], ts: now });
|
|
3129
|
+
userScrolledUp = false;
|
|
3130
|
+
scrollBottom(true);
|
|
2885
3131
|
|
|
2886
3132
|
ta.value = "";
|
|
2887
3133
|
state.attachedImages = [];
|
|
@@ -2894,10 +3140,26 @@ function submitPrompt() {
|
|
|
2894
3140
|
ensureStreamingMsg(now);
|
|
2895
3141
|
refreshStreamingContent();
|
|
2896
3142
|
|
|
3143
|
+
// Streaming watchdog: if no agent_end in 5 minutes, auto-finalize
|
|
3144
|
+
if (state.streamingWatchdog) clearTimeout(state.streamingWatchdog);
|
|
3145
|
+
state.streamingWatchdog = setTimeout(() => {
|
|
3146
|
+
if (state.streaming) {
|
|
3147
|
+
toast("生成超时(5 分钟无响应),已自动结束。", "warn");
|
|
3148
|
+
finalizeStreamingMsg();
|
|
3149
|
+
}
|
|
3150
|
+
}, 5 * 60 * 1000);
|
|
3151
|
+
|
|
3152
|
+
// If sessionFile is already known, immediately anchor it to browser URL so page reloads don't lose the active conversation
|
|
3153
|
+
if (state.currentSessionFile) {
|
|
3154
|
+
updateUrlSession(state.currentSessionFile);
|
|
3155
|
+
}
|
|
3156
|
+
|
|
2897
3157
|
// Set session name from the first prompt of a brand-new session.
|
|
2898
|
-
|
|
3158
|
+
const isFirstPrompt = ($("#chat-inner")?.querySelectorAll(".msg").length || 0) <= 2;
|
|
3159
|
+
if (isFirstPrompt && text) {
|
|
2899
3160
|
const promptTitle = text.slice(0, 60).replace(/\s+/g, " ");
|
|
2900
3161
|
$("#topSessionName").textContent = promptTitle;
|
|
3162
|
+
updatePageTitle(promptTitle);
|
|
2901
3163
|
sendWs({ type: "set_session_name", name: promptTitle });
|
|
2902
3164
|
|
|
2903
3165
|
// Update draft session item in sidebar immediately
|
|
@@ -2925,12 +3187,23 @@ function submitPrompt() {
|
|
|
2925
3187
|
state.aborting = false;
|
|
2926
3188
|
setComposerStreaming(false);
|
|
2927
3189
|
stopStreamingTimer();
|
|
3190
|
+
if (state.streamingWatchdog) { clearTimeout(state.streamingWatchdog); state.streamingWatchdog = null; }
|
|
2928
3191
|
state.turnStartedAt = null;
|
|
2929
3192
|
state.streamingMsgDurationEl = null;
|
|
2930
3193
|
if (state.streamingMsg) { state.streamingMsg.remove(); state.streamingMsg = null; }
|
|
2931
3194
|
state.streamingItems = [];
|
|
3195
|
+
// Restore user input and attachments so text is not lost
|
|
3196
|
+
if (ta) {
|
|
3197
|
+
ta.value = text;
|
|
3198
|
+
autoResize();
|
|
3199
|
+
ta.focus();
|
|
3200
|
+
}
|
|
3201
|
+
if (imagesToSend && imagesToSend.length > 0) {
|
|
3202
|
+
state.attachedImages = imagesToSend;
|
|
3203
|
+
renderImagePreviews();
|
|
3204
|
+
}
|
|
2932
3205
|
const hint = $(".composer-hint");
|
|
2933
|
-
if (hint) hint.textContent = "发送失败:WebSocket
|
|
3206
|
+
if (hint) hint.textContent = "发送失败:WebSocket 连接已断开,已为你恢复输入内容。正在尝试重连…";
|
|
2934
3207
|
scheduleReconnect(0);
|
|
2935
3208
|
}
|
|
2936
3209
|
}
|
|
@@ -3142,6 +3415,12 @@ async function init() {
|
|
|
3142
3415
|
exportBtn.addEventListener("click", exportCurrentSession);
|
|
3143
3416
|
}
|
|
3144
3417
|
|
|
3418
|
+
// Compact context button (manual compaction for long/slow sessions)
|
|
3419
|
+
const compactBtn = $("#btnCompactChat");
|
|
3420
|
+
if (compactBtn) {
|
|
3421
|
+
compactBtn.addEventListener("click", compactContext);
|
|
3422
|
+
}
|
|
3423
|
+
|
|
3145
3424
|
// Image and file attach / picker / paste / drag-and-drop
|
|
3146
3425
|
const btnAttach = $("#btnAttachImage");
|
|
3147
3426
|
const fileInput = $("#imageFileInput");
|
|
@@ -3350,12 +3629,16 @@ async function init() {
|
|
|
3350
3629
|
}
|
|
3351
3630
|
});
|
|
3352
3631
|
|
|
3353
|
-
// Background session status polling
|
|
3632
|
+
// Background session status polling — refresh every 15s when page is visible.
|
|
3633
|
+
// Skip when sidebar is collapsed on mobile to avoid unnecessary API calls.
|
|
3354
3634
|
setInterval(() => {
|
|
3355
3635
|
if (document.visibilityState === "visible") {
|
|
3356
|
-
|
|
3636
|
+
const isSidebarVisible = window.innerWidth > 768 || $(".app").classList.contains("sidebar-open");
|
|
3637
|
+
if (isSidebarVisible || state.streaming) {
|
|
3638
|
+
refreshSessions();
|
|
3639
|
+
}
|
|
3357
3640
|
}
|
|
3358
|
-
},
|
|
3641
|
+
}, 15000);
|
|
3359
3642
|
|
|
3360
3643
|
// Global event delegation for code block copy buttons
|
|
3361
3644
|
document.addEventListener("click", async (e) => {
|
package/public/index.html
CHANGED
|
@@ -76,6 +76,14 @@
|
|
|
76
76
|
</svg>
|
|
77
77
|
<span class="topbar-action-label">导出</span>
|
|
78
78
|
</button>
|
|
79
|
+
<button class="btn-topbar-action" id="btnCompactChat" title="压缩当前上下文(长会话变慢时使用)">
|
|
80
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
81
|
+
<polyline points="4 7 4 4 20 4 20 7"></polyline>
|
|
82
|
+
<line x1="9" y1="20" x2="15" y2="20"></line>
|
|
83
|
+
<line x1="12" y1="4" x2="12" y2="20"></line>
|
|
84
|
+
</svg>
|
|
85
|
+
<span class="topbar-action-label">压缩</span>
|
|
86
|
+
</button>
|
|
79
87
|
<div class="model-menu" id="modelMenu"></div>
|
|
80
88
|
<div class="thinking-menu" id="thinkingMenu"></div>
|
|
81
89
|
</div>
|
|
@@ -111,7 +119,7 @@
|
|
|
111
119
|
<div class="composer">
|
|
112
120
|
<div class="image-preview-bar" id="imagePreviewBar" style="display:none;"></div>
|
|
113
121
|
<div class="composer-inner" id="composerInner">
|
|
114
|
-
<input type="file" id="imageFileInput" class="sr-only-file-input"
|
|
122
|
+
<input type="file" id="imageFileInput" class="sr-only-file-input" multiple>
|
|
115
123
|
<label class="attach-btn" id="btnAttachImage" for="imageFileInput" role="button" tabindex="0" title="添加附件 / 图片 (支持拖拽与截图粘贴)">
|
|
116
124
|
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
117
125
|
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
|
package/public/markdown.js
CHANGED
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
function sanitizeUrl(url) {
|
|
43
43
|
if (!url) return "#";
|
|
44
44
|
const trimmed = url.trim();
|
|
45
|
-
if (/^(https?:\/\/|mailto
|
|
45
|
+
if (/^(https?:\/\/|mailto:|#)/i.test(trimmed)) {
|
|
46
46
|
return trimmed.replace(/"/g, """).replace(/'/g, "'");
|
|
47
47
|
}
|
|
48
48
|
return "#";
|
|
@@ -111,6 +111,7 @@
|
|
|
111
111
|
}
|
|
112
112
|
let outHtml = "";
|
|
113
113
|
let para = [];
|
|
114
|
+
let quoteLines = [];
|
|
114
115
|
let linkListOpen = null;
|
|
115
116
|
let linkListOrdered = null;
|
|
116
117
|
|
|
@@ -122,6 +123,13 @@
|
|
|
122
123
|
}
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
function flushQuote() {
|
|
127
|
+
if (quoteLines.length === 0) return;
|
|
128
|
+
const block = quoteLines.map(l => mdInlineBlock(l)).join("<br>");
|
|
129
|
+
quoteLines = [];
|
|
130
|
+
outHtml += `<blockquote>${block}</blockquote>`;
|
|
131
|
+
}
|
|
132
|
+
|
|
125
133
|
function flushPara() {
|
|
126
134
|
if (para.length === 0) return;
|
|
127
135
|
const block = para.join("\n").trim();
|
|
@@ -132,30 +140,35 @@
|
|
|
132
140
|
if (seg.kind === "table") {
|
|
133
141
|
flushPara();
|
|
134
142
|
flushList();
|
|
143
|
+
flushQuote();
|
|
135
144
|
outHtml += mdTable(seg.lines);
|
|
136
145
|
} else if (seg.kind === "line") {
|
|
137
146
|
// horizontal rule
|
|
138
147
|
if (/^\s*([-*_])\s*\1\s*\1[\s\-_*]*$/.test(seg.text)) {
|
|
139
148
|
flushPara();
|
|
140
149
|
flushList();
|
|
150
|
+
flushQuote();
|
|
141
151
|
outHtml += "<hr>";
|
|
142
152
|
} else if (/^(#{1,6})\s+(.*)$/.test(seg.text)) {
|
|
143
153
|
// headings
|
|
144
154
|
const m = seg.text.match(/^(#{1,6})\s+(.*)$/);
|
|
145
155
|
flushPara();
|
|
146
156
|
flushList();
|
|
157
|
+
flushQuote();
|
|
147
158
|
const level = m[1].length;
|
|
148
159
|
outHtml += `<h${level}>${mdInlineBlock(m[2])}</h${level}>`;
|
|
149
160
|
} else if (/^\s*$/.test(seg.text)) {
|
|
150
161
|
flushPara();
|
|
151
162
|
flushList();
|
|
163
|
+
flushQuote();
|
|
152
164
|
} else if (/^>\s?/.test(seg.text)) {
|
|
153
|
-
// blockquote line
|
|
165
|
+
// blockquote line (accumulate adjacent lines into a single blockquote)
|
|
154
166
|
flushPara();
|
|
155
167
|
flushList();
|
|
156
|
-
|
|
168
|
+
quoteLines.push(seg.text.replace(/^>\s?/, ""));
|
|
157
169
|
} else if (/^\s*[-*+]\s+/.test(seg.text) || /^\s*\d+\.\s+/.test(seg.text)) {
|
|
158
170
|
// list item
|
|
171
|
+
flushQuote();
|
|
159
172
|
const isOrdered = /^\s*\d+\.\s+/.test(seg.text);
|
|
160
173
|
if (!linkListOpen || linkListOrdered !== isOrdered) {
|
|
161
174
|
flushPara();
|
|
@@ -176,11 +189,13 @@
|
|
|
176
189
|
outHtml += `<li>${taskPrefix}${mdInlineBlock(itemText)}</li>`;
|
|
177
190
|
} else {
|
|
178
191
|
flushList();
|
|
192
|
+
flushQuote();
|
|
179
193
|
para.push(seg.text);
|
|
180
194
|
}
|
|
181
195
|
}
|
|
182
196
|
}
|
|
183
197
|
flushList();
|
|
198
|
+
flushQuote();
|
|
184
199
|
flushPara();
|
|
185
200
|
// restore inline code
|
|
186
201
|
outHtml = outHtml.replace(/\u0000CODE(\d+)\u0000/g, (_, n) => {
|
package/public/style.css
CHANGED
|
@@ -741,6 +741,9 @@ body {
|
|
|
741
741
|
stroke-linejoin: round;
|
|
742
742
|
flex-shrink: 0;
|
|
743
743
|
}
|
|
744
|
+
.btn-copy-code svg *, .btn-copy-msg svg *, .btn-copy-tool svg * {
|
|
745
|
+
fill: none;
|
|
746
|
+
}
|
|
744
747
|
.msg.assistant .content ul, .msg.assistant .content ol { margin: 8px 0 12px 24px; }
|
|
745
748
|
.msg.assistant .content li { margin: 4px 0; }
|
|
746
749
|
.msg.assistant .content h1, .msg.assistant .content h2, .msg.assistant .content h3,
|
|
@@ -801,10 +804,48 @@ body {
|
|
|
801
804
|
.tool-head .state.error { color: var(--danger); background: #3a1a1a; }
|
|
802
805
|
.tool-body {
|
|
803
806
|
padding: 12px 14px; border-top: 1px solid var(--tool-border);
|
|
804
|
-
max-height:
|
|
805
|
-
white-space: pre-wrap; word-break: break-word;
|
|
807
|
+
max-height: 380px; overflow-y: auto;
|
|
806
808
|
font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 12px; color: #cfcfcf;
|
|
807
809
|
}
|
|
810
|
+
.tool-section {
|
|
811
|
+
margin-bottom: 12px;
|
|
812
|
+
}
|
|
813
|
+
.tool-section:last-child {
|
|
814
|
+
margin-bottom: 0;
|
|
815
|
+
}
|
|
816
|
+
.tool-section-header {
|
|
817
|
+
display: flex; align-items: center; justify-content: space-between;
|
|
818
|
+
margin-bottom: 6px;
|
|
819
|
+
}
|
|
820
|
+
.tool-section-title {
|
|
821
|
+
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
|
822
|
+
letter-spacing: 0.5px; color: var(--text-muted);
|
|
823
|
+
}
|
|
824
|
+
.tool-running-pulse {
|
|
825
|
+
font-size: 11px; color: var(--accent);
|
|
826
|
+
animation: pulse-op 1.4s infinite ease-in-out;
|
|
827
|
+
}
|
|
828
|
+
@keyframes pulse-op {
|
|
829
|
+
0%, 100% { opacity: 0.5; }
|
|
830
|
+
50% { opacity: 1; }
|
|
831
|
+
}
|
|
832
|
+
.tool-code-block, .tool-output-block {
|
|
833
|
+
margin: 0; padding: 8px 12px;
|
|
834
|
+
background: rgba(0, 0, 0, 0.35);
|
|
835
|
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
836
|
+
border-radius: 6px;
|
|
837
|
+
white-space: pre-wrap; word-break: break-word;
|
|
838
|
+
font-family: inherit; font-size: 12px; line-height: 1.45;
|
|
839
|
+
color: #e5e5e5;
|
|
840
|
+
}
|
|
841
|
+
.tool-code-block {
|
|
842
|
+
color: #93c5fd;
|
|
843
|
+
border-left: 2px solid var(--accent);
|
|
844
|
+
}
|
|
845
|
+
.tool-output-block.is-error {
|
|
846
|
+
color: var(--danger);
|
|
847
|
+
border-left: 2px solid var(--danger);
|
|
848
|
+
}
|
|
808
849
|
|
|
809
850
|
/* Thinking blocks */
|
|
810
851
|
.thinking-block {
|