@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 CHANGED
@@ -1,10 +1,15 @@
1
1
  # pi-web-chat
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@liguoshuai/pi-web-chat.svg)](https://www.npmjs.com/package/@liguoshuai/pi-web-chat)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
5
+ [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-brightgreen.svg)](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
- > 项目主页:https://github.com/liguoshuai-1990/pi-web-chat
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. 安装 pi 编程代理 (pi agent)
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. 克隆与启动 pi-web-chat
100
+ ### 4. 安装与启动 pi-web-chat
96
101
 
97
- #### 克隆仓库与安装依赖
102
+ #### 方式 A:通过 npm / pnpm 全局安装(推荐,最快捷)
98
103
 
99
104
  ```bash
100
- git clone https://github.com/liguoshuai-1990/pi-web-chat.git
101
- cd pi-web-chat
102
- npm install
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
- #### 启动 Web 服务
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
- 👉 **http://localhost:3000**
141
+ ---
142
+
143
+ ### 5. 打开浏览器体验
144
+
145
+ Web 服务启动后,在浏览器中访问:
146
+ 👉 **http://localhost:3000** (或自定义的端口)
114
147
 
115
148
  ---
116
149
 
117
- ### 5. 命令行启动选项与全局 CLI(可选)
150
+ ### 6. CLI 命令行参数与选项
118
151
 
119
- 你也可以通过 `bin/pi-web-chat.js` 指定工作目录或监听端口:
152
+ `pi-web-chat` CLI 提供了便捷的命令行参数与环境变量支持:
120
153
 
121
- ```bash
122
- # 指定端口和工作目录 (cwd)
123
- node bin/pi-web-chat.js --port 8080 --cwd /path/to/your/project
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
- # 或通过 npm 全局安装后在任意目录下启动
126
- npm install -g .
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": "1.10.2",
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 fetch(`${API}/api/config${cwdParam}`);
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 fetch(`${API}/api/validate-dir?path=${encodeURIComponent(rawPath)}`);
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 fetch(`${API}/api/sessions?cwd=${encodeURIComponent(cwd)}`);
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 fetch(`${API}/api/session?file=${encodeURIComponent(file)}`, {
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 fetch(`${API}/api/session?file=${encodeURIComponent(file)}`);
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", { class: "image-preview-item" }, [
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 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 => {
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
- 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
- };
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 url = `${proto}://${location.host}/ws?cwd=${cwd}${sess}`;
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 fetch(`${API}/api/set-default-model`, {
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 / file picker / paste / drag-and-drop
2898
+ // Image and file attach / picker / paste / drag-and-drop
2718
2899
  const btnAttach = $("#btnAttachImage");
2719
2900
  const fileInput = $("#imageFileInput");
2720
- if (btnAttach && fileInput) {
2721
- btnAttach.addEventListener("click", () => fileInput.click());
2901
+ if (fileInput) {
2722
2902
  fileInput.addEventListener("change", (e) => {
2723
- handleImageFiles(e.target.files);
2903
+ handleIncomingFiles(e.target.files);
2724
2904
  fileInput.value = "";
2725
2905
  });
2726
2906
  }
2727
2907
 
2728
- // Image paste support
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].type.indexOf("image") !== -1) {
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
- handleImageFiles(files);
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
- handleImageFiles(e.dataTransfer.files);
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 style="display:none;">
110
- <button class="attach-btn" id="btnAttachImage" type="button" title="添加附件 / 图片 (支持拖拽与截图粘贴)">
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
- </button>
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>