@deepseek-ai/dsh-experimental-client-ui-voice-input 0.2.2-alpha.3 → 0.2.2-rc.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 (47) hide show
  1. package/lib/client.js +1 -3710
  2. package/lib/client.js.map +1 -0
  3. package/lib/types/client/MicrophonePicker.d.ts.map +1 -0
  4. package/lib/types/client/MicrophonePicker.js.map +1 -0
  5. package/lib/types/client/PreparationCard.d.ts.map +1 -0
  6. package/lib/types/client/PreparationCard.js.map +1 -0
  7. package/lib/types/client/VoiceInput.d.ts.map +1 -0
  8. package/lib/types/client/VoiceInput.js.map +1 -0
  9. package/lib/types/client/VoiceSetupDialog.d.ts.map +1 -0
  10. package/lib/types/client/VoiceSetupDialog.js.map +1 -0
  11. package/lib/types/client/VoiceSetupPrompt.d.ts.map +1 -0
  12. package/lib/types/client/VoiceSetupPrompt.js.map +1 -0
  13. package/lib/types/client/Waveform.d.ts.map +1 -0
  14. package/lib/types/client/Waveform.js.map +1 -0
  15. package/lib/types/client/audio.d.ts.map +1 -0
  16. package/lib/types/client/audio.js.map +1 -0
  17. package/lib/types/client/failure-text.d.ts.map +1 -0
  18. package/lib/types/client/failure-text.js.map +1 -0
  19. package/lib/types/client/index.d.ts.map +1 -0
  20. package/lib/types/client/index.js.map +1 -0
  21. package/lib/types/client/locales.d.ts.map +1 -0
  22. package/lib/types/client/locales.js.map +1 -0
  23. package/lib/types/client/microphone-device.d.ts.map +1 -0
  24. package/lib/types/client/microphone-device.js.map +1 -0
  25. package/lib/types/client/mount.d.ts.map +1 -0
  26. package/lib/types/client/mount.js.map +1 -0
  27. package/lib/types/client/readiness.d.ts.map +1 -0
  28. package/lib/types/client/readiness.js.map +1 -0
  29. package/lib/types/index.d.ts.map +1 -0
  30. package/lib/types/index.js.map +1 -0
  31. package/package.json +23 -20
  32. package/src/client/MicrophonePicker.tsx +140 -0
  33. package/src/client/PreparationCard.tsx +165 -0
  34. package/src/client/VoiceInput.module.css +55 -0
  35. package/src/client/VoiceInput.tsx +182 -0
  36. package/src/client/VoiceSetupDialog.tsx +32 -0
  37. package/src/client/VoiceSetupPrompt.tsx +27 -0
  38. package/src/client/Waveform.tsx +33 -0
  39. package/src/client/audio.ts +188 -0
  40. package/src/client/failure-text.ts +15 -0
  41. package/src/client/index.ts +15 -0
  42. package/src/client/locales.ts +834 -0
  43. package/src/client/microphone-device.ts +20 -0
  44. package/src/client/mount.ts +77 -0
  45. package/src/client/readiness.ts +40 -0
  46. package/src/css-modules.d.ts +5 -0
  47. package/src/index.ts +3 -0
@@ -0,0 +1,165 @@
1
+ /** Provider-owned preparation steps and persisted recognition preferences. */
2
+ import { useEffect, useState } from 'react'
3
+ import { Button, DisclosureRow, IconChevronDownOutlineRegular, StateDot, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
4
+ import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
5
+ import type { SpeechPreparationState, SpeechProviderId, SpeechProviderView, SpeechSelectionPatch } from '@deepseek-ai/dsh-experimental-speech-to-text/types'
6
+ import type { VoiceInputInjected } from './VoiceInput.tsx'
7
+ import { MicrophonePicker } from './MicrophonePicker.tsx'
8
+ import { NS } from './locales.ts'
9
+ import css from './VoiceInput.module.css'
10
+
11
+ /** One recognizer's resource readiness and explicit preparation controls. */
12
+ export type PreparationCardProps = Pick<InjectFace<VoiceInputInjected>, 'prepare' | 'cancelPreparation'>
13
+ & PropsLocale<typeof NS> & { readonly provider: SpeechProviderView; readonly connected: boolean }
14
+
15
+ function preparationTone(state: SpeechPreparationState): StateDotState {
16
+ if (state.phase === 'ready' || state.phase === 'standby') return 'done'
17
+ if (state.phase === 'failed') return 'error'
18
+ if (state.phase === 'unprepared' || state.phase === 'cancelled') return 'idle'
19
+ return 'ongoing'
20
+ }
21
+
22
+ function byteText(state: Extract<SpeechPreparationState, { phase: 'downloading' }>, t: PropsLocale<typeof NS>['t']): string {
23
+ return state.totalBytes === undefined ? t('downloadUnknown', { completed: (state.completedBytes / 1_000_000).toFixed(1) })
24
+ : t('downloadBytes', { completed: (state.completedBytes / 1_000_000).toFixed(1),
25
+ total: (state.totalBytes / 1_000_000).toFixed(1), percent: String(Math.floor(state.completedBytes / state.totalBytes * 100)) })
26
+ }
27
+
28
+ function DownloadProgress({ state, t }: { state: SpeechPreparationState } & PropsLocale<typeof NS>) {
29
+ return state.phase === 'downloading' && state.totalBytes !== undefined
30
+ ? <progress className={css.progress} aria-label={t('downloadProgress')} value={state.completedBytes} max={state.totalBytes} /> : null
31
+ }
32
+
33
+ function PreparationFailure({ state, t }: { state: Extract<SpeechPreparationState, { phase: 'failed' }> } & PropsLocale<typeof NS>) {
34
+ const failure = state.download
35
+ if (!failure) return <p className={css.preparationError} role="alert">{t('preparationFailed', { message: state.message })}</p>
36
+ return <div className={css.downloadFailure} role="alert">
37
+ <p className={css.preparationError}>{t(`download.${failure.reason}`, { resource: failure.resource, status: String(failure.status) })}</p>
38
+ <p>{t(`downloadAdvice.${failure.reason}`)}</p>
39
+ <small className={css.metric}>{t('downloadSource', { source: failure.source })}</small>
40
+ {failure.code && <small className={css.metric}>{t('downloadCode', { code: failure.code })}</small>}
41
+ </div>
42
+ }
43
+
44
+ /** Render a collapsed current-step summary or all Host-owned preparation steps. */
45
+ export function PreparationCard({ provider, connected, prepare, cancelPreparation, t }: PreparationCardProps) {
46
+ const state = provider.preparation
47
+ const [expanded, setExpanded] = useState(false), [now, setNow] = useState(Date.now), [error, setError] = useState('')
48
+ const [source, setSource] = useState(''), [submitting, setSubmitting] = useState(false)
49
+ const sources = provider.downloadSources ?? []
50
+ const [firstSource = ''] = sources
51
+ const selectedSource = sources.includes(source) ? source : sources.length === 1 ? firstSource : ''
52
+ const canPrepare = ['unprepared', 'cancelled', 'failed'].includes(state.phase)
53
+ const current = state.steps?.find(step => step.status === 'running' || step.status === 'failed' || step.status === 'cancelled')
54
+ const preparing = preparationTone(state) === 'ongoing'
55
+ const startedAt = current?.startedAt ?? ('startedAt' in state ? state.startedAt : undefined)
56
+ useEffect(() => {
57
+ if (!preparing || startedAt === undefined) return
58
+ const timer = setInterval(() => { setNow(Date.now()) }, 1000)
59
+ return () => { clearInterval(timer) }
60
+ }, [preparing, startedAt])
61
+ const run = async (action: () => Promise<unknown>): Promise<void> => {
62
+ setError(''); setSubmitting(true)
63
+ try { await action() } catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)) }
64
+ finally { setSubmitting(false) }
65
+ }
66
+ const metric = state.phase === 'downloading' ? byteText(state, t)
67
+ : preparing && startedAt !== undefined ? t('elapsed', { seconds: String(Math.max(0, Math.floor((now - startedAt) / 1000))) }) : ''
68
+ const summary = !connected ? t('reconnecting') : preparing && current ? t(`step.${current.kind}`, { name: provider.name })
69
+ : state.phase === 'downloading' ? t('short.downloading')
70
+ : state.phase === 'failed' ? t('short.failed')
71
+ : state.phase === 'ready' && provider.location === 'cloud' ? t('cloudReady')
72
+ : t(`preparation.${state.phase}`, { name: provider.name })
73
+ return <section className={css.preparation} data-speech-provider={provider.id}>
74
+ <strong>{provider.name}</strong>
75
+ {state.phase === 'unprepared' && provider.location === 'host-local' && provider.setupEstimate && <div className={css.setupEstimate}>
76
+ <p>{t('setup.local')}</p>
77
+ <dl>
78
+ <div><dt>{t('setup.disk')}</dt><dd>{t('setup.diskValue', {
79
+ gb: String(provider.setupEstimate.recommendedDiskBytes / 1_000_000_000),
80
+ })}</dd></div>
81
+ <div><dt>{t('setup.memory')}</dt><dd>{t('setup.memoryValue', {
82
+ gb: String(provider.setupEstimate.expectedMemoryBytes / 1_000_000_000),
83
+ })}</dd></div>
84
+ <div><dt>{t('setup.time')}</dt><dd>{t('setup.timeValue', {
85
+ min: String(provider.setupEstimate.minimumMinutes), max: String(provider.setupEstimate.maximumMinutes),
86
+ })}</dd></div>
87
+ </dl>
88
+ <small>{t('setup.estimateNote')}</small>
89
+ </div>}
90
+ <DisclosureRow icon={<StateDot state={preparationTone(state)} size={16} appearance="step" />}
91
+ title={expanded ? t('preparationSteps') : summary} open={expanded} expandable={(state.steps?.length ?? 0) > 0} expandOnRowClick previewChevron={false}
92
+ onToggle={() => { setExpanded(value => !value) }} rowClassName={css.summaryRow} titleClassName={css.summaryTitle}
93
+ collapsedContent={<><span className={css.metric} role="status">{metric}</span>{state.steps && <IconChevronDownOutlineRegular />}</>}>
94
+ <ol className={css.steps}>
95
+ {state.steps?.map(step => <li key={step.kind} data-step={step.kind} data-step-state={step.status}
96
+ aria-label={`${t(`step.${step.kind}`, { name: provider.name })} · ${t(`stepStatus.${step.status}`)}`}>
97
+ <StateDot appearance="step" size={16} state={step.status === 'complete' ? 'done' : step.status === 'running' ? 'ongoing'
98
+ : step.status === 'failed' ? 'error' : 'idle'} />
99
+ <div className={css.stepBody}><span>{t(`step.${step.kind}`, { name: provider.name })}</span>
100
+ {step.status === 'running' && <div className={css.stepProgress}>
101
+ <span className={css.metric} role="status">{metric}</span><DownloadProgress state={state} t={t} />
102
+ {state.phase === 'downloading' && <small>{state.resource}</small>}
103
+ </div>}
104
+ </div>
105
+ </li>)}
106
+ </ol>
107
+ </DisclosureRow>
108
+ {!expanded && <DownloadProgress state={state} t={t} />}
109
+ {state.phase === 'failed' && <PreparationFailure state={state} t={t} />}
110
+ {canPrepare && sources.length > 0 && <div className={css.sourceChoice}>
111
+ <label>{t('sourceChoice')}<select aria-label={t('sourceChoice')} value={selectedSource} disabled={!connected || submitting || sources.length === 1}
112
+ onChange={(event) => { setSource(event.target.value) }}>
113
+ {sources.length > 1 && <option value="">{t('sourceAuto')}</option>}
114
+ {sources.map(origin => <option key={origin} value={origin}>
115
+ {origin === 'https://huggingface.co' ? t('sourceHuggingFace')
116
+ : origin === 'https://hf-mirror.com' ? t('sourceMirror') : origin}
117
+ </option>)}
118
+ </select></label>
119
+ <small className={css.metric}>{t(selectedSource === '' ? 'sourceAutoHelp' : 'sourceManualHelp')}</small>
120
+ </div>}
121
+ <div className={css.preparationActions}>
122
+ {canPrepare && <Button variant="outline" size="sm" disabled={!connected || submitting}
123
+ onClick={() => { void run(() => selectedSource === '' ? prepare(provider.id) : prepare(provider.id, { downloadSource: selectedSource })) }}>{t(state.phase === 'unprepared' ? 'prepare' : 'retryPrepare')}</Button>}
124
+ {preparing && state.phase !== 'waking' && <Button variant="ghost" size="sm" disabled={!connected || submitting || state.phase === 'cancelling'}
125
+ onClick={() => { void run(() => cancelPreparation(provider.id)) }}>{t('cancelPrepare')}</Button>}
126
+ </div>
127
+ {error && <p className={css.preparationError} role="alert">{t('failed', { message: error })}</p>}
128
+ </section>
129
+ }
130
+
131
+ /** Recognition preferences and preparation cards shared by plugin details and Settings. */
132
+ export function VoicePreparation({ useSpeechReadiness, ...props }: Pick<InjectFace<VoiceInputInjected>,
133
+ 'useSpeechReadiness' | 'configure' | 'prepare' | 'cancelPreparation' | 'createRecording' | 'useMicrophoneDevice' | 'selectMicrophone'> & PropsLocale<typeof NS>) {
134
+ const readiness = useSpeechReadiness(value => value), catalog = readiness.catalog
135
+ const [saving, setSaving] = useState(false), [error, setError] = useState('')
136
+ const configure = async (patch: SpeechSelectionPatch): Promise<void> => {
137
+ setSaving(true); setError('')
138
+ try { await props.configure(patch) } catch (failure) { setError(failure instanceof Error ? failure.message : String(failure)) }
139
+ finally { setSaving(false) }
140
+ }
141
+ const selected = catalog?.providers.find(provider => provider.id === catalog.selection.providerId)
142
+ const languageNames: Readonly<Record<string, string>> = {
143
+ auto: props.t('auto'), zh: props.t('zh'), en: props.t('en'), yue: props.t('yue'), ja: props.t('ja'), es: props.t('es'), fr: props.t('fr'), de: props.t('de'), ru: props.t('ru'), pt: props.t('pt'), ko: props.t('ko'),
144
+ }
145
+ return <div>
146
+ {catalog && <div className={css.preferences}>
147
+ <MicrophonePicker {...props} />
148
+ <label>{props.t('provider')}<select value={catalog.selection.providerId} disabled={!readiness.connected || saving}
149
+ onChange={(event) => { void configure({ providerId: event.target.value as SpeechProviderId }) }}>
150
+ {catalog.providers.map(provider => <option key={provider.id} value={provider.id}>{provider.name}</option>)}
151
+ </select></label>
152
+ <label>{props.t('language')}<select value={catalog.selection.language} disabled={!readiness.connected || saving}
153
+ onChange={(event) => { void configure({ language: event.target.value }) }}>
154
+ {selected?.languages.map(language =>
155
+ <option key={language} value={language}>{languageNames[language] ?? language}</option>)}
156
+ </select></label>
157
+ <p>{props.t(selected?.location === 'cloud' ? 'cloud' : 'local')}</p>
158
+ {error && <p role="alert">{props.t('failed', { message: error })}</p>}
159
+ </div>}
160
+ {catalog?.providers.map(provider =>
161
+ <PreparationCard key={provider.id} provider={provider} connected={readiness.connected} {...props} />)}
162
+ {!catalog && <p role="status">{props.t('loading')}</p>}
163
+ {readiness.error && <p role="alert">{props.t('failed', { message: readiness.error })}</p>}
164
+ </div>
165
+ }
@@ -0,0 +1,55 @@
1
+ .trigger { width: 28px; padding: 0; flex: none; }
2
+ .triggerAnchor { display: inline-flex; flex: none; }
3
+ .captureRow { display: flex; align-items: center; gap: 12px; width: 100%; min-width: 0; min-height: 34px; }
4
+ .roundButton { width: 32px; height: 32px; padding: 0; border-radius: 50%; corner-shape: round; flex: none; background: var(--dsw-specific-selector); }
5
+ @media (hover: hover) {
6
+ .roundButton:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-solid); }
7
+ }
8
+ .waveform { display: block; flex: 1; width: 0; height: 24px; min-width: 24px; color: var(--dsw-alias-label-secondary); }
9
+ .activityMessage { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; font-size: 12px; color: var(--dsw-alias-label-secondary); overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
10
+ .inlineAction { flex: none; }
11
+
12
+ .preparation { display: flex; flex-direction: column; gap: 10px; padding: 14px; margin: 12px 0; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; font-size: 13px; }
13
+ .preparation p { margin: 0; }
14
+ .preparationActions { display: flex; gap: 8px; }
15
+ .preparationActions:empty { display: none; }
16
+ .preparationError { color: var(--dsw-alias-state-error-primary); overflow-wrap: anywhere; font-size: 12px; }
17
+ .downloadFailure { display: grid; gap: 6px; overflow-wrap: anywhere; }
18
+ .summaryRow { min-height: 28px; height: auto; flex-wrap: wrap; gap: 4px; }
19
+ .summaryTitle { flex: 1; min-width: 0; }
20
+ .metric { font-size: 12px; color: var(--dsw-alias-label-tertiary); }
21
+ .progress { width: 100%; height: 6px; accent-color: var(--dsw-alias-state-business-primary); }
22
+ .steps { list-style: none; display: flex; flex-direction: column; gap: 14px; padding: 16px 0 2px; margin: 0; }
23
+ .steps li { display: flex; align-items: flex-start; gap: 10px; line-height: 18px; }
24
+ .steps li > :first-child { margin-top: 1px; }
25
+ .steps li[data-step-state='pending'] { color: var(--dsw-alias-label-tertiary); }
26
+ .stepBody { flex: 1; min-width: 0; }
27
+ .stepProgress { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
28
+ .stepProgress small { font-size: 11px; color: var(--dsw-alias-label-tertiary); overflow-wrap: anywhere; }
29
+ .preferences { display: grid; gap: 12px; font-size: 13px; margin: 18px 0; }
30
+ .deviceRow, .preferences label, .sourceChoice label { display: grid; grid-template-columns: 100px minmax(120px, 280px); align-items: center; gap: 12px; }
31
+ .preferences select, .sourceChoice select { border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 6px; padding: 6px 10px; background: transparent; color: inherit; font: inherit; }
32
+ .sourceChoice { display: grid; gap: 6px; }
33
+ .preferences p { margin: 0; }
34
+ .settingsCard { list-style: none; padding: 16px; border: 0.5px solid var(--dsw-alias-border-l1); border-radius: 10px; }
35
+ .settingsCard > p { font-size: 13px; color: var(--dsw-alias-label-secondary); margin: 8px 0 0; }
36
+ .setupEstimate { display: grid; gap: 10px; padding: 12px; border-radius: 8px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); line-height: 1.6; }
37
+ .setupEstimate dl { display: grid; gap: 6px; margin: 0; }
38
+ .setupEstimate dl > div { display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 10px; }
39
+ .setupEstimate dt { color: var(--dsw-alias-label-tertiary); }
40
+ .setupEstimate dd { margin: 0; }
41
+ .setupEstimate small { color: var(--dsw-alias-label-tertiary); font-size: 12px; }
42
+
43
+ .deviceField { position: relative; display: block; min-width: 0; }
44
+ .deviceField select { width: 100%; }
45
+ .deviceField select.deviceSelectMeter { padding-right: 72px; }
46
+ .deviceMeter { position: absolute; top: 50%; right: 24px; display: inline-flex; transform: translateY(-50%); pointer-events: none; }
47
+ .deviceError { grid-column: 2; min-width: 0; color: var(--dsw-alias-state-error-primary); overflow-wrap: anywhere; font-size: 12px; }
48
+ .inputLevel { display: inline-flex; align-items: center; gap: 3px; flex: none; }
49
+ .inputLevel > span { width: 3px; height: 12px; border-radius: 1px; background: var(--dsw-alias-border-l1); }
50
+ .inputLevel > span[data-active='true'] { background: var(--dsw-alias-state-success-primary); }
51
+ @media (max-width: 480px) {
52
+ .deviceRow, .preferences label, .sourceChoice label { grid-template-columns: minmax(0, 1fr); gap: 6px; }
53
+
54
+ .deviceError { grid-column: 1; }
55
+ }
@@ -0,0 +1,182 @@
1
+ /** Click-to-record toolbar activity; transcripts remain in the original Session draft. */
2
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react'
3
+ import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
4
+ import type { TokenSpan } from '@deepseek-ai/dsh-client-ui-conversation/client'
5
+ import type { TranscriptionRequest } from '@deepseek-ai/dsh-experimental-api-speech-to-text/types'
6
+ import type { SpeechPreparationOptions, SpeechProviderId, SpeechSelection, SpeechSelectionPatch, Transcript } from '@deepseek-ai/dsh-experimental-speech-to-text/types'
7
+ import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
8
+ import { audioBase64, type Recording } from './audio.ts'
9
+ import { failureText } from './failure-text.ts'
10
+ import type { MicrophoneDevice } from './microphone-device.ts'
11
+ import type { SpeechReadiness } from './readiness.ts'
12
+ import { Waveform } from './Waveform.tsx'
13
+ import { VoiceSetupDialog } from './VoiceSetupDialog.tsx'
14
+ import { NS } from './locales.ts'
15
+ import { Button, IconCloseOutlineRegular, IconStopFillRegular, IconMicrophoneOutlineRegular, StateDot, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
16
+ import css from './VoiceInput.module.css'
17
+
18
+ /** Host calls injected without exposing a Cordis Context to React. */
19
+ export interface VoiceInputActions {
20
+ /** Open the voice bundle details without starting preparation. */
21
+ openSettings: () => void
22
+ /** @returns one microphone operation owned by the plugin lifecycle. */
23
+ createRecording: () => Recording
24
+ /** Remember the browser microphone used by previews and subsequent recordings. */
25
+ selectMicrophone: (device: MicrophoneDevice) => void
26
+ transcribe: (request: TranscriptionRequest, signal: AbortSignal) => Promise<RemoteResult<Transcript>>
27
+ prepare: (providerId: SpeechProviderId, options?: SpeechPreparationOptions) => Promise<void>
28
+ cancelPreparation: (providerId: SpeechProviderId) => Promise<void>
29
+ configure: (patch: SpeechSelectionPatch) => Promise<void>
30
+ }
31
+
32
+ /** Entry-injected Host readiness and microphone operations. */
33
+ export interface VoiceInputInjected extends VoiceInputActions {
34
+ hooks: {
35
+ speechReadiness: HostObservable<SpeechReadiness>
36
+ microphoneDevice: HostObservable<MicrophoneDevice>
37
+ /** Whether the Plugins page navigation service is present; the setup dialog offers the details only while it is. */
38
+ pluginNavigation: HostObservable<boolean>
39
+ }
40
+ }
41
+
42
+ /** Composer-owned expansion and draft actions; preferences stay in plugin settings. */
43
+ export type VoiceInputProps = Pick<PropsRuntime<'conversation.input.activity'>, 'sessionId' | 'inputActions' | 'locked' | 'onActiveChange'>
44
+ & PropsLocale<typeof NS> & InjectFace<VoiceInputInjected>
45
+
46
+ type Phase = 'idle' | 'requesting' | 'recording' | 'transcribing' | 'feedback'
47
+ interface ActiveRecording {
48
+ readonly capture: Recording
49
+ readonly abort: AbortController
50
+ readonly span: TokenSpan
51
+ readonly selection: SpeechSelection
52
+ readonly maxDurationSeconds: number
53
+ readonly maxAudioBytes: number
54
+ phase: 'requesting' | 'recording' | 'transcribing'
55
+ timer?: ReturnType<typeof setTimeout> | undefined
56
+ }
57
+
58
+ async function disposeRecording(capture: Recording): Promise<void> {
59
+ try { await capture.dispose() } catch (_error) {
60
+ // Tracks stop before AudioContext.close(); a close failure must not hide the recording result.
61
+ }
62
+ }
63
+
64
+ /** Render a compact microphone or an expanded capture, transcription, or retry row. */
65
+ export function VoiceInput({ sessionId, inputActions, locked, onActiveChange,
66
+ createRecording, transcribe, openSettings, useSpeechReadiness, usePluginNavigation, t }: VoiceInputProps) {
67
+ const readiness = useSpeechReadiness(value => value), catalog = readiness.catalog
68
+ const navigable = usePluginNavigation(present => present)
69
+ const provider = catalog?.providers.find(item => item.id === catalog.selection.providerId)
70
+ const usable = readiness.connected && (provider?.preparation.phase === 'ready' || provider?.preparation.phase === 'standby'
71
+ || provider?.preparation.phase === 'waking')
72
+ const [phase, setPhase] = useState<Phase>('idle'), [message, setMessage] = useState(''), [pending, setPending] = useState('')
73
+ const [setupOpen, setSetupOpen] = useState(false)
74
+ useEffect(() => { if (usable) setSetupOpen(false) }, [usable])
75
+ const current = useRef<ActiveRecording>(), generation = useRef(0)
76
+ const expanded = phase !== 'idle'
77
+ useLayoutEffect(() => { onActiveChange(expanded); return () => { onActiveChange(false) } }, [expanded, onActiveChange])
78
+
79
+ const cancel = (): void => {
80
+ generation.current++
81
+ const active = current.current
82
+ current.current = undefined
83
+ if (active) { clearTimeout(active.timer); active.abort.abort(); void disposeRecording(active.capture) }
84
+ setPending(''); setMessage(''); setPhase('idle'); setSetupOpen(false)
85
+ }
86
+ useEffect(() => {
87
+ setPending(''); setMessage(''); setPhase('idle'); setSetupOpen(false)
88
+ const blur = (): void => { if (current.current?.phase === 'recording') cancel() }
89
+ const visibility = (): void => {
90
+ if (document.hidden && current.current && current.current.phase !== 'transcribing') cancel()
91
+ }
92
+ const escape = (event: KeyboardEvent): void => {
93
+ if (event.key === 'Escape' && current.current) { event.preventDefault(); cancel() }
94
+ }
95
+ window.addEventListener('blur', blur); document.addEventListener('visibilitychange', visibility)
96
+ document.addEventListener('keydown', escape)
97
+ return () => {
98
+ window.removeEventListener('blur', blur); document.removeEventListener('visibilitychange', visibility)
99
+ document.removeEventListener('keydown', escape)
100
+ generation.current++
101
+ const active = current.current
102
+ current.current = undefined
103
+ if (active) { clearTimeout(active.timer); active.abort.abort(); void disposeRecording(active.capture) }
104
+ }
105
+ }, [sessionId])
106
+
107
+ const feedback = (text: string): void => { setMessage(text); setPhase('feedback') }
108
+ const finish = async (): Promise<void> => {
109
+ const active = current.current
110
+ if (!active || active.phase !== 'recording') return
111
+ active.phase = 'transcribing'
112
+ const run = generation.current
113
+ clearTimeout(active.timer); setPhase('transcribing')
114
+ try {
115
+ const audio = await active.capture.stop(active.maxDurationSeconds)
116
+ if (run !== generation.current) return
117
+ if (audio.byteLength > active.maxAudioBytes) { feedback(t('tooLarge')); return }
118
+ const result = await transcribe({ audioBase64: audioBase64(audio), ...active.selection }, active.abort.signal)
119
+ if (run !== generation.current) return
120
+ if (!result.ok) { feedback(t('failed', { message: result.error.message })); return }
121
+ if (result.value.text === '') { feedback(t('empty')); return }
122
+ if (!inputActions.insertText(result.value.text, active.span)) { setPending(result.value.text); feedback(t('conflict')); return }
123
+ setPhase('idle')
124
+ } catch (failure) {
125
+ await disposeRecording(active.capture)
126
+ if (run === generation.current) feedback(failureText(failure, t))
127
+ } finally { if (run === generation.current) current.current = undefined }
128
+ }
129
+ const start = async (): Promise<void> => {
130
+ if (!catalog || !usable || locked || current.current) return
131
+ const run = ++generation.current
132
+ const active: ActiveRecording = { capture: createRecording(), abort: new AbortController(),
133
+ span: inputActions.captureInsertion(), selection: catalog.selection,
134
+ maxDurationSeconds: catalog.maxDurationSeconds, maxAudioBytes: catalog.maxAudioBytes, phase: 'requesting' }
135
+ current.current = active
136
+ setMessage(''); setPending(''); setPhase('requesting')
137
+ try {
138
+ await active.capture.start((failure) => {
139
+ if (run !== generation.current || current.current !== active || active.phase === 'transcribing') return
140
+ current.current = undefined
141
+ clearTimeout(active.timer); active.abort.abort()
142
+ feedback(failureText(failure, t))
143
+ })
144
+ if (run !== generation.current || current.current !== active) return
145
+ active.phase = 'recording'
146
+ setPhase('recording')
147
+ active.timer = setTimeout(() => { void finish() }, active.maxDurationSeconds * 1000)
148
+ } catch (failure) {
149
+ await disposeRecording(active.capture)
150
+ if (run === generation.current) { current.current = undefined; feedback(failureText(failure, t)) }
151
+ }
152
+ }
153
+ if (!expanded) return <>
154
+ <Tooltip label={t('dictate')} disabled={!usable} side="top" portal>
155
+ <span className={css.triggerAnchor}><Button className={css.trigger} size="sm" disabled={locked}
156
+ aria-label={t(usable ? 'start' : 'setupPrompt.trigger')} aria-haspopup={usable ? undefined : 'dialog'}
157
+ onMouseDown={(event) => { event.preventDefault() }}
158
+ onClick={() => { if (usable) void start(); else setSetupOpen(true) }}><IconMicrophoneOutlineRegular size={18} /></Button></span>
159
+ </Tooltip>
160
+ <VoiceSetupDialog open={setupOpen && !usable}
161
+ needsInstallation={readiness.connected && provider?.location === 'host-local' && provider.preparation.phase === 'unprepared'}
162
+ onDismiss={() => { setSetupOpen(false) }}
163
+ onOpenDetails={navigable ? () => { setSetupOpen(false); openSettings() } : undefined} t={t} />
164
+ </>
165
+ return <div className={css.captureRow} data-voice-activity={phase}>
166
+ <Button type="button" className={css.roundButton} size="sm" aria-label={t(pending ? 'discard' : 'cancel')}
167
+ onClick={cancel}><IconCloseOutlineRegular size={14} /></Button>
168
+ {phase === 'recording' ? <Waveform recording={current.current?.capture} label={t('recording')} />
169
+ : <span className={css.activityMessage} role="status" title={pending || message}>
170
+ {(phase === 'requesting' || phase === 'transcribing') && <StateDot state="ongoing" />}
171
+ {phase === 'feedback' ? message : t(phase === 'requesting' ? 'requesting'
172
+ : provider?.preparation.phase === 'waking' ? 'wakingShort' : 'transcribingShort')}</span>}
173
+ {phase === 'recording' && <Button type="button" className={css.roundButton} size="sm" aria-label={t('stop')}
174
+ onClick={() => { void finish() }}><IconStopFillRegular size={14} /></Button>}
175
+ {phase === 'feedback' && (pending
176
+ ? <Button className={css.inlineAction} size="sm" type="button" onClick={() => {
177
+ if (inputActions.insertText(pending, inputActions.captureInsertion())) { setPending(''); setPhase('idle') }
178
+ }}>{t('insert')}</Button>
179
+ : <Button className={css.roundButton} size="sm" type="button" aria-label={t('retryRecording')} disabled={!usable || locked}
180
+ onClick={() => { void start() }}><IconMicrophoneOutlineRegular size={18} /></Button>)}
181
+ </div>
182
+ }
@@ -0,0 +1,32 @@
1
+ /** Shared modal for voice activation and unavailable recognition. */
2
+ import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
3
+ import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
4
+ import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
5
+ import type { NS } from './locales.ts'
6
+
7
+ type VoiceSetupDialogProps = PropsLocale<typeof NS>
8
+ & Pick<PropsRuntime<'plugins.bundle.activation'>, 'onDismiss'>
9
+ & {
10
+ open: boolean
11
+ needsInstallation: boolean
12
+ /** Open the plugin details; the details button is absent while it is undefined. */
13
+ onOpenDetails: PropsRuntime<'plugins.bundle.activation'>['onOpenDetails'] | undefined
14
+ }
15
+
16
+ /**
17
+ * Guide activation or microphone clicks to the existing plugin details.
18
+ * @param props - visibility, installation need and navigation callbacks.
19
+ * @returns a dismissible prompt that never starts preparation or recording.
20
+ */
21
+ export function VoiceSetupDialog({ open, needsInstallation, onDismiss, onOpenDetails, t }: VoiceSetupDialogProps) {
22
+ return <Modal open={open} title={t(needsInstallation ? 'setupPrompt.title' : 'setupPrompt.unavailableTitle')}
23
+ closeLabel={t('cancel')} onClose={onDismiss}
24
+ footer={<>
25
+ <Button variant="ghost" onClick={onDismiss}>{t('setupPrompt.later')}</Button>
26
+ {onOpenDetails !== undefined && <Button variant="primary" data-modal-autofocus onClick={onOpenDetails}>
27
+ {t(needsInstallation ? 'setupPrompt.open' : 'setupPrompt.details')}
28
+ </Button>}
29
+ </>}>
30
+ <p>{t(needsInstallation ? 'setupPrompt.body' : 'setupPrompt.unavailableBody')}</p>
31
+ </Modal>
32
+ }
@@ -0,0 +1,27 @@
1
+ /** Activation guidance after the Host inspects local recognition resources. */
2
+ import { useEffect } from 'react'
3
+ import { VoiceSetupDialog } from './VoiceSetupDialog.tsx'
4
+ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
5
+ import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
6
+ import type { VoiceInputInjected } from './VoiceInput.tsx'
7
+ import type { NS } from './locales.ts'
8
+
9
+ /** Registration shares for the bundle's activation guidance. */
10
+ export type VoiceSetupPromptProps = PropsRuntime<'plugins.bundle.activation'> & PropsLocale<typeof NS>
11
+ & Pick<InjectFace<VoiceInputInjected>, 'useSpeechReadiness'>
12
+
13
+ /**
14
+ * Offer navigation to installation without starting a download.
15
+ * @param props - activation navigation and the shared Host readiness observer.
16
+ * @returns the shared modal only when the selected local provider needs preparation.
17
+ */
18
+ export function VoiceSetupPrompt({ useSpeechReadiness, onDismiss, onOpenDetails, t }: VoiceSetupPromptProps) {
19
+ const readiness = useSpeechReadiness(value => value)
20
+ const provider = readiness.catalog?.providers.find(item => item.id === readiness.catalog?.selection.providerId)
21
+ const phase = readiness.connected ? provider?.preparation.phase : undefined
22
+ const needsSetup = phase === 'unprepared' && provider?.location === 'host-local'
23
+ useEffect(() => {
24
+ if (phase !== undefined && phase !== 'checking' && !needsSetup) onDismiss()
25
+ }, [phase, needsSetup, onDismiss])
26
+ return <VoiceSetupDialog open={needsSetup} needsInstallation onDismiss={onDismiss} onOpenDetails={onOpenDetails} t={t} />
27
+ }
@@ -0,0 +1,33 @@
1
+ /** Live microphone amplitude history; animation updates SVG geometry without React state churn. */
2
+ import { useEffect, useRef } from 'react'
3
+ import type { Recording } from './audio.ts'
4
+ import css from './VoiceInput.module.css'
5
+
6
+ /** Render recent measured microphone levels; silent audio remains a dotted baseline. */
7
+ export function Waveform({ recording, label }: { readonly recording: Recording | undefined; readonly label: string }) {
8
+ const svg = useRef<SVGSVGElement>(null)
9
+ useEffect(() => {
10
+ const bars = Array.from((svg.current as SVGSVGElement).querySelectorAll('line')).reverse()
11
+ .map(element => ({ element, level: 0 }))
12
+ let frame: number, previous = -Infinity
13
+ const draw = (now: number): void => {
14
+ if (now - previous >= 50) {
15
+ previous = now
16
+ let next = recording?.amplitude() ?? 0
17
+ for (const bar of bars) {
18
+ const previousLevel = bar.level
19
+ bar.level = next; next = previousLevel
20
+ const height = 1 + Math.min(1, bar.level * 5) * 17
21
+ bar.element.setAttribute('y1', String(20 - height)); bar.element.setAttribute('y2', String(20 + height))
22
+ }
23
+ }
24
+ frame = requestAnimationFrame(draw)
25
+ }
26
+ frame = requestAnimationFrame(draw)
27
+ return () => { cancelAnimationFrame(frame) }
28
+ }, [recording])
29
+ return <svg ref={svg} className={css.waveform} viewBox="0 0 640 40" preserveAspectRatio="none" role="img" aria-label={label}>
30
+ {Array.from({ length: 80 }, (_, index) => <line key={index} x1={index * 8 + 4} x2={index * 8 + 4}
31
+ y1="19" y2="21" stroke="currentColor" strokeWidth="3" strokeLinecap="round" opacity={0.25 + index / 120} />)}
32
+ </svg>
33
+ }