@oadank/dsh-input-tools 0.3.22 → 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.
- package/lib/client.js +19 -1
- package/lib/index.js +176 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -284,7 +284,25 @@ window.__ModuleLoader__.load({
|
|
|
284
284
|
setRecording(true);
|
|
285
285
|
setSeconds(0);
|
|
286
286
|
timerRef.current = setInterval(() => setSeconds((s) => s + 1), 1000);
|
|
287
|
-
} catch {
|
|
287
|
+
} catch (e) {
|
|
288
|
+
// [2026-08-23 修] 原来静默失败:无麦克风的电脑点语音按钮"点了没反应",用户完全不知道为啥。
|
|
289
|
+
// 分情况给明确提示,不再吞错误。
|
|
290
|
+
const name = e && e.name ? String(e.name) : "";
|
|
291
|
+
let msg = "";
|
|
292
|
+
if (name === "NotFoundError" || name === "OverconstrainedError" || name === "DevicesNotFoundError") {
|
|
293
|
+
msg = "未检测到录音设备,请连接麦克风,或在 Windows 声音设置中启用「立体声混音」作为录音设备。";
|
|
294
|
+
} else if (name === "NotAllowedError" || name === "PermissionDeniedError") {
|
|
295
|
+
msg = "麦克风权限被拒绝。请点击浏览器地址栏左侧的锁/摄像头图标,允许本站使用麦克风后重试。";
|
|
296
|
+
} else if (name === "NotReadableError" || name === "TrackStartError") {
|
|
297
|
+
msg = "麦克风被其他程序占用或不可读。请关闭正在使用麦克风的应用(如会议软件)后重试。";
|
|
298
|
+
} else if (name === "SecurityError") {
|
|
299
|
+
msg = "当前页面环境不允许访问麦克风(需要 HTTPS 或 localhost)。";
|
|
300
|
+
} else {
|
|
301
|
+
msg = "无法访问录音设备:" + (e && e.message ? e.message : String(e)) + "。请检查麦克风连接或浏览器权限。";
|
|
302
|
+
}
|
|
303
|
+
try { alert("无法开始录音\n\n" + msg); } catch { /* alert 被禁时退化为 console */ }
|
|
304
|
+
console.warn("[voice] getUserMedia failed:", e);
|
|
305
|
+
}
|
|
288
306
|
}, [connection, sessionId, sendVoiceBlob]);
|
|
289
307
|
|
|
290
308
|
// [2026-08-22] 大图自动缩放:官方限制图片宽高 ≤2000px,超出则 canvas 缩小(最长边对齐 2000px,转 jpeg)再上传
|
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({
|