@oversk7/pi-web-dsh-style 1.0.1 → 1.1.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 +21 -0
- package/THIRD_PARTY_NOTICES.md +4 -0
- package/attachment-parser.ts +210 -0
- package/attachment-store.ts +67 -24
- package/background-jobs.ts +52 -0
- package/extension-settings.ts +50 -0
- package/file-preview.ts +7 -1
- package/package.json +21 -3
- package/pi-rpc.ts +7 -5
- package/server.ts +251 -83
- package/sse-channel.ts +37 -0
- package/terminal-proxy.ts +50 -0
- package/transcript.ts +13 -0
- package/web/app.css +187 -55
- package/web/app.js +5187 -4619
- package/web/assets/markdown.LICENSE.txt +200 -0
- package/web/assets/markdown.js +25 -0
- package/web/assets/themes/sakura.webp +0 -0
- package/web/assets/themes/starlight.webp +0 -0
- package/web/index.html +5 -1
- package/web/markdown.ts +118 -0
- package/web/slash-commands.d.ts +11 -0
- package/web/slash-commands.js +46 -0
- package/web/themes.css +158 -0
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @oversk7/pi-web-dsh-style
|
|
2
2
|
|
|
3
|
+
[GitHub 源码](https://github.com/oversk7/pi-web-dsh-style) · [问题与建议](https://github.com/oversk7/pi-web-dsh-style/issues) · [npm](https://www.npmjs.com/package/@oversk7/pi-web-dsh-style) · [Pi 扩展目录](https://pi.dev/packages/@oversk7/pi-web-dsh-style)
|
|
4
|
+
|
|
3
5
|
采用 DeepSeek Harness(DSH)视觉风格、面向 Windows 的非官方 Pi Coding Agent 本地浏览器界面扩展。后端使用 Pi 的公开 RPC 模式,每个会话对应独立的 RPC 进程;历史会话可直接从 Pi 会话文件恢复和渲染。
|
|
4
6
|
|
|
5
7
|
> 仅承诺 Windows 10/11。首次启动只监听 `127.0.0.1`;电脑网页的“设置 → 手机访问”可配置仅本机、局域网和服务器中转。之后 `pi --web` 使用已保存的模式。
|
|
@@ -40,6 +42,19 @@ pi --web
|
|
|
40
42
|
/web --stop
|
|
41
43
|
```
|
|
42
44
|
|
|
45
|
+
### 网页会话中的斜杠命令
|
|
46
|
+
|
|
47
|
+
输入 `/` 可查看当前可用命令。Web 内置命令包括 `/settings`、`/model`、`/thinking`、`/export`、`/copy`、`/name`、`/session`、`/new`、`/resume`、`/rewind`、`/tree`、`/compact` 和 `/reload`。
|
|
48
|
+
|
|
49
|
+
- `/model provider/model` 精确匹配时切换模型;未唯一匹配时打开带搜索词的模型选择器。`/thinking high` 切换当前模型支持的思考强度,两者不带参数时打开选择器。
|
|
50
|
+
- `/compact 压缩要求` 会将要求传给 Pi;`/name 名称` 修改会话名称,不带参数时显示当前名称。
|
|
51
|
+
- `/resume` 打开侧栏会话搜索;`/export` 下载 HTML 到浏览器下载目录,暂不接受导出路径或 JSONL 格式参数。
|
|
52
|
+
- 网页会话中的 `/reload` 只重载当前会话的扩展与配置。要重载整个 Web 服务,请使用设置中的“重新加载”或在宿主 Pi 终端执行 `/reload`。
|
|
53
|
+
|
|
54
|
+
扩展命令、提示词模板和 `/skill:名称` 由当前会话的 Pi RPC 进程执行。未知命令会报错并保留草稿,不会作为普通消息发送给模型;普通消息需要避免以 `/` 开头。
|
|
55
|
+
|
|
56
|
+
`/scoped-models`、`/import`、`/share`、`/fork`、`/clone`、`/trust`、`/changelog`、`/hotkeys`、`/login`、`/logout` 和 `/quit` 尚未提供对应的 Web 操作,输入时会显示说明。需要这些 Pi 功能时请使用终端。
|
|
57
|
+
|
|
43
58
|
### 手机通过局域网访问
|
|
44
59
|
|
|
45
60
|
电脑和手机连接同一 Wi-Fi,在电脑本机网页“设置 → 手机访问”中选择“局域网”并保存。也可以执行 `/web --lan`,或直接以 `pi --web-lan` 启动。首次使用时,在同一设置中设置固定密码;之后手机打开显示的地址,输入该密码即可登录。界面只显示一个手机访问地址,优先使用 Wi-Fi,并过滤 WSL、Hyper-V 等虚拟网卡。
|
|
@@ -130,6 +145,12 @@ npm login --registry=https://registry.npmjs.org
|
|
|
130
145
|
npm publish --access public --tag latest
|
|
131
146
|
```
|
|
132
147
|
|
|
148
|
+
## 反馈与相关扩展
|
|
149
|
+
|
|
150
|
+
欢迎通过 [GitHub Issues](https://github.com/oversk7/pi-web-dsh-style/issues) 反馈问题、提出建议,或分享使用场景。报告问题时,请附上扩展版本、Pi 版本、Windows 版本和复现步骤;涉及手机访问时,请说明使用的是局域网还是服务器中转。
|
|
151
|
+
|
|
152
|
+
同一作者的 [pi-pwsh-notify](https://github.com/oversk7/pi-pwsh-notify) 为 Pi 提供 Windows PowerShell 7 工具,以及后台任务完成自动通知功能。
|
|
153
|
+
|
|
133
154
|
## 许可证
|
|
134
155
|
|
|
135
156
|
原创代码使用 MIT License。Web UI 的部分样式和设计 token 改编自 DeepSeek Harness,字体资源来自 KaTeX,代码高亮使用 highlight.js。完整归属与许可证见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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
|
+
}
|
package/attachment-store.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
70
|
+
private readonly entries = new Map<string, StoredAttachment>();
|
|
64
71
|
|
|
65
|
-
constructor(options:
|
|
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<
|
|
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(
|
|
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}.
|
|
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
|
|
83
|
-
if (!
|
|
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:
|
|
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):
|
|
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:
|
|
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
|
|
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.
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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:
|
|
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
|
|
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,
|
package/package.json
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oversk7/pi-web-dsh-style",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "An unofficial DSH-styled Windows browser UI for pi coding agent with configurable LAN and native frp relay access",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "oversk7",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/oversk7/pi-web-dsh-style.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/oversk7/pi-web-dsh-style#readme",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/oversk7/pi-web-dsh-style/issues"
|
|
15
|
+
},
|
|
8
16
|
"keywords": [
|
|
9
17
|
"pi-package",
|
|
10
18
|
"pi-coding-agent",
|
|
@@ -21,12 +29,16 @@
|
|
|
21
29
|
},
|
|
22
30
|
"files": [
|
|
23
31
|
"abort-session.ts",
|
|
32
|
+
"background-jobs.ts",
|
|
24
33
|
"attachment-store.ts",
|
|
34
|
+
"attachment-parser.ts",
|
|
25
35
|
"file-preview.ts",
|
|
36
|
+
"extension-settings.ts",
|
|
26
37
|
"index.ts",
|
|
27
38
|
"local-session.ts",
|
|
28
39
|
"network-access.ts",
|
|
29
40
|
"network-settings.ts",
|
|
41
|
+
"terminal-proxy.ts",
|
|
30
42
|
"frp-process.ts",
|
|
31
43
|
"pi-rpc.ts",
|
|
32
44
|
"sse-channel.ts",
|
|
@@ -41,11 +53,12 @@
|
|
|
41
53
|
"THIRD_PARTY_NOTICES.md"
|
|
42
54
|
],
|
|
43
55
|
"scripts": {
|
|
56
|
+
"build:markdown": "node scripts/build-markdown.mjs",
|
|
44
57
|
"typecheck": "tsc --noEmit",
|
|
45
58
|
"test": "node --test tests/*.test.ts",
|
|
46
59
|
"check:package": "node scripts/check-package.mjs",
|
|
47
60
|
"verify": "npm run typecheck && npm test && npm run check:package",
|
|
48
|
-
"prepack": "npm run verify"
|
|
61
|
+
"prepack": "npm run build:markdown && npm run verify"
|
|
49
62
|
},
|
|
50
63
|
"pi": {
|
|
51
64
|
"extensions": [
|
|
@@ -61,6 +74,7 @@
|
|
|
61
74
|
"@earendil-works/pi-coding-agent": "0.85.0",
|
|
62
75
|
"@earendil-works/pi-server": "0.85.0",
|
|
63
76
|
"@types/node": "^22.19.19",
|
|
77
|
+
"esbuild": "^0.28.2",
|
|
64
78
|
"typescript": "^5.9.3"
|
|
65
79
|
},
|
|
66
80
|
"publishConfig": {
|
|
@@ -69,6 +83,10 @@
|
|
|
69
83
|
"tag": "latest"
|
|
70
84
|
},
|
|
71
85
|
"dependencies": {
|
|
72
|
-
"
|
|
86
|
+
"fast-xml-parser": "^5.11.1",
|
|
87
|
+
"fflate": "^0.8.3",
|
|
88
|
+
"highlight.js": "^11.12.0",
|
|
89
|
+
"markdown-it": "^15.0.2",
|
|
90
|
+
"pdfjs-dist": "^6.3.289"
|
|
73
91
|
}
|
|
74
92
|
}
|