elec_rag_ui/docs/feature-pic-design.md
ssurui 3a34aabd13 docs: 补充拍照提问二次架构(接入检索)的实现与验证记录
新增 §12:真机两次完整拍照问答的实测数据(图像理解稳定 4.0-5.3s,
落在设计目标内;固定提示词把输出压到 45 tokens 上下,对比旧方案
454 tokens/31s 差一个数量级)。

记录两个验证中确认的正确行为:拍无关照片+明确文字问题时,答案准确
命中知识库文档且带引用、没被图像描述带偏——反证了"检索用组合文本、
生成用用户原始问题"这个决策是对的;拍完全无关的照片时回答"我不知道"
是遵守 systemPrompt 的正确行为而非 bug。

记录发现并修复的 deviceId 跨会话失效 bug,以及一个顺带确认的事实:
视觉模型会把画面"倒置"当成图像特征来描述,说明之前那个旋转修复直接
影响识别质量,不只是预览观感问题。

同时更新文档头部状态,明确 §1-§10 是已被推翻的第一版设计(历史记录),
§11 才是当前实现。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QeoAy5KmkHuKWHAktpEsaN
2026-09-03 20:51:14 +08:00

405 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 拍照提问模式 —— 设计方案
状态:**已实现并通过真机端到端验证**(2026-09-03)。当前生效的是 §11 的二次架构
(拍照 → 图像理解转述 → 接入本地知识库检索 → 生成),验证记录见 §12。
§1–§10 保留第一版"纯视觉直接问答"的设计与验证过程作为历史记录,**已被 §11 推翻,不再是当前实现**。
## 1. 背景与目标
现有 `elec_rag_ui` 是纯文本 RAG 问答:用户打字提问 → 检索本地知识库 → llama-server 生成回答。
本功能希望新增一种输入方式:**用摄像头拍一张照片,直接针对这张照片提问**(例如对着一个实物部件拍照问"这是什么型号"),
而不是先打字检索文档。
## 2. 关键前提核实(2026-09-03 现场查证,ER160 pad,192.168.31.48)
### 2.1 摄像头硬件 —— 已具备,风险低
```
/dev/video0/1 -> Hy-Usb2.0-1*MIC: Hy-QSXGA(8950) # 约500万像素 USB 摄像头,带麦克风
/dev/video2/3 -> Hy-Usb2.0-2*MIC: Hy-8M(8079)-Ca # 约800万像素 USB 摄像头,带麦克风
lsusb: 1bcf:2cef / 1bcf:2ce4 Sunplus Innovation Technology Inc.
```
两个都是标准 **USB UVC(USB Video Class)** 摄像头,不是需要专有 ISP 驱动的 CSI 摄像头。
Chromium/Electron 的 `navigator.mediaDevices.getUserMedia()` 原生支持 UVC 设备,**不需要额外驱动或胶水代码**。
一台设备接了两个摄像头,需要在 UI 里做设备选择(`enumerateDevices()` 列出后选 `deviceId`),
或者约定固定用其中一个(待确认哪个摄像头是安装时实际对外的那个)。
### 2.2 推理后端视觉能力 —— **已确认可用(2026-09-03 复查更新)**
初次核实时(见本文档历史)当前模型是纯文本的,`allow_image=0`;**复查时 er_backend 已经换成带
`--mmproj` 的多模态配置**:
```
/home/work/houmo-HLIELLama-xh2-2.1.0/bin/llama-server \
-m /home/work/models/LLM/HiModel_xh2_qwen3.5-9b_w4a8_256_256k_b1_1chip_2cores_v1.4.0_20260708.gguf \
--mmproj /home/work/models/LLM/mmproj_xh2_qwen3.5-9b_w4a8_2cores_vit_896x896_v1.4.0_20260708.gguf \
-c 8192 --parallel 1 --host 127.0.0.1 --no-warmup --port 8081 --presence_penalty 1.5 --reasoning-budget 0
```
启动日志确认:
```
loaded multimodal model, '.../mmproj_xh2_qwen3.5-9b_w4a8_2cores_vit_896x896_v1.4.0_20260708.gguf'
==========model info===========================
=allow_image = 1
=allow_audio = 0
=vision_width = 896
=vision_height= 896
=model_desc = qwen35 9B Q4_0
==========model info===========================
```
**并且做了端到端真实请求验证**(不只是看模型加载日志),直接对 `http://127.0.0.1:17700/v1/chat/completions`
(即前端 `generator.ts` 实际会调用的同一个地址)发了一个带 `image_url` content-part 的请求:
```
HTTP 200,耗时 2.6s
返回:{"content": "黑色"} —— 测试图片本身就是 1x1 黑色像素,模型回答正确,
证明图片被真实理解,不是被忽略/幻觉回答
```
**结论:阻塞项已解除**,现在的 er_backend 代理路径(`ER_BACKEND_URL/v1/chat/completions`)原生支持
OpenAI 风格的 `image_url` content-part,前端可以直接照 §5.2 的格式发请求,不需要额外适配层。
**顺带记录的性能数据**(用于 §6 加载态 UI 设计参考):这次测试用的是 1×1 像素的极简测试图,
`prompt_tokens` 却达到了 **808**——印证了启动日志里的警告"Qwen-VL models require at minimum
1024 image tokens",即**无论照片内容/分辨率如何,视觉编码本身有一个较高的固定 token 开销**
(896×896 的 ViT 编码器决定的,不是按源图片大小线性变化)。prefill 速度约 373 tokens/s,
换算下来光是"看懂一张图"这一步就要 ~2 秒起,生成回答的时间还要另外算。
> **2026-09-03 补充:真实照片实测数据(见 §10)**,一次完整的"描述图片"回答(454 tokens)
> 实际耗时 **~31 秒**(14.7 tokens/s),比这里用极简测试图估算的"3-8秒"长不少——真实照片信息量大,
> 模型生成的描述篇幅也更长,这才是真实用户会遇到的等待时长。UI 的等待态提示要按这个量级设计,
> 不能只按几秒钟的"识别中"来做。
## 3. 功能范围(建议先做 MVP)
**MVP 包含:**
- 聊天输入区新增"拍照"入口,唤起摄像头取景 → 拍照 → 预览/重拍 → 确认
- 确认后的照片作为一条用户消息的"附件",和文字问题一起发送
- 复用现有流式回答渲染(think 块、逐字输出等现有 ChatPanel 能力不用改)
- 不做检索(不查向量库):拍照提问是纯粹的"图 + 文"直接对话,不掺 RAG 上下文
**明确不做(本期):**
- 不做"拍照 + 知识库联合检索"(比如拍照后再去匹配文档库里的相关图片/文字)——这需要图像
embedding/CLIP 之类的能力,目前项目里完全没有这块基础设施,属于完全独立的大功能,先不纳入
- 不做连续视频流分析,只做单张静态拍照
- 不做照片持久化存储/历史相册(先按"用完即弃"设计,见 §6.5 的取舍说明)
## 4. 用户体验流程
```
[聊天界面输入框旁] 新增相机图标按钮
│ 点击
▼
[取景弹层] 全屏/半屏显示 <video> 实时预览
├─ 有多个摄像头 → 顶部下拉切换设备
├─ [拍照] 按钮 → 定格成 <canvas> 快照
└─ 拍完后:[重拍] [使用这张]
│ 使用这张
▼
[输入框上方] 出现照片缩略图(可点 x 移除,可再次点击重新打开取景)
│ 用户在输入框打字问题(可选,留空则用默认 prompt "描述这张图片")
│ 点击发送
▼
[聊天记录] 新增一条用户消息:缩略图 + 问题文字
[聊天记录] 助手消息流式输出回答(复用现有渲染)
```
## 5. 技术方案
### 5.1 摄像头采集(渲染进程)
用浏览器标准 API,**不经过 IPC**:
```typescript
const stream = await navigator.mediaDevices.getUserMedia({ video: { deviceId } })
// <video> 绑定 stream 实时预览
// 拍照:canvas.drawImage(videoEl, 0, 0) 后 canvas.toDataURL('image/jpeg', quality)
```
需要在 `src/main/index.ts` 的权限处理器里确认/放行摄像头权限——现有代码:
```typescript
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
const allowed = permission === 'media' || permission === 'microphone'
callback(allowed)
})
```
Electron 里摄像头请求的 `permission` 具体是不是也归为 `'media'`(跟麦克风共用同一个值)需要
实测确认,不确定的话要加日志打印实际收到的 permission 字符串,照实际情况调整判断条件。
### 5.2 请求格式:OpenAI 视觉 content-parts
现有 `generator.ts` 发给 llama-server 的 `messages[].content` 是纯字符串。视觉请求要改成
OpenAI 兼容的多段 content:
```typescript
{
role: 'user',
content: [
{ type: 'text', text: userQuestion },
{ type: 'image_url', image_url: { url: `data:image/jpeg;base64,${base64Data}` } }
]
}
```
`generator.ts` 的 `streamGenerateLlamaCpp` 需要能接受这种数组形式的 content(简单改成联合类型,
现有文本对话路径不受影响,用字符串走老路径)。
### 5.3 IPC 设计(新增,不改动现有 `query` 通道)
参照现有 `query` 的"invoke 触发 + push 事件流"模式,新增一套平行的通道,互不干扰:
```typescript
// preload
visionQuery: (payload: { imageBase64: string; question: string }) => Promise<void>
// 主进程新增 vision-query handler,push 事件:
// vision-query:delta / vision-query:done / vision-query:error
```
主进程侧新建 `src/main/rag/visionGenerator.ts`(或直接扩展 `generator.ts` 加一个
`streamGenerateVision()`),直接调 `ER_BACKEND_URL/v1/chat/completions`,**跳过**
`embedOne`/`search` 这两步(MVP 不做检索,见 §3)。
### 5.4 配置项(`AppConfig` 新增字段)
> **实现时做了裁剪**:`maxImageDimension`/`jpegQuality`/`defaultPrompt` 最终没有做成配置项,
> 改成代码里的硬编码值(JPEG 质量 0.85,默认问题"请描述这张图片的内容"),且**没有做
> 上传前压缩**——拍照直接用视频原始分辨率生成 dataURL。原因:真机验证时发现视觉模型的
> `prompt_tokens` 是固定开销(895×896 ViT 编码器决定,跟源图片分辨率无关,见 §2.2),
> 客户端压缩分辨率对推理速度没有帮助,只对传输体积有一点点好处,MVP 阶段先不加这层配置,
> 避免为一个还没证明有必要的问题预先设计开关。实际字段:
```typescript
vision: {
enabled: boolean // 总开关,默认 false
cameraDeviceId: string // getUserMedia deviceId,空字符串 = 使用第一个可用设备
}
```
`enabled` 默认关闭,需要在设置页手动打开(现在后端已具备视觉能力,但默认关闭仍然是合理的——
不是所有部署的 er_backend 都会配置视觉模型,不能假设每台设备都支持)。
### 5.5 是否持久化照片
**建议不落盘**,只在当次会话的内存/聊天记录里保留 base64(或一个内存 Blob URL 用于渲染缩略图),
应用重启后历史照片随聊天记录一起清空——跟现有"聊天记录本身也不持久化到数据库"的现状保持一致
(现有文本问答记录也只是内存里的 Pinia state,没有存 LanceDB 或别的持久层)。如果以后要做"拍照历史",
再单独设计存储方案。
## 6. UI 改动点(`ChatPanel.vue`)
> **写这份设计时的一个错误假设**:以为 `ChatPanel.vue` 是"消息列表"结构(数组形式的多轮对话历史),
> 实现时才发现现有代码根本不是这样——`ChatPanel` 只维护"当前这一轮"的单个问题+单个流式回答
> (`sentQuestion` + `store.rawAnswer`,每次提问直接覆盖上一轮,没有历史记录数组)。这让实现比设计时
> 想的更简单:不需要改消息数据结构,只要给"当前这一轮"平行加一个 `sentImage` 就行。
实际做法:
- 输入区工具栏新增相机图标按钮,跟现有语音按钮同一排、同一视觉风格,仅 `vision.enabled` 为
`true` 时显示
- 新增 `CameraCapture.vue`,职责单一:拿 stream、渲染预览、拍照出 dataURL、emit 给父组件,
弹层自己不关心业务逻辑
- `ChatPanel.vue` 新增三个本地 ref:`cameraOpen`(弹层开关)、`pendingImage`(已拍但未发送,
显示成输入框上方一个可移除的缩略图 chip)、`sentImage`(已发送,跟 `sentQuestion` 一起显示在
问题气泡里)
- 抽出 `beginQuery()` 把文字提问和拍照提问共用的准备逻辑(重置状态、打断语音输入、停止旧的
TTS 播报)合并成一个函数,避免两条路径各写一遍
## 7. 分阶段实施计划
| 阶段 | 内容 | 状态 |
|---|---|---|
| 0 | 跟 er_backend/硬件团队确认视觉模型可用性 | ✅ 完成(2026-09-03,见 §2.2) |
| 1 | 摄像头取景/拍照 UI(`CameraCapture.vue`) | ✅ 完成,真机验证过真实拍照可用(见 §10) |
| 2 | IPC + `generator.ts` 改造支持 image_url content-part | ✅ 完成,真机验证过完整回答(见 §10) |
| 3 | 配置项、设置页开关、真机联调 | ✅ 完成(配置项范围比设计时缩减,见 §5.4) |
| 4 | 打包验证(照片相关无新增大文件,不影响现有打包体积) | ⬜ 未做——这次验证走的是 `dev:linux` 开发模式,没有重新打包 deb |
## 8. 风险与未决问题(更新为已验证/未验证状态)
1. ~~Electron 权限处理器对摄像头请求的 `permission` 字符串需要实测确认~~
**已确认**:真机日志 `[permission] media -> granted`,getUserMedia 摄像头请求确实归为 `media`,
跟麦克风共用同一个权限判断,不需要额外代码。
2. **未解决**:两个摄像头哪个是实际安装朝向的那个,仍未现场确认物理安装位置——目前实现是
`CameraCapture.vue` 里有下拉框给用户自己选,没有做默认值的硬编码判断,算是绕开了这个问题
(用户自己选,不需要我们替他决定),但如果产品上想要"打开就是对的那个摄像头",还是要现场确认。
3. **已验证,风险比预想低**:真实分辨率照片(未压缩,直接原始 dataURL)经 er_backend 代理转发
`/v1/chat/completions` 没有遇到任何请求体大小限制,正常返回。**没有做 §5.4 提到的客户端压缩**——
实测证明视觉模型的 `prompt_tokens` 开销是固定的(跟源图片分辨率无关,见 §2.2),压缩分辨率对
推理速度没有帮助,暂时不需要加这层。如果以后遇到极端大尺寸照片(比如高分辨率单反直出)传输
变慢,再回头考虑加压缩。
4. **已用真实数据更新**:完整一段描述类回答实测 **~31 秒**(454 tokens,14.7 tokens/s),不是
§2.2 早期用 1×1 测试图估算的"3-8秒"。当前实现**还没有做专门的长等待 UI 提示**——现有的
`stage-row`"识别中"loading 态是复用文字问答那套,能用但没有针对"这可能要等半分钟"做优化,
是一个值得后续改进但不阻塞可用性的点。
5. `--reasoning-budget 0` 这个启动参数暗示当前配置关闭了推理/think 模式——真机测试的回答是
直接输出内容,没有 `<think>` 块,跟这条预判一致,现有 `<think>` 渲染逻辑没有被拍照提问路径
触发,也没有报错,两条路径共用同一套 markdown 渲染没有问题。
## 9. 后续可选优化(不阻塞当前可用性)
- 针对 §8.4 的长等待时间,给拍照提问单独设计一个更明确的等待态提示(比如预估时间、进度感的动画)
- 针对 §8.2,现场确认两个摄像头哪个是产品实际会用的那个,考虑是否需要写死默认设备
- 打包验证(§7 阶段4):确认这次改动不影响现有 664M 左右的 deb 体积(新增代码本身很小,
不应该有明显影响,但要走一次真实打包确认)
- 如果以后有极端大尺寸照片的性能问题,再考虑加回 §5.4 提到的客户端压缩
## 10. 实现与验证记录(2026-09-03)
代码实现:`feature_pic` 分支 commit `21f9c7e`。改动文件清单、每个文件做了什么,见该 commit
的提交信息,这里不重复。
验证方式:rsync 到 pad → `npx vue-tsc --noEmit`(**一次性通过,零错误**,没有需要修的类型问题)
→ `npx electron-vite build`(成功)→ `DISPLAY=:0` 直接跑编译产物(未打包 deb,走的是开发产物
直接运行)→ `ffmpeg -f x11grab` 截图 + `xdotool` 模拟点击操作,验证到:
- 拍照弹层打开、真实摄像头画面可见(拍到桌面上的 Mac mini、显示器)、多摄像头下拉正确列出
真实设备名 `Hy-8M(8079)-Camera`
- 拍照 → 图片正确附加到问题气泡(缩略图渲染正常)→ 默认问题文案 `VISION_DEFAULT_QUESTION`
正确生效 → 流式回答完整渲染
- **回答内容真实针对图片作答**:准确识别出 Mac Mini、AOC 显示器,读出包装盒上的具体文字
"100Gbps Super Fast SSD Storage Anywhere"、充电器上的"22W"——细到具体文本级别,
不是通用套话或幻觉
- 进程全程稳定,没有因为这次改动崩溃;唯一的无关日志是 SSL 证书握手失败(pad 时钟问题,
跟这次改动无关,见项目里其他关于 pad 时钟的记录)
**这次验证没有覆盖到的**:正式打包(deb)流程、设置页手动切换开关的 UI 操作(是直接改
`config.json` 打开的 `vision.enabled`,没有走设置页表单)、长时间使用的稳定性。
## 11. 架构调整:接入本地知识库检索(2026-09-03 二次设计)
**状态:已实现并通过真机端到端验证**(commit `687e0ca`,验证记录见 §12)。本节内容推翻
§3 "不做检索" 的原始决定,是当前的权威设计,§3/§5.2/§5.3/§6 保留作为"第一版为什么这么想"
的历史记录。
### 11.1 为什么要改
第一版 MVP 是"拍照直接问视觉模型",图片内容和本地知识库(维修手册文档)完全没有关联。
但这个应用的核心价值是本地知识库问答,拍照应该是**新增一种提问入口**,而不是一条完全独立
于知识库之外的旁路。新方案让拍照提问最终也能从知识库里查到相关手册内容来回答。
### 11.2 新流程
```
用户拍照 + 可选输入问题
│
[主进程] 调视觉模型一次,用固定默认提示词(不是用户问题):"请用一句话简要描述这张图片
中的部件、故障现象或异常情况,不要展开解释、不要客套。"
→ push 'vision-query:stage' {stage:'正在识别图片...'}
→ 得到一段简短描述 caption(目标 5-10 秒内,靠提示词要求"一句话"+"不展开"来压时长,
而不是硬截断 max_tokens——见 §11.3)
→ push 'vision-query:caption' {caption} ← 渲染层立刻展示"识别到:{caption}",简洁一行
│
组装检索查询 retrievalQuery:
- 用户打字了问题 → `${caption}\n${question}`(图像描述 + 用户问题拼接,扩大召回面)
- 用户没打字 → caption 本身
│
[主进程] 复用现有 embedOne(retrievalQuery) → search(dbPath, vector, topK)
→ push 'vision-query:stage' {stage:'正在检索...'}
→ push 'vision-query:context' {chunks}
│
生成用的问题 genQuestion:
- 用户打字了问题 → 用户原始问题(不用 retrievalQuery,避免 caption 把回答带偏,
检索阶段负责扩大召回,生成阶段保持聚焦用户真实意图)
- 用户没打字 → caption 本身(这种情况下 caption 就是唯一的"意图"来源,没有更聚焦的选项)
│
[主进程] 复用现有 streamGenerate(genQuestion, chunks, systemPrompt, callbacks)——
**纯文字生成,图片到这一步已经不再传给模型**(第③步转成文字后,图片就完成使命了)
→ push 'vision-query:stage' {stage:'正在生成...'}
→ push 'vision-query:delta' 流式吐字,跟文字问答体验一致
→ push 'vision-query:done'
```
### 11.3 关键决策记录
| 决策点 | 结论 | 理由 |
|---|---|---|
| 图像理解默认提示词 | "请用一句话简要描述这张图片中的部件、故障现象或异常情况,不要展开解释、不要客套。" | 单句、明确要求"不展开",从提示词层面主动压生成长度,比硬 `max_tokens` 截断更不容易截出语义不完整的半句话 |
| 图像理解耗时目标 | 5-10 秒 | 用户明确的目标区间;实测极简回答("黑色"两个字)2.6s,§2.2 记录的极简测试图基准可作为下限参考 |
| 最终生成是否带图 | 不带,纯文字生成 | 图片信息已经浓缩进 caption,复用现有纯文字 `streamGenerate`,不需要每次生成都重新过一遍视觉编码的固定开销(见 §2.2,~800 token/次),更快 |
| 检索查询 vs 生成问题是否用同一段文字 | 不同:检索用 `caption+question` 拼接(更大召回面),生成用用户原始 `question`(无问题时退化为 caption) | 检索要"宁可多召回",生成要"保持聚焦",两个阶段的目标不一样,没必要共用一份文本 |
| 中间结果是否展示给用户 | 展示,"识别到:xxx",一行、简洁 | 5-10 秒的图像理解本身就是一段等待,把中间结果先亮出来能实质性缓解等待焦虑,而不是纯转圈 |
| 加载态设计 | 三段式:正在识别图片... → 正在检索... → 正在生成...,复用现有 `query:stage` 这套推送机制的样式 | 用户已经熟悉这套"正在xxx..."的提示语汇,不需要发明新的交互语言 |
### 11.4 技术实现要点(待写代码时展开,这里先定方向)
- `vision-query` 这个 IPC handler 内部变成"两段式":先调一次视觉模型拿 caption,再原样调用
现有 `embedOne`/`search`/`streamGenerate`(`queryHandler.ts` 已有的三步,直接复用,不用照抄一遍)
- `generator.ts` 的 `streamGenerateVision()`(第一版为了直接视觉问答新增的函数)现在只在
"第③步图像理解"这一次性调用里用得上;原来那种"图+文一起发给视觉模型直接生成完整答案"的
用法不再需要,`ContentPart`/`image_url` 那套改造保留(第③步仍然要用),但调用方式变成
"只问一句话、只要一个简短 caption",跟原来"生成完整长回答"的调用方式不一样,需要单独判断
是否要一个新的轻量函数(比如 `describeImage()`)而不是复用 `streamGenerateVision` 的完整流式
长回答路径
- 新增 `vision-query:caption` 这个推送事件(原来只有 delta/done/error),渲染层要新增监听
和展示逻辑
- `ChatPanel.vue` 的 `sendVisionQuery()` 需要重写内部流程以匹配上面的多阶段推送,但外层交互
(拍照、缩略图、发送按钮)不用动
- 是否需要给"识别图片"这一步单独设一个超时/失败兜底(比如识别失败了要不要还能继续走检索,
还是直接报错终止)——待实现时决定,本节暂不展开
## 12. 二次架构(接入检索)的实现与验证记录(2026-09-03)
代码:commit `687e0ca`。实现完全按 §11 的设计,没有中途改方向。
### 12.1 真机验证结果
验证环境:pad(当时地址 `192.168.1.37`,注意这台设备 IP 会变,`192.168.31.48` 那个地址
当时能 ping 通但 SSH 被拒,实际是别的设备占用),`npx electron-vite preview` 跑编译产物,
`xdotool` 模拟点击 + `ffmpeg -f x11grab` 截图取证。
类型检查 `npx vue-tsc --noEmit` 零错误,`npx electron-vite build` 构建成功。
两次真实拍照问答的完整链路数据:
| 场景 | 图像理解 | 检索 | 生成 | 结果 |
|---|---|---|---|---|
| 未输入文字(拍室内台灯托盘) | 4.0s | 召回 20 片段 | 0.1s / 3 tokens | 正确回答"我不知道" |
| 输入"如何更换汽车前大灯。" | 5.3s | 召回 20 片段 | 17.5s / 233 tokens | 正确答出换灯步骤,带引用 [18] |
**图像理解稳定在 4.0–5.3 秒**,落在 §11 定的 5–10 秒目标内。固定提示词里"一句话""不展开"
的约束确实生效:输出 45 tokens 上下,对比旧方案(让视觉模型直接完整作答)的 454 tokens / 31 秒,
差了一个数量级。
### 12.2 验证中确认为正确的两个行为
**其一,第二个场景恰好验证了 §11.3 那个关键决策是对的**:拍的是保温杯/厨房桌面,跟"更换前大灯"
毫无关系,但因为检索用的是 `caption + 用户问题` 拼接(扩大召回),生成用的是**用户原始问题**
(保持聚焦),最终答案准确命中前大灯文档并带正确引用,完全没被"保温杯、厨房环境"这些无关的
图像描述带偏。如果当初决定生成也用组合文本,这个场景大概率会跑偏。
**其二,第一个场景回答"我不知道"是正确行为不是 bug**:图片内容跟知识库(汽车维修手册)无关,
检索虽然照常返回 topK=20 个片段(暴力余弦排序总会返回够数),但内容都不相关,模型遵守
systemPrompt 里"上下文找不到就回答我不知道"的规则。这跟文字问答问无关问题时的行为一致。
### 12.3 验证中发现并修复的真 bug:摄像头 deviceId 跨会话失效
首次点开拍照弹层直接报 **`OverconstrainedError`**、预览全黑,整个功能不可用。
根因:`CameraCapture.vue` 把上次选用的 `deviceId` 持久化到 config.json,下次启动时拿它做
`getUserMedia({ video: { deviceId: { exact: ... } } })` 的约束。但 **deviceId 在 Web 标准里
不保证跨会话稳定**,换一次会话后旧 id 往往已经不存在,`exact` 约束匹配不上就直接抛错。
修复:抽出 `openStream()`,指定 id 打不开时自动回退到 `{ video: true }` 默认摄像头,并把
实际拿到的设备 id(`getVideoTracks()[0].getSettings().deviceId`)回写到 `selectedDeviceId`,
让"自动切到后置摄像头"的逻辑在回退场景下同样能继续工作(原来的写法只在 `initialDeviceId`
为空时才找后置摄像头,回退场景会被漏掉)。
### 12.4 顺带确认的一件事:画面方向会影响识别质量
用 `ffmpeg` 直接从 `/dev/video2` 抓的**原始帧**(不经过前端旋转)送去识别,模型的描述里明确
写了"图片显示一个**倒置的**室内场景"。这说明摄像头原始画面确实是倒的,而且**视觉模型会把
"倒置"当成图像的一个特征去描述**——即 commit `0d19be5` 那个旋转修复不只是预览好看的问题,
它直接影响识别质量。经过前端旋转后拍的照片(见 §12.1 两次实测),描述里再没出现"倒置"字样。
### 12.5 这次没有覆盖到的
- 正式 deb 打包(走的是 `electron-vite preview` 跑编译产物)
- "识别图片"这一步失败时的兜底行为(§11.4 最后一条列为待定,实现时也没专门处理,
目前是整个 vision-query 抛错走 `vision-query:error`,前端弹错误提示)
- 长时间连续使用的稳定性