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.
@@ -0,0 +1,140 @@
1
+ /**
2
+ * dsh-plugin-image-tools 自检:对 lib/index.js 导出的纯函数做离线冒烟测试。
3
+ * 运行:node scripts/selfcheck.mjs
4
+ */
5
+ import { strict as assert } from 'node:assert'
6
+ import {
7
+ sniffMediaType,
8
+ resolveMediaType,
9
+ buildPickMarker,
10
+ parsePickMarker,
11
+ loadOptionImage,
12
+ originOf,
13
+ safeAlt,
14
+ MAX_IMAGE_BYTES,
15
+ } from '../lib/index.js'
16
+
17
+ let passed = 0
18
+ async function ok(name, fn) {
19
+ await fn()
20
+ passed += 1
21
+ console.log(` ok ${name}`)
22
+ }
23
+
24
+ async function main() {
25
+ console.log('[dsh-plugin-image-tools] selfcheck')
26
+
27
+ // --- sniffMediaType ---
28
+ await ok('sniff PNG', () => {
29
+ const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00])
30
+ assert.equal(sniffMediaType(png), 'image/png')
31
+ })
32
+ await ok('sniff JPEG', () => {
33
+ assert.equal(sniffMediaType(Buffer.from([0xff, 0xd8, 0xff, 0xe0])), 'image/jpeg')
34
+ })
35
+ await ok('sniff WebP', () => {
36
+ const webp = Buffer.concat([Buffer.from('RIFF'), Buffer.alloc(4), Buffer.from('WEBP')])
37
+ assert.equal(sniffMediaType(webp), 'image/webp')
38
+ })
39
+ await ok('sniff GIF', () => {
40
+ assert.equal(sniffMediaType(Buffer.from('GIF89a')), 'image/gif')
41
+ })
42
+ await ok('sniff unknown -> undefined', () => {
43
+ assert.equal(sniffMediaType(Buffer.from('hello world')), undefined)
44
+ })
45
+
46
+ // --- resolveMediaType ---
47
+ await ok('resolve by magic bytes', () => {
48
+ const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
49
+ assert.equal(resolveMediaType(undefined, png), 'image/png')
50
+ })
51
+ await ok('resolve honors declared type', () => {
52
+ const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
53
+ assert.equal(resolveMediaType('png', png), 'image/png')
54
+ assert.equal(resolveMediaType('image/png', png), 'image/png')
55
+ })
56
+ await ok('resolve rejects mismatch', () => {
57
+ const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
58
+ assert.throws(() => resolveMediaType('jpeg', png), /不符/)
59
+ })
60
+ await ok('resolve rejects unsupported', () => {
61
+ assert.throws(() => resolveMediaType('image/bmp', Buffer.from([0x42, 0x4d])), /不支持/)
62
+ assert.throws(() => resolveMediaType(undefined, Buffer.from('nope')), /无法识别/)
63
+ })
64
+
65
+ // --- 标记编解码 round-trip(ASCII JSON,客户端 atob 可直接解析) ---
66
+ await ok('marker round-trip', () => {
67
+ const marker = buildPickMarker('abc-123', [0, 2, 5])
68
+ const parsed = parsePickMarker(marker + '\n\n补充说明')
69
+ assert.deepEqual(parsed, { pickId: 'abc-123', images: [0, 2, 5], human: '\n\n补充说明' })
70
+ assert.ok(/^[A-Za-z0-9_-]+$/.test(marker.slice('<!--dsh-pick:v1:'.length, marker.length - '-->'.length)))
71
+ })
72
+ await ok('marker absent -> null', () => {
73
+ assert.equal(parsePickMarker(undefined), null)
74
+ assert.equal(parsePickMarker('普通 detail'), null)
75
+ assert.equal(parsePickMarker('<!--dsh-pick:v1:broken-->'), null)
76
+ })
77
+
78
+ // --- originOf(show_images 的绝对 URL 推导) ---
79
+ await ok('origin uses host/port', () => {
80
+ assert.equal(originOf({ webServer: { host: '127.0.0.1', port: 3080 } }), 'http://127.0.0.1:3080')
81
+ })
82
+ await ok('origin falls back for 0.0.0.0', () => {
83
+ assert.equal(originOf({ webServer: { host: '0.0.0.0', port: 5173 } }), 'http://127.0.0.1:5173')
84
+ })
85
+ await ok('origin tolerates missing ctx', () => {
86
+ assert.equal(originOf(undefined), 'http://127.0.0.1')
87
+ assert.equal(originOf({}), 'http://127.0.0.1')
88
+ })
89
+
90
+ // --- safeAlt(caption → markdown alt 安全文本) ---
91
+ await ok('safeAlt keeps normal caption', () => {
92
+ assert.equal(safeAlt('深海鲸鱼封面'), '深海鲸鱼封面')
93
+ })
94
+ await ok('safeAlt strips markdown-breaking chars', () => {
95
+ assert.equal(safeAlt('A]B\nC\rD'), 'A B C D')
96
+ assert.equal(safeAlt(' '), '图片')
97
+ assert.equal(safeAlt(undefined), '图片')
98
+ })
99
+
100
+ // --- loadOptionImage:data URI / path(url 需要网络,跳过) ---
101
+ await ok('load data URI', async () => {
102
+ const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
103
+ const data = `data:image/png;base64,${pngBytes.toString('base64')}`
104
+ const loaded = await loadOptionImage({ data }, process.cwd())
105
+ assert.equal(loaded.mediaType, 'image/png')
106
+ assert.deepEqual(loaded.bytes, pngBytes)
107
+ })
108
+ await ok('load data URI respects declared mediaType', async () => {
109
+ const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
110
+ const data = `data:image/png;base64,${pngBytes.toString('base64')}`
111
+ const loaded = await loadOptionImage({ data, mediaType: 'png' }, process.cwd())
112
+ assert.equal(loaded.mediaType, 'image/png')
113
+ })
114
+ await ok('load rejects declared/media mismatch', async () => {
115
+ const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
116
+ const data = `data:image/png;base64,${pngBytes.toString('base64')}`
117
+ await assert.rejects(() => loadOptionImage({ data, mediaType: 'webp' }, process.cwd()), /不符/)
118
+ })
119
+ await ok('load rejects bad data URI', async () => {
120
+ await assert.rejects(() => loadOptionImage({ data: 'not-a-uri' }, process.cwd()), /data URI/)
121
+ })
122
+ await ok('load rejects missing sources', async () => {
123
+ await assert.rejects(() => loadOptionImage({}, process.cwd()), /path \/ url \/ data/)
124
+ })
125
+ await ok('load rejects oversized', async () => {
126
+ // 构造一个超过上限的 data URI(只做 base64 展开,不落盘)
127
+ const big = Buffer.alloc(MAX_IMAGE_BYTES + 1, 0x89)
128
+ await assert.rejects(
129
+ () => loadOptionImage({ data: `data:image/png;base64,${big.toString('base64')}` }, process.cwd()),
130
+ /大小上限/,
131
+ )
132
+ })
133
+
134
+ console.log(`\n[dsh-plugin-image-tools] ${passed} checks passed`)
135
+ }
136
+
137
+ main().catch((error) => {
138
+ console.error(error)
139
+ process.exit(1)
140
+ })
@@ -0,0 +1,116 @@
1
+ /**
2
+ * dsh-plugin-image-tools 客户端冒烟测试:
3
+ * 用 Node 模拟浏览器模块加载器执行 lib/client.js,再用 profile 里的真实
4
+ * react / react-dom/server 渲染一次图片选择卡,验证:
5
+ * 1. 模块加载、apply/inject 导出正常;
6
+ * 2. selectPickChoice 认领带图片标记的问题、放过纯文字问题;
7
+ * 3. parseMarker 与服务端 buildPickMarker 产出互通;
8
+ * 4. 组件能完整渲染出图片卡片(hooks/JSX 全部跑通);
9
+ * 5. 内嵌图片增强的纯函数(isShowImageSrc / IMAGE_SHOW_PREFIX)行为正确。
10
+ *
11
+ * 运行:node scripts/smoke-client.mjs
12
+ */
13
+ import { readFileSync } from 'node:fs'
14
+ import { createRequire } from 'node:module'
15
+ import { dirname, join } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+ import { strict as assert } from 'node:assert'
18
+ import { buildPickMarker } from '../lib/index.js'
19
+
20
+ const require = createRequire(import.meta.url)
21
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
22
+ // 从 profile 的共享 node_modules 树解析 react(单实例:jsx-runtime/react-dom 同源)
23
+ const profileRequire = createRequire(join('C:/Users/18303/.dsh/profiles/node_modules', 'noop.cjs'))
24
+ const reactRoot = dirname(profileRequire.resolve('react'))
25
+ const jsxRuntime = join(reactRoot, 'jsx-runtime.js')
26
+ const reactModule = profileRequire('react')
27
+ const reactDomServer = profileRequire('react-dom/server')
28
+
29
+ // 1) 模拟浏览器模块加载器
30
+ let spec = null
31
+ globalThis.window = {
32
+ __ModuleLoader__: {
33
+ load: (value) => { spec = value },
34
+ },
35
+ }
36
+ const code = readFileSync(join(ROOT, 'lib', 'client.js'), 'utf8')
37
+ new Function(code)()
38
+ assert.ok(spec !== null, 'module loader not invoked')
39
+ assert.equal(spec.id, 'dsh-plugin-image-tools')
40
+
41
+ const shimRequire = (name) => {
42
+ if (name === 'react/jsx-runtime') return require(jsxRuntime)
43
+ if (name === 'react') return reactModule
44
+ throw new Error(`unexpected require: ${name}`)
45
+ }
46
+ const mod = spec.factory(shimRequire)
47
+ assert.equal(typeof mod.apply, 'function')
48
+ assert.deepEqual(mod.inject, ['slots', 'locale'])
49
+ assert.equal(typeof mod.selectPickChoice, 'function')
50
+ assert.equal(typeof mod.ImageChoiceComposer, 'function')
51
+ assert.equal(typeof mod.Lightbox, 'function')
52
+ assert.equal(typeof mod.isShowImageSrc, 'function')
53
+ assert.equal(typeof mod.startInlineEnhancer, 'function')
54
+
55
+ // 5) 内嵌图片增强纯函数
56
+ assert.ok(mod.isShowImageSrc('/dsh-plugin-image-tools/show/abc-123/0'), 'show 路由 src 应被识别')
57
+ assert.ok(mod.isShowImageSrc('http://127.0.0.1:3080/dsh-plugin-image-tools/show/abc/1'), '绝对 URL 也应被识别')
58
+ assert.ok(!mod.isShowImageSrc('/dsh-plugin-image-tools/abc/0'), '选择卡路由不应被识别为内嵌图')
59
+ assert.ok(!mod.isShowImageSrc('https://example.com/a.png'), '外部图片不应被识别')
60
+ assert.equal(mod.startInlineEnhancer(), null, '无 DOM 环境下不应启动观察器')
61
+
62
+ // 2) select:认领带标记问题,放过纯文字问题
63
+ const markerDetail = buildPickMarker('pick-1', [0, 2]) + '\n\n请选择一张图'
64
+ const withImage = {
65
+ kind: 'question',
66
+ key: 'q:1',
67
+ sessionId: 's1',
68
+ payload: { type: 'question/requested', sessionId: 's1', questions: [{ id: 'a', question: '选图', detail: markerDetail, options: [{ label: 'A' }, { label: 'B' }, { label: 'C' }] }] },
69
+ }
70
+ const plain = {
71
+ kind: 'question',
72
+ key: 'q:2',
73
+ sessionId: 's2',
74
+ payload: { type: 'question/requested', sessionId: 's2', questions: [{ id: 'b', question: '纯文字', options: [{ label: 'X' }] }] },
75
+ }
76
+ const approval = { kind: 'approval', key: 'a:1', sessionId: 's1', payload: {} }
77
+ assert.equal(mod.selectPickChoice({ interactions: [plain] }), null, '纯文字问题应放行')
78
+ assert.equal(mod.selectPickChoice({ interactions: [approval, plain] }), null, '无图问题应放行')
79
+ assert.equal(mod.selectPickChoice({ interactions: [plain, withImage] }), withImage, '带图问题应认领')
80
+
81
+ // 3) parseMarker 与服务端互通
82
+ const parsed = mod.parseMarker(markerDetail)
83
+ assert.deepEqual({ pickId: parsed.pickId, images: parsed.images, human: parsed.human }, { pickId: 'pick-1', images: [0, 2], human: '\n\n请选择一张图' })
84
+
85
+ // 4) 渲染图片选择卡(react-dom/server 初次渲染)
86
+ const { renderToString } = reactDomServer
87
+ const fakeT = (key) => ({ 'nav.cancel': '取消', 'action.skip': '跳过', 'action.next': '下一步', 'submit': '提交', 'option.recommended': '推荐', 'custom.placeholder': '输入答案', 'image.failed': '加载失败', 'image.zoom': '放大查看', 'image.close': '关闭' }[key] ?? key)
88
+ const fakeWait = {
89
+ key: 'q:1',
90
+ sessionId: 's1',
91
+ payload: { type: 'question/requested', sessionId: 's1', questions: [{ id: 'a', question: '选一张封面', header: '封面', detail: markerDetail, multiSelect: false, options: [{ label: 'A (Recommended)', description: '第一张' }, { label: 'B' }, { label: 'C' }] }] },
92
+ respond: async (r) => ({ accepted: true }),
93
+ }
94
+ const html = renderToString(reactModule.createElement(mod.ImageChoiceComposer, { matched: fakeWait, t: fakeT }))
95
+ assert.ok(html.includes('dshpick-card'), '卡片未渲染')
96
+ assert.ok(html.includes('选一张封面'), '问题文本缺失')
97
+ assert.ok(html.includes('/dsh-plugin-image-tools/pick-1/0'), '图片 URL 缺失')
98
+ assert.ok(html.includes('/dsh-plugin-image-tools/pick-1/2'), '第二张图片 URL 缺失')
99
+ assert.ok(html.includes('A'), '选项 label 缺失')
100
+ assert.ok(!html.includes('<!--dsh-pick:v1:'), '标记注释应被剥离,不能出现在界面上')
101
+
102
+ // 5) 放大查看:卡片渲染出 zoom 触发按钮(aria-label/title 走 locale),
103
+ // Lightbox 单独渲染时包含大图、说明与关闭按钮
104
+ assert.ok(html.includes('放大查看'), 'zoom 触发按钮的 locale 文案缺失')
105
+ assert.ok(html.includes('dshpick-zoomHint'), 'zoom 提示图标缺失')
106
+ const lightboxHtml = renderToString(reactModule.createElement(mod.Lightbox, {
107
+ zoom: { src: '/dsh-plugin-image-tools/pick-1/0', label: 'A', description: '第一张' },
108
+ onClose: () => {},
109
+ t: fakeT,
110
+ }))
111
+ assert.ok(lightboxHtml.includes('dshpick-lightbox'), 'lightbox 遮罩未渲染')
112
+ assert.ok(lightboxHtml.includes('/dsh-plugin-image-tools/pick-1/0'), 'lightbox 大图 URL 缺失')
113
+ assert.ok(lightboxHtml.includes('第一张'), 'lightbox 说明缺失')
114
+ assert.ok(lightboxHtml.includes('关闭'), 'lightbox 关闭按钮文案缺失')
115
+
116
+ console.log('[dsh-plugin-image-tools] client smoke passed')
@@ -0,0 +1,194 @@
1
+ /**
2
+ * dsh-plugin-image-tools 服务端集成冒烟:
3
+ * 用假 ctx 挂载插件(apply),完整验证:
4
+ * 1. 工具以 ask_user_choice / show_images 注册,schema 含 image 字段;
5
+ * 2. ask_user_choice.execute 归一化图片 → ask 请求的 detail 带标记、
6
+ * 选项只剩 label/description;
7
+ * 3. 图片路由能按 /dsh-plugin-image-tools/<pickId>/<index> 与
8
+ * /dsh-plugin-image-tools/show/<showId>/<index> 出字节和 content-type;
9
+ * 4. 回答映射与清理(pick 条目在答案返回后被删除);
10
+ * 5. show_images.execute 返回绝对 URL 的 markdown 片段,图片注册表存活
11
+ * (回复渲染需要),路由可访问。
12
+ *
13
+ * 运行:node scripts/smoke-server.mjs
14
+ */
15
+ import { strict as assert } from 'node:assert'
16
+ import { apply, ROUTE_PREFIX } from '../lib/index.js'
17
+
18
+ const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4])
19
+
20
+ // --- 假 ctx ---
21
+ let toolDefs = []
22
+ let route = null
23
+ let capturedAsk = null
24
+ let resolveAsk = null
25
+ const askPromise = new Promise((resolve) => { resolveAsk = resolve })
26
+
27
+ const ctx = {
28
+ effect(fn) {
29
+ const result = fn()
30
+ return () => { if (typeof result === 'function') result() }
31
+ },
32
+ webServer: {
33
+ host: '127.0.0.1',
34
+ port: 3080,
35
+ register(r) { route = r; return () => {} },
36
+ },
37
+ tools: {
38
+ register(def) { toolDefs.push(def); return () => {} },
39
+ },
40
+ userQuestions: {
41
+ ask(request) {
42
+ capturedAsk = request
43
+ return askPromise
44
+ },
45
+ },
46
+ }
47
+
48
+ apply(ctx)
49
+
50
+ const defByName = (name) => toolDefs.find((def) => def.name === name)
51
+ const choiceDef = defByName('ask_user_choice')
52
+ const showDef = defByName('show_images')
53
+
54
+ // --- 1) 工具定义 ---
55
+ assert.ok(choiceDef, 'ask_user_choice 未注册')
56
+ assert.ok(choiceDef.parameters.properties.questions.items.properties.options.items.properties.image, '选项 schema 缺少 image 字段')
57
+ assert.ok(choiceDef.parameters.properties.questions.items.properties.options.items.properties.label, '选项 schema 缺少 label')
58
+ assert.equal(typeof choiceDef.execute, 'function')
59
+ assert.ok(showDef, 'show_images 未注册')
60
+ assert.ok(showDef.parameters.properties.images.items.properties.image, 'show_images schema 缺少 image 字段')
61
+ assert.equal(typeof showDef.execute, 'function')
62
+
63
+ // --- 2) ask_user_choice 执行:data URI 图片 + 纯文字选项混排 ---
64
+ const dataUri = `data:image/png;base64,${pngBytes.toString('base64')}`
65
+ const exec = { agent: { session: { header: { cwd: process.cwd() } } }, signal: undefined }
66
+ const runPromise = choiceDef.execute({
67
+ questions: [
68
+ {
69
+ id: 'q1',
70
+ question: '选一张图',
71
+ header: '选图',
72
+ multi_select: true,
73
+ options: [
74
+ { label: '方案A (推荐)', description: '第一张', image: { data: dataUri } },
75
+ { label: '方案B' },
76
+ { label: '方案C', image: { data: dataUri } },
77
+ ],
78
+ },
79
+ { id: 'q2', question: '纯文字题', options: [{ label: '是' }, { label: '否' }] },
80
+ ],
81
+ }, exec)
82
+
83
+ // 等 execute 把 ask 请求送出去(假 ask 挂起,等待我们手动回答)
84
+ await new Promise((r) => setTimeout(r, 0))
85
+ assert.ok(capturedAsk !== null, 'execute 未调用 userQuestions.ask')
86
+ assert.equal(capturedAsk.questions.length, 2)
87
+ assert.equal(capturedAsk.questions[0].id, 'q1')
88
+ assert.equal(capturedAsk.questions[0].multiSelect, true)
89
+
90
+ // detail 带标记,标记 JSON 为 ASCII
91
+ const detail = capturedAsk.questions[0].detail
92
+ assert.ok(detail.startsWith('<!--dsh-pick:v1:'), 'detail 缺少标记')
93
+ const markerBody = detail.slice('<!--dsh-pick:v1:'.length, detail.indexOf('-->'))
94
+ assert.ok(/^[A-Za-z0-9_-]+$/.test(markerBody), '标记 JSON 应为 ASCII base64url')
95
+ const marker = JSON.parse(Buffer.from(markerBody, 'base64url').toString('utf8'))
96
+ assert.deepEqual(marker, { pickId: marker.pickId, images: [0, 2] })
97
+ const pickId = marker.pickId
98
+
99
+ // 选项只含 label/description(标准字段)
100
+ assert.deepEqual(Object.keys(capturedAsk.questions[0].options[0]).sort(), ['description', 'label'])
101
+ assert.deepEqual(capturedAsk.questions[0].options[1], { label: '方案B' })
102
+ assert.equal(capturedAsk.questions[1].detail, undefined, '纯文字题不应带标记')
103
+
104
+ // --- 3) 图片路由出图(选择卡) ---
105
+ assert.equal(route.kind, 'prefix')
106
+ assert.equal(route.path, ROUTE_PREFIX)
107
+ let served = null
108
+ route.handler(
109
+ { url: `${ROUTE_PREFIX}/${pickId}/0` },
110
+ {
111
+ writeHead(status, headers) { served = { status, headers } },
112
+ end(body) { served.body = body },
113
+ },
114
+ )
115
+ assert.equal(served.status, 200)
116
+ assert.equal(served.headers['content-type'], 'image/png')
117
+ assert.deepEqual(served.body, pngBytes)
118
+ // 越界下标 → 404
119
+ let notFound = null
120
+ route.handler(
121
+ { url: `${ROUTE_PREFIX}/${pickId}/9` },
122
+ { writeHead(status, headers) { notFound = { status } }, end() {} },
123
+ )
124
+ assert.equal(notFound.status, 404)
125
+
126
+ // --- 4) 回答映射 + 清理 ---
127
+ resolveAsk({
128
+ answers: [
129
+ { id: 'q1', selected: ['方案A (推荐)', '方案C'] },
130
+ { id: 'q2', selected: [], custom: '自定义' },
131
+ ],
132
+ })
133
+ const result = await runPromise
134
+ assert.deepEqual(result, {
135
+ answers: [
136
+ { id: 'q1', selected: ['方案A (推荐)', '方案C'] },
137
+ { id: 'q2', selected: [], custom: '自定义' },
138
+ ],
139
+ })
140
+ // finally 已清理:路由再访问应 404
141
+ let afterCleanup = null
142
+ route.handler(
143
+ { url: `${ROUTE_PREFIX}/${pickId}/0` },
144
+ { writeHead(status) { afterCleanup = { status } }, end() {} },
145
+ )
146
+ assert.equal(afterCleanup.status, 404, '回答后图片注册表应清理')
147
+
148
+ // --- 5) show_images:注册 + markdown 片段 + 路由出图(条目存活供渲染) ---
149
+ const showResult = await showDef.execute({
150
+ images: [
151
+ { image: { data: dataUri }, caption: '深海鲸鱼封面' },
152
+ { image: { data: dataUri } },
153
+ ],
154
+ }, exec)
155
+ assert.ok(Array.isArray(showResult.markdown), 'show_images 应返回 markdown 数组')
156
+ assert.equal(showResult.markdown.length, 2)
157
+ const url0 = showResult.markdown[0].match(/\]\((http[^)]+)\)/)?.[1]
158
+ const url1 = showResult.markdown[1].match(/\]\((http[^)]+)\)/)?.[1]
159
+ assert.ok(url0 && url0.startsWith('http://127.0.0.1:3080'), `URL 应为绝对 origin:${url0}`)
160
+ assert.ok(url0.includes(`${ROUTE_PREFIX}/show/`), `URL 应含 show 路由:${url0}`)
161
+ assert.ok(showResult.markdown[0].includes('![深海鲸鱼封面]'), '第一张应带 caption alt')
162
+ assert.ok(showResult.markdown[1].includes('![图片]'), '无 caption 时 alt 用默认文案')
163
+ const showId = url0.match(/\/show\/([^/]+)\//)?.[1]
164
+ assert.ok(showId, '无法从 URL 提取 showId')
165
+
166
+ // 回复渲染期条目应存活:两张都能出图
167
+ let showServed = []
168
+ for (const index of [0, 1]) {
169
+ route.handler(
170
+ { url: `${ROUTE_PREFIX}/show/${showId}/${index}` },
171
+ {
172
+ writeHead(status, headers) { showServed.push({ status, headers }) },
173
+ end(body) { showServed[showServed.length - 1].body = body },
174
+ },
175
+ )
176
+ }
177
+ assert.equal(showServed[0].status, 200)
178
+ assert.equal(showServed[0].headers['content-type'], 'image/png')
179
+ assert.deepEqual(showServed[0].body, pngBytes)
180
+ assert.equal(showServed[1].status, 200)
181
+ // 越界 → 404
182
+ let showNotFound = null
183
+ route.handler(
184
+ { url: `${ROUTE_PREFIX}/show/${showId}/9` },
185
+ { writeHead(status) { showNotFound = { status } }, end() {} },
186
+ )
187
+ assert.equal(showNotFound.status, 404)
188
+
189
+ // show_images 参数校验
190
+ await assert.rejects(() => showDef.execute({ images: [] }, exec), /不能为空/)
191
+ await assert.rejects(() => showDef.execute({ images: [{ caption: '没有图' }] }, exec), /image 字段/)
192
+ await assert.rejects(() => showDef.execute({ images: [{ image: {} }] }, exec), /path \/ url \/ data/)
193
+
194
+ console.log('[dsh-plugin-image-tools] server smoke passed')
@@ -0,0 +1,161 @@
1
+ # dsh-plugin-image-tools 设计说明
2
+
3
+ ## 目标
4
+
5
+ 给 dsh Web GUI 增加两项图片能力:
6
+
7
+ 1. **带图片的选项选择**(`ask_user_choice`):模型提问时,选项可以是纯图片、
8
+ 纯文字、或图片+文字混合;用户在 GUI 里点图片卡片完成选择,答案协议与原生
9
+ `ask_user_question` 完全一致。
10
+ 2. **回复内嵌图片**(`show_images`):模型在回复正文里展示图片(图片与文字混排),
11
+ 点击可放大查看。
12
+
13
+ ## 现状(原生链路)
14
+
15
+ ```
16
+ 模型 → ask_user_question 工具
17
+ → ctx.userQuestions.ask({ questions })
18
+ → host-apiproxy 的 userQuestions provider:
19
+ 注册 pending → 向浏览器 mux 流推送 question/requested 帧
20
+ → 浏览器 dsh-client-connection 用 muxFrameSchema.parse() 解析帧
21
+ → dsh-client-runtime 生成 PendingWait(kind="question")
22
+ → dsh-client-ui-user-questions 在 conversation.composer slot 链
23
+ select({ interactions }) 匹配 kind === "question"
24
+ → QuestionComposer 渲染文字选项卡 → 用户点选 → wait.respond({...}) 回传
25
+ → provider 的 pending resolve → 工具返回 answers
26
+ ```
27
+
28
+ 助手消息渲染:模型文本 → 核心渲染器(micromark 直接 mdast→React)逐 block 渲染;
29
+ `text` 块走 `MarkdownText`,`image` 块(需 attachment ref)走 `ImageGallery`——
30
+ 模型输出只有文本,没有携带结构化图片块的通道。
31
+
32
+ ## 关键约束
33
+
34
+ - 浏览器端(`dsh-client-connection/lib/client.js`)消费 mux 流时执行
35
+ `muxFrameSchema.parse(payload)`(zod,默认 strip)。`question/requested` 帧的
36
+ 选项 schema 只允许 `label` / `description`,**任何额外字段(如 `image`)都会被
37
+ 静默剥离**。因此:
38
+ - ❌ 不能往 option 里塞图片数据/引用,客户端收不到;
39
+ - ❌ 不能新增 intent kind(discriminatedUnion 只认 `plan-review`,未知类型整帧被丢弃);
40
+ - ✅ `detail`、`header`、`question`、`id` 等标准字符串字段**原样透传**——这是唯一的
41
+ 出站通道。
42
+ - 核心渲染器的 markdown 图片只放行**绝对 http(s) URL**(`sanitizeUrl`/`remoteImageUrl`
43
+ 协议白名单),相对路径与原始 HTML 都不会进入 DOM。
44
+ - 外壳把 `dsh-client-ui-primitives` 打成 frozen 静态模块,**无法包装 MarkdownText**;
45
+ 消息渲染也没有 content 级 slot。因此回复内嵌图片走「绝对 URL markdown + DOM
46
+ 渐进增强」而不是改渲染器。
47
+
48
+ ## 方案 A:选择卡(detail 携带不可见标记 + 独立图片字节路由)
49
+
50
+ ```
51
+ 模型 → ask_user_choice(本插件工具)
52
+ → 逐选项归一化图片(path/url/data → Buffer + 魔数校验 mediaType)
53
+ → pickId = uuid;bytes 存入内存注册表 picks:Map<pickId, images[]>
54
+ → 构造 ask() 请求:
55
+ options 只留 { label, description }(标准字段)
56
+ detail = "<!--dsh-pick:v1:<base64url({pickId,images:[下标]})-->" + 人类可读 detail
57
+ (标记是 HTML 注释,markdown 渲染不可见;JSON 全 ASCII,浏览器 atob 直接解)
58
+ → ctx.userQuestions.ask()(标准通道,provider/协议零改动)
59
+ → 浏览器照常收到 question/requested 帧(detail 原样)
60
+ → 本插件客户端在 conversation.composer 链注册 priority:-100 的条目:
61
+ select 检查任一 question.detail 是否带 dsh-pick 标记
62
+ · 带标记 → 认领,渲染图片选择卡(<img src="/dsh-plugin-image-tools/<pickId>/<index>">)
63
+ · 不带 → 返回 null,原生 QuestionComposer 照常接管(优雅降级)
64
+ → 用户点选 → 同一 wait.respond 协议回传 → 工具返回 answers
65
+ → finally:按批次删除注册表条目(字节立即释放;未答条目 30 分钟 TTL)
66
+ ```
67
+
68
+ ## 方案 B:回复内嵌图片(绝对 URL markdown + DOM 渐进增强)
69
+
70
+ ```
71
+ 模型 → show_images(本插件工具)
72
+ → 逐张归一化图片(复用 loadOptionImage)→ 内存注册表 shows:Map<showId, images[]>
73
+ → origin = originOf(ctx)(ctx.webServer.host/port;0.0.0.0 回退 127.0.0.1)
74
+ → 返回 { markdown: ["![caption](http://host:port/dsh-plugin-image-tools/show/<id>/<i>)"...] }
75
+ → 模型把 markdown 片段原样粘贴进回复正文
76
+ → 核心 MarkdownText 渲染绝对 URL 图片 → 图片随文字显示(零核心改动基线)
77
+ → 本插件客户端(MutationObserver 监听 document.body,rAF 合帧扫描
78
+ img[src^="/dsh-plugin-image-tools/show/"])对图片做渐进增强:
79
+ 加 .dshimg-inline 样式类(圆角/限高/悬浮说明)、点击打开命令式 Lightbox
80
+ (复用 .dshpick-lightbox 样式)、加载失败降级
81
+ → shows 条目无「回答完成」事件可依,依赖 30 分钟 TTL 清理
82
+ ```
83
+
84
+ 为什么选「绝对 URL markdown」作为主链路:模型输出只有文本,唯一能进正文且被
85
+ 核心渲染器安全显示的方式就是绝对 http(s) 图片 URL;客户端增强只是锦上添花,
86
+ 即使增强失效(无 DOM/观察器被禁),markdown 图片本身也完整可用。
87
+
88
+ ## 各侧职责
89
+
90
+ ### 服务端 lib/index.js(零运行时依赖,不 import @deepseek-ai/*)
91
+
92
+ - 工具 `ask_user_choice`(原始 definition 形状,与 dsh-plugin-novel 同策略):
93
+ - schema 与 `ask_user_question` 对齐 + 选项 `image` 字段;
94
+ - execute:归一化图片 → 组 ask 请求 → 等待 → 映射答案 → 清理。
95
+ - 工具 `show_images`:
96
+ - schema:`images: [{ image: {path|url|data|mediaType}, caption? }]`(1~9 张);
97
+ - execute:归一化 → 注册 shows → 返回绝对 URL markdown 片段 + 提示语。
98
+ - web 路由 `ROUTE_PREFIX = /dsh-plugin-image-tools`(`ctx.webServer.register`,kind=prefix):
99
+ - `/dsh-plugin-image-tools/<pickId>/<index>` → 选择卡图片;
100
+ - `/dsh-plugin-image-tools/show/<showId>/<index>` → 回复内嵌图片;
101
+ - bytes + content-type(nosniff + 短缓存),越界/未知 id → 404。
102
+ - 图片来源归一化(两种工具共用 `loadOptionImage`):
103
+ - `data`:data URI 解析(声明类型优先,魔数兜底,两者冲突报错);
104
+ - `url`:`fetch`(30s 超时),content-type 或魔数定类型;
105
+ - `path`:相对会话 cwd(`agent.session.header.cwd`)解析,`readFile` 读取。
106
+ - 上限 20 MiB/张;类型限 PNG/JPEG/WebP/GIF(魔数校验)。
107
+
108
+ ### 客户端 lib/client.js(浏览器模块加载器格式,免构建)
109
+
110
+ - 只 `require("react")` / `react/jsx-runtime`(与核心 client bundle 同解析方式),
111
+ 不依赖其它 client 包;样式用全局主题 CSS 变量自绘。
112
+ - `PendingChoice`:与原生 `PendingQuestion` 相同的 wire 编码
113
+ (`wait.respond({ ok:true, value:{ sessionId, answer } })`)。
114
+ - `selectPickChoice`:只认领带标记的 question 交互。
115
+ - `ImageChoiceFlow`:复刻原生 QuestionFlow 的交互模型(分页、多选、自定义答案、
116
+ 跳过、取消、推荐标注、Enter 提交),差异是带图选项渲染为图片卡片网格;
117
+ 同题内无图选项仍渲染为文字行。
118
+ - `ImageCardBtn`:卡片 = `div[role=radio|checkbox]`(整卡可点选中)+ 内部
119
+ `button.dshpick-thumb`(点击放大查看,独立焦点)——避免嵌套 button 的非法 HTML。
120
+ - `Lightbox`(React):点击缩略图弹出全屏遮罩大图,关闭途径 Esc / 点遮罩 / 关闭按钮。
121
+ - **内嵌图片增强**(命令式 DOM,与 React 树隔离):
122
+ - `isShowImageSrc(src)`:纯字符串判定 `/dsh-plugin-image-tools/show/` 前缀;
123
+ - `upgradeInlineImage(img)`:只做**单节点**变更(dataset/class/事件监听),
124
+ 不插入/移除 React 管理的结构 → 重渲染安全,观察器会重新应用;
125
+ - `startInlineEnhancer()`:`MutationObserver(document.body, childList+subtree)`
126
+ + rAF 合帧扫描;无 DOM 环境返回 null(优雅降级);
127
+ - `openImageZoom(src, caption)`:命令式 Lightbox(复用 `.dshpick-lightbox` 样式),
128
+ 带 Esc / 点遮罩 / 关闭按钮。
129
+ - 注册:
130
+ - `ctx.slots.inject("conversation.composer", ...)`(priority -100,先于原生);
131
+ - `ctx.effect(() => startInlineEnhancer())`(随插件卸载停止观察器)。
132
+
133
+ ## 为什么不改核心包
134
+
135
+ - `dsh-tool-ask-user` / `dsh-user-questions` / `dsh-client-ui-user-questions` /
136
+ `dsh-host-apiproxy` 全是 npm 安装的发布包,改它们 = fork 核心,升级即失效。
137
+ - 本方案所有改动都在插件包内:两个新工具 + 新客户端链条目 + 自有路由 + DOM 增强;
138
+ 对核心只有「detail 里多了个不可见注释」和「正文里多了条 markdown 图片 URL」
139
+ 这两个纯数据副作用。
140
+ - 优雅降级:未装客户端(或纯文字题)时,问题退化为原生文字选项;内嵌图片
141
+ 退化为核心 markdown 原生渲染。任何一侧缺失都不破坏流程。
142
+
143
+ ## 边界与取舍
144
+
145
+ - 图片字节在内存(v1 简单可靠);20 MiB × 张数有总量风险,后续可落盘/走附件。
146
+ - 图片路由与 GUI 同信任级别,未加额外鉴权(与整站一致)。
147
+ - detail 以纯文本展示(未引 MarkdownText,避免对 primitives 的依赖面)。
148
+ - URL 图片由服务端拉取(浏览器跨域/CSP 不受影响)。
149
+ - 内嵌图片 URL 是绝对地址:GUI 经反向代理/换端口访问时历史消息图片可能失效
150
+ (可后续用 `x-forwarded-*` / Host 头改进)。
151
+ - `shows` 无「展示完成」事件,采用 TTL 清理(30 分钟),重启即失效(与选择卡一致)。
152
+
153
+ ## 验证
154
+
155
+ - `scripts/selfcheck.mjs`:纯函数(魔数、类型、标记 round-trip、data URI、
156
+ originOf、safeAlt)。
157
+ - `scripts/smoke-server.mjs`:假 ctx 全链路(两工具注册 → 归一化 → ask 请求 →
158
+ 路由出图(pick + show)→ 回答映射 → 清理 + show 条目存活)。
159
+ - `scripts/smoke-client.mjs`:真实 react 渲染一次图片选择卡 + 链选择器行为 +
160
+ Lightbox 放大层渲染 + 内嵌图片增强纯函数。
161
+ - 均离线可跑:`npm run smoke`;发布前 `prepublishOnly` 自动重跑全套。