Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d2164e1af | ||
|
|
95ee66e836 | ||
|
|
417fbbd9f5 | ||
|
|
e95699b21b | ||
|
|
b45c7d2f27 | ||
|
|
a8bce9f985 | ||
|
|
6a19270514 | ||
|
|
1fdbc7353f | ||
|
|
b7409e5391 | ||
|
|
46cd3ed68b | ||
|
|
ec7f111038 | ||
|
|
ae4ecd645d | ||
|
|
4dee32195f | ||
|
|
3a34aabd13 | ||
|
|
687e0ca9e2 | ||
|
|
87b9ffbab5 | ||
|
|
b918a0c53f | ||
|
|
0d19be58c3 | ||
|
|
8054fcdad1 | ||
|
|
48a9fb0f6d | ||
|
|
a78c88c86a | ||
|
|
ec634c4133 | ||
|
|
ab01effd5b | ||
|
|
21f9c7e453 | ||
|
|
2ae01c2050 | ||
|
|
ee9b1153d5 | ||
|
|
6faf48fff4 | ||
|
|
d686e600be | ||
|
|
33ce4ec09c | ||
|
|
d512ad41f9 | ||
|
|
b512857629 | ||
|
|
1cb953eeb3 | ||
|
|
598922dd16 | ||
|
|
0ae330c4ca | ||
|
|
836d25cad2 | ||
|
|
a319caeff6 |
152
CLAUDE.md
@ -8,6 +8,8 @@
|
||||
|
||||
服务进程管理(llama-server / embed-server 的启动停止)**完全委托给 er_backend**(HTTP 17700),前端不直接 spawn 任何推理进程。设置页面不再提供计算卡型号、嵌入模型、生成模型路径等配置入口,这些均由 er_backend Web 管理界面(端口 17700)配置。
|
||||
|
||||
输入方式有三种:打字、语音输入(ASR)、**拍照提问**(用摄像头拍一张照片,先由多模态模型转成一句话描述,再接入同一套知识库检索流程,见"拍照提问"章节)。
|
||||
|
||||
本仓库从 monorepo(electronrag)通过 `scripts/sync-elec-rag-ui.sh` 单向同步而来,历史不保留。
|
||||
|
||||
## 技术栈
|
||||
@ -18,7 +20,7 @@
|
||||
- **向量数据库**:LanceDB(`@lancedb/lancedb`,嵌入式)
|
||||
- **本地向量化**:`@huggingface/transformers`(底层 `onnxruntime-node`)
|
||||
- **Markdown 渲染**:`marked`
|
||||
- **打包**:electron-builder(Linux AppImage + deb)
|
||||
- **打包**: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` 打包,已在真机验证
|
||||
|
||||
## 文件说明
|
||||
|
||||
@ -31,6 +33,7 @@ src/
|
||||
│ ├── index.ts # 注册所有 IPC 处理器
|
||||
│ ├── indexHandler.ts # 文件选择、文档入库(分块→向量化→写入LanceDB)
|
||||
│ ├── queryHandler.ts # 查询(向量化→检索→流式生成),query:done 含 GeneratorStats
|
||||
│ ├── visionHandler.ts # 拍照提问(图像理解→拼检索查询→复用检索+生成)
|
||||
│ ├── statusHandler.ts # 数据库状态、配置读写、测试连接(无 Ollama 分支)
|
||||
│ ├── erBackendHandler.ts # er_backend HTTP 17700 代理 IPC(启动/停止/状态)
|
||||
│ ├── hardwareHandler.ts # 硬件探测:hardware:get-detected / hardware:detected
|
||||
@ -39,7 +42,7 @@ src/
|
||||
│ ├── splitter.ts # 递归收集 .md/.txt,自实现递归字符分块
|
||||
│ ├── embedder.ts # 向量化路由:local / llamacpp(无 Ollama 分支)
|
||||
│ ├── localEmbedder.ts # 本地 CPU 向量化(Transformers.js 单例,懒加载)
|
||||
│ ├── generator.ts # 流式生成:仅 llamacpp(接受 llamacppServer 配置)
|
||||
│ ├── generator.ts # 流式生成 + describeImage 图像理解,统一走 er_backend 代理
|
||||
│ ├── hardwareDetector.ts # 探测 hm_smi / rpp-info 判断 er160/er25/none
|
||||
│ └── ollamaModels.ts # (保留文件,此项目不使用 Ollama)
|
||||
├── preload/index.ts # contextBridge 暴露 electronAPI(无 llamaServer IPC)
|
||||
@ -48,11 +51,13 @@ src/
|
||||
├── App.vue # 根布局:顶栏、主区;服务状态由 SSE 维护
|
||||
├── components/
|
||||
│ ├── IndexPanel.vue # 文档入库面板
|
||||
│ ├── ChatPanel.vue # 问答气泡、Think 块、引用徽章、批量发送
|
||||
│ ├── ChatPanel.vue # 问答气泡、Think 块、引用徽章、批量发送、拍照入口
|
||||
│ ├── CameraCapture.vue # 拍照弹层:取景/拍照/重拍/切换摄像头(纯交互,不含业务逻辑)
|
||||
│ ├── ContextDrawer.vue # 检索片段抽屉
|
||||
│ ├── SettingsModal.vue # 设置抽屉(仅 RAG 参数 + 语音 + 批量测试)
|
||||
│ ├── SettingsModal.vue # 设置抽屉(RAG 参数 + 语音 + 拍照开关 + 批量测试)
|
||||
│ └── ServiceControl.vue # 顶栏"启用/停止"按钮(走 er_backend HTTP)
|
||||
├── stores/rag.ts # Pinia store
|
||||
├── utils/tts.ts # TTS 播放器:流式分句播报、自适应语速、原生/Web 双后端
|
||||
├── stores/rag.ts # Pinia store(含 visionAvailable:后端是否支持图像输入)
|
||||
└── types/index.ts # TS 接口 + Window.electronAPI 类型声明
|
||||
```
|
||||
|
||||
@ -77,28 +82,37 @@ interface AppConfig {
|
||||
hardware: { cardType: 'er160' | 'er25' }
|
||||
batchQuestions: string[]
|
||||
batchWaitSeconds: number
|
||||
voice: { enabled: boolean; ttsRate: number; inputGain: number; [key: string]: unknown }
|
||||
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` / `embed.model` 用户配置。
|
||||
**注意**:本项目无 `chat` 字段(生成固定走 llamacppServer)。`embed.baseUrl` 字段存在但不对用户暴露,由 `applyPlatformOverrides` 固定推导。
|
||||
|
||||
**改 `DEFAULT_CONFIG` 只对新配置生效**:`getConfig()` 是 `{ ...DEFAULT_CONFIG.rag, ...raw.rag }` 这样合并的,已部署设备上 `config.json` 里存过的字段会盖掉新默认值。所以调整默认系统提示词这类改动,已有设备不会自动生效,需要删掉 `config.json` 里对应字段(删掉后会回落到新默认值)或在设置页重新保存一次。
|
||||
|
||||
## applyPlatformOverrides 逻辑
|
||||
|
||||
`getConfig()` 末尾调用,按计算卡型号覆盖关键配置:
|
||||
`getConfig()` 末尾调用。**自 `836d25c` 起,推理请求(生成 + 嵌入)统一走 er_backend 代理**(`ER_BACKEND_URL = 'http://127.0.0.1:17700'`),不再由前端按 cardType 自己推导 llama-server/embed-server 端口——服务实际监听哪个端口完全由 er_backend 后端配置决定,前端只认这一个代理入口:
|
||||
|
||||
**ER160(揽月 M50)**:
|
||||
- `chat.provider = 'llamacpp'`(内部字段)
|
||||
- `embed.provider` 若为 `ollama` 改为 `llamacpp`,`local` 保持不变
|
||||
- `llamacppServer.port = 8081`
|
||||
- `llamacppEmbed.port = 17701`
|
||||
- `embed.baseUrl = 'http://127.0.0.1:17701'`(内部推导)
|
||||
- `extraArgs` 为空时自动补默认值
|
||||
- `config.embed.baseUrl` 恒等于 `ER_BACKEND_URL`,与 cardType 无关
|
||||
- `generator.ts`、`statusHandler.ts` 的 `chat-test`、`erBackendHandler.ts` 全部请求 `ER_BACKEND_URL/v1/*`
|
||||
|
||||
**ER25(芯动力 RPP)**:
|
||||
- `embed.provider = 'local'`(强制,本地 CPU)
|
||||
- `llamacppServer.port = 8080`
|
||||
- `extraArgs` 为空时补 `'--ctk bf16 --ctv bf16 --no-warmup -ub 128'`
|
||||
`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 结构(精简后)
|
||||
|
||||
@ -123,6 +137,39 @@ NDrawer (width=900)
|
||||
|
||||
**erBackendHandler.ts** 提供对应 IPC,渲染进程通过 `window.electronAPI.erBackend.*` 调用。
|
||||
|
||||
## 拍照提问
|
||||
|
||||
用摄像头拍照代替打字提问。**不是把图片直接丢给模型问答,而是先把图像转成文字,再走同一套知识库检索流程**——这样答案依然基于手册、带引用标号,跟文字问答一致。
|
||||
|
||||
### 三段式流程(visionHandler.ts)
|
||||
|
||||
```
|
||||
① 图像理解 describeImage(图片) → 一句话描述 caption 推送 vision-query:caption
|
||||
② 检索 embedOne(caption + 用户问题) → search() 取 topK 推送 vision-query:context
|
||||
③ 生成 streamGenerate(用户原始问题, chunks, systemPrompt) 推送 vision-query:delta/done
|
||||
```
|
||||
|
||||
②③ 直接复用文字问答的 `embedOne`/`search`/`streamGenerate`,**没有平行实现一套**;图片只参与第①步,不会传给最终生成模型。事件通道与 `query:*` 平行(`vision-query:stage` / `:caption` / `:context` / `:delta` / `:done` / `:error`)。
|
||||
|
||||
两个刻意的取舍:
|
||||
|
||||
- **检索用 `caption + 问题` 拼接,生成只用用户原始问题**。检索侧拼接是为了扩大召回;生成侧只用原问题是为了让回答聚焦。实测拍无关物体 + 问"如何更换前大灯",答案准确命中前大灯文档且没被图像描述带偏,证明这个分工是必要的。
|
||||
- **第①步用固定提示词而非用户问题**,且明确要求"一句话、不展开"。实测输出 45 tokens 左右、耗时 4.0–5.3 秒;对比让视觉模型直接完整作答的 454 tokens / 31 秒,差一个数量级。
|
||||
|
||||
### 视觉能力探测(ServiceControl.vue → store.visionAvailable)
|
||||
|
||||
后端加载的不是多模态模型时,拍照按钮置灰;换回多模态模型后下一轮轮询(5 秒)自动恢复,不用重启应用。判据顺序:
|
||||
|
||||
1. 日志里的 `allow_image = 1` —— 最准确,但**er_backend 日志缓冲只留最近 200 条,服务跑一会儿这行就被推理日志挤掉**,所以只在还翻得到时采信
|
||||
2. 启动命令 `lastCommand` 含 `--mmproj` —— 不随日志滚动,长期可靠的兜底
|
||||
|
||||
**不要改用 `config.generate.modelType === 'vlm'`**:那是配置意图,用户改了配置但没重启服务时与实际运行的模型不符。
|
||||
|
||||
### 摄像头的两个坑
|
||||
|
||||
- **deviceId 跨会话会失效**:Web 标准不保证 `deviceId` 稳定,配置里存的旧 id 换会话后常常已不存在,拿它做 `getUserMedia` 的 `exact` 约束会抛 `OverconstrainedError`,整个拍照功能不可用。`CameraCapture.vue` 的 `openStream()` 因此带了回退:指定 id 打不开就退回默认摄像头,并把实际拿到的设备 id 回写。
|
||||
- **画面倒置会影响识别质量,不只是观感问题**:这批设备摄像头原始画面是倒的。实测把未旋转的原始帧送去识别,模型描述里会明确写"倒置的室内场景"——它把方向当成了图像特征。所以预览的 CSS 旋转和 `capture()` 里 canvas 的旋转**必须成对出现**,只转预览会变成"看着是正的、存下来的图是倒的"。
|
||||
|
||||
## 关键技术约定
|
||||
|
||||
### LanceDB 余弦检索(lancedb.ts)
|
||||
@ -147,6 +194,53 @@ 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 的响应式对象必须先序列化**:
|
||||
@ -167,7 +261,22 @@ asarUnpack: ['node_modules/@lancedb/**', 'node_modules/apache-arrow/**', 'node_m
|
||||
|
||||
### 版本号管理
|
||||
|
||||
版本号取自最近 git tag(`git describe --tags --abbrev=0`),无 git 环境时读 `.app-version` 文件(已加入 `.gitignore`,构建前生成)。
|
||||
版本号解析在三处保持一致:`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 状态监控
|
||||
|
||||
@ -206,7 +315,8 @@ git push origin main
|
||||
npm install
|
||||
npm run dev:linux # 开发模式(--no-sandbox)
|
||||
npm run package:kylin # 打包 x64(AppImage + deb)
|
||||
npm run package:kylin:arm64 # 打包 arm64(须在 ARM64 机器上,先安装系统 fpm)
|
||||
npm run package:kylin:arm64 # 打包 arm64(须在 ARM64 机器上;真机 Kylin 上 deb 目标会因 fpm bug 失败)
|
||||
npm run package:kylin:arm64:deb # 打包 arm64 deb(推荐,已在真机验证;绕开 fpm bug,见上文"打包"条目)
|
||||
```
|
||||
|
||||
## 同步来源
|
||||
|
||||
53
README.md
@ -4,6 +4,14 @@
|
||||
|
||||
**仅支持 Linux(银河麒麟 Kylin V10 / Ubuntu)**,适配 ER160(揽月 M50 NPU)和 ER25(芯动力 RPP)计算卡。服务进程管理委托给 [er_backend](https://git.drumtm.com/EdgeRhythm/er_backend)(HTTP 17700),前端专注 RAG 逻辑与用户交互。
|
||||
|
||||
## 功能
|
||||
|
||||
- **文档问答**:导入 Markdown/文本手册,提问后检索相关片段并生成带引用标号的回答,点引用可查看原文片段
|
||||
- **语音输入**:Paraformer 流式识别,说话即转文字填入输入框
|
||||
- **语音输出**:边生成边播报,不用等整段答案写完
|
||||
- **拍照提问**:用摄像头拍下实物,先由多模态模型转成一句话描述,再接入同一套知识库检索——所以答案同样基于手册内容、带引用,而不是模型凭空描述图片。需要 er_backend 当前加载的是多模态模型,否则按钮会自动置灰(换回多模态模型后自动恢复)
|
||||
- **批量测试**:按预设问题列表循环提问,用于压测和效果回归
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**:Electron + electron-vite + Vue3 + TypeScript
|
||||
@ -16,10 +24,12 @@
|
||||
|
||||
## 支持硬件
|
||||
|
||||
| 计算卡 | 嵌入方式 | 生成方式 | 生成端口 |
|
||||
|--------|---------|---------|---------|
|
||||
| **ER160**(揽月 M50 NPU) | llamacpp NPU(默认)或本地 CPU | llamacpp | 8081 |
|
||||
| **ER25**(芯动力 RPP) | 本地 CPU(固定) | llamacpp | 8080 |
|
||||
| 计算卡 | 嵌入方式 | 生成方式 |
|
||||
|--------|---------|---------|
|
||||
| **ER160**(揽月 M50 NPU) | llamacpp NPU(默认)或本地 CPU | llamacpp |
|
||||
| **ER25**(芯动力 RPP) | 本地 CPU(固定) | llamacpp |
|
||||
|
||||
推理请求(生成 + 嵌入)统一经 er_backend 代理转发(`http://127.0.0.1:17700`),实际 llama-server 监听哪个端口由 er_backend 后端配置决定,前端不感知、也不需要按计算卡型号推导端口。
|
||||
|
||||
## 目录结构
|
||||
|
||||
@ -35,16 +45,14 @@ elec_rag_ui/
|
||||
│ ├── renderer/ # Vue3 前端
|
||||
│ │ └── src/
|
||||
│ │ ├── App.vue # 根布局(顶栏 + 主区)
|
||||
│ │ ├── components/ # ChatPanel / IndexPanel / SettingsModal 等
|
||||
│ │ ├── components/ # ChatPanel / IndexPanel / CameraCapture / SettingsModal 等
|
||||
│ │ ├── stores/ # Pinia store
|
||||
│ │ ├── utils/ # TTS 播放器、音频采集、Markdown 处理
|
||||
│ │ └── types/ # TypeScript 类型定义
|
||||
│ └── batch_questions.txt # 批量测试问题示例
|
||||
├── models/ # 本地模型(onnx 由 Git LFS 管理)
|
||||
│ ├── bge-small-zh-v1.5/ # 嵌入模型(512 维,~24MB)
|
||||
│ ├── bge-base-zh-v1.5/ # 嵌入模型(768 维,~98MB,onnx 需自行下载)
|
||||
│ ├── bge-large-zh-v1.5/ # 嵌入模型(1024 维,~312MB,onnx 需自行下载)
|
||||
│ ├── asr/ # Paraformer 流式语音识别模型(~376MB)
|
||||
│ └── punct/ # 标点恢复模型(~72MB)
|
||||
│ ├── bge-small-zh-v1.5/ # 嵌入模型(512 维,~24MB),本地 CPU 向量化回退用
|
||||
│ └── asr/ # Paraformer 流式语音识别模型(~376MB)
|
||||
├── docs/ # 示例文档(用于测试索引与问答)
|
||||
├── resources/ # 应用图标、TTS/ASR worker
|
||||
├── stubs/sharp/ # sharp 原生模块 stub
|
||||
@ -68,6 +76,15 @@ systemctl status er-backend
|
||||
|
||||
er_backend 管理界面:`http://<PAD-IP>:17700`(配置模型路径、启动/停止服务)
|
||||
|
||||
## 启用拍照提问
|
||||
|
||||
需要两个条件同时满足:
|
||||
|
||||
1. **er_backend 加载的是多模态模型**——在管理界面把生成模型配成带 mmproj 的视觉模型(启动命令里应带 `--mmproj`)。应用会自动探测:不是多模态模型时拍照按钮置灰,换回多模态模型后 5 秒内自动恢复可用,不用重启应用。
|
||||
2. **在「设置 → 基本设置 → 拍照提问」里打开开关**(默认关闭,因为不是所有部署都会配视觉模型)。
|
||||
|
||||
摄像头用标准 USB UVC 设备即可,无需额外驱动。有多个摄像头时,拍照弹层右上角可以切换。
|
||||
|
||||
## 模型文件
|
||||
|
||||
`models/` 目录中的 `.onnx` 文件通过 **Git LFS** 管理。克隆后拉取实体文件:
|
||||
@ -77,9 +94,9 @@ git lfs install
|
||||
GIT_SSL_NO_VERIFY=true git lfs pull
|
||||
```
|
||||
|
||||
**bge-base / bge-large** 的 onnx 文件需自行从 HuggingFace 下载:
|
||||
- [bge-base-zh-v1.5](https://huggingface.co/BAAI/bge-base-zh-v1.5)(下载 `onnx/model_quantized.onnx`,约 98MB)
|
||||
- [bge-large-zh-v1.5](https://huggingface.co/BAAI/bge-large-zh-v1.5)(下载 `onnx/model_quantized.onnx`,约 312MB)
|
||||
> 语音识别默认不带自动加标点(标点恢复模型未随包分发);如需要可自行准备
|
||||
> [sherpa-onnx punct 模型](https://k2-fsa.github.io/sherpa/onnx/punctuation/models.html),
|
||||
> 在设置里把"标点恢复模型目录"指过去即可,代码本身已支持该功能。
|
||||
|
||||
## 开发模式
|
||||
|
||||
@ -94,16 +111,18 @@ npm run dev:linux # Linux(--no-sandbox,开发专用)
|
||||
|
||||
```bash
|
||||
npm run package:kylin # Linux x64,产出 AppImage + .deb
|
||||
npm run package:kylin:arm64 # Linux ARM64(在 ARM64 机器上执行)
|
||||
# 须先安装系统 fpm:sudo gem install fpm
|
||||
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/simple-rag-app/config.json` |
|
||||
| 向量数据库 | `~/.config/simple-rag-app/lancedb/` |
|
||||
| 配置文件 | `~/.config/elec_rag_ui/config.json` |
|
||||
| 向量数据库 | `~/.config/elec_rag_ui/lancedb/` |
|
||||
|
||||
## 许可证
|
||||
|
||||
|
||||
404
docs/feature-pic-design.md
Normal file
@ -0,0 +1,404 @@
|
||||
# 拍照提问模式 —— 设计方案
|
||||
|
||||
状态:**已实现并通过真机端到端验证**(2026-09-03)。当前生效的是 §11 的二次架构
|
||||
(拍照 → 图像理解转述 → 接入本地知识库检索 → 生成),验证记录见 §12。
|
||||
§1–§10 保留第一版"纯视觉直接问答"的设计与验证过程作为历史记录,**已被 §11 推翻,不再是当前实现**。
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
现有 `elec_rag_ui` 是纯文本 RAG 问答:用户打字提问 → 检索本地知识库 → llama-server 生成回答。
|
||||
本功能希望新增一种输入方式:**用摄像头拍一张照片,直接针对这张照片提问**(例如对着一个实物部件拍照问"这是什么型号"),
|
||||
而不是先打字检索文档。
|
||||
|
||||
## 2. 关键前提核实(2026-09-03 现场查证,ER160 pad,192.168.31.48)
|
||||
|
||||
### 2.1 摄像头硬件 —— 已具备,风险低
|
||||
|
||||
```
|
||||
/dev/video0/1 -> Hy-Usb2.0-1*MIC: Hy-QSXGA(8950) # 约500万像素 USB 摄像头,带麦克风
|
||||
/dev/video2/3 -> Hy-Usb2.0-2*MIC: Hy-8M(8079)-Ca # 约800万像素 USB 摄像头,带麦克风
|
||||
lsusb: 1bcf:2cef / 1bcf:2ce4 Sunplus Innovation Technology Inc.
|
||||
```
|
||||
|
||||
两个都是标准 **USB UVC(USB Video Class)** 摄像头,不是需要专有 ISP 驱动的 CSI 摄像头。
|
||||
Chromium/Electron 的 `navigator.mediaDevices.getUserMedia()` 原生支持 UVC 设备,**不需要额外驱动或胶水代码**。
|
||||
一台设备接了两个摄像头,需要在 UI 里做设备选择(`enumerateDevices()` 列出后选 `deviceId`),
|
||||
或者约定固定用其中一个(待确认哪个摄像头是安装时实际对外的那个)。
|
||||
|
||||
### 2.2 推理后端视觉能力 —— **已确认可用(2026-09-03 复查更新)**
|
||||
|
||||
初次核实时(见本文档历史)当前模型是纯文本的,`allow_image=0`;**复查时 er_backend 已经换成带
|
||||
`--mmproj` 的多模态配置**:
|
||||
|
||||
```
|
||||
/home/work/houmo-HLIELLama-xh2-2.1.0/bin/llama-server \
|
||||
-m /home/work/models/LLM/HiModel_xh2_qwen3.5-9b_w4a8_256_256k_b1_1chip_2cores_v1.4.0_20260708.gguf \
|
||||
--mmproj /home/work/models/LLM/mmproj_xh2_qwen3.5-9b_w4a8_2cores_vit_896x896_v1.4.0_20260708.gguf \
|
||||
-c 8192 --parallel 1 --host 127.0.0.1 --no-warmup --port 8081 --presence_penalty 1.5 --reasoning-budget 0
|
||||
```
|
||||
|
||||
启动日志确认:
|
||||
|
||||
```
|
||||
loaded multimodal model, '.../mmproj_xh2_qwen3.5-9b_w4a8_2cores_vit_896x896_v1.4.0_20260708.gguf'
|
||||
==========model info===========================
|
||||
=allow_image = 1
|
||||
=allow_audio = 0
|
||||
=vision_width = 896
|
||||
=vision_height= 896
|
||||
=model_desc = qwen35 9B Q4_0
|
||||
==========model info===========================
|
||||
```
|
||||
|
||||
**并且做了端到端真实请求验证**(不只是看模型加载日志),直接对 `http://127.0.0.1:17700/v1/chat/completions`
|
||||
(即前端 `generator.ts` 实际会调用的同一个地址)发了一个带 `image_url` content-part 的请求:
|
||||
|
||||
```
|
||||
HTTP 200,耗时 2.6s
|
||||
返回:{"content": "黑色"} —— 测试图片本身就是 1x1 黑色像素,模型回答正确,
|
||||
证明图片被真实理解,不是被忽略/幻觉回答
|
||||
```
|
||||
|
||||
**结论:阻塞项已解除**,现在的 er_backend 代理路径(`ER_BACKEND_URL/v1/chat/completions`)原生支持
|
||||
OpenAI 风格的 `image_url` content-part,前端可以直接照 §5.2 的格式发请求,不需要额外适配层。
|
||||
|
||||
**顺带记录的性能数据**(用于 §6 加载态 UI 设计参考):这次测试用的是 1×1 像素的极简测试图,
|
||||
`prompt_tokens` 却达到了 **808**——印证了启动日志里的警告"Qwen-VL models require at minimum
|
||||
1024 image tokens",即**无论照片内容/分辨率如何,视觉编码本身有一个较高的固定 token 开销**
|
||||
(896×896 的 ViT 编码器决定的,不是按源图片大小线性变化)。prefill 速度约 373 tokens/s,
|
||||
换算下来光是"看懂一张图"这一步就要 ~2 秒起,生成回答的时间还要另外算。
|
||||
|
||||
> **2026-09-03 补充:真实照片实测数据(见 §10)**,一次完整的"描述图片"回答(454 tokens)
|
||||
> 实际耗时 **~31 秒**(14.7 tokens/s),比这里用极简测试图估算的"3-8秒"长不少——真实照片信息量大,
|
||||
> 模型生成的描述篇幅也更长,这才是真实用户会遇到的等待时长。UI 的等待态提示要按这个量级设计,
|
||||
> 不能只按几秒钟的"识别中"来做。
|
||||
|
||||
## 3. 功能范围(建议先做 MVP)
|
||||
|
||||
**MVP 包含:**
|
||||
- 聊天输入区新增"拍照"入口,唤起摄像头取景 → 拍照 → 预览/重拍 → 确认
|
||||
- 确认后的照片作为一条用户消息的"附件",和文字问题一起发送
|
||||
- 复用现有流式回答渲染(think 块、逐字输出等现有 ChatPanel 能力不用改)
|
||||
- 不做检索(不查向量库):拍照提问是纯粹的"图 + 文"直接对话,不掺 RAG 上下文
|
||||
|
||||
**明确不做(本期):**
|
||||
- 不做"拍照 + 知识库联合检索"(比如拍照后再去匹配文档库里的相关图片/文字)——这需要图像
|
||||
embedding/CLIP 之类的能力,目前项目里完全没有这块基础设施,属于完全独立的大功能,先不纳入
|
||||
- 不做连续视频流分析,只做单张静态拍照
|
||||
- 不做照片持久化存储/历史相册(先按"用完即弃"设计,见 §6.5 的取舍说明)
|
||||
|
||||
## 4. 用户体验流程
|
||||
|
||||
```
|
||||
[聊天界面输入框旁] 新增相机图标按钮
|
||||
│ 点击
|
||||
▼
|
||||
[取景弹层] 全屏/半屏显示 <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`,前端弹错误提示)
|
||||
- 长时间连续使用的稳定性
|
||||
@ -26,7 +26,24 @@ const config: Configuration = {
|
||||
buildResources: 'resources',
|
||||
output: 'dist'
|
||||
},
|
||||
files: ['out/**/*'],
|
||||
// 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/**'
|
||||
],
|
||||
asarUnpack: [
|
||||
'node_modules/@lancedb/**',
|
||||
'node_modules/apache-arrow/**',
|
||||
|
||||
@ -1,39 +0,0 @@
|
||||
{
|
||||
"_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
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"cls_token": "[CLS]",
|
||||
"mask_token": "[MASK]",
|
||||
"pad_token": "[PAD]",
|
||||
"sep_token": "[SEP]",
|
||||
"unk_token": "[UNK]"
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
{
|
||||
"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]"
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
{
|
||||
"_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
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
{
|
||||
"cls_token": "[CLS]",
|
||||
"mask_token": "[MASK]",
|
||||
"pad_token": "[PAD]",
|
||||
"sep_token": "[SEP]",
|
||||
"unk_token": "[UNK]"
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
{
|
||||
"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]"
|
||||
}
|
||||
@ -1,56 +0,0 @@
|
||||
# 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
|
||||
```
|
||||
@ -1,46 +0,0 @@
|
||||
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>
|
||||
|
||||
|
||||
|
||||
BIN
models/punct/sherpa-onnx-punct-ct-transformer-zh-en-vocab272727-2024-04-12-int8/model.int8.onnx
(Stored with Git LFS)
@ -14,7 +14,8 @@
|
||||
"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": "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"
|
||||
},
|
||||
"workspaces": ["stubs/sharp"],
|
||||
"dependencies": {
|
||||
|
||||
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 1.2 MiB |
BIN
resources/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
resources/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
resources/icons/24x24.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
resources/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
resources/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
resources/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
resources/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 343 KiB |
BIN
resources/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
@ -1,487 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
137
scripts/package-kylin-arm64-deb.sh
Executable file
@ -0,0 +1,137 @@
|
||||
#!/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
|
||||
@ -2,11 +2,14 @@ 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 自动推导,不对用户暴露
|
||||
baseUrl: string // 由 applyPlatformOverrides 固定为 er_backend 代理地址,不对用户暴露
|
||||
}
|
||||
rag: {
|
||||
chunkSize: number
|
||||
@ -14,6 +17,7 @@ export interface AppConfig {
|
||||
topK: number
|
||||
systemPrompt: string
|
||||
}
|
||||
// 服务的启动参数/端口由 er_backend 统一管理,以下字段仅为历史配置保留,前端不再用于路由
|
||||
llamacppServer: {
|
||||
executablePath: string
|
||||
modelPath: string
|
||||
@ -54,13 +58,17 @@ export interface AppConfig {
|
||||
volume: number
|
||||
}
|
||||
}
|
||||
vision: {
|
||||
enabled: boolean // 拍照提问入口总开关,默认关闭(依赖 er_backend 是否加载了支持视觉的模型)
|
||||
cameraDeviceId: string // 上次选用的摄像头 deviceId,空字符串 = 使用第一个可用设备
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: AppConfig = {
|
||||
embed: {
|
||||
provider: 'llamacpp',
|
||||
localModelPath: '',
|
||||
baseUrl: 'http://127.0.0.1:17701'
|
||||
baseUrl: ER_BACKEND_URL
|
||||
},
|
||||
llamacppServer: {
|
||||
executablePath: '',
|
||||
@ -102,6 +110,10 @@ export const DEFAULT_CONFIG: AppConfig = {
|
||||
volume: 1
|
||||
}
|
||||
},
|
||||
vision: {
|
||||
enabled: false,
|
||||
cameraDeviceId: ''
|
||||
},
|
||||
rag: {
|
||||
chunkSize: 480,
|
||||
chunkOverlap: 0,
|
||||
@ -110,7 +122,9 @@ export const DEFAULT_CONFIG: AppConfig = {
|
||||
你的任务是:
|
||||
- 只能依据用户提供的上下文内容进行回答;
|
||||
- 每个答案中如果有信息来源于上下文,必须标注对应的引用标号(例如:[1],也可以是多个标号,例如:[1][3]);
|
||||
- 如果问题的信息在上下文中找不到,请直接回复:"我不知道。"
|
||||
- 如果上下文中没有足够的信息回答问题,不要编造内容,也不要生硬地只回一句"我不知道"。
|
||||
请用一两句自然、平和的话说明手册中暂未查到相关内容,并给出一条简短、可操作的建议,
|
||||
例如请对方补充车型/年款、具体部件名称,或把故障现象描述得更具体,以便更准确地定位;
|
||||
- 不得使用自己的知识库或常识来回答问题;
|
||||
- 回答应简洁明了,不添加额外解释或发挥。
|
||||
|
||||
@ -194,6 +208,10 @@ 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)
|
||||
@ -206,19 +224,18 @@ 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'
|
||||
}
|
||||
@ -226,8 +243,8 @@ function applyPlatformOverrides(config: AppConfig): void {
|
||||
config.llamacppEmbed.extraArgs = DEFAULT_CONFIG.llamacppEmbed.extraArgs
|
||||
}
|
||||
}
|
||||
// 嵌入服务地址跟随端口
|
||||
config.embed.baseUrl = `http://127.0.0.1:${config.llamacppEmbed.port}`
|
||||
// 嵌入/生成服务地址固定为 er_backend 代理
|
||||
config.embed.baseUrl = ER_BACKEND_URL
|
||||
}
|
||||
|
||||
export function saveConfig(config: AppConfig): void {
|
||||
|
||||
@ -80,7 +80,8 @@ 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'}`)
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { ipcMain } from 'electron'
|
||||
import { execFile } from 'child_process'
|
||||
|
||||
const ER_BACKEND_URL = 'http://localhost:17700'
|
||||
import { ER_BACKEND_URL } from '../config'
|
||||
|
||||
async function isRunning(): Promise<boolean> {
|
||||
try {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { registerIndexHandlers } from './indexHandler'
|
||||
import { registerQueryHandlers } from './queryHandler'
|
||||
import { registerVisionHandlers } from './visionHandler'
|
||||
import { registerStatusHandlers } from './statusHandler'
|
||||
import { registerAsrHandlers } from './asrHandler'
|
||||
import { registerTtsHandlers } from './ttsHandler'
|
||||
@ -11,6 +12,7 @@ import type { BrowserWindow } from 'electron'
|
||||
export function registerIpcHandlers(getWin: () => BrowserWindow | null): void {
|
||||
registerIndexHandlers(getWin)
|
||||
registerQueryHandlers(getWin)
|
||||
registerVisionHandlers(getWin)
|
||||
registerStatusHandlers(getWin)
|
||||
registerAsrHandlers(getWin)
|
||||
registerTtsHandlers(getWin)
|
||||
|
||||
@ -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, '生成服务端口:', config.llamacppServer.port)
|
||||
console.log('[query] 向量化方式:', config.embed.provider, '生成/嵌入服务:er_backend 代理(127.0.0.1:17700)')
|
||||
|
||||
const send = (channel: string, data: unknown): void => {
|
||||
getWin()?.webContents.send(channel, data)
|
||||
@ -40,7 +40,12 @@ export function registerQueryHandlers(getWin: () => BrowserWindow | null): void
|
||||
send('query:stage', { stage: '正在生成...' })
|
||||
|
||||
if (chunks.length === 0) {
|
||||
send('query:delta', { delta: '我不知道。' })
|
||||
// 走到这里说明知识库里一条记录都没有(search 是全表余弦排序后截断,
|
||||
// 只要有文档就一定会返回结果),是"还没入库"而不是"没找到相关内容",
|
||||
// 提示要引导去导入文档,不能只回一句"我不知道"让人以为是模型答不上来
|
||||
send('query:delta', {
|
||||
delta: '知识库中还没有任何文档,暂时无法回答。请先在「设置 → 文档入库」中导入手册,再来提问。'
|
||||
})
|
||||
send('query:done', {})
|
||||
return
|
||||
}
|
||||
@ -50,7 +55,6 @@ 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 }),
|
||||
|
||||
@ -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 } from '../config'
|
||||
import { getConfig, saveConfig, getDbPath, AppConfig, ER_BACKEND_URL } from '../config'
|
||||
import { getDocCount } from '../db/lancedb'
|
||||
import { embedOneLocal, resolveLocalModelPath } from '../rag/localEmbedder'
|
||||
import { syncNpuPolling } from './npuHandler'
|
||||
@ -100,11 +100,12 @@ export function registerStatusHandlers(getWin: () => BrowserWindow | null): void
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('chat-test', async (_event, llamacppConfig: AppConfig['llamacppServer']): Promise<TestResult> => {
|
||||
// 生成服务连接测试(经 er_backend 代理转发到 llama-server)
|
||||
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(`http://127.0.0.1:${llamacppConfig.port}/v1/chat/completions`, {
|
||||
const res = await fetch(`${ER_BACKEND_URL}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
|
||||
68
src/main/ipc/visionHandler.ts
Normal file
@ -0,0 +1,68 @@
|
||||
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) })
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import type { SearchResult } from '../db/lancedb'
|
||||
import type { AppConfig } from '../config'
|
||||
import { ER_BACKEND_URL } from '../config'
|
||||
|
||||
export interface GeneratorStats {
|
||||
tokensPerSecond: number
|
||||
@ -14,12 +14,59 @@ export interface GeneratorCallbacks {
|
||||
onError: (message: string) => void
|
||||
}
|
||||
|
||||
// 生成服务固定使用 llamacpp(ER160/ER25 均通过 er_backend 管理的 llama-server)
|
||||
// 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 流式)
|
||||
export async function streamGenerate(
|
||||
question: string,
|
||||
chunks: SearchResult[],
|
||||
systemPrompt: string,
|
||||
llamacppConfig: AppConfig['llamacppServer'],
|
||||
callbacks: GeneratorCallbacks
|
||||
): Promise<void> {
|
||||
const context = chunks
|
||||
@ -32,23 +79,72 @@ ${context}
|
||||
|
||||
请回答以下问题:${question}`
|
||||
|
||||
const finalSystemPrompt = withToneHint(systemPrompt)
|
||||
|
||||
console.log('\n[generator] ---- 发送给 LLM 的完整消息 ----')
|
||||
console.log(`[generator] 服务地址:http://127.0.0.1:${llamacppConfig.port}`)
|
||||
console.log('[generator] system prompt:\n', systemPrompt)
|
||||
console.log(`[generator] 服务地址:${ER_BACKEND_URL}(er_backend 代理)`)
|
||||
console.log('[generator] system prompt:\n', finalSystemPrompt)
|
||||
console.log('[generator] user message:\n', userMessage)
|
||||
console.log('[generator] ---- 消息结束 ----\n')
|
||||
|
||||
await streamGenerateLlamaCpp(userMessage, systemPrompt, llamacppConfig, callbacks)
|
||||
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
|
||||
}
|
||||
|
||||
// llama.cpp server OpenAI 兼容接口(/v1/chat/completions),SSE 格式
|
||||
async function streamGenerateLlamaCpp(
|
||||
userMessage: string,
|
||||
systemPrompt: string,
|
||||
llamacppConfig: AppConfig['llamacppServer'],
|
||||
async function streamChatCompletion(
|
||||
messages: ChatMessage[],
|
||||
callbacks: GeneratorCallbacks
|
||||
): Promise<void> {
|
||||
const baseUrl = `http://127.0.0.1:${llamacppConfig.port}`
|
||||
const baseUrl = ER_BACKEND_URL
|
||||
let fullResponse = ''
|
||||
let thinkOpen = false
|
||||
const requestSentTime = Date.now()
|
||||
@ -62,21 +158,18 @@ async function streamGenerateLlamaCpp(
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'default',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userMessage }
|
||||
],
|
||||
messages,
|
||||
stream: true,
|
||||
stream_options: { include_usage: true }
|
||||
})
|
||||
})
|
||||
} catch (e) {
|
||||
callbacks.onError(`无法连接 llama.cpp server(${baseUrl}):${String(e)}`)
|
||||
callbacks.onError(`无法连接生成服务(${baseUrl},er_backend 代理):${String(e)}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
callbacks.onError(`llama.cpp server 请求失败:${response.status}`)
|
||||
callbacks.onError(`生成服务请求失败:${response.status}`)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -122,6 +122,25 @@ 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'),
|
||||
@ -188,6 +207,18 @@ 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)
|
||||
@ -303,15 +334,13 @@ 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 bundle 里的 espeak-ng-data 作为 dataDir(sherpa-onnx 校验需要),
|
||||
// 不需要 Piper 引擎本身(bin/model/config),见 findPiperEspeakDataDir 注释。
|
||||
const bundledVits = findBundledVits()
|
||||
const piperForData = findBundledPiper()
|
||||
if (bundledVits && piperForData) {
|
||||
const piperDataDir = findPiperEspeakDataDir()
|
||||
if (bundledVits && piperDataDir) {
|
||||
bundledVitsDir = bundledVits
|
||||
bundledPiperBin = piperForData.bin
|
||||
bundledPiperDir = piperForData.dir
|
||||
bundledPiperModel = piperForData.model
|
||||
bundledPiperConfig = piperForData.config
|
||||
bundledPiperDir = piperDataDir
|
||||
detectedBackend = 'bundled-vits'
|
||||
console.log('[systemTts] detected backend:', detectedBackend, 'at', bundledVitsDir)
|
||||
return detectedBackend
|
||||
@ -594,9 +623,17 @@ 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 })
|
||||
@ -727,6 +764,19 @@ 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)
|
||||
}
|
||||
})()
|
||||
|
||||
@ -894,10 +944,18 @@ 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' })
|
||||
@ -998,6 +1056,20 @@ 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)
|
||||
}
|
||||
})()
|
||||
|
||||
@ -1118,7 +1190,23 @@ 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
|
||||
@ -1162,6 +1250,33 @@ 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++
|
||||
@ -1173,11 +1288,7 @@ export function systemTtsStop(): void {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
currentProcess.kill('SIGTERM')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
killWithEscalation(currentProcess)
|
||||
currentProcess = null
|
||||
}
|
||||
if (currentPlayer) {
|
||||
@ -1186,11 +1297,7 @@ export function systemTtsStop(): void {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
currentPlayer.kill('SIGTERM')
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
killWithEscalation(currentPlayer)
|
||||
currentPlayer = null
|
||||
}
|
||||
queue = []
|
||||
|
||||
@ -17,6 +17,10 @@ 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'),
|
||||
|
||||
|
Before Width: | Height: | Size: 197 KiB After Width: | Height: | Size: 1.2 MiB |
230
src/renderer/src/components/CameraCapture.vue
Normal file
@ -0,0 +1,230 @@
|
||||
<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>
|
||||
@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } 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 {
|
||||
@ -153,19 +154,14 @@ function onAnswerClick(e: MouseEvent): void {
|
||||
popupVisible.value = true
|
||||
}
|
||||
|
||||
async function sendQuery(): Promise<void> {
|
||||
const q = question.value.trim()
|
||||
if (!q) return
|
||||
if (!store.dbStatus.ready) { message.warning('向量库为空,请先入库文档'); return }
|
||||
if (store.querying) return
|
||||
|
||||
// 两种提问方式(文字检索 / 拍照)共用的准备工作:重置状态、打断正在进行的语音输入、停止旧的 TTS 播报
|
||||
function beginQuery(): void {
|
||||
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 = ''
|
||||
@ -185,6 +181,19 @@ async function sendQuery(): Promise<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
|
||||
|
||||
@ -192,13 +201,16 @@ 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() }
|
||||
store.rawAnswer += (data as { delta: string }).delta
|
||||
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()
|
||||
@ -207,6 +219,7 @@ async function sendQuery(): Promise<void> {
|
||||
stopWaitTimer()
|
||||
store.querying = false
|
||||
queryStage.value = ''
|
||||
if (ttsEnabled.value) ttsPlayer.flush()
|
||||
message.error((data as { message: string }).message)
|
||||
cleanup()
|
||||
}
|
||||
@ -228,8 +241,102 @@ 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(); sendQuery() }
|
||||
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 } })
|
||||
}
|
||||
|
||||
// ── 语音输入 ──
|
||||
@ -396,6 +503,18 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -462,13 +581,6 @@ onMounted(() => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => store.querying, (querying) => {
|
||||
// 答案流式生成结束后,整段朗读(先播"开始播报")
|
||||
if (!querying && ttsEnabled.value && store.rawAnswer) {
|
||||
ttsPlayer.speakWithAnnouncement(store.rawAnswer)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopWaitTimer()
|
||||
if (hoverTimer) clearTimeout(hoverTimer)
|
||||
@ -498,10 +610,14 @@ onUnmounted(() => {
|
||||
<div class="q-ic">问</div>
|
||||
<div>
|
||||
<div class="q-label">提问</div>
|
||||
<div class="q-text">{{ sentQuestion }}</div>
|
||||
<img v-if="sentImage" :src="sentImage" class="q-image" alt="提问附带的照片" />
|
||||
<div class="q-text">{{ sentQuestion || (sentImage ? '(未输入文字,根据图片自动识别提问)' : '') }}</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" />
|
||||
@ -571,22 +687,44 @@ 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="输入问题,Enter 发送,Shift+Enter 换行…"
|
||||
:placeholder="pendingImage ? '针对这张照片提问,留空则默认描述图片…' : '输入问题,Enter 发送,Shift+Enter 换行…'"
|
||||
@keydown="handleEnter"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Send -->
|
||||
<button
|
||||
class="i-btn i-btn-primary i-btn-lg send-btn"
|
||||
:disabled="store.querying || !question.trim() || batchRunning"
|
||||
@click="sendQuery"
|
||||
:disabled="store.querying || (!question.trim() && !pendingImage) || batchRunning"
|
||||
@click="handleSend"
|
||||
>
|
||||
<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"/>
|
||||
@ -619,6 +757,14 @@ 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>
|
||||
|
||||
@ -662,6 +808,18 @@ 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 {
|
||||
@ -750,9 +908,11 @@ onUnmounted(() => {
|
||||
display: flex; align-items: flex-end; gap: 14px;
|
||||
}
|
||||
|
||||
.q-input-wrap { flex: 1; min-width: 0; height: 96px; position: relative; }
|
||||
|
||||
.q-input {
|
||||
flex: 1; min-width: 0;
|
||||
height: 96px;
|
||||
width: 100%; box-sizing: border-box;
|
||||
height: 96px; min-height: 0; max-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;
|
||||
@ -762,6 +922,26 @@ 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; }
|
||||
@ -792,15 +972,32 @@ onUnmounted(() => {
|
||||
font-weight: 700;
|
||||
font-family: inherit;
|
||||
transition: all .15s;
|
||||
/* 这三个按钮之前没有 :active 态,点下去没有任何即时反馈。加上即时反馈的同时顺手
|
||||
关掉原生 outline(这是纯触屏界面,不依赖 Tab 键盘导航,不需要保留原生焦点框)。*/
|
||||
outline: none;
|
||||
}
|
||||
.voice-btn:hover { background: var(--panel-3); }
|
||||
/* 真正的"绿框"根因不在 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.on {
|
||||
background: var(--green);
|
||||
border-color: var(--green);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 0 #0a6238;
|
||||
}
|
||||
.voice-btn:disabled { opacity: .6; cursor: default; }
|
||||
.voice-btn:disabled {
|
||||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
background: var(--panel-3);
|
||||
border-color: var(--line);
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.voice-spinner {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
|
||||
@ -30,21 +30,41 @@ 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; return }
|
||||
if (!res.ok) { erUp.value = false; store.visionAvailable = 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -25,7 +25,8 @@ 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')
|
||||
@ -175,6 +176,23 @@ 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><!-- /基本设置 -->
|
||||
|
||||
<!-- ── 语音输入输出 ── -->
|
||||
|
||||
@ -33,6 +33,11 @@ export const useRagStore = defineStore('rag', () => {
|
||||
// 启动时硬件探测结果(null=尚未完成探测)
|
||||
const detectedCard = ref<DetectedCard | null>(null)
|
||||
|
||||
// 生成服务当前加载的模型是否支持图像输入(由 ServiceControl 的状态轮询写入)。
|
||||
// 拍照提问要靠它决定按钮能不能点——后端换成纯文本模型时按钮置灰,
|
||||
// 换回多模态模型后无需重启应用,下一轮轮询就会自动恢复可用。
|
||||
const visionAvailable = ref(false)
|
||||
|
||||
// think 标签由 ChatPanel 负责渲染,此处直接透传
|
||||
const displayAnswer = computed(() => rawAnswer.value)
|
||||
|
||||
@ -83,6 +88,7 @@ export const useRagStore = defineStore('rag', () => {
|
||||
embedServerLogs,
|
||||
npuStats,
|
||||
detectedCard,
|
||||
visionAvailable,
|
||||
refreshDbStatus,
|
||||
loadConfig,
|
||||
saveConfig,
|
||||
|
||||
@ -50,6 +50,10 @@ export interface AppConfig {
|
||||
volume: number
|
||||
}
|
||||
}
|
||||
vision: {
|
||||
enabled: boolean
|
||||
cameraDeviceId: string
|
||||
}
|
||||
}
|
||||
|
||||
export type DetectedCard = 'er160' | 'er25' | 'none'
|
||||
@ -113,6 +117,7 @@ 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>
|
||||
|
||||
@ -148,13 +148,24 @@ export class TtsPlayer {
|
||||
|
||||
this.rawText += text
|
||||
const thinkFree = this.stripThinkContent(this.rawText)
|
||||
const plain = this.markdownToPlain(thinkFree)
|
||||
// 只处理到"安全边界"为止:排除末尾可能还没闭合的 ** 加粗标记。
|
||||
// markdownToPlain 每次都是从头重新解析整段 rawText 的,闭合符号到达前后,
|
||||
// 同一段文字的纯文本渲染结果会不一样(比如未闭合时星号被当普通字符处理,
|
||||
// 闭合后星号被去掉、文字保留)。如果提前把这段还不稳定的文字提交为
|
||||
// lastSpokenPlain,闭合符号一到,plain 文本变化,会被 computePendingDelta
|
||||
// 重新判定成"待朗读",导致这段文字被朗读两遍。
|
||||
const stableBoundary = this.findStableBoundary(thinkFree)
|
||||
const stableRaw = thinkFree.slice(0, stableBoundary)
|
||||
const plain = this.markdownToPlain(stableRaw)
|
||||
const pending = this.computePendingDelta(plain)
|
||||
if (!pending) return
|
||||
|
||||
// 直接用当前未朗读的纯文本替换缓冲区,避免旧 buffer 因格式被二次清洗而变得陈旧/重复
|
||||
// pending 是相对 lastSpokenPlain 的增量(已排除重复),累加进缓冲区而不是替换——
|
||||
// 替换会丢失上一次 append() 里 processBuffer() 还没来得及提取成句子的剩余文本
|
||||
// (比如队列满了暂停提取,或者还没遇到句尾标点),高频小增量调用下会导致正文
|
||||
// 大量丢字,只有 flush() 时残留的最后一小段才会被朗读。
|
||||
this.lastSpokenPlain = plain
|
||||
this.buffer = pending
|
||||
this.buffer += pending
|
||||
this.recordAppend(pending.length)
|
||||
this.processBuffer()
|
||||
}
|
||||
@ -164,6 +175,15 @@ 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 = ''
|
||||
@ -176,6 +196,17 @@ 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)
|
||||
@ -328,6 +359,27 @@ 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
|
||||
|
||||