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.
- package/CHANGELOG.md +89 -1
- package/PLAN.md +25 -1
- package/README.md +4 -2
- package/docs/ARCHITECTURE.md +9 -16
- package/docs/CONFIGURATION.md +17 -3
- package/docs/VOICE-LIFECYCLE.md +5 -1
- package/lib/client.js +543 -326
- package/lib/server.js +158 -5
- package/package.json +2 -2
- package/src/app/AGENTS.md +7 -0
- package/src/app/ARCHITECTURE.md +10 -0
- package/src/app/client/apply.tsx +96 -40
- package/src/app/client/i18n/catalogs/base.ts +7 -0
- package/src/app/client/i18n/catalogs/en.ts +10 -0
- package/src/app/client/i18n/catalogs/es.ts +10 -0
- package/src/app/client/i18n/catalogs/fr.ts +10 -0
- package/src/app/client/i18n/catalogs/hi.ts +10 -0
- package/src/app/client/i18n/catalogs/pt-BR.ts +10 -0
- package/src/app/client/i18n/catalogs/zh.ts +9 -0
- package/src/app/server/apply.ts +4 -0
- package/src/app/server/registerRoutes.ts +40 -0
- package/src/modules/AGENTS.md +5 -0
- package/src/modules/conversation/ARCHITECTURE.md +7 -0
- package/src/modules/core/ARCHITECTURE.md +5 -0
- package/src/modules/core/coordinator.ts +8 -6
- package/src/modules/core/qwen/QwenHttpHost.ts +1 -1
- package/src/modules/core/qwen/QwenSettings.tsx +15 -14
- package/src/modules/core/settings.ts +26 -8
- package/src/modules/core/transcript.ts +5 -43
- package/src/modules/recognition/ARCHITECTURE.md +5 -0
- package/src/modules/recognition/engines/whisper/WhisperRecognitionEngine.ts +5 -3
- package/src/modules/recognition/engines/whisper/WhisperSettings.tsx +19 -20
- package/src/modules/settings/ARCHITECTURE.md +5 -0
- package/src/modules/settings/components/LiveVoiceSettings.tsx +11 -3
- package/src/modules/settings/models/settingsHost.ts +45 -0
- package/src/modules/settings/models/settingsStorage.ts +45 -10
- package/src/modules/settings/sections/conversation/ConversationSettingsSection.tsx +12 -6
- package/src/modules/settings/sections/recognition/RecognitionEngineSettings.tsx +2 -2
- package/src/modules/settings/sections/recognition/RecognitionFilterSettings.tsx +3 -3
- package/src/modules/settings/sections/recognition/RecognitionSettingsSection.tsx +7 -3
- package/src/modules/settings/sections/recognition/SilenceDetectionSettings.tsx +38 -8
- package/src/modules/settings/sections/recognition/VoiceCommandSettings.tsx +3 -3
- package/src/modules/settings/sections/speak/OutputFilterSettings.tsx +5 -5
- package/src/modules/settings/sections/speak/SpeakSettingsSection.tsx +7 -3
- package/src/modules/settings/sections/speak/SpeechAdvancedSettings.tsx +4 -4
- package/src/modules/settings/sections/speak/SpeechEngineSettings.tsx +2 -2
- package/src/modules/settings/services/releases.ts +10 -2
- package/src/modules/speak/ARCHITECTURE.md +5 -0
- package/src/shared/ARCHITECTURE.md +5 -0
- package/src/shared/design-system/forms/DraftField.tsx +79 -0
- package/src/shared/design-system/forms/NumberField.tsx +4 -10
- package/src/shared/design-system/forms/TextAreaField.tsx +4 -10
- package/src/shared/design-system/forms/TextField.tsx +4 -10
- package/src/shared/design-system/icons/icons.ts +4 -0
- package/src/shared/design-system/layout/SettingsSubcard.tsx +15 -3
- package/src/shared/design-system/layout/SettingsTabs.tsx +4 -2
- 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
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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 {
|
|
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
|
-
<
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
26
|
-
const value = Number(
|
|
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 {
|
|
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
|
-
<
|
|
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 {
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
const value = Number(
|
|
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.
|
|
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]
|
|
34
|
+
value={settings[key] ?? fallback}
|
|
35
35
|
disabled={!enabled}
|
|
36
|
-
|
|
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
|
-
|
|
26
|
-
const value = Number(
|
|
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
|
-
|
|
33
|
+
value={settings.outputCodeNotice ?? (speak as any).filters.code.notice()}
|
|
34
34
|
disabled={!enabled}
|
|
35
|
-
|
|
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 {
|
|
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
|
-
<
|
|
11
|
-
|
|
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
|
|
54
|
+
value={settings.agentVoiceContext ?? defaultAgentVoiceContext}
|
|
55
55
|
disabled={settings.agentVoiceContextEnabled === false}
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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}
|
|
10
|
-
<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.
|
|
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>
|