diff --git a/CLAUDE.md b/CLAUDE.md index 5c50b34..22d41e9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,8 @@ 服务进程管理(llama-server / embed-server 的启动停止)**完全委托给 er_backend**(HTTP 17700),前端不直接 spawn 任何推理进程。设置页面不再提供计算卡型号、嵌入模型、生成模型路径等配置入口,这些均由 er_backend Web 管理界面(端口 17700)配置。 +输入方式有三种:打字、语音输入(ASR)、**拍照提问**(用摄像头拍一张照片,先由多模态模型转成一句话描述,再接入同一套知识库检索流程,见"拍照提问"章节)。 + 本仓库从 monorepo(electronrag)通过 `scripts/sync-elec-rag-ui.sh` 单向同步而来,历史不保留。 ## 技术栈 @@ -31,6 +33,7 @@ src/ │ ├── index.ts # 注册所有 IPC 处理器 │ ├── indexHandler.ts # 文件选择、文档入库(分块→向量化→写入LanceDB) │ ├── queryHandler.ts # 查询(向量化→检索→流式生成),query:done 含 GeneratorStats +│ ├── visionHandler.ts # 拍照提问(图像理解→拼检索查询→复用检索+生成) │ ├── statusHandler.ts # 数据库状态、配置读写、测试连接(无 Ollama 分支) │ ├── erBackendHandler.ts # er_backend HTTP 17700 代理 IPC(启动/停止/状态) │ ├── hardwareHandler.ts # 硬件探测:hardware:get-detected / hardware:detected @@ -39,7 +42,7 @@ src/ │ ├── splitter.ts # 递归收集 .md/.txt,自实现递归字符分块 │ ├── embedder.ts # 向量化路由:local / llamacpp(无 Ollama 分支) │ ├── localEmbedder.ts # 本地 CPU 向量化(Transformers.js 单例,懒加载) -│ ├── generator.ts # 流式生成:仅 llamacpp(接受 llamacppServer 配置) +│ ├── generator.ts # 流式生成 + describeImage 图像理解,统一走 er_backend 代理 │ ├── hardwareDetector.ts # 探测 hm_smi / rpp-info 判断 er160/er25/none │ └── ollamaModels.ts # (保留文件,此项目不使用 Ollama) ├── preload/index.ts # contextBridge 暴露 electronAPI(无 llamaServer IPC) @@ -48,11 +51,13 @@ src/ ├── App.vue # 根布局:顶栏、主区;服务状态由 SSE 维护 ├── components/ │ ├── IndexPanel.vue # 文档入库面板 - │ ├── ChatPanel.vue # 问答气泡、Think 块、引用徽章、批量发送 + │ ├── ChatPanel.vue # 问答气泡、Think 块、引用徽章、批量发送、拍照入口 + │ ├── CameraCapture.vue # 拍照弹层:取景/拍照/重拍/切换摄像头(纯交互,不含业务逻辑) │ ├── ContextDrawer.vue # 检索片段抽屉 - │ ├── SettingsModal.vue # 设置抽屉(仅 RAG 参数 + 语音 + 批量测试) + │ ├── SettingsModal.vue # 设置抽屉(RAG 参数 + 语音 + 拍照开关 + 批量测试) │ └── 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 类型声明 ``` @@ -85,11 +90,17 @@ interface AppConfig { } tts: { enabled: boolean; voiceURI: string; pitch: number; rate: number; volume: number } } + vision: { + enabled: boolean // 拍照提问入口的总开关,默认关闭 + cameraDeviceId: string // 上次选用的摄像头,空=用第一个可用设备(该 id 可能失效,见"拍照提问") + } } ``` **注意**:本项目无 `chat` 字段(生成固定走 llamacppServer)。`embed.baseUrl` 字段存在但不对用户暴露,由 `applyPlatformOverrides` 固定推导。 +**改 `DEFAULT_CONFIG` 只对新配置生效**:`getConfig()` 是 `{ ...DEFAULT_CONFIG.rag, ...raw.rag }` 这样合并的,已部署设备上 `config.json` 里存过的字段会盖掉新默认值。所以调整默认系统提示词这类改动,已有设备不会自动生效,需要删掉 `config.json` 里对应字段(删掉后会回落到新默认值)或在设置页重新保存一次。 + ## applyPlatformOverrides 逻辑 `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.*` 调用。 +## 拍照提问 + +用摄像头拍照代替打字提问。**不是把图片直接丢给模型问答,而是先把图像转成文字,再走同一套知识库检索流程**——这样答案依然基于手册、带引用标号,跟文字问答一致。 + +### 三段式流程(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) @@ -150,6 +194,26 @@ const vectors = await Promise.all(texts.map((t, i) => fetchOne(t, i))) ``` 每条 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 序列化 Pinia 响应式对象直接传给 `ipcRenderer.invoke()` 会触发结构化克隆错误。**所有传入 IPC 的响应式对象必须先序列化**: diff --git a/README.md b/README.md index 35143b8..727a090 100644 --- a/README.md +++ b/README.md @@ -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 逻辑与用户交互。 +## 功能 + +- **文档问答**:导入 Markdown/文本手册,提问后检索相关片段并生成带引用标号的回答,点引用可查看原文片段 +- **语音输入**:Paraformer 流式识别,说话即转文字填入输入框 +- **语音输出**:边生成边播报,不用等整段答案写完 +- **拍照提问**:用摄像头拍下实物,先由多模态模型转成一句话描述,再接入同一套知识库检索——所以答案同样基于手册内容、带引用,而不是模型凭空描述图片。需要 er_backend 当前加载的是多模态模型,否则按钮会自动置灰(换回多模态模型后自动恢复) +- **批量测试**:按预设问题列表循环提问,用于压测和效果回归 + ## 技术栈 - **框架**:Electron + electron-vite + Vue3 + TypeScript @@ -37,8 +45,9 @@ elec_rag_ui/ │ ├── renderer/ # Vue3 前端 │ │ └── src/ │ │ ├── App.vue # 根布局(顶栏 + 主区) -│ │ ├── components/ # ChatPanel / IndexPanel / SettingsModal 等 +│ │ ├── components/ # ChatPanel / IndexPanel / CameraCapture / SettingsModal 等 │ │ ├── stores/ # Pinia store +│ │ ├── utils/ # TTS 播放器、音频采集、Markdown 处理 │ │ └── types/ # TypeScript 类型定义 │ └── batch_questions.txt # 批量测试问题示例 ├── models/ # 本地模型(onnx 由 Git LFS 管理) @@ -67,6 +76,15 @@ systemctl status er-backend er_backend 管理界面:`http://:17700`(配置模型路径、启动/停止服务) +## 启用拍照提问 + +需要两个条件同时满足: + +1. **er_backend 加载的是多模态模型**——在管理界面把生成模型配成带 mmproj 的视觉模型(启动命令里应带 `--mmproj`)。应用会自动探测:不是多模态模型时拍照按钮置灰,换回多模态模型后 5 秒内自动恢复可用,不用重启应用。 +2. **在「设置 → 基本设置 → 拍照提问」里打开开关**(默认关闭,因为不是所有部署都会配视觉模型)。 + +摄像头用标准 USB UVC 设备即可,无需额外驱动。有多个摄像头时,拍照弹层右上角可以切换。 + ## 模型文件 `models/` 目录中的 `.onnx` 文件通过 **Git LFS** 管理。克隆后拉取实体文件: diff --git a/docs/feature-pic-design.md b/docs/feature-pic-design.md new file mode 100644 index 0000000..7aacb94 --- /dev/null +++ b/docs/feature-pic-design.md @@ -0,0 +1,404 @@ +# 拍照提问模式 —— 设计方案 + +状态:**已实现并通过真机端到端验证**(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. 用户体验流程 + +``` +[聊天界面输入框旁] 新增相机图标按钮 + │ 点击 + ▼ +[取景弹层] 全屏/半屏显示