dsh-dafeiyu-emotion 0.2.4 → 0.2.6
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/CHANGELOG.md +19 -0
- package/README.md +3 -3
- package/docs/MARKETPLACE.md +1 -1
- package/docs/RELEASE-0.2.4.md +14 -0
- package/docs/RELEASE-0.2.5.md +19 -0
- package/docs/RELEASE-0.2.6.md +20 -0
- package/lib/client.js +57 -13
- package/package.json +3 -2
- package/scripts/test-analyze-handler.mjs +11 -0
- package/scripts/test-backend-cancel.mjs +56 -0
- package/scripts/test-client-registration.mjs +2 -2
- package/scripts/test-config-handler.mjs +8 -0
- package/scripts/test-config-schema.mjs +53 -0
- package/src/index.js +79 -10
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.6
|
|
4
|
+
|
|
5
|
+
- Fixed a blocking `Config` validation error: the 12 MiB `maxAudioBytes` default violated the old 1 MiB step relative to the 256 KiB minimum. Removed the schema step so the shipped default and the settings inputs validate.
|
|
6
|
+
- Added `scripts/test-config-schema.mjs`, which now parses `Config({})` and the shipped defaults before release.
|
|
7
|
+
- Settings changes now apply live: saving `enabled` or `language` broadcasts to the mounted fish-ear, hides it when disabled, and stops the native shortcut from starting a recording.
|
|
8
|
+
- Added request-scoped cancellation. Cancelling a queued request no longer kills the worker serving another session; `close()` invalidates queued work.
|
|
9
|
+
- Added visible keyboard focus to the three numeric settings inputs.
|
|
10
|
+
- `statusCopy` now shows “已停用” instead of “已就绪” when the plugin is disabled.
|
|
11
|
+
- Numeric config patches now require real integers instead of accepting booleans, `null`, or numeric strings.
|
|
12
|
+
- Removed the undeclared `settings.plugin.item` slot registration; the supported slots are `plugins.bundle.config` and `settings.plugins.tab`.
|
|
13
|
+
- Marked `Config` fields volatile at construction instead of mutating the exported schema.
|
|
14
|
+
- Weakened the auto-send success copy so a host that silently refuses `submit()` does not produce a false “delivered” claim.
|
|
15
|
+
|
|
16
|
+
## 0.2.5
|
|
17
|
+
|
|
18
|
+
- Exported the Schemastery `Config` schema so DSH can persist setting changes from the panel.
|
|
19
|
+
- Marked every config field volatile, matching the DSH settings service contract.
|
|
20
|
+
- 0.2.4 shipped the settings UI; on hosts that require a registered volatile schema, saving failed. Install 0.2.5 for the full read/write path.
|
|
21
|
+
|
|
3
22
|
## 0.2.4
|
|
4
23
|
|
|
5
24
|
- Added a full settings card for DSH's plugin settings, with live status for lightweight and enhanced modes.
|
package/README.md
CHANGED
|
@@ -111,9 +111,9 @@ dsh plugin --profile desktop add github:LiDu-china/dsh-dafeiyu-emotion
|
|
|
111
111
|
### 通过 Release tarball
|
|
112
112
|
|
|
113
113
|
```bash
|
|
114
|
-
curl -L -o dsh-dafeiyu-emotion-0.2.
|
|
115
|
-
https://github.com/LiDu-china/dsh-dafeiyu-emotion/releases/download/v0.2.
|
|
116
|
-
dsh plugin --profile desktop add "D:/path/to/dsh-dafeiyu-emotion-0.2.
|
|
114
|
+
curl -L -o dsh-dafeiyu-emotion-0.2.6.tgz \
|
|
115
|
+
https://github.com/LiDu-china/dsh-dafeiyu-emotion/releases/download/v0.2.6/dsh-dafeiyu-emotion-0.2.6.tgz
|
|
116
|
+
dsh plugin --profile desktop add "D:/path/to/dsh-dafeiyu-emotion-0.2.6.tgz"
|
|
117
117
|
```
|
|
118
118
|
|
|
119
119
|
> DSH Desktop 的 profile 由 Electron 应用管理;命令行安装请使用桌面应用自带的 `dsh` 命令。
|
package/docs/MARKETPLACE.md
CHANGED
|
@@ -72,7 +72,7 @@ https://github.com/LiDu-china/dsh-dafeiyu-emotion/releases/download/v0.2.3/dsh-d
|
|
|
72
72
|
dsh plugin --profile desktop add dsh-dafeiyu-emotion
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
Published as `dsh-dafeiyu-emotion@0.2.
|
|
75
|
+
Published as `dsh-dafeiyu-emotion@0.2.6` on npm. The 1024Store entry refreshes its one-click install automatically. GitHub-source and Release-tarball installs remain available as fallbacks.
|
|
76
76
|
|
|
77
77
|
## Privacy
|
|
78
78
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
## 设置页与侧边栏
|
|
2
|
+
|
|
3
|
+
- 新增 DSH 插件设置卡片:启用/停用、识别语言、VoiceSense 目录、Python 路径、录音上限、启动/分析超时。
|
|
4
|
+
- 安装 Better Sidebar 后,可在右侧边栏 `+` 菜单固定「情绪耳朵」,在对话旁边直接调整。
|
|
5
|
+
- 顶部实时显示轻量模式、增强模式、模型加载中、后端目录缺失或等待 Voice Input。
|
|
6
|
+
- 保存后立即写入 DSH 设置;路径或超时变化时自动重启语音 worker。
|
|
7
|
+
|
|
8
|
+
## 安装
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
dsh plugin --profile desktop add dsh-dafeiyu-emotion
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
完整变更见 [CHANGELOG](https://github.com/LiDu-china/dsh-dafeiyu-emotion/blob/main/CHANGELOG.md)。
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
## 设置页与侧边栏
|
|
2
|
+
|
|
3
|
+
- 新增 DSH 插件设置卡片:启用/停用、识别语言、VoiceSense 目录、Python 路径、录音上限、启动/分析超时。
|
|
4
|
+
- 安装 Better Sidebar 后,可在右侧边栏 `+` 菜单固定「情绪耳朵」,在对话旁边直接调整。
|
|
5
|
+
- 顶部实时显示轻量模式、增强模式、模型加载中、后端目录缺失或等待 Voice Input。
|
|
6
|
+
- 保存后立即写入 DSH 设置;路径或超时变化时自动重启语音 worker。
|
|
7
|
+
|
|
8
|
+
## 0.2.5 修复
|
|
9
|
+
|
|
10
|
+
- 导出 DSH 所需的 Schemastery `Config` schema,并把每个字段标记为 volatile。
|
|
11
|
+
- 0.2.4 的设置面板可以显示,但在要求注册 schema 的宿主上保存会失败;0.2.5 修复完整读写链路。
|
|
12
|
+
|
|
13
|
+
## 安装
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
dsh plugin --profile desktop add dsh-dafeiyu-emotion
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
完整变更见 [CHANGELOG](https://github.com/LiDu-china/dsh-dafeiyu-emotion/blob/main/CHANGELOG.md)。
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
## 0.2.6
|
|
2
|
+
|
|
3
|
+
这是一个恢复可安装性并收紧设置链路的修复版本。
|
|
4
|
+
|
|
5
|
+
- 修复 `Config` 的 `maxAudioBytes` 默认值与旧 1 MiB step 冲突的问题;0.2.5 在部分宿主上会因为 schema 校验失败而无法激活。
|
|
6
|
+
- 设置页保存 `enabled` / `language` 后立即通知已挂载的鱼耳:关闭时隐藏按钮并禁用 `Alt+V`,切换语言时使用新值。
|
|
7
|
+
- 取消一个排队中的分析请求不再杀掉正在服务另一个会话的 worker。
|
|
8
|
+
- 三个数字输入框补上可见键盘焦点。
|
|
9
|
+
- 插件停用后状态卡显示“已停用”,不再显示“已就绪”。
|
|
10
|
+
- 数字配置只接受真实整数,拒绝布尔值、`null` 和数字字符串。
|
|
11
|
+
- 删除当前宿主未声明的 `settings.plugin.item` slot 注册。
|
|
12
|
+
- 新增 `Config` schema 解析测试,避免默认值再次违反自身约束。
|
|
13
|
+
|
|
14
|
+
## 安装
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
dsh plugin --profile desktop add dsh-dafeiyu-emotion
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
完整变更见 [CHANGELOG](https://github.com/LiDu-china/dsh-dafeiyu-emotion/blob/main/CHANGELOG.md)。
|
package/lib/client.js
CHANGED
|
@@ -552,6 +552,13 @@ window.__ModuleLoader__.load({
|
|
|
552
552
|
}
|
|
553
553
|
|
|
554
554
|
function statusCopy(health, config) {
|
|
555
|
+
if (config?.enabled === false || health?.enabled === false) {
|
|
556
|
+
return {
|
|
557
|
+
label: '已停用',
|
|
558
|
+
tone: 'muted',
|
|
559
|
+
detail: '在设置里重新启用后,鱼耳和语音快捷键会恢复。',
|
|
560
|
+
}
|
|
561
|
+
}
|
|
555
562
|
if (!health) {
|
|
556
563
|
return {
|
|
557
564
|
label: '正在检测',
|
|
@@ -674,6 +681,8 @@ window.__ModuleLoader__.load({
|
|
|
674
681
|
'aria-label': label,
|
|
675
682
|
style: {
|
|
676
683
|
display: 'inline-flex',
|
|
684
|
+
flexWrap: 'wrap',
|
|
685
|
+
maxWidth: '100%',
|
|
677
686
|
gap: 2,
|
|
678
687
|
padding: 3,
|
|
679
688
|
border: `1px solid ${T.border}`,
|
|
@@ -805,6 +814,9 @@ window.__ModuleLoader__.load({
|
|
|
805
814
|
setConfig(payload)
|
|
806
815
|
setDraft(payload)
|
|
807
816
|
setNotice({ kind: 'success', text: '设置已保存' })
|
|
817
|
+
try {
|
|
818
|
+
window.dispatchEvent(new CustomEvent('dsh-dafeiyu-emotion:config', { detail: payload }))
|
|
819
|
+
} catch {}
|
|
808
820
|
await refreshHealth()
|
|
809
821
|
} catch (error) {
|
|
810
822
|
setNotice({ kind: 'error', text: error instanceof Error ? error.message : String(error) })
|
|
@@ -896,6 +908,14 @@ window.__ModuleLoader__.load({
|
|
|
896
908
|
boxSizing: 'border-box',
|
|
897
909
|
transition: 'border-color 150ms ease, outline-color 150ms ease',
|
|
898
910
|
}
|
|
911
|
+
const focusRing = (event) => {
|
|
912
|
+
event.currentTarget.style.outlineColor = `color-mix(in srgb, ${T.brand} 28%, transparent)`
|
|
913
|
+
event.currentTarget.style.borderColor = T.brand
|
|
914
|
+
}
|
|
915
|
+
const blurRing = (event) => {
|
|
916
|
+
event.currentTarget.style.outlineColor = 'transparent'
|
|
917
|
+
event.currentTarget.style.borderColor = T.border
|
|
918
|
+
}
|
|
899
919
|
const numberStyle = { ...inputStyle, width: 96, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }
|
|
900
920
|
const secondaryButton = {
|
|
901
921
|
height: 32,
|
|
@@ -948,7 +968,7 @@ window.__ModuleLoader__.load({
|
|
|
948
968
|
: 'linear-gradient(135deg, color-mix(in srgb, #3e7cb1 10%, transparent), transparent 62%)',
|
|
949
969
|
},
|
|
950
970
|
},
|
|
951
|
-
h('div', { style: { display: 'flex', alignItems: 'center', gap: 12 } },
|
|
971
|
+
h('div', { style: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' } },
|
|
952
972
|
h('div', {
|
|
953
973
|
style: {
|
|
954
974
|
width: 44,
|
|
@@ -989,6 +1009,7 @@ window.__ModuleLoader__.load({
|
|
|
989
1009
|
gap: 5,
|
|
990
1010
|
height: 24,
|
|
991
1011
|
padding: '0 9px',
|
|
1012
|
+
flexShrink: 0,
|
|
992
1013
|
border: `1px solid color-mix(in srgb, ${color} 24%, transparent)`,
|
|
993
1014
|
borderRadius: 999,
|
|
994
1015
|
background: `color-mix(in srgb, ${color} 11%, transparent)`,
|
|
@@ -1221,6 +1242,8 @@ window.__ModuleLoader__.load({
|
|
|
1221
1242
|
disabled,
|
|
1222
1243
|
'aria-label': '单次录音上限(MB)',
|
|
1223
1244
|
onChange: (event) => update('maxAudioBytes', Math.round(clamp(Number(event.target.value) || 1, 1, 32)) * 1024 * 1024),
|
|
1245
|
+
onFocus: focusRing,
|
|
1246
|
+
onBlur: blurRing,
|
|
1224
1247
|
style: numberStyle,
|
|
1225
1248
|
}),
|
|
1226
1249
|
h('span', { style: { color: T.faint, fontSize: 11.5 } }, 'MB'),
|
|
@@ -1238,6 +1261,8 @@ window.__ModuleLoader__.load({
|
|
|
1238
1261
|
disabled,
|
|
1239
1262
|
'aria-label': '后端启动超时(秒)',
|
|
1240
1263
|
onChange: (event) => update('startupTimeoutMs', Math.round(clamp(Number(event.target.value) || 10, 10, 600)) * 1000),
|
|
1264
|
+
onFocus: focusRing,
|
|
1265
|
+
onBlur: blurRing,
|
|
1241
1266
|
style: numberStyle,
|
|
1242
1267
|
}),
|
|
1243
1268
|
h('span', { style: { color: T.faint, fontSize: 11.5 } }, '秒'),
|
|
@@ -1255,6 +1280,8 @@ window.__ModuleLoader__.load({
|
|
|
1255
1280
|
disabled,
|
|
1256
1281
|
'aria-label': '单次分析超时(秒)',
|
|
1257
1282
|
onChange: (event) => update('requestTimeoutMs', Math.round(clamp(Number(event.target.value) || 10, 10, 600)) * 1000),
|
|
1283
|
+
onFocus: focusRing,
|
|
1284
|
+
onBlur: blurRing,
|
|
1258
1285
|
style: numberStyle,
|
|
1259
1286
|
}),
|
|
1260
1287
|
h('span', { style: { color: T.faint, fontSize: 11.5 } }, '秒'),
|
|
@@ -1276,7 +1303,7 @@ window.__ModuleLoader__.load({
|
|
|
1276
1303
|
},
|
|
1277
1304
|
notice
|
|
1278
1305
|
? h('span', {
|
|
1279
|
-
role: 'status',
|
|
1306
|
+
role: notice.kind === 'error' ? 'alert' : 'status',
|
|
1280
1307
|
style: {
|
|
1281
1308
|
display: 'inline-flex',
|
|
1282
1309
|
alignItems: 'center',
|
|
@@ -1322,7 +1349,8 @@ window.__ModuleLoader__.load({
|
|
|
1322
1349
|
const [analysis, setAnalysis] = useState(null)
|
|
1323
1350
|
const [sent, setSent] = useState(false)
|
|
1324
1351
|
const [sending, setSending] = useState(false)
|
|
1325
|
-
const [pluginEnabled, setPluginEnabled] = useState(
|
|
1352
|
+
const [pluginEnabled, setPluginEnabled] = useState(null)
|
|
1353
|
+
const [language, setLanguage] = useState('zh')
|
|
1326
1354
|
const recorder = useRef(null)
|
|
1327
1355
|
const abort = useRef(null)
|
|
1328
1356
|
const timer = useRef(null)
|
|
@@ -1401,7 +1429,7 @@ window.__ModuleLoader__.load({
|
|
|
1401
1429
|
}
|
|
1402
1430
|
|
|
1403
1431
|
const start = async () => {
|
|
1404
|
-
if (locked || recorder.current) return
|
|
1432
|
+
if (pluginEnabled !== true || locked || recorder.current) return
|
|
1405
1433
|
const run = ++generation.current
|
|
1406
1434
|
clearTimers()
|
|
1407
1435
|
setMessage('')
|
|
@@ -1477,7 +1505,7 @@ window.__ModuleLoader__.load({
|
|
|
1477
1505
|
body: JSON.stringify({
|
|
1478
1506
|
audioBase64: bytesToBase64(wav),
|
|
1479
1507
|
sampleRate: 16000,
|
|
1480
|
-
language
|
|
1508
|
+
language,
|
|
1481
1509
|
prosody: capture.prosody,
|
|
1482
1510
|
}),
|
|
1483
1511
|
signal: abort.current.signal,
|
|
@@ -1538,7 +1566,7 @@ window.__ModuleLoader__.load({
|
|
|
1538
1566
|
inputActions.submit()
|
|
1539
1567
|
if (!isRunCurrent(run, generation)) return false
|
|
1540
1568
|
setSent(true)
|
|
1541
|
-
setMessage(`听出${payload.emotionZh || '你的情绪'}
|
|
1569
|
+
setMessage(`听出${payload.emotionZh || '你的情绪'},已填入并提交;若未出现请重试`)
|
|
1542
1570
|
await sleep(1350)
|
|
1543
1571
|
if (isRunCurrent(run, generation)) await reset()
|
|
1544
1572
|
return true
|
|
@@ -1571,9 +1599,9 @@ window.__ModuleLoader__.load({
|
|
|
1571
1599
|
|
|
1572
1600
|
useEffect(() => {
|
|
1573
1601
|
shortcutAction.current = {
|
|
1574
|
-
canHandle: () => canUseVoiceShortcut(phase, locked),
|
|
1602
|
+
canHandle: () => pluginEnabled === true && canUseVoiceShortcut(phase, locked),
|
|
1575
1603
|
run: () => {
|
|
1576
|
-
if (locked) return false
|
|
1604
|
+
if (pluginEnabled !== true || locked) return false
|
|
1577
1605
|
if (phase === 'idle') {
|
|
1578
1606
|
void start()
|
|
1579
1607
|
return true
|
|
@@ -1589,7 +1617,7 @@ window.__ModuleLoader__.load({
|
|
|
1589
1617
|
return false
|
|
1590
1618
|
},
|
|
1591
1619
|
}
|
|
1592
|
-
}, [locked, phase, sessionId])
|
|
1620
|
+
}, [locked, phase, sessionId, pluginEnabled])
|
|
1593
1621
|
|
|
1594
1622
|
useEffect(() => registerVoiceShortcut({
|
|
1595
1623
|
canHandle: () => shortcutAction.current?.canHandle?.() === true,
|
|
@@ -1598,18 +1626,34 @@ window.__ModuleLoader__.load({
|
|
|
1598
1626
|
|
|
1599
1627
|
useEffect(() => {
|
|
1600
1628
|
let active = true
|
|
1629
|
+
const applyConfig = (next) => {
|
|
1630
|
+
if (!active || !next) return
|
|
1631
|
+
setPluginEnabled(next.enabled !== false)
|
|
1632
|
+
if (next.language) setLanguage(next.language)
|
|
1633
|
+
}
|
|
1601
1634
|
fetch(CONFIG_ENDPOINT, { cache: 'no-store' })
|
|
1602
1635
|
.then((response) => (response.ok ? response.json() : null))
|
|
1603
1636
|
.then((next) => {
|
|
1604
|
-
if (
|
|
1637
|
+
if (!next) {
|
|
1638
|
+
if (active) setPluginEnabled(true)
|
|
1639
|
+
return
|
|
1640
|
+
}
|
|
1641
|
+
applyConfig(next)
|
|
1642
|
+
})
|
|
1643
|
+
.catch(() => {
|
|
1644
|
+
// An older host may not expose /config yet. Keep the fish-ear
|
|
1645
|
+
// visible rather than silently removing the entry point.
|
|
1646
|
+
if (active) setPluginEnabled(true)
|
|
1605
1647
|
})
|
|
1606
|
-
|
|
1648
|
+
const onConfig = (event) => applyConfig(event.detail)
|
|
1649
|
+
window.addEventListener?.('dsh-dafeiyu-emotion:config', onConfig)
|
|
1607
1650
|
return () => {
|
|
1608
1651
|
active = false
|
|
1652
|
+
window.removeEventListener?.('dsh-dafeiyu-emotion:config', onConfig)
|
|
1609
1653
|
}
|
|
1610
1654
|
}, [])
|
|
1611
1655
|
|
|
1612
|
-
if (
|
|
1656
|
+
if (pluginEnabled !== true) return null
|
|
1613
1657
|
|
|
1614
1658
|
if (phase === 'idle') {
|
|
1615
1659
|
return h('button', {
|
|
@@ -1744,7 +1788,7 @@ window.__ModuleLoader__.load({
|
|
|
1744
1788
|
return () => {}
|
|
1745
1789
|
}
|
|
1746
1790
|
}
|
|
1747
|
-
for (const slot of ['plugins.bundle.config', 'settings.plugins.tab'
|
|
1791
|
+
for (const slot of ['plugins.bundle.config', 'settings.plugins.tab']) {
|
|
1748
1792
|
try {
|
|
1749
1793
|
ctx.slots.inject(slot, registerSettingsCard(slot))
|
|
1750
1794
|
} catch (error) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-dafeiyu-emotion",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"description": "大肥鱼语音情绪耳朵:听语气、识别情绪,并让 DSH 按对方当下的状态自然回复。 · Voice + emotion-aware companion for DeepSeek Harness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -62,9 +62,10 @@
|
|
|
62
62
|
},
|
|
63
63
|
"scripts": {
|
|
64
64
|
"check": "node --check src/index.js && node --check lib/client.js",
|
|
65
|
-
"test": "node --check src/index.js && node --check lib/client.js && node --test scripts/test-
|
|
65
|
+
"test": "node --check src/index.js && node --check lib/client.js && node --test scripts/test-config-schema.mjs scripts/test-config-handler.mjs scripts/test-analyze-handler.mjs scripts/test-backend-cancel.mjs scripts/test-delivery-policy.mjs scripts/test-shortcut-registration.mjs scripts/test-client-registration.mjs"
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
|
+
"@deepseek-ai/schemastery": "3.18.5",
|
|
68
69
|
"dsh-dafeiyu": "0.1.23"
|
|
69
70
|
},
|
|
70
71
|
"dsh": {
|
|
@@ -99,6 +99,17 @@ async function call(handler, req) {
|
|
|
99
99
|
assert.equal(json(res).error, '只允许本机访问')
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
// Disabling the plugin must stop analysis even if the route stays mounted.
|
|
103
|
+
{
|
|
104
|
+
const { ctx, handlers } = makeCtx(undefined)
|
|
105
|
+
apply(ctx, { enabled: false, backendRoot: '' })
|
|
106
|
+
const res = await call(handlers.get(ANALYZE), makeReq({
|
|
107
|
+
body: { audioBase64: 'AA==' },
|
|
108
|
+
}))
|
|
109
|
+
assert.equal(res.status, 503)
|
|
110
|
+
assert.match(json(res).error, /已.*关闭/)
|
|
111
|
+
}
|
|
112
|
+
|
|
102
113
|
// Wrong method is rejected.
|
|
103
114
|
{
|
|
104
115
|
const { ctx, handlers } = makeCtx(undefined)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
|
|
3
|
+
import { VoiceBackend } from '../src/index.js'
|
|
4
|
+
|
|
5
|
+
function makeBackend() {
|
|
6
|
+
return new VoiceBackend({
|
|
7
|
+
enabled: true,
|
|
8
|
+
backendRoot: '',
|
|
9
|
+
pythonPath: '',
|
|
10
|
+
language: 'zh',
|
|
11
|
+
maxAudioBytes: 12 * 1024 * 1024,
|
|
12
|
+
startupTimeoutMs: 180000,
|
|
13
|
+
requestTimeoutMs: 180000,
|
|
14
|
+
}, { debug() {} })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Cancelling a queued request must not kill the worker serving another one.
|
|
18
|
+
{
|
|
19
|
+
const backend = makeBackend()
|
|
20
|
+
let killed = 0
|
|
21
|
+
backend.proc = {
|
|
22
|
+
killed: false,
|
|
23
|
+
kill() {
|
|
24
|
+
killed += 1
|
|
25
|
+
this.killed = true
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
backend.activeRequestId = 7
|
|
29
|
+
backend.cancel(8)
|
|
30
|
+
assert.equal(killed, 0, 'a non-active request must not kill the worker')
|
|
31
|
+
backend.cancel(7)
|
|
32
|
+
assert.equal(killed, 1, 'the active request may stop its own worker')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// close() invalidates queued work and records the generation boundary.
|
|
36
|
+
{
|
|
37
|
+
const backend = makeBackend()
|
|
38
|
+
const generation = backend.generation
|
|
39
|
+
await backend.close()
|
|
40
|
+
assert.equal(backend.closed, true)
|
|
41
|
+
assert.equal(backend.generation, generation + 1)
|
|
42
|
+
await assert.rejects(() => backend.ensureStarted(), /已关闭/)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Re-enabling through syncOptions clears the closed latch without starting a
|
|
46
|
+
// worker immediately; the next request starts it on demand.
|
|
47
|
+
{
|
|
48
|
+
const backend = makeBackend()
|
|
49
|
+
backend.closed = true
|
|
50
|
+
backend.options.enabled = true
|
|
51
|
+
backend.syncOptions({ ...backend.options, enabled: false })
|
|
52
|
+
assert.equal(backend.closed, false)
|
|
53
|
+
assert.equal(backend.proc, null)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log('backend cancellation tests passed')
|
|
@@ -117,13 +117,13 @@ client.apply(ctx)
|
|
|
117
117
|
assert.ok(injected.includes('conversation.input.activity'), 'voice activity slot is injected')
|
|
118
118
|
assert.ok(injected.includes('plugins.bundle.config'), 'bundle config slot is injected')
|
|
119
119
|
assert.ok(injected.includes('settings.plugins.tab'), 'settings tab slot is injected')
|
|
120
|
-
assert.
|
|
120
|
+
assert.equal(injected.includes('settings.plugin.item'), false, 'undeclared settings item slot is not injected')
|
|
121
121
|
|
|
122
122
|
const names = registrations.map((entry) => entry.spec.name)
|
|
123
123
|
assert.ok(names.includes('conversation.input.activity'))
|
|
124
124
|
assert.ok(names.includes('plugins.bundle.config'))
|
|
125
125
|
assert.ok(names.includes('settings.plugins.tab'))
|
|
126
|
-
assert.
|
|
126
|
+
assert.equal(names.includes('settings.plugin.item'), false)
|
|
127
127
|
|
|
128
128
|
for (const entry of registrations) {
|
|
129
129
|
assert.equal(entry.spec.id, 'dsh-dafeiyu-emotion')
|
|
@@ -96,6 +96,9 @@ async function call(handler, req) {
|
|
|
96
96
|
assert.throws(() => normalizeConfigPatch({ language: 'fr' }), /language/)
|
|
97
97
|
assert.throws(() => normalizeConfigPatch({ backendRoot: 'C:\\bad\u0000path' }), /本地路径/)
|
|
98
98
|
assert.throws(() => normalizeConfigPatch(null), /对象/)
|
|
99
|
+
assert.throws(() => normalizeConfigPatch({ maxAudioBytes: true }), /整数/)
|
|
100
|
+
assert.throws(() => normalizeConfigPatch({ maxAudioBytes: 1024.5 }), /整数/)
|
|
101
|
+
assert.throws(() => normalizeConfigPatch({ startupTimeoutMs: '12000' }), /整数/)
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
// GET returns the current runtime values.
|
|
@@ -183,6 +186,11 @@ async function call(handler, req) {
|
|
|
183
186
|
})
|
|
184
187
|
const invalid = await call(handler, makeReq({ method: 'PATCH', body: undefined }))
|
|
185
188
|
assert.equal(invalid.status, 400)
|
|
189
|
+
const tooLarge = await call(handler, makeReq({
|
|
190
|
+
method: 'PATCH',
|
|
191
|
+
body: { backendRoot: 'x'.repeat(20000) },
|
|
192
|
+
}))
|
|
193
|
+
assert.equal(tooLarge.status, 413)
|
|
186
194
|
}
|
|
187
195
|
|
|
188
196
|
console.log('config handler tests passed')
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
|
|
3
|
+
import { Config } from '../src/index.js'
|
|
4
|
+
|
|
5
|
+
// The DSH settings service parses the schema with its defaults before a plugin
|
|
6
|
+
// row can become configurable. A default that violates its own step/min/max
|
|
7
|
+
// makes the whole plugin fail to activate, not just the settings card.
|
|
8
|
+
assert.doesNotThrow(() => Config({}), 'empty config must validate')
|
|
9
|
+
assert.doesNotThrow(
|
|
10
|
+
() => Config({ maxAudioBytes: 12 * 1024 * 1024 }),
|
|
11
|
+
'the shipped 12 MiB default must validate',
|
|
12
|
+
)
|
|
13
|
+
assert.doesNotThrow(
|
|
14
|
+
() => Config({ startupTimeoutMs: 12000, requestTimeoutMs: 12000 }),
|
|
15
|
+
'non-five-second timeouts must validate',
|
|
16
|
+
)
|
|
17
|
+
assert.doesNotThrow(
|
|
18
|
+
() => Config({
|
|
19
|
+
enabled: false,
|
|
20
|
+
backendRoot: 'D:/voice-sense',
|
|
21
|
+
pythonPath: 'D:/voice-sense/.venv-ser/Scripts/python.exe',
|
|
22
|
+
language: 'auto',
|
|
23
|
+
maxAudioBytes: 8 * 1024 * 1024,
|
|
24
|
+
startupTimeoutMs: 180000,
|
|
25
|
+
requestTimeoutMs: 180000,
|
|
26
|
+
}),
|
|
27
|
+
'a full settings payload must validate',
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
for (const key of [
|
|
31
|
+
'enabled',
|
|
32
|
+
'backendRoot',
|
|
33
|
+
'pythonPath',
|
|
34
|
+
'language',
|
|
35
|
+
'maxAudioBytes',
|
|
36
|
+
'startupTimeoutMs',
|
|
37
|
+
'requestTimeoutMs',
|
|
38
|
+
]) {
|
|
39
|
+
assert.equal(Config.dict[key]?.meta?.volatile, true, `${key} must be volatile`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
assert.throws(
|
|
43
|
+
() => Config({ maxAudioBytes: 1 }),
|
|
44
|
+
/maxAudioBytes/,
|
|
45
|
+
'below-min values must be rejected by the schema',
|
|
46
|
+
)
|
|
47
|
+
assert.throws(
|
|
48
|
+
() => Config({ language: 'fr' }),
|
|
49
|
+
/language/,
|
|
50
|
+
'unknown languages must be rejected by the schema',
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
console.log('config schema tests passed')
|
package/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createInterface } from 'node:readline'
|
|
|
5
5
|
import { tmpdir } from 'node:os'
|
|
6
6
|
import { dirname, join, resolve } from 'node:path'
|
|
7
7
|
import { fileURLToPath } from 'node:url'
|
|
8
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
8
9
|
|
|
9
10
|
export const name = 'dsh-dafeiyu-emotion'
|
|
10
11
|
export const inject = ['webServer']
|
|
@@ -30,6 +31,38 @@ const DEFAULTS = Object.freeze({
|
|
|
30
31
|
const CONFIG_FIELDS = new Set(Object.keys(DEFAULTS))
|
|
31
32
|
const LANGUAGES = new Set(['zh', 'en', 'auto'])
|
|
32
33
|
|
|
34
|
+
export const Config = Schema.object({
|
|
35
|
+
enabled: Schema.boolean().default(true).description('启用情绪耳朵').volatile(),
|
|
36
|
+
backendRoot: Schema.string().default('').description('VoiceSense 项目目录').volatile(),
|
|
37
|
+
pythonPath: Schema.string().default('').description('Python 解释器路径').volatile(),
|
|
38
|
+
language: Schema.union([
|
|
39
|
+
Schema.const('zh').description('中文'),
|
|
40
|
+
Schema.const('en').description('English'),
|
|
41
|
+
Schema.const('auto').description('自动'),
|
|
42
|
+
]).default('zh').description('识别语言').volatile(),
|
|
43
|
+
// Avoid step(): DSH validates numbers against (value - min) / step, and
|
|
44
|
+
// the old 1 MiB step rejected the 12 MiB default. The custom settings card
|
|
45
|
+
// already enforces its own input granularity.
|
|
46
|
+
maxAudioBytes: Schema.number()
|
|
47
|
+
.min(256 * 1024)
|
|
48
|
+
.max(32 * 1024 * 1024)
|
|
49
|
+
.default(12 * 1024 * 1024)
|
|
50
|
+
.description('单次录音上限(字节)')
|
|
51
|
+
.volatile(),
|
|
52
|
+
startupTimeoutMs: Schema.number()
|
|
53
|
+
.min(10000)
|
|
54
|
+
.max(600000)
|
|
55
|
+
.default(180000)
|
|
56
|
+
.description('后端启动超时(毫秒)')
|
|
57
|
+
.volatile(),
|
|
58
|
+
requestTimeoutMs: Schema.number()
|
|
59
|
+
.min(10000)
|
|
60
|
+
.max(600000)
|
|
61
|
+
.default(180000)
|
|
62
|
+
.description('单次分析超时(毫秒)')
|
|
63
|
+
.volatile(),
|
|
64
|
+
}).description('大肥鱼语音情绪耳朵')
|
|
65
|
+
|
|
33
66
|
const EMOTION_ZH = Object.freeze({
|
|
34
67
|
happy: '喜悦',
|
|
35
68
|
sad: '低落',
|
|
@@ -158,8 +191,10 @@ function normalizeConfigPatch(value) {
|
|
|
158
191
|
patch[key] = text
|
|
159
192
|
continue
|
|
160
193
|
}
|
|
161
|
-
|
|
162
|
-
|
|
194
|
+
if (typeof next !== 'number' || !Number.isFinite(next) || !Number.isInteger(next)) {
|
|
195
|
+
throw new HttpError(400, `${key} 必须是整数`)
|
|
196
|
+
}
|
|
197
|
+
const numeric = next
|
|
163
198
|
if (key === 'maxAudioBytes') {
|
|
164
199
|
patch.maxAudioBytes = Math.round(Math.min(32 * 1024 * 1024, Math.max(256 * 1024, numeric)))
|
|
165
200
|
} else {
|
|
@@ -453,7 +488,11 @@ class VoiceBackend {
|
|
|
453
488
|
this.startupAbort = null
|
|
454
489
|
this.pending = new Map()
|
|
455
490
|
this.nextId = 0
|
|
491
|
+
this.nextQueueId = 0
|
|
492
|
+
this.activeRequestId = null
|
|
456
493
|
this.queue = Promise.resolve()
|
|
494
|
+
this.closed = false
|
|
495
|
+
this.generation = 0
|
|
457
496
|
this.stderr = []
|
|
458
497
|
this.lastError = null
|
|
459
498
|
}
|
|
@@ -484,8 +523,13 @@ class VoiceBackend {
|
|
|
484
523
|
} catch {}
|
|
485
524
|
}
|
|
486
525
|
|
|
487
|
-
/**
|
|
488
|
-
|
|
526
|
+
/**
|
|
527
|
+
* Abort one request. A queued request that has not reached the worker yet
|
|
528
|
+
* must not kill the worker serving the active request; its own AbortSignal
|
|
529
|
+
* will make it exit before it writes anything.
|
|
530
|
+
*/
|
|
531
|
+
cancel(requestId) {
|
|
532
|
+
if (requestId !== undefined && this.activeRequestId !== requestId) return
|
|
489
533
|
const proc = this.proc
|
|
490
534
|
if (!proc) return
|
|
491
535
|
this.stopProcess(proc, { error: createAbortError(), preserveLastError: true })
|
|
@@ -500,6 +544,7 @@ class VoiceBackend {
|
|
|
500
544
|
syncOptions(previous = {}) {
|
|
501
545
|
const next = this.options
|
|
502
546
|
this.python = locatePython(next)
|
|
547
|
+
this.closed = !next.enabled
|
|
503
548
|
const restart = previous.backendRoot !== next.backendRoot
|
|
504
549
|
|| previous.pythonPath !== next.pythonPath
|
|
505
550
|
|| previous.startupTimeoutMs !== next.startupTimeoutMs
|
|
@@ -530,6 +575,9 @@ class VoiceBackend {
|
|
|
530
575
|
}
|
|
531
576
|
|
|
532
577
|
ensureStarted() {
|
|
578
|
+
if (this.closed || !this.options.enabled) {
|
|
579
|
+
return Promise.reject(new Error('情绪耳朵已关闭'))
|
|
580
|
+
}
|
|
533
581
|
if (this.proc && !this.proc.killed && this.ready) return Promise.resolve()
|
|
534
582
|
if (this.startup) return this.startup
|
|
535
583
|
this.startup = new Promise((resolvePromise, rejectPromise) => {
|
|
@@ -668,14 +716,25 @@ class VoiceBackend {
|
|
|
668
716
|
return this.startup
|
|
669
717
|
}
|
|
670
718
|
|
|
671
|
-
request(wavPath) {
|
|
719
|
+
request(wavPath, signal) {
|
|
720
|
+
const requestId = ++this.nextQueueId
|
|
721
|
+
const generation = this.generation
|
|
672
722
|
const run = async () => {
|
|
723
|
+
if (this.closed || generation !== this.generation) throw new Error('语音后端已关闭')
|
|
724
|
+
if (signal?.aborted) throw createAbortError()
|
|
673
725
|
await this.ensureStarted()
|
|
726
|
+
if (this.closed || generation !== this.generation) throw new Error('语音后端已关闭')
|
|
727
|
+
if (signal?.aborted) throw createAbortError()
|
|
674
728
|
const proc = this.proc
|
|
675
729
|
if (!proc || proc.killed || !proc.stdin) throw new Error('语音后端不可用')
|
|
730
|
+
this.activeRequestId = requestId
|
|
676
731
|
const id = ++this.nextId
|
|
677
732
|
let settled = false
|
|
678
733
|
let timer
|
|
734
|
+
const onAbort = () => {
|
|
735
|
+
if (this.activeRequestId === requestId) this.cancel(requestId)
|
|
736
|
+
}
|
|
737
|
+
signal?.addEventListener?.('abort', onAbort, { once: true })
|
|
679
738
|
const result = new Promise((resolvePromise, rejectPromise) => {
|
|
680
739
|
const finish = (callback, value) => {
|
|
681
740
|
if (settled) return
|
|
@@ -702,20 +761,30 @@ class VoiceBackend {
|
|
|
702
761
|
this.lastError = `语音后端写入失败:${wrapped.message}`
|
|
703
762
|
this.stopProcess(proc, { error: wrapped, preserveLastError: true })
|
|
704
763
|
result.catch(() => {})
|
|
764
|
+
signal?.removeEventListener?.('abort', onAbort)
|
|
765
|
+
if (this.activeRequestId === requestId) this.activeRequestId = null
|
|
705
766
|
throw wrapped
|
|
706
767
|
}
|
|
707
|
-
|
|
768
|
+
try {
|
|
769
|
+
return await result
|
|
770
|
+
} finally {
|
|
771
|
+
signal?.removeEventListener?.('abort', onAbort)
|
|
772
|
+
if (this.activeRequestId === requestId) this.activeRequestId = null
|
|
773
|
+
}
|
|
708
774
|
}
|
|
709
775
|
const task = this.queue.then(run, run)
|
|
710
776
|
this.queue = task.catch(() => {})
|
|
711
777
|
return task
|
|
712
778
|
}
|
|
713
779
|
|
|
714
|
-
async analyze(wavPath) {
|
|
715
|
-
return decorateAnalysis(await this.request(wavPath))
|
|
780
|
+
async analyze(wavPath, signal) {
|
|
781
|
+
return decorateAnalysis(await this.request(wavPath, signal))
|
|
716
782
|
}
|
|
717
783
|
|
|
718
784
|
async close() {
|
|
785
|
+
this.closed = true
|
|
786
|
+
this.generation += 1
|
|
787
|
+
this.activeRequestId = null
|
|
719
788
|
const proc = this.proc
|
|
720
789
|
this.startupAbort?.()
|
|
721
790
|
this.proc = null
|
|
@@ -803,7 +872,6 @@ function createAnalyzeHandler(runtime, backend, getSpeechToText) {
|
|
|
803
872
|
const onClose = () => {
|
|
804
873
|
if (res.writableEnded) return
|
|
805
874
|
controller?.abort()
|
|
806
|
-
backend.cancel()
|
|
807
875
|
}
|
|
808
876
|
try {
|
|
809
877
|
controller = new AbortController()
|
|
@@ -832,7 +900,7 @@ function createAnalyzeHandler(runtime, backend, getSpeechToText) {
|
|
|
832
900
|
}
|
|
833
901
|
if (options.backendRoot && existsSync(options.backendRoot)) {
|
|
834
902
|
try {
|
|
835
|
-
analysis = await backend.analyze(wavPath.replaceAll('\\', '/'))
|
|
903
|
+
analysis = await backend.analyze(wavPath.replaceAll('\\', '/'), controller.signal)
|
|
836
904
|
} catch (error) {
|
|
837
905
|
localError = error instanceof Error ? error.message : String(error)
|
|
838
906
|
}
|
|
@@ -959,6 +1027,7 @@ export function apply(ctx, rawConfig = {}) {
|
|
|
959
1027
|
}
|
|
960
1028
|
|
|
961
1029
|
export {
|
|
1030
|
+
VoiceBackend,
|
|
962
1031
|
buildFallbackAnalysis,
|
|
963
1032
|
createConfigHandler,
|
|
964
1033
|
decorateAnalysis,
|