@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
|
@@ -65,6 +65,18 @@ export interface UseChatReturn extends ChatState {
|
|
|
65
65
|
loadMore: () => Promise<void>;
|
|
66
66
|
newSession: () => Promise<void>;
|
|
67
67
|
lastError: Error | null;
|
|
68
|
+
/**
|
|
69
|
+
* Inject a complete message from outside (push notification, admin
|
|
70
|
+
* takeover, system notice). De-duped by id. Position defaults to
|
|
71
|
+
* `append` — pass `prepend` for retroactive inserts.
|
|
72
|
+
*/
|
|
73
|
+
injectMessage: (message: ChatMessage, position?: 'append' | 'prepend') => void;
|
|
74
|
+
/**
|
|
75
|
+
* Patch fields of an existing message in place (e.g. live-edit the
|
|
76
|
+
* admin's last reply, mark a message as resolved). No-op if the id
|
|
77
|
+
* doesn't exist.
|
|
78
|
+
*/
|
|
79
|
+
updateMessage: (id: string, patch: Partial<ChatMessage>) => void;
|
|
68
80
|
}
|
|
69
81
|
|
|
70
82
|
export function useChat(config: UseChatConfig): UseChatReturn {
|
|
@@ -519,6 +531,20 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
519
531
|
dispatch({ type: 'MESSAGE_DELETE', id });
|
|
520
532
|
}, []);
|
|
521
533
|
|
|
534
|
+
const injectMessage = useCallback(
|
|
535
|
+
(message: ChatMessage, position?: 'append' | 'prepend') => {
|
|
536
|
+
dispatch({ type: 'MESSAGE_INJECT', message, position });
|
|
537
|
+
},
|
|
538
|
+
[],
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
const updateMessage = useCallback(
|
|
542
|
+
(id: string, patch: Partial<ChatMessage>) => {
|
|
543
|
+
dispatch({ type: 'MESSAGE_PATCH', id, patch });
|
|
544
|
+
},
|
|
545
|
+
[],
|
|
546
|
+
);
|
|
547
|
+
|
|
522
548
|
const clearMessages = useCallback(() => {
|
|
523
549
|
abortRef.current?.abort();
|
|
524
550
|
dispatch({ type: 'MESSAGES_CLEAR' });
|
|
@@ -591,6 +617,8 @@ export function useChat(config: UseChatConfig): UseChatReturn {
|
|
|
591
617
|
loadMore,
|
|
592
618
|
newSession,
|
|
593
619
|
lastError: lastErrorRef.current,
|
|
620
|
+
injectMessage,
|
|
621
|
+
updateMessage,
|
|
594
622
|
};
|
|
595
623
|
}
|
|
596
624
|
|
|
@@ -1,191 +1,70 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { useNotificationSounds } from '@djangocfg/ui-core/hooks';
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import { useChatAudioPrefs } from '../core/audio/preferences';
|
|
5
|
+
import { DEFAULT_CHAT_SOUNDS } from '../core/audio/defaults';
|
|
7
6
|
import type {
|
|
8
7
|
ChatAudioConfig,
|
|
9
8
|
ChatAudioEvent,
|
|
10
9
|
UseChatAudioReturn,
|
|
11
10
|
} from '../core/audio/types';
|
|
12
11
|
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
12
|
+
const STORAGE_KEY = 'djangocfg-chat-audio:prefs';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Slack / Linear / Intercom-style per-event volume scale. Applied on top
|
|
16
|
+
* of the master volume so error / status sounds don't startle the user
|
|
17
|
+
* sitting next to a busy chat. Override per-event via
|
|
18
|
+
* `config.eventVolumes`, or pass `eventVolumes: {}` to disable defaults.
|
|
19
|
+
*/
|
|
20
|
+
const DEFAULT_EVENT_VOLUMES: Partial<Record<ChatAudioEvent, number>> = {
|
|
21
|
+
error: 0.25,
|
|
22
|
+
mention: 1,
|
|
23
|
+
messageSent: 0.5,
|
|
24
|
+
messageReceived: 0.7,
|
|
25
|
+
streamStart: 0.3,
|
|
26
|
+
notification: 0.9,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Chat-specific audio facade. Thin wrapper over `useNotificationSounds`
|
|
31
|
+
* from `@djangocfg/ui-core` — keeps the `ChatAudioEvent` typing while
|
|
32
|
+
* the underlying bus / prefs / Safari-unlock logic lives in ui-core.
|
|
33
|
+
*/
|
|
37
34
|
export function useChatAudio(config: ChatAudioConfig = {}): UseChatAudioReturn {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const mqData = window.matchMedia('(prefers-reduced-data: reduce)');
|
|
74
|
-
const onMotion = () => {
|
|
75
|
-
reducedMotionRef.current = mqMotion.matches;
|
|
76
|
-
};
|
|
77
|
-
const onData = () => {
|
|
78
|
-
reducedDataRef.current = mqData.matches;
|
|
79
|
-
};
|
|
80
|
-
mqMotion.addEventListener('change', onMotion);
|
|
81
|
-
mqData.addEventListener('change', onData);
|
|
82
|
-
return () => {
|
|
83
|
-
mqMotion.removeEventListener('change', onMotion);
|
|
84
|
-
mqData.removeEventListener('change', onData);
|
|
85
|
-
};
|
|
86
|
-
}, []);
|
|
87
|
-
|
|
88
|
-
// Visibility tracking — mute while tab is hidden.
|
|
89
|
-
useEffect(() => {
|
|
90
|
-
if (!muteWhenHidden || typeof document === 'undefined') return;
|
|
91
|
-
const onVis = () => {
|
|
92
|
-
hiddenRef.current = document.visibilityState === 'hidden';
|
|
93
|
-
};
|
|
94
|
-
document.addEventListener('visibilitychange', onVis);
|
|
95
|
-
return () => document.removeEventListener('visibilitychange', onVis);
|
|
96
|
-
}, [muteWhenHidden]);
|
|
97
|
-
|
|
98
|
-
// Bus instance — created once per provider, sounds map hot-swapped on change.
|
|
99
|
-
const busRef = useRef<ChatAudioBus | null>(null);
|
|
100
|
-
if (busRef.current === null) {
|
|
101
|
-
busRef.current = createAudioBus({
|
|
102
|
-
sounds,
|
|
103
|
-
getVolume: () => volumeRef.current,
|
|
104
|
-
getMuted: () => effectiveMuted(),
|
|
105
|
-
isEnabled: (event) => isEnabledImpl(event),
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function effectiveMuted(): boolean {
|
|
110
|
-
if (mutedRef.current) return true;
|
|
111
|
-
if (muteWhenHidden && hiddenRef.current) return true;
|
|
112
|
-
if (respectReducedMotion && reducedMotionRef.current) return true;
|
|
113
|
-
if (respectReducedData && reducedDataRef.current) return true;
|
|
114
|
-
return false;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function isEnabledImpl(event: ChatAudioEvent): boolean {
|
|
118
|
-
if (shouldPlayRef.current && shouldPlayRef.current(event) === false) return false;
|
|
119
|
-
const flag = enabledRef.current[event];
|
|
120
|
-
if (flag === false) return false;
|
|
121
|
-
return true;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Hot-swap sounds when caller-provided map changes.
|
|
125
|
-
useEffect(() => {
|
|
126
|
-
busRef.current?.setSounds(sounds);
|
|
127
|
-
}, [sounds]);
|
|
128
|
-
|
|
129
|
-
// Preload all configured events once.
|
|
130
|
-
useEffect(() => {
|
|
131
|
-
const bus = busRef.current;
|
|
132
|
-
if (!bus) return;
|
|
133
|
-
for (const event of ALL_EVENTS) {
|
|
134
|
-
bus.preload(event);
|
|
135
|
-
}
|
|
136
|
-
}, [sounds]);
|
|
137
|
-
|
|
138
|
-
// Dispose on unmount.
|
|
139
|
-
useEffect(() => {
|
|
140
|
-
const bus = busRef.current;
|
|
141
|
-
return () => {
|
|
142
|
-
bus?.dispose();
|
|
143
|
-
};
|
|
144
|
-
}, []);
|
|
145
|
-
|
|
146
|
-
// Reactive `isUnlocked`.
|
|
147
|
-
const isUnlocked = useSyncExternalStore(
|
|
148
|
-
useCallback((cb) => busRef.current?.subscribeUnlock(cb) ?? (() => undefined), []),
|
|
149
|
-
() => busRef.current?.isUnlocked() ?? false,
|
|
150
|
-
() => false,
|
|
151
|
-
);
|
|
152
|
-
|
|
153
|
-
const play = useCallback((event: ChatAudioEvent) => {
|
|
154
|
-
busRef.current?.play(event);
|
|
155
|
-
}, []);
|
|
156
|
-
const preload = useCallback((event: ChatAudioEvent) => {
|
|
157
|
-
busRef.current?.preload(event);
|
|
158
|
-
}, []);
|
|
159
|
-
const unlock = useCallback(() => {
|
|
160
|
-
busRef.current?.unlock();
|
|
161
|
-
}, []);
|
|
162
|
-
|
|
163
|
-
const isEventEnabled = useCallback(
|
|
164
|
-
(event: ChatAudioEvent) => isEnabledImpl(event),
|
|
165
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
166
|
-
[],
|
|
167
|
-
);
|
|
168
|
-
|
|
169
|
-
const api = useMemo<UseChatAudioReturn>(
|
|
170
|
-
() => ({
|
|
171
|
-
play,
|
|
172
|
-
preload,
|
|
173
|
-
unlock,
|
|
174
|
-
isUnlocked,
|
|
175
|
-
muted,
|
|
176
|
-
setMuted: (m: boolean) => setMutedPref(m),
|
|
177
|
-
volume,
|
|
178
|
-
setVolume: (v: number) => setVolumePref(v),
|
|
179
|
-
isEventEnabled,
|
|
180
|
-
setEventEnabled: (event, enabled) => setEventEnabledPref(event, enabled),
|
|
181
|
-
}),
|
|
182
|
-
[play, preload, unlock, isUnlocked, muted, volume, isEventEnabled, setMutedPref, setVolumePref, setEventEnabledPref],
|
|
183
|
-
);
|
|
184
|
-
|
|
185
|
-
// We need a useState here just to register a re-render trigger when the
|
|
186
|
-
// bus reports unlock changes — but we already wired `useSyncExternalStore`
|
|
187
|
-
// above, so this is a no-op holder.
|
|
188
|
-
void useState;
|
|
189
|
-
|
|
190
|
-
return api;
|
|
35
|
+
// Auto-fallback to built-in sounds when:
|
|
36
|
+
// - host hasn't passed a `sounds` map (most apps)
|
|
37
|
+
// - host hasn't opted into `silenced` mode (native hosts skip this)
|
|
38
|
+
// Pass `sounds: {}` explicitly to disable both defaults and embedded.
|
|
39
|
+
const resolvedSounds =
|
|
40
|
+
config.sounds === undefined && !config.silenced ? DEFAULT_CHAT_SOUNDS : config.sounds;
|
|
41
|
+
|
|
42
|
+
const api = useNotificationSounds<ChatAudioEvent>({
|
|
43
|
+
storageKey: STORAGE_KEY,
|
|
44
|
+
sounds: resolvedSounds,
|
|
45
|
+
volume: config.volume,
|
|
46
|
+
eventVolumes: config.eventVolumes ?? DEFAULT_EVENT_VOLUMES,
|
|
47
|
+
muted: config.muted,
|
|
48
|
+
shouldPlay: config.shouldPlay,
|
|
49
|
+
respectReducedMotion: config.respectReducedMotion,
|
|
50
|
+
respectReducedData: config.respectReducedData,
|
|
51
|
+
muteWhenHidden: config.muteWhenHidden,
|
|
52
|
+
silenced: config.silenced,
|
|
53
|
+
onSoundEvent: config.onSoundEvent,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
play: api.play,
|
|
58
|
+
preload: api.preload,
|
|
59
|
+
unlock: api.unlock,
|
|
60
|
+
isUnlocked: api.isUnlocked,
|
|
61
|
+
muted: api.muted,
|
|
62
|
+
setMuted: api.setMuted,
|
|
63
|
+
toggleMute: api.toggleMute,
|
|
64
|
+
volume: api.volume,
|
|
65
|
+
setVolume: api.setVolume,
|
|
66
|
+
isEventEnabled: api.isEventEnabled,
|
|
67
|
+
setEventEnabled: api.setEventEnabled,
|
|
68
|
+
isSilent: api.isSilent,
|
|
69
|
+
};
|
|
191
70
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback } from 'react';
|
|
4
|
+
|
|
5
|
+
import { useLocalStorage } from '@djangocfg/ui-core/hooks';
|
|
6
|
+
|
|
7
|
+
import type { ChatDockMode, ChatDockSide } from '../launcher/ChatDock';
|
|
8
|
+
|
|
9
|
+
export interface ChatDockPrefs {
|
|
10
|
+
/** Popover (FAB-style) or side-docked panel. */
|
|
11
|
+
mode: ChatDockMode;
|
|
12
|
+
/** Which edge the side dock attaches to. */
|
|
13
|
+
side: ChatDockSide;
|
|
14
|
+
/** Width in px when side-docked (resizable in the future). */
|
|
15
|
+
sideWidth: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_DOCK_PREFS: ChatDockPrefs = {
|
|
19
|
+
mode: 'popover',
|
|
20
|
+
side: 'right',
|
|
21
|
+
sideWidth: 420,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const DEFAULT_KEY = 'chat.dock.prefs';
|
|
25
|
+
|
|
26
|
+
export interface UseChatDockPrefsOptions {
|
|
27
|
+
/** localStorage key. @default 'chat.dock.prefs' */
|
|
28
|
+
storageKey?: string;
|
|
29
|
+
/** Override the baseline defaults (per-product branding, etc.). */
|
|
30
|
+
defaults?: Partial<ChatDockPrefs>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface UseChatDockPrefsReturn extends ChatDockPrefs {
|
|
34
|
+
/** Merge-update — pass only the fields that changed. */
|
|
35
|
+
setPrefs: (patch: Partial<ChatDockPrefs>) => void;
|
|
36
|
+
/** Convenience toggle: popover ⇆ side. */
|
|
37
|
+
toggleMode: () => void;
|
|
38
|
+
/** Convenience toggle: right ⇆ left (only affects side mode). */
|
|
39
|
+
toggleSide: () => void;
|
|
40
|
+
/** Reset to defaults. */
|
|
41
|
+
reset: () => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Persistent dock preferences (mode / side / width) via ui-core localStorage.
|
|
46
|
+
*
|
|
47
|
+
* SSR-safe: returns defaults on the server, hydrates on mount.
|
|
48
|
+
* Survives reloads — power users keep their preferred chat layout.
|
|
49
|
+
*/
|
|
50
|
+
export function useChatDockPrefs(opts: UseChatDockPrefsOptions = {}): UseChatDockPrefsReturn {
|
|
51
|
+
const key = opts.storageKey ?? DEFAULT_KEY;
|
|
52
|
+
const initial: ChatDockPrefs = { ...DEFAULT_DOCK_PREFS, ...opts.defaults };
|
|
53
|
+
|
|
54
|
+
const [prefs, setStored] = useLocalStorage<ChatDockPrefs>(key, initial);
|
|
55
|
+
|
|
56
|
+
const setPrefs = useCallback(
|
|
57
|
+
(patch: Partial<ChatDockPrefs>) => {
|
|
58
|
+
setStored((prev) => ({ ...prev, ...patch }));
|
|
59
|
+
},
|
|
60
|
+
[setStored],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const toggleMode = useCallback(() => {
|
|
64
|
+
setStored((prev) => ({ ...prev, mode: prev.mode === 'side' ? 'popover' : 'side' }));
|
|
65
|
+
}, [setStored]);
|
|
66
|
+
|
|
67
|
+
const toggleSide = useCallback(() => {
|
|
68
|
+
setStored((prev) => ({ ...prev, side: prev.side === 'right' ? 'left' : 'right' }));
|
|
69
|
+
}, [setStored]);
|
|
70
|
+
|
|
71
|
+
const reset = useCallback(() => setStored(initial), [setStored, initial]);
|
|
72
|
+
|
|
73
|
+
return { ...prefs, setPrefs, toggleMode, toggleSide, reset };
|
|
74
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
export interface UseChatResetOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Backend call that performs the actual reset (e.g. POST /chat/reset).
|
|
8
|
+
* Should resolve to `true` on success, `false` on failure.
|
|
9
|
+
* Throwing also counts as failure — caught and logged.
|
|
10
|
+
*/
|
|
11
|
+
onReset: () => Promise<boolean>;
|
|
12
|
+
/**
|
|
13
|
+
* Called after a successful reset (status === true). Use to clear the
|
|
14
|
+
* local message list, navigate, or fire analytics.
|
|
15
|
+
*/
|
|
16
|
+
onSuccess?: () => void;
|
|
17
|
+
/**
|
|
18
|
+
* Called when reset fails (returned `false` or threw). Defaults to no-op
|
|
19
|
+
* — wire to a toast/banner if you want to surface it.
|
|
20
|
+
*/
|
|
21
|
+
onError?: (error?: unknown) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface UseChatResetReturn {
|
|
25
|
+
/** Trigger the reset. Safe to call multiple times — re-entrant guard. */
|
|
26
|
+
reset: () => Promise<boolean>;
|
|
27
|
+
/** True while the reset is in flight. */
|
|
28
|
+
isResetting: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Generic "clear chat context" hook.
|
|
33
|
+
*
|
|
34
|
+
* Stays generic — the backend call lives in the host (it knows the URL,
|
|
35
|
+
* auth, project slug). Provides the in-flight state and success/error
|
|
36
|
+
* callbacks every consumer wires up the same way.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```tsx
|
|
40
|
+
* const { reset, isResetting } = useChatReset({
|
|
41
|
+
* onReset: async () => {
|
|
42
|
+
* const res = await fetch('/api/chat/reset', { method: 'POST', credentials: 'include' });
|
|
43
|
+
* return res.ok;
|
|
44
|
+
* },
|
|
45
|
+
* onSuccess: () => chat.clearMessages(),
|
|
46
|
+
* });
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function useChatReset(opts: UseChatResetOptions): UseChatResetReturn {
|
|
50
|
+
const { onReset, onSuccess, onError } = opts;
|
|
51
|
+
const [isResetting, setIsResetting] = useState(false);
|
|
52
|
+
|
|
53
|
+
const reset = useCallback(async (): Promise<boolean> => {
|
|
54
|
+
if (isResetting) return false;
|
|
55
|
+
setIsResetting(true);
|
|
56
|
+
try {
|
|
57
|
+
const ok = await onReset();
|
|
58
|
+
if (ok) onSuccess?.();
|
|
59
|
+
else onError?.();
|
|
60
|
+
return ok;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
onError?.(err);
|
|
63
|
+
return false;
|
|
64
|
+
} finally {
|
|
65
|
+
setIsResetting(false);
|
|
66
|
+
}
|
|
67
|
+
}, [isResetting, onReset, onSuccess, onError]);
|
|
68
|
+
|
|
69
|
+
return { reset, isResetting };
|
|
70
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import { useChatContext } from '../context';
|
|
6
|
+
import type { ChatMessage } from '../types';
|
|
7
|
+
|
|
8
|
+
export interface UseChatUnreadOptions {
|
|
9
|
+
/**
|
|
10
|
+
* When true, unread state is auto-cleared (treated as "user is reading
|
|
11
|
+
* the chat right now"). Pass your dock-open boolean here so the badge
|
|
12
|
+
* resets the moment the user opens the chat.
|
|
13
|
+
*/
|
|
14
|
+
open?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Which message roles count as "unread". Defaults to `['assistant']` —
|
|
17
|
+
* we only count inbound replies, not the user's own messages.
|
|
18
|
+
*/
|
|
19
|
+
countRoles?: Array<ChatMessage['role']>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface UseChatUnreadReturn {
|
|
23
|
+
/** Most-recent inbound message since the last mark-as-read. */
|
|
24
|
+
unread: ChatMessage | null;
|
|
25
|
+
/** Total inbound messages since the last mark-as-read. */
|
|
26
|
+
count: number;
|
|
27
|
+
/** Manually clear the unread state. */
|
|
28
|
+
markRead: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Track inbound chat messages while the user isn't watching.
|
|
33
|
+
*
|
|
34
|
+
* Must be called **inside** the chat's `<ChatProvider>` (i.e. inside the
|
|
35
|
+
* `children` of `ChatLauncher`, alongside `ChatRoot`).
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```tsx
|
|
39
|
+
* function ChatRootWithUnreadBadge({ open, onUnread }: { open: boolean; onUnread: (m: ChatMessage | null) => void }) {
|
|
40
|
+
* const { unread, count, markRead } = useChatUnread({ open });
|
|
41
|
+
* useEffect(() => onUnread(unread), [unread, onUnread]);
|
|
42
|
+
* // pass `count` to your FAB badge via the host's state
|
|
43
|
+
* return <ChatRoot transport={transport} />;
|
|
44
|
+
* }
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* For end-to-end wiring with `<ChatLauncher unreadMessage onMarkRead>`,
|
|
48
|
+
* see the `Launcher / WithLivePush` story.
|
|
49
|
+
*/
|
|
50
|
+
export function useChatUnread(opts: UseChatUnreadOptions = {}): UseChatUnreadReturn {
|
|
51
|
+
const { open = false, countRoles = ['assistant'] } = opts;
|
|
52
|
+
const ctx = useChatContext();
|
|
53
|
+
|
|
54
|
+
const [lastSeenId, setLastSeenId] = useState<string | null>(null);
|
|
55
|
+
// On first mount, treat the current tail as already seen so old history
|
|
56
|
+
// doesn't immediately register as unread.
|
|
57
|
+
const initialized = useRef(false);
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (initialized.current) return;
|
|
60
|
+
initialized.current = true;
|
|
61
|
+
const tail = ctx.messages[ctx.messages.length - 1];
|
|
62
|
+
setLastSeenId(tail?.id ?? null);
|
|
63
|
+
}, [ctx.messages]);
|
|
64
|
+
|
|
65
|
+
// While the dock is open, auto-advance the seen pointer to the tail so
|
|
66
|
+
// count stays at 0.
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!open) return;
|
|
69
|
+
const tail = ctx.messages[ctx.messages.length - 1];
|
|
70
|
+
setLastSeenId(tail?.id ?? null);
|
|
71
|
+
}, [open, ctx.messages]);
|
|
72
|
+
|
|
73
|
+
// Compute unread tail since `lastSeenId`.
|
|
74
|
+
const seenIdx = lastSeenId
|
|
75
|
+
? ctx.messages.findIndex((m) => m.id === lastSeenId)
|
|
76
|
+
: -1;
|
|
77
|
+
const after = seenIdx === -1 ? ctx.messages : ctx.messages.slice(seenIdx + 1);
|
|
78
|
+
const inbound = after.filter((m) => countRoles.includes(m.role));
|
|
79
|
+
const unread = inbound.length > 0 ? inbound[inbound.length - 1]! : null;
|
|
80
|
+
|
|
81
|
+
const markRead = useCallback(() => {
|
|
82
|
+
const tail = ctx.messages[ctx.messages.length - 1];
|
|
83
|
+
setLastSeenId(tail?.id ?? null);
|
|
84
|
+
}, [ctx.messages]);
|
|
85
|
+
|
|
86
|
+
return { unread, count: inbound.length, markRead };
|
|
87
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef } from 'react';
|
|
4
|
+
import type { MouseEvent as ReactMouseEvent, RefObject } from 'react';
|
|
5
|
+
|
|
6
|
+
import { useChatContextOptional, type ComposerHandle } from '../context';
|
|
7
|
+
import type { Focusable } from './useAutoFocusOnStreamEnd';
|
|
8
|
+
|
|
9
|
+
export interface UseFocusOnEmptyClickOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Custom focus target. Defaults to the composer registered in the chat
|
|
12
|
+
* context.
|
|
13
|
+
*/
|
|
14
|
+
targetRef?: RefObject<Focusable | HTMLElement | null>;
|
|
15
|
+
/** Opt-out without unmounting the hook. @default true */
|
|
16
|
+
enabled?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Don't focus if currently streaming (user is reading the reply).
|
|
19
|
+
* @default true
|
|
20
|
+
*/
|
|
21
|
+
skipWhileStreaming?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* "Click anywhere in the chat → focus the composer" — Slack / Linear /
|
|
26
|
+
* ChatGPT behaviour.
|
|
27
|
+
*
|
|
28
|
+
* Returns a single `onMouseUp` handler to attach to the scrollable
|
|
29
|
+
* messages container. Heuristics that mirror the popular chat apps:
|
|
30
|
+
*
|
|
31
|
+
* 1. Ignore clicks on interactive elements (`button`, `a`, `input`,
|
|
32
|
+
* `textarea`, `[role="button"]`, `[contenteditable]`, …) — they
|
|
33
|
+
* have their own behaviour.
|
|
34
|
+
* 2. Ignore clicks that produced a text selection (drag-to-select).
|
|
35
|
+
* Stealing focus would break copy-paste flow.
|
|
36
|
+
* 3. Ignore touch input — on mobile the system focuses the textarea
|
|
37
|
+
* via tap on the composer itself; touching messages should never
|
|
38
|
+
* open the keyboard.
|
|
39
|
+
* 4. Optionally skip while the assistant is streaming so the user
|
|
40
|
+
* can keep reading without the keyboard popping up.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```tsx
|
|
44
|
+
* const onMouseUp = useFocusOnEmptyClick();
|
|
45
|
+
* <div ref={scrollRef} onMouseUp={onMouseUp}>{messages}</div>
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function useFocusOnEmptyClick(
|
|
49
|
+
options: UseFocusOnEmptyClickOptions = {},
|
|
50
|
+
): (event: ReactMouseEvent<HTMLElement>) => void {
|
|
51
|
+
const { targetRef, enabled = true, skipWhileStreaming = true } = options;
|
|
52
|
+
const ctx = useChatContextOptional();
|
|
53
|
+
|
|
54
|
+
const composerHandleRef = useRef<ComposerHandle | null>(null);
|
|
55
|
+
composerHandleRef.current = ctx?.composer ?? null;
|
|
56
|
+
|
|
57
|
+
const isStreamingRef = useRef(false);
|
|
58
|
+
isStreamingRef.current = ctx?.isStreaming ?? false;
|
|
59
|
+
|
|
60
|
+
return useCallback(
|
|
61
|
+
(event: ReactMouseEvent<HTMLElement>) => {
|
|
62
|
+
if (!enabled) return;
|
|
63
|
+
if (skipWhileStreaming && isStreamingRef.current) return;
|
|
64
|
+
|
|
65
|
+
// Touch / pen → never steal focus (mobile keyboard pop is hostile).
|
|
66
|
+
const pointerType = (event.nativeEvent as PointerEvent).pointerType;
|
|
67
|
+
if (pointerType && pointerType !== 'mouse') return;
|
|
68
|
+
|
|
69
|
+
// Drag-selected text → don't steal focus.
|
|
70
|
+
const selection = typeof window !== 'undefined' ? window.getSelection?.() : null;
|
|
71
|
+
if (selection && !selection.isCollapsed && selection.toString().length > 0) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Click landed on something interactive → it handles itself.
|
|
76
|
+
const target = event.target as HTMLElement | null;
|
|
77
|
+
if (!target) return;
|
|
78
|
+
if (isInteractive(target)) return;
|
|
79
|
+
|
|
80
|
+
// Refocus.
|
|
81
|
+
const explicit = targetRef?.current as Focusable | HTMLElement | null | undefined;
|
|
82
|
+
if (explicit) {
|
|
83
|
+
explicit.focus?.();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
composerHandleRef.current?.focus?.();
|
|
87
|
+
},
|
|
88
|
+
[enabled, skipWhileStreaming, targetRef],
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const INTERACTIVE_SELECTORS = [
|
|
93
|
+
'a[href]',
|
|
94
|
+
'button',
|
|
95
|
+
'input',
|
|
96
|
+
'textarea',
|
|
97
|
+
'select',
|
|
98
|
+
'label',
|
|
99
|
+
'summary',
|
|
100
|
+
'[role="button"]',
|
|
101
|
+
'[role="link"]',
|
|
102
|
+
'[role="menuitem"]',
|
|
103
|
+
'[role="tab"]',
|
|
104
|
+
'[contenteditable]:not([contenteditable="false"])',
|
|
105
|
+
'[data-no-autofocus]',
|
|
106
|
+
].join(',');
|
|
107
|
+
|
|
108
|
+
function isInteractive(el: HTMLElement | null): boolean {
|
|
109
|
+
if (!el) return false;
|
|
110
|
+
return !!el.closest(INTERACTIVE_SELECTORS);
|
|
111
|
+
}
|