From c335a6065d99e3a71b6ab2cf16b927bc8bbefe34 Mon Sep 17 00:00:00 2001 From: yimi528 <148250049+yimi528@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:40:54 +0800 Subject: [PATCH 01/11] feat(web): add one-shot camera photo input --- web/src/MultimodalComposer.jsx | 110 ++++++++++++++++++++++++++++++++- 1 file changed, 109 insertions(+), 1 deletion(-) diff --git a/web/src/MultimodalComposer.jsx b/web/src/MultimodalComposer.jsx index bea7e56e..ef293309 100644 --- a/web/src/MultimodalComposer.jsx +++ b/web/src/MultimodalComposer.jsx @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { MAX_INPUT_FILE_BYTES, createInputFilePart, @@ -6,6 +6,11 @@ import { withAttachmentAnchors, } from '../../shared/input-parts.mjs' import { t } from './i18n.js' +import { + CAMERA_IMAGE_TOO_LARGE, + captureCameraFrame, + stopCameraStream, +} from './camera-input.js' function filePart(file, index, sourceType = 'file') { return new Promise((resolve, reject) => { @@ -37,11 +42,71 @@ export default function MultimodalComposer({ const [text, setText] = useState('') const [attachments, setAttachments] = useState([]) const [error, setError] = useState('') + const [cameraOpen, setCameraOpen] = useState(false) + const [cameraReady, setCameraReady] = useState(false) const picker = useRef(null) + const cameraVideo = useRef(null) + const cameraStream = useRef(null) const updateAttachments = useCallback(next => { setAttachments(next) }, []) + const closeCamera = useCallback(() => { + stopCameraStream(cameraStream.current) + cameraStream.current = null + if (cameraVideo.current) { + cameraVideo.current.pause?.() + cameraVideo.current.srcObject = null + } + setCameraReady(false) + setCameraOpen(false) + }, []) + + useEffect(() => { + if (!cameraOpen || !cameraStream.current || !cameraVideo.current) return undefined + const video = cameraVideo.current + const stream = cameraStream.current + video.srcObject = stream + void video.play().catch(() => {}) + return () => { + if (video.srcObject === stream) video.srcObject = null + } + }, [cameraOpen]) + + useEffect(() => { + const onVisibilityChange = () => { + if (document.hidden) closeCamera() + } + document.addEventListener('visibilitychange', onVisibilityChange) + return () => document.removeEventListener('visibilitychange', onVisibilityChange) + }, [closeCamera]) + + useEffect(() => () => stopCameraStream(cameraStream.current), []) + + const openCamera = useCallback(async () => { + if (cameraStream.current) return + if (!navigator.mediaDevices?.getUserMedia) { + setError(t('相机不可用')) + return + } + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: false, + video: { + facingMode: { ideal: 'environment' }, + width: { ideal: 1280 }, + height: { ideal: 720 }, + }, + }) + cameraStream.current = stream + setCameraReady(false) + setCameraOpen(true) + setError('') + } catch { + setError(t('无法打开相机')) + } + }, []) + const addFiles = useCallback(async (fileList, sourceType = 'file') => { const files = [...fileList] if (!files.length) return @@ -51,11 +116,26 @@ export default function MultimodalComposer({ ))) updateAttachments([...attachments, ...next]) setError('') + return true } catch (reason) { setError(reason?.message || String(reason)) + return false } }, [attachments, updateAttachments]) + const capturePhoto = useCallback(async () => { + if (!cameraReady || !cameraVideo.current) return + try { + const blob = await captureCameraFrame(cameraVideo.current) + const file = new File([blob], 'photo.jpg', { type: 'image/jpeg' }) + if (await addFiles([file], 'camera')) closeCamera() + } catch (reason) { + setError(reason?.message === CAMERA_IMAGE_TOO_LARGE + ? t('照片超过 256 KiB 限制') + : t('无法拍摄照片')) + } + }, [addFiles, cameraReady, closeCamera]) + const submit = event => { event.preventDefault() const content = text.trim() @@ -100,6 +180,18 @@ export default function MultimodalComposer({ aria-label={t('添加图片或文件')} onClick={() => picker.current?.click()} >+ + + {cameraOpen &&
+
} {error && {error}} } From bad3a1a255f6fc582ee5fb7d0113f31474c69f11 Mon Sep 17 00:00:00 2001 From: yimi528 <148250049+yimi528@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:44:21 +0800 Subject: [PATCH 02/11] feat(web): translate camera capture controls --- web/src/i18n.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/web/src/i18n.js b/web/src/i18n.js index bbd37a7f..002bf022 100644 --- a/web/src/i18n.js +++ b/web/src/i18n.js @@ -83,6 +83,14 @@ const translations = { '输入文字,或粘贴、拖入图片和文件': 'Type a message, or paste and drop images and files', '输入文字或图片': 'Type text or add an image', '添加图片或文件': 'Add images or files', + '拍照': 'Take photo', + '相机预览': 'Camera preview', + '相机不可用': 'Camera is not available', + '无法打开相机': 'Unable to access the camera', + '无法拍摄照片': 'Unable to capture the photo', + '照片超过 256 KiB 限制': 'The photo exceeds the 256 KiB limit', + '拍摄': 'Capture', + '取消': 'Cancel', '移除附件': 'Remove attachment', '发送': 'Send', 'Gateway 尚未连接': 'Gateway is not connected yet', From a54b29d380f330e14e6a38615b887b1cca2b988d Mon Sep 17 00:00:00 2001 From: yimi528 <148250049+yimi528@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:44:54 +0800 Subject: [PATCH 03/11] feat(web): style camera capture panel --- web/src/styles.css | 50 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/web/src/styles.css b/web/src/styles.css index 982e5226..7f8f3d9a 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -36,6 +36,7 @@ } .composer-attach, +.composer-camera, .composer-send { min-width: 42px; height: 42px; @@ -46,6 +47,54 @@ cursor: pointer; } +.composer-camera svg { + width: 19px; + height: 19px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.7; + vertical-align: middle; +} + +.camera-capture { + display: grid; + gap: 10px; + margin-top: 10px; + padding: 10px; + border-radius: 14px; + background: rgba(0, 0, 0, 0.28); +} + +.camera-capture video { + width: 100%; + max-height: 320px; + border-radius: 10px; + background: #000; + object-fit: contain; +} + +.camera-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.camera-actions .ghost { + min-height: 42px; + padding: 0 14px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 13px; + color: inherit; + background: rgba(255, 255, 255, 0.06); +} + +.camera-actions button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + .composer-send { padding: 0 16px; background: #6d5dfc; @@ -227,6 +276,7 @@ html[data-desktop="orb"] #root { } .desktop-conversation-panel .composer-attach, +.desktop-conversation-panel .composer-camera, .desktop-conversation-panel .composer-send { min-width: 38px; height: 38px; From 70c5973352d1c6001d064616ea6d96d9fefc670f Mon Sep 17 00:00:00 2001 From: yimi528 <148250049+yimi528@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:45:27 +0800 Subject: [PATCH 04/11] docs: document one-shot camera input --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c51e814e..20051db9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- WebUI 新增用户主动触发的一次性拍照输入:照片会在浏览器本地压缩后作为附件发送, + 不会自动调用模型或持久化摄像头画面。 - 新增 Pi 后台支持:通过社区 `pi-acp` 适配器接入,并支持一键安装。Pi 没有 内置沙箱与权限审批机制,始终等效于 `full` 权限,请仅在可信环境中使用。 From 71350d17a8181ed124212133ab09edb849cf22fe Mon Sep 17 00:00:00 2001 From: yimi528 <148250049+yimi528@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:47:52 +0800 Subject: [PATCH 05/11] feat(web): add bounded camera frame encoding --- .../camera-input.js/web/src/camera-input.js | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 web/src/camera-input.js/web/src/camera-input.js diff --git a/web/src/camera-input.js/web/src/camera-input.js b/web/src/camera-input.js/web/src/camera-input.js new file mode 100644 index 00000000..004d7c43 --- /dev/null +++ b/web/src/camera-input.js/web/src/camera-input.js @@ -0,0 +1,85 @@ +export const CAMERA_MAX_WIDTH = 1280 +export const CAMERA_MAX_HEIGHT = 720 +export const CAMERA_MAX_BYTES = 256 * 1024 +export const CAMERA_IMAGE_TOO_LARGE = 'camera_image_too_large' + +export const CAMERA_JPEG_QUALITIES = Object.freeze([ + 0.86, + 0.74, + 0.62, + 0.5, + 0.38, + 0.25, + 0.18, + 0.12, + 0.08, +]) + +export function cameraFrameSize( + width, + height, + { maxWidth = CAMERA_MAX_WIDTH, maxHeight = CAMERA_MAX_HEIGHT } = {}, +) { + const sourceWidth = Number(width) + const sourceHeight = Number(height) + if (!Number.isFinite(sourceWidth) || !Number.isFinite(sourceHeight) + || sourceWidth <= 0 || sourceHeight <= 0) { + throw new Error('camera_dimensions_unavailable') + } + const scale = Math.min(1, maxWidth / sourceWidth, maxHeight / sourceHeight) + return { + width: Math.max(1, Math.round(sourceWidth * scale)), + height: Math.max(1, Math.round(sourceHeight * scale)), + } +} + +export function stopCameraStream(stream) { + stream?.getTracks?.().forEach(track => track.stop()) +} + +export function encodeCameraCanvas( + canvas, + { + maxBytes = CAMERA_MAX_BYTES, + qualities = CAMERA_JPEG_QUALITIES, + } = {}, +) { + if (!canvas || typeof canvas.toBlob !== 'function') { + return Promise.reject(new Error('camera_encoder_unavailable')) + } + const candidates = [...qualities].filter(value => Number.isFinite(value)) + if (!candidates.length) { + return Promise.reject(new Error('camera_encoder_unavailable')) + } + return new Promise((resolve, reject) => { + let index = 0 + const encode = () => { + canvas.toBlob(blob => { + if (blob && blob.size <= maxBytes) { + resolve(blob) + return + } + if (index + 1 >= candidates.length) { + reject(new Error( + blob ? CAMERA_IMAGE_TOO_LARGE : 'camera_encoder_unavailable', + )) + return + } + index += 1 + encode() + }, 'image/jpeg', candidates[index]) + } + encode() + }) +} + +export async function captureCameraFrame(video, options = {}) { + const dimensions = cameraFrameSize(video?.videoWidth, video?.videoHeight, options) + const canvas = document.createElement('canvas') + canvas.width = dimensions.width + canvas.height = dimensions.height + const context = canvas.getContext('2d') + if (!context) throw new Error('camera_encoder_unavailable') + context.drawImage(video, 0, 0, dimensions.width, dimensions.height) + return encodeCameraCanvas(canvas, options) +} From 090ac9476f65ca42386a80d54f78f60f059a14fd Mon Sep 17 00:00:00 2001 From: yimi528 <148250049+yimi528@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:48:44 +0800 Subject: [PATCH 06/11] test(web): cover one-shot camera capture limits --- .../web/test/camera-input.test.mjs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 web/test/camera-input.test.mjs/web/test/camera-input.test.mjs diff --git a/web/test/camera-input.test.mjs/web/test/camera-input.test.mjs b/web/test/camera-input.test.mjs/web/test/camera-input.test.mjs new file mode 100644 index 00000000..48816a20 --- /dev/null +++ b/web/test/camera-input.test.mjs/web/test/camera-input.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + CAMERA_IMAGE_TOO_LARGE, + cameraFrameSize, + captureCameraFrame, + encodeCameraCanvas, + stopCameraStream, +} from '../src/camera-input.js' + +test('fits camera frames inside the 720p capture bounds', () => { + assert.deepEqual(cameraFrameSize(1920, 1080), { width: 1280, height: 720 }) + assert.deepEqual(cameraFrameSize(1080, 1920), { width: 405, height: 720 }) + assert.deepEqual(cameraFrameSize(640, 480), { width: 640, height: 480 }) +}) + +test('rejects camera frames without usable dimensions', () => { + assert.throws(() => cameraFrameSize(0, 720), /camera_dimensions_unavailable/) + assert.throws(() => cameraFrameSize(Number.NaN, 720), /camera_dimensions_unavailable/) +}) + +test('lowers JPEG quality until the camera image fits the byte limit', async () => { + const qualities = [] + const canvas = { + toBlob(callback, mime, quality) { + qualities.push([mime, quality]) + callback({ size: Math.round(400_000 * quality) }) + }, + } + const blob = await encodeCameraCanvas(canvas, { + maxBytes: 200_000, + qualities: [0.8, 0.4], + }) + assert.equal(blob.size, 160_000) + assert.deepEqual(qualities, [ + ['image/jpeg', 0.8], + ['image/jpeg', 0.4], + ]) +}) + +test('rejects an image that cannot fit the camera byte limit', async () => { + const canvas = { + toBlob(callback) { + callback({ size: 500_000 }) + }, + } + await assert.rejects( + encodeCameraCanvas(canvas, { maxBytes: 200_000, qualities: [0.8, 0.4] }), + error => error.message === CAMERA_IMAGE_TOO_LARGE, + ) +}) + +test('draws the current video frame at the bounded size before encoding', async () => { + const previousDocument = globalThis.document + let canvas + let drawn + globalThis.document = { + createElement(type) { + assert.equal(type, 'canvas') + canvas = { + getContext: () => ({ + drawImage: (...args) => { drawn = args }, + }), + toBlob: callback => callback({ size: 100 }), + } + return canvas + }, + } + try { + await captureCameraFrame( + { videoWidth: 1920, videoHeight: 1080 }, + { maxBytes: 200, qualities: [0.8] }, + ) + assert.equal(canvas.width, 1280) + assert.equal(canvas.height, 720) + assert.deepEqual(drawn, [ + { videoWidth: 1920, videoHeight: 1080 }, + 0, + 0, + 1280, + 720, + ]) + } finally { + globalThis.document = previousDocument + } +}) + +test('stops every track when closing a camera stream', () => { + const stopped = [] + stopCameraStream({ + getTracks: () => [ + { stop: () => stopped.push('video') }, + { stop: () => stopped.push('audio') }, + ], + }) + assert.deepEqual(stopped, ['video', 'audio']) +}) From b6ea26d8f9c2b4be90a88bb306662fa3283f68ad Mon Sep 17 00:00:00 2001 From: yimi528 <148250049+yimi528@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:53:55 +0800 Subject: [PATCH 07/11] fix(web): remove duplicate camera module path --- .../camera-input.js/web/src/camera-input.js | 85 ------------------- 1 file changed, 85 deletions(-) delete mode 100644 web/src/camera-input.js/web/src/camera-input.js diff --git a/web/src/camera-input.js/web/src/camera-input.js b/web/src/camera-input.js/web/src/camera-input.js deleted file mode 100644 index 004d7c43..00000000 --- a/web/src/camera-input.js/web/src/camera-input.js +++ /dev/null @@ -1,85 +0,0 @@ -export const CAMERA_MAX_WIDTH = 1280 -export const CAMERA_MAX_HEIGHT = 720 -export const CAMERA_MAX_BYTES = 256 * 1024 -export const CAMERA_IMAGE_TOO_LARGE = 'camera_image_too_large' - -export const CAMERA_JPEG_QUALITIES = Object.freeze([ - 0.86, - 0.74, - 0.62, - 0.5, - 0.38, - 0.25, - 0.18, - 0.12, - 0.08, -]) - -export function cameraFrameSize( - width, - height, - { maxWidth = CAMERA_MAX_WIDTH, maxHeight = CAMERA_MAX_HEIGHT } = {}, -) { - const sourceWidth = Number(width) - const sourceHeight = Number(height) - if (!Number.isFinite(sourceWidth) || !Number.isFinite(sourceHeight) - || sourceWidth <= 0 || sourceHeight <= 0) { - throw new Error('camera_dimensions_unavailable') - } - const scale = Math.min(1, maxWidth / sourceWidth, maxHeight / sourceHeight) - return { - width: Math.max(1, Math.round(sourceWidth * scale)), - height: Math.max(1, Math.round(sourceHeight * scale)), - } -} - -export function stopCameraStream(stream) { - stream?.getTracks?.().forEach(track => track.stop()) -} - -export function encodeCameraCanvas( - canvas, - { - maxBytes = CAMERA_MAX_BYTES, - qualities = CAMERA_JPEG_QUALITIES, - } = {}, -) { - if (!canvas || typeof canvas.toBlob !== 'function') { - return Promise.reject(new Error('camera_encoder_unavailable')) - } - const candidates = [...qualities].filter(value => Number.isFinite(value)) - if (!candidates.length) { - return Promise.reject(new Error('camera_encoder_unavailable')) - } - return new Promise((resolve, reject) => { - let index = 0 - const encode = () => { - canvas.toBlob(blob => { - if (blob && blob.size <= maxBytes) { - resolve(blob) - return - } - if (index + 1 >= candidates.length) { - reject(new Error( - blob ? CAMERA_IMAGE_TOO_LARGE : 'camera_encoder_unavailable', - )) - return - } - index += 1 - encode() - }, 'image/jpeg', candidates[index]) - } - encode() - }) -} - -export async function captureCameraFrame(video, options = {}) { - const dimensions = cameraFrameSize(video?.videoWidth, video?.videoHeight, options) - const canvas = document.createElement('canvas') - canvas.width = dimensions.width - canvas.height = dimensions.height - const context = canvas.getContext('2d') - if (!context) throw new Error('camera_encoder_unavailable') - context.drawImage(video, 0, 0, dimensions.width, dimensions.height) - return encodeCameraCanvas(canvas, options) -} From 332f33edf57805c4ea6d869198773b5704b6953e Mon Sep 17 00:00:00 2001 From: yimi528 <148250049+yimi528@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:54:25 +0800 Subject: [PATCH 08/11] fix(web): remove duplicate camera test path --- .../web/test/camera-input.test.mjs | 97 ------------------- 1 file changed, 97 deletions(-) delete mode 100644 web/test/camera-input.test.mjs/web/test/camera-input.test.mjs diff --git a/web/test/camera-input.test.mjs/web/test/camera-input.test.mjs b/web/test/camera-input.test.mjs/web/test/camera-input.test.mjs deleted file mode 100644 index 48816a20..00000000 --- a/web/test/camera-input.test.mjs/web/test/camera-input.test.mjs +++ /dev/null @@ -1,97 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' -import { - CAMERA_IMAGE_TOO_LARGE, - cameraFrameSize, - captureCameraFrame, - encodeCameraCanvas, - stopCameraStream, -} from '../src/camera-input.js' - -test('fits camera frames inside the 720p capture bounds', () => { - assert.deepEqual(cameraFrameSize(1920, 1080), { width: 1280, height: 720 }) - assert.deepEqual(cameraFrameSize(1080, 1920), { width: 405, height: 720 }) - assert.deepEqual(cameraFrameSize(640, 480), { width: 640, height: 480 }) -}) - -test('rejects camera frames without usable dimensions', () => { - assert.throws(() => cameraFrameSize(0, 720), /camera_dimensions_unavailable/) - assert.throws(() => cameraFrameSize(Number.NaN, 720), /camera_dimensions_unavailable/) -}) - -test('lowers JPEG quality until the camera image fits the byte limit', async () => { - const qualities = [] - const canvas = { - toBlob(callback, mime, quality) { - qualities.push([mime, quality]) - callback({ size: Math.round(400_000 * quality) }) - }, - } - const blob = await encodeCameraCanvas(canvas, { - maxBytes: 200_000, - qualities: [0.8, 0.4], - }) - assert.equal(blob.size, 160_000) - assert.deepEqual(qualities, [ - ['image/jpeg', 0.8], - ['image/jpeg', 0.4], - ]) -}) - -test('rejects an image that cannot fit the camera byte limit', async () => { - const canvas = { - toBlob(callback) { - callback({ size: 500_000 }) - }, - } - await assert.rejects( - encodeCameraCanvas(canvas, { maxBytes: 200_000, qualities: [0.8, 0.4] }), - error => error.message === CAMERA_IMAGE_TOO_LARGE, - ) -}) - -test('draws the current video frame at the bounded size before encoding', async () => { - const previousDocument = globalThis.document - let canvas - let drawn - globalThis.document = { - createElement(type) { - assert.equal(type, 'canvas') - canvas = { - getContext: () => ({ - drawImage: (...args) => { drawn = args }, - }), - toBlob: callback => callback({ size: 100 }), - } - return canvas - }, - } - try { - await captureCameraFrame( - { videoWidth: 1920, videoHeight: 1080 }, - { maxBytes: 200, qualities: [0.8] }, - ) - assert.equal(canvas.width, 1280) - assert.equal(canvas.height, 720) - assert.deepEqual(drawn, [ - { videoWidth: 1920, videoHeight: 1080 }, - 0, - 0, - 1280, - 720, - ]) - } finally { - globalThis.document = previousDocument - } -}) - -test('stops every track when closing a camera stream', () => { - const stopped = [] - stopCameraStream({ - getTracks: () => [ - { stop: () => stopped.push('video') }, - { stop: () => stopped.push('audio') }, - ], - }) - assert.deepEqual(stopped, ['video', 'audio']) -}) From 4ced21cebc8c25e3c7bba79d0a82c3f36f2d54cf Mon Sep 17 00:00:00 2001 From: yimi528 <148250049+yimi528@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:03:46 +0800 Subject: [PATCH 09/11] fix(web): add camera encoder at the correct path --- web/src/camera-input.js | 85 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 web/src/camera-input.js diff --git a/web/src/camera-input.js b/web/src/camera-input.js new file mode 100644 index 00000000..004d7c43 --- /dev/null +++ b/web/src/camera-input.js @@ -0,0 +1,85 @@ +export const CAMERA_MAX_WIDTH = 1280 +export const CAMERA_MAX_HEIGHT = 720 +export const CAMERA_MAX_BYTES = 256 * 1024 +export const CAMERA_IMAGE_TOO_LARGE = 'camera_image_too_large' + +export const CAMERA_JPEG_QUALITIES = Object.freeze([ + 0.86, + 0.74, + 0.62, + 0.5, + 0.38, + 0.25, + 0.18, + 0.12, + 0.08, +]) + +export function cameraFrameSize( + width, + height, + { maxWidth = CAMERA_MAX_WIDTH, maxHeight = CAMERA_MAX_HEIGHT } = {}, +) { + const sourceWidth = Number(width) + const sourceHeight = Number(height) + if (!Number.isFinite(sourceWidth) || !Number.isFinite(sourceHeight) + || sourceWidth <= 0 || sourceHeight <= 0) { + throw new Error('camera_dimensions_unavailable') + } + const scale = Math.min(1, maxWidth / sourceWidth, maxHeight / sourceHeight) + return { + width: Math.max(1, Math.round(sourceWidth * scale)), + height: Math.max(1, Math.round(sourceHeight * scale)), + } +} + +export function stopCameraStream(stream) { + stream?.getTracks?.().forEach(track => track.stop()) +} + +export function encodeCameraCanvas( + canvas, + { + maxBytes = CAMERA_MAX_BYTES, + qualities = CAMERA_JPEG_QUALITIES, + } = {}, +) { + if (!canvas || typeof canvas.toBlob !== 'function') { + return Promise.reject(new Error('camera_encoder_unavailable')) + } + const candidates = [...qualities].filter(value => Number.isFinite(value)) + if (!candidates.length) { + return Promise.reject(new Error('camera_encoder_unavailable')) + } + return new Promise((resolve, reject) => { + let index = 0 + const encode = () => { + canvas.toBlob(blob => { + if (blob && blob.size <= maxBytes) { + resolve(blob) + return + } + if (index + 1 >= candidates.length) { + reject(new Error( + blob ? CAMERA_IMAGE_TOO_LARGE : 'camera_encoder_unavailable', + )) + return + } + index += 1 + encode() + }, 'image/jpeg', candidates[index]) + } + encode() + }) +} + +export async function captureCameraFrame(video, options = {}) { + const dimensions = cameraFrameSize(video?.videoWidth, video?.videoHeight, options) + const canvas = document.createElement('canvas') + canvas.width = dimensions.width + canvas.height = dimensions.height + const context = canvas.getContext('2d') + if (!context) throw new Error('camera_encoder_unavailable') + context.drawImage(video, 0, 0, dimensions.width, dimensions.height) + return encodeCameraCanvas(canvas, options) +} From 35fe035a7c6d393501b8faa3121a8d4615bac483 Mon Sep 17 00:00:00 2001 From: yimi528 <148250049+yimi528@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:09:10 +0800 Subject: [PATCH 10/11] fix(web): add camera tests at the correct path --- web/test/camera-input.test.mjs | 97 ++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 web/test/camera-input.test.mjs diff --git a/web/test/camera-input.test.mjs b/web/test/camera-input.test.mjs new file mode 100644 index 00000000..48816a20 --- /dev/null +++ b/web/test/camera-input.test.mjs @@ -0,0 +1,97 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + CAMERA_IMAGE_TOO_LARGE, + cameraFrameSize, + captureCameraFrame, + encodeCameraCanvas, + stopCameraStream, +} from '../src/camera-input.js' + +test('fits camera frames inside the 720p capture bounds', () => { + assert.deepEqual(cameraFrameSize(1920, 1080), { width: 1280, height: 720 }) + assert.deepEqual(cameraFrameSize(1080, 1920), { width: 405, height: 720 }) + assert.deepEqual(cameraFrameSize(640, 480), { width: 640, height: 480 }) +}) + +test('rejects camera frames without usable dimensions', () => { + assert.throws(() => cameraFrameSize(0, 720), /camera_dimensions_unavailable/) + assert.throws(() => cameraFrameSize(Number.NaN, 720), /camera_dimensions_unavailable/) +}) + +test('lowers JPEG quality until the camera image fits the byte limit', async () => { + const qualities = [] + const canvas = { + toBlob(callback, mime, quality) { + qualities.push([mime, quality]) + callback({ size: Math.round(400_000 * quality) }) + }, + } + const blob = await encodeCameraCanvas(canvas, { + maxBytes: 200_000, + qualities: [0.8, 0.4], + }) + assert.equal(blob.size, 160_000) + assert.deepEqual(qualities, [ + ['image/jpeg', 0.8], + ['image/jpeg', 0.4], + ]) +}) + +test('rejects an image that cannot fit the camera byte limit', async () => { + const canvas = { + toBlob(callback) { + callback({ size: 500_000 }) + }, + } + await assert.rejects( + encodeCameraCanvas(canvas, { maxBytes: 200_000, qualities: [0.8, 0.4] }), + error => error.message === CAMERA_IMAGE_TOO_LARGE, + ) +}) + +test('draws the current video frame at the bounded size before encoding', async () => { + const previousDocument = globalThis.document + let canvas + let drawn + globalThis.document = { + createElement(type) { + assert.equal(type, 'canvas') + canvas = { + getContext: () => ({ + drawImage: (...args) => { drawn = args }, + }), + toBlob: callback => callback({ size: 100 }), + } + return canvas + }, + } + try { + await captureCameraFrame( + { videoWidth: 1920, videoHeight: 1080 }, + { maxBytes: 200, qualities: [0.8] }, + ) + assert.equal(canvas.width, 1280) + assert.equal(canvas.height, 720) + assert.deepEqual(drawn, [ + { videoWidth: 1920, videoHeight: 1080 }, + 0, + 0, + 1280, + 720, + ]) + } finally { + globalThis.document = previousDocument + } +}) + +test('stops every track when closing a camera stream', () => { + const stopped = [] + stopCameraStream({ + getTracks: () => [ + { stop: () => stopped.push('video') }, + { stop: () => stopped.push('audio') }, + ], + }) + assert.deepEqual(stopped, ['video', 'audio']) +}) From 73989f7a743381c8939b307e45bfc4752ce72bc0 Mon Sep 17 00:00:00 2001 From: yimi528 Date: Wed, 2 Sep 2026 12:52:48 +0800 Subject: [PATCH 11/11] feat(web): add continuous camera observation --- CHANGELOG.md | 2 + README.md | 7 +- README_ZH.md | 5 +- docs/configuration.md | 12 +- docs/configuration.zh.md | 11 +- docs/contract.md | 4 +- docs/contract.zh.md | 3 +- server/src/core/gateway-protocol.mjs | 5 +- .../providers/openai-compatible-protocol.mjs | 5 + server/src/voice/realtime-gateway.mjs | 49 ++++- .../voice/realtime-observation-runtime.mjs | 190 ++++++++++++++++++ .../realtime-observation-runtime.test.mjs | 115 +++++++++++ server/test/realtime-provider.test.mjs | 4 +- shared/client-input-capabilities.mjs | 16 +- shared/input-parts.mjs | 2 +- shared/protocol/gateway-events.mjs | 19 ++ shared/realtime-events.mjs | 4 + shared/realtime-model-catalog.mjs | 2 +- test/realtime-provider-catalog.test.mjs | 2 +- web/src/App.jsx | 10 + web/src/MultimodalComposer.jsx | 172 +++++++++++++++- web/src/camera-input.js | 40 ++++ web/src/i18n.js | 9 + web/src/styles.css | 22 ++ web/src/useRealtimeVoice.js | 33 +++ web/test/camera-input.test.mjs | 45 +++++ web/test/client-input-capabilities.test.mjs | 3 +- web/test/health-capability.test.mjs | 22 +- 28 files changed, 775 insertions(+), 38 deletions(-) create mode 100644 server/src/voice/realtime-observation-runtime.mjs create mode 100644 server/test/realtime-observation-runtime.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 20051db9..56bd3921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - WebUI 新增用户主动触发的一次性拍照输入:照片会在浏览器本地压缩后作为附件发送, 不会自动调用模型或持久化摄像头画面。 +- WebUI 支持用户主动开启摄像头连续观察:约每秒发送一帧 JPEG,内存中最多保留最近 + 8 帧;不会因观察自动触发模型回复,页面隐藏、断线或停止时会释放摄像头。 - 新增 Pi 后台支持:通过社区 `pi-acp` 适配器接入,并支持一键安装。Pi 没有 内置沙箱与权限审批机制,始终等效于 `full` 权限,请仅在可信环境中使用。 diff --git a/README.md b/README.md index e1f6a2c1..0ea93d32 100644 --- a/README.md +++ b/README.md @@ -144,9 +144,10 @@ QWEN_AUDIO_AGENT_BACKEND_MODEL=qwen3.7-max > Uses DashScope realtime voice frontend by default; alternatively, switch to a local [speech-to-speech frontend](docs/voice-frontends/speech-to-speech.md), no cloud API Key needed. > `qwen3.5-omni-flash-realtime` and `qwen3.5-omni-plus-realtime` -> accept text, audio, and image at the model level. This release transports text and -> audio only; image/frame and native-video transport remain disabled until their client and -> Gateway paths are implemented. +> accept text, audio, and image at the model level. The WebUI also supports explicit +> camera observation for these Omni models: it sends one bounded JPEG frame per second, +> keeps at most eight recent frames in memory, and never creates a response from observation +> alone. Native video and observation on legacy Audio models remain unavailable. The Desktop app or `qwenaudio config set --realtime-model ` configures the single Gateway-wide model. Restart the Gateway after a CLI change. WebUI and TUI display the active diff --git a/README_ZH.md b/README_ZH.md index df2fe46c..39f00644 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -136,8 +136,9 @@ QWEN_AUDIO_AGENT_BACKEND_MODEL=qwen3.7-max > 默认使用 DashScope 实时语音前台;也可切换为本地 [speech-to-speech 前台](docs/voice-frontends/speech-to-speech.zh.md),无需云端 API Key。 > `qwen3.5-omni-flash-realtime` 与 `qwen3.5-omni-plus-realtime` -> 在模型层支持文本、音频和图片输入。本版本客户端传输层仅启用文本和音频; -> 图片/画面帧与原生视频传输仍保持关闭,待对应客户端和 Gateway 链路实现。 +> 在模型层支持文本、音频和图片输入。WebUI 对这两个 Omni 模型还支持用户显式开启的 +> 摄像头连续观察:每秒发送一帧有界 JPEG,内存中最多保留最近 8 帧,观察本身不会创建 +> 模型回复。原生视频和旧版 Audio 模型的画面观察仍不可用。 桌面版或 `qwenaudio config set --realtime-model ` 配置 Gateway 统一使用的模型; CLI 修改后需要重启 Gateway。WebUI 与 TUI 只显示当前生效模型,不单独覆盖模型。 diff --git a/docs/configuration.md b/docs/configuration.md index 63129ad9..22e10824 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -889,14 +889,16 @@ The exact supported IDs are: | Model | Model input | Model output | Current client transport | | --- | --- | --- | --- | -| `qwen3.5-omni-flash-realtime` | text, audio, image | text, audio | text, audio | -| `qwen3.5-omni-plus-realtime` | text, audio, image | text, audio | text, audio | +| `qwen3.5-omni-flash-realtime` | text, audio, image | text, audio | text, audio, JPEG observation | +| `qwen3.5-omni-plus-realtime` | text, audio, image | text, audio | text, audio, JPEG observation | | `qwen-audio-3.0-realtime-plus` (default) | text, audio | text, audio | text, audio | | `qwen-audio-3.0-realtime-flash` | text, audio | text, audio | text, audio | -All four profiles support Function Calling. Model capability is not the same as an implemented -client transport: JPEG observation frames and native video are both disabled in this release. -WebUI and TUI read the authoritative profile from Gateway health and only display it. Separate +All four profiles support Function Calling. The two Omni profiles support the WebUI's explicit +JPEG observation transport; frames are sent at about 1 FPS, capped at eight recent in-memory +frames, and do not create model responses by themselves. Native video and observation on the +legacy Audio profiles remain unavailable. WebUI and TUI read the authoritative profile from +Gateway health and use it to gate or display the available inputs. Separate clients cannot select conflicting models on one Gateway. A Desktop attached to a borrowed Gateway, or a later CLI runtime using a conflicting configured model, refuses the mismatch instead of silently changing the running service. To roll back, set the legacy ID above and diff --git a/docs/configuration.zh.md b/docs/configuration.zh.md index a19b7ed7..8b68187e 100644 --- a/docs/configuration.zh.md +++ b/docs/configuration.zh.md @@ -730,14 +730,15 @@ qwenaudio gateway restart | 模型 | 模型输入 | 模型输出 | 当前客户端传输 | | --- | --- | --- | --- | -| `qwen3.5-omni-flash-realtime` | 文本、音频、图片 | 文本、音频 | 文本、音频 | -| `qwen3.5-omni-plus-realtime` | 文本、音频、图片 | 文本、音频 | 文本、音频 | +| `qwen3.5-omni-flash-realtime` | 文本、音频、图片 | 文本、音频 | 文本、音频、JPEG 观察 | +| `qwen3.5-omni-plus-realtime` | 文本、音频、图片 | 文本、音频 | 文本、音频、JPEG 观察 | | `qwen-audio-3.0-realtime-plus`(默认) | 文本、音频 | 文本、音频 | 文本、音频 | | `qwen-audio-3.0-realtime-flash` | 文本、音频 | 文本、音频 | 文本、音频 | -四个档案都支持 Function Calling。模型能力不等于客户端已经实现的传输能力:本版本 -仍关闭 JPEG 观察帧和原生视频传输。WebUI 与 TUI 从 Gateway health 读取权威档案并 -只读展示;同一 Gateway 上的不同客户端不能选择互相冲突的模型。桌面版附着到借用的 +四个档案都支持 Function Calling。两个 Omni 档案支持 WebUI 显式开启的 JPEG 观察传输: +约每秒发送一帧,内存中最多保留最近 8 帧,观察本身不会创建模型回复。原生视频以及 +旧版 Audio 档案的画面观察仍不可用。WebUI 与 TUI 从 Gateway health 读取权威档案,并据此 +限制或展示可用输入;同一 Gateway 上的不同客户端不能选择互相冲突的模型。桌面版附着到借用的 Gateway 时,或后续 CLI 运行时使用了冲突的已配置模型时,会拒绝不一致,而不会静默 修改运行中服务。回滚时设置上表的旧版模型 ID 并重启 Gateway。 diff --git a/docs/contract.md b/docs/contract.md index 1aabc55f..81d2ebf6 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -18,7 +18,8 @@ a feature then degrades instead of failing. Versioning follows SemVer: the minor rises for an additive capability, the major for a breaking change to any endpoint or event named below. -The current version is `5.0.0`. The `5.0` line removes the backend-controlled +The current version is `5.1.0`. The `5.1` line adds explicit camera observation +events; the `5.0` line removes the backend-controlled Task `presentation` envelope. A backend returns factual `content` plus optional typed `artifacts`; the foreground Chatbot decides how to speak, while each Conversation Client decides how to render. The same line publishes the existing @@ -57,6 +58,7 @@ below instead of assuming the old list. | `tasks.unified-id-updates` | A Task exposes one short `id`; `task.updated` carries adapter-normalized incremental messages and artifacts | `test/gateway-event-schema.test.mjs`, `server/test/task-manager.test.mjs` | | `messages.citations` | Final assistant `transcript.final` events may carry normalized citations collected from frontend retrieval in the same turn | `test/gateway-event-schema.test.mjs`, `server/test/realtime-presentation-runtime.test.mjs` | | `realtime.conversation-client-v1` | `WS /api/realtime`, published event constants, and message schemas form the replaceable text/audio/multimodal Conversation Client boundary | `test/gateway-event-schema.test.mjs`, `test/custom-conversation-client.test.mjs` | +| `realtime.camera-observation-v1` | After an explicit WebUI opt-in, sends at most one bounded JPEG frame per second and retains at most eight recent frames for a vision-capable Realtime frontend; it never creates a model response and releases the camera on stop, disconnect, or page hide | `server/test/realtime-observation-runtime.test.mjs`, `web/test/camera-input.test.mjs` | | `desktop.orb-shell` | The orb form's main-process contract ships: `bindOrbShell` answers the channels the shipped preload sends | `desktop/test/orb-shell.test.mjs` | | `desktop.orb-window-factory` | `createOrbWindow` owns the orb window recipe; its `destroy()` is the host's synchronous teardown path (renderer exit is what releases the microphone) | `desktop/test/orb-window.test.mjs` | | `desktop.orb-placement` | `createOrbPlacement` covers the default anchor, display clamping and drop persistence | `desktop/test/orb-placement.test.mjs` | diff --git a/docs/contract.zh.md b/docs/contract.zh.md index 2a913d05..7482eb04 100644 --- a/docs/contract.zh.md +++ b/docs/contract.zh.md @@ -14,7 +14,7 @@ 版本号遵循 SemVer:新增能力升 minor;下文点名的任一端点或事件发生破坏性 变更升 major。 -当前版本为 `5.0.0`。`5.0` 删除由后台控制的 Task `presentation` 包装:后台只返回 +当前版本为 `5.1.0`。`5.1` 增加用户显式开启的摄像头画面观察事件;`5.0` 删除由后台控制的 Task `presentation` 包装:后台只返回 事实性 `content` 与可选的类型化 `artifacts`,前台 Chatbot 决定如何播报,各个对话 客户端决定如何呈现。同一版本同时将现有 `WS /api/realtime` 事件模型正式发布为 可替换的对话客户端边界。`4.0` 将原来的 `workId` / `jobId` 双重身份收敛为 Task 的唯一短 @@ -47,6 +47,7 @@ Task 事件提供与 A2A 对齐的 `submitted`、 | `tasks.unified-id-updates` | Task 只公开一个短 `id`;`task.updated` 携带 Adapter 归一化后的增量消息与产物 | `test/gateway-event-schema.test.mjs`、`server/test/task-manager.test.mjs` | | `messages.citations` | 最终助手 `transcript.final` 可以携带同一轮前台检索产生的规范化 Citation | `test/gateway-event-schema.test.mjs`、`server/test/realtime-presentation-runtime.test.mjs` | | `realtime.conversation-client-v1` | `WS /api/realtime`、公开事件常量与消息 Schema 共同构成可替换的文本/音频/多模态对话客户端边界 | `test/gateway-event-schema.test.mjs`、`test/custom-conversation-client.test.mjs` | +| `realtime.camera-observation-v1` | WebUI 显式开启后,以约 1 FPS 发送最近最多 8 帧 JPEG 到支持视觉输入的 Realtime 前台;不自动创建模型响应,停止、断线、隐藏页面时释放相机 | `server/test/realtime-observation-runtime.test.mjs`、`web/test/camera-input.test.mjs` | | `desktop.orb-shell` | 悬浮球形态的主进程契约随包发布:`bindOrbShell` 应答随包 preload 发出的全部通道 | `desktop/test/orb-shell.test.mjs` | | `desktop.orb-window-factory` | `createOrbWindow` 持有悬浮球窗口配方;其 `destroy()` 是宿主的同步销毁路径(渲染进程退出才能确定性释放麦克风) | `desktop/test/orb-window.test.mjs` | | `desktop.orb-placement` | `createOrbPlacement` 覆盖默认锚点、显示器夹取与拖放持久化 | `desktop/test/orb-placement.test.mjs` | diff --git a/server/src/core/gateway-protocol.mjs b/server/src/core/gateway-protocol.mjs index 8fcc5b54..63886a01 100644 --- a/server/src/core/gateway-protocol.mjs +++ b/server/src/core/gateway-protocol.mjs @@ -30,7 +30,7 @@ // desktop.settings-window, …) are not part of this contract, and a removed // capability is a breaking change. Hosts migrating from the fork must branch // on the capability list below, never on the version number. -export const GATEWAY_PROTOCOL_VERSION = '5.0.0' +export const GATEWAY_PROTOCOL_VERSION = '5.1.0' export const GATEWAY_CAPABILITIES = Object.freeze([ // The Gateway statically hosts web/dist at its own origin, so a client may @@ -86,6 +86,9 @@ export const GATEWAY_CAPABILITIES = Object.freeze([ // replaceable Conversation Client boundary for audio, text, multimodal // input, transcripts, playback receipts, voice state and Task projections. 'realtime.conversation-client-v1', + // The WebUI may explicitly open a camera and stream bounded JPEG snapshots + // to a vision-capable Realtime frontend without triggering responses. + 'realtime.camera-observation-v1', // The orb shell contract ships: qwen-audio-agent/orb/preload plus // orb/main's bindOrbShell, so a host may run the floating orb form. 'desktop.orb-shell', diff --git a/server/src/voice/providers/openai-compatible-protocol.mjs b/server/src/voice/providers/openai-compatible-protocol.mjs index 116abbea..ba421519 100644 --- a/server/src/voice/providers/openai-compatible-protocol.mjs +++ b/server/src/voice/providers/openai-compatible-protocol.mjs @@ -28,6 +28,11 @@ export const openAiCompatibleProtocol = Object.freeze({ audio, }), + imageAppend: image => ({ + type: 'input_image_buffer.append', + image, + }), + // Client-assigned id for a conversation item. The beta dialect accepts one // opaque namespace for every item type. conversationItemId: () => `item_${randomUUID().replaceAll('-', '')}`, diff --git a/server/src/voice/realtime-gateway.mjs b/server/src/voice/realtime-gateway.mjs index e5c2aa59..26db7e5c 100644 --- a/server/src/voice/realtime-gateway.mjs +++ b/server/src/voice/realtime-gateway.mjs @@ -28,6 +28,7 @@ import { ToolCallHandler } from './tools/tool-call-handler.mjs' import { TurnTranscripts } from './tools/turn-transcripts.mjs' import { TurnCitations } from './turn-citations.mjs' import { RealtimeInputRuntime } from './realtime-input-runtime.mjs' +import { RealtimeObservationRuntime } from './realtime-observation-runtime.mjs' import { acceptsPlaybackReceipt, confirmsTaskNotificationOnPlaybackStart, @@ -207,6 +208,7 @@ export function attachRealtimeGateway(server, { const announcedPermissions = new Set() let permissionRetryTimer = null let realtimeSession + let observationRuntime const activeSessionTasks = () => taskManager.list({ ownerId, sessionId, @@ -378,10 +380,13 @@ export function attachRealtimeGateway(server, { announcements.flush() } }, - onDisconnected: () => send(ws, { - type: GatewayServerEvent.VOICE_STATE, - state: 'idle', - }), + onDisconnected: () => { + observationRuntime?.stop('realtime_disconnected') + send(ws, { + type: GatewayServerEvent.VOICE_STATE, + state: 'idle', + }) + }, onReconnected: () => { announcements.flush() progressAnnouncements.flush() @@ -399,6 +404,12 @@ export function attachRealtimeGateway(server, { maxPendingAudioChunks: MAX_PENDING_AUDIO_CHUNKS, stableConnectionMs: REALTIME_STABLE_CONNECTION_MS, }) + observationRuntime = new RealtimeObservationRuntime({ + ensureFrontend: () => realtimeSession.ensure(), + getFrontend: () => realtimeSession.frontend, + send: event => send(ws, event), + onError: reportFrontendError, + }) const voiceClient = { ws, descriptor, @@ -412,6 +423,7 @@ export function attachRealtimeGateway(server, { if (suspend) { // Buffered audio predates the suspension and is no longer wanted. realtimeSession.clearPendingAudio() + observationRuntime?.stop('input_suspended') sleepController?.disable() realtimeSession.cancelResponse() send(ws, { type: GatewayServerEvent.PLAYBACK_CLEAR, reason: 'input_suspended' }) @@ -439,6 +451,7 @@ export function attachRealtimeGateway(server, { sleepController?.disable() inputEnabled = false outputEnabled = false + observationRuntime?.stop('voice_deactivated') announcementWindow.reset() announcements.pause() progressAnnouncements.clear() @@ -801,6 +814,7 @@ export function attachRealtimeGateway(server, { const enterSleep = () => { if (sleeping) return + observationRuntime?.stop('sleeping') sleeping = true waking = false announcementWindow.reset() @@ -1051,6 +1065,7 @@ export function attachRealtimeGateway(server, { text: event.inputCapabilities.text === true, audio: event.inputCapabilities.audio === true, image: event.inputCapabilities.image === true, + observation: event.inputCapabilities.observation === true, resource: event.inputCapabilities.resource === true, } : null @@ -1131,6 +1146,29 @@ export function attachRealtimeGateway(server, { return } realtimeSession.appendAudio(event.audio) + } else if (event.type === GatewayClientEvent.OBSERVATION_START) { + if (sleeping || waking) { + send(ws, { + type: GatewayServerEvent.ERROR, + message: `已休眠,请先说“${config.wakeWord}”唤醒。`, + }) + return + } + if (inputSuspended) { + send(ws, { + type: GatewayServerEvent.ERROR, + message: '当前输入正被其他客户端占用,无法进行画面观察。', + }) + return + } + sleepController.recordActivity() + observationRuntime.start() + } else if (event.type === GatewayClientEvent.OBSERVATION_FRAME) { + if (sleeping || waking) return + sleepController.recordActivity() + observationRuntime.frame(event) + } else if (event.type === GatewayClientEvent.OBSERVATION_STOP) { + observationRuntime.stop(event.reason || 'user') } else if ( event.type === GatewayClientEvent.TEXT_MESSAGE || event.type === GatewayClientEvent.INPUT_MESSAGE @@ -1177,6 +1215,7 @@ export function attachRealtimeGateway(server, { } } else if (event.type === GatewayClientEvent.MUTE) { explicitSleepRequested = false + observationRuntime.stop('voice_muted') releaseVoiceClient() sleeping = false waking = false @@ -1187,6 +1226,7 @@ export function attachRealtimeGateway(server, { realtimeSession.close({ notifyDisconnected: true }) } else if (event.type === GatewayClientEvent.INPUT_MUTE) { inputEnabled = false + observationRuntime.stop('input_muted') realtimeSession.clearPendingAudio() } else if (event.type === GatewayClientEvent.SLEEP) { requestExplicitSleep() @@ -1224,6 +1264,7 @@ export function attachRealtimeGateway(server, { clearTimeout(permissionRetryTimer) permissionRetryTimer = null sleepController?.close() + observationRuntime?.stop('gateway_disconnected') realtimeSession.close() // Invisible memory: distil durable personal facts from this session in // the background. All gating (debounce, minimum turns, disabled state) diff --git a/server/src/voice/realtime-observation-runtime.mjs b/server/src/voice/realtime-observation-runtime.mjs new file mode 100644 index 00000000..61d83ebd --- /dev/null +++ b/server/src/voice/realtime-observation-runtime.mjs @@ -0,0 +1,190 @@ +import { GatewayServerEvent } from '../../../shared/realtime-events.mjs' + +export const OBSERVATION_INTERVAL_MS = 1000 +export const OBSERVATION_MAX_FRAMES = 8 +export const OBSERVATION_MAX_BASE64_BYTES = 256 * 1024 + +const BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/ + +function normalizeImage(value, maxBytes) { + const image = String(value || '').trim() + if (!image) throw new Error('画面观察缺少图片数据') + if (image.length > maxBytes) { + throw new Error('画面观察图片超过 256 KiB 限制') + } + if (image.length % 4 === 1 || !BASE64_PATTERN.test(image)) { + throw new Error('画面观察图片不是有效的 Base64 数据') + } + return image +} + +function statePayload(state, frames) { + return { + type: GatewayServerEvent.OBSERVATION_STATE, + state, + frames, + } +} + +/** + * Relays explicitly-enabled browser camera frames to a Realtime provider. + * + * This runtime deliberately never calls response.create. The image buffer is + * part of the provider's current audio timeline and is consumed by the next + * user turn. Raw Base64 is kept only in a bounded in-memory ring while this + * connection is observing; stop(), provider disconnects, and socket teardown + * clear it. + */ +export class RealtimeObservationRuntime { + constructor({ + ensureFrontend, + getFrontend, + send, + onError, + now = () => Date.now(), + intervalMs = OBSERVATION_INTERVAL_MS, + maxFrames = OBSERVATION_MAX_FRAMES, + maxBase64Bytes = OBSERVATION_MAX_BASE64_BYTES, + } = {}) { + this.ensureFrontend = ensureFrontend + this.getFrontend = getFrontend + this.send = send + this.onError = onError + this.now = now + this.intervalMs = Math.max(1, Number(intervalMs) || OBSERVATION_INTERVAL_MS) + this.maxFrames = Math.max(1, Math.floor(Number(maxFrames) || OBSERVATION_MAX_FRAMES)) + this.maxBase64Bytes = Math.max( + 4, + Math.floor(Number(maxBase64Bytes) || OBSERVATION_MAX_BASE64_BYTES), + ) + this.state = 'idle' + this.frames = [] + this.lastFrameAt = 0 + this.generation = 0 + this.startPromise = null + } + + snapshot() { + return { + state: this.state, + frames: this.frames.length, + lastFrameAt: this.lastFrameAt, + } + } + + start() { + if (this.state === 'active') return Promise.resolve(true) + if (this.startPromise) return this.startPromise + + const generation = ++this.generation + this.frames = [] + this.lastFrameAt = 0 + this.state = 'starting' + this.#publish() + + const start = Promise.resolve() + .then(() => this.ensureFrontend?.()) + .then(() => { + if (generation !== this.generation) return false + const frontend = this.getFrontend?.() + if (!frontend || frontend.transportCapabilities?.observationInput !== true) { + throw new Error('当前 Realtime 模型不支持画面观察') + } + this.state = 'active' + this.#publish() + return true + }) + .catch(error => { + if (generation !== this.generation) return false + this.state = 'unavailable' + this.frames = [] + this.lastFrameAt = 0 + this.#publish() + this.onError?.(error) + return false + }) + .finally(() => { + if (this.startPromise === start) this.startPromise = null + }) + this.startPromise = start + return start + } + + stop(reason = 'user') { + const safeReason = String(reason || 'user').slice(0, 80) + const hadObservation = ( + this.state !== 'idle' + || Boolean(this.startPromise) + || this.frames.length > 0 + ) + this.generation += 1 + this.startPromise = null + this.state = 'idle' + this.frames = [] + this.lastFrameAt = 0 + if (hadObservation) this.#publish(safeReason) + } + + frame({ image, sequence } = {}) { + if (this.state !== 'active') return false + + let normalized + try { + normalized = normalizeImage(image, this.maxBase64Bytes) + } catch (error) { + this.onError?.(error) + return false + } + + const now = this.now() + if (this.frames.length && now - this.lastFrameAt < this.intervalMs) return false + + const frontend = this.getFrontend?.() + if (!frontend || frontend.transportCapabilities?.observationInput !== true) { + this.stop('provider_unavailable') + this.onError?.(new Error('画面观察的 Realtime 连接不可用')) + return false + } + if (typeof frontend.send !== 'function') { + this.stop('provider_unavailable') + this.onError?.(new Error('Realtime 前台不支持画面观察传输')) + return false + } + + try { + // DashScope Qwen-Omni WebSocket input contract. No response.create is + // sent here, so observation alone never makes the assistant speak. + const payload = typeof frontend.protocol?.imageAppend === 'function' + ? frontend.protocol.imageAppend(normalized) + : { + type: 'input_image_buffer.append', + image: normalized, + } + frontend.send(payload) + } catch (error) { + this.onError?.(error) + return false + } + + this.lastFrameAt = now + this.frames.push({ + image: normalized, + sequence: Number.isInteger(sequence) ? sequence : null, + capturedAt: now, + }) + if (this.frames.length > this.maxFrames) { + this.frames.splice(0, this.frames.length - this.maxFrames) + } + this.#publish() + return true + } + + #publish(reason = '') { + this.send?.({ + ...statePayload(this.state, this.frames.length), + ...(reason ? { reason } : {}), + }) + } +} + +export { normalizeImage as normalizeObservationImage } diff --git a/server/test/realtime-observation-runtime.test.mjs b/server/test/realtime-observation-runtime.test.mjs new file mode 100644 index 00000000..9657ab5e --- /dev/null +++ b/server/test/realtime-observation-runtime.test.mjs @@ -0,0 +1,115 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + OBSERVATION_INTERVAL_MS, + OBSERVATION_MAX_FRAMES, + RealtimeObservationRuntime, +} from '../src/voice/realtime-observation-runtime.mjs' +import { openAiCompatibleProtocol } from '../src/voice/providers/openai-compatible-protocol.mjs' + +function fixture({ supported = true } = {}) { + let now = 0 + const providerEvents = [] + const gatewayEvents = [] + const errors = [] + const frontend = { + transportCapabilities: { observationInput: supported }, + send: event => providerEvents.push(event), + } + const runtime = new RealtimeObservationRuntime({ + ensureFrontend: async () => {}, + getFrontend: () => frontend, + send: event => gatewayEvents.push(event), + onError: error => errors.push(error), + now: () => now, + }) + return { + runtime, + frontend, + providerEvents, + gatewayEvents, + errors, + setNow(value) { + now = value + }, + } +} + +const JPEG = '/9j/fake' + +test('encodes a camera frame with the DashScope image-buffer event', () => { + assert.deepEqual(openAiCompatibleProtocol.imageAppend(JPEG), { + type: 'input_image_buffer.append', + image: JPEG, + }) +}) + +test('starts explicitly and forwards observation frames without creating responses', async () => { + const fixtureState = fixture() + assert.equal(await fixtureState.runtime.start(), true) + assert.deepEqual( + fixtureState.gatewayEvents.map(event => event.state), + ['starting', 'active'], + ) + + assert.equal(fixtureState.runtime.frame({ image: JPEG, sequence: 1 }), true) + assert.deepEqual(fixtureState.providerEvents, [{ + type: 'input_image_buffer.append', + image: JPEG, + }]) + assert.equal( + fixtureState.providerEvents.some(event => event.type === 'response.create'), + false, + ) +}) + +test('limits observation to about one frame per second and retains eight frames', async () => { + const fixtureState = fixture() + await fixtureState.runtime.start() + for (let sequence = 0; sequence < OBSERVATION_MAX_FRAMES + 2; sequence += 1) { + fixtureState.setNow(sequence * OBSERVATION_INTERVAL_MS) + assert.equal(fixtureState.runtime.frame({ image: JPEG, sequence }), true) + } + fixtureState.setNow(OBSERVATION_INTERVAL_MS * (OBSERVATION_MAX_FRAMES + 1) + 1) + assert.equal(fixtureState.runtime.frame({ image: JPEG, sequence: 99 }), false) + assert.equal(fixtureState.runtime.snapshot().frames, OBSERVATION_MAX_FRAMES) + assert.equal(fixtureState.providerEvents.length, OBSERVATION_MAX_FRAMES + 2) +}) + +test('rejects malformed or oversized frames without forwarding them', async () => { + const fixtureState = fixture() + await fixtureState.runtime.start() + assert.equal(fixtureState.runtime.frame({ image: 'not base64!' }), false) + assert.equal( + fixtureState.runtime.frame({ image: `A${'a'.repeat(256 * 1024)}` }), + false, + ) + assert.equal(fixtureState.providerEvents.length, 0) + assert.equal(fixtureState.errors.length, 2) +}) + +test('fails closed when the active model does not support observation', async () => { + const fixtureState = fixture({ supported: false }) + assert.equal(await fixtureState.runtime.start(), false) + assert.equal(fixtureState.runtime.snapshot().state, 'unavailable') + assert.equal(fixtureState.errors.length, 1) + assert.equal(fixtureState.errors[0].message, '当前 Realtime 模型不支持画面观察') +}) + +test('stop clears raw frame memory and publishes an idle state', async () => { + const fixtureState = fixture() + await fixtureState.runtime.start() + fixtureState.runtime.frame({ image: JPEG, sequence: 1 }) + fixtureState.runtime.stop('page_hidden') + assert.deepEqual(fixtureState.runtime.snapshot(), { + state: 'idle', + frames: 0, + lastFrameAt: 0, + }) + assert.deepEqual(fixtureState.gatewayEvents.at(-1), { + type: 'observation.state', + state: 'idle', + frames: 0, + reason: 'page_hidden', + }) +}) diff --git a/server/test/realtime-provider.test.mjs b/server/test/realtime-provider.test.mjs index 5d5691eb..ad760852 100644 --- a/server/test/realtime-provider.test.mjs +++ b/server/test/realtime-provider.test.mjs @@ -458,7 +458,7 @@ test('prefers the selected DashScope family voice override over the profile defa ) }) -test('advertises Omni model vision without admitting unsupported visual transport', t => { +test('advertises Omni model vision and supported observation transport', t => { const originalModel = config.audioModel t.after(() => { config.audioModel = originalModel @@ -471,7 +471,7 @@ test('advertises Omni model vision without admitting unsupported visual transpor assert.equal(profile.modelCapabilities.imageInput, true) assert.equal(profile.modelCapabilities.videoInput, false) assert.equal(profile.transportCapabilities.imageInput, false) - assert.equal(profile.transportCapabilities.observationInput, false) + assert.equal(profile.transportCapabilities.observationInput, true) assert.equal(profile.transportCapabilities.nativeVideoInput, false) assert.equal(frontend.modelProfile, profile) assert.equal(frontend.modelCapabilities, profile.modelCapabilities) diff --git a/shared/client-input-capabilities.mjs b/shared/client-input-capabilities.mjs index b6bfa1f0..2c0980ae 100644 --- a/shared/client-input-capabilities.mjs +++ b/shared/client-input-capabilities.mjs @@ -1,10 +1,22 @@ const PROFILES = Object.freeze({ - web: Object.freeze({ text: true, audio: true, image: true, resource: true }), + web: Object.freeze({ + text: true, + audio: true, + image: true, + observation: true, + resource: true, + }), cli: Object.freeze({ text: true, audio: true, image: true, resource: true }), // The desktop orb and conversation panel are two presentations of the same // client connection. Advertise the panel's inputs for the whole connection // so expanding the window never requires a Realtime reconnect. - desktop: Object.freeze({ text: true, audio: true, image: true, resource: true }), + desktop: Object.freeze({ + text: true, + audio: true, + image: true, + observation: true, + resource: true, + }), }) export function clientInputCapabilities(clientType = 'web') { diff --git a/shared/input-parts.mjs b/shared/input-parts.mjs index 48ba98c5..7cea13e5 100644 --- a/shared/input-parts.mjs +++ b/shared/input-parts.mjs @@ -37,7 +37,7 @@ export function parseDataUrl(value) { function normalizeSource(source) { if (!source || typeof source !== 'object') return undefined - const type = ['clipboard', 'file', 'resource'].includes(source.type) + const type = ['clipboard', 'file', 'resource', 'camera'].includes(source.type) ? source.type : 'file' const text = source.text && typeof source.text === 'object' diff --git a/shared/protocol/gateway-events.mjs b/shared/protocol/gateway-events.mjs index 18b526c4..112f0765 100644 --- a/shared/protocol/gateway-events.mjs +++ b/shared/protocol/gateway-events.mjs @@ -159,6 +159,14 @@ const GatewayInputMessagePayloadSchema = z.object({ message: 'a text or parts payload is required', }) +const GatewayObservationFramePayloadSchema = z.object({ + // The browser sends the Base64 body rather than a data URL. Keeping the + // protocol bound close to the provider's 256 KiB encoded-image limit avoids + // accepting a large frame that would only be rejected downstream. + image: z.string().min(1).max(256 * 1024), + sequence: z.number().int().nonnegative().optional(), +}).passthrough() + const GatewayClientPayloadSchemas = Object.freeze({ [GatewayClientEvent.CONNECT]: z.object({ voiceEnabled: z.boolean().optional(), @@ -177,6 +185,7 @@ const GatewayClientPayloadSchemas = Object.freeze({ text: z.boolean().optional(), audio: z.boolean().optional(), image: z.boolean().optional(), + observation: z.boolean().optional(), resource: z.boolean().optional(), }).passthrough().optional(), clientStates: z.array(z.string().min(1)).optional(), @@ -186,6 +195,11 @@ const GatewayClientPayloadSchemas = Object.freeze({ }).passthrough(), [GatewayClientEvent.TEXT_MESSAGE]: GatewayInputMessagePayloadSchema, [GatewayClientEvent.INPUT_MESSAGE]: GatewayInputMessagePayloadSchema, + [GatewayClientEvent.OBSERVATION_START]: z.object({}).passthrough(), + [GatewayClientEvent.OBSERVATION_FRAME]: GatewayObservationFramePayloadSchema, + [GatewayClientEvent.OBSERVATION_STOP]: z.object({ + reason: z.string().max(80).optional(), + }).passthrough(), [GatewayClientEvent.PLAYBACK_STARTED]: z.object({ responseId: z.string().min(1), }).passthrough(), @@ -254,6 +268,11 @@ const GatewayVoicePayloadSchemas = Object.freeze({ [GatewayServerEvent.TRANSCRIPT_DISCARD]: z.object({ role: z.enum(['user', 'assistant']), }).passthrough(), + [GatewayServerEvent.OBSERVATION_STATE]: z.object({ + state: z.enum(['idle', 'starting', 'active', 'unavailable']), + frames: z.number().int().nonnegative().optional(), + reason: z.string().max(80).optional(), + }).passthrough(), [GatewayServerEvent.AGENT_ACTIVITY]: z.object({ activity: z.string().min(1), }).passthrough(), diff --git a/shared/realtime-events.mjs b/shared/realtime-events.mjs index bf4d30f0..46d39bcd 100644 --- a/shared/realtime-events.mjs +++ b/shared/realtime-events.mjs @@ -7,6 +7,9 @@ export const GatewayClientEvent = Object.freeze({ AUDIO_APPEND: 'audio.append', TEXT_MESSAGE: 'text.message', INPUT_MESSAGE: 'input.message', + OBSERVATION_START: 'observation.start', + OBSERVATION_FRAME: 'observation.frame', + OBSERVATION_STOP: 'observation.stop', INTERRUPT: 'interrupt', SLEEP: 'sleep', WAKE: 'wake', @@ -43,6 +46,7 @@ export const GatewayServerEvent = Object.freeze({ TRANSCRIPT_DELTA: 'transcript.delta', TRANSCRIPT_FINAL: 'transcript.final', TRANSCRIPT_DISCARD: 'transcript.discard', + OBSERVATION_STATE: 'observation.state', AGENT_ACTIVITY: 'agent.activity', CLIENT_STATE: 'client.state', ERROR: 'error', diff --git a/shared/realtime-model-catalog.mjs b/shared/realtime-model-catalog.mjs index 275a919b..5c74692a 100644 --- a/shared/realtime-model-catalog.mjs +++ b/shared/realtime-model-catalog.mjs @@ -11,7 +11,7 @@ const OMNI_MODEL_CAPABILITIES = Object.freeze({ }) const OMNI_TRANSPORT_CAPABILITIES = Object.freeze({ textInput: true, audioInput: true, imageInput: false, - observationInput: false, nativeVideoInput: false, + observationInput: true, nativeVideoInput: false, }) const LEGACY_MODEL_CAPABILITIES = Object.freeze({ textInput: true, audioInput: true, imageInput: false, videoInput: false, diff --git a/test/realtime-provider-catalog.test.mjs b/test/realtime-provider-catalog.test.mjs index cbc327ed..c207c11a 100644 --- a/test/realtime-provider-catalog.test.mjs +++ b/test/realtime-provider-catalog.test.mjs @@ -27,7 +27,7 @@ const omniTransportCapabilities = { textInput: true, audioInput: true, imageInput: false, - observationInput: false, + observationInput: true, nativeVideoInput: false, } diff --git a/web/src/App.jsx b/web/src/App.jsx index 43153045..4937350b 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -1006,6 +1006,10 @@ export default function App() { nativeVideo: t('原生视频'), } const modeList = modes => modes.map(mode => inputModeLabels[mode]).join(' / ') + const observationAvailable = ( + modelStatus.metadataStatus === 'current' + && modelStatus.observationInputEnabled === true + ) const resetSession = () => { taskDismissTimers.current.forEach(timer => clearTimeout(timer)) @@ -1440,6 +1444,12 @@ export default function App() { {composerEnabled && } diff --git a/web/src/MultimodalComposer.jsx b/web/src/MultimodalComposer.jsx index ef293309..4fce9c4b 100644 --- a/web/src/MultimodalComposer.jsx +++ b/web/src/MultimodalComposer.jsx @@ -8,6 +8,11 @@ import { import { t } from './i18n.js' import { CAMERA_IMAGE_TOO_LARGE, + OBSERVATION_INTERVAL_MS, + OBSERVATION_MAX_BYTES, + OBSERVATION_MAX_FRAMES, + appendRecentObservationFrame, + blobToBase64, captureCameraFrame, stopCameraStream, } from './camera-input.js' @@ -37,6 +42,12 @@ function filePart(file, index, sourceType = 'file') { export default function MultimodalComposer({ onSend, + onObservationStart, + onObservationFrame, + onObservationStop, + observationAvailable = false, + observationState = 'idle', + connectionState = 'connected', compact = false, }) { const [text, setText] = useState('') @@ -44,14 +55,29 @@ export default function MultimodalComposer({ const [error, setError] = useState('') const [cameraOpen, setCameraOpen] = useState(false) const [cameraReady, setCameraReady] = useState(false) + const [observationRequested, setObservationRequested] = useState(false) + const [observationFrameCount, setObservationFrameCount] = useState(0) const picker = useRef(null) const cameraVideo = useRef(null) const cameraStream = useRef(null) + const observationRequestedRef = useRef(false) + const observationFramesRef = useRef([]) + const observationSequenceRef = useRef(0) + const previousObservationStateRef = useRef(observationState) const updateAttachments = useCallback(next => { setAttachments(next) }, []) - const closeCamera = useCallback(() => { + const stopObservation = useCallback((reason = 'user') => { + if (observationRequestedRef.current) onObservationStop?.(reason) + observationRequestedRef.current = false + observationFramesRef.current = [] + setObservationRequested(false) + setObservationFrameCount(0) + }, [onObservationStop]) + + const closeCamera = useCallback((reason = 'user') => { + stopObservation(reason) stopCameraStream(cameraStream.current) cameraStream.current = null if (cameraVideo.current) { @@ -60,7 +86,7 @@ export default function MultimodalComposer({ } setCameraReady(false) setCameraOpen(false) - }, []) + }, [stopObservation]) useEffect(() => { if (!cameraOpen || !cameraStream.current || !cameraVideo.current) return undefined @@ -73,15 +99,119 @@ export default function MultimodalComposer({ } }, [cameraOpen]) + useEffect(() => { + if (!cameraOpen || !cameraStream.current) return undefined + const stream = cameraStream.current + const onTrackEnded = () => closeCamera('camera_disconnected') + const tracks = stream.getTracks?.() || [] + tracks.forEach(track => track.addEventListener?.('ended', onTrackEnded)) + return () => tracks.forEach(track => ( + track.removeEventListener?.('ended', onTrackEnded) + )) + }, [cameraOpen, closeCamera]) + useEffect(() => { const onVisibilityChange = () => { - if (document.hidden) closeCamera() + if (document.hidden) closeCamera('page_hidden') } document.addEventListener('visibilitychange', onVisibilityChange) return () => document.removeEventListener('visibilitychange', onVisibilityChange) }, [closeCamera]) - useEffect(() => () => stopCameraStream(cameraStream.current), []) + useEffect(() => () => { + if (observationRequestedRef.current) onObservationStop?.('unmount') + observationRequestedRef.current = false + observationFramesRef.current = [] + stopCameraStream(cameraStream.current) + }, [onObservationStop]) + + const startObservation = useCallback(() => { + if (observationRequestedRef.current) return + if (!observationAvailable) { + setError(t('当前模型不支持画面观察')) + return + } + if (!cameraReady || !cameraVideo.current) return + if (onObservationStart && onObservationStart() === false) { + setError(t('画面观察连接不可用')) + return + } + observationRequestedRef.current = true + setObservationRequested(true) + setError('') + }, [cameraReady, observationAvailable, onObservationStart]) + + useEffect(() => { + const previous = previousObservationStateRef.current + previousObservationStateRef.current = observationState + if (!observationRequestedRef.current) return + if (!observationAvailable) { + setError(t('当前模型不支持画面观察')) + closeCamera('model_changed') + return + } + if (['unavailable', 'hidden'].includes(connectionState)) { + setError(t('画面观察连接不可用')) + closeCamera('gateway_disconnected') + return + } + if (observationState === 'unavailable') { + setError(t('画面观察连接不可用')) + closeCamera('provider_unavailable') + return + } + if ( + observationState === 'idle' + && ['starting', 'active', 'unavailable'].includes(previous) + ) { + closeCamera('observation_stopped') + } + }, [closeCamera, connectionState, observationAvailable, observationState]) + + useEffect(() => { + if ( + !observationRequested + || observationState !== 'active' + || !cameraReady + ) return undefined + let disposed = false + let capturing = false + const captureAndSend = async () => { + const video = cameraVideo.current + if (disposed || capturing || !video) return + capturing = true + try { + const blob = await captureCameraFrame(video, { + maxBytes: OBSERVATION_MAX_BYTES, + }) + const image = await blobToBase64(blob) + if (disposed || !observationRequestedRef.current) return + const sequence = observationSequenceRef.current++ + const recent = appendRecentObservationFrame( + observationFramesRef.current, + { image, sequence }, + OBSERVATION_MAX_FRAMES, + ) + observationFramesRef.current = recent + setObservationFrameCount(recent.length) + onObservationFrame?.(image, sequence) + } catch (reason) { + if (disposed) return + setError(reason?.message === CAMERA_IMAGE_TOO_LARGE + ? t('画面观察图片超过 256 KiB 限制') + : t('画面观察捕获失败')) + closeCamera('capture_error') + } finally { + capturing = false + } + } + void captureAndSend() + const timer = setInterval(captureAndSend, OBSERVATION_INTERVAL_MS) + return () => { + disposed = true + clearInterval(timer) + } + }, [cameraReady, closeCamera, observationRequested, observationState, onObservationFrame]) const openCamera = useCallback(async () => { if (cameraStream.current) return @@ -128,7 +258,9 @@ export default function MultimodalComposer({ try { const blob = await captureCameraFrame(cameraVideo.current) const file = new File([blob], 'photo.jpg', { type: 'image/jpeg' }) - if (await addFiles([file], 'camera')) closeCamera() + if (await addFiles([file], 'camera') && !observationRequestedRef.current) { + closeCamera('photo_captured') + } } catch (reason) { setError(reason?.message === CAMERA_IMAGE_TOO_LARGE ? t('照片超过 256 KiB 限制') @@ -230,9 +362,35 @@ export default function MultimodalComposer({ onLoadedMetadata={() => setCameraReady(true)} aria-label={t('相机预览')} /> + {observationRequested && + {observationState !== 'active' + ? t('正在启动画面观察') + : t('连续观察中:最近 {count}/8 帧', { count: observationFrameCount })} + }
- - + {!observationRequested && } + {observationRequested && } +
diff --git a/web/src/camera-input.js b/web/src/camera-input.js index 004d7c43..145117f3 100644 --- a/web/src/camera-input.js +++ b/web/src/camera-input.js @@ -1,6 +1,12 @@ export const CAMERA_MAX_WIDTH = 1280 export const CAMERA_MAX_HEIGHT = 720 export const CAMERA_MAX_BYTES = 256 * 1024 +// DashScope measures the encoded Base64 payload, so keep the raw JPEG below +// the provider's recommended ~190 KiB margin before converting it to Base64. +export const OBSERVATION_MAX_BYTES = 190 * 1024 +export const OBSERVATION_MAX_BASE64_BYTES = 256 * 1024 +export const OBSERVATION_MAX_FRAMES = 8 +export const OBSERVATION_INTERVAL_MS = 1000 export const CAMERA_IMAGE_TOO_LARGE = 'camera_image_too_large' export const CAMERA_JPEG_QUALITIES = Object.freeze([ @@ -37,6 +43,40 @@ export function stopCameraStream(stream) { stream?.getTracks?.().forEach(track => track.stop()) } +export function blobToBase64(blob) { + if (!blob || typeof FileReader === 'undefined') { + return Promise.reject(new Error('camera_encoder_unavailable')) + } + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onerror = () => reject(reader.error || new Error('camera_encoder_unavailable')) + reader.onload = () => { + const dataUrl = String(reader.result || '') + const separator = dataUrl.indexOf(',') + if (separator < 0) { + reject(new Error('camera_encoder_unavailable')) + return + } + const base64 = dataUrl.slice(separator + 1) + if (base64.length > OBSERVATION_MAX_BASE64_BYTES) { + reject(new Error(CAMERA_IMAGE_TOO_LARGE)) + return + } + resolve(base64) + } + reader.readAsDataURL(blob) + }) +} + +export function appendRecentObservationFrame( + frames, + frame, + maxFrames = OBSERVATION_MAX_FRAMES, +) { + const next = [...(Array.isArray(frames) ? frames : []), frame] + return next.slice(-Math.max(1, Math.floor(Number(maxFrames) || OBSERVATION_MAX_FRAMES))) +} + export function encodeCameraCanvas( canvas, { diff --git a/web/src/i18n.js b/web/src/i18n.js index 002bf022..6328bc2a 100644 --- a/web/src/i18n.js +++ b/web/src/i18n.js @@ -91,6 +91,15 @@ const translations = { '照片超过 256 KiB 限制': 'The photo exceeds the 256 KiB limit', '拍摄': 'Capture', '取消': 'Cancel', + '连续观察': 'Continuous observation', + '停止观察': 'Stop observation', + '关闭相机': 'Close camera', + '连续观察中:最近 {count}/8 帧': 'Observing continuously: latest {count}/8 frames', + '正在启动画面观察': 'Starting visual observation', + '当前模型不支持画面观察': 'The current model does not support visual observation', + '画面观察连接不可用': 'Visual observation is unavailable', + '画面观察图片超过 256 KiB 限制': 'The observation image exceeds the 256 KiB limit', + '画面观察捕获失败': 'Could not capture an observation frame', '移除附件': 'Remove attachment', '发送': 'Send', 'Gateway 尚未连接': 'Gateway is not connected yet', diff --git a/web/src/styles.css b/web/src/styles.css index 7f8f3d9a..72e88921 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -77,10 +77,32 @@ .camera-actions { display: flex; + align-items: center; + flex-wrap: wrap; justify-content: flex-end; gap: 8px; } +.camera-observation-status { + color: #b8b0ff; + font-size: 12px; +} + +.camera-observation { + min-height: 42px; + padding: 0 14px; + border: 1px solid rgba(154, 139, 255, 0.45); + border-radius: 13px; + color: #e5e0ff; + background: rgba(109, 93, 252, 0.16); +} + +.camera-observation.active { + border-color: rgba(126, 229, 197, 0.45); + color: #bff6e3; + background: rgba(48, 148, 117, 0.2); +} + .camera-actions .ghost { min-height: 42px; padding: 0 14px; diff --git a/web/src/useRealtimeVoice.js b/web/src/useRealtimeVoice.js index 3e28837f..f3f51553 100644 --- a/web/src/useRealtimeVoice.js +++ b/web/src/useRealtimeVoice.js @@ -136,6 +136,7 @@ export function realtimeModelStatus(health = {}) { TRANSPORT_INPUT_CAPABILITIES, ), imageInputEnabled: transportCapabilities?.imageInput === true, + observationInputEnabled: transportCapabilities?.observationInput === true, } } @@ -219,6 +220,7 @@ export default function useRealtimeVoice({ createGatewayClientState, ) const [inputReady, setInputReady] = useState(false) + const [observationState, setObservationState] = useState('idle') const [error, setError] = useState('') const [visualError, setVisualError] = useState(false) const { @@ -556,6 +558,7 @@ export default function useRealtimeVoice({ state: 'hidden', }) setInputReady(false) + setObservationState('idle') setError('') setVisualError(false) return undefined @@ -628,6 +631,9 @@ export default function useRealtimeVoice({ setVisualError(true) } } + if (event.type === GatewayServerEvent.OBSERVATION_STATE) { + setObservationState(event.state || 'idle') + } if (event.type === GatewayServerEvent.TURN_STARTED) { currentTurnId.current = event.turnId || '' if ( @@ -682,6 +688,7 @@ export default function useRealtimeVoice({ if (disposed) return releaseManualInputGuard() stopPlayback() + setObservationState('idle') const disconnectedEvent = { type: GatewayServerEvent.GATEWAY_DISCONNECTED, } @@ -733,6 +740,7 @@ export default function useRealtimeVoice({ useEffect(() => { pendingManualInputsRef.current = [] + setObservationState('idle') }, [sessionId]) useEffect(() => { @@ -881,6 +889,27 @@ export default function useRealtimeVoice({ return false }, [holdManualInputGuard, releaseManualInputGuard, sendSocketEvent]) + const sendObservationStart = useCallback(() => ( + sendSocketEvent({ type: GatewayClientEvent.OBSERVATION_START }) + ), [sendSocketEvent]) + + const sendObservationFrame = useCallback((image, sequence) => { + const value = String(image || '').trim() + if (!value) return false + return sendSocketEvent({ + type: GatewayClientEvent.OBSERVATION_FRAME, + image: value, + ...(Number.isInteger(sequence) ? { sequence } : {}), + }) + }, [sendSocketEvent]) + + const sendObservationStop = useCallback((reason = 'user') => ( + sendSocketEvent({ + type: GatewayClientEvent.OBSERVATION_STOP, + reason: String(reason || 'user').slice(0, 80), + }) + ), [sendSocketEvent]) + return { state, visualState: visualVoiceState(state), @@ -888,11 +917,15 @@ export default function useRealtimeVoice({ error, visualError, connectionState, + observationState, wakeWordActive, ownership, activateAudio, interrupt, wake, sendInput, + sendObservationStart, + sendObservationFrame, + sendObservationStop, } } diff --git a/web/test/camera-input.test.mjs b/web/test/camera-input.test.mjs index 48816a20..1720d380 100644 --- a/web/test/camera-input.test.mjs +++ b/web/test/camera-input.test.mjs @@ -2,6 +2,9 @@ import assert from 'node:assert/strict' import test from 'node:test' import { CAMERA_IMAGE_TOO_LARGE, + OBSERVATION_MAX_BASE64_BYTES, + appendRecentObservationFrame, + blobToBase64, cameraFrameSize, captureCameraFrame, encodeCameraCanvas, @@ -95,3 +98,45 @@ test('stops every track when closing a camera stream', () => { }) assert.deepEqual(stopped, ['video', 'audio']) }) + +test('keeps only the latest eight observation frames', () => { + const frames = Array.from({ length: 8 }, (_, sequence) => ({ sequence })) + const next = appendRecentObservationFrame(frames, { sequence: 8 }) + assert.equal(next.length, 8) + assert.deepEqual(next.map(frame => frame.sequence), [1, 2, 3, 4, 5, 6, 7, 8]) +}) + +test('converts a captured JPEG blob to its Base64 body', async () => { + const previousReader = globalThis.FileReader + class FakeFileReader { + readAsDataURL(blob) { + assert.equal(blob, 'blob') + this.result = 'data:image/jpeg;base64,/9j/fake' + this.onload?.() + } + } + globalThis.FileReader = FakeFileReader + try { + assert.equal(await blobToBase64('blob'), '/9j/fake') + } finally { + globalThis.FileReader = previousReader + } +}) + +test('rejects a Base64 observation body above the provider limit', async () => { + const previousReader = globalThis.FileReader + class FakeFileReader { + readAsDataURL() { + this.result = `data:image/jpeg;base64,${'a'.repeat(OBSERVATION_MAX_BASE64_BYTES + 1)}` + this.onload?.() + } + } + globalThis.FileReader = FakeFileReader + try { + await assert.rejects(blobToBase64('blob'), error => ( + error.message === CAMERA_IMAGE_TOO_LARGE + )) + } finally { + globalThis.FileReader = previousReader + } +}) diff --git a/web/test/client-input-capabilities.test.mjs b/web/test/client-input-capabilities.test.mjs index ec3e3b76..d0c32770 100644 --- a/web/test/client-input-capabilities.test.mjs +++ b/web/test/client-input-capabilities.test.mjs @@ -5,11 +5,12 @@ import { supportsComposerInput, } from '../../shared/client-input-capabilities.mjs' -test('WebUI advertises text, audio, image, and resource input', () => { +test('WebUI advertises text, audio, image, observation, and resource input', () => { assert.deepEqual(clientInputCapabilities('web'), { text: true, audio: true, image: true, + observation: true, resource: true, }) assert.equal(supportsComposerInput('web'), true) diff --git a/web/test/health-capability.test.mjs b/web/test/health-capability.test.mjs index fc467ab6..494c4626 100644 --- a/web/test/health-capability.test.mjs +++ b/web/test/health-capability.test.mjs @@ -25,6 +25,7 @@ function profile(id, label, { imageInput = false, videoInput = false, transportImageInput = false, + transportObservationInput = false, } = {}) { return { id, @@ -40,7 +41,7 @@ function profile(id, label, { textInput: true, audioInput: true, imageInput: transportImageInput, - observationInput: false, + observationInput: transportObservationInput, nativeVideoInput: false, }, } @@ -68,6 +69,7 @@ for (const activeProfile of [flash, plus]) { assert.deepEqual(status.modelInputModes, ['text', 'audio', 'image']) assert.deepEqual(status.transportInputModes, ['text', 'audio']) assert.equal(status.imageInputEnabled, false) + assert.equal(status.observationInputEnabled, false) }) } @@ -82,6 +84,7 @@ test('shows the legacy model without claiming image support', () => { assert.deepEqual(status.modelInputModes, ['text', 'audio']) assert.deepEqual(status.transportInputModes, ['text', 'audio']) assert.equal(status.imageInputEnabled, false) + assert.equal(status.observationInputEnabled, false) }) test('fails closed when model profile metadata is missing', () => { @@ -96,6 +99,7 @@ test('fails closed when model profile metadata is missing', () => { assert.deepEqual(status.modelInputModes, []) assert.deepEqual(status.transportInputModes, []) assert.equal(status.imageInputEnabled, false) + assert.equal(status.observationInputEnabled, false) }) test('rejects stale profile capabilities when the active model changed', () => { @@ -130,6 +134,22 @@ test('enables image controls only for current exact catalog transport truth', () assert.equal(status.imageInputEnabled, true) assert.deepEqual(status.transportInputModes, ['text', 'audio', 'image']) + assert.equal(status.observationInputEnabled, false) +}) + +test('enables continuous observation only for current exact transport truth', () => { + const observable = profile('observable-model', 'Observable model', { + imageInput: true, + transportObservationInput: true, + }) + const status = realtimeModelStatus({ + realtimeModel: observable.id, + realtimeModelProfile: observable, + realtimeModelCatalog: [observable], + }) + + assert.equal(status.observationInputEnabled, true) + assert.deepEqual(status.transportInputModes, ['text', 'audio', 'observation']) }) test('keeps an advertised provider selection separate from the model', () => {