@deepseek-ai/dsh-experimental-client-ui-voice-input 0.2.2-rc.1 → 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.
- package/lib/client.js +1 -3710
- package/lib/client.js.map +1 -0
- package/lib/types/client/MicrophonePicker.d.ts.map +1 -0
- package/lib/types/client/MicrophonePicker.js.map +1 -0
- package/lib/types/client/PreparationCard.d.ts.map +1 -0
- package/lib/types/client/PreparationCard.js.map +1 -0
- package/lib/types/client/VoiceInput.d.ts.map +1 -0
- package/lib/types/client/VoiceInput.js.map +1 -0
- package/lib/types/client/VoiceSetupDialog.d.ts.map +1 -0
- package/lib/types/client/VoiceSetupDialog.js.map +1 -0
- package/lib/types/client/VoiceSetupPrompt.d.ts.map +1 -0
- package/lib/types/client/VoiceSetupPrompt.js.map +1 -0
- package/lib/types/client/Waveform.d.ts.map +1 -0
- package/lib/types/client/Waveform.js.map +1 -0
- package/lib/types/client/audio.d.ts.map +1 -0
- package/lib/types/client/audio.js.map +1 -0
- package/lib/types/client/failure-text.d.ts.map +1 -0
- package/lib/types/client/failure-text.js.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/index.js.map +1 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/locales.js.map +1 -0
- package/lib/types/client/microphone-device.d.ts.map +1 -0
- package/lib/types/client/microphone-device.js.map +1 -0
- package/lib/types/client/mount.d.ts.map +1 -0
- package/lib/types/client/mount.js.map +1 -0
- package/lib/types/client/readiness.d.ts.map +1 -0
- package/lib/types/client/readiness.js.map +1 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index.js.map +1 -0
- package/package.json +21 -18
- package/src/client/MicrophonePicker.tsx +140 -0
- package/src/client/PreparationCard.tsx +165 -0
- package/src/client/VoiceInput.module.css +55 -0
- package/src/client/VoiceInput.tsx +182 -0
- package/src/client/VoiceSetupDialog.tsx +32 -0
- package/src/client/VoiceSetupPrompt.tsx +27 -0
- package/src/client/Waveform.tsx +33 -0
- package/src/client/audio.ts +188 -0
- package/src/client/failure-text.ts +15 -0
- package/src/client/index.ts +15 -0
- package/src/client/locales.ts +834 -0
- package/src/client/microphone-device.ts +20 -0
- package/src/client/mount.ts +77 -0
- package/src/client/readiness.ts +40 -0
- package/src/css-modules.d.ts +5 -0
- 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
|
+
}
|