@yfwu2020/dsh-selection-explain 0.10.2 → 0.10.4

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
@@ -8,7 +8,7 @@ DSH 中的翻译、上下文解读与语音追问。
8
8
 
9
9
  [快速安装](#install) · [语音输入](#voice) · [功能一览](#read) · [使用与设置](#settings)
10
10
 
11
- `DSH 插件` · `v0.10.2` · `MIT`
11
+ `DSH 插件` · `v0.10.3` · `MIT`
12
12
 
13
13
  </div>
14
14
 
@@ -103,11 +103,11 @@ dsh plugin --profile web add @yfwu2020/dsh-selection-explain
103
103
  <details>
104
104
  <summary>安装 Release 构建包</summary>
105
105
 
106
- 将下载的 `.tgz` 放到当前目录,以 v0.10.2 为例:
106
+ 将下载的 `.tgz` 放到当前目录,以 v0.10.3 为例:
107
107
 
108
108
  ```bash
109
109
  mkdir -p ~/.dsh/plugins/dsh-selection-explain
110
- tar -xzf yfwu2020-dsh-selection-explain-0.10.2.tgz -C ~/.dsh/plugins/dsh-selection-explain --strip-components=1
110
+ tar -xzf yfwu2020-dsh-selection-explain-0.10.3.tgz -C ~/.dsh/plugins/dsh-selection-explain --strip-components=1
111
111
  dsh plugin --profile web add ~/.dsh/plugins/dsh-selection-explain
112
112
  ```
113
113
 
@@ -159,6 +159,10 @@ dsh plugin --profile web remove @yfwu2020/dsh-selection-explain
159
159
 
160
160
  聊天与解读可以同时打开。与 `dsh-full-view` **0.1.6**、`dsh-recent-sessions` **0.2.3** 配合时,`Esc` 先退出最后操作的小窗;点击两窗之外后先退解读,再按一次收起聊天。此次调整需一起更新两个插件,未改变 Harness 的公共浮层或设置。
161
161
 
162
+ ### 快捷键与 Esc 层级(0.10.3)
163
+
164
+ 模型菜单收起后不再被 DSH 识别为弹层,避免设置等快捷键失效。官方设置等模态浮层打开时,`Esc` 优先交给官方浮层处理,保留解读小窗、插件设置抽屉、模型菜单和语音卡;官方浮层关闭后,再按 `Esc` 继续退出插件层级。
165
+
162
166
  ## 数据与使用范围
163
167
 
164
168
  选中文字、必要的前后文,以及开启的会话背景会交给你配置的模型。可在设置中关闭会话背景或工具调用。
package/lib/client.js CHANGED
@@ -2648,7 +2648,7 @@ window.__ModuleLoader__.load({
2648
2648
  modelPill.appendChild(modelPillTier)
2649
2649
  modelPill.appendChild(el('span', 'dsh-sel-picker-caret', '▼'))
2650
2650
  var modelMenu = el('div', 'dsh-sel-pickermenu')
2651
- modelMenu.setAttribute('role', 'menu')
2651
+ // role 随开合同步:DSH 按 [role="menu"] 判定弹层,不检查 CSS 可见性。
2652
2652
 
2653
2653
  // ── 录音行(与主会话 ui-voice-input 同一套结构)──
2654
2654
  // [✕ 取消] [实时波形 / 状态文案] [■ 停止 或 行内动作] …… [↑ 发送(原位不动)]
@@ -9484,6 +9484,7 @@ window.__ModuleLoader__.load({
9484
9484
  */
9485
9485
  var offVoiceEscape = listen(document, 'keydown', function (event) {
9486
9486
  if (event.key !== 'Escape' || !vcardState.open || event.defaultPrevented) return
9487
+ if (document.querySelector('[role="dialog"][aria-modal="true"]')) return
9487
9488
  event.preventDefault()
9488
9489
  event.stopPropagation()
9489
9490
  vcardState.suppress = true
@@ -9936,11 +9937,13 @@ window.__ModuleLoader__.load({
9936
9937
 
9937
9938
  function closeModelMenu() {
9938
9939
  modelMenu.setAttribute('data-open', '0')
9940
+ modelMenu.removeAttribute('role')
9939
9941
  modelPill.setAttribute('aria-expanded', 'false')
9940
9942
  }
9941
9943
 
9942
9944
  function openModelMenu() {
9943
9945
  modelMenu.setAttribute('data-open', '1')
9946
+ modelMenu.setAttribute('role', 'menu')
9944
9947
  modelPill.setAttribute('aria-expanded', 'true')
9945
9948
  renderModelMenu()
9946
9949
  loadModelCatalog(false).then(function () {
@@ -9962,7 +9965,7 @@ window.__ModuleLoader__.load({
9962
9965
  event.stopPropagation() // 菜单内部点击不要触发"点到外面"
9963
9966
  })
9964
9967
  var offModelKeys = listen(modelMenu, 'keydown', function (event) {
9965
- if (event.key === 'Escape') closeModelMenu()
9968
+ if (event.key === 'Escape' && !document.querySelector('[role="dialog"][aria-modal="true"]')) closeModelMenu()
9966
9969
  })
9967
9970
 
9968
9971
  // —— 网页模式开关 ——
@@ -10140,6 +10143,9 @@ window.__ModuleLoader__.load({
10140
10143
 
10141
10144
  var offKeyDown = listen(document, 'keydown', function (event) {
10142
10145
  if (event.key !== 'Escape' || !panelOpen || event.defaultPrevented || event.isComposing || event.keyCode === 229) return
10146
+ // 官方模态浮层的 Esc 在冒泡阶段处理;capture 不消费,先让官方设置等浮层退出。
10147
+ // 插件面板和设置抽屉是非模态 dialog,不带 aria-modal,不会命中这里。
10148
+ if (document.querySelector('[role="dialog"][aria-modal="true"]')) return
10143
10149
  // 完整视图记录最后点击/操作的小窗;不能只看可能停在另一窗的键盘焦点。
10144
10150
  var fullViewFrame = document.querySelector('[data-dsh-full-view]')
10145
10151
  if (fullViewFrame) {
package/lib/index.js CHANGED
@@ -12,8 +12,8 @@
12
12
  */
13
13
  import { createHash } from 'node:crypto';
14
14
  import { lookup } from 'node:dns/promises';
15
- import { spawn } from 'node:child_process';
16
- import { existsSync } from 'node:fs';
15
+ import { spawn, spawnSync } from 'node:child_process';
16
+ import { chmodSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
17
17
  import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
18
18
  import { homedir } from 'node:os';
19
19
  import { join, resolve, sep } from 'node:path';
@@ -89,8 +89,81 @@ const MIC_IDLE_CHECK_MS = Number(process.env.DSH_SEL_MIC_CHECK_MS || 5000);
89
89
  * 原生探针会把区间夹在 50~5000ms 之间(见 native/mic-probe.c)。
90
90
  */
91
91
  const MIC_WATCH_MS = Number(process.env.DSH_SEL_MIC_WATCH_MS || 125);
92
- /** 探针路径:lib/native/mic-probe(与编译产物 lib/index.js 同级;测试可注入假的)。 */
93
- const MIC_PROBE = process.env.DSH_SEL_MIC_PROBE || fileURLToPath(new URL('native/mic-probe', import.meta.url));
92
+ /** 探针二进制:随包构建出来的那个(与编译产物 lib/index.js 同级;测试可注入假的)。 */
93
+ const MIC_PROBE = fileURLToPath(new URL('native/mic-probe', import.meta.url));
94
+ /** 探针源码:发布包里**一定有它**(`files` 含 native/),二进制则不一定(见下)。 */
95
+ const MIC_PROBE_SRC = fileURLToPath(new URL('../native/mic-probe.c', import.meta.url));
96
+ /** 解析结果缓存:'' = 解析过了但没有可用探针(reason 说明为什么)。 */
97
+ let micProbeResolved = null;
98
+ let micProbeReason = '';
99
+ /** 插件自己的数据目录(历史 / 记忆 / 就地编译出来的探针都放这儿)。 */
100
+ function pluginDataDir() {
101
+ return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain');
102
+ }
103
+ /**
104
+ * 找到探针二进制 —— 找不到就**现场编一份**。
105
+ *
106
+ * 为什么必须运行时编译:CI 跑在 ubuntu 上,`build.sh` 里的原生探针那一步会被跳过
107
+ * (日志原话:"跳过原生探针(非 macOS 或没有 clang)"),于是 npm / Release 装下来的包里
108
+ * **只有 `native/mic-probe.c` 源码、没有 `lib/native/mic-probe`** —— macOS 用户装上就是
109
+ * `probe-missing`,豆包开麦永远不弹卡(2026-10-11 用户在另一台 Mac 上实测到的就是这个)。
110
+ *
111
+ * 顺序:环境变量覆盖(测试用)→ 随包构建的 → 数据目录里按**源码 hash** 缓存的那份
112
+ * (没有就 clang 编一次;源码改了就自然重建)→ 都没有则如实报原因。
113
+ * 编译失败不算致命:其余功能一律不受影响(和"没装识别服务"一个待遇)。
114
+ */
115
+ function resolveMicProbe() {
116
+ if (micProbeResolved !== null)
117
+ return micProbeResolved;
118
+ const override = process.env.DSH_SEL_MIC_PROBE;
119
+ if (override) {
120
+ micProbeResolved = existsSync(override) ? override : '';
121
+ if (!micProbeResolved)
122
+ micProbeReason = 'probe-missing';
123
+ return micProbeResolved;
124
+ }
125
+ if (existsSync(MIC_PROBE)) {
126
+ micProbeResolved = MIC_PROBE;
127
+ return micProbeResolved;
128
+ }
129
+ if (!existsSync(MIC_PROBE_SRC)) {
130
+ micProbeResolved = '';
131
+ micProbeReason = 'probe-missing';
132
+ return micProbeResolved;
133
+ }
134
+ let tag = 'v1';
135
+ try {
136
+ tag = createHash('sha1').update(readFileSync(MIC_PROBE_SRC)).digest('hex').slice(0, 8);
137
+ }
138
+ catch {
139
+ /* 读不了源码就按固定名缓存 */
140
+ }
141
+ const built = join(pluginDataDir(), `mic-probe-${tag}`);
142
+ if (existsSync(built)) {
143
+ micProbeResolved = built;
144
+ return micProbeResolved;
145
+ }
146
+ try {
147
+ mkdirSync(pluginDataDir(), { recursive: true });
148
+ const result = spawnSync('clang', ['-O2', '-framework', 'CoreAudio', '-framework', 'CoreFoundation', MIC_PROBE_SRC, '-o', built], { timeout: 60_000 });
149
+ if (result.status === 0 && existsSync(built)) {
150
+ try {
151
+ chmodSync(built, 0o755);
152
+ }
153
+ catch {
154
+ /* 权限位设不上也还能试跑 */
155
+ }
156
+ micProbeResolved = built;
157
+ return micProbeResolved;
158
+ }
159
+ }
160
+ catch {
161
+ /* 下面统一给失败原因 */
162
+ }
163
+ micProbeResolved = '';
164
+ micProbeReason = 'probe-build-failed';
165
+ return micProbeResolved;
166
+ }
94
167
  /**
95
168
  * 懒启动探针(第一次有人来问才 spawn)。
96
169
  *
@@ -106,12 +179,13 @@ function ensureMicProbe() {
106
179
  micState.reason = 'not-macos';
107
180
  return;
108
181
  }
109
- if (!existsSync(MIC_PROBE)) {
110
- micState.reason = 'probe-missing';
182
+ const probe = resolveMicProbe();
183
+ if (!probe) {
184
+ micState.reason = micProbeReason || 'probe-missing';
111
185
  return;
112
186
  }
113
187
  try {
114
- const child = spawn(MIC_PROBE, ['--watch', String(MIC_WATCH_MS)], {
188
+ const child = spawn(probe, ['--watch', String(MIC_WATCH_MS)], {
115
189
  stdio: ['ignore', 'pipe', 'ignore'],
116
190
  });
117
191
  micChild = child;