Compare commits

..

No commits in common. "46cd3ed68b597c353412bc2f7a563b7d8193cd8b" and "6faf48fff47435307f0900948e7d754cc30ce652" have entirely different histories.

17 changed files with 54 additions and 1217 deletions

View File

@ -8,8 +8,6 @@
服务进程管理(llama-server / embed-server 的启动停止)**完全委托给 er_backend**(HTTP 17700),前端不直接 spawn 任何推理进程。设置页面不再提供计算卡型号、嵌入模型、生成模型路径等配置入口,这些均由 er_backend Web 管理界面(端口 17700)配置。
输入方式有三种:打字、语音输入(ASR)、**拍照提问**(用摄像头拍一张照片,先由多模态模型转成一句话描述,再接入同一套知识库检索流程,见"拍照提问"章节)。
本仓库从 monorepo(electronrag)通过 `scripts/sync-elec-rag-ui.sh` 单向同步而来,历史不保留。
## 技术栈
@ -33,7 +31,6 @@ 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
@ -42,7 +39,7 @@ src/
│ ├── splitter.ts # 递归收集 .md/.txt,自实现递归字符分块
│ ├── embedder.ts # 向量化路由:local / llamacpp(无 Ollama 分支)
│ ├── localEmbedder.ts # 本地 CPU 向量化(Transformers.js 单例,懒加载)
│ ├── generator.ts # 流式生成 + describeImage 图像理解,统一走 er_backend 代理
│ ├── generator.ts # 流式生成:仅 llamacpp(接受 llamacppServer 配置)
│ ├── hardwareDetector.ts # 探测 hm_smi / rpp-info 判断 er160/er25/none
│ └── ollamaModels.ts # (保留文件,此项目不使用 Ollama)
├── preload/index.ts # contextBridge 暴露 electronAPI(无 llamaServer IPC)
@ -51,13 +48,11 @@ src/
├── App.vue # 根布局:顶栏、主区;服务状态由 SSE 维护
├── components/
│ ├── IndexPanel.vue # 文档入库面板
│ ├── ChatPanel.vue # 问答气泡、Think 块、引用徽章、批量发送、拍照入口
│ ├── CameraCapture.vue # 拍照弹层:取景/拍照/重拍/切换摄像头(纯交互,不含业务逻辑)
│ ├── ChatPanel.vue # 问答气泡、Think 块、引用徽章、批量发送
│ ├── ContextDrawer.vue # 检索片段抽屉
│ ├── SettingsModal.vue # 设置抽屉(RAG 参数 + 语音 + 拍照开关 + 批量测试)
│ ├── SettingsModal.vue # 设置抽屉(仅 RAG 参数 + 语音 + 批量测试)
│ └── ServiceControl.vue # 顶栏"启用/停止"按钮(走 er_backend HTTP)
├── utils/tts.ts # TTS 播放器:流式分句播报、自适应语速、原生/Web 双后端
├── stores/rag.ts # Pinia store(含 visionAvailable:后端是否支持图像输入)
├── stores/rag.ts # Pinia store
└── types/index.ts # TS 接口 + Window.electronAPI 类型声明
```
@ -90,17 +85,11 @@ interface AppConfig {
}
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 后端配置决定,前端只认这一个代理入口:
@ -137,39 +126,6 @@ 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)
@ -194,26 +150,6 @@ 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 的做法**:那会同时影响命中文档的正常回答,而维修手册问答更看重稳定准确。
另外,`queryHandler`/`visionHandler` 里 `chunks.length === 0` 的兜底文案说的是"知识库还没有文档,请先导入"而不是"我不知道"——`search()` 是全表余弦排序后截断,只要库里有文档就一定返回结果,走到那个分支只可能是库为空。
### Vue Proxy IPC 序列化
Pinia 响应式对象直接传给 `ipcRenderer.invoke()` 会触发结构化克隆错误。**所有传入 IPC 的响应式对象必须先序列化**:

View File

@ -4,14 +4,6 @@
**仅支持 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
@ -45,9 +37,8 @@ elec_rag_ui/
│ ├── renderer/ # Vue3 前端
│ │ └── src/
│ │ ├── App.vue # 根布局(顶栏 + 主区)
│ │ ├── components/ # ChatPanel / IndexPanel / CameraCapture / SettingsModal 等
│ │ ├── components/ # ChatPanel / IndexPanel / SettingsModal 等
│ │ ├── stores/ # Pinia store
│ │ ├── utils/ # TTS 播放器、音频采集、Markdown 处理
│ │ └── types/ # TypeScript 类型定义
│ └── batch_questions.txt # 批量测试问题示例
├── models/ # 本地模型(onnx 由 Git LFS 管理)
@ -76,15 +67,6 @@ systemctl status er-backend
er_backend 管理界面:`http://<PAD-IP>:17700`(配置模型路径、启动/停止服务)
## 启用拍照提问
需要两个条件同时满足:
1. **er_backend 加载的是多模态模型**——在管理界面把生成模型配成带 mmproj 的视觉模型(启动命令里应带 `--mmproj`)。应用会自动探测:不是多模态模型时拍照按钮置灰,换回多模态模型后 5 秒内自动恢复可用,不用重启应用。
2. **在「设置 → 基本设置 → 拍照提问」里打开开关**(默认关闭,因为不是所有部署都会配视觉模型)。
摄像头用标准 USB UVC 设备即可,无需额外驱动。有多个摄像头时,拍照弹层右上角可以切换。
## 模型文件
`models/` 目录中的 `.onnx` 文件通过 **Git LFS** 管理。克隆后拉取实体文件:

View File

@ -1,404 +0,0 @@
# 拍照提问模式 —— 设计方案
状态:**已实现并通过真机端到端验证**(2026-09-03)。当前生效的是 §11 的二次架构
(拍照 → 图像理解转述 → 接入本地知识库检索 → 生成),验证记录见 §12。
§1–§10 保留第一版"纯视觉直接问答"的设计与验证过程作为历史记录,**已被 §11 推翻,不再是当前实现**。
## 1. 背景与目标
现有 `elec_rag_ui` 是纯文本 RAG 问答:用户打字提问 → 检索本地知识库 → llama-server 生成回答。
本功能希望新增一种输入方式:**用摄像头拍一张照片,直接针对这张照片提问**(例如对着一个实物部件拍照问"这是什么型号"),
而不是先打字检索文档。
## 2. 关键前提核实(2026-09-03 现场查证,ER160 pad,192.168.31.48)
### 2.1 摄像头硬件 —— 已具备,风险低
```
/dev/video0/1 -> Hy-Usb2.0-1*MIC: Hy-QSXGA(8950) # 约500万像素 USB 摄像头,带麦克风
/dev/video2/3 -> Hy-Usb2.0-2*MIC: Hy-8M(8079)-Ca # 约800万像素 USB 摄像头,带麦克风
lsusb: 1bcf:2cef / 1bcf:2ce4 Sunplus Innovation Technology Inc.
```
两个都是标准 **USB UVC(USB Video Class)** 摄像头,不是需要专有 ISP 驱动的 CSI 摄像头。
Chromium/Electron 的 `navigator.mediaDevices.getUserMedia()` 原生支持 UVC 设备,**不需要额外驱动或胶水代码**。
一台设备接了两个摄像头,需要在 UI 里做设备选择(`enumerateDevices()` 列出后选 `deviceId`),
或者约定固定用其中一个(待确认哪个摄像头是安装时实际对外的那个)。
### 2.2 推理后端视觉能力 —— **已确认可用(2026-09-03 复查更新)**
初次核实时(见本文档历史)当前模型是纯文本的,`allow_image=0`;**复查时 er_backend 已经换成带
`--mmproj` 的多模态配置**:
```
/home/work/houmo-HLIELLama-xh2-2.1.0/bin/llama-server \
-m /home/work/models/LLM/HiModel_xh2_qwen3.5-9b_w4a8_256_256k_b1_1chip_2cores_v1.4.0_20260708.gguf \
--mmproj /home/work/models/LLM/mmproj_xh2_qwen3.5-9b_w4a8_2cores_vit_896x896_v1.4.0_20260708.gguf \
-c 8192 --parallel 1 --host 127.0.0.1 --no-warmup --port 8081 --presence_penalty 1.5 --reasoning-budget 0
```
启动日志确认:
```
loaded multimodal model, '.../mmproj_xh2_qwen3.5-9b_w4a8_2cores_vit_896x896_v1.4.0_20260708.gguf'
==========model info===========================
=allow_image = 1
=allow_audio = 0
=vision_width = 896
=vision_height= 896
=model_desc = qwen35 9B Q4_0
==========model info===========================
```
**并且做了端到端真实请求验证**(不只是看模型加载日志),直接对 `http://127.0.0.1:17700/v1/chat/completions`
(即前端 `generator.ts` 实际会调用的同一个地址)发了一个带 `image_url` content-part 的请求:
```
HTTP 200,耗时 2.6s
返回:{"content": "黑色"} —— 测试图片本身就是 1x1 黑色像素,模型回答正确,
证明图片被真实理解,不是被忽略/幻觉回答
```
**结论:阻塞项已解除**,现在的 er_backend 代理路径(`ER_BACKEND_URL/v1/chat/completions`)原生支持
OpenAI 风格的 `image_url` content-part,前端可以直接照 §5.2 的格式发请求,不需要额外适配层。
**顺带记录的性能数据**(用于 §6 加载态 UI 设计参考):这次测试用的是 1×1 像素的极简测试图,
`prompt_tokens` 却达到了 **808**——印证了启动日志里的警告"Qwen-VL models require at minimum
1024 image tokens",即**无论照片内容/分辨率如何,视觉编码本身有一个较高的固定 token 开销**
(896×896 的 ViT 编码器决定的,不是按源图片大小线性变化)。prefill 速度约 373 tokens/s,
换算下来光是"看懂一张图"这一步就要 ~2 秒起,生成回答的时间还要另外算。
> **2026-09-03 补充:真实照片实测数据(见 §10)**,一次完整的"描述图片"回答(454 tokens)
> 实际耗时 **~31 秒**(14.7 tokens/s),比这里用极简测试图估算的"3-8秒"长不少——真实照片信息量大,
> 模型生成的描述篇幅也更长,这才是真实用户会遇到的等待时长。UI 的等待态提示要按这个量级设计,
> 不能只按几秒钟的"识别中"来做。
## 3. 功能范围(建议先做 MVP)
**MVP 包含:**
- 聊天输入区新增"拍照"入口,唤起摄像头取景 → 拍照 → 预览/重拍 → 确认
- 确认后的照片作为一条用户消息的"附件",和文字问题一起发送
- 复用现有流式回答渲染(think 块、逐字输出等现有 ChatPanel 能力不用改)
- 不做检索(不查向量库):拍照提问是纯粹的"图 + 文"直接对话,不掺 RAG 上下文
**明确不做(本期):**
- 不做"拍照 + 知识库联合检索"(比如拍照后再去匹配文档库里的相关图片/文字)——这需要图像
embedding/CLIP 之类的能力,目前项目里完全没有这块基础设施,属于完全独立的大功能,先不纳入
- 不做连续视频流分析,只做单张静态拍照
- 不做照片持久化存储/历史相册(先按"用完即弃"设计,见 §6.5 的取舍说明)
## 4. 用户体验流程
```
[聊天界面输入框旁] 新增相机图标按钮
│ 点击
▼
[取景弹层] 全屏/半屏显示 <video> 实时预览
├─ 有多个摄像头 → 顶部下拉切换设备
├─ [拍照] 按钮 → 定格成 <canvas> 快照
└─ 拍完后:[重拍] [使用这张]
│ 使用这张
▼
[输入框上方] 出现照片缩略图(可点 x 移除,可再次点击重新打开取景)
│ 用户在输入框打字问题(可选,留空则用默认 prompt "描述这张图片")
│ 点击发送
▼
[聊天记录] 新增一条用户消息:缩略图 + 问题文字
[聊天记录] 助手消息流式输出回答(复用现有渲染)
```
## 5. 技术方案
### 5.1 摄像头采集(渲染进程)
用浏览器标准 API,**不经过 IPC**:
```typescript
const stream = await navigator.mediaDevices.getUserMedia({ video: { deviceId } })
// <video> 绑定 stream 实时预览
// 拍照:canvas.drawImage(videoEl, 0, 0) 后 canvas.toDataURL('image/jpeg', quality)
```
需要在 `src/main/index.ts` 的权限处理器里确认/放行摄像头权限——现有代码:
```typescript
session.defaultSession.setPermissionRequestHandler((webContents, permission, callback) => {
const allowed = permission === 'media' || permission === 'microphone'
callback(allowed)
})
```
Electron 里摄像头请求的 `permission` 具体是不是也归为 `'media'`(跟麦克风共用同一个值)需要
实测确认,不确定的话要加日志打印实际收到的 permission 字符串,照实际情况调整判断条件。
### 5.2 请求格式:OpenAI 视觉 content-parts
现有 `generator.ts` 发给 llama-server 的 `messages[].content` 是纯字符串。视觉请求要改成
OpenAI 兼容的多段 content:
```typescript
{
role: 'user',
content: [
{ type: 'text', text: userQuestion },
{ type: 'image_url', image_url: { url: `data:image/jpeg;base64,${base64Data}` } }
]
}
```
`generator.ts` 的 `streamGenerateLlamaCpp` 需要能接受这种数组形式的 content(简单改成联合类型,
现有文本对话路径不受影响,用字符串走老路径)。
### 5.3 IPC 设计(新增,不改动现有 `query` 通道)
参照现有 `query` 的"invoke 触发 + push 事件流"模式,新增一套平行的通道,互不干扰:
```typescript
// preload
visionQuery: (payload: { imageBase64: string; question: string }) => Promise<void>
// 主进程新增 vision-query handler,push 事件:
// vision-query:delta / vision-query:done / vision-query:error
```
主进程侧新建 `src/main/rag/visionGenerator.ts`(或直接扩展 `generator.ts` 加一个
`streamGenerateVision()`),直接调 `ER_BACKEND_URL/v1/chat/completions`,**跳过**
`embedOne`/`search` 这两步(MVP 不做检索,见 §3)。
### 5.4 配置项(`AppConfig` 新增字段)
> **实现时做了裁剪**:`maxImageDimension`/`jpegQuality`/`defaultPrompt` 最终没有做成配置项,
> 改成代码里的硬编码值(JPEG 质量 0.85,默认问题"请描述这张图片的内容"),且**没有做
> 上传前压缩**——拍照直接用视频原始分辨率生成 dataURL。原因:真机验证时发现视觉模型的
> `prompt_tokens` 是固定开销(895×896 ViT 编码器决定,跟源图片分辨率无关,见 §2.2),
> 客户端压缩分辨率对推理速度没有帮助,只对传输体积有一点点好处,MVP 阶段先不加这层配置,
> 避免为一个还没证明有必要的问题预先设计开关。实际字段:
```typescript
vision: {
enabled: boolean // 总开关,默认 false
cameraDeviceId: string // getUserMedia deviceId,空字符串 = 使用第一个可用设备
}
```
`enabled` 默认关闭,需要在设置页手动打开(现在后端已具备视觉能力,但默认关闭仍然是合理的——
不是所有部署的 er_backend 都会配置视觉模型,不能假设每台设备都支持)。
### 5.5 是否持久化照片
**建议不落盘**,只在当次会话的内存/聊天记录里保留 base64(或一个内存 Blob URL 用于渲染缩略图),
应用重启后历史照片随聊天记录一起清空——跟现有"聊天记录本身也不持久化到数据库"的现状保持一致
(现有文本问答记录也只是内存里的 Pinia state,没有存 LanceDB 或别的持久层)。如果以后要做"拍照历史",
再单独设计存储方案。
## 6. UI 改动点(`ChatPanel.vue`)
> **写这份设计时的一个错误假设**:以为 `ChatPanel.vue` 是"消息列表"结构(数组形式的多轮对话历史),
> 实现时才发现现有代码根本不是这样——`ChatPanel` 只维护"当前这一轮"的单个问题+单个流式回答
> (`sentQuestion` + `store.rawAnswer`,每次提问直接覆盖上一轮,没有历史记录数组)。这让实现比设计时
> 想的更简单:不需要改消息数据结构,只要给"当前这一轮"平行加一个 `sentImage` 就行。
实际做法:
- 输入区工具栏新增相机图标按钮,跟现有语音按钮同一排、同一视觉风格,仅 `vision.enabled` 为
`true` 时显示
- 新增 `CameraCapture.vue`,职责单一:拿 stream、渲染预览、拍照出 dataURL、emit 给父组件,
弹层自己不关心业务逻辑
- `ChatPanel.vue` 新增三个本地 ref:`cameraOpen`(弹层开关)、`pendingImage`(已拍但未发送,
显示成输入框上方一个可移除的缩略图 chip)、`sentImage`(已发送,跟 `sentQuestion` 一起显示在
问题气泡里)
- 抽出 `beginQuery()` 把文字提问和拍照提问共用的准备逻辑(重置状态、打断语音输入、停止旧的
TTS 播报)合并成一个函数,避免两条路径各写一遍
## 7. 分阶段实施计划
| 阶段 | 内容 | 状态 |
|---|---|---|
| 0 | 跟 er_backend/硬件团队确认视觉模型可用性 | ✅ 完成(2026-09-03,见 §2.2) |
| 1 | 摄像头取景/拍照 UI(`CameraCapture.vue`) | ✅ 完成,真机验证过真实拍照可用(见 §10) |
| 2 | IPC + `generator.ts` 改造支持 image_url content-part | ✅ 完成,真机验证过完整回答(见 §10) |
| 3 | 配置项、设置页开关、真机联调 | ✅ 完成(配置项范围比设计时缩减,见 §5.4) |
| 4 | 打包验证(照片相关无新增大文件,不影响现有打包体积) | ⬜ 未做——这次验证走的是 `dev:linux` 开发模式,没有重新打包 deb |
## 8. 风险与未决问题(更新为已验证/未验证状态)
1. ~~Electron 权限处理器对摄像头请求的 `permission` 字符串需要实测确认~~
**已确认**:真机日志 `[permission] media -> granted`,getUserMedia 摄像头请求确实归为 `media`,
跟麦克风共用同一个权限判断,不需要额外代码。
2. **未解决**:两个摄像头哪个是实际安装朝向的那个,仍未现场确认物理安装位置——目前实现是
`CameraCapture.vue` 里有下拉框给用户自己选,没有做默认值的硬编码判断,算是绕开了这个问题
(用户自己选,不需要我们替他决定),但如果产品上想要"打开就是对的那个摄像头",还是要现场确认。
3. **已验证,风险比预想低**:真实分辨率照片(未压缩,直接原始 dataURL)经 er_backend 代理转发
`/v1/chat/completions` 没有遇到任何请求体大小限制,正常返回。**没有做 §5.4 提到的客户端压缩**——
实测证明视觉模型的 `prompt_tokens` 开销是固定的(跟源图片分辨率无关,见 §2.2),压缩分辨率对
推理速度没有帮助,暂时不需要加这层。如果以后遇到极端大尺寸照片(比如高分辨率单反直出)传输
变慢,再回头考虑加压缩。
4. **已用真实数据更新**:完整一段描述类回答实测 **~31 秒**(454 tokens,14.7 tokens/s),不是
§2.2 早期用 1×1 测试图估算的"3-8秒"。当前实现**还没有做专门的长等待 UI 提示**——现有的
`stage-row`"识别中"loading 态是复用文字问答那套,能用但没有针对"这可能要等半分钟"做优化,
是一个值得后续改进但不阻塞可用性的点。
5. `--reasoning-budget 0` 这个启动参数暗示当前配置关闭了推理/think 模式——真机测试的回答是
直接输出内容,没有 `<think>` 块,跟这条预判一致,现有 `<think>` 渲染逻辑没有被拍照提问路径
触发,也没有报错,两条路径共用同一套 markdown 渲染没有问题。
## 9. 后续可选优化(不阻塞当前可用性)
- 针对 §8.4 的长等待时间,给拍照提问单独设计一个更明确的等待态提示(比如预估时间、进度感的动画)
- 针对 §8.2,现场确认两个摄像头哪个是产品实际会用的那个,考虑是否需要写死默认设备
- 打包验证(§7 阶段4):确认这次改动不影响现有 664M 左右的 deb 体积(新增代码本身很小,
不应该有明显影响,但要走一次真实打包确认)
- 如果以后有极端大尺寸照片的性能问题,再考虑加回 §5.4 提到的客户端压缩
## 10. 实现与验证记录(2026-09-03)
代码实现:`feature_pic` 分支 commit `21f9c7e`。改动文件清单、每个文件做了什么,见该 commit
的提交信息,这里不重复。
验证方式:rsync 到 pad → `npx vue-tsc --noEmit`(**一次性通过,零错误**,没有需要修的类型问题)
→ `npx electron-vite build`(成功)→ `DISPLAY=:0` 直接跑编译产物(未打包 deb,走的是开发产物
直接运行)→ `ffmpeg -f x11grab` 截图 + `xdotool` 模拟点击操作,验证到:
- 拍照弹层打开、真实摄像头画面可见(拍到桌面上的 Mac mini、显示器)、多摄像头下拉正确列出
真实设备名 `Hy-8M(8079)-Camera`
- 拍照 → 图片正确附加到问题气泡(缩略图渲染正常)→ 默认问题文案 `VISION_DEFAULT_QUESTION`
正确生效 → 流式回答完整渲染
- **回答内容真实针对图片作答**:准确识别出 Mac Mini、AOC 显示器,读出包装盒上的具体文字
"100Gbps Super Fast SSD Storage Anywhere"、充电器上的"22W"——细到具体文本级别,
不是通用套话或幻觉
- 进程全程稳定,没有因为这次改动崩溃;唯一的无关日志是 SSL 证书握手失败(pad 时钟问题,
跟这次改动无关,见项目里其他关于 pad 时钟的记录)
**这次验证没有覆盖到的**:正式打包(deb)流程、设置页手动切换开关的 UI 操作(是直接改
`config.json` 打开的 `vision.enabled`,没有走设置页表单)、长时间使用的稳定性。
## 11. 架构调整:接入本地知识库检索(2026-09-03 二次设计)
**状态:已实现并通过真机端到端验证**(commit `687e0ca`,验证记录见 §12)。本节内容推翻
§3 "不做检索" 的原始决定,是当前的权威设计,§3/§5.2/§5.3/§6 保留作为"第一版为什么这么想"
的历史记录。
### 11.1 为什么要改
第一版 MVP 是"拍照直接问视觉模型",图片内容和本地知识库(维修手册文档)完全没有关联。
但这个应用的核心价值是本地知识库问答,拍照应该是**新增一种提问入口**,而不是一条完全独立
于知识库之外的旁路。新方案让拍照提问最终也能从知识库里查到相关手册内容来回答。
### 11.2 新流程
```
用户拍照 + 可选输入问题
│
[主进程] 调视觉模型一次,用固定默认提示词(不是用户问题):"请用一句话简要描述这张图片
中的部件、故障现象或异常情况,不要展开解释、不要客套。"
→ push 'vision-query:stage' {stage:'正在识别图片...'}
→ 得到一段简短描述 caption(目标 5-10 秒内,靠提示词要求"一句话"+"不展开"来压时长,
而不是硬截断 max_tokens——见 §11.3)
→ push 'vision-query:caption' {caption} ← 渲染层立刻展示"识别到:{caption}",简洁一行
│
组装检索查询 retrievalQuery:
- 用户打字了问题 → `${caption}\n${question}`(图像描述 + 用户问题拼接,扩大召回面)
- 用户没打字 → caption 本身
│
[主进程] 复用现有 embedOne(retrievalQuery) → search(dbPath, vector, topK)
→ push 'vision-query:stage' {stage:'正在检索...'}
→ push 'vision-query:context' {chunks}
│
生成用的问题 genQuestion:
- 用户打字了问题 → 用户原始问题(不用 retrievalQuery,避免 caption 把回答带偏,
检索阶段负责扩大召回,生成阶段保持聚焦用户真实意图)
- 用户没打字 → caption 本身(这种情况下 caption 就是唯一的"意图"来源,没有更聚焦的选项)
│
[主进程] 复用现有 streamGenerate(genQuestion, chunks, systemPrompt, callbacks)——
**纯文字生成,图片到这一步已经不再传给模型**(第③步转成文字后,图片就完成使命了)
→ push 'vision-query:stage' {stage:'正在生成...'}
→ push 'vision-query:delta' 流式吐字,跟文字问答体验一致
→ push 'vision-query:done'
```
### 11.3 关键决策记录
| 决策点 | 结论 | 理由 |
|---|---|---|
| 图像理解默认提示词 | "请用一句话简要描述这张图片中的部件、故障现象或异常情况,不要展开解释、不要客套。" | 单句、明确要求"不展开",从提示词层面主动压生成长度,比硬 `max_tokens` 截断更不容易截出语义不完整的半句话 |
| 图像理解耗时目标 | 5-10 秒 | 用户明确的目标区间;实测极简回答("黑色"两个字)2.6s,§2.2 记录的极简测试图基准可作为下限参考 |
| 最终生成是否带图 | 不带,纯文字生成 | 图片信息已经浓缩进 caption,复用现有纯文字 `streamGenerate`,不需要每次生成都重新过一遍视觉编码的固定开销(见 §2.2,~800 token/次),更快 |
| 检索查询 vs 生成问题是否用同一段文字 | 不同:检索用 `caption+question` 拼接(更大召回面),生成用用户原始 `question`(无问题时退化为 caption) | 检索要"宁可多召回",生成要"保持聚焦",两个阶段的目标不一样,没必要共用一份文本 |
| 中间结果是否展示给用户 | 展示,"识别到:xxx",一行、简洁 | 5-10 秒的图像理解本身就是一段等待,把中间结果先亮出来能实质性缓解等待焦虑,而不是纯转圈 |
| 加载态设计 | 三段式:正在识别图片... → 正在检索... → 正在生成...,复用现有 `query:stage` 这套推送机制的样式 | 用户已经熟悉这套"正在xxx..."的提示语汇,不需要发明新的交互语言 |
### 11.4 技术实现要点(待写代码时展开,这里先定方向)
- `vision-query` 这个 IPC handler 内部变成"两段式":先调一次视觉模型拿 caption,再原样调用
现有 `embedOne`/`search`/`streamGenerate`(`queryHandler.ts` 已有的三步,直接复用,不用照抄一遍)
- `generator.ts` 的 `streamGenerateVision()`(第一版为了直接视觉问答新增的函数)现在只在
"第③步图像理解"这一次性调用里用得上;原来那种"图+文一起发给视觉模型直接生成完整答案"的
用法不再需要,`ContentPart`/`image_url` 那套改造保留(第③步仍然要用),但调用方式变成
"只问一句话、只要一个简短 caption",跟原来"生成完整长回答"的调用方式不一样,需要单独判断
是否要一个新的轻量函数(比如 `describeImage()`)而不是复用 `streamGenerateVision` 的完整流式
长回答路径
- 新增 `vision-query:caption` 这个推送事件(原来只有 delta/done/error),渲染层要新增监听
和展示逻辑
- `ChatPanel.vue` 的 `sendVisionQuery()` 需要重写内部流程以匹配上面的多阶段推送,但外层交互
(拍照、缩略图、发送按钮)不用动
- 是否需要给"识别图片"这一步单独设一个超时/失败兜底(比如识别失败了要不要还能继续走检索,
还是直接报错终止)——待实现时决定,本节暂不展开
## 12. 二次架构(接入检索)的实现与验证记录(2026-09-03)
代码:commit `687e0ca`。实现完全按 §11 的设计,没有中途改方向。
### 12.1 真机验证结果
验证环境:pad(当时地址 `192.168.1.37`,注意这台设备 IP 会变,`192.168.31.48` 那个地址
当时能 ping 通但 SSH 被拒,实际是别的设备占用),`npx electron-vite preview` 跑编译产物,
`xdotool` 模拟点击 + `ffmpeg -f x11grab` 截图取证。
类型检查 `npx vue-tsc --noEmit` 零错误,`npx electron-vite build` 构建成功。
两次真实拍照问答的完整链路数据:
| 场景 | 图像理解 | 检索 | 生成 | 结果 |
|---|---|---|---|---|
| 未输入文字(拍室内台灯托盘) | 4.0s | 召回 20 片段 | 0.1s / 3 tokens | 正确回答"我不知道" |
| 输入"如何更换汽车前大灯。" | 5.3s | 召回 20 片段 | 17.5s / 233 tokens | 正确答出换灯步骤,带引用 [18] |
**图像理解稳定在 4.0–5.3 秒**,落在 §11 定的 5–10 秒目标内。固定提示词里"一句话""不展开"
的约束确实生效:输出 45 tokens 上下,对比旧方案(让视觉模型直接完整作答)的 454 tokens / 31 秒,
差了一个数量级。
### 12.2 验证中确认为正确的两个行为
**其一,第二个场景恰好验证了 §11.3 那个关键决策是对的**:拍的是保温杯/厨房桌面,跟"更换前大灯"
毫无关系,但因为检索用的是 `caption + 用户问题` 拼接(扩大召回),生成用的是**用户原始问题**
(保持聚焦),最终答案准确命中前大灯文档并带正确引用,完全没被"保温杯、厨房环境"这些无关的
图像描述带偏。如果当初决定生成也用组合文本,这个场景大概率会跑偏。
**其二,第一个场景回答"我不知道"是正确行为不是 bug**:图片内容跟知识库(汽车维修手册)无关,
检索虽然照常返回 topK=20 个片段(暴力余弦排序总会返回够数),但内容都不相关,模型遵守
systemPrompt 里"上下文找不到就回答我不知道"的规则。这跟文字问答问无关问题时的行为一致。
### 12.3 验证中发现并修复的真 bug:摄像头 deviceId 跨会话失效
首次点开拍照弹层直接报 **`OverconstrainedError`**、预览全黑,整个功能不可用。
根因:`CameraCapture.vue` 把上次选用的 `deviceId` 持久化到 config.json,下次启动时拿它做
`getUserMedia({ video: { deviceId: { exact: ... } } })` 的约束。但 **deviceId 在 Web 标准里
不保证跨会话稳定**,换一次会话后旧 id 往往已经不存在,`exact` 约束匹配不上就直接抛错。
修复:抽出 `openStream()`,指定 id 打不开时自动回退到 `{ video: true }` 默认摄像头,并把
实际拿到的设备 id(`getVideoTracks()[0].getSettings().deviceId`)回写到 `selectedDeviceId`,
让"自动切到后置摄像头"的逻辑在回退场景下同样能继续工作(原来的写法只在 `initialDeviceId`
为空时才找后置摄像头,回退场景会被漏掉)。
### 12.4 顺带确认的一件事:画面方向会影响识别质量
用 `ffmpeg` 直接从 `/dev/video2` 抓的**原始帧**(不经过前端旋转)送去识别,模型的描述里明确
写了"图片显示一个**倒置的**室内场景"。这说明摄像头原始画面确实是倒的,而且**视觉模型会把
"倒置"当成图像的一个特征去描述**——即 commit `0d19be5` 那个旋转修复不只是预览好看的问题,
它直接影响识别质量。经过前端旋转后拍的照片(见 §12.1 两次实测),描述里再没出现"倒置"字样。
### 12.5 这次没有覆盖到的
- 正式 deb 打包(走的是 `electron-vite preview` 跑编译产物)
- "识别图片"这一步失败时的兜底行为(§11.4 最后一条列为待定,实现时也没专门处理,
目前是整个 vision-query 抛错走 `vision-query:error`,前端弹错误提示)
- 长时间连续使用的稳定性

View File

@ -58,10 +58,6 @@ export interface AppConfig {
volume: number
}
}
vision: {
enabled: boolean // 拍照提问入口总开关,默认关闭(依赖 er_backend 是否加载了支持视觉的模型)
cameraDeviceId: string // 上次选用的摄像头 deviceId,空字符串 = 使用第一个可用设备
}
}
export const DEFAULT_CONFIG: AppConfig = {
@ -110,10 +106,6 @@ export const DEFAULT_CONFIG: AppConfig = {
volume: 1
}
},
vision: {
enabled: false,
cameraDeviceId: ''
},
rag: {
chunkSize: 480,
chunkOverlap: 0,
@ -122,9 +114,7 @@ export const DEFAULT_CONFIG: AppConfig = {
你的任务是:
- 只能依据用户提供的上下文内容进行回答;
- 每个答案中如果有信息来源于上下文,必须标注对应的引用标号(例如:[1],也可以是多个标号,例如:[1][3]);
- 如果上下文中没有足够的信息回答问题,不要编造内容,也不要生硬地只回一句"我不知道"。
请用一两句自然、平和的话说明手册中暂未查到相关内容,并给出一条简短、可操作的建议,
例如请对方补充车型/年款、具体部件名称,或把故障现象描述得更具体,以便更准确地定位;
- 如果问题的信息在上下文中找不到,请直接回复:"我不知道。"
- 不得使用自己的知识库或常识来回答问题;
- 回答应简洁明了,不添加额外解释或发挥。
@ -208,10 +198,6 @@ export function getConfig(): AppConfig {
rate: typeof raw.voice?.tts?.rate === 'number' ? raw.voice.tts.rate : DEFAULT_CONFIG.voice.tts.rate,
volume: typeof raw.voice?.tts?.volume === 'number' ? raw.voice.tts.volume : DEFAULT_CONFIG.voice.tts.volume
}
},
vision: {
enabled: raw.vision?.enabled === true,
cameraDeviceId: typeof raw.vision?.cameraDeviceId === 'string' ? raw.vision.cameraDeviceId : ''
}
}
applyPlatformOverrides(config)

View File

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

View File

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

View File

@ -40,12 +40,7 @@ export function registerQueryHandlers(getWin: () => BrowserWindow | null): void
send('query:stage', { stage: '正在生成...' })
if (chunks.length === 0) {
// 走到这里说明知识库里一条记录都没有(search 是全表余弦排序后截断,
// 只要有文档就一定会返回结果),是"还没入库"而不是"没找到相关内容",
// 提示要引导去导入文档,不能只回一句"我不知道"让人以为是模型答不上来
send('query:delta', {
delta: '知识库中还没有任何文档,暂时无法回答。请先在「设置 → 文档入库」中导入手册,再来提问。'
})
send('query:delta', { delta: '我不知道。' })
send('query:done', {})
return
}

View File

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

View File

@ -14,37 +14,6 @@ export interface GeneratorCallbacks {
onError: (message: string) => void
}
// OpenAI 视觉 API 的 content-part 格式,multimodal 模型(如 Qwen-VL + mmproj)用这种数组形式
// 代替纯字符串 content 来同时携带文字与图片
type ContentPart =
| { type: 'text'; text: string }
| { type: 'image_url'; image_url: { url: string } }
interface ChatMessage {
role: 'system' | 'user'
content: string | ContentPart[]
}
// 每次请求对模型来说都是无状态的,它并不知道"上一次说了什么",所以只在系统提示词里
// 写"每次换一种措辞"是无效的——实测同样的输入连发三次,答复一字不差。这里改为每次请求
// 随机挑一条措辞引导追加到系统提示词末尾,让输入本身产生差异,答复自然就不会千篇一律。
//
// 之所以不用调高 temperature 来制造随机性:那会同时影响命中文档的正常回答,而维修手册
// 问答更看重稳定准确。这些引导只在"查不到内容"时才起作用,正常回答不受影响。
const NO_MATCH_TONE_HINTS = [
'若本次需要说明手册中未查到相关内容,请以"很抱歉"起头组织这句话。',
'若本次需要说明手册中未查到相关内容,请以"手册里"起头组织这句话。',
'若本次需要说明手册中未查到相关内容,请以"目前检索到的资料"起头组织这句话。',
'若本次需要说明手册中未查到相关内容,请以"这部分内容"起头组织这句话。',
'若本次需要说明手册中未查到相关内容,请用两句话表达:先说明情况,再给一条补充信息的建议。',
'若本次需要说明手册中未查到相关内容,请先简短致歉,再说明可以如何把问题描述得更具体。'
]
function withToneHint(systemPrompt: string): string {
const hint = NO_MATCH_TONE_HINTS[Math.floor(Math.random() * NO_MATCH_TONE_HINTS.length)]
return `${systemPrompt}\n${hint}`
}
// 生成服务经 er_backend 代理访问其管理的 llama-server(OpenAI 兼容接口,SSE 流式)
export async function streamGenerate(
question: string,
@ -62,69 +31,19 @@ ${context}
请回答以下问题:${question}`
const finalSystemPrompt = withToneHint(systemPrompt)
console.log('\n[generator] ---- 发送给 LLM 的完整消息 ----')
console.log(`[generator] 服务地址:${ER_BACKEND_URL}(er_backend 代理)`)
console.log('[generator] system prompt:\n', finalSystemPrompt)
console.log('[generator] system prompt:\n', systemPrompt)
console.log('[generator] user message:\n', userMessage)
console.log('[generator] ---- 消息结束 ----\n')
await streamChatCompletion(
[
{ role: 'system', content: finalSystemPrompt },
{ role: 'user', content: userMessage }
],
callbacks
)
}
// 拍照提问第一步:用固定的默认提示词(不是用户问题)让视觉模型快速给一句话描述,
// 用于后续拼检索查询。单次非流式请求——只要一句简短结果,不需要逐字显示,
// 也不需要每次都重新走一遍长回答的 SSE 解析。
const IMAGE_DESCRIBE_PROMPT =
'请用一句话简要描述这张图片中的部件、故障现象或异常情况,不要展开解释、不要客套。'
export async function describeImage(imageDataUrl: string): Promise<string> {
const baseUrl = ER_BACKEND_URL
const messages: ChatMessage[] = [
{
role: 'user',
content: [
{ type: 'text', text: IMAGE_DESCRIBE_PROMPT },
{ type: 'image_url', image_url: { url: imageDataUrl } }
]
}
]
console.log('\n[generator] ---- 图像理解(拍照提问第①步)----')
console.log(`[generator] 服务地址:${baseUrl}(er_backend 代理)`)
const start = Date.now()
let response: Response
try {
response = await fetch(`${baseUrl}/v1/chat/completions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'default', messages, stream: false })
})
} catch (e) {
throw new Error(`无法连接视觉理解服务(${baseUrl},er_backend 代理):${String(e)}`)
}
if (!response.ok) {
throw new Error(`图像理解请求失败:${response.status}`)
}
const data = await response.json()
const caption: string = data.choices?.[0]?.message?.content?.trim() ?? ''
console.log(`[generator] 图像描述(耗时 ${((Date.now() - start) / 1000).toFixed(1)}s):${caption}`)
return caption
await streamGenerateLlamaCpp(userMessage, systemPrompt, callbacks)
}
// llama.cpp server OpenAI 兼容接口(/v1/chat/completions),SSE 格式
async function streamChatCompletion(
messages: ChatMessage[],
async function streamGenerateLlamaCpp(
userMessage: string,
systemPrompt: string,
callbacks: GeneratorCallbacks
): Promise<void> {
const baseUrl = ER_BACKEND_URL
@ -141,7 +60,10 @@ async function streamChatCompletion(
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'default',
messages,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userMessage }
],
stream: true,
stream_options: { include_usage: true }
})

View File

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

View File

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

View File

@ -1,12 +1,11 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { NModal, NCard, NScrollbar, useMessage } from 'naive-ui'
import { marked } from 'marked'
import { useRagStore } from '../stores/rag'
import { resolveImagePaths } from '../utils/markdown'
import { AudioRecorder } from '../utils/audio'
import { ttsPlayer } from '../utils/tts'
import CameraCapture from './CameraCapture.vue'
import type { Chunk } from '../types'
interface InferStats {
@ -154,14 +153,19 @@ function onAnswerClick(e: MouseEvent): void {
popupVisible.value = true
}
// 两种提问方式(文字检索 / 拍照)共用的准备工作:重置状态、打断正在进行的语音输入、停止旧的 TTS 播报
function beginQuery(): void {
async function sendQuery(): Promise<void> {
const q = question.value.trim()
if (!q) return
if (!store.dbStatus.ready) { message.warning('向量库为空,请先入库文档'); return }
if (store.querying) return
store.querying = true
store.resetAnswer()
inferStats.value = null
visionCaption.value = ''
readyCitations.value = new Set()
if (hoverTimer) { clearTimeout(hoverTimer); hoverTimer = null }
sentQuestion.value = q
question.value = ''
// 立即解除 ASR 监听并清空状态,防止延迟到达的 asr:stopped 通过 syncAsrText() 恢复输入框
cleanupAsrListeners()
asrBaseText = ''
@ -181,19 +185,6 @@ function beginQuery(): void {
startWaitTimer()
ttsPlayer.stop()
syncTtsConfig()
if (ttsEnabled.value) ttsPlayer.announceStart()
}
async function sendQuery(): Promise<void> {
const q = question.value.trim()
if (!q) return
if (!store.dbStatus.ready) { message.warning('向量库为空,请先入库文档'); return }
if (store.querying) return
beginQuery()
sentQuestion.value = q
sentImage.value = ''
question.value = ''
let firstDelta = true
@ -201,16 +192,13 @@ async function sendQuery(): Promise<void> {
const onContext = (data: unknown) => { store.chunks = (data as { chunks: Chunk[] }).chunks }
const onDelta = (data: unknown) => {
if (firstDelta) { firstDelta = false; stopWaitTimer() }
const delta = (data as { delta: string }).delta
store.rawAnswer += delta
if (ttsEnabled.value) ttsPlayer.append(delta)
store.rawAnswer += (data as { delta: string }).delta
scrollToBottom()
}
const onDone = (data: unknown) => {
stopWaitTimer()
store.querying = false
queryStage.value = ''
if (ttsEnabled.value) ttsPlayer.flush()
const stats = (data as { stats?: InferStats | null }).stats
if (stats) inferStats.value = stats
cleanup()
@ -219,7 +207,6 @@ async function sendQuery(): Promise<void> {
stopWaitTimer()
store.querying = false
queryStage.value = ''
if (ttsEnabled.value) ttsPlayer.flush()
message.error((data as { message: string }).message)
cleanup()
}
@ -241,102 +228,8 @@ async function sendQuery(): Promise<void> {
await window.electronAPI.query({ question: q, systemPrompt: store.config?.rag.systemPrompt ?? '', topK: topK.value })
}
// 拍照提问:先让视觉模型快速生成一句图像描述(跟用户问题拼接后去检索知识库),
// 再走一遍跟文字问答一样的检索+生成,图片本身不会传给最终生成模型
async function sendVisionQuery(): Promise<void> {
if (!pendingImage.value) return
if (store.querying) return
// 防守拍完照到点发送之间后端被切成纯文本模型的空窗(按钮此时已置灰,但图片还留在输入区)
if (!store.visionAvailable) {
message.warning('当前生成服务加载的不是多模态模型,无法识别图片')
return
}
const q = question.value.trim()
const img = pendingImage.value
beginQuery()
sentQuestion.value = q
sentImage.value = img
question.value = ''
pendingImage.value = ''
let firstDelta = true
const onStage = (data: unknown) => { queryStage.value = (data as { stage: string }).stage }
const onCaption = (data: unknown) => { visionCaption.value = (data as { caption: string }).caption }
const onContext = (data: unknown) => { store.chunks = (data as { chunks: Chunk[] }).chunks }
const onDelta = (data: unknown) => {
if (firstDelta) { firstDelta = false; stopWaitTimer() }
const delta = (data as { delta: string }).delta
store.rawAnswer += delta
if (ttsEnabled.value) ttsPlayer.append(delta)
scrollToBottom()
}
const onDone = (data: unknown) => {
stopWaitTimer()
store.querying = false
queryStage.value = ''
if (ttsEnabled.value) ttsPlayer.flush()
const stats = (data as { stats?: InferStats | null }).stats
if (stats) inferStats.value = stats
cleanup()
}
const onError = (data: unknown) => {
stopWaitTimer()
store.querying = false
queryStage.value = ''
if (ttsEnabled.value) ttsPlayer.flush()
message.error((data as { message: string }).message)
cleanup()
}
function cleanup(): void {
window.electronAPI.off('vision-query:stage')
window.electronAPI.off('vision-query:caption')
window.electronAPI.off('vision-query:context')
window.electronAPI.off('vision-query:delta')
window.electronAPI.off('vision-query:done')
window.electronAPI.off('vision-query:error')
}
window.electronAPI.on('vision-query:stage', onStage)
window.electronAPI.on('vision-query:caption', onCaption)
window.electronAPI.on('vision-query:context', onContext)
window.electronAPI.on('vision-query:delta', onDelta)
window.electronAPI.on('vision-query:done', onDone)
window.electronAPI.on('vision-query:error', onError)
await window.electronAPI.visionQuery({ question: q, imageDataUrl: img })
}
function handleSend(): void {
if (pendingImage.value) { sendVisionQuery(); return }
sendQuery()
}
function handleEnter(e: KeyboardEvent): void {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend() }
}
// ── 拍照提问 ──
const cameraOpen = ref(false)
const pendingImage = ref('')
const sentImage = ref('')
const visionCaption = ref('')
function onCaptured(dataUrl: string): void {
pendingImage.value = dataUrl
}
function removePendingImage(): void {
pendingImage.value = ''
}
async function onCameraDeviceChange(deviceId: string): Promise<void> {
if (!store.config) return
if (store.config.vision.cameraDeviceId === deviceId) return
await store.saveConfig({ ...store.config, vision: { ...store.config.vision, cameraDeviceId: deviceId } })
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendQuery() }
}
// ── 语音输入 ──
@ -569,6 +462,13 @@ onMounted(() => {
}
})
watch(() => store.querying, (querying) => {
// 答案流式生成结束后,整段朗读(先播"开始播报")
if (!querying && ttsEnabled.value && store.rawAnswer) {
ttsPlayer.speakWithAnnouncement(store.rawAnswer)
}
})
onUnmounted(() => {
stopWaitTimer()
if (hoverTimer) clearTimeout(hoverTimer)
@ -598,14 +498,10 @@ onUnmounted(() => {
<div class="q-ic">问</div>
<div>
<div class="q-label">提问</div>
<img v-if="sentImage" :src="sentImage" class="q-image" alt="提问附带的照片" />
<div class="q-text">{{ sentQuestion || (sentImage ? '(未输入文字,根据图片自动识别提问)' : '') }}</div>
<div class="q-text">{{ sentQuestion }}</div>
</div>
</div>
<!-- Vision caption:拍照提问识别到的图像描述,识别完就常驻显示 -->
<div v-if="visionCaption" class="vision-caption">识别到:{{ visionCaption }}</div>
<!-- Stage indicator -->
<div v-if="store.querying && !store.rawAnswer" class="stage-row">
<span class="stage-spinner" />
@ -675,44 +571,22 @@ onUnmounted(() => {
语音输出
</button>
</div>
<!-- Camera button:显示与否看用户开关,能否点击看后端当前模型是否支持图像输入 -->
<div v-if="store.config?.vision.enabled" class="voice-btn-wrap">
<button
class="voice-btn"
:disabled="!store.visionAvailable"
:title="store.visionAvailable ? '拍照提问' : '当前生成服务加载的不是多模态模型,无法识别图片'"
@click="cameraOpen = true"
>
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/>
<circle cx="12" cy="13" r="4"/>
</svg>
拍照提问
</button>
</div>
</div>
<!-- Question textarea -->
<div class="q-input-wrap">
<div v-if="pendingImage" class="pending-image-chip">
<img :src="pendingImage" alt="待发送的照片" />
<button class="pending-image-remove" title="移除照片" @click="removePendingImage">×</button>
</div>
<textarea
v-model="question"
class="q-input"
rows="1"
:disabled="batchRunning"
:placeholder="pendingImage ? '针对这张照片提问,留空则默认描述图片…' : '输入问题,Enter 发送,Shift+Enter 换行…'"
@keydown="handleEnter"
/>
</div>
<textarea
v-model="question"
class="q-input"
:disabled="batchRunning"
placeholder="输入问题,Enter 发送,Shift+Enter 换行…"
@keydown="handleEnter"
/>
<!-- Send -->
<button
class="i-btn i-btn-primary i-btn-lg send-btn"
:disabled="store.querying || (!question.trim() && !pendingImage) || batchRunning"
@click="handleSend"
:disabled="store.querying || !question.trim() || batchRunning"
@click="sendQuery"
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 2L11 13"/><path d="M22 2l-7 20-4-9-9-4 20-7z"/>
@ -745,14 +619,6 @@ onUnmounted(() => {
</n-scrollbar>
</n-card>
</n-modal>
<!-- Camera capture -->
<CameraCapture
v-model:show="cameraOpen"
:initial-device-id="store.config?.vision.cameraDeviceId ?? ''"
@captured="onCaptured"
@device-change="onCameraDeviceChange"
/>
</div>
</template>
@ -796,18 +662,6 @@ onUnmounted(() => {
}
.q-label { font-size: 13px; color: var(--blue-d); font-weight: 700; letter-spacing: .8px; margin-bottom: 3px; }
.q-text { font-size: 20px; font-weight: 700; line-height: 1.4; color: var(--ink); }
.q-image {
max-width: 240px; max-height: 240px; object-fit: contain;
border-radius: 8px; margin-bottom: 10px; display: block;
border: 2px solid var(--line);
}
/* Vision caption */
.vision-caption {
font-size: 15px; color: var(--ink-3);
background: var(--panel-2); border-radius: var(--radius-sm);
padding: 8px 14px; margin-bottom: 14px;
}
/* Stage indicator */
.stage-row {
@ -896,11 +750,9 @@ onUnmounted(() => {
display: flex; align-items: flex-end; gap: 14px;
}
.q-input-wrap { flex: 1; min-width: 0; height: 96px; position: relative; }
.q-input {
width: 100%; box-sizing: border-box;
height: 96px; min-height: 0; max-height: 96px;
flex: 1; min-width: 0;
height: 96px;
border: 2px solid var(--line-2); border-radius: var(--radius);
padding: 16px 20px; font-family: inherit;
font-size: 20px; line-height: 1.5; resize: none;
@ -910,26 +762,6 @@ onUnmounted(() => {
.q-input:focus { outline: none; border-color: var(--blue); }
.q-input:disabled { background: var(--panel-2); color: var(--ink-3); }
.pending-image-chip {
position: absolute; left: 14px; bottom: 100%;
margin-bottom: 8px;
display: flex; align-items: center;
border: 2px solid var(--line-2); border-radius: var(--radius-sm);
background: var(--panel); padding: 4px;
box-shadow: 0 6px 20px rgba(0,0,0,.12);
}
.pending-image-chip img {
width: 64px; height: 64px; object-fit: cover;
border-radius: 6px;
}
.pending-image-remove {
position: absolute; top: -8px; right: -8px;
width: 22px; height: 22px; border-radius: 50%;
border: none; background: var(--red); color: #fff;
font-size: 15px; line-height: 1; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.send-btn { height: 96px; min-width: 150px; }
.batch-btn { height: 96px; min-width: 130px; }
.i-btn-success { background: var(--green); color: #fff; box-shadow: 0 2px 0 #0a6238; }
@ -961,20 +793,14 @@ onUnmounted(() => {
font-family: inherit;
transition: all .15s;
}
.voice-btn:not(:disabled):hover { background: var(--panel-3); }
.voice-btn:hover { background: var(--panel-3); }
.voice-btn.on {
background: var(--green);
border-color: var(--green);
color: #fff;
box-shadow: 0 2px 0 #0a6238;
}
.voice-btn:disabled {
opacity: .5;
cursor: not-allowed;
background: var(--panel-3);
border-color: var(--line);
color: var(--ink-3);
}
.voice-btn:disabled { opacity: .6; cursor: default; }
.voice-spinner {
width: 22px;
height: 22px;

View File

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

View File

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

View File

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

View File

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

View File

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