elec_rag_ui/CLAUDE.md
ssurui 1fdbc7353f docs: 记录措辞引导语位置导致误判"查不到"的真实踩坑与修复
补充 withToneHint() 的插入位置问题:追加在提示词末尾会让这条本该
"仅在没有足够信息时才用"的条件引导,变成模型倾向主动找机会使用的
独立指令,真机复测连续3次有1次把有效答案误判成"查不到"。记录修复
方式(嵌入原条件分句内部)和验证方法论的教训(测试必须走完整调用链,
不能绕开直接测生成接口)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QeoAy5KmkHuKWHAktpEsaN
2026-10-05 17:06:14 +08:00

22 KiB
Raw Blame History

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 结构

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://: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 余弦相似度:

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:

const vectors = await Promise.all(texts.map((t, i) => fetchOne(t, i)))

每条 60 秒超时,失败重试 2 次。

语音输出:流式播报(utils/tts.ts)

答案边生成边播报,不等整段生成完。ChatPanel 在 onDelta 里实时 ttsPlayer.append(delta),onDone/onError 里 flush() 收尾;TtsPlayer 内部按句子边界切分入队,播放跟不上文字时会自适应降速。

三个踩过的坑:

  • append() 里必须是 this.buffer += pending 而不是 =。LLM 流式增量很碎(常几个字一个 chunk),用赋值会把上一次还没凑够一句、尚未送去朗读的残留直接冲掉,实际效果是整段正文都丢了、只念出 flush() 时残留的一个句号。
  • 加粗文本会被念两遍,根因是 markdownToPlain() 每次都从头重新解析整段 rawText,而 ** 闭合前后同一段文字的纯文本结果不一样(未闭合时星号当普通字符,闭合后星号被去掉)。提前把这段还不稳定的文字记为"已朗读",闭合符号一到就会被当成新内容重念。findStableBoundary() 因此只处理到最后一个已闭合的 ** 为止,未闭合的尾巴留到下次;flush() 时生成已结束,不再有这顾虑,直接处理完。
  • "开始播报"提示音要单独入队(announceStart()),不能碰流式缓冲区状态。

系统提示词:查不到内容时的答复

提示词要求"说明手册中暂未查到,并给一条可操作的建议",而不是硬邦邦一句"我不知道"。

光在提示词里写"每次换一种措辞"是无效的——模型每次请求都是无状态的,不知道上次说了什么,实测同样输入连发三次答复一字不差,而且会照抄提示词里给的示例。所以措辞变化由代码侧提供:generator.ts 的 withToneHint() 每次请求随机挑一条引导,让输入本身产生差异。

没有采用调高 temperature 的做法:那会同时影响命中文档的正常回答,而维修手册问答更看重稳定准确。

withToneHint() 的插入位置踩过一次真实的坑:第一版把引导语整句追加在系统提示词最末尾(紧邻用户问题,模型注意力权重最高的位置)。真机复测发现:同一个检索到充分相关内容、理应直接作答的问题,连续追问 3 次,有 1 次被模型硬套上"手册里未查到…"的开场白,即便后面还是照常给出了详细步骤——答案读起来却像没查到,用户也确实这样反馈过。根因是这条指令脱离了它原本所属的"仅在没有足够信息时才用"的条件分句,变成了提示词末尾一条独立显眼的指令,模型会倾向"找机会用上它"。现在改为把引导语嵌入到原本那条"如果没有足够信息"分句内部(NO_MATCH_BULLET_MARKER 定位插入点,在 config.ts 的默认提示词里找 以便更准确地定位; 这句),位置和从属语气都跟原本不带随机性的版本保持一致。用户在设置页改写过这句话、不含该标记时,withToneHint() 原样返回不插入,不强行拼接导致语义错位。

排查这个 bug 时的一个教训:之前测试这个功能时没能发现问题,是因为测试脚本直接拿纯净的 systemPrompt 调用生成接口,没有复现 withToneHint() 在真实请求里的行为——以后验证跟 prompt 拼接相关的改动,测试必须经过完整的 streamGenerate()/withToneHint() 调用链,不能绕开直接测底层生成接口。

另外,queryHandler/visionHandler 里 chunks.length === 0 的兜底文案说的是"知识库还没有文档,请先导入"而不是"我不知道"——search() 是全表余弦排序后截断,只要库里有文档就一定返回结果,走到那个分支只可能是库为空。

Vue Proxy IPC 序列化

Pinia 响应式对象直接传给 ipcRenderer.invoke() 会触发结构化克隆错误。所有传入 IPC 的响应式对象必须先序列化:

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"),避免打包后崩溃。

原生模块打包

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 事件:

interface NpuStats { memUsed: number; memTotal: number; temp: number }

仅 cardType === 'er160' 时启动轮询;切换为 er25 时停止并推送 null。

Git LFS 说明

models/**/*.onnx 通过 Git LFS 管理(见 .gitattributes)。克隆后必须:

git lfs install
GIT_SSL_NO_VERIFY=true git lfs pull   # git.drumtm.com TLS 证书已过期,需绕过

推送含 LFS 对象的提交:

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 重定向无法跟随)

开发命令

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)通过以下方式单向同步:

# 在 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 为兆芯集显无害日志,可忽略