@oversk7/pi-web-dsh-style 1.0.0 → 1.1.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  采用 DeepSeek Harness(DSH)视觉风格、面向 Windows 的非官方 Pi Coding Agent 本地浏览器界面扩展。后端使用 Pi 的公开 RPC 模式,每个会话对应独立的 RPC 进程;历史会话可直接从 Pi 会话文件恢复和渲染。
4
4
 
5
- > 当前正式版本为 1.0.0,仅承诺 Windows 10/11。服务只监听 `127.0.0.1`,不应通过端口转发或反向代理暴露到局域网或公网。
5
+ > 仅承诺 Windows 10/11。首次启动只监听 `127.0.0.1`;电脑网页的“设置 → 手机访问”可配置仅本机、局域网和服务器中转。之后 `pi --web` 使用已保存的模式。
6
6
 
7
7
  ## 安装
8
8
 
@@ -34,9 +34,54 @@ pi --web
34
34
  /web
35
35
  /web --no-open
36
36
  /web --port 18789
37
+ /web --lan
38
+ /web --relay
39
+ /web --local
37
40
  /web --stop
38
41
  ```
39
42
 
43
+ ### 网页会话中的斜杠命令
44
+
45
+ 输入 `/` 可查看当前可用命令。Web 内置命令包括 `/settings`、`/model`、`/thinking`、`/export`、`/copy`、`/name`、`/session`、`/new`、`/resume`、`/rewind`、`/tree`、`/compact` 和 `/reload`。
46
+
47
+ - `/model provider/model` 精确匹配时切换模型;未唯一匹配时打开带搜索词的模型选择器。`/thinking high` 切换当前模型支持的思考强度,两者不带参数时打开选择器。
48
+ - `/compact 压缩要求` 会将要求传给 Pi;`/name 名称` 修改会话名称,不带参数时显示当前名称。
49
+ - `/resume` 打开侧栏会话搜索;`/export` 下载 HTML 到浏览器下载目录,暂不接受导出路径或 JSONL 格式参数。
50
+ - 网页会话中的 `/reload` 只重载当前会话的扩展与配置。要重载整个 Web 服务,请使用设置中的“重新加载”或在宿主 Pi 终端执行 `/reload`。
51
+
52
+ 扩展命令、提示词模板和 `/skill:名称` 由当前会话的 Pi RPC 进程执行。未知命令会报错并保留草稿,不会作为普通消息发送给模型;普通消息需要避免以 `/` 开头。
53
+
54
+ `/scoped-models`、`/import`、`/share`、`/fork`、`/clone`、`/trust`、`/changelog`、`/hotkeys`、`/login`、`/logout` 和 `/quit` 尚未提供对应的 Web 操作,输入时会显示说明。需要这些 Pi 功能时请使用终端。
55
+
56
+ ### 手机通过局域网访问
57
+
58
+ 电脑和手机连接同一 Wi-Fi,在电脑本机网页“设置 → 手机访问”中选择“局域网”并保存。也可以执行 `/web --lan`,或直接以 `pi --web-lan` 启动。首次使用时,在同一设置中设置固定密码;之后手机打开显示的地址,输入该密码即可登录。界面只显示一个手机访问地址,优先使用 Wi-Fi,并过滤 WSL、Hyper-V 等虚拟网卡。
59
+
60
+ 手机登录状态保留 30 天。密码在服务重启后继续有效,可在电脑本机网页中修改;修改密码后,已登录的手机需要重新登录。
61
+
62
+ 手机与电脑共用工作区和会话,可各自切换、发送消息、停止生成及回答扩展提问。手机上点击左上角按钮展开会话列表;输入框回车换行,点击发送按钮提交;图片按钮选择手机图片。手机切回前台或网络重连时会刷新会话内容。
63
+
64
+ 设置面板及 `/web --lan`、`/web --relay`、`/web --local` 会保存模式并切换正在运行的服务,保留后台会话进程。切换时浏览器实时连接会重新连接。执行 `/reload` 会保留端口和访问模式;重载或重启服务会保留密码和未过期的登录状态。
65
+
66
+ 首次开启时,如 Windows 弹出防火墙提示,请允许 Node.js 在当前可信网络上接收连接。若手机仍无法打开,检查防火墙是否允许该端口,以及 Wi-Fi 是否启用了客户端隔离。电脑需要保持运行且未休眠。
67
+
68
+ 局域网直连使用 HTTP,请只在可信局域网内使用。
69
+
70
+ ### 通过服务器中转
71
+
72
+ 电脑运行扩展和原生 `frpc`,服务器运行 `frps`、作为 STCP 访问端的 `frpc` 以及 Nginx。手机通过服务器的 HTTPS 域名访问,适用于校园网客户端隔离等无法直连的网络。电脑上的 `frpc` 由扩展启动和关闭,中转请求直接进入扩展的 Web 服务。
73
+
74
+ 1. 从 [frp 官方 Releases](https://github.com/fatedier/frp/releases) 下载电脑对应架构的 Windows 版本,将 `frpc.exe` 解压到固定位置。客户端和服务器建议使用相同版本(已验证 0.70.1)。
75
+ 2. 在电脑网页“设置 → 手机访问”选择“服务器中转”,填写服务器域名或 IPv4、frps 端口(默认 `7000`)、frps 认证密钥、手机 HTTPS 入口(如 `https://web.example.com`),以及 `frpc.exe` 路径。路径留空时从 PATH 查找 `frpc`;相对路径以状态目录为基准。认证密钥留空会保留已保存的值。
76
+ 3. 保存后,下载设置中生成的 `frps.toml`、`visitor.toml` 和 `nginx.conf`。隧道名称和私有密钥自动生成;隧道名称及服务器内部端口可在高级配置中调整。将域名 DNS 指向服务器,并为该域名准备有效的 HTTPS 证书。
77
+ 4. 服务器安装原生 frp 和 Nginx。新服务器使用 `frps -c frps.toml`,访问端使用 `frpc -c visitor.toml`,并用 systemd 等服务管理器保持运行。已有 frps 时复用现有端口和认证密钥,保留其配置。把生成的 Nginx 配置中的证书路径替换成实际证书路径,再安装到 Nginx 的站点配置目录,执行 `nginx -t` 验证并重载。
78
+
79
+ 手机打开显示的 HTTPS 地址,输入原有固定密码即可使用。设置会显示 frpc 的连接或错误状态;服务器暂时不可达时 frpc 自动重连,本机网页仍可使用。后续执行 `pi --web` 会自动恢复保存的中转模式;也可以用 `pi --web-relay` 或 `/web --relay` 明确启用已配置的中转。
80
+
81
+ 服务器需开放 HTTPS 端口和 frps 控制端口;生成的 STCP 访问端仅监听服务器 `127.0.0.1`,无须开放其内部端口。Nginx 配置保留域名 Host、附加代理密钥、限制登录请求速率,并关闭缓冲以支持实时消息。公网访问由扩展直接验证来源和固定密码,登录 Cookie 带有 `Secure` 标记。中转模式下,电脑 Web 服务仅监听本机回环地址。
82
+
83
+ 网络配置和服务器配置下载仅对电脑本机网页开放。更换服务器域名、隧道名称、认证密钥或服务器内部端口后,需要同步部署重新下载的服务器配置。电脑上的 Pi Web 需要保持运行且未休眠。
84
+
40
85
  ## 功能
41
86
 
42
87
  - 多工作区、多会话和后台会话并行运行。
@@ -45,14 +90,14 @@ pi --web
45
90
  - 模型、思考等级、中断、压缩和扩展交互对话框。
46
91
  - 运行中逐条显示待处理消息,可单条撤回并重新编辑;`Alt+Q` 可将全部队列消息恢复到输入框。
47
92
  - 会话历史保留完整分支与检查点,支持回溯后返回切换前的位置,以及 `pi-rewind` 文件恢复。
48
- - Windows 系统剪贴板图片和文本粘贴。
93
+ - Windows 系统剪贴板图片和文本粘贴;手机支持选择或粘贴本机图片。
49
94
  - 会话中的本地文件链接可在右侧分栏预览,支持语法高亮、行定位和拖拽调整宽度。
50
95
  - 设置面板内更新扩展并热重载 Pi 运行时,无需退出 Web UI。
51
96
  - 会话 HTML 导出。
52
97
 
53
98
  ## 进程生命周期
54
99
 
55
- `/web --stop`、宿主 Pi 退出或运行时重载会关闭 Web 会话进程及其后代,包括 detached 后台进程;即使宿主被强制结束,Windows 托管进程也会检测到宿主退出并清理整棵进程树。不会扫描或结束工作区外独立启动的进程,也不会结束其他 Pi 实例。
100
+ `/web --stop`、宿主 Pi 退出或运行时重载会关闭扩展托管的 frpc 和 Web 会话进程及其后代,包括 detached 后台进程;即使宿主被强制结束,Windows 托管进程也会检测到宿主退出并清理整棵进程树。不会扫描或结束工作区外独立启动的进程,也不会结束其他 Pi 实例。
56
101
 
57
102
  单个 RPC 进程退出、异常终止或被空闲进程回收时,它启动的后台进程也会一起结束。需要独立于会话长期运行的服务,应从 Web 会话之外的终端启动。通过系统服务管理器、计划任务等外部机制启动的进程不属于此进程树。
58
103
 
@@ -66,6 +111,10 @@ pi --web
66
111
  ~/.pi/agent/pi-web/state.json
67
112
  ```
68
113
 
114
+ 访问密码的加盐哈希和登录签名密钥保存在同一目录的 `auth.json` 中,不保存明文密码。
115
+
116
+ 访问模式及中转配置保存在同一目录的 `network.json` 中。中转所需的 frps 认证密钥、STCP 私有密钥和代理密钥保存在此文件;普通设置响应不返回这些密钥,服务器配置下载会包含部署所需的密钥。配置文件不属于发布包。运行时的 frpc 配置写入状态目录下的 `runtime/`,正常停止时清理。
117
+
69
118
  从旧版目录安装首次升级时,如果新位置尚无状态文件,扩展会从扩展目录中的旧 `state.json` 原样复制并继续使用。旧文件不会被删除或改写,可作为迁移备份。
70
119
 
71
120
  可用环境变量:
@@ -3,6 +3,10 @@
3
3
  This package contains adapted web UI styles and bundled font assets from the
4
4
  projects below. Their licenses are reproduced here as required.
5
5
 
6
+ The browser Markdown renderer also bundles markdown-it and highlight.js with
7
+ their dependencies. Their license texts are included in
8
+ `web/assets/markdown.LICENSE.txt`, next to the bundled renderer.
9
+
6
10
  ## DeepSeek Harness
7
11
 
8
12
  Source: https://github.com/deepseek-ai/deepseek-harness
@@ -0,0 +1,210 @@
1
+ import { extname, posix } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { unzipSync } from "fflate";
4
+ import { XMLParser, XMLValidator } from "fast-xml-parser";
5
+
6
+ const MAX_TEXT = 200_000;
7
+ const MAX_XML_BYTES = 32 * 1024 * 1024;
8
+ const OFFICE_MIME: Record<string, string> = {
9
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
10
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
11
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
12
+ };
13
+
14
+ type XmlNode = { [key: string]: XmlNode[] | Record<string, string> | string };
15
+ const parser = new XMLParser({ preserveOrder: true, ignoreAttributes: false, attributeNamePrefix: "", parseTagValue: false, parseAttributeValue: false, trimValues: false });
16
+
17
+ function tag(node: XmlNode): string {
18
+ return Object.keys(node).find((key) => key !== ":@") ?? "";
19
+ }
20
+ function children(node: XmlNode): XmlNode[] {
21
+ const value = node[tag(node)];
22
+ return Array.isArray(value) ? value : [];
23
+ }
24
+ function attr(node: XmlNode, name: string): string {
25
+ return (node[":@"] as Record<string, string> | undefined)?.[name] ?? "";
26
+ }
27
+ function find(nodes: XmlNode[], name: string): XmlNode[] {
28
+ const found: XmlNode[] = [];
29
+ for (const node of nodes) {
30
+ if (tag(node).split(":").at(-1) === name) found.push(node);
31
+ else found.push(...find(children(node), name));
32
+ }
33
+ return found;
34
+ }
35
+ function value(nodes: XmlNode[]): string {
36
+ return nodes.map((node) => tag(node) === "#text" ? String(node["#text"]) : value(children(node))).join("");
37
+ }
38
+ function runs(nodes: XmlNode[]): string {
39
+ return nodes.map((node) => {
40
+ const name = tag(node).split(":").at(-1);
41
+ if (name === "t") return value(children(node));
42
+ if (name === "tab") return "\t";
43
+ if (name === "br" || name === "cr") return "\n";
44
+ return runs(children(node));
45
+ }).join("");
46
+ }
47
+ function checked(text: string): string {
48
+ if (text.length > MAX_TEXT) throw new Error("解析文本超过 200000 字符,请拆分文件后上传");
49
+ return text;
50
+ }
51
+ function decode(bytes: Uint8Array): string {
52
+ const encoding = bytes[0] === 0xff && bytes[1] === 0xfe ? "utf-16le"
53
+ : bytes[0] === 0xfe && bytes[1] === 0xff ? "utf-16be" : "utf-8";
54
+ try {
55
+ return new TextDecoder(encoding, { fatal: true }).decode(bytes);
56
+ } catch {
57
+ throw new Error("文件编码无效,仅支持 UTF-8 或带 BOM 的 UTF-16 文本");
58
+ }
59
+ }
60
+
61
+ function officeFiles(bytes: Uint8Array): Record<string, Uint8Array> {
62
+ let total = 0;
63
+ try {
64
+ const files = unzipSync(bytes, { filter(entry) {
65
+ if (!/\.(xml|rels)$/i.test(entry.name)) return false;
66
+ total += entry.originalSize;
67
+ if (total > MAX_XML_BYTES) throw new Error("Office XML 解压总量超过 32MB");
68
+ return true;
69
+ } });
70
+ if (Object.values(files).reduce((sum, file) => sum + file.length, 0) > MAX_XML_BYTES) throw new Error("Office XML 解压总量超过 32MB");
71
+ return files;
72
+ } catch (error) {
73
+ throw new Error(`无法解析 Office 压缩包:${error instanceof Error ? error.message : String(error)}`);
74
+ }
75
+ }
76
+ function xml(files: Record<string, Uint8Array>, path: string): XmlNode[] {
77
+ if (!files[path]) throw new Error(`Office 文件缺少 ${path}`);
78
+ const text = decode(files[path]);
79
+ if (/<!\s*(DOCTYPE|ENTITY)\b/i.test(text)) throw new Error("不支持含 DTD 或实体声明的 Office XML");
80
+ if (XMLValidator.validate(text) !== true) throw new Error(`Office XML 格式无效:${path}`);
81
+ return parser.parse(text) as XmlNode[];
82
+ }
83
+ function relationships(files: Record<string, Uint8Array>, source: string): Map<string, { path: string; type: string }> {
84
+ const relPath = posix.join(posix.dirname(source), "_rels", `${posix.basename(source)}.rels`);
85
+ const result = new Map<string, { path: string; type: string }>();
86
+ if (!files[relPath]) return result;
87
+ for (const rel of find(xml(files, relPath), "Relationship")) {
88
+ if (attr(rel, "TargetMode") === "External") continue;
89
+ const target = attr(rel, "Target");
90
+ const path = posix.normalize(target.startsWith("/") ? target.slice(1) : posix.join(posix.dirname(source), target));
91
+ if (path.startsWith("../") || path.includes("\\")) throw new Error("Office 关系路径无效");
92
+ result.set(attr(rel, "Id"), { path, type: attr(rel, "Type") });
93
+ }
94
+ return result;
95
+ }
96
+ function related(rels: ReturnType<typeof relationships>, id: string): string {
97
+ const rel = rels.get(id);
98
+ if (!rel) throw new Error(`Office 文件缺少内部关系:${id}`);
99
+ return rel.path;
100
+ }
101
+ function word(nodes: XmlNode[]): string {
102
+ return nodes.map((node) => {
103
+ const name = tag(node).split(":").at(-1);
104
+ if (name === "p") return `${runs(children(node))}\n`;
105
+ if (name === "tr") return `${find(children(node), "tc").map((cell) => word(children(cell)).trimEnd()).join("\t")}\n`;
106
+ return word(children(node));
107
+ }).join("");
108
+ }
109
+ function excel(files: Record<string, Uint8Array>): string {
110
+ const source = "xl/workbook.xml";
111
+ const rels = relationships(files, source);
112
+ const sharedPath = [...rels.values()].find((rel) => rel.type.endsWith("/sharedStrings"))?.path;
113
+ const shared = sharedPath ? find(xml(files, sharedPath), "si").map((node) => runs(children(node))) : [];
114
+ let text = "";
115
+ for (const sheet of find(xml(files, source), "sheet")) {
116
+ text = checked(`${text}工作表:${attr(sheet, "name")}\n`);
117
+ const root = xml(files, related(rels, attr(sheet, "r:id")));
118
+ for (const cell of find(root, "c")) {
119
+ const address = attr(cell, "r");
120
+ if (!/^[A-Z]+[1-9]\d*$/.test(address)) throw new Error("工作表单元格缺少有效坐标");
121
+ const type = attr(cell, "t");
122
+ let content = value(find(children(cell), "v"));
123
+ if (type === "s") {
124
+ if (!/^\d+$/.test(content) || shared[Number(content)] === undefined) throw new Error("工作表共享字符串索引无效");
125
+ content = shared[Number(content)];
126
+ } else if (type === "inlineStr") content = runs(children(cell));
127
+ else if (type === "b" && content) content = content === "1" ? "TRUE" : "FALSE";
128
+ text = checked(`${text}${address}: ${content}\n`);
129
+ }
130
+ text = checked(`${text}\n`);
131
+ }
132
+ return text.trimEnd();
133
+ }
134
+ function powerpoint(files: Record<string, Uint8Array>): string {
135
+ const source = "ppt/presentation.xml";
136
+ const rels = relationships(files, source);
137
+ let text = "";
138
+ let index = 0;
139
+ for (const slide of find(xml(files, source), "sldId")) {
140
+ const path = related(rels, attr(slide, "r:id"));
141
+ const content = find(xml(files, path), "p").map((node) => runs(children(node))).join("\n");
142
+ text = checked(`${text}幻灯片 ${++index}\n${content}\n`);
143
+ for (const rel of relationships(files, path).values()) {
144
+ if (rel.type.endsWith("/notesSlide")) {
145
+ const notes = find(xml(files, rel.path), "p").map((node) => runs(children(node))).join("\n");
146
+ text = checked(`${text}备注:\n${notes}\n`);
147
+ }
148
+ }
149
+ text = checked(`${text}\n`);
150
+ }
151
+ return text.trimEnd();
152
+ }
153
+ async function pdf(bytes: Uint8Array): Promise<string> {
154
+ const { getDocument } = await import("pdfjs-dist/legacy/build/pdf.mjs");
155
+ const packageUrl = import.meta.resolve("pdfjs-dist/package.json");
156
+ const task = getDocument({
157
+ data: Uint8Array.from(bytes),
158
+ useSystemFonts: true,
159
+ cMapUrl: fileURLToPath(new URL("./cmaps/", packageUrl)).replace(/\\/g, "/"),
160
+ cMapPacked: true,
161
+ standardFontDataUrl: fileURLToPath(new URL("./standard_fonts/", packageUrl)).replace(/\\/g, "/"),
162
+ });
163
+ try {
164
+ const document = await task.promise;
165
+ let text = "";
166
+ for (let index = 1; index <= document.numPages; index++) {
167
+ const page = await document.getPage(index);
168
+ try {
169
+ const content = await page.getTextContent();
170
+ for (const item of content.items) {
171
+ if ("str" in item) text = checked(text + item.str + (item.hasEOL ? "\n" : " "));
172
+ }
173
+ text = checked(`${text}\n`);
174
+ } finally {
175
+ page.cleanup();
176
+ }
177
+ }
178
+ if (!text.trim()) throw new Error("PDF 无可提取文本,可能是扫描件;当前不支持 OCR");
179
+ return text.trim();
180
+ } catch (error) {
181
+ if (error instanceof Error && error.name === "PasswordException") throw new Error("PDF 受密码保护,无法提取文本");
182
+ throw error;
183
+ } finally {
184
+ await task.destroy();
185
+ }
186
+ }
187
+
188
+ export async function parseDocument(bytes: Uint8Array, filename: string): Promise<{ text: string; mimeType: string }> {
189
+ const extension = extname(filename).toLowerCase();
190
+ if (/^\.(doc|xls|ppt|zip|rar|7z|gz|tar|mp3|mp4|wav|ogg|flac|aac|m4a|m4v|mov|avi|webm|mkv|wma|wmv|exe|dll|png|jpg|jpeg|gif|webp)$/.test(extension)) {
191
+ throw new Error("不支持此文件类型,请上传文本、PDF、DOCX、XLSX 或 PPTX");
192
+ }
193
+ if (Buffer.from(bytes.subarray(0, 8)).equals(Buffer.from([0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1]))) {
194
+ throw new Error("不支持旧版二进制 Office 或加密 Office 文件");
195
+ }
196
+ if (extension === ".pdf" || Buffer.from(bytes.subarray(0, 5)).toString("ascii") === "%PDF-") {
197
+ return { text: checked(await pdf(bytes)), mimeType: "application/pdf" };
198
+ }
199
+ if (OFFICE_MIME[extension]) {
200
+ const files = officeFiles(bytes);
201
+ const text = extension === ".docx" ? word(xml(files, "word/document.xml")).trimEnd()
202
+ : extension === ".xlsx" ? excel(files) : powerpoint(files);
203
+ return { text: checked(text), mimeType: OFFICE_MIME[extension] };
204
+ }
205
+ if (bytes[0] === 0x50 && bytes[1] === 0x4b) throw new Error("不支持 ZIP 压缩文件,请上传 DOCX、XLSX 或 PPTX");
206
+ const text = decode(bytes);
207
+ if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/.test(text)) throw new Error("文件包含二进制控制符,无法作为文本解析");
208
+ const mimeType = ({ ".json": "application/json", ".csv": "text/csv", ".md": "text/markdown", ".markdown": "text/markdown" } as Record<string, string>)[extension] ?? "text/plain";
209
+ return { text: checked(text), mimeType };
210
+ }
@@ -2,17 +2,22 @@ import { randomUUID } from "node:crypto";
2
2
  import { readFile, unlink, writeFile } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { parseDocument } from "./attachment-parser.ts";
5
6
 
6
- export interface ImageAttachmentMeta {
7
+ export interface AttachmentMeta {
7
8
  id: string;
9
+ kind: "image" | "document";
10
+ name: string;
11
+ textLength?: number;
8
12
  mimeType: string;
9
13
  size: number;
10
14
  createdAt: number;
11
15
  expiresAt: number;
12
16
  }
13
17
 
14
- interface StoredImageAttachment extends ImageAttachmentMeta {
18
+ interface StoredAttachment extends AttachmentMeta {
15
19
  path: string;
20
+ text?: string;
16
21
  timer: ReturnType<typeof setTimeout>;
17
22
  }
18
23
 
@@ -43,53 +48,62 @@ function fallbackImageMimeType(bytes: Uint8Array): string | null {
43
48
  return null;
44
49
  }
45
50
 
46
- async function detectImageMimeType(path: string, bytes: Uint8Array): Promise<string | null> {
51
+ export async function detectImageMimeType(path: string, bytes: Uint8Array): Promise<string | null> {
47
52
  const detector = await resolvePublicMimeDetector();
48
53
  return detector ? detector(path) : fallbackImageMimeType(bytes);
49
54
  }
50
55
 
51
- export interface ImageAttachmentStoreOptions {
56
+ export interface AttachmentStoreOptions {
52
57
  directory?: string;
53
58
  ttlMs?: number;
54
59
  maxBytes?: number;
55
60
  maxPromptImages?: number;
61
+ maxPromptAttachments?: number;
56
62
  }
57
63
 
58
- export class ImageAttachmentStore {
64
+ export class AttachmentStore {
59
65
  readonly maxPromptImages: number;
66
+ readonly maxPromptAttachments: number;
60
67
  private readonly directory: string;
61
68
  private readonly ttlMs: number;
62
69
  private readonly maxBytes: number;
63
- private readonly entries = new Map<string, StoredImageAttachment>();
70
+ private readonly entries = new Map<string, StoredAttachment>();
64
71
 
65
- constructor(options: ImageAttachmentStoreOptions = {}) {
72
+ constructor(options: AttachmentStoreOptions = {}) {
66
73
  this.directory = options.directory ?? tmpdir();
67
74
  this.ttlMs = options.ttlMs ?? 24 * 60 * 60 * 1000;
68
75
  this.maxBytes = options.maxBytes ?? 15 * 1024 * 1024;
69
76
  this.maxPromptImages = options.maxPromptImages ?? 4;
77
+ this.maxPromptAttachments = options.maxPromptAttachments ?? 8;
70
78
  }
71
79
 
72
- async create(bytes: Uint8Array): Promise<ImageAttachmentMeta> {
73
- if (bytes.byteLength === 0) throw new Error("图片内容为空");
80
+ async create(bytes: Uint8Array, filename?: string): Promise<AttachmentMeta> {
81
+ if (bytes.byteLength === 0) throw new Error("文件内容为空");
74
82
  if (bytes.byteLength > this.maxBytes) {
75
- throw new Error(`图片不能超过 ${Math.floor(this.maxBytes / 1024 / 1024)} MB`);
83
+ throw new Error(`文件不能超过 ${Math.floor(this.maxBytes / 1024 / 1024)} MB`);
76
84
  }
77
85
 
78
86
  const id = randomUUID();
79
- const path = join(this.directory, `pi-web-attachment-${id}.img`);
87
+ const path = join(this.directory, `pi-web-attachment-${id}.upload`);
88
+ const name = filename?.split(/[\\/]/).pop()?.replace(/[\x00-\x1f\x7f]/g, "").slice(0, 255) || "";
80
89
  await writeFile(path, bytes);
81
90
  try {
82
- const mimeType = await detectImageMimeType(path, bytes);
83
- if (!mimeType) throw new Error("不支持的图片格式");
91
+ const imageMimeType = await detectImageMimeType(path, bytes);
92
+ if (!imageMimeType && !name) throw new Error("不支持的图片格式");
93
+ const document = imageMimeType ? undefined : await parseDocument(bytes, name);
94
+ const mimeType = imageMimeType || document!.mimeType;
84
95
  const createdAt = Date.now();
85
96
  const expiresAt = createdAt + this.ttlMs;
86
97
  const timer = setTimeout(() => {
87
98
  void this.remove(id);
88
99
  }, this.ttlMs);
89
100
  timer.unref();
90
- const stored: StoredImageAttachment = {
101
+ const stored: StoredAttachment = {
91
102
  id,
92
103
  path,
104
+ kind: imageMimeType ? "image" : "document",
105
+ name: name || `图片.${mimeType.split("/")[1]}`,
106
+ ...(document ? { text: document.text, textLength: document.text.length } : {}),
93
107
  mimeType,
94
108
  size: bytes.byteLength,
95
109
  createdAt,
@@ -104,7 +118,7 @@ export class ImageAttachmentStore {
104
118
  }
105
119
  }
106
120
 
107
- get(id: string): ImageAttachmentMeta | undefined {
121
+ get(id: string): AttachmentMeta | undefined {
108
122
  const stored = this.entries.get(id);
109
123
  if (!stored) return undefined;
110
124
  if (stored.expiresAt <= Date.now()) {
@@ -114,26 +128,52 @@ export class ImageAttachmentStore {
114
128
  return this.meta(stored);
115
129
  }
116
130
 
117
- async read(id: string): Promise<{ meta: ImageAttachmentMeta; bytes: Buffer }> {
131
+ async read(id: string): Promise<{ meta: AttachmentMeta; bytes: Buffer }> {
118
132
  const stored = this.entries.get(id);
119
133
  if (!stored || stored.expiresAt <= Date.now()) {
120
134
  if (stored) await this.remove(id);
121
- throw new Error("图片附件不存在或已过期");
135
+ throw new Error("附件不存在或已过期");
122
136
  }
123
137
  return { meta: this.meta(stored), bytes: await readFile(stored.path) };
124
138
  }
125
139
 
126
- async imagesForRpc(ids: string[]): Promise<Array<{ type: "image"; data: string; mimeType: string }>> {
140
+ async promptForRpc(ids: string[], message: string): Promise<{
141
+ message: string;
142
+ images: Array<{ type: "image"; data: string; mimeType: string }>;
143
+ }> {
127
144
  const uniqueIds = [...new Set(ids)];
128
- if (uniqueIds.length > this.maxPromptImages) {
145
+ if (uniqueIds.length > this.maxPromptAttachments) {
146
+ throw new Error(`每条消息最多附加 ${this.maxPromptAttachments} 个文件`);
147
+ }
148
+ const attachments = uniqueIds.map((id) => {
149
+ const stored = this.entries.get(id);
150
+ if (!stored || !this.get(id)) throw new Error("附件不存在或已过期,请重新上传");
151
+ return stored;
152
+ });
153
+ if (attachments.filter((entry) => entry.kind === "image").length > this.maxPromptImages) {
129
154
  throw new Error(`每条消息最多附加 ${this.maxPromptImages} 张图片`);
130
155
  }
156
+ if (attachments.reduce((length, entry) => length + (entry.textLength || 0), 0) > 200_000) {
157
+ throw new Error("附件文本总量不能超过 200000 字符,请拆分发送");
158
+ }
131
159
  const images = [];
132
- for (const id of uniqueIds) {
133
- const { meta, bytes } = await this.read(id);
134
- images.push({ type: "image" as const, data: bytes.toString("base64"), mimeType: meta.mimeType });
160
+ const documents = [];
161
+ for (const attachment of attachments) {
162
+ if (attachment.kind === "image") {
163
+ const { meta, bytes } = await this.read(attachment.id);
164
+ images.push({ type: "image" as const, data: bytes.toString("base64"), mimeType: meta.mimeType });
165
+ } else {
166
+ const text = attachment.text!;
167
+ let fenceLength = 3;
168
+ for (const match of text.matchAll(/`+/g)) fenceLength = Math.max(fenceLength, match[0].length + 1);
169
+ const fence = "`".repeat(fenceLength);
170
+ documents.push(`附件:${JSON.stringify(attachment.name)}\n${fence}text\n${text}\n${fence}`);
171
+ }
135
172
  }
136
- return images;
173
+ return {
174
+ message: [message.trim() || (documents.length ? "请分析附加文件。" : "请分析附加图片。"), ...documents].join("\n\n"),
175
+ images,
176
+ };
137
177
  }
138
178
 
139
179
  async remove(id: string): Promise<boolean> {
@@ -149,9 +189,12 @@ export class ImageAttachmentStore {
149
189
  await Promise.all([...this.entries.keys()].map((id) => this.remove(id)));
150
190
  }
151
191
 
152
- private meta(stored: StoredImageAttachment): ImageAttachmentMeta {
192
+ private meta(stored: StoredAttachment): AttachmentMeta {
153
193
  return {
154
194
  id: stored.id,
195
+ kind: stored.kind,
196
+ name: stored.name,
197
+ ...(stored.textLength !== undefined ? { textLength: stored.textLength } : {}),
155
198
  mimeType: stored.mimeType,
156
199
  size: stored.size,
157
200
  createdAt: stored.createdAt,
@@ -0,0 +1,52 @@
1
+ import { open } from "node:fs/promises";
2
+ import { basename, dirname } from "node:path";
3
+ import { StringDecoder } from "node:string_decoder";
4
+ import { stripAnsi, type RenderedMessage } from "./transcript.ts";
5
+
6
+ export interface BackgroundJob {
7
+ id: string;
8
+ jobId: string;
9
+ name?: string;
10
+ command: string;
11
+ logPath: string;
12
+ }
13
+
14
+ export function collectBackgroundJobs(messages: RenderedMessage[]): BackgroundJob[] {
15
+ const jobs = new Map<string, BackgroundJob>();
16
+ for (const message of messages) {
17
+ for (const block of message.blocks ?? []) {
18
+ if (block.type !== "toolCall" || block.name !== "pwsh" || !block.id || block.result?.isError) continue;
19
+ const text = block.result?.content ?? "";
20
+ const started = /^Started background job (bg-\d+)\b/.exec(text);
21
+ const logPath = /Full log: ([^\r\n]+)$/.exec(text)?.[1].trim();
22
+ if (!started || !logPath || basename(logPath) !== `${started[1]}.log` || !basename(dirname(logPath)).startsWith("pi-pwsh-notify-")) continue;
23
+ const args = block.arguments && typeof block.arguments === "object" ? block.arguments as Record<string, unknown> : {};
24
+ // Job numbers restart with each extension instance; tool call IDs remain session-scoped.
25
+ jobs.set(block.id, {
26
+ id: block.id,
27
+ jobId: started[1],
28
+ name: typeof args.name === "string" ? args.name : undefined,
29
+ command: typeof args.command === "string" ? args.command : block.argumentsText ?? "",
30
+ logPath,
31
+ });
32
+ }
33
+ }
34
+ return [...jobs.values()].reverse();
35
+ }
36
+
37
+ export async function readBackgroundJobLog(logPath: string): Promise<{ output: string; truncated: boolean }> {
38
+ const file = await open(logPath, "r");
39
+ try {
40
+ const { size } = await file.stat();
41
+ const start = Math.max(0, size - 128 * 1024);
42
+ const buffer = Buffer.alloc(Math.min(size, 128 * 1024));
43
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, start);
44
+ let offset = 0;
45
+ if (start > 0) while (offset < bytesRead && (buffer[offset] & 0xc0) === 0x80) offset++;
46
+ const text = new StringDecoder("utf8").write(buffer.subarray(offset, bytesRead));
47
+ const lines = text.split("\n");
48
+ return { output: stripAnsi(lines.slice(-1000).join("\n")), truncated: start > 0 || lines.length > 1000 };
49
+ } finally {
50
+ await file.close();
51
+ }
52
+ }
@@ -0,0 +1,50 @@
1
+ import { getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
2
+
3
+ type PackageEntry = ReturnType<SettingsManager["getPackages"]>[number];
4
+ type ManagedPackage = Exclude<PackageEntry, string> & { piWebExtensionFilter?: string[] };
5
+
6
+ export function togglePackage(entry: PackageEntry, enabled: boolean): PackageEntry {
7
+ const next: ManagedPackage = typeof entry === "string" ? { source: entry } : { ...entry };
8
+ const disabled = Array.isArray(next.extensions) && next.extensions.length === 0;
9
+ if (enabled === !disabled) return entry;
10
+ if (enabled) {
11
+ if (next.piWebExtensionFilter) next.extensions = next.piWebExtensionFilter;
12
+ else delete next.extensions;
13
+ delete next.piWebExtensionFilter;
14
+ } else {
15
+ if (next.extensions) next.piWebExtensionFilter = next.extensions;
16
+ next.extensions = [];
17
+ }
18
+ return next;
19
+ }
20
+
21
+ export async function extensionSettings(cwd: string, change?: { scope: "global" | "project"; source: string; enabled: boolean }, agentDir = getAgentDir()) {
22
+ const manager = SettingsManager.create(cwd, agentDir, { projectTrusted: true });
23
+ const checkErrors = () => {
24
+ const errors = manager.drainErrors();
25
+ if (errors.length) throw new Error(errors.map((item) => item.error.message).join("\n"));
26
+ };
27
+ checkErrors();
28
+ if (change) {
29
+ const settings = change.scope === "global" ? manager.getGlobalSettings() : manager.getProjectSettings();
30
+ const packages = settings.packages ?? [];
31
+ if (!packages.some((entry) => (typeof entry === "string" ? entry : entry.source) === change.source)) throw new Error("扩展包配置已变化,请刷新列表");
32
+ const next = packages.map((entry) => (typeof entry === "string" ? entry : entry.source) === change.source ? togglePackage(entry, change.enabled) : entry);
33
+ if (change.scope === "global") manager.setPackages(next);
34
+ else manager.setProjectPackages(next);
35
+ await manager.flush();
36
+ checkErrors();
37
+ }
38
+ return {
39
+ cwd,
40
+ packages: (["global", "project"] as const).flatMap((scope) => {
41
+ const settings = scope === "global" ? manager.getGlobalSettings() : manager.getProjectSettings();
42
+ return (settings.packages ?? []).map((entry) => ({
43
+ scope,
44
+ source: typeof entry === "string" ? entry : entry.source,
45
+ enabled: typeof entry === "string" || entry.extensions?.length !== 0,
46
+ filtered: typeof entry !== "string" && Boolean(entry.extensions?.length),
47
+ }));
48
+ }),
49
+ };
50
+ }
package/file-preview.ts CHANGED
@@ -55,6 +55,8 @@ export interface FilePreviewData {
55
55
  path: string;
56
56
  name: string;
57
57
  language: string;
58
+ kind: "code" | "markdown" | "html";
59
+ content?: string;
58
60
  highlighted: boolean;
59
61
  highlightedHtml: string;
60
62
  lineCount: number;
@@ -68,7 +70,9 @@ function escapeHtml(value: string): string {
68
70
  export function buildFilePreview(filePath: string, data: Buffer): FilePreviewData {
69
71
  const name = basename(filePath);
70
72
  const text = data.toString("utf8").replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n");
71
- const language = LANGUAGES_BY_FILENAME[name.toLowerCase()] ?? LANGUAGES_BY_EXTENSION[extname(name).toLowerCase()] ?? "plaintext";
73
+ const extension = extname(name).toLowerCase();
74
+ const language = LANGUAGES_BY_FILENAME[name.toLowerCase()] ?? LANGUAGES_BY_EXTENSION[extension] ?? "plaintext";
75
+ const kind = language === "markdown" ? "markdown" : [".html", ".htm"].includes(extension) ? "html" : "code";
72
76
  const canHighlight = data.byteLength <= MAX_HIGHLIGHT_BYTES && Boolean(hljs.getLanguage(language));
73
77
  let highlightedHtml = escapeHtml(text);
74
78
  let highlighted = false;
@@ -86,6 +90,8 @@ export function buildFilePreview(filePath: string, data: Buffer): FilePreviewDat
86
90
  path: filePath,
87
91
  name,
88
92
  language,
93
+ kind,
94
+ ...(kind !== "code" ? { content: text } : {}),
89
95
  highlighted,
90
96
  highlightedHtml,
91
97
  lineCount: text.split("\n").length,