@orderg-x/dsh-video-preview 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +134 -2
- package/assets/demo.gif +0 -0
- package/assets/hero.png +0 -0
- package/assets/hero.svg +70 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +5 -0
- package/lib/auto-present.js +267 -0
- package/lib/client.js +696 -0
- package/lib/index.js +455 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +53 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OrderG-X
|
|
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
CHANGED
|
@@ -1,3 +1,135 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<img src="assets/hero.svg" alt="dsh-video-preview — 模型录屏、配音做完,播放器自动出现在回复下面" width="760" />
|
|
4
|
+
|
|
5
|
+
<p>
|
|
6
|
+
<a href="https://github.com/OrderG-X/dsh-video-preview/releases"><img src="https://img.shields.io/github/v/release/OrderG-X/dsh-video-preview?label=release&color=4D6BFE" alt="release" /></a>
|
|
7
|
+
<img src="https://img.shields.io/badge/license-MIT-blue" alt="License: MIT" />
|
|
8
|
+
<a href="https://github.com/awesome-dsh-plugin/awesome-dsh-plugin"><img src="https://img.shields.io/badge/DSH-plugin-4D6BFE" alt="DSH plugin" /></a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
让 DSH(DeepSeek Harness)的对话流里直接播放本地视频 / 音频:模型录屏、转码、配音生成的文件,回合结束时播放器自动出现在回复正下方——Claude 桌面端 / Codex 同款位置,不用点任何东西,也不用模型专门调工具。说一句「播一下 ~/Movies/a.mp4」也行。
|
|
16
|
+
|
|
17
|
+
> **一句话**:DeepSeek Harness(DSH)插件,在对话里直接播放本地视频和音频。模型录屏、转码、配音做出来的文件,播放器自动出现在回复下面;别的文件用 `play_video` 工具播。
|
|
18
|
+
>
|
|
19
|
+
> **什么时候用**:「录个屏」「把视频转成 mp4」「生成一段配音」之后,想直接在对话里看、听。
|
|
20
|
+
>
|
|
21
|
+
> **安装**:`dsh plugin add @orderg-x/dsh-video-preview`(npm 包),重启 DSH。
|
|
22
|
+
>
|
|
23
|
+
> **English:** a DeepSeek Harness (DSH) plugin that plays local video and audio inline in the chat. Files the model makes (screen recordings, transcodes, voiceovers) get a player under its reply automatically, and the `play_video` tool plays any other file. Install with the `dsh plugin add` command above, then restart DSH.
|
|
24
|
+
|
|
25
|
+

|
|
26
|
+
|
|
27
|
+
## 安装
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
dsh plugin add @orderg-x/dsh-video-preview
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
也可以直接装发布包:`dsh plugin add https://github.com/OrderG-X/dsh-video-preview/releases/latest/download/dsh-video-preview.tgz`。装完**重启 DSH**(客户端代码在 DSH 启动时打包,不重启看不到播放器)。DSH「插件」页里显示为「视频播放」。
|
|
34
|
+
|
|
35
|
+
> **从 0.3.x 升级**:0.4.0 起包名改成 `@orderg-x/dsh-video-preview`(npm 上的 `dsh-video-preview` 是别人的包)。装上新版后,到「插件」页把旧的「dsh-video-preview」移除,再重启 DSH。旧版还在时新版不会启动,旧版照常能用;以前对话里的视频链接都还能播。
|
|
36
|
+
|
|
37
|
+
开发时可以直接链到本地目录:`dsh plugin --profile desktop add link:/path/to/dsh-video-preview`,改了代码同样要重启。
|
|
38
|
+
|
|
39
|
+
## 它解决什么
|
|
40
|
+
|
|
41
|
+
DSH 的消息渲染块只有 text / image / file / table,**没有 video**,工具调用块又默认折叠。这个插件用纯官方插件 API 补上内联播放(零 `@deepseek-ai/*` 依赖,和官方 `present` 工具同款机制)。
|
|
42
|
+
|
|
43
|
+
一次 `play_video` 调用后,用户能看到:
|
|
44
|
+
|
|
45
|
+
1. **回复下方的播放器**(主显示位)——小卡片,一个一行:
|
|
46
|
+
- 视频只露画面(200 高、按原比例),角上标时长;鼠标移上去静音预览、浮出文件名 / 大小 / 下载,点一下从头带声音播
|
|
47
|
+
- 音频是一条细条:圆形播放键、文件名、大小、时长、可拖的进度条
|
|
48
|
+
- 超过 3 个先折叠成「还有 N 个媒体文件」;同一时间只有一个在出声
|
|
49
|
+
- 配色、圆角、字体用 DSH 自己的设计变量,深浅色主题跟着走
|
|
50
|
+
2. **交付文件芯片**——点「在侧边栏预览」打开大屏播放器(DSH 原生把 mp4 列为「不可预览」,这里接管)
|
|
51
|
+
3. **工具块里的小播放器**(兜底)——展开工具调用块后可见
|
|
52
|
+
|
|
53
|
+
模型用自带 `present` 工具交付的媒体文件,也会自动出现播放器。
|
|
54
|
+
|
|
55
|
+
## 自动播放(不用调工具)
|
|
56
|
+
|
|
57
|
+
模型这一轮里生成的视频 / 音频(录屏、ffmpeg / avconvert 转码、`say` 配音……),回合收尾时自动挂上播放器:
|
|
58
|
+
|
|
59
|
+
- **怎么找**:从这一轮的命令参数、命令输出、回复正文里挖出媒体文件路径(带空格、中文、`~`、相对路径、shell 转义都认)
|
|
60
|
+
- **挂哪些**:只挂这一轮里新写出来的文件(刚下载、解压出来的也算),以前就有的不挂;回复正文提到的优先,没提才用命令里的,中间文件不刷屏;一轮最多 8 个
|
|
61
|
+
- **不重复**:已经用 `play_video` / `present` 播过的不再挂
|
|
62
|
+
- **中途停掉**:来不及在收尾时挂,会在回合结束后补上(重新打开会话可见)
|
|
63
|
+
|
|
64
|
+
## 用法
|
|
65
|
+
|
|
66
|
+
对话里直接说「播一下 /Users/me/Movies/a.mp4」,模型会调 `play_video`。参数:
|
|
67
|
+
|
|
68
|
+
| 参数 | 必填 | 说明 |
|
|
69
|
+
| --- | --- | --- |
|
|
70
|
+
| `path` | 是 | 本地文件路径(支持 `~`;相对路径按会话的工作目录算) |
|
|
71
|
+
| `title` | 否 | 播放器标题,默认文件名 |
|
|
72
|
+
| `autoplay` | 否 | 自动播放(浏览器通常会拦) |
|
|
73
|
+
| `loop` | 否 | 循环播放 |
|
|
74
|
+
|
|
75
|
+
## 能播什么
|
|
76
|
+
|
|
77
|
+
DSH 的播放器是 Chromium 内核,能播什么由它决定:
|
|
78
|
+
|
|
79
|
+
| | 格式 |
|
|
80
|
+
| --- | --- |
|
|
81
|
+
| 能播 | 视频 mp4 / m4v / mov / webm / ogv,音频 mp3 / wav / ogg / m4a / aac / flac / opus |
|
|
82
|
+
| 看编码 | mkv / 3gp:要看文件里的编码,播不了会在播放器下面说明并给出转码命令 |
|
|
83
|
+
| 播不了 | avi / flv / wmv / wma / ts / mpg / rmvb 等 —— `play_video` 会直接说明,并给出转码命令 |
|
|
84
|
+
|
|
85
|
+
转码(需要 [ffmpeg](https://ffmpeg.org/),macOS 上 `brew install ffmpeg`):
|
|
86
|
+
|
|
87
|
+
```sh
|
|
88
|
+
ffmpeg -i in.avi -c:v libx264 -c:a aac -movflags +faststart out.mp4 # 视频
|
|
89
|
+
ffmpeg -i in.wma -c:a aac out.m4a # 音频
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
播放出错时,播放器下面会告诉你是哪种情况:文件被移走了、没有权限、插件没加载(要重启 DSH),还是编码不支持(附转码命令)。
|
|
93
|
+
|
|
94
|
+
## 安全
|
|
95
|
+
|
|
96
|
+
DSH 的本地网页服务本身不做鉴权,浏览器里任何网页都摸得到 `127.0.0.1`,所以两条播放路由自己把门:
|
|
97
|
+
|
|
98
|
+
- **只给媒体文件**:按扩展名判断,软链接按它指向的真实文件算;别的文件一律 403。
|
|
99
|
+
- **防 DNS 重绑定和跨站**:Host 必须是本机名或 DSH 自己的地址;跨站请求、Origin 不是 DSH 自己的都拒。
|
|
100
|
+
- **签名地址**(`/dsh-video-preview/media`):token = HMAC(密钥, 文件绝对路径),密钥存在 `~/.dsh/cache/dsh-video-preview.secret`(0600),同一台机器上跨重启、跨宿主都有效。
|
|
101
|
+
- **侧边栏 / 回复下方的地址**(`/dsh-video-preview/preview`,没有 token):交给 DSH 自己的 `connection` 服务验登录 cookie;老版本 DSH 没有这个服务时只收本机连接。
|
|
102
|
+
|
|
103
|
+
v0.3.0 之前的版本只看 cookie 名字,网页能伪造 cookie 读到任意文件,请升级。
|
|
104
|
+
|
|
105
|
+
## 架构
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
服务端 play_video 工具 ──校验文件──▶ 返回带签名的 URL
|
|
109
|
+
│
|
|
110
|
+
└─append deliverables/presented 事件(官方 present 工具同款通道)
|
|
111
|
+
自动 session/event 收集这一轮的路径 → agent/turn-stopping 时挑出新文件 → 同一条通道
|
|
112
|
+
路由 /dsh-video-preview/media 签名 token · HTTP Range 流式(大文件不进内存)
|
|
113
|
+
/dsh-video-preview/preview DSH 登录态 · 供侧边栏 / 回复下方的播放器
|
|
114
|
+
客户端 turnTail 槽 / documentPreviews 渲染器 / toolview 槽
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
- `lib/index.js` —— 服务端:工具 + deliverables 事件 + 两条流式路由(读盘出错只断当前请求,不会带崩 DSH)
|
|
118
|
+
- `lib/auto-present.js` —— 自动播放:挖路径、挑新文件、回合收尾前呈现(回合结束后才 append 的话,界面要重开会话才刷出来)
|
|
119
|
+
- `lib/client.js` —— 客户端:回复下方播放器 / 侧边栏预览 / 工具块兜底 / 出错诊断(`exports.inject` 是客户端**短服务名**,写包名会一直 pending)
|
|
120
|
+
- `locale/*.json`、`icon.svg` —— DSH 插件页的卡片标题、说明和图标
|
|
121
|
+
- `cordis.patch.yml` —— bundle patch(insert 行的 `name` 必须是完整包名,客户端按它解析 bundle)
|
|
122
|
+
|
|
123
|
+
## 测试
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
node test/test-routes.mjs # 把门、Range、HEAD、读盘出错、工具的格式提示
|
|
127
|
+
node test/test-client.mjs # 三个挂载点、视频卡 / 音频条、折叠、出错诊断
|
|
128
|
+
node test/test-auto.mjs # 自动播放:新 / 老文件、正文优先、各种路径写法、不重复、重放不动手
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## 已验证
|
|
132
|
+
|
|
133
|
+
DeepSeek Harness 0.2.1-rc.2 桌面端端到端实测:回复下方播放器(mp4 / HEVC 的 mov / m4a,中文文件名)、侧边栏预览和播放、avi 的转码提示、文件被移走后的出错提示;伪造登录 cookie 的请求被拒;模型用 say / avconvert 生成的 m4a、mp4(含带空格的中文文件名)没调 `play_video`,回合结束时播放器实时出现且能播;视频卡悬停预览 / 点击播放 / 下载、音频条播放、超过 3 个时折叠展开、深浅两种主题下的配色。
|
|
134
|
+
|
|
135
|
+
参考实现:[@goodandready/dsh-image-gen](https://www.npmjs.com/package/@goodandready/dsh-image-gen)(客户端插件套路)与官方 `dsh-tool-present`(deliverables 通道)。
|
package/assets/demo.gif
ADDED
|
Binary file
|
package/assets/hero.png
ADDED
|
Binary file
|
package/assets/hero.svg
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="760" height="210" viewBox="0 0 760 210" role="img" aria-label="dsh-video-preview">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#0d1117"/>
|
|
5
|
+
<stop offset="1" stop-color="#161b22"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
|
|
8
|
+
<stop offset="0" stop-color="#4D6BFE" stop-opacity="0.55"/>
|
|
9
|
+
<stop offset="1" stop-color="#4D6BFE" stop-opacity="0"/>
|
|
10
|
+
</radialGradient>
|
|
11
|
+
</defs>
|
|
12
|
+
|
|
13
|
+
<style>
|
|
14
|
+
.float { animation: float 3.4s ease-in-out infinite; }
|
|
15
|
+
@keyframes float { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
|
|
16
|
+
.halo { animation: halo 3.4s ease-in-out infinite; transform-box: fill-box; transform-origin: center; }
|
|
17
|
+
@keyframes halo { 0%, 100% { opacity: 0.75; transform: scale(1); } 50% { opacity: 1; transform: scale(1.08); } }
|
|
18
|
+
.ripple { fill: none; stroke: #4D6BFE; stroke-width: 2; opacity: 0; transform-box: fill-box; transform-origin: center;
|
|
19
|
+
animation: ripple 3.4s cubic-bezier(0.2, 0.6, 0.4, 1) infinite; }
|
|
20
|
+
.r2 { animation-delay: 1.7s; }
|
|
21
|
+
@keyframes ripple { 0% { transform: scale(0.55); opacity: 0.85; } 70% { transform: scale(1.7); opacity: 0; } 100% { transform: scale(1.7); opacity: 0; } }
|
|
22
|
+
.press { transform-box: fill-box; transform-origin: center; animation: press 3.4s ease-in-out infinite; }
|
|
23
|
+
@keyframes press { 0%, 55%, 100% { transform: scale(1); } 62% { transform: scale(0.9); } 70% { transform: scale(1); } }
|
|
24
|
+
.pill { animation: pill 3.4s ease-in-out infinite; }
|
|
25
|
+
.p2 { animation-delay: 0.12s; } .p3 { animation-delay: 0.24s; } .p4 { animation-delay: 0.36s; }
|
|
26
|
+
@keyframes pill { 0%, 60%, 100% { opacity: 1; } 66% { opacity: 0.72; } }
|
|
27
|
+
</style>
|
|
28
|
+
|
|
29
|
+
<rect width="760" height="210" rx="18" fill="url(#bg)"/>
|
|
30
|
+
<rect x="0.5" y="0.5" width="759" height="209" rx="17.5" fill="none" stroke="#30363d"/>
|
|
31
|
+
|
|
32
|
+
<circle class="halo" cx="76" cy="92" r="62" fill="url(#glow)"/>
|
|
33
|
+
<circle class="ripple" cx="76" cy="92" r="30"/>
|
|
34
|
+
<circle class="ripple r2" cx="76" cy="92" r="30"/>
|
|
35
|
+
|
|
36
|
+
<g class="float">
|
|
37
|
+
<g class="press">
|
|
38
|
+
<circle cx="76" cy="92" r="36" fill="#4D6BFE"/>
|
|
39
|
+
<path d="M67 75v34l27-17z" fill="#ffffff"/>
|
|
40
|
+
</g>
|
|
41
|
+
</g>
|
|
42
|
+
|
|
43
|
+
<text x="146" y="86" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif"
|
|
44
|
+
font-size="38" font-weight="700" fill="#ffffff" letter-spacing="-0.5">dsh-video-preview</text>
|
|
45
|
+
|
|
46
|
+
<text x="146" y="116" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif"
|
|
47
|
+
font-size="14.5" fill="#8b949e">Inline video and audio for DSH chats: the player shows up under the reply.</text>
|
|
48
|
+
|
|
49
|
+
<text x="146" y="138" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif"
|
|
50
|
+
font-size="14.5" fill="#8b949e">模型录屏、配音做完,播放器自动出现在回复下面,不用调工具。</text>
|
|
51
|
+
|
|
52
|
+
<g font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif" font-size="12" font-weight="600">
|
|
53
|
+
<g class="pill">
|
|
54
|
+
<rect x="146" y="156" width="126" height="26" rx="13" fill="#4D6BFE" fill-opacity="0.16" stroke="#4D6BFE" stroke-opacity="0.45"/>
|
|
55
|
+
<text x="209" y="173" fill="#8ea6ff" text-anchor="middle">inline player</text>
|
|
56
|
+
</g>
|
|
57
|
+
<g class="pill p2">
|
|
58
|
+
<rect x="280" y="156" width="141" height="26" rx="13" fill="#4D6BFE" fill-opacity="0.16" stroke="#4D6BFE" stroke-opacity="0.45"/>
|
|
59
|
+
<text x="350.5" y="173" fill="#8ea6ff" text-anchor="middle">Range streaming</text>
|
|
60
|
+
</g>
|
|
61
|
+
<g class="pill p3">
|
|
62
|
+
<rect x="429" y="156" width="97" height="26" rx="13" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.4"/>
|
|
63
|
+
<text x="477.5" y="173" fill="#7ee787" text-anchor="middle">auto-play</text>
|
|
64
|
+
</g>
|
|
65
|
+
<g class="pill p4">
|
|
66
|
+
<rect x="534" y="156" width="67" height="26" rx="13" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.4"/>
|
|
67
|
+
<text x="567.5" y="173" fill="#7ee787" text-anchor="middle">audio</text>
|
|
68
|
+
</g>
|
|
69
|
+
</g>
|
|
70
|
+
</svg>
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
|
|
2
|
+
<rect width="128" height="128" rx="28" fill="#4D6BFE"/>
|
|
3
|
+
<rect x="18" y="28" width="92" height="72" rx="12" fill="none" stroke="#FFFFFF" stroke-width="7"/>
|
|
4
|
+
<path d="M55 47 L55 81 L83 64 Z" fill="#FFFFFF" stroke="#FFFFFF" stroke-width="5" stroke-linejoin="round"/>
|
|
5
|
+
</svg>
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自动呈现:这一轮里新生成的视频 / 音频,不用模型调 play_video,回合结束时自动挂上播放器。
|
|
3
|
+
*
|
|
4
|
+
* 做法(全走官方事件,不改 DSH):
|
|
5
|
+
* session/event 里盯住这一轮的 tool/call(参数)、tool/result(输出)、assistant/message(回复正文),
|
|
6
|
+
* 从里面挖出像媒体文件路径的字符串;回合收尾前(agent/turn-stopping)逐个 stat,
|
|
7
|
+
* 留下「存在、是普通文件、这一轮里才写过」的,append 一条 deliverables/presented ——
|
|
8
|
+
* 和 play_video / 官方 present 同一条通道,turnTail 播放器和交付芯片自然就出来了。
|
|
9
|
+
*
|
|
10
|
+
* 去噪:回复正文里提到的新文件优先(那是模型要交给用户的);正文一个都没提,才用命令里挖到的。
|
|
11
|
+
* 老文件(这一轮之前就有的)不算:修改时间、创建时间、ctime 任一落在这一轮里就算新
|
|
12
|
+
* (下载、解压出来的文件修改时间常是旧的);同一轮已经呈现过的不重复;最多 MAX_FILES 个。
|
|
13
|
+
* 收尾钩子没跑到(中途停掉)才在实时的 turn/end 上补;重放历史时事件时间是旧的,不会往老会话里补东西。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs'
|
|
17
|
+
import os from 'node:os'
|
|
18
|
+
import path from 'node:path'
|
|
19
|
+
|
|
20
|
+
const EXT_RE = /\.(mp4|m4v|mov|webm|ogv|mkv|3gp|mp3|wav|ogg|oga|m4a|aac|flac|opus|weba)(?![A-Za-z0-9_])/gi
|
|
21
|
+
// 路径只会从这些字符后面开始(空白、引号、括号、= : , 等,含中文标点)
|
|
22
|
+
const BEFORE = new Set([...' \t"\'`=:,;()[]{}<>|*()「」『』【】《》“”‘’,、:;'])
|
|
23
|
+
const MAX_STARTS = 12 // 每个扩展名往回最多试这么多个起点
|
|
24
|
+
const LOOKBACK = 1024 // 往回最多看这么远
|
|
25
|
+
const MAX_TOOL_GROUPS = 300
|
|
26
|
+
const MAX_TEXT_GROUPS = 100
|
|
27
|
+
const MAX_FILES = 8
|
|
28
|
+
const CLOCK_SLACK_MS = 2000 // 文件时间和事件时间的误差余量
|
|
29
|
+
const LIVE_MS = 120000 // turn/end 离现在超过这么久就当是重放
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 从一段文字里挖出可能是媒体路径的片段。
|
|
33
|
+
* 每处 .mp4 之类的结尾给一组候选:从长到短(「/Users/me/My Movies/a.mp4」排在「Movies/a.mp4」前面),
|
|
34
|
+
* 检查时取第一个真实存在的,这样带空格的路径也能认出来。
|
|
35
|
+
*/
|
|
36
|
+
export function candidatesIn(text) {
|
|
37
|
+
const groups = []
|
|
38
|
+
if (typeof text !== 'string' || !text) return groups
|
|
39
|
+
for (const line of text.split(/\r?\n/)) {
|
|
40
|
+
EXT_RE.lastIndex = 0
|
|
41
|
+
let m
|
|
42
|
+
while ((m = EXT_RE.exec(line))) {
|
|
43
|
+
const end = m.index + m[0].length
|
|
44
|
+
const from = Math.max(0, end - LOOKBACK)
|
|
45
|
+
const starts = []
|
|
46
|
+
for (let s = m.index - 1; s >= from && starts.length < MAX_STARTS; s--) {
|
|
47
|
+
if (BEFORE.has(line[s])) continue
|
|
48
|
+
if (s === 0 || BEFORE.has(line[s - 1])) starts.push(s)
|
|
49
|
+
}
|
|
50
|
+
if (starts.length) groups.push(starts.reverse().map((s) => line.slice(s, end)))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return groups
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 工具参数是 JSON 字符串:拆开把所有字符串值都拿出来(命令里的引号就不会挡路了) */
|
|
57
|
+
function stringsOf(value, out = []) {
|
|
58
|
+
if (typeof value === 'string') {
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(value)
|
|
61
|
+
if (parsed && typeof parsed === 'object') return stringsOf(parsed, out)
|
|
62
|
+
} catch { /* 不是 JSON,就当普通文字 */ }
|
|
63
|
+
out.push(value)
|
|
64
|
+
} else if (Array.isArray(value)) {
|
|
65
|
+
for (const v of value) stringsOf(v, out)
|
|
66
|
+
} else if (value && typeof value === 'object') {
|
|
67
|
+
for (const v of Object.values(value)) stringsOf(v, out)
|
|
68
|
+
}
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 消息里的文字块;assistant 的 reasoning 不算(那是打算,不是结果) */
|
|
73
|
+
function textsOf(content) {
|
|
74
|
+
if (typeof content === 'string') return [content]
|
|
75
|
+
if (!Array.isArray(content)) return []
|
|
76
|
+
return content.filter((c) => c && c.type === 'text' && typeof c.text === 'string').map((c) => c.text)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 会话现在的工作目录:DSH 的 workingDirectory 投影(会话里切过目录就是新的),
|
|
81
|
+
* 没有就用会话头里建会话时的目录。都拿不到返回空串。
|
|
82
|
+
*/
|
|
83
|
+
export function sessionCwd(ctx, session) {
|
|
84
|
+
let cwd
|
|
85
|
+
try {
|
|
86
|
+
cwd = ctx && ctx.sessionProjections && ctx.sessionProjections.stateOf(session, 'workingDirectory')
|
|
87
|
+
} catch { /* 没装这个投影 */ }
|
|
88
|
+
if (typeof cwd !== 'string' || !cwd) cwd = session && session.header && session.header.cwd
|
|
89
|
+
return typeof cwd === 'string' && path.isAbsolute(cwd) ? cwd : ''
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 候选片段 → 每种写法一组绝对路径(相对路径按工作目录挨个试,越靠后的目录越新)。
|
|
94
|
+
* 写法按顺序:字面的先试(文件名本身就可能带 %20),再试 URL 解码、shell 转义去掉的。
|
|
95
|
+
*/
|
|
96
|
+
function resolveAll(raw, cwds) {
|
|
97
|
+
const p = raw.replace(/^file:\/\//i, '')
|
|
98
|
+
const variants = [p]
|
|
99
|
+
if (p.includes('%')) {
|
|
100
|
+
try {
|
|
101
|
+
const d = decodeURIComponent(p)
|
|
102
|
+
if (d !== p) variants.push(d)
|
|
103
|
+
} catch { /* 不是编码过的,只用字面的 */ }
|
|
104
|
+
}
|
|
105
|
+
for (const v of [...variants]) {
|
|
106
|
+
if (v.includes('\\')) variants.push(v.replace(/\\(.)/g, '$1')) // shell 里转义的空格:My\ Movies
|
|
107
|
+
}
|
|
108
|
+
return variants.map((v) => {
|
|
109
|
+
if (v === '~' || v.startsWith('~/')) v = path.join(os.homedir(), v.slice(1))
|
|
110
|
+
return path.isAbsolute(v) ? [path.normalize(v)] : cwds.map((c) => path.resolve(c, v))
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param ctx 插件 ctx(要 ctx.on)
|
|
116
|
+
* @param opts.kindOf 判断媒体类型(和路由同一把尺子)
|
|
117
|
+
* @param opts.now 测试用
|
|
118
|
+
* @param opts.log 出错时写日志
|
|
119
|
+
*/
|
|
120
|
+
export function autoPresent(ctx, { kindOf, now = Date.now, log = () => {} }) {
|
|
121
|
+
const turns = new WeakMap()
|
|
122
|
+
|
|
123
|
+
function addGroups(st, texts, tier) {
|
|
124
|
+
const max = tier === 'text' ? MAX_TEXT_GROUPS : MAX_TOOL_GROUPS
|
|
125
|
+
const list = st[tier]
|
|
126
|
+
for (const t of texts) {
|
|
127
|
+
for (const g of candidatesIn(t)) {
|
|
128
|
+
if (list.length >= max) return
|
|
129
|
+
const key = g[0]
|
|
130
|
+
if (st.seen.has(tier + key)) continue
|
|
131
|
+
st.seen.add(tier + key)
|
|
132
|
+
list.push(g)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 这一轮里写过:修改时间、创建时间、ctime 任一落在这一轮里(下载、解压出来的修改时间常是旧的) */
|
|
138
|
+
function isFresh(stat, st) {
|
|
139
|
+
const t = Math.max(stat.mtimeMs || 0, stat.ctimeMs || 0, stat.birthtimeMs || 0)
|
|
140
|
+
return stat.isFile() && stat.size > 0 && t >= st.start - CLOCK_SLACK_MS
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 一组候选里取第一个存在的;它要是这一轮新写的媒体文件就返回路径。
|
|
145
|
+
* 相对路径在好几个工作目录下都有:旧的跳过接着看,都是新的取最后一个目录(最近用过的)。
|
|
146
|
+
*/
|
|
147
|
+
async function pick(group, st, cwds) {
|
|
148
|
+
for (const raw of group) {
|
|
149
|
+
for (const paths of resolveAll(raw, cwds)) {
|
|
150
|
+
let exists = false
|
|
151
|
+
let hit = null
|
|
152
|
+
for (const p of paths) {
|
|
153
|
+
let stat
|
|
154
|
+
try { stat = await fs.promises.stat(p) } catch { continue }
|
|
155
|
+
exists = true
|
|
156
|
+
if (isFresh(stat, st) && kindOf(p)) hit = p
|
|
157
|
+
}
|
|
158
|
+
if (exists) return hit
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 这些候选里「这一轮新写的媒体文件」,按出现顺序去重 */
|
|
165
|
+
async function freshIn(groups, st, cwds) {
|
|
166
|
+
const found = []
|
|
167
|
+
for (const g of groups) {
|
|
168
|
+
if (found.length >= MAX_FILES * 2) break
|
|
169
|
+
const p = await pick(g, st, cwds)
|
|
170
|
+
if (p && !found.includes(p)) found.push(p)
|
|
171
|
+
}
|
|
172
|
+
return found
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 挑出要呈现的文件并 append。回合收尾前(agent/turn-stopping)调一次,界面实时就能看到;
|
|
177
|
+
* 收尾钩子没跑到(中途停掉、出错)才在 turn/end 补一次,那时要重新打开会话才看得到。
|
|
178
|
+
* 可以重复调:已经呈现过的不会再来。
|
|
179
|
+
*/
|
|
180
|
+
async function finish(session, st) {
|
|
181
|
+
st.finished = true
|
|
182
|
+
// 工具跑过的目录按最近用过的顺序排;会话的工作目录没被工具用过就排最前
|
|
183
|
+
const base = sessionCwd(ctx, session)
|
|
184
|
+
const cwds = [...st.cwds]
|
|
185
|
+
if (base && !st.cwds.has(base)) cwds.unshift(base)
|
|
186
|
+
// 正文里提到了新文件,就只认正文的(哪怕已经用 play_video 播过),命令里的中间文件不挂
|
|
187
|
+
let fresh = await freshIn(st.text, st, cwds)
|
|
188
|
+
if (!fresh.length) fresh = await freshIn(st.tool, st, cwds)
|
|
189
|
+
const files = fresh.filter((p) => !st.presented.has(p)).slice(0, Math.max(0, MAX_FILES - st.auto))
|
|
190
|
+
if (!files.length || typeof session.append !== 'function') return
|
|
191
|
+
for (const p of files) st.presented.add(p)
|
|
192
|
+
st.auto += files.length
|
|
193
|
+
session.append('deliverables/presented', {
|
|
194
|
+
turn: st.turn,
|
|
195
|
+
callId: `dsh-video-preview:auto:${st.turn}:${++st.batches}`,
|
|
196
|
+
files: files.map((p) => ({ path: p, description: `▶️ ${path.basename(p)}` })),
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function observe(session, event) {
|
|
201
|
+
const d = (event && event.data) || {}
|
|
202
|
+
if (event.type === 'turn/start') {
|
|
203
|
+
turns.set(session, {
|
|
204
|
+
turn: d.turn,
|
|
205
|
+
start: typeof event.time === 'number' ? event.time : now(),
|
|
206
|
+
cwds: new Set(), // 这一轮工具跑过的目录,最近用过的排最后
|
|
207
|
+
text: [],
|
|
208
|
+
tool: [],
|
|
209
|
+
seen: new Set(),
|
|
210
|
+
presented: new Set(),
|
|
211
|
+
auto: 0,
|
|
212
|
+
batches: 0,
|
|
213
|
+
finished: false,
|
|
214
|
+
})
|
|
215
|
+
return
|
|
216
|
+
}
|
|
217
|
+
const st = turns.get(session)
|
|
218
|
+
if (!st || (d.turn !== undefined && d.turn !== st.turn)) return
|
|
219
|
+
switch (event.type) {
|
|
220
|
+
case 'tool/call':
|
|
221
|
+
addGroups(st, stringsOf(d.arguments), 'tool')
|
|
222
|
+
break
|
|
223
|
+
case 'tool/result':
|
|
224
|
+
if (d.meta && typeof d.meta.cwd === 'string' && path.isAbsolute(d.meta.cwd)) {
|
|
225
|
+
st.cwds.delete(d.meta.cwd)
|
|
226
|
+
st.cwds.add(d.meta.cwd)
|
|
227
|
+
}
|
|
228
|
+
addGroups(st, textsOf(d.message && d.message.content), 'tool')
|
|
229
|
+
break
|
|
230
|
+
case 'assistant/message':
|
|
231
|
+
addGroups(st, textsOf(d.message && d.message.content), 'text')
|
|
232
|
+
break
|
|
233
|
+
case 'deliverables/presented':
|
|
234
|
+
for (const f of Array.isArray(d.files) ? d.files : []) {
|
|
235
|
+
if (f && typeof f.path === 'string') st.presented.add(path.normalize(f.path))
|
|
236
|
+
}
|
|
237
|
+
break
|
|
238
|
+
case 'turn/end': {
|
|
239
|
+
turns.delete(session)
|
|
240
|
+
const t = typeof event.time === 'number' ? event.time : now()
|
|
241
|
+
if (st.finished || now() - t > LIVE_MS) return
|
|
242
|
+
finish(session, st).catch((e) => log(e))
|
|
243
|
+
break
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
ctx.on('session/event', (session, event) => {
|
|
249
|
+
try {
|
|
250
|
+
if (session && event) observe(session, event)
|
|
251
|
+
} catch (e) {
|
|
252
|
+
log(e)
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
// 回合正常收尾前:这时候 append,事件还在这一轮里,界面实时刷出播放器
|
|
257
|
+
ctx.on('agent/turn-stopping', async ({ agent, turn, signal } = {}) => {
|
|
258
|
+
try {
|
|
259
|
+
const session = agent && agent.session
|
|
260
|
+
const st = session && turns.get(session)
|
|
261
|
+
if (!st || st.turn !== turn || (signal && signal.aborted)) return
|
|
262
|
+
await finish(session, st)
|
|
263
|
+
} catch (e) {
|
|
264
|
+
log(e)
|
|
265
|
+
}
|
|
266
|
+
})
|
|
267
|
+
}
|