@liguoshuai/pi-web-chat 1.9.1 → 1.10.2

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,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [1.10.2] - 2026-08-30
11
+
12
+ ### Fixed & Improved
13
+ - **移动端顶栏排版与信息密度重构 (Mobile Topbar UX)**:
14
+ - 在移动端隐藏多余的会话名截断占位,隐藏导出按钮文字标签仅保留操作图标,为工作目录与模型选择胶囊腾出充足显示空间。
15
+ - 优化移动端触控胶囊布局与间距,杜绝文字极端挤压和残缺问题。
16
+ - **欢迎区与模型卡片响应式布局优化 (Empty State & Model Banner)**:
17
+ - 优化欢迎引导文案,兼容移动端抽屉式侧边栏交互。
18
+ - 为 `★ 默认模型` 徽标增加专用样式并强制单行禁止折行,彻底消除断词分行问题。
19
+ - 修复模型卡片在移动端下的左右对齐与视觉比例,“切换模型”按钮平滑对齐。
20
+ - 优化快捷推荐指令标签(Suggestions Chips),支持移动端优雅折行排版。
21
+ - **移动端输入框与占位提示适配 (Composer Mobile Adaptation)**:
22
+ - 移动端自动使用精简的 Placeholder 提示文案,彻底解决长提示在窄屏单行输入框下文字被腰斩截断的问题。
23
+ - 增强底部操作区全面屏手势安全区(`safe-area-inset-bottom`)适配。
24
+
25
+ ---
26
+
27
+ ## [1.10.1] - 2026-08-30
28
+
29
+ ### Improved
30
+ - **附件上传图标与交互优化 (Attachment Icon & UX)**:
31
+ - 将输入框上传图标更换为美观清晰的标准曲别针附件矢量图标(Paperclip 📎)。
32
+ - 优化按钮尺寸(`32px × 32px` 标准圆形)、对称内边距与悬停/按压交互动效。
33
+ - **文件与拖拽兼容性增强 (Upload Compatibility)**:
34
+ - 完善文件类型扩展(支持 `image/*` 及 `.png, .jpg, .jpeg, .webp, .gif, .bmp, .svg, .ico, .avif`)。
35
+ - 增强 MIME 类型缺失时的扩展名自动推断与回退处理机制。
36
+ - 增加全局 Drag & Drop 默认事件拦截,防止图片拖拽偏离输入框时误触浏览器页面跳转。
37
+
38
+ ---
39
+
40
+ ## [1.10.0] - 2026-08-30
41
+
42
+ ### Security
43
+ - **WebSocket 跨站劫持 (CSWSH) 防护与 Origin 校验**:
44
+ - 在 `server.js` 增加 `verifyClient` 来源校验,严格拦截非授权外部网页发起的跨域 WebSocket 连接,防御针对本地服务与命令执行的 CSWSH 攻击。
45
+ - 支持同源、`localhost`、`127.0.0.1` 及通过 `ALLOWED_ORIGINS` 环境变量配置的反向代理域名白名单。
46
+ - **路径与系统配置写入加固**:
47
+ - 加固 `/api/set-default-model` 接口,严格校验客户端传入的目标工作目录,防止任意路径操作。
48
+ - **Markdown 链接协议与属性逃逸 XSS 防护**:
49
+ - 增强链接协议校验(仅允许 `http:`、`https:`、`mailto:`),修复 URL 包含特定引号实体时在属性反转义下的 XSS 逃逸隐患。
50
+
51
+ ### Added
52
+ - **多模态图片输入与预览支持 (Multimodal Image Attachments & Lightbox)**:
53
+ - 输入框支持截图粘贴(`Ctrl+V` / `Cmd+V`)、本地图片文件拖拽(Drag & Drop)以及附件图标上传。
54
+ - 输入框上方显示待发送图片缩略图胶囊,支持单张移除。
55
+ - 消息气泡中支持渲染用户上传的多张图片缩略图,点击可弹出全屏大图预览(Lightbox)。
56
+ - 加载历史会话记录时自动提取并渲染历史消息中的图片附件。
57
+ - **对话导出为 Markdown 功能 (Export Chat as Markdown)**:
58
+ - 顶栏新增“导出”按钮,一键将当前会话(包含会话名称、模型信息、工作目录、思考过程、工具调用与对话正文)导出为标准 Markdown(`.md`)文件下载。
59
+ - **全局快捷键扩充 (Keyboard Shortcuts)**:
60
+ - `Ctrl/Cmd + Shift + N`:快速新建会话。
61
+ - `Ctrl/Cmd + K` 或 `Ctrl + /`:快速聚焦侧边栏搜索框。
62
+ - `Ctrl/Cmd + B`:快速切换侧边栏展开/折叠。
63
+ - `Escape`:支持快速关闭大图预览、搜索框失焦、关闭菜单及模态框。
64
+ - **侧边栏搜索体验优化**:
65
+ - 搜索结果为空时展示“未找到匹配的会话”空提示。
66
+ - 会话列表刷新时自动保持当前搜索过滤词。
67
+ - **自动化单元测试套件**:
68
+ - 新增基于 `node --test` 的单元测试,覆盖安全规则、Origin 校验与路径穿越检查。
69
+
70
+ ### Changed
71
+ - **段落排版与换行优化**:Markdown 段落内单换行符自动转为 `<br>`,优化 AI 输出文本的行间结构与排版体验。
72
+ - **跨平台工程化优化**:优化 `package.json` 中的 `prepare` 脚本,避免 Windows 下执行 `chmod` 报错;将 `rpctest.mjs` 整理归入 `scripts/` 目录。
73
+
74
+ ---
75
+
10
76
  ## [1.9.1] - 2026-08-29
11
77
 
12
78
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@liguoshuai/pi-web-chat",
3
- "version": "1.9.1",
3
+ "version": "1.10.2",
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();
@@ -2123,6 +2335,21 @@ function baseName(p) {
2123
2335
  }
2124
2336
 
2125
2337
  // ---- Composer ----
2338
+ function getComposerPlaceholder() {
2339
+ const isMobile = window.innerWidth <= 768;
2340
+ if (state.aborting) {
2341
+ return isMobile ? "正在中止…" : "正在中止当前任务…";
2342
+ }
2343
+ if (state.streaming) {
2344
+ return isMobile
2345
+ ? "AI 运行中… 可输入补充指令"
2346
+ : "AI 正在运行中… 可在此输入补充/指导指令,按 Enter 或点击【插入指令】实时调整";
2347
+ }
2348
+ return isMobile
2349
+ ? "给 pi 发消息…"
2350
+ : "给 pi 发消息… (Enter 发送,Shift+Enter 换行)";
2351
+ }
2352
+
2126
2353
  function updateComposerUI() {
2127
2354
  const ta = $("#composer");
2128
2355
  const text = ta ? ta.value.trim() : "";
@@ -2144,7 +2371,7 @@ function updateComposerUI() {
2144
2371
  if (steerBtn) {
2145
2372
  steerBtn.style.display = "none";
2146
2373
  }
2147
- if (ta) ta.placeholder = "正在中止当前任务…";
2374
+ if (ta) ta.placeholder = getComposerPlaceholder();
2148
2375
  } else if (state.streaming) {
2149
2376
  if (sendBtn) {
2150
2377
  sendBtn.classList.add("stop");
@@ -2160,7 +2387,7 @@ function updateComposerUI() {
2160
2387
  steerBtn.style.display = "none";
2161
2388
  }
2162
2389
  }
2163
- if (ta) ta.placeholder = "AI 正在运行中… 可在此输入补充/指导指令,按 Enter 或点击【插入指令】实时调整";
2390
+ if (ta) ta.placeholder = getComposerPlaceholder();
2164
2391
  } else {
2165
2392
  if (sendBtn) {
2166
2393
  sendBtn.classList.remove("stop");
@@ -2171,7 +2398,7 @@ function updateComposerUI() {
2171
2398
  if (steerBtn) {
2172
2399
  steerBtn.style.display = "none";
2173
2400
  }
2174
- if (ta) ta.placeholder = "给 pi 发消息… (Enter 发送,Shift+Enter 换行)";
2401
+ if (ta) ta.placeholder = getComposerPlaceholder();
2175
2402
  }
2176
2403
  }
2177
2404
 
@@ -2243,8 +2470,16 @@ function submitPrompt() {
2243
2470
 
2244
2471
  const ta = $("#composer");
2245
2472
  const text = ta ? ta.value.trim() : "";
2473
+ const imagesToSend = (state.attachedImages && state.attachedImages.length > 0)
2474
+ ? state.attachedImages.map(img => ({
2475
+ type: "image",
2476
+ data: img.data,
2477
+ mimeType: img.mimeType
2478
+ }))
2479
+ : undefined;
2480
+
2246
2481
  const hint = $(".composer-hint");
2247
- if (!text) return;
2482
+ if (!text && (!imagesToSend || imagesToSend.length === 0)) return;
2248
2483
  if (!state.wsConnected) {
2249
2484
  if (hint) hint.textContent = "发送失败:WebSocket 未连接。正在尝试重连…";
2250
2485
  scheduleReconnect(0);
@@ -2255,8 +2490,11 @@ function submitPrompt() {
2255
2490
 
2256
2491
  if (hint) hint.textContent = "pi 会执行命令与读写你的文件 —— 请注意操作内容。"; // restore default
2257
2492
  // Render the user's message locally for instant feedback.
2258
- appendMessageNode("user", { text });
2493
+ appendMessageNode("user", { text, images: state.attachedImages ? [...state.attachedImages] : [] });
2494
+
2259
2495
  ta.value = "";
2496
+ state.attachedImages = [];
2497
+ renderImagePreviews();
2260
2498
  autoResize();
2261
2499
 
2262
2500
  state.streaming = true;
@@ -2266,10 +2504,10 @@ function submitPrompt() {
2266
2504
  refreshStreamingContent();
2267
2505
 
2268
2506
  // Set session name from the first prompt of a brand-new session.
2269
- if (state.currentSessionFile == null) {
2507
+ if (state.currentSessionFile == null && text) {
2270
2508
  sendWs({ type: "set_session_name", name: text.slice(0, 60).replace(/\s+/g, " ") });
2271
2509
  }
2272
- sendWs({ type: "prompt", message: text });
2510
+ sendWs({ type: "prompt", message: text, images: imagesToSend });
2273
2511
  }
2274
2512
 
2275
2513
  function autoResize() {
@@ -2409,20 +2647,139 @@ async function init() {
2409
2647
  });
2410
2648
  }
2411
2649
 
2412
- // Keyboard shortcut: Ctrl+M / Cmd+M to toggle model selector, Escape to abort if streaming
2650
+ // Keyboard shortcuts:
2651
+ // Ctrl+M / Cmd+M: toggle model selector
2652
+ // Ctrl+Shift+N / Cmd+Shift+N: new session
2653
+ // Ctrl+K / Cmd+K or Ctrl+/: search sidebar
2654
+ // Ctrl+B / Cmd+B: toggle sidebar
2655
+ // Escape: abort if streaming, close modals / lightbox / search
2413
2656
  window.addEventListener("keydown", (e) => {
2414
- if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "m") {
2657
+ const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
2658
+ const isCmdOrCtrl = isMac ? e.metaKey : e.ctrlKey;
2659
+
2660
+ if (isCmdOrCtrl && e.shiftKey && e.key.toLowerCase() === "n") {
2661
+ e.preventDefault();
2662
+ startNewSession(true);
2663
+ return;
2664
+ }
2665
+ if ((isCmdOrCtrl && e.key.toLowerCase() === "k") || (e.ctrlKey && e.key === "/")) {
2666
+ e.preventDefault();
2667
+ const search = $("#sidebarSearch");
2668
+ if (search) {
2669
+ if (window.innerWidth <= 768) {
2670
+ $(".app").classList.add("sidebar-open");
2671
+ }
2672
+ search.focus();
2673
+ search.select();
2674
+ }
2675
+ return;
2676
+ }
2677
+ if (isCmdOrCtrl && e.key.toLowerCase() === "b") {
2678
+ e.preventDefault();
2679
+ toggleSidebar();
2680
+ return;
2681
+ }
2682
+ if (isCmdOrCtrl && e.key.toLowerCase() === "m") {
2415
2683
  e.preventDefault();
2416
2684
  toggleModelMenu();
2417
- } else if (e.key === "Escape" && state.streaming) {
2685
+ return;
2686
+ }
2687
+ if (e.key === "Escape") {
2688
+ const lightbox = $("#imageLightbox");
2689
+ if (lightbox && lightbox.style.display !== "none") {
2690
+ closeLightbox();
2691
+ return;
2692
+ }
2418
2693
  const isMenuOpen = $("#modelMenu")?.classList.contains("open") || $("#thinkingMenu")?.classList.contains("open");
2419
2694
  const isModalOpen = $("#cwdModal")?.classList.contains("open");
2420
- if (!isMenuOpen && !isModalOpen) {
2695
+ if (isMenuOpen || isModalOpen) {
2696
+ if ($("#modelMenu")) $("#modelMenu").classList.remove("open");
2697
+ if ($("#thinkingMenu")) $("#thinkingMenu").classList.remove("open");
2698
+ if (isModalOpen) closeCwdModal();
2699
+ return;
2700
+ }
2701
+ if (state.streaming) {
2421
2702
  abortGeneration();
2703
+ return;
2704
+ }
2705
+ if (document.activeElement === $("#sidebarSearch")) {
2706
+ $("#sidebarSearch").blur();
2707
+ }
2708
+ }
2709
+ });
2710
+
2711
+ // Export chat button
2712
+ const exportBtn = $("#btnExportChat");
2713
+ if (exportBtn) {
2714
+ exportBtn.addEventListener("click", exportCurrentSession);
2715
+ }
2716
+
2717
+ // Image attach / file picker / paste / drag-and-drop
2718
+ const btnAttach = $("#btnAttachImage");
2719
+ const fileInput = $("#imageFileInput");
2720
+ if (btnAttach && fileInput) {
2721
+ btnAttach.addEventListener("click", () => fileInput.click());
2722
+ fileInput.addEventListener("change", (e) => {
2723
+ handleImageFiles(e.target.files);
2724
+ fileInput.value = "";
2725
+ });
2726
+ }
2727
+
2728
+ // Image paste support
2729
+ document.addEventListener("paste", (e) => {
2730
+ const items = e.clipboardData?.items;
2731
+ if (!items) return;
2732
+ const files = [];
2733
+ for (let i = 0; i < items.length; i++) {
2734
+ if (items[i].type.indexOf("image") !== -1) {
2735
+ const file = items[i].getAsFile();
2736
+ if (file) files.push(file);
2422
2737
  }
2423
2738
  }
2739
+ if (files.length > 0) {
2740
+ handleImageFiles(files);
2741
+ const ta = $("#composer");
2742
+ if (ta) ta.focus();
2743
+ }
2424
2744
  });
2425
2745
 
2746
+ // Global preventDefault to avoid browser opening dragged images in new tab
2747
+ window.addEventListener("dragover", (e) => e.preventDefault(), false);
2748
+ window.addEventListener("drop", (e) => e.preventDefault(), false);
2749
+
2750
+ // Drag and drop images to composer
2751
+ const composerBox = $("#composerInner") || $(".composer");
2752
+ if (composerBox) {
2753
+ composerBox.addEventListener("dragover", (e) => {
2754
+ e.preventDefault();
2755
+ e.stopPropagation();
2756
+ composerBox.classList.add("drag-over");
2757
+ });
2758
+ composerBox.addEventListener("dragleave", (e) => {
2759
+ e.preventDefault();
2760
+ e.stopPropagation();
2761
+ composerBox.classList.remove("drag-over");
2762
+ });
2763
+ composerBox.addEventListener("drop", (e) => {
2764
+ e.preventDefault();
2765
+ e.stopPropagation();
2766
+ composerBox.classList.remove("drag-over");
2767
+ if (e.dataTransfer?.files) {
2768
+ handleImageFiles(e.dataTransfer.files);
2769
+ }
2770
+ });
2771
+ }
2772
+
2773
+ // Lightbox close listeners
2774
+ const lightboxEl = $("#imageLightbox");
2775
+ if (lightboxEl) {
2776
+ lightboxEl.addEventListener("click", (e) => {
2777
+ if (e.target === lightboxEl || e.target.id === "lightboxClose") {
2778
+ closeLightbox();
2779
+ }
2780
+ });
2781
+ }
2782
+
2426
2783
  document.addEventListener("click", (e) => {
2427
2784
  if (!e.target.closest("#modelMenu") && !e.target.closest("#modelPill") && !e.target.closest("#emptyChangeModelBtn")) {
2428
2785
  const m = $("#modelMenu");
@@ -2448,12 +2805,7 @@ async function init() {
2448
2805
 
2449
2806
  // sidebar search (client side filter)
2450
2807
  $("#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
- });
2808
+ filterSessions(e.target.value);
2457
2809
  });
2458
2810
 
2459
2811
  // Restore sidebar state for desktop, collapse by default on mobile
@@ -2487,6 +2839,7 @@ async function init() {
2487
2839
  lastIsMobile = isMobile;
2488
2840
  // Crossing the breakpoint: always close the drawer to reach a known state.
2489
2841
  $(".app").classList.remove("sidebar-open");
2842
+ updateComposerUI();
2490
2843
  }
2491
2844
  });
2492
2845
 
package/public/index.html CHANGED
@@ -63,6 +63,14 @@
63
63
  <span class="thinking-label" id="thinkingPillLabel">High</span>
64
64
  </button>
65
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>
66
74
  <div class="model-menu" id="modelMenu"></div>
67
75
  <div class="thinking-menu" id="thinkingMenu"></div>
68
76
  </div>
@@ -71,7 +79,7 @@
71
79
  <div id="emptyState" class="empty-state" style="display:flex;">
72
80
  <div class="logo">π</div>
73
81
  <h1>有什么可以帮你?</h1>
74
- <p>我是 <strong>pi</strong> 编程助手,可以读写文件、运行命令、改代码。在下面输入你的需求,或选择左侧的历史会话继续。</p>
82
+ <p>我是 <strong>pi</strong> 编程助手,可以读写文件、运行命令、改代码。在下方输入你的需求开始,或打开历史会话继续。</p>
75
83
  <div class="empty-model-banner" id="emptyModelBanner">
76
84
  <div class="empty-model-left">
77
85
  <span class="empty-model-icon">🤖</span>
@@ -96,8 +104,15 @@
96
104
  </div>
97
105
 
98
106
  <div class="composer">
107
+ <div class="image-preview-bar" id="imagePreviewBar" style="display:none;"></div>
99
108
  <div class="composer-inner" id="composerInner">
100
- <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 发消息…"></textarea>
101
116
  <button class="steer-btn" id="steerBtn" title="在 AI 运行过程中插入指导指令" style="display:none;">
102
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>
103
118
  <span>插入指令</span>
@@ -139,6 +154,12 @@
139
154
  </svg>
140
155
  </button>
141
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
+
142
163
  <!-- Toast notification -->
143
164
  <div class="toast" id="toast"></div>
144
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;
@@ -739,6 +812,7 @@ body {
739
812
  gap: 6px;
740
813
  font-size: 13px;
741
814
  color: var(--text);
815
+ flex-wrap: wrap;
742
816
  }
743
817
  .empty-model-label {
744
818
  color: var(--text-muted);
@@ -752,15 +826,41 @@ body {
752
826
  white-space: nowrap;
753
827
  }
754
828
  .badge-default {
755
- font-size: 10px;
829
+ display: inline-flex;
830
+ align-items: center;
831
+ font-size: 11px;
832
+ font-weight: 500;
833
+ color: #f59e0b;
834
+ background: rgba(245, 158, 11, 0.12);
835
+ border: 1px solid rgba(245, 158, 11, 0.25);
836
+ border-radius: 4px;
756
837
  padding: 1px 6px;
757
- border-radius: 999px;
758
- background: rgba(59, 130, 246, 0.18);
759
- color: #60a5fa;
760
- font-weight: 600;
838
+ white-space: nowrap !important;
839
+ flex-shrink: 0;
761
840
  line-height: 1.3;
841
+ }
842
+ .btn-topbar-action {
843
+ display: inline-flex;
844
+ align-items: center;
845
+ gap: 4px;
846
+ background: transparent;
847
+ border: 1px solid var(--border);
848
+ color: var(--text-muted);
849
+ border-radius: 8px;
850
+ padding: 5px 9px;
851
+ font-size: 12px;
852
+ cursor: pointer;
853
+ transition: all 0.15s ease;
762
854
  flex-shrink: 0;
763
855
  }
856
+ .btn-topbar-action:hover {
857
+ background: var(--bg-hover);
858
+ color: var(--text);
859
+ border-color: var(--text-dim);
860
+ }
861
+ .topbar-action-label {
862
+ font-weight: 500;
863
+ }
764
864
  .empty-model-features {
765
865
  display: flex;
766
866
  align-items: center;
@@ -794,25 +894,98 @@ body {
794
894
  }
795
895
  .suggestions .chip:hover { border-color: var(--text-dim); color: var(--text); }
796
896
 
797
- /* ---------- Composer ---------- */
897
+ /* ---------- Composer & Image Upload ---------- */
798
898
  .composer {
799
- padding: 16px 24px 24px;
899
+ padding: 12px 24px 20px;
800
900
  background: var(--bg);
801
901
  }
902
+ .image-preview-bar {
903
+ max-width: 820px;
904
+ margin: 0 auto 8px auto;
905
+ display: flex;
906
+ flex-wrap: wrap;
907
+ gap: 8px;
908
+ padding: 4px 0;
909
+ }
910
+ .image-preview-item {
911
+ position: relative;
912
+ width: 56px;
913
+ height: 56px;
914
+ border-radius: 8px;
915
+ overflow: hidden;
916
+ border: 1px solid var(--border);
917
+ background: #111;
918
+ flex-shrink: 0;
919
+ box-shadow: 0 2px 6px rgba(0,0,0,0.3);
920
+ }
921
+ .image-preview-item img {
922
+ width: 100%;
923
+ height: 100%;
924
+ object-fit: cover;
925
+ display: block;
926
+ }
927
+ .image-preview-remove {
928
+ position: absolute;
929
+ top: 2px;
930
+ right: 2px;
931
+ width: 18px;
932
+ height: 18px;
933
+ border-radius: 50%;
934
+ background: rgba(0, 0, 0, 0.75);
935
+ color: #fff;
936
+ border: none;
937
+ cursor: pointer;
938
+ display: flex;
939
+ align-items: center;
940
+ justify-content: center;
941
+ font-size: 12px;
942
+ line-height: 1;
943
+ padding: 0;
944
+ transition: background 0.15s;
945
+ }
946
+ .image-preview-remove:hover {
947
+ background: var(--danger, #ef4444);
948
+ }
949
+
802
950
  .composer-inner {
803
951
  max-width: 820px; margin: 0 auto;
804
952
  background: var(--bg-input);
805
953
  border: 1px solid var(--border);
806
954
  border-radius: 24px;
807
- padding: 6px 8px 6px 18px;
955
+ padding: 6px 8px 6px 8px;
808
956
  display: flex; align-items: flex-end; gap: 8px;
809
- transition: border-color .15s;
957
+ transition: border-color .15s, box-shadow .15s;
958
+ }
959
+ .composer-inner.drag-over {
960
+ border-color: var(--accent);
961
+ box-shadow: 0 0 0 2px rgba(16, 163, 127, 0.3);
810
962
  }
811
963
  .composer-inner:focus-within { border-color: var(--text-dim); }
812
964
  .composer-inner.aborting {
813
965
  border-color: var(--danger);
814
966
  box-shadow: 0 0 0 1px var(--danger);
815
967
  }
968
+ .composer .attach-btn {
969
+ width: 32px;
970
+ height: 32px;
971
+ background: transparent;
972
+ border: none;
973
+ color: var(--text-muted);
974
+ cursor: pointer;
975
+ display: flex;
976
+ align-items: center;
977
+ justify-content: center;
978
+ border-radius: 50%;
979
+ transition: color 0.15s ease, background 0.15s ease, transform 0.1s ease;
980
+ flex-shrink: 0;
981
+ }
982
+ .composer .attach-btn:hover {
983
+ color: var(--text);
984
+ background: var(--bg-hover);
985
+ }
986
+ .composer .attach-btn:active {
987
+ transform: scale(0.92);
988
+ }
816
989
  .composer textarea {
817
990
  flex: 1; background: transparent; border: none; outline: none; resize: none;
818
991
  color: var(--text); font-family: inherit; font-size: 15px; line-height: 20px;
@@ -1346,7 +1519,7 @@ body {
1346
1519
 
1347
1520
  /* Topbar Mobile Adjustments — fixed, taller, shadowed so it is always findable */
1348
1521
  .topbar {
1349
- height: 52px;
1522
+ height: 50px;
1350
1523
  padding: 0 8px;
1351
1524
  gap: 6px;
1352
1525
  position: fixed;
@@ -1357,21 +1530,16 @@ body {
1357
1530
  background: var(--bg);
1358
1531
  border-bottom: 1px solid var(--border);
1359
1532
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
1533
+ display: flex;
1534
+ align-items: center;
1360
1535
  }
1361
1536
 
1362
1537
  .main {
1363
- padding-top: 52px;
1538
+ padding-top: 50px;
1364
1539
  }
1365
1540
 
1366
1541
  .topbar .session-name {
1367
- font-size: 13px;
1368
- font-weight: 500;
1369
- min-width: 0;
1370
- flex: 1;
1371
- overflow: hidden;
1372
- text-overflow: ellipsis;
1373
- white-space: nowrap;
1374
- padding: 0 2px;
1542
+ display: none !important;
1375
1543
  }
1376
1544
 
1377
1545
  .btn-toggle-sidebar {
@@ -1390,7 +1558,7 @@ body {
1390
1558
  #cwdPillWrap {
1391
1559
  padding: 4px 7px;
1392
1560
  font-size: 11px;
1393
- max-width: 75px;
1561
+ max-width: 80px;
1394
1562
  overflow: hidden;
1395
1563
  text-overflow: ellipsis;
1396
1564
  white-space: nowrap;
@@ -1401,22 +1569,26 @@ body {
1401
1569
  display: flex;
1402
1570
  align-items: center;
1403
1571
  gap: 4px;
1404
- flex-shrink: 0;
1405
- max-width: none;
1572
+ flex: 1;
1573
+ min-width: 0;
1406
1574
  }
1407
1575
 
1408
1576
  .model-pill {
1409
1577
  padding: 4px 8px;
1410
1578
  font-size: 11px;
1411
- max-width: 105px;
1579
+ max-width: 125px;
1412
1580
  overflow: hidden;
1413
1581
  text-overflow: ellipsis;
1414
1582
  white-space: nowrap;
1415
- flex-shrink: 0;
1583
+ flex-shrink: 1;
1584
+ min-width: 0;
1416
1585
  gap: 4px;
1417
1586
  }
1418
1587
  .model-pill-name {
1419
- max-width: 65px;
1588
+ max-width: 100%;
1589
+ overflow: hidden;
1590
+ text-overflow: ellipsis;
1591
+ white-space: nowrap;
1420
1592
  }
1421
1593
  .model-pill-badge {
1422
1594
  display: none !important;
@@ -1439,20 +1611,30 @@ body {
1439
1611
  font-weight: 500;
1440
1612
  }
1441
1613
 
1614
+ .btn-topbar-action {
1615
+ width: 32px;
1616
+ height: 32px;
1617
+ padding: 0;
1618
+ display: inline-flex;
1619
+ align-items: center;
1620
+ justify-content: center;
1621
+ flex-shrink: 0;
1622
+ }
1623
+ .topbar-action-label {
1624
+ display: none !important;
1625
+ }
1626
+
1442
1627
  @media (max-width: 360px) {
1443
1628
  .topbar {
1444
1629
  padding: 0 4px;
1445
1630
  gap: 4px;
1446
1631
  }
1447
1632
  #cwdPillWrap {
1448
- max-width: 50px;
1633
+ max-width: 60px;
1449
1634
  padding: 4px 5px;
1450
1635
  }
1451
1636
  .model-pill {
1452
- max-width: 90px;
1453
- }
1454
- .model-pill-name {
1455
- max-width: 50px;
1637
+ max-width: 95px;
1456
1638
  }
1457
1639
  .thinking-pill {
1458
1640
  padding: 4px 5px;
@@ -1479,13 +1661,48 @@ body {
1479
1661
  }
1480
1662
 
1481
1663
  .empty-model-banner {
1482
- padding: 10px 12px;
1483
- flex-direction: column;
1664
+ padding: 12px 14px;
1665
+ flex-direction: row;
1666
+ align-items: center;
1667
+ justify-content: space-between;
1668
+ gap: 10px;
1669
+ width: 100%;
1670
+ max-width: 100%;
1671
+ box-sizing: border-box;
1672
+ }
1673
+ .empty-model-left {
1674
+ display: flex;
1484
1675
  align-items: flex-start;
1485
- gap: 8px;
1676
+ gap: 10px;
1677
+ flex: 1;
1678
+ min-width: 0;
1679
+ }
1680
+ .empty-model-icon {
1681
+ font-size: 22px;
1682
+ margin-top: 1px;
1683
+ flex-shrink: 0;
1684
+ }
1685
+ .empty-model-info {
1686
+ flex: 1;
1687
+ min-width: 0;
1688
+ gap: 4px;
1689
+ }
1690
+ .empty-model-title-row {
1691
+ flex-wrap: wrap;
1692
+ gap: 4px 6px;
1693
+ font-size: 12.5px;
1694
+ line-height: 1.4;
1695
+ }
1696
+ .empty-model-name {
1697
+ font-weight: 600;
1698
+ word-break: break-all;
1699
+ white-space: normal;
1486
1700
  }
1487
1701
  .btn-change-model {
1488
- align-self: flex-end;
1702
+ align-self: center;
1703
+ padding: 6px 10px;
1704
+ font-size: 11.5px;
1705
+ flex-shrink: 0;
1489
1706
  }
1490
1707
 
1491
1708
  .mobile-toolbar-fab {
@@ -1533,7 +1750,7 @@ body {
1533
1750
 
1534
1751
  /* Empty state mobile adjustments */
1535
1752
  .empty-state {
1536
- padding: 24px 16px;
1753
+ padding: 20px 14px;
1537
1754
  gap: 12px;
1538
1755
  }
1539
1756
 
@@ -1551,31 +1768,44 @@ body {
1551
1768
  }
1552
1769
 
1553
1770
  .suggestions {
1771
+ display: flex;
1554
1772
  gap: 8px;
1555
- margin-top: 8px;
1773
+ margin-top: 10px;
1774
+ flex-wrap: wrap;
1775
+ justify-content: center;
1776
+ width: 100%;
1777
+ max-width: 100%;
1778
+ box-sizing: border-box;
1556
1779
  }
1557
1780
 
1558
1781
  .suggestions .chip {
1559
- padding: 7px 12px;
1782
+ padding: 8px 12px;
1560
1783
  font-size: 12px;
1784
+ line-height: 1.35;
1785
+ text-align: center;
1786
+ border-radius: 18px;
1787
+ max-width: 100%;
1788
+ box-sizing: border-box;
1789
+ white-space: normal;
1561
1790
  }
1562
1791
 
1563
1792
  /* Composer input mobile adjustments */
1564
1793
  .composer {
1565
- padding: 8px 12px 12px;
1794
+ padding: 8px 12px;
1795
+ padding-bottom: max(12px, calc(8px + env(safe-area-inset-bottom, 0px)));
1566
1796
  }
1567
1797
 
1568
1798
  .composer-inner {
1569
- padding: 6px 8px 6px 14px;
1799
+ padding: 5px 8px 5px 12px;
1570
1800
  border-radius: 22px;
1571
1801
  gap: 6px;
1572
1802
  }
1573
1803
 
1574
1804
  .composer textarea {
1575
1805
  padding: 6px 0;
1576
- font-size: 14px;
1806
+ font-size: 15px;
1577
1807
  line-height: 20px;
1578
- min-height: 32px;
1808
+ min-height: 28px;
1579
1809
  max-height: 160px;
1580
1810
  }
1581
1811
 
@@ -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(() => {