@yfwu2020/dsh-selection-explain 0.2.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -66,6 +66,58 @@
66
66
 
67
67
  <img src="assets/stage2.png" alt="展开详解后:翻译与详解两节卡片,下方可继续追问" width="540">
68
68
 
69
+ ### 语音输入:不想打字就说话
70
+
71
+ 追问往往只有一两句("这词在这里是不是贬义?"),可**打字**这件事本身就要把手从鼠标挪到键盘——和"划词"这个动作是矛盾的。输入框右下角因此多了一个麦克风,**交互与主会话的语音输入完全一致**(同一套录音行、同一个波形):
72
+
73
+ | 操作 | 结果 |
74
+ | --- | --- |
75
+ | 点一下 🎤 | 工具行换成**录音行**:`✕` 取消 · **实时波形**(跟着说话强弱) · `■` 停止 |
76
+ | **说话的时候** | **字就跟着往输入框里长**:半句预览(说了 1 秒多就出字,之后每 ~1.3 秒刷新)+ 停顿定稿(停 0.6 秒算这句说完,定稿的字不再变) |
77
+ | 点 `■` / 到了 60 秒 | 结束录音 → 整段再识别一次做收口(这段时间显示「识别中…」+ 呼吸点) |
78
+ | 点 `✕` / 录音中按 `Esc` | 取消这次语音:**松开麦克风**、实时字幕停下;**已经说出来的字留在输入框**(不删字)。`Esc` 只取消这一次语音,**面板留着**(再按一次才关窗) |
79
+ | 收起小窗 / 切走这个窗口 | 停录、**松开麦克风**(不会留下"正在使用麦克风"的标识),屏幕上已有的字留着 |
80
+ | 录音中把这条消息发出去了 | 停止录音(已经写进输入框的字跟着这条消息发出去) |
81
+
82
+ <img src="assets/voice.png" alt="录音中的小窗:工具行是「✕ 取消 · 实时波形 · ■ 停止」,输入框里已经跟着长出了两行文字(框自己撑开了)" width="540">
83
+
84
+ - **插进去,不是发出去**:识别结果落在光标处,可以和已经写了一半的话接着拼、改错字、再补第二段。识别有错字是常态,能改才有用。
85
+ - **框会跟着字长**:边说边落字时输入框自己往上撑(最多约 7 行,再多就内部滚动并停在最新一行)——字是"自己长出来"的,别指望你会去拖那个框。
86
+ - **实时字幕是"预览 + 定稿"两层**:说的过程中每 ~1.3 秒把没说完的半句送去识别一次,半句预览**整段替换**上一拍(窗口固定 ≤10 秒,所以说到第 40 秒也不会变慢);停 0.6 秒就算这句说完,送一次识别后**定稿**,之后不再变。点 `■` 结束时用整段录音再识别一次收口——**整段结果以已定稿的文字开头就只补后半句,定稿的字一个不动**(对不上才整块替换)。
87
+ - **实时字幕只在本地识别器上默认开**:每一拍都是一次识别调用,本机模型不花钱、只占点 CPU(实测 8 秒窗口约 0.2 秒);云端识别器按次计费,不做预览。拿不到麦克风原始采样(老浏览器)、或你手动改了预览那半句,就自动退回"停止后出字"。
88
+ - **音频不落盘、不进会话**:录到的音频只在这一次识别里用一下,host 转写完即丢——不会写进 `~/.dsh`,也不会出现在会话记录里。
89
+ - **识别在 host 上做**(装了本地 SenseVoice 就是**在你机器上**):音频不出机器;识别器走 DSH 的语音识别服务,本机模型 / 云端服务都能用。
90
+ - **出错留在录音行里说清是哪一种**:没授权 / 没麦克风设备 / 浏览器不支持 / 没启用识别服务 / 录音太长 / 没听清——各给一句原因 + 一个行内动作(🎤 重录,或模型没准备好时的 **`准备模型`** 按钮,下载在 host 上跑、关掉面板也继续)。按 `✕` 就收起、工具行原样还回来。
91
+ - 一次最长 **60 秒**;需要 DSH 装着语音识别服务(`@deepseek-ai/dsh-experimental-speech-to-text`)。没有那个服务时,点麦克风会明说不可用,不会装作在录。
92
+
93
+ <img src="assets/voice-inserted.png" alt="识别完成后:转写文字插进了输入框,录音行收起、🎤 回位" width="540">
94
+
95
+ ### 引用:把别处的文字带进追问
96
+
97
+ 追问时经常要指着一段东西问——"这段和上面那段矛盾吗"、"按这个格式再来一版"。**引用**就是把那段文字挂到输入框上,跟着这条提问一起发给模型:
98
+
99
+ | 想引用什么 | 怎么操作 |
100
+ | --- | --- |
101
+ | **小窗里的内容**(翻译 / 详解 / 某条回答里的几句) | 直接在小窗里划选 → 浮出 `❝ 引用` → 点一下 |
102
+ | **主界面上选中的文字**(小窗开着的时候) | 在页面里划选 → 同一个 `❝ 引用` → 点一下(侧边栏网页里划词也一样) |
103
+ | **整条回答** | 鼠标移到那条回答上,末尾浮出 `❝ 引用整条`(网页回答会先折成 Markdown,不会引用一整页 HTML 源码) |
104
+
105
+ <img src="assets/quotes.png" alt="引用:输入框上方的引用卡片、气泡里的引用块、回答末尾的「引用整条」" width="540">
106
+
107
+ - **引用卡片贴在输入框上方**:来源(小窗第 2 轮回答 / 小窗「翻译」节 / 主界面选中 / 侧边栏网页…)+ 内容摘要,右侧 `✕` 单条删除。最多 4 段,重复的不会重复加。
108
+ - **引用只带"模型看不到的东西"**:每段引用都写清**出自哪里**;只有材料本身不在模型上下文里时,才额外附上它当时所在的那一段对话。
109
+ - **小窗里的引用**(某条回答 / 翻译节 / 详解节 / 顶部选中文字条)→ **只写出处**(`小窗第 2 轮回答`、`小窗「翻译」节`……):小窗这几轮对话本来就会随历史进模型,再贴一遍是重复;
110
+ - **主界面(会话)里的引用** → 附上 `【引用处上下文】`:引用所在的**那一组对话 ± 一组**(一组 = 一条用户消息 + 它的回答,被引用的部分用 `【】` 标出),由 host 从**会话记录**里取,工具调用/思考/系统注入全部滤掉;
111
+ - **侧边栏网页里的引用** → 附上网页里选区前后各 1500 字的窗口。
112
+ - **发送时才拼进消息**:`【引用 1】(来自小窗第 2 轮回答)` +(需要时)`【引用处上下文】…` + `【我的问题】…` —— 出处/上下文在前、问题在后,用户气泡里也照这个顺序显示,回头翻记录知道当时在问什么。
113
+ - **只挂引用、一个字不写也能发**:这时用一句兜底提问("就上面引用的文字,说说它在这里是什么意思。"),不会空发。
114
+ - **只属于下一条提问**:发出即清空;换一段新的选中文字、或从「最近」回放另一段对话时也会清掉(那些引用属于上一次对话)。
115
+ - **小窗开着时浮标就是「引用」**:要解读新的一段,先按 `Esc` 收起小窗再划词——避免两个小窗抢同一处选区。
116
+ - **点完「✦ 解读」那行高亮会立刻收掉**(`Esc`、重开也一样):小窗顶部本来就写着选中文字,留着高亮反而碍事——
117
+ 小窗亮着时它是「活的选区」,在输入框里按一下方向键就会又冒出一个「❝ 引用」,指的却是刚才解读的那段。
118
+ 要引用它,重新划一次即可(那时浮标才是「❝ 引用」)。
119
+ - 引用会**进历史、也进升格**:回放这段对话时引用块跟着回来(以拼好的原文形式),升格出来的正式会话里同样带着。
120
+
69
121
  ### 输出偏好:Markdown ↔ 网页
70
122
 
71
123
  输入区左下角那个两格开关(`M↓` = Markdown / `▭` = 网页)决定**追问的回答用什么形式**。默认是 **Markdown**,选择记在浏览器本地,下次打开还是它。
@@ -224,18 +276,25 @@ dsh plugin --profile web remove @yfwu2020/dsh-selection-explain
224
276
  | 点击按钮 | 弹出面板:顶部是选中文字,正文是「翻译」卡片 |
225
277
  | 点 `↓ 展开详解` | 加载完整会话背景,追加「详解」卡片 |
226
278
  | 面板底部输入框 | 就这段文字继续追问(`Enter` 发送 / `Shift+Enter` 换行) |
279
+ | 输入框右边 🎤 | **语音输入**(与主会话同一套 UI):点一下开始说,`■` 结束并把文字插进输入框、`✕` 取消(`Esc` 同样取消,一次最长 60 秒) |
280
+ | 小窗开着时划词 | 浮出的是 `❝ 引用`(不是 `✦ 解读`):把这段文字挂进输入框的引用区,跟着下一条提问发给模型 |
281
+ | 鼠标移到某条回答上 | 末尾浮出 `❝ 引用整条`:把整条回答挂进引用区(网页回答先折成 Markdown) |
227
282
  | `🕘 最近` | 打开最近划过的列表,点一条调回那段对话 |
228
283
  | `↗ 升格` | 把这次解读变成正式会话 |
229
- | `✕` / `Esc` | 关闭面板(**任何时候都能关**,一次到位) |
284
+ | `✕` / `Esc` | 关闭面板。**语音进行中按 `Esc` 只取消这次语音(等价于点 `✕`,已说出来的字留着),面板留着**;再按一次才关窗 |
230
285
  | 右下角悬浮胶囊 | 点一下:关着就回到最近一次小窗,开着就收起(来回切换,不重新请求) |
231
286
 
232
287
  **几个不别扭的细节:**
233
288
 
234
289
  - **点面板外不会关闭面板**。答案留在原地,只有 `✕` / `Esc` / 右下角胶囊能收起——不会因为手滑点一下就得重新问。
290
+ - **输入框跟着内容长高**:写(或说)到第二行就自己撑开,最多约 7 行(132px);再长就内部滚动并**自动停在最新一行**——不会像以前那样第二行以下的字被裁掉。
291
+ - **开窗即能打字**:小窗打开、输入框出现后会自动获得焦点(只有"你正在别处打字"时才不抢)。`Tab` 也能摸到输入框和 🎤。
292
+ - **说话时看得见光标在跳**:录音一开始光标就落在输入框里,预览每落一句、光标跟到最新文字末尾——像个正在打字的人,而不是一坨自己冒出来的字。
235
293
  - 关闭时**会中止正在飞的请求**,并记成「已停止」(不是失败);内容都留着,点胶囊回来还是原样。
236
294
  - 拖动面板:抓头部即可拖动。
237
295
  - 追问时消息区**贴底跟随**;你主动往上翻就不会被拽回去。
238
296
  - `🕘 最近` 的列表点别处就收(不连带关面板),但 `Esc` 会把面板和它一起收。
297
+ - **引用是"下一条提问"的**:发出去就清空;引用卡片上的 `✕` 可以单条撤掉;最多 4 段。
239
298
 
240
299
  ### 调试钩子
241
300
 
@@ -247,6 +306,15 @@ window.__dshSelectionExplain.state() // { phase, chars, model, sections }
247
306
  window.__dshSelectionExplain.ping() // 当前模型路由与限制
248
307
  window.__dshSelectionExplain.bridge() // 侧边栏网页划词桥:{ on, frames:[{connected,own,srcdoc,sandbox}], active, selection }
249
308
  window.__dshSelectionExplain.selection() // 当前选区:{ text, source:'document'|'iframe', label, context }
309
+ window.__dshSelectionExplain.quotes() // 待发送的引用:[{ id, label, text, context, contextChars, session }]
310
+ window.__dshSelectionExplain.quote('某段文字', '主界面选中') // 手工加一段引用(等价于点浮标)
311
+ window.__dshSelectionExplain.compose() // 引用 + 提问拼出来的那条消息原文(发给模型的就是它)
312
+ window.__dshSelectionExplain.quoteState() // 引用浮标:{ visible, selection:{text,label,source}, panelOpen }
313
+ window.__dshSelectionExplain.voice() // 语音输入:{ phase, capture, activity, dot, waveform, stop, action, bars, lastText, catalog, live:{ active, committed, preview, passes, rewritten, tap } }
314
+ window.__dshSelectionExplain.voiceTick() // 手动催一拍实时字幕(测试用,不用等 1.3 秒节流)
315
+ window.__dshSelectionExplain.voiceNode() // 🎤 触发键节点(无头环境里可能挂了两棵树,用它拿真正带监听的那个)
316
+ window.__dshSelectionExplain.voiceNodes() // 录音行的各部件:{ row, cancel, wave, activity, action, stop }
317
+ window.__dshSelectionExplain.askValue() // 读追问输入框(传参即写入),用来验"转写文字插进去了"
250
318
  ```
251
319
 
252
320
  ### HTTP 接口
@@ -259,7 +327,20 @@ host 半注册了同源路由,可以单独调用:
259
327
  | `GET /selection-explain/api/ping` | 健康检查:当前模型路由、各阶段推理档位、限制 |
260
328
  | `GET /selection-explain/api/models` | 可选模型列表(面板里的模型选择器用) |
261
329
  | `GET/POST /selection-explain/api/history` | 小窗对话历史的读 / 写 / 删除 |
330
+ | `POST /selection-explain/api/quote-context` | 引用上下文:`{ sessionId, text }` → 该引用所在的**一组对话 ± 一组**(`{ matched, context, rounds }`;定位不到时 `matched:false`,客户端退回自己采的局部上下文) |
262
331
  | `POST /selection-explain/api/promote` | 升格为正式会话 |
332
+ | `GET /selection-explain/api/speech` | 语音输入:识别器目录与就绪状态(`{ available, providers:[{id,phase}], limits:{maxSeconds,maxBytes} }`) |
333
+ | `POST /selection-explain/api/speech/transcribe` | 一段 **16kHz 单声道 PCM16 WAV**(base64)→ 文字:`{ audioBase64, language?, providerId? }` |
334
+ | `POST /selection-explain/api/speech/prepare` | 显式准备本地识别模型(首次要下载,只在用户点了提示里的「准备模型」时调用) |
335
+
336
+ 转写示例(`SPEECH_WAV` 是一段规范 WAV):
337
+
338
+ ```bash
339
+ node -e 'const fs=require("fs");const b=fs.readFileSync(process.argv[1]);process.stdout.write(JSON.stringify({audioBase64:b.toString("base64")}))' /tmp/v.wav > /tmp/v.json
340
+ curl -s -X POST http://127.0.0.1:3080/selection-explain/api/speech/transcribe \
341
+ -H 'content-type: application/json' --data-binary @/tmp/v.json
342
+ # → {"ok":true,"text":"这是一段语音输入的测试…","providerId":"sensevoice-local",...}
343
+ ```
263
344
 
264
345
  健康检查示例:
265
346
 
@@ -309,6 +390,9 @@ curl -s http://127.0.0.1:3080/selection-explain/api/ping
309
390
  | `sessionContextFastMessages` | `8` | **首轮翻译**用的背景条数;越小首字越快 |
310
391
  | `sessionContextMaxMessages` | `24` | **详解 / 追问**用的背景条数(自选中文字所在消息向上取) |
311
392
  | `sessionContextMaxChars` | `0` | 会话背景字符上限;**`0` = 不限**(只按条数取窗口) |
393
+ | `quoteContextRounds` | `1` | **引用**带几组上下文:引用所在那一组对话 ± 这么多组(一组 = 用户消息 + 它的回答) |
394
+ | `quoteContextMaxCharsPerTurn` | `2000` | 引用上下文里单条消息的上限(超了**围绕引用**截断,不裁掉引用本身) |
395
+ | `quoteContextMaxChars` | `6000` | 引用上下文整段上限(超了先丢最早的、再丢引用之后的) |
312
396
  | `resultCacheTtlMs` | `600000` | 共享结果缓存 TTL(毫秒);`0` = 关闭 |
313
397
  | `historyMaxEntries` | `20` | 小窗对话历史保留多少个划词条目;`0` = 关闭历史 |
314
398
  | `maxRequestsPerMinute` | `40` | 本地限流,防误触发刷爆额度 |
@@ -359,8 +443,9 @@ curl -s http://127.0.0.1:3080/selection-explain/api/ping
359
443
 
360
444
  ```bash
361
445
  npm run build # 构建(= bash scripts/build.sh)
362
- npm test # 576 条断言(filters / prompt / bridge / smoke / client / transcript / guard)
363
- npm run test:smoke # 只跑真浏览器冒烟(本机 Chrome;找不到自动 SKIP)
446
+ npm test # 600+ 条断言(filters / prompt / bridge / smoke / client / transcript / guard / speech / voice)
447
+ npm run test:smoke # 只跑划词桥的真浏览器冒烟(本机 Chrome;找不到自动 SKIP)
448
+ npm run test:voice # 只跑语音输入的真浏览器冒烟(Chrome + 假麦克风 + 真识别;缺东西自动 SKIP)
364
449
  npm run typecheck # tsc --noEmit
365
450
  ```
366
451
 
@@ -373,6 +458,8 @@ npm run typecheck # tsc --noEmit
373
458
  | `scripts/test-filters.mjs` | host 侧流式过滤器单测(思考泄漏、工具调用残渣等) |
374
459
  | `scripts/test-transcript.mjs` | 会话背景窗口的离线测试(锚点 / 条数 / 不截断 / 噪音剔除 / 退化路径) |
375
460
  | `scripts/test-guard.mjs` | 文件工具读取边界的路径校验 |
461
+ | `scripts/test-speech.mjs` | **语音输入契约 + 真路由**:`validateWave` 逐字段打表(采样率/声道/位深/长度字段/多余 chunk 全都要拒)+ 真 host 的三个语音路由(坏 base64 / 非规范 WAV / 超大音频 / 非 POST / 正弦波不会"听"成话);给了 `SPEECH_WAV=/tmp/v.wav` 还会真识别一句 |
462
+ | `scripts/smoke-voice.mjs` | **语音输入真浏览器冒烟**:headless Chrome + `--use-file-for-fake-audio-capture` 把一段真人语音当麦克风,页面里跑**真的 lib/client.js**,走完 录音 → MediaRecorder → 重采样 → WAV → 真 host 识别 → 插进输入框;顺带断言浏览器产出的 WAV 能过 host 的逐字段校验(缺 Chrome / 缺 `say` / host 没在跑都自动 SKIP) |
376
463
  | `scripts/dump-prompt.mjs` | 打印实际注入的提示词(system + user + 各段尺寸),排查"模型到底看到了什么" |
377
464
 
378
465
  ```bash
@@ -395,9 +482,15 @@ git tag v0.2.0 && git push origin v0.2.0
395
482
 
396
483
  npm 侧只需配一次:包设置 → **Trusted Publisher** → GitHub Actions,填 `yfwu2020` / `dsh-selection-explain` / `publish.yml`,并勾选允许 **`npm publish`**(默认只允许 `npm stage publish`)。
397
484
 
398
- > 注意:`devDependencies` 里的 `@deepseek-ai/*` 钉在 **`0.1.5-rc.3`**(与插件开发所依据的运行时一致)。
485
+ > 注意:`devDependencies` 里的 `@deepseek-ai/*` 钉在 **`0.1.5-rc.3`**(CI 构建校验用的就是这份类型)。
399
486
  > npm 上这些包的 `latest` 还停在 `0.0.1-rc.x`,API 更旧(缺 `WebServer`、`createSystemMessage` 等),
400
487
  > 用 `latest` 会编译失败——升级运行时时要同步改这里。
488
+ >
489
+ > **本机运行时通常比它新**(如 `0.1.7-rc.2`),两者 API 会漂移:例如 `createSystemMessage` 在
490
+ > `0.1.5-rc.3` 是 `(text, plugin)` 两个必填参数,在 `0.1.7-rc.x` 只剩 `(text)`。此时**别只按本机类型写**,
491
+ > 走一层宽松别名兼容两边(见 `src/index.ts` 的 `systemMessageOf`)——v0.4.0 就是漏了这一步,
492
+ > 本机构建全绿、CI 构建报 TS2554,整条发布卡在第一步(与 Trusted Publisher 无关)。
493
+ > 想提前发现:把仓库复制到临时目录、`npm install --no-save`(会装到 `0.1.5-rc.3`)再 `bash scripts/build.sh`。
401
494
 
402
495
  ## 目录
403
496
 
@@ -422,8 +515,10 @@ lib/ 构建产物(已 gitignore,克隆后需 npm run buil
422
515
  - 侧边栏 **HTML 文件预览**已覆盖(靠帧内桥,见上);但**内置浏览器标签页里的外部站点**(远端 URL 的 iframe,插件没有任何注入手段)与**源码视图**(没有 iframe)不触发。
423
516
  - 预览帧被换成路由 URL 而非 `srcdoc`/`blob` 的第三方预览器不接管(不动别人的资源改写与生命周期)。
424
517
  - 面板**不跟随页面滚动**(滚动即收起浮标 / 面板),符合「划完即看」的一次性使用预期;侧边栏网页内部滚动会重新上报位置,浮标跟着走。
518
+ - **小窗里的网页回答预览(iframe)内部**划不了词(不透明源帧,插件不桥自己的预览):想引用它就用气泡末尾的 `❝ 引用整条`,或切到「源码」视图再划。
425
519
  - 结论来自当前模型,专业领域术语请以人工判断为准。
426
520
  - 依赖 DSH 的 `webServer` 与 `llm` 服务;宿主版本过旧可能不兼容(peerDependencies 见 `package.json`)。
521
+ - **语音输入**还需要宿主的语音识别服务(`@deepseek-ai/dsh-experimental-speech-to-text` + 一个识别器,例如本机 SenseVoice)。没装 / 没准备模型时,麦克风按钮会明说原因,不会静默失败。识别器只收 **16kHz 单声道 PCM16 WAV**(插件在浏览器里按这个格式编码);一次最长 60 秒,**不做边录边出的流式识别**(说完再转,一次给全)。
427
522
 
428
523
  ## License
429
524
 
Binary file
Binary file
Binary file