Compare commits

..

No commits in common. "main" and "v8.0.0" have entirely different histories.
main ... v8.0.0

59 changed files with 316089 additions and 1943 deletions

341
CLAUDE.md
View File

@ -1,341 +0,0 @@
# 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()`),不能碰流式缓冲区状态。
- **生成中途才打开 TTS,会漏掉开关之前已生成的正文**。`append()` 开头是 `if (!this.options.enabled) return`,TTS 关闭期间每次 `onDelta` 调用都被直接挡掉,内部 `rawText`/`lastSpokenPlain` 完全不累积;中途打开后下一次 `onDelta` 只送来新增量,等于从打开那一刻"之后"才开始播。`ChatPanel.vue` 的 `toggleTts()` 因此在 `store.querying` 为真时,除了 `announceStart()` 还要补一次 `ttsPlayer.append(store.rawAnswer)`,把已生成的全文"补喂"一遍,相当于告诉播放器"这就是从头到现在的内容",之后的增量在这个基础上正常追加、不会重复。
**调试这类"实际发声内容对不对"的问题时的教训**:想在渲染进程 monkey-patch `window.electronAPI.xxx` 来拦截调用做验证是无效的——`contextBridge.exposeInMainWorld` 暴露的对象默认被冻结,赋值会静默失败、不报错,很容易误判成"这条代码路径没走到"。真正能验证的办法是去主进程对应的 `ipcMain.handle(...)` 里临时加 `console.log`(验证完记得删),或者用 CDP 的 `Input.dispatchMouseEvent` 模拟真实点击、`Runtime.evaluate` 读 DOM/计算样式——这两种都是从"实际发生的效果"验证,不依赖能否篡改渲染进程里的 API 对象。
### 长时间运行后语音输入输出一起失效(systemTts.ts)
真机反馈:pad 跑了一两周之后语音输入输出全部失效。现场诊断:pulseaudio 占 49% CPU,系统里积压了 70 个从几天前就存在的 `aplay -t raw` 残留进程。
**根因跟 `WAKE_IDLE_THRESHOLD_MS` 那条注释里提到的"PulseAudio 大约 5 秒空闲就挂起音频设备"是同一件事**:`speakWithVits`/`speakWithPiperStreaming` 每播一个分句就 spawn 一个 aplay,写完数据后调用 `stdin.end()`,然后完全依赖 aplay 自己触发 `'exit'` 事件才清理 `isSpeaking` 状态——没有任何超时兜底(合成侧 `vitsWorker` 倒是本来就有 60 秒超时)。长时间运行后一旦某次"唤醒音频通路"没成功,aplay 会卡在往挂死的 PulseAudio 写数据,`'exit'` 永远不触发,`isSpeaking` 永久卡 `true`,`playNext()` 不再处理队列,TTS 直接哑掉。**ASR 走的是渲染进程 `getUserMedia`/`AudioContext`,和 TTS 共用同一个被拖垮的 PulseAudio 实例**,所以语音输入也一起失效。更麻烦的是 `systemTtsStop()` 原来只发 `SIGTERM`,卡在系统调用里的 aplay 根本杀不掉——每次打断播报(每次提问前都会调一次)都可能攒下一个杀不死的僵尸进程,日积月累到 70 个,把 PulseAudio 彻底拖垮。
**修复**:播放阶段加 `PLAYER_EXIT_TIMEOUT_MS`(8 秒)看门狗,`stdin.end()` 后超时未触发 `'exit'` 就判定通路卡死,`SIGKILL` 强杀并 `done(error)` 收尾;`systemTtsStop()` 加 `killWithEscalation()`,先 `SIGTERM`,1.5 秒宽限期后还没退出就 `SIGKILL`。
**验证方法**:真机上拿 `kill -STOP <pulseaudio_pid>` 故意冻结 PulseAudio,模拟"音频通路卡死"的真实场景,再用 CDP 触发一次播报,确认看门狗在约 8 秒后正确强杀卡住的 aplay、队列自动继续、最终无残留进程,`kill -CONT` 解冻收尾。**这个冻结/解冻手法可以复用**:任何"子进程偶发挂死"的 bug,不必真的等它在真机自然复现,直接 `SIGSTOP` 它依赖的下游服务就能可控地造出卡死场景。
### 系统提示词:查不到内容时的答复
提示词要求"说明手册中暂未查到,并给一条可操作的建议",而不是硬邦邦一句"我不知道"。
**光在提示词里写"每次换一种措辞"是无效的**——模型每次请求都是无状态的,不知道上次说了什么,实测同样输入连发三次答复一字不差,而且会照抄提示词里给的示例。所以措辞变化由代码侧提供:`generator.ts` 的 `withToneHint()` 每次请求随机挑一条引导,让输入本身产生差异。
**没有采用调高 temperature 的做法**:那会同时影响命中文档的正常回答,而维修手册问答更看重稳定准确。
**`withToneHint()` 的插入位置踩过一次真实的坑**:第一版把引导语整句追加在系统提示词最末尾(紧邻用户问题,模型注意力权重最高的位置)。真机复测发现:同一个检索到充分相关内容、理应直接作答的问题,连续追问 3 次,有 1 次被模型硬套上"手册里未查到…"的开场白,即便后面还是照常给出了详细步骤——答案读起来却像没查到,用户也确实这样反馈过。根因是这条指令脱离了它原本所属的"仅在没有足够信息时才用"的条件分句,变成了提示词末尾一条独立显眼的指令,模型会倾向"找机会用上它"。**现在改为把引导语嵌入到原本那条"如果没有足够信息"分句内部**(`NO_MATCH_BULLET_MARKER` 定位插入点,在 `config.ts` 的默认提示词里找 `以便更准确地定位;` 这句),位置和从属语气都跟原本不带随机性的版本保持一致。用户在设置页改写过这句话、不含该标记时,`withToneHint()` 原样返回不插入,不强行拼接导致语义错位。
**排查这个 bug 时的一个教训**:之前测试这个功能时没能发现问题,是因为测试脚本直接拿纯净的 `systemPrompt` 调用生成接口,没有复现 `withToneHint()` 在真实请求里的行为——以后验证跟 prompt 拼接相关的改动,测试必须经过完整的 `streamGenerate()`/`withToneHint()` 调用链,不能绕开直接测底层生成接口。
另外,`queryHandler`/`visionHandler` 里 `chunks.length === 0` 的兜底文案说的是"知识库还没有文档,请先导入"而不是"我不知道"——`search()` 是全表余弦排序后截断,只要库里有文档就一定返回结果,走到那个分支只可能是库为空。
### 触屏 `:hover` 粘滞导致状态色被覆盖(ChatPanel.vue 语音按钮)
`.voice-btn`(语音输入/语音输出/拍照提问)点击切换"开启"状态后,按钮背景该变成纯 `--green` 填充,真机上却只有边框变绿、背景还是浅灰——看起来像凭空多了一个"焦点框"。
**排查弯路**:一开始以为是浏览器原生 focus outline,加了 `outline:none` + `:active` 反馈,真机复测毫无变化。
**真正根因是 CSS 优先级,跟 outline/focus 完全无关**:`.voice-btn.on`(2 个选择器)想把背景铺满 `--green`,但 `.voice-btn:not(:disabled):hover`(3 个选择器)优先级更高。触屏点击后没有鼠标可以"移开",`:hover` 状态会一直粘在刚点过的按钮上不消失(触屏设备通病),于是 hover 规则的浅灰 `background` 把 `.on` 的绿色背景盖掉,只剩下没被 hover 碰到的 `border-color: var(--green)` 还在——视觉上就是一个孤零零的绿框。修复:hover 规则加 `:not(.on)` 排除已开启状态。
**排查方法论**:真机截图只能看到表象("好像是焦点框"),容易被误导。改用 CDP(Chrome DevTools Protocol)——启动时加 `--remote-debugging-port=9222`,写个不依赖 `ws` 包的最小原生 WebSocket 客户端(`net`+`crypto` 手搓握手帧)连上去,用 `Input.dispatchMouseEvent` 模拟真实点击、`Runtime.evaluate` 读点击后的 `getComputedStyle()`,直接看到 `background` 实际渲染值是 `--panel-3` 不是 `--green`,才一次性定位到真正被覆盖的是哪条规则。**以后排查"样式明明写了但没生效"类问题,CDP 读计算样式比盯着截图猜更可靠。**
### 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` 为兆芯集显无害日志,可忽略

129
README.md
View File

@ -1,129 +0,0 @@
# elec_rag_ui — 智能电子手册前端
基于 Electron + Vue3 + TypeScript + LanceDB 构建的本地化 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
- **UI**:Naive UI(弹窗/通知)+ 自定义工业风 CSS
- **状态管理**:Pinia
- **向量数据库**:LanceDB(嵌入式,无需额外服务)
- **本地向量化**:`@huggingface/transformers` + `onnxruntime-node`
- **生成引擎**:llama.cpp server(通过 er_backend 管理)
- **Markdown 渲染**:marked
## 支持硬件
| 计算卡 | 嵌入方式 | 生成方式 |
|--------|---------|---------|
| **ER160**(揽月 M50 NPU) | llamacpp NPU(默认)或本地 CPU | llamacpp |
| **ER25**(芯动力 RPP) | 本地 CPU(固定) | llamacpp |
推理请求(生成 + 嵌入)统一经 er_backend 代理转发(`http://127.0.0.1:17700`),实际 llama-server 监听哪个端口由 er_backend 后端配置决定,前端不感知、也不需要按计算卡型号推导端口。
## 目录结构
```
elec_rag_ui/
├── src/
│ ├── main/ # Electron 主进程
│ │ ├── index.ts # 入口:全屏启动、菜单禁用、信号处理
│ │ ├── config.ts # 配置读写(userData/config.json)
│ │ ├── ipc/ # IPC 处理器
│ │ └── rag/ # RAG 核心逻辑
│ ├── preload/index.ts # contextBridge
│ ├── renderer/ # Vue3 前端
│ │ └── src/
│ │ ├── App.vue # 根布局(顶栏 + 主区)
│ │ ├── components/ # ChatPanel / IndexPanel / CameraCapture / SettingsModal 等
│ │ ├── stores/ # Pinia store
│ │ ├── utils/ # TTS 播放器、音频采集、Markdown 处理
│ │ └── types/ # TypeScript 类型定义
│ └── batch_questions.txt # 批量测试问题示例
├── models/ # 本地模型(onnx 由 Git LFS 管理)
│ ├── bge-small-zh-v1.5/ # 嵌入模型(512 维,~24MB),本地 CPU 向量化回退用
│ └── asr/ # Paraformer 流式语音识别模型(~376MB)
├── docs/ # 示例文档(用于测试索引与问答)
├── resources/ # 应用图标、TTS/ASR worker
├── stubs/sharp/ # sharp 原生模块 stub
├── package.json
├── electron-builder.config.ts
└── electron.vite.config.ts
```
## 依赖:er_backend
本应用依赖 **er_backend**(端口 17700)管理 llama-server 推理服务进程。启动前请确保 er_backend 已运行:
```bash
# PAD 上
cd /opt/er-backend
nohup ./node/node manager/index.js &
# 或通过 systemd(deb 安装后自动配置)
systemctl status er-backend
```
er_backend 管理界面:`http://<PAD-IP>:17700`(配置模型路径、启动/停止服务)
## 启用拍照提问
需要两个条件同时满足:
1. **er_backend 加载的是多模态模型**——在管理界面把生成模型配成带 mmproj 的视觉模型(启动命令里应带 `--mmproj`)。应用会自动探测:不是多模态模型时拍照按钮置灰,换回多模态模型后 5 秒内自动恢复可用,不用重启应用。
2. **在「设置 → 基本设置 → 拍照提问」里打开开关**(默认关闭,因为不是所有部署都会配视觉模型)。
摄像头用标准 USB UVC 设备即可,无需额外驱动。有多个摄像头时,拍照弹层右上角可以切换。
## 模型文件
`models/` 目录中的 `.onnx` 文件通过 **Git LFS** 管理。克隆后拉取实体文件:
```bash
git lfs install
GIT_SSL_NO_VERIFY=true git lfs pull
```
> 语音识别默认不带自动加标点(标点恢复模型未随包分发);如需要可自行准备
> [sherpa-onnx punct 模型](https://k2-fsa.github.io/sherpa/onnx/punctuation/models.html),
> 在设置里把"标点恢复模型目录"指过去即可,代码本身已支持该功能。
## 开发模式
```bash
npm install
npm run dev:linux # Linux(--no-sandbox,开发专用)
```
> 开发模式下需要设置 `DISPLAY` 环境变量(有图形界面时自动可用)。
## 打包
```bash
npm run package:kylin # Linux x64,产出 AppImage + .deb
npm run package:kylin:arm64:deb # Linux ARM64 deb(推荐,须在 ARM64 机器/真机上执行,已验证可行)
npm run package:kylin:arm64 # Linux ARM64(AppImage + deb),真机 Kylin 上 deb 目标
# 走 electron-builder 自带 fpm 必现 tar failed,
# 用 package:kylin:arm64:deb 代替
```
## 数据存储
| 内容 | 路径 |
|------|------|
| 配置文件 | `~/.config/elec_rag_ui/config.json` |
| 向量数据库 | `~/.config/elec_rag_ui/lancedb/` |
## 许可证
MIT License

View File

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

View File

@ -26,24 +26,7 @@ const config: Configuration = {
buildResources: 'resources',
output: 'dist'
},
// resources/icon.png 单独列出:src/main/index.ts 用相对路径 __dirname/../../resources/icon.png
// 定位窗口图标(打包后 __dirname 是 <app>/out/main),必须让它落在 <app>/resources/ 下
//
// 排除项:这几个包只在 src/renderer/ 被引用,electron-vite build 时已经被 Vite
// 静态打包进 out/renderer/*.js,主进程运行时不会去 node_modules 里 require 它们;
// onnxruntime-web 是 @huggingface/transformers 的硬依赖但项目强制走 onnxruntime-node,
// 从未被使用。排除后单包体积减少约 227M(naive-ui/date-fns/highlight.js/lodash 系
// 渲染层已打包、onnxruntime-web 完全未用,详见排查记录)。
files: [
'out/**/*',
'resources/icon.png',
'!**/node_modules/onnxruntime-web/**',
'!**/node_modules/naive-ui/**',
'!**/node_modules/date-fns/**',
'!**/node_modules/highlight.js/**',
'!**/node_modules/lodash/**',
'!**/node_modules/lodash-es/**'
],
files: ['out/**/*'],
asarUnpack: [
'node_modules/@lancedb/**',
'node_modules/apache-arrow/**',
@ -57,7 +40,7 @@ const config: Configuration = {
? [{ from: process.env.LOCAL_MODEL_PATH, to: 'models/bge-small-zh-v1.5' }]
: []),
{
from: 'models/asr/sherpa-onnx-streaming-paraformer-bilingual-zh-en',
from: '../../models/asr/sherpa-onnx-streaming-paraformer-bilingual-zh-en',
to: 'models/asr/sherpa-onnx-streaming-paraformer-bilingual-zh-en'
},
// 内嵌离线 TTS 引擎(espeak-ng + 数据 + 依赖库),避免目标系统无 speech-dispatcher/espeak

View File

@ -0,0 +1,39 @@
{
"_name_or_path": "BAAI/bge-base-zh-v1.5",
"architectures": [
"BertModel"
],
"attention_probs_dropout_prob": 0.1,
"bos_token_id": 0,
"classifier_dropout": null,
"directionality": "bidi",
"eos_token_id": 2,
"hidden_act": "gelu",
"hidden_dropout_prob": 0.1,
"hidden_size": 768,
"id2label": {
"0": "LABEL_0"
},
"initializer_range": 0.02,
"intermediate_size": 3072,
"label2id": {
"LABEL_0": 0
},
"layer_norm_eps": 1e-12,
"max_position_embeddings": 512,
"model_type": "bert",
"num_attention_heads": 12,
"num_hidden_layers": 12,
"output_past": true,
"pad_token_id": 0,
"pooler_fc_size": 768,
"pooler_num_attention_heads": 12,
"pooler_num_fc_layers": 3,
"pooler_size_per_head": 128,
"pooler_type": "first_token_transform",
"position_embedding_type": "absolute",
"transformers_version": "4.34.0.dev0",
"type_vocab_size": 2,
"use_cache": true,
"vocab_size": 21128
}

View File

@ -0,0 +1,7 @@
{
"cls_token": "[CLS]",
"mask_token": "[MASK]",
"pad_token": "[PAD]",
"sep_token": "[SEP]",
"unk_token": "[UNK]"
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,15 @@
{
"clean_up_tokenization_spaces": true,
"cls_token": "[CLS]",
"do_basic_tokenize": true,
"do_lower_case": true,
"mask_token": "[MASK]",
"model_max_length": 512,
"never_split": null,
"pad_token": "[PAD]",
"sep_token": "[SEP]",
"strip_accents": null,
"tokenize_chinese_chars": true,
"tokenizer_class": "BertTokenizer",
"unk_token": "[UNK]"
}

View File

@ -0,0 +1,39 @@
{
"_name_or_path": "BAAI/bge-large-zh-v1.5",
"architectures": [
"BertModel"
],
"attention_probs_dropout_prob": 0.1,
"bos_token_id": 0,
"classifier_dropout": null,
"directionality": "bidi",
"eos_token_id": 2,
"hidden_act": "gelu",
"hidden_dropout_prob": 0.1,
"hidden_size": 1024,
"id2label": {
"0": "LABEL_0"
},
"initializer_range": 0.02,
"intermediate_size": 4096,
"label2id": {
"LABEL_0": 0
},
"layer_norm_eps": 1e-12,
"max_position_embeddings": 512,
"model_type": "bert",
"num_attention_heads": 16,
"num_hidden_layers": 24,
"output_past": true,
"pad_token_id": 0,
"pooler_fc_size": 768,
"pooler_num_attention_heads": 12,
"pooler_num_fc_layers": 3,
"pooler_size_per_head": 128,
"pooler_type": "first_token_transform",
"position_embedding_type": "absolute",
"transformers_version": "4.34.0.dev0",
"type_vocab_size": 2,
"use_cache": true,
"vocab_size": 21128
}

View File

@ -0,0 +1,7 @@
{
"cls_token": "[CLS]",
"mask_token": "[MASK]",
"pad_token": "[PAD]",
"sep_token": "[SEP]",
"unk_token": "[UNK]"
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,15 @@
{
"clean_up_tokenization_spaces": true,
"cls_token": "[CLS]",
"do_basic_tokenize": true,
"do_lower_case": true,
"mask_token": "[MASK]",
"model_max_length": 1000000000000000019884624838656,
"never_split": null,
"pad_token": "[PAD]",
"sep_token": "[SEP]",
"strip_accents": null,
"tokenize_chinese_chars": true,
"tokenizer_class": "BertTokenizer",
"unk_token": "[UNK]"
}

View File

@ -0,0 +1,56 @@
# Introduction
This model is converted from
https://modelscope.cn/models/iic/punc_ct-transformer_zh-cn-common-vocab272727-pytorch/summary
The commands to generate the onnx model are given below:
```
pip install funasr modelscope
pip install kaldi-native-fbank torchaudio onnx onnxruntime
mkdir -p /tmp/models
cd /tmp/models
git clone https://www.modelscope.cn/iic/punc_ct-transformer_zh-cn-common-vocab272727-pytorch.git
cd punc_ct-transformer_zh-cn-common-vocab272727-pytorch
git lfs pull --include model.pt
cd /tmp
git clone https://github.com/alibaba-damo-academy/FunASR
cd FunASR/runtime/python/onnxruntime
cat >export-onnx.py <<EOF
from funasr_onnx import CT_Transformer
model_dir = "/tmp/punc_ct-transformer_zh-cn-common-vocab272727-pytorch" # model = CT_Transformer(model_dir, quantize=True) model = CT_Transformer(model_dir)
EOF
chmod +x export-onnx.py
./export-onnx.py
```
You will find the exported
`model.onnx` file inside
`/tmp/models/punc_ct-transformer_zh-cn-common-vocab272727-pytorch`.
Now you can use ./add-model-metadata.py in this repo to add metadata to the generated
`model.onnx`.
You can use
```
from onnxruntime.quantization import QuantType, quantize_dynamic
quantize_dynamic(
model_input="./model.onnx",
model_output="./model.int8.onnx",
weight_type=QuantType.QUInt8,
)
```
to get the `int8` quantized model
```
-rw-r--r-- 1 fangjun staff 72M Jun 18 10:33 model.int8.onnx
-rw-r--r-- 1 fangjun staff 281M Apr 12 2024 model.onnx
```

View File

@ -0,0 +1,46 @@
model: CTTransformer
model_conf:
ignore_id: 0
embed_unit: 256
att_unit: 256
dropout_rate: 0.1
punc_list:
- <unk>
- _
- ,
- 。
- ?
- 、
punc_weight:
- 1.0
- 1.0
- 1.0
- 1.0
- 1.0
- 1.0
sentence_end_id: 3
encoder: SANMEncoder
encoder_conf:
input_size: 256
output_size: 256
attention_heads: 8
linear_units: 1024
num_blocks: 4
dropout_rate: 0.1
positional_dropout_rate: 0.1
attention_dropout_rate: 0.0
input_layer: pe
pos_enc_class: SinusoidalPositionEncoder
normalize_before: true
kernel_size: 11
sanm_shfit: 0
selfattention_layer_type: sanm
padding_idx: 0
tokenizer: CharTokenizer
tokenizer_conf:
unk_symbol: <unk>

View File

@ -14,8 +14,7 @@
"build": "vue-tsc --noEmit && electron-vite build",
"preview": "electron-vite preview",
"package:kylin": "electron-vite build && cp resources/asr-worker.cjs out/main/asr-worker.cjs && ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-builder --config electron-builder.config.ts --linux --x64",
"package:kylin:arm64": "electron-vite build && cp resources/asr-worker.cjs out/main/asr-worker.cjs && USE_SYSTEM_FPM=true ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-builder --config electron-builder.config.ts --linux deb AppImage --arm64",
"package:kylin:arm64:deb": "bash scripts/package-kylin-arm64-deb.sh"
"package:kylin:arm64": "electron-vite build && cp resources/asr-worker.cjs out/main/asr-worker.cjs && USE_SYSTEM_FPM=true ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ electron-builder --config electron-builder.config.ts --linux deb AppImage --arm64"
},
"workspaces": ["stubs/sharp"],
"dependencies": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 343 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,487 @@
{
"audio": {
"sample_rate": 22050,
"quality": "medium"
},
"espeak": {
"voice": "cmn"
},
"inference": {
"noise_scale": 0.667,
"length_scale": 1,
"noise_w": 0.8
},
"phoneme_type": "espeak",
"phoneme_map": {},
"phoneme_id_map": {
"_": [
0
],
"^": [
1
],
"$": [
2
],
" ": [
3
],
"!": [
4
],
"'": [
5
],
"(": [
6
],
")": [
7
],
",": [
8
],
"-": [
9
],
".": [
10
],
":": [
11
],
";": [
12
],
"?": [
13
],
"a": [
14
],
"b": [
15
],
"c": [
16
],
"d": [
17
],
"e": [
18
],
"f": [
19
],
"h": [
20
],
"i": [
21
],
"j": [
22
],
"k": [
23
],
"l": [
24
],
"m": [
25
],
"n": [
26
],
"o": [
27
],
"p": [
28
],
"q": [
29
],
"r": [
30
],
"s": [
31
],
"t": [
32
],
"u": [
33
],
"v": [
34
],
"w": [
35
],
"x": [
36
],
"y": [
37
],
"z": [
38
],
"æ": [
39
],
"ç": [
40
],
"ð": [
41
],
"ø": [
42
],
"ħ": [
43
],
"ŋ": [
44
],
"œ": [
45
],
"ǀ": [
46
],
"ǁ": [
47
],
"ǂ": [
48
],
"ǃ": [
49
],
"ɐ": [
50
],
"ɑ": [
51
],
"ɒ": [
52
],
"ɓ": [
53
],
"ɔ": [
54
],
"ɕ": [
55
],
"ɖ": [
56
],
"ɗ": [
57
],
"ɘ": [
58
],
"ə": [
59
],
"ɚ": [
60
],
"ɛ": [
61
],
"ɜ": [
62
],
"ɞ": [
63
],
"ɟ": [
64
],
"ɠ": [
65
],
"ɡ": [
66
],
"ɢ": [
67
],
"ɣ": [
68
],
"ɤ": [
69
],
"ɥ": [
70
],
"ɦ": [
71
],
"ɧ": [
72
],
"ɨ": [
73
],
"ɪ": [
74
],
"ɫ": [
75
],
"ɬ": [
76
],
"ɭ": [
77
],
"ɮ": [
78
],
"ɯ": [
79
],
"ɰ": [
80
],
"ɱ": [
81
],
"ɲ": [
82
],
"ɳ": [
83
],
"ɴ": [
84
],
"ɵ": [
85
],
"ɶ": [
86
],
"ɸ": [
87
],
"ɹ": [
88
],
"ɺ": [
89
],
"ɻ": [
90
],
"ɽ": [
91
],
"ɾ": [
92
],
"ʀ": [
93
],
"ʁ": [
94
],
"ʂ": [
95
],
"ʃ": [
96
],
"ʄ": [
97
],
"ʈ": [
98
],
"ʉ": [
99
],
"ʊ": [
100
],
"ʋ": [
101
],
"ʌ": [
102
],
"ʍ": [
103
],
"ʎ": [
104
],
"ʏ": [
105
],
"ʐ": [
106
],
"ʑ": [
107
],
"ʒ": [
108
],
"ʔ": [
109
],
"ʕ": [
110
],
"ʘ": [
111
],
"ʙ": [
112
],
"ʛ": [
113
],
"ʜ": [
114
],
"ʝ": [
115
],
"ʟ": [
116
],
"ʡ": [
117
],
"ʢ": [
118
],
"ʲ": [
119
],
"ˈ": [
120
],
"ˌ": [
121
],
"ː": [
122
],
"ˑ": [
123
],
"˞": [
124
],
"β": [
125
],
"θ": [
126
],
"χ": [
127
],
"ᵻ": [
128
],
"ⱱ": [
129
],
"0": [
130
],
"1": [
131
],
"2": [
132
],
"3": [
133
],
"4": [
134
],
"5": [
135
],
"6": [
136
],
"7": [
137
],
"8": [
138
],
"9": [
139
],
"̧": [
140
],
"̃": [
141
],
"̪": [
142
],
"̯": [
143
],
"̩": [
144
],
"ʰ": [
145
],
"ˤ": [
146
],
"ε": [
147
],
"↓": [
148
],
"#": [
149
],
"\"": [
150
],
"↑": [
151
]
},
"num_symbols": 256,
"num_speakers": 1,
"speaker_id_map": {},
"piper_version": "1.0.0",
"language": {
"code": "zh_CN",
"family": "zh",
"region": "CN",
"name_native": "简体中文",
"name_english": "Chinese",
"country_english": "China"
},
"dataset": "huayan"
}

BIN
resources/tts/piper/piper Executable file

Binary file not shown.

Binary file not shown.

View File

@ -1,137 +0,0 @@
#!/bin/bash
# 在 Kylin V10 (RK3588, arm64) 上构建 elec_rag_ui 的 deb 安装包。
#
# 背景:electron-builder 自带的 fpm 打包路径在这类 arm64 Kylin 板子上有已知 bug——
# fpm 内部调用 tar 归档 data.tar.gz 时报 "tar failed (exit code 2)",无法通过
# `electron-builder --linux deb` 直接产出 deb。此脚本改用 electron-builder 的
# `dir` target 先产出 unpacked 应用目录,再手工拼装 DEBIAN 控制文件用 dpkg-deb 打包,
# 这条路径已在真机上验证可行。
#
# 必须在 arm64 Linux(如目标 pad 本身)上执行,依赖 dpkg-deb。
#
# 用法:bash scripts/package-kylin-arm64-deb.sh
set -e
cd "$(dirname "$0")/.."
if ! command -v dpkg-deb >/dev/null 2>&1; then
echo "错误:找不到 dpkg-deb,本脚本只能在 Debian/Kylin 系 arm64 Linux 上运行" >&2
exit 1
fi
if [ "$(uname -m)" != "aarch64" ]; then
echo "警告:当前架构不是 aarch64(实测为 $(uname -m)),继续执行但产物大概率无法在 arm64 pad 上运行" >&2
fi
# 版本号解析:优先 git tag,其次 .app-version(rsync 到无 .git 的 pad 时使用),
# 最后回退 package.json 里的版本号。与 electron-builder.config.ts / electron.vite.config.ts
# 的解析逻辑保持一致,确保三处版本号(deb 文件名、界面显示、控制文件)永远一致。
VERSION=$(git describe --tags --abbrev=0 2>/dev/null | sed 's/^v//') || true
if [ -z "$VERSION" ] && [ -f .app-version ]; then
VERSION=$(cat .app-version | tr -d '[:space:]' | sed 's/^v//')
fi
if [ -z "$VERSION" ]; then
VERSION=$(node -p "require('./package.json').version")
fi
echo "=== 打包版本号: $VERSION ==="
PRODUCT_NAME="智能电子手册"
EXE_NAME="elec_rag_ui"
PKG_NAME="elec-rag-ui"
APP_DIR="dist/linux-arm64-unpacked"
STAGING="dist/deb-staging"
OUT="dist/${PKG_NAME}_${VERSION}_arm64.deb"
echo "=== 1/4 electron-vite build ==="
npx electron-vite build
cp resources/asr-worker.cjs out/main/asr-worker.cjs
echo "=== 2/4 electron-builder(仅 dir target,跳过 fpm)==="
ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/ \
npx electron-builder --config electron-builder.config.ts --linux dir --arm64
if [ ! -d "$APP_DIR" ]; then
echo "错误:electron-builder 没有产出 $APP_DIR" >&2
exit 1
fi
echo "=== 3/4 手工拼装 deb 结构 ==="
rm -rf "$STAGING"
mkdir -p "$STAGING/DEBIAN" \
"$STAGING/opt/$PRODUCT_NAME" \
"$STAGING/usr/bin" \
"$STAGING/usr/share/applications" \
"$STAGING/usr/share/pixmaps"
cp -a "$APP_DIR/." "$STAGING/opt/$PRODUCT_NAME/"
# chrome-sandbox 必须是 setuid root,否则 Electron 沙箱初始化失败,
# 启动即崩溃报 "Failed sending zygote boot message: 断开的管道"
chmod 4755 "$STAGING/opt/$PRODUCT_NAME/chrome-sandbox"
# 只装 1024x1024 会导致部分桌面菜单(如 UKUI)在按常规尺寸(48x48 等)查找图标时
# 找不到、直接回退成缺省图标,而不是从 1024 缩放——所以要把常规尺寸全部装齐
for size in 16 24 32 48 64 128 256 512 1024; do
mkdir -p "$STAGING/usr/share/icons/hicolor/${size}x${size}/apps"
if [ "$size" = "1024" ]; then
cp resources/icon.png "$STAGING/usr/share/icons/hicolor/1024x1024/apps/$PKG_NAME.png"
else
cp "resources/icons/${size}x${size}.png" "$STAGING/usr/share/icons/hicolor/${size}x${size}/apps/$PKG_NAME.png"
fi
done
# 兼容不认 icon 主题、直接读 pixmaps 的老式菜单实现
cp resources/icons/48x48.png "$STAGING/usr/share/pixmaps/$PKG_NAME.png"
cat > "$STAGING/usr/share/applications/$PKG_NAME.desktop" <<EOF
[Desktop Entry]
Name=$PRODUCT_NAME
Exec=/opt/$PRODUCT_NAME/$EXE_NAME %U
Terminal=false
Type=Application
Icon=$PKG_NAME
StartupWMClass=$PRODUCT_NAME
Comment=RAG 知识库查询桌面应用
Categories=Utility;
EOF
INSTALLED_SIZE=$(du -sk "$STAGING/opt" | cut -f1)
cat > "$STAGING/DEBIAN/control" <<EOF
Package: $PKG_NAME
Version: $VERSION
Section: utils
Priority: optional
Architecture: arm64
Installed-Size: $INSTALLED_SIZE
Depends: libgtk-3-0, libnotify4, libnss3, libxss1, libxtst6, xdg-utils, libatspi2.0-0, libuuid1, libsecret-1-0
Recommends: libappindicator3-1
Maintainer: EdgeRhythm <ssurui@gmail.com>
Homepage: https://gitee.com/EdgeRhythm/elec_rag_ui
Description: 本地化 RAG 问答桌面前端
EOF
cat > "$STAGING/DEBIAN/postinst" <<EOF
#!/bin/sh
set -e
ln -sf "/opt/$PRODUCT_NAME/$EXE_NAME" /usr/bin/$PKG_NAME
if [ -x /usr/bin/update-desktop-database ]; then
update-desktop-database /usr/share/applications >/dev/null 2>&1 || true
fi
if [ -x /usr/bin/gtk-update-icon-cache ]; then
gtk-update-icon-cache -q /usr/share/icons/hicolor >/dev/null 2>&1 || true
fi
EOF
cat > "$STAGING/DEBIAN/postrm" <<EOF
#!/bin/sh
set -e
rm -f /usr/bin/$PKG_NAME
EOF
chmod 755 "$STAGING/DEBIAN/postinst" "$STAGING/DEBIAN/postrm"
echo "=== 4/4 dpkg-deb --build ==="
dpkg-deb --root-owner-group -Zgzip --build "$STAGING" "$OUT"
echo "=== 完成: $OUT ==="
ls -la "$OUT"
dpkg-deb -I "$OUT" | head -20

View File

@ -2,14 +2,11 @@ import { app } from 'electron'
import { join } from 'path'
import { existsSync, readFileSync, writeFileSync } from 'fs'
// 推理请求统一走 er_backend 代理(管理面端口,/v1/* 由后端转发到其实际管理的 llama-server)
export const ER_BACKEND_URL = 'http://127.0.0.1:17700'
export interface AppConfig {
embed: {
provider: 'local' | 'llamacpp'
localModelPath: string
baseUrl: string // 由 applyPlatformOverrides 固定为 er_backend 代理地址,不对用户暴露
baseUrl: string // 由 applyPlatformOverrides 自动推导,不对用户暴露
}
rag: {
chunkSize: number
@ -17,7 +14,6 @@ export interface AppConfig {
topK: number
systemPrompt: string
}
// 服务的启动参数/端口由 er_backend 统一管理,以下字段仅为历史配置保留,前端不再用于路由
llamacppServer: {
executablePath: string
modelPath: string
@ -58,17 +54,13 @@ export interface AppConfig {
volume: number
}
}
vision: {
enabled: boolean // 拍照提问入口总开关,默认关闭(依赖 er_backend 是否加载了支持视觉的模型)
cameraDeviceId: string // 上次选用的摄像头 deviceId,空字符串 = 使用第一个可用设备
}
}
export const DEFAULT_CONFIG: AppConfig = {
embed: {
provider: 'llamacpp',
localModelPath: '',
baseUrl: ER_BACKEND_URL
baseUrl: 'http://127.0.0.1:17701'
},
llamacppServer: {
executablePath: '',
@ -110,10 +102,6 @@ export const DEFAULT_CONFIG: AppConfig = {
volume: 1
}
},
vision: {
enabled: false,
cameraDeviceId: ''
},
rag: {
chunkSize: 480,
chunkOverlap: 0,
@ -122,9 +110,7 @@ export const DEFAULT_CONFIG: AppConfig = {
你的任务是:
- 只能依据用户提供的上下文内容进行回答;
- 每个答案中如果有信息来源于上下文,必须标注对应的引用标号(例如:[1],也可以是多个标号,例如:[1][3]);
- 如果上下文中没有足够的信息回答问题,不要编造内容,也不要生硬地只回一句"我不知道"。
请用一两句自然、平和的话说明手册中暂未查到相关内容,并给出一条简短、可操作的建议,
例如请对方补充车型/年款、具体部件名称,或把故障现象描述得更具体,以便更准确地定位;
- 如果问题的信息在上下文中找不到,请直接回复:"我不知道。"
- 不得使用自己的知识库或常识来回答问题;
- 回答应简洁明了,不添加额外解释或发挥。
@ -208,10 +194,6 @@ export function getConfig(): AppConfig {
rate: typeof raw.voice?.tts?.rate === 'number' ? raw.voice.tts.rate : DEFAULT_CONFIG.voice.tts.rate,
volume: typeof raw.voice?.tts?.volume === 'number' ? raw.voice.tts.volume : DEFAULT_CONFIG.voice.tts.volume
}
},
vision: {
enabled: raw.vision?.enabled === true,
cameraDeviceId: typeof raw.vision?.cameraDeviceId === 'string' ? raw.vision.cameraDeviceId : ''
}
}
applyPlatformOverrides(config)
@ -224,18 +206,19 @@ export function getConfig(): AppConfig {
}
// 按计算卡型号覆盖 embed provider 和默认启动参数
// 推理请求统一走 er_backend 代理(ER_BACKEND_URL),服务端口以后端配置为准,前端不再维护
function applyPlatformOverrides(config: AppConfig): void {
const card = config.hardware.cardType
if (card === 'er25') {
// ER25:嵌入固定本地 CPU,无嵌入服务进程
config.embed.provider = 'local'
config.llamacppServer.port = 8080
if (!config.llamacppServer.extraArgs.trim()) {
config.llamacppServer.extraArgs = '--port 8080 --ctk bf16 --ctv bf16 --no-warmup -ub 128'
}
} else {
// ER160:嵌入默认 llamacpp NPU(local 保持不变)
if (config.embed.provider !== 'local') config.embed.provider = 'llamacpp'
config.llamacppServer.port = 8081
if (!config.llamacppServer.extraArgs.trim()) {
config.llamacppServer.extraArgs = '--port 8081 --presence_penalty 1.5'
}
@ -243,8 +226,8 @@ function applyPlatformOverrides(config: AppConfig): void {
config.llamacppEmbed.extraArgs = DEFAULT_CONFIG.llamacppEmbed.extraArgs
}
}
// 嵌入/生成服务地址固定为 er_backend 代理
config.embed.baseUrl = ER_BACKEND_URL
// 嵌入服务地址跟随端口
config.embed.baseUrl = `http://127.0.0.1:${config.llamacppEmbed.port}`
}
export function saveConfig(config: AppConfig): void {

View File

@ -80,8 +80,7 @@ function getMimeType(filePath: string): string {
}
app.whenReady().then(() => {
// 允许渲染进程请求麦克风权限(语音输入所需);Electron 的 getUserMedia 摄像头请求
// (拍照提问用)在权限系统里也归为 'media',跟麦克风共用同一个判断,不需要单独加 'camera'
// 允许渲染进程请求麦克风权限(语音输入所需)
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
const allowed = permission === 'media' || permission === 'microphone'
console.log(`[permission] ${permission} -> ${allowed ? 'granted' : 'denied'}`)

View File

@ -1,6 +1,7 @@
import { ipcMain } from 'electron'
import { execFile } from 'child_process'
import { ER_BACKEND_URL } from '../config'
const ER_BACKEND_URL = 'http://localhost:17700'
async function isRunning(): Promise<boolean> {
try {

View File

@ -1,6 +1,5 @@
import { registerIndexHandlers } from './indexHandler'
import { registerQueryHandlers } from './queryHandler'
import { registerVisionHandlers } from './visionHandler'
import { registerStatusHandlers } from './statusHandler'
import { registerAsrHandlers } from './asrHandler'
import { registerTtsHandlers } from './ttsHandler'
@ -12,7 +11,6 @@ import type { BrowserWindow } from 'electron'
export function registerIpcHandlers(getWin: () => BrowserWindow | null): void {
registerIndexHandlers(getWin)
registerQueryHandlers(getWin)
registerVisionHandlers(getWin)
registerStatusHandlers(getWin)
registerAsrHandlers(getWin)
registerTtsHandlers(getWin)

View File

@ -16,7 +16,7 @@ export function registerQueryHandlers(getWin: () => BrowserWindow | null): void
console.log(`\n${'='.repeat(60)}`)
console.log('[query] 问题:', payload.question)
console.log('[query] topK:', payload.topK)
console.log('[query] 向量化方式:', config.embed.provider, '生成/嵌入服务:er_backend 代理(127.0.0.1:17700)')
console.log('[query] 向量化方式:', config.embed.provider, '生成服务端口:', config.llamacppServer.port)
const send = (channel: string, data: unknown): void => {
getWin()?.webContents.send(channel, data)
@ -40,12 +40,7 @@ export function registerQueryHandlers(getWin: () => BrowserWindow | null): void
send('query:stage', { stage: '正在生成...' })
if (chunks.length === 0) {
// 走到这里说明知识库里一条记录都没有(search 是全表余弦排序后截断,
// 只要有文档就一定会返回结果),是"还没入库"而不是"没找到相关内容",
// 提示要引导去导入文档,不能只回一句"我不知道"让人以为是模型答不上来
send('query:delta', {
delta: '知识库中还没有任何文档,暂时无法回答。请先在「设置 → 文档入库」中导入手册,再来提问。'
})
send('query:delta', { delta: '我不知道。' })
send('query:done', {})
return
}
@ -55,6 +50,7 @@ export function registerQueryHandlers(getWin: () => BrowserWindow | null): void
payload.question,
chunks,
payload.systemPrompt,
config.llamacppServer,
{
onDelta: (delta) => send('query:delta', { delta }),
onDone: (stats) => send('query:done', { stats: stats ?? null }),

View File

@ -2,7 +2,7 @@ import { ipcMain, dialog } from 'electron'
import type { BrowserWindow } from 'electron'
import { existsSync, readFileSync, writeFileSync } from 'fs'
import { join } from 'path'
import { getConfig, saveConfig, getDbPath, AppConfig, ER_BACKEND_URL } from '../config'
import { getConfig, saveConfig, getDbPath, AppConfig } from '../config'
import { getDocCount } from '../db/lancedb'
import { embedOneLocal, resolveLocalModelPath } from '../rag/localEmbedder'
import { syncNpuPolling } from './npuHandler'
@ -100,12 +100,11 @@ export function registerStatusHandlers(getWin: () => BrowserWindow | null): void
}
})
// 生成服务连接测试(经 er_backend 代理转发到 llama-server)
ipcMain.handle('chat-test', async (_event, _llamacppConfig: AppConfig['llamacppServer']): Promise<TestResult> => {
ipcMain.handle('chat-test', async (_event, llamacppConfig: AppConfig['llamacppServer']): Promise<TestResult> => {
const start = Date.now()
const elapsed = (): string => `${Date.now() - start}ms`
try {
const res = await fetch(`${ER_BACKEND_URL}/v1/chat/completions`, {
const res = await fetch(`http://127.0.0.1:${llamacppConfig.port}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({

View File

@ -1,68 +0,0 @@
import { ipcMain, BrowserWindow } from 'electron'
import { getConfig, getDbPath } from '../config'
import { embedOne } from '../rag/embedder'
import { search } from '../db/lancedb'
import { describeImage, streamGenerate } from '../rag/generator'
// 拍照提问:先用视觉模型快速生成一句图像描述(不查库),描述+用户问题拼接去检索本地
// 知识库,再用用户原始问题(没打字就用描述本身)+ 检索到的上下文做纯文字生成——
// 跟 queryHandler.ts 的文字问答共用 embedOne/search/streamGenerate,只是多了第①步。
export function registerVisionHandlers(getWin: () => BrowserWindow | null): void {
ipcMain.handle('vision-query', async (_event, payload: {
question: string
imageDataUrl: string
}) => {
const config = getConfig()
const dbPath = getDbPath()
console.log(`\n${'='.repeat(60)}`)
console.log('[vision-query] 用户问题:', payload.question || '(未输入)')
const send = (channel: string, data: unknown): void => {
getWin()?.webContents.send(channel, data)
}
try {
// ① 图像理解:固定提示词,快速拿一句话描述
send('vision-query:stage', { stage: '正在识别图片...' })
const caption = await describeImage(payload.imageDataUrl)
send('vision-query:caption', { caption })
// ② 检索:描述 + 用户问题拼接,扩大召回面
const retrievalQuery = payload.question ? `${caption}\n${payload.question}` : caption
send('vision-query:stage', { stage: '正在检索...' })
const queryVector = await embedOne(retrievalQuery, config.embed)
const chunks = await search(dbPath, queryVector, config.rag.topK)
console.log(`[vision-query] 检索到 ${chunks.length} 个片段:`)
chunks.forEach((c, i) => {
console.log(` 片段${i + 1} 相关度=${c.score} 来源=${c.source}`)
})
send('vision-query:context', { chunks })
if (chunks.length === 0) {
// 同 queryHandler:这里只可能是知识库为空,不是"没检索到相关内容"
send('vision-query:delta', {
delta: '知识库中还没有任何文档,暂时无法结合手册回答。请先在「设置 → 文档入库」中导入手册,再来提问。'
})
send('vision-query:done', {})
return
}
// ③ 生成:用用户原始问题保持聚焦,没打字就用描述本身
const genQuestion = payload.question || caption
send('vision-query:stage', { stage: '正在生成...' })
await streamGenerate(
genQuestion,
chunks,
config.rag.systemPrompt,
{
onDelta: (delta) => send('vision-query:delta', { delta }),
onDone: (stats) => send('vision-query:done', { stats: stats ?? null }),
onError: (message) => send('vision-query:error', { message })
}
)
} catch (e) {
send('vision-query:error', { message: String(e) })
}
})
}

View File

@ -1,5 +1,5 @@
import type { SearchResult } from '../db/lancedb'
import { ER_BACKEND_URL } from '../config'
import type { AppConfig } from '../config'
export interface GeneratorStats {
tokensPerSecond: number
@ -14,59 +14,12 @@ export interface GeneratorCallbacks {
onError: (message: string) => void
}
// OpenAI 视觉 API 的 content-part 格式,multimodal 模型(如 Qwen-VL + mmproj)用这种数组形式
// 代替纯字符串 content 来同时携带文字与图片
type ContentPart =
| { type: 'text'; text: string }
| { type: 'image_url'; image_url: { url: string } }
interface ChatMessage {
role: 'system' | 'user'
content: string | ContentPart[]
}
// 每次请求对模型来说都是无状态的,它并不知道"上一次说了什么",所以只在系统提示词里
// 写"每次换一种措辞"是无效的——实测同样的输入连发三次,答复一字不差。这里改为每次请求
// 随机挑一条措辞引导,让输入本身产生差异,答复自然就不会千篇一律。
//
// 之所以不用调高 temperature 来制造随机性:那会同时影响命中文档的正常回答,而维修手册
// 问答更看重稳定准确。
//
// 踩过的坑:第一版把引导语整句追加在系统提示词最末尾(紧邻用户问题,模型注意力权重最高
// 的位置),结果它从"仅在查不到内容时才用"的次要条件,变成了一条独立显眼的指令——真机
// 复测发现同一个检索到充分相关内容的问题,连续追问 3 次,有 1 次被模型硬套上了"手册里
// 未查到…"的开场白,即使后面还是照常给出了详细步骤,答案读起来却像没查到。原因是这条
// 指令脱离了它所属的条件分句、又处于 prompt 末尾最显眼的位置,模型会"找机会用上它"。
//
// 现在改为嵌入到原本那条"如果没有足够信息"分句的内部(MARKER 之后、下一条分句之前),
// 并把措辞明确改成"仅当确实需要使用这句话时"——位置和从属语气都跟原本那句不带随机性的
// 版本一致,只是具体措辞有随机性。真机复测:同一个问题连跑 5 次全部正常作答,真正查不到
// 内容的问题也依然能正确触发(且带上变化后的措辞)。
const NO_MATCH_TONE_HINTS = [
'(仅当确实需要使用这句话时,才以"很抱歉"起头组织这句话。)',
'(仅当确实需要使用这句话时,才以"手册里"起头组织这句话。)',
'(仅当确实需要使用这句话时,才以"目前检索到的资料"起头组织这句话。)',
'(仅当确实需要使用这句话时,才以"这部分内容"起头组织这句话。)',
'(仅当确实需要使用这句话时,才用两句话表达:先说明情况,再给一条补充信息的建议。)',
'(仅当确实需要使用这句话时,才先简短致歉,再说明可以如何把问题描述得更具体。)'
]
// 必须跟 config.ts 里 DEFAULT_CONFIG.rag.systemPrompt 那条"没有足够信息"分句的结尾保持一致,
// 用于定位插入点。用户在设置页改写过这句话、不含这个标记时,原样返回不插入——
// 不强行拼接导致语义错位,顶多是少一点措辞变化,不会把提示词弄坏。
const NO_MATCH_BULLET_MARKER = '以便更准确地定位;'
function withToneHint(systemPrompt: string): string {
if (!systemPrompt.includes(NO_MATCH_BULLET_MARKER)) return systemPrompt
const hint = NO_MATCH_TONE_HINTS[Math.floor(Math.random() * NO_MATCH_TONE_HINTS.length)]
return systemPrompt.replace(NO_MATCH_BULLET_MARKER, `${NO_MATCH_BULLET_MARKER}\n ${hint}`)
}
// 生成服务经 er_backend 代理访问其管理的 llama-server(OpenAI 兼容接口,SSE 流式)
// 生成服务固定使用 llamacpp(ER160/ER25 均通过 er_backend 管理的 llama-server)
export async function streamGenerate(
question: string,
chunks: SearchResult[],
systemPrompt: string,
llamacppConfig: AppConfig['llamacppServer'],
callbacks: GeneratorCallbacks
): Promise<void> {
const context = chunks
@ -79,72 +32,23 @@ ${context}
请回答以下问题:${question}`
const finalSystemPrompt = withToneHint(systemPrompt)
console.log('\n[generator] ---- 发送给 LLM 的完整消息 ----')
console.log(`[generator] 服务地址:${ER_BACKEND_URL}(er_backend 代理)`)
console.log('[generator] system prompt:\n', finalSystemPrompt)
console.log(`[generator] 服务地址:http://127.0.0.1:${llamacppConfig.port}`)
console.log('[generator] system prompt:\n', systemPrompt)
console.log('[generator] user message:\n', userMessage)
console.log('[generator] ---- 消息结束 ----\n')
await streamChatCompletion(
[
{ role: 'system', content: finalSystemPrompt },
{ role: 'user', content: userMessage }
],
callbacks
)
}
// 拍照提问第一步:用固定的默认提示词(不是用户问题)让视觉模型快速给一句话描述,
// 用于后续拼检索查询。单次非流式请求——只要一句简短结果,不需要逐字显示,
// 也不需要每次都重新走一遍长回答的 SSE 解析。
const IMAGE_DESCRIBE_PROMPT =
'请用一句话简要描述这张图片中的部件、故障现象或异常情况,不要展开解释、不要客套。'
export async function describeImage(imageDataUrl: string): Promise<string> {
const baseUrl = ER_BACKEND_URL
const messages: ChatMessage[] = [
{
role: 'user',
content: [
{ type: 'text', text: IMAGE_DESCRIBE_PROMPT },
{ type: 'image_url', image_url: { url: imageDataUrl } }
]
}
]
console.log('\n[generator] ---- 图像理解(拍照提问第①步)----')
console.log(`[generator] 服务地址:${baseUrl}(er_backend 代理)`)
const start = Date.now()
let response: Response
try {
response = await fetch(`${baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'default', messages, stream: false })
})
} catch (e) {
throw new Error(`无法连接视觉理解服务(${baseUrl},er_backend 代理):${String(e)}`)
}
if (!response.ok) {
throw new Error(`图像理解请求失败:${response.status}`)
}
const data = await response.json()
const caption: string = data.choices?.[0]?.message?.content?.trim() ?? ''
console.log(`[generator] 图像描述(耗时 ${((Date.now() - start) / 1000).toFixed(1)}s):${caption}`)
return caption
await streamGenerateLlamaCpp(userMessage, systemPrompt, llamacppConfig, callbacks)
}
// llama.cpp server OpenAI 兼容接口(/v1/chat/completions),SSE 格式
async function streamChatCompletion(
messages: ChatMessage[],
async function streamGenerateLlamaCpp(
userMessage: string,
systemPrompt: string,
llamacppConfig: AppConfig['llamacppServer'],
callbacks: GeneratorCallbacks
): Promise<void> {
const baseUrl = ER_BACKEND_URL
const baseUrl = `http://127.0.0.1:${llamacppConfig.port}`
let fullResponse = ''
let thinkOpen = false
const requestSentTime = Date.now()
@ -158,18 +62,21 @@ async function streamChatCompletion(
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'default',
messages,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage }
],
stream: true,
stream_options: { include_usage: true }
})
})
} catch (e) {
callbacks.onError(`无法连接生成服务(${baseUrl},er_backend 代理):${String(e)}`)
callbacks.onError(`无法连接 llama.cpp server(${baseUrl}):${String(e)}`)
return
}
if (!response.ok) {
callbacks.onError(`生成服务请求失败:${response.status}`)
callbacks.onError(`llama.cpp server 请求失败:${response.status}`)
return
}

View File

@ -122,25 +122,6 @@ function findBundledVits(): string | null {
return null
}
/**
* VITS(小雅)的 sherpa-onnx 校验要求指定一个 espeak-ng-data 目录,历史上直接复用
* Piper bundle 里的那份数据。Piper 自己的引擎(模型/二进制/onnxruntime/phonemize)
* 在有 VITS 时永远不会被实际调用(detectBackend 第一优先级永远命中 VITS),
* 所以只保留 piper/espeak-ng-data 这一个子目录,不要求整套 Piper 引擎存在。
*/
function findPiperEspeakDataDir(): string | null {
const candidates = [
join(process.resourcesPath, 'tts', 'piper'),
join(__dirname, '..', '..', 'resources', 'tts', 'piper'),
process.env.TTS_BUNDLE_DIR ? join(process.env.TTS_BUNDLE_DIR, 'piper') : ''
].filter(Boolean)
for (const dir of candidates) {
if (existsSync(join(dir, 'espeak-ng-data'))) return dir
}
return null
}
function findBundledEspeakNg(): string | null {
const candidates = [
join(process.resourcesPath, 'tts', 'espeak-ng'),
@ -207,18 +188,6 @@ const WAKE_TIMEOUT_MS = WAKE_SILENCE_MS + 3000
const PAUSE_LONG_MS = 200
const PAUSE_SHORT_MS = 100
/**
* stdin 写完(EOF)后等待播放器自行退出的最长时间。正常情况下 aplay 把缓冲里剩余的
* 音频播完就会退出,通常远小于 1 秒。真机长时间运行后发现过 PulseAudio 音频通路挂起
* 唤醒失败的情况(见上面 WAKE_IDLE_THRESHOLD_MS 的注释——这台设备的 PulseAudio 大约
* 5 秒空闲就会把音频设备挂起),aplay 卡在给挂死的通路写数据,stdin.end() 之后永远不
* 触发 'exit':没有这个兜底的话,isSpeaking 会永久卡在 true,playNext() 再也不会处理
* 队列里后续的句子,相当于整个 TTS 直接哑掉;ASR 共用同一个 PulseAudio 实例,通路卡死
* 后录音也会跟着失败,这正是"长时间运行后语音输入输出都不行"的根因。超时后强制
* SIGKILL 并以 done(error) 收尾,让队列能继续推进而不是永久卡死。
*/
const PLAYER_EXIT_TIMEOUT_MS = 8000
/** 生成指定时长的裸静音 PCM(s16le 单声道) */
function buildSilenceRaw(durationMs: number, sampleRate: number): Buffer {
return Buffer.alloc(Math.floor((sampleRate * durationMs) / 1000) * 2)
@ -334,13 +303,15 @@ function detectBackend(): Promise<Backend | null> {
if (detectedBackendPromise) return detectedBackendPromise
detectedBackendPromise = (async () => {
// 1. 优先使用内嵌 sherpa-onnx VITS 小雅模型(BZNSYP 标准普通话)。
// 依赖 Piper bundle 里的 espeak-ng-data 作为 dataDir(sherpa-onnx 校验需要),
// 不需要 Piper 引擎本身(bin/model/config),见 findPiperEspeakDataDir 注释。
// 依赖 Piper bundle 里的 espeak-ng-data 作为 dataDir(sherpa-onnx 校验需要)。
const bundledVits = findBundledVits()
const piperDataDir = findPiperEspeakDataDir()
if (bundledVits && piperDataDir) {
const piperForData = findBundledPiper()
if (bundledVits && piperForData) {
bundledVitsDir = bundledVits
bundledPiperDir = piperDataDir
bundledPiperBin = piperForData.bin
bundledPiperDir = piperForData.dir
bundledPiperModel = piperForData.model
bundledPiperConfig = piperForData.config
detectedBackend = 'bundled-vits'
console.log('[systemTts] detected backend:', detectedBackend, 'at', bundledVitsDir)
return detectedBackend
@ -623,17 +594,9 @@ function speakWithPiperStreaming(
const fullText = segments.join('')
let resolved = false
let playerExitTimer: NodeJS.Timeout | null = null
const clearPlayerExitTimer = () => {
if (playerExitTimer) {
clearTimeout(playerExitTimer)
playerExitTimer = null
}
}
const done = (error?: string) => {
if (resolved) return
resolved = true
clearPlayerExitTimer()
cleanup()
try {
rmSync(segDir, { recursive: true, force: true })
@ -764,19 +727,6 @@ function speakWithPiperStreaming(
} catch {
// ignore
}
// 见 PLAYER_EXIT_TIMEOUT_MS 注释:音频通路卡死时 aplay 可能永远不触发 'exit',
// 这里兜底强制结束,避免 isSpeaking 卡死导致后续队列全部哑掉。
const stuckPlayer = currentPlayer
playerExitTimer = setTimeout(() => {
if (!isAlive()) return
console.warn('[systemTts] 播放器退出超时,判定音频通路卡死,强制结束本轮播报')
try {
stuckPlayer?.kill('SIGKILL')
} catch {
// ignore
}
done('player exit timeout')
}, PLAYER_EXIT_TIMEOUT_MS)
}
})()
@ -944,18 +894,10 @@ function speakWithVits(segments: string[], needWake: boolean): Promise<void> {
const segDir = mkdtempSync(join(tmpdir(), 'tts-vits-'))
let resolved = false
let playerExitTimer: NodeJS.Timeout | null = null
const clearPlayerExitTimer = () => {
if (playerExitTimer) {
clearTimeout(playerExitTimer)
playerExitTimer = null
}
}
const isAlive = () => myToken === ttsStreamToken && !resolved
const done = (error?: string) => {
if (resolved) return
resolved = true
clearPlayerExitTimer()
// 通知 worker 停止后续合成(模型保持加载,worker 常驻不退出)
try {
vitsWorker?.send({ cmd: 'cancel' })
@ -1056,20 +998,6 @@ function speakWithVits(segments: string[], needWake: boolean): Promise<void> {
} catch {
// ignore
}
// 数据已全部写完,正常情况下 aplay 播完缓冲就会触发上面的 'exit' 处理器。
// 若音频通路卡死(见 PLAYER_EXIT_TIMEOUT_MS 注释),'exit' 永远不会来,
// 这里兜底强制结束本轮播报,避免 isSpeaking 卡死导致后续队列全部哑掉。
const stuckPlayer = currentPlayer
playerExitTimer = setTimeout(() => {
if (!isAlive()) return
console.warn('[systemTts] 播放器退出超时,判定音频通路卡死,强制结束本轮播报')
try {
stuckPlayer?.kill('SIGKILL')
} catch {
// ignore
}
done('player exit timeout')
}, PLAYER_EXIT_TIMEOUT_MS)
}
})()
@ -1190,23 +1118,7 @@ function speakWithPiper(
})
}
/**
* 每轮播报结束时统一调用。之前只是把 currentProcess/currentPlayer 引用置空,
* 没有保证对应的系统进程真的退出——真机复测时发现合成卡死分支(见上面
* "synth timeout" 处)只杀了 vitsWorker,没杀已经 spawn 出来、stdin 还开着
* 干等数据的 aplay,这个进程会永远卡在那等一个再也不会来的 EOF,形成和
* PLAYER_EXIT_TIMEOUT_MS 要解决的问题同一性质的泄漏,只是触发路径不同。
* 与其在每个 done() 调用点各自记得去杀 currentPlayer,不如在 cleanup()
* 这个唯一的收尾出口统一兜底:无论从哪条路径走到这里,都先尝试杀掉还活着
* 的进程,再清空引用。
*/
function cleanup(): void {
if (currentProcess) {
killWithEscalation(currentProcess)
}
if (currentPlayer) {
killWithEscalation(currentPlayer)
}
currentProcess = null
currentPlayer = null
isSpeaking = false
@ -1250,33 +1162,6 @@ export async function systemTtsSpeak(text: string): Promise<void> {
})
}
/**
* 先 SIGTERM,一段宽限期后如果进程还没退出再 SIGKILL。
* 音频通路卡死时 aplay 可能卡在不可中断的系统调用里,单发 SIGTERM 不一定能把它
* 真正杀掉——这里只是把 JS 里的引用清空了、不代表对应的系统进程真的退出了,不加
* 兜底的话每次中途打断播报都可能在后台攒下一个杀不掉的僵尸 aplay,长时间运行下来
* 越积越多,最终把 PulseAudio 拖垮(见 PLAYER_EXIT_TIMEOUT_MS 注释)。
*/
function killWithEscalation(proc: ChildProcess, graceMs = 1500): void {
let exited = false
proc.once('exit', () => {
exited = true
})
try {
proc.kill('SIGTERM')
} catch {
// ignore
}
setTimeout(() => {
if (exited) return
try {
proc.kill('SIGKILL')
} catch {
// ignore
}
}, graceMs)
}
export function systemTtsStop(): void {
// 递增代际标记,进行中的分段喂流循环会据此中止
ttsStreamToken++
@ -1288,7 +1173,11 @@ export function systemTtsStop(): void {
} catch {
// ignore
}
killWithEscalation(currentProcess)
try {
currentProcess.kill('SIGTERM')
} catch {
// ignore
}
currentProcess = null
}
if (currentPlayer) {
@ -1297,7 +1186,11 @@ export function systemTtsStop(): void {
} catch {
// ignore
}
killWithEscalation(currentPlayer)
try {
currentPlayer.kill('SIGTERM')
} catch {
// ignore
}
currentPlayer = null
}
queue = []

View File

@ -17,10 +17,6 @@ contextBridge.exposeInMainWorld('electronAPI', {
query: (payload: { question: string; systemPrompt: string; topK: number }): Promise<void> =>
ipcRenderer.invoke('query', payload),
// 拍照提问(不走检索,直接把图片+问题发给多模态生成服务)
visionQuery: (payload: { question: string; imageDataUrl: string }): Promise<void> =>
ipcRenderer.invoke('vision-query', payload),
// 状态 & 配置
getDbStatus: (): Promise<{ count: number; ready: boolean }> =>
ipcRenderer.invoke('get-db-status'),

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 197 KiB

View File

@ -1,230 +0,0 @@
<script setup lang="ts">
import { ref, watch, onUnmounted } from 'vue'
import { NModal, NCard } from 'naive-ui'
const props = defineProps<{ show: boolean; initialDeviceId: string }>()
const emit = defineEmits<{
'update:show': [value: boolean]
captured: [dataUrl: string]
deviceChange: [deviceId: string]
}>()
const videoRef = ref<HTMLVideoElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const devices = ref<MediaDeviceInfo[]>([])
const selectedDeviceId = ref('')
const capturedDataUrl = ref('')
const starting = ref(false)
const errorMessage = ref('')
// 真机实测确认的后置摄像头(朝外拍摄那颗)标签是 "Hy-8M(8079)-Camera",
// 用型号里的数字串匹配,不依赖完整标签文案(避免语言环境/固件版本差异导致匹配不上)
const REAR_CAMERA_LABEL_HINT = '8079'
let stream: MediaStream | null = null
function stopStream(): void {
stream?.getTracks().forEach((t) => t.stop())
stream = null
}
/**
* 优先按指定 deviceId 打开摄像头;该 id 打不开时退回默认摄像头。
* deviceId 在 Web 标准里不保证跨会话稳定,配置里存的是上次运行时的 id,
* 换一次会话后经常已经失效——此时 exact 约束会抛 OverconstrainedError,
* 不兜底的话整个拍照功能都用不了。
*/
async function openStream(deviceId: string): Promise<MediaStream> {
if (!deviceId) return navigator.mediaDevices.getUserMedia({ video: true })
try {
return await navigator.mediaDevices.getUserMedia({ video: { deviceId: { exact: deviceId } } })
} catch (e) {
console.warn('[camera] 指定的摄像头不可用,回退到默认摄像头:', e)
return navigator.mediaDevices.getUserMedia({ video: true })
}
}
async function startStream(deviceId: string): Promise<void> {
stopStream()
errorMessage.value = ''
starting.value = true
try {
stream = await openStream(deviceId)
if (videoRef.value) {
videoRef.value.srcObject = stream
await videoRef.value.play()
}
// 记录实际拿到的设备(回退时跟请求的 deviceId 不是同一个),
// 后续切换/匹配后置摄像头都以这个真实值为准
const actualId = stream.getVideoTracks()[0]?.getSettings().deviceId
if (actualId) selectedDeviceId.value = actualId
// 拿到权限后 label 才会有值,借机刷新一次设备列表
await refreshDevices()
} catch (e) {
errorMessage.value = `无法打开摄像头:${String(e)}`
} finally {
starting.value = false
}
}
async function refreshDevices(): Promise<void> {
try {
const list = await navigator.mediaDevices.enumerateDevices()
devices.value = list.filter((d) => d.kind === 'videoinput')
if (!selectedDeviceId.value && devices.value.length > 0) {
selectedDeviceId.value = devices.value[0].deviceId
}
} catch {
// enumerateDevices 在少数环境下可能不可用,静默忽略,不影响已经拿到的 stream
}
}
function findRearCamera(): MediaDeviceInfo | undefined {
return devices.value.find((d) => d.label.includes(REAR_CAMERA_LABEL_HINT))
}
async function switchDevice(deviceId: string): Promise<void> {
selectedDeviceId.value = deviceId
emit('deviceChange', deviceId)
await startStream(deviceId)
}
function capture(): void {
const video = videoRef.value
const canvas = canvasRef.value
if (!video || !canvas || video.videoWidth === 0) return
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
if (!ctx) return
// 这台设备的摄像头画面是倒置的(见下方 .cam-preview-video 的 CSS 旋转),
// 拍出来的照片也要同步转正,不然预览是正的、存下来的图是倒的
ctx.save()
ctx.translate(canvas.width, canvas.height)
ctx.rotate(Math.PI)
ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
ctx.restore()
capturedDataUrl.value = canvas.toDataURL('image/jpeg', 0.85)
}
function switchCamera(): void {
if (devices.value.length < 2) return
const idx = devices.value.findIndex((d) => d.deviceId === selectedDeviceId.value)
const nextIdx = (idx + 1) % devices.value.length
switchDevice(devices.value[nextIdx].deviceId)
}
function retake(): void {
capturedDataUrl.value = ''
}
function confirm(): void {
if (!capturedDataUrl.value) return
emit('captured', capturedDataUrl.value)
close()
}
function close(): void {
emit('update:show', false)
}
watch(
() => props.show,
async (val) => {
if (val) {
capturedDataUrl.value = ''
selectedDeviceId.value = props.initialDeviceId
// 摄像头 label 在拿到权限前是空的(浏览器隐私限制),没法在这一步就按标签
// 找后置摄像头,先随便开一个拿到权限;startStream 成功后 devices 里才有真实 label。
await startStream(props.initialDeviceId)
if (!stream) return // 打开失败,错误信息已经显示在弹层里
// 配置里没指定过摄像头,或指定的那个已失效(startStream 回退后 selectedDeviceId
// 会被改写成实际设备),两种情况都尝试切到实测确认的后置摄像头
const usingRequested =
!!props.initialDeviceId && selectedDeviceId.value === props.initialDeviceId
if (!usingRequested) {
const rear = findRearCamera()
if (rear && rear.deviceId !== selectedDeviceId.value) {
await switchDevice(rear.deviceId)
}
}
} else {
stopStream()
}
}
)
onUnmounted(() => stopStream())
</script>
<template>
<n-modal :show="show" :mask-closable="false" style="width: 720px; max-width: 92vw;" @update:show="close">
<n-card title="拍照提问" closable @close="close">
<div class="cam-body">
<div v-if="errorMessage" class="cam-error">{{ errorMessage }}</div>
<div v-show="!capturedDataUrl" class="cam-preview-wrap">
<video ref="videoRef" class="cam-preview cam-preview-video" autoplay muted playsinline />
<div v-if="starting" class="cam-loading">正在打开摄像头...</div>
<button
v-if="devices.length > 1"
class="cam-switch-btn"
title="切换前后摄像头"
@click="switchCamera"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>
</svg>
</button>
</div>
<img v-if="capturedDataUrl" :src="capturedDataUrl" class="cam-preview" alt="拍摄预览" />
<canvas ref="canvasRef" style="display:none" />
</div>
<template #footer>
<div class="cam-actions">
<template v-if="!capturedDataUrl">
<button class="i-btn i-btn-primary i-btn-lg" :disabled="starting || !!errorMessage" @click="capture">拍照</button>
</template>
<template v-else>
<button class="i-btn i-btn-ghost" @click="retake">重拍</button>
<button class="i-btn i-btn-primary i-btn-lg" @click="confirm">使用这张</button>
</template>
</div>
</template>
</n-card>
</n-modal>
</template>
<style scoped>
.cam-body { display: flex; flex-direction: column; gap: 14px; }
.cam-preview-wrap { position: relative; }
.cam-preview {
width: 100%; max-height: 60vh; object-fit: contain;
background: #000; border-radius: var(--radius-sm);
}
/* 这台设备的摄像头画面是倒置的,转正预览;capture() 里对拍照结果做了同样的旋转 */
.cam-preview-video { transform: rotate(180deg); }
.cam-loading {
position: absolute; inset: 0; display: flex;
align-items: center; justify-content: center;
color: #fff; font-size: 15px; background: rgba(0,0,0,.35);
border-radius: var(--radius-sm);
}
.cam-switch-btn {
position: absolute; top: 14px; right: 14px;
width: 44px; height: 44px; border-radius: 50%;
border: none; background: rgba(0,0,0,.45); color: #fff;
display: flex; align-items: center; justify-content: center;
cursor: pointer;
}
.cam-switch-btn:hover { background: rgba(0,0,0,.65); }
.cam-error {
color: var(--red); background: #FBEAE8;
border-radius: var(--radius-sm); padding: 12px 14px; font-size: 15px;
}
.cam-actions { display: flex; justify-content: flex-end; gap: 12px; }
</style>

View File

@ -1,12 +1,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { NModal, NCard, NScrollbar, useMessage } from 'naive-ui'
import { marked } from 'marked'
import { useRagStore } from '../stores/rag'
import { resolveImagePaths } from '../utils/markdown'
import { AudioRecorder } from '../utils/audio'
import { ttsPlayer } from '../utils/tts'
import CameraCapture from './CameraCapture.vue'
import type { Chunk } from '../types'
interface InferStats {
@ -154,14 +153,19 @@ function onAnswerClick(e: MouseEvent): void {
popupVisible.value = true
}
// 两种提问方式(文字检索 / 拍照)共用的准备工作:重置状态、打断正在进行的语音输入、停止旧的 TTS 播报
function beginQuery(): void {
async function sendQuery(): Promise<void> {
const q = question.value.trim()
if (!q) return
if (!store.dbStatus.ready) { message.warning('向量库为空,请先入库文档'); return }
if (store.querying) return
store.querying = true
store.resetAnswer()
inferStats.value = null
visionCaption.value = ''
readyCitations.value = new Set()
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null }
sentQuestion.value = q
question.value = ''
// 立即解除 ASR 监听并清空状态,防止延迟到达的 asr:stopped 通过 syncAsrText() 恢复输入框
cleanupAsrListeners()
asrBaseText = ''
@ -181,19 +185,6 @@ function beginQuery(): void {
startWaitTimer()
ttsPlayer.stop()
syncTtsConfig()
if (ttsEnabled.value) ttsPlayer.announceStart()
}
async function sendQuery(): Promise<void> {
const q = question.value.trim()
if (!q) return
if (!store.dbStatus.ready) { message.warning('向量库为空,请先入库文档'); return }
if (store.querying) return
beginQuery()
sentQuestion.value = q
sentImage.value = ''
question.value = ''
let firstDelta = true
@ -201,16 +192,13 @@ async function sendQuery(): Promise<void> {
const onContext = (data: unknown) => { store.chunks = (data as { chunks: Chunk[] }).chunks }
const onDelta = (data: unknown) => {
if (firstDelta) { firstDelta = false; stopWaitTimer() }
const delta = (data as { delta: string }).delta
store.rawAnswer += delta
if (ttsEnabled.value) ttsPlayer.append(delta)
store.rawAnswer += (data as { delta: string }).delta
scrollToBottom()
}
const onDone = (data: unknown) => {
stopWaitTimer()
store.querying = false
queryStage.value = ''
if (ttsEnabled.value) ttsPlayer.flush()
const stats = (data as { stats?: InferStats | null }).stats
if (stats) inferStats.value = stats
cleanup()
@ -219,7 +207,6 @@ async function sendQuery(): Promise<void> {
stopWaitTimer()
store.querying = false
queryStage.value = ''
if (ttsEnabled.value) ttsPlayer.flush()
message.error((data as { message: string }).message)
cleanup()
}
@ -241,102 +228,8 @@ async function sendQuery(): Promise<void> {
await window.electronAPI.query({ question: q, systemPrompt: store.config?.rag.systemPrompt ?? '', topK: topK.value })
}
// 拍照提问:先让视觉模型快速生成一句图像描述(跟用户问题拼接后去检索知识库),
// 再走一遍跟文字问答一样的检索+生成,图片本身不会传给最终生成模型
async function sendVisionQuery(): Promise<void> {
if (!pendingImage.value) return
if (store.querying) return
// 防守拍完照到点发送之间后端被切成纯文本模型的空窗(按钮此时已置灰,但图片还留在输入区)
if (!store.visionAvailable) {
message.warning('当前生成服务加载的不是多模态模型,无法识别图片')
return
}
const q = question.value.trim()
const img = pendingImage.value
beginQuery()
sentQuestion.value = q
sentImage.value = img
question.value = ''
pendingImage.value = ''
let firstDelta = true
const onStage = (data: unknown) => { queryStage.value = (data as { stage: string }).stage }
const onCaption = (data: unknown) => { visionCaption.value = (data as { caption: string }).caption }
const onContext = (data: unknown) => { store.chunks = (data as { chunks: Chunk[] }).chunks }
const onDelta = (data: unknown) => {
if (firstDelta) { firstDelta = false; stopWaitTimer() }
const delta = (data as { delta: string }).delta
store.rawAnswer += delta
if (ttsEnabled.value) ttsPlayer.append(delta)
scrollToBottom()
}
const onDone = (data: unknown) => {
stopWaitTimer()
store.querying = false
queryStage.value = ''
if (ttsEnabled.value) ttsPlayer.flush()
const stats = (data as { stats?: InferStats | null }).stats
if (stats) inferStats.value = stats
cleanup()
}
const onError = (data: unknown) => {
stopWaitTimer()
store.querying = false
queryStage.value = ''
if (ttsEnabled.value) ttsPlayer.flush()
message.error((data as { message: string }).message)
cleanup()
}
function cleanup(): void {
window.electronAPI.off('vision-query:stage')
window.electronAPI.off('vision-query:caption')
window.electronAPI.off('vision-query:context')
window.electronAPI.off('vision-query:delta')
window.electronAPI.off('vision-query:done')
window.electronAPI.off('vision-query:error')
}
window.electronAPI.on('vision-query:stage', onStage)
window.electronAPI.on('vision-query:caption', onCaption)
window.electronAPI.on('vision-query:context', onContext)
window.electronAPI.on('vision-query:delta', onDelta)
window.electronAPI.on('vision-query:done', onDone)
window.electronAPI.on('vision-query:error', onError)
await window.electronAPI.visionQuery({ question: q, imageDataUrl: img })
}
function handleSend(): void {
if (pendingImage.value) { sendVisionQuery(); return }
sendQuery()
}
function handleEnter(e: KeyboardEvent): void {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
}
// ── 拍照提问 ──
const cameraOpen = ref(false)
const pendingImage = ref('')
const sentImage = ref('')
const visionCaption = ref('')
function onCaptured(dataUrl: string): void {
pendingImage.value = dataUrl
}
function removePendingImage(): void {
pendingImage.value = ''
}
async function onCameraDeviceChange(deviceId: string): Promise<void> {
if (!store.config) return
if (store.config.vision.cameraDeviceId === deviceId) return
await store.saveConfig({ ...store.config, vision: { ...store.config.vision, cameraDeviceId: deviceId } })
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendQuery() }
}
// ── 语音输入 ──
@ -503,18 +396,6 @@ async function toggleTts(): Promise<void> {
} else if (store.rawAnswer && !store.querying) {
// 答案已显示完毕时打开 TTS,立即从头播
ttsPlayer.speakWithAnnouncement(store.rawAnswer)
} else if (store.querying) {
// 提问还在流式生成中途打开 TTS:ttsPlayer 内部的 rawText 此前因为
// enabled=false 一直没有累积(append() 一开头就 return),直接等后续
// onDelta 的话只会从"此刻之后的新增量"开始朗读,把已经显示在屏幕上但
// 开 TTS 之前生成的那部分正文漏掉。这里先播"开始播报"提示音(跟正常
// 提问开始时一致),再用 append() 把已生成的全文"补喂"一遍,相当于
// 告诉播放器"这就是从头到现在的内容",让它从头开始排队朗读;之后
// onDelta 送来的增量会在此基础上正常追加,衔接不会重复。此刻还没有
// 任何输出(刚发问、第一个 delta 还没到)时 append 传空字符串会被
// append() 内部的 if (!text) return 挡掉,不会误触发。
ttsPlayer.announceStart()
ttsPlayer.append(store.rawAnswer)
}
}
@ -581,6 +462,13 @@ onMounted(() => {
}
})
watch(() => store.querying, (querying) => {
// 答案流式生成结束后,整段朗读(先播"开始播报")
if (!querying && ttsEnabled.value && store.rawAnswer) {
ttsPlayer.speakWithAnnouncement(store.rawAnswer)
}
})
onUnmounted(() => {
stopWaitTimer()
if (hoverTimer) clearTimeout(hoverTimer)
@ -610,14 +498,10 @@ onUnmounted(() => {
<div class="q-ic">问</div>
<div>
<div class="q-label">提问</div>
<img v-if="sentImage" :src="sentImage" class="q-image" alt="提问附带的照片" />
<div class="q-text">{{ sentQuestion || (sentImage ? '(未输入文字,根据图片自动识别提问)' : '') }}</div>
<div class="q-text">{{ sentQuestion }}</div>
</div>
</div>
<!-- Vision caption:拍照提问识别到的图像描述,识别完就常驻显示 -->
<div v-if="visionCaption" class="vision-caption">识别到:{{ visionCaption }}</div>
<!-- Stage indicator -->
<div v-if="store.querying && !store.rawAnswer" class="stage-row">
<span class="stage-spinner" />
@ -687,44 +571,22 @@ onUnmounted(() => {
语音输出
</button>
</div>
<!-- Camera button:显示与否看用户开关,能否点击看后端当前模型是否支持图像输入 -->
<div v-if="store.config?.vision.enabled" class="voice-btn-wrap">
<button
class="voice-btn"
:disabled="!store.visionAvailable"
:title="store.visionAvailable ? '拍照提问' : '当前生成服务加载的不是多模态模型,无法识别图片'"
@click="cameraOpen = true"
>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
拍照提问
</button>
</div>
</div>
<!-- Question textarea -->
<div class="q-input-wrap">
<div v-if="pendingImage" class="pending-image-chip">
<img :src="pendingImage" alt="待发送的照片" />
<button class="pending-image-remove" title="移除照片" @click="removePendingImage">×</button>
</div>
<textarea
v-model="question"
class="q-input"
rows="1"
:disabled="batchRunning"
:placeholder="pendingImage ? '针对这张照片提问,留空则默认描述图片…' : '输入问题,Enter 发送,Shift+Enter 换行…'"
placeholder="输入问题,Enter 发送,Shift+Enter 换行…"
@keydown="handleEnter"
/>
</div>
<!-- Send -->
<button
class="i-btn i-btn-primary i-btn-lg send-btn"
:disabled="store.querying || (!question.trim() && !pendingImage) || batchRunning"
@click="handleSend"
:disabled="store.querying || !question.trim() || batchRunning"
@click="sendQuery"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/>
@ -757,14 +619,6 @@ onUnmounted(() => {
</n-scrollbar>
</n-card>
</n-modal>
<!-- Camera capture -->
<CameraCapture
v-model:show="cameraOpen"
:initial-device-id="store.config?.vision.cameraDeviceId ?? ''"
@captured="onCaptured"
@device-change="onCameraDeviceChange"
/>
</div>
</template>
@ -808,18 +662,6 @@ onUnmounted(() => {
}
.q-label { font-size: 13px; color: var(--blue-d); font-weight: 700; letter-spacing: .8px; margin-bottom: 3px; }
.q-text { font-size: 20px; font-weight: 700; line-height: 1.4; color: var(--ink); }
.q-image {
max-width: 240px; max-height: 240px; object-fit: contain;
border-radius: 8px; margin-bottom: 10px; display: block;
border: 2px solid var(--line);
}
/* Vision caption */
.vision-caption {
font-size: 15px; color: var(--ink-3);
background: var(--panel-2); border-radius: var(--radius-sm);
padding: 8px 14px; margin-bottom: 14px;
}
/* Stage indicator */
.stage-row {
@ -908,11 +750,9 @@ onUnmounted(() => {
display: flex; align-items: flex-end; gap: 14px;
}
.q-input-wrap { flex: 1; min-width: 0; height: 96px; position: relative; }
.q-input {
width: 100%; box-sizing: border-box;
height: 96px; min-height: 0; max-height: 96px;
flex: 1; min-width: 0;
height: 96px;
border: 2px solid var(--line-2); border-radius: var(--radius);
padding: 16px 20px; font-family: inherit;
font-size: 20px; line-height: 1.5; resize: none;
@ -922,26 +762,6 @@ onUnmounted(() => {
.q-input:focus { outline: none; border-color: var(--blue); }
.q-input:disabled { background: var(--panel-2); color: var(--ink-3); }
.pending-image-chip {
position: absolute; left: 14px; bottom: 100%;
margin-bottom: 8px;
display: flex; align-items: center;
border: 2px solid var(--line-2); border-radius: var(--radius-sm);
background: var(--panel); padding: 4px;
box-shadow: 0 6px 20px rgba(0,0,0,.12);
}
.pending-image-chip img {
width: 64px; height: 64px; object-fit: cover;
border-radius: 6px;
}
.pending-image-remove {
position: absolute; top: -8px; right: -8px;
width: 22px; height: 22px; border-radius: 50%;
border: none; background: var(--red); color: #fff;
font-size: 15px; line-height: 1; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.send-btn { height: 96px; min-width: 150px; }
.batch-btn { height: 96px; min-width: 130px; }
.i-btn-success { background: var(--green); color: #fff; box-shadow: 0 2px 0 #0a6238; }
@ -972,32 +792,15 @@ onUnmounted(() => {
font-weight: 700;
font-family: inherit;
transition: all .15s;
/* 这三个按钮之前没有 :active 态,点下去没有任何即时反馈。加上即时反馈的同时顺手
关掉原生 outline(这是纯触屏界面,不依赖 Tab 键盘导航,不需要保留原生焦点框)。*/
outline: none;
}
/* 真正的"绿框"根因不在 outline/focus,而是 CSS 优先级:.voice-btn:not(:disabled):hover
(3 个选择器)比 .voice-btn.on(2 个选择器)优先级更高。触屏点击后没有鼠标可以"移开",
:hover 状态会一直粘在刚点过的按钮上不消失(触屏设备的通病),于是 hover 规则的浅灰
background 把 .on 本该铺满的纯绿色背景盖掉了,只剩下没被 hover 碰到的绿色边框还在——
看起来就像按钮周围凭空多出一个绿框。真机用 CDP 直接读取点击后的计算样式实锤:
className 带 "on",但 background 实际渲染出的是 --panel-3 的浅灰色,不是 --green。
加 :not(.on) 让 hover 规则在"已开启"状态下让位,背景就能正常显示成绿色。 */
.voice-btn:not(:disabled):not(.on):hover { background: var(--panel-3); }
.voice-btn:not(:disabled):active { filter: brightness(.92); }
.voice-btn:hover { background: var(--panel-3); }
.voice-btn.on {
background: var(--green);
border-color: var(--green);
color: #fff;
box-shadow: 0 2px 0 #0a6238;
}
.voice-btn:disabled {
opacity: .5;
cursor: not-allowed;
background: var(--panel-3);
border-color: var(--line);
color: var(--ink-3);
}
.voice-btn:disabled { opacity: .6; cursor: default; }
.voice-spinner {
width: 22px;
height: 22px;

View File

@ -30,41 +30,21 @@ let pollTimer: ReturnType<typeof setInterval> | null = null
let genSse: EventSource | null = null
let embSse: EventSource | null = null
/**
* 判断生成服务当前加载的模型能不能吃图片输入。
*
* 首选依据是模型加载后打印的 `allow_image = 1`,但 er_backend 的日志缓冲只留最近 200 条,
* 服务跑一会儿这行就会被推理日志挤掉(实测确认),所以只在还能翻到时采信;翻不到时退回
* 看启动命令里有没有 `--mmproj`——这个字段不随日志滚动,是长期可靠的依据。
*/
function detectVisionCapability(gen: {
status?: string
lastCommand?: string
logs?: Array<{ text?: string }>
}): boolean {
if (gen?.status !== 'running') return false
const logHit = gen.logs?.find((l) => l.text?.includes('allow_image'))
if (logHit?.text) return /allow_image\s*=\s*1/.test(logHit.text)
return !!gen.lastCommand?.includes('--mmproj')
}
async function pollStatus() {
if (!needsService.value) return
try {
const res = await fetch(`${ER_BACKEND}/api/status`, { signal: AbortSignal.timeout(2000) })
if (!res.ok) { erUp.value = false; store.visionAvailable = false; return }
if (!res.ok) { erUp.value = false; return }
const data = await res.json()
erUp.value = true
erGenStatus.value = data.generate?.status || 'stopped'
erEmbedStatus.value = data.embed?.status || 'stopped'
store.serverState = { status: erGenStatus.value, logs: store.serverState.logs }
store.embedServerState = { status: erEmbedStatus.value, logs: store.embedServerState.logs }
store.visionAvailable = detectVisionCapability(data.generate ?? {})
} catch {
erUp.value = false
erGenStatus.value = 'stopped'
erEmbedStatus.value = 'stopped'
store.visionAvailable = false
}
}

View File

@ -25,8 +25,7 @@ const form = ref<AppConfig>({
voice: {
asr: { enabled: false, executablePath: '', modelDir: '', numThreads: 2, vadSilenceTimeoutMs: 5000, vadEnergyThreshold: 0.005, inputGain: 10, punctuationModelDir: '' },
tts: { enabled: false, voiceURI: '', pitch: 1, rate: 1, volume: 1 }
},
vision: { enabled: false, cameraDeviceId: '' }
}
})
const activeTab = ref<'index' | 'basic' | 'voice' | 'batch' | 'log'>('basic')
@ -176,23 +175,6 @@ async function save(): Promise<void> {
</div>
</div>
<!-- ── 拍照提问 ── -->
<div class="sgroup-title">拍照提问</div>
<div class="field">
<div class="fl">启用拍照提问</div>
<div class="fc">
<label class="s-radio" :class="{ on: form.vision.enabled }" @click="form.vision.enabled = true">
<span class="rk"></span>开启
</label>
<label class="s-radio" :class="{ on: !form.vision.enabled }" @click="form.vision.enabled = false">
<span class="rk"></span>关闭
</label>
</div>
</div>
<p class="s-note" style="padding-left:120px;margin:-6px 0 0;">
依赖 er_backend 当前加载的是支持视觉输入的模型(能看到 allow_image=1),否则开启后拍照提问会请求失败。
</p>
</div><!-- /基本设置 -->
<!-- ── 语音输入输出 ── -->

View File

@ -33,11 +33,6 @@ export const useRagStore = defineStore('rag', () => {
// 启动时硬件探测结果(null=尚未完成探测)
const detectedCard = ref<DetectedCard | null>(null)
// 生成服务当前加载的模型是否支持图像输入(由 ServiceControl 的状态轮询写入)。
// 拍照提问要靠它决定按钮能不能点——后端换成纯文本模型时按钮置灰,
// 换回多模态模型后无需重启应用,下一轮轮询就会自动恢复可用。
const visionAvailable = ref(false)
// think 标签由 ChatPanel 负责渲染,此处直接透传
const displayAnswer = computed(() => rawAnswer.value)
@ -88,7 +83,6 @@ export const useRagStore = defineStore('rag', () => {
embedServerLogs,
npuStats,
detectedCard,
visionAvailable,
refreshDbStatus,
loadConfig,
saveConfig,

View File

@ -50,10 +50,6 @@ export interface AppConfig {
volume: number
}
}
vision: {
enabled: boolean
cameraDeviceId: string
}
}
export type DetectedCard = 'er160' | 'er25' | 'none'
@ -117,7 +113,6 @@ declare global {
selectDirectory: () => Promise<string | null>
indexDocuments: (payload: { paths: string[]; mode: 'append' | 'rebuild' }) => Promise<void>
query: (payload: { question: string; systemPrompt: string; topK: number }) => Promise<void>
visionQuery: (payload: { question: string; imageDataUrl: string }) => Promise<void>
getDbStatus: () => Promise<DbStatus>
getConfig: () => Promise<AppConfig>
saveConfig: (config: AppConfig) => Promise<void>

View File

@ -148,24 +148,13 @@ export class TtsPlayer {
this.rawText += text
const thinkFree = this.stripThinkContent(this.rawText)
// 只处理到"安全边界"为止:排除末尾可能还没闭合的 ** 加粗标记。
// markdownToPlain 每次都是从头重新解析整段 rawText 的,闭合符号到达前后,
// 同一段文字的纯文本渲染结果会不一样(比如未闭合时星号被当普通字符处理,
// 闭合后星号被去掉、文字保留)。如果提前把这段还不稳定的文字提交为
// lastSpokenPlain,闭合符号一到,plain 文本变化,会被 computePendingDelta
// 重新判定成"待朗读",导致这段文字被朗读两遍。
const stableBoundary = this.findStableBoundary(thinkFree)
const stableRaw = thinkFree.slice(0, stableBoundary)
const plain = this.markdownToPlain(stableRaw)
const plain = this.markdownToPlain(thinkFree)
const pending = this.computePendingDelta(plain)
if (!pending) return
// pending 是相对 lastSpokenPlain 的增量(已排除重复),累加进缓冲区而不是替换——
// 替换会丢失上一次 append() 里 processBuffer() 还没来得及提取成句子的剩余文本
// (比如队列满了暂停提取,或者还没遇到句尾标点),高频小增量调用下会导致正文
// 大量丢字,只有 flush() 时残留的最后一小段才会被朗读。
// 直接用当前未朗读的纯文本替换缓冲区,避免旧 buffer 因格式被二次清洗而变得陈旧/重复
this.lastSpokenPlain = plain
this.buffer += pending
this.buffer = pending
this.recordAppend(pending.length)
this.processBuffer()
}
@ -175,15 +164,6 @@ export class TtsPlayer {
*/
flush(): void {
if (!this.options.enabled) return
// 收尾:答案已经生成完毕,不用再担心格式标记后续会变化,
// 把 append() 因 findStableBoundary 搁置的尾部(如收尾正好停在未闭合的 ** 上)也一并处理掉
const thinkFree = this.stripThinkContent(this.rawText)
const plain = this.markdownToPlain(thinkFree)
const pending = this.computePendingDelta(plain)
if (pending) {
this.lastSpokenPlain = plain
this.buffer += pending
}
if (this.buffer.trim()) {
this.enqueue(this.buffer.trim())
this.buffer = ''
@ -196,17 +176,6 @@ export class TtsPlayer {
* 分段规则:"。"、";"、"!"、"?" 后长停顿,"、"、":" 后短停顿(约 0.2s~0.3s),
* 每段一行发给主进程,由主进程统一合成并在段间插入对应时长的静音。
*/
/**
* 播报"开始播报"提示音,用在流式问答开始时(配合 append()/flush())。
* 只入队播放,不碰 rawText/buffer 等流式缓冲区状态,调用前应已经
* 通过 stop() 清空过一轮的状态。
*/
announceStart(): void {
if (!this.options.enabled) return
this.introSpoken = true
this.enqueue('开始播报')
}
speakWithAnnouncement(rawText: string): void {
if (!this.options.enabled) return
const plain = this.stripThinkAndMarkdown(rawText)
@ -359,27 +328,6 @@ export class TtsPlayer {
* 用“后缀-前缀对齐”计算真正新增的纯文本。
* 当 cleanMarkdown/marked 把 HTML 实体或格式符号压缩时,按内容对齐可避免丢字或重复。
*/
/**
* 找到文本里可以安全处理的边界,排除末尾可能还没闭合的 ** 加粗标记
* (出现次数为奇数,说明最后一个 ** 还没等到它的另一半)。
* 只处理 ** 是因为这是目前唯一被实测复现"闭合前后纯文本渲染不一致"问题的标记;
* 其它行内标记(*斜体*、`代码`、~~删除线~~)出现频率低很多,先不处理。
*/
private findStableBoundary(text: string): number {
let count = 0
let lastUnclosedIndex = -1
let from = 0
while (true) {
const found = text.indexOf('**', from)
if (found === -1) break
count++
lastUnclosedIndex = found
from = found + 2
}
if (count % 2 === 1) return lastUnclosedIndex
return text.length
}
private computePendingDelta(newPlain: string): string {
const prev = this.decodeHtmlEntities(this.lastSpokenPlain)
if (!prev) return newPlain