@djangocfg/ui-tools 2.1.334 → 2.1.336

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/README.md +68 -2
  2. package/dist/ChatRoot-IIYQEWUU.mjs +5 -0
  3. package/dist/ChatRoot-IIYQEWUU.mjs.map +1 -0
  4. package/dist/ChatRoot-PNNGQCYF.css +7 -0
  5. package/dist/ChatRoot-PNNGQCYF.css.map +1 -0
  6. package/dist/ChatRoot-UUKTYM4N.cjs +14 -0
  7. package/dist/ChatRoot-UUKTYM4N.cjs.map +1 -0
  8. package/dist/{CronScheduler.client-3O3VU4CI.mjs → CronScheduler.client-DLMXCPAJ.mjs} +4 -4
  9. package/dist/{CronScheduler.client-3O3VU4CI.mjs.map → CronScheduler.client-DLMXCPAJ.mjs.map} +1 -1
  10. package/dist/{CronScheduler.client-A4GO6YBY.cjs → CronScheduler.client-WEJF4PWQ.cjs} +14 -14
  11. package/dist/{CronScheduler.client-A4GO6YBY.cjs.map → CronScheduler.client-WEJF4PWQ.cjs.map} +1 -1
  12. package/dist/{DocsLayout-XLDB6CJ2.cjs → DocsLayout-N5ZJZPBY.cjs} +200 -199
  13. package/dist/DocsLayout-N5ZJZPBY.cjs.map +1 -0
  14. package/dist/{DocsLayout-CTJINVBM.mjs → DocsLayout-VFPPNKSQ.mjs} +7 -6
  15. package/dist/DocsLayout-VFPPNKSQ.mjs.map +1 -0
  16. package/dist/JsonSchemaForm-DD7CLRIG.cjs +13 -0
  17. package/dist/{JsonSchemaForm-OSPUUUHM.cjs.map → JsonSchemaForm-DD7CLRIG.cjs.map} +1 -1
  18. package/dist/JsonSchemaForm-XKUIVELK.mjs +4 -0
  19. package/dist/{JsonSchemaForm-TSLX2GRO.mjs.map → JsonSchemaForm-XKUIVELK.mjs.map} +1 -1
  20. package/dist/JsonTree-55625VVH.mjs +5 -0
  21. package/dist/{JsonTree-F27RMYSI.cjs.map → JsonTree-55625VVH.mjs.map} +1 -1
  22. package/dist/JsonTree-DCM5QGWF.cjs +11 -0
  23. package/dist/{JsonTree-QTJYSHCV.mjs.map → JsonTree-DCM5QGWF.cjs.map} +1 -1
  24. package/dist/{LottiePlayer.client-6WVWDO75.cjs → LottiePlayer.client-2S7ISJ2S.cjs} +6 -6
  25. package/dist/{LottiePlayer.client-6WVWDO75.cjs.map → LottiePlayer.client-2S7ISJ2S.cjs.map} +1 -1
  26. package/dist/{LottiePlayer.client-B4I6WNZM.mjs → LottiePlayer.client-5LDSSJWS.mjs} +4 -4
  27. package/dist/{LottiePlayer.client-B4I6WNZM.mjs.map → LottiePlayer.client-5LDSSJWS.mjs.map} +1 -1
  28. package/dist/{MapContainer-RYG4HPH4.cjs → MapContainer-76YL2JXL.cjs} +8 -8
  29. package/dist/{MapContainer-RYG4HPH4.cjs.map → MapContainer-76YL2JXL.cjs.map} +1 -1
  30. package/dist/{MapContainer-GXQLP5WY.mjs → MapContainer-7HXBI3OH.mjs} +3 -3
  31. package/dist/{MapContainer-GXQLP5WY.mjs.map → MapContainer-7HXBI3OH.mjs.map} +1 -1
  32. package/dist/{Mermaid.client-SXRRI2YW.mjs → Mermaid.client-NL4SVR7F.mjs} +4 -4
  33. package/dist/{Mermaid.client-SXRRI2YW.mjs.map → Mermaid.client-NL4SVR7F.mjs.map} +1 -1
  34. package/dist/{Mermaid.client-W76R5AKJ.cjs → Mermaid.client-NNTI6DFX.cjs} +26 -26
  35. package/dist/{Mermaid.client-W76R5AKJ.cjs.map → Mermaid.client-NNTI6DFX.cjs.map} +1 -1
  36. package/dist/Player-BRV7XTWR.mjs +4 -0
  37. package/dist/{Player-M3GC3VPE.mjs.map → Player-BRV7XTWR.mjs.map} +1 -1
  38. package/dist/Player-PM7F7DD7.cjs +13 -0
  39. package/dist/{Player-ZL2X5LGG.cjs.map → Player-PM7F7DD7.cjs.map} +1 -1
  40. package/dist/{PrettyCode.client-RPDIE5CH.cjs → PrettyCode.client-KOHDVPPN.cjs} +13 -13
  41. package/dist/{PrettyCode.client-RPDIE5CH.cjs.map → PrettyCode.client-KOHDVPPN.cjs.map} +1 -1
  42. package/dist/{PrettyCode.client-SPMTQEG4.mjs → PrettyCode.client-ZGYGKE7G.mjs} +4 -4
  43. package/dist/{PrettyCode.client-SPMTQEG4.mjs.map → PrettyCode.client-ZGYGKE7G.mjs.map} +1 -1
  44. package/dist/TreeRoot-N72OYKXU.cjs +19 -0
  45. package/dist/{TreeRoot-A3J65L6F.mjs.map → TreeRoot-N72OYKXU.cjs.map} +1 -1
  46. package/dist/TreeRoot-VGAIXCUA.mjs +4 -0
  47. package/dist/{TreeRoot-DSK5JILT.cjs.map → TreeRoot-VGAIXCUA.mjs.map} +1 -1
  48. package/dist/chunk-2ZLKZ5VR.mjs +631 -0
  49. package/dist/chunk-2ZLKZ5VR.mjs.map +1 -0
  50. package/dist/{chunk-LFWQ36LJ.mjs → chunk-5G5YBFS6.mjs} +4 -4
  51. package/dist/{chunk-LFWQ36LJ.mjs.map → chunk-5G5YBFS6.mjs.map} +1 -1
  52. package/dist/{chunk-IHAY6FO6.cjs → chunk-5I5QNGUG.cjs} +17 -17
  53. package/dist/{chunk-IHAY6FO6.cjs.map → chunk-5I5QNGUG.cjs.map} +1 -1
  54. package/dist/{chunk-F2CMIIOH.cjs → chunk-76NNDZH6.cjs} +42 -42
  55. package/dist/{chunk-F2CMIIOH.cjs.map → chunk-76NNDZH6.cjs.map} +1 -1
  56. package/dist/chunk-B5AWZOHJ.cjs +649 -0
  57. package/dist/chunk-B5AWZOHJ.cjs.map +1 -0
  58. package/dist/{chunk-KR6B3LVY.mjs → chunk-B6IR5KSC.mjs} +3 -3
  59. package/dist/{chunk-KR6B3LVY.mjs.map → chunk-B6IR5KSC.mjs.map} +1 -1
  60. package/dist/{chunk-5LBDYFWH.mjs → chunk-C6GXVH5J.mjs} +3 -3
  61. package/dist/{chunk-5LBDYFWH.mjs.map → chunk-C6GXVH5J.mjs.map} +1 -1
  62. package/dist/{chunk-4IW7GZFQ.cjs → chunk-FEN5S772.cjs} +74 -48
  63. package/dist/chunk-FEN5S772.cjs.map +1 -0
  64. package/dist/{chunk-2SMCH62O.cjs → chunk-FP2RLYQZ.cjs} +11 -11
  65. package/dist/{chunk-2SMCH62O.cjs.map → chunk-FP2RLYQZ.cjs.map} +1 -1
  66. package/dist/{chunk-MOME6KYD.mjs → chunk-G5IEC7SR.mjs} +3 -3
  67. package/dist/{chunk-MOME6KYD.mjs.map → chunk-G5IEC7SR.mjs.map} +1 -1
  68. package/dist/{chunk-EXGXUK2N.mjs → chunk-GYIO7W7M.mjs} +41 -15
  69. package/dist/chunk-GYIO7W7M.mjs.map +1 -0
  70. package/dist/{chunk-3Z3A7FHA.cjs → chunk-IEEAENLX.cjs} +48 -48
  71. package/dist/{chunk-3Z3A7FHA.cjs.map → chunk-IEEAENLX.cjs.map} +1 -1
  72. package/dist/{chunk-DFTVB66S.cjs → chunk-KNDLV4PI.cjs} +85 -85
  73. package/dist/{chunk-DFTVB66S.cjs.map → chunk-KNDLV4PI.cjs.map} +1 -1
  74. package/dist/{chunk-SSUOENAZ.mjs → chunk-KNEQRUBA.mjs} +3 -3
  75. package/dist/{chunk-SSUOENAZ.mjs.map → chunk-KNEQRUBA.mjs.map} +1 -1
  76. package/dist/chunk-KRETIZU6.mjs +2218 -0
  77. package/dist/chunk-KRETIZU6.mjs.map +1 -0
  78. package/dist/{chunk-CGILA3WO.mjs → chunk-N2XQF2OL.mjs} +5 -3
  79. package/dist/{chunk-CGILA3WO.mjs.map → chunk-N2XQF2OL.mjs.map} +1 -1
  80. package/dist/{chunk-EUADAUBQ.mjs → chunk-N4MZYNR4.mjs} +4 -4
  81. package/dist/{chunk-EUADAUBQ.mjs.map → chunk-N4MZYNR4.mjs.map} +1 -1
  82. package/dist/chunk-NRXYYO5V.cjs +2257 -0
  83. package/dist/chunk-NRXYYO5V.cjs.map +1 -0
  84. package/dist/{chunk-GGKGH5PM.mjs → chunk-OBRSGM64.mjs} +4 -4
  85. package/dist/{chunk-GGKGH5PM.mjs.map → chunk-OBRSGM64.mjs.map} +1 -1
  86. package/dist/{chunk-6JTB2X72.mjs → chunk-ODO4GMW7.mjs} +3 -3
  87. package/dist/{chunk-6JTB2X72.mjs.map → chunk-ODO4GMW7.mjs.map} +1 -1
  88. package/dist/{chunk-WGEGR3DF.cjs → chunk-OLISEQHS.cjs} +5 -2
  89. package/dist/{chunk-WGEGR3DF.cjs.map → chunk-OLISEQHS.cjs.map} +1 -1
  90. package/dist/{chunk-PZKAH7WQ.mjs → chunk-PVAX67JG.mjs} +3 -3
  91. package/dist/{chunk-PZKAH7WQ.mjs.map → chunk-PVAX67JG.mjs.map} +1 -1
  92. package/dist/{chunk-PRPG2T2E.cjs → chunk-QJ6GTUCO.cjs} +6 -6
  93. package/dist/{chunk-PRPG2T2E.cjs.map → chunk-QJ6GTUCO.cjs.map} +1 -1
  94. package/dist/chunk-QW4RBGHN.cjs +961 -0
  95. package/dist/chunk-QW4RBGHN.cjs.map +1 -0
  96. package/dist/{chunk-33AMWFBZ.cjs → chunk-SGP7V2UW.cjs} +15 -15
  97. package/dist/{chunk-33AMWFBZ.cjs.map → chunk-SGP7V2UW.cjs.map} +1 -1
  98. package/dist/{chunk-FX2QFYWF.mjs → chunk-VWQ5WOIL.mjs} +3 -3
  99. package/dist/{chunk-FX2QFYWF.mjs.map → chunk-VWQ5WOIL.mjs.map} +1 -1
  100. package/dist/{chunk-ZLQHUZDU.cjs → chunk-YDPDTOSP.cjs} +139 -139
  101. package/dist/{chunk-ZLQHUZDU.cjs.map → chunk-YDPDTOSP.cjs.map} +1 -1
  102. package/dist/{chunk-77HQWEQ6.cjs → chunk-YW5IVWHQ.cjs} +33 -33
  103. package/dist/{chunk-77HQWEQ6.cjs.map → chunk-YW5IVWHQ.cjs.map} +1 -1
  104. package/dist/{chunk-YXBOAGIM.cjs → chunk-YXZ6GU7H.cjs} +7 -7
  105. package/dist/{chunk-YXBOAGIM.cjs.map → chunk-YXZ6GU7H.cjs.map} +1 -1
  106. package/dist/{chunk-62Y65TGK.mjs → chunk-ZUFTH5IR.mjs} +8 -631
  107. package/dist/chunk-ZUFTH5IR.mjs.map +1 -0
  108. package/dist/components-EHOGXATG.cjs +22 -0
  109. package/dist/{components-5UXYNAKR.cjs.map → components-EHOGXATG.cjs.map} +1 -1
  110. package/dist/components-MQ6DR7TX.cjs +26 -0
  111. package/dist/{components-CFXOEVPN.mjs.map → components-MQ6DR7TX.cjs.map} +1 -1
  112. package/dist/components-XRX7QGLB.mjs +5 -0
  113. package/dist/{components-WYEZL5TE.cjs.map → components-XRX7QGLB.mjs.map} +1 -1
  114. package/dist/components-YATKRWLH.mjs +5 -0
  115. package/dist/{components-ZAGG2PBO.mjs.map → components-YATKRWLH.mjs.map} +1 -1
  116. package/dist/file-icon/index.cjs +6 -6
  117. package/dist/file-icon/index.mjs +1 -1
  118. package/dist/index.cjs +735 -215
  119. package/dist/index.cjs.map +1 -1
  120. package/dist/index.d.cts +972 -39
  121. package/dist/index.d.ts +972 -39
  122. package/dist/index.mjs +387 -31
  123. package/dist/index.mjs.map +1 -1
  124. package/dist/tree/index.cjs +38 -38
  125. package/dist/tree/index.d.cts +2 -2
  126. package/dist/tree/index.d.ts +2 -2
  127. package/dist/tree/index.mjs +3 -3
  128. package/package.json +6 -6
  129. package/src/index.ts +5 -0
  130. package/src/stories/index.ts +3 -1
  131. package/src/tools/Chat/Chat.story.tsx +1006 -0
  132. package/src/tools/Chat/README.md +528 -0
  133. package/src/tools/Chat/components/Attachments.tsx +192 -0
  134. package/src/tools/Chat/components/ChatRoot.tsx +201 -0
  135. package/src/tools/Chat/components/Composer.tsx +134 -0
  136. package/src/tools/Chat/components/EmptyState.tsx +47 -0
  137. package/src/tools/Chat/components/ErrorBanner.tsx +47 -0
  138. package/src/tools/Chat/components/JumpToLatest.tsx +30 -0
  139. package/src/tools/Chat/components/MessageActions.tsx +72 -0
  140. package/src/tools/Chat/components/MessageBubble.tsx +228 -0
  141. package/src/tools/Chat/components/MessageList.tsx +82 -0
  142. package/src/tools/Chat/components/Sources.tsx +55 -0
  143. package/src/tools/Chat/components/StreamingIndicator.tsx +29 -0
  144. package/src/tools/Chat/components/ToolCalls.tsx +172 -0
  145. package/src/tools/Chat/components/index.ts +24 -0
  146. package/src/tools/Chat/config.ts +55 -0
  147. package/src/tools/Chat/context/ChatProvider.tsx +122 -0
  148. package/src/tools/Chat/context/index.ts +9 -0
  149. package/src/tools/Chat/core/audio/audioBus.ts +172 -0
  150. package/src/tools/Chat/core/audio/index.ts +8 -0
  151. package/src/tools/Chat/core/audio/preferences.ts +68 -0
  152. package/src/tools/Chat/core/audio/types.ts +49 -0
  153. package/src/tools/Chat/core/ids.ts +16 -0
  154. package/src/tools/Chat/core/index.ts +5 -0
  155. package/src/tools/Chat/core/markdown.ts +56 -0
  156. package/src/tools/Chat/core/payload-dispatch.ts +54 -0
  157. package/src/tools/Chat/core/persona.ts +35 -0
  158. package/src/tools/Chat/core/reducer.ts +335 -0
  159. package/src/tools/Chat/core/transport/http.ts +167 -0
  160. package/src/tools/Chat/core/transport/index.ts +13 -0
  161. package/src/tools/Chat/core/transport/mock.ts +134 -0
  162. package/src/tools/Chat/core/transport/sse.ts +116 -0
  163. package/src/tools/Chat/core/transport/types.ts +24 -0
  164. package/src/tools/Chat/hooks/index.ts +26 -0
  165. package/src/tools/Chat/hooks/useChat.ts +440 -0
  166. package/src/tools/Chat/hooks/useChatAudio.ts +191 -0
  167. package/src/tools/Chat/hooks/useChatComposer.ts +227 -0
  168. package/src/tools/Chat/hooks/useChatHistory.ts +59 -0
  169. package/src/tools/Chat/hooks/useChatLayout.ts +111 -0
  170. package/src/tools/Chat/hooks/useChatLightbox.ts +34 -0
  171. package/src/tools/Chat/hooks/useChatScroll.ts +132 -0
  172. package/src/tools/Chat/index.ts +158 -0
  173. package/src/tools/Chat/lazy.tsx +14 -0
  174. package/src/tools/Chat/types.ts +237 -0
  175. package/src/tools/Chat/utils/collectImageAttachments.ts +13 -0
  176. package/src/tools/JsonForm/JsonSchemaForm.tsx +32 -1
  177. package/src/tools/Map/README.md +384 -0
  178. package/dist/DocsLayout-CTJINVBM.mjs.map +0 -1
  179. package/dist/DocsLayout-XLDB6CJ2.cjs.map +0 -1
  180. package/dist/JsonSchemaForm-OSPUUUHM.cjs +0 -13
  181. package/dist/JsonSchemaForm-TSLX2GRO.mjs +0 -4
  182. package/dist/JsonTree-F27RMYSI.cjs +0 -11
  183. package/dist/JsonTree-QTJYSHCV.mjs +0 -5
  184. package/dist/Player-M3GC3VPE.mjs +0 -4
  185. package/dist/Player-ZL2X5LGG.cjs +0 -13
  186. package/dist/TreeRoot-A3J65L6F.mjs +0 -4
  187. package/dist/TreeRoot-DSK5JILT.cjs +0 -19
  188. package/dist/chunk-4IW7GZFQ.cjs.map +0 -1
  189. package/dist/chunk-62Y65TGK.mjs.map +0 -1
  190. package/dist/chunk-EXGXUK2N.mjs.map +0 -1
  191. package/dist/chunk-TKSFZHCG.cjs +0 -1597
  192. package/dist/chunk-TKSFZHCG.cjs.map +0 -1
  193. package/dist/components-5UXYNAKR.cjs +0 -22
  194. package/dist/components-CFXOEVPN.mjs +0 -5
  195. package/dist/components-WYEZL5TE.cjs +0 -26
  196. package/dist/components-ZAGG2PBO.mjs +0 -5
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Chat defaults and constants.
3
+ */
4
+
5
+ export const STORAGE_KEYS = {
6
+ mode: 'djc-chat-mode',
7
+ sidebarWidth: 'djc-chat-sidebar-width',
8
+ composerHistory: 'djc-chat-composer-history',
9
+ } as const;
10
+
11
+ export const CSS_VARS = {
12
+ reserve: '--djc-chat-reserve',
13
+ } as const;
14
+
15
+ export const DEFAULT_Z_INDEX = 9000;
16
+
17
+ export const LIMITS = {
18
+ /** Max characters per single message. */
19
+ messageMaxLength: 8000,
20
+ /** Max attachments per message. */
21
+ attachmentsMax: 10,
22
+ /** Composer history slots. */
23
+ composerHistorySize: 50,
24
+ /** Coalesce stream tokens within this window before dispatching. */
25
+ streamCoalesceMs: 16,
26
+ /** Default history page size. */
27
+ pageSize: 50,
28
+ /** Virtualize list when >= this many messages (host-controlled threshold). */
29
+ virtualizeThreshold: 50,
30
+ /** SSE idle timeout. */
31
+ sseIdleMs: 45_000,
32
+ } as const;
33
+
34
+ export const DEFAULT_SIDEBAR = {
35
+ width: 420,
36
+ min: 320,
37
+ max: 720,
38
+ } as const;
39
+
40
+ export const HOTKEYS = {
41
+ send: 'mod+enter',
42
+ cancel: 'esc',
43
+ newChat: 'mod+shift+n',
44
+ toggleOpen: 'mod+/',
45
+ focusComposer: 'mod+l',
46
+ } as const;
47
+
48
+ export const CHAT_EVENT_NAME = 'djc:chat:send';
49
+
50
+ export interface ChatEventDetail {
51
+ content: string;
52
+ sessionId?: string;
53
+ attachments?: unknown[];
54
+ metadata?: Record<string, unknown>;
55
+ }
@@ -0,0 +1,122 @@
1
+ 'use client';
2
+
3
+ import {
4
+ createContext,
5
+ type ReactNode,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useMemo,
10
+ useRef,
11
+ } from 'react';
12
+
13
+ import type { ChatConfig, ChatLabels, ChatTransport } from '../types';
14
+ import { DEFAULT_LABELS } from '../types';
15
+ import { useChat, type UseChatReturn } from '../hooks/useChat';
16
+ import { useChatLayout, type UseChatLayoutReturn } from '../hooks/useChatLayout';
17
+ import { useChatAudio } from '../hooks/useChatAudio';
18
+ import type { ChatAudioConfig, UseChatAudioReturn } from '../core/audio/types';
19
+
20
+ export interface ChatContextValue extends UseChatReturn {
21
+ layout: UseChatLayoutReturn;
22
+ config: ChatConfig;
23
+ labels: ChatLabels;
24
+ audio: UseChatAudioReturn;
25
+ }
26
+
27
+ const Ctx = createContext<ChatContextValue | null>(null);
28
+
29
+ export interface ChatProviderProps {
30
+ transport: ChatTransport;
31
+ config?: ChatConfig;
32
+ initialSessionId?: string;
33
+ autoCreateSession?: boolean;
34
+ streaming?: boolean;
35
+ /** Audio-trigger configuration. Off by default (no `sounds` map). */
36
+ audio?: ChatAudioConfig;
37
+ children?: ReactNode;
38
+ }
39
+
40
+ export function ChatProvider({
41
+ transport,
42
+ config = {},
43
+ initialSessionId,
44
+ autoCreateSession,
45
+ streaming,
46
+ audio,
47
+ children,
48
+ }: ChatProviderProps) {
49
+ const audioApi = useChatAudio(audio ?? {});
50
+
51
+ // Keep latest audio API in a ref so the chat-callback closures stay
52
+ // referentially stable (don't re-mount transport on every audio change).
53
+ const audioRef = useRef(audioApi);
54
+ audioRef.current = audioApi;
55
+
56
+ const onMessageSent = useCallback(() => audioRef.current.play('messageSent'), []);
57
+ const onMessageEnd = useCallback(() => audioRef.current.play('messageReceived'), []);
58
+ const onStreamStart = useCallback(() => audioRef.current.play('streamStart'), []);
59
+ const onError = useCallback(() => audioRef.current.play('error'), []);
60
+
61
+ const chat = useChat({
62
+ transport,
63
+ initialSessionId,
64
+ autoCreateSession,
65
+ streaming,
66
+ metadata: {
67
+ locale: config.locale ?? config.prefs?.locale,
68
+ slug: config.slug,
69
+ },
70
+ userPersona: config.user,
71
+ onMessageSent,
72
+ onMessageEnd,
73
+ onStreamStart,
74
+ onError,
75
+ });
76
+ const layout = useChatLayout({ defaultMode: 'embedded' });
77
+
78
+ // Auto-unlock audio on the first user gesture inside the provider.
79
+ const rootRef = useRef<HTMLDivElement | null>(null);
80
+ useEffect(() => {
81
+ if (audioApi.isUnlocked) return;
82
+ const root = rootRef.current;
83
+ if (!root) return;
84
+ const handler = () => {
85
+ audioApi.unlock();
86
+ };
87
+ root.addEventListener('pointerdown', handler, { once: true, capture: true });
88
+ root.addEventListener('keydown', handler, { once: true, capture: true });
89
+ return () => {
90
+ root.removeEventListener('pointerdown', handler, { capture: true });
91
+ root.removeEventListener('keydown', handler, { capture: true });
92
+ };
93
+ }, [audioApi]);
94
+
95
+ const labels = useMemo<ChatLabels>(
96
+ () => ({ ...DEFAULT_LABELS, ...(config.labels ?? {}) }),
97
+ [config.labels],
98
+ );
99
+
100
+ const value = useMemo<ChatContextValue>(
101
+ () => ({ ...chat, layout, config, labels, audio: audioApi }),
102
+ [chat, layout, config, labels, audioApi],
103
+ );
104
+
105
+ return (
106
+ <Ctx.Provider value={value}>
107
+ <div ref={rootRef} style={{ display: 'contents' }}>
108
+ {children}
109
+ </div>
110
+ </Ctx.Provider>
111
+ );
112
+ }
113
+
114
+ export function useChatContext(): ChatContextValue {
115
+ const v = useContext(Ctx);
116
+ if (!v) throw new Error('useChatContext must be used inside <ChatProvider>');
117
+ return v;
118
+ }
119
+
120
+ export function useChatContextOptional(): ChatContextValue | null {
121
+ return useContext(Ctx);
122
+ }
@@ -0,0 +1,9 @@
1
+ 'use client';
2
+
3
+ export {
4
+ ChatProvider,
5
+ useChatContext,
6
+ useChatContextOptional,
7
+ type ChatContextValue,
8
+ type ChatProviderProps,
9
+ } from './ChatProvider';
@@ -0,0 +1,172 @@
1
+ // Per-provider audio bus.
2
+ //
3
+ // Owns the actual `<audio>` elements (one per (bus × event)) plus the global
4
+ // "unlocked" flag. Pure module / class — no React. The hook layer is in
5
+ // `hooks/useChatAudio.ts`.
6
+ //
7
+ // Pitfalls this addresses (lessons from AudioPlayer/audio):
8
+ // - Safari needs a user-gesture transaction to unlock playback. We pre-allocate
9
+ // an `<audio>` per event and call `play()` on each during the unlock event.
10
+ // - Multiple `play()` calls in quick succession on the same element: we clone
11
+ // a fresh `HTMLAudioElement` from the cache for each fire so they don't
12
+ // cancel each other (cheap — same `src` reuses the HTTP cache).
13
+ // - SSR safety: ALL DOM access is gated; the bus is only constructed in
14
+ // a `'use client'` component (the provider).
15
+ // - `play()` returns a Promise; uncaught rejections show up as warnings on
16
+ // Chrome. We attach `.catch()` everywhere.
17
+ // - Module unload cleanup: `dispose()` revokes blob URLs, drops listeners,
18
+ // and clears the cache.
19
+
20
+ import type { ChatAudioEvent, ChatAudioSounds } from './types';
21
+
22
+ interface BusOptions {
23
+ sounds: ChatAudioSounds;
24
+ /** Returns the current master volume 0..1. Read on each play. */
25
+ getVolume: () => number;
26
+ /** Returns master mute. Read on each play. */
27
+ getMuted: () => boolean;
28
+ /** Per-event predicate. */
29
+ isEnabled: (event: ChatAudioEvent) => boolean;
30
+ }
31
+
32
+ export interface ChatAudioBus {
33
+ play: (event: ChatAudioEvent) => void;
34
+ preload: (event: ChatAudioEvent) => void;
35
+ unlock: () => void;
36
+ isUnlocked: () => boolean;
37
+ /** Lets the provider re-publish unlock changes to React via a listener. */
38
+ subscribeUnlock: (cb: (unlocked: boolean) => void) => () => void;
39
+ /** Hot-swap the sounds map without re-creating the bus. */
40
+ setSounds: (sounds: ChatAudioSounds) => void;
41
+ dispose: () => void;
42
+ }
43
+
44
+ // One unlock state per tab — first gesture inside ANY <ChatProvider> unlocks
45
+ // every bus. AudioPlayer follows the same "global per tab" rule for its
46
+ // AudioContext (ADR-004).
47
+ let unlocked = false;
48
+ const unlockListeners = new Set<(v: boolean) => void>();
49
+
50
+ function setUnlocked(value: boolean) {
51
+ if (unlocked === value) return;
52
+ unlocked = value;
53
+ for (const cb of unlockListeners) cb(value);
54
+ }
55
+
56
+ export function _resetUnlockForTesting(): void {
57
+ unlocked = false;
58
+ unlockListeners.clear();
59
+ }
60
+
61
+ export function createAudioBus(options: BusOptions): ChatAudioBus {
62
+ // SSR guard.
63
+ if (typeof window === 'undefined') {
64
+ return noopBus();
65
+ }
66
+
67
+ let sounds = options.sounds;
68
+ /** Cache of "template" audio elements per URL — reused across plays. */
69
+ const cache = new Map<string, HTMLAudioElement>();
70
+
71
+ const getOrCreate = (url: string): HTMLAudioElement => {
72
+ const hit = cache.get(url);
73
+ if (hit) return hit;
74
+ const el = new Audio(url);
75
+ el.preload = 'auto';
76
+ el.crossOrigin = 'anonymous';
77
+ cache.set(url, el);
78
+ return el;
79
+ };
80
+
81
+ const resolveUrl = (event: ChatAudioEvent): string | null => {
82
+ const v = sounds[event];
83
+ if (!v) return null;
84
+ return v;
85
+ };
86
+
87
+ const play = (event: ChatAudioEvent) => {
88
+ if (options.getMuted()) return;
89
+ if (!options.isEnabled(event)) return;
90
+ const url = resolveUrl(event);
91
+ if (!url) return;
92
+
93
+ // Use the cached template just for HTTP cache warming; clone so two rapid
94
+ // events don't cut each other off on the same element.
95
+ getOrCreate(url);
96
+ const fresh = new Audio(url);
97
+ fresh.preload = 'auto';
98
+ fresh.volume = options.getVolume();
99
+ // Fire-and-forget; the promise rejects when autoplay is blocked.
100
+ const p = fresh.play();
101
+ if (p && typeof p.catch === 'function') {
102
+ p.catch(() => {
103
+ // Browser blocked playback (no gesture yet) — ignore.
104
+ });
105
+ }
106
+ };
107
+
108
+ const preload = (event: ChatAudioEvent) => {
109
+ const url = resolveUrl(event);
110
+ if (!url) return;
111
+ const el = getOrCreate(url);
112
+ // Trigger a low-priority load.
113
+ try {
114
+ el.load();
115
+ } catch {
116
+ // ignore
117
+ }
118
+ };
119
+
120
+ const unlock = () => {
121
+ if (unlocked) return;
122
+ // Play (silently) every cached element in one user-gesture transaction so
123
+ // Safari/iOS lifts the autoplay block for the whole bus at once.
124
+ for (const el of cache.values()) {
125
+ const wasMuted = el.muted;
126
+ el.muted = true;
127
+ const p = el.play();
128
+ if (p && typeof p.then === 'function') {
129
+ p.then(() => {
130
+ el.pause();
131
+ el.currentTime = 0;
132
+ el.muted = wasMuted;
133
+ }).catch(() => {
134
+ el.muted = wasMuted;
135
+ });
136
+ } else {
137
+ el.pause();
138
+ el.muted = wasMuted;
139
+ }
140
+ }
141
+ setUnlocked(true);
142
+ };
143
+
144
+ return {
145
+ play,
146
+ preload,
147
+ unlock,
148
+ isUnlocked: () => unlocked,
149
+ subscribeUnlock(cb) {
150
+ unlockListeners.add(cb);
151
+ return () => unlockListeners.delete(cb);
152
+ },
153
+ setSounds(next) {
154
+ sounds = next;
155
+ },
156
+ dispose() {
157
+ cache.clear();
158
+ },
159
+ };
160
+ }
161
+
162
+ function noopBus(): ChatAudioBus {
163
+ return {
164
+ play: () => undefined,
165
+ preload: () => undefined,
166
+ unlock: () => undefined,
167
+ isUnlocked: () => false,
168
+ subscribeUnlock: () => () => undefined,
169
+ setSounds: () => undefined,
170
+ dispose: () => undefined,
171
+ };
172
+ }
@@ -0,0 +1,8 @@
1
+ export type {
2
+ ChatAudioEvent,
3
+ ChatAudioSounds,
4
+ ChatAudioConfig,
5
+ UseChatAudioReturn,
6
+ } from './types';
7
+ export { createAudioBus, type ChatAudioBus } from './audioBus';
8
+ export { useChatAudioPrefs, type ChatAudioPrefsState } from './preferences';
@@ -0,0 +1,68 @@
1
+ // Cross-tab persistent chat-audio prefs (master volume / muted / unlocked).
2
+ //
3
+ // Why Zustand here (vs. AudioPlayer's plain module-level store): the chat
4
+ // audio system has more knobs (per-event toggles + volume + muted) and lives
5
+ // behind a React context where multiple chat providers might exist. We get the
6
+ // `subscribe()` + `useSyncExternalStore` plumbing for free, and the persist
7
+ // middleware handles cross-tab sync via the `storage` event.
8
+ //
9
+ // We DO NOT close the AudioContext (matches AudioPlayer's ADR-004) and we
10
+ // remain SSR-safe by gating window access in the bus, not here.
11
+
12
+ 'use client';
13
+
14
+ import { create } from 'zustand';
15
+ import { persist, createJSONStorage } from 'zustand/middleware';
16
+
17
+ import type { ChatAudioEvent } from './types';
18
+
19
+ const STORAGE_KEY = 'djangocfg-chat-audio:prefs';
20
+
21
+ export interface ChatAudioPrefsState {
22
+ /** 0..1 master volume. */
23
+ volume: number;
24
+ /** Master mute (overrides per-event toggles). */
25
+ muted: boolean;
26
+ /** Per-event opt-out — `false` silences a single trigger. */
27
+ enabled: Partial<Record<ChatAudioEvent, boolean>>;
28
+
29
+ setVolume: (v: number) => void;
30
+ setMuted: (m: boolean) => void;
31
+ setEventEnabled: (event: ChatAudioEvent, enabled: boolean) => void;
32
+ }
33
+
34
+ const clamp01 = (v: number): number => {
35
+ if (!Number.isFinite(v)) return 1;
36
+ return v < 0 ? 0 : v > 1 ? 1 : v;
37
+ };
38
+
39
+ export const useChatAudioPrefs = create<ChatAudioPrefsState>()(
40
+ persist(
41
+ (set) => ({
42
+ volume: 1,
43
+ muted: false,
44
+ enabled: {},
45
+
46
+ setVolume: (v) => set({ volume: clamp01(v) }),
47
+ setMuted: (m) => set({ muted: !!m }),
48
+ setEventEnabled: (event, enabled) =>
49
+ set((s) => ({ enabled: { ...s.enabled, [event]: enabled } })),
50
+ }),
51
+ {
52
+ name: STORAGE_KEY,
53
+ storage: createJSONStorage(() => {
54
+ // SSR-safe: zustand calls `getStorage()` lazily, but be defensive.
55
+ if (typeof window === 'undefined') {
56
+ return {
57
+ getItem: () => null,
58
+ setItem: () => undefined,
59
+ removeItem: () => undefined,
60
+ };
61
+ }
62
+ return window.localStorage;
63
+ }),
64
+ partialize: (s) => ({ volume: s.volume, muted: s.muted, enabled: s.enabled }),
65
+ version: 1,
66
+ },
67
+ ),
68
+ );
@@ -0,0 +1,49 @@
1
+ // Public types for the chat-audio subsystem.
2
+
3
+ export type ChatAudioEvent =
4
+ | 'messageSent'
5
+ | 'messageReceived'
6
+ | 'streamStart'
7
+ | 'error'
8
+ | 'mention'
9
+ | 'notification';
10
+
11
+ /** Map an event to a sound URL. `false` silences the event explicitly. */
12
+ export type ChatAudioSounds = Partial<Record<ChatAudioEvent, string | false>>;
13
+
14
+ export interface ChatAudioConfig {
15
+ /** Map event → asset URL. Omit (or set `false`) to silence one event. */
16
+ sounds?: ChatAudioSounds;
17
+ /** Master volume 0..1. Persisted via the global prefs store. */
18
+ volume?: number;
19
+ /** Master mute. */
20
+ muted?: boolean;
21
+ /** Custom predicate — return `false` to suppress a play call. */
22
+ shouldPlay?: (event: ChatAudioEvent) => boolean;
23
+ /** Default-suppress when user prefers reduced motion. Default: true. */
24
+ respectReducedMotion?: boolean;
25
+ /** Default-suppress when user prefers reduced data. Default: true. */
26
+ respectReducedData?: boolean;
27
+ /** Mute when host page is hidden (`visibilityState === 'hidden'`). Default: true. */
28
+ muteWhenHidden?: boolean;
29
+ }
30
+
31
+ export interface UseChatAudioReturn {
32
+ /** Play a sound for an event. No-ops if the event has no URL or is muted. */
33
+ play: (event: ChatAudioEvent) => void;
34
+ /** Eagerly load an event's audio (called automatically on mount). */
35
+ preload: (event: ChatAudioEvent) => void;
36
+ /** Manually unlock — useful in stories/tests. */
37
+ unlock: () => void;
38
+ /** True after the first user gesture inside the chat root. */
39
+ isUnlocked: boolean;
40
+ /** Master mute (persistent). */
41
+ muted: boolean;
42
+ setMuted: (m: boolean) => void;
43
+ /** Master volume 0..1 (persistent). */
44
+ volume: number;
45
+ setVolume: (v: number) => void;
46
+ /** Per-event opt-out (persistent). */
47
+ isEventEnabled: (event: ChatAudioEvent) => boolean;
48
+ setEventEnabled: (event: ChatAudioEvent, enabled: boolean) => void;
49
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * ID generation. Uses crypto.randomUUID when available with a fallback
3
+ * for older environments (and SSR bundles that may not have crypto).
4
+ */
5
+
6
+ let counter = 0;
7
+
8
+ export function createId(prefix = 'm'): string {
9
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
10
+ return `${prefix}_${crypto.randomUUID()}`;
11
+ }
12
+ counter += 1;
13
+ return `${prefix}_${Date.now().toString(36)}_${counter.toString(36)}_${Math.random()
14
+ .toString(36)
15
+ .slice(2, 8)}`;
16
+ }
@@ -0,0 +1,5 @@
1
+ export { reducer, initialState, type ChatState, type ChatAction } from './reducer';
2
+ export { createId } from './ids';
3
+ export { createTokenBuffer, type TokenBuffer } from './markdown';
4
+ export { resolvePersona, deriveInitials } from './persona';
5
+ export * from './transport';
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Token coalescer. Buffers stream tokens within a small time window before
3
+ * dispatching a single aggregated chunk. Prevents 60+ re-renders per second
4
+ * on fast streams.
5
+ */
6
+
7
+ import { LIMITS } from '../config';
8
+
9
+ export interface TokenBuffer {
10
+ /** Append a delta. Returns immediately. */
11
+ push(delta: string): void;
12
+ /** Force flush and resolve any pending timer. */
13
+ flush(): void;
14
+ /** Stop accepting tokens; flush whatever is buffered. */
15
+ close(): void;
16
+ }
17
+
18
+ export function createTokenBuffer(
19
+ onFlush: (delta: string) => void,
20
+ windowMs = LIMITS.streamCoalesceMs,
21
+ ): TokenBuffer {
22
+ let pending = '';
23
+ let timer: ReturnType<typeof setTimeout> | null = null;
24
+ let closed = false;
25
+
26
+ const flush = () => {
27
+ if (timer) {
28
+ clearTimeout(timer);
29
+ timer = null;
30
+ }
31
+ if (pending) {
32
+ const out = pending;
33
+ pending = '';
34
+ onFlush(out);
35
+ }
36
+ };
37
+
38
+ return {
39
+ push(delta: string) {
40
+ if (closed || !delta) return;
41
+ pending += delta;
42
+ if (windowMs <= 0) {
43
+ flush();
44
+ return;
45
+ }
46
+ if (timer === null) {
47
+ timer = setTimeout(flush, windowMs);
48
+ }
49
+ },
50
+ flush,
51
+ close() {
52
+ closed = true;
53
+ flush();
54
+ },
55
+ };
56
+ }
@@ -0,0 +1,54 @@
1
+ // Tiny dispatcher for tool-call payload renderers.
2
+ //
3
+ // Hosts compose matchers (predicate + render) and a fallback. The result is a
4
+ // `renderPayload` function compatible with `<ToolCalls renderPayload>`.
5
+ //
6
+ // Pure — no React, no DOM. The matchers themselves return ReactNode.
7
+
8
+ import type { ReactNode } from 'react';
9
+
10
+ import type { ChatToolCall } from '../types';
11
+ import type { ToolPayloadKind } from '../components/ToolCalls';
12
+
13
+ export interface ToolPayloadMatcher {
14
+ /** Cheap predicate. First match wins. */
15
+ match: (value: unknown, kind: ToolPayloadKind, call: ChatToolCall) => boolean;
16
+ render: (value: unknown, kind: ToolPayloadKind, call: ChatToolCall) => ReactNode;
17
+ }
18
+
19
+ export type ToolPayloadFallback = (
20
+ value: unknown,
21
+ kind: ToolPayloadKind,
22
+ call: ChatToolCall,
23
+ ) => ReactNode;
24
+
25
+ export function dispatchToolPayload(
26
+ matchers: ToolPayloadMatcher[],
27
+ fallback: ToolPayloadFallback,
28
+ ): ToolPayloadFallback {
29
+ return (value, kind, call) => {
30
+ for (const m of matchers) {
31
+ if (m.match(value, kind, call)) return m.render(value, kind, call);
32
+ }
33
+ return fallback(value, kind, call);
34
+ };
35
+ }
36
+
37
+ // ---- Common matcher predicates -------------------------------------------
38
+ // Re-export as building blocks. Hosts can use directly or compose.
39
+
40
+ export function isPlainObject(v: unknown): v is Record<string, unknown> {
41
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
42
+ }
43
+
44
+ export function isLatLng(v: unknown): v is { lat: number; lng: number } {
45
+ return isPlainObject(v) && typeof v.lat === 'number' && typeof v.lng === 'number';
46
+ }
47
+
48
+ export function isGeoJSONFeatureCollection(v: unknown): v is { type: 'FeatureCollection'; features: unknown[] } {
49
+ return isPlainObject(v) && v.type === 'FeatureCollection' && Array.isArray(v.features);
50
+ }
51
+
52
+ export function isStringValue(v: unknown): v is string {
53
+ return typeof v === 'string';
54
+ }
@@ -0,0 +1,35 @@
1
+ // Persona resolution helpers — pure, no React.
2
+ //
3
+ // Resolution cascade for a bubble's persona:
4
+ // 1. message.sender (per-message override; multi-user chats)
5
+ // 2. config.user / config.assistant (provider-level default)
6
+ // 3. role-based fallback ('You' / 'AI')
7
+
8
+ import type { ChatAssistantContext, ChatMessage, ChatPersona, ChatUserContext } from '../types';
9
+
10
+ const FALLBACK_USER: ChatPersona = { name: 'You', initials: 'You' };
11
+ const FALLBACK_ASSISTANT: ChatPersona = { name: 'AI', initials: 'AI' };
12
+
13
+ export function resolvePersona(
14
+ message: Pick<ChatMessage, 'role' | 'sender'>,
15
+ user?: ChatUserContext,
16
+ assistant?: ChatAssistantContext,
17
+ ): ChatPersona {
18
+ if (message.sender) return message.sender;
19
+ if (message.role === 'user') return user ?? FALLBACK_USER;
20
+ if (message.role === 'assistant') return assistant ?? FALLBACK_ASSISTANT;
21
+ return { name: message.role };
22
+ }
23
+
24
+ /** Compute initials for an avatar fallback. */
25
+ export function deriveInitials(persona: ChatPersona, role?: string): string {
26
+ if (persona.initials) return persona.initials;
27
+ if (persona.name) {
28
+ const parts = persona.name.trim().split(/\s+/);
29
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
30
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
31
+ }
32
+ if (role === 'user') return 'You';
33
+ if (role === 'assistant') return 'AI';
34
+ return '?';
35
+ }