新增 §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
405 lines
25 KiB
Markdown
405 lines
25 KiB
Markdown
# 拍照提问模式 —— 设计方案
|
||
|
||
状态:**已实现并通过真机端到端验证**(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`,前端弹错误提示)
|
||
- 长时间连续使用的稳定性
|