elec_rag_ui/docs/feature-pic-design.md
ssurui ab01effd5b docs: 用真机验证结果更新拍照提问设计文档
记录 vue-tsc/构建/端到端全部验证通过,修正设计与实现的几处出入:
ChatPanel 实际是单轮问答不是消息列表、vision 配置字段做了裁剪(去掉
maxImageDimension/jpegQuality/defaultPrompt 改成硬编码)、真实照片
完整回答实测 ~31s(早期用1x1测试图估算的3-8s严重偏低)。风险清单
标注哪些已解决、哪些还没验证,新增"后续可选优化"区分阻塞项和锦上添花项。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 14:59:46 +08:00

16 KiB
Raw Blame History

拍照提问模式 —— 设计方案

状态:MVP 已实现并通过真机端到端验证(2026-09-03)。类型检查、构建、真实摄像头拍照到 真实视觉模型作答的完整链路已跑通,细节见 §10。

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:

const stream = await navigator.mediaDevices.getUserMedia({ video: { deviceId } })
// <video> 绑定 stream 实时预览
// 拍照:canvas.drawImage(videoEl, 0, 0) 后 canvas.toDataURL('image/jpeg', quality)

需要在 src/main/index.ts 的权限处理器里确认/放行摄像头权限——现有代码:

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:

{
  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 事件流"模式,新增一套平行的通道,互不干扰:

// 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 阶段先不加这层配置, 避免为一个还没证明有必要的问题预先设计开关。实际字段:

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,没有走设置页表单)、长时间使用的稳定性。