@liguoshuai/pi-web-chat 1.0.1 → 1.1.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/CHANGELOG.md +12 -1
- package/package.json +1 -1
- package/public/app.js +340 -33
- package/public/index.html +38 -2
- package/public/style.css +540 -64
- package/server.js +147 -25
package/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
-
## [
|
|
10
|
+
## [1.1.0] - 2026-07-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **后台进程持久化 (Process Persistence)**:刷新或暂时离开 Web 页面时,`pi` Agent 进程继续在后台运行,重新打开/刷新网页会自动重挂载 (re-attach) 正在运行的进程,任务不会中断。
|
|
14
|
+
- **历史工具输出归折 (Clean Tool Results Rendering)**:从历史记录恢复会话时,将离散的 `toolResult` 输出与对应 `toolCall` 绑定,整洁收纳于工具调用的 `⚙` 折叠卡片内,避免原始日志/代码平铺乱穿于聊天框中。
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- **历史会话加载异常**:修复 `/api/session` 接口中局部变量名遮蔽 Node.js `path` 模块导致的无法弹出会话历史问题。
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## [1.0.1] - 2026-07-20
|
|
11
22
|
|
|
12
23
|
### Fixed
|
|
13
24
|
- **空内容 pi 气泡**:`message_start` 原本不区分角色,用户消息回显也建了一个空 pi 气泡 — 现在只对 `role:assistant` 开 streaming 块。
|
package/package.json
CHANGED
package/public/app.js
CHANGED
|
@@ -24,13 +24,16 @@ const state = {
|
|
|
24
24
|
ws: null,
|
|
25
25
|
wsConnected: false,
|
|
26
26
|
cwd: null,
|
|
27
|
+
homeDir: "",
|
|
28
|
+
serverCwd: "",
|
|
27
29
|
currentSessionFile: null,
|
|
28
30
|
// entriesByCallId: for live assistant messages we accumulate tool calls + text
|
|
29
31
|
streamingMsg: null, // DOM node for the in-progress assistant message
|
|
30
32
|
streamingText: "", // accumulated text deltas
|
|
31
33
|
streamingThinking: "",
|
|
34
|
+
thinkingOpen: true,
|
|
35
|
+
thinkingUserToggled: false,
|
|
32
36
|
activeToolCalls: new Map(), // toolCallId -> { node, body, state }
|
|
33
|
-
thinkingOpen: false,
|
|
34
37
|
queuedAssistantTextId: null,
|
|
35
38
|
streaming: false,
|
|
36
39
|
models: [],
|
|
@@ -39,6 +42,134 @@ const state = {
|
|
|
39
42
|
sessionId: null,
|
|
40
43
|
};
|
|
41
44
|
|
|
45
|
+
let toastTimer = null;
|
|
46
|
+
function showToast(msg) {
|
|
47
|
+
const toast = $("#toast");
|
|
48
|
+
if (!toast) return;
|
|
49
|
+
toast.textContent = msg;
|
|
50
|
+
toast.classList.add("show");
|
|
51
|
+
if (toastTimer) clearTimeout(toastTimer);
|
|
52
|
+
toastTimer = setTimeout(() => {
|
|
53
|
+
toast.classList.remove("show");
|
|
54
|
+
}, 2500);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function formatCwdDisplay(p) {
|
|
58
|
+
if (!p) return "~";
|
|
59
|
+
const home = state.homeDir;
|
|
60
|
+
if (home && (p === home || p === home + "/")) return "~";
|
|
61
|
+
if (home && p.startsWith(home + "/")) return "~/" + p.slice(home.length + 1);
|
|
62
|
+
return p;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function updateCwdDisplay() {
|
|
66
|
+
const pill = $("#cwdPill");
|
|
67
|
+
const wrap = $("#cwdPillWrap");
|
|
68
|
+
if (pill) pill.textContent = formatCwdDisplay(state.cwd);
|
|
69
|
+
if (wrap) wrap.title = `工作目录: ${state.cwd || "~"}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function setCwd(newCwd) {
|
|
73
|
+
state.cwd = newCwd;
|
|
74
|
+
localStorage.setItem("pi_cwd", newCwd);
|
|
75
|
+
updateCwdDisplay();
|
|
76
|
+
clearChat();
|
|
77
|
+
showEmptyState(true);
|
|
78
|
+
state.currentSessionFile = null;
|
|
79
|
+
$("#topSessionName").textContent = "新对话";
|
|
80
|
+
connectWs({});
|
|
81
|
+
refreshSessions();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function loadServerConfig() {
|
|
85
|
+
try {
|
|
86
|
+
const res = await fetch(`${API}/api/config`);
|
|
87
|
+
const data = await res.json();
|
|
88
|
+
if (data.home) state.homeDir = data.home;
|
|
89
|
+
if (data.serverCwd) state.serverCwd = data.serverCwd;
|
|
90
|
+
} catch {}
|
|
91
|
+
if (!state.cwd) {
|
|
92
|
+
state.cwd = localStorage.getItem("pi_cwd") || state.serverCwd || state.homeDir || "";
|
|
93
|
+
}
|
|
94
|
+
updateCwdDisplay();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function openCwdModal() {
|
|
98
|
+
await loadServerConfig();
|
|
99
|
+
const modal = $("#cwdModal");
|
|
100
|
+
const input = $("#cwdInput");
|
|
101
|
+
const errorEl = $("#cwdError");
|
|
102
|
+
const chipsEl = $("#quickDirChips");
|
|
103
|
+
if (!modal || !input) return;
|
|
104
|
+
|
|
105
|
+
input.value = state.cwd || state.homeDir || "";
|
|
106
|
+
if (errorEl) { errorEl.style.display = "none"; errorEl.textContent = ""; }
|
|
107
|
+
|
|
108
|
+
if (chipsEl) {
|
|
109
|
+
chipsEl.innerHTML = "";
|
|
110
|
+
const quicks = [];
|
|
111
|
+
if (state.homeDir) quicks.push({ label: "~ (用户主页)", path: state.homeDir });
|
|
112
|
+
if (state.serverCwd && state.serverCwd !== state.homeDir) {
|
|
113
|
+
quicks.push({ label: "服务启动目录", path: state.serverCwd });
|
|
114
|
+
}
|
|
115
|
+
let recents = [];
|
|
116
|
+
try { recents = JSON.parse(localStorage.getItem("pi_recent_cwds") || "[]"); } catch {}
|
|
117
|
+
recents.forEach(r => {
|
|
118
|
+
if (r && r !== state.homeDir && r !== state.serverCwd && !quicks.some(q => q.path === r)) {
|
|
119
|
+
quicks.push({ label: formatCwdDisplay(r), path: r });
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
quicks.forEach(q => {
|
|
124
|
+
chipsEl.appendChild(el("div", {
|
|
125
|
+
class: "chip",
|
|
126
|
+
text: q.label,
|
|
127
|
+
onclick: () => { input.value = q.path; }
|
|
128
|
+
}));
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
modal.classList.add("open");
|
|
133
|
+
setTimeout(() => input.select(), 50);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function closeCwdModal() {
|
|
137
|
+
const modal = $("#cwdModal");
|
|
138
|
+
if (modal) modal.classList.remove("open");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function confirmCwdChange() {
|
|
142
|
+
const input = $("#cwdInput");
|
|
143
|
+
const errorEl = $("#cwdError");
|
|
144
|
+
const rawPath = input.value.trim();
|
|
145
|
+
if (!rawPath) return;
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
const res = await fetch(`${API}/api/validate-dir?path=${encodeURIComponent(rawPath)}`);
|
|
149
|
+
const data = await res.json();
|
|
150
|
+
if (data.ok && data.path) {
|
|
151
|
+
let recents = [];
|
|
152
|
+
try { recents = JSON.parse(localStorage.getItem("pi_recent_cwds") || "[]"); } catch {}
|
|
153
|
+
recents = [data.path, ...recents.filter(r => r !== data.path)].slice(0, 8);
|
|
154
|
+
localStorage.setItem("pi_recent_cwds", JSON.stringify(recents));
|
|
155
|
+
|
|
156
|
+
closeCwdModal();
|
|
157
|
+
setCwd(data.path);
|
|
158
|
+
showToast(`已切换工作目录: ${formatCwdDisplay(data.path)}`);
|
|
159
|
+
} else {
|
|
160
|
+
if (errorEl) {
|
|
161
|
+
errorEl.textContent = data.error || "指定路径无法访问";
|
|
162
|
+
errorEl.style.display = "block";
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} catch (e) {
|
|
166
|
+
if (errorEl) {
|
|
167
|
+
errorEl.textContent = "网络请求失败,请重试";
|
|
168
|
+
errorEl.style.display = "block";
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
42
173
|
// ---- Markdown render (small, safe renderer) ----
|
|
43
174
|
function escapeHtml(s) {
|
|
44
175
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
@@ -249,8 +380,24 @@ function renderSidebar(sessions) {
|
|
|
249
380
|
});
|
|
250
381
|
}
|
|
251
382
|
|
|
383
|
+
async function toggleSidebar() {
|
|
384
|
+
const app = $(".app");
|
|
385
|
+
const isOpen = app.classList.toggle("sidebar-open");
|
|
386
|
+
if (window.innerWidth > 768) {
|
|
387
|
+
localStorage.setItem("sidebarCollapsed", !isOpen);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function closeSidebar() {
|
|
392
|
+
$(".app").classList.remove("sidebar-open");
|
|
393
|
+
}
|
|
394
|
+
|
|
252
395
|
async function loadSession(file) {
|
|
253
396
|
state.currentSessionFile = file;
|
|
397
|
+
// Mobile: close sidebar on selection
|
|
398
|
+
if (window.innerWidth <= 768) {
|
|
399
|
+
closeSidebar();
|
|
400
|
+
}
|
|
254
401
|
// pull transcript from REST then connect a fresh WS pointed at this session
|
|
255
402
|
const res = await fetch(`${API}/api/session?file=${encodeURIComponent(file)}`);
|
|
256
403
|
const data = await res.json();
|
|
@@ -270,7 +417,17 @@ async function loadSession(file) {
|
|
|
270
417
|
}
|
|
271
418
|
|
|
272
419
|
function reconstructFromEntries(entries) {
|
|
273
|
-
//
|
|
420
|
+
// Map toolResults by toolCallId so we can attach them to their toolCall in the assistant message
|
|
421
|
+
const toolResults = new Map();
|
|
422
|
+
for (const e of entries) {
|
|
423
|
+
if (e.type === "message" && e.message?.role === "toolResult") {
|
|
424
|
+
const m = e.message;
|
|
425
|
+
if (m.toolCallId) {
|
|
426
|
+
toolResults.set(m.toolCallId, m);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
274
431
|
const out = [];
|
|
275
432
|
for (const e of entries) {
|
|
276
433
|
if (e.type !== "message") continue;
|
|
@@ -280,10 +437,17 @@ function reconstructFromEntries(entries) {
|
|
|
280
437
|
// skip "bash execution" pseudo-users (those have role user but content type special)
|
|
281
438
|
out.push({ role: "user", text: extractContentText(m.content), ts: m.timestamp });
|
|
282
439
|
} else if (m.role === "assistant") {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
440
|
+
const rawContent = Array.isArray(m.content) ? m.content : (m.content ? [{ type: "text", text: String(m.content) }] : []);
|
|
441
|
+
const content = rawContent.map(part => {
|
|
442
|
+
if (part && part.type === "toolCall") {
|
|
443
|
+
const res = toolResults.get(part.id);
|
|
444
|
+
return { ...part, result: res || null };
|
|
445
|
+
}
|
|
446
|
+
return part;
|
|
447
|
+
});
|
|
448
|
+
out.push({ role: "assistant", content, ts: m.timestamp, usage: m.usage });
|
|
286
449
|
}
|
|
450
|
+
// toolResult entries are attached directly to assistant toolCall parts, so they don't produce standalone messages
|
|
287
451
|
}
|
|
288
452
|
return out;
|
|
289
453
|
}
|
|
@@ -304,6 +468,8 @@ function clearChat() {
|
|
|
304
468
|
state.streamingMsg = null;
|
|
305
469
|
state.streamingText = "";
|
|
306
470
|
state.streamingThinking = "";
|
|
471
|
+
state.thinkingOpen = true;
|
|
472
|
+
state.thinkingUserToggled = false;
|
|
307
473
|
state.activeToolCalls.clear();
|
|
308
474
|
}
|
|
309
475
|
|
|
@@ -349,14 +515,30 @@ function renderAssistantBlock(m) {
|
|
|
349
515
|
return node;
|
|
350
516
|
}
|
|
351
517
|
|
|
352
|
-
function makeThinkingBlock(thinkingText) {
|
|
353
|
-
const block = el("div", { class: "thinking-block" });
|
|
354
|
-
const head = el("div", {
|
|
355
|
-
|
|
356
|
-
|
|
518
|
+
function makeThinkingBlock(thinkingText, isActivelyThinking = false) {
|
|
519
|
+
const block = el("div", { class: "thinking-block" + (isActivelyThinking ? " active" : "") });
|
|
520
|
+
const head = el("div", {
|
|
521
|
+
class: "thinking-head",
|
|
522
|
+
onclick: () => {
|
|
523
|
+
const isHidden = body.style.display === "none";
|
|
524
|
+
body.style.display = isHidden ? "block" : "none";
|
|
525
|
+
state.thinkingOpen = isHidden;
|
|
526
|
+
state.thinkingUserToggled = true;
|
|
527
|
+
}
|
|
528
|
+
}, [
|
|
529
|
+
el("span", { class: "thinking-title", text: isActivelyThinking ? "💭 正在思考中…" : "💭 思考过程" }),
|
|
530
|
+
isActivelyThinking ? el("span", { class: "thinking-pulse" }) : null,
|
|
531
|
+
el("span", { class: "thinking-toggle-hint", text: "(点击展开/收起)" }),
|
|
357
532
|
]);
|
|
533
|
+
|
|
358
534
|
const body = el("div", { class: "thinking-body", html: escapeHtml(thinkingText) });
|
|
359
|
-
|
|
535
|
+
|
|
536
|
+
if (state.thinkingUserToggled) {
|
|
537
|
+
body.style.display = state.thinkingOpen ? "block" : "none";
|
|
538
|
+
} else {
|
|
539
|
+
body.style.display = isActivelyThinking ? "block" : "none";
|
|
540
|
+
}
|
|
541
|
+
|
|
360
542
|
block.appendChild(head);
|
|
361
543
|
block.appendChild(body);
|
|
362
544
|
return block;
|
|
@@ -364,21 +546,42 @@ function makeThinkingBlock(thinkingText) {
|
|
|
364
546
|
|
|
365
547
|
function makeToolBlockFromCall(call) {
|
|
366
548
|
const block = el("div", { class: "tool-block" });
|
|
549
|
+
const hasResult = Boolean(call.result);
|
|
550
|
+
const isError = call.result ? Boolean(call.result.isError) : false;
|
|
551
|
+
|
|
552
|
+
let resultText = "";
|
|
553
|
+
if (hasResult) {
|
|
554
|
+
resultText = extractContentText(call.result.content);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const stateText = hasResult ? (isError ? "错误" : "完成") : "…";
|
|
558
|
+
const stateClass = "state" + (hasResult && isError ? " error" : "");
|
|
559
|
+
|
|
367
560
|
const head = el("div", { class: "tool-head" }, [
|
|
368
561
|
el("span", { class: "ic", text: "⚙" }),
|
|
369
562
|
el("span", { class: "name", text: call.name }),
|
|
370
563
|
el("span", { class: "args", text: summaryArgs(call.name, call.arguments) }),
|
|
371
|
-
el("span", { class:
|
|
564
|
+
el("span", { class: stateClass, text: stateText }),
|
|
372
565
|
]);
|
|
373
|
-
|
|
566
|
+
|
|
567
|
+
const bodyText = hasResult ? (resultText || "(无输出)") : "执行中…";
|
|
568
|
+
const body = el("div", { class: "tool-body", html: escapeHtml(bodyText) });
|
|
374
569
|
body.style.display = "none";
|
|
375
|
-
|
|
570
|
+
|
|
571
|
+
head.addEventListener("click", () => {
|
|
572
|
+
body.style.display = body.style.display === "none" ? "block" : "none";
|
|
573
|
+
});
|
|
574
|
+
|
|
376
575
|
block.appendChild(head);
|
|
377
576
|
block.appendChild(body);
|
|
378
577
|
block._head = head;
|
|
379
578
|
block._body = body;
|
|
380
579
|
block._callId = call.id;
|
|
381
|
-
|
|
580
|
+
|
|
581
|
+
if (!hasResult && call.id) {
|
|
582
|
+
state.activeToolCalls.set(call.id, { block, body, head });
|
|
583
|
+
}
|
|
584
|
+
|
|
382
585
|
return block;
|
|
383
586
|
}
|
|
384
587
|
|
|
@@ -412,6 +615,8 @@ function ensureStreamingMsg() {
|
|
|
412
615
|
state.streamingMsg = node;
|
|
413
616
|
state.streamingText = "";
|
|
414
617
|
state.streamingThinking = "";
|
|
618
|
+
state.thinkingOpen = true;
|
|
619
|
+
state.thinkingUserToggled = false;
|
|
415
620
|
state.activeToolCalls.clear();
|
|
416
621
|
$("#chat-inner").appendChild(node);
|
|
417
622
|
scrollBottom();
|
|
@@ -422,28 +627,45 @@ function refreshStreamingContent() {
|
|
|
422
627
|
const node = state.streamingMsg;
|
|
423
628
|
if (!node) return;
|
|
424
629
|
const content = node.querySelector(".content");
|
|
425
|
-
// Build the current content html again from scratch.
|
|
426
|
-
// Order: text then thinking then tool calls. We keep it simple — append in
|
|
427
|
-
// arrival order using permanent child slots keyed by index.
|
|
428
|
-
// Easiest: rebuild.
|
|
429
630
|
content.innerHTML = "";
|
|
430
|
-
|
|
431
|
-
|
|
631
|
+
|
|
632
|
+
const hasThinking = Boolean(state.streamingThinking);
|
|
633
|
+
const hasText = Boolean(state.streamingText);
|
|
634
|
+
const hasTools = state.activeToolCalls.size > 0;
|
|
635
|
+
|
|
636
|
+
// Immediate visual feedback placeholder while waiting for LLM first token
|
|
637
|
+
if (state.streaming && !hasThinking && !hasText && !hasTools) {
|
|
638
|
+
content.appendChild(el("div", { class: "thinking-placeholder" }, [
|
|
639
|
+
el("span", { class: "thinking-spinner" }),
|
|
640
|
+
el("span", { class: "thinking-label", text: "正在思考中…" })
|
|
641
|
+
]));
|
|
642
|
+
scrollBottom();
|
|
643
|
+
return;
|
|
432
644
|
}
|
|
433
|
-
|
|
434
|
-
|
|
645
|
+
|
|
646
|
+
if (state.streamingThinking) {
|
|
647
|
+
const isActivelyThinking = state.streaming && !hasText && !hasTools;
|
|
648
|
+
content.appendChild(makeThinkingBlock(state.streamingThinking, isActivelyThinking));
|
|
435
649
|
}
|
|
436
|
-
// Re-append tool call blocks. Active ones are kept in a Map by insertion order.
|
|
437
650
|
for (const v of state.activeToolCalls.values()) {
|
|
438
651
|
content.appendChild(v.block);
|
|
439
652
|
}
|
|
653
|
+
if (state.streamingText) {
|
|
654
|
+
content.appendChild(el("div", { html: renderMarkdown(state.streamingText) + (state.streaming ? '<span class="typing-cursor"></span>' : "") }));
|
|
655
|
+
}
|
|
440
656
|
scrollBottom();
|
|
441
657
|
}
|
|
442
658
|
|
|
443
659
|
function finalizeStreamingMsg() {
|
|
660
|
+
state.streaming = false;
|
|
661
|
+
if (state.streamingMsg) {
|
|
662
|
+
refreshStreamingContent();
|
|
663
|
+
}
|
|
444
664
|
state.streamingMsg = null;
|
|
445
665
|
state.streamingText = "";
|
|
446
666
|
state.streamingThinking = "";
|
|
667
|
+
state.thinkingOpen = true;
|
|
668
|
+
state.thinkingUserToggled = false;
|
|
447
669
|
state.activeToolCalls.clear();
|
|
448
670
|
}
|
|
449
671
|
|
|
@@ -511,6 +733,18 @@ function handlePiMessage(obj) {
|
|
|
511
733
|
if (obj.type === "response") {
|
|
512
734
|
if (obj.command === "get_state" && obj.success) updateState(obj.data);
|
|
513
735
|
else if (obj.command === "get_available_models" && obj.success) updateModels(obj.data.models || []);
|
|
736
|
+
else if (obj.command === "set_model") {
|
|
737
|
+
if (obj.success) {
|
|
738
|
+
if (obj.data) state.currentModel = obj.data;
|
|
739
|
+
renderModelPill();
|
|
740
|
+
renderModelMenu();
|
|
741
|
+
showToast(`已切换模型: ${state.currentModel?.name || state.currentModel?.id || ""}`);
|
|
742
|
+
sendWs({ type: "get_state" });
|
|
743
|
+
} else {
|
|
744
|
+
showToast(`切换模型失败: ${obj.error || "未知错误"}`);
|
|
745
|
+
renderModelPill();
|
|
746
|
+
}
|
|
747
|
+
}
|
|
514
748
|
else if (obj.command === "switch_session" && obj.success) {
|
|
515
749
|
// ask pi for current state so we can get session id, name
|
|
516
750
|
sendWs({ type: "get_state" });
|
|
@@ -533,17 +767,15 @@ function handlePiMessage(obj) {
|
|
|
533
767
|
case "agent_start":
|
|
534
768
|
state.streaming = true;
|
|
535
769
|
setComposerAborting(true);
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
// later agent_settled branch refreshes too — so a new conversation
|
|
539
|
-
// shows up in the sidebar as soon as the reply finishes, without
|
|
540
|
-
// needing a manual page reload.
|
|
770
|
+
ensureStreamingMsg();
|
|
771
|
+
refreshStreamingContent();
|
|
541
772
|
refreshSessions();
|
|
542
773
|
break;
|
|
543
774
|
case "agent_end":
|
|
544
775
|
finalizeStreamingMsg();
|
|
545
776
|
break;
|
|
546
777
|
case "agent_settled":
|
|
778
|
+
finalizeStreamingMsg();
|
|
547
779
|
state.streaming = false;
|
|
548
780
|
setComposerAborting(false);
|
|
549
781
|
refreshSessions(); // titles may have changed
|
|
@@ -657,6 +889,7 @@ function handlePiMessage(obj) {
|
|
|
657
889
|
break;
|
|
658
890
|
}
|
|
659
891
|
case "pi_exit":
|
|
892
|
+
finalizeStreamingMsg();
|
|
660
893
|
state.streaming = false;
|
|
661
894
|
setComposerAborting(false);
|
|
662
895
|
$("#connDot").style.color = "var(--danger)";
|
|
@@ -698,7 +931,9 @@ function renderModelPill() {
|
|
|
698
931
|
const pill = $("#modelPill");
|
|
699
932
|
if (!m) { pill.textContent = "选择模型"; return; }
|
|
700
933
|
const provider = m.provider || "?";
|
|
701
|
-
|
|
934
|
+
const name = m.name || m.id;
|
|
935
|
+
pill.textContent = `${provider} / ${name}`;
|
|
936
|
+
pill.title = `当前模型: ${provider} / ${name} (${m.id})`;
|
|
702
937
|
}
|
|
703
938
|
|
|
704
939
|
function renderModelMenu() {
|
|
@@ -716,7 +951,11 @@ function renderModelMenu() {
|
|
|
716
951
|
const active = state.currentModel && m.id === state.currentModel.id && m.provider === state.currentModel.provider;
|
|
717
952
|
menu.appendChild(el("div", {
|
|
718
953
|
class: "opt" + (active ? " active" : ""),
|
|
719
|
-
onclick: () => {
|
|
954
|
+
onclick: () => {
|
|
955
|
+
$("#modelPill").textContent = "切换中…";
|
|
956
|
+
sendWs({ type: "set_model", provider: m.provider, modelId: m.id });
|
|
957
|
+
menu.classList.remove("open");
|
|
958
|
+
},
|
|
720
959
|
}, [
|
|
721
960
|
el("span", { class: "check", html: active ? "✓ " : "" }),
|
|
722
961
|
document.createTextNode(`${m.name || m.id}`),
|
|
@@ -766,6 +1005,12 @@ function submitPrompt() {
|
|
|
766
1005
|
appendMessageNode("user", { text });
|
|
767
1006
|
ta.value = "";
|
|
768
1007
|
autoResize();
|
|
1008
|
+
|
|
1009
|
+
state.streaming = true;
|
|
1010
|
+
setComposerAborting(true);
|
|
1011
|
+
ensureStreamingMsg();
|
|
1012
|
+
refreshStreamingContent();
|
|
1013
|
+
|
|
769
1014
|
// Set session name from the first prompt of a brand-new session.
|
|
770
1015
|
if (state.currentSessionFile == null) {
|
|
771
1016
|
sendWs({ type: "set_session_name", name: text.slice(0, 60).replace(/\s+/g, " ") });
|
|
@@ -784,6 +1029,7 @@ function init() {
|
|
|
784
1029
|
// Default cwd to home (server uses home default too).
|
|
785
1030
|
state.cwd = document.body.dataset.cwd || "";
|
|
786
1031
|
|
|
1032
|
+
// event listeners
|
|
787
1033
|
// event listeners
|
|
788
1034
|
$("#btnNew").addEventListener("click", () => {
|
|
789
1035
|
if (state.streaming) {
|
|
@@ -795,8 +1041,8 @@ function init() {
|
|
|
795
1041
|
connectWs({}); // no session -> pi creates a new one
|
|
796
1042
|
$("#topSessionName").textContent = "新对话";
|
|
797
1043
|
state.currentSessionFile = null;
|
|
798
|
-
//
|
|
799
|
-
|
|
1044
|
+
// Mobile: close sidebar on new session
|
|
1045
|
+
if (window.innerWidth <= 768) closeSidebar();
|
|
800
1046
|
refreshSessions();
|
|
801
1047
|
});
|
|
802
1048
|
|
|
@@ -831,6 +1077,9 @@ function init() {
|
|
|
831
1077
|
});
|
|
832
1078
|
});
|
|
833
1079
|
|
|
1080
|
+
$("#btnToggleSidebar").addEventListener("click", toggleSidebar);
|
|
1081
|
+
$("#sidebarOverlay").addEventListener("click", closeSidebar);
|
|
1082
|
+
|
|
834
1083
|
// sidebar search (client side filter)
|
|
835
1084
|
$("#sidebarSearch").addEventListener("input", (e) => {
|
|
836
1085
|
const q = e.target.value.toLowerCase();
|
|
@@ -839,6 +1088,64 @@ function init() {
|
|
|
839
1088
|
});
|
|
840
1089
|
});
|
|
841
1090
|
|
|
1091
|
+
// Restore sidebar state for desktop, collapse by default on mobile
|
|
1092
|
+
if (window.innerWidth > 768) {
|
|
1093
|
+
if (localStorage.getItem("sidebarCollapsed") === "true") {
|
|
1094
|
+
$(".app").classList.remove("sidebar-open");
|
|
1095
|
+
}
|
|
1096
|
+
} else {
|
|
1097
|
+
$(".app").classList.remove("sidebar-open");
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// Warn user if navigating/refreshing while a response is streaming
|
|
1101
|
+
window.addEventListener("beforeunload", (e) => {
|
|
1102
|
+
if (state.streaming) {
|
|
1103
|
+
e.preventDefault();
|
|
1104
|
+
e.returnValue = "任务正在运行中,刷新或离开页面将中断当前任务。";
|
|
1105
|
+
return e.returnValue;
|
|
1106
|
+
}
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
// Keep sidebar state consistent across viewport resizes.
|
|
1110
|
+
// Desktop uses an inline sidebar; mobile uses a drawer with an overlay.
|
|
1111
|
+
// If the user resizes across the 768px breakpoint, a stale `sidebar-open`
|
|
1112
|
+
// class would either show a stray inline sidebar on a collapsed desktop
|
|
1113
|
+
// layout, or worse, leave the full-screen overlay covering the main area
|
|
1114
|
+
// on mobile — making the whole UI unclickable. Sync on every resize.
|
|
1115
|
+
let lastIsMobile = window.innerWidth <= 768;
|
|
1116
|
+
window.addEventListener("resize", () => {
|
|
1117
|
+
const isMobile = window.innerWidth <= 768;
|
|
1118
|
+
if (isMobile !== lastIsMobile) {
|
|
1119
|
+
lastIsMobile = isMobile;
|
|
1120
|
+
// Crossing the breakpoint: always close the drawer to reach a known state.
|
|
1121
|
+
$(".app").classList.remove("sidebar-open");
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
// CWD modal listeners
|
|
1126
|
+
const cwdBtn = $("#cwdPillWrap");
|
|
1127
|
+
if (cwdBtn) cwdBtn.addEventListener("click", openCwdModal);
|
|
1128
|
+
const cwdCloseBtn = $("#cwdModalClose");
|
|
1129
|
+
if (cwdCloseBtn) cwdCloseBtn.addEventListener("click", closeCwdModal);
|
|
1130
|
+
const cwdConfirmBtn = $("#cwdConfirmBtn");
|
|
1131
|
+
if (cwdConfirmBtn) cwdConfirmBtn.addEventListener("click", confirmCwdChange);
|
|
1132
|
+
const cwdModalEl = $("#cwdModal");
|
|
1133
|
+
if (cwdModalEl) {
|
|
1134
|
+
cwdModalEl.addEventListener("click", (e) => {
|
|
1135
|
+
if (e.target === cwdModalEl) closeCwdModal();
|
|
1136
|
+
});
|
|
1137
|
+
}
|
|
1138
|
+
const cwdInputEl = $("#cwdInput");
|
|
1139
|
+
if (cwdInputEl) {
|
|
1140
|
+
cwdInputEl.addEventListener("keydown", (e) => {
|
|
1141
|
+
if (e.key === "Enter") { e.preventDefault(); confirmCwdChange(); }
|
|
1142
|
+
else if (e.key === "Escape") { e.preventDefault(); closeCwdModal(); }
|
|
1143
|
+
});
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Load server config & restore saved CWD
|
|
1147
|
+
loadServerConfig();
|
|
1148
|
+
|
|
842
1149
|
refreshSessions();
|
|
843
1150
|
// start in the disconnected state; connectWs will flip to green on open.
|
|
844
1151
|
const initDot = $("#connDot");
|
package/public/index.html
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Ctext y='20' font-size='20'%3Eπ%3C/text%3E%3C/svg%3E">
|
|
9
9
|
</head>
|
|
10
10
|
<body data-cwd="">
|
|
11
|
-
<div class="app">
|
|
11
|
+
<div class="app sidebar-open">
|
|
12
12
|
<!-- Sidebar -->
|
|
13
13
|
<aside class="sidebar">
|
|
14
14
|
<div class="sidebar-top">
|
|
@@ -27,11 +27,22 @@
|
|
|
27
27
|
</div>
|
|
28
28
|
</aside>
|
|
29
29
|
|
|
30
|
+
<!-- Sidebar Overlay -->
|
|
31
|
+
<div class="sidebar-overlay" id="sidebarOverlay"></div>
|
|
32
|
+
|
|
30
33
|
<!-- Main -->
|
|
31
34
|
<main class="main">
|
|
32
35
|
<div class="topbar">
|
|
36
|
+
<button class="btn-toggle-sidebar" id="btnToggleSidebar" title="切换侧边栏">
|
|
37
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
38
|
+
<line x1="3" y1="12" x2="21" y2="12"></line>
|
|
39
|
+
<line x1="3" y1="6" x2="21" y2="6"></line>
|
|
40
|
+
<line x1="3" y1="18" x2="21" y2="18"></line>
|
|
41
|
+
</svg>
|
|
42
|
+
</button>
|
|
33
43
|
<div class="session-name" id="topSessionName">新对话</div>
|
|
34
|
-
<button class="btn-ghost" id="
|
|
44
|
+
<button class="btn-ghost" id="cwdPillWrap" title="点击切换工作目录">
|
|
45
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px; vertical-align:-2px;"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
|
|
35
46
|
<span id="cwdPill">~</span>
|
|
36
47
|
</button>
|
|
37
48
|
<div style="position:relative;">
|
|
@@ -63,6 +74,31 @@
|
|
|
63
74
|
</div>
|
|
64
75
|
</main>
|
|
65
76
|
</div>
|
|
77
|
+
|
|
78
|
+
<!-- CWD Modal -->
|
|
79
|
+
<div class="modal-overlay" id="cwdModal">
|
|
80
|
+
<div class="modal">
|
|
81
|
+
<div class="modal-header">
|
|
82
|
+
<h3>切换工作目录</h3>
|
|
83
|
+
<button class="btn-close" id="cwdModalClose">×</button>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="modal-body">
|
|
86
|
+
<p class="modal-desc">选择或输入 pi 代理运行的目标工作目录:</p>
|
|
87
|
+
<div class="input-group">
|
|
88
|
+
<input type="text" id="cwdInput" placeholder="/path/to/project" />
|
|
89
|
+
<button class="btn-primary" id="cwdConfirmBtn">确定切换</button>
|
|
90
|
+
</div>
|
|
91
|
+
<div id="cwdError" class="modal-error"></div>
|
|
92
|
+
<div class="quick-dirs">
|
|
93
|
+
<span class="quick-label">快捷选择:</span>
|
|
94
|
+
<div id="quickDirChips" class="chips"></div>
|
|
95
|
+
</div>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
|
|
100
|
+
<!-- Toast notification -->
|
|
101
|
+
<div class="toast" id="toast"></div>
|
|
66
102
|
<script src="/app.js"></script>
|
|
67
103
|
</body>
|
|
68
104
|
</html>
|