feat: 拍照提问接入本地知识库检索;修复摄像头 deviceId 失效导致打不开

架构调整(按 docs/feature-pic-design.md §11):拍照提问从"图+文直接
问视觉模型"改成三段式——① 用固定提示词让视觉模型快一句话描述图片
(describeImage,非流式,实测 6.7s / 45 tokens)② 描述+用户问题拼接
去检索本地知识库 ③ 用户原始问题+检索到的上下文做纯文字生成。②③ 完全
复用文字问答的 embedOne/search/streamGenerate,图片不再传给最终生成。
前端补 vision-query:stage / :caption / :context 三个事件,识别结果以
"识别到:xxx"常驻显示。

同时修一个真机暴露的 bug:CameraCapture 用配置里持久化的 deviceId 做
exact 约束打开摄像头,但 deviceId 在 Web 标准里不保证跨会话稳定,重启
后旧 id 失效直接抛 OverconstrainedError,整个拍照功能不可用(真机截图
确认弹层报错、预览全黑)。改为失效时自动回退默认摄像头,并把实际拿到的
设备 id 回写,让"切到后置摄像头"的逻辑在回退场景下同样生效。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QeoAy5KmkHuKWHAktpEsaN
This commit is contained in:
ssurui 2026-09-03 20:47:33 +08:00
parent 87b9ffbab5
commit 687e0ca9e2
4 changed files with 133 additions and 39 deletions

View File

@ -1,23 +1,57 @@
import { ipcMain, BrowserWindow } from 'electron' import { ipcMain, BrowserWindow } from 'electron'
import { streamGenerateVision } from '../rag/generator' import { getConfig, getDbPath } from '../config'
import { embedOne } from '../rag/embedder'
import { search } from '../db/lancedb'
import { describeImage, streamGenerate } from '../rag/generator'
// 拍照提问:跳过向量检索,图片 + 问题直接发给 er_backend 代理的多模态生成服务 // 拍照提问:先用视觉模型快速生成一句图像描述(不查库),描述+用户问题拼接去检索本地
// 知识库,再用用户原始问题(没打字就用描述本身)+ 检索到的上下文做纯文字生成——
// 跟 queryHandler.ts 的文字问答共用 embedOne/search/streamGenerate,只是多了第①步。
export function registerVisionHandlers(getWin: () => BrowserWindow | null): void { export function registerVisionHandlers(getWin: () => BrowserWindow | null): void {
ipcMain.handle('vision-query', async (_event, payload: { ipcMain.handle('vision-query', async (_event, payload: {
question: string question: string
imageDataUrl: string imageDataUrl: string
}) => { }) => {
const config = getConfig()
const dbPath = getDbPath()
console.log(`\n${'='.repeat(60)}`) console.log(`\n${'='.repeat(60)}`)
console.log('[vision-query] 问题:', payload.question) console.log('[vision-query] 用户问题:', payload.question || '(未输入)')
const send = (channel: string, data: unknown): void => { const send = (channel: string, data: unknown): void => {
getWin()?.webContents.send(channel, data) getWin()?.webContents.send(channel, data)
} }
try { try {
await streamGenerateVision( // ① 图像理解:固定提示词,快速拿一句话描述
payload.question, send('vision-query:stage', { stage: '正在识别图片...' })
payload.imageDataUrl, const caption = await describeImage(payload.imageDataUrl)
send('vision-query:caption', { caption })
// ② 检索:描述 + 用户问题拼接,扩大召回面
const retrievalQuery = payload.question ? `${caption}\n${payload.question}` : caption
send('vision-query:stage', { stage: '正在检索...' })
const queryVector = await embedOne(retrievalQuery, config.embed)
const chunks = await search(dbPath, queryVector, config.rag.topK)
console.log(`[vision-query] 检索到 ${chunks.length} 个片段:`)
chunks.forEach((c, i) => {
console.log(` 片段${i + 1} 相关度=${c.score} 来源=${c.source}`)
})
send('vision-query:context', { chunks })
if (chunks.length === 0) {
send('vision-query:delta', { delta: '我不知道。' })
send('vision-query:done', {})
return
}
// ③ 生成:用用户原始问题保持聚焦,没打字就用描述本身
const genQuestion = payload.question || caption
send('vision-query:stage', { stage: '正在生成...' })
await streamGenerate(
genQuestion,
chunks,
config.rag.systemPrompt,
{ {
onDelta: (delta) => send('vision-query:delta', { delta }), onDelta: (delta) => send('vision-query:delta', { delta }),
onDone: (stats) => send('vision-query:done', { stats: stats ?? null }), onDone: (stats) => send('vision-query:done', { stats: stats ?? null }),

View File

@ -57,29 +57,47 @@ ${context}
) )
} }
// 拍照提问:不检索、不拼接知识库上下文,图片 + 问题直接发给多模态生成服务 // 拍照提问第一步:用固定的默认提示词(不是用户问题)让视觉模型快速给一句话描述,
export async function streamGenerateVision( // 用于后续拼检索查询。单次非流式请求——只要一句简短结果,不需要逐字显示,
question: string, // 也不需要每次都重新走一遍长回答的 SSE 解析。
imageDataUrl: string, const IMAGE_DESCRIBE_PROMPT =
callbacks: GeneratorCallbacks '请用一句话简要描述这张图片中的部件、故障现象或异常情况,不要展开解释、不要客套。'
): Promise<void> {
console.log('\n[generator] ---- 拍照提问 ----')
console.log(`[generator] 服务地址:${ER_BACKEND_URL}(er_backend 代理)`)
console.log('[generator] 问题:', question)
console.log('[generator] ---- 消息结束 ----\n')
await streamChatCompletion( export async function describeImage(imageDataUrl: string): Promise<string> {
[ const baseUrl = ER_BACKEND_URL
const messages: ChatMessage[] = [
{ {
role: 'user', role: 'user',
content: [ content: [
{ type: 'text', text: question }, { type: 'text', text: IMAGE_DESCRIBE_PROMPT },
{ type: 'image_url', image_url: { url: imageDataUrl } } { type: 'image_url', image_url: { url: imageDataUrl } }
] ]
} }
], ]
callbacks
) console.log('\n[generator] ---- 图像理解(拍照提问第①步)----')
console.log(`[generator] 服务地址:${baseUrl}(er_backend 代理)`)
const start = Date.now()
let response: Response
try {
response = await fetch(`${baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'default', messages, stream: false })
})
} catch (e) {
throw new Error(`无法连接视觉理解服务(${baseUrl},er_backend 代理):${String(e)}`)
}
if (!response.ok) {
throw new Error(`图像理解请求失败:${response.status}`)
}
const data = await response.json()
const caption: string = data.choices?.[0]?.message?.content?.trim() ?? ''
console.log(`[generator] 图像描述(耗时 ${((Date.now() - start) / 1000).toFixed(1)}s):${caption}`)
return caption
} }
// llama.cpp server OpenAI 兼容接口(/v1/chat/completions),SSE 格式 // llama.cpp server OpenAI 兼容接口(/v1/chat/completions),SSE 格式

View File

@ -29,18 +29,36 @@ function stopStream(): void {
stream = null stream = null
} }
/**
* 优先按指定 deviceId 打开摄像头;该 id 打不开时退回默认摄像头。
* deviceId 在 Web 标准里不保证跨会话稳定,配置里存的是上次运行时的 id,
* 换一次会话后经常已经失效——此时 exact 约束会抛 OverconstrainedError,
* 不兜底的话整个拍照功能都用不了。
*/
async function openStream(deviceId: string): Promise<MediaStream> {
if (!deviceId) return navigator.mediaDevices.getUserMedia({ video: true })
try {
return await navigator.mediaDevices.getUserMedia({ video: { deviceId: { exact: deviceId } } })
} catch (e) {
console.warn('[camera] 指定的摄像头不可用,回退到默认摄像头:', e)
return navigator.mediaDevices.getUserMedia({ video: true })
}
}
async function startStream(deviceId: string): Promise<void> { async function startStream(deviceId: string): Promise<void> {
stopStream() stopStream()
errorMessage.value = '' errorMessage.value = ''
starting.value = true starting.value = true
try { try {
stream = await navigator.mediaDevices.getUserMedia({ stream = await openStream(deviceId)
video: deviceId ? { deviceId: { exact: deviceId } } : true
})
if (videoRef.value) { if (videoRef.value) {
videoRef.value.srcObject = stream videoRef.value.srcObject = stream
await videoRef.value.play() await videoRef.value.play()
} }
// 记录实际拿到的设备(回退时跟请求的 deviceId 不是同一个),
// 后续切换/匹配后置摄像头都以这个真实值为准
const actualId = stream.getVideoTracks()[0]?.getSettings().deviceId
if (actualId) selectedDeviceId.value = actualId
// 拿到权限后 label 才会有值,借机刷新一次设备列表 // 拿到权限后 label 才会有值,借机刷新一次设备列表
await refreshDevices() await refreshDevices()
} catch (e) { } catch (e) {
@ -119,9 +137,13 @@ watch(
selectedDeviceId.value = props.initialDeviceId selectedDeviceId.value = props.initialDeviceId
// 摄像头 label 在拿到权限前是空的(浏览器隐私限制),没法在这一步就按标签 // 摄像头 label 在拿到权限前是空的(浏览器隐私限制),没法在这一步就按标签
// 找后置摄像头,先随便开一个拿到权限;startStream 成功后 devices 里才有真实 label。 // 找后置摄像头,先随便开一个拿到权限;startStream 成功后 devices 里才有真实 label。
await startStream(selectedDeviceId.value) await startStream(props.initialDeviceId)
// 用户/配置没有指定具体摄像头时,权限到手后尝试切到确认过的后置摄像头 if (!stream) return // 打开失败,错误信息已经显示在弹层里
if (!props.initialDeviceId) { // 配置里没指定过摄像头,或指定的那个已失效(startStream 回退后 selectedDeviceId
// 会被改写成实际设备),两种情况都尝试切到实测确认的后置摄像头
const usingRequested =
!!props.initialDeviceId && selectedDeviceId.value === props.initialDeviceId
if (!usingRequested) {
const rear = findRearCamera() const rear = findRearCamera()
if (rear && rear.deviceId !== selectedDeviceId.value) { if (rear && rear.deviceId !== selectedDeviceId.value) {
await switchDevice(rear.deviceId) await switchDevice(rear.deviceId)

View File

@ -159,6 +159,7 @@ function beginQuery(): void {
store.querying = true store.querying = true
store.resetAnswer() store.resetAnswer()
inferStats.value = null inferStats.value = null
visionCaption.value = ''
readyCitations.value = new Set() readyCitations.value = new Set()
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null } if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null }
// 立即解除 ASR 监听并清空状态,防止延迟到达的 asr:stopped 通过 syncAsrText() 恢复输入框 // 立即解除 ASR 监听并清空状态,防止延迟到达的 asr:stopped 通过 syncAsrText() 恢复输入框
@ -240,14 +241,13 @@ async function sendQuery(): Promise<void> {
await window.electronAPI.query({ question: q, systemPrompt: store.config?.rag.systemPrompt ?? '', topK: topK.value }) await window.electronAPI.query({ question: q, systemPrompt: store.config?.rag.systemPrompt ?? '', topK: topK.value })
} }
const VISION_DEFAULT_QUESTION = '请描述这张图片的内容' // 拍照提问:先让视觉模型快速生成一句图像描述(跟用户问题拼接后去检索知识库),
// 再走一遍跟文字问答一样的检索+生成,图片本身不会传给最终生成模型
// 拍照提问:不查向量库,图片 + 问题直接发给多模态生成服务
async function sendVisionQuery(): Promise<void> { async function sendVisionQuery(): Promise<void> {
if (!pendingImage.value) return if (!pendingImage.value) return
if (store.querying) return if (store.querying) return
const q = question.value.trim() || VISION_DEFAULT_QUESTION const q = question.value.trim()
const img = pendingImage.value const img = pendingImage.value
beginQuery() beginQuery()
@ -258,6 +258,9 @@ async function sendVisionQuery(): Promise<void> {
let firstDelta = true let firstDelta = true
const onStage = (data: unknown) => { queryStage.value = (data as { stage: string }).stage }
const onCaption = (data: unknown) => { visionCaption.value = (data as { caption: string }).caption }
const onContext = (data: unknown) => { store.chunks = (data as { chunks: Chunk[] }).chunks }
const onDelta = (data: unknown) => { const onDelta = (data: unknown) => {
if (firstDelta) { firstDelta = false; stopWaitTimer() } if (firstDelta) { firstDelta = false; stopWaitTimer() }
const delta = (data as { delta: string }).delta const delta = (data as { delta: string }).delta
@ -284,11 +287,17 @@ async function sendVisionQuery(): Promise<void> {
} }
function cleanup(): void { function cleanup(): void {
window.electronAPI.off('vision-query:stage')
window.electronAPI.off('vision-query:caption')
window.electronAPI.off('vision-query:context')
window.electronAPI.off('vision-query:delta') window.electronAPI.off('vision-query:delta')
window.electronAPI.off('vision-query:done') window.electronAPI.off('vision-query:done')
window.electronAPI.off('vision-query:error') window.electronAPI.off('vision-query:error')
} }
window.electronAPI.on('vision-query:stage', onStage)
window.electronAPI.on('vision-query:caption', onCaption)
window.electronAPI.on('vision-query:context', onContext)
window.electronAPI.on('vision-query:delta', onDelta) window.electronAPI.on('vision-query:delta', onDelta)
window.electronAPI.on('vision-query:done', onDone) window.electronAPI.on('vision-query:done', onDone)
window.electronAPI.on('vision-query:error', onError) window.electronAPI.on('vision-query:error', onError)
@ -309,6 +318,7 @@ function handleEnter(e: KeyboardEvent): void {
const cameraOpen = ref(false) const cameraOpen = ref(false)
const pendingImage = ref('') const pendingImage = ref('')
const sentImage = ref('') const sentImage = ref('')
const visionCaption = ref('')
function onCaptured(dataUrl: string): void { function onCaptured(dataUrl: string): void {
pendingImage.value = dataUrl pendingImage.value = dataUrl
@ -584,10 +594,13 @@ onUnmounted(() => {
<div> <div>
<div class="q-label">提问</div> <div class="q-label">提问</div>
<img v-if="sentImage" :src="sentImage" class="q-image" alt="提问附带的照片" /> <img v-if="sentImage" :src="sentImage" class="q-image" alt="提问附带的照片" />
<div class="q-text">{{ sentQuestion }}</div> <div class="q-text">{{ sentQuestion || (sentImage ? '(未输入文字,根据图片自动识别提问)' : '') }}</div>
</div> </div>
</div> </div>
<!-- Vision caption:拍照提问识别到的图像描述,识别完就常驻显示 -->
<div v-if="visionCaption" class="vision-caption">识别到:{{ visionCaption }}</div>
<!-- Stage indicator --> <!-- Stage indicator -->
<div v-if="store.querying && !store.rawAnswer" class="stage-row"> <div v-if="store.querying && !store.rawAnswer" class="stage-row">
<span class="stage-spinner" /> <span class="stage-spinner" />
@ -779,6 +792,13 @@ onUnmounted(() => {
border: 2px solid var(--line); border: 2px solid var(--line);
} }
/* Vision caption */
.vision-caption {
font-size: 15px; color: var(--ink-3);
background: var(--panel-2); border-radius: var(--radius-sm);
padding: 8px 14px; margin-bottom: 14px;
}
/* Stage indicator */ /* Stage indicator */
.stage-row { .stage-row {
display: flex; align-items: center; gap: 10px; display: flex; align-items: center; gap: 10px;