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 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.4.tgz \
115
- https://github.com/LiDu-china/dsh-dafeiyu-emotion/releases/download/v0.2.4/dsh-dafeiyu-emotion-0.2.4.tgz
116
- dsh plugin --profile desktop add "D:/path/to/dsh-dafeiyu-emotion-0.2.4.tgz"
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` 命令。
@@ -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.4` on npm. The 1024Store entry refreshes its one-click install automatically. GitHub-source and Release-tarball installs remain available as fallbacks.
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(true)
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: 'zh',
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 (active && next) setPluginEnabled(next.enabled !== false)
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
- .catch(() => {})
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 (!pluginEnabled) return null
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', 'settings.plugin.item']) {
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.4",
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-shortcut-registration.mjs scripts/test-delivery-policy.mjs scripts/test-analyze-handler.mjs scripts/test-config-handler.mjs scripts/test-client-registration.mjs"
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.ok(injected.includes('settings.plugin.item'), 'settings item slot is injected')
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.ok(names.includes('settings.plugin.item'))
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
- const numeric = Number(next)
162
- if (!Number.isFinite(numeric)) throw new HttpError(400, `${key} 必须是数字`)
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
- /** Abort the in-flight worker so a cancelled request cannot block the queue. */
488
- cancel() {
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
- return result
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,