补充长时间运行后 TTS/ASR 一起失效的真实根因(播放阶段缺超时兜底 导致 aplay 卡死、isSpeaking 永久卡住、僵尸进程拖垮 PulseAudio), 以及用 kill -STOP/-CONT 冻结下游服务来可控复现"子进程偶发挂死" 类 bug 的通用排查手法。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QeoAy5KmkHuKWHAktpEsaN
342 lines
27 KiB
Markdown
342 lines
27 KiB
Markdown
# CLAUDE.md — elec_rag_ui 项目约定与开发指南
|
||
|
||
本文件供 Claude Code 在协助开发此项目时参考。所有文档、注释、提交信息均使用中文。
|
||
|
||
## 项目概述
|
||
|
||
**elec_rag_ui** 是智能电子手册的开源前端工程,**仅支持 Linux(Kylin V10 / Ubuntu)**,**仅支持 ER160(揽月 M50 NPU)和 ER25(芯动力 RPP)硬件**。
|
||
|
||
服务进程管理(llama-server / embed-server 的启动停止)**完全委托给 er_backend**(HTTP 17700),前端不直接 spawn 任何推理进程。设置页面不再提供计算卡型号、嵌入模型、生成模型路径等配置入口,这些均由 er_backend Web 管理界面(端口 17700)配置。
|
||
|
||
输入方式有三种:打字、语音输入(ASR)、**拍照提问**(用摄像头拍一张照片,先由多模态模型转成一句话描述,再接入同一套知识库检索流程,见"拍照提问"章节)。
|
||
|
||
本仓库从 monorepo(electronrag)通过 `scripts/sync-elec-rag-ui.sh` 单向同步而来,历史不保留。
|
||
|
||
## 技术栈
|
||
|
||
- **框架**:Electron + electron-vite + Vue3 + TypeScript
|
||
- **UI 组件库**:Naive UI(弹窗/通知/滑出容器)+ 自定义工业风 CSS
|
||
- **状态管理**:Pinia
|
||
- **向量数据库**:LanceDB(`@lancedb/lancedb`,嵌入式)
|
||
- **本地向量化**:`@huggingface/transformers`(底层 `onnxruntime-node`)
|
||
- **Markdown 渲染**:`marked`
|
||
- **打包**:electron-builder(Linux AppImage + deb)。**arm64 deb 在真机 Kylin 上走 electron-builder 自带的 fpm 路径必现 `tar failed (exit code 2)`**,实际可用的是 `scripts/package-kylin-arm64-deb.sh`(`npm run package:kylin:arm64:deb`),改用 `electron-builder --linux dir --arm64` 产出 unpacked 目录后手工 `dpkg-deb` 打包,已在真机验证
|
||
|
||
## 文件说明
|
||
|
||
```
|
||
src/
|
||
├── main/
|
||
│ ├── index.ts # 主进程:disableHardwareAcceleration, setFullScreen, Menu null
|
||
│ ├── config.ts # 读写 config.json,含 applyPlatformOverrides
|
||
│ └── ipc/
|
||
│ ├── index.ts # 注册所有 IPC 处理器
|
||
│ ├── indexHandler.ts # 文件选择、文档入库(分块→向量化→写入LanceDB)
|
||
│ ├── queryHandler.ts # 查询(向量化→检索→流式生成),query:done 含 GeneratorStats
|
||
│ ├── visionHandler.ts # 拍照提问(图像理解→拼检索查询→复用检索+生成)
|
||
│ ├── statusHandler.ts # 数据库状态、配置读写、测试连接(无 Ollama 分支)
|
||
│ ├── erBackendHandler.ts # er_backend HTTP 17700 代理 IPC(启动/停止/状态)
|
||
│ ├── hardwareHandler.ts # 硬件探测:hardware:get-detected / hardware:detected
|
||
│ └── npuHandler.ts # NPU 状态轮询(hm_smi),推送 npu:stats
|
||
│ └── rag/
|
||
│ ├── splitter.ts # 递归收集 .md/.txt,自实现递归字符分块
|
||
│ ├── embedder.ts # 向量化路由:local / llamacpp(无 Ollama 分支)
|
||
│ ├── localEmbedder.ts # 本地 CPU 向量化(Transformers.js 单例,懒加载)
|
||
│ ├── generator.ts # 流式生成 + describeImage 图像理解,统一走 er_backend 代理
|
||
│ ├── hardwareDetector.ts # 探测 hm_smi / rpp-info 判断 er160/er25/none
|
||
│ └── ollamaModels.ts # (保留文件,此项目不使用 Ollama)
|
||
├── preload/index.ts # contextBridge 暴露 electronAPI(无 llamaServer IPC)
|
||
└── renderer/src/
|
||
├── assets/main.css # CSS 变量(色板)、.i-btn 按钮体系
|
||
├── App.vue # 根布局:顶栏、主区;服务状态由 SSE 维护
|
||
├── components/
|
||
│ ├── IndexPanel.vue # 文档入库面板
|
||
│ ├── ChatPanel.vue # 问答气泡、Think 块、引用徽章、批量发送、拍照入口
|
||
│ ├── CameraCapture.vue # 拍照弹层:取景/拍照/重拍/切换摄像头(纯交互,不含业务逻辑)
|
||
│ ├── ContextDrawer.vue # 检索片段抽屉
|
||
│ ├── SettingsModal.vue # 设置抽屉(RAG 参数 + 语音 + 拍照开关 + 批量测试)
|
||
│ └── ServiceControl.vue # 顶栏"启用/停止"按钮(走 er_backend HTTP)
|
||
├── utils/tts.ts # TTS 播放器:流式分句播报、自适应语速、原生/Web 双后端
|
||
├── stores/rag.ts # Pinia store(含 visionAvailable:后端是否支持图像输入)
|
||
└── types/index.ts # TS 接口 + Window.electronAPI 类型声明
|
||
```
|
||
|
||
## AppConfig 结构
|
||
|
||
```typescript
|
||
interface AppConfig {
|
||
embed: {
|
||
provider: 'local' | 'llamacpp' // 无 Ollama 分支
|
||
localModelPath: string // 本地 onnx 模型目录
|
||
// baseUrl / model 由 applyPlatformOverrides 内部推导,不暴露给用户
|
||
}
|
||
rag: { chunkSize: number; chunkOverlap: number; topK: number; systemPrompt: string }
|
||
llamacppServer: {
|
||
executablePath: string; modelPath: string; port: number; numCtx: number
|
||
parallelSlots: number; threads: number; gpuLayers: number
|
||
noWarmup: boolean; extraArgs: string
|
||
}
|
||
llamacppEmbed: {
|
||
modelPath: string; port: number; extraArgs: string
|
||
}
|
||
hardware: { cardType: 'er160' | 'er25' }
|
||
batchQuestions: string[]
|
||
batchWaitSeconds: number
|
||
voice: {
|
||
asr: {
|
||
enabled: boolean; executablePath: string; modelDir: string; numThreads: number
|
||
vadSilenceTimeoutMs: number; vadEnergyThreshold: number; inputGain: number
|
||
punctuationModelDir: string
|
||
}
|
||
tts: { enabled: boolean; voiceURI: string; pitch: number; rate: number; volume: number }
|
||
}
|
||
vision: {
|
||
enabled: boolean // 拍照提问入口的总开关,默认关闭
|
||
cameraDeviceId: string // 上次选用的摄像头,空=用第一个可用设备(该 id 可能失效,见"拍照提问")
|
||
}
|
||
}
|
||
```
|
||
|
||
**注意**:本项目无 `chat` 字段(生成固定走 llamacppServer)。`embed.baseUrl` 字段存在但不对用户暴露,由 `applyPlatformOverrides` 固定推导。
|
||
|
||
**改 `DEFAULT_CONFIG` 只对新配置生效**:`getConfig()` 是 `{ ...DEFAULT_CONFIG.rag, ...raw.rag }` 这样合并的,已部署设备上 `config.json` 里存过的字段会盖掉新默认值。所以调整默认系统提示词这类改动,已有设备不会自动生效,需要删掉 `config.json` 里对应字段(删掉后会回落到新默认值)或在设置页重新保存一次。
|
||
|
||
## applyPlatformOverrides 逻辑
|
||
|
||
`getConfig()` 末尾调用。**自 `836d25c` 起,推理请求(生成 + 嵌入)统一走 er_backend 代理**(`ER_BACKEND_URL = 'http://127.0.0.1:17700'`),不再由前端按 cardType 自己推导 llama-server/embed-server 端口——服务实际监听哪个端口完全由 er_backend 后端配置决定,前端只认这一个代理入口:
|
||
|
||
- `config.embed.baseUrl` 恒等于 `ER_BACKEND_URL`,与 cardType 无关
|
||
- `generator.ts`、`statusHandler.ts` 的 `chat-test`、`erBackendHandler.ts` 全部请求 `ER_BACKEND_URL/v1/*`
|
||
|
||
`applyPlatformOverrides` 现在只负责按计算卡型号补默认启动参数(`extraArgs` 为空时才生效):
|
||
|
||
**ER160(揽月 M50)**:`embed.provider` 若不是 `local` 则改为 `llamacpp`;`llamacppServer.extraArgs` 默认 `'--port 8081 --presence_penalty 1.5'`;`llamacppEmbed.extraArgs` 默认走 `DEFAULT_CONFIG` 里的值
|
||
|
||
**ER25(芯动力 RPP)**:`embed.provider = 'local'`(强制,本地 CPU);`llamacppServer.extraArgs` 默认 `'--port 8080 --ctk bf16 --ctv bf16 --no-warmup -ub 128'`
|
||
|
||
## SettingsModal.vue 结构(精简后)
|
||
|
||
```
|
||
NDrawer (width=900)
|
||
├── .s-tabs 'index' | 'basic' | 'voice' | 'batch' | 'log'
|
||
├── 文档入库 <IndexPanel />
|
||
├── 基本设置 检索参数(Top-K + 系统提示词)── 无计算卡/模型配置
|
||
├── 语音设置 TTS/ASR 参数
|
||
├── 批量测试 问题列表 + 等待时间滑块
|
||
└── 服务日志 嵌入/生成服务日志框(isLinux 可见)
|
||
```
|
||
|
||
计算卡型号、嵌入模型路径、生成模型路径等配置已从设置页移除,统一由 er_backend(http://<PAD>:17700)管理。
|
||
|
||
## er_backend 集成
|
||
|
||
**ServiceControl.vue** 始终走 er_backend HTTP(`useErBackend = true`):
|
||
- 启动:`POST /api/generate/start`,`POST /api/embed/start`(ER25 跳过)
|
||
- 停止:`POST /api/generate/stop`,`POST /api/embed/stop`
|
||
- 状态 SSE:`GET /api/generate/logs`,`GET /api/embed/logs`
|
||
|
||
**erBackendHandler.ts** 提供对应 IPC,渲染进程通过 `window.electronAPI.erBackend.*` 调用。
|
||
|
||
## 拍照提问
|
||
|
||
用摄像头拍照代替打字提问。**不是把图片直接丢给模型问答,而是先把图像转成文字,再走同一套知识库检索流程**——这样答案依然基于手册、带引用标号,跟文字问答一致。
|
||
|
||
### 三段式流程(visionHandler.ts)
|
||
|
||
```
|
||
① 图像理解 describeImage(图片) → 一句话描述 caption 推送 vision-query:caption
|
||
② 检索 embedOne(caption + 用户问题) → search() 取 topK 推送 vision-query:context
|
||
③ 生成 streamGenerate(用户原始问题, chunks, systemPrompt) 推送 vision-query:delta/done
|
||
```
|
||
|
||
②③ 直接复用文字问答的 `embedOne`/`search`/`streamGenerate`,**没有平行实现一套**;图片只参与第①步,不会传给最终生成模型。事件通道与 `query:*` 平行(`vision-query:stage` / `:caption` / `:context` / `:delta` / `:done` / `:error`)。
|
||
|
||
两个刻意的取舍:
|
||
|
||
- **检索用 `caption + 问题` 拼接,生成只用用户原始问题**。检索侧拼接是为了扩大召回;生成侧只用原问题是为了让回答聚焦。实测拍无关物体 + 问"如何更换前大灯",答案准确命中前大灯文档且没被图像描述带偏,证明这个分工是必要的。
|
||
- **第①步用固定提示词而非用户问题**,且明确要求"一句话、不展开"。实测输出 45 tokens 左右、耗时 4.0–5.3 秒;对比让视觉模型直接完整作答的 454 tokens / 31 秒,差一个数量级。
|
||
|
||
### 视觉能力探测(ServiceControl.vue → store.visionAvailable)
|
||
|
||
后端加载的不是多模态模型时,拍照按钮置灰;换回多模态模型后下一轮轮询(5 秒)自动恢复,不用重启应用。判据顺序:
|
||
|
||
1. 日志里的 `allow_image = 1` —— 最准确,但**er_backend 日志缓冲只留最近 200 条,服务跑一会儿这行就被推理日志挤掉**,所以只在还翻得到时采信
|
||
2. 启动命令 `lastCommand` 含 `--mmproj` —— 不随日志滚动,长期可靠的兜底
|
||
|
||
**不要改用 `config.generate.modelType === 'vlm'`**:那是配置意图,用户改了配置但没重启服务时与实际运行的模型不符。
|
||
|
||
### 摄像头的两个坑
|
||
|
||
- **deviceId 跨会话会失效**:Web 标准不保证 `deviceId` 稳定,配置里存的旧 id 换会话后常常已不存在,拿它做 `getUserMedia` 的 `exact` 约束会抛 `OverconstrainedError`,整个拍照功能不可用。`CameraCapture.vue` 的 `openStream()` 因此带了回退:指定 id 打不开就退回默认摄像头,并把实际拿到的设备 id 回写。
|
||
- **画面倒置会影响识别质量,不只是观感问题**:这批设备摄像头原始画面是倒的。实测把未旋转的原始帧送去识别,模型描述里会明确写"倒置的室内场景"——它把方向当成了图像特征。所以预览的 CSS 旋转和 `capture()` 里 canvas 的旋转**必须成对出现**,只转预览会变成"看着是正的、存下来的图是倒的"。
|
||
|
||
## 关键技术约定
|
||
|
||
### LanceDB 余弦检索(lancedb.ts)
|
||
|
||
兆芯 KX-U6780A 无 AVX2,LanceDB 预编译包 SIMD 代码触发 `Illegal instruction`。`search()` 改为 `t.query().toArray()` + 纯 JS 余弦相似度:
|
||
|
||
```typescript
|
||
function toNumberArray(v: unknown): number[] | Float32Array {
|
||
if (v instanceof Float32Array || Array.isArray(v)) return v
|
||
if (typeof (v as any)?.toArray === 'function') return (v as any).toArray()
|
||
return Array.from(v as Iterable<number>)
|
||
}
|
||
```
|
||
|
||
**Arrow Vector 陷阱**:`toArray()` 返回行中的 vector 字段是 Arrow `Vector<FixedSizeList>` 对象,`b[i]` 返回 `undefined`,必须用 `toNumberArray()` 转换。
|
||
|
||
### 并发向量化(embedder.ts)
|
||
|
||
llamacpp 分支使用 `Promise.all` 并发发送,让服务端有机会攒批送 NPU:
|
||
```typescript
|
||
const vectors = await Promise.all(texts.map((t, i) => fetchOne(t, i)))
|
||
```
|
||
每条 60 秒超时,失败重试 2 次。
|
||
|
||
### 语音输出:流式播报(utils/tts.ts)
|
||
|
||
答案边生成边播报,不等整段生成完。`ChatPanel` 在 `onDelta` 里实时 `ttsPlayer.append(delta)`,`onDone`/`onError` 里 `flush()` 收尾;`TtsPlayer` 内部按句子边界切分入队,播放跟不上文字时会自适应降速。
|
||
|
||
三个踩过的坑:
|
||
|
||
- **`append()` 里必须是 `this.buffer += pending` 而不是 `=`**。LLM 流式增量很碎(常几个字一个 chunk),用赋值会把上一次还没凑够一句、尚未送去朗读的残留直接冲掉,实际效果是整段正文都丢了、只念出 `flush()` 时残留的一个句号。
|
||
- **加粗文本会被念两遍**,根因是 `markdownToPlain()` 每次都从头重新解析整段 `rawText`,而 `**` 闭合前后同一段文字的纯文本结果不一样(未闭合时星号当普通字符,闭合后星号被去掉)。提前把这段还不稳定的文字记为"已朗读",闭合符号一到就会被当成新内容重念。`findStableBoundary()` 因此只处理到最后一个已闭合的 `**` 为止,未闭合的尾巴留到下次;`flush()` 时生成已结束,不再有这顾虑,直接处理完。
|
||
- **"开始播报"提示音要单独入队**(`announceStart()`),不能碰流式缓冲区状态。
|
||
- **生成中途才打开 TTS,会漏掉开关之前已生成的正文**。`append()` 开头是 `if (!this.options.enabled) return`,TTS 关闭期间每次 `onDelta` 调用都被直接挡掉,内部 `rawText`/`lastSpokenPlain` 完全不累积;中途打开后下一次 `onDelta` 只送来新增量,等于从打开那一刻"之后"才开始播。`ChatPanel.vue` 的 `toggleTts()` 因此在 `store.querying` 为真时,除了 `announceStart()` 还要补一次 `ttsPlayer.append(store.rawAnswer)`,把已生成的全文"补喂"一遍,相当于告诉播放器"这就是从头到现在的内容",之后的增量在这个基础上正常追加、不会重复。
|
||
|
||
**调试这类"实际发声内容对不对"的问题时的教训**:想在渲染进程 monkey-patch `window.electronAPI.xxx` 来拦截调用做验证是无效的——`contextBridge.exposeInMainWorld` 暴露的对象默认被冻结,赋值会静默失败、不报错,很容易误判成"这条代码路径没走到"。真正能验证的办法是去主进程对应的 `ipcMain.handle(...)` 里临时加 `console.log`(验证完记得删),或者用 CDP 的 `Input.dispatchMouseEvent` 模拟真实点击、`Runtime.evaluate` 读 DOM/计算样式——这两种都是从"实际发生的效果"验证,不依赖能否篡改渲染进程里的 API 对象。
|
||
|
||
### 长时间运行后语音输入输出一起失效(systemTts.ts)
|
||
|
||
真机反馈:pad 跑了一两周之后语音输入输出全部失效。现场诊断:pulseaudio 占 49% CPU,系统里积压了 70 个从几天前就存在的 `aplay -t raw` 残留进程。
|
||
|
||
**根因跟 `WAKE_IDLE_THRESHOLD_MS` 那条注释里提到的"PulseAudio 大约 5 秒空闲就挂起音频设备"是同一件事**:`speakWithVits`/`speakWithPiperStreaming` 每播一个分句就 spawn 一个 aplay,写完数据后调用 `stdin.end()`,然后完全依赖 aplay 自己触发 `'exit'` 事件才清理 `isSpeaking` 状态——没有任何超时兜底(合成侧 `vitsWorker` 倒是本来就有 60 秒超时)。长时间运行后一旦某次"唤醒音频通路"没成功,aplay 会卡在往挂死的 PulseAudio 写数据,`'exit'` 永远不触发,`isSpeaking` 永久卡 `true`,`playNext()` 不再处理队列,TTS 直接哑掉。**ASR 走的是渲染进程 `getUserMedia`/`AudioContext`,和 TTS 共用同一个被拖垮的 PulseAudio 实例**,所以语音输入也一起失效。更麻烦的是 `systemTtsStop()` 原来只发 `SIGTERM`,卡在系统调用里的 aplay 根本杀不掉——每次打断播报(每次提问前都会调一次)都可能攒下一个杀不死的僵尸进程,日积月累到 70 个,把 PulseAudio 彻底拖垮。
|
||
|
||
**修复**:播放阶段加 `PLAYER_EXIT_TIMEOUT_MS`(8 秒)看门狗,`stdin.end()` 后超时未触发 `'exit'` 就判定通路卡死,`SIGKILL` 强杀并 `done(error)` 收尾;`systemTtsStop()` 加 `killWithEscalation()`,先 `SIGTERM`,1.5 秒宽限期后还没退出就 `SIGKILL`。
|
||
|
||
**验证方法**:真机上拿 `kill -STOP <pulseaudio_pid>` 故意冻结 PulseAudio,模拟"音频通路卡死"的真实场景,再用 CDP 触发一次播报,确认看门狗在约 8 秒后正确强杀卡住的 aplay、队列自动继续、最终无残留进程,`kill -CONT` 解冻收尾。**这个冻结/解冻手法可以复用**:任何"子进程偶发挂死"的 bug,不必真的等它在真机自然复现,直接 `SIGSTOP` 它依赖的下游服务就能可控地造出卡死场景。
|
||
|
||
### 系统提示词:查不到内容时的答复
|
||
|
||
提示词要求"说明手册中暂未查到,并给一条可操作的建议",而不是硬邦邦一句"我不知道"。
|
||
|
||
**光在提示词里写"每次换一种措辞"是无效的**——模型每次请求都是无状态的,不知道上次说了什么,实测同样输入连发三次答复一字不差,而且会照抄提示词里给的示例。所以措辞变化由代码侧提供:`generator.ts` 的 `withToneHint()` 每次请求随机挑一条引导,让输入本身产生差异。
|
||
|
||
**没有采用调高 temperature 的做法**:那会同时影响命中文档的正常回答,而维修手册问答更看重稳定准确。
|
||
|
||
**`withToneHint()` 的插入位置踩过一次真实的坑**:第一版把引导语整句追加在系统提示词最末尾(紧邻用户问题,模型注意力权重最高的位置)。真机复测发现:同一个检索到充分相关内容、理应直接作答的问题,连续追问 3 次,有 1 次被模型硬套上"手册里未查到…"的开场白,即便后面还是照常给出了详细步骤——答案读起来却像没查到,用户也确实这样反馈过。根因是这条指令脱离了它原本所属的"仅在没有足够信息时才用"的条件分句,变成了提示词末尾一条独立显眼的指令,模型会倾向"找机会用上它"。**现在改为把引导语嵌入到原本那条"如果没有足够信息"分句内部**(`NO_MATCH_BULLET_MARKER` 定位插入点,在 `config.ts` 的默认提示词里找 `以便更准确地定位;` 这句),位置和从属语气都跟原本不带随机性的版本保持一致。用户在设置页改写过这句话、不含该标记时,`withToneHint()` 原样返回不插入,不强行拼接导致语义错位。
|
||
|
||
**排查这个 bug 时的一个教训**:之前测试这个功能时没能发现问题,是因为测试脚本直接拿纯净的 `systemPrompt` 调用生成接口,没有复现 `withToneHint()` 在真实请求里的行为——以后验证跟 prompt 拼接相关的改动,测试必须经过完整的 `streamGenerate()`/`withToneHint()` 调用链,不能绕开直接测底层生成接口。
|
||
|
||
另外,`queryHandler`/`visionHandler` 里 `chunks.length === 0` 的兜底文案说的是"知识库还没有文档,请先导入"而不是"我不知道"——`search()` 是全表余弦排序后截断,只要库里有文档就一定返回结果,走到那个分支只可能是库为空。
|
||
|
||
### 触屏 `:hover` 粘滞导致状态色被覆盖(ChatPanel.vue 语音按钮)
|
||
|
||
`.voice-btn`(语音输入/语音输出/拍照提问)点击切换"开启"状态后,按钮背景该变成纯 `--green` 填充,真机上却只有边框变绿、背景还是浅灰——看起来像凭空多了一个"焦点框"。
|
||
|
||
**排查弯路**:一开始以为是浏览器原生 focus outline,加了 `outline:none` + `:active` 反馈,真机复测毫无变化。
|
||
|
||
**真正根因是 CSS 优先级,跟 outline/focus 完全无关**:`.voice-btn.on`(2 个选择器)想把背景铺满 `--green`,但 `.voice-btn:not(:disabled):hover`(3 个选择器)优先级更高。触屏点击后没有鼠标可以"移开",`:hover` 状态会一直粘在刚点过的按钮上不消失(触屏设备通病),于是 hover 规则的浅灰 `background` 把 `.on` 的绿色背景盖掉,只剩下没被 hover 碰到的 `border-color: var(--green)` 还在——视觉上就是一个孤零零的绿框。修复:hover 规则加 `:not(.on)` 排除已开启状态。
|
||
|
||
**排查方法论**:真机截图只能看到表象("好像是焦点框"),容易被误导。改用 CDP(Chrome DevTools Protocol)——启动时加 `--remote-debugging-port=9222`,写个不依赖 `ws` 包的最小原生 WebSocket 客户端(`net`+`crypto` 手搓握手帧)连上去,用 `Input.dispatchMouseEvent` 模拟真实点击、`Runtime.evaluate` 读点击后的 `getComputedStyle()`,直接看到 `background` 实际渲染值是 `--panel-3` 不是 `--green`,才一次性定位到真正被覆盖的是哪条规则。**以后排查"样式明明写了但没生效"类问题,CDP 读计算样式比盯着截图猜更可靠。**
|
||
|
||
### Vue Proxy IPC 序列化
|
||
|
||
Pinia 响应式对象直接传给 `ipcRenderer.invoke()` 会触发结构化克隆错误。**所有传入 IPC 的响应式对象必须先序列化**:
|
||
```typescript
|
||
const plain = JSON.parse(JSON.stringify(reactiveObj))
|
||
await window.electronAPI.someIpc(plain)
|
||
```
|
||
|
||
### sharp stub
|
||
|
||
`stubs/sharp/` 作为 workspace 包(名称 `sharp`,版本 `0.34.5`)满足 `@huggingface/transformers` v4 的硬性 `require("sharp")`,避免打包后崩溃。
|
||
|
||
### 原生模块打包
|
||
|
||
```typescript
|
||
asarUnpack: ['node_modules/@lancedb/**', 'node_modules/apache-arrow/**', 'node_modules/onnxruntime-node/**']
|
||
```
|
||
|
||
### 版本号管理
|
||
|
||
版本号解析在三处保持一致:`electron-builder.config.ts`、`electron.vite.config.ts`、`scripts/package-kylin-arm64-deb.sh`。逻辑都是:`git describe --tags --abbrev=0`(去掉 `v` 前缀)→ 失败读项目根目录 `.app-version` 文件(同样去掉 `v` 前缀,已加入 `.gitignore`,PAD 部署等无 `.git` 环境下手动生成)→ 再失败分别 fallback 到 `'0.0.0'` / `'dev'` / `package.json` 版本号。**`.app-version` 缺失是 PAD 上界面显示版本号变成 `dev` 的常见原因**——rsync 部署到 PAD 时要记得同步这个文件。
|
||
|
||
### 应用图标打包踩坑
|
||
|
||
`resources/icon.png` 是图标源文件(1024×1024),`resources/icons/{16,24,32,48,64,128,256,512}x{同}.png` 是预生成的常规尺寸(`sips`/ImageMagick 生成,随图标一起更新)。两处坑:
|
||
|
||
1. **主进程窗口图标**:`src/main/index.ts` 用 `join(__dirname, '../../resources/icon.png')` 相对路径定位,`electron-builder.config.ts` 的 `files` 必须显式包含 `'resources/icon.png'`(默认只有 `out/**/*`,不会自动带上)。
|
||
2. **桌面菜单图标只装 1024×1024 不够**:不少桌面环境(如 UKUI)按常规小尺寸(48×48 等)查找图标,找不到就直接回退成缺省图标,不会从 1024 自动缩放。`scripts/package-kylin-arm64-deb.sh` 把 9 个尺寸全部装进 `hicolor/<size>x<size>/apps/`,另外在 `/usr/share/pixmaps/` 放一份兼容不认图标主题的老式菜单。装完新图标后需要**注销重登或重启**桌面 shell 才会刷新(图标缓存 `~/.cache/icon-cache.kcache`、`~/.cache/ksycoca5_*` 不会主动感知内容变化)。
|
||
|
||
### 打包体积精简(789M → 664M)
|
||
|
||
`electron-builder.config.ts` 的 `files` 排除了几个只在渲染层用、主进程从不 `require` 的包(`naive-ui`/`date-fns`/`highlight.js`/`lodash`/`lodash-es`,electron-vite build 时已被 Vite 静态打包进 `out/renderer/*.js`),以及 `onnxruntime-web`(`@huggingface/transformers` 的硬依赖,但项目强制走 `onnxruntime-node`,完全未用)。**排除 glob 必须写成 `'!**/node_modules/xxx/**'`,不能省 `**/` 前缀**——`'!node_modules/xxx/**'` 在真机验证时基本不生效(electron-builder 按完整相对路径匹配)。
|
||
|
||
`resources/tts/piper/` 只保留 `espeak-ng-data/` 子目录(131M → 18M):TTS 优先级第一位永远命中 VITS(小雅),Piper 自己的引擎(神经语音模型、二进制、自带 onnxruntime、phonemize 库)从未被实际调用,VITS 只是借用 Piper bundle 里的 `espeak-ng-data` 当 sherpa-onnx 校验用的 dataDir。`systemTts.ts` 的 `findPiperEspeakDataDir()` 只检查这个子目录是否存在,不要求 Piper 引擎的 bin/model/config 齐全。
|
||
|
||
`models/bge-base-zh-v1.5`、`models/bge-large-zh-v1.5`(只有 tokenizer 元数据无 onnx 权重,代码零引用)、`models/punct`(标点恢复模型,`extraResources` 从未把它纳入打包,功能一直是禁用状态)已从仓库删除。标点恢复功能代码保留(`punctuationModelDir` 为空时优雅跳过),需要的话自行准备模型再在设置里指定目录。
|
||
|
||
## NPU 状态监控
|
||
|
||
每 5 秒调用 `hm_smi --fields DDR_Memory_Free DDR_Memory_Total Core0 Core1 -a`,推送 `npu:stats` 事件:
|
||
```typescript
|
||
interface NpuStats { memUsed: number; memTotal: number; temp: number }
|
||
```
|
||
仅 `cardType === 'er160'` 时启动轮询;切换为 er25 时停止并推送 `null`。
|
||
|
||
## Git LFS 说明
|
||
|
||
`models/**/*.onnx` 通过 Git LFS 管理(见 `.gitattributes`)。克隆后必须:
|
||
|
||
```bash
|
||
git lfs install
|
||
GIT_SSL_NO_VERIFY=true git lfs pull # git.drumtm.com TLS 证书已过期,需绕过
|
||
```
|
||
|
||
推送含 LFS 对象的提交:
|
||
```bash
|
||
GIT_SSL_NO_VERIFY=true git lfs push origin main
|
||
git push origin main
|
||
```
|
||
|
||
## Kylin V10 / Ubuntu 适配要点
|
||
|
||
1. **禁用硬件加速**:兆芯集显 `MAX_FRAGMENT_UNIFORM_VECTORS=0` 导致 Skia 崩溃,`app.disableHardwareAcceleration()` 在 Linux 下强制启用
|
||
2. **全屏**:`ready-to-show` 后调用 `setFullScreen(true)`,覆盖任务栏
|
||
3. **菜单**:`Menu.setApplicationMenu(null)` 移除菜单栏
|
||
4. **onnxruntime-node**:必须在 `dependencies` 直接声明(不能为传递依赖),用 `require()` 而非 `await import()`
|
||
5. **npmrc**:`onnxruntime_node_install=skip` 跳过 CUDA EP 下载(NuGet 302 重定向无法跟随)
|
||
|
||
## 开发命令
|
||
|
||
```bash
|
||
npm install
|
||
npm run dev:linux # 开发模式(--no-sandbox)
|
||
npm run package:kylin # 打包 x64(AppImage + deb)
|
||
npm run package:kylin:arm64 # 打包 arm64(须在 ARM64 机器上;真机 Kylin 上 deb 目标会因 fpm bug 失败)
|
||
npm run package:kylin:arm64:deb # 打包 arm64 deb(推荐,已在真机验证;绕开 fpm bug,见上文"打包"条目)
|
||
```
|
||
|
||
## 同步来源
|
||
|
||
本仓库由 monorepo(electronrag)通过以下方式单向同步:
|
||
|
||
```bash
|
||
# 在 monorepo 根目录执行
|
||
bash scripts/sync-elec-rag-ui.sh "feat: 功能说明"
|
||
```
|
||
|
||
脚本同步 `src/elec_rag_ui/` → 独立仓库根目录,`models/` 和 `docs/` 同步至对应子目录,自动提交并推送(含 LFS 对象)。
|
||
|
||
**不要直接向本仓库提交代码**(除紧急修复外),应在 monorepo 的 `src/elec_rag_ui/` 修改后通过同步脚本推送。
|
||
|
||
## 注意事项
|
||
|
||
- 切换嵌入模型后必须重建索引(维度不同)
|
||
- Vue 响应式对象通过 IPC 传递前必须序列化
|
||
- PAD 部署使用 `--no-sandbox` 启动(`/usr/bin/智能电子手册` 脚本已内置)
|
||
- LFS push 须加 `GIT_SSL_NO_VERIFY=true`(git.drumtm.com 证书已过期)
|
||
- `libva error` 和 `GLSL warning` 为兆芯集显无害日志,可忽略
|