@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.
Files changed (178) hide show
  1. package/README.md +132 -899
  2. package/dist/ChatRoot-6IZFM5HM.mjs +5 -0
  3. package/dist/{ChatRoot-EJC5Y2YM.cjs.map → ChatRoot-6IZFM5HM.mjs.map} +1 -1
  4. package/dist/ChatRoot-LW4XNIKP.cjs +14 -0
  5. package/dist/{ChatRoot-QOSKJPM6.mjs.map → ChatRoot-LW4XNIKP.cjs.map} +1 -1
  6. package/dist/DictationField-U25MEYAL.mjs +4 -0
  7. package/dist/DictationField-U25MEYAL.mjs.map +1 -0
  8. package/dist/DictationField-XWR5VOID.cjs +13 -0
  9. package/dist/DictationField-XWR5VOID.cjs.map +1 -0
  10. package/dist/{DocsLayout-2YKPXZYO.mjs → DocsLayout-2P3ONDWJ.mjs} +3 -3
  11. package/dist/{DocsLayout-2YKPXZYO.mjs.map → DocsLayout-2P3ONDWJ.mjs.map} +1 -1
  12. package/dist/{DocsLayout-Q4KS3QWW.cjs → DocsLayout-2YZNS5VK.cjs} +8 -8
  13. package/dist/{DocsLayout-Q4KS3QWW.cjs.map → DocsLayout-2YZNS5VK.cjs.map} +1 -1
  14. package/dist/chunk-4PFW7MIJ.cjs +837 -0
  15. package/dist/chunk-4PFW7MIJ.cjs.map +1 -0
  16. package/dist/chunk-C2YN6WEO.mjs +833 -0
  17. package/dist/chunk-C2YN6WEO.mjs.map +1 -0
  18. package/dist/{chunk-XACCHZH2.cjs → chunk-FIRK5CEH.cjs} +42 -4
  19. package/dist/chunk-FIRK5CEH.cjs.map +1 -0
  20. package/dist/{chunk-NWUT327A.mjs → chunk-HIK6BPL7.mjs} +38 -5
  21. package/dist/chunk-HIK6BPL7.mjs.map +1 -0
  22. package/dist/chunk-OZAU3QWD.cjs +2493 -0
  23. package/dist/chunk-OZAU3QWD.cjs.map +1 -0
  24. package/dist/chunk-UWVP6LCW.mjs +2447 -0
  25. package/dist/chunk-UWVP6LCW.mjs.map +1 -0
  26. package/dist/index.cjs +1668 -99
  27. package/dist/index.cjs.map +1 -1
  28. package/dist/index.d.cts +1215 -107
  29. package/dist/index.d.ts +1215 -107
  30. package/dist/index.mjs +1555 -50
  31. package/dist/index.mjs.map +1 -1
  32. package/package.json +16 -15
  33. package/src/audio-assets.d.ts +8 -0
  34. package/src/components/markdown/MarkdownMessage/CollapseToggle.tsx +3 -1
  35. package/src/components/markdown/MarkdownMessage/components.tsx +2 -5
  36. package/src/tools/Chat/README.md +347 -530
  37. package/src/tools/Chat/components/Attachments.tsx +6 -1
  38. package/src/tools/Chat/components/ChatRoot.tsx +30 -2
  39. package/src/tools/Chat/components/Composer.tsx +20 -3
  40. package/src/tools/Chat/components/ErrorBanner.tsx +7 -3
  41. package/src/tools/Chat/components/MessageActions.tsx +3 -1
  42. package/src/tools/Chat/components/MessageBubble.tsx +6 -5
  43. package/src/tools/Chat/components/MessageList.tsx +87 -1
  44. package/src/tools/Chat/components/ToolCalls.tsx +21 -3
  45. package/src/tools/Chat/context/ChatProvider.tsx +21 -3
  46. package/src/tools/Chat/core/audio/audioBus.ts +10 -163
  47. package/src/tools/Chat/core/audio/defaults.ts +43 -0
  48. package/src/tools/Chat/core/audio/index.ts +1 -0
  49. package/src/tools/Chat/core/audio/preferences.ts +5 -59
  50. package/src/tools/Chat/core/audio/sounds/error.mp3 +0 -0
  51. package/src/tools/Chat/core/audio/sounds/mention.mp3 +0 -0
  52. package/src/tools/Chat/core/audio/sounds/notification.mp3 +0 -0
  53. package/src/tools/Chat/core/audio/sounds/received.mp3 +0 -0
  54. package/src/tools/Chat/core/audio/sounds/sent.mp3 +0 -0
  55. package/src/tools/Chat/core/audio/sounds/start.mp3 +0 -0
  56. package/src/tools/Chat/core/audio/types.ts +28 -0
  57. package/src/tools/Chat/core/reducer.ts +33 -0
  58. package/src/tools/Chat/core/transport/index.ts +13 -0
  59. package/src/tools/Chat/core/transport/mappers/index.ts +6 -0
  60. package/src/tools/Chat/core/transport/mappers/pydantic-ai.ts +142 -0
  61. package/src/tools/Chat/core/transport/pydantic-ai-transport.ts +208 -0
  62. package/src/tools/Chat/core/transport/sse.ts +18 -5
  63. package/src/tools/Chat/hooks/index.ts +25 -0
  64. package/src/tools/Chat/hooks/useAutoFocusOnStreamEnd.ts +5 -3
  65. package/src/tools/Chat/hooks/useChat.ts +28 -0
  66. package/src/tools/Chat/hooks/useChatAudio.ts +59 -180
  67. package/src/tools/Chat/hooks/useChatDockPrefs.ts +74 -0
  68. package/src/tools/Chat/hooks/useChatReset.ts +70 -0
  69. package/src/tools/Chat/hooks/useChatUnread.ts +87 -0
  70. package/src/tools/Chat/hooks/useFocusOnEmptyClick.ts +111 -0
  71. package/src/tools/Chat/hooks/useVisitorFingerprint.ts +48 -0
  72. package/src/tools/Chat/index.ts +84 -1
  73. package/src/tools/Chat/launcher/ChatDock.tsx +263 -0
  74. package/src/tools/Chat/launcher/ChatFAB.tsx +349 -0
  75. package/src/tools/Chat/launcher/ChatGreeting.tsx +200 -0
  76. package/src/tools/Chat/launcher/ChatHeader.tsx +76 -0
  77. package/src/tools/Chat/launcher/ChatHeaderActionButton.tsx +87 -0
  78. package/src/tools/Chat/launcher/ChatHeaderAudioToggle.tsx +47 -0
  79. package/src/tools/Chat/launcher/ChatHeaderLanguageButton.tsx +179 -0
  80. package/src/tools/Chat/launcher/ChatHeaderModeToggle.tsx +57 -0
  81. package/src/tools/Chat/launcher/ChatHeaderResetButton.tsx +93 -0
  82. package/src/tools/Chat/launcher/ChatLauncher.tsx +321 -0
  83. package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +197 -0
  84. package/src/tools/Chat/launcher/index.ts +46 -0
  85. package/src/tools/Chat/launcher/useChatPresence.ts +44 -0
  86. package/src/tools/Chat/styles/bubbleTokens.ts +71 -0
  87. package/src/tools/Chat/styles/index.ts +16 -0
  88. package/src/tools/Chat/styles/useChatStyles.ts +101 -0
  89. package/src/tools/Chat/types/attachment.ts +25 -0
  90. package/src/tools/Chat/types/config.ts +48 -0
  91. package/src/tools/Chat/types/events.ts +35 -0
  92. package/src/tools/Chat/types/index.ts +34 -0
  93. package/src/tools/Chat/types/labels.ts +38 -0
  94. package/src/tools/Chat/types/message.ts +32 -0
  95. package/src/tools/Chat/types/persona.ts +31 -0
  96. package/src/tools/Chat/types/session.ts +43 -0
  97. package/src/tools/Chat/types/tool-call.ts +17 -0
  98. package/src/tools/Chat/types/transport.ts +28 -0
  99. package/src/tools/Chat/types.ts +5 -240
  100. package/src/tools/MarkdownEditor/MarkdownEditor.tsx +50 -14
  101. package/src/tools/MarkdownEditor/index.ts +1 -1
  102. package/src/tools/SpeechRecognition/README.md +336 -0
  103. package/src/tools/SpeechRecognition/__tests__/ids.test.ts +15 -0
  104. package/src/tools/SpeechRecognition/__tests__/language.test.ts +59 -0
  105. package/src/tools/SpeechRecognition/__tests__/reducer.test.ts +71 -0
  106. package/src/tools/SpeechRecognition/__tests__/transcript.test.ts +52 -0
  107. package/src/tools/SpeechRecognition/components/DevicePicker.tsx +49 -0
  108. package/src/tools/SpeechRecognition/components/DictationButton.tsx +93 -0
  109. package/src/tools/SpeechRecognition/components/EngineBadge.tsx +30 -0
  110. package/src/tools/SpeechRecognition/components/ErrorBanner.tsx +52 -0
  111. package/src/tools/SpeechRecognition/components/LanguagePicker.tsx +63 -0
  112. package/src/tools/SpeechRecognition/components/MicMeter.tsx +63 -0
  113. package/src/tools/SpeechRecognition/components/PushToTalkHint.tsx +51 -0
  114. package/src/tools/SpeechRecognition/components/TranscriptView.tsx +55 -0
  115. package/src/tools/SpeechRecognition/components/index.ts +16 -0
  116. package/src/tools/SpeechRecognition/context/SpeechRecognitionProvider.tsx +47 -0
  117. package/src/tools/SpeechRecognition/context/index.ts +6 -0
  118. package/src/tools/SpeechRecognition/core/audio/defaults.ts +24 -0
  119. package/src/tools/SpeechRecognition/core/engine/external.ts +222 -0
  120. package/src/tools/SpeechRecognition/core/engine/http.ts +147 -0
  121. package/src/tools/SpeechRecognition/core/engine/index.ts +52 -0
  122. package/src/tools/SpeechRecognition/core/engine/mediarecorder.ts +105 -0
  123. package/src/tools/SpeechRecognition/core/engine/websocket.ts +211 -0
  124. package/src/tools/SpeechRecognition/core/engine/webspeech.ts +188 -0
  125. package/src/tools/SpeechRecognition/core/ids.ts +11 -0
  126. package/src/tools/SpeechRecognition/core/index.ts +14 -0
  127. package/src/tools/SpeechRecognition/core/language.ts +78 -0
  128. package/src/tools/SpeechRecognition/core/languages-catalog.ts +229 -0
  129. package/src/tools/SpeechRecognition/core/logger.ts +3 -0
  130. package/src/tools/SpeechRecognition/core/reducer.ts +105 -0
  131. package/src/tools/SpeechRecognition/core/transcript.ts +36 -0
  132. package/src/tools/SpeechRecognition/hooks/index.ts +14 -0
  133. package/src/tools/SpeechRecognition/hooks/useDictation.ts +59 -0
  134. package/src/tools/SpeechRecognition/hooks/useEnginePrefs.ts +15 -0
  135. package/src/tools/SpeechRecognition/hooks/useMicDevices.ts +57 -0
  136. package/src/tools/SpeechRecognition/hooks/useMicLevel.ts +52 -0
  137. package/src/tools/SpeechRecognition/hooks/usePushToTalk.ts +85 -0
  138. package/src/tools/SpeechRecognition/hooks/useResolvedLanguage.ts +28 -0
  139. package/src/tools/SpeechRecognition/hooks/useSpeechLanguageInfo.ts +108 -0
  140. package/src/tools/SpeechRecognition/hooks/useSpeechRecognition.ts +188 -0
  141. package/src/tools/SpeechRecognition/hooks/useVoiceSupport.ts +78 -0
  142. package/src/tools/SpeechRecognition/index.ts +82 -0
  143. package/src/tools/SpeechRecognition/lazy.tsx +19 -0
  144. package/src/tools/SpeechRecognition/store/index.ts +2 -0
  145. package/src/tools/SpeechRecognition/store/prefsStore.ts +54 -0
  146. package/src/tools/SpeechRecognition/types.ts +133 -0
  147. package/src/tools/SpeechRecognition/widgets/DictationField.tsx +105 -0
  148. package/src/tools/SpeechRecognition/widgets/VoiceComposerSlot.tsx +305 -0
  149. package/src/tools/SpeechRecognition/widgets/VoiceMessageRecorder.tsx +88 -0
  150. package/src/tools/SpeechRecognition/widgets/index.ts +6 -0
  151. package/dist/ChatRoot-EJC5Y2YM.cjs +0 -14
  152. package/dist/ChatRoot-QOSKJPM6.mjs +0 -5
  153. package/dist/chunk-NWUT327A.mjs.map +0 -1
  154. package/dist/chunk-QLMKCSR6.mjs +0 -2420
  155. package/dist/chunk-QLMKCSR6.mjs.map +0 -1
  156. package/dist/chunk-SI5RD2GD.cjs +0 -2460
  157. package/dist/chunk-SI5RD2GD.cjs.map +0 -1
  158. package/dist/chunk-XACCHZH2.cjs.map +0 -1
  159. package/src/components/markdown/MarkdownMessage/MarkdownMessage.story.tsx +0 -771
  160. package/src/stories/index.ts +0 -33
  161. package/src/tools/AudioPlayer/AudioPlayer.story.tsx +0 -481
  162. package/src/tools/Chat/Chat.story.tsx +0 -1457
  163. package/src/tools/CodeEditor/CodeEditor.story.tsx +0 -202
  164. package/src/tools/CronScheduler/CronScheduler.story.tsx +0 -300
  165. package/src/tools/Gallery/Gallery.story.tsx +0 -237
  166. package/src/tools/ImageViewer/ImageViewer.story.tsx +0 -85
  167. package/src/tools/JsonForm/JsonForm.story.tsx +0 -350
  168. package/src/tools/JsonTree/JsonTree.story.tsx +0 -141
  169. package/src/tools/LottiePlayer/LottiePlayer.story.tsx +0 -95
  170. package/src/tools/Map/Map.story.tsx +0 -458
  171. package/src/tools/MarkdownEditor/MarkdownEditor.story.tsx +0 -225
  172. package/src/tools/Mermaid/Mermaid.story.tsx +0 -251
  173. package/src/tools/OpenapiViewer/OpenapiViewer.story.tsx +0 -230
  174. package/src/tools/PrettyCode/PrettyCode.story.tsx +0 -304
  175. package/src/tools/Tour/Tour.story.tsx +0 -279
  176. package/src/tools/Tree/Tree.story.tsx +0 -620
  177. package/src/tools/Uploader/Uploader.story.tsx +0 -415
  178. package/src/tools/VideoPlayer/VideoPlayer.story.tsx +0 -87
@@ -6,3 +6,4 @@ export type {
6
6
  } from './types';
7
7
  export { createAudioBus, type ChatAudioBus } from './audioBus';
8
8
  export { useChatAudioPrefs, type ChatAudioPrefsState } from './preferences';
9
+ export { DEFAULT_CHAT_SOUNDS } from './defaults';
@@ -1,68 +1,14 @@
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.
1
+ // Thin re-export audio prefs storage now lives in `@djangocfg/ui-core/hooks`.
2
+ // Existing direct consumers (`AudioToggle`) keep working through this wrapper.
11
3
 
12
4
  'use client';
13
5
 
14
- import { create } from 'zustand';
15
- import { persist, createJSONStorage } from 'zustand/middleware';
6
+ import { createAudioPrefsStore, type AudioPrefsState } from '@djangocfg/ui-core/hooks';
16
7
 
17
8
  import type { ChatAudioEvent } from './types';
18
9
 
19
10
  const STORAGE_KEY = 'djangocfg-chat-audio:prefs';
20
11
 
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>>;
12
+ export type ChatAudioPrefsState = AudioPrefsState<ChatAudioEvent>;
28
13
 
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
- );
14
+ export const useChatAudioPrefs = createAudioPrefsStore<ChatAudioEvent>(STORAGE_KEY);
@@ -16,6 +16,19 @@ export interface ChatAudioConfig {
16
16
  sounds?: ChatAudioSounds;
17
17
  /** Master volume 0..1. Persisted via the global prefs store. */
18
18
  volume?: number;
19
+ /**
20
+ * Per-event volume multipliers (0..1). Applied on top of `volume`.
21
+ * Defaults applied by `useChatAudio` if not provided:
22
+ * - error: 0.25 (gentle — error UI is the loud signal, not the sound)
23
+ * - mention: 1.0 (louder than baseline received)
24
+ * - messageSent: 0.5 (subtle self-confirmation)
25
+ * - messageReceived: 0.7
26
+ * - streamStart: 0.3 (very subtle, fires often)
27
+ * - notification: 0.9
28
+ *
29
+ * Pass `{}` to disable defaults; pass overrides to tweak.
30
+ */
31
+ eventVolumes?: Partial<Record<ChatAudioEvent, number>>;
19
32
  /** Master mute. */
20
33
  muted?: boolean;
21
34
  /** Custom predicate — return `false` to suppress a play call. */
@@ -26,6 +39,17 @@ export interface ChatAudioConfig {
26
39
  respectReducedData?: boolean;
27
40
  /** Mute when host page is hidden (`visibilityState === 'hidden'`). Default: true. */
28
41
  muteWhenHidden?: boolean;
42
+ /**
43
+ * Skip web playback entirely — `play()` becomes a no-op. Pair with
44
+ * `onSoundEvent` for native hosts (cmdop_go / Tauri) that play sounds
45
+ * outside the browser.
46
+ */
47
+ silenced?: boolean;
48
+ /**
49
+ * Side-channel fired whenever `play(event)` is called. Stays active
50
+ * even when `silenced=true`. Use to bridge into a native audio backend.
51
+ */
52
+ onSoundEvent?: (event: ChatAudioEvent) => void;
29
53
  }
30
54
 
31
55
  export interface UseChatAudioReturn {
@@ -40,10 +64,14 @@ export interface UseChatAudioReturn {
40
64
  /** Master mute (persistent). */
41
65
  muted: boolean;
42
66
  setMuted: (m: boolean) => void;
67
+ /** Flip mute state — convenience. */
68
+ toggleMute: () => void;
43
69
  /** Master volume 0..1 (persistent). */
44
70
  volume: number;
45
71
  setVolume: (v: number) => void;
46
72
  /** Per-event opt-out (persistent). */
47
73
  isEventEnabled: (event: ChatAudioEvent) => boolean;
48
74
  setEventEnabled: (event: ChatAudioEvent, enabled: boolean) => void;
75
+ /** True when no sounds are configured (or `silenced`). */
76
+ isSilent: boolean;
49
77
  }
@@ -83,6 +83,8 @@ export type ChatAction =
83
83
  | { type: 'STREAM_RESUME_EXISTING' }
84
84
  | { type: 'MESSAGE_EDIT'; id: string; content: string }
85
85
  | { type: 'MESSAGE_DELETE'; id: string }
86
+ | { type: 'MESSAGE_INJECT'; message: ChatMessage; position?: 'append' | 'prepend' }
87
+ | { type: 'MESSAGE_PATCH'; id: string; patch: Partial<ChatMessage> }
86
88
  | { type: 'MESSAGES_CLEAR' }
87
89
  | { type: 'ERROR_SET'; error: string | null }
88
90
  | {
@@ -327,6 +329,37 @@ export function reducer(state: ChatState, action: ChatAction): ChatState {
327
329
  messages: state.messages.filter((m) => m.id !== action.id),
328
330
  };
329
331
 
332
+ case 'MESSAGE_INJECT': {
333
+ // De-dupe by id: if a message with this id already exists, merge
334
+ // instead of duplicating. Avoids double-render when external code
335
+ // re-emits the same payload (Centrifugo replay, SWR retry, …).
336
+ const existingIdx = state.messages.findIndex((m) => m.id === action.message.id);
337
+ if (existingIdx >= 0) {
338
+ const messages = state.messages.slice();
339
+ const prev = messages[existingIdx]!;
340
+ messages[existingIdx] = {
341
+ ...prev,
342
+ ...action.message,
343
+ version: (prev.version ?? 0) + 1,
344
+ };
345
+ return { ...state, messages };
346
+ }
347
+ const next =
348
+ action.position === 'prepend'
349
+ ? [action.message, ...state.messages]
350
+ : [...state.messages, action.message];
351
+ return { ...state, messages: next };
352
+ }
353
+
354
+ case 'MESSAGE_PATCH': {
355
+ const messages = patchMessageById(state.messages, action.id, (m) => ({
356
+ ...m,
357
+ ...action.patch,
358
+ version: (m.version ?? 0) + 1,
359
+ }));
360
+ return { ...state, messages };
361
+ }
362
+
330
363
  case 'MESSAGES_CLEAR':
331
364
  return {
332
365
  ...state,
@@ -11,3 +11,16 @@ export type {
11
11
  StreamOptions,
12
12
  SendOptions,
13
13
  } from './types';
14
+
15
+ export {
16
+ createPydanticAIChatTransport,
17
+ type PydanticAIChatTransportOpts,
18
+ } from './pydantic-ai-transport';
19
+
20
+ export {
21
+ createToolIdQueue,
22
+ mapPydanticAIEvent,
23
+ createPydanticAISSEMap,
24
+ type PydanticAIEvent,
25
+ type ToolIdQueue,
26
+ } from './mappers';
@@ -0,0 +1,6 @@
1
+ export {
2
+ createToolIdQueue,
3
+ mapPydanticAIEvent,
4
+ createPydanticAISSEMap,
5
+ } from './pydantic-ai';
6
+ export type { PydanticAIEvent, ToolIdQueue } from './pydantic-ai';
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Pydantic-AI SSE event mapper.
3
+ *
4
+ * Translates the event shape emitted by pydantic-AI–style Django backends
5
+ * (text_delta / tool_call / tool_result / done / error / approval_required)
6
+ * into the canonical `ChatStreamEvent` stream consumed by the Chat reducer.
7
+ *
8
+ * Backends that don't expose a stable `tool_call_id` are supported via a
9
+ * per-stream FIFO queue keyed by tool name. The earlier "Map<name, toolId>"
10
+ * approach lost the first toolId when a tool was invoked twice in one turn
11
+ * (e.g. `list_tasks` for search and again for confirmation) — using a queue
12
+ * keeps each call/result pair correctly matched.
13
+ */
14
+
15
+ import type { ChatStreamEvent } from '../../../types';
16
+ import type { ParseSSEOptions } from '../sse';
17
+
18
+ export interface PydanticAIEvent {
19
+ type:
20
+ | 'text_delta'
21
+ | 'tool_call'
22
+ | 'tool_result'
23
+ | 'done'
24
+ | 'error'
25
+ | 'approval_required';
26
+ delta?: string;
27
+ tool?: string;
28
+ args?: unknown;
29
+ result?: unknown;
30
+ /**
31
+ * Structured frontend payload — present on `tool_result` when the tool has
32
+ * a `result_schema`. The LLM sees only `result` (compact text); the
33
+ * frontend uses `data` to render rich UI (e.g. vehicle cards).
34
+ */
35
+ data?: unknown;
36
+ total_tokens?: number;
37
+ error?: string;
38
+ tool_call_id?: string;
39
+ session_id?: string;
40
+ }
41
+
42
+ /** Per-stream FIFO queue keyed by tool name. Created via `createToolIdQueue`. */
43
+ export interface ToolIdQueue {
44
+ /** Allocate a new toolId for a `tool_call` event and enqueue it under `name`. */
45
+ push(name: string): string;
46
+ /** Pop the oldest toolId for `name` (or return an orphan marker if none). */
47
+ shift(name: string): string;
48
+ /** Reset all queues (e.g. on stream close). */
49
+ clear(): void;
50
+ }
51
+
52
+ export function createToolIdQueue(): ToolIdQueue {
53
+ const queues = new Map<string, string[]>();
54
+ let counter = 0;
55
+ return {
56
+ push(name) {
57
+ const id = `${name}-${counter++}-${Date.now()}`;
58
+ const q = queues.get(name) ?? [];
59
+ q.push(id);
60
+ queues.set(name, q);
61
+ return id;
62
+ },
63
+ shift(name) {
64
+ return queues.get(name)?.shift() ?? `${name}-orphan-${counter++}`;
65
+ },
66
+ clear() {
67
+ queues.clear();
68
+ },
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Translate a single pydantic-AI event into zero or more `ChatStreamEvent`s.
74
+ * Pass a `ToolIdQueue` shared across the lifetime of one stream so that
75
+ * `tool_call` / `tool_result` pairs match correctly.
76
+ */
77
+ export function* mapPydanticAIEvent(
78
+ ev: PydanticAIEvent,
79
+ toolIds: ToolIdQueue,
80
+ ): Generator<ChatStreamEvent> {
81
+ switch (ev.type) {
82
+ case 'text_delta':
83
+ if (ev.delta) yield { type: 'chunk', delta: ev.delta };
84
+ return;
85
+
86
+ case 'tool_call': {
87
+ const name = ev.tool ?? 'tool';
88
+ const toolId = toolIds.push(name);
89
+ yield { type: 'tool_call_start', toolId, name, input: ev.args };
90
+ return;
91
+ }
92
+
93
+ case 'tool_result': {
94
+ const name = ev.tool ?? 'tool';
95
+ const toolId = toolIds.shift(name);
96
+ // `data` is the structured JSON for the frontend; `result` is the LLM-facing text.
97
+ const output: unknown = ev.data !== undefined ? ev.data : ev.result;
98
+ yield { type: 'tool_call_end', toolId, output, status: 'success' };
99
+ return;
100
+ }
101
+
102
+ case 'done':
103
+ yield { type: 'message_end', tokensOut: ev.total_tokens };
104
+ return;
105
+
106
+ case 'error':
107
+ yield { type: 'error', code: 'backend_error', message: ev.error ?? 'Unknown error' };
108
+ return;
109
+
110
+ case 'approval_required':
111
+ // Surfaced via a separate side-channel (see ResumableTransport in
112
+ // host packages). Not translated to a ChatStreamEvent here.
113
+ return;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Convenience factory: returns a `ParseSSEOptions['map']` callback that
119
+ * decodes raw SSE frames as `PydanticAIEvent` JSON and yields zero or
120
+ * more `ChatStreamEvent`s through `mapPydanticAIEvent`.
121
+ *
122
+ * Allocates an internal `ToolIdQueue` — call this once per stream.
123
+ */
124
+ export function createPydanticAISSEMap(): NonNullable<ParseSSEOptions['map']> {
125
+ const toolIds = createToolIdQueue();
126
+ return (raw) => {
127
+ if (!raw.data) return null;
128
+ let parsed: PydanticAIEvent;
129
+ try {
130
+ parsed = JSON.parse(raw.data) as PydanticAIEvent;
131
+ } catch {
132
+ return null;
133
+ }
134
+ const out: ChatStreamEvent[] = [];
135
+ for (const evt of mapPydanticAIEvent(parsed, toolIds)) {
136
+ out.push(evt);
137
+ }
138
+ if (out.length === 0) return null;
139
+ if (out.length === 1) return out[0]!;
140
+ return out;
141
+ };
142
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * High-level transport factory for pydantic-AI–style backends.
3
+ *
4
+ * Composes:
5
+ * - `parseSSE` for spec-compliant SSE framing (multi-line `data:`, comments, idle timeout).
6
+ * - `createPydanticAISSEMap` for normalizing pydantic-AI events into `ChatStreamEvent`.
7
+ *
8
+ * Use when your backend speaks the canonical pydantic-AI stream shape
9
+ * (`text_delta` / `tool_call` / `tool_result` / `done` / `error`).
10
+ * URL building, auth headers, history loading, and optional session
11
+ * bootstrapping are caller responsibilities — pass them in.
12
+ */
13
+
14
+ import type {
15
+ ChatMessage,
16
+ ChatStreamEvent,
17
+ ChatTransport,
18
+ CreateSessionOptions,
19
+ HistoryPage,
20
+ SendOptions,
21
+ SessionInfo,
22
+ StreamOptions,
23
+ } from '../../types';
24
+ import { TransportError } from './types';
25
+ import { parseSSE } from './sse';
26
+ import {
27
+ createPydanticAISSEMap,
28
+ mapPydanticAIEvent,
29
+ createToolIdQueue,
30
+ type PydanticAIEvent,
31
+ } from './mappers';
32
+
33
+ export interface PydanticAIChatTransportOpts {
34
+ /**
35
+ * Build the SSE stream URL for a user message turn.
36
+ * @example (sessionId, message) => `${base}/stream?session_id=${sessionId}&message=${encodeURIComponent(message)}`
37
+ */
38
+ buildStreamUrl: (sessionId: string, message: string) => string | URL;
39
+
40
+ /** Optional history loader. If omitted, `loadHistory` returns an empty page. */
41
+ loadHistory?: (sessionId: string, cursor?: string | null) => Promise<HistoryPage>;
42
+
43
+ /**
44
+ * Optional session bootstrap. Called from `createSession`. Useful for
45
+ * backends that need a `POST /sessions` round-trip or want to pre-seed
46
+ * history.
47
+ */
48
+ bootstrapSession?: (opts?: CreateSessionOptions) => Promise<SessionInfo>;
49
+
50
+ /** Optional session teardown. */
51
+ closeSession?: (sessionId: string) => Promise<void>;
52
+
53
+ /**
54
+ * Optional non-streaming send (for hosts that need a buffered fallback,
55
+ * e.g. when the user disables streaming). Defaults to throwing — most
56
+ * hosts only use streaming.
57
+ */
58
+ send?: (
59
+ sessionId: string,
60
+ content: string,
61
+ options?: SendOptions,
62
+ ) => Promise<ChatMessage>;
63
+
64
+ /** Request headers (Authorization, content-type, etc.). */
65
+ buildHeaders?: () => HeadersInit | Promise<HeadersInit>;
66
+
67
+ /** Override fetch (tests, retry layers). */
68
+ fetchImpl?: typeof fetch;
69
+
70
+ /**
71
+ * HTTP method for the stream request. Defaults to `'POST'` with
72
+ * `{ content, attachments, metadata }` JSON body. Set to `'GET'` if
73
+ * your backend embeds the message in the URL via `buildStreamUrl`.
74
+ */
75
+ streamMethod?: 'GET' | 'POST';
76
+
77
+ /** Idle timeout for the SSE connection, in ms. Forwarded to `parseSSE`. */
78
+ idleTimeoutMs?: number;
79
+
80
+ /**
81
+ * Side-channel for events that don't translate to `ChatStreamEvent`
82
+ * (e.g. `approval_required` — surfaces interactive prompts outside the
83
+ * normal message stream). Called synchronously while parsing the SSE
84
+ * frame; mutate caller-owned state, don't `await` long work here.
85
+ */
86
+ onPydanticEvent?: (event: PydanticAIEvent) => void;
87
+ }
88
+
89
+ const DEFAULT_SESSION_ID = 'default';
90
+
91
+ function mapStatusToCode(status: number): string {
92
+ if (status === 401 || status === 403) return 'unauthorized';
93
+ if (status === 404) return 'not_found';
94
+ if (status === 408) return 'timeout';
95
+ if (status === 429) return 'rate_limited';
96
+ if (status >= 500) return 'server_error';
97
+ return 'error';
98
+ }
99
+
100
+ export function createPydanticAIChatTransport(
101
+ opts: PydanticAIChatTransportOpts,
102
+ ): ChatTransport {
103
+ const fetchImpl = opts.fetchImpl ?? fetch.bind(globalThis);
104
+ const streamMethod = opts.streamMethod ?? 'POST';
105
+
106
+ async function resolvedHeaders(extra?: Record<string, string>): Promise<Headers> {
107
+ const base = opts.buildHeaders ? await opts.buildHeaders() : {};
108
+ const headers = new Headers(base as HeadersInit);
109
+ if (extra) {
110
+ for (const [k, v] of Object.entries(extra)) headers.set(k, v);
111
+ }
112
+ return headers;
113
+ }
114
+
115
+ return {
116
+ async createSession(createOpts) {
117
+ if (opts.bootstrapSession) return opts.bootstrapSession(createOpts);
118
+ return { sessionId: DEFAULT_SESSION_ID };
119
+ },
120
+
121
+ async loadHistory(sessionId, cursor) {
122
+ if (opts.loadHistory) return opts.loadHistory(sessionId, cursor);
123
+ return { messages: [], hasMore: false, nextCursor: null };
124
+ },
125
+
126
+ async *stream(
127
+ sessionId: string,
128
+ content: string,
129
+ options: StreamOptions,
130
+ ): AsyncGenerator<ChatStreamEvent, void, void> {
131
+ const url = opts.buildStreamUrl(sessionId, content);
132
+
133
+ const headers = await resolvedHeaders({ Accept: 'text/event-stream' });
134
+ const init: RequestInit = {
135
+ method: streamMethod,
136
+ headers,
137
+ signal: options.signal,
138
+ };
139
+ if (streamMethod === 'POST') {
140
+ headers.set('Content-Type', 'application/json');
141
+ init.body = JSON.stringify({
142
+ content,
143
+ attachments: options.attachments ?? [],
144
+ metadata: options.metadata ?? {},
145
+ });
146
+ }
147
+
148
+ const res = await fetchImpl(typeof url === 'string' ? url : url.toString(), init);
149
+
150
+ if (!res.ok) {
151
+ const text = await res.text().catch(() => '');
152
+ throw new TransportError(
153
+ `stream failed (${res.status}): ${text || res.statusText}`,
154
+ mapStatusToCode(res.status),
155
+ );
156
+ }
157
+
158
+ const sideChannel = opts.onPydanticEvent;
159
+ if (!sideChannel) {
160
+ yield* parseSSE(res, {
161
+ signal: options.signal,
162
+ idleTimeoutMs: opts.idleTimeoutMs,
163
+ map: createPydanticAISSEMap(),
164
+ });
165
+ return;
166
+ }
167
+
168
+ // Side-channel mode: parse the raw pydantic event, fire callback,
169
+ // then forward through the canonical mapper.
170
+ const toolIds = createToolIdQueue();
171
+ yield* parseSSE(res, {
172
+ signal: options.signal,
173
+ idleTimeoutMs: opts.idleTimeoutMs,
174
+ map: (raw) => {
175
+ if (!raw.data) return null;
176
+ let parsed: PydanticAIEvent;
177
+ try {
178
+ parsed = JSON.parse(raw.data) as PydanticAIEvent;
179
+ } catch {
180
+ return null;
181
+ }
182
+ try {
183
+ sideChannel(parsed);
184
+ } catch {
185
+ // Side-channel handler must not break the stream.
186
+ }
187
+ const out: ChatStreamEvent[] = [];
188
+ for (const evt of mapPydanticAIEvent(parsed, toolIds)) out.push(evt);
189
+ if (out.length === 0) return null;
190
+ if (out.length === 1) return out[0]!;
191
+ return out;
192
+ },
193
+ });
194
+ },
195
+
196
+ async send(sessionId, content, sendOpts) {
197
+ if (opts.send) return opts.send(sessionId, content, sendOpts);
198
+ throw new TransportError(
199
+ 'Buffered send is not supported by this transport',
200
+ 'unsupported',
201
+ );
202
+ },
203
+
204
+ async closeSession(sessionId) {
205
+ if (opts.closeSession) await opts.closeSession(sessionId);
206
+ },
207
+ };
208
+ }
@@ -16,9 +16,10 @@ interface RawEvent {
16
16
 
17
17
  export interface ParseSSEOptions {
18
18
  signal?: AbortSignal;
19
- /** Map a raw SSE event to a ChatStreamEvent. Default: parse `data` as JSON
20
- * and assume the JSON shape already matches `ChatStreamEvent`. */
21
- map?: (raw: RawEvent) => ChatStreamEvent | null;
19
+ /** Map a raw SSE event to zero, one, or many `ChatStreamEvent`s.
20
+ * Default: parse `data` as JSON and assume the JSON shape already
21
+ * matches `ChatStreamEvent`. */
22
+ map?: (raw: RawEvent) => ChatStreamEvent | ChatStreamEvent[] | null;
22
23
  idleTimeoutMs?: number;
23
24
  }
24
25
 
@@ -75,7 +76,13 @@ export async function* parseSSE(
75
76
 
76
77
  const raw = parseEventBlock(rawBlock);
77
78
  const evt = map(raw);
78
- if (evt) yield evt;
79
+ if (evt) {
80
+ if (Array.isArray(evt)) {
81
+ for (const e of evt) yield e;
82
+ } else {
83
+ yield evt;
84
+ }
85
+ }
79
86
 
80
87
  separator = buffer.indexOf('\n\n');
81
88
  }
@@ -87,7 +94,13 @@ export async function* parseSSE(
87
94
  if (buffer.trim()) {
88
95
  const raw = parseEventBlock(buffer);
89
96
  const evt = map(raw);
90
- if (evt) yield evt;
97
+ if (evt) {
98
+ if (Array.isArray(evt)) {
99
+ for (const e of evt) yield e;
100
+ } else {
101
+ yield evt;
102
+ }
103
+ }
91
104
  }
92
105
  } finally {
93
106
  try {
@@ -30,3 +30,28 @@ export {
30
30
  type UseAutoFocusOnStreamEndOptions,
31
31
  type Focusable,
32
32
  } from './useAutoFocusOnStreamEnd';
33
+ export {
34
+ useChatReset,
35
+ type UseChatResetOptions,
36
+ type UseChatResetReturn,
37
+ } from './useChatReset';
38
+ export {
39
+ useVisitorFingerprint,
40
+ type UseVisitorFingerprintOptions,
41
+ } from './useVisitorFingerprint';
42
+ export {
43
+ useChatDockPrefs,
44
+ DEFAULT_DOCK_PREFS,
45
+ type ChatDockPrefs,
46
+ type UseChatDockPrefsOptions,
47
+ type UseChatDockPrefsReturn,
48
+ } from './useChatDockPrefs';
49
+ export {
50
+ useFocusOnEmptyClick,
51
+ type UseFocusOnEmptyClickOptions,
52
+ } from './useFocusOnEmptyClick';
53
+ export {
54
+ useChatUnread,
55
+ type UseChatUnreadOptions,
56
+ type UseChatUnreadReturn,
57
+ } from './useChatUnread';
@@ -117,12 +117,14 @@ export function useAutoFocusOnStreamEnd(
117
117
  *
118
118
  * No-op when called outside a `<ChatProvider>`.
119
119
  */
120
- export function useRegisterComposer(focus: () => void): void {
120
+ export function useRegisterComposer(handle: ComposerHandle): void {
121
121
  const ctx = useChatContextOptional();
122
122
  const register = ctx?.registerComposer;
123
+ const focus = handle.focus;
124
+ const moveCursorToEnd = handle.moveCursorToEnd;
123
125
  useEffect(() => {
124
126
  if (!register) return;
125
- register({ focus });
127
+ register({ focus, moveCursorToEnd });
126
128
  return () => register(null);
127
- }, [register, focus]);
129
+ }, [register, focus, moveCursorToEnd]);
128
130
  }