@djangocfg/ui-tools 2.1.381 → 2.1.382
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +132 -899
- package/dist/ChatRoot-6IZFM5HM.mjs +5 -0
- package/dist/{ChatRoot-EJC5Y2YM.cjs.map → ChatRoot-6IZFM5HM.mjs.map} +1 -1
- package/dist/ChatRoot-LW4XNIKP.cjs +14 -0
- package/dist/{ChatRoot-QOSKJPM6.mjs.map → ChatRoot-LW4XNIKP.cjs.map} +1 -1
- package/dist/DictationField-2ZLQWLYV.mjs +4 -0
- package/dist/DictationField-2ZLQWLYV.mjs.map +1 -0
- package/dist/DictationField-IPPJ54CU.cjs +13 -0
- package/dist/DictationField-IPPJ54CU.cjs.map +1 -0
- package/dist/{DocsLayout-2YKPXZYO.mjs → DocsLayout-2P3ONDWJ.mjs} +3 -3
- package/dist/{DocsLayout-2YKPXZYO.mjs.map → DocsLayout-2P3ONDWJ.mjs.map} +1 -1
- package/dist/{DocsLayout-Q4KS3QWW.cjs → DocsLayout-2YZNS5VK.cjs} +8 -8
- package/dist/{DocsLayout-Q4KS3QWW.cjs.map → DocsLayout-2YZNS5VK.cjs.map} +1 -1
- package/dist/chunk-4LXG3NBV.mjs +833 -0
- package/dist/chunk-4LXG3NBV.mjs.map +1 -0
- package/dist/{chunk-XACCHZH2.cjs → chunk-FIRK5CEH.cjs} +42 -4
- package/dist/chunk-FIRK5CEH.cjs.map +1 -0
- package/dist/{chunk-NWUT327A.mjs → chunk-HIK6BPL7.mjs} +38 -5
- package/dist/chunk-HIK6BPL7.mjs.map +1 -0
- package/dist/chunk-KMSBGNVC.cjs +835 -0
- package/dist/chunk-KMSBGNVC.cjs.map +1 -0
- package/dist/chunk-OZAU3QWD.cjs +2493 -0
- package/dist/chunk-OZAU3QWD.cjs.map +1 -0
- package/dist/chunk-UWVP6LCW.mjs +2447 -0
- package/dist/chunk-UWVP6LCW.mjs.map +1 -0
- package/dist/index.cjs +1532 -100
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1148 -107
- package/dist/index.d.ts +1148 -107
- package/dist/index.mjs +1421 -51
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -8
- package/src/audio-assets.d.ts +8 -0
- package/src/components/markdown/MarkdownMessage/CollapseToggle.tsx +3 -1
- package/src/components/markdown/MarkdownMessage/components.tsx +2 -5
- package/src/stories/index.ts +32 -2
- package/src/tools/Chat/README.md +347 -530
- package/src/tools/Chat/components/Attachments.tsx +6 -1
- package/src/tools/Chat/components/ChatRoot.tsx +30 -2
- package/src/tools/Chat/components/Composer.tsx +20 -3
- package/src/tools/Chat/components/ErrorBanner.tsx +7 -3
- package/src/tools/Chat/components/MessageActions.tsx +3 -1
- package/src/tools/Chat/components/MessageBubble.tsx +6 -5
- package/src/tools/Chat/components/MessageList.tsx +87 -1
- package/src/tools/Chat/components/ToolCalls.tsx +21 -3
- package/src/tools/Chat/context/ChatProvider.tsx +21 -3
- package/src/tools/Chat/core/audio/audioBus.ts +10 -163
- package/src/tools/Chat/core/audio/defaults.ts +43 -0
- package/src/tools/Chat/core/audio/index.ts +1 -0
- package/src/tools/Chat/core/audio/preferences.ts +5 -59
- package/src/tools/Chat/core/audio/sounds/error.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/mention.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/notification.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/received.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/sent.mp3 +0 -0
- package/src/tools/Chat/core/audio/sounds/start.mp3 +0 -0
- package/src/tools/Chat/core/audio/types.ts +28 -0
- package/src/tools/Chat/core/reducer.ts +33 -0
- package/src/tools/Chat/core/transport/index.ts +13 -0
- package/src/tools/Chat/core/transport/mappers/index.ts +6 -0
- package/src/tools/Chat/core/transport/mappers/pydantic-ai.ts +142 -0
- package/src/tools/Chat/core/transport/pydantic-ai-transport.ts +208 -0
- package/src/tools/Chat/core/transport/sse.ts +18 -5
- package/src/tools/Chat/hooks/index.ts +25 -0
- package/src/tools/Chat/hooks/useAutoFocusOnStreamEnd.ts +5 -3
- package/src/tools/Chat/hooks/useChat.ts +28 -0
- package/src/tools/Chat/hooks/useChatAudio.ts +59 -180
- package/src/tools/Chat/hooks/useChatDockPrefs.ts +74 -0
- package/src/tools/Chat/hooks/useChatReset.ts +70 -0
- package/src/tools/Chat/hooks/useChatUnread.ts +87 -0
- package/src/tools/Chat/hooks/useFocusOnEmptyClick.ts +111 -0
- package/src/tools/Chat/hooks/useVisitorFingerprint.ts +48 -0
- package/src/tools/Chat/index.ts +69 -1
- package/src/tools/Chat/launcher/ChatDock.tsx +263 -0
- package/src/tools/Chat/launcher/ChatFAB.tsx +349 -0
- package/src/tools/Chat/launcher/ChatGreeting.tsx +200 -0
- package/src/tools/Chat/launcher/ChatHeader.tsx +76 -0
- package/src/tools/Chat/launcher/ChatHeaderActionButton.tsx +87 -0
- package/src/tools/Chat/launcher/ChatHeaderAudioToggle.tsx +47 -0
- package/src/tools/Chat/launcher/ChatHeaderLanguageButton.tsx +179 -0
- package/src/tools/Chat/launcher/ChatHeaderModeToggle.tsx +57 -0
- package/src/tools/Chat/launcher/ChatHeaderResetButton.tsx +93 -0
- package/src/tools/Chat/launcher/ChatLauncher.tsx +321 -0
- package/src/tools/Chat/launcher/ChatUnreadPreview.tsx +197 -0
- package/src/tools/Chat/launcher/index.ts +46 -0
- package/src/tools/Chat/launcher/useChatPresence.ts +44 -0
- package/src/tools/Chat/stories/01-basic.story.tsx +64 -0
- package/src/tools/Chat/stories/02-bubbles.story.tsx +21 -0
- package/src/tools/Chat/stories/03-tool-calls.story.tsx +59 -0
- package/src/tools/Chat/stories/04-personas.story.tsx +78 -0
- package/src/tools/Chat/stories/05-launcher.story.tsx +321 -0
- package/src/tools/Chat/stories/06-header.story.tsx +147 -0
- package/src/tools/Chat/stories/07-audio-actions.story.tsx +112 -0
- package/src/tools/Chat/stories/shared/Frame.tsx +21 -0
- package/src/tools/Chat/stories/shared/index.ts +5 -0
- package/src/tools/Chat/stories/shared/messages.ts +39 -0
- package/src/tools/Chat/stories/shared/personas.ts +13 -0
- package/src/tools/Chat/stories/shared/seeds.ts +92 -0
- package/src/tools/Chat/stories/shared/transports.ts +36 -0
- package/src/tools/Chat/styles/bubbleTokens.ts +71 -0
- package/src/tools/Chat/styles/index.ts +16 -0
- package/src/tools/Chat/styles/useChatStyles.ts +101 -0
- package/src/tools/Chat/types/attachment.ts +25 -0
- package/src/tools/Chat/types/config.ts +48 -0
- package/src/tools/Chat/types/events.ts +35 -0
- package/src/tools/Chat/types/index.ts +34 -0
- package/src/tools/Chat/types/labels.ts +38 -0
- package/src/tools/Chat/types/message.ts +32 -0
- package/src/tools/Chat/types/persona.ts +31 -0
- package/src/tools/Chat/types/session.ts +43 -0
- package/src/tools/Chat/types/tool-call.ts +17 -0
- package/src/tools/Chat/types/transport.ts +28 -0
- package/src/tools/Chat/types.ts +5 -240
- package/src/tools/MarkdownEditor/MarkdownEditor.tsx +50 -14
- package/src/tools/MarkdownEditor/index.ts +1 -1
- package/src/tools/SpeechRecognition/README.md +336 -0
- package/src/tools/SpeechRecognition/__tests__/ids.test.ts +15 -0
- package/src/tools/SpeechRecognition/__tests__/language.test.ts +59 -0
- package/src/tools/SpeechRecognition/__tests__/reducer.test.ts +71 -0
- package/src/tools/SpeechRecognition/__tests__/transcript.test.ts +52 -0
- package/src/tools/SpeechRecognition/components/DevicePicker.tsx +49 -0
- package/src/tools/SpeechRecognition/components/DictationButton.tsx +93 -0
- package/src/tools/SpeechRecognition/components/EngineBadge.tsx +30 -0
- package/src/tools/SpeechRecognition/components/ErrorBanner.tsx +52 -0
- package/src/tools/SpeechRecognition/components/LanguagePicker.tsx +63 -0
- package/src/tools/SpeechRecognition/components/MicMeter.tsx +63 -0
- package/src/tools/SpeechRecognition/components/PushToTalkHint.tsx +51 -0
- package/src/tools/SpeechRecognition/components/TranscriptView.tsx +55 -0
- package/src/tools/SpeechRecognition/components/index.ts +16 -0
- package/src/tools/SpeechRecognition/context/SpeechRecognitionProvider.tsx +47 -0
- package/src/tools/SpeechRecognition/context/index.ts +6 -0
- package/src/tools/SpeechRecognition/core/audio/defaults.ts +24 -0
- package/src/tools/SpeechRecognition/core/engine/external.ts +222 -0
- package/src/tools/SpeechRecognition/core/engine/http.ts +147 -0
- package/src/tools/SpeechRecognition/core/engine/index.ts +52 -0
- package/src/tools/SpeechRecognition/core/engine/mediarecorder.ts +105 -0
- package/src/tools/SpeechRecognition/core/engine/websocket.ts +211 -0
- package/src/tools/SpeechRecognition/core/engine/webspeech.ts +188 -0
- package/src/tools/SpeechRecognition/core/ids.ts +11 -0
- package/src/tools/SpeechRecognition/core/index.ts +14 -0
- package/src/tools/SpeechRecognition/core/language.ts +78 -0
- package/src/tools/SpeechRecognition/core/languages-catalog.ts +229 -0
- package/src/tools/SpeechRecognition/core/logger.ts +3 -0
- package/src/tools/SpeechRecognition/core/reducer.ts +105 -0
- package/src/tools/SpeechRecognition/core/transcript.ts +36 -0
- package/src/tools/SpeechRecognition/hooks/index.ts +14 -0
- package/src/tools/SpeechRecognition/hooks/useDictation.ts +59 -0
- package/src/tools/SpeechRecognition/hooks/useEnginePrefs.ts +15 -0
- package/src/tools/SpeechRecognition/hooks/useMicDevices.ts +57 -0
- package/src/tools/SpeechRecognition/hooks/useMicLevel.ts +52 -0
- package/src/tools/SpeechRecognition/hooks/usePushToTalk.ts +85 -0
- package/src/tools/SpeechRecognition/hooks/useResolvedLanguage.ts +28 -0
- package/src/tools/SpeechRecognition/hooks/useSpeechLanguageInfo.ts +108 -0
- package/src/tools/SpeechRecognition/hooks/useSpeechRecognition.ts +188 -0
- package/src/tools/SpeechRecognition/hooks/useVoiceSupport.ts +78 -0
- package/src/tools/SpeechRecognition/index.ts +82 -0
- package/src/tools/SpeechRecognition/lazy.tsx +19 -0
- package/src/tools/SpeechRecognition/store/index.ts +2 -0
- package/src/tools/SpeechRecognition/store/prefsStore.ts +54 -0
- package/src/tools/SpeechRecognition/stories/01-basic.story.tsx +32 -0
- package/src/tools/SpeechRecognition/stories/02-dictation-field.story.tsx +32 -0
- package/src/tools/SpeechRecognition/stories/03-push-to-talk.story.tsx +27 -0
- package/src/tools/SpeechRecognition/stories/04-mic-meter.story.tsx +35 -0
- package/src/tools/SpeechRecognition/stories/05-custom-engine-http.story.tsx +40 -0
- package/src/tools/SpeechRecognition/stories/06-custom-engine-ws.story.tsx +48 -0
- package/src/tools/SpeechRecognition/stories/07-language-device.story.tsx +57 -0
- package/src/tools/SpeechRecognition/stories/08-errors-permissions.story.tsx +25 -0
- package/src/tools/SpeechRecognition/stories/09-chat-voice.story.tsx +90 -0
- package/src/tools/SpeechRecognition/stories/shared.tsx +123 -0
- package/src/tools/SpeechRecognition/types.ts +133 -0
- package/src/tools/SpeechRecognition/widgets/DictationField.tsx +105 -0
- package/src/tools/SpeechRecognition/widgets/VoiceComposerSlot.tsx +305 -0
- package/src/tools/SpeechRecognition/widgets/VoiceMessageRecorder.tsx +88 -0
- package/src/tools/SpeechRecognition/widgets/index.ts +6 -0
- package/dist/ChatRoot-EJC5Y2YM.cjs +0 -14
- package/dist/ChatRoot-QOSKJPM6.mjs +0 -5
- package/dist/chunk-NWUT327A.mjs.map +0 -1
- package/dist/chunk-QLMKCSR6.mjs +0 -2420
- package/dist/chunk-QLMKCSR6.mjs.map +0 -1
- package/dist/chunk-SI5RD2GD.cjs +0 -2460
- package/dist/chunk-SI5RD2GD.cjs.map +0 -1
- package/dist/chunk-XACCHZH2.cjs.map +0 -1
- package/src/tools/Chat/Chat.story.tsx +0 -1457
|
@@ -1,68 +1,14 @@
|
|
|
1
|
-
//
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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);
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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,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
|
|
20
|
-
* and assume the JSON shape already
|
|
21
|
-
|
|
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)
|
|
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)
|
|
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(
|
|
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
|
}
|