dsh-dafeiyu-emotion 0.2.5 → 0.2.7

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,10 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.7
4
+
5
+ - Fixed a host activation failure: `apply` read `ctx.settings` while the plugin only declared `webServer`. Cordis throws `cannot get property "settings" without inject`, so the whole host plugin failed to mount and `/plugins/dsh-dafeiyu-emotion/config` returned 404.
6
+ - Added `settings` to the declared inject list so the host can mount and register the health, analyze and config routes.
7
+ - Added a regression assertion that both `webServer` and `settings` are declared dependencies.
8
+
9
+ ## 0.2.6
10
+
11
+ - 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.
12
+ - Added `scripts/test-config-schema.mjs`, which now parses `Config({})` and the shipped defaults before release.
13
+ - 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.
14
+ - Added request-scoped cancellation. Cancelling a queued request no longer kills the worker serving another session; `close()` invalidates queued work.
15
+ - Added visible keyboard focus to the three numeric settings inputs.
16
+ - `statusCopy` now shows “已停用” instead of “已就绪” when the plugin is disabled.
17
+ - Numeric config patches now require real integers instead of accepting booleans, `null`, or numeric strings.
18
+ - Removed the undeclared `settings.plugin.item` slot registration; the supported slots are `plugins.bundle.config` and `settings.plugins.tab`.
19
+ - Marked `Config` fields volatile at construction instead of mutating the exported schema.
20
+ - Weakened the auto-send success copy so a host that silently refuses `submit()` does not produce a false “delivered” claim.
21
+
3
22
  ## 0.2.5
4
23
 
5
24
  - Exported the Schemastery `Config` schema so DSH can persist setting changes from the panel.
6
25
  - Marked every config field volatile, matching the DSH settings service contract.
7
- - 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.
26
+ - 0.2.4 shipped the settings UI; on hosts that require a registered volatile schema, saving failed. 0.2.5 added the schema but its 1 MiB step rejected the 12 MiB default; install 0.2.6 instead.
8
27
 
9
28
  ## 0.2.4
10
29
 
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.5.tgz \
115
- https://github.com/LiDu-china/dsh-dafeiyu-emotion/releases/download/v0.2.5/dsh-dafeiyu-emotion-0.2.5.tgz
116
- dsh plugin --profile desktop add "D:/path/to/dsh-dafeiyu-emotion-0.2.5.tgz"
114
+ curl -L -o dsh-dafeiyu-emotion-0.2.7.tgz \
115
+ https://github.com/LiDu-china/dsh-dafeiyu-emotion/releases/download/v0.2.7/dsh-dafeiyu-emotion-0.2.7.tgz
116
+ dsh plugin --profile desktop add "D:/path/to/dsh-dafeiyu-emotion-0.2.7.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.5` 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.7` 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,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)。
@@ -0,0 +1,15 @@
1
+ ## 0.2.7
2
+
3
+ 修复设置页显示“设置暂时不可用 / 读取设置失败(404)”的 Host 激活问题。
4
+
5
+ - 0.2.5/0.2.6 在 `apply` 中读取了 `ctx.settings`,但插件只声明了 `webServer` 依赖。Cordis 会抛 `cannot get property "settings" without inject`,导致整个 Host 插件没有挂载,`/plugins/dsh-dafeiyu-emotion/config` 返回 404。
6
+ - 0.2.7 把 `settings` 加入声明依赖,Host 现在可以正常注册 health、analyze 和 config 路由。
7
+ - 新增回归断言:`webServer` 和 `settings` 必须同时出现在 `inject` 中。
8
+
9
+ ## 安装
10
+
11
+ ```bash
12
+ dsh plugin --profile desktop add dsh-dafeiyu-emotion
13
+ ```
14
+
15
+ 升级后请完全退出并重新启动 the agent harness,让 Host 重新加载插件。
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.5",
3
+ "version": "0.2.7",
4
4
  "description": "大肥鱼语音情绪耳朵:听语气、识别情绪,并让 DSH 按对方当下的状态自然回复。 · Voice + emotion-aware companion for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -62,7 +62,7 @@
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
68
  "@deepseek-ai/schemastery": "3.18.5",
@@ -2,7 +2,10 @@ import assert from 'node:assert/strict'
2
2
  import { EventEmitter } from 'node:events'
3
3
  import { Readable } from 'node:stream'
4
4
 
5
- import { apply } from '../src/index.js'
5
+ import { apply, inject } from '../src/index.js'
6
+
7
+ assert.ok(inject.includes('webServer'), 'webServer must be a declared dependency')
8
+ assert.ok(inject.includes('settings'), 'settings must be a declared dependency')
6
9
 
7
10
  const ANALYZE = '/plugins/dsh-dafeiyu-emotion/analyze'
8
11
  const HEALTH = '/plugins/dsh-dafeiyu-emotion/health'
@@ -99,6 +102,17 @@ async function call(handler, req) {
99
102
  assert.equal(json(res).error, '只允许本机访问')
100
103
  }
101
104
 
105
+ // Disabling the plugin must stop analysis even if the route stays mounted.
106
+ {
107
+ const { ctx, handlers } = makeCtx(undefined)
108
+ apply(ctx, { enabled: false, backendRoot: '' })
109
+ const res = await call(handlers.get(ANALYZE), makeReq({
110
+ body: { audioBase64: 'AA==' },
111
+ }))
112
+ assert.equal(res.status, 503)
113
+ assert.match(json(res).error, /已.*关闭/)
114
+ }
115
+
102
116
  // Wrong method is rejected.
103
117
  {
104
118
  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
@@ -8,7 +8,7 @@ import { fileURLToPath } from 'node:url'
8
8
  import Schema from '@deepseek-ai/schemastery'
9
9
 
10
10
  export const name = 'dsh-dafeiyu-emotion'
11
- export const inject = ['webServer']
11
+ export const inject = ['webServer', 'settings']
12
12
 
13
13
  const ANALYZE_ENDPOINT = '/plugins/dsh-dafeiyu-emotion/analyze'
14
14
  const HEALTH_ENDPOINT = '/plugins/dsh-dafeiyu-emotion/health'
@@ -32,40 +32,37 @@ const CONFIG_FIELDS = new Set(Object.keys(DEFAULTS))
32
32
  const LANGUAGES = new Set(['zh', 'en', 'auto'])
33
33
 
34
34
  export const Config = Schema.object({
35
- enabled: Schema.boolean().default(true).description('启用情绪耳朵'),
36
- backendRoot: Schema.string().default('').description('VoiceSense 项目目录'),
37
- pythonPath: Schema.string().default('').description('Python 解释器路径'),
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
38
  language: Schema.union([
39
39
  Schema.const('zh').description('中文'),
40
40
  Schema.const('en').description('English'),
41
41
  Schema.const('auto').description('自动'),
42
- ]).default('zh').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.
43
46
  maxAudioBytes: Schema.number()
44
47
  .min(256 * 1024)
45
48
  .max(32 * 1024 * 1024)
46
- .step(1024 * 1024)
47
49
  .default(12 * 1024 * 1024)
48
- .description('单次录音上限(字节)'),
50
+ .description('单次录音上限(字节)')
51
+ .volatile(),
49
52
  startupTimeoutMs: Schema.number()
50
53
  .min(10000)
51
54
  .max(600000)
52
- .step(5000)
53
55
  .default(180000)
54
- .description('后端启动超时(毫秒)'),
56
+ .description('后端启动超时(毫秒)')
57
+ .volatile(),
55
58
  requestTimeoutMs: Schema.number()
56
59
  .min(10000)
57
60
  .max(600000)
58
- .step(5000)
59
61
  .default(180000)
60
- .description('单次分析超时(毫秒)'),
62
+ .description('单次分析超时(毫秒)')
63
+ .volatile(),
61
64
  }).description('大肥鱼语音情绪耳朵')
62
65
 
63
- // DSH settings only exposes fields marked volatile, and the settings panel
64
- // writes through the same projection. Mark every field, not the root object.
65
- for (const [key, field] of Object.entries(Config.dict)) {
66
- Config.dict[key] = field.volatile()
67
- }
68
-
69
66
  const EMOTION_ZH = Object.freeze({
70
67
  happy: '喜悦',
71
68
  sad: '低落',
@@ -194,8 +191,10 @@ function normalizeConfigPatch(value) {
194
191
  patch[key] = text
195
192
  continue
196
193
  }
197
- const numeric = Number(next)
198
- 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
199
198
  if (key === 'maxAudioBytes') {
200
199
  patch.maxAudioBytes = Math.round(Math.min(32 * 1024 * 1024, Math.max(256 * 1024, numeric)))
201
200
  } else {
@@ -489,7 +488,11 @@ class VoiceBackend {
489
488
  this.startupAbort = null
490
489
  this.pending = new Map()
491
490
  this.nextId = 0
491
+ this.nextQueueId = 0
492
+ this.activeRequestId = null
492
493
  this.queue = Promise.resolve()
494
+ this.closed = false
495
+ this.generation = 0
493
496
  this.stderr = []
494
497
  this.lastError = null
495
498
  }
@@ -520,8 +523,13 @@ class VoiceBackend {
520
523
  } catch {}
521
524
  }
522
525
 
523
- /** Abort the in-flight worker so a cancelled request cannot block the queue. */
524
- 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
525
533
  const proc = this.proc
526
534
  if (!proc) return
527
535
  this.stopProcess(proc, { error: createAbortError(), preserveLastError: true })
@@ -536,6 +544,7 @@ class VoiceBackend {
536
544
  syncOptions(previous = {}) {
537
545
  const next = this.options
538
546
  this.python = locatePython(next)
547
+ this.closed = !next.enabled
539
548
  const restart = previous.backendRoot !== next.backendRoot
540
549
  || previous.pythonPath !== next.pythonPath
541
550
  || previous.startupTimeoutMs !== next.startupTimeoutMs
@@ -566,6 +575,9 @@ class VoiceBackend {
566
575
  }
567
576
 
568
577
  ensureStarted() {
578
+ if (this.closed || !this.options.enabled) {
579
+ return Promise.reject(new Error('情绪耳朵已关闭'))
580
+ }
569
581
  if (this.proc && !this.proc.killed && this.ready) return Promise.resolve()
570
582
  if (this.startup) return this.startup
571
583
  this.startup = new Promise((resolvePromise, rejectPromise) => {
@@ -704,14 +716,25 @@ class VoiceBackend {
704
716
  return this.startup
705
717
  }
706
718
 
707
- request(wavPath) {
719
+ request(wavPath, signal) {
720
+ const requestId = ++this.nextQueueId
721
+ const generation = this.generation
708
722
  const run = async () => {
723
+ if (this.closed || generation !== this.generation) throw new Error('语音后端已关闭')
724
+ if (signal?.aborted) throw createAbortError()
709
725
  await this.ensureStarted()
726
+ if (this.closed || generation !== this.generation) throw new Error('语音后端已关闭')
727
+ if (signal?.aborted) throw createAbortError()
710
728
  const proc = this.proc
711
729
  if (!proc || proc.killed || !proc.stdin) throw new Error('语音后端不可用')
730
+ this.activeRequestId = requestId
712
731
  const id = ++this.nextId
713
732
  let settled = false
714
733
  let timer
734
+ const onAbort = () => {
735
+ if (this.activeRequestId === requestId) this.cancel(requestId)
736
+ }
737
+ signal?.addEventListener?.('abort', onAbort, { once: true })
715
738
  const result = new Promise((resolvePromise, rejectPromise) => {
716
739
  const finish = (callback, value) => {
717
740
  if (settled) return
@@ -738,20 +761,30 @@ class VoiceBackend {
738
761
  this.lastError = `语音后端写入失败:${wrapped.message}`
739
762
  this.stopProcess(proc, { error: wrapped, preserveLastError: true })
740
763
  result.catch(() => {})
764
+ signal?.removeEventListener?.('abort', onAbort)
765
+ if (this.activeRequestId === requestId) this.activeRequestId = null
741
766
  throw wrapped
742
767
  }
743
- return result
768
+ try {
769
+ return await result
770
+ } finally {
771
+ signal?.removeEventListener?.('abort', onAbort)
772
+ if (this.activeRequestId === requestId) this.activeRequestId = null
773
+ }
744
774
  }
745
775
  const task = this.queue.then(run, run)
746
776
  this.queue = task.catch(() => {})
747
777
  return task
748
778
  }
749
779
 
750
- async analyze(wavPath) {
751
- return decorateAnalysis(await this.request(wavPath))
780
+ async analyze(wavPath, signal) {
781
+ return decorateAnalysis(await this.request(wavPath, signal))
752
782
  }
753
783
 
754
784
  async close() {
785
+ this.closed = true
786
+ this.generation += 1
787
+ this.activeRequestId = null
755
788
  const proc = this.proc
756
789
  this.startupAbort?.()
757
790
  this.proc = null
@@ -839,7 +872,6 @@ function createAnalyzeHandler(runtime, backend, getSpeechToText) {
839
872
  const onClose = () => {
840
873
  if (res.writableEnded) return
841
874
  controller?.abort()
842
- backend.cancel()
843
875
  }
844
876
  try {
845
877
  controller = new AbortController()
@@ -868,7 +900,7 @@ function createAnalyzeHandler(runtime, backend, getSpeechToText) {
868
900
  }
869
901
  if (options.backendRoot && existsSync(options.backendRoot)) {
870
902
  try {
871
- analysis = await backend.analyze(wavPath.replaceAll('\\', '/'))
903
+ analysis = await backend.analyze(wavPath.replaceAll('\\', '/'), controller.signal)
872
904
  } catch (error) {
873
905
  localError = error instanceof Error ? error.message : String(error)
874
906
  }
@@ -995,6 +1027,7 @@ export function apply(ctx, rawConfig = {}) {
995
1027
  }
996
1028
 
997
1029
  export {
1030
+ VoiceBackend,
998
1031
  buildFallbackAnalysis,
999
1032
  createConfigHandler,
1000
1033
  decorateAnalysis,