@oversk7/pi-web-dsh-style 0.1.0-alpha.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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/THIRD_PARTY_NOTICES.md +90 -0
  4. package/abort-session.ts +150 -0
  5. package/attachment-store.ts +161 -0
  6. package/file-preview.ts +94 -0
  7. package/index.ts +177 -0
  8. package/local-session.ts +190 -0
  9. package/package.json +70 -0
  10. package/pi-rpc.ts +253 -0
  11. package/server.ts +3060 -0
  12. package/sse-channel.ts +72 -0
  13. package/state-store.ts +64 -0
  14. package/transcript.ts +328 -0
  15. package/web/app.css +535 -0
  16. package/web/app.js +4228 -0
  17. package/web/assets/fonts/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  18. package/web/assets/fonts/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  19. package/web/assets/fonts/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  20. package/web/assets/fonts/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  21. package/web/assets/fonts/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  22. package/web/assets/fonts/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  23. package/web/assets/fonts/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  24. package/web/assets/fonts/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  25. package/web/assets/fonts/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  26. package/web/assets/fonts/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  27. package/web/assets/fonts/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  28. package/web/assets/fonts/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  29. package/web/assets/fonts/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  30. package/web/assets/fonts/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  31. package/web/assets/fonts/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  32. package/web/assets/fonts/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  33. package/web/assets/fonts/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  34. package/web/assets/fonts/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  35. package/web/assets/fonts/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  36. package/web/assets/fonts/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  37. package/web/assets/fonts/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  38. package/web/assets/fonts/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  39. package/web/assets/fonts/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  40. package/web/assets/fonts/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  41. package/web/assets/fonts/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  42. package/web/assets/fonts/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  43. package/web/assets/fonts/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  44. package/web/assets/fonts/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  45. package/web/assets/fonts/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  46. package/web/assets/fonts/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  47. package/web/assets/fonts/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  48. package/web/assets/fonts/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  49. package/web/assets/fonts/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  50. package/web/assets/fonts/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  51. package/web/assets/fonts/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  52. package/web/assets/fonts/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  53. package/web/assets/fonts/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  54. package/web/assets/fonts/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  55. package/web/assets/fonts/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  56. package/web/assets/fonts/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  57. package/web/assets/fonts/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  58. package/web/assets/fonts/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  59. package/web/assets/fonts/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  60. package/web/assets/fonts/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  61. package/web/assets/fonts/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  62. package/web/assets/fonts/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  63. package/web/assets/fonts/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  64. package/web/assets/fonts/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  65. package/web/assets/fonts/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  66. package/web/assets/fonts/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  67. package/web/assets/fonts/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  68. package/web/assets/fonts/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  69. package/web/assets/fonts/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  70. package/web/assets/fonts/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  71. package/web/assets/fonts/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  72. package/web/assets/fonts/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  73. package/web/assets/fonts/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  74. package/web/assets/fonts/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  75. package/web/assets/fonts/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  76. package/web/favicon.svg +4 -0
  77. package/web/index.html +25 -0
  78. package/web/manifest.webmanifest +9 -0
  79. package/windows-clipboard.ts +81 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 oversk7
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # @oversk7/pi-web-dsh-style
2
+
3
+ 采用 DeepSeek Harness(DSH)视觉风格、面向 Windows 的非官方 Pi Coding Agent 本地浏览器界面扩展。后端使用 Pi 的公开 RPC 模式,每个会话对应独立的 RPC 进程;历史会话可直接从 Pi 会话文件恢复和渲染。
4
+
5
+ > 当前版本为 alpha,仅承诺 Windows 10/11。服务只监听 `127.0.0.1`,不应通过端口转发或反向代理暴露到局域网或公网。
6
+
7
+ ## 安装
8
+
9
+ 需要 Node.js 22.19 或更高版本,以及 Pi Coding Agent 0.84.4 或更高版本。
10
+
11
+ ```powershell
12
+ pi install npm:@oversk7/pi-web-dsh-style@alpha
13
+ ```
14
+
15
+ 也可以从源码目录安装:
16
+
17
+ ```powershell
18
+ pi install C:\path\to\pi-web
19
+ ```
20
+
21
+ 安装后重启 Pi,或执行 `/reload`。
22
+
23
+ ## 使用
24
+
25
+ 直接启动:
26
+
27
+ ```powershell
28
+ pi --web
29
+ ```
30
+
31
+ 也可以在 Pi 终端中执行:
32
+
33
+ ```text
34
+ /web
35
+ /web --no-open
36
+ /web --port 18789
37
+ /web --stop
38
+ ```
39
+
40
+ ## 功能
41
+
42
+ - 多工作区、多会话和后台会话并行运行。
43
+ - 按需启动 Pi RPC 进程,最多保留一个空闲进程。
44
+ - 模型、思考等级、中断、压缩和扩展交互对话框。
45
+ - 运行中逐条显示待处理消息,可单条撤回并重新编辑;`Alt+Q` 可将全部队列消息恢复到输入框。
46
+ - 会话历史、分支树、对话回溯及 `pi-rewind` 文件恢复。
47
+ - Windows 系统剪贴板图片和文本粘贴。
48
+ - 会话中的本地文件链接可在右侧分栏预览,支持语法高亮、行定位和拖拽调整宽度。
49
+ - 设置面板内更新扩展并热重载 Pi 运行时,无需退出 Web UI。
50
+ - 会话 HTML 导出。
51
+
52
+ ## 数据与升级
53
+
54
+ 运行状态保存在:
55
+
56
+ ```text
57
+ ~/.pi/agent/pi-web/state.json
58
+ ```
59
+
60
+ 从旧版目录安装首次升级时,如果新位置尚无状态文件,扩展会从扩展目录中的旧 `state.json` 原样复制并继续使用。旧文件不会被删除或改写,可作为迁移备份。
61
+
62
+ 可用环境变量:
63
+
64
+ - `PI_WEB_STATE_DIR`:覆盖状态目录。
65
+ - `PI_WEB_STATE_FILE`:覆盖状态文件。
66
+ - `PI_WEB_LEGACY_STATE_FILE`:指定迁移来源。
67
+ - `PI_WEB_RPC_ENTRY`:覆盖 Pi 的公开 RPC 入口文件。
68
+ - `PI_WEB_PI_CLI`:兼容旧版的 Pi CLI 路径覆盖。
69
+ - `PI_WEB_POWERSHELL`:覆盖用于剪贴板读取的 `powershell.exe`。
70
+
71
+ 截图会临时写入系统临时目录,并在 24 小时后或服务停止时清理。Pi 会话内容仍由 Pi 自身保存在其会话目录中。
72
+
73
+ ## 开发验证
74
+
75
+ ```powershell
76
+ npm install
77
+ npm run verify
78
+ npm pack --dry-run
79
+ ```
80
+
81
+ 发布 alpha:
82
+
83
+ ```powershell
84
+ npm login --registry=https://registry.npmjs.org
85
+ npm publish --access public --tag alpha
86
+ ```
87
+
88
+ ## 许可证
89
+
90
+ 原创代码使用 MIT License。Web UI 的部分样式和设计 token 改编自 DeepSeek Harness,字体资源来自 KaTeX,代码高亮使用 highlight.js。完整归属与许可证见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
@@ -0,0 +1,90 @@
1
+ # Third-Party Notices
2
+
3
+ This package contains adapted web UI styles and bundled font assets from the
4
+ projects below. Their licenses are reproduced here as required.
5
+
6
+ ## DeepSeek Harness
7
+
8
+ Source: https://github.com/deepseek-ai/deepseek-harness
9
+
10
+ Portions of the web UI styling and design tokens are adapted from DeepSeek
11
+ Harness.
12
+
13
+ MIT License
14
+
15
+ Copyright (c) 2026 DeepSeek
16
+
17
+ Permission is hereby granted, free of charge, to any person obtaining a copy
18
+ of this software and associated documentation files (the "Software"), to deal
19
+ in the Software without restriction, including without limitation the rights
20
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
21
+ copies of the Software, and to permit persons to whom the Software is
22
+ furnished to do so, subject to the following conditions:
23
+
24
+ The above copyright notice and this permission notice shall be included in all
25
+ copies or substantial portions of the Software.
26
+
27
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
28
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
29
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
30
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
31
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
32
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
33
+ SOFTWARE.
34
+
35
+ ## KaTeX
36
+
37
+ Source: https://github.com/KaTeX/KaTeX
38
+
39
+ The files under `web/assets/fonts/` are distributed by KaTeX.
40
+
41
+ The MIT License (MIT)
42
+
43
+ Copyright (c) 2013-2020 Khan Academy and other contributors
44
+
45
+ Permission is hereby granted, free of charge, to any person obtaining a copy
46
+ of this software and associated documentation files (the "Software"), to deal
47
+ in the Software without restriction, including without limitation the rights
48
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
49
+ copies of the Software, and to permit persons to whom the Software is
50
+ furnished to do so, subject to the following conditions:
51
+
52
+ The above copyright notice and this permission notice shall be included in all
53
+ copies or substantial portions of the Software.
54
+
55
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
56
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
57
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
58
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
59
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
60
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
61
+ SOFTWARE.
62
+
63
+ ## highlight.js
64
+
65
+ Source: https://github.com/highlightjs/highlight.js
66
+
67
+ Copyright (c) 2006, Ivan Sagalaev.
68
+ All rights reserved.
69
+ Redistribution and use in source and binary forms, with or without
70
+ modification, are permitted provided that the following conditions are met:
71
+
72
+ - Redistributions of source code must retain the above copyright notice, this
73
+ list of conditions and the following disclaimer.
74
+ - Redistributions in binary form must reproduce the above copyright notice,
75
+ this list of conditions and the following disclaimer in the documentation
76
+ and/or other materials provided with the distribution.
77
+ - Neither the name of highlight.js nor the names of its contributors may be
78
+ used to endorse or promote products derived from this software without
79
+ specific prior written permission.
80
+
81
+ THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND ANY
82
+ EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
83
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
84
+ DISCLAIMED. IN NO EVENT SHALL THE REGENTS AND CONTRIBUTORS BE LIABLE FOR ANY
85
+ DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
86
+ (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
87
+ LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
88
+ ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
89
+ (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
90
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,150 @@
1
+ import type { Json } from "./pi-rpc.ts";
2
+
3
+ export interface AbortRequestResult {
4
+ acknowledged: boolean;
5
+ response?: Json;
6
+ errorMessage?: string;
7
+ }
8
+
9
+ export interface ClearedRpcQueue {
10
+ supported: boolean;
11
+ steering: string[];
12
+ followUp: string[];
13
+ response?: Json;
14
+ errorMessage?: string;
15
+ }
16
+
17
+ export type RpcQueueKind = "steering" | "followUp";
18
+
19
+ export interface RpcQueueSnapshot {
20
+ steering: string[];
21
+ followUp: string[];
22
+ }
23
+
24
+ export interface RpcQueueItemTarget {
25
+ kind: RpcQueueKind;
26
+ index: number;
27
+ message: string;
28
+ }
29
+
30
+ export interface WithdrawRpcQueueResult {
31
+ supported: boolean;
32
+ withdrawn: boolean;
33
+ queue: RpcQueueSnapshot;
34
+ unrestored: RpcQueueSnapshot;
35
+ errorMessage?: string;
36
+ }
37
+
38
+ function stringArray(value: unknown): string[] {
39
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
40
+ }
41
+
42
+ function emptyQueue(): RpcQueueSnapshot {
43
+ return { steering: [], followUp: [] };
44
+ }
45
+
46
+ function queueFromEntries(entries: Array<{ kind: RpcQueueKind; message: string }>): RpcQueueSnapshot {
47
+ const queue = emptyQueue();
48
+ for (const entry of entries) queue[entry.kind].push(entry.message);
49
+ return queue;
50
+ }
51
+
52
+ async function restoreRpcQueue(
53
+ proc: { send(command: Json, timeoutMs?: number): Promise<Json> },
54
+ queue: RpcQueueSnapshot,
55
+ timeoutMs: number,
56
+ ): Promise<{ queue: RpcQueueSnapshot; unrestored: RpcQueueSnapshot; errorMessage?: string }> {
57
+ const entries: Array<{ kind: RpcQueueKind; message: string }> = [
58
+ ...queue.steering.map((message) => ({ kind: "steering" as const, message })),
59
+ ...queue.followUp.map((message) => ({ kind: "followUp" as const, message })),
60
+ ];
61
+ const restored: Array<{ kind: RpcQueueKind; message: string }> = [];
62
+ for (let index = 0; index < entries.length; index += 1) {
63
+ const entry = entries[index];
64
+ try {
65
+ await proc.send({ type: entry.kind === "steering" ? "steer" : "follow_up", message: entry.message }, timeoutMs);
66
+ restored.push(entry);
67
+ } catch (error) {
68
+ return {
69
+ queue: queueFromEntries(restored),
70
+ unrestored: queueFromEntries(entries.slice(index)),
71
+ errorMessage: error instanceof Error ? error.message : String(error),
72
+ };
73
+ }
74
+ }
75
+ return { queue: queueFromEntries(restored), unrestored: emptyQueue() };
76
+ }
77
+
78
+ export async function requestRpcClearQueue(
79
+ proc: { send(command: Json, timeoutMs?: number): Promise<Json> },
80
+ timeoutMs: number,
81
+ ): Promise<ClearedRpcQueue> {
82
+ try {
83
+ const response = await proc.send({ type: "clear_queue" }, timeoutMs);
84
+ const data = response.data && typeof response.data === "object" ? response.data as Json : {};
85
+ return {
86
+ supported: true,
87
+ steering: stringArray(data.steering),
88
+ followUp: stringArray(data.followUp),
89
+ response,
90
+ };
91
+ } catch (error) {
92
+ return {
93
+ supported: false,
94
+ steering: [],
95
+ followUp: [],
96
+ errorMessage: error instanceof Error ? error.message : String(error),
97
+ };
98
+ }
99
+ }
100
+
101
+ export async function requestRpcWithdrawQueueItem(
102
+ proc: { send(command: Json, timeoutMs?: number): Promise<Json> },
103
+ target: RpcQueueItemTarget,
104
+ timeoutMs: number,
105
+ ): Promise<WithdrawRpcQueueResult> {
106
+ const cleared = await requestRpcClearQueue(proc, timeoutMs);
107
+ if (!cleared.supported) {
108
+ return {
109
+ supported: false,
110
+ withdrawn: false,
111
+ queue: emptyQueue(),
112
+ unrestored: emptyQueue(),
113
+ errorMessage: cleared.errorMessage,
114
+ };
115
+ }
116
+
117
+ const remaining: RpcQueueSnapshot = {
118
+ steering: [...cleared.steering],
119
+ followUp: [...cleared.followUp],
120
+ };
121
+ const targetQueue = remaining[target.kind];
122
+ const withdrawn = Number.isInteger(target.index)
123
+ && target.index >= 0
124
+ && targetQueue[target.index] === target.message;
125
+ if (withdrawn) targetQueue.splice(target.index, 1);
126
+
127
+ const restored = await restoreRpcQueue(proc, remaining, timeoutMs);
128
+ return {
129
+ supported: true,
130
+ withdrawn,
131
+ queue: restored.queue,
132
+ unrestored: restored.unrestored,
133
+ errorMessage: restored.errorMessage,
134
+ };
135
+ }
136
+
137
+ export async function requestRpcAbort(
138
+ proc: { send(command: Json, timeoutMs?: number): Promise<Json> },
139
+ timeoutMs: number,
140
+ ): Promise<AbortRequestResult> {
141
+ try {
142
+ const response = await proc.send({ type: "abort" }, timeoutMs);
143
+ return { acknowledged: true, response };
144
+ } catch (error) {
145
+ return {
146
+ acknowledged: false,
147
+ errorMessage: error instanceof Error ? error.message : String(error),
148
+ };
149
+ }
150
+ }
@@ -0,0 +1,161 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFile, unlink, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export interface ImageAttachmentMeta {
7
+ id: string;
8
+ mimeType: string;
9
+ size: number;
10
+ createdAt: number;
11
+ expiresAt: number;
12
+ }
13
+
14
+ interface StoredImageAttachment extends ImageAttachmentMeta {
15
+ path: string;
16
+ timer: ReturnType<typeof setTimeout>;
17
+ }
18
+
19
+ type PublicMimeDetector = (filePath: string) => Promise<string | null>;
20
+
21
+ let publicMimeDetector: Promise<PublicMimeDetector | null> | undefined;
22
+
23
+ async function resolvePublicMimeDetector(): Promise<PublicMimeDetector | null> {
24
+ publicMimeDetector ??= import("@earendil-works/pi-coding-agent").then((module) => {
25
+ const detector = (module as unknown as { detectSupportedImageMimeTypeFromFile?: unknown })
26
+ .detectSupportedImageMimeTypeFromFile;
27
+ return typeof detector === "function" ? detector as PublicMimeDetector : null;
28
+ });
29
+ return publicMimeDetector;
30
+ }
31
+
32
+ function fallbackImageMimeType(bytes: Uint8Array): string | null {
33
+ const ascii = (start: number, value: string) => value.split("").every(
34
+ (char, index) => bytes[start + index] === char.charCodeAt(0),
35
+ );
36
+ if (bytes.length >= 16
37
+ && [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((value, index) => bytes[index] === value)
38
+ && ascii(12, "IHDR")) return "image/png";
39
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
40
+ if (bytes.length >= 6 && (ascii(0, "GIF87a") || ascii(0, "GIF89a"))) return "image/gif";
41
+ if (bytes.length >= 12 && ascii(0, "RIFF") && ascii(8, "WEBP")) return "image/webp";
42
+ if (bytes.length >= 26 && ascii(0, "BM")) return "image/bmp";
43
+ return null;
44
+ }
45
+
46
+ async function detectImageMimeType(path: string, bytes: Uint8Array): Promise<string | null> {
47
+ const detector = await resolvePublicMimeDetector();
48
+ return detector ? detector(path) : fallbackImageMimeType(bytes);
49
+ }
50
+
51
+ export interface ImageAttachmentStoreOptions {
52
+ directory?: string;
53
+ ttlMs?: number;
54
+ maxBytes?: number;
55
+ maxPromptImages?: number;
56
+ }
57
+
58
+ export class ImageAttachmentStore {
59
+ readonly maxPromptImages: number;
60
+ private readonly directory: string;
61
+ private readonly ttlMs: number;
62
+ private readonly maxBytes: number;
63
+ private readonly entries = new Map<string, StoredImageAttachment>();
64
+
65
+ constructor(options: ImageAttachmentStoreOptions = {}) {
66
+ this.directory = options.directory ?? tmpdir();
67
+ this.ttlMs = options.ttlMs ?? 24 * 60 * 60 * 1000;
68
+ this.maxBytes = options.maxBytes ?? 15 * 1024 * 1024;
69
+ this.maxPromptImages = options.maxPromptImages ?? 4;
70
+ }
71
+
72
+ async create(bytes: Uint8Array): Promise<ImageAttachmentMeta> {
73
+ if (bytes.byteLength === 0) throw new Error("图片内容为空");
74
+ if (bytes.byteLength > this.maxBytes) {
75
+ throw new Error(`图片不能超过 ${Math.floor(this.maxBytes / 1024 / 1024)} MB`);
76
+ }
77
+
78
+ const id = randomUUID();
79
+ const path = join(this.directory, `pi-web-attachment-${id}.img`);
80
+ await writeFile(path, bytes);
81
+ try {
82
+ const mimeType = await detectImageMimeType(path, bytes);
83
+ if (!mimeType) throw new Error("不支持的图片格式");
84
+ const createdAt = Date.now();
85
+ const expiresAt = createdAt + this.ttlMs;
86
+ const timer = setTimeout(() => {
87
+ void this.remove(id);
88
+ }, this.ttlMs);
89
+ timer.unref();
90
+ const stored: StoredImageAttachment = {
91
+ id,
92
+ path,
93
+ mimeType,
94
+ size: bytes.byteLength,
95
+ createdAt,
96
+ expiresAt,
97
+ timer,
98
+ };
99
+ this.entries.set(id, stored);
100
+ return this.meta(stored);
101
+ } catch (error) {
102
+ await unlink(path).catch(() => {});
103
+ throw error;
104
+ }
105
+ }
106
+
107
+ get(id: string): ImageAttachmentMeta | undefined {
108
+ const stored = this.entries.get(id);
109
+ if (!stored) return undefined;
110
+ if (stored.expiresAt <= Date.now()) {
111
+ void this.remove(id);
112
+ return undefined;
113
+ }
114
+ return this.meta(stored);
115
+ }
116
+
117
+ async read(id: string): Promise<{ meta: ImageAttachmentMeta; bytes: Buffer }> {
118
+ const stored = this.entries.get(id);
119
+ if (!stored || stored.expiresAt <= Date.now()) {
120
+ if (stored) await this.remove(id);
121
+ throw new Error("图片附件不存在或已过期");
122
+ }
123
+ return { meta: this.meta(stored), bytes: await readFile(stored.path) };
124
+ }
125
+
126
+ async imagesForRpc(ids: string[]): Promise<Array<{ type: "image"; data: string; mimeType: string }>> {
127
+ const uniqueIds = [...new Set(ids)];
128
+ if (uniqueIds.length > this.maxPromptImages) {
129
+ throw new Error(`每条消息最多附加 ${this.maxPromptImages} 张图片`);
130
+ }
131
+ 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 });
135
+ }
136
+ return images;
137
+ }
138
+
139
+ async remove(id: string): Promise<boolean> {
140
+ const stored = this.entries.get(id);
141
+ if (!stored) return false;
142
+ this.entries.delete(id);
143
+ clearTimeout(stored.timer);
144
+ await unlink(stored.path).catch(() => {});
145
+ return true;
146
+ }
147
+
148
+ async close(): Promise<void> {
149
+ await Promise.all([...this.entries.keys()].map((id) => this.remove(id)));
150
+ }
151
+
152
+ private meta(stored: StoredImageAttachment): ImageAttachmentMeta {
153
+ return {
154
+ id: stored.id,
155
+ mimeType: stored.mimeType,
156
+ size: stored.size,
157
+ createdAt: stored.createdAt,
158
+ expiresAt: stored.expiresAt,
159
+ };
160
+ }
161
+ }
@@ -0,0 +1,94 @@
1
+ import { basename, extname } from "node:path";
2
+ import hljs from "highlight.js/lib/common";
3
+ import dockerfile from "highlight.js/lib/languages/dockerfile";
4
+ import powershell from "highlight.js/lib/languages/powershell";
5
+
6
+ hljs.registerLanguage("dockerfile", dockerfile);
7
+ hljs.registerLanguage("powershell", powershell);
8
+
9
+ const MAX_HIGHLIGHT_BYTES = 512 * 1024;
10
+
11
+ const LANGUAGES_BY_EXTENSION: Record<string, string> = {
12
+ ".bash": "bash",
13
+ ".c": "c",
14
+ ".cc": "cpp",
15
+ ".cjs": "javascript",
16
+ ".cpp": "cpp",
17
+ ".cs": "csharp",
18
+ ".css": "css",
19
+ ".cts": "typescript",
20
+ ".go": "go",
21
+ ".h": "c",
22
+ ".hpp": "cpp",
23
+ ".htm": "xml",
24
+ ".html": "xml",
25
+ ".java": "java",
26
+ ".js": "javascript",
27
+ ".json": "json",
28
+ ".jsonc": "json",
29
+ ".jsx": "javascript",
30
+ ".md": "markdown",
31
+ ".mdx": "markdown",
32
+ ".mjs": "javascript",
33
+ ".mts": "typescript",
34
+ ".ps1": "powershell",
35
+ ".psd1": "powershell",
36
+ ".psm1": "powershell",
37
+ ".py": "python",
38
+ ".rs": "rust",
39
+ ".sh": "bash",
40
+ ".sql": "sql",
41
+ ".svg": "xml",
42
+ ".ts": "typescript",
43
+ ".tsx": "typescript",
44
+ ".xml": "xml",
45
+ ".yaml": "yaml",
46
+ ".yml": "yaml",
47
+ };
48
+
49
+ const LANGUAGES_BY_FILENAME: Record<string, string> = {
50
+ dockerfile: "dockerfile",
51
+ makefile: "makefile",
52
+ };
53
+
54
+ export interface FilePreviewData {
55
+ path: string;
56
+ name: string;
57
+ language: string;
58
+ highlighted: boolean;
59
+ highlightedHtml: string;
60
+ lineCount: number;
61
+ size: number;
62
+ }
63
+
64
+ function escapeHtml(value: string): string {
65
+ return value.replace(/[&<>]/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[char]!);
66
+ }
67
+
68
+ export function buildFilePreview(filePath: string, data: Buffer): FilePreviewData {
69
+ const name = basename(filePath);
70
+ 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";
72
+ const canHighlight = data.byteLength <= MAX_HIGHLIGHT_BYTES && Boolean(hljs.getLanguage(language));
73
+ let highlightedHtml = escapeHtml(text);
74
+ let highlighted = false;
75
+
76
+ if (canHighlight) {
77
+ try {
78
+ highlightedHtml = hljs.highlight(text, { language, ignoreIllegals: true }).value;
79
+ highlighted = true;
80
+ } catch {
81
+ // Plain escaped text remains readable when a grammar rejects the input.
82
+ }
83
+ }
84
+
85
+ return {
86
+ path: filePath,
87
+ name,
88
+ language,
89
+ highlighted,
90
+ highlightedHtml,
91
+ lineCount: text.split("\n").length,
92
+ size: data.byteLength,
93
+ };
94
+ }