@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.
- package/LICENSE +21 -0
- package/README.md +90 -0
- package/THIRD_PARTY_NOTICES.md +90 -0
- package/abort-session.ts +150 -0
- package/attachment-store.ts +161 -0
- package/file-preview.ts +94 -0
- package/index.ts +177 -0
- package/local-session.ts +190 -0
- package/package.json +70 -0
- package/pi-rpc.ts +253 -0
- package/server.ts +3060 -0
- package/sse-channel.ts +72 -0
- package/state-store.ts +64 -0
- package/transcript.ts +328 -0
- package/web/app.css +535 -0
- package/web/app.js +4228 -0
- package/web/assets/fonts/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web/assets/fonts/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/web/assets/fonts/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/web/assets/fonts/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/web/assets/fonts/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/web/assets/fonts/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/web/assets/fonts/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/web/assets/fonts/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/web/assets/fonts/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/web/assets/fonts/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/web/assets/fonts/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/web/assets/fonts/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/web/assets/fonts/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/web/assets/fonts/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/web/assets/fonts/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/web/assets/fonts/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/web/assets/fonts/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/web/assets/fonts/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/web/assets/fonts/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/web/assets/fonts/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/web/assets/fonts/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/web/assets/fonts/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/web/assets/fonts/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/web/assets/fonts/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web/assets/fonts/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/web/assets/fonts/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web/assets/fonts/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/web/assets/fonts/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web/assets/fonts/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/web/assets/fonts/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/web/assets/fonts/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/web/assets/fonts/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/web/assets/fonts/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/web/assets/fonts/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/web/assets/fonts/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/web/assets/fonts/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/web/assets/fonts/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/web/assets/fonts/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/web/assets/fonts/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/web/assets/fonts/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/web/assets/fonts/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/web/assets/fonts/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/web/assets/fonts/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/web/assets/fonts/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web/assets/fonts/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/web/favicon.svg +4 -0
- package/web/index.html +25 -0
- package/web/manifest.webmanifest +9 -0
- 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.
|
package/abort-session.ts
ADDED
|
@@ -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
|
+
}
|
package/file-preview.ts
ADDED
|
@@ -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) => ({ "&": "&", "<": "<", ">": ">" })[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
|
+
}
|