@djangocfg/ui-tools 2.1.335 → 2.1.337
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 +68 -2
- package/dist/ChatRoot-PNNGQCYF.css +7 -0
- package/dist/ChatRoot-PNNGQCYF.css.map +1 -0
- package/dist/ChatRoot-XV2QXMV4.mjs +5 -0
- package/dist/ChatRoot-XV2QXMV4.mjs.map +1 -0
- package/dist/ChatRoot-YX4RLHQX.cjs +14 -0
- package/dist/ChatRoot-YX4RLHQX.cjs.map +1 -0
- package/dist/{CronScheduler.client-3O3VU4CI.mjs → CronScheduler.client-DLMXCPAJ.mjs} +4 -4
- package/dist/{CronScheduler.client-3O3VU4CI.mjs.map → CronScheduler.client-DLMXCPAJ.mjs.map} +1 -1
- package/dist/{CronScheduler.client-A4GO6YBY.cjs → CronScheduler.client-WEJF4PWQ.cjs} +14 -14
- package/dist/{CronScheduler.client-A4GO6YBY.cjs.map → CronScheduler.client-WEJF4PWQ.cjs.map} +1 -1
- package/dist/{DocsLayout-XLDB6CJ2.cjs → DocsLayout-N5ZJZPBY.cjs} +200 -199
- package/dist/DocsLayout-N5ZJZPBY.cjs.map +1 -0
- package/dist/{DocsLayout-CTJINVBM.mjs → DocsLayout-VFPPNKSQ.mjs} +7 -6
- package/dist/DocsLayout-VFPPNKSQ.mjs.map +1 -0
- package/dist/JsonSchemaForm-DD7CLRIG.cjs +13 -0
- package/dist/{JsonSchemaForm-6WMS4CIY.cjs.map → JsonSchemaForm-DD7CLRIG.cjs.map} +1 -1
- package/dist/JsonSchemaForm-XKUIVELK.mjs +4 -0
- package/dist/{JsonSchemaForm-KX4JT3M4.mjs.map → JsonSchemaForm-XKUIVELK.mjs.map} +1 -1
- package/dist/JsonTree-55625VVH.mjs +5 -0
- package/dist/{JsonTree-F27RMYSI.cjs.map → JsonTree-55625VVH.mjs.map} +1 -1
- package/dist/JsonTree-DCM5QGWF.cjs +11 -0
- package/dist/{JsonTree-QTJYSHCV.mjs.map → JsonTree-DCM5QGWF.cjs.map} +1 -1
- package/dist/{LottiePlayer.client-6WVWDO75.cjs → LottiePlayer.client-2S7ISJ2S.cjs} +6 -6
- package/dist/{LottiePlayer.client-6WVWDO75.cjs.map → LottiePlayer.client-2S7ISJ2S.cjs.map} +1 -1
- package/dist/{LottiePlayer.client-B4I6WNZM.mjs → LottiePlayer.client-5LDSSJWS.mjs} +4 -4
- package/dist/{LottiePlayer.client-B4I6WNZM.mjs.map → LottiePlayer.client-5LDSSJWS.mjs.map} +1 -1
- package/dist/{MapContainer-RYG4HPH4.cjs → MapContainer-76YL2JXL.cjs} +8 -8
- package/dist/{MapContainer-RYG4HPH4.cjs.map → MapContainer-76YL2JXL.cjs.map} +1 -1
- package/dist/{MapContainer-GXQLP5WY.mjs → MapContainer-7HXBI3OH.mjs} +3 -3
- package/dist/{MapContainer-GXQLP5WY.mjs.map → MapContainer-7HXBI3OH.mjs.map} +1 -1
- package/dist/{Mermaid.client-SXRRI2YW.mjs → Mermaid.client-NL4SVR7F.mjs} +4 -4
- package/dist/{Mermaid.client-SXRRI2YW.mjs.map → Mermaid.client-NL4SVR7F.mjs.map} +1 -1
- package/dist/{Mermaid.client-W76R5AKJ.cjs → Mermaid.client-NNTI6DFX.cjs} +26 -26
- package/dist/{Mermaid.client-W76R5AKJ.cjs.map → Mermaid.client-NNTI6DFX.cjs.map} +1 -1
- package/dist/Player-BRV7XTWR.mjs +4 -0
- package/dist/{Player-M3GC3VPE.mjs.map → Player-BRV7XTWR.mjs.map} +1 -1
- package/dist/Player-PM7F7DD7.cjs +13 -0
- package/dist/{Player-ZL2X5LGG.cjs.map → Player-PM7F7DD7.cjs.map} +1 -1
- package/dist/{PrettyCode.client-RPDIE5CH.cjs → PrettyCode.client-KOHDVPPN.cjs} +13 -13
- package/dist/{PrettyCode.client-RPDIE5CH.cjs.map → PrettyCode.client-KOHDVPPN.cjs.map} +1 -1
- package/dist/{PrettyCode.client-SPMTQEG4.mjs → PrettyCode.client-ZGYGKE7G.mjs} +4 -4
- package/dist/{PrettyCode.client-SPMTQEG4.mjs.map → PrettyCode.client-ZGYGKE7G.mjs.map} +1 -1
- package/dist/TreeRoot-N72OYKXU.cjs +19 -0
- package/dist/{TreeRoot-A3J65L6F.mjs.map → TreeRoot-N72OYKXU.cjs.map} +1 -1
- package/dist/TreeRoot-VGAIXCUA.mjs +4 -0
- package/dist/{TreeRoot-DSK5JILT.cjs.map → TreeRoot-VGAIXCUA.mjs.map} +1 -1
- package/dist/chunk-2ZLKZ5VR.mjs +631 -0
- package/dist/chunk-2ZLKZ5VR.mjs.map +1 -0
- package/dist/{chunk-LFWQ36LJ.mjs → chunk-5G5YBFS6.mjs} +4 -4
- package/dist/{chunk-LFWQ36LJ.mjs.map → chunk-5G5YBFS6.mjs.map} +1 -1
- package/dist/{chunk-IHAY6FO6.cjs → chunk-5I5QNGUG.cjs} +17 -17
- package/dist/{chunk-IHAY6FO6.cjs.map → chunk-5I5QNGUG.cjs.map} +1 -1
- package/dist/{chunk-F2CMIIOH.cjs → chunk-76NNDZH6.cjs} +42 -42
- package/dist/{chunk-F2CMIIOH.cjs.map → chunk-76NNDZH6.cjs.map} +1 -1
- package/dist/chunk-B5AWZOHJ.cjs +649 -0
- package/dist/chunk-B5AWZOHJ.cjs.map +1 -0
- package/dist/{chunk-KR6B3LVY.mjs → chunk-B6IR5KSC.mjs} +3 -3
- package/dist/{chunk-KR6B3LVY.mjs.map → chunk-B6IR5KSC.mjs.map} +1 -1
- package/dist/{chunk-5LBDYFWH.mjs → chunk-C6GXVH5J.mjs} +3 -3
- package/dist/{chunk-5LBDYFWH.mjs.map → chunk-C6GXVH5J.mjs.map} +1 -1
- package/dist/{chunk-NRKD4F5X.cjs → chunk-FEN5S772.cjs} +36 -36
- package/dist/{chunk-NRKD4F5X.cjs.map → chunk-FEN5S772.cjs.map} +1 -1
- package/dist/{chunk-2SMCH62O.cjs → chunk-FP2RLYQZ.cjs} +11 -11
- package/dist/{chunk-2SMCH62O.cjs.map → chunk-FP2RLYQZ.cjs.map} +1 -1
- package/dist/{chunk-MOME6KYD.mjs → chunk-G5IEC7SR.mjs} +3 -3
- package/dist/{chunk-MOME6KYD.mjs.map → chunk-G5IEC7SR.mjs.map} +1 -1
- package/dist/{chunk-SE5IERVH.mjs → chunk-GYIO7W7M.mjs} +3 -3
- package/dist/{chunk-SE5IERVH.mjs.map → chunk-GYIO7W7M.mjs.map} +1 -1
- package/dist/{chunk-3Z3A7FHA.cjs → chunk-IEEAENLX.cjs} +48 -48
- package/dist/{chunk-3Z3A7FHA.cjs.map → chunk-IEEAENLX.cjs.map} +1 -1
- package/dist/{chunk-DFTVB66S.cjs → chunk-KNDLV4PI.cjs} +85 -85
- package/dist/{chunk-DFTVB66S.cjs.map → chunk-KNDLV4PI.cjs.map} +1 -1
- package/dist/{chunk-SSUOENAZ.mjs → chunk-KNEQRUBA.mjs} +3 -3
- package/dist/{chunk-SSUOENAZ.mjs.map → chunk-KNEQRUBA.mjs.map} +1 -1
- package/dist/{chunk-CGILA3WO.mjs → chunk-N2XQF2OL.mjs} +5 -3
- package/dist/{chunk-CGILA3WO.mjs.map → chunk-N2XQF2OL.mjs.map} +1 -1
- package/dist/{chunk-EUADAUBQ.mjs → chunk-N4MZYNR4.mjs} +4 -4
- package/dist/{chunk-EUADAUBQ.mjs.map → chunk-N4MZYNR4.mjs.map} +1 -1
- package/dist/{chunk-GGKGH5PM.mjs → chunk-OBRSGM64.mjs} +4 -4
- package/dist/{chunk-GGKGH5PM.mjs.map → chunk-OBRSGM64.mjs.map} +1 -1
- package/dist/{chunk-6JTB2X72.mjs → chunk-ODO4GMW7.mjs} +3 -3
- package/dist/{chunk-6JTB2X72.mjs.map → chunk-ODO4GMW7.mjs.map} +1 -1
- package/dist/{chunk-WGEGR3DF.cjs → chunk-OLISEQHS.cjs} +5 -2
- package/dist/{chunk-WGEGR3DF.cjs.map → chunk-OLISEQHS.cjs.map} +1 -1
- package/dist/{chunk-PZKAH7WQ.mjs → chunk-PVAX67JG.mjs} +3 -3
- package/dist/{chunk-PZKAH7WQ.mjs.map → chunk-PVAX67JG.mjs.map} +1 -1
- package/dist/{chunk-PRPG2T2E.cjs → chunk-QJ6GTUCO.cjs} +6 -6
- package/dist/{chunk-PRPG2T2E.cjs.map → chunk-QJ6GTUCO.cjs.map} +1 -1
- package/dist/chunk-QW4RBGHN.cjs +961 -0
- package/dist/chunk-QW4RBGHN.cjs.map +1 -0
- package/dist/{chunk-33AMWFBZ.cjs → chunk-SGP7V2UW.cjs} +15 -15
- package/dist/{chunk-33AMWFBZ.cjs.map → chunk-SGP7V2UW.cjs.map} +1 -1
- package/dist/{chunk-FX2QFYWF.mjs → chunk-VWQ5WOIL.mjs} +3 -3
- package/dist/{chunk-FX2QFYWF.mjs.map → chunk-VWQ5WOIL.mjs.map} +1 -1
- package/dist/{chunk-ZLQHUZDU.cjs → chunk-YDPDTOSP.cjs} +139 -139
- package/dist/{chunk-ZLQHUZDU.cjs.map → chunk-YDPDTOSP.cjs.map} +1 -1
- package/dist/{chunk-77HQWEQ6.cjs → chunk-YW5IVWHQ.cjs} +33 -33
- package/dist/{chunk-77HQWEQ6.cjs.map → chunk-YW5IVWHQ.cjs.map} +1 -1
- package/dist/chunk-YWSQDBNU.mjs +2339 -0
- package/dist/chunk-YWSQDBNU.mjs.map +1 -0
- package/dist/{chunk-YXBOAGIM.cjs → chunk-YXZ6GU7H.cjs} +7 -7
- package/dist/{chunk-YXBOAGIM.cjs.map → chunk-YXZ6GU7H.cjs.map} +1 -1
- package/dist/{chunk-62Y65TGK.mjs → chunk-ZUFTH5IR.mjs} +8 -631
- package/dist/chunk-ZUFTH5IR.mjs.map +1 -0
- package/dist/chunk-ZWPBBAR2.cjs +2379 -0
- package/dist/chunk-ZWPBBAR2.cjs.map +1 -0
- package/dist/components-EHOGXATG.cjs +22 -0
- package/dist/{components-5UXYNAKR.cjs.map → components-EHOGXATG.cjs.map} +1 -1
- package/dist/components-MQ6DR7TX.cjs +26 -0
- package/dist/{components-CFXOEVPN.mjs.map → components-MQ6DR7TX.cjs.map} +1 -1
- package/dist/components-XRX7QGLB.mjs +5 -0
- package/dist/{components-WYEZL5TE.cjs.map → components-XRX7QGLB.mjs.map} +1 -1
- package/dist/components-YATKRWLH.mjs +5 -0
- package/dist/{components-ZAGG2PBO.mjs.map → components-YATKRWLH.mjs.map} +1 -1
- package/dist/file-icon/index.cjs +6 -6
- package/dist/file-icon/index.mjs +1 -1
- package/dist/index.cjs +739 -215
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1025 -39
- package/dist/index.d.ts +1025 -39
- package/dist/index.mjs +387 -31
- package/dist/index.mjs.map +1 -1
- package/dist/tree/index.cjs +38 -38
- package/dist/tree/index.d.cts +2 -2
- package/dist/tree/index.d.ts +2 -2
- package/dist/tree/index.mjs +3 -3
- package/package.json +6 -6
- package/src/index.ts +5 -0
- package/src/stories/index.ts +3 -1
- package/src/tools/Chat/Chat.story.tsx +1006 -0
- package/src/tools/Chat/README.md +528 -0
- package/src/tools/Chat/components/Attachments.tsx +192 -0
- package/src/tools/Chat/components/ChatRoot.tsx +208 -0
- package/src/tools/Chat/components/Composer.tsx +134 -0
- package/src/tools/Chat/components/EmptyState.tsx +47 -0
- package/src/tools/Chat/components/ErrorBanner.tsx +47 -0
- package/src/tools/Chat/components/JumpToLatest.tsx +30 -0
- package/src/tools/Chat/components/MessageActions.tsx +72 -0
- package/src/tools/Chat/components/MessageBubble.tsx +228 -0
- package/src/tools/Chat/components/MessageList.tsx +82 -0
- package/src/tools/Chat/components/Sources.tsx +55 -0
- package/src/tools/Chat/components/StreamingIndicator.tsx +29 -0
- package/src/tools/Chat/components/ToolCalls.tsx +172 -0
- package/src/tools/Chat/components/index.ts +24 -0
- package/src/tools/Chat/config.ts +55 -0
- package/src/tools/Chat/context/ChatProvider.tsx +126 -0
- package/src/tools/Chat/context/index.ts +9 -0
- package/src/tools/Chat/core/audio/audioBus.ts +172 -0
- package/src/tools/Chat/core/audio/index.ts +8 -0
- package/src/tools/Chat/core/audio/preferences.ts +68 -0
- package/src/tools/Chat/core/audio/types.ts +49 -0
- package/src/tools/Chat/core/ids.ts +16 -0
- package/src/tools/Chat/core/index.ts +5 -0
- package/src/tools/Chat/core/logger.ts +73 -0
- package/src/tools/Chat/core/markdown.ts +56 -0
- package/src/tools/Chat/core/payload-dispatch.ts +54 -0
- package/src/tools/Chat/core/persona.ts +35 -0
- package/src/tools/Chat/core/reducer.ts +335 -0
- package/src/tools/Chat/core/transport/http.ts +167 -0
- package/src/tools/Chat/core/transport/index.ts +13 -0
- package/src/tools/Chat/core/transport/mock.ts +134 -0
- package/src/tools/Chat/core/transport/sse.ts +116 -0
- package/src/tools/Chat/core/transport/types.ts +24 -0
- package/src/tools/Chat/hooks/index.ts +26 -0
- package/src/tools/Chat/hooks/useChat.ts +555 -0
- package/src/tools/Chat/hooks/useChatAudio.ts +191 -0
- package/src/tools/Chat/hooks/useChatComposer.ts +227 -0
- package/src/tools/Chat/hooks/useChatHistory.ts +59 -0
- package/src/tools/Chat/hooks/useChatLayout.ts +111 -0
- package/src/tools/Chat/hooks/useChatLightbox.ts +34 -0
- package/src/tools/Chat/hooks/useChatScroll.ts +132 -0
- package/src/tools/Chat/index.ts +161 -0
- package/src/tools/Chat/lazy.tsx +14 -0
- package/src/tools/Chat/types.ts +237 -0
- package/src/tools/Chat/utils/collectImageAttachments.ts +13 -0
- package/src/tools/Map/README.md +384 -0
- package/dist/DocsLayout-CTJINVBM.mjs.map +0 -1
- package/dist/DocsLayout-XLDB6CJ2.cjs.map +0 -1
- package/dist/JsonSchemaForm-6WMS4CIY.cjs +0 -13
- package/dist/JsonSchemaForm-KX4JT3M4.mjs +0 -4
- package/dist/JsonTree-F27RMYSI.cjs +0 -11
- package/dist/JsonTree-QTJYSHCV.mjs +0 -5
- package/dist/Player-M3GC3VPE.mjs +0 -4
- package/dist/Player-ZL2X5LGG.cjs +0 -13
- package/dist/TreeRoot-A3J65L6F.mjs +0 -4
- package/dist/TreeRoot-DSK5JILT.cjs +0 -19
- package/dist/chunk-62Y65TGK.mjs.map +0 -1
- package/dist/chunk-TKSFZHCG.cjs +0 -1597
- package/dist/chunk-TKSFZHCG.cjs.map +0 -1
- package/dist/components-5UXYNAKR.cjs +0 -22
- package/dist/components-CFXOEVPN.mjs +0 -5
- package/dist/components-WYEZL5TE.cjs +0 -26
- 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,126 @@
|
|
|
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
|
+
/** Enable verbose dev logging via consola. Defaults to `isDev`. */
|
|
38
|
+
debug?: boolean;
|
|
39
|
+
children?: ReactNode;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function ChatProvider({
|
|
43
|
+
transport,
|
|
44
|
+
config = {},
|
|
45
|
+
initialSessionId,
|
|
46
|
+
autoCreateSession,
|
|
47
|
+
streaming,
|
|
48
|
+
audio,
|
|
49
|
+
debug,
|
|
50
|
+
children,
|
|
51
|
+
}: ChatProviderProps) {
|
|
52
|
+
const audioApi = useChatAudio(audio ?? {});
|
|
53
|
+
|
|
54
|
+
// Keep latest audio API in a ref so the chat-callback closures stay
|
|
55
|
+
// referentially stable (don't re-mount transport on every audio change).
|
|
56
|
+
const audioRef = useRef(audioApi);
|
|
57
|
+
audioRef.current = audioApi;
|
|
58
|
+
|
|
59
|
+
const onMessageSent = useCallback(() => audioRef.current.play('messageSent'), []);
|
|
60
|
+
const onMessageEnd = useCallback(() => audioRef.current.play('messageReceived'), []);
|
|
61
|
+
const onStreamStart = useCallback(() => audioRef.current.play('streamStart'), []);
|
|
62
|
+
const onError = useCallback(() => audioRef.current.play('error'), []);
|
|
63
|
+
|
|
64
|
+
const chat = useChat({
|
|
65
|
+
transport,
|
|
66
|
+
initialSessionId,
|
|
67
|
+
autoCreateSession,
|
|
68
|
+
streaming,
|
|
69
|
+
debug,
|
|
70
|
+
metadata: {
|
|
71
|
+
locale: config.locale ?? config.prefs?.locale,
|
|
72
|
+
slug: config.slug,
|
|
73
|
+
},
|
|
74
|
+
userPersona: config.user,
|
|
75
|
+
onMessageSent,
|
|
76
|
+
onMessageEnd,
|
|
77
|
+
onStreamStart,
|
|
78
|
+
onError,
|
|
79
|
+
});
|
|
80
|
+
const layout = useChatLayout({ defaultMode: 'embedded' });
|
|
81
|
+
|
|
82
|
+
// Auto-unlock audio on the first user gesture inside the provider.
|
|
83
|
+
const rootRef = useRef<HTMLDivElement | null>(null);
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (audioApi.isUnlocked) return;
|
|
86
|
+
const root = rootRef.current;
|
|
87
|
+
if (!root) return;
|
|
88
|
+
const handler = () => {
|
|
89
|
+
audioApi.unlock();
|
|
90
|
+
};
|
|
91
|
+
root.addEventListener('pointerdown', handler, { once: true, capture: true });
|
|
92
|
+
root.addEventListener('keydown', handler, { once: true, capture: true });
|
|
93
|
+
return () => {
|
|
94
|
+
root.removeEventListener('pointerdown', handler, { capture: true });
|
|
95
|
+
root.removeEventListener('keydown', handler, { capture: true });
|
|
96
|
+
};
|
|
97
|
+
}, [audioApi]);
|
|
98
|
+
|
|
99
|
+
const labels = useMemo<ChatLabels>(
|
|
100
|
+
() => ({ ...DEFAULT_LABELS, ...(config.labels ?? {}) }),
|
|
101
|
+
[config.labels],
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
const value = useMemo<ChatContextValue>(
|
|
105
|
+
() => ({ ...chat, layout, config, labels, audio: audioApi }),
|
|
106
|
+
[chat, layout, config, labels, audioApi],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<Ctx.Provider value={value}>
|
|
111
|
+
<div ref={rootRef} style={{ display: 'contents' }}>
|
|
112
|
+
{children}
|
|
113
|
+
</div>
|
|
114
|
+
</Ctx.Provider>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function useChatContext(): ChatContextValue {
|
|
119
|
+
const v = useContext(Ctx);
|
|
120
|
+
if (!v) throw new Error('useChatContext must be used inside <ChatProvider>');
|
|
121
|
+
return v;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function useChatContextOptional(): ChatContextValue | null {
|
|
125
|
+
return useContext(Ctx);
|
|
126
|
+
}
|
|
@@ -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,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,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat dev logger.
|
|
3
|
+
*
|
|
4
|
+
* A thin namespaced wrapper over `consola` that no-ops in production unless
|
|
5
|
+
* the host app explicitly opts in via `<ChatRoot debug />`. The default
|
|
6
|
+
* detection uses `isDev` from `@djangocfg/ui-core/lib/env` (NODE_ENV).
|
|
7
|
+
*
|
|
8
|
+
* Why a dedicated module: chat is async and event-heavy (bootstrap, transport,
|
|
9
|
+
* SSE chunks, tool calls, regenerate, …). Inline `console.log`s rot fast and
|
|
10
|
+
* leak into prod. A single `getChatLogger()` call gives every layer the same
|
|
11
|
+
* namespaced sub-logger and keeps zero-cost gating in one place.
|
|
12
|
+
*
|
|
13
|
+
* Sub-loggers:
|
|
14
|
+
* bootstrap — initial session bootstrap (createSession / loadHistory)
|
|
15
|
+
* transport — outbound transport calls + responses
|
|
16
|
+
* stream — SSE chunk / tool / message_end events
|
|
17
|
+
* lifecycle — sendMessage, regenerate, newSession, edits
|
|
18
|
+
* tools — tool_call_start / _delta / _end specifics
|
|
19
|
+
* error — caught errors (always emitted as `error` level)
|
|
20
|
+
*/
|
|
21
|
+
import { consola, type ConsolaInstance } from 'consola';
|
|
22
|
+
|
|
23
|
+
import { isDev } from '@djangocfg/ui-core/lib';
|
|
24
|
+
|
|
25
|
+
export type ChatLogScope = 'bootstrap' | 'transport' | 'stream' | 'lifecycle' | 'tools' | 'error';
|
|
26
|
+
|
|
27
|
+
export interface ChatLogger {
|
|
28
|
+
bootstrap: ConsolaInstance;
|
|
29
|
+
transport: ConsolaInstance;
|
|
30
|
+
stream: ConsolaInstance;
|
|
31
|
+
lifecycle: ConsolaInstance;
|
|
32
|
+
tools: ConsolaInstance;
|
|
33
|
+
error: ConsolaInstance;
|
|
34
|
+
/** True when this logger is actually emitting (host opted in or NODE_ENV=development). */
|
|
35
|
+
enabled: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SCOPES: ChatLogScope[] = ['bootstrap', 'transport', 'stream', 'lifecycle', 'tools', 'error'];
|
|
39
|
+
|
|
40
|
+
/** Module-level cache so all hooks/components share the same logger instance per `enabled` mode. */
|
|
41
|
+
const cache = new Map<boolean, ChatLogger>();
|
|
42
|
+
|
|
43
|
+
function buildLogger(enabled: boolean): ChatLogger {
|
|
44
|
+
const root = consola.withTag('chat');
|
|
45
|
+
const subs = Object.fromEntries(
|
|
46
|
+
SCOPES.map((scope) => [scope, root.withTag(scope)]),
|
|
47
|
+
) as Record<ChatLogScope, ConsolaInstance>;
|
|
48
|
+
|
|
49
|
+
if (!enabled) {
|
|
50
|
+
// Silence everything except `error` — surfaced errors should never go
|
|
51
|
+
// missing even if the host didn't opt in to debug logs.
|
|
52
|
+
for (const scope of SCOPES) {
|
|
53
|
+
if (scope === 'error') continue;
|
|
54
|
+
subs[scope].level = -999;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return { ...subs, enabled };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get the chat logger.
|
|
63
|
+
* @param debug Explicit override from the host. `undefined` falls back to `isDev`.
|
|
64
|
+
*/
|
|
65
|
+
export function getChatLogger(debug?: boolean): ChatLogger {
|
|
66
|
+
const enabled = debug ?? isDev;
|
|
67
|
+
let logger = cache.get(enabled);
|
|
68
|
+
if (!logger) {
|
|
69
|
+
logger = buildLogger(enabled);
|
|
70
|
+
cache.set(enabled, logger);
|
|
71
|
+
}
|
|
72
|
+
return logger;
|
|
73
|
+
}
|
|
@@ -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
|
+
}
|