@liguoshuai/pi-web-chat 1.9.0 → 1.10.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- package/docs/CHANGELOG.md +57 -0
- package/package.json +3 -2
- package/public/app.js +360 -23
- package/public/index.html +27 -2
- package/public/style.css +185 -13
- 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,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [1.10.1] - 2026-08-30
|
|
11
|
+
|
|
12
|
+
### Improved
|
|
13
|
+
- **附件上传图标与交互优化 (Attachment Icon & UX)**:
|
|
14
|
+
- 将输入框上传图标更换为美观清晰的标准曲别针附件矢量图标(Paperclip 📎)。
|
|
15
|
+
- 优化按钮尺寸(`32px × 32px` 标准圆形)、对称内边距与悬停/按压交互动效。
|
|
16
|
+
- **文件与拖拽兼容性增强 (Upload Compatibility)**:
|
|
17
|
+
- 完善文件类型扩展(支持 `image/*` 及 `.png, .jpg, .jpeg, .webp, .gif, .bmp, .svg, .ico, .avif`)。
|
|
18
|
+
- 增强 MIME 类型缺失时的扩展名自动推断与回退处理机制。
|
|
19
|
+
- 增加全局 Drag & Drop 默认事件拦截,防止图片拖拽偏离输入框时误触浏览器页面跳转。
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## [1.10.0] - 2026-08-30
|
|
24
|
+
|
|
25
|
+
### Security
|
|
26
|
+
- **WebSocket 跨站劫持 (CSWSH) 防护与 Origin 校验**:
|
|
27
|
+
- 在 `server.js` 增加 `verifyClient` 来源校验,严格拦截非授权外部网页发起的跨域 WebSocket 连接,防御针对本地服务与命令执行的 CSWSH 攻击。
|
|
28
|
+
- 支持同源、`localhost`、`127.0.0.1` 及通过 `ALLOWED_ORIGINS` 环境变量配置的反向代理域名白名单。
|
|
29
|
+
- **路径与系统配置写入加固**:
|
|
30
|
+
- 加固 `/api/set-default-model` 接口,严格校验客户端传入的目标工作目录,防止任意路径操作。
|
|
31
|
+
- **Markdown 链接协议与属性逃逸 XSS 防护**:
|
|
32
|
+
- 增强链接协议校验(仅允许 `http:`、`https:`、`mailto:`),修复 URL 包含特定引号实体时在属性反转义下的 XSS 逃逸隐患。
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
- **多模态图片输入与预览支持 (Multimodal Image Attachments & Lightbox)**:
|
|
36
|
+
- 输入框支持截图粘贴(`Ctrl+V` / `Cmd+V`)、本地图片文件拖拽(Drag & Drop)以及附件图标上传。
|
|
37
|
+
- 输入框上方显示待发送图片缩略图胶囊,支持单张移除。
|
|
38
|
+
- 消息气泡中支持渲染用户上传的多张图片缩略图,点击可弹出全屏大图预览(Lightbox)。
|
|
39
|
+
- 加载历史会话记录时自动提取并渲染历史消息中的图片附件。
|
|
40
|
+
- **对话导出为 Markdown 功能 (Export Chat as Markdown)**:
|
|
41
|
+
- 顶栏新增“导出”按钮,一键将当前会话(包含会话名称、模型信息、工作目录、思考过程、工具调用与对话正文)导出为标准 Markdown(`.md`)文件下载。
|
|
42
|
+
- **全局快捷键扩充 (Keyboard Shortcuts)**:
|
|
43
|
+
- `Ctrl/Cmd + Shift + N`:快速新建会话。
|
|
44
|
+
- `Ctrl/Cmd + K` 或 `Ctrl + /`:快速聚焦侧边栏搜索框。
|
|
45
|
+
- `Ctrl/Cmd + B`:快速切换侧边栏展开/折叠。
|
|
46
|
+
- `Escape`:支持快速关闭大图预览、搜索框失焦、关闭菜单及模态框。
|
|
47
|
+
- **侧边栏搜索体验优化**:
|
|
48
|
+
- 搜索结果为空时展示“未找到匹配的会话”空提示。
|
|
49
|
+
- 会话列表刷新时自动保持当前搜索过滤词。
|
|
50
|
+
- **自动化单元测试套件**:
|
|
51
|
+
- 新增基于 `node --test` 的单元测试,覆盖安全规则、Origin 校验与路径穿越检查。
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
- **段落排版与换行优化**:Markdown 段落内单换行符自动转为 `<br>`,优化 AI 输出文本的行间结构与排版体验。
|
|
55
|
+
- **跨平台工程化优化**:优化 `package.json` 中的 `prepare` 脚本,避免 Windows 下执行 `chmod` 报错;将 `rpctest.mjs` 整理归入 `scripts/` 目录。
|
|
56
|
+
|
|
57
|
+
---
|
|
58
|
+
|
|
59
|
+
## [1.9.1] - 2026-08-29
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
- **侧边栏底部开源项目跳转链接 (Repository Link in Sidebar Bottom)**:
|
|
63
|
+
- 在侧边栏底部元信息区域增加 `pi-web-chat` 项目开源仓库链接(`https://github.com/liguoshuai-1990/pi-web-chat`),方便用户快速跳转与查阅源码。
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
10
67
|
## [1.9.0] - 2026-08-29
|
|
11
68
|
|
|
12
69
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liguoshuai/pi-web-chat",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.1",
|
|
4
4
|
"description": "A ChatGPT/Gemini-style web UI for the pi coding agent, powered by pi's RPC mode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "server.js",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"scripts": {
|
|
20
20
|
"start": "node server.js",
|
|
21
21
|
"dev": "node --watch server.js",
|
|
22
|
-
"
|
|
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();
|
|
@@ -2243,8 +2455,16 @@ function submitPrompt() {
|
|
|
2243
2455
|
|
|
2244
2456
|
const ta = $("#composer");
|
|
2245
2457
|
const text = ta ? ta.value.trim() : "";
|
|
2458
|
+
const imagesToSend = (state.attachedImages && state.attachedImages.length > 0)
|
|
2459
|
+
? state.attachedImages.map(img => ({
|
|
2460
|
+
type: "image",
|
|
2461
|
+
data: img.data,
|
|
2462
|
+
mimeType: img.mimeType
|
|
2463
|
+
}))
|
|
2464
|
+
: undefined;
|
|
2465
|
+
|
|
2246
2466
|
const hint = $(".composer-hint");
|
|
2247
|
-
if (!text) return;
|
|
2467
|
+
if (!text && (!imagesToSend || imagesToSend.length === 0)) return;
|
|
2248
2468
|
if (!state.wsConnected) {
|
|
2249
2469
|
if (hint) hint.textContent = "发送失败:WebSocket 未连接。正在尝试重连…";
|
|
2250
2470
|
scheduleReconnect(0);
|
|
@@ -2255,8 +2475,11 @@ function submitPrompt() {
|
|
|
2255
2475
|
|
|
2256
2476
|
if (hint) hint.textContent = "pi 会执行命令与读写你的文件 —— 请注意操作内容。"; // restore default
|
|
2257
2477
|
// Render the user's message locally for instant feedback.
|
|
2258
|
-
appendMessageNode("user", { text });
|
|
2478
|
+
appendMessageNode("user", { text, images: state.attachedImages ? [...state.attachedImages] : [] });
|
|
2479
|
+
|
|
2259
2480
|
ta.value = "";
|
|
2481
|
+
state.attachedImages = [];
|
|
2482
|
+
renderImagePreviews();
|
|
2260
2483
|
autoResize();
|
|
2261
2484
|
|
|
2262
2485
|
state.streaming = true;
|
|
@@ -2266,10 +2489,10 @@ function submitPrompt() {
|
|
|
2266
2489
|
refreshStreamingContent();
|
|
2267
2490
|
|
|
2268
2491
|
// Set session name from the first prompt of a brand-new session.
|
|
2269
|
-
if (state.currentSessionFile == null) {
|
|
2492
|
+
if (state.currentSessionFile == null && text) {
|
|
2270
2493
|
sendWs({ type: "set_session_name", name: text.slice(0, 60).replace(/\s+/g, " ") });
|
|
2271
2494
|
}
|
|
2272
|
-
sendWs({ type: "prompt", message: text });
|
|
2495
|
+
sendWs({ type: "prompt", message: text, images: imagesToSend });
|
|
2273
2496
|
}
|
|
2274
2497
|
|
|
2275
2498
|
function autoResize() {
|
|
@@ -2409,20 +2632,139 @@ async function init() {
|
|
|
2409
2632
|
});
|
|
2410
2633
|
}
|
|
2411
2634
|
|
|
2412
|
-
// Keyboard
|
|
2635
|
+
// Keyboard shortcuts:
|
|
2636
|
+
// Ctrl+M / Cmd+M: toggle model selector
|
|
2637
|
+
// Ctrl+Shift+N / Cmd+Shift+N: new session
|
|
2638
|
+
// Ctrl+K / Cmd+K or Ctrl+/: search sidebar
|
|
2639
|
+
// Ctrl+B / Cmd+B: toggle sidebar
|
|
2640
|
+
// Escape: abort if streaming, close modals / lightbox / search
|
|
2413
2641
|
window.addEventListener("keydown", (e) => {
|
|
2414
|
-
|
|
2642
|
+
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
|
2643
|
+
const isCmdOrCtrl = isMac ? e.metaKey : e.ctrlKey;
|
|
2644
|
+
|
|
2645
|
+
if (isCmdOrCtrl && e.shiftKey && e.key.toLowerCase() === "n") {
|
|
2646
|
+
e.preventDefault();
|
|
2647
|
+
startNewSession(true);
|
|
2648
|
+
return;
|
|
2649
|
+
}
|
|
2650
|
+
if ((isCmdOrCtrl && e.key.toLowerCase() === "k") || (e.ctrlKey && e.key === "/")) {
|
|
2651
|
+
e.preventDefault();
|
|
2652
|
+
const search = $("#sidebarSearch");
|
|
2653
|
+
if (search) {
|
|
2654
|
+
if (window.innerWidth <= 768) {
|
|
2655
|
+
$(".app").classList.add("sidebar-open");
|
|
2656
|
+
}
|
|
2657
|
+
search.focus();
|
|
2658
|
+
search.select();
|
|
2659
|
+
}
|
|
2660
|
+
return;
|
|
2661
|
+
}
|
|
2662
|
+
if (isCmdOrCtrl && e.key.toLowerCase() === "b") {
|
|
2663
|
+
e.preventDefault();
|
|
2664
|
+
toggleSidebar();
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
if (isCmdOrCtrl && e.key.toLowerCase() === "m") {
|
|
2415
2668
|
e.preventDefault();
|
|
2416
2669
|
toggleModelMenu();
|
|
2417
|
-
|
|
2670
|
+
return;
|
|
2671
|
+
}
|
|
2672
|
+
if (e.key === "Escape") {
|
|
2673
|
+
const lightbox = $("#imageLightbox");
|
|
2674
|
+
if (lightbox && lightbox.style.display !== "none") {
|
|
2675
|
+
closeLightbox();
|
|
2676
|
+
return;
|
|
2677
|
+
}
|
|
2418
2678
|
const isMenuOpen = $("#modelMenu")?.classList.contains("open") || $("#thinkingMenu")?.classList.contains("open");
|
|
2419
2679
|
const isModalOpen = $("#cwdModal")?.classList.contains("open");
|
|
2420
|
-
if (
|
|
2680
|
+
if (isMenuOpen || isModalOpen) {
|
|
2681
|
+
if ($("#modelMenu")) $("#modelMenu").classList.remove("open");
|
|
2682
|
+
if ($("#thinkingMenu")) $("#thinkingMenu").classList.remove("open");
|
|
2683
|
+
if (isModalOpen) closeCwdModal();
|
|
2684
|
+
return;
|
|
2685
|
+
}
|
|
2686
|
+
if (state.streaming) {
|
|
2421
2687
|
abortGeneration();
|
|
2688
|
+
return;
|
|
2689
|
+
}
|
|
2690
|
+
if (document.activeElement === $("#sidebarSearch")) {
|
|
2691
|
+
$("#sidebarSearch").blur();
|
|
2422
2692
|
}
|
|
2423
2693
|
}
|
|
2424
2694
|
});
|
|
2425
2695
|
|
|
2696
|
+
// Export chat button
|
|
2697
|
+
const exportBtn = $("#btnExportChat");
|
|
2698
|
+
if (exportBtn) {
|
|
2699
|
+
exportBtn.addEventListener("click", exportCurrentSession);
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
// Image attach / file picker / paste / drag-and-drop
|
|
2703
|
+
const btnAttach = $("#btnAttachImage");
|
|
2704
|
+
const fileInput = $("#imageFileInput");
|
|
2705
|
+
if (btnAttach && fileInput) {
|
|
2706
|
+
btnAttach.addEventListener("click", () => fileInput.click());
|
|
2707
|
+
fileInput.addEventListener("change", (e) => {
|
|
2708
|
+
handleImageFiles(e.target.files);
|
|
2709
|
+
fileInput.value = "";
|
|
2710
|
+
});
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
// Image paste support
|
|
2714
|
+
document.addEventListener("paste", (e) => {
|
|
2715
|
+
const items = e.clipboardData?.items;
|
|
2716
|
+
if (!items) return;
|
|
2717
|
+
const files = [];
|
|
2718
|
+
for (let i = 0; i < items.length; i++) {
|
|
2719
|
+
if (items[i].type.indexOf("image") !== -1) {
|
|
2720
|
+
const file = items[i].getAsFile();
|
|
2721
|
+
if (file) files.push(file);
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
if (files.length > 0) {
|
|
2725
|
+
handleImageFiles(files);
|
|
2726
|
+
const ta = $("#composer");
|
|
2727
|
+
if (ta) ta.focus();
|
|
2728
|
+
}
|
|
2729
|
+
});
|
|
2730
|
+
|
|
2731
|
+
// Global preventDefault to avoid browser opening dragged images in new tab
|
|
2732
|
+
window.addEventListener("dragover", (e) => e.preventDefault(), false);
|
|
2733
|
+
window.addEventListener("drop", (e) => e.preventDefault(), false);
|
|
2734
|
+
|
|
2735
|
+
// Drag and drop images to composer
|
|
2736
|
+
const composerBox = $("#composerInner") || $(".composer");
|
|
2737
|
+
if (composerBox) {
|
|
2738
|
+
composerBox.addEventListener("dragover", (e) => {
|
|
2739
|
+
e.preventDefault();
|
|
2740
|
+
e.stopPropagation();
|
|
2741
|
+
composerBox.classList.add("drag-over");
|
|
2742
|
+
});
|
|
2743
|
+
composerBox.addEventListener("dragleave", (e) => {
|
|
2744
|
+
e.preventDefault();
|
|
2745
|
+
e.stopPropagation();
|
|
2746
|
+
composerBox.classList.remove("drag-over");
|
|
2747
|
+
});
|
|
2748
|
+
composerBox.addEventListener("drop", (e) => {
|
|
2749
|
+
e.preventDefault();
|
|
2750
|
+
e.stopPropagation();
|
|
2751
|
+
composerBox.classList.remove("drag-over");
|
|
2752
|
+
if (e.dataTransfer?.files) {
|
|
2753
|
+
handleImageFiles(e.dataTransfer.files);
|
|
2754
|
+
}
|
|
2755
|
+
});
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
// Lightbox close listeners
|
|
2759
|
+
const lightboxEl = $("#imageLightbox");
|
|
2760
|
+
if (lightboxEl) {
|
|
2761
|
+
lightboxEl.addEventListener("click", (e) => {
|
|
2762
|
+
if (e.target === lightboxEl || e.target.id === "lightboxClose") {
|
|
2763
|
+
closeLightbox();
|
|
2764
|
+
}
|
|
2765
|
+
});
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2426
2768
|
document.addEventListener("click", (e) => {
|
|
2427
2769
|
if (!e.target.closest("#modelMenu") && !e.target.closest("#modelPill") && !e.target.closest("#emptyChangeModelBtn")) {
|
|
2428
2770
|
const m = $("#modelMenu");
|
|
@@ -2448,12 +2790,7 @@ async function init() {
|
|
|
2448
2790
|
|
|
2449
2791
|
// sidebar search (client side filter)
|
|
2450
2792
|
$("#sidebarSearch").addEventListener("input", (e) => {
|
|
2451
|
-
|
|
2452
|
-
document.querySelectorAll(".session-item").forEach((it) => {
|
|
2453
|
-
const titleEl = it.querySelector(".title");
|
|
2454
|
-
const text = titleEl ? titleEl.textContent.toLowerCase() : it.textContent.toLowerCase();
|
|
2455
|
-
it.style.display = text.includes(q) ? "" : "none";
|
|
2456
|
-
});
|
|
2793
|
+
filterSessions(e.target.value);
|
|
2457
2794
|
});
|
|
2458
2795
|
|
|
2459
2796
|
// Restore sidebar state for desktop, collapse by default on mobile
|
package/public/index.html
CHANGED
|
@@ -24,7 +24,11 @@
|
|
|
24
24
|
<div class="sidebar-bottom">
|
|
25
25
|
<div id="connStatus" class="conn-status" title="点击可重新连接"><span id="connDot" style="color: var(--danger);">●</span> <span id="connLabel">连接中…</span></div>
|
|
26
26
|
<div class="sidebar-bottom-meta">
|
|
27
|
-
<
|
|
27
|
+
<div class="sidebar-bottom-links">
|
|
28
|
+
<a href="https://pi.dev" target="_blank" rel="noopener">pi.dev</a>
|
|
29
|
+
<span class="sidebar-link-sep">·</span>
|
|
30
|
+
<a href="https://github.com/liguoshuai-1990/pi-web-chat" target="_blank" rel="noopener">pi-web-chat</a>
|
|
31
|
+
</div>
|
|
28
32
|
<span id="appVersion" class="app-version" title="pi-web-chat 版本"></span>
|
|
29
33
|
</div>
|
|
30
34
|
</div>
|
|
@@ -59,6 +63,14 @@
|
|
|
59
63
|
<span class="thinking-label" id="thinkingPillLabel">High</span>
|
|
60
64
|
</button>
|
|
61
65
|
</div>
|
|
66
|
+
<button class="btn-topbar-action" id="btnExportChat" title="导出当前对话为 Markdown">
|
|
67
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
68
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
|
69
|
+
<polyline points="7 10 12 15 17 10"></polyline>
|
|
70
|
+
<line x1="12" y1="15" x2="12" y2="3"></line>
|
|
71
|
+
</svg>
|
|
72
|
+
<span class="topbar-action-label">导出</span>
|
|
73
|
+
</button>
|
|
62
74
|
<div class="model-menu" id="modelMenu"></div>
|
|
63
75
|
<div class="thinking-menu" id="thinkingMenu"></div>
|
|
64
76
|
</div>
|
|
@@ -92,8 +104,15 @@
|
|
|
92
104
|
</div>
|
|
93
105
|
|
|
94
106
|
<div class="composer">
|
|
107
|
+
<div class="image-preview-bar" id="imagePreviewBar" style="display:none;"></div>
|
|
95
108
|
<div class="composer-inner" id="composerInner">
|
|
96
|
-
<
|
|
109
|
+
<input type="file" id="imageFileInput" accept="image/*,.png,.jpg,.jpeg,.webp,.gif,.bmp,.svg" multiple style="display:none;">
|
|
110
|
+
<button class="attach-btn" id="btnAttachImage" type="button" title="添加附件 / 图片 (支持拖拽与截图粘贴)">
|
|
111
|
+
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
112
|
+
<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
|
|
113
|
+
</svg>
|
|
114
|
+
</button>
|
|
115
|
+
<textarea id="composer" rows="1" placeholder="给 pi 发消息… (Enter 发送,Shift+Enter 换行,支持粘贴/拖拽图片)"></textarea>
|
|
97
116
|
<button class="steer-btn" id="steerBtn" title="在 AI 运行过程中插入指导指令" style="display:none;">
|
|
98
117
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polygon points="16.24 7.76 14.12 14.12 7.76 16.24 9.88 9.88 16.24 7.76"/></svg>
|
|
99
118
|
<span>插入指令</span>
|
|
@@ -135,6 +154,12 @@
|
|
|
135
154
|
</svg>
|
|
136
155
|
</button>
|
|
137
156
|
|
|
157
|
+
<!-- Image Lightbox Modal -->
|
|
158
|
+
<div class="image-lightbox" id="imageLightbox" style="display:none;">
|
|
159
|
+
<button class="lightbox-close" id="lightboxClose" title="关闭大图">×</button>
|
|
160
|
+
<img id="lightboxImg" src="" alt="大图预览">
|
|
161
|
+
</div>
|
|
162
|
+
|
|
138
163
|
<!-- Toast notification -->
|
|
139
164
|
<div class="toast" id="toast"></div>
|
|
140
165
|
<script src="/app.js"></script>
|
package/public/style.css
CHANGED
|
@@ -217,7 +217,80 @@ body {
|
|
|
217
217
|
.is-resizing .sidebar {
|
|
218
218
|
transition: none !important;
|
|
219
219
|
}
|
|
220
|
-
|
|
220
|
+
/* Message Images */
|
|
221
|
+
.msg-images {
|
|
222
|
+
display: flex;
|
|
223
|
+
flex-wrap: wrap;
|
|
224
|
+
gap: 8px;
|
|
225
|
+
margin-top: 8px;
|
|
226
|
+
}
|
|
227
|
+
.msg.user .msg-images {
|
|
228
|
+
justify-content: flex-end;
|
|
229
|
+
}
|
|
230
|
+
.msg-image-thumb {
|
|
231
|
+
width: 90px;
|
|
232
|
+
height: 90px;
|
|
233
|
+
border-radius: 8px;
|
|
234
|
+
object-fit: cover;
|
|
235
|
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
236
|
+
cursor: pointer;
|
|
237
|
+
transition: transform 0.15s ease, opacity 0.15s ease;
|
|
238
|
+
}
|
|
239
|
+
.msg-image-thumb:hover {
|
|
240
|
+
transform: scale(1.04);
|
|
241
|
+
opacity: 0.9;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/* Image Lightbox */
|
|
245
|
+
.image-lightbox {
|
|
246
|
+
position: fixed;
|
|
247
|
+
top: 0; left: 0; right: 0; bottom: 0;
|
|
248
|
+
background: rgba(0, 0, 0, 0.85);
|
|
249
|
+
backdrop-filter: blur(4px);
|
|
250
|
+
z-index: 1000;
|
|
251
|
+
display: flex;
|
|
252
|
+
align-items: center;
|
|
253
|
+
justify-content: center;
|
|
254
|
+
padding: 24px;
|
|
255
|
+
cursor: zoom-out;
|
|
256
|
+
animation: fade 0.15s ease;
|
|
257
|
+
}
|
|
258
|
+
.image-lightbox img {
|
|
259
|
+
max-width: 90vw;
|
|
260
|
+
max-height: 90vh;
|
|
261
|
+
object-fit: contain;
|
|
262
|
+
border-radius: 8px;
|
|
263
|
+
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
|
|
264
|
+
cursor: default;
|
|
265
|
+
}
|
|
266
|
+
.lightbox-close {
|
|
267
|
+
position: absolute;
|
|
268
|
+
top: 20px;
|
|
269
|
+
right: 24px;
|
|
270
|
+
width: 36px;
|
|
271
|
+
height: 36px;
|
|
272
|
+
border-radius: 50%;
|
|
273
|
+
background: rgba(255, 255, 255, 0.15);
|
|
274
|
+
border: none;
|
|
275
|
+
color: #fff;
|
|
276
|
+
font-size: 24px;
|
|
277
|
+
line-height: 1;
|
|
278
|
+
display: flex;
|
|
279
|
+
align-items: center;
|
|
280
|
+
justify-content: center;
|
|
281
|
+
cursor: pointer;
|
|
282
|
+
transition: background 0.15s;
|
|
283
|
+
}
|
|
284
|
+
.lightbox-close:hover {
|
|
285
|
+
background: rgba(255, 255, 255, 0.3);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
.sidebar-search-empty {
|
|
289
|
+
color: var(--text-dim);
|
|
290
|
+
font-size: 12.5px;
|
|
291
|
+
padding: 16px 12px;
|
|
292
|
+
text-align: center;
|
|
293
|
+
}
|
|
221
294
|
|
|
222
295
|
.sidebar-bottom {
|
|
223
296
|
flex-shrink: 0;
|
|
@@ -235,6 +308,20 @@ body {
|
|
|
235
308
|
display: flex;
|
|
236
309
|
align-items: center;
|
|
237
310
|
justify-content: space-between;
|
|
311
|
+
gap: 8px;
|
|
312
|
+
}
|
|
313
|
+
.sidebar-bottom-links {
|
|
314
|
+
display: inline-flex;
|
|
315
|
+
align-items: center;
|
|
316
|
+
gap: 5px;
|
|
317
|
+
min-width: 0;
|
|
318
|
+
overflow: hidden;
|
|
319
|
+
text-overflow: ellipsis;
|
|
320
|
+
white-space: nowrap;
|
|
321
|
+
}
|
|
322
|
+
.sidebar-link-sep {
|
|
323
|
+
color: var(--text-dim);
|
|
324
|
+
font-size: 11px;
|
|
238
325
|
}
|
|
239
326
|
.app-version {
|
|
240
327
|
font-size: 11px;
|
|
@@ -737,16 +824,28 @@ body {
|
|
|
737
824
|
text-overflow: ellipsis;
|
|
738
825
|
white-space: nowrap;
|
|
739
826
|
}
|
|
740
|
-
.
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
background:
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
827
|
+
.btn-topbar-action {
|
|
828
|
+
display: inline-flex;
|
|
829
|
+
align-items: center;
|
|
830
|
+
gap: 4px;
|
|
831
|
+
background: transparent;
|
|
832
|
+
border: 1px solid var(--border);
|
|
833
|
+
color: var(--text-muted);
|
|
834
|
+
border-radius: 8px;
|
|
835
|
+
padding: 5px 9px;
|
|
836
|
+
font-size: 12px;
|
|
837
|
+
cursor: pointer;
|
|
838
|
+
transition: all 0.15s ease;
|
|
748
839
|
flex-shrink: 0;
|
|
749
840
|
}
|
|
841
|
+
.btn-topbar-action:hover {
|
|
842
|
+
background: var(--bg-hover);
|
|
843
|
+
color: var(--text);
|
|
844
|
+
border-color: var(--text-dim);
|
|
845
|
+
}
|
|
846
|
+
.topbar-action-label {
|
|
847
|
+
font-weight: 500;
|
|
848
|
+
}
|
|
750
849
|
.empty-model-features {
|
|
751
850
|
display: flex;
|
|
752
851
|
align-items: center;
|
|
@@ -780,25 +879,98 @@ body {
|
|
|
780
879
|
}
|
|
781
880
|
.suggestions .chip:hover { border-color: var(--text-dim); color: var(--text); }
|
|
782
881
|
|
|
783
|
-
/* ---------- Composer ---------- */
|
|
882
|
+
/* ---------- Composer & Image Upload ---------- */
|
|
784
883
|
.composer {
|
|
785
|
-
padding:
|
|
884
|
+
padding: 12px 24px 20px;
|
|
786
885
|
background: var(--bg);
|
|
787
886
|
}
|
|
887
|
+
.image-preview-bar {
|
|
888
|
+
max-width: 820px;
|
|
889
|
+
margin: 0 auto 8px auto;
|
|
890
|
+
display: flex;
|
|
891
|
+
flex-wrap: wrap;
|
|
892
|
+
gap: 8px;
|
|
893
|
+
padding: 4px 0;
|
|
894
|
+
}
|
|
895
|
+
.image-preview-item {
|
|
896
|
+
position: relative;
|
|
897
|
+
width: 56px;
|
|
898
|
+
height: 56px;
|
|
899
|
+
border-radius: 8px;
|
|
900
|
+
overflow: hidden;
|
|
901
|
+
border: 1px solid var(--border);
|
|
902
|
+
background: #111;
|
|
903
|
+
flex-shrink: 0;
|
|
904
|
+
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
|
905
|
+
}
|
|
906
|
+
.image-preview-item img {
|
|
907
|
+
width: 100%;
|
|
908
|
+
height: 100%;
|
|
909
|
+
object-fit: cover;
|
|
910
|
+
display: block;
|
|
911
|
+
}
|
|
912
|
+
.image-preview-remove {
|
|
913
|
+
position: absolute;
|
|
914
|
+
top: 2px;
|
|
915
|
+
right: 2px;
|
|
916
|
+
width: 18px;
|
|
917
|
+
height: 18px;
|
|
918
|
+
border-radius: 50%;
|
|
919
|
+
background: rgba(0, 0, 0, 0.75);
|
|
920
|
+
color: #fff;
|
|
921
|
+
border: none;
|
|
922
|
+
cursor: pointer;
|
|
923
|
+
display: flex;
|
|
924
|
+
align-items: center;
|
|
925
|
+
justify-content: center;
|
|
926
|
+
font-size: 12px;
|
|
927
|
+
line-height: 1;
|
|
928
|
+
padding: 0;
|
|
929
|
+
transition: background 0.15s;
|
|
930
|
+
}
|
|
931
|
+
.image-preview-remove:hover {
|
|
932
|
+
background: var(--danger, #ef4444);
|
|
933
|
+
}
|
|
934
|
+
|
|
788
935
|
.composer-inner {
|
|
789
936
|
max-width: 820px; margin: 0 auto;
|
|
790
937
|
background: var(--bg-input);
|
|
791
938
|
border: 1px solid var(--border);
|
|
792
939
|
border-radius: 24px;
|
|
793
|
-
padding: 6px 8px 6px
|
|
940
|
+
padding: 6px 8px 6px 8px;
|
|
794
941
|
display: flex; align-items: flex-end; gap: 8px;
|
|
795
|
-
transition: border-color .15s;
|
|
942
|
+
transition: border-color .15s, box-shadow .15s;
|
|
943
|
+
}
|
|
944
|
+
.composer-inner.drag-over {
|
|
945
|
+
border-color: var(--accent);
|
|
946
|
+
box-shadow: 0 0 0 2px rgba(16, 163, 127, 0.3);
|
|
796
947
|
}
|
|
797
948
|
.composer-inner:focus-within { border-color: var(--text-dim); }
|
|
798
949
|
.composer-inner.aborting {
|
|
799
950
|
border-color: var(--danger);
|
|
800
951
|
box-shadow: 0 0 0 1px var(--danger);
|
|
801
952
|
}
|
|
953
|
+
.composer .attach-btn {
|
|
954
|
+
width: 32px;
|
|
955
|
+
height: 32px;
|
|
956
|
+
background: transparent;
|
|
957
|
+
border: none;
|
|
958
|
+
color: var(--text-muted);
|
|
959
|
+
cursor: pointer;
|
|
960
|
+
display: flex;
|
|
961
|
+
align-items: center;
|
|
962
|
+
justify-content: center;
|
|
963
|
+
border-radius: 50%;
|
|
964
|
+
transition: color 0.15s ease, background 0.15s ease, transform 0.1s ease;
|
|
965
|
+
flex-shrink: 0;
|
|
966
|
+
}
|
|
967
|
+
.composer .attach-btn:hover {
|
|
968
|
+
color: var(--text);
|
|
969
|
+
background: var(--bg-hover);
|
|
970
|
+
}
|
|
971
|
+
.composer .attach-btn:active {
|
|
972
|
+
transform: scale(0.92);
|
|
973
|
+
}
|
|
802
974
|
.composer textarea {
|
|
803
975
|
flex: 1; background: transparent; border: none; outline: none; resize: none;
|
|
804
976
|
color: var(--text); font-family: inherit; font-size: 15px; line-height: 20px;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { existsSync } from "fs";
|
|
5
|
+
|
|
6
|
+
function resolvePiBin() {
|
|
7
|
+
if (process.env.PI_BIN && existsSync(process.env.PI_BIN)) return process.env.PI_BIN;
|
|
8
|
+
const home = os.homedir();
|
|
9
|
+
const candidates = [
|
|
10
|
+
path.join(home, ".npm-global/bin/pi"),
|
|
11
|
+
"/usr/local/bin/pi",
|
|
12
|
+
"/usr/bin/pi",
|
|
13
|
+
];
|
|
14
|
+
for (const c of candidates) if (existsSync(c)) return c;
|
|
15
|
+
return "pi";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const PI_BIN = resolvePiBin();
|
|
19
|
+
const SESSIONS_DIR = process.env.PI_SESSIONS_DIR || path.join(os.homedir(), ".pi/agent/sessions");
|
|
20
|
+
|
|
21
|
+
console.log("Spawning pi...");
|
|
22
|
+
const proc = spawn(PI_BIN, ["--mode", "rpc", "--session-dir", SESSIONS_DIR], {
|
|
23
|
+
cwd: os.homedir(),
|
|
24
|
+
env: { ...process.env, PI_SKIP_VERSION_CHECK: "1" }
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
proc.stdout.on("data", (d) => {
|
|
28
|
+
console.log("STDOUT:", d.toString().trim());
|
|
29
|
+
});
|
|
30
|
+
proc.stderr.on("data", (d) => {
|
|
31
|
+
console.error("STDERR:", d.toString().trim());
|
|
32
|
+
});
|
|
33
|
+
proc.on("exit", (code, sig) => console.log("EXIT:", code, "sig:", sig));
|
|
34
|
+
|
|
35
|
+
// Send set_session_name FIRST, then prompt
|
|
36
|
+
setTimeout(() => {
|
|
37
|
+
console.log("Sending set_session_name...");
|
|
38
|
+
proc.stdin.write(JSON.stringify({ id: "1", type: "set_session_name", name: "My New Web Session Name" }) + "\n");
|
|
39
|
+
}, 500);
|
|
40
|
+
|
|
41
|
+
setTimeout(() => {
|
|
42
|
+
console.log("Sending prompt...");
|
|
43
|
+
proc.stdin.write(JSON.stringify({ id: "2", type: "prompt", message: "Say exactly: OK" }) + "\n");
|
|
44
|
+
}, 1000);
|
|
45
|
+
|
|
46
|
+
setTimeout(() => {
|
|
47
|
+
console.log("Killing...");
|
|
48
|
+
proc.kill("SIGTERM");
|
|
49
|
+
}, 12000);
|
package/server.js
CHANGED
|
@@ -492,6 +492,10 @@ class PiAgent {
|
|
|
492
492
|
this.pending.clear();
|
|
493
493
|
for (const s of this.sockets) { try { s.close(); } catch {} }
|
|
494
494
|
this.sockets.clear();
|
|
495
|
+
this.buffer = "";
|
|
496
|
+
this.eventBuffer = [];
|
|
497
|
+
this.bufferHead = 0;
|
|
498
|
+
this.lastUserPrompt = null;
|
|
495
499
|
try { this.proc && this.proc.kill("SIGTERM"); } catch {}
|
|
496
500
|
}
|
|
497
501
|
}
|
|
@@ -574,9 +578,21 @@ app.post("/api/set-default-model", async (req, res) => {
|
|
|
574
578
|
}
|
|
575
579
|
|
|
576
580
|
const isProject = scope === "project" && reqCwd;
|
|
577
|
-
|
|
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(() => {
|