docs: 更新项目文档,补齐拍照提问、流式播报与提示词机制

CLAUDE.md:
- 文件说明补 visionHandler.ts / CameraCapture.vue / utils/tts.ts,
  并修正 generator.ts 的过时描述(早就不接受 llamacppServer 配置了)
- AppConfig 补 vision 字段,并写明"改 DEFAULT_CONFIG 只对新配置生效"
  这个部署坑(已有设备的 config.json 会盖掉新默认值)
- 新增"拍照提问"章节:三段式流程、"检索用组合文本+生成用原始问题"
  这个分工的理由、视觉能力探测的判据顺序(含 er_backend 日志只留 200 条
  导致 allow_image 会被挤掉、以及为什么不用 config.modelType)、
  摄像头 deviceId 跨会话失效和画面倒置影响识别质量两个坑
- 关键技术约定新增语音流式播报(append 必须累加、加粗重复朗读的成因)
  和系统提示词(为什么"每次换措辞"只写提示词无效、为什么不调 temperature)

README.md:新增"功能"和"启用拍照提问"两节,说明拍照提问是接入知识库
检索而非让模型凭空描述图片,以及需要多模态模型+设置开关两个前提。

文档里引用的文件、函数、IPC 事件已逐一核对与代码一致。

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 21:22:49 +08:00
parent ae4ecd645d
commit ec7f111038
2 changed files with 87 additions and 5 deletions

View File

@ -8,6 +8,8 @@
服务进程管理(llama-server / embed-server 的启动停止)**完全委托给 er_backend**(HTTP 17700),前端不直接 spawn 任何推理进程。设置页面不再提供计算卡型号、嵌入模型、生成模型路径等配置入口,这些均由 er_backend Web 管理界面(端口 17700)配置。 服务进程管理(llama-server / embed-server 的启动停止)**完全委托给 er_backend**(HTTP 17700),前端不直接 spawn 任何推理进程。设置页面不再提供计算卡型号、嵌入模型、生成模型路径等配置入口,这些均由 er_backend Web 管理界面(端口 17700)配置。
输入方式有三种:打字、语音输入(ASR)、**拍照提问**(用摄像头拍一张照片,先由多模态模型转成一句话描述,再接入同一套知识库检索流程,见"拍照提问"章节)。
本仓库从 monorepo(electronrag)通过 `scripts/sync-elec-rag-ui.sh` 单向同步而来,历史不保留。 本仓库从 monorepo(electronrag)通过 `scripts/sync-elec-rag-ui.sh` 单向同步而来,历史不保留。
## 技术栈 ## 技术栈
@ -31,6 +33,7 @@ src/
│ ├── index.ts # 注册所有 IPC 处理器 │ ├── index.ts # 注册所有 IPC 处理器
│ ├── indexHandler.ts # 文件选择、文档入库(分块→向量化→写入LanceDB) │ ├── indexHandler.ts # 文件选择、文档入库(分块→向量化→写入LanceDB)
│ ├── queryHandler.ts # 查询(向量化→检索→流式生成),query:done 含 GeneratorStats │ ├── queryHandler.ts # 查询(向量化→检索→流式生成),query:done 含 GeneratorStats
│ ├── visionHandler.ts # 拍照提问(图像理解→拼检索查询→复用检索+生成)
│ ├── statusHandler.ts # 数据库状态、配置读写、测试连接(无 Ollama 分支) │ ├── statusHandler.ts # 数据库状态、配置读写、测试连接(无 Ollama 分支)
│ ├── erBackendHandler.ts # er_backend HTTP 17700 代理 IPC(启动/停止/状态) │ ├── erBackendHandler.ts # er_backend HTTP 17700 代理 IPC(启动/停止/状态)
│ ├── hardwareHandler.ts # 硬件探测:hardware:get-detected / hardware:detected │ ├── hardwareHandler.ts # 硬件探测:hardware:get-detected / hardware:detected
@ -39,7 +42,7 @@ src/
│ ├── splitter.ts # 递归收集 .md/.txt,自实现递归字符分块 │ ├── splitter.ts # 递归收集 .md/.txt,自实现递归字符分块
│ ├── embedder.ts # 向量化路由:local / llamacpp(无 Ollama 分支) │ ├── embedder.ts # 向量化路由:local / llamacpp(无 Ollama 分支)
│ ├── localEmbedder.ts # 本地 CPU 向量化(Transformers.js 单例,懒加载) │ ├── localEmbedder.ts # 本地 CPU 向量化(Transformers.js 单例,懒加载)
│ ├── generator.ts # 流式生成:仅 llamacpp(接受 llamacppServer 配置) │ ├── generator.ts # 流式生成 + describeImage 图像理解,统一走 er_backend 代理
│ ├── hardwareDetector.ts # 探测 hm_smi / rpp-info 判断 er160/er25/none │ ├── hardwareDetector.ts # 探测 hm_smi / rpp-info 判断 er160/er25/none
│ └── ollamaModels.ts # (保留文件,此项目不使用 Ollama) │ └── ollamaModels.ts # (保留文件,此项目不使用 Ollama)
├── preload/index.ts # contextBridge 暴露 electronAPI(无 llamaServer IPC) ├── preload/index.ts # contextBridge 暴露 electronAPI(无 llamaServer IPC)
@ -48,11 +51,13 @@ src/
├── App.vue # 根布局:顶栏、主区;服务状态由 SSE 维护 ├── App.vue # 根布局:顶栏、主区;服务状态由 SSE 维护
├── components/ ├── components/
│ ├── IndexPanel.vue # 文档入库面板 │ ├── IndexPanel.vue # 文档入库面板
│ ├── ChatPanel.vue # 问答气泡、Think 块、引用徽章、批量发送 │ ├── ChatPanel.vue # 问答气泡、Think 块、引用徽章、批量发送、拍照入口
│ ├── CameraCapture.vue # 拍照弹层:取景/拍照/重拍/切换摄像头(纯交互,不含业务逻辑)
│ ├── ContextDrawer.vue # 检索片段抽屉 │ ├── ContextDrawer.vue # 检索片段抽屉
│ ├── SettingsModal.vue # 设置抽屉(仅 RAG 参数 + 语音 + 批量测试) │ ├── SettingsModal.vue # 设置抽屉(RAG 参数 + 语音 + 拍照开关 + 批量测试)
│ └── ServiceControl.vue # 顶栏"启用/停止"按钮(走 er_backend HTTP) │ └── ServiceControl.vue # 顶栏"启用/停止"按钮(走 er_backend HTTP)
├── stores/rag.ts # Pinia store ├── utils/tts.ts # TTS 播放器:流式分句播报、自适应语速、原生/Web 双后端
├── stores/rag.ts # Pinia store(含 visionAvailable:后端是否支持图像输入)
└── types/index.ts # TS 接口 + Window.electronAPI 类型声明 └── types/index.ts # TS 接口 + Window.electronAPI 类型声明
``` ```
@ -85,11 +90,17 @@ interface AppConfig {
} }
tts: { enabled: boolean; voiceURI: string; pitch: number; rate: number; volume: number } tts: { enabled: boolean; voiceURI: string; pitch: number; rate: number; volume: number }
} }
vision: {
enabled: boolean // 拍照提问入口的总开关,默认关闭
cameraDeviceId: string // 上次选用的摄像头,空=用第一个可用设备(该 id 可能失效,见"拍照提问")
}
} }
``` ```
**注意**:本项目无 `chat` 字段(生成固定走 llamacppServer)。`embed.baseUrl` 字段存在但不对用户暴露,由 `applyPlatformOverrides` 固定推导。 **注意**:本项目无 `chat` 字段(生成固定走 llamacppServer)。`embed.baseUrl` 字段存在但不对用户暴露,由 `applyPlatformOverrides` 固定推导。
**改 `DEFAULT_CONFIG` 只对新配置生效**:`getConfig()` 是 `{ ...DEFAULT_CONFIG.rag, ...raw.rag }` 这样合并的,已部署设备上 `config.json` 里存过的字段会盖掉新默认值。所以调整默认系统提示词这类改动,已有设备不会自动生效,需要删掉 `config.json` 里对应字段(删掉后会回落到新默认值)或在设置页重新保存一次。
## applyPlatformOverrides 逻辑 ## applyPlatformOverrides 逻辑
`getConfig()` 末尾调用。**自 `836d25c` 起,推理请求(生成 + 嵌入)统一走 er_backend 代理**(`ER_BACKEND_URL = 'http://127.0.0.1:17700'`),不再由前端按 cardType 自己推导 llama-server/embed-server 端口——服务实际监听哪个端口完全由 er_backend 后端配置决定,前端只认这一个代理入口: `getConfig()` 末尾调用。**自 `836d25c` 起,推理请求(生成 + 嵌入)统一走 er_backend 代理**(`ER_BACKEND_URL = 'http://127.0.0.1:17700'`),不再由前端按 cardType 自己推导 llama-server/embed-server 端口——服务实际监听哪个端口完全由 er_backend 后端配置决定,前端只认这一个代理入口:
@ -126,6 +137,39 @@ NDrawer (width=900)
**erBackendHandler.ts** 提供对应 IPC,渲染进程通过 `window.electronAPI.erBackend.*` 调用。 **erBackendHandler.ts** 提供对应 IPC,渲染进程通过 `window.electronAPI.erBackend.*` 调用。
## 拍照提问
用摄像头拍照代替打字提问。**不是把图片直接丢给模型问答,而是先把图像转成文字,再走同一套知识库检索流程**——这样答案依然基于手册、带引用标号,跟文字问答一致。
### 三段式流程(visionHandler.ts)
```
① 图像理解 describeImage(图片) → 一句话描述 caption 推送 vision-query:caption
② 检索 embedOne(caption + 用户问题) → search() 取 topK 推送 vision-query:context
③ 生成 streamGenerate(用户原始问题, chunks, systemPrompt) 推送 vision-query:delta/done
```
②③ 直接复用文字问答的 `embedOne`/`search`/`streamGenerate`,**没有平行实现一套**;图片只参与第①步,不会传给最终生成模型。事件通道与 `query:*` 平行(`vision-query:stage` / `:caption` / `:context` / `:delta` / `:done` / `:error`)。
两个刻意的取舍:
- **检索用 `caption + 问题` 拼接,生成只用用户原始问题**。检索侧拼接是为了扩大召回;生成侧只用原问题是为了让回答聚焦。实测拍无关物体 + 问"如何更换前大灯",答案准确命中前大灯文档且没被图像描述带偏,证明这个分工是必要的。
- **第①步用固定提示词而非用户问题**,且明确要求"一句话、不展开"。实测输出 45 tokens 左右、耗时 4.0–5.3 秒;对比让视觉模型直接完整作答的 454 tokens / 31 秒,差一个数量级。
### 视觉能力探测(ServiceControl.vue → store.visionAvailable)
后端加载的不是多模态模型时,拍照按钮置灰;换回多模态模型后下一轮轮询(5 秒)自动恢复,不用重启应用。判据顺序:
1. 日志里的 `allow_image = 1` —— 最准确,但**er_backend 日志缓冲只留最近 200 条,服务跑一会儿这行就被推理日志挤掉**,所以只在还翻得到时采信
2. 启动命令 `lastCommand` 含 `--mmproj` —— 不随日志滚动,长期可靠的兜底
**不要改用 `config.generate.modelType === 'vlm'`**:那是配置意图,用户改了配置但没重启服务时与实际运行的模型不符。
### 摄像头的两个坑
- **deviceId 跨会话会失效**:Web 标准不保证 `deviceId` 稳定,配置里存的旧 id 换会话后常常已不存在,拿它做 `getUserMedia` 的 `exact` 约束会抛 `OverconstrainedError`,整个拍照功能不可用。`CameraCapture.vue` 的 `openStream()` 因此带了回退:指定 id 打不开就退回默认摄像头,并把实际拿到的设备 id 回写。
- **画面倒置会影响识别质量,不只是观感问题**:这批设备摄像头原始画面是倒的。实测把未旋转的原始帧送去识别,模型描述里会明确写"倒置的室内场景"——它把方向当成了图像特征。所以预览的 CSS 旋转和 `capture()` 里 canvas 的旋转**必须成对出现**,只转预览会变成"看着是正的、存下来的图是倒的"。
## 关键技术约定 ## 关键技术约定
### LanceDB 余弦检索(lancedb.ts) ### LanceDB 余弦检索(lancedb.ts)
@ -150,6 +194,26 @@ const vectors = await Promise.all(texts.map((t, i) => fetchOne(t, i)))
``` ```
每条 60 秒超时,失败重试 2 次。 每条 60 秒超时,失败重试 2 次。
### 语音输出:流式播报(utils/tts.ts)
答案边生成边播报,不等整段生成完。`ChatPanel` 在 `onDelta` 里实时 `ttsPlayer.append(delta)`,`onDone`/`onError` 里 `flush()` 收尾;`TtsPlayer` 内部按句子边界切分入队,播放跟不上文字时会自适应降速。
三个踩过的坑:
- **`append()` 里必须是 `this.buffer += pending` 而不是 `=`**。LLM 流式增量很碎(常几个字一个 chunk),用赋值会把上一次还没凑够一句、尚未送去朗读的残留直接冲掉,实际效果是整段正文都丢了、只念出 `flush()` 时残留的一个句号。
- **加粗文本会被念两遍**,根因是 `markdownToPlain()` 每次都从头重新解析整段 `rawText`,而 `**` 闭合前后同一段文字的纯文本结果不一样(未闭合时星号当普通字符,闭合后星号被去掉)。提前把这段还不稳定的文字记为"已朗读",闭合符号一到就会被当成新内容重念。`findStableBoundary()` 因此只处理到最后一个已闭合的 `**` 为止,未闭合的尾巴留到下次;`flush()` 时生成已结束,不再有这顾虑,直接处理完。
- **"开始播报"提示音要单独入队**(`announceStart()`),不能碰流式缓冲区状态。
### 系统提示词:查不到内容时的答复
提示词要求"说明手册中暂未查到,并给一条可操作的建议",而不是硬邦邦一句"我不知道"。
**光在提示词里写"每次换一种措辞"是无效的**——模型每次请求都是无状态的,不知道上次说了什么,实测同样输入连发三次答复一字不差,而且会照抄提示词里给的示例。所以措辞变化由代码侧提供:`generator.ts` 的 `withToneHint()` 每次请求随机挑一条引导追加到系统提示词末尾,让输入本身产生差异。
**没有采用调高 temperature 的做法**:那会同时影响命中文档的正常回答,而维修手册问答更看重稳定准确。
另外,`queryHandler`/`visionHandler` 里 `chunks.length === 0` 的兜底文案说的是"知识库还没有文档,请先导入"而不是"我不知道"——`search()` 是全表余弦排序后截断,只要库里有文档就一定返回结果,走到那个分支只可能是库为空。
### Vue Proxy IPC 序列化 ### Vue Proxy IPC 序列化
Pinia 响应式对象直接传给 `ipcRenderer.invoke()` 会触发结构化克隆错误。**所有传入 IPC 的响应式对象必须先序列化**: Pinia 响应式对象直接传给 `ipcRenderer.invoke()` 会触发结构化克隆错误。**所有传入 IPC 的响应式对象必须先序列化**:

View File

@ -4,6 +4,14 @@
**仅支持 Linux(银河麒麟 Kylin V10 / Ubuntu)**,适配 ER160(揽月 M50 NPU)和 ER25(芯动力 RPP)计算卡。服务进程管理委托给 [er_backend](https://git.drumtm.com/EdgeRhythm/er_backend)(HTTP 17700),前端专注 RAG 逻辑与用户交互。 **仅支持 Linux(银河麒麟 Kylin V10 / Ubuntu)**,适配 ER160(揽月 M50 NPU)和 ER25(芯动力 RPP)计算卡。服务进程管理委托给 [er_backend](https://git.drumtm.com/EdgeRhythm/er_backend)(HTTP 17700),前端专注 RAG 逻辑与用户交互。
## 功能
- **文档问答**:导入 Markdown/文本手册,提问后检索相关片段并生成带引用标号的回答,点引用可查看原文片段
- **语音输入**:Paraformer 流式识别,说话即转文字填入输入框
- **语音输出**:边生成边播报,不用等整段答案写完
- **拍照提问**:用摄像头拍下实物,先由多模态模型转成一句话描述,再接入同一套知识库检索——所以答案同样基于手册内容、带引用,而不是模型凭空描述图片。需要 er_backend 当前加载的是多模态模型,否则按钮会自动置灰(换回多模态模型后自动恢复)
- **批量测试**:按预设问题列表循环提问,用于压测和效果回归
## 技术栈 ## 技术栈
- **框架**:Electron + electron-vite + Vue3 + TypeScript - **框架**:Electron + electron-vite + Vue3 + TypeScript
@ -37,8 +45,9 @@ elec_rag_ui/
│ ├── renderer/ # Vue3 前端 │ ├── renderer/ # Vue3 前端
│ │ └── src/ │ │ └── src/
│ │ ├── App.vue # 根布局(顶栏 + 主区) │ │ ├── App.vue # 根布局(顶栏 + 主区)
│ │ ├── components/ # ChatPanel / IndexPanel / SettingsModal 等 │ │ ├── components/ # ChatPanel / IndexPanel / CameraCapture / SettingsModal 等
│ │ ├── stores/ # Pinia store │ │ ├── stores/ # Pinia store
│ │ ├── utils/ # TTS 播放器、音频采集、Markdown 处理
│ │ └── types/ # TypeScript 类型定义 │ │ └── types/ # TypeScript 类型定义
│ └── batch_questions.txt # 批量测试问题示例 │ └── batch_questions.txt # 批量测试问题示例
├── models/ # 本地模型(onnx 由 Git LFS 管理) ├── models/ # 本地模型(onnx 由 Git LFS 管理)
@ -67,6 +76,15 @@ systemctl status er-backend
er_backend 管理界面:`http://<PAD-IP>:17700`(配置模型路径、启动/停止服务) er_backend 管理界面:`http://<PAD-IP>:17700`(配置模型路径、启动/停止服务)
## 启用拍照提问
需要两个条件同时满足:
1. **er_backend 加载的是多模态模型**——在管理界面把生成模型配成带 mmproj 的视觉模型(启动命令里应带 `--mmproj`)。应用会自动探测:不是多模态模型时拍照按钮置灰,换回多模态模型后 5 秒内自动恢复可用,不用重启应用。
2. **在「设置 → 基本设置 → 拍照提问」里打开开关**(默认关闭,因为不是所有部署都会配视觉模型)。
摄像头用标准 USB UVC 设备即可,无需额外驱动。有多个摄像头时,拍照弹层右上角可以切换。
## 模型文件 ## 模型文件
`models/` 目录中的 `.onnx` 文件通过 **Git LFS** 管理。克隆后拉取实体文件: `models/` 目录中的 `.onnx` 文件通过 **Git LFS** 管理。克隆后拉取实体文件: