现场重新查询 er_backend 状态,确认已换成带 --mmproj 的多模态配置 (allow_image=1,vision 896x896),并对 ER_BACKEND_URL/v1/chat/completions 做了端到端真实请求验证(HTTP 200,正确识别测试图片颜色),证明前端 可以直接按现有代理路径发送 image_url content-part,不需要额外适配层。 顺带记录实测性能数据(极简图片 prompt_tokens=808,prefill ~2s)用于 后续加载态 UI 设计参考,更新分阶段计划:阶段0 已完成,阶段1/2 可并行开始。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
11 KiB
拍照提问模式 —— 设计方案
状态:草案,未实现。本文档只做设计,feature_pic 分支尚未修改任何业务代码。
唯一的硬阻塞项(后端视觉能力)已于 2026-09-03 复查解除,见 §2.2。
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 秒起,生成回答的时间还要另外算——真实拍照场景(图片本身
- 问题文字)单次问答预计在 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 新增字段)
vision: {
enabled: boolean // 总开关,默认 false,等后端具备能力再打开
cameraDeviceId: string // getUserMedia deviceId,空字符串 = 使用默认/第一个
maxImageDimension: number // 上传前压缩的最长边(如 1024),控制 payload 大小和推理时延
jpegQuality: number // 0-1,默认 0.85
defaultPrompt: string // 用户没打字时的默认问题,如"描述这张图片的内容"
}
enabled 默认关闭:在 er_backend 视觉能力就绪之前,这个入口在设置里默认不显示,避免用户点了
拍照却发现后端直接报错。
5.5 是否持久化照片
建议不落盘,只在当次会话的内存/聊天记录里保留 base64(或一个内存 Blob URL 用于渲染缩略图), 应用重启后历史照片随聊天记录一起清空——跟现有"聊天记录本身也不持久化到数据库"的现状保持一致 (现有文本问答记录也只是内存里的 Pinia state,没有存 LanceDB 或别的持久层)。如果以后要做"拍照历史", 再单独设计存储方案。
6. UI 改动点(ChatPanel.vue)
- 输入区工具栏新增相机图标按钮(现有已有语音输入按钮,风格保持一致)
- 新增一个取景弹层组件(新文件,如
CameraCapture.vue),职责单一:拿 stream、渲染预览、拍照出 dataURL、 emit 给父组件,弹层自己不关心业务逻辑 - 消息气泡的数据结构(渲染消息列表用的 message 类型)加一个可选
imageDataUrl字段, 用户消息如果带图就在文字上方渲染一个缩略图(点击可放大,简单用原生<img>+ 一个全屏预览态即可, 不需要引入新组件库)
7. 分阶段实施计划
| 阶段 | 内容 | 前置条件 |
|---|---|---|
| 0 | — | |
| 1 | 摄像头取景/拍照 UI(CameraCapture.vue),先只做交互,用假数据验证体验 |
无 |
| 2 | IPC + visionGenerator.ts 打通,generator.ts content 类型改造 |
无(阶段0已就绪,可直接对真实后端联调) |
| 3 | 配置项、设置页开关、真机联调(含真实摄像头 + 真实视觉模型,重点测真实照片而非1x1测试图的延迟/效果) | 阶段1、2完成 |
| 4 | 打包验证(照片相关无新增大文件,不影响现有打包体积) | 阶段3完成 |
阶段 0 已解除,1/2 可以直接开始且互不依赖,建议并行推进。
8. 风险与未决问题
- Electron 权限处理器对摄像头请求的
permission字符串需要实测确认(§5.1)。 - 两个摄像头哪个是实际安装朝向的那个(500万 vs 800万像素那颗),需要现场看一下物理安装位置, UI 上是默认写死还是给用户切换待定。
- 图片 base64 塞进
/v1/chat/completions请求体,走 er_backend 代理转发时有没有请求体大小限制 (nginx/代理层常见的client_max_body_size之类),已验证极小图片可以正常通过代理,但没测过 真实分辨率照片(896×896 甚至更大的 base64 体积),§5.4 的maxImageDimension压缩正是为了 控制这个风险,阶段3要专门测一次。 - 揽月 NPU 视觉推理延迟:见 §2.2 实测数据,极简图片仅 prefill 就要 ~2s,真实照片 + 生成完整回答 预计 3-8 秒量级,UI 必须要有明确的"识别中"等待态。
--reasoning-budget 0这个启动参数暗示当前配置关闭了推理/think 模式,视觉问答场景要不要开 thinking(现有 ChatPanel 的<think>块渲染能力目前用不上),待联调时确认输出格式是否有变化。
9. 下一步
阻塞项已解除,可以直接开始 §7 阶段 1(UI 壳子)和阶段 2(后端联调),两者互不依赖,建议同时推进。