elec_rag_ui/CLAUDE.md
ssurui ec7f111038 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
2026-09-03 21:22:49 +08:00

315 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# CLAUDE.md — elec_rag_ui 项目约定与开发指南
本文件供 Claude Code 在协助开发此项目时参考。所有文档、注释、提交信息均使用中文。
## 项目概述
**elec_rag_ui** 是智能电子手册的开源前端工程,**仅支持 Linux(Kylin V10 / Ubuntu)**,**仅支持 ER160(揽月 M50 NPU)和 ER25(芯动力 RPP)硬件**。
服务进程管理(llama-server / embed-server 的启动停止)**完全委托给 er_backend**(HTTP 17700),前端不直接 spawn 任何推理进程。设置页面不再提供计算卡型号、嵌入模型、生成模型路径等配置入口,这些均由 er_backend Web 管理界面(端口 17700)配置。
输入方式有三种:打字、语音输入(ASR)、**拍照提问**(用摄像头拍一张照片,先由多模态模型转成一句话描述,再接入同一套知识库检索流程,见"拍照提问"章节)。
本仓库从 monorepo(electronrag)通过 `scripts/sync-elec-rag-ui.sh` 单向同步而来,历史不保留。
## 技术栈
- **框架**:Electron + electron-vite + Vue3 + TypeScript
- **UI 组件库**:Naive UI(弹窗/通知/滑出容器)+ 自定义工业风 CSS
- **状态管理**:Pinia
- **向量数据库**:LanceDB(`@lancedb/lancedb`,嵌入式)
- **本地向量化**:`@huggingface/transformers`(底层 `onnxruntime-node`)
- **Markdown 渲染**:`marked`
- **打包**:electron-builder(Linux AppImage + deb)。**arm64 deb 在真机 Kylin 上走 electron-builder 自带的 fpm 路径必现 `tar failed (exit code 2)`**,实际可用的是 `scripts/package-kylin-arm64-deb.sh`(`npm run package:kylin:arm64:deb`),改用 `electron-builder --linux dir --arm64` 产出 unpacked 目录后手工 `dpkg-deb` 打包,已在真机验证
## 文件说明
```
src/
├── main/
│ ├── index.ts # 主进程:disableHardwareAcceleration, setFullScreen, Menu null
│ ├── config.ts # 读写 config.json,含 applyPlatformOverrides
│ └── ipc/
│ ├── 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
│ └── npuHandler.ts # NPU 状态轮询(hm_smi),推送 npu:stats
│ └── rag/
│ ├── splitter.ts # 递归收集 .md/.txt,自实现递归字符分块
│ ├── embedder.ts # 向量化路由:local / llamacpp(无 Ollama 分支)
│ ├── localEmbedder.ts # 本地 CPU 向量化(Transformers.js 单例,懒加载)
│ ├── generator.ts # 流式生成 + describeImage 图像理解,统一走 er_backend 代理
│ ├── hardwareDetector.ts # 探测 hm_smi / rpp-info 判断 er160/er25/none
│ └── ollamaModels.ts # (保留文件,此项目不使用 Ollama)
├── preload/index.ts # contextBridge 暴露 electronAPI(无 llamaServer IPC)
└── renderer/src/
├── assets/main.css # CSS 变量(色板)、.i-btn 按钮体系
├── App.vue # 根布局:顶栏、主区;服务状态由 SSE 维护
├── components/
│ ├── IndexPanel.vue # 文档入库面板
│ ├── ChatPanel.vue # 问答气泡、Think 块、引用徽章、批量发送、拍照入口
│ ├── CameraCapture.vue # 拍照弹层:取景/拍照/重拍/切换摄像头(纯交互,不含业务逻辑)
│ ├── ContextDrawer.vue # 检索片段抽屉
│ ├── SettingsModal.vue # 设置抽屉(RAG 参数 + 语音 + 拍照开关 + 批量测试)
│ └── ServiceControl.vue # 顶栏"启用/停止"按钮(走 er_backend HTTP)
├── utils/tts.ts # TTS 播放器:流式分句播报、自适应语速、原生/Web 双后端
├── stores/rag.ts # Pinia store(含 visionAvailable:后端是否支持图像输入)
└── types/index.ts # TS 接口 + Window.electronAPI 类型声明
```
## AppConfig 结构
```typescript
interface AppConfig {
embed: {
provider: 'local' | 'llamacpp' // 无 Ollama 分支
localModelPath: string // 本地 onnx 模型目录
// baseUrl / model 由 applyPlatformOverrides 内部推导,不暴露给用户
}
rag: { chunkSize: number; chunkOverlap: number; topK: number; systemPrompt: string }
llamacppServer: {
executablePath: string; modelPath: string; port: number; numCtx: number
parallelSlots: number; threads: number; gpuLayers: number
noWarmup: boolean; extraArgs: string
}
llamacppEmbed: {
modelPath: string; port: number; extraArgs: string
}
hardware: { cardType: 'er160' | 'er25' }
batchQuestions: string[]
batchWaitSeconds: number
voice: {
asr: {
enabled: boolean; executablePath: string; modelDir: string; numThreads: number
vadSilenceTimeoutMs: number; vadEnergyThreshold: number; inputGain: number
punctuationModelDir: string
}
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 后端配置决定,前端只认这一个代理入口:
- `config.embed.baseUrl` 恒等于 `ER_BACKEND_URL`,与 cardType 无关
- `generator.ts`、`statusHandler.ts` 的 `chat-test`、`erBackendHandler.ts` 全部请求 `ER_BACKEND_URL/v1/*`
`applyPlatformOverrides` 现在只负责按计算卡型号补默认启动参数(`extraArgs` 为空时才生效):
**ER160(揽月 M50)**:`embed.provider` 若不是 `local` 则改为 `llamacpp`;`llamacppServer.extraArgs` 默认 `'--port 8081 --presence_penalty 1.5'`;`llamacppEmbed.extraArgs` 默认走 `DEFAULT_CONFIG` 里的值
**ER25(芯动力 RPP)**:`embed.provider = 'local'`(强制,本地 CPU);`llamacppServer.extraArgs` 默认 `'--port 8080 --ctk bf16 --ctv bf16 --no-warmup -ub 128'`
## SettingsModal.vue 结构(精简后)
```
NDrawer (width=900)
├── .s-tabs 'index' | 'basic' | 'voice' | 'batch' | 'log'
├── 文档入库 <IndexPanel />
├── 基本设置 检索参数(Top-K + 系统提示词)── 无计算卡/模型配置
├── 语音设置 TTS/ASR 参数
├── 批量测试 问题列表 + 等待时间滑块
└── 服务日志 嵌入/生成服务日志框(isLinux 可见)
```
计算卡型号、嵌入模型路径、生成模型路径等配置已从设置页移除,统一由 er_backend(http://<PAD>:17700)管理。
## er_backend 集成
**ServiceControl.vue** 始终走 er_backend HTTP(`useErBackend = true`):
- 启动:`POST /api/generate/start`,`POST /api/embed/start`(ER25 跳过)
- 停止:`POST /api/generate/stop`,`POST /api/embed/stop`
- 状态 SSE:`GET /api/generate/logs`,`GET /api/embed/logs`
**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)
兆芯 KX-U6780A 无 AVX2,LanceDB 预编译包 SIMD 代码触发 `Illegal instruction`。`search()` 改为 `t.query().toArray()` + 纯 JS 余弦相似度:
```typescript
function toNumberArray(v: unknown): number[] | Float32Array {
if (v instanceof Float32Array || Array.isArray(v)) return v
if (typeof (v as any)?.toArray === 'function') return (v as any).toArray()
return Array.from(v as Iterable<number>)
}
```
**Arrow Vector 陷阱**:`toArray()` 返回行中的 vector 字段是 Arrow `Vector<FixedSizeList>` 对象,`b[i]` 返回 `undefined`,必须用 `toNumberArray()` 转换。
### 并发向量化(embedder.ts)
llamacpp 分支使用 `Promise.all` 并发发送,让服务端有机会攒批送 NPU:
```typescript
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 的响应式对象必须先序列化**:
```typescript
const plain = JSON.parse(JSON.stringify(reactiveObj))
await window.electronAPI.someIpc(plain)
```
### sharp stub
`stubs/sharp/` 作为 workspace 包(名称 `sharp`,版本 `0.34.5`)满足 `@huggingface/transformers` v4 的硬性 `require("sharp")`,避免打包后崩溃。
### 原生模块打包
```typescript
asarUnpack: ['node_modules/@lancedb/**', 'node_modules/apache-arrow/**', 'node_modules/onnxruntime-node/**']
```
### 版本号管理
版本号解析在三处保持一致:`electron-builder.config.ts`、`electron.vite.config.ts`、`scripts/package-kylin-arm64-deb.sh`。逻辑都是:`git describe --tags --abbrev=0`(去掉 `v` 前缀)→ 失败读项目根目录 `.app-version` 文件(同样去掉 `v` 前缀,已加入 `.gitignore`,PAD 部署等无 `.git` 环境下手动生成)→ 再失败分别 fallback 到 `'0.0.0'` / `'dev'` / `package.json` 版本号。**`.app-version` 缺失是 PAD 上界面显示版本号变成 `dev` 的常见原因**——rsync 部署到 PAD 时要记得同步这个文件。
### 应用图标打包踩坑
`resources/icon.png` 是图标源文件(1024×1024),`resources/icons/{16,24,32,48,64,128,256,512}x{同}.png` 是预生成的常规尺寸(`sips`/ImageMagick 生成,随图标一起更新)。两处坑:
1. **主进程窗口图标**:`src/main/index.ts` 用 `join(__dirname, '../../resources/icon.png')` 相对路径定位,`electron-builder.config.ts` 的 `files` 必须显式包含 `'resources/icon.png'`(默认只有 `out/**/*`,不会自动带上)。
2. **桌面菜单图标只装 1024×1024 不够**:不少桌面环境(如 UKUI)按常规小尺寸(48×48 等)查找图标,找不到就直接回退成缺省图标,不会从 1024 自动缩放。`scripts/package-kylin-arm64-deb.sh` 把 9 个尺寸全部装进 `hicolor/<size>x<size>/apps/`,另外在 `/usr/share/pixmaps/` 放一份兼容不认图标主题的老式菜单。装完新图标后需要**注销重登或重启**桌面 shell 才会刷新(图标缓存 `~/.cache/icon-cache.kcache`、`~/.cache/ksycoca5_*` 不会主动感知内容变化)。
### 打包体积精简(789M → 664M)
`electron-builder.config.ts` 的 `files` 排除了几个只在渲染层用、主进程从不 `require` 的包(`naive-ui`/`date-fns`/`highlight.js`/`lodash`/`lodash-es`,electron-vite build 时已被 Vite 静态打包进 `out/renderer/*.js`),以及 `onnxruntime-web`(`@huggingface/transformers` 的硬依赖,但项目强制走 `onnxruntime-node`,完全未用)。**排除 glob 必须写成 `'!**/node_modules/xxx/**'`,不能省 `**/` 前缀**——`'!node_modules/xxx/**'` 在真机验证时基本不生效(electron-builder 按完整相对路径匹配)。
`resources/tts/piper/` 只保留 `espeak-ng-data/` 子目录(131M → 18M):TTS 优先级第一位永远命中 VITS(小雅),Piper 自己的引擎(神经语音模型、二进制、自带 onnxruntime、phonemize 库)从未被实际调用,VITS 只是借用 Piper bundle 里的 `espeak-ng-data` 当 sherpa-onnx 校验用的 dataDir。`systemTts.ts` 的 `findPiperEspeakDataDir()` 只检查这个子目录是否存在,不要求 Piper 引擎的 bin/model/config 齐全。
`models/bge-base-zh-v1.5`、`models/bge-large-zh-v1.5`(只有 tokenizer 元数据无 onnx 权重,代码零引用)、`models/punct`(标点恢复模型,`extraResources` 从未把它纳入打包,功能一直是禁用状态)已从仓库删除。标点恢复功能代码保留(`punctuationModelDir` 为空时优雅跳过),需要的话自行准备模型再在设置里指定目录。
## NPU 状态监控
每 5 秒调用 `hm_smi --fields DDR_Memory_Free DDR_Memory_Total Core0 Core1 -a`,推送 `npu:stats` 事件:
```typescript
interface NpuStats { memUsed: number; memTotal: number; temp: number }
```
仅 `cardType === 'er160'` 时启动轮询;切换为 er25 时停止并推送 `null`。
## Git LFS 说明
`models/**/*.onnx` 通过 Git LFS 管理(见 `.gitattributes`)。克隆后必须:
```bash
git lfs install
GIT_SSL_NO_VERIFY=true git lfs pull # git.drumtm.com TLS 证书已过期,需绕过
```
推送含 LFS 对象的提交:
```bash
GIT_SSL_NO_VERIFY=true git lfs push origin main
git push origin main
```
## Kylin V10 / Ubuntu 适配要点
1. **禁用硬件加速**:兆芯集显 `MAX_FRAGMENT_UNIFORM_VECTORS=0` 导致 Skia 崩溃,`app.disableHardwareAcceleration()` 在 Linux 下强制启用
2. **全屏**:`ready-to-show` 后调用 `setFullScreen(true)`,覆盖任务栏
3. **菜单**:`Menu.setApplicationMenu(null)` 移除菜单栏
4. **onnxruntime-node**:必须在 `dependencies` 直接声明(不能为传递依赖),用 `require()` 而非 `await import()`
5. **npmrc**:`onnxruntime_node_install=skip` 跳过 CUDA EP 下载(NuGet 302 重定向无法跟随)
## 开发命令
```bash
npm install
npm run dev:linux # 开发模式(--no-sandbox)
npm run package:kylin # 打包 x64(AppImage + deb)
npm run package:kylin:arm64 # 打包 arm64(须在 ARM64 机器上;真机 Kylin 上 deb 目标会因 fpm bug 失败)
npm run package:kylin:arm64:deb # 打包 arm64 deb(推荐,已在真机验证;绕开 fpm bug,见上文"打包"条目)
```
## 同步来源
本仓库由 monorepo(electronrag)通过以下方式单向同步:
```bash
# 在 monorepo 根目录执行
bash scripts/sync-elec-rag-ui.sh "feat: 功能说明"
```
脚本同步 `src/elec_rag_ui/` → 独立仓库根目录,`models/` 和 `docs/` 同步至对应子目录,自动提交并推送(含 LFS 对象)。
**不要直接向本仓库提交代码**(除紧急修复外),应在 monorepo 的 `src/elec_rag_ui/` 修改后通过同步脚本推送。
## 注意事项
- 切换嵌入模型后必须重建索引(维度不同)
- Vue 响应式对象通过 IPC 传递前必须序列化
- PAD 部署使用 `--no-sandbox` 启动(`/usr/bin/智能电子手册` 脚本已内置)
- LFS push 须加 `GIT_SSL_NO_VERIFY=true`(git.drumtm.com 证书已过期)
- `libva error` 和 `GLSL warning` 为兆芯集显无害日志,可忽略