@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 +7 -3
- package/lib/client.js +8 -2
- package/lib/index.js +81 -7
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/scripts/smoke-panel.mjs +66 -0
- package/scripts/test-escape-focus.mjs +39 -4
- package/scripts/test-mic-build.mjs +146 -0
- package/src/client/index.js +8 -2
- package/src/index.ts +83 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yfwu2020/dsh-selection-explain",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.4",
|
|
4
4
|
"description": "DSH 划词解读:选中文字浮出按钮,点击弹窗流式显示专业中英翻译 + 该文字在当前上下文中的含义详解",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"scripts": {
|
|
44
44
|
"build": "bash scripts/build.sh",
|
|
45
45
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
46
|
-
"test": "node scripts/test-filters.mjs && node scripts/test-prompt.mjs && node scripts/test-model-follow.mjs && node scripts/test-bridge.mjs && node scripts/smoke-bridge.mjs && node scripts/smoke-panel.mjs && node scripts/test-escape-focus.mjs && node scripts/test-effort-retry.mjs && node scripts/test-effort-map.mjs && node scripts/test-client.mjs && node scripts/test-settings-host.mjs && node scripts/test-settings-behavior.mjs && node scripts/test-route-cache.mjs && node scripts/test-transcript.mjs && node scripts/test-guard.mjs && node scripts/test-settings.mjs && node scripts/test-format.mjs && node scripts/test-speech.mjs && node scripts/test-mic-idle.mjs && node scripts/smoke-voice.mjs",
|
|
46
|
+
"test": "node scripts/test-filters.mjs && node scripts/test-prompt.mjs && node scripts/test-model-follow.mjs && node scripts/test-bridge.mjs && node scripts/smoke-bridge.mjs && node scripts/smoke-panel.mjs && node scripts/test-escape-focus.mjs && node scripts/test-effort-retry.mjs && node scripts/test-effort-map.mjs && node scripts/test-client.mjs && node scripts/test-settings-host.mjs && node scripts/test-settings-behavior.mjs && node scripts/test-route-cache.mjs && node scripts/test-transcript.mjs && node scripts/test-guard.mjs && node scripts/test-settings.mjs && node scripts/test-format.mjs && node scripts/test-speech.mjs && node scripts/test-mic-idle.mjs && node scripts/test-mic-build.mjs && node scripts/smoke-voice.mjs",
|
|
47
47
|
"test:effort": "node scripts/test-effort-retry.mjs",
|
|
48
48
|
"test:effort-map": "node scripts/test-effort-map.mjs",
|
|
49
49
|
"test:transcript": "node scripts/test-transcript.mjs",
|
package/scripts/smoke-panel.mjs
CHANGED
|
@@ -1504,8 +1504,57 @@ async function main() {
|
|
|
1504
1504
|
hook.open('模型菜单勾选', '', '菜单冒烟')
|
|
1505
1505
|
await waitFor(function () { return hook.state().phase === 'done' }, 8000)
|
|
1506
1506
|
await hook.loadModels(true)
|
|
1507
|
+
var modelNodes = hook.modelNodes()
|
|
1508
|
+
// DSH 按属性判定弹层,不检查 CSS 可见性(Issue #1)。
|
|
1509
|
+
var modalSelector = '[role="dialog"][aria-modal="true"], [role="menu"]'
|
|
1510
|
+
function modelMenuOwnsShortcuts() {
|
|
1511
|
+
return Array.from(document.querySelectorAll(modalSelector)).includes(modelNodes.menu)
|
|
1512
|
+
}
|
|
1513
|
+
function checkModelMenuClosed(reason) {
|
|
1514
|
+
check(reason + ':模型菜单不再占用 DSH 快捷键',
|
|
1515
|
+
!modelMenuOwnsShortcuts() && modelNodes.menu.getAttribute('data-open') !== '1' &&
|
|
1516
|
+
modelNodes.pill.getAttribute('aria-expanded') === 'false')
|
|
1517
|
+
}
|
|
1518
|
+
checkModelMenuClosed('初始收起')
|
|
1519
|
+
// 官方 useModalLayer 在 document 冒泡阶段处理 Esc;插件 capture 必须让行。
|
|
1520
|
+
function checkHostEscapeFirst(target, label) {
|
|
1521
|
+
var hostSettings = document.createElement('div')
|
|
1522
|
+
hostSettings.setAttribute('role', 'dialog')
|
|
1523
|
+
hostSettings.setAttribute('aria-modal', 'true')
|
|
1524
|
+
hostSettings.setAttribute('data-shortcut-modal', 'settings')
|
|
1525
|
+
var hostInput = document.createElement('input')
|
|
1526
|
+
hostSettings.appendChild(hostInput)
|
|
1527
|
+
document.body.appendChild(hostSettings)
|
|
1528
|
+
function dismissHost(event) {
|
|
1529
|
+
if (event.key !== 'Escape' || event.defaultPrevented) return
|
|
1530
|
+
event.preventDefault()
|
|
1531
|
+
hostSettings.remove()
|
|
1532
|
+
}
|
|
1533
|
+
document.addEventListener('keydown', dismissHost)
|
|
1534
|
+
try {
|
|
1535
|
+
var event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
|
1536
|
+
;(target || hostInput).dispatchEvent(event)
|
|
1537
|
+
check(label + ':Esc 到达官方浮层并只关闭官方设置',
|
|
1538
|
+
event.defaultPrevented && !hostSettings.isConnected && panel.style.display === 'flex')
|
|
1539
|
+
} finally {
|
|
1540
|
+
document.removeEventListener('keydown', dismissHost)
|
|
1541
|
+
hostSettings.remove()
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
var escapeModeFrame = document.createElement('div')
|
|
1545
|
+
escapeModeFrame.setAttribute('data-dsh-full-view', '')
|
|
1546
|
+
escapeModeFrame.setAttribute('data-dsh-active-floating-window', 'explanation')
|
|
1547
|
+
for (var fullViewMode of [false, true]) {
|
|
1548
|
+
if (fullViewMode) document.body.appendChild(escapeModeFrame)
|
|
1549
|
+
var modeLabel = fullViewMode ? '完整视图' : '普通分栏'
|
|
1550
|
+
checkHostEscapeFirst(null, modeLabel + '/焦点在官方设置')
|
|
1551
|
+
checkHostEscapeFirst(document.querySelector('.dsh-sel-askbox'), modeLabel + '/焦点仍在解读输入框')
|
|
1552
|
+
}
|
|
1553
|
+
escapeModeFrame.remove()
|
|
1507
1554
|
hook.openModel()
|
|
1508
1555
|
await sleep(120)
|
|
1556
|
+
check('模型菜单展开时占用 DSH 快捷键',
|
|
1557
|
+
modelMenuOwnsShortcuts() && modelNodes.pill.getAttribute('aria-expanded') === 'true')
|
|
1509
1558
|
var menu = hook.modelNodes().menu
|
|
1510
1559
|
var rows = Array.prototype.slice.call(menu.querySelectorAll('.dsh-sel-pickerrow'))
|
|
1511
1560
|
check('模型菜单列出 3 个模型(含同名 id 的另一 provider)', rows.length === 3, String(rows.length))
|
|
@@ -1529,7 +1578,24 @@ async function main() {
|
|
|
1529
1578
|
var rowBox = onRows[0] ? onRows[0].getBoundingClientRect() : null
|
|
1530
1579
|
check('打勾那一行仍是单行(高度不超过 30px,没折成两行)',
|
|
1531
1580
|
!!rowBox && rowBox.height <= 30, rowBox ? Math.round(rowBox.height) + 'px' : '没找到那一行')
|
|
1581
|
+
|
|
1582
|
+
checkHostEscapeFirst(modelNodes.menu, '模型菜单仍展开')
|
|
1583
|
+
check('官方设置关闭时保留插件模型菜单', modelNodes.menu.getAttribute('data-open') === '1')
|
|
1584
|
+
|
|
1585
|
+
clickOn(modelNodes.pill)
|
|
1586
|
+
checkModelMenuClosed('再次点击胶囊')
|
|
1587
|
+
hook.openModel()
|
|
1588
|
+
modelNodes.menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
|
1589
|
+
checkModelMenuClosed('按 Esc')
|
|
1590
|
+
hook.openModel()
|
|
1591
|
+
document.getElementById('docp').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
|
1592
|
+
checkModelMenuClosed('点击菜单外部')
|
|
1593
|
+
hook.openModel()
|
|
1594
|
+
clickOn(modelNodes.menu.querySelector('.dsh-sel-pickerrow'))
|
|
1595
|
+
checkModelMenuClosed('选择模型')
|
|
1596
|
+
hook.openModel()
|
|
1532
1597
|
hook.close() // 关窗会顺手收起模型菜单
|
|
1598
|
+
checkModelMenuClosed('关闭解读面板')
|
|
1533
1599
|
await sleep(30)
|
|
1534
1600
|
}
|
|
1535
1601
|
|
|
@@ -7,12 +7,14 @@ const start = source.indexOf(' var offKeyDown = listen(document,')
|
|
|
7
7
|
const end = source.indexOf('\n\n var offSettingsOpen', start)
|
|
8
8
|
assert.ok(start >= 0 && end > start)
|
|
9
9
|
const ask = {}, historyInput = {}, outside = {}, expandedChat = { closest: selector => selector.startsWith('[data-dsh-floating-chat]') ? {} : null }, compactChat = { closest: () => null }, nativeMenu = { closest: selector => selector.startsWith('[role=') ? {} : null }
|
|
10
|
-
let handler, fullView = true, calls = [], activeWindow = null, chatExpanded = true
|
|
10
|
+
let handler, fullView = true, calls = [], activeWindow = null, chatExpanded = true, hostModalOpen = false
|
|
11
11
|
const context = vm.createContext({
|
|
12
12
|
panelOpen: true, settingsOpen: false,
|
|
13
13
|
panel: { contains: node => node === ask },
|
|
14
14
|
historyList: { contains: node => node === historyInput },
|
|
15
|
-
document: { querySelector: selector => selector === '[
|
|
15
|
+
document: { querySelector: selector => selector === '[role="dialog"][aria-modal="true"]'
|
|
16
|
+
? hostModalOpen ? {} : null
|
|
17
|
+
: selector === '[data-dsh-full-view]' && fullView
|
|
16
18
|
? { getAttribute: () => activeWindow }
|
|
17
19
|
: selector.startsWith('[data-dsh-floating-chat]') && chatExpanded ? {} : null },
|
|
18
20
|
listen(target, type, callback, capture) { assert.equal(type, 'keydown'); assert.equal(capture, true); handler = callback },
|
|
@@ -27,7 +29,22 @@ function escape(target, extra = {}) {
|
|
|
27
29
|
const event = { key: 'Escape', target, ...extra, preventDefault() { calls.push('prevent') }, stopPropagation() { calls.push('stop') } }
|
|
28
30
|
handler(event)
|
|
29
31
|
}
|
|
30
|
-
function reset() { calls = []; activeWindow = null; chatExpanded = true; context.panelOpen = true; context.settingsOpen = false; context.settingsByKey = {}; context.captureRow.getAttribute = () => '0' }
|
|
32
|
+
function reset() { calls = []; activeWindow = null; chatExpanded = true; hostModalOpen = false; context.panelOpen = true; context.settingsOpen = false; context.settingsByKey = {}; context.captureRow.getAttribute = () => '0' }
|
|
33
|
+
for (const mode of [true, false]) {
|
|
34
|
+
for (const target of [ask, historyInput, outside, expandedChat]) {
|
|
35
|
+
reset(); fullView = mode; hostModalOpen = true; activeWindow = 'explanation'
|
|
36
|
+
escape(target)
|
|
37
|
+
assert.deepEqual(calls, [], '官方模态设置浮层优先消费 Esc,不受视图模式、焦点或最后操作窗口影响')
|
|
38
|
+
assert.equal(context.panelOpen, true)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
reset(); fullView = true; hostModalOpen = true; context.settingsOpen = true; escape(ask)
|
|
42
|
+
assert.deepEqual(calls, [], '官方设置打开时不关闭插件设置抽屉')
|
|
43
|
+
assert.equal(context.settingsOpen, true)
|
|
44
|
+
reset(); hostModalOpen = true; context.captureRow.getAttribute = () => '1'; escape(ask)
|
|
45
|
+
assert.deepEqual(calls, [], '官方设置打开时不取消插件录音')
|
|
46
|
+
hostModalOpen = false; escape(ask)
|
|
47
|
+
assert.deepEqual(calls, ['prevent', 'stop', 'voice'], '官方设置退出后,下一次 Esc 恢复插件的内层退出顺序')
|
|
31
48
|
reset(); activeWindow = 'chat'; escape(ask)
|
|
32
49
|
assert.deepEqual(calls, [], '最后点击聊天后,即使键盘仍在解读输入框,也让聊天先退出')
|
|
33
50
|
reset(); activeWindow = 'explanation'; escape(expandedChat)
|
|
@@ -63,4 +80,22 @@ for (const target of [ask, historyInput]) {
|
|
|
63
80
|
}
|
|
64
81
|
reset(); fullView = false; escape(outside)
|
|
65
82
|
assert.equal(context.panelOpen, false, '普通分栏保留解读原来的全局 Esc 行为')
|
|
66
|
-
|
|
83
|
+
|
|
84
|
+
// 语音卡另有一个更早注册的 capture 监听,也必须给官方浮层让出 Esc。
|
|
85
|
+
const panelHandler = handler
|
|
86
|
+
const voiceStart = source.indexOf(' var offVoiceEscape = listen(document,')
|
|
87
|
+
const voiceEnd = source.indexOf('\n }, true)', voiceStart) + '\n }, true)'.length
|
|
88
|
+
assert.ok(voiceStart >= 0 && voiceEnd > voiceStart)
|
|
89
|
+
context.vcardState = { open: true, suppress: false }
|
|
90
|
+
context.closeVCard = () => { calls.push('vcard'); context.vcardState.open = false }
|
|
91
|
+
new vm.Script(source.slice(voiceStart, voiceEnd)).runInContext(context)
|
|
92
|
+
const voiceHandler = handler
|
|
93
|
+
handler = panelHandler
|
|
94
|
+
reset(); hostModalOpen = true
|
|
95
|
+
voiceHandler({ key: 'Escape', preventDefault() { calls.push('prevent') }, stopPropagation() { calls.push('stop') } })
|
|
96
|
+
assert.deepEqual(calls, [], '官方设置打开时,语音卡不抢先消费 Esc')
|
|
97
|
+
assert.equal(context.vcardState.open, true)
|
|
98
|
+
hostModalOpen = false
|
|
99
|
+
voiceHandler({ key: 'Escape', preventDefault() { calls.push('prevent') }, stopPropagation() { calls.push('stop') } })
|
|
100
|
+
assert.deepEqual(calls, ['prevent', 'stop', 'vcard'], '官方设置退出后,Esc 可以关闭语音卡')
|
|
101
|
+
console.log('PASS focused Esc: host modals, independent windows, IME, nested settings, voice, history and split-mode behavior')
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「就地编译探针」:npm / Release 装下来的包里**没有** `lib/native/mic-probe`
|
|
3
|
+
* —— CI 跑在 ubuntu 上,`build.sh` 里那一步会被跳过(日志原话:
|
|
4
|
+
* "跳过原生探针(非 macOS 或没有 clang)"),所以包里只有 `native/mic-probe.c` 源码。
|
|
5
|
+
*
|
|
6
|
+
* 后果(2026-10-11 用户实测):从 npm 装到别的 Mac 上,豆包开麦永远不弹卡 ——
|
|
7
|
+
* host 直接报 `probe-missing`。修法是第一次有人问麦克风时**用 clang 现场编一份**,
|
|
8
|
+
* 按源码 hash 缓存进数据目录。
|
|
9
|
+
*
|
|
10
|
+
* 这条用例就复现"装包的机器":把随包的二进制**暂时挪开**、数据目录指向一个全新的
|
|
11
|
+
* DSH_HOME,然后问一次 /mic —— 应该 available:true,而且编出来的二进制真能跑(--once)。
|
|
12
|
+
*
|
|
13
|
+
* 只在 macOS + 有 clang 时有意义;其它平台 SKIP(与真浏览器冒烟同一套约定:跑不了就跳过)。
|
|
14
|
+
*
|
|
15
|
+
* 用法:node scripts/test-mic-build.mjs
|
|
16
|
+
*/
|
|
17
|
+
import { createServer } from 'node:http'
|
|
18
|
+
import { existsSync } from 'node:fs'
|
|
19
|
+
import { mkdtemp, readdir, rename, rm } from 'node:fs/promises'
|
|
20
|
+
import { spawnSync } from 'node:child_process'
|
|
21
|
+
import { tmpdir } from 'node:os'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
import { fileURLToPath } from 'node:url'
|
|
24
|
+
|
|
25
|
+
if (process.platform !== 'darwin' || spawnSync('clang', ['--version']).status !== 0) {
|
|
26
|
+
console.log('SKIP 就地编译探针是 macOS 专用(且需要 clang)—— 本机跳过')
|
|
27
|
+
process.exit(0)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const root = fileURLToPath(new URL('..', import.meta.url))
|
|
31
|
+
const packaged = join(root, 'lib/native/mic-probe')
|
|
32
|
+
const stash = packaged + '.probe-build-test'
|
|
33
|
+
const home = await mkdtemp(join(tmpdir(), 'dsh-mic-build-'))
|
|
34
|
+
// 数据目录与"注入了假探针"那条路都要避开:前者要全新,后者要显式删除(否则测不到编译)
|
|
35
|
+
process.env.DSH_HOME = home
|
|
36
|
+
delete process.env.DSH_SEL_MIC_PROBE
|
|
37
|
+
|
|
38
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
|
|
39
|
+
let passed = 0
|
|
40
|
+
let failed = 0
|
|
41
|
+
function check(label, ok, detail) {
|
|
42
|
+
if (ok) {
|
|
43
|
+
passed += 1
|
|
44
|
+
console.log(`PASS ${label}${detail ? ' — ' + detail : ''}`)
|
|
45
|
+
} else {
|
|
46
|
+
failed += 1
|
|
47
|
+
console.log(`FAIL ${label}${detail ? ' — ' + detail : ''}`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let moved = false
|
|
52
|
+
if (existsSync(packaged)) {
|
|
53
|
+
await rename(packaged, stash)
|
|
54
|
+
moved = true
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const routes = new Map()
|
|
58
|
+
const disposers = []
|
|
59
|
+
const ctx = {
|
|
60
|
+
llm: { listProviders: () => [] },
|
|
61
|
+
get: () => undefined,
|
|
62
|
+
on: () => () => {},
|
|
63
|
+
effect: (fn) => {
|
|
64
|
+
const dispose = fn()
|
|
65
|
+
disposers.push(dispose)
|
|
66
|
+
return dispose
|
|
67
|
+
},
|
|
68
|
+
logger: { info() {}, warn() {} },
|
|
69
|
+
webServer: {
|
|
70
|
+
register: (route) => {
|
|
71
|
+
routes.set(route.path, route.handler)
|
|
72
|
+
return () => routes.delete(route.path)
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
// ⚠️ 必须在"挪开二进制"之后才 import:探针路径是在模块求值时算的
|
|
79
|
+
const { apply } = await import('../lib/index.js')
|
|
80
|
+
apply(ctx, {})
|
|
81
|
+
const server = createServer((req, res) => {
|
|
82
|
+
const handler = routes.get(new URL(req.url, 'http://localhost').pathname)
|
|
83
|
+
if (!handler) {
|
|
84
|
+
res.writeHead(404)
|
|
85
|
+
res.end()
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
Promise.resolve(handler(req, res)).catch(() => {
|
|
89
|
+
if (!res.headersSent) res.writeHead(500)
|
|
90
|
+
res.end()
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
|
94
|
+
const origin = 'http://127.0.0.1:' + server.address().port
|
|
95
|
+
const dataDir = join(home, 'selection-explain')
|
|
96
|
+
|
|
97
|
+
check('(前提)装包的样子:随包的二进制不在,只剩源码', !existsSync(packaged) && existsSync(join(root, 'native/mic-probe.c')), '')
|
|
98
|
+
|
|
99
|
+
const payload = await (await fetch(`${origin}/selection-explain/api/mic`)).json()
|
|
100
|
+
check(
|
|
101
|
+
'装包的机器上第一次问麦克风 → 就地编出来,状态是 available(不再 probe-missing)',
|
|
102
|
+
payload.ok === true && payload.available === true && payload.reason === '',
|
|
103
|
+
JSON.stringify({ available: payload.available, reason: payload.reason }),
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
const files = existsSync(dataDir) ? await readdir(dataDir) : []
|
|
107
|
+
const compiled = files.filter((f) => /^mic-probe-[0-9a-f]{8}$/.test(f))
|
|
108
|
+
check(
|
|
109
|
+
'编出来的探针按源码 hash 缓存进数据目录(源码改了会自然重建)',
|
|
110
|
+
compiled.length === 1,
|
|
111
|
+
JSON.stringify(files),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
if (compiled.length === 1) {
|
|
115
|
+
const run = spawnSync(join(dataDir, compiled[0]), ['--once'], { encoding: 'utf8', timeout: 20_000 })
|
|
116
|
+
let parsed = null
|
|
117
|
+
try {
|
|
118
|
+
parsed = JSON.parse(String(run.stdout || '').trim().split('\n').pop() || 'null')
|
|
119
|
+
} catch {
|
|
120
|
+
/* 保持 null */
|
|
121
|
+
}
|
|
122
|
+
check(
|
|
123
|
+
'编出来的二进制真能跑(--once 输出一行合法 JSON)',
|
|
124
|
+
run.status === 0 && !!parsed && typeof parsed.capturing === 'boolean',
|
|
125
|
+
JSON.stringify({ status: run.status, stdout: String(run.stdout || '').trim().slice(0, 80) }),
|
|
126
|
+
)
|
|
127
|
+
} else {
|
|
128
|
+
check('编出来的二进制真能跑(--once 输出一行合法 JSON)', false, '没有找到编出来的探针')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
server.close()
|
|
132
|
+
} finally {
|
|
133
|
+
for (const dispose of disposers) {
|
|
134
|
+
try {
|
|
135
|
+
dispose()
|
|
136
|
+
} catch {
|
|
137
|
+
/* 忽略 */
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
await sleep(300)
|
|
141
|
+
if (moved) await rename(stash, packaged)
|
|
142
|
+
await rm(home, { recursive: true, force: true })
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
console.log(`\n探针就地编译:${passed} 通过 / ${failed} 失败`)
|
|
146
|
+
process.exit(failed ? 1 : 0)
|
package/src/client/index.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
|
-
|
|
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/src/index.ts
CHANGED
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { createHash } from 'node:crypto'
|
|
14
14
|
import { lookup } from 'node:dns/promises'
|
|
15
|
-
import { spawn } from 'node:child_process'
|
|
15
|
+
import { spawn, spawnSync } from 'node:child_process'
|
|
16
16
|
import type { ChildProcess } from 'node:child_process'
|
|
17
|
-
import { existsSync } from 'node:fs'
|
|
17
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
18
18
|
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
|
19
19
|
import { homedir } from 'node:os'
|
|
20
20
|
import { join, resolve, sep } from 'node:path'
|
|
@@ -133,9 +133,83 @@ const MIC_IDLE_CHECK_MS = Number(process.env.DSH_SEL_MIC_CHECK_MS || 5000)
|
|
|
133
133
|
*/
|
|
134
134
|
const MIC_WATCH_MS = Number(process.env.DSH_SEL_MIC_WATCH_MS || 125)
|
|
135
135
|
|
|
136
|
-
/**
|
|
137
|
-
const MIC_PROBE =
|
|
138
|
-
|
|
136
|
+
/** 探针二进制:随包构建出来的那个(与编译产物 lib/index.js 同级;测试可注入假的)。 */
|
|
137
|
+
const MIC_PROBE = fileURLToPath(new URL('native/mic-probe', import.meta.url))
|
|
138
|
+
/** 探针源码:发布包里**一定有它**(`files` 含 native/),二进制则不一定(见下)。 */
|
|
139
|
+
const MIC_PROBE_SRC = fileURLToPath(new URL('../native/mic-probe.c', import.meta.url))
|
|
140
|
+
|
|
141
|
+
/** 解析结果缓存:'' = 解析过了但没有可用探针(reason 说明为什么)。 */
|
|
142
|
+
let micProbeResolved: string | null = null
|
|
143
|
+
let micProbeReason = ''
|
|
144
|
+
|
|
145
|
+
/** 插件自己的数据目录(历史 / 记忆 / 就地编译出来的探针都放这儿)。 */
|
|
146
|
+
function pluginDataDir(): string {
|
|
147
|
+
return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'selection-explain')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 找到探针二进制 —— 找不到就**现场编一份**。
|
|
152
|
+
*
|
|
153
|
+
* 为什么必须运行时编译:CI 跑在 ubuntu 上,`build.sh` 里的原生探针那一步会被跳过
|
|
154
|
+
* (日志原话:"跳过原生探针(非 macOS 或没有 clang)"),于是 npm / Release 装下来的包里
|
|
155
|
+
* **只有 `native/mic-probe.c` 源码、没有 `lib/native/mic-probe`** —— macOS 用户装上就是
|
|
156
|
+
* `probe-missing`,豆包开麦永远不弹卡(2026-10-11 用户在另一台 Mac 上实测到的就是这个)。
|
|
157
|
+
*
|
|
158
|
+
* 顺序:环境变量覆盖(测试用)→ 随包构建的 → 数据目录里按**源码 hash** 缓存的那份
|
|
159
|
+
* (没有就 clang 编一次;源码改了就自然重建)→ 都没有则如实报原因。
|
|
160
|
+
* 编译失败不算致命:其余功能一律不受影响(和"没装识别服务"一个待遇)。
|
|
161
|
+
*/
|
|
162
|
+
function resolveMicProbe(): string {
|
|
163
|
+
if (micProbeResolved !== null) return micProbeResolved
|
|
164
|
+
const override = process.env.DSH_SEL_MIC_PROBE
|
|
165
|
+
if (override) {
|
|
166
|
+
micProbeResolved = existsSync(override) ? override : ''
|
|
167
|
+
if (!micProbeResolved) micProbeReason = 'probe-missing'
|
|
168
|
+
return micProbeResolved
|
|
169
|
+
}
|
|
170
|
+
if (existsSync(MIC_PROBE)) {
|
|
171
|
+
micProbeResolved = MIC_PROBE
|
|
172
|
+
return micProbeResolved
|
|
173
|
+
}
|
|
174
|
+
if (!existsSync(MIC_PROBE_SRC)) {
|
|
175
|
+
micProbeResolved = ''
|
|
176
|
+
micProbeReason = 'probe-missing'
|
|
177
|
+
return micProbeResolved
|
|
178
|
+
}
|
|
179
|
+
let tag = 'v1'
|
|
180
|
+
try {
|
|
181
|
+
tag = createHash('sha1').update(readFileSync(MIC_PROBE_SRC)).digest('hex').slice(0, 8)
|
|
182
|
+
} catch {
|
|
183
|
+
/* 读不了源码就按固定名缓存 */
|
|
184
|
+
}
|
|
185
|
+
const built = join(pluginDataDir(), `mic-probe-${tag}`)
|
|
186
|
+
if (existsSync(built)) {
|
|
187
|
+
micProbeResolved = built
|
|
188
|
+
return micProbeResolved
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
mkdirSync(pluginDataDir(), { recursive: true })
|
|
192
|
+
const result = spawnSync(
|
|
193
|
+
'clang',
|
|
194
|
+
['-O2', '-framework', 'CoreAudio', '-framework', 'CoreFoundation', MIC_PROBE_SRC, '-o', built],
|
|
195
|
+
{ timeout: 60_000 },
|
|
196
|
+
)
|
|
197
|
+
if (result.status === 0 && existsSync(built)) {
|
|
198
|
+
try {
|
|
199
|
+
chmodSync(built, 0o755)
|
|
200
|
+
} catch {
|
|
201
|
+
/* 权限位设不上也还能试跑 */
|
|
202
|
+
}
|
|
203
|
+
micProbeResolved = built
|
|
204
|
+
return micProbeResolved
|
|
205
|
+
}
|
|
206
|
+
} catch {
|
|
207
|
+
/* 下面统一给失败原因 */
|
|
208
|
+
}
|
|
209
|
+
micProbeResolved = ''
|
|
210
|
+
micProbeReason = 'probe-build-failed'
|
|
211
|
+
return micProbeResolved
|
|
212
|
+
}
|
|
139
213
|
|
|
140
214
|
/**
|
|
141
215
|
* 懒启动探针(第一次有人来问才 spawn)。
|
|
@@ -151,12 +225,13 @@ function ensureMicProbe(): void {
|
|
|
151
225
|
micState.reason = 'not-macos'
|
|
152
226
|
return
|
|
153
227
|
}
|
|
154
|
-
|
|
155
|
-
|
|
228
|
+
const probe = resolveMicProbe()
|
|
229
|
+
if (!probe) {
|
|
230
|
+
micState.reason = micProbeReason || 'probe-missing'
|
|
156
231
|
return
|
|
157
232
|
}
|
|
158
233
|
try {
|
|
159
|
-
const child = spawn(
|
|
234
|
+
const child = spawn(probe, ['--watch', String(MIC_WATCH_MS)], {
|
|
160
235
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
161
236
|
})
|
|
162
237
|
micChild = child
|