@liguoshuai/pi-web-chat 1.9.0 → 1.10.1

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 CHANGED
@@ -11,12 +11,16 @@
11
11
  ## 📸 功能特性
12
12
 
13
13
  - 💬 **流式对话** —— 文本逐字流式渲染,带打字光标与 Markdown 高亮
14
+ - 🖼️ **多模态图片支持** —— 支持截图粘贴(Ctrl+V)、文件拖拽及附件上传,可在对话中渲染图片缩略图与大图预览
15
+ - 📥 **会话一键导出** —— 顶栏支持一键将当前完整会话导出为标准 Markdown(`.md`)文件
14
16
  - ⚡ **后台任务持久化** —— 关闭网页/标签页不中断正在生成的任务,重新进入自动回放(Backfill)追平进度
15
17
  - 🌐 **多端/多设备同步协同** —— 多个浏览器标签页或多设备(手机/电脑)可同时连入同一会话,实时双向同步
16
18
  - 🧭 **运行时实时插入指令 (Steer)** —— 生成过程中可随时插入转向指令指导 AI 调整后续行动
17
- - 🗂️ **会话历史与管理** —— 自动读取并显示 `~/.pi/agent/sessions/` 下所有历史会话,实时自动落盘
19
+ - 🗂️ **会话历史与管理** —— 自动读取并显示 `~/.pi/agent/sessions/` 下所有历史会话,实时自动落盘与删除
18
20
  - ⚙️ **工具与思考过程折叠** —— bash / read / write / edit 等工具调用与 thinking 思考卡片实时展开/收起
19
- - 🧩 **动态模型切换** —— 顶栏模型胶囊,零延迟覆盖所有 pi 已配置的模型
21
+ - 🧩 **动态模型切换** —— 顶栏模型胶囊,零延迟覆盖所有 pi 已配置的模型,支持深度思考级别调节
22
+ - ⌨️ **丰富快捷键** —— 支持 `Ctrl/Cmd+M`(切模型)、`Ctrl/Cmd+Shift+N`(新建)、`Ctrl/Cmd+K`(搜索)、`Ctrl/Cmd+B`(折叠侧边栏)、`Esc`(中断/关闭)
23
+ - 🛡️ **安全加固** —— WebSocket 跨站劫持 (CSWSH) Origin 防护与 Markdown 属性逃逸防御
20
24
  - 🔄 **断线自动重连与心跳** —— 带指数退避自动重连、双向心跳 Ping/Pong 检测
21
25
  - 📋 **快捷复制** —— 支持代码块、工具指令、回答全文一键复制
22
26
  - 📱 **移动端响应式** —— Viewport `100dvh` 优化、顶栏常驻固定、滚动置顶/置底快捷悬浮按钮(FAB)
package/docs/CHANGELOG.md CHANGED
@@ -7,6 +7,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.10.1] - 2026-08-30
11
+
12
+ ### Improved
13
+ - **附件上传图标与交互优化 (Attachment Icon & UX)**:
14
+ - 将输入框上传图标更换为美观清晰的标准曲别针附件矢量图标(Paperclip 📎)。
15
+ - 优化按钮尺寸(`32px × 32px` 标准圆形)、对称内边距与悬停/按压交互动效。
16
+ - **文件与拖拽兼容性增强 (Upload Compatibility)**:
17
+ - 完善文件类型扩展(支持 `image/*` 及 `.png, .jpg, .jpeg, .webp, .gif, .bmp, .svg, .ico, .avif`)。
18
+ - 增强 MIME 类型缺失时的扩展名自动推断与回退处理机制。
19
+ - 增加全局 Drag & Drop 默认事件拦截,防止图片拖拽偏离输入框时误触浏览器页面跳转。
20
+
21
+ ---
22
+
23
+ ## [1.10.0] - 2026-08-30
24
+
25
+ ### Security
26
+ - **WebSocket 跨站劫持 (CSWSH) 防护与 Origin 校验**:
27
+ - 在 `server.js` 增加 `verifyClient` 来源校验,严格拦截非授权外部网页发起的跨域 WebSocket 连接,防御针对本地服务与命令执行的 CSWSH 攻击。
28
+ - 支持同源、`localhost`、`127.0.0.1` 及通过 `ALLOWED_ORIGINS` 环境变量配置的反向代理域名白名单。
29
+ - **路径与系统配置写入加固**:
30
+ - 加固 `/api/set-default-model` 接口,严格校验客户端传入的目标工作目录,防止任意路径操作。
31
+ - **Markdown 链接协议与属性逃逸 XSS 防护**:
32
+ - 增强链接协议校验(仅允许 `http:`、`https:`、`mailto:`),修复 URL 包含特定引号实体时在属性反转义下的 XSS 逃逸隐患。
33
+
34
+ ### Added
35
+ - **多模态图片输入与预览支持 (Multimodal Image Attachments & Lightbox)**:
36
+ - 输入框支持截图粘贴(`Ctrl+V` / `Cmd+V`)、本地图片文件拖拽(Drag & Drop)以及附件图标上传。
37
+ - 输入框上方显示待发送图片缩略图胶囊,支持单张移除。
38
+ - 消息气泡中支持渲染用户上传的多张图片缩略图,点击可弹出全屏大图预览(Lightbox)。
39
+ - 加载历史会话记录时自动提取并渲染历史消息中的图片附件。
40
+ - **对话导出为 Markdown 功能 (Export Chat as Markdown)**:
41
+ - 顶栏新增“导出”按钮,一键将当前会话(包含会话名称、模型信息、工作目录、思考过程、工具调用与对话正文)导出为标准 Markdown(`.md`)文件下载。
42
+ - **全局快捷键扩充 (Keyboard Shortcuts)**:
43
+ - `Ctrl/Cmd + Shift + N`:快速新建会话。
44
+ - `Ctrl/Cmd + K` 或 `Ctrl + /`:快速聚焦侧边栏搜索框。
45
+ - `Ctrl/Cmd + B`:快速切换侧边栏展开/折叠。
46
+ - `Escape`:支持快速关闭大图预览、搜索框失焦、关闭菜单及模态框。
47
+ - **侧边栏搜索体验优化**:
48
+ - 搜索结果为空时展示“未找到匹配的会话”空提示。
49
+ - 会话列表刷新时自动保持当前搜索过滤词。
50
+ - **自动化单元测试套件**:
51
+ - 新增基于 `node --test` 的单元测试,覆盖安全规则、Origin 校验与路径穿越检查。
52
+
53
+ ### Changed
54
+ - **段落排版与换行优化**:Markdown 段落内单换行符自动转为 `<br>`,优化 AI 输出文本的行间结构与排版体验。
55
+ - **跨平台工程化优化**:优化 `package.json` 中的 `prepare` 脚本,避免 Windows 下执行 `chmod` 报错;将 `rpctest.mjs` 整理归入 `scripts/` 目录。
56
+
57
+ ---
58
+
59
+ ## [1.9.1] - 2026-08-29
60
+
61
+ ### Added
62
+ - **侧边栏底部开源项目跳转链接 (Repository Link in Sidebar Bottom)**:
63
+ - 在侧边栏底部元信息区域增加 `pi-web-chat` 项目开源仓库链接(`https://github.com/liguoshuai-1990/pi-web-chat`),方便用户快速跳转与查阅源码。
64
+
65
+ ---
66
+
10
67
  ## [1.9.0] - 2026-08-29
11
68
 
12
69
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "1.9.0",
3
+ "version": "1.10.1",
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",
@@ -19,7 +19,8 @@
19
19
  "scripts": {
20
20
  "start": "node server.js",
21
21
  "dev": "node --watch server.js",
22
- "prepare": "chmod +x bin/pi-web-chat.js"
22
+ "test": "node --test",
23
+ "prepare": "node -e \"import('fs').then(fs => { try { fs.chmodSync('bin/pi-web-chat.js', 0o755); } catch {} })\""
23
24
  },
24
25
  "keywords": [
25
26
  "pi",
package/public/app.js CHANGED
@@ -43,6 +43,7 @@ const state = {
43
43
  sessionId: null,
44
44
  isBackfilling: false,
45
45
  aborting: false,
46
+ attachedImages: [], // Array of { data: string (base64), mimeType: string, url: string }
46
47
  };
47
48
 
48
49
  let toastTimer = null;
@@ -358,7 +359,7 @@ function renderInlineMd(text) {
358
359
  if (para.length === 0) return;
359
360
  const block = para.join("\n").trim();
360
361
  para = [];
361
- outHtml += "<p>" + mdInlineBlock(block) + "</p>";
362
+ outHtml += "<p>" + mdInlineBlock(block).replace(/\n/g, "<br>") + "</p>";
362
363
  }
363
364
  for (const seg of out) {
364
365
  if (seg.kind === "table") {
@@ -420,6 +421,15 @@ function renderInlineMd(text) {
420
421
  return outHtml;
421
422
  }
422
423
 
424
+ function sanitizeUrl(url) {
425
+ if (!url) return "#";
426
+ const trimmed = url.trim();
427
+ if (/^(https?:\/\/|mailto:)/i.test(trimmed)) {
428
+ return escapeHtml(trimmed);
429
+ }
430
+ return "#";
431
+ }
432
+
423
433
  // helper state bag attached to the function during line scan
424
434
  function mdInlineBlock(text) {
425
435
  let s = escapeHtml(text);
@@ -432,8 +442,11 @@ function mdInlineBlock(text) {
432
442
  s = s.replace(/(^|[^*])\*([^*\s](?:[^*]*?[^*\s])?)\*(?!\*)/g, "$1<em>$2</em>");
433
443
  // italic (underscore): only match boundary/space so identifier_names are preserved
434
444
  s = s.replace(/(^|[\s(\[<,;:])_([^_\s](?:[^_]*?[^_\s])?)_(?=[\s)\]>,;:!?.]|$)/g, "$1<em>$2</em>");
435
- // links [txt](url)
436
- s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
445
+ // links [txt](url) - strictly sanitize URL
446
+ s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, (match, txt, url) => {
447
+ const safeUrl = sanitizeUrl(url);
448
+ return `<a href="${safeUrl}" target="_blank" rel="noopener">${txt}</a>`;
449
+ });
437
450
  return s;
438
451
  }
439
452
 
@@ -516,6 +529,31 @@ function renderSidebar(sessions) {
516
529
  ]);
517
530
  list.appendChild(item);
518
531
  });
532
+ filterSessions();
533
+ }
534
+
535
+ function filterSessions(query) {
536
+ const q = (query !== undefined ? query : ($("#sidebarSearch")?.value || "")).toLowerCase().trim();
537
+ const list = $("#sessionList");
538
+ if (!list) return;
539
+ const items = list.querySelectorAll(".session-item");
540
+ let visibleCount = 0;
541
+ items.forEach((it) => {
542
+ const titleEl = it.querySelector(".session-item-name") || it.querySelector(".title");
543
+ const text = titleEl ? titleEl.textContent.toLowerCase() : it.textContent.toLowerCase();
544
+ const match = !q || text.includes(q);
545
+ it.style.display = match ? "" : "none";
546
+ if (match) visibleCount++;
547
+ });
548
+ let emptyTip = list.querySelector(".sidebar-search-empty");
549
+ if (items.length > 0 && visibleCount === 0 && q) {
550
+ if (!emptyTip) {
551
+ emptyTip = el("div", { class: "sidebar-search-empty", text: "未找到匹配的会话" });
552
+ list.appendChild(emptyTip);
553
+ }
554
+ } else if (emptyTip) {
555
+ emptyTip.remove();
556
+ }
519
557
  }
520
558
 
521
559
  function startNewSession(askConfirm = true) {
@@ -672,8 +710,18 @@ function reconstructFromEntries(entries) {
672
710
  const m = e.message;
673
711
  if (!m || m.role === "bashExecution") continue;
674
712
  if (m.role === "user") {
675
- // skip "bash execution" pseudo-users (those have role user but content type special)
676
- out.push({ role: "user", text: extractContentText(m.content), ts: m.timestamp });
713
+ // Extract optional images from user message content array
714
+ let images = [];
715
+ if (Array.isArray(m.content)) {
716
+ images = m.content
717
+ .filter(c => c && (c.type === "image" || c.mimeType))
718
+ .map(c => ({
719
+ data: c.data,
720
+ mimeType: c.mimeType || "image/png",
721
+ url: c.data ? `data:${c.mimeType || "image/png"};base64,${c.data}` : (c.url || "")
722
+ }));
723
+ }
724
+ out.push({ role: "user", text: extractContentText(m.content), images, ts: m.timestamp });
677
725
  } else if (m.role === "assistant") {
678
726
  const rawContent = Array.isArray(m.content) ? m.content : (m.content ? [{ type: "text", text: String(m.content) }] : []);
679
727
  const content = rawContent.map(part => {
@@ -734,14 +782,178 @@ function showEmptyState(show) {
734
782
  document.querySelector("#emptyState").style.display = show ? "flex" : "none";
735
783
  }
736
784
 
785
+ function openLightbox(src) {
786
+ const box = $("#imageLightbox");
787
+ const img = $("#lightboxImg");
788
+ if (!box || !img) return;
789
+ img.src = src;
790
+ box.style.display = "flex";
791
+ }
792
+
793
+ function closeLightbox() {
794
+ const box = $("#imageLightbox");
795
+ if (box) box.style.display = "none";
796
+ }
797
+
798
+ function removeAttachedImage(index) {
799
+ if (index >= 0 && index < state.attachedImages.length) {
800
+ state.attachedImages.splice(index, 1);
801
+ renderImagePreviews();
802
+ }
803
+ }
804
+
805
+ function renderImagePreviews() {
806
+ const bar = $("#imagePreviewBar");
807
+ if (!bar) return;
808
+ bar.innerHTML = "";
809
+ if (!state.attachedImages || state.attachedImages.length === 0) {
810
+ bar.style.display = "none";
811
+ return;
812
+ }
813
+ bar.style.display = "flex";
814
+ state.attachedImages.forEach((img, idx) => {
815
+ const item = el("div", { class: "image-preview-item" }, [
816
+ el("img", { src: img.url, alt: "预览" }),
817
+ el("button", {
818
+ class: "image-preview-remove",
819
+ type: "button",
820
+ title: "移除此图片",
821
+ onclick: (e) => {
822
+ e.stopPropagation();
823
+ removeAttachedImage(idx);
824
+ }
825
+ }, ["×"])
826
+ ]);
827
+ bar.appendChild(item);
828
+ });
829
+ }
830
+
831
+ function detectImageMimeType(file) {
832
+ if (file.type && file.type.startsWith("image/")) return file.type;
833
+ const ext = file.name ? file.name.split(".").pop().toLowerCase() : "";
834
+ const map = {
835
+ png: "image/png",
836
+ jpg: "image/jpeg",
837
+ jpeg: "image/jpeg",
838
+ webp: "image/webp",
839
+ gif: "image/gif",
840
+ bmp: "image/bmp",
841
+ svg: "image/svg+xml",
842
+ ico: "image/x-icon",
843
+ avif: "image/avif"
844
+ };
845
+ return map[ext] || "image/png";
846
+ }
847
+
848
+ function handleImageFiles(files) {
849
+ if (!files || files.length === 0) return;
850
+ const imageFiles = Array.from(files).filter(f => {
851
+ return (f.type && f.type.startsWith("image/")) ||
852
+ (f.name && /\.(png|jpe?g|webp|gif|bmp|svg|ico|avif)$/i.test(f.name));
853
+ });
854
+ if (imageFiles.length === 0) return;
855
+
856
+ imageFiles.forEach(file => {
857
+ const reader = new FileReader();
858
+ reader.onload = () => {
859
+ const result = reader.result;
860
+ if (typeof result !== "string") return;
861
+ const commaIdx = result.indexOf(",");
862
+ const base64 = commaIdx !== -1 ? result.slice(commaIdx + 1) : result;
863
+ const mimeType = detectImageMimeType(file);
864
+ state.attachedImages.push({
865
+ data: base64,
866
+ mimeType,
867
+ url: result
868
+ });
869
+ renderImagePreviews();
870
+ };
871
+ reader.readAsDataURL(file);
872
+ });
873
+ }
874
+
875
+ function exportCurrentSession() {
876
+ const chatInner = $("#chat-inner");
877
+ if (!chatInner || chatInner.children.length === 0) {
878
+ showToast("当前会话没有内容可导出");
879
+ return;
880
+ }
881
+ const sessionTitle = $("#topSessionName")?.textContent || "pi-chat-export";
882
+ let md = `# ${sessionTitle}\n\n`;
883
+ md += `> 导出时间: ${new Date().toLocaleString()}\n`;
884
+ if (state.currentModel?.id) {
885
+ md += `> 模型: ${state.currentModel.provider ? state.currentModel.provider + " / " : ""}${state.currentModel.name || state.currentModel.id}\n`;
886
+ }
887
+ if (state.cwd) {
888
+ md += `> 工作目录: \`${state.cwd}\`\n`;
889
+ }
890
+ md += `\n---\n\n`;
891
+
892
+ const msgs = chatInner.querySelectorAll(".msg");
893
+ msgs.forEach(msg => {
894
+ if (msg.classList.contains("user")) {
895
+ const isSteer = !!msg.querySelector(".steer-badge");
896
+ const bubble = msg.querySelector(".bubble");
897
+ const text = bubble ? bubble.innerText.replace("🧭 指导指令", "").trim() : "";
898
+ md += `### 👤 User${isSteer ? " (🧭 指导指令)" : ""}\n\n${text}\n\n`;
899
+ } else if (msg.classList.contains("assistant")) {
900
+ md += `### 🤖 pi\n\n`;
901
+ const thinking = msg.querySelector(".thinking-body");
902
+ if (thinking && thinking.innerText.trim()) {
903
+ md += `<details><summary>💭 思考过程</summary>\n\n${thinking.innerText.trim()}\n\n</details>\n\n`;
904
+ }
905
+ const tools = msg.querySelectorAll(".tool-block");
906
+ tools.forEach(tool => {
907
+ const name = tool.querySelector(".name")?.innerText || "tool";
908
+ const args = tool.querySelector(".args")?.innerText || "";
909
+ const body = tool.querySelector(".tool-body")?.innerText || "";
910
+ md += `\`\`\`bash\n# Tool: ${name} ${args}\n${body.trim()}\n\`\`\`\n\n`;
911
+ });
912
+ const paragraphs = msg.querySelectorAll(".content > div:not(.thinking-block):not(.tool-block)");
913
+ paragraphs.forEach(p => {
914
+ md += `${p.innerText.trim()}\n\n`;
915
+ });
916
+ }
917
+ });
918
+
919
+ const blob = new Blob([md], { type: "text/markdown;charset=utf-8" });
920
+ const url = URL.createObjectURL(blob);
921
+ const a = document.createElement("a");
922
+ a.href = url;
923
+ const safeName = sessionTitle.replace(/[/\\?%*:|"<>]/g, "_").slice(0, 40);
924
+ a.download = `${safeName}_${new Date().toISOString().slice(0, 10)}.md`;
925
+ document.body.appendChild(a);
926
+ a.click();
927
+ document.body.removeChild(a);
928
+ URL.revokeObjectURL(url);
929
+ showToast("对话已导出为 Markdown 文件");
930
+ }
931
+
737
932
  function appendMessageNode(role, m) {
738
933
  if (role === "user") {
739
934
  const isSteer = m.isSteer || false;
740
935
  const steerBadge = isSteer ? el("span", { class: "steer-badge", text: "🧭 指导指令" }) : null;
741
- const bubble = el("div", { class: "bubble" + (isSteer ? " steer" : "") }, [
742
- steerBadge,
743
- document.createTextNode(m.text || "")
744
- ]);
936
+ const bubbleChildren = [steerBadge];
937
+ if (m.text) {
938
+ bubbleChildren.push(document.createTextNode(m.text));
939
+ }
940
+ if (m.images && m.images.length > 0) {
941
+ const imgsContainer = el("div", { class: "msg-images" });
942
+ m.images.forEach(img => {
943
+ const src = img.url || (img.data ? `data:${img.mimeType || "image/png"};base64,${img.data}` : "");
944
+ if (!src) return;
945
+ const imgEl = el("img", {
946
+ class: "msg-image-thumb",
947
+ src,
948
+ alt: "用户上传图片",
949
+ title: "点击查看大图",
950
+ onclick: () => openLightbox(src)
951
+ });
952
+ imgsContainer.appendChild(imgEl);
953
+ });
954
+ bubbleChildren.push(imgsContainer);
955
+ }
956
+ const bubble = el("div", { class: "bubble" + (isSteer ? " steer" : "") }, bubbleChildren);
745
957
  const node = el("div", { class: "msg user" }, [bubble]);
746
958
  $("#chat-inner").appendChild(node);
747
959
  scrollBottom();
@@ -2243,8 +2455,16 @@ function submitPrompt() {
2243
2455
 
2244
2456
  const ta = $("#composer");
2245
2457
  const text = ta ? ta.value.trim() : "";
2458
+ const imagesToSend = (state.attachedImages && state.attachedImages.length > 0)
2459
+ ? state.attachedImages.map(img => ({
2460
+ type: "image",
2461
+ data: img.data,
2462
+ mimeType: img.mimeType
2463
+ }))
2464
+ : undefined;
2465
+
2246
2466
  const hint = $(".composer-hint");
2247
- if (!text) return;
2467
+ if (!text && (!imagesToSend || imagesToSend.length === 0)) return;
2248
2468
  if (!state.wsConnected) {
2249
2469
  if (hint) hint.textContent = "发送失败:WebSocket 未连接。正在尝试重连…";
2250
2470
  scheduleReconnect(0);
@@ -2255,8 +2475,11 @@ function submitPrompt() {
2255
2475
 
2256
2476
  if (hint) hint.textContent = "pi 会执行命令与读写你的文件 —— 请注意操作内容。"; // restore default
2257
2477
  // Render the user's message locally for instant feedback.
2258
- appendMessageNode("user", { text });
2478
+ appendMessageNode("user", { text, images: state.attachedImages ? [...state.attachedImages] : [] });
2479
+
2259
2480
  ta.value = "";
2481
+ state.attachedImages = [];
2482
+ renderImagePreviews();
2260
2483
  autoResize();
2261
2484
 
2262
2485
  state.streaming = true;
@@ -2266,10 +2489,10 @@ function submitPrompt() {
2266
2489
  refreshStreamingContent();
2267
2490
 
2268
2491
  // Set session name from the first prompt of a brand-new session.
2269
- if (state.currentSessionFile == null) {
2492
+ if (state.currentSessionFile == null && text) {
2270
2493
  sendWs({ type: "set_session_name", name: text.slice(0, 60).replace(/\s+/g, " ") });
2271
2494
  }
2272
- sendWs({ type: "prompt", message: text });
2495
+ sendWs({ type: "prompt", message: text, images: imagesToSend });
2273
2496
  }
2274
2497
 
2275
2498
  function autoResize() {
@@ -2409,20 +2632,139 @@ async function init() {
2409
2632
  });
2410
2633
  }
2411
2634
 
2412
- // Keyboard shortcut: Ctrl+M / Cmd+M to toggle model selector, Escape to abort if streaming
2635
+ // Keyboard shortcuts:
2636
+ // Ctrl+M / Cmd+M: toggle model selector
2637
+ // Ctrl+Shift+N / Cmd+Shift+N: new session
2638
+ // Ctrl+K / Cmd+K or Ctrl+/: search sidebar
2639
+ // Ctrl+B / Cmd+B: toggle sidebar
2640
+ // Escape: abort if streaming, close modals / lightbox / search
2413
2641
  window.addEventListener("keydown", (e) => {
2414
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "m") {
2642
+ const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
2643
+ const isCmdOrCtrl = isMac ? e.metaKey : e.ctrlKey;
2644
+
2645
+ if (isCmdOrCtrl && e.shiftKey && e.key.toLowerCase() === "n") {
2646
+ e.preventDefault();
2647
+ startNewSession(true);
2648
+ return;
2649
+ }
2650
+ if ((isCmdOrCtrl && e.key.toLowerCase() === "k") || (e.ctrlKey && e.key === "/")) {
2651
+ e.preventDefault();
2652
+ const search = $("#sidebarSearch");
2653
+ if (search) {
2654
+ if (window.innerWidth <= 768) {
2655
+ $(".app").classList.add("sidebar-open");
2656
+ }
2657
+ search.focus();
2658
+ search.select();
2659
+ }
2660
+ return;
2661
+ }
2662
+ if (isCmdOrCtrl && e.key.toLowerCase() === "b") {
2663
+ e.preventDefault();
2664
+ toggleSidebar();
2665
+ return;
2666
+ }
2667
+ if (isCmdOrCtrl && e.key.toLowerCase() === "m") {
2415
2668
  e.preventDefault();
2416
2669
  toggleModelMenu();
2417
- } else if (e.key === "Escape" && state.streaming) {
2670
+ return;
2671
+ }
2672
+ if (e.key === "Escape") {
2673
+ const lightbox = $("#imageLightbox");
2674
+ if (lightbox && lightbox.style.display !== "none") {
2675
+ closeLightbox();
2676
+ return;
2677
+ }
2418
2678
  const isMenuOpen = $("#modelMenu")?.classList.contains("open") || $("#thinkingMenu")?.classList.contains("open");
2419
2679
  const isModalOpen = $("#cwdModal")?.classList.contains("open");
2420
- if (!isMenuOpen && !isModalOpen) {
2680
+ if (isMenuOpen || isModalOpen) {
2681
+ if ($("#modelMenu")) $("#modelMenu").classList.remove("open");
2682
+ if ($("#thinkingMenu")) $("#thinkingMenu").classList.remove("open");
2683
+ if (isModalOpen) closeCwdModal();
2684
+ return;
2685
+ }
2686
+ if (state.streaming) {
2421
2687
  abortGeneration();
2688
+ return;
2689
+ }
2690
+ if (document.activeElement === $("#sidebarSearch")) {
2691
+ $("#sidebarSearch").blur();
2422
2692
  }
2423
2693
  }
2424
2694
  });
2425
2695
 
2696
+ // Export chat button
2697
+ const exportBtn = $("#btnExportChat");
2698
+ if (exportBtn) {
2699
+ exportBtn.addEventListener("click", exportCurrentSession);
2700
+ }
2701
+
2702
+ // Image attach / file picker / paste / drag-and-drop
2703
+ const btnAttach = $("#btnAttachImage");
2704
+ const fileInput = $("#imageFileInput");
2705
+ if (btnAttach && fileInput) {
2706
+ btnAttach.addEventListener("click", () => fileInput.click());
2707
+ fileInput.addEventListener("change", (e) => {
2708
+ handleImageFiles(e.target.files);
2709
+ fileInput.value = "";
2710
+ });
2711
+ }
2712
+
2713
+ // Image paste support
2714
+ document.addEventListener("paste", (e) => {
2715
+ const items = e.clipboardData?.items;
2716
+ if (!items) return;
2717
+ const files = [];
2718
+ for (let i = 0; i < items.length; i++) {
2719
+ if (items[i].type.indexOf("image") !== -1) {
2720
+ const file = items[i].getAsFile();
2721
+ if (file) files.push(file);
2722
+ }
2723
+ }
2724
+ if (files.length > 0) {
2725
+ handleImageFiles(files);
2726
+ const ta = $("#composer");
2727
+ if (ta) ta.focus();
2728
+ }
2729
+ });
2730
+
2731
+ // Global preventDefault to avoid browser opening dragged images in new tab
2732
+ window.addEventListener("dragover", (e) => e.preventDefault(), false);
2733
+ window.addEventListener("drop", (e) => e.preventDefault(), false);
2734
+
2735
+ // Drag and drop images to composer
2736
+ const composerBox = $("#composerInner") || $(".composer");
2737
+ if (composerBox) {
2738
+ composerBox.addEventListener("dragover", (e) => {
2739
+ e.preventDefault();
2740
+ e.stopPropagation();
2741
+ composerBox.classList.add("drag-over");
2742
+ });
2743
+ composerBox.addEventListener("dragleave", (e) => {
2744
+ e.preventDefault();
2745
+ e.stopPropagation();
2746
+ composerBox.classList.remove("drag-over");
2747
+ });
2748
+ composerBox.addEventListener("drop", (e) => {
2749
+ e.preventDefault();
2750
+ e.stopPropagation();
2751
+ composerBox.classList.remove("drag-over");
2752
+ if (e.dataTransfer?.files) {
2753
+ handleImageFiles(e.dataTransfer.files);
2754
+ }
2755
+ });
2756
+ }
2757
+
2758
+ // Lightbox close listeners
2759
+ const lightboxEl = $("#imageLightbox");
2760
+ if (lightboxEl) {
2761
+ lightboxEl.addEventListener("click", (e) => {
2762
+ if (e.target === lightboxEl || e.target.id === "lightboxClose") {
2763
+ closeLightbox();
2764
+ }
2765
+ });
2766
+ }
2767
+
2426
2768
  document.addEventListener("click", (e) => {
2427
2769
  if (!e.target.closest("#modelMenu") && !e.target.closest("#modelPill") && !e.target.closest("#emptyChangeModelBtn")) {
2428
2770
  const m = $("#modelMenu");
@@ -2448,12 +2790,7 @@ async function init() {
2448
2790
 
2449
2791
  // sidebar search (client side filter)
2450
2792
  $("#sidebarSearch").addEventListener("input", (e) => {
2451
- const q = e.target.value.toLowerCase();
2452
- document.querySelectorAll(".session-item").forEach((it) => {
2453
- const titleEl = it.querySelector(".title");
2454
- const text = titleEl ? titleEl.textContent.toLowerCase() : it.textContent.toLowerCase();
2455
- it.style.display = text.includes(q) ? "" : "none";
2456
- });
2793
+ filterSessions(e.target.value);
2457
2794
  });
2458
2795
 
2459
2796
  // Restore sidebar state for desktop, collapse by default on mobile
package/public/index.html CHANGED
@@ -24,7 +24,11 @@
24
24
  <div class="sidebar-bottom">
25
25
  <div id="connStatus" class="conn-status" title="点击可重新连接"><span id="connDot" style="color: var(--danger);">●</span>&nbsp;<span id="connLabel">连接中…</span></div>
26
26
  <div class="sidebar-bottom-meta">
27
- <a href="https://pi.dev" target="_blank" rel="noopener">pi.dev</a>
27
+ <div class="sidebar-bottom-links">
28
+ <a href="https://pi.dev" target="_blank" rel="noopener">pi.dev</a>
29
+ <span class="sidebar-link-sep">·</span>
30
+ <a href="https://github.com/liguoshuai-1990/pi-web-chat" target="_blank" rel="noopener">pi-web-chat</a>
31
+ </div>
28
32
  <span id="appVersion" class="app-version" title="pi-web-chat 版本"></span>
29
33
  </div>
30
34
  </div>
@@ -59,6 +63,14 @@
59
63
  <span class="thinking-label" id="thinkingPillLabel">High</span>
60
64
  </button>
61
65
  </div>
66
+ <button class="btn-topbar-action" id="btnExportChat" title="导出当前对话为 Markdown">
67
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
68
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
69
+ <polyline points="7 10 12 15 17 10"></polyline>
70
+ <line x1="12" y1="15" x2="12" y2="3"></line>
71
+ </svg>
72
+ <span class="topbar-action-label">导出</span>
73
+ </button>
62
74
  <div class="model-menu" id="modelMenu"></div>
63
75
  <div class="thinking-menu" id="thinkingMenu"></div>
64
76
  </div>
@@ -92,8 +104,15 @@
92
104
  </div>
93
105
 
94
106
  <div class="composer">
107
+ <div class="image-preview-bar" id="imagePreviewBar" style="display:none;"></div>
95
108
  <div class="composer-inner" id="composerInner">
96
- <textarea id="composer" rows="1" placeholder=" pi 发消息… (Enter 发送,Shift+Enter 换行)"></textarea>
109
+ <input type="file" id="imageFileInput" accept="image/*,.png,.jpg,.jpeg,.webp,.gif,.bmp,.svg" multiple style="display:none;">
110
+ <button class="attach-btn" id="btnAttachImage" type="button" title="添加附件 / 图片 (支持拖拽与截图粘贴)">
111
+ <svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
112
+ <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>
113
+ </svg>
114
+ </button>
115
+ <textarea id="composer" rows="1" placeholder="给 pi 发消息… (Enter 发送,Shift+Enter 换行,支持粘贴/拖拽图片)"></textarea>
97
116
  <button class="steer-btn" id="steerBtn" title="在 AI 运行过程中插入指导指令" style="display:none;">
98
117
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/></svg>
99
118
  <span>插入指令</span>
@@ -135,6 +154,12 @@
135
154
  </svg>
136
155
  </button>
137
156
 
157
+ <!-- Image Lightbox Modal -->
158
+ <div class="image-lightbox" id="imageLightbox" style="display:none;">
159
+ <button class="lightbox-close" id="lightboxClose" title="关闭大图">&times;</button>
160
+ <img id="lightboxImg" src="" alt="大图预览">
161
+ </div>
162
+
138
163
  <!-- Toast notification -->
139
164
  <div class="toast" id="toast"></div>
140
165
  <script src="/app.js"></script>
package/public/style.css CHANGED
@@ -217,7 +217,80 @@ body {
217
217
  .is-resizing .sidebar {
218
218
  transition: none !important;
219
219
  }
220
- .sidebar-empty { color: var(--text-dim); font-size: 13px; padding: 20px 12px; text-align: center; }
220
+ /* Message Images */
221
+ .msg-images {
222
+ display: flex;
223
+ flex-wrap: wrap;
224
+ gap: 8px;
225
+ margin-top: 8px;
226
+ }
227
+ .msg.user .msg-images {
228
+ justify-content: flex-end;
229
+ }
230
+ .msg-image-thumb {
231
+ width: 90px;
232
+ height: 90px;
233
+ border-radius: 8px;
234
+ object-fit: cover;
235
+ border: 1px solid rgba(255, 255, 255, 0.1);
236
+ cursor: pointer;
237
+ transition: transform 0.15s ease, opacity 0.15s ease;
238
+ }
239
+ .msg-image-thumb:hover {
240
+ transform: scale(1.04);
241
+ opacity: 0.9;
242
+ }
243
+
244
+ /* Image Lightbox */
245
+ .image-lightbox {
246
+ position: fixed;
247
+ top: 0; left: 0; right: 0; bottom: 0;
248
+ background: rgba(0, 0, 0, 0.85);
249
+ backdrop-filter: blur(4px);
250
+ z-index: 1000;
251
+ display: flex;
252
+ align-items: center;
253
+ justify-content: center;
254
+ padding: 24px;
255
+ cursor: zoom-out;
256
+ animation: fade 0.15s ease;
257
+ }
258
+ .image-lightbox img {
259
+ max-width: 90vw;
260
+ max-height: 90vh;
261
+ object-fit: contain;
262
+ border-radius: 8px;
263
+ box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
264
+ cursor: default;
265
+ }
266
+ .lightbox-close {
267
+ position: absolute;
268
+ top: 20px;
269
+ right: 24px;
270
+ width: 36px;
271
+ height: 36px;
272
+ border-radius: 50%;
273
+ background: rgba(255, 255, 255, 0.15);
274
+ border: none;
275
+ color: #fff;
276
+ font-size: 24px;
277
+ line-height: 1;
278
+ display: flex;
279
+ align-items: center;
280
+ justify-content: center;
281
+ cursor: pointer;
282
+ transition: background 0.15s;
283
+ }
284
+ .lightbox-close:hover {
285
+ background: rgba(255, 255, 255, 0.3);
286
+ }
287
+
288
+ .sidebar-search-empty {
289
+ color: var(--text-dim);
290
+ font-size: 12.5px;
291
+ padding: 16px 12px;
292
+ text-align: center;
293
+ }
221
294
 
222
295
  .sidebar-bottom {
223
296
  flex-shrink: 0;
@@ -235,6 +308,20 @@ body {
235
308
  display: flex;
236
309
  align-items: center;
237
310
  justify-content: space-between;
311
+ gap: 8px;
312
+ }
313
+ .sidebar-bottom-links {
314
+ display: inline-flex;
315
+ align-items: center;
316
+ gap: 5px;
317
+ min-width: 0;
318
+ overflow: hidden;
319
+ text-overflow: ellipsis;
320
+ white-space: nowrap;
321
+ }
322
+ .sidebar-link-sep {
323
+ color: var(--text-dim);
324
+ font-size: 11px;
238
325
  }
239
326
  .app-version {
240
327
  font-size: 11px;
@@ -737,16 +824,28 @@ body {
737
824
  text-overflow: ellipsis;
738
825
  white-space: nowrap;
739
826
  }
740
- .badge-default {
741
- font-size: 10px;
742
- padding: 1px 6px;
743
- border-radius: 999px;
744
- background: rgba(59, 130, 246, 0.18);
745
- color: #60a5fa;
746
- font-weight: 600;
747
- line-height: 1.3;
827
+ .btn-topbar-action {
828
+ display: inline-flex;
829
+ align-items: center;
830
+ gap: 4px;
831
+ background: transparent;
832
+ border: 1px solid var(--border);
833
+ color: var(--text-muted);
834
+ border-radius: 8px;
835
+ padding: 5px 9px;
836
+ font-size: 12px;
837
+ cursor: pointer;
838
+ transition: all 0.15s ease;
748
839
  flex-shrink: 0;
749
840
  }
841
+ .btn-topbar-action:hover {
842
+ background: var(--bg-hover);
843
+ color: var(--text);
844
+ border-color: var(--text-dim);
845
+ }
846
+ .topbar-action-label {
847
+ font-weight: 500;
848
+ }
750
849
  .empty-model-features {
751
850
  display: flex;
752
851
  align-items: center;
@@ -780,25 +879,98 @@ body {
780
879
  }
781
880
  .suggestions .chip:hover { border-color: var(--text-dim); color: var(--text); }
782
881
 
783
- /* ---------- Composer ---------- */
882
+ /* ---------- Composer & Image Upload ---------- */
784
883
  .composer {
785
- padding: 16px 24px 24px;
884
+ padding: 12px 24px 20px;
786
885
  background: var(--bg);
787
886
  }
887
+ .image-preview-bar {
888
+ max-width: 820px;
889
+ margin: 0 auto 8px auto;
890
+ display: flex;
891
+ flex-wrap: wrap;
892
+ gap: 8px;
893
+ padding: 4px 0;
894
+ }
895
+ .image-preview-item {
896
+ position: relative;
897
+ width: 56px;
898
+ height: 56px;
899
+ border-radius: 8px;
900
+ overflow: hidden;
901
+ border: 1px solid var(--border);
902
+ background: #111;
903
+ flex-shrink: 0;
904
+ box-shadow: 0 2px 6px rgba(0,0,0,0.3);
905
+ }
906
+ .image-preview-item img {
907
+ width: 100%;
908
+ height: 100%;
909
+ object-fit: cover;
910
+ display: block;
911
+ }
912
+ .image-preview-remove {
913
+ position: absolute;
914
+ top: 2px;
915
+ right: 2px;
916
+ width: 18px;
917
+ height: 18px;
918
+ border-radius: 50%;
919
+ background: rgba(0, 0, 0, 0.75);
920
+ color: #fff;
921
+ border: none;
922
+ cursor: pointer;
923
+ display: flex;
924
+ align-items: center;
925
+ justify-content: center;
926
+ font-size: 12px;
927
+ line-height: 1;
928
+ padding: 0;
929
+ transition: background 0.15s;
930
+ }
931
+ .image-preview-remove:hover {
932
+ background: var(--danger, #ef4444);
933
+ }
934
+
788
935
  .composer-inner {
789
936
  max-width: 820px; margin: 0 auto;
790
937
  background: var(--bg-input);
791
938
  border: 1px solid var(--border);
792
939
  border-radius: 24px;
793
- padding: 6px 8px 6px 18px;
940
+ padding: 6px 8px 6px 8px;
794
941
  display: flex; align-items: flex-end; gap: 8px;
795
- transition: border-color .15s;
942
+ transition: border-color .15s, box-shadow .15s;
943
+ }
944
+ .composer-inner.drag-over {
945
+ border-color: var(--accent);
946
+ box-shadow: 0 0 0 2px rgba(16, 163, 127, 0.3);
796
947
  }
797
948
  .composer-inner:focus-within { border-color: var(--text-dim); }
798
949
  .composer-inner.aborting {
799
950
  border-color: var(--danger);
800
951
  box-shadow: 0 0 0 1px var(--danger);
801
952
  }
953
+ .composer .attach-btn {
954
+ width: 32px;
955
+ height: 32px;
956
+ background: transparent;
957
+ border: none;
958
+ color: var(--text-muted);
959
+ cursor: pointer;
960
+ display: flex;
961
+ align-items: center;
962
+ justify-content: center;
963
+ border-radius: 50%;
964
+ transition: color 0.15s ease, background 0.15s ease, transform 0.1s ease;
965
+ flex-shrink: 0;
966
+ }
967
+ .composer .attach-btn:hover {
968
+ color: var(--text);
969
+ background: var(--bg-hover);
970
+ }
971
+ .composer .attach-btn:active {
972
+ transform: scale(0.92);
973
+ }
802
974
  .composer textarea {
803
975
  flex: 1; background: transparent; border: none; outline: none; resize: none;
804
976
  color: var(--text); font-family: inherit; font-size: 15px; line-height: 20px;
@@ -0,0 +1,49 @@
1
+ import { spawn } from "child_process";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { existsSync } from "fs";
5
+
6
+ function resolvePiBin() {
7
+ if (process.env.PI_BIN && existsSync(process.env.PI_BIN)) return process.env.PI_BIN;
8
+ const home = os.homedir();
9
+ const candidates = [
10
+ path.join(home, ".npm-global/bin/pi"),
11
+ "/usr/local/bin/pi",
12
+ "/usr/bin/pi",
13
+ ];
14
+ for (const c of candidates) if (existsSync(c)) return c;
15
+ return "pi";
16
+ }
17
+
18
+ const PI_BIN = resolvePiBin();
19
+ const SESSIONS_DIR = process.env.PI_SESSIONS_DIR || path.join(os.homedir(), ".pi/agent/sessions");
20
+
21
+ console.log("Spawning pi...");
22
+ const proc = spawn(PI_BIN, ["--mode", "rpc", "--session-dir", SESSIONS_DIR], {
23
+ cwd: os.homedir(),
24
+ env: { ...process.env, PI_SKIP_VERSION_CHECK: "1" }
25
+ });
26
+
27
+ proc.stdout.on("data", (d) => {
28
+ console.log("STDOUT:", d.toString().trim());
29
+ });
30
+ proc.stderr.on("data", (d) => {
31
+ console.error("STDERR:", d.toString().trim());
32
+ });
33
+ proc.on("exit", (code, sig) => console.log("EXIT:", code, "sig:", sig));
34
+
35
+ // Send set_session_name FIRST, then prompt
36
+ setTimeout(() => {
37
+ console.log("Sending set_session_name...");
38
+ proc.stdin.write(JSON.stringify({ id: "1", type: "set_session_name", name: "My New Web Session Name" }) + "\n");
39
+ }, 500);
40
+
41
+ setTimeout(() => {
42
+ console.log("Sending prompt...");
43
+ proc.stdin.write(JSON.stringify({ id: "2", type: "prompt", message: "Say exactly: OK" }) + "\n");
44
+ }, 1000);
45
+
46
+ setTimeout(() => {
47
+ console.log("Killing...");
48
+ proc.kill("SIGTERM");
49
+ }, 12000);
package/server.js CHANGED
@@ -492,6 +492,10 @@ class PiAgent {
492
492
  this.pending.clear();
493
493
  for (const s of this.sockets) { try { s.close(); } catch {} }
494
494
  this.sockets.clear();
495
+ this.buffer = "";
496
+ this.eventBuffer = [];
497
+ this.bufferHead = 0;
498
+ this.lastUserPrompt = null;
495
499
  try { this.proc && this.proc.kill("SIGTERM"); } catch {}
496
500
  }
497
501
  }
@@ -574,9 +578,21 @@ app.post("/api/set-default-model", async (req, res) => {
574
578
  }
575
579
 
576
580
  const isProject = scope === "project" && reqCwd;
577
- const settingsPath = isProject
578
- ? path.join(normalizeCwd(reqCwd), ".pi", "settings.json")
579
- : path.join(home(), ".pi", "agent", "settings.json");
581
+ let settingsPath;
582
+ if (isProject) {
583
+ const resolvedCwd = normalizeCwd(reqCwd);
584
+ try {
585
+ const s = await stat(resolvedCwd);
586
+ if (!s.isDirectory()) {
587
+ return res.status(400).json({ ok: false, error: "指定的工作目录不是一个有效目录" });
588
+ }
589
+ } catch {
590
+ return res.status(400).json({ ok: false, error: "指定的工作目录不存在或无权访问" });
591
+ }
592
+ settingsPath = path.join(resolvedCwd, ".pi", "settings.json");
593
+ } else {
594
+ settingsPath = path.join(home(), ".pi", "agent", "settings.json");
595
+ }
580
596
 
581
597
  await mkdir(path.dirname(settingsPath), { recursive: true });
582
598
 
@@ -917,7 +933,47 @@ const httpServer = app.listen(PORT, () => {
917
933
  console.log(`pi-web-chat on http://localhost:${PORT}`);
918
934
  });
919
935
 
920
- const wss = new WebSocketServer({ server: httpServer, path: "/ws" });
936
+ // Origin check helper to defend against Cross-Site WebSocket Hijacking (CSWSH)
937
+ function isAllowedOrigin(origin, host) {
938
+ if (!origin) return true; // Direct non-browser clients (curl, electron, etc.)
939
+ try {
940
+ const originUrl = new URL(origin);
941
+ const originHost = originUrl.host;
942
+ // Direct match with Host header (same-origin)
943
+ if (originHost.toLowerCase() === (host || "").toLowerCase()) return true;
944
+
945
+ // Allow localhost / 127.0.0.1 loopback variations
946
+ const isLocalOrigin = originUrl.hostname === "localhost" || originUrl.hostname === "127.0.0.1" || originUrl.hostname === "::1";
947
+ const hostName = (host || "").split(":")[0].toLowerCase();
948
+ const isLocalHost = hostName === "localhost" || hostName === "127.0.0.1" || hostName === "::1";
949
+ if (isLocalOrigin && isLocalHost) return true;
950
+
951
+ // Custom allowed origins from ALLOWED_ORIGINS env var (comma-separated)
952
+ if (process.env.ALLOWED_ORIGINS) {
953
+ const allowed = process.env.ALLOWED_ORIGINS.split(",").map(s => s.trim().toLowerCase());
954
+ if (allowed.includes(origin.toLowerCase()) || allowed.includes(originUrl.origin.toLowerCase())) {
955
+ return true;
956
+ }
957
+ }
958
+ return false;
959
+ } catch {
960
+ return false;
961
+ }
962
+ }
963
+
964
+ const wss = new WebSocketServer({
965
+ server: httpServer,
966
+ path: "/ws",
967
+ verifyClient: (info, callback) => {
968
+ const origin = info.origin || info.req.headers.origin;
969
+ const host = info.req.headers.host;
970
+ if (!isAllowedOrigin(origin, host)) {
971
+ console.warn(`[pi-web-chat] Rejected WebSocket connection from unauthorized origin: ${origin} (host: ${host})`);
972
+ return callback(false, 403, "Forbidden: Cross-origin WebSocket connection denied");
973
+ }
974
+ callback(true);
975
+ }
976
+ });
921
977
 
922
978
  // WebSocket server heartbeat (ping/pong at protocol level)
923
979
  const heartbeatInterval = setInterval(() => {