dsh-live-voice 0.3.0 → 0.3.2

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +89 -1
  2. package/PLAN.md +25 -1
  3. package/README.md +4 -2
  4. package/docs/ARCHITECTURE.md +9 -16
  5. package/docs/CONFIGURATION.md +17 -3
  6. package/docs/VOICE-LIFECYCLE.md +5 -1
  7. package/lib/client.js +543 -326
  8. package/lib/server.js +158 -5
  9. package/package.json +2 -2
  10. package/src/app/AGENTS.md +7 -0
  11. package/src/app/ARCHITECTURE.md +10 -0
  12. package/src/app/client/apply.tsx +96 -40
  13. package/src/app/client/i18n/catalogs/base.ts +7 -0
  14. package/src/app/client/i18n/catalogs/en.ts +10 -0
  15. package/src/app/client/i18n/catalogs/es.ts +10 -0
  16. package/src/app/client/i18n/catalogs/fr.ts +10 -0
  17. package/src/app/client/i18n/catalogs/hi.ts +10 -0
  18. package/src/app/client/i18n/catalogs/pt-BR.ts +10 -0
  19. package/src/app/client/i18n/catalogs/zh.ts +9 -0
  20. package/src/app/server/apply.ts +4 -0
  21. package/src/app/server/registerRoutes.ts +40 -0
  22. package/src/modules/AGENTS.md +5 -0
  23. package/src/modules/conversation/ARCHITECTURE.md +7 -0
  24. package/src/modules/core/ARCHITECTURE.md +5 -0
  25. package/src/modules/core/coordinator.ts +8 -6
  26. package/src/modules/core/qwen/QwenHttpHost.ts +1 -1
  27. package/src/modules/core/qwen/QwenSettings.tsx +15 -14
  28. package/src/modules/core/settings.ts +26 -8
  29. package/src/modules/core/transcript.ts +5 -43
  30. package/src/modules/recognition/ARCHITECTURE.md +5 -0
  31. package/src/modules/recognition/engines/whisper/WhisperRecognitionEngine.ts +5 -3
  32. package/src/modules/recognition/engines/whisper/WhisperSettings.tsx +19 -20
  33. package/src/modules/settings/ARCHITECTURE.md +5 -0
  34. package/src/modules/settings/components/LiveVoiceSettings.tsx +11 -3
  35. package/src/modules/settings/models/settingsHost.ts +45 -0
  36. package/src/modules/settings/models/settingsStorage.ts +45 -10
  37. package/src/modules/settings/sections/conversation/ConversationSettingsSection.tsx +12 -6
  38. package/src/modules/settings/sections/recognition/RecognitionEngineSettings.tsx +2 -2
  39. package/src/modules/settings/sections/recognition/RecognitionFilterSettings.tsx +3 -3
  40. package/src/modules/settings/sections/recognition/RecognitionSettingsSection.tsx +7 -3
  41. package/src/modules/settings/sections/recognition/SilenceDetectionSettings.tsx +38 -8
  42. package/src/modules/settings/sections/recognition/VoiceCommandSettings.tsx +3 -3
  43. package/src/modules/settings/sections/speak/OutputFilterSettings.tsx +5 -5
  44. package/src/modules/settings/sections/speak/SpeakSettingsSection.tsx +7 -3
  45. package/src/modules/settings/sections/speak/SpeechAdvancedSettings.tsx +4 -4
  46. package/src/modules/settings/sections/speak/SpeechEngineSettings.tsx +2 -2
  47. package/src/modules/settings/services/releases.ts +10 -2
  48. package/src/modules/speak/ARCHITECTURE.md +5 -0
  49. package/src/shared/ARCHITECTURE.md +5 -0
  50. package/src/shared/design-system/forms/DraftField.tsx +79 -0
  51. package/src/shared/design-system/forms/NumberField.tsx +4 -10
  52. package/src/shared/design-system/forms/TextAreaField.tsx +4 -10
  53. package/src/shared/design-system/forms/TextField.tsx +4 -10
  54. package/src/shared/design-system/icons/icons.ts +4 -0
  55. package/src/shared/design-system/layout/SettingsSubcard.tsx +15 -3
  56. package/src/shared/design-system/layout/SettingsTabs.tsx +4 -2
  57. package/src/styles/index.ts +3 -3
@@ -0,0 +1,45 @@
1
+ import { readFile, mkdir, writeFile, rename, rm } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { normalizeSettings } from '../../core/settings.js';
6
+
7
+ /** Host-wide preferences, shared by every authenticated browser. */
8
+ export function createSettingsStore(
9
+ path = join(homedir(), '.dsh', 'dsh-live-voice.settings.json'),
10
+ ) {
11
+ let queue = Promise.resolve();
12
+ async function load() {
13
+ try {
14
+ return normalizeSettings(JSON.parse(await readFile(path, 'utf8')));
15
+ } catch (error) {
16
+ if (error.code === 'ENOENT') return normalizeSettings({});
17
+ throw error;
18
+ }
19
+ }
20
+ return {
21
+ load: () => queue.then(load),
22
+ save(patch: unknown) {
23
+ const operation = queue.then(async () => {
24
+ const settings = normalizeSettings({ ...(await load()), ...(patch as object) });
25
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
26
+ const temporary = path + '.' + randomUUID() + '.tmp';
27
+ try {
28
+ await writeFile(temporary, JSON.stringify(settings, null, 2) + '\n', {
29
+ mode: 0o600,
30
+ flag: 'wx',
31
+ });
32
+ await rename(temporary, path);
33
+ } finally {
34
+ await rm(temporary, { force: true });
35
+ }
36
+ return settings;
37
+ });
38
+ queue = operation.then(
39
+ () => {},
40
+ () => {},
41
+ );
42
+ return operation;
43
+ },
44
+ };
45
+ }
@@ -1,14 +1,49 @@
1
1
  import { normalizeSettings } from '../../core/settings.js';
2
2
  export const SETTINGS_STORAGE_KEY = 'dsh-live-voice.settings';
3
- export function readSettings(storage: Pick<Storage, 'getItem'> = localStorage) {
4
- try {
5
- return normalizeSettings(JSON.parse(storage.getItem(SETTINGS_STORAGE_KEY) || '{}'));
6
- } catch {
7
- return normalizeSettings({});
3
+ export const SETTINGS_PATH = '/api/dsh-live-voice/settings';
4
+
5
+ /** No browser persistence or migration from legacy browser preferences. */
6
+ export function createSettingsClient(fetchImpl = globalThis.fetch) {
7
+ let settings = normalizeSettings({});
8
+ let queue = Promise.resolve();
9
+ const listeners = new Set<(settings: ReturnType<typeof normalizeSettings>) => void>();
10
+ async function request(method: string, patch?: unknown) {
11
+ const response = await fetchImpl(SETTINGS_PATH, {
12
+ method,
13
+ credentials: 'same-origin',
14
+ cache: 'no-store',
15
+ ...(patch === undefined
16
+ ? {}
17
+ : { headers: { 'content-type': 'application/json' }, body: JSON.stringify(patch) }),
18
+ });
19
+ const body = await response.json();
20
+ if (!response.ok || body?.ok !== true) throw new Error('settings-request-failed');
21
+ settings = normalizeSettings(body.value);
22
+ for (const listener of listeners) listener(settings);
23
+ return settings;
8
24
  }
9
- }
10
- export function writeSettings(settings: unknown, storage: Pick<Storage, 'setItem'> = localStorage) {
11
- const normalized = normalizeSettings(settings);
12
- storage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(normalized));
13
- return normalized;
25
+ const ready = request('GET');
26
+ // Consumers report failures in their translated controller UI.
27
+ void ready.catch(() => {});
28
+ return {
29
+ ready,
30
+ getSnapshot: () => settings,
31
+ subscribe(listener: (settings: ReturnType<typeof normalizeSettings>) => void) {
32
+ listeners.add(listener);
33
+ return () => {
34
+ listeners.delete(listener);
35
+ };
36
+ },
37
+ save(patch: unknown) {
38
+ const operation = queue.then(async () => {
39
+ await ready;
40
+ return request('PUT', patch);
41
+ });
42
+ queue = operation.then(
43
+ () => {},
44
+ () => {},
45
+ );
46
+ return operation;
47
+ },
48
+ };
14
49
  }
@@ -1,5 +1,6 @@
1
1
  import React from 'react';
2
- import { SettingsCard } from '../../../../shared/design-system/index.js';
2
+ import { useLanguage } from '../../../../app/client/i18n/index.js';
3
+ import { SettingsCard, SettingsSubcard } from '../../../../shared/design-system/index.js';
3
4
  import { DeliverySettings } from './DeliverySettings.js';
4
5
  import { HoldToTalkSettings } from './HoldToTalkSettings.js';
5
6
  import { VoiceModeSettings } from './VoiceModeSettings.js';
@@ -9,14 +10,19 @@ export function ConversationSettingsSection(props: {
9
10
  settings: any;
10
11
  updateSettings(value: any): void;
11
12
  }) {
13
+ const { scoped: settingsLanguage } = useLanguage((ctx) => ctx.settings);
12
14
  return (
13
15
  <SettingsCard>
14
16
  <div className="dlv-settings-card-body">
15
- <DeliverySettings {...props} />
16
- <HoldToTalkSettings {...props} />
17
- <VoiceModeSettings {...props} />
18
- <PlaybackPolicySettings {...props} />
19
- <ConversationDelaySettings {...props} />
17
+ <SettingsSubcard title={(settingsLanguage as any).general.title()} icon="send">
18
+ <div className="dlv-conversation-fields">
19
+ <DeliverySettings {...props} />
20
+ <HoldToTalkSettings {...props} />
21
+ <VoiceModeSettings {...props} />
22
+ <PlaybackPolicySettings {...props} />
23
+ <ConversationDelaySettings {...props} />
24
+ </div>
25
+ </SettingsSubcard>
20
26
  </div>
21
27
  </SettingsCard>
22
28
  );
@@ -93,13 +93,13 @@ export function RecognitionEngineSettings({
93
93
  </>
94
94
  )}
95
95
  {engine === 'whisper-http' && (
96
- <SettingsSubcard title={(commons as any).connection.title()}>
96
+ <SettingsSubcard title={(commons as any).connection.title()} icon="link">
97
97
  <p>{(recognition as any).whisper.captureHelp()}</p>
98
98
  <WhisperSettings controller={controller} />
99
99
  </SettingsSubcard>
100
100
  )}
101
101
  {engine === 'qwen-http' && (
102
- <SettingsSubcard title={(commons as any).connection.title()}>
102
+ <SettingsSubcard title={(commons as any).connection.title()} icon="link">
103
103
  <p>{(recognition as any).qwen.captureHelp()}</p>
104
104
  {settings.engine !== 'qwen-http' && <QwenSettings controller={controller} />}
105
105
  </SettingsSubcard>
@@ -9,7 +9,7 @@ export function RecognitionFilterSettings({ settings, updateSettings }: any) {
9
9
  const { scoped: recognition } = useLanguage((ctx) => ctx.recognition);
10
10
  const enabled = settings.recognitionFilterEnabled !== false;
11
11
  return (
12
- <SettingsSubcard title={(recognition as any).minimumWords.label()}>
12
+ <SettingsSubcard title={(recognition as any).minimumWords.label()} icon="filter">
13
13
  <CheckboxField
14
14
  label={(recognition as any).minimumWords.enabled()}
15
15
  description={(recognition as any).minimumWords.help()}
@@ -22,8 +22,8 @@ export function RecognitionFilterSettings({ settings, updateSettings }: any) {
22
22
  max={20}
23
23
  value={settings.recognitionMinimumWords ?? 2}
24
24
  disabled={!enabled}
25
- onChange={(event) => {
26
- const value = Number(event.target.value);
25
+ onCommit={(raw) => {
26
+ const value = Number(raw);
27
27
  if (Number.isInteger(value) && value >= 1 && value <= 20)
28
28
  updateSettings({ recognitionMinimumWords: value });
29
29
  }}
@@ -1,19 +1,23 @@
1
1
  import React from 'react';
2
- import { SettingsCard } from '../../../../shared/design-system/index.js';
2
+ import { useLanguage } from '../../../../app/client/i18n/index.js';
3
+ import { SettingsCard, SettingsSubcard } from '../../../../shared/design-system/index.js';
3
4
  import { RecognitionEngineSettings } from './RecognitionEngineSettings.js';
4
5
  import { RecognitionFilterSettings } from './RecognitionFilterSettings.js';
5
6
  import { SilenceDetectionSettings } from './SilenceDetectionSettings.js';
6
7
  import { VoiceCommandSettings } from './VoiceCommandSettings.js';
7
8
  import { RecognitionStatus } from './RecognitionStatus.js';
8
9
  export function RecognitionSettingsSection(props: any) {
10
+ const { scoped: settingsLanguage } = useLanguage((ctx) => ctx.settings);
9
11
  return (
10
12
  <SettingsCard>
11
13
  <div className="dlv-settings-card-body">
12
- <RecognitionEngineSettings {...props} />
14
+ <RecognitionStatus {...props} />
15
+ <SettingsSubcard title={(settingsLanguage as any).general.title()} icon="mic">
16
+ <RecognitionEngineSettings {...props} />
17
+ </SettingsSubcard>
13
18
  <VoiceCommandSettings {...props} />
14
19
  <RecognitionFilterSettings {...props} />
15
20
  <SilenceDetectionSettings {...props} />
16
- <RecognitionStatus {...props} />
17
21
  </div>
18
22
  </SettingsCard>
19
23
  );
@@ -1,16 +1,25 @@
1
1
  import React from 'react';
2
2
  import { useLanguage } from '../../../../app/client/i18n/index.js';
3
- import { usesPluginVoiceDetection, voiceDetectionPresets } from '../../../core/settings.js';
3
+ import {
4
+ defaultSettings,
5
+ customSilenceMinMs,
6
+ customSilenceMaxMs,
7
+ usesPluginVoiceDetection,
8
+ voiceDetectionPresets,
9
+ voiceDetectionSilenceMs,
10
+ normalizeCustomSilenceMs,
11
+ } from '../../../core/settings.js';
4
12
  import { NumberField, SettingsSubcard } from '../../../../shared/design-system/index.js';
5
13
  export function SilenceDetectionSettings({ settings, updateSettings }: any) {
6
14
  const { scoped: recognition } = useLanguage((ctx) => ctx.recognition);
15
+ const savedCustomMs = normalizeCustomSilenceMs(settings.voiceDetectionCustomSilenceMs);
7
16
  if (!usesPluginVoiceDetection(settings.recognitionEngine)) return null;
8
- const selected = settings.voiceDetectionPreset || 'natural';
17
+ const selected = settings.voiceDetectionPreset || defaultSettings.voiceDetectionPreset;
9
18
  return (
10
19
  <SettingsSubcard
11
20
  title={(recognition as any).silenceDetection.label()}
12
21
  ariaLabel={(recognition as any).silenceDetection.title()}
13
- open
22
+ icon="pause"
14
23
  >
15
24
  <p>{(recognition as any).silenceDetection.help()}</p>
16
25
  <NumberField
@@ -19,8 +28,8 @@ export function SilenceDetectionSettings({ settings, updateSettings }: any) {
19
28
  max={300}
20
29
  step={1}
21
30
  value={settings.recognitionMaxUtteranceSeconds ?? 60}
22
- onChange={(event) => {
23
- const value = Number(event.target.value);
31
+ onCommit={(raw) => {
32
+ const value = Number(raw);
24
33
  if (Number.isInteger(value) && value >= 10 && value <= 300)
25
34
  updateSettings({ recognitionMaxUtteranceSeconds: value });
26
35
  }}
@@ -31,7 +40,7 @@ export function SilenceDetectionSettings({ settings, updateSettings }: any) {
31
40
  role="radiogroup"
32
41
  aria-label={(recognition as any).silenceDetection.pauseLabel()}
33
42
  >
34
- {Object.entries(voiceDetectionPresets).map(([value, preset]: [string, any]) => (
43
+ {[...Object.keys(voiceDetectionPresets), 'custom'].map((value) => (
35
44
  <label key={value} className="dlv-preset">
36
45
  <input
37
46
  type="radio"
@@ -47,10 +56,31 @@ export function SilenceDetectionSettings({ settings, updateSettings }: any) {
47
56
  </label>
48
57
  ))}
49
58
  </div>
59
+ {selected === 'custom' && (
60
+ <>
61
+ <NumberField
62
+ label={(recognition as any).silenceDetection.customLabel()}
63
+ min={customSilenceMinMs}
64
+ max={customSilenceMaxMs}
65
+ step={1}
66
+ value={savedCustomMs}
67
+ onCommit={(draft) => {
68
+ const value = Number(draft);
69
+ if (
70
+ draft.trim() &&
71
+ Number.isInteger(value) &&
72
+ value >= customSilenceMinMs &&
73
+ value <= customSilenceMaxMs
74
+ )
75
+ updateSettings({ voiceDetectionCustomSilenceMs: value });
76
+ }}
77
+ />
78
+ <small>{(recognition as any).silenceDetection.customHelp()}</small>
79
+ </>
80
+ )}
50
81
  <p className="dlv-vad-summary">
51
82
  {(recognition as any).silenceDetection.duration({
52
- milliseconds:
53
- voiceDetectionPresets[selected]?.silenceMs || voiceDetectionPresets.natural.silenceMs,
83
+ milliseconds: voiceDetectionSilenceMs(settings),
54
84
  })}
55
85
  </p>
56
86
  </SettingsSubcard>
@@ -18,7 +18,7 @@ export function VoiceCommandSettings({ settings, updateSettings }: any) {
18
18
  const { scoped: recognition } = useLanguage((ctx) => ctx.recognition);
19
19
  const enabled = settings.voiceCommandsEnabled !== false;
20
20
  return (
21
- <SettingsSubcard title={(recognition as any).commands.title()}>
21
+ <SettingsSubcard title={(recognition as any).commands.title()} icon="send">
22
22
  <CheckboxField
23
23
  label={(recognition as any).commands.enabled()}
24
24
  checked={enabled}
@@ -31,9 +31,9 @@ export function VoiceCommandSettings({ settings, updateSettings }: any) {
31
31
  rows={2}
32
32
  maxLength={1000}
33
33
  label={(recognition as any).commands[label]()}
34
- value={settings[key] || fallback}
34
+ value={settings[key] ?? fallback}
35
35
  disabled={!enabled}
36
- onChange={(event) => updateSettings({ [key]: event.target.value })}
36
+ onCommit={(value) => updateSettings({ [key]: value })}
37
37
  />
38
38
  ))}
39
39
  </SettingsSubcard>
@@ -10,7 +10,7 @@ export function OutputFilterSettings({ settings, updateSettings }: any) {
10
10
  const { scoped: speak } = useLanguage((ctx) => ctx.speak);
11
11
  const enabled = settings.outputCodeFilterEnabled !== false;
12
12
  return (
13
- <SettingsSubcard title={(speak as any).filters.code.enabled()}>
13
+ <SettingsSubcard title={(speak as any).filters.code.enabled()} icon="filter">
14
14
  <CheckboxField
15
15
  label={(speak as any).filters.code.enabled()}
16
16
  checked={enabled}
@@ -22,17 +22,17 @@ export function OutputFilterSettings({ settings, updateSettings }: any) {
22
22
  max={100}
23
23
  value={settings.outputCodeMaxLines ?? 5}
24
24
  disabled={!enabled}
25
- onChange={(event) => {
26
- const value = Number(event.target.value);
25
+ onCommit={(raw) => {
26
+ const value = Number(raw);
27
27
  if (Number.isInteger(value) && value >= 0 && value <= 100)
28
28
  updateSettings({ outputCodeMaxLines: value });
29
29
  }}
30
30
  />
31
31
  <TextField
32
32
  label={(speak as any).filters.code.replacement()}
33
- defaultValue={settings.outputCodeNotice || (speak as any).filters.code.notice()}
33
+ value={settings.outputCodeNotice ?? (speak as any).filters.code.notice()}
34
34
  disabled={!enabled}
35
- onBlur={(event) => updateSettings({ outputCodeNotice: event.target.value })}
35
+ onCommit={(value) => updateSettings({ outputCodeNotice: value })}
36
36
  />
37
37
  </SettingsSubcard>
38
38
  );
@@ -1,14 +1,18 @@
1
1
  import React from 'react';
2
- import { SettingsCard } from '../../../../shared/design-system/index.js';
2
+ import { useLanguage } from '../../../../app/client/i18n/index.js';
3
+ import { SettingsCard, SettingsSubcard } from '../../../../shared/design-system/index.js';
3
4
  import { OutputFilterSettings } from './OutputFilterSettings.js';
4
5
  import { SpeechEngineSettings } from './SpeechEngineSettings.js';
5
6
  import { SpeechAdvancedSettings } from './SpeechAdvancedSettings.js';
6
7
  export function SpeakSettingsSection(props: any) {
8
+ const { scoped: settingsLanguage } = useLanguage((ctx) => ctx.settings);
7
9
  return (
8
10
  <SettingsCard>
9
11
  <div className="dlv-settings-card-body">
10
- <SpeechEngineSettings {...props} />
11
- <SpeechAdvancedSettings {...props} />
12
+ <SettingsSubcard title={(settingsLanguage as any).general.title()} icon="speaker">
13
+ <SpeechEngineSettings {...props} />
14
+ <SpeechAdvancedSettings {...props} />
15
+ </SettingsSubcard>
12
16
  <OutputFilterSettings {...props} />
13
17
  </div>
14
18
  </SettingsCard>
@@ -39,11 +39,11 @@ export function SpeechAdvancedSettings({
39
39
  <p className="dlv-setting-description">{(speak as any).macos.outputHelp()}</p>
40
40
  )}
41
41
  {settings.engine === 'qwen-http' && (
42
- <SettingsSubcard title={(speak as any).qwen.connection()}>
42
+ <SettingsSubcard title={(speak as any).qwen.connection()} icon="link">
43
43
  <QwenSettings controller={controller} />
44
44
  </SettingsSubcard>
45
45
  )}
46
- <SettingsSubcard title={(speak as any).agentContext.label()}>
46
+ <SettingsSubcard title={(speak as any).agentContext.label()} icon="settings">
47
47
  <CheckboxField
48
48
  label={(speak as any).agentContext.enabled()}
49
49
  checked={settings.agentVoiceContextEnabled !== false}
@@ -51,9 +51,9 @@ export function SpeechAdvancedSettings({
51
51
  />
52
52
  <TextAreaField
53
53
  label={(speak as any).agentContext.label()}
54
- value={settings.agentVoiceContext || defaultAgentVoiceContext}
54
+ value={settings.agentVoiceContext ?? defaultAgentVoiceContext}
55
55
  disabled={settings.agentVoiceContextEnabled === false}
56
- onChange={(event) => updateSettings({ agentVoiceContext: event.target.value })}
56
+ onCommit={(value) => updateSettings({ agentVoiceContext: value })}
57
57
  />
58
58
  <button
59
59
  type="button"
@@ -59,7 +59,7 @@ export function SpeechEngineSettings({ settings, capabilities, updateSettings }:
59
59
  max={3}
60
60
  step={0.1}
61
61
  value={settings.rate ?? 1}
62
- onChange={(event) => updateSettings({ rate: Number(event.target.value) })}
62
+ onCommit={(value) => updateSettings({ rate: Number(value) })}
63
63
  />
64
64
  <NumberField
65
65
  label={(speak as any).segmentGap.label()}
@@ -67,7 +67,7 @@ export function SpeechEngineSettings({ settings, capabilities, updateSettings }:
67
67
  max={2000}
68
68
  step={50}
69
69
  value={settings.segmentGapMs ?? 400}
70
- onChange={(event) => updateSettings({ segmentGapMs: Number(event.target.value) })}
70
+ onCommit={(value) => updateSettings({ segmentGapMs: Number(value) })}
71
71
  />
72
72
  {capabilities?.[engine === 'qwen-http' ? 'qwen' : engine]?.supported === false && (
73
73
  <p role="status">
@@ -13,7 +13,7 @@ export const RELEASE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
13
13
 
14
14
  export const LIVE_VOICE_BADGE_URL = 'https://cdn.simpleicons.org/npm/white';
15
15
  export const DSH_BADGE_URL = 'https://cdn.simpleicons.org/deepseek/white';
16
- export const TESTED_DSH_RELEASE_URL = `https://github.com/deepseek-ai/deepseek-harness/releases/tag/v${TESTED_DSH_VERSION}`;
16
+ export const TESTED_DSH_RELEASE_URL = `https://github.com/deepseek-ai/deepseek-harness/releases/tag/dsh-v${TESTED_DSH_VERSION}`;
17
17
 
18
18
  function versionParts(version) {
19
19
  const normalized = String(version || '')
@@ -77,9 +77,17 @@ function writeCached(storage, value) {
77
77
  } catch {}
78
78
  }
79
79
 
80
+ const releaseMemory = new Map<string, string>();
81
+ const memoryStorage = {
82
+ getItem: (key: string) => releaseMemory.get(key) ?? null,
83
+ setItem: (key: string, value: string) => {
84
+ releaseMemory.set(key, value);
85
+ },
86
+ };
87
+
80
88
  export async function checkLatestRelease({
81
89
  fetchImpl = globalThis.window?.fetch?.bind(globalThis.window),
82
- storage = globalThis.window?.localStorage,
90
+ storage = memoryStorage,
83
91
  now = Date.now(),
84
92
  } = {}) {
85
93
  const cached = readCached(storage, now);
@@ -0,0 +1,5 @@
1
+ # Speaking module
2
+
3
+ `services/speechQueue.ts` schedules speech independently of capture and agent generation. `engines/browser/BrowserSpeakingEngine.ts` uses browser voices, `engines/qwen/QwenSpeakingEngine.ts` uses the Qwen HTTP bridge, and `engines/say/` uses the macOS host speaker. `engines/audio/` manages host audio playback and transcoding. `qwen/QwenSpeakingSettings.tsx` presents engine-specific settings.
4
+
5
+ Keep provider adapters separate from conversation policy and DSH composition. Maintain truthful pause/resume capability and distinguish pause from stop/cancel. Abort old operations and prevent stale synthesized audio from beginning after a newer turn or cancellation. Do not let a Settings speech test bypass page-level ownership. Local TTS is an option; it does not imply the DSH model runs offline.
@@ -0,0 +1,5 @@
1
+ # Shared design system
2
+
3
+ `design-system/` contains domain-independent React primitives: buttons, form fields, feedback, layout, and SVG icons. It must never import feature modules or DSH integration. Modules supply translated strings and handlers; shared components only render and dispatch them. Icons use `currentColor` so they follow surrounding text across themes.
4
+
5
+ Settings layouts include accessible cards, tab navigation, and native disclosure sections. Keep `<details>/<summary>` keyboard behavior and ARIA semantics when changing a subsection; headings and icons should not become separate unlabeled controls. Shared primitives should not own persisted settings, product policy, or translation catalogs. `forms/DraftField.tsx` centralizes local raw editing for text, textarea, and numeric controls. Consumers use `onCommit` for normalization and persistence on blur; parent updates must not replace active drafts. Invalid numeric drafts restore the saved value on blur.
@@ -0,0 +1,79 @@
1
+ import React from 'react';
2
+
3
+ type Element = HTMLInputElement | HTMLTextAreaElement;
4
+ export type DraftFieldProps = {
5
+ label: string;
6
+ value?: string | number | readonly string[];
7
+ defaultValue?: string | number | readonly string[];
8
+ onCommit?: (value: string) => void;
9
+ multiline?: boolean;
10
+ } & Omit<
11
+ React.InputHTMLAttributes<HTMLInputElement> & React.TextareaHTMLAttributes<HTMLTextAreaElement>,
12
+ 'value' | 'defaultValue'
13
+ >;
14
+
15
+ /** Keep raw edits local; parent normalization and persistence run only on blur. */
16
+ export function DraftField({
17
+ label,
18
+ value,
19
+ defaultValue,
20
+ onCommit,
21
+ multiline,
22
+ onInput,
23
+ onChange,
24
+ onFocus,
25
+ onBlur,
26
+ ...props
27
+ }: DraftFieldProps) {
28
+ const saved = String(value ?? defaultValue ?? '');
29
+ const [draft, setDraft] = React.useState(saved);
30
+ const editing = React.useRef(false);
31
+ const dirty = React.useRef(false);
32
+ React.useEffect(() => {
33
+ if (!editing.current) setDraft(saved);
34
+ }, [saved]);
35
+ const inputProps = {
36
+ ...props,
37
+ value: draft,
38
+ onFocus: (event: React.FocusEvent<Element>) => {
39
+ editing.current = true;
40
+ onFocus?.(event as never);
41
+ },
42
+ onInput: (event: React.FormEvent<Element>) => {
43
+ editing.current = true;
44
+ dirty.current = true;
45
+ setDraft(event.currentTarget.value);
46
+ onInput?.(event as never);
47
+ },
48
+ onChange: (event: React.ChangeEvent<Element>) => {
49
+ editing.current = true;
50
+ dirty.current = true;
51
+ setDraft(event.currentTarget.value);
52
+ onChange?.(event as never);
53
+ },
54
+ onBlur: (event: React.FocusEvent<Element>) => {
55
+ editing.current = false;
56
+ const raw = event.currentTarget.value;
57
+ const changed = dirty.current;
58
+ dirty.current = false;
59
+ // Restore the canonical value until the committed value arrives from the parent.
60
+ setDraft(saved);
61
+ const valid =
62
+ multiline ||
63
+ props.type !== 'number' ||
64
+ (raw.trim() !== '' && (event.currentTarget as HTMLInputElement).validity.valid);
65
+ if (changed && valid) onCommit?.(raw);
66
+ onBlur?.(event as never);
67
+ },
68
+ };
69
+ return (
70
+ <label>
71
+ {label}
72
+ {multiline ? (
73
+ <textarea {...(inputProps as React.TextareaHTMLAttributes<HTMLTextAreaElement>)} />
74
+ ) : (
75
+ <input {...(inputProps as React.InputHTMLAttributes<HTMLInputElement>)} />
76
+ )}
77
+ </label>
78
+ );
79
+ }
@@ -1,12 +1,6 @@
1
1
  import React from 'react';
2
- export type NumberFieldProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> & {
3
- label: string;
4
- };
5
- export function NumberField({ label, ...props }: NumberFieldProps) {
6
- return (
7
- <label>
8
- {label}
9
- <input {...props} type="number" />
10
- </label>
11
- );
2
+ import { DraftField, type DraftFieldProps } from './DraftField.js';
3
+ export type NumberFieldProps = Omit<DraftFieldProps, 'type' | 'multiline'>;
4
+ export function NumberField(props: NumberFieldProps) {
5
+ return <DraftField {...props} type="number" />;
12
6
  }
@@ -1,12 +1,6 @@
1
1
  import React from 'react';
2
- export type TextAreaFieldProps = React.TextareaHTMLAttributes<HTMLTextAreaElement> & {
3
- label: string;
4
- };
5
- export function TextAreaField({ label, ...props }: TextAreaFieldProps) {
6
- return (
7
- <label>
8
- {label}
9
- <textarea {...props} />
10
- </label>
11
- );
2
+ import { DraftField, type DraftFieldProps } from './DraftField.js';
3
+ export type TextAreaFieldProps = Omit<DraftFieldProps, 'type' | 'multiline'>;
4
+ export function TextAreaField(props: TextAreaFieldProps) {
5
+ return <DraftField {...props} multiline />;
12
6
  }
@@ -1,12 +1,6 @@
1
1
  import React from 'react';
2
- export type TextFieldProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> & {
3
- label: string;
4
- };
5
- export function TextField({ label, ...props }: TextFieldProps) {
6
- return (
7
- <label>
8
- {label}
9
- <input {...props} type="text" />
10
- </label>
11
- );
2
+ import { DraftField, type DraftFieldProps } from './DraftField.js';
3
+ export type TextFieldProps = Omit<DraftFieldProps, 'type' | 'multiline'>;
4
+ export function TextField(props: TextFieldProps) {
5
+ return <DraftField {...props} type="text" />;
12
6
  }
@@ -10,6 +10,10 @@ export const iconPaths = {
10
10
  skipNext: 'M5 5l10 7-10 7V5M19 5v14',
11
11
  send: 'M3 11.5L21 3l-8.5 18-2-7.5L3 11.5zm7.5 2L21 3',
12
12
  queue: 'M5 6h14M5 12h10M5 18h6M18 15v6M15 18h6',
13
+ settings: 'M4 7h16M4 17h16M8 4v6M16 14v6',
14
+ filter: 'M3 5h18l-7 8v6l-4 2v-8L3 5z',
15
+ link: 'M10 13a5 5 0 0 0 7.07 0l3-3a5 5 0 0 0-7.07-7.07l-1.7 1.7M14 11a5 5 0 0 0-7.07 0l-3 3a5 5 0 0 0 7.07 7.07l1.7-1.7',
16
+ chevron: 'M6 9l6 6 6-6',
13
17
  close: 'M6 6l12 12M18 6 6 18',
14
18
  } as const;
15
19
 
@@ -1,13 +1,25 @@
1
1
  import React from 'react';
2
+ import { Icon, type IconName } from '../icons/index.js';
3
+
2
4
  export function SettingsSubcard({
3
5
  title,
4
6
  children,
5
7
  open = false,
6
8
  ariaLabel,
7
- }: React.PropsWithChildren<{ title: string; open?: boolean; ariaLabel?: string }>) {
9
+ icon = 'settings',
10
+ }: React.PropsWithChildren<{
11
+ title: string;
12
+ open?: boolean;
13
+ ariaLabel?: string;
14
+ icon?: IconName;
15
+ }>) {
8
16
  return (
9
- <details className="dlv-settings-subcard" open={open} aria-label={ariaLabel}>
10
- <summary>{title}</summary>
17
+ <details className="dlv-settings-subcard" open={open}>
18
+ <summary aria-label={ariaLabel}>
19
+ <Icon name={icon} className="dlv-settings-subcard-icon" />
20
+ <span>{title}</span>
21
+ <Icon name="chevron" className="dlv-settings-subcard-chevron" />
22
+ </summary>
11
23
  <div className="dlv-settings-subcard-body">{children}</div>
12
24
  </details>
13
25
  );
@@ -1,6 +1,7 @@
1
1
  import React from 'react';
2
+ import { Icon, type IconName } from '../icons/index.js';
2
3
 
3
- export type SettingsTab = { id: string; label: string };
4
+ export type SettingsTab = { id: string; label: string; icon?: IconName };
4
5
  export type SettingsTabsProps = {
5
6
  label: string;
6
7
  tabs: readonly SettingsTab[];
@@ -46,7 +47,8 @@ export function SettingsTabs({ label, tabs, active, idPrefix, onChange }: Settin
46
47
  onClick={() => onChange(tab.id)}
47
48
  onKeyDown={(event) => onKeyDown(event, index)}
48
49
  >
49
- {tab.label}
50
+ {tab.icon ? <Icon name={tab.icon} className="dlv-settings-tab-icon" /> : null}
51
+ <span>{tab.label}</span>
50
52
  </button>
51
53
  ))}
52
54
  </div>