@djangocfg/ui-tools 2.1.381 → 2.1.383
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-U25MEYAL.mjs +4 -0
- package/dist/DictationField-U25MEYAL.mjs.map +1 -0
- package/dist/DictationField-XWR5VOID.cjs +13 -0
- package/dist/DictationField-XWR5VOID.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-4PFW7MIJ.cjs +837 -0
- package/dist/chunk-4PFW7MIJ.cjs.map +1 -0
- package/dist/chunk-C2YN6WEO.mjs +833 -0
- package/dist/chunk-C2YN6WEO.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-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 +1668 -99
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1215 -107
- package/dist/index.d.ts +1215 -107
- package/dist/index.mjs +1555 -50
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -15
- 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/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 +84 -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/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/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/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +0 -771
- package/src/stories/index.ts +0 -33
- package/src/tools/AudioPlayer/AudioPlayer.story.tsx +0 -481
- package/src/tools/Chat/Chat.story.tsx +0 -1457
- package/src/tools/CodeEditor/CodeEditor.story.tsx +0 -202
- package/src/tools/CronScheduler/CronScheduler.story.tsx +0 -300
- package/src/tools/Gallery/Gallery.story.tsx +0 -237
- package/src/tools/ImageViewer/ImageViewer.story.tsx +0 -85
- package/src/tools/JsonForm/JsonForm.story.tsx +0 -350
- package/src/tools/JsonTree/JsonTree.story.tsx +0 -141
- package/src/tools/LottiePlayer/LottiePlayer.story.tsx +0 -95
- package/src/tools/Map/Map.story.tsx +0 -458
- package/src/tools/MarkdownEditor/MarkdownEditor.story.tsx +0 -225
- package/src/tools/Mermaid/Mermaid.story.tsx +0 -251
- package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +0 -230
- package/src/tools/PrettyCode/PrettyCode.story.tsx +0 -304
- package/src/tools/Tour/Tour.story.tsx +0 -279
- package/src/tools/Tree/Tree.story.tsx +0 -620
- package/src/tools/Uploader/Uploader.story.tsx +0 -415
- package/src/tools/VideoPlayer/VideoPlayer.story.tsx +0 -87
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type * as React from 'react';
|
|
4
|
+
|
|
5
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
6
|
+
|
|
7
|
+
import { DictationButton } from '../components/DictationButton';
|
|
8
|
+
import { ErrorBanner } from '../components/ErrorBanner';
|
|
9
|
+
import { MicMeter } from '../components/MicMeter';
|
|
10
|
+
import { PushToTalkHint } from '../components/PushToTalkHint';
|
|
11
|
+
import { useDictation } from '../hooks/useDictation';
|
|
12
|
+
import { usePushToTalk } from '../hooks/usePushToTalk';
|
|
13
|
+
import type { RecognitionEngine } from '../types';
|
|
14
|
+
|
|
15
|
+
export interface DictationFieldProps {
|
|
16
|
+
value: string;
|
|
17
|
+
onChange: (next: string) => void;
|
|
18
|
+
/** Custom engine. Defaults to Web Speech via `useSpeechRecognition`. */
|
|
19
|
+
engine?: RecognitionEngine;
|
|
20
|
+
/** Override the language stored in `useSpeechPrefs`. */
|
|
21
|
+
language?: string;
|
|
22
|
+
/** Push-to-talk chord (e.g. `'alt'`, `'mod+alt'`). Disabled when omitted. */
|
|
23
|
+
pushToTalk?: { key: string; enabled?: boolean };
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
rows?: number;
|
|
26
|
+
disabled?: boolean;
|
|
27
|
+
/** Show the interim transcript as a ghost overlay below the textarea. */
|
|
28
|
+
showInterim?: boolean;
|
|
29
|
+
/** Show a small RMS meter inside the toolbar. */
|
|
30
|
+
showMeter?: boolean;
|
|
31
|
+
className?: string;
|
|
32
|
+
textareaClassName?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Opinionated textarea + dictation button assembly. Final segments are
|
|
37
|
+
* appended to the controlled `value` automatically. Press-and-hold
|
|
38
|
+
* shortcut is optional; the mic button itself works as a toggle.
|
|
39
|
+
*/
|
|
40
|
+
export function DictationField({
|
|
41
|
+
value,
|
|
42
|
+
onChange,
|
|
43
|
+
engine,
|
|
44
|
+
language,
|
|
45
|
+
pushToTalk,
|
|
46
|
+
placeholder = 'Type or press the mic to dictate…',
|
|
47
|
+
rows = 3,
|
|
48
|
+
disabled,
|
|
49
|
+
showInterim = true,
|
|
50
|
+
showMeter = true,
|
|
51
|
+
className,
|
|
52
|
+
textareaClassName,
|
|
53
|
+
}: DictationFieldProps): React.ReactElement {
|
|
54
|
+
const rec = useDictation({
|
|
55
|
+
value,
|
|
56
|
+
onChange,
|
|
57
|
+
engine,
|
|
58
|
+
language,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
usePushToTalk(rec, {
|
|
62
|
+
key: pushToTalk?.key ?? 'alt',
|
|
63
|
+
enabled: !!pushToTalk && pushToTalk.enabled !== false,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className={cn('flex flex-col gap-2', className)}>
|
|
68
|
+
<div className="relative">
|
|
69
|
+
<textarea
|
|
70
|
+
value={value}
|
|
71
|
+
onChange={(e) => onChange(e.target.value)}
|
|
72
|
+
placeholder={placeholder}
|
|
73
|
+
rows={rows}
|
|
74
|
+
disabled={disabled}
|
|
75
|
+
className={cn(
|
|
76
|
+
'w-full resize-y rounded-md border border-input bg-background px-3 py-2 text-sm',
|
|
77
|
+
'placeholder:text-muted-foreground',
|
|
78
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
79
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
80
|
+
textareaClassName,
|
|
81
|
+
)}
|
|
82
|
+
/>
|
|
83
|
+
{showInterim && rec.transcript.interim && (
|
|
84
|
+
<div className="pointer-events-none mt-1 text-xs italic text-muted-foreground">
|
|
85
|
+
… {rec.transcript.interim}
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<div className="flex items-center gap-2">
|
|
91
|
+
<DictationButton
|
|
92
|
+
status={rec.status}
|
|
93
|
+
isSupported={rec.isSupported}
|
|
94
|
+
onClick={() => void rec.toggleDictation()}
|
|
95
|
+
size="sm"
|
|
96
|
+
disabled={disabled}
|
|
97
|
+
/>
|
|
98
|
+
{showMeter && <MicMeter level={rec.level} bars={10} height={20} />}
|
|
99
|
+
{pushToTalk && <PushToTalkHint chord={pushToTalk.key} className="ml-auto" />}
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<ErrorBanner error={rec.error} />
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type * as React from 'react';
|
|
4
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
5
|
+
import { Loader2, Mic } from 'lucide-react';
|
|
6
|
+
|
|
7
|
+
import { useCountdownFromSeconds, useNotificationSounds } from '@djangocfg/ui-core/hooks';
|
|
8
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
9
|
+
|
|
10
|
+
import { useChatContextOptional } from '../../Chat/context';
|
|
11
|
+
import { useSpeechRecognition } from '../hooks/useSpeechRecognition';
|
|
12
|
+
import { useVoiceSupport } from '../hooks/useVoiceSupport';
|
|
13
|
+
import { normaliseFinal } from '../core/transcript';
|
|
14
|
+
import { DEFAULT_VOICE_SOUNDS, type VoiceSoundEvent } from '../core/audio/defaults';
|
|
15
|
+
import type { RecognitionEngine } from '../types';
|
|
16
|
+
|
|
17
|
+
export interface VoiceComposerSlotProps {
|
|
18
|
+
/**
|
|
19
|
+
* Controlled composer value. Optional — when omitted, the slot
|
|
20
|
+
* reads/writes through the Chat context's registered `ComposerHandle`
|
|
21
|
+
* (built-in `<Composer>` and any host that calls
|
|
22
|
+
* `useRegisterComposer({ getValue, setValue })`). Pass explicitly
|
|
23
|
+
* only for standalone usage outside a `<ChatProvider>`.
|
|
24
|
+
*/
|
|
25
|
+
value?: string;
|
|
26
|
+
/** Composer setter — see `value`. Optional when used inside a chat. */
|
|
27
|
+
onChange?: (next: string) => void;
|
|
28
|
+
/** Optional custom engine (Deepgram / HTTP / WS). Defaults to Web Speech. */
|
|
29
|
+
engine?: RecognitionEngine;
|
|
30
|
+
/** BCP-47 language override. Otherwise `useSpeechPrefs` decides. */
|
|
31
|
+
language?: string;
|
|
32
|
+
/** Hide the button if the host wants to disable voice on phones. */
|
|
33
|
+
hideOnMobile?: boolean;
|
|
34
|
+
/** Max session length in seconds before we auto-stop. Default 90. */
|
|
35
|
+
maxSeconds?: number;
|
|
36
|
+
/** Auto-stop after this many ms of silence. Default 2500. */
|
|
37
|
+
silenceMs?: number;
|
|
38
|
+
/** Button size. @default 'md' */
|
|
39
|
+
size?: 'sm' | 'md' | 'lg';
|
|
40
|
+
/** Override classes on the button. */
|
|
41
|
+
className?: string;
|
|
42
|
+
/** Fires when dictation finishes — useful for parent-side analytics. */
|
|
43
|
+
onFinish?: (transcript: string) => void;
|
|
44
|
+
/**
|
|
45
|
+
* Start/stop earcons. `true` (default) plays the bundled sounds,
|
|
46
|
+
* `false` disables them, or pass `{ start, stop }` data-URLs to
|
|
47
|
+
* override. Master mute lives in `useNotificationSounds` localStorage
|
|
48
|
+
* — users can silence everything via their own UI.
|
|
49
|
+
*/
|
|
50
|
+
sounds?: boolean | { start?: string; stop?: string };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const SIZE_CLS: Record<NonNullable<VoiceComposerSlotProps['size']>, string> = {
|
|
54
|
+
sm: 'h-8 w-8 [&_svg]:h-4 [&_svg]:w-4',
|
|
55
|
+
md: 'h-9 w-9 [&_svg]:h-4 [&_svg]:w-4',
|
|
56
|
+
lg: 'h-12 w-12 [&_svg]:h-5 [&_svg]:w-5',
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const STORAGE_KEY = 'djangocfg-stt:voice-sounds';
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Drop-in slot for the `<Composer toolbarEnd>` (or `toolbarStart`) prop.
|
|
63
|
+
*
|
|
64
|
+
* Renders a microphone button — but only when the browser + device
|
|
65
|
+
* combination can actually do speech recognition. Firefox, in-app
|
|
66
|
+
* WebViews, and missing `getUserMedia` all collapse the component to
|
|
67
|
+
* `null`, so the chat composer never shows a broken affordance.
|
|
68
|
+
*
|
|
69
|
+
* While listening, interim+final transcript is pushed live into the
|
|
70
|
+
* composer's `value` — like dictation on iOS / Android keyboards. The
|
|
71
|
+
* user's already-typed prefix is preserved (anchored on press). Cancel
|
|
72
|
+
* with the same button or by pressing Escape (handled upstream).
|
|
73
|
+
*
|
|
74
|
+
* Soft start/stop earcons play by default. Pass `sounds={false}` to
|
|
75
|
+
* mute, or `sounds={{ start, stop }}` to override the audio URLs.
|
|
76
|
+
*/
|
|
77
|
+
export function VoiceComposerSlot({
|
|
78
|
+
value,
|
|
79
|
+
onChange,
|
|
80
|
+
engine,
|
|
81
|
+
language,
|
|
82
|
+
hideOnMobile = false,
|
|
83
|
+
maxSeconds = 90,
|
|
84
|
+
silenceMs = 2500,
|
|
85
|
+
size = 'md',
|
|
86
|
+
className,
|
|
87
|
+
onFinish,
|
|
88
|
+
sounds = true,
|
|
89
|
+
}: VoiceComposerSlotProps): React.ReactElement | null {
|
|
90
|
+
const support = useVoiceSupport(engine);
|
|
91
|
+
|
|
92
|
+
// Read the composer handle from chat context — works transparently
|
|
93
|
+
// for the built-in `<Composer>` (registers itself) and for TipTap
|
|
94
|
+
// hosts that call `useRegisterComposer({ getValue, setValue, focus,
|
|
95
|
+
// moveCursorToEnd })`. Falls back to a no-op when mounted outside of
|
|
96
|
+
// a chat.
|
|
97
|
+
const chatCtx = useChatContextOptional();
|
|
98
|
+
const composerHandleRef = useRef(chatCtx?.composer ?? null);
|
|
99
|
+
composerHandleRef.current = chatCtx?.composer ?? null;
|
|
100
|
+
|
|
101
|
+
// Resolve value/onChange: prop wins; otherwise pull from the
|
|
102
|
+
// registered composer handle. The slot can therefore be dropped into
|
|
103
|
+
// `composerToolbarEnd` of `ChatRoot` with zero props.
|
|
104
|
+
const resolvedGetValue = useCallback((): string => {
|
|
105
|
+
if (value !== undefined) return value;
|
|
106
|
+
return composerHandleRef.current?.getValue?.() ?? '';
|
|
107
|
+
}, [value]);
|
|
108
|
+
const resolvedSetValue = useCallback(
|
|
109
|
+
(next: string): void => {
|
|
110
|
+
if (onChange) {
|
|
111
|
+
onChange(next);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
composerHandleRef.current?.setValue?.(next);
|
|
115
|
+
},
|
|
116
|
+
[onChange],
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
// Anchor: what was already in the textarea when the user pressed the
|
|
120
|
+
// mic. Live transcript is appended to this baseline so manual typing
|
|
121
|
+
// before pressing the button is never overwritten.
|
|
122
|
+
const anchorRef = useRef<string>('');
|
|
123
|
+
const onFinishRef = useRef(onFinish);
|
|
124
|
+
onFinishRef.current = onFinish;
|
|
125
|
+
|
|
126
|
+
// Push caret to the end on the next frame — after React commits the
|
|
127
|
+
// new `value` into the DOM. Without the rAF the selection lands on
|
|
128
|
+
// the old text length, leaving the cursor mid-string while the live
|
|
129
|
+
// transcript visually keeps growing.
|
|
130
|
+
const pinCaretToEnd = useCallback(() => {
|
|
131
|
+
const handle = composerHandleRef.current;
|
|
132
|
+
if (!handle?.moveCursorToEnd) return;
|
|
133
|
+
requestAnimationFrame(() => {
|
|
134
|
+
composerHandleRef.current?.moveCursorToEnd?.();
|
|
135
|
+
});
|
|
136
|
+
}, []);
|
|
137
|
+
|
|
138
|
+
const [countdown, startCountdown] = useCountdownFromSeconds();
|
|
139
|
+
|
|
140
|
+
// Earcon bus. `sounds === false` → pass empty map so the bus stays
|
|
141
|
+
// silent. Object overrides merge with bundled defaults.
|
|
142
|
+
const soundMap =
|
|
143
|
+
sounds === false
|
|
144
|
+
? undefined
|
|
145
|
+
: sounds === true
|
|
146
|
+
? DEFAULT_VOICE_SOUNDS
|
|
147
|
+
: { ...DEFAULT_VOICE_SOUNDS, ...sounds };
|
|
148
|
+
const audio = useNotificationSounds<VoiceSoundEvent>({
|
|
149
|
+
storageKey: STORAGE_KEY,
|
|
150
|
+
sounds: soundMap,
|
|
151
|
+
muted: sounds === false,
|
|
152
|
+
// Both earcons stay deliberately quiet — they're self-initiated
|
|
153
|
+
// micro-confirmations, not notifications. Anything louder feels
|
|
154
|
+
// attention-grabbing when the user pressed the button themselves.
|
|
155
|
+
eventVolumes: { start: 0.35, stop: 0.5 },
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const handlePartial = useCallback(
|
|
159
|
+
(text: string) => {
|
|
160
|
+
const next = anchorRef.current
|
|
161
|
+
? `${anchorRef.current} ${text}`
|
|
162
|
+
: text;
|
|
163
|
+
resolvedSetValue(next);
|
|
164
|
+
pinCaretToEnd();
|
|
165
|
+
},
|
|
166
|
+
[pinCaretToEnd, resolvedSetValue],
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const handleFinal = useCallback(
|
|
170
|
+
(text: string) => {
|
|
171
|
+
const clean = normaliseFinal(text);
|
|
172
|
+
if (!clean) return;
|
|
173
|
+
const merged = anchorRef.current ? `${anchorRef.current} ${clean}` : clean;
|
|
174
|
+
anchorRef.current = merged;
|
|
175
|
+
resolvedSetValue(merged);
|
|
176
|
+
pinCaretToEnd();
|
|
177
|
+
},
|
|
178
|
+
[pinCaretToEnd, resolvedSetValue],
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
const rec = useSpeechRecognition({
|
|
182
|
+
engine,
|
|
183
|
+
language,
|
|
184
|
+
interim: true,
|
|
185
|
+
autoStop: { silenceMs, maxMs: maxSeconds * 1000 },
|
|
186
|
+
onPartial: (text) => handlePartial(text),
|
|
187
|
+
onFinal: (text) => handleFinal(text),
|
|
188
|
+
onStart: () => {
|
|
189
|
+
void audio.play('start');
|
|
190
|
+
// Focus the composer + park caret at the end so the live
|
|
191
|
+
// transcript visibly grows where the user expects it to.
|
|
192
|
+
composerHandleRef.current?.focus();
|
|
193
|
+
pinCaretToEnd();
|
|
194
|
+
},
|
|
195
|
+
onStop: () => {
|
|
196
|
+
void audio.play('stop');
|
|
197
|
+
// Re-focus on stop too — auto-stop on silence happens without
|
|
198
|
+
// a user gesture, and we want the user to keep typing seamlessly.
|
|
199
|
+
composerHandleRef.current?.focus();
|
|
200
|
+
pinCaretToEnd();
|
|
201
|
+
onFinishRef.current?.(resolvedGetValue());
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// Drive the countdown alongside the listening state.
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (rec.status === 'listening') {
|
|
208
|
+
startCountdown(maxSeconds);
|
|
209
|
+
}
|
|
210
|
+
}, [rec.status, maxSeconds, startCountdown]);
|
|
211
|
+
|
|
212
|
+
// Hotkeys while listening:
|
|
213
|
+
// Esc — cancel dictation (and stop event propagation so the
|
|
214
|
+
// outer chat doesn't *also* close — same convention as
|
|
215
|
+
// ChatGPT / Slack voice mode).
|
|
216
|
+
// Enter — finish dictation, KEEP what we already pushed into the
|
|
217
|
+
// composer, do NOT submit the chat (avoids accidental
|
|
218
|
+
// sends while the user is still talking). We block the
|
|
219
|
+
// Enter that would otherwise reach the composer textarea
|
|
220
|
+
// by listening in the capture phase.
|
|
221
|
+
const listening = rec.status === 'listening' || rec.status === 'starting';
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
if (!listening) return undefined;
|
|
224
|
+
const onKey = (e: KeyboardEvent): void => {
|
|
225
|
+
if (e.key === 'Escape') {
|
|
226
|
+
e.preventDefault();
|
|
227
|
+
e.stopPropagation();
|
|
228
|
+
rec.abort();
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
232
|
+
// Block the chat composer's "Enter to send" while we're
|
|
233
|
+
// dictating — finish recording instead.
|
|
234
|
+
e.preventDefault();
|
|
235
|
+
e.stopPropagation();
|
|
236
|
+
void rec.stop();
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
// `capture: true` so we run before the composer textarea's
|
|
240
|
+
// keydown handler (which would otherwise close the chat on Esc
|
|
241
|
+
// or submit the form on Enter).
|
|
242
|
+
window.addEventListener('keydown', onKey, true);
|
|
243
|
+
return () => {
|
|
244
|
+
window.removeEventListener('keydown', onKey, true);
|
|
245
|
+
};
|
|
246
|
+
}, [listening, rec]);
|
|
247
|
+
|
|
248
|
+
const toggle = useCallback(() => {
|
|
249
|
+
if (rec.status === 'listening' || rec.status === 'starting') {
|
|
250
|
+
void rec.stop();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
anchorRef.current = resolvedGetValue().trim();
|
|
254
|
+
void rec.start();
|
|
255
|
+
}, [rec, resolvedGetValue]);
|
|
256
|
+
|
|
257
|
+
if (!support.supported) return null;
|
|
258
|
+
if (hideOnMobile && support.isMobile) return null;
|
|
259
|
+
|
|
260
|
+
const stopping = rec.status === 'stopping';
|
|
261
|
+
|
|
262
|
+
// Tooltip: countdown + hotkey hint while listening, plain copy
|
|
263
|
+
// otherwise. Avoids the absolutely-positioned label that clipped
|
|
264
|
+
// against the composer bottom edge in the previous design.
|
|
265
|
+
const tooltip = listening
|
|
266
|
+
? `Listening — ${countdown.label || `${maxSeconds}s left`} · Enter to finish · Esc to cancel`
|
|
267
|
+
: 'Dictate message';
|
|
268
|
+
|
|
269
|
+
return (
|
|
270
|
+
<span className="inline-flex items-center gap-1.5 !h-auto">
|
|
271
|
+
{listening && countdown.label ? (
|
|
272
|
+
<span
|
|
273
|
+
aria-hidden
|
|
274
|
+
className="rounded-full bg-destructive/10 px-1.5 py-0.5 font-mono text-[10px] leading-none text-destructive tabular-nums"
|
|
275
|
+
>
|
|
276
|
+
{countdown.label}
|
|
277
|
+
</span>
|
|
278
|
+
) : null}
|
|
279
|
+
<button
|
|
280
|
+
type="button"
|
|
281
|
+
onClick={toggle}
|
|
282
|
+
aria-pressed={listening}
|
|
283
|
+
aria-label={listening ? 'Stop dictation' : 'Dictate message'}
|
|
284
|
+
title={tooltip}
|
|
285
|
+
className={cn(
|
|
286
|
+
'relative inline-flex items-center justify-center rounded-full transition-colors',
|
|
287
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
|
288
|
+
SIZE_CLS[size],
|
|
289
|
+
listening
|
|
290
|
+
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
|
291
|
+
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
|
292
|
+
className,
|
|
293
|
+
)}
|
|
294
|
+
>
|
|
295
|
+
{listening && (
|
|
296
|
+
<span
|
|
297
|
+
aria-hidden
|
|
298
|
+
className="absolute inset-0 rounded-full bg-destructive/30 animate-ping"
|
|
299
|
+
/>
|
|
300
|
+
)}
|
|
301
|
+
{stopping ? <Loader2 className="animate-spin" /> : <Mic />}
|
|
302
|
+
</button>
|
|
303
|
+
</span>
|
|
304
|
+
);
|
|
305
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type * as React from 'react';
|
|
4
|
+
import { useEffect } from 'react';
|
|
5
|
+
|
|
6
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
7
|
+
|
|
8
|
+
import { DictationButton } from '../components/DictationButton';
|
|
9
|
+
import { ErrorBanner } from '../components/ErrorBanner';
|
|
10
|
+
import { MicMeter } from '../components/MicMeter';
|
|
11
|
+
import { TranscriptView } from '../components/TranscriptView';
|
|
12
|
+
import { useSpeechRecognition } from '../hooks/useSpeechRecognition';
|
|
13
|
+
import type { RecognitionEngine, Segment } from '../types';
|
|
14
|
+
|
|
15
|
+
export interface VoiceMessageRecorderProps {
|
|
16
|
+
/** Called once when the user stops, with the full final transcript. */
|
|
17
|
+
onSubmit: (text: string, segments: Segment[]) => void;
|
|
18
|
+
onCancel?: () => void;
|
|
19
|
+
engine?: RecognitionEngine;
|
|
20
|
+
language?: string;
|
|
21
|
+
/** Auto-stop after this many ms of silence. Default 1500. */
|
|
22
|
+
silenceMs?: number;
|
|
23
|
+
/** Hard cap on session length. Default 60000 ms. */
|
|
24
|
+
maxMs?: number;
|
|
25
|
+
className?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Press-to-record assembly for "voice memo" UIs. Records until the user
|
|
30
|
+
* presses stop or silence-detection fires, then emits the full final
|
|
31
|
+
* transcript via `onSubmit`. Pair with a chat composer or any send-button
|
|
32
|
+
* flow.
|
|
33
|
+
*/
|
|
34
|
+
export function VoiceMessageRecorder({
|
|
35
|
+
onSubmit,
|
|
36
|
+
onCancel,
|
|
37
|
+
engine,
|
|
38
|
+
language,
|
|
39
|
+
silenceMs = 1500,
|
|
40
|
+
maxMs = 60000,
|
|
41
|
+
className,
|
|
42
|
+
}: VoiceMessageRecorderProps): React.ReactElement {
|
|
43
|
+
const rec = useSpeechRecognition({
|
|
44
|
+
engine,
|
|
45
|
+
language,
|
|
46
|
+
autoStop: { silenceMs, maxMs },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Auto-submit on transition out of listening — fires once the engine
|
|
50
|
+
// finalises and closes. Reducer resets after.
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (rec.status !== 'idle') return;
|
|
53
|
+
if (!rec.transcript.final) return;
|
|
54
|
+
onSubmit(rec.transcript.final, rec.transcript.segments);
|
|
55
|
+
rec.reset();
|
|
56
|
+
// run only when status flips to idle
|
|
57
|
+
}, [rec.status, rec.transcript.final, rec.transcript.segments, onSubmit, rec.reset]);
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div
|
|
61
|
+
className={cn(
|
|
62
|
+
'flex w-full flex-col gap-3 rounded-lg border border-border bg-card p-3',
|
|
63
|
+
className,
|
|
64
|
+
)}
|
|
65
|
+
>
|
|
66
|
+
<div className="flex items-center gap-3">
|
|
67
|
+
<DictationButton
|
|
68
|
+
status={rec.status}
|
|
69
|
+
isSupported={rec.isSupported}
|
|
70
|
+
onClick={() => void rec.toggle()}
|
|
71
|
+
size="lg"
|
|
72
|
+
/>
|
|
73
|
+
<MicMeter level={rec.level} bars={14} height={28} className="flex-1" />
|
|
74
|
+
{onCancel && (
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
onClick={onCancel}
|
|
78
|
+
className="rounded-md border border-border px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
|
|
79
|
+
>
|
|
80
|
+
Cancel
|
|
81
|
+
</button>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
<TranscriptView transcript={rec.transcript} />
|
|
85
|
+
<ErrorBanner error={rec.error} />
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { DictationField } from './DictationField';
|
|
2
|
+
export type { DictationFieldProps } from './DictationField';
|
|
3
|
+
export { VoiceMessageRecorder } from './VoiceMessageRecorder';
|
|
4
|
+
export type { VoiceMessageRecorderProps } from './VoiceMessageRecorder';
|
|
5
|
+
export { VoiceComposerSlot } from './VoiceComposerSlot';
|
|
6
|
+
export type { VoiceComposerSlotProps } from './VoiceComposerSlot';
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
var chunkSI5RD2GD_cjs = require('./chunk-SI5RD2GD.cjs');
|
|
4
|
-
require('./chunk-XACCHZH2.cjs');
|
|
5
|
-
require('./chunk-OLISEQHS.cjs');
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
Object.defineProperty(exports, "ChatRoot", {
|
|
10
|
-
enumerable: true,
|
|
11
|
-
get: function () { return chunkSI5RD2GD_cjs.ChatRoot; }
|
|
12
|
-
});
|
|
13
|
-
//# sourceMappingURL=ChatRoot-EJC5Y2YM.cjs.map
|
|
14
|
-
//# sourceMappingURL=ChatRoot-EJC5Y2YM.cjs.map
|