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/lib/index.js ADDED
@@ -0,0 +1,578 @@
1
+ /**
2
+ * dsh-plugin-image-tools — 图片插件(服务端半边)
3
+ *
4
+ * 两个工具:
5
+ * 1. ask_user_choice:与原生 ask_user_question 同一答案协议
6
+ * (answers: [{ id, selected[], custom? }]),但每个选项可携带一张图片,
7
+ * 支持三种来源:path(本地文件)/ url(http(s))/ data(base64 data URI)。
8
+ * 浏览器端渲染图片选择卡,用户点卡片选择。
9
+ * 2. show_images:把图片注册到内存,返回绝对 URL 的 markdown 图片片段,
10
+ * 模型把片段原样粘贴进回复正文 → 图片随回复文字一起显示在聊天里。
11
+ *
12
+ * 为什么图片不走 option 字段 / 消息 content 字段:
13
+ * 浏览器端消费 question/requested 帧时用 zod schema 严格解析,选项对象上
14
+ * 的未知字段(如 image)会被剥离;助手消息 content 由模型文本生成,也没有
15
+ * 通道携带结构化图片块。因此本插件采用:
16
+ * 1) 服务端把图片字节归一化进内存注册表,并通过自定义 web 路由
17
+ * /dsh-plugin-image-tools/<pickId>/<index>(选择卡)与
18
+ * /dsh-plugin-image-tools/show/<showId>/<index>(回复内嵌)提供字节;
19
+ * 2) 选择卡:在问题的 detail(标准字符串字段,原样透传)开头写入不可见
20
+ * HTML 注释标记 <!--dsh-pick:v1:<base64url JSON>-->,客户端按标记
21
+ * 认领问题并渲染图片选择卡;
22
+ * 3) 回复内嵌:show_images 返回绝对 URL 的 markdown 图片行(宿主 origin
23
+ * 由 ctx.webServer.host/port 推导),模型粘贴进正文,核心 markdown
24
+ * 渲染器原生显示;客户端插件再对这类图片做增强(样式 + 点击放大)。
25
+ * 纯文字问题不带标记 → 客户端 select 放弃 → 原生文字 UI 兜底,优雅降级。
26
+ *
27
+ * 零运行时依赖:不 import 任何 @deepseek-ai/* 包(与 dsh-plugin-novel 同策略,
28
+ * 工具定义走 ctx.tools.register 的原始 definition 形状)。
29
+ *
30
+ * @module dsh-plugin-image-tools
31
+ */
32
+ import { readFile } from 'node:fs/promises'
33
+ import { isAbsolute, join } from 'node:path'
34
+ import { randomUUID } from 'node:crypto'
35
+
36
+ export const name = 'dsh-plugin-image-tools'
37
+ /** 需要的主机端服务:tools(注册工具)、userQuestions(问询)、webServer(图片路由与 origin)。 */
38
+ export const inject = ['tools', 'userQuestions', 'webServer']
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // 常量
42
+ // ---------------------------------------------------------------------------
43
+
44
+ /** 图片字节服务路由前缀。 */
45
+ export const ROUTE_PREFIX = '/dsh-plugin-image-tools'
46
+ /** 单个图片字节上限(超出直接报错,保护内存)。 */
47
+ export const MAX_IMAGE_BYTES = 20 * 1024 * 1024
48
+ /** 未答/未展示图片注册条目的存活时间(被放弃的提问与历史回复最终被清理)。 */
49
+ export const IMAGE_TTL_MS = 30 * 60 * 1000
50
+ /** 拉取远程图片的超时(ms)。 */
51
+ export const FETCH_TIMEOUT_MS = 30 * 1000
52
+ /** detail 里不可见标记的前缀/后缀。 */
53
+ export const MARKER_PREFIX = '<!--dsh-pick:v1:'
54
+ export const MARKER_SUFFIX = '-->'
55
+
56
+ /** 支持的图片媒体类型。 */
57
+ const SUPPORTED_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif'])
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // 内存图片注册表(进程生命周期;每次新增/路由命中时顺带做 TTL 清理)
61
+ // picks:ask_user_choice 的选择卡图片(随回答/取消立即释放)
62
+ // shows:show_images 的回复内嵌图片(依赖 TTL 清理,需存活到回复渲染完)
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /** @type {Map<string, { createdAt: number, images: { bytes: Buffer, mediaType: string, name?: string }[] }>} */
66
+ const picks = new Map()
67
+ /** @type {Map<string, { createdAt: number, images: { bytes: Buffer, mediaType: string, caption?: string }[] }>} */
68
+ const shows = new Map()
69
+
70
+ function pruneRegistry(registry, now = Date.now()) {
71
+ for (const [id, entry] of registry) {
72
+ if (now - entry.createdAt > IMAGE_TTL_MS) registry.delete(id)
73
+ }
74
+ }
75
+
76
+ function prunePicks(now = Date.now()) {
77
+ pruneRegistry(picks, now)
78
+ }
79
+
80
+ function pruneShows(now = Date.now()) {
81
+ pruneRegistry(shows, now)
82
+ }
83
+
84
+ function getPick(pickId) {
85
+ prunePicks()
86
+ return picks.get(pickId)
87
+ }
88
+
89
+ function getShow(showId) {
90
+ pruneShows()
91
+ return shows.get(showId)
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // 纯函数:媒体类型探测 / 标记编解码 / 宿主 origin(导出供 selfcheck 测试)
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /**
99
+ * 按魔数探测图片媒体类型(PNG / JPEG / WebP / GIF)。
100
+ * @param {Buffer} bytes - 图片字节。
101
+ * @returns {string|undefined} 规范 mediaType;无法识别返回 undefined。
102
+ */
103
+ export function sniffMediaType(bytes) {
104
+ if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return 'image/png'
105
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg'
106
+ if (bytes.length >= 12 && bytes.toString('ascii', 0, 4) === 'RIFF' && bytes.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'
107
+ if (bytes.length >= 6) {
108
+ const head = bytes.toString('ascii', 0, 6)
109
+ if (head === 'GIF87a' || head === 'GIF89a') return 'image/gif'
110
+ }
111
+ return undefined
112
+ }
113
+
114
+ /**
115
+ * 归一化媒体类型:显式声明优先,否则按魔数探测;两者都无 → 报错。
116
+ * @param {string|undefined} declared - 调用方显式声明的 mediaType(png/jpeg/webp/gif 或完整 image/*)。
117
+ * @param {Buffer} bytes - 图片字节。
118
+ * @returns {string} 规范 mediaType(image/*)。
119
+ * @throws 无法识别或不支持时抛错。
120
+ */
121
+ export function resolveMediaType(declared, bytes) {
122
+ const normalized = typeof declared === 'string' && declared.trim()
123
+ ? (declared.trim().toLowerCase().startsWith('image/') ? declared.trim().toLowerCase() : `image/${declared.trim().toLowerCase()}`)
124
+ : undefined
125
+ const sniffed = sniffMediaType(bytes)
126
+ const mediaType = normalized !== undefined ? normalized : sniffed
127
+ if (mediaType === undefined || !SUPPORTED_MEDIA_TYPES.has(mediaType)) {
128
+ throw new Error(`不支持的图片类型${normalized !== undefined ? `:${normalized}` : '(无法识别)'},仅支持 PNG/JPEG/WebP/GIF`)
129
+ }
130
+ if (sniffed !== undefined && normalized !== undefined && sniffed !== normalized) {
131
+ throw new Error(`图片类型声明与内容不符:声明 ${normalized},实际 ${sniffed}`)
132
+ }
133
+ return mediaType
134
+ }
135
+
136
+ /**
137
+ * 构建 detail 中的不可见标记(ASCII 输出,客户端 atob 后可直接 JSON.parse)。
138
+ * @param {string} pickId - 图片注册表键。
139
+ * @param {number[]} imageIndexes - 带图选项的下标集合。
140
+ * @returns {string} 形如 <!--dsh-pick:v1:<base64url JSON>--> 的注释。
141
+ */
142
+ export function buildPickMarker(pickId, imageIndexes) {
143
+ const json = JSON.stringify({ pickId, images: imageIndexes })
144
+ return `${MARKER_PREFIX}${Buffer.from(json, 'utf8').toString('base64url')}${MARKER_SUFFIX}`
145
+ }
146
+
147
+ /**
148
+ * 从 detail 中解析标记(服务端侧实现,与客户端 parseMarker 同契约)。
149
+ * @param {string|undefined} detail - 问题的 detail 字段。
150
+ * @returns {{ pickId: string, images: number[], human: string }|null} 无标记返回 null。
151
+ */
152
+ export function parsePickMarker(detail) {
153
+ if (typeof detail !== 'string' || !detail.startsWith(MARKER_PREFIX)) return null
154
+ const end = detail.indexOf(MARKER_SUFFIX, MARKER_PREFIX.length)
155
+ if (end < 0) return null
156
+ try {
157
+ const data = JSON.parse(Buffer.from(detail.slice(MARKER_PREFIX.length, end), 'base64url').toString('utf8'))
158
+ if (data === null || typeof data !== 'object' || typeof data.pickId !== 'string') return null
159
+ const images = Array.isArray(data.images) ? data.images.filter((n) => Number.isInteger(n) && n >= 0) : []
160
+ return { pickId: data.pickId, images, human: detail.slice(end + MARKER_SUFFIX.length) }
161
+ } catch {
162
+ return null
163
+ }
164
+ }
165
+
166
+ /**
167
+ * 推导宿主 origin(markdown 图片需要绝对 http(s) URL)。
168
+ * 绑定 0.0.0.0 时对浏览器回退 127.0.0.1;端口用实际监听端口。
169
+ * @param {object} [ctx] - 插件上下文(读取 ctx.webServer.host/port)。
170
+ * @returns {string} 形如 http://127.0.0.1:3080。
171
+ */
172
+ export function originOf(ctx) {
173
+ const webServer = ctx?.webServer
174
+ const host = webServer?.host === '0.0.0.0' ? '127.0.0.1' : (typeof webServer?.host === 'string' && webServer.host !== '' ? webServer.host : '127.0.0.1')
175
+ const port = typeof webServer?.port === 'number' ? webServer.port : undefined
176
+ return `http://${host}${port !== undefined ? `:${port}` : ''}`
177
+ }
178
+
179
+ /**
180
+ * 把 caption 转成安全的 markdown alt 文本(去掉会破坏语法的字符)。
181
+ * @param {string|undefined} caption - 图片说明。
182
+ * @returns {string} alt 文本(空 caption 时用「图片」)。
183
+ */
184
+ export function safeAlt(caption) {
185
+ const raw = typeof caption === 'string' && caption.trim() !== '' ? caption.trim() : '图片'
186
+ return raw.replace(/[\]\n\r]/g, ' ').slice(0, 200)
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // 图片加载
191
+ // ---------------------------------------------------------------------------
192
+
193
+ /** 解析 data URI:data:image/png;base64,xxxx(也兼容无 ;base64 的原样文本,仅 base64 场景)。 */
194
+ function decodeDataUri(value) {
195
+ const match = /^data:([^;,]*)(;base64)?,(.*)$/s.exec(value)
196
+ if (!match) throw new Error('data 字段必须是 data URI(形如 data:image/png;base64,...)')
197
+ const declaredType = match[1] || undefined
198
+ const body = match[3]
199
+ return { declaredType, bytes: Buffer.from(body, 'base64') }
200
+ }
201
+
202
+ /**
203
+ * 加载单个图片字节。
204
+ * @param {object} image - 图片字段 { path?, url?, data?, mediaType? }。
205
+ * @param {string} cwd - 会话工作区(相对路径的基准)。
206
+ * @returns {Promise<{ bytes: Buffer, mediaType: string, name?: string }>}
207
+ * @throws 字段缺失 / 拉取失败 / 类型不支持时抛错(错误信息面向模型)。
208
+ */
209
+ export async function loadOptionImage(image, cwd) {
210
+ if (image === null || typeof image !== 'object') throw new Error('image 字段必须是对象')
211
+ const declared = typeof image.mediaType === 'string' ? image.mediaType : undefined
212
+
213
+ if (typeof image.data === 'string' && image.data.length > 0) {
214
+ const { declaredType, bytes } = decodeDataUri(image.data)
215
+ if (bytes.byteLength === 0) throw new Error('data URI 内容为空')
216
+ if (bytes.byteLength > MAX_IMAGE_BYTES) throw new Error(`图片超过大小上限 ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)} MiB`)
217
+ return { bytes, mediaType: resolveMediaType(declared ?? declaredType, bytes) }
218
+ }
219
+
220
+ if (typeof image.url === 'string' && image.url.length > 0) {
221
+ let response
222
+ try {
223
+ response = await fetch(image.url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
224
+ } catch (error) {
225
+ throw new Error(`拉取图片失败:${image.url}(${error instanceof Error ? error.message : String(error)})`)
226
+ }
227
+ if (!response.ok) throw new Error(`拉取图片失败:${image.url}(HTTP ${response.status})`)
228
+ const bytes = Buffer.from(await response.arrayBuffer())
229
+ if (bytes.byteLength > MAX_IMAGE_BYTES) throw new Error(`图片超过大小上限 ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)} MiB`)
230
+ const fromHeader = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase()
231
+ return { bytes, mediaType: resolveMediaType(declared ?? fromHeader, bytes) }
232
+ }
233
+
234
+ if (typeof image.path === 'string' && image.path.length > 0) {
235
+ const target = isAbsolute(image.path) ? image.path : join(cwd, image.path)
236
+ let bytes
237
+ try {
238
+ bytes = await readFile(target)
239
+ } catch (error) {
240
+ throw new Error(`无法读取图片文件:${image.path}(${error instanceof Error ? error.message : String(error)})`)
241
+ }
242
+ if (bytes.byteLength > MAX_IMAGE_BYTES) throw new Error(`图片超过大小上限 ${Math.round(MAX_IMAGE_BYTES / 1024 / 1024)} MiB`)
243
+ return { bytes, mediaType: resolveMediaType(declared, bytes), name: image.path.split(/[\\/]/).pop() }
244
+ }
245
+
246
+ throw new Error('image 必须提供 path / url / data 三者之一')
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // 工具定义
251
+ // ---------------------------------------------------------------------------
252
+
253
+ /** 会话工作区(相对路径的基准)。 */
254
+ function sessionCwd(agent) {
255
+ const cwd = agent?.session?.header?.cwd
256
+ return typeof cwd === 'string' && cwd.length > 0 ? cwd : process.cwd()
257
+ }
258
+
259
+ /** 三种图片来源的公共 schema 形状。 */
260
+ function imageSourceShape() {
261
+ return {
262
+ type: 'object',
263
+ additionalProperties: false,
264
+ properties: {
265
+ path: {
266
+ type: 'string',
267
+ description: '本地图片路径(相对会话工作区或绝对路径,含 AI 出图产物)。'
268
+ },
269
+ url: {
270
+ type: 'string',
271
+ description: 'http(s) 图片地址,服务端拉取后转存显示。'
272
+ },
273
+ data: {
274
+ type: 'string',
275
+ description: 'base64 data URI(data:image/png;base64,...)。'
276
+ },
277
+ mediaType: {
278
+ type: 'string',
279
+ description: '可选:显式声明图片类型(png/jpeg/webp/gif),缺省按内容自动探测。'
280
+ }
281
+ }
282
+ }
283
+ }
284
+
285
+ /**
286
+ * ask_user_choice 工具定义(ctx.tools.register 原始 definition 形状)。
287
+ * @param {object} ctx - 插件上下文(execute 闭包使用 ctx.userQuestions.ask)。
288
+ * @param {object} [opts] - 预留配置(未来可放 TTL / 大小上限覆盖)。
289
+ * @returns {object} 工具 definition。
290
+ */
291
+ export function choiceTool(ctx, opts = {}) {
292
+ const optionShape = {
293
+ type: 'object',
294
+ additionalProperties: true,
295
+ required: ['label'],
296
+ properties: {
297
+ label: {
298
+ type: 'string',
299
+ description: '选项文字(展示用,也是用户选中后返回给模型的答案值)。图片选项的 label 请简短,作为图片下的说明文字。'
300
+ },
301
+ description: {
302
+ type: 'string',
303
+ description: '选项补充说明(图片选项显示在图片下方)。'
304
+ },
305
+ image: imageSourceShape()
306
+ }
307
+ }
308
+ return {
309
+ name: 'ask_user_choice',
310
+ description:
311
+ '给用户几个(可含图片的)选项让用户选择。与 ask_user_question 同一答案协议,但每个选项可携带一张图片:' +
312
+ '选项的 image 字段支持 path(本地文件,含 AI 出图产物)、url(http(s) 地址)、data(base64 data URI)三种来源,' +
313
+ '纯文字提问仍用 ask_user_question。图片+文字混合、纯图片、纯文字选项可在同一题内混排;' +
314
+ '推荐项放第一位并在 label 末尾标注 "(Recommended)" 或 "(推荐)"。' +
315
+ '用户点选后返回的 selected 就是该选项的 label 文本。',
316
+ parameters: {
317
+ type: 'object',
318
+ additionalProperties: false,
319
+ required: ['questions'],
320
+ properties: {
321
+ questions: {
322
+ type: 'array',
323
+ description: '要问用户的问题列表(一次可问多题,客户端逐题展示)。',
324
+ items: {
325
+ type: 'object',
326
+ additionalProperties: true,
327
+ required: ['id', 'question'],
328
+ properties: {
329
+ id: { type: 'string', description: '稳定问题 id,随答案原样回传。' },
330
+ question: { type: 'string', description: '问题文本。' },
331
+ header: { type: 'string', description: '可选短标题(如 "确认" / "选择模式")。' },
332
+ detail: { type: 'string', description: '可选补充说明(Markdown 文本,显示在问题下方)。' },
333
+ multi_select: { type: 'boolean', description: '是否允许多选,默认单选。' },
334
+ options: {
335
+ type: 'array',
336
+ description: '选项列表;带 image 的选项渲染为图片卡片,不带 image 的渲染为文字按钮。',
337
+ items: optionShape
338
+ }
339
+ }
340
+ }
341
+ }
342
+ }
343
+ },
344
+ output: {
345
+ schema: {
346
+ type: 'object',
347
+ additionalProperties: false,
348
+ required: ['answers'],
349
+ properties: {
350
+ answers: {
351
+ type: 'array',
352
+ items: {
353
+ type: 'object',
354
+ additionalProperties: false,
355
+ required: ['id', 'selected'],
356
+ properties: {
357
+ id: { type: 'string' },
358
+ selected: { type: 'array', items: { type: 'string' } },
359
+ custom: { type: 'string' }
360
+ }
361
+ }
362
+ }
363
+ }
364
+ },
365
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }]
366
+ },
367
+ async execute(args, exec) {
368
+ const cwd = sessionCwd(exec?.agent)
369
+ const now = Date.now()
370
+ prunePicks(now)
371
+
372
+ // 本次 ask 创建的 pickId,无论成功/失败/中止都统一释放(含中途加载报错)。
373
+ const batches = []
374
+ try {
375
+ // 1) 逐题归一化图片,生成带图问题的 pickId 与标记。
376
+ const askQuestions = []
377
+ for (const question of args.questions ?? []) {
378
+ const options = Array.isArray(question.options) ? question.options : []
379
+ const imageIndexes = []
380
+ const normalized = []
381
+ for (let index = 0; index < options.length; index++) {
382
+ const option = options[index]
383
+ if (option !== null && typeof option === 'object' && option.image !== undefined && option.image !== null) {
384
+ const loaded = await loadOptionImage(option.image, cwd)
385
+ imageIndexes.push(index)
386
+ normalized.push({ label: option.label, description: option.description, image: loaded })
387
+ } else {
388
+ normalized.push({ label: option.label, description: option.description })
389
+ }
390
+ }
391
+
392
+ let detail = typeof question.detail === 'string' ? question.detail : ''
393
+ if (imageIndexes.length > 0) {
394
+ const pickId = randomUUID()
395
+ picks.set(pickId, { createdAt: now, images: normalized.filter((o) => o.image !== undefined).map((o) => o.image) })
396
+ batches.push(pickId)
397
+ detail = buildPickMarker(pickId, imageIndexes) + detail
398
+ }
399
+
400
+ askQuestions.push({
401
+ id: question.id,
402
+ question: question.question,
403
+ ...(question.header !== undefined ? { header: question.header } : {}),
404
+ ...(detail !== '' ? { detail } : {}),
405
+ ...(options.length > 0 ? { options: normalized.map((o) => ({ label: o.label, ...(o.description !== undefined ? { description: o.description } : {}) })) } : {}),
406
+ ...(question.multi_select !== undefined ? { multiSelect: question.multi_select } : {})
407
+ })
408
+ }
409
+
410
+ // 2) 走标准问询通道(与 ask_user_question 同款),等待用户回答。
411
+ const result = await ctx.userQuestions.ask({
412
+ questions: askQuestions,
413
+ ...(exec.agent !== undefined ? { agent: exec.agent } : {}),
414
+ ...(exec.signal !== undefined ? { signal: exec.signal } : {})
415
+ })
416
+ return {
417
+ answers: result.answers.map((answer) => ({
418
+ id: answer.id,
419
+ selected: [...answer.selected],
420
+ ...(answer.custom !== undefined ? { custom: answer.custom } : {})
421
+ }))
422
+ }
423
+ } finally {
424
+ // 3) 无论回答、中止还是加载报错,都释放本次图片字节。
425
+ for (const pickId of batches) picks.delete(pickId)
426
+ }
427
+ }
428
+ }
429
+ }
430
+
431
+ /**
432
+ * show_images 工具定义:把图片注册进内存,返回绝对 URL 的 markdown 片段,
433
+ * 模型把片段原样粘贴进回复正文,图片随文字一起显示。
434
+ * @param {object} ctx - 插件上下文(execute 闭包读取 ctx.webServer 推导 origin)。
435
+ * @param {object} [opts] - 预留配置。
436
+ * @returns {object} 工具 definition。
437
+ */
438
+ export function showImagesTool(ctx, opts = {}) {
439
+ return {
440
+ name: 'show_images',
441
+ description:
442
+ '在本次回复中向用户展示图片(图片与文字混排)。调用后注册图片并返回 markdown 片段数组;' +
443
+ '你必须把返回的 markdown 片段**原样**逐行粘贴进自己的回复正文(每个片段一行,不要改写、不要截断 URL),' +
444
+ '图片就会随文字一起显示在聊天里。每张图片可带 caption(简短说明,作为图片的 alt/说明文字)。' +
445
+ '图片来源支持 path(本地文件,含 AI 出图产物)、url(http(s) 地址)、data(base64 data URI)三种。' +
446
+ '需要展示多张图时传多张,并把每张的 markdown 片段放在回复中对应位置。',
447
+ parameters: {
448
+ type: 'object',
449
+ additionalProperties: false,
450
+ required: ['images'],
451
+ properties: {
452
+ images: {
453
+ type: 'array',
454
+ description: '要在回复中展示的图片列表(1~9 张)。',
455
+ items: {
456
+ type: 'object',
457
+ additionalProperties: false,
458
+ required: ['image'],
459
+ properties: {
460
+ image: imageSourceShape(),
461
+ caption: {
462
+ type: 'string',
463
+ description: '可选图片说明文字(简短,显示在图片上/悬停/放大时)。'
464
+ }
465
+ }
466
+ }
467
+ }
468
+ }
469
+ },
470
+ output: {
471
+ schema: {
472
+ type: 'object',
473
+ additionalProperties: false,
474
+ required: ['markdown'],
475
+ properties: {
476
+ markdown: {
477
+ type: 'array',
478
+ items: { type: 'string' },
479
+ description: '粘贴进回复正文的 markdown 图片片段(每项一行)。'
480
+ },
481
+ note: { type: 'string', description: '给模型的提示语。' }
482
+ }
483
+ },
484
+ render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }]
485
+ },
486
+ async execute(args, exec) {
487
+ const cwd = sessionCwd(exec?.agent)
488
+ pruneShows()
489
+
490
+ const images = []
491
+ for (const item of args.images ?? []) {
492
+ if (item === null || typeof item !== 'object' || item.image === undefined || item.image === null) {
493
+ throw new Error('images 的每一项都必须提供 image 字段(path / url / data 三选一)')
494
+ }
495
+ const loaded = await loadOptionImage(item.image, cwd)
496
+ images.push({ ...loaded, caption: typeof item.caption === 'string' ? item.caption : undefined })
497
+ }
498
+ if (images.length === 0) throw new Error('images 不能为空')
499
+ if (images.length > 9) throw new Error('一次最多展示 9 张图片')
500
+
501
+ const showId = randomUUID()
502
+ shows.set(showId, { createdAt: Date.now(), images })
503
+
504
+ const origin = originOf(ctx)
505
+ const markdown = images.map(
506
+ (image, index) => `![${safeAlt(image.caption)}](${origin}${ROUTE_PREFIX}/show/${showId}/${index})`
507
+ )
508
+ return {
509
+ markdown,
510
+ note: '把 markdown 数组中的片段原样逐行粘贴进你的回复正文(不要改写 URL),图片会随回复显示在聊天里。'
511
+ }
512
+ }
513
+ }
514
+ }
515
+
516
+ // ---------------------------------------------------------------------------
517
+ // 插件入口
518
+ // ---------------------------------------------------------------------------
519
+
520
+ export function apply(ctx) {
521
+ // 图片字节服务路由:同源 <img src> 直接加载。
522
+ // /dsh-plugin-image-tools/<pickId>/<index> 选择卡图片
523
+ // /dsh-plugin-image-tools/show/<showId>/<index> 回复内嵌图片
524
+ const disposeRoute = ctx.effect(() => ctx.webServer.register({
525
+ kind: 'prefix',
526
+ path: ROUTE_PREFIX,
527
+ handler: (req, res) => {
528
+ try {
529
+ const method = (req.method ?? 'GET').toUpperCase()
530
+ if (method !== 'GET') {
531
+ res.writeHead(405, { 'content-type': 'text/plain; charset=utf-8' })
532
+ res.end('method not allowed')
533
+ return
534
+ }
535
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
536
+ const rest = url.pathname.slice(ROUTE_PREFIX.length).replace(/^\/+/, '')
537
+ let image
538
+ if (rest.startsWith('show/')) {
539
+ const [, showId, indexRaw] = rest.split('/')
540
+ const show = showId !== undefined ? getShow(showId) : undefined
541
+ const index = Number(indexRaw)
542
+ image = show !== undefined && Number.isInteger(index) && index >= 0 ? show.images[index] : undefined
543
+ } else {
544
+ const [pickId, indexRaw] = rest.split('/')
545
+ const pick = pickId !== undefined ? getPick(pickId) : undefined
546
+ const index = Number(indexRaw)
547
+ image = pick !== undefined && Number.isInteger(index) && index >= 0 ? pick.images[index] : undefined
548
+ }
549
+ if (image === undefined) {
550
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
551
+ res.end('not found')
552
+ return
553
+ }
554
+ res.writeHead(200, {
555
+ 'content-type': image.mediaType,
556
+ 'content-length': image.bytes.byteLength,
557
+ 'cache-control': 'private, max-age=300',
558
+ 'x-content-type-options': 'nosniff'
559
+ })
560
+ res.end(image.bytes)
561
+ } catch (error) {
562
+ res.writeHead(500, { 'content-type': 'text/plain; charset=utf-8' })
563
+ res.end('internal error')
564
+ }
565
+ }
566
+ }), 'dsh-plugin-image-tools: image route')
567
+
568
+ const disposeChoice = ctx.effect(() => ctx.tools.register(choiceTool(ctx)), 'dsh-plugin-image-tools: ask_user_choice tool')
569
+ const disposeShow = ctx.effect(() => ctx.tools.register(showImagesTool(ctx)), 'dsh-plugin-image-tools: show_images tool')
570
+
571
+ ctx.effect(() => () => {
572
+ disposeRoute()
573
+ disposeChoice()
574
+ disposeShow()
575
+ picks.clear()
576
+ shows.clear()
577
+ })
578
+ }
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "dsh-plugin-image-tools",
3
+ "description": "DSH 图片插件:ask_user_choice 图片/图文混合选项(Web GUI 渲染图片选择卡,可放大查看)+ show_images 在回复中内嵌图片(图片与文字混排)。图片来源支持本地路径 / http(s) URL / base64 data URI。纯插件实现,不改核心包。",
4
+ "version": "0.3.1",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "scripts",
15
+ "docs",
16
+ "cordis.patch.yml",
17
+ "README.md",
18
+ "设计说明.md"
19
+ ],
20
+ "scripts": {
21
+ "selfcheck": "node scripts/selfcheck.mjs",
22
+ "smoke": "node scripts/selfcheck.mjs && node scripts/smoke-server.mjs && node scripts/smoke-client.mjs",
23
+ "pack": "npm pack",
24
+ "prepublishOnly": "npm run smoke"
25
+ },
26
+ "peerDependencies": {
27
+ "@deepseek-ai/cordis": "^4.0.1"
28
+ },
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "keywords": [
33
+ "dsh",
34
+ "dsh-plugin",
35
+ "deepseek",
36
+ "harness",
37
+ "cordis",
38
+ "plugin",
39
+ "ask",
40
+ "choice",
41
+ "image",
42
+ "picker",
43
+ "show",
44
+ "inline"
45
+ ],
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/Pasumao/dsh-plugin-image-tools.git"
49
+ },
50
+ "homepage": "https://github.com/Pasumao/dsh-plugin-image-tools#readme",
51
+ "bugs": {
52
+ "url": "https://github.com/Pasumao/dsh-plugin-image-tools/issues"
53
+ },
54
+ "license": "MIT",
55
+ "dsh": {
56
+ "bundle": {
57
+ "patch": "./cordis.patch.yml"
58
+ },
59
+ "client": {
60
+ "inject": [],
61
+ "platform": "web"
62
+ }
63
+ }
64
+ }