@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 +6 -2
- package/docs/CHANGELOG.md +66 -0
- package/package.json +3 -2
- package/public/app.js +379 -26
- package/public/index.html +23 -2
- package/public/style.css +272 -42
- package/scripts/rpctest.mjs +49 -0
- package/server.js +60 -4
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.
|
|
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
|
-
"
|
|
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,
|
|
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
|
-
//
|
|
676
|
-
|
|
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
|
|
742
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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>
|
|
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
|
-
<
|
|
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="关闭大图">×</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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
758
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
1538
|
+
padding-top: 50px;
|
|
1364
1539
|
}
|
|
1365
1540
|
|
|
1366
1541
|
.topbar .session-name {
|
|
1367
|
-
|
|
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:
|
|
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
|
|
1405
|
-
|
|
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:
|
|
1579
|
+
max-width: 125px;
|
|
1412
1580
|
overflow: hidden;
|
|
1413
1581
|
text-overflow: ellipsis;
|
|
1414
1582
|
white-space: nowrap;
|
|
1415
|
-
flex-shrink:
|
|
1583
|
+
flex-shrink: 1;
|
|
1584
|
+
min-width: 0;
|
|
1416
1585
|
gap: 4px;
|
|
1417
1586
|
}
|
|
1418
1587
|
.model-pill-name {
|
|
1419
|
-
max-width:
|
|
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:
|
|
1633
|
+
max-width: 60px;
|
|
1449
1634
|
padding: 4px 5px;
|
|
1450
1635
|
}
|
|
1451
1636
|
.model-pill {
|
|
1452
|
-
max-width:
|
|
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:
|
|
1483
|
-
flex-direction:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
1806
|
+
font-size: 15px;
|
|
1577
1807
|
line-height: 20px;
|
|
1578
|
-
min-height:
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
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
|
-
|
|
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(() => {
|