@oadank/dsh-input-tools 0.3.23 → 0.3.24

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.
Files changed (2) hide show
  1. package/lib/index.js +176 -0
  2. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -310,6 +310,105 @@ async function saveVoiceFile(root, data, mediaType, durationMs) {
310
310
  }
311
311
  }
312
312
 
313
+ // ──────────────────────────────────────────────────────────────
314
+ // 图片对象存储(内容寻址,与语音/用户附件同池:DSH_HOME/attachments/v1/objects)
315
+ // [2026-08-23] send_image 工具:agent 主动发图,落盘为独立 image/reply 事件。
316
+ // ──────────────────────────────────────────────────────────────
317
+ const MAX_IMAGE_BYTES = 30 * 1024 * 1024
318
+
319
+ /** 从文件路径嗅探图片媒体类型(send_image 用)。 */
320
+ function sniffImageType(path) {
321
+ const ext = (path.split('.').pop() ?? '').toLowerCase()
322
+ if (ext === 'png') return 'image/png'
323
+ if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg'
324
+ if (ext === 'gif') return 'image/gif'
325
+ if (ext === 'webp') return 'image/webp'
326
+ return undefined
327
+ }
328
+
329
+ /** 最佳努力解析图片内禀尺寸(png/jpeg/gif/webp),失败返回 1×1(RPC schema 要求正数)。 */
330
+ function readImageSize(data) {
331
+ try {
332
+ if (data.length >= 24 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47) {
333
+ const w = (data[16] << 24) | (data[17] << 16) | (data[18] << 8) | data[19]
334
+ const h = (data[20] << 24) | (data[21] << 16) | (data[22] << 8) | data[23]
335
+ return { width: Math.max(1, w), height: Math.max(1, h) }
336
+ }
337
+ if (data.length >= 10 && data[0] === 0x47 && data[1] === 0x49 && data[2] === 0x46) {
338
+ const w = data[6] | (data[7] << 8)
339
+ const h = data[8] | (data[9] << 8)
340
+ return { width: Math.max(1, w), height: Math.max(1, h) }
341
+ }
342
+ if (data.length >= 4 && data[0] === 0xff && data[1] === 0xd8) {
343
+ let i = 2
344
+ while (i + 9 < data.length) {
345
+ if (data[i] !== 0xff) { i += 1; continue }
346
+ const marker = data[i + 1]
347
+ if (marker === 0xd9 || marker === 0xda) break
348
+ if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
349
+ const h = (data[i + 5] << 8) | data[i + 6]
350
+ const w = (data[i + 7] << 8) | data[i + 8]
351
+ return { width: Math.max(1, w), height: Math.max(1, h) }
352
+ }
353
+ const len = (data[i + 2] << 8) | data[i + 3]
354
+ i += 2 + len
355
+ }
356
+ }
357
+ if (
358
+ data.length >= 16
359
+ && data[0] === 0x52 && data[1] === 0x49 && data[2] === 0x46 && data[3] === 0x46
360
+ && data[8] === 0x57 && data[9] === 0x45 && data[10] === 0x42 && data[11] === 0x50
361
+ ) {
362
+ const fourcc = String.fromCharCode(data[12], data[13], data[14], data[15])
363
+ if (fourcc === 'VP8X' && data.length >= 30) {
364
+ const w = (data[24] | (data[25] << 8) | (data[26] << 16)) + 1
365
+ const h = (data[27] | (data[28] << 8) | (data[29] << 16)) + 1
366
+ return { width: w, height: h }
367
+ }
368
+ if (fourcc === 'VP8L' && data.length >= 25) {
369
+ const b = data[21] | (data[22] << 8) | (data[23] << 16) | (data[24] << 24)
370
+ return { width: (b & 0x3fff) + 1, height: ((b >> 14) & 0x3fff) + 1 }
371
+ }
372
+ if (fourcc === 'VP8 ' && data.length >= 32) {
373
+ const w = data[26] | (data[27] << 8) | ((data[28] & 0x3f) << 16)
374
+ const h = data[29] | (data[30] << 8) | ((data[31] & 0x3f) << 16)
375
+ return { width: Math.max(1, w), height: Math.max(1, h) }
376
+ }
377
+ }
378
+ } catch { /* 忽略 */ }
379
+ return { width: 1, height: 1 }
380
+ }
381
+
382
+ async function saveImageFile(root, data, mediaType) {
383
+ if (data.byteLength > MAX_IMAGE_BYTES) {
384
+ throw new Error(`Image object exceeds the ${MAX_IMAGE_BYTES}-byte limit.`)
385
+ }
386
+ const sha256 = createHash('sha256').update(data).digest('hex')
387
+ const bucket = join(root, 'objects', sha256.slice(0, 2))
388
+ const target = objectPath(root, sha256)
389
+ await mkdir(bucket, { recursive: true, mode: 0o700 })
390
+ let handle
391
+ try {
392
+ handle = await open(target, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600)
393
+ await handle.writeFile(data)
394
+ await handle.close()
395
+ handle = undefined
396
+ } catch (error) {
397
+ if (handle !== undefined) await handle.close().catch(() => {})
398
+ if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) {
399
+ throw new Error(`Unable to persist image object: ${String(error)}`, { cause: error })
400
+ }
401
+ }
402
+ const { width, height } = readImageSize(data)
403
+ return {
404
+ attachmentId: `sha256:${sha256}`,
405
+ mediaType,
406
+ bytes: data.byteLength,
407
+ width,
408
+ height,
409
+ }
410
+ }
411
+
313
412
  // ──────────────────────────────────────────────────────────────
314
413
  // TTS 引擎
315
414
  // ──────────────────────────────────────────────────────────────
@@ -1674,6 +1773,83 @@ async function apply(ctx) {
1674
1773
  },
1675
1774
  })))
1676
1775
 
1776
+ // 4) send_image 工具 [2026-08-23]:agent 主动发图(仿 send_voice)
1777
+ disposers.push(ctx.tools.register(defineTool({
1778
+ name: 'send_image',
1779
+ description: '向用户发送一张图片:把本地图片文件(png/jpg/jpeg/gif/webp)作为独立图片横条出现在聊天里(可点开放大、可翻查、手机可看)。'
1780
+ + '【何时调用】① 用户明确要求"发张图/发个图片/把这张图发给我";② 你判断发图比文字更直观时(截图、示意图、生成的图);③ 用户让你"把刚才生成的/看到的图发出来"。'
1781
+ + '【imagePath】本地图片文件的绝对路径;不支持远程 URL(先下载到本地再传)。'
1782
+ + '【alt】可选的图片说明文字(显示在图片下方,也作为无障碍标签)。',
1783
+ parameters: {
1784
+ imagePath: {
1785
+ type: 'string', required: true,
1786
+ description: '要发送的本地图片文件绝对路径(png/jpg/jpeg/gif/webp)',
1787
+ },
1788
+ alt: {
1789
+ type: 'string',
1790
+ description: '可选的图片说明文字(显示在图片下方/作为无障碍标签)',
1791
+ },
1792
+ },
1793
+ output: {
1794
+ schema: {
1795
+ type: 'object',
1796
+ additionalProperties: false,
1797
+ properties: {
1798
+ ok: { type: 'boolean', required: true },
1799
+ attachmentId: { type: 'string' },
1800
+ width: { type: 'number' },
1801
+ height: { type: 'number' },
1802
+ error: { type: 'string' },
1803
+ },
1804
+ },
1805
+ render(_args, value) {
1806
+ if (value.ok) {
1807
+ return [{ type: 'text', text: `图片已发送(attachmentId: ${value.attachmentId},${value.width}×${value.height})` }]
1808
+ }
1809
+ return [{ type: 'text', text: `图片发送失败:${value.error ?? '未知错误'}` }]
1810
+ },
1811
+ },
1812
+ async execute(args, exec) {
1813
+ const agent = exec?.agent
1814
+ if (agent === undefined) return { ok: false, error: 'no session context (tool exec signature unsupported)' }
1815
+ const session = agent.session
1816
+ const imagePath = String(args.imagePath ?? '').trim()
1817
+ if (imagePath === '') return { ok: false, error: 'imagePath is empty' }
1818
+ const mediaType = sniffImageType(imagePath)
1819
+ if (mediaType === undefined) return { ok: false, error: 'unsupported image type (png/jpg/jpeg/gif/webp expected)' }
1820
+ const alt = typeof args.alt === 'string' ? args.alt.trim() : undefined
1821
+ try {
1822
+ const data = new Uint8Array(await readFile(imagePath))
1823
+ const attachment = await saveImageFile(voiceStorageRoot(), data, mediaType)
1824
+ let turn = 0
1825
+ try {
1826
+ turn = session.events
1827
+ .filter((event) => event.type === 'turn/start')
1828
+ .at(-1)?.data.turn ?? 0
1829
+ } catch { /* rc.7 结构差异:忽略 */ }
1830
+ try {
1831
+ session.append('image/reply', {
1832
+ turn,
1833
+ attachmentId: attachment.attachmentId,
1834
+ mediaType: attachment.mediaType,
1835
+ bytes: attachment.bytes,
1836
+ width: attachment.width,
1837
+ height: attachment.height,
1838
+ ...(alt === undefined || alt === '' ? {} : { alt }),
1839
+ })
1840
+ } catch { /* rc.7 无 append:忽略 */ }
1841
+ return {
1842
+ ok: true,
1843
+ attachmentId: attachment.attachmentId,
1844
+ width: attachment.width,
1845
+ height: attachment.height,
1846
+ }
1847
+ } catch (error) {
1848
+ return { ok: false, error: error instanceof Error ? error.message : 'unknown error' }
1849
+ }
1850
+ },
1851
+ })))
1852
+
1677
1853
  // 3.5) voice_config 实时查询工具 [2026-08-22]
1678
1854
  // send_voice 描述里的配置摘要是服务启动时的快照;AI 发送语音前可用本工具拿到最新配置
1679
1855
  disposers.push(ctx.tools.register(defineTool({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oadank/dsh-input-tools",
3
- "version": "0.3.23",
3
+ "version": "0.3.24",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "lib/index.js",