@liguoshuai/pi-web-chat 1.10.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -19
- package/docs/CHANGELOG.md +33 -0
- package/package.json +12 -8
- package/public/app.js +235 -41
- package/public/index.html +3 -3
- package/public/style.css +61 -3
- package/scripts/install-service.sh +0 -0
- package/scripts/uninstall-service.sh +0 -0
- package/server.js +16 -1130
package/README.md
CHANGED
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
# pi-web-chat
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@liguoshuai/pi-web-chat)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://nodejs.org/)
|
|
6
|
+
|
|
3
7
|
一个 [pi](https://pi.dev) 编程代理的 Web 界面,风格参考 ChatGPT / Gemini ——
|
|
4
8
|
左侧历史会话侧边栏 + 右侧对话区 + 底部输入框。底层通过 pi 的 **RPC 模式**
|
|
5
9
|
(`pi --mode rpc`) 与 pi 子进程通信,前端走 WebSocket 流式渲染。
|
|
6
10
|
|
|
7
|
-
>
|
|
11
|
+
> 📦 **NPM 软件包**:[`@liguoshuai/pi-web-chat`](https://www.npmjs.com/package/@liguoshuai/pi-web-chat)
|
|
12
|
+
> 🔗 **项目主页**:https://github.com/liguoshuai-1990/pi-web-chat
|
|
8
13
|
|
|
9
14
|
---
|
|
10
15
|
|
|
@@ -35,7 +40,7 @@
|
|
|
35
40
|
|
|
36
41
|
---
|
|
37
42
|
|
|
38
|
-
### 2.
|
|
43
|
+
### 2. 安装底层 pi 编程代理 (pi agent)
|
|
39
44
|
|
|
40
45
|
`pi-web-chat` 通过 RPC 模式 (`pi --mode rpc`) 与底层 `pi` 命令行 Agent 子进程通信。若系统中尚未安装 `pi`,请选择以下任一方式进行全局安装:
|
|
41
46
|
|
|
@@ -92,39 +97,79 @@ pi
|
|
|
92
97
|
|
|
93
98
|
---
|
|
94
99
|
|
|
95
|
-
### 4.
|
|
100
|
+
### 4. 安装与启动 pi-web-chat
|
|
96
101
|
|
|
97
|
-
####
|
|
102
|
+
#### 方式 A:通过 npm / pnpm 全局安装(推荐,最快捷)
|
|
98
103
|
|
|
99
104
|
```bash
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
105
|
+
# 使用 npm 全局安装
|
|
106
|
+
npm install -g @liguoshuai/pi-web-chat
|
|
107
|
+
|
|
108
|
+
# 或使用 pnpm 全局安装
|
|
109
|
+
pnpm add -g @liguoshuai/pi-web-chat
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
安装完成后,在终端任意目录下直接运行 `pi-web-chat` 命令启动:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
# 启动 Web 服务(默认监听 3000 端口,工作目录为当前目录)
|
|
116
|
+
pi-web-chat
|
|
117
|
+
|
|
118
|
+
# 自定义端口和指定工作目录 (cwd)
|
|
119
|
+
pi-web-chat --port 8080 --cwd /path/to/your/project
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
#### 方式 B:使用 npx 免安装即时运行
|
|
123
|
+
|
|
124
|
+
无需全局安装,直接通过 npx 快速启动:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npx @liguoshuai/pi-web-chat
|
|
128
|
+
# 或指定参数
|
|
129
|
+
npx @liguoshuai/pi-web-chat --port 8080 --cwd /path/to/your/project
|
|
103
130
|
```
|
|
104
131
|
|
|
105
|
-
####
|
|
132
|
+
#### 方式 C:通过 Git 源码克隆与启动(适合二次开发)
|
|
106
133
|
|
|
107
134
|
```bash
|
|
135
|
+
git clone https://github.com/liguoshuai-1990/pi-web-chat.git
|
|
136
|
+
cd pi-web-chat
|
|
137
|
+
npm install
|
|
108
138
|
npm start
|
|
109
139
|
```
|
|
110
140
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
### 5. 打开浏览器体验
|
|
144
|
+
|
|
145
|
+
Web 服务启动后,在浏览器中访问:
|
|
146
|
+
👉 **http://localhost:3000** (或自定义的端口)
|
|
114
147
|
|
|
115
148
|
---
|
|
116
149
|
|
|
117
|
-
###
|
|
150
|
+
### 6. CLI 命令行参数与选项
|
|
118
151
|
|
|
119
|
-
|
|
152
|
+
`pi-web-chat` CLI 提供了便捷的命令行参数与环境变量支持:
|
|
120
153
|
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
|
|
154
|
+
```text
|
|
155
|
+
用法:
|
|
156
|
+
pi-web-chat [选项]
|
|
157
|
+
|
|
158
|
+
选项:
|
|
159
|
+
-p, --port <number> 指定 Web 服务监听端口(默认: 3000,或读取 PORT 环境变量)
|
|
160
|
+
-c, --cwd <path> 指定 pi 会话初始工作目录(默认: 当前终端路径 / 用户主目录)
|
|
161
|
+
-h, --help 显示帮助信息
|
|
162
|
+
|
|
163
|
+
环境变量:
|
|
164
|
+
PORT 服务端口(同 --port)
|
|
165
|
+
PI_BIN pi 可执行文件绝对路径(未设置时自动探测)
|
|
166
|
+
PI_SESSIONS_DIR pi 会话数据存储目录(默认: ~/.pi/agent/sessions)
|
|
124
167
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
pi-web-chat --port 8080
|
|
168
|
+
运行示例:
|
|
169
|
+
pi-web-chat # 默认端口 3000 启动
|
|
170
|
+
pi-web-chat --port 8080 # 指定 8080 端口启动
|
|
171
|
+
pi-web-chat --port 8080 --cwd ~/projects/my-app # 指定端口与初始工作区
|
|
172
|
+
PORT=4000 pi-web-chat # 通过环境变量指定端口
|
|
128
173
|
```
|
|
129
174
|
|
|
130
175
|
---
|
package/docs/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [2.0.0] - 2026-08-30
|
|
11
|
+
|
|
12
|
+
### Major Architecture Refactoring (Monorepo & Multi-Client Ecosystem)
|
|
13
|
+
- **Monorepo 多端大仓库架构升级**:
|
|
14
|
+
- 项目重构为 `pi-chat` Monorepo,采用 pnpm Workspaces 进行多包统一管理。
|
|
15
|
+
- 核心架构拆分为 `clients/web`、`clients/android`、`clients/harmony`、`server`(VPS 网关)与 `packages/protocol`(跨端共享协议)。
|
|
16
|
+
- **服务端网关统一解耦与单真源设计 (`@pi-chat/server`)**:
|
|
17
|
+
- 提取 `@pi-chat/server` 为统一后端网关核心,支持全双工 WebSocket、SSE 打字机增量推送(`/api/stream`)、多端统一 Token 鉴权(`AUTH_TOKEN`)、30s 心跳保活与空闲进程内存自动回收。
|
|
18
|
+
- `clients/web/server.js` 瘦身为极简适配层,保持对 `@liguoshuai/pi-web-chat` npm 发行版及 `pi-web-chat` 全局 CLI 的 100% 独立向后兼容。
|
|
19
|
+
- **Android 原生端应用骨架 (`clients/android`)**:
|
|
20
|
+
- 基于 Kotlin + Jetpack Compose + Material 3 + OkHttp WebSocket + Coroutines/StateFlow 搭建,支持生成 APK 安装包。
|
|
21
|
+
- **华为鸿蒙 OS 原生端应用骨架 (`clients/harmony`)**:
|
|
22
|
+
- 基于 ArkTS + 声明式 ArkUI (Stage 模型,API 12+) + `@ohos.net.webSocket` 搭建,支持生成 HAP 安装包。
|
|
23
|
+
- **跨端标准通信协议 (`@pi-chat/protocol`)**:
|
|
24
|
+
- 统一 JSON Schema 标准、TypeScript 定义与自动容错校验器,三端交互格式完全对齐。
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
### Added & Published
|
|
29
|
+
- **NPM 官方包发布与全局命令行安装支持 (Official NPM Package & Global CLI)**:
|
|
30
|
+
- 正式发布软件包至 npm 官方注册表([`@liguoshuai/pi-web-chat`](https://www.npmjs.com/package/@liguoshuai/pi-web-chat)),支持通过 `npm i -g @liguoshuai/pi-web-chat` 或 `pnpm add -g @liguoshuai/pi-web-chat` 全局安装并随时随地使用 `pi-web-chat` 命令启动。
|
|
31
|
+
- 支持通过 `npx @liguoshuai/pi-web-chat` 免安装即时启动,并在 README 中完善了全局安装、npx 启动与源码安装三种模式的快速指引。
|
|
32
|
+
|
|
33
|
+
### Fixed & Improved
|
|
34
|
+
- **移动端附件导入功能全面重构与修复 (Mobile Attachment Import Fix)**:
|
|
35
|
+
- **移动端文件选择器唤起修复**:将上传触发器重构为标准可访问的 `<label for="imageFileInput">` 并配合 CSS `.sr-only-file-input`,彻底解决 iOS Safari、Android Chrome、移动端 WebView 及微信内置浏览器中因 `display: none` 导致异步 JS `.click()` 被浏览器安全策略拦截而无法调起系统相册/文件选择器的问题。
|
|
36
|
+
- **移动端格式与相机照片兼容性**:扩展对 iOS HEIC/HEIF 相机原图、AVIF、ICO、SVG 及大写扩展名的识别与支持。
|
|
37
|
+
- **移动端高清原图智能压缩降采样 (Client-side Downscale)**:针对手机拍摄的超高分辨率照片(12MP~48MP),自动使用 Canvas 进行等比下采样(最大 2048px)与高质量编码压缩,防止超大 Base64 数据挤爆移动端内存或造成 WebSocket 通信中断,同时确保所有主流大模型 Vision API 稳定解析。
|
|
38
|
+
- **文本与源码文件附件导入支持 (Text & Code Attachment Parsing)**:支持通过附件按钮、拖拽或剪贴板直接导入 `.py`、`.js`、`.json`、`.md`、`.txt`、`.log` 等各类代码和文本附件,自动提取并按对应语言 Markdown 代码块注入输入框。
|
|
39
|
+
- **预览交互与触控体验优化 (Touch UX & Preview Lightbox)**:输入框待发送图片缩略图支持点击直接弹出 Lightbox 大图预览,加大移动端单张删除按钮的触控热区(22px)与操作反馈。
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
10
43
|
## [1.10.2] - 2026-08-30
|
|
11
44
|
|
|
12
45
|
### Fixed & Improved
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@liguoshuai/pi-web-chat",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
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",
|
|
@@ -16,12 +16,6 @@
|
|
|
16
16
|
"README.md",
|
|
17
17
|
"LICENSE"
|
|
18
18
|
],
|
|
19
|
-
"scripts": {
|
|
20
|
-
"start": "node server.js",
|
|
21
|
-
"dev": "node --watch server.js",
|
|
22
|
-
"test": "node --test",
|
|
23
|
-
"prepare": "node -e \"import('fs').then(fs => { try { fs.chmodSync('bin/pi-web-chat.js', 0o755); } catch {} })\""
|
|
24
|
-
},
|
|
25
19
|
"keywords": [
|
|
26
20
|
"pi",
|
|
27
21
|
"coding-agent",
|
|
@@ -39,11 +33,21 @@
|
|
|
39
33
|
},
|
|
40
34
|
"homepage": "https://github.com/liguoshuai-1990/pi-web-chat#readme",
|
|
41
35
|
"license": "MIT",
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
42
39
|
"engines": {
|
|
43
40
|
"node": ">=18"
|
|
44
41
|
},
|
|
45
42
|
"dependencies": {
|
|
43
|
+
"@liguoshuai/pi-chat-protocol": "2.0.0",
|
|
44
|
+
"@liguoshuai/pi-chat-server": "2.0.0",
|
|
46
45
|
"express": "^4.21.2",
|
|
47
46
|
"ws": "^8.18.0"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"start": "node server.js",
|
|
50
|
+
"dev": "node --watch server.js",
|
|
51
|
+
"test": "node --test"
|
|
48
52
|
}
|
|
49
|
-
}
|
|
53
|
+
}
|
package/public/app.js
CHANGED
|
@@ -20,6 +20,37 @@ window.onerror = function (message, source, lineno, colno, error) {
|
|
|
20
20
|
};
|
|
21
21
|
|
|
22
22
|
const API = ""; // same origin
|
|
23
|
+
|
|
24
|
+
function getAuthToken() {
|
|
25
|
+
try {
|
|
26
|
+
const urlToken = new URLSearchParams(window.location.search).get("token");
|
|
27
|
+
if (urlToken) {
|
|
28
|
+
localStorage.setItem("pi_auth_token", urlToken);
|
|
29
|
+
return urlToken;
|
|
30
|
+
}
|
|
31
|
+
return localStorage.getItem("pi_auth_token") || "";
|
|
32
|
+
} catch {
|
|
33
|
+
return "";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function authFetch(url, options = {}) {
|
|
38
|
+
const token = getAuthToken();
|
|
39
|
+
const headers = { ...(options.headers || {}) };
|
|
40
|
+
if (token) {
|
|
41
|
+
headers["Authorization"] = `Bearer ${token}`;
|
|
42
|
+
}
|
|
43
|
+
return fetch(url, { ...options, headers }).then(async (res) => {
|
|
44
|
+
if (res.status === 401) {
|
|
45
|
+
const entered = prompt("Pi Gateway 开启了访问鉴权,请输入访问 Token:");
|
|
46
|
+
if (entered) {
|
|
47
|
+
localStorage.setItem("pi_auth_token", entered.trim());
|
|
48
|
+
window.location.reload();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return res;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
23
54
|
const state = {
|
|
24
55
|
ws: null,
|
|
25
56
|
wsConnected: false,
|
|
@@ -120,7 +151,7 @@ async function loadServerConfig() {
|
|
|
120
151
|
}
|
|
121
152
|
try {
|
|
122
153
|
const cwdParam = state.cwd ? `?cwd=${encodeURIComponent(state.cwd)}` : "";
|
|
123
|
-
const res = await
|
|
154
|
+
const res = await authFetch(`${API}/api/config${cwdParam}`);
|
|
124
155
|
const data = await res.json();
|
|
125
156
|
if (data.home) state.homeDir = data.home;
|
|
126
157
|
if (data.serverCwd) state.serverCwd = data.serverCwd;
|
|
@@ -199,7 +230,7 @@ async function confirmCwdChange() {
|
|
|
199
230
|
if (!rawPath) return;
|
|
200
231
|
|
|
201
232
|
try {
|
|
202
|
-
const res = await
|
|
233
|
+
const res = await authFetch(`${API}/api/validate-dir?path=${encodeURIComponent(rawPath)}`);
|
|
203
234
|
const data = await res.json();
|
|
204
235
|
if (data.ok && data.path) {
|
|
205
236
|
let recents = [];
|
|
@@ -489,7 +520,7 @@ const el = (tag, props = {}, children = []) => {
|
|
|
489
520
|
// ---- Sidebar / sessions ----
|
|
490
521
|
async function refreshSessions() {
|
|
491
522
|
const cwd = state.cwd || "";
|
|
492
|
-
const res = await
|
|
523
|
+
const res = await authFetch(`${API}/api/sessions?cwd=${encodeURIComponent(cwd)}`);
|
|
493
524
|
const data = await res.json();
|
|
494
525
|
renderSidebar(data.sessions || []);
|
|
495
526
|
}
|
|
@@ -579,7 +610,7 @@ async function deleteSession(file, title) {
|
|
|
579
610
|
return;
|
|
580
611
|
}
|
|
581
612
|
try {
|
|
582
|
-
const res = await
|
|
613
|
+
const res = await authFetch(`${API}/api/session?file=${encodeURIComponent(file)}`, {
|
|
583
614
|
method: "DELETE",
|
|
584
615
|
});
|
|
585
616
|
const data = await res.json();
|
|
@@ -633,7 +664,7 @@ function initMobileToolbarFab() {
|
|
|
633
664
|
async function syncSessionHistory(file, force = false) {
|
|
634
665
|
if (!file) return;
|
|
635
666
|
try {
|
|
636
|
-
const res = await
|
|
667
|
+
const res = await authFetch(`${API}/api/session?file=${encodeURIComponent(file)}`);
|
|
637
668
|
if (!res.ok) return;
|
|
638
669
|
const data = await res.json();
|
|
639
670
|
if (!data || data.error) return;
|
|
@@ -812,7 +843,11 @@ function renderImagePreviews() {
|
|
|
812
843
|
}
|
|
813
844
|
bar.style.display = "flex";
|
|
814
845
|
state.attachedImages.forEach((img, idx) => {
|
|
815
|
-
const item = el("div", {
|
|
846
|
+
const item = el("div", {
|
|
847
|
+
class: "image-preview-item",
|
|
848
|
+
title: "点击查看大图",
|
|
849
|
+
onclick: () => openLightbox(img.url)
|
|
850
|
+
}, [
|
|
816
851
|
el("img", { src: img.url, alt: "预览" }),
|
|
817
852
|
el("button", {
|
|
818
853
|
class: "image-preview-remove",
|
|
@@ -840,38 +875,173 @@ function detectImageMimeType(file) {
|
|
|
840
875
|
bmp: "image/bmp",
|
|
841
876
|
svg: "image/svg+xml",
|
|
842
877
|
ico: "image/x-icon",
|
|
843
|
-
avif: "image/avif"
|
|
878
|
+
avif: "image/avif",
|
|
879
|
+
heic: "image/heic",
|
|
880
|
+
heif: "image/heif"
|
|
844
881
|
};
|
|
845
882
|
return map[ext] || "image/png";
|
|
846
883
|
}
|
|
847
884
|
|
|
848
|
-
function
|
|
849
|
-
|
|
850
|
-
const
|
|
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 => {
|
|
885
|
+
async function processImageFile(file) {
|
|
886
|
+
const mimeType = detectImageMimeType(file);
|
|
887
|
+
const dataUrl = await new Promise((resolve, reject) => {
|
|
857
888
|
const reader = new FileReader();
|
|
858
|
-
reader.onload = () =>
|
|
859
|
-
|
|
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
|
-
};
|
|
889
|
+
reader.onload = () => resolve(reader.result);
|
|
890
|
+
reader.onerror = reject;
|
|
871
891
|
reader.readAsDataURL(file);
|
|
872
892
|
});
|
|
893
|
+
|
|
894
|
+
if (typeof dataUrl !== "string") return null;
|
|
895
|
+
|
|
896
|
+
// For SVG or GIF (which might be animated), or small images, keep original
|
|
897
|
+
if (mimeType.includes("svg") || mimeType.includes("gif") || (file.size && file.size < 800 * 1024)) {
|
|
898
|
+
const commaIdx = dataUrl.indexOf(",");
|
|
899
|
+
const base64 = commaIdx !== -1 ? dataUrl.slice(commaIdx + 1) : dataUrl;
|
|
900
|
+
return { data: base64, mimeType, url: dataUrl };
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Optimize large phone camera photos / oversized images via canvas downscale
|
|
904
|
+
try {
|
|
905
|
+
const img = await new Promise((resolve, reject) => {
|
|
906
|
+
const i = new Image();
|
|
907
|
+
i.onload = () => resolve(i);
|
|
908
|
+
i.onerror = reject;
|
|
909
|
+
i.src = dataUrl;
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
const maxDim = 2048;
|
|
913
|
+
let { width, height } = img;
|
|
914
|
+
if (width > maxDim || height > maxDim) {
|
|
915
|
+
if (width > height) {
|
|
916
|
+
height = Math.round((height * maxDim) / width);
|
|
917
|
+
width = maxDim;
|
|
918
|
+
} else {
|
|
919
|
+
width = Math.round((width * maxDim) / height);
|
|
920
|
+
height = maxDim;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const canvas = document.createElement("canvas");
|
|
925
|
+
canvas.width = width;
|
|
926
|
+
canvas.height = height;
|
|
927
|
+
const ctx = canvas.getContext("2d");
|
|
928
|
+
if (!ctx) throw new Error("Canvas 2D unavailable");
|
|
929
|
+
ctx.drawImage(img, 0, 0, width, height);
|
|
930
|
+
|
|
931
|
+
const targetMime = (mimeType === "image/png" && file.size < 2 * 1024 * 1024) ? "image/png" : "image/jpeg";
|
|
932
|
+
const quality = 0.88;
|
|
933
|
+
const optimizedUrl = canvas.toDataURL(targetMime, quality);
|
|
934
|
+
const commaIdx = optimizedUrl.indexOf(",");
|
|
935
|
+
const base64 = commaIdx !== -1 ? optimizedUrl.slice(commaIdx + 1) : optimizedUrl;
|
|
936
|
+
|
|
937
|
+
return {
|
|
938
|
+
data: base64,
|
|
939
|
+
mimeType: targetMime,
|
|
940
|
+
url: optimizedUrl
|
|
941
|
+
};
|
|
942
|
+
} catch {
|
|
943
|
+
// Fallback to original base64 if canvas processing is unsupported
|
|
944
|
+
const commaIdx = dataUrl.indexOf(",");
|
|
945
|
+
const base64 = commaIdx !== -1 ? dataUrl.slice(commaIdx + 1) : dataUrl;
|
|
946
|
+
return { data: base64, mimeType, url: dataUrl };
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function getLanguageFromFilename(filename) {
|
|
951
|
+
if (!filename) return "";
|
|
952
|
+
const ext = filename.split(".").pop().toLowerCase();
|
|
953
|
+
const langMap = {
|
|
954
|
+
js: "javascript", mjs: "javascript", cjs: "javascript",
|
|
955
|
+
ts: "typescript", tsx: "tsx", jsx: "jsx",
|
|
956
|
+
py: "python", pyw: "python",
|
|
957
|
+
rb: "ruby", rs: "rust", go: "go", java: "java",
|
|
958
|
+
c: "c", cpp: "cpp", cc: "cpp", cxx: "cpp", h: "c", hpp: "cpp",
|
|
959
|
+
sh: "bash", bash: "bash", zsh: "bash",
|
|
960
|
+
json: "json", yaml: "yaml", yml: "yaml", toml: "toml",
|
|
961
|
+
md: "markdown", markdown: "markdown",
|
|
962
|
+
html: "html", htm: "html", css: "css", scss: "scss", less: "less",
|
|
963
|
+
sql: "sql", xml: "xml", svg: "xml",
|
|
964
|
+
log: "log", env: "ini", ini: "ini", conf: "ini",
|
|
965
|
+
diff: "diff", patch: "diff", dockerfile: "dockerfile", makefile: "makefile"
|
|
966
|
+
};
|
|
967
|
+
return langMap[ext] || "";
|
|
873
968
|
}
|
|
874
969
|
|
|
970
|
+
function isTextFile(file) {
|
|
971
|
+
if (file.type && (file.type.startsWith("text/") || file.type.includes("json") || file.type.includes("xml") || file.type.includes("javascript") || file.type.includes("yaml"))) {
|
|
972
|
+
return true;
|
|
973
|
+
}
|
|
974
|
+
const name = file.name ? file.name.toLowerCase() : "";
|
|
975
|
+
return /\.(txt|md|markdown|json|js|mjs|cjs|ts|tsx|jsx|py|pyw|rb|php|java|c|cpp|cc|cxx|h|hpp|rs|go|sh|bash|zsh|sql|html|htm|css|scss|sass|less|vue|svelte|yaml|yml|toml|ini|env|xml|log|csv|tsv|diff|patch|dockerfile|makefile)$/i.test(name);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
async function handleIncomingFiles(files) {
|
|
979
|
+
if (!files || files.length === 0) return;
|
|
980
|
+
const list = Array.from(files);
|
|
981
|
+
|
|
982
|
+
let imageCount = 0;
|
|
983
|
+
let textCount = 0;
|
|
984
|
+
|
|
985
|
+
for (const file of list) {
|
|
986
|
+
const isImg = (file.type && file.type.startsWith("image/")) ||
|
|
987
|
+
(file.name && /\.(png|jpe?g|webp|gif|bmp|svg|ico|avif|heic|heif)$/i.test(file.name));
|
|
988
|
+
|
|
989
|
+
if (isImg) {
|
|
990
|
+
try {
|
|
991
|
+
const imgObj = await processImageFile(file);
|
|
992
|
+
if (imgObj) {
|
|
993
|
+
state.attachedImages.push(imgObj);
|
|
994
|
+
renderImagePreviews();
|
|
995
|
+
imageCount++;
|
|
996
|
+
}
|
|
997
|
+
} catch (err) {
|
|
998
|
+
console.error("Failed to process image file:", err);
|
|
999
|
+
}
|
|
1000
|
+
} else if (isTextFile(file)) {
|
|
1001
|
+
if (file.size > 1024 * 1024) {
|
|
1002
|
+
showToast(`文件 ${file.name} 较大,建议放入工作目录供 pi 访问`);
|
|
1003
|
+
continue;
|
|
1004
|
+
}
|
|
1005
|
+
try {
|
|
1006
|
+
const content = await new Promise((resolve, reject) => {
|
|
1007
|
+
const reader = new FileReader();
|
|
1008
|
+
reader.onload = () => resolve(reader.result);
|
|
1009
|
+
reader.onerror = reject;
|
|
1010
|
+
reader.readAsText(file);
|
|
1011
|
+
});
|
|
1012
|
+
if (typeof content === "string") {
|
|
1013
|
+
const lang = getLanguageFromFilename(file.name);
|
|
1014
|
+
const composer = $("#composer");
|
|
1015
|
+
if (composer) {
|
|
1016
|
+
const block = `[附件: ${file.name}]\n\`\`\`${lang}\n${content}\n\`\`\`\n`;
|
|
1017
|
+
if (composer.value.trim()) {
|
|
1018
|
+
composer.value = composer.value.trimEnd() + "\n\n" + block;
|
|
1019
|
+
} else {
|
|
1020
|
+
composer.value = block;
|
|
1021
|
+
}
|
|
1022
|
+
autoResize();
|
|
1023
|
+
composer.focus();
|
|
1024
|
+
textCount++;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
} catch (err) {
|
|
1028
|
+
console.error("Failed to read text file:", err);
|
|
1029
|
+
}
|
|
1030
|
+
} else {
|
|
1031
|
+
showToast(`暂不支持直接解析该附件格式 (${file.name || "未知类型"})`);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (imageCount > 0) {
|
|
1036
|
+
showToast(`已添加 ${imageCount} 张图片附件`);
|
|
1037
|
+
}
|
|
1038
|
+
if (textCount > 0) {
|
|
1039
|
+
showToast(`已导入 ${textCount} 个文本文件`);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
const handleImageFiles = handleIncomingFiles;
|
|
1044
|
+
|
|
875
1045
|
function exportCurrentSession() {
|
|
876
1046
|
const chatInner = $("#chat-inner");
|
|
877
1047
|
if (!chatInner || chatInner.children.length === 0) {
|
|
@@ -1428,7 +1598,9 @@ function connectWs(opts = {}) {
|
|
|
1428
1598
|
state.activeToolCalls.clear();
|
|
1429
1599
|
}
|
|
1430
1600
|
|
|
1431
|
-
const
|
|
1601
|
+
const token = getAuthToken();
|
|
1602
|
+
const tokParam = token ? `&token=${encodeURIComponent(token)}` : "";
|
|
1603
|
+
const url = `${proto}://${location.host}/ws?cwd=${cwd}${sess}${tokParam}`;
|
|
1432
1604
|
const ws = new WebSocket(url);
|
|
1433
1605
|
state.ws = ws;
|
|
1434
1606
|
ws._gen = myGen;
|
|
@@ -1478,6 +1650,15 @@ function connectWs(opts = {}) {
|
|
|
1478
1650
|
let obj;
|
|
1479
1651
|
try { obj = JSON.parse(ev.data); } catch { return; }
|
|
1480
1652
|
if (obj.type === "pong") return;
|
|
1653
|
+
|
|
1654
|
+
if (obj.type === "error" && (obj.code === "unauthorized" || (obj.message && obj.message.toLowerCase().includes("unauthorized")))) {
|
|
1655
|
+
const entered = prompt("Pi Gateway 开启了访问鉴权,请输入访问 Token:");
|
|
1656
|
+
if (entered) {
|
|
1657
|
+
localStorage.setItem("pi_auth_token", entered.trim());
|
|
1658
|
+
initWebSocket({ explicitNewSession: false, isReconnect: true });
|
|
1659
|
+
}
|
|
1660
|
+
return;
|
|
1661
|
+
}
|
|
1481
1662
|
handlePiMessage(obj);
|
|
1482
1663
|
};
|
|
1483
1664
|
}
|
|
@@ -2063,7 +2244,7 @@ function updateEmptyStateModelInfo() {
|
|
|
2063
2244
|
|
|
2064
2245
|
async function setDefaultModel(m) {
|
|
2065
2246
|
try {
|
|
2066
|
-
const res = await
|
|
2247
|
+
const res = await authFetch(`${API}/api/set-default-model`, {
|
|
2067
2248
|
method: "POST",
|
|
2068
2249
|
headers: { "Content-Type": "application/json" },
|
|
2069
2250
|
body: JSON.stringify({
|
|
@@ -2376,7 +2557,7 @@ function updateComposerUI() {
|
|
|
2376
2557
|
if (sendBtn) {
|
|
2377
2558
|
sendBtn.classList.add("stop");
|
|
2378
2559
|
sendBtn.disabled = !state.wsConnected;
|
|
2379
|
-
sendBtn.textContent = "
|
|
2560
|
+
sendBtn.textContent = "";
|
|
2380
2561
|
sendBtn.title = "中止当前生成";
|
|
2381
2562
|
}
|
|
2382
2563
|
if (steerBtn) {
|
|
@@ -2714,30 +2895,43 @@ async function init() {
|
|
|
2714
2895
|
exportBtn.addEventListener("click", exportCurrentSession);
|
|
2715
2896
|
}
|
|
2716
2897
|
|
|
2717
|
-
// Image attach /
|
|
2898
|
+
// Image and file attach / picker / paste / drag-and-drop
|
|
2718
2899
|
const btnAttach = $("#btnAttachImage");
|
|
2719
2900
|
const fileInput = $("#imageFileInput");
|
|
2720
|
-
if (
|
|
2721
|
-
btnAttach.addEventListener("click", () => fileInput.click());
|
|
2901
|
+
if (fileInput) {
|
|
2722
2902
|
fileInput.addEventListener("change", (e) => {
|
|
2723
|
-
|
|
2903
|
+
handleIncomingFiles(e.target.files);
|
|
2724
2904
|
fileInput.value = "";
|
|
2725
2905
|
});
|
|
2726
2906
|
}
|
|
2727
2907
|
|
|
2728
|
-
|
|
2908
|
+
if (btnAttach) {
|
|
2909
|
+
// If not a label (e.g. fallback button), click input
|
|
2910
|
+
if (btnAttach.tagName !== "LABEL") {
|
|
2911
|
+
btnAttach.addEventListener("click", () => fileInput?.click());
|
|
2912
|
+
}
|
|
2913
|
+
// Keyboard accessibility for Enter / Space
|
|
2914
|
+
btnAttach.addEventListener("keydown", (e) => {
|
|
2915
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
2916
|
+
e.preventDefault();
|
|
2917
|
+
fileInput?.click();
|
|
2918
|
+
}
|
|
2919
|
+
});
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
// Image and file paste support
|
|
2729
2923
|
document.addEventListener("paste", (e) => {
|
|
2730
2924
|
const items = e.clipboardData?.items;
|
|
2731
2925
|
if (!items) return;
|
|
2732
2926
|
const files = [];
|
|
2733
2927
|
for (let i = 0; i < items.length; i++) {
|
|
2734
|
-
if (items[i].
|
|
2928
|
+
if (items[i].kind === "file") {
|
|
2735
2929
|
const file = items[i].getAsFile();
|
|
2736
2930
|
if (file) files.push(file);
|
|
2737
2931
|
}
|
|
2738
2932
|
}
|
|
2739
2933
|
if (files.length > 0) {
|
|
2740
|
-
|
|
2934
|
+
handleIncomingFiles(files);
|
|
2741
2935
|
const ta = $("#composer");
|
|
2742
2936
|
if (ta) ta.focus();
|
|
2743
2937
|
}
|
|
@@ -2747,7 +2941,7 @@ async function init() {
|
|
|
2747
2941
|
window.addEventListener("dragover", (e) => e.preventDefault(), false);
|
|
2748
2942
|
window.addEventListener("drop", (e) => e.preventDefault(), false);
|
|
2749
2943
|
|
|
2750
|
-
// Drag and drop images to composer
|
|
2944
|
+
// Drag and drop images and files to composer
|
|
2751
2945
|
const composerBox = $("#composerInner") || $(".composer");
|
|
2752
2946
|
if (composerBox) {
|
|
2753
2947
|
composerBox.addEventListener("dragover", (e) => {
|
|
@@ -2765,7 +2959,7 @@ async function init() {
|
|
|
2765
2959
|
e.stopPropagation();
|
|
2766
2960
|
composerBox.classList.remove("drag-over");
|
|
2767
2961
|
if (e.dataTransfer?.files) {
|
|
2768
|
-
|
|
2962
|
+
handleIncomingFiles(e.dataTransfer.files);
|
|
2769
2963
|
}
|
|
2770
2964
|
});
|
|
2771
2965
|
}
|
package/public/index.html
CHANGED
|
@@ -106,12 +106,12 @@
|
|
|
106
106
|
<div class="composer">
|
|
107
107
|
<div class="image-preview-bar" id="imagePreviewBar" style="display:none;"></div>
|
|
108
108
|
<div class="composer-inner" id="composerInner">
|
|
109
|
-
<input type="file" id="imageFileInput" accept="image/*,.png,.jpg,.jpeg,.webp,.gif,.bmp,.svg" multiple
|
|
110
|
-
<
|
|
109
|
+
<input type="file" id="imageFileInput" class="sr-only-file-input" accept="image/*,.png,.jpg,.jpeg,.webp,.gif,.bmp,.svg,.heic,.heif,.ico,.avif,text/*,.txt,.md,.json,.js,.ts,.py,.sh,.html,.css,.yaml,.yml,.toml,.xml,.sql,.log,.csv" multiple>
|
|
110
|
+
<label class="attach-btn" id="btnAttachImage" for="imageFileInput" role="button" tabindex="0" title="添加附件 / 图片 (支持拖拽与截图粘贴)">
|
|
111
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
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
113
|
</svg>
|
|
114
|
-
</
|
|
114
|
+
</label>
|
|
115
115
|
<textarea id="composer" rows="1" placeholder="给 pi 发消息…"></textarea>
|
|
116
116
|
<button class="steer-btn" id="steerBtn" title="在 AI 运行过程中插入指导指令" style="display:none;">
|
|
117
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>
|