dsh-plugin-image-tools 0.3.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 ADDED
@@ -0,0 +1,161 @@
1
+ # dsh-plugin-image-tools
2
+
3
+ 给 DeepSeek Harness Web GUI 增加**图片能力**的插件,两个工具覆盖两种场景:
4
+
5
+ | 工具 | 场景 | 效果 |
6
+ |---|---|---|
7
+ | `ask_user_choice` | 模型让用户在**选项**里挑图 | Web GUI 渲染图片选择卡,可放大查看,答案协议与原生一致 |
8
+ | `show_images` | 模型在**回复正文**里展示图片 | 图片与文字混排显示在聊天里,点击可放大 |
9
+
10
+ 图片来源统一支持三种:**本地路径**(相对会话工作区或绝对路径,含 ComfyUI 出图产物)、
11
+ **http(s) URL**(服务端拉取后转存)、**base64 data URI**。纯插件实现,不改核心包。
12
+
13
+ ## 效果图
14
+
15
+ ### 图片选择卡(ask_user_choice)
16
+
17
+ ![图片选择卡](docs/mockup-choice.png)
18
+
19
+ 模型问「选一张封面」,每个选项带一张图,用户点卡片(或放大镜)即可选择。
20
+
21
+ ### 回复内嵌图片(show_images)
22
+
23
+ ![回复内嵌图片](docs/mockup-inline.png)
24
+
25
+ 模型在回复里调用 `show_images`,把返回的 markdown 片段粘贴进正文,图片就随文字一起显示。
26
+
27
+ ## 功能
28
+
29
+ - **`ask_user_choice`**(图片 / 图文混合选项):
30
+ - 每个选项可带一张图片(`path` / `url` / `data`),纯图片、纯文字、图片+文字可同题混排;
31
+ - 支持多题分页、单选/多选、自定义答案、跳过、推荐标注
32
+ (label 末尾 `(Recommended)` / `(推荐)`);
33
+ - 点击缩略图(或放大镜按钮)弹出 Lightbox 大图,Esc / 点遮罩 / 关闭按钮退出。
34
+ - **`show_images`**(回复内嵌图片):
35
+ - 一次展示 1~9 张图,每张可带 `caption` 说明;
36
+ - 工具返回绝对 URL 的 markdown 图片片段,模型原样粘贴进回复正文即随文显示;
37
+ - 客户端插件自动增强这类图片:圆角样式、悬停显示说明、点击放大查看、加载失败降级。
38
+ - 纯文字问题不带图片时,客户端自动放行给原生 UI,互不影响。
39
+
40
+ ## 实现要点(为什么是插件而不是改核心)
41
+
42
+ 浏览器端消费 `question/requested` 帧时用 zod schema 严格解析,选项对象上的未知
43
+ 字段会被剥离;助手消息 content 由模型文本生成,也没有携带结构化图片块的通道。
44
+ 所以图片**不能**塞进 option / content 字段。本插件改为:
45
+
46
+ 1. 服务端把图片字节归一化进内存注册表,通过自定义 web 路由
47
+ `/dsh-plugin-image-tools/<pickId>/<index>`(选择卡)与
48
+ `/dsh-plugin-image-tools/show/<showId>/<index>`(回复内嵌)直接提供字节
49
+ (同源 `<img src>` 加载);
50
+ 2. 选择卡:在问题的 `detail`(标准字符串字段,原样透传)开头写入不可见的
51
+ HTML 注释标记 `<!--dsh-pick:v1:<base64url JSON>-->`,携带 pickId 与带图选项下标;
52
+ 客户端插件在 `conversation.composer` slot 链注册条目(priority 更小,优先于原生),
53
+ 识别标记后渲染图片选择卡;无标记的问题交给原生 UI;
54
+ 3. 回复内嵌:`show_images` 返回绝对 URL(宿主 origin 由 `ctx.webServer.host/port`
55
+ 推导),模型粘贴进正文,核心 markdown 渲染器原生显示;客户端再对
56
+ `/dsh-plugin-image-tools/show/` 前缀的图片做渐进增强(MutationObserver 发现 +
57
+ 单节点样式/事件注入,纯 DOM,不侵入 React 渲染树)。
58
+
59
+ 详见 `设计说明.md`。
60
+
61
+ ## 目录结构
62
+
63
+ ```
64
+ lib/index.js 服务端:两个工具注册 + 图片注册表 + web 路由(零运行时依赖)
65
+ lib/client.js 客户端:composer 链条目 + 图片选择 UI + 内嵌图片增强(浏览器模块加载器格式,免构建)
66
+ scripts/selfcheck.mjs 纯函数自检(node scripts/selfcheck.mjs)
67
+ scripts/smoke-server.mjs 服务端集成冒烟(假 ctx 跑通工具→路由→回答全链路)
68
+ scripts/smoke-client.mjs 客户端冒烟(真实 react 渲染选择卡 + 增强纯函数)
69
+ cordis.patch.yml bundle 补丁(挂载行)
70
+ docs/ 效果图(README 展示用)
71
+ ```
72
+
73
+ ## 安装(web profile)
74
+
75
+ ```powershell
76
+ # 1. 把插件 link 进 profile(已写入 package.json 时跳过这步的编辑)
77
+ cd C:\Users\18303\.dsh\profiles\web
78
+ pnpm install
79
+
80
+ # 2. 重启 dsh(launcher),然后刷新浏览器页面
81
+ ```
82
+
83
+ profile 的 `package.json` 需要包含:
84
+
85
+ ```jsonc
86
+ {
87
+ "dependencies": {
88
+ "dsh-plugin-image-tools": "link:D:/dsh/plugins/dsh-plugin-image-tools"
89
+ },
90
+ "dsh": {
91
+ "profile": {
92
+ "bundles": [ /* ... */, "dsh-plugin-image-tools" ]
93
+ }
94
+ }
95
+ }
96
+ ```
97
+
98
+ `dsh.profile.bundles` 里的包会自动应用其自带 `cordis.patch.yml` 的挂载行
99
+ (与 dsh-notify 同机制)。
100
+
101
+ ## 模型用法示例
102
+
103
+ ### ask_user_choice:看图挑选项
104
+
105
+ ```jsonc
106
+ {
107
+ "questions": [
108
+ {
109
+ "id": "cover",
110
+ "question": "选一张封面图",
111
+ "header": "封面选择",
112
+ "options": [
113
+ { "label": "深海鲸鱼 (Recommended)", "image": { "path": "novel/assets/covers/whale.png" } },
114
+ { "label": "星空",
115
+ "image": { "url": "https://example.com/stars.png" } },
116
+ { "label": "手绘风",
117
+ "image": { "data": "data:image/png;base64,iVBORw0KGgo..." } },
118
+ { "label": "都不选,我自己说", "description": "选这个可以在下方输入自定义答案" }
119
+ ],
120
+ "multi_select": false
121
+ }
122
+ ]
123
+ }
124
+ // 返回:{ "answers": [ { "id": "cover", "selected": ["深海鲸鱼 (Recommended)"] } ] }
125
+ ```
126
+
127
+ ### show_images:回复里展示图片
128
+
129
+ ```jsonc
130
+ // 调用 show_images
131
+ {
132
+ "images": [
133
+ { "image": { "path": "novel/assets/covers/whale.png" }, "caption": "深海鲸鱼封面" },
134
+ { "image": { "url": "https://example.com/stars.png" }, "caption": "星空" }
135
+ ]
136
+ }
137
+ // 返回:{ "markdown": ["![深海鲸鱼封面](http://127.0.0.1:3080/dsh-plugin-image-tools/show/<id>/0)", "![星空](http://127.0.0.1:3080/dsh-plugin-image-tools/show/<id>/1)"], "note": "..." }
138
+ ```
139
+
140
+ 模型把 `markdown` 数组里的片段**原样**逐行粘贴进回复正文,图片随文字显示:
141
+
142
+ ```markdown
143
+ 这是为你生成的封面候选:
144
+
145
+ ![深海鲸鱼封面](http://127.0.0.1:3080/dsh-plugin-image-tools/show/<id>/0)
146
+
147
+ 需要调整配色或构图可以告诉我。
148
+ ```
149
+
150
+ ## 安全与限制
151
+
152
+ - 图片字节仅存于进程内存:选择卡图片随问题回答/取消立即释放;
153
+ 内嵌图片依赖 30 分钟 TTL 清理(需存活到回复渲染完成)。
154
+ - 单张图片上限 20 MiB;仅支持 PNG / JPEG / WebP / GIF(按魔数校验,声明不符会报错)。
155
+ - 图片路由为同源普通 HTTP 路由(与 GUI 同信任级别),未加额外鉴权。
156
+ - 内嵌图片的 markdown URL 是绝对地址(`http://host:port`,由服务端监听配置推导);
157
+ 若 GUI 经过反向代理/换端口访问,历史消息里的图片地址可能失效(同选择卡的限制)。
158
+
159
+ ## 许可证
160
+
161
+ MIT
@@ -0,0 +1,7 @@
1
+ # dsh-plugin-image-tools bundle patch: 挂载一行,交给 profile 的
2
+ # dsh.profile.bundles(与 dsh-notify 同款机制)自动应用。
3
+ # `name` 必须等于 package.json 的 `name`(loader 按该名从 profile 的
4
+ # node_modules 解析插件模块)。
5
+ - insert:
6
+ - id: dsh-plugin-image-tools
7
+ name: dsh-plugin-image-tools
@@ -0,0 +1,185 @@
1
+ # dsh-plugin-image-tools 效果图生成脚本(README 展示用)
2
+ # 用法:powershell -ExecutionPolicy Bypass -File docs\gen-mockups.ps1
3
+ # 输出:docs/mockup-choice.png(图片选择卡)、docs/mockup-inline.png(回复内嵌图片)
4
+ Add-Type -AssemblyName System.Drawing
5
+
6
+ $docs = Join-Path $PSScriptRoot '.'
7
+ $sunset = [System.Drawing.Image]::FromFile((Join-Path $docs '..\..\..\.tmp\pickdemo\sunset.png'))
8
+ $stars = [System.Drawing.Image]::FromFile((Join-Path $docs '..\..\..\.tmp\pickdemo\stars.png'))
9
+ $whale = [System.Drawing.Image]::FromFile((Join-Path $docs '..\..\..\.tmp\pickdemo\whale.png'))
10
+ $font = New-Object System.Drawing.Font('Microsoft YaHei UI', 20, [System.Drawing.FontStyle]::Regular)
11
+ $fontBold = New-Object System.Drawing.Font('Microsoft YaHei UI', 22, [System.Drawing.FontStyle]::Bold)
12
+ $fontSmall = New-Object System.Drawing.Font('Microsoft YaHei UI', 13, [System.Drawing.FontStyle]::Regular)
13
+ $fontTiny = New-Object System.Drawing.Font('Microsoft YaHei UI', 11, [System.Drawing.FontStyle]::Regular)
14
+
15
+ $bg = [System.Drawing.Color]::FromArgb(255, 14, 17, 23)
16
+ $panel = [System.Drawing.Color]::FromArgb(255, 27, 32, 43)
17
+ $border = [System.Drawing.Color]::FromArgb(255, 43, 51, 69)
18
+ $thumbBg = [System.Drawing.Color]::FromArgb(255, 35, 41, 54)
19
+ $text1 = [System.Drawing.Color]::FromArgb(255, 237, 240, 246)
20
+ $text2 = [System.Drawing.Color]::FromArgb(255, 154, 163, 181)
21
+ $text3 = [System.Drawing.Color]::FromArgb(255, 107, 116, 136)
22
+ $accent = [System.Drawing.Color]::FromArgb(255, 76, 125, 255)
23
+ $warnBg = [System.Drawing.Color]::FromArgb(255, 58, 46, 20)
24
+ $warnText = [System.Drawing.Color]::FromArgb(255, 232, 163, 61)
25
+ $white = [System.Drawing.Color]::FromArgb(255, 255, 255, 255)
26
+
27
+ function New-Brush($c) { New-Object System.Drawing.SolidBrush($c) }
28
+ function RoundedPath([float]$x, [float]$y, [float]$w, [float]$h, [float]$r) {
29
+ $p = New-Object System.Drawing.Drawing2D.GraphicsPath
30
+ $d = $r * 2
31
+ $p.AddArc($x, $y, $d, $d, 180, 90)
32
+ $p.AddArc($x + $w - $d, $y, $d, $d, 270, 90)
33
+ $p.AddArc($x + $w - $d, $y + $h - $d, $d, $d, 0, 90)
34
+ $p.AddArc($x, $y + $h - $d, $d, $d, 90, 90)
35
+ $p.CloseFigure()
36
+ return $p
37
+ }
38
+ function Draw-Rounded([System.Drawing.Graphics]$g, $brush, $rect, [float]$radius) {
39
+ $p = RoundedPath $rect.X $rect.Y $rect.Width $rect.Height $radius
40
+ $g.FillPath($brush, $p)
41
+ $p.Dispose()
42
+ }
43
+ function Draw-ImageCrop([System.Drawing.Graphics]$g, [System.Drawing.Image]$img, $rect) {
44
+ # object-fit: cover 的近似:按目标矩形等比裁剪
45
+ $ratio = [Math]::Min($rect.Width / $img.Width, $rect.Height / $img.Height)
46
+ $w = $img.Width * $ratio; $h = $img.Height * $ratio
47
+ $sx = ($rect.Width - $w) / 2; $sy = ($rect.Height - $h) / 2
48
+ $src = New-Object System.Drawing.Rectangle(0, 0, $img.Width, $img.Height)
49
+ $dst = New-Object System.Drawing.RectangleF(($rect.X + $sx), ($rect.Y + $sy), $w, $h)
50
+ $g.DrawImage($img, $dst, $src, [System.Drawing.GraphicsUnit]::Pixel)
51
+ }
52
+ function Text-Len($g, $text, $font) { return $g.MeasureString($text, $font).Width }
53
+
54
+ # ============================================================
55
+ # 效果图 1:图片选择卡(mockup-choice.png)
56
+ # 布局对齐修复后的真实卡片:标题/说明/图片网格/选项行统一左缩进 24px
57
+ # ============================================================
58
+ $W = 1240; $H = 860
59
+ $bmp = New-Object System.Drawing.Bitmap($W, $H)
60
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
61
+ $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
62
+ $g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::ClearTypeGridFit
63
+ $g.Clear($bg)
64
+
65
+ # 卡片
66
+ $cardX = 120.0; $cardY = 60.0; $cardW = 1000.0; $cardH = 720.0
67
+ $inner = $cardX + 24.0 # 统一 24px 左缩进
68
+ $right = $cardX + $cardW - 24.0 # 右侧对称
69
+ Draw-Rounded $g (New-Brush $panel) (New-Object System.Drawing.RectangleF($cardX, $cardY, $cardW, $cardH)) 24
70
+ $g.DrawPath((New-Object System.Drawing.Pen($border, 1.5)), (RoundedPath $cardX $cardY $cardW $cardH 24))
71
+
72
+ # 头部:eyebrow + 标题(24px)
73
+ $g.DrawString('封面选择', $fontTiny, (New-Brush $text3), $inner, 92.0)
74
+ $g.DrawString('选一张封面图', $fontBold, (New-Brush $text1), $inner, 114.0)
75
+
76
+ # 说明文字(与标题左对齐,24px)
77
+ $g.DrawString('三张候选图,点击卡片或放大镜可查看大图。', $fontTiny, (New-Brush $text2), $inner, 158.0)
78
+
79
+ # 图片卡片(3 张,网格左缩进 24px;列宽按可用宽度均分)
80
+ $thumbW = 298.0; $thumbH = 298.0; $gap = 29.0
81
+ $x0 = $inner; $y0 = 210.0
82
+ $imgs = @($sunset, $stars, $whale)
83
+ $labels = @('深海鲸鱼', '星空', '手绘风')
84
+ for ($i = 0; $i -lt 3; $i++) {
85
+ $tx = $x0 + $i * ($thumbW + $gap)
86
+ $tRect = New-Object System.Drawing.RectangleF($tx, $y0, $thumbW, $thumbH)
87
+ # 选中态:第一张带 accent 边框
88
+ if ($i -eq 0) {
89
+ $selPen = New-Object System.Drawing.Pen($accent, 3)
90
+ $g.DrawPath($selPen, (RoundedPath $tx $y0 $thumbW $thumbH 16))
91
+ $selPen.Dispose()
92
+ }
93
+ Draw-Rounded $g (New-Brush $thumbBg) $tRect 16
94
+ # 图(内缩 2px 让边框可见)
95
+ $inner2 = New-Object System.Drawing.RectangleF(($tx + 2), ($y0 + 2), ($thumbW - 4), ($thumbH - 4))
96
+ $clip = New-Object System.Drawing.Region((RoundedPath ($inner2.X) ($inner2.Y) ($inner2.Width) ($inner2.Height) 14))
97
+ $g.SetClip($clip, [System.Drawing.Drawing2D.CombineMode]::Replace)
98
+ Draw-ImageCrop $g $imgs[$i] $inner2
99
+ $g.ResetClip(); $clip.Dispose()
100
+ # 放大镜提示(第一张)
101
+ if ($i -eq 0) {
102
+ $hintRect = New-Object System.Drawing.RectangleF(($tx + 10), ($y0 + 10), 30, 30)
103
+ $g.FillEllipse((New-Brush ([System.Drawing.Color]::FromArgb(140, 145, 160, 180))), $hintRect)
104
+ $g.DrawEllipse((New-Object System.Drawing.Pen($white, 2)), ($tx + 17), ($y0 + 17), 10, 10)
105
+ $g.DrawLine((New-Object System.Drawing.Pen($white, 2.5)), ($tx + 25), ($y0 + 25), ($tx + 30), ($y0 + 30))
106
+ }
107
+ # 选中对勾(第一张)
108
+ if ($i -eq 0) {
109
+ $checkRect = New-Object System.Drawing.RectangleF(($tx + $thumbW - 40), ($y0 + 10), 24, 24)
110
+ $g.FillEllipse((New-Brush $accent), $checkRect)
111
+ $g.DrawString('✓', (New-Object System.Drawing.Font('Microsoft YaHei UI', 15, [System.Drawing.FontStyle]::Bold)), (New-Brush $white), ($tx + $thumbW - 33.5), ($y0 + 9))
112
+ }
113
+ # label + 推荐徽标(24px 对齐)
114
+ $ly = $y0 + $thumbH + 14
115
+ $g.DrawString($labels[$i], $font, (New-Brush $text1), $tx, $ly)
116
+ if ($i -eq 0) {
117
+ $badgeX = $tx + (Text-Len $g $labels[$i] $font) + 12
118
+ $badgeRect = New-Object System.Drawing.RectangleF($badgeX, ($ly + 3), 48, 22)
119
+ Draw-Rounded $g (New-Brush $warnBg) $badgeRect 11
120
+ $g.DrawString('推荐', $fontTiny, (New-Brush $warnText), ($badgeX + 11), ($ly + 2))
121
+ }
122
+ $g.DrawString('点击可放大查看', $fontTiny, (New-Brush $text3), $tx, ($ly + 34))
123
+ }
124
+
125
+ # 页脚:进度 + 按钮(右侧对称 24px)
126
+ $g.DrawString('1 / 1', $font, (New-Brush $text2), $inner, 700.0)
127
+ $skipRect = New-Object System.Drawing.RectangleF(($right - 86 - 12 - 86), 686.0, 86, 36)
128
+ $g.DrawPath((New-Object System.Drawing.Pen($border, 1.5)), (RoundedPath $skipRect.X $skipRect.Y $skipRect.Width $skipRect.Height 18))
129
+ $g.DrawString('跳过', $fontSmall, (New-Brush $text1), ($skipRect.X + 23), 692.0)
130
+ $submitRect = New-Object System.Drawing.RectangleF(($right - 86), 686.0, 86, 36)
131
+ Draw-Rounded $g (New-Brush $accent) $submitRect 18
132
+ $g.DrawString('提交', $fontSmall, (New-Brush $white), ($submitRect.X + 28), 692.0)
133
+
134
+ $g.Dispose()
135
+ $bmp.Save((Join-Path $docs 'mockup-choice.png'), [System.Drawing.Imaging.ImageFormat]::Png)
136
+ $bmp.Dispose()
137
+ Write-Host 'mockup-choice.png saved'
138
+
139
+ # ============================================================
140
+ # 效果图 2:回复内嵌图片(mockup-inline.png)
141
+ # ============================================================
142
+ $W2 = 1240; $H2 = 900
143
+ $bmp2 = New-Object System.Drawing.Bitmap($W2, $H2)
144
+ $g2 = [System.Drawing.Graphics]::FromImage($bmp2)
145
+ $g2.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
146
+ $g2.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::ClearTypeGridFit
147
+ $g2.Clear($bg)
148
+
149
+ # 助手身份行
150
+ $g2.FillEllipse((New-Brush $accent), 120.0, 84.0, 40, 40)
151
+ $g2.DrawString('D', (New-Object System.Drawing.Font('Microsoft YaHei UI', 20, [System.Drawing.FontStyle]::Bold)), (New-Brush $white), 131.0, 88.0)
152
+ $g2.DrawString('DeepSeek Harness', $fontSmall, (New-Brush $text1), 172.0, 90.0)
153
+ $g2.DrawString('刚刚', $fontTiny, (New-Brush $text3), 340.0, 96.0)
154
+
155
+ # 消息正文(markdown 风格)
156
+ $g2.DrawString('这是为你生成的两张候选图,点图片可以放大查看:', $font, (New-Brush $text1), 122.0, 152.0)
157
+
158
+ # 图片(限宽 560,圆角)
159
+ $imgW = 560.0; $imgH = 360.0; $imgX = 122.0; $imgY = 214.0; $imgGap = 34.0
160
+ for ($i = 0; $i -lt 2; $i++) {
161
+ $iy = $imgY + $i * ($imgH + $imgGap)
162
+ $iRect = New-Object System.Drawing.RectangleF($imgX, $iy, $imgW, $imgH)
163
+ Draw-Rounded $g2 (New-Brush $thumbBg) $iRect 16
164
+ $g2.DrawPath((New-Object System.Drawing.Pen($border, 1.5)), (RoundedPath $imgX $iy $imgW $imgH 16))
165
+ $clip2 = New-Object System.Drawing.Region((RoundedPath $imgX $iy $imgW $imgH 16))
166
+ $g2.SetClip($clip2, [System.Drawing.Drawing2D.CombineMode]::Replace)
167
+ Draw-ImageCrop $g2 $imgs[$i] $iRect
168
+ $g2.ResetClip(); $clip2.Dispose()
169
+ # caption(alt 说明,悬浮可见的语义)
170
+ $capY = $iy + $imgH + 10
171
+ $g2.DrawString(('图片说明:' + $labels[$i]), $fontTiny, (New-Brush $text3), $imgX, $capY)
172
+ }
173
+
174
+ # 下方继续文字
175
+ $g2.DrawString('需要调整配色或构图,或者换成横版,随时告诉我。', $font, (New-Brush $text1), 122.0, 664.0)
176
+
177
+ # 用户消息气泡
178
+ $bubble = New-Object System.Drawing.RectangleF(760.0, 764.0, 360, 52)
179
+ Draw-Rounded $g2 (New-Brush ([System.Drawing.Color]::FromArgb(255, 59, 98, 214))) $bubble 26
180
+ $g2.DrawString('选第 1 张,谢谢!', $fontSmall, (New-Brush $white), 794.0, 780.0)
181
+
182
+ $g2.Dispose()
183
+ $bmp2.Save((Join-Path $docs 'mockup-inline.png'), [System.Drawing.Imaging.ImageFormat]::Png)
184
+ $bmp2.Dispose()
185
+ Write-Host 'mockup-inline.png saved'
Binary file
Binary file