@djangocfg/ui-tools 2.1.381 → 2.1.382
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/README.md +132 -899
- package/dist/ChatRoot-6IZFM5HM.mjs +5 -0
- package/dist/{ChatRoot-EJC5Y2YM.cjs.map → ChatRoot-6IZFM5HM.mjs.map} +1 -1
- package/dist/ChatRoot-LW4XNIKP.cjs +14 -0
- package/dist/{ChatRoot-QOSKJPM6.mjs.map → ChatRoot-LW4XNIKP.cjs.map} +1 -1
- package/dist/DictationField-2ZLQWLYV.mjs +4 -0
- package/dist/DictationField-2ZLQWLYV.mjs.map +1 -0
- package/dist/DictationField-IPPJ54CU.cjs +13 -0
- package/dist/DictationField-IPPJ54CU.cjs.map +1 -0
- package/dist/{DocsLayout-2YKPXZYO.mjs → DocsLayout-2P3ONDWJ.mjs} +3 -3
- package/dist/{DocsLayout-2YKPXZYO.mjs.map → DocsLayout-2P3ONDWJ.mjs.map} +1 -1
- package/dist/{DocsLayout-Q4KS3QWW.cjs → DocsLayout-2YZNS5VK.cjs} +8 -8
- package/dist/{DocsLayout-Q4KS3QWW.cjs.map → DocsLayout-2YZNS5VK.cjs.map} +1 -1
- package/dist/chunk-4LXG3NBV.mjs +833 -0
- package/dist/chunk-4LXG3NBV.mjs.map +1 -0
- package/dist/{chunk-XACCHZH2.cjs → chunk-FIRK5CEH.cjs} +42 -4
- package/dist/chunk-FIRK5CEH.cjs.map +1 -0
- package/dist/{chunk-NWUT327A.mjs → chunk-HIK6BPL7.mjs} +38 -5
- package/dist/chunk-HIK6BPL7.mjs.map +1 -0
- package/dist/chunk-KMSBGNVC.cjs +835 -0
- package/dist/chunk-KMSBGNVC.cjs.map +1 -0
- package/dist/chunk-OZAU3QWD.cjs +2493 -0
- package/dist/chunk-OZAU3QWD.cjs.map +1 -0
- package/dist/chunk-UWVP6LCW.mjs +2447 -0
- package/dist/chunk-UWVP6LCW.mjs.map +1 -0
- package/dist/index.cjs +1532 -100
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1148 -107
- package/dist/index.d.ts +1148 -107
- package/dist/index.mjs +1421 -51
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -8
- package/src/audio-assets.d.ts +8 -0
- package/src/components/markdown/MarkdownMessage/CollapseToggle.tsx +3 -1
- package/src/components/markdown/MarkdownMessage/components.tsx +2 -5
- package/src/stories/index.ts +32 -2
- package/src/tools/Chat/README.md +347 -530
- package/src/tools/Chat/components/Attachments.tsx +6 -1
- package/src/tools/Chat/components/ChatRoot.tsx +30 -2
- package/src/tools/Chat/components/Composer.tsx +20 -3
- package/src/tools/Chat/components/ErrorBanner.tsx +7 -3
- package/src/tools/Chat/components/MessageActions.tsx +3 -1
- package/src/tools/Chat/components/MessageBubble.tsx +6 -5
- package/src/tools/Chat/components/MessageList.tsx +87 -1
- package/src/tools/Chat/components/ToolCalls.tsx +21 -3
- package/src/tools/Chat/context/ChatProvider.tsx +21 -3
- package/src/tools/Chat/core/audio/audioBus.ts +10 -163
- package/src/tools/Chat/core/audio/defaults.ts +43 -0
- package/src/tools/Chat/core/audio/index.ts +1 -0
- package/src/tools/Chat/core/audio/preferences.ts +5 -59
- package/src/tools/Chat/core/audio/sounds/error.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/mention.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/notification.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/received.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/sent.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/start.mp3 +0 -0
- package/src/tools/Chat/core/audio/types.ts +28 -0
- package/src/tools/Chat/core/reducer.ts +33 -0
- package/src/tools/Chat/core/transport/index.ts +13 -0
- package/src/tools/Chat/core/transport/mappers/index.ts +6 -0
- package/src/tools/Chat/core/transport/mappers/pydantic-ai.ts +142 -0
- package/src/tools/Chat/core/transport/pydantic-ai-transport.ts +208 -0
- package/src/tools/Chat/core/transport/sse.ts +18 -5
- package/src/tools/Chat/hooks/index.ts +25 -0
- package/src/tools/Chat/hooks/useAutoFocusOnStreamEnd.ts +5 -3
- package/src/tools/Chat/hooks/useChat.ts +28 -0
- package/src/tools/Chat/hooks/useChatAudio.ts +59 -180
- package/src/tools/Chat/hooks/useChatDockPrefs.ts +74 -0
- package/src/tools/Chat/hooks/useChatReset.ts +70 -0
- package/src/tools/Chat/hooks/useChatUnread.ts +87 -0
- package/src/tools/Chat/hooks/useFocusOnEmptyClick.ts +111 -0
- package/src/tools/Chat/hooks/useVisitorFingerprint.ts +48 -0
- package/src/tools/Chat/index.ts +69 -1
- package/src/tools/Chat/launcher/ChatDock.tsx +263 -0
- package/src/tools/Chat/launcher/ChatFAB.tsx +349 -0
- package/src/tools/Chat/launcher/ChatGreeting.tsx +200 -0
- package/src/tools/Chat/launcher/ChatHeader.tsx +76 -0
- package/src/tools/Chat/launcher/ChatHeaderActionButton.tsx +87 -0
- package/src/tools/Chat/launcher/ChatHeaderAudioToggle.tsx +47 -0
- package/src/tools/Chat/launcher/ChatHeaderLanguageButton.tsx +179 -0
- package/src/tools/Chat/launcher/ChatHeaderModeToggle.tsx +57 -0
- package/src/tools/Chat/launcher/ChatHeaderResetButton.tsx +93 -0
- package/src/tools/Chat/launcher/ChatLauncher.tsx +321 -0
- package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +197 -0
- package/src/tools/Chat/launcher/index.ts +46 -0
- package/src/tools/Chat/launcher/useChatPresence.ts +44 -0
- package/src/tools/Chat/stories/01-basic.story.tsx +64 -0
- package/src/tools/Chat/stories/02-bubbles.story.tsx +21 -0
- package/src/tools/Chat/stories/03-tool-calls.story.tsx +59 -0
- package/src/tools/Chat/stories/04-personas.story.tsx +78 -0
- package/src/tools/Chat/stories/05-launcher.story.tsx +321 -0
- package/src/tools/Chat/stories/06-header.story.tsx +147 -0
- package/src/tools/Chat/stories/07-audio-actions.story.tsx +112 -0
- package/src/tools/Chat/stories/shared/Frame.tsx +21 -0
- package/src/tools/Chat/stories/shared/index.ts +5 -0
- package/src/tools/Chat/stories/shared/messages.ts +39 -0
- package/src/tools/Chat/stories/shared/personas.ts +13 -0
- package/src/tools/Chat/stories/shared/seeds.ts +92 -0
- package/src/tools/Chat/stories/shared/transports.ts +36 -0
- package/src/tools/Chat/styles/bubbleTokens.ts +71 -0
- package/src/tools/Chat/styles/index.ts +16 -0
- package/src/tools/Chat/styles/useChatStyles.ts +101 -0
- package/src/tools/Chat/types/attachment.ts +25 -0
- package/src/tools/Chat/types/config.ts +48 -0
- package/src/tools/Chat/types/events.ts +35 -0
- package/src/tools/Chat/types/index.ts +34 -0
- package/src/tools/Chat/types/labels.ts +38 -0
- package/src/tools/Chat/types/message.ts +32 -0
- package/src/tools/Chat/types/persona.ts +31 -0
- package/src/tools/Chat/types/session.ts +43 -0
- package/src/tools/Chat/types/tool-call.ts +17 -0
- package/src/tools/Chat/types/transport.ts +28 -0
- package/src/tools/Chat/types.ts +5 -240
- package/src/tools/MarkdownEditor/MarkdownEditor.tsx +50 -14
- package/src/tools/MarkdownEditor/index.ts +1 -1
- package/src/tools/SpeechRecognition/README.md +336 -0
- package/src/tools/SpeechRecognition/__tests__/ids.test.ts +15 -0
- package/src/tools/SpeechRecognition/__tests__/language.test.ts +59 -0
- package/src/tools/SpeechRecognition/__tests__/reducer.test.ts +71 -0
- package/src/tools/SpeechRecognition/__tests__/transcript.test.ts +52 -0
- package/src/tools/SpeechRecognition/components/DevicePicker.tsx +49 -0
- package/src/tools/SpeechRecognition/components/DictationButton.tsx +93 -0
- package/src/tools/SpeechRecognition/components/EngineBadge.tsx +30 -0
- package/src/tools/SpeechRecognition/components/ErrorBanner.tsx +52 -0
- package/src/tools/SpeechRecognition/components/LanguagePicker.tsx +63 -0
- package/src/tools/SpeechRecognition/components/MicMeter.tsx +63 -0
- package/src/tools/SpeechRecognition/components/PushToTalkHint.tsx +51 -0
- package/src/tools/SpeechRecognition/components/TranscriptView.tsx +55 -0
- package/src/tools/SpeechRecognition/components/index.ts +16 -0
- package/src/tools/SpeechRecognition/context/SpeechRecognitionProvider.tsx +47 -0
- package/src/tools/SpeechRecognition/context/index.ts +6 -0
- package/src/tools/SpeechRecognition/core/audio/defaults.ts +24 -0
- package/src/tools/SpeechRecognition/core/engine/external.ts +222 -0
- package/src/tools/SpeechRecognition/core/engine/http.ts +147 -0
- package/src/tools/SpeechRecognition/core/engine/index.ts +52 -0
- package/src/tools/SpeechRecognition/core/engine/mediarecorder.ts +105 -0
- package/src/tools/SpeechRecognition/core/engine/websocket.ts +211 -0
- package/src/tools/SpeechRecognition/core/engine/webspeech.ts +188 -0
- package/src/tools/SpeechRecognition/core/ids.ts +11 -0
- package/src/tools/SpeechRecognition/core/index.ts +14 -0
- package/src/tools/SpeechRecognition/core/language.ts +78 -0
- package/src/tools/SpeechRecognition/core/languages-catalog.ts +229 -0
- package/src/tools/SpeechRecognition/core/logger.ts +3 -0
- package/src/tools/SpeechRecognition/core/reducer.ts +105 -0
- package/src/tools/SpeechRecognition/core/transcript.ts +36 -0
- package/src/tools/SpeechRecognition/hooks/index.ts +14 -0
- package/src/tools/SpeechRecognition/hooks/useDictation.ts +59 -0
- package/src/tools/SpeechRecognition/hooks/useEnginePrefs.ts +15 -0
- package/src/tools/SpeechRecognition/hooks/useMicDevices.ts +57 -0
- package/src/tools/SpeechRecognition/hooks/useMicLevel.ts +52 -0
- package/src/tools/SpeechRecognition/hooks/usePushToTalk.ts +85 -0
- package/src/tools/SpeechRecognition/hooks/useResolvedLanguage.ts +28 -0
- package/src/tools/SpeechRecognition/hooks/useSpeechLanguageInfo.ts +108 -0
- package/src/tools/SpeechRecognition/hooks/useSpeechRecognition.ts +188 -0
- package/src/tools/SpeechRecognition/hooks/useVoiceSupport.ts +78 -0
- package/src/tools/SpeechRecognition/index.ts +82 -0
- package/src/tools/SpeechRecognition/lazy.tsx +19 -0
- package/src/tools/SpeechRecognition/store/index.ts +2 -0
- package/src/tools/SpeechRecognition/store/prefsStore.ts +54 -0
- package/src/tools/SpeechRecognition/stories/01-basic.story.tsx +32 -0
- package/src/tools/SpeechRecognition/stories/02-dictation-field.story.tsx +32 -0
- package/src/tools/SpeechRecognition/stories/03-push-to-talk.story.tsx +27 -0
- package/src/tools/SpeechRecognition/stories/04-mic-meter.story.tsx +35 -0
- package/src/tools/SpeechRecognition/stories/05-custom-engine-http.story.tsx +40 -0
- package/src/tools/SpeechRecognition/stories/06-custom-engine-ws.story.tsx +48 -0
- package/src/tools/SpeechRecognition/stories/07-language-device.story.tsx +57 -0
- package/src/tools/SpeechRecognition/stories/08-errors-permissions.story.tsx +25 -0
- package/src/tools/SpeechRecognition/stories/09-chat-voice.story.tsx +90 -0
- package/src/tools/SpeechRecognition/stories/shared.tsx +123 -0
- package/src/tools/SpeechRecognition/types.ts +133 -0
- package/src/tools/SpeechRecognition/widgets/DictationField.tsx +105 -0
- package/src/tools/SpeechRecognition/widgets/VoiceComposerSlot.tsx +305 -0
- package/src/tools/SpeechRecognition/widgets/VoiceMessageRecorder.tsx +88 -0
- package/src/tools/SpeechRecognition/widgets/index.ts +6 -0
- package/dist/ChatRoot-EJC5Y2YM.cjs +0 -14
- package/dist/ChatRoot-QOSKJPM6.mjs +0 -5
- package/dist/chunk-NWUT327A.mjs.map +0 -1
- package/dist/chunk-QLMKCSR6.mjs +0 -2420
- package/dist/chunk-QLMKCSR6.mjs.map +0 -1
- package/dist/chunk-SI5RD2GD.cjs +0 -2460
- package/dist/chunk-SI5RD2GD.cjs.map +0 -1
- package/dist/chunk-XACCHZH2.cjs.map +0 -1
- package/src/tools/Chat/Chat.story.tsx +0 -1457
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type * as React from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
|
+
|
|
7
|
+
export interface EngineBadgeProps {
|
|
8
|
+
engineId: string;
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const LABELS: Record<string, string> = {
|
|
13
|
+
webspeech: 'Web Speech',
|
|
14
|
+
http: 'Custom HTTP',
|
|
15
|
+
websocket: 'WebSocket',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function EngineBadge({ engineId, className }: EngineBadgeProps): React.ReactElement {
|
|
19
|
+
const label = LABELS[engineId] ?? engineId;
|
|
20
|
+
return (
|
|
21
|
+
<span
|
|
22
|
+
className={cn(
|
|
23
|
+
'inline-flex items-center rounded-full border border-border bg-muted/60 px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted-foreground',
|
|
24
|
+
className,
|
|
25
|
+
)}
|
|
26
|
+
>
|
|
27
|
+
{label}
|
|
28
|
+
</span>
|
|
29
|
+
);
|
|
30
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type * as React from 'react';
|
|
4
|
+
|
|
5
|
+
import { AlertTriangle } from 'lucide-react';
|
|
6
|
+
|
|
7
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
8
|
+
|
|
9
|
+
import type { RecognitionError } from '../types';
|
|
10
|
+
|
|
11
|
+
const FRIENDLY: Record<RecognitionError['code'], string> = {
|
|
12
|
+
unsupported: 'Speech recognition isn\'t available in this browser.',
|
|
13
|
+
'permission-denied': 'Microphone access was denied. Allow it in your browser settings to dictate.',
|
|
14
|
+
'no-microphone': 'No microphone found.',
|
|
15
|
+
network: 'Network error talking to the speech service.',
|
|
16
|
+
aborted: 'Recognition was interrupted.',
|
|
17
|
+
'no-speech': 'No speech was detected. Try again.',
|
|
18
|
+
language: 'The requested language isn\'t supported by the engine.',
|
|
19
|
+
engine: 'The speech engine reported an error.',
|
|
20
|
+
unknown: 'Something went wrong with the microphone.',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export interface ErrorBannerProps {
|
|
24
|
+
error: RecognitionError | null;
|
|
25
|
+
className?: string;
|
|
26
|
+
onDismiss?: () => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ErrorBanner({ error, className, onDismiss }: ErrorBannerProps): React.ReactElement | null {
|
|
30
|
+
if (!error) return null;
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
role="alert"
|
|
34
|
+
className={cn(
|
|
35
|
+
'flex items-start gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive',
|
|
36
|
+
className,
|
|
37
|
+
)}
|
|
38
|
+
>
|
|
39
|
+
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
|
40
|
+
<div className="flex-1">{FRIENDLY[error.code] ?? error.message}</div>
|
|
41
|
+
{onDismiss && (
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
onClick={onDismiss}
|
|
45
|
+
className="text-destructive hover:underline"
|
|
46
|
+
>
|
|
47
|
+
Dismiss
|
|
48
|
+
</button>
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type * as React from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
|
+
|
|
7
|
+
export interface LanguageOption {
|
|
8
|
+
value: string;
|
|
9
|
+
label: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DEFAULT_LANGUAGES: LanguageOption[] = [
|
|
13
|
+
{ value: 'en-US', label: 'English (US)' },
|
|
14
|
+
{ value: 'en-GB', label: 'English (UK)' },
|
|
15
|
+
{ value: 'ru-RU', label: 'Русский' },
|
|
16
|
+
{ value: 'ko-KR', label: '한국어' },
|
|
17
|
+
{ value: 'ja-JP', label: '日本語' },
|
|
18
|
+
{ value: 'zh-CN', label: '中文 (简体)' },
|
|
19
|
+
{ value: 'es-ES', label: 'Español' },
|
|
20
|
+
{ value: 'fr-FR', label: 'Français' },
|
|
21
|
+
{ value: 'de-DE', label: 'Deutsch' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export interface LanguagePickerProps {
|
|
25
|
+
value: string;
|
|
26
|
+
onChange: (value: string) => void;
|
|
27
|
+
options?: LanguageOption[];
|
|
28
|
+
className?: string;
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
ariaLabel?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function LanguagePicker({
|
|
34
|
+
value,
|
|
35
|
+
onChange,
|
|
36
|
+
options = DEFAULT_LANGUAGES,
|
|
37
|
+
className,
|
|
38
|
+
disabled,
|
|
39
|
+
ariaLabel = 'Recognition language',
|
|
40
|
+
}: LanguagePickerProps): React.ReactElement {
|
|
41
|
+
return (
|
|
42
|
+
<select
|
|
43
|
+
value={value}
|
|
44
|
+
onChange={(e) => onChange(e.target.value)}
|
|
45
|
+
disabled={disabled}
|
|
46
|
+
aria-label={ariaLabel}
|
|
47
|
+
className={cn(
|
|
48
|
+
'h-8 rounded-md border border-input bg-background px-2 text-xs text-foreground',
|
|
49
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
50
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
51
|
+
className,
|
|
52
|
+
)}
|
|
53
|
+
>
|
|
54
|
+
{options.map((o) => (
|
|
55
|
+
<option key={o.value} value={o.value}>
|
|
56
|
+
{o.label}
|
|
57
|
+
</option>
|
|
58
|
+
))}
|
|
59
|
+
</select>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export { DEFAULT_LANGUAGES };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type * as React from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
|
+
|
|
7
|
+
export interface MicMeterProps {
|
|
8
|
+
/** RMS level 0..1 (use the value returned by `useMicLevel`). */
|
|
9
|
+
level: number;
|
|
10
|
+
/** Number of bars rendered. @default 12 */
|
|
11
|
+
bars?: number;
|
|
12
|
+
/** Bar gap in px. @default 2 */
|
|
13
|
+
gap?: number;
|
|
14
|
+
/** Bar width in px. @default 3 */
|
|
15
|
+
barWidth?: number;
|
|
16
|
+
/** Container height in px. @default 24 */
|
|
17
|
+
height?: number;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Discrete-bar VU meter — small, dependency-free, no canvas. Each bar
|
|
23
|
+
* lights up when the current `level` exceeds its threshold. Centre bars
|
|
24
|
+
* are tallest so the meter reads like a classic mic level indicator.
|
|
25
|
+
*/
|
|
26
|
+
export function MicMeter({
|
|
27
|
+
level,
|
|
28
|
+
bars = 12,
|
|
29
|
+
gap = 2,
|
|
30
|
+
barWidth = 3,
|
|
31
|
+
height = 24,
|
|
32
|
+
className,
|
|
33
|
+
}: MicMeterProps): React.ReactElement {
|
|
34
|
+
const clamped = Math.min(1, Math.max(0, level));
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
role="meter"
|
|
38
|
+
aria-valuemin={0}
|
|
39
|
+
aria-valuemax={1}
|
|
40
|
+
aria-valuenow={clamped}
|
|
41
|
+
className={cn('inline-flex items-center', className)}
|
|
42
|
+
style={{ gap, height }}
|
|
43
|
+
>
|
|
44
|
+
{Array.from({ length: bars }).map((_, i) => {
|
|
45
|
+
const ratio = (i + 1) / bars;
|
|
46
|
+
const active = clamped >= ratio - 0.5 / bars;
|
|
47
|
+
// taller in the middle, shorter at edges
|
|
48
|
+
const heightRatio = 1 - Math.abs(i - (bars - 1) / 2) / ((bars - 1) / 2 || 1);
|
|
49
|
+
const barH = Math.max(2, height * (0.35 + heightRatio * 0.65));
|
|
50
|
+
return (
|
|
51
|
+
<span
|
|
52
|
+
key={i}
|
|
53
|
+
className={cn(
|
|
54
|
+
'rounded-sm transition-colors',
|
|
55
|
+
active ? 'bg-primary' : 'bg-border',
|
|
56
|
+
)}
|
|
57
|
+
style={{ width: barWidth, height: barH }}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
})}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type * as React from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
|
+
|
|
7
|
+
export interface PushToTalkHintProps {
|
|
8
|
+
/** Same chord string passed to `usePushToTalk`. */
|
|
9
|
+
chord: string;
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Renders "Hold ⌥ to talk" with the chord pretty-printed. Tiny helper
|
|
15
|
+
* meant to live next to a `DictationButton`.
|
|
16
|
+
*/
|
|
17
|
+
export function PushToTalkHint({ chord, className }: PushToTalkHintProps): React.ReactElement {
|
|
18
|
+
const formatted = chord
|
|
19
|
+
.split('+')
|
|
20
|
+
.map((p) => p.trim().toLowerCase())
|
|
21
|
+
.map((p) => {
|
|
22
|
+
switch (p) {
|
|
23
|
+
case 'mod':
|
|
24
|
+
case 'meta':
|
|
25
|
+
return '⌘';
|
|
26
|
+
case 'alt':
|
|
27
|
+
return '⌥';
|
|
28
|
+
case 'shift':
|
|
29
|
+
return '⇧';
|
|
30
|
+
case 'ctrl':
|
|
31
|
+
return '⌃';
|
|
32
|
+
default:
|
|
33
|
+
return p.length === 1 ? p.toUpperCase() : p;
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
.join('');
|
|
37
|
+
return (
|
|
38
|
+
<span
|
|
39
|
+
className={cn(
|
|
40
|
+
'inline-flex items-center gap-1 text-[11px] text-muted-foreground',
|
|
41
|
+
className,
|
|
42
|
+
)}
|
|
43
|
+
>
|
|
44
|
+
Hold
|
|
45
|
+
<kbd className="rounded border border-border bg-muted px-1 py-0.5 font-mono text-[10px] text-foreground">
|
|
46
|
+
{formatted}
|
|
47
|
+
</kbd>
|
|
48
|
+
to talk
|
|
49
|
+
</span>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type * as React from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
|
+
|
|
7
|
+
import type { Transcript } from '../types';
|
|
8
|
+
|
|
9
|
+
export interface TranscriptViewProps {
|
|
10
|
+
transcript: Transcript;
|
|
11
|
+
/** Render the empty state when there is nothing yet. */
|
|
12
|
+
emptyState?: React.ReactNode;
|
|
13
|
+
/** Tone the interim text down so users can tell it's still "wet". */
|
|
14
|
+
dimInterim?: boolean;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Two-tone transcript: final segments in foreground, the trailing
|
|
20
|
+
* interim chunk dimmed so users see the model "thinking". Pure
|
|
21
|
+
* presentational — pair with `useSpeechRecognition().transcript`.
|
|
22
|
+
*/
|
|
23
|
+
export function TranscriptView({
|
|
24
|
+
transcript,
|
|
25
|
+
emptyState,
|
|
26
|
+
dimInterim = true,
|
|
27
|
+
className,
|
|
28
|
+
}: TranscriptViewProps): React.ReactElement {
|
|
29
|
+
const { final, interim } = transcript;
|
|
30
|
+
if (!final && !interim) {
|
|
31
|
+
return (
|
|
32
|
+
<div className={cn('text-sm text-muted-foreground', className)}>
|
|
33
|
+
{emptyState ?? 'Press the mic to start dictating…'}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return (
|
|
38
|
+
<div className={cn('text-sm leading-relaxed', className)}>
|
|
39
|
+
<span className="text-foreground whitespace-pre-wrap">{final}</span>
|
|
40
|
+
{interim && (
|
|
41
|
+
<>
|
|
42
|
+
{' '}
|
|
43
|
+
<span
|
|
44
|
+
className={cn(
|
|
45
|
+
'whitespace-pre-wrap',
|
|
46
|
+
dimInterim ? 'text-muted-foreground' : 'text-foreground',
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
{interim}
|
|
50
|
+
</span>
|
|
51
|
+
</>
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { DictationButton } from './DictationButton';
|
|
2
|
+
export type { DictationButtonProps } from './DictationButton';
|
|
3
|
+
export { MicMeter } from './MicMeter';
|
|
4
|
+
export type { MicMeterProps } from './MicMeter';
|
|
5
|
+
export { TranscriptView } from './TranscriptView';
|
|
6
|
+
export type { TranscriptViewProps } from './TranscriptView';
|
|
7
|
+
export { LanguagePicker, DEFAULT_LANGUAGES } from './LanguagePicker';
|
|
8
|
+
export type { LanguagePickerProps, LanguageOption } from './LanguagePicker';
|
|
9
|
+
export { DevicePicker } from './DevicePicker';
|
|
10
|
+
export type { DevicePickerProps } from './DevicePicker';
|
|
11
|
+
export { EngineBadge } from './EngineBadge';
|
|
12
|
+
export type { EngineBadgeProps } from './EngineBadge';
|
|
13
|
+
export { ErrorBanner } from './ErrorBanner';
|
|
14
|
+
export type { ErrorBannerProps } from './ErrorBanner';
|
|
15
|
+
export { PushToTalkHint } from './PushToTalkHint';
|
|
16
|
+
export type { PushToTalkHintProps } from './PushToTalkHint';
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type * as React from 'react';
|
|
4
|
+
import { createContext, useContext, useMemo } from 'react';
|
|
5
|
+
|
|
6
|
+
import type { UseSpeechRecognitionConfig, UseSpeechRecognitionReturn } from '../types';
|
|
7
|
+
import { useSpeechRecognition } from '../hooks/useSpeechRecognition';
|
|
8
|
+
|
|
9
|
+
const Ctx = createContext<UseSpeechRecognitionReturn | null>(null);
|
|
10
|
+
|
|
11
|
+
export interface SpeechRecognitionProviderProps extends UseSpeechRecognitionConfig {
|
|
12
|
+
children?: React.ReactNode;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Lifts a single `useSpeechRecognition` instance into a context so any
|
|
17
|
+
* descendant — composer slot, header badge, transcript overlay — can
|
|
18
|
+
* read the same status / transcript / level without each component
|
|
19
|
+
* spinning up its own engine. Mount it once per Chat (or per
|
|
20
|
+
* dictation-aware screen).
|
|
21
|
+
*/
|
|
22
|
+
export function SpeechRecognitionProvider({
|
|
23
|
+
children,
|
|
24
|
+
...config
|
|
25
|
+
}: SpeechRecognitionProviderProps): React.ReactElement {
|
|
26
|
+
const rec = useSpeechRecognition(config);
|
|
27
|
+
// Memo prevents context-value churn from leaking through to every
|
|
28
|
+
// useContext consumer when only one of {status, level, transcript}
|
|
29
|
+
// changes — React already triggers them via the inner state, the
|
|
30
|
+
// outer object identity should stay stable across renders.
|
|
31
|
+
const value = useMemo(() => rec, [rec]);
|
|
32
|
+
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useSpeechRecognitionContext(): UseSpeechRecognitionReturn {
|
|
36
|
+
const ctx = useContext(Ctx);
|
|
37
|
+
if (!ctx) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'useSpeechRecognitionContext must be used inside a <SpeechRecognitionProvider>.',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return ctx;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function useSpeechRecognitionContextOptional(): UseSpeechRecognitionReturn | null {
|
|
46
|
+
return useContext(Ctx);
|
|
47
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default earcons for speech sessions. We reuse two of the bundled
|
|
3
|
+
* Chat notification sounds rather than ship new MP3s — same lazy chunk
|
|
4
|
+
* already pays for them, and the tonal palette stays consistent across
|
|
5
|
+
* the chat + dictation UI.
|
|
6
|
+
*
|
|
7
|
+
* - `start` → the chat "stream start" chime (calm low-key tone).
|
|
8
|
+
* - `stop` → the chat "sent" tick (2KB, very short — feels like
|
|
9
|
+
* "captured" rather than a notification ping).
|
|
10
|
+
* Previously used `notification.mp3` (30KB, long, loud) which was
|
|
11
|
+
* too attention-grabbing for a self-initiated action.
|
|
12
|
+
*
|
|
13
|
+
* Loaded as `dataurl` by tsup (see `tsup.config.ts`), so consumers get
|
|
14
|
+
* working audio with zero asset setup.
|
|
15
|
+
*/
|
|
16
|
+
import start from '../../../Chat/core/audio/sounds/start.mp3';
|
|
17
|
+
import sent from '../../../Chat/core/audio/sounds/sent.mp3';
|
|
18
|
+
|
|
19
|
+
export type VoiceSoundEvent = 'start' | 'stop';
|
|
20
|
+
|
|
21
|
+
export const DEFAULT_VOICE_SOUNDS: Record<VoiceSoundEvent, string> = {
|
|
22
|
+
start,
|
|
23
|
+
stop: sent,
|
|
24
|
+
};
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External-controlled engine — for backends that own the entire
|
|
3
|
+
* record / decode / transcribe pipeline (Wails / Tauri / native
|
|
4
|
+
* sidecar). The frontend exposes "start" and "stop" verbs to the
|
|
5
|
+
* native layer, which then pushes a single `final` (or rolling
|
|
6
|
+
* `partial`s + `final`) back through events.
|
|
7
|
+
*
|
|
8
|
+
* Use this when:
|
|
9
|
+
* - Audio capture lives outside the browser (cmdop_go OS-wide
|
|
10
|
+
* hotkey, system audio device claims, etc.).
|
|
11
|
+
* - Transcription runs on the backend (whisper.cpp, Vosk, custom
|
|
12
|
+
* ONNX) and the browser never sees the raw audio.
|
|
13
|
+
*
|
|
14
|
+
* Compared to `createHttpEngine`/`createWebSocketEngine`:
|
|
15
|
+
* - No `MediaRecorder` / `getUserMedia` involvement.
|
|
16
|
+
* - `isSupported` defaults to `true` (the host knows whether the
|
|
17
|
+
* native side is present — let it gate via `supported`).
|
|
18
|
+
* - No `MediaStream` to expose, so the VU meter falls through to
|
|
19
|
+
* the host's own level event (cmdop wires it via a separate hook).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { newSegmentId } from '../ids';
|
|
23
|
+
import { sttLogger } from '../logger';
|
|
24
|
+
import { createEngineBus } from './index';
|
|
25
|
+
import type {
|
|
26
|
+
EngineStartOptions,
|
|
27
|
+
RecognitionEngine,
|
|
28
|
+
RecognitionError,
|
|
29
|
+
Unsub,
|
|
30
|
+
} from '../../types';
|
|
31
|
+
|
|
32
|
+
export interface ExternalEngineHandle {
|
|
33
|
+
/**
|
|
34
|
+
* Push an interim transcript fragment. Wrapped into a `partial`
|
|
35
|
+
* event with a generated segment id. Subsequent calls before
|
|
36
|
+
* `emitFinal` mutate the same interim segment.
|
|
37
|
+
*/
|
|
38
|
+
emitPartial(text: string): void;
|
|
39
|
+
/**
|
|
40
|
+
* Push the final transcript. Closes the current segment; the engine
|
|
41
|
+
* emits `final` then transitions to `closed`.
|
|
42
|
+
*/
|
|
43
|
+
emitFinal(text: string, confidence?: number): void;
|
|
44
|
+
/** Surface a backend error. Engine transitions to `closed`. */
|
|
45
|
+
emitError(err: RecognitionError): void;
|
|
46
|
+
/**
|
|
47
|
+
* Notify the engine that the native side actually started capturing.
|
|
48
|
+
* Flips status to `listening`. Call from a backend `recording`
|
|
49
|
+
* event. Optional — see `autoMarkListening`.
|
|
50
|
+
*/
|
|
51
|
+
markListening(): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ExternalEngineOptions {
|
|
55
|
+
/** Stable engine id for telemetry / UI badge. */
|
|
56
|
+
id?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Whether the host believes the native side is available. Wire to
|
|
59
|
+
* a Wails ping / Tauri capability check. Defaults to `true`.
|
|
60
|
+
*/
|
|
61
|
+
supported?: boolean;
|
|
62
|
+
/** Ask the backend to start capture. */
|
|
63
|
+
onStart: (opts: EngineStartOptions) => Promise<void> | void;
|
|
64
|
+
/** Ask the backend to stop capture (and finalise the buffer). */
|
|
65
|
+
onStop: () => Promise<void> | void;
|
|
66
|
+
/**
|
|
67
|
+
* Optional hard cancel — `onStop` may finalise, while `onAbort`
|
|
68
|
+
* discards. Falls back to `onStop` when omitted.
|
|
69
|
+
*/
|
|
70
|
+
onAbort?: () => Promise<void> | void;
|
|
71
|
+
/**
|
|
72
|
+
* Subscribe to backend events. Called once per `start()`. The host
|
|
73
|
+
* wires its native event source (Wails `EventsOn`, Tauri
|
|
74
|
+
* `appWindow.listen`, …) and uses the supplied `handle` to push
|
|
75
|
+
* transcript fragments through the engine bus.
|
|
76
|
+
*
|
|
77
|
+
* Must return an unsubscribe function so the engine can detach on
|
|
78
|
+
* teardown.
|
|
79
|
+
*/
|
|
80
|
+
subscribe: (handle: ExternalEngineHandle) => Unsub;
|
|
81
|
+
/**
|
|
82
|
+
* If `true` (default), the engine flips state to `listening` right
|
|
83
|
+
* after `onStart` resolves. Set `false` and call
|
|
84
|
+
* `handle.markListening()` explicitly when you want to wait for the
|
|
85
|
+
* native side to confirm the capture session opened.
|
|
86
|
+
*/
|
|
87
|
+
autoMarkListening?: boolean;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Wraps a backend-driven STT pipeline into the standard
|
|
92
|
+
* `RecognitionEngine` shape so it works with `useSpeechRecognition`,
|
|
93
|
+
* `VoiceComposerSlot`, and every other piece of the SpeechRecognition
|
|
94
|
+
* tool.
|
|
95
|
+
*
|
|
96
|
+
* Example (cmdop Wails):
|
|
97
|
+
*
|
|
98
|
+
* ```ts
|
|
99
|
+
* import { EventsOn } from '@runtime';
|
|
100
|
+
* import * as VoiceService from '@bindings/desktop/services/voice/service';
|
|
101
|
+
*
|
|
102
|
+
* const engine = createExternalEngine({
|
|
103
|
+
* id: 'wails-whisper',
|
|
104
|
+
* onStart: () => VoiceService.StartRecordingForChat(),
|
|
105
|
+
* onStop: () => VoiceService.StopRecordingForChat(),
|
|
106
|
+
* subscribe: (handle) => {
|
|
107
|
+
* const offText = EventsOn('voice:chat-text', (p) => {
|
|
108
|
+
* if (p?.error) handle.emitError({ code: 'engine', message: p.error });
|
|
109
|
+
* else if (p?.text) handle.emitFinal(p.text);
|
|
110
|
+
* else handle.emitError({ code: 'no-speech', message: '' });
|
|
111
|
+
* });
|
|
112
|
+
* const offState = EventsOn('voice:state', (s) => {
|
|
113
|
+
* if (s.state === 'recording' || s.state === 'streaming') {
|
|
114
|
+
* handle.markListening();
|
|
115
|
+
* }
|
|
116
|
+
* if (s.partial) handle.emitPartial(s.partial);
|
|
117
|
+
* });
|
|
118
|
+
* return () => { offText(); offState(); };
|
|
119
|
+
* },
|
|
120
|
+
* });
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
export function createExternalEngine(
|
|
124
|
+
opts: ExternalEngineOptions,
|
|
125
|
+
): RecognitionEngine {
|
|
126
|
+
const bus = createEngineBus();
|
|
127
|
+
let currentSegmentId: string | null = null;
|
|
128
|
+
let unsubscribe: Unsub | null = null;
|
|
129
|
+
let running = false;
|
|
130
|
+
|
|
131
|
+
function teardown(): void {
|
|
132
|
+
unsubscribe?.();
|
|
133
|
+
unsubscribe = null;
|
|
134
|
+
currentSegmentId = null;
|
|
135
|
+
running = false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const handle: ExternalEngineHandle = {
|
|
139
|
+
emitPartial(text: string): void {
|
|
140
|
+
if (!running) return;
|
|
141
|
+
if (!currentSegmentId) currentSegmentId = newSegmentId();
|
|
142
|
+
bus.emit('partial', text, currentSegmentId);
|
|
143
|
+
},
|
|
144
|
+
emitFinal(text: string, confidence?: number): void {
|
|
145
|
+
if (!running) return;
|
|
146
|
+
const id = currentSegmentId ?? newSegmentId();
|
|
147
|
+
bus.emit('final', text, id, confidence);
|
|
148
|
+
// External engines almost always go idle right after their
|
|
149
|
+
// final — close the session so consumers' `onStop` fires
|
|
150
|
+
// without requiring a separate `stop()` call.
|
|
151
|
+
bus.emit('state', 'closed');
|
|
152
|
+
teardown();
|
|
153
|
+
},
|
|
154
|
+
emitError(err: RecognitionError): void {
|
|
155
|
+
bus.emit('error', err);
|
|
156
|
+
bus.emit('state', 'closed');
|
|
157
|
+
teardown();
|
|
158
|
+
},
|
|
159
|
+
markListening(): void {
|
|
160
|
+
if (!running) return;
|
|
161
|
+
bus.emit('state', 'listening');
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
id: opts.id ?? 'external',
|
|
167
|
+
isSupported: opts.supported ?? true,
|
|
168
|
+
on(event, cb): Unsub {
|
|
169
|
+
return bus.on(event, cb);
|
|
170
|
+
},
|
|
171
|
+
async start(start: EngineStartOptions): Promise<void> {
|
|
172
|
+
if (running) return;
|
|
173
|
+
running = true;
|
|
174
|
+
bus.emit('state', 'connecting');
|
|
175
|
+
// Subscribe before the native side starts so we never miss the
|
|
176
|
+
// first event (some backends emit `recording` synchronously).
|
|
177
|
+
unsubscribe = opts.subscribe(handle);
|
|
178
|
+
try {
|
|
179
|
+
await opts.onStart(start);
|
|
180
|
+
} catch (cause) {
|
|
181
|
+
const err: RecognitionError = {
|
|
182
|
+
code: 'engine',
|
|
183
|
+
message: 'External engine failed to start.',
|
|
184
|
+
cause,
|
|
185
|
+
};
|
|
186
|
+
bus.emit('error', err);
|
|
187
|
+
bus.emit('state', 'closed');
|
|
188
|
+
teardown();
|
|
189
|
+
throw err;
|
|
190
|
+
}
|
|
191
|
+
if (opts.autoMarkListening !== false) {
|
|
192
|
+
bus.emit('state', 'listening');
|
|
193
|
+
}
|
|
194
|
+
start.signal?.addEventListener('abort', () => {
|
|
195
|
+
this.abort();
|
|
196
|
+
});
|
|
197
|
+
},
|
|
198
|
+
async stop(): Promise<void> {
|
|
199
|
+
if (!running) return;
|
|
200
|
+
bus.emit('state', 'closing');
|
|
201
|
+
try {
|
|
202
|
+
await opts.onStop();
|
|
203
|
+
} catch (cause) {
|
|
204
|
+
sttLogger.warn('[external] onStop threw', cause);
|
|
205
|
+
}
|
|
206
|
+
// Note: we DO NOT flip to `closed` here — most external engines
|
|
207
|
+
// need a roundtrip (transcribe + LLM rewrite) before the final
|
|
208
|
+
// text arrives. `emitFinal` / `emitError` are responsible for
|
|
209
|
+
// closing the session.
|
|
210
|
+
},
|
|
211
|
+
abort(): void {
|
|
212
|
+
if (!running) return;
|
|
213
|
+
bus.emit('state', 'closing');
|
|
214
|
+
const stopper = opts.onAbort ?? opts.onStop;
|
|
215
|
+
Promise.resolve(stopper()).catch((cause) => {
|
|
216
|
+
sttLogger.warn('[external] abort hook threw', cause);
|
|
217
|
+
});
|
|
218
|
+
bus.emit('state', 'closed');
|
|
219
|
+
teardown();
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|