18 Commits

Author SHA1 Message Date
ssurui
b45c7d2f27 fix: 修正答案生成中途打开语音输出只从当前位置播报的问题
用户反馈:答案还在流式输出时才打开"语音输出",朗读会从打开那一刻
"之后"的新增量开始,开 TTS 之前已经显示在屏幕上的那部分正文被跳过
没有播报。

根因:TtsPlayer.append() 第一行是 `if (!this.options.enabled) return`,
TTS 关闭期间每次 onDelta 调用 append() 都会被直接挡掉,内部的 rawText/
lastSpokenPlain 完全不会累积。toggleTts() 原来只处理了"答案已生成完毕
时打开 TTS"(调用 speakWithAnnouncement 整段重播)这一种情况,没有处理
"生成中途打开"的情况——这时 ttsEnabled 变 true 后,下一次 onDelta 送来
的只是新增的那一小段 delta,append() 在一个空白的 rawText 基础上开始
累积,效果就是只从当前位置往后播。

修复:toggleTts() 新增分支,检测到 store.querying 为 true 时,先播
"开始播报"提示音,再用 append(store.rawAnswer) 把已生成的全文"补喂"
一遍,相当于告诉播放器"这就是从头到现在的内容";之后 onDelta 的增量
在这个基础上正常追加,衔接不重复。

验证方式:真机通过 CDP 直连运行中的页面,用 Input.dispatchMouseEvent
模拟真实点击触发中途开启,在主进程 tts:speak IPC 处理器临时加日志
(验证后已移除),确认实际送去朗读的文本顺序是"开始播报"→"根据手册,
蓄电池的拆卸与安装步骤如下:"→"拆卸:"→"一、旋松蓄电池正极导线端子
螺母并取下端子"→...,正确从头开始、后续衔接无重复无跳字。

过程中也确认了 window.electronAPI 这类 contextBridge 暴露对象默认被
冻结,渲染进程侧没法通过 monkey-patch 拦截调用做验证(赋值会静默失败),
后续类似验证只能从主进程 IPC handler 侧加日志或用其他方式。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QeoAy5KmkHuKWHAktpEsaN
2026-10-05 17:40:16 +08:00
ssurui
6a19270514 fix: 修正语音输出按钮开启态被 hover 背景覆盖导致的"孤立绿框"
用户反馈截图:点击"语音输出"按钮后,按钮周围会冒出一个绿色的框,
且感觉像是松手后才延迟出现,怀疑是交互逻辑问题。

最初怀疑是浏览器原生 focus outline,加了 outline:none + :active
反馈后用户反馈真机复测没有变化。用 CDP(Chrome DevTools Protocol)
直连真机运行中的页面,模拟真实点击并读取点击后的计算样式,才找到
真正根因——跟 outline/focus 完全无关,是 CSS 优先级问题:

`.voice-btn.on`(2 个选择器)想把按钮背景铺满 `--green` 表示"已开启",
但 `.voice-btn:not(:disabled):hover`(3 个选择器)优先级更高。触屏
点击后没有鼠标可移开,:hover 状态会一直粘在刚点过的按钮上不消失,
于是 hover 规则的浅灰 background 把 .on 本该铺满的绿色背景盖掉,
只剩下没被 hover 规则碰到的绿色边框还在——看起来就像凭空多出一个
绿框。CDP 实测确认:className 带 "on",但 background 实际渲染出的是
`--panel-3`(rgb(233,237,243))的浅灰色,不是 `--green`。

修复:hover 规则加 :not(.on),让它在"已开启"状态下让位。真机 CDP
复测:点击后 background 正确变成 rgb(15,138,76)(--green),再点一次
关闭后正确恢复悬停灰色,两个状态互不干扰。

这次排查的教训:光看真机截图判断 CSS 问题会被表象迷惑(一直以为是
"焦点框"),改用 CDP 直接读取计算样式和实际渲染颜色值,才找到真正
被覆盖的是哪条规则。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QeoAy5KmkHuKWHAktpEsaN
2026-10-05 17:23:33 +08:00
ssurui
b7409e5391 fix: 修正"措辞变化"引导语导致有效答案被误判成"查不到"的回归
用户反馈真机上连续测试很多问题都答"找不到答案",发回一张截图:问的
"如何取消RCD 510收音机防盗系统编码",检索到的片段明明有具体步骤
(VAS 5051B诊断仪、手工输入编码),答案却说"并未包含具体的操作说明",
只生成了 75 tokens,比预期简单很多。

排查定位到根因:4dee321 那次加的 withToneHint(),把随机措辞引导整句
追加在系统提示词最末尾(紧邻用户问题,模型注意力权重最高的位置)。
真机 A/B 复测(同一问题、同一批真实检索片段,只是否加这条引导语):
不加引导语 3/3 正常作答;加了引导语后,连续3次里有1次被带偏成"手册里
未查到…"的开场白,即便后面照常给出了同样详细的步骤。根因是这条指令
脱离了它所属的"仅在没有足够信息时才用"的条件分句,变成一条独立显眼
的指令,模型会倾向"找机会用上它"。

之前排查这个功能本身(见 ae4ecd6 前后几次提交)时测试都没有发现这个
问题,是因为测试脚本直接拿纯净的 systemPrompt 调用,没有复现
withToneHint() 在真实请求里的行为——这是本次新发现的测试方法论漏洞。

修复:不再把引导语追加到提示词末尾,改为嵌入到原本那条"如果没有足够
信息"分句内部(NO_MATCH_BULLET_MARKER 定位插入点),位置和"仅当确实
需要使用这句话时"的从属语气都跟原本不带随机性的版本保持一致。用户在
设置页改写过这句话、不含该标记时原样返回,不强行拼接。

真机复测:同一问题(真实检索片段)连跑 5 次全部正常作答,无一次被
带偏;已知真实内容缺口的问题(蓄电池不稳定原因)连跑 4 次依然正确
识别为查不到,且措辞仍有变化。验证时额外发现并清理了服务器单并发槽位
(--parallel 1)被一次意外触发的"思考模式"长请求占满导致全局卡死的
情况,已重启 er_backend 生成服务恢复,确认 mmproj 仍在、响应正常,
无遗留影响。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QeoAy5KmkHuKWHAktpEsaN
2026-10-05 17:05:27 +08:00
ssurui
ae4ecd645d feat: 拍照按钮按后端模型的视觉能力自动启用/置灰
后端加载的不是多模态模型时,拍照提问按钮置灰不可点;换回多模态模型后
下一轮状态轮询(5 秒)自动恢复可用,不需要重启应用。

判据放在 ServiceControl 已有的 /api/status 轮询里顺带算出来,写进 store
的 visionAvailable,这样复用现成的轮询节奏,模型切换能被自动感知。

detectVisionCapability 的判据顺序:模型加载时打印的 allow_image 最准确,
但实测发现 er_backend 的日志缓冲只留最近 200 条,服务跑一会儿这行就被
推理日志挤掉了,所以只在还能翻到时采信;翻不到时退回看启动命令里有没有
--mmproj——该字段不随日志滚动,是长期可靠的依据。没有采用 config 里的
modelType 字段:那是配置意图,用户改了配置但没重启服务时会与实际运行的
模型不符。

同时:按钮禁用态样式加强(原来只有 opacity .6,且 hover 规则没排除禁用态,
看着像还能点),补 not-allowed 光标和灰底灰字;sendVisionQuery 入口加一道
校验,防守拍完照到点发送之间后端被切走的空窗。

验证:真机停掉生成服务后拍照按钮图标灰度 80→184、另两个按钮 80→80 纹丝
不动,重启服务后自动回到 80,全程没有重启应用;detectVisionCapability
另用构造数据覆盖 7 个分支(含纯文本模型、allow_image=0、starting、字段
缺失)全部通过。测试用的停服务操作已确认恢复,启动命令与操作前逐字一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QeoAy5KmkHuKWHAktpEsaN
2026-09-03 21:17:03 +08:00
ssurui
4dee32195f fix: 检索不到内容时的答复改得柔和并每次换措辞,修正一处语义错误的兜底文案
三处改动:

1. 系统提示词:把"直接回复我不知道"改成"说明手册中暂未查到相关内容
   并给一条可操作的建议(补充车型/年款、部件名称,或把故障现象描述得
   更具体)"。顺带把提示词从 510 字精简到 318 字。

2. generator.ts 新增 withToneHint():每次请求随机挑一条措辞引导追加到
   系统提示词末尾。原因是实测发现,只在提示词里写"每次换一种措辞"完全
   无效——模型每次请求都是无状态的,不知道上次说了什么,同样输入连发
   三次答复一字不差。改为让输入本身产生差异后,实测六次开头分别是
   "很抱歉/手册里/目前检索到的资料/这部分内容/手册中/抱歉",措辞不再
   雷同。没有采用调高 temperature 的做法:那会同时影响命中文档的正常
   回答,而维修手册问答更看重稳定准确;实测正常问答两次结果都忠于原文
   且带正确引用,确认不受影响。

3. queryHandler/visionHandler 里 chunks.length===0 时硬编码回复的
   "我不知道。"是语义错误的:search() 是全表余弦排序后截断,只要库里有
   文档就一定返回结果,走到这个分支只可能是知识库为空。回一句"我不知道"
   会让人以为是模型答不上来,实际应该引导去导入文档,已改为对应文案。

注意:改的是 DEFAULT_CONFIG,只对新配置生效。已部署设备的 config.json
里存的仍是旧提示词(getConfig 合并时会覆盖默认值),需要手动删除该字段
或在设置页重新保存才会用上新版。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QeoAy5KmkHuKWHAktpEsaN
2026-09-03 21:05:17 +08:00
ssurui
687e0ca9e2 feat: 拍照提问接入本地知识库检索;修复摄像头 deviceId 失效导致打不开
架构调整(按 docs/feature-pic-design.md §11):拍照提问从"图+文直接
问视觉模型"改成三段式——① 用固定提示词让视觉模型快一句话描述图片
(describeImage,非流式,实测 6.7s / 45 tokens)② 描述+用户问题拼接
去检索本地知识库 ③ 用户原始问题+检索到的上下文做纯文字生成。②③ 完全
复用文字问答的 embedOne/search/streamGenerate,图片不再传给最终生成。
前端补 vision-query:stage / :caption / :context 三个事件,识别结果以
"识别到:xxx"常驻显示。

同时修一个真机暴露的 bug:CameraCapture 用配置里持久化的 deviceId 做
exact 约束打开摄像头,但 deviceId 在 Web 标准里不保证跨会话稳定,重启
后旧 id 失效直接抛 OverconstrainedError,整个拍照功能不可用(真机截图
确认弹层报错、预览全黑)。改为失效时自动回退默认摄像头,并把实际拿到的
设备 id 回写,让"切到后置摄像头"的逻辑在回退场景下同样生效。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QeoAy5KmkHuKWHAktpEsaN
2026-09-03 20:47:33 +08:00
ssurui
b918a0c53f fix: 拍照默认摄像头按标签精确匹配后置,不再依赖枚举顺序
之前默认摄像头是"设备列表第一个",依赖操作系统 enumerateDevices()
返回顺序,不可靠。真机测出后置摄像头(朝外拍摄那颗)的 label 是
"Hy-8M(8079)-Camera",改成按标签里的型号数字串精确匹配。

摄像头 label 在 getUserMedia 拿到权限前是空的(浏览器隐私限制),
没法一开始就按标签选,所以流程改成:先用默认设备开流拿到权限,
权限到手后 label 才是真的,这时候如果不是用户/配置显式指定的摄像头,
再检查当前是不是已经是后置、不是就切过去。

真机验证:清空之前手动切换保留下来的摄像头偏好后重新打开,稳定落在
后置摄像头(桌面/Mac mini 视角),不再随机。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 16:06:28 +08:00
ssurui
0d19be58c3 fix: 拍照提问摄像头画面倒置、下拉菜单换成前后切换按钮
真机拍照实测画面上下颠倒(Mac mini 的苹果标志倒着)。CameraCapture.vue
的 <video> 加 transform: rotate(180deg) 转正预览;capture() 画到 canvas
时同步做相同的 180° 旋转(translate 到右下角再 rotate(Math.PI)),
不然预览是正的、实际拍下来的照片还是倒的。

摄像头选择从下拉菜单换成一个悬浮在预览右上角的圆形切换按钮
(switchCamera(),在设备列表里循环切换,只有 >1 个摄像头时才显示)。
默认摄像头沿用之前"取设备列表第一个"的逻辑不变——真机实测这个正好是
朝外拍摄的那颗(另一颗是朝向操作者的),语义上更接近用户要的默认
"后置摄像头",暂不改动。

真机验证:预览转正、切换按钮能真实切到另一颗摄像头且画面同样是正的、
拍照后的照片方向跟预览一致(不会出现预览正但存图倒的情况)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 16:02:31 +08:00
ssurui
8054fcdad1 fix: 语音输出恢复"开始播报"、消除加粗文本重复朗读;输入框真正对齐
真机复测又发现三个问题,逐一定位修复:

1. 流式播报接上后"开始播报"提示语丢了。TtsPlayer 新增 announceStart(),
   只入队播放不碰流式缓冲区状态,beginQuery() 里 stop()+配置同步之后
   调用。真机验证:连续问答里从第2轮开始稳定播报"开始播报"(第1轮偶发
   缺失,是启动时批量测试自动开始跟语音配置同步的时序竞争,非本次改动
   引入的逻辑问题)。

2. 加粗文本(**xxx**)被朗读两遍。根因:markdownToPlain 每次都从头
   重新解析整段 rawText,** 闭合符号到达前后同一段文字的纯文本渲染
   结果不一样(未闭合时星号当普通字符、闭合后星号被去掉),提前把这段
   不稳定文字提交为 lastSpokenPlain 会在闭合符号到达后被重新判定成
   "待朗读"造成重复。新增 findStableBoundary(),只处理到最后一个已闭合
   的 ** 为止,未闭合的尾部留到下次 append 再处理;flush() 收尾时因为
   生成已结束,不再有这个顾虑,直接处理全部剩余内容。真机验证:连续
   3 轮问答,每轮都有加粗小标题,全部按顺序推进朗读新内容,无重复。

3. 输入框和按钮行的对不齐问题:之前一直在改 .q-input(文本框本身)的
   高度,但真实 DOM 测量显示文本框本身一直是精确的 96px,真正超高的
   是它的父容器 .q-input-wrap(渲染成 102.4px,比 96px 多 6.4px),
   文本框贴容器顶部导致整体被顶高。给 .q-input-wrap 也显式定死
   height:96px。真机像素测量确认对齐。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 15:54:34 +08:00
ssurui
48a9fb0f6d fix: TTS append() 高频小增量下丢字、输入框 max-height 兜底
真机复测发现两个问题:

1. 语音输出改成流式后实测只播一个孤零零的句号,正文完全没读出来。
   根因:TtsPlayer.append() 每次调用都用 this.buffer = pending 直接
   覆盖缓冲区,而不是累加。LLM 流式返回的增量通常很小(经常几个字一
   个 chunk),大部分时候 processBuffer() 没能凑出完整句子就被下一次
   append() 覆盖掉,只有 flush() 时残留的最后一小段才被朗读。改成
   this.buffer += pending,累加未处理完的内容,不再丢字。真机验证:
   同一次问答里生成过程中(回复结束前)已经出现 4 条完整正文的 speak
   调用,不再是只有收尾的空句号。

2. 输入框 rows="1" + min-height:0 没能完全消除跟按钮行的高度差
   (真机截图像素测量仍有约10px gap),补充 max-height:96px 硬性封顶,
   重启(不是 HMR 热更)后截图确认对齐。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 15:34:01 +08:00
ssurui
a78c88c86a fix: 输入框与旁边的按钮行高度对不齐
<textarea class="q-input"> 没设 rows 属性,浏览器按默认 2 行的最小
内容高度处理,字体度量四舍五入后比显式 height:96px 略高几像素,
把整个输入框撑高,导致跟同高度的语音/拍照按钮顶边对不齐(底边因为
input-bar 是 align-items:flex-end 对齐所以没发现,只有顶边露馅)。
加 rows="1" 让最小内容高度不再跟显式高度打架,同时加 min-height:0
防御 UA 默认样式。

用像素网格量过真机截图确认:这是 textarea 本来就有的问题,不是这次
加拍照按钮引入的,只是三个按钮排一起后更显眼。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 15:14:04 +08:00
ssurui
ec634c4133 fix: 语音输出改为边生成边播报,不再等整段答案完成
TtsPlayer 本来就实现了完整的流式播报能力(append() 按句子边界切分
入队、flush() 收尾剩余文本),只是 ChatPanel 一直没接上,只在
querying 结束后调用一次 speakWithAnnouncement(全文)。

改为在 onDelta 里实时 append(delta),onDone/onError 里 flush()
收尾。删除了原来 querying 结束时整段朗读的 watch(不删会导致
整段答案被朗读两遍)。效果:不用等生成完,第一句话生成出来就
开始播,播放速度跟不上文字输出时 TtsPlayer 自带的自适应语速会
自动降速跟上。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 15:07:01 +08:00
ssurui
21f9c7e453 feat: 实现拍照提问模式 MVP(阶段1+2)
按 docs/feature-pic-design.md 的方案实现:

- AppConfig 新增 vision 字段(enabled 总开关、cameraDeviceId 记住上次
  选用的摄像头),主进程/渲染层类型两处同步
- generator.ts 抽出共享的 streamChatCompletion(),新增
  streamGenerateVision() 支持 OpenAI 视觉 API 的 image_url content-part,
  文字问答路径不受影响
- 新增 visionHandler.ts(vision-query IPC,跳过向量检索,直接转发
  图片+问题给 er_backend 代理),注册进 ipc/index.ts
- 新增 CameraCapture.vue:getUserMedia 取景/拍照/重拍/多摄像头切换,
  纯交互组件不掺业务逻辑
- ChatPanel.vue 接入拍照入口(仅 vision.enabled 时显示)、待发送图片
  缩略图、问题气泡里的图片展示;抽出 beginQuery() 复用文字/拍照两条
  路径共同的状态重置逻辑
- SettingsModal.vue 加拍照提问开关
- index.ts 权限处理器注释说明 getUserMedia 摄像头请求也走 'media'

不做检索、不做照片持久化、不做多摄像头预配置界面(MVP 范围见设计文档 §3)。
本地未跑 vue-tsc,接下来在真机上验证类型检查 + 构建 + 端到端联调。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 14:50:52 +08:00
ssurui
d512ad41f9 perf: 精简打包体积,砍掉从未被使用的 Piper 引擎和渲染层专用依赖
排查确认两处真实浪费:

1. resources/tts/piper/ 里除 espeak-ng-data 外的所有文件(Piper 神经
   语音模型、二进制、自带 onnxruntime、phonemize 库、tashkeel 阿拉伯语
   音标模型,合计 ~113M)从未被实际调用——detectBackend() 第一优先级
   永远命中 VITS(小雅),Piper 引擎分支代码走不到,只有它 bundle 里的
   espeak-ng-data 目录被 VITS 借用当 sherpa-onnx 校验的 dataDir。
   systemTts.ts 新增 findPiperEspeakDataDir(),VITS 分支改为只依赖这一
   个子目录,不再要求 Piper 引擎的 bin/model/config 存在。

2. electron-builder.config.ts 的 files 补充排除 onnxruntime-web(
   @huggingface/transformers 的硬依赖但项目强制走 onnxruntime-node,
   完全未用)、naive-ui/date-fns/highlight.js/lodash 系(只在
   src/renderer/ 被引用,electron-vite build 时已被 Vite 打包进
   out/renderer/*.js,主进程不需要原始 node_modules),约省 227M。

两项合计预计单包体积从 ~789M 降到 ~560M 左右,待真机重新打包安装、
实际听一次语音播报确认功能未受影响。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-23 00:19:14 +08:00
ssurui
0ae330c4ca feat: 替换应用图标为新版"电子手册"图标
同时替换 main 进程窗口图标源(resources/icon.png,也是打包脚本里
桌面快捷方式图标的来源)和渲染层内嵌 logo 源(src/renderer/public/icon.png)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-22 10:35:32 +08:00
surui
836d25cad2 refactor: 推理请求统一走 er_backend 代理(127.0.0.1:17700)
- 嵌入/生成服务地址不再由前端按 cardType 推导端口,改为经 er_backend
  的 /v1/* 代理转发,服务端口以后端配置为唯一真相来源
- config.ts: 新增 ER_BACKEND_URL 统一常量,删除端口推导逻辑
- generator/statusHandler/queryHandler/erBackendHandler 跟随改造
- fix: package-kylin-arm64-deb.sh 从 .app-version 读版本号时剥离 v 前缀,
  修复无 git 环境下 dpkg-deb 报"版本号没有以数字开头"
2026-08-16 21:16:01 +08:00
ssurui
71dce3b37b docs: 新增批量测试问题示例文件 batch_questions.txt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-15 19:56:22 +08:00
ssurui
6f93912c86 feat: 初始提交——elec_rag_ui 独立前端工程
基于 Electron + Vue3 + TypeScript + LanceDB 的本地化 RAG 问答桌面应用。
仅支持 Linux(Kylin V10 / Ubuntu),适配 ER160(揽月 M50 NPU)和 ER25(芯动力 RPP)计算卡。
服务进程管理委托给 er_backend(HTTP 17700),前端专注 RAG 逻辑与用户交互。

包含:
- src/elec_rag_ui 前端工程(Electron + Vue3)
- models/ 本地嵌入模型(bge-small/base/large,ASR,标点恢复,onnx 文件由 Git LFS 管理)
- docs/ 示例文档(用于测试索引与问答)
2026-08-15 19:25:28 +08:00