import { type ComponentPropsWithoutRef, createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState, } from 'react'; import { customAlphabet } from 'nanoid'; import clsx from 'clsx'; import { StateStore } from '@stream-io/state-store'; import { useStateStore } from '@stream-io/state-store/react-bindings'; import { AttachmentPreview } from './attachment-preview'; import { useSpeechToText, type UseSpeechToTextOptions, } from './use-speech-to-text'; import { useStableCallback } from '../../hooks/use-stable-callback'; const nanoId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 15); const FileInput = ({ labelProps, ...restProps }: ComponentPropsWithoutRef<'input'> & { labelProps?: ComponentPropsWithoutRef<'label'>; }) => { const { disabled } = useIsDisabled(); return ( {({ id }) => ( <> )} ); }; const WithStableId = ({ children, }: { children?: ReactNode | (({ id }: { id: string }) => ReactNode); }) => { const id = useMemo(() => `file-input-${nanoId()}`, []); return <>{typeof children === 'function' ? children({ id }) : children}; }; FileInput.WithStableId = WithStableId; export type AIMessageComposerStore = { attachments: { id: string; file: File; meta?: Record; }[]; text: string; disabled?: boolean; }; const initialStoreState: AIMessageComposerStore = { attachments: [], text: '', disabled: false, }; const AIMessageComposerContext = createContext< StateStore >(new StateStore(initialStoreState)); export const useAIMessageComposerContext = () => useContext(AIMessageComposerContext); export const useAttachments = () => { const store = useAIMessageComposerContext(); const removeAttachment = useCallback( (idOrFile: string | File) => { store.next((currentState) => ({ ...currentState, attachments: currentState.attachments.filter((attachment) => { if (typeof idOrFile === 'string') { return attachment.id !== idOrFile; } return attachment.file !== idOrFile; }), })); }, [store], ); const updateAttachments = useCallback( ( idsOrAttachments: ( | string | AIMessageComposerStore['attachments'][number] )[], update: ( attachment: AIMessageComposerStore['attachments'][number], ) => AIMessageComposerStore['attachments'][number], ) => { store.next((currentState) => { let hasChanges = false; const newAttachments = [...currentState.attachments]; for (const idOrAttachment of idsOrAttachments) { const attachmentIndex = typeof idOrAttachment === 'string' ? currentState.attachments.findIndex( (a) => a.id === idOrAttachment, ) : currentState.attachments.indexOf(idOrAttachment); if (attachmentIndex === -1) { continue; } const updatedAttachment = update( currentState.attachments[attachmentIndex]!, ); if ( updatedAttachment !== currentState.attachments[attachmentIndex]! ) { newAttachments[attachmentIndex] = updatedAttachment; hasChanges = true; } } if (!hasChanges) { return currentState; } return { ...currentState, attachments: newAttachments, }; }); }, [store], ); const selector = useCallback( (currentState: AIMessageComposerStore) => ({ attachments: currentState.attachments, }), [], ); const { attachments } = useStateStore(store, selector); return useMemo( () => ({ attachments, removeAttachment, updateAttachments }), [attachments, removeAttachment, updateAttachments], ); }; export const useText = () => { const store = useAIMessageComposerContext(); const selector = useCallback( (currentState: AIMessageComposerStore) => ({ text: currentState.text, }), [], ); const setText = useCallback( (text: string) => { store.next((currentState) => { if (currentState.text === text) { return currentState; } return { ...currentState, text, }; }); }, [store], ); const { text } = useStateStore(store, selector); return { text, setText }; }; export const useIsDisabled = () => { const store = useAIMessageComposerContext(); const selector = useCallback( (currentState: AIMessageComposerStore) => ({ disabled: currentState.disabled, }), [], ); const setDisabled = useCallback( (disabled: boolean) => store.partialNext({ disabled }), [store], ); const { disabled } = useStateStore(store, selector); return { disabled, setDisabled }; }; type AIMessageComposerProps = ComponentPropsWithoutRef<'form'> & { /** * Resets a value of an input with name `attachments` and of type `file` when user selects files so that * they can select the same file again if needed. * * @default true */ resetAttachmentsOnSelect?: boolean; nameMapping?: { message?: string; attachments?: string; }; /** * Disables the composer. */ disabled?: boolean; }; interface AIMessageComposer { (props: AIMessageComposerProps): JSX.Element; FileInput: typeof FileInput; TextInput: typeof TextInput; SpeechToTextButton: typeof SpeechToTextButton; SubmitButton: typeof SubmitButton; ModelSelect: typeof ModelSelect; AttachmentPreview: typeof AttachmentPreview; } export const AIMessageComposer: AIMessageComposer = ({ children, onChange, onReset, resetAttachmentsOnSelect = true, nameMapping, disabled, ...restProps }) => { const [stateStore] = useState( () => new StateStore(initialStoreState), ); useEffect(() => { stateStore.partialNext({ disabled }); }, [disabled, stateStore]); const handleChange = useStableCallback( (e: React.ChangeEvent) => { onChange?.(e); const inputElement = e.target as unknown as HTMLInputElement; const messageName = nameMapping?.message ?? 'message'; const attachmentsName = nameMapping?.attachments ?? 'attachments'; const files = inputElement.name === attachmentsName ? inputElement.files : null; const text = inputElement.name === messageName ? inputElement.value : null; stateStore.next((currentState) => { const newState = { ...currentState }; if (files && files.length > 0) { const newFiles = Array.from(files).map( (file) => ({ id: nanoId(), file, }) satisfies AIMessageComposerStore['attachments'][number], ); newState.attachments = newState.attachments.concat(newFiles); } if (text !== null) { newState.text = text; } if ( newState.attachments !== currentState.attachments || newState.text !== currentState.text ) { return newState; } return currentState; }); if ( resetAttachmentsOnSelect && inputElement.type === 'file' && inputElement.name === attachmentsName ) { inputElement.value = ''; } }, ); return (
{ onReset?.(e); stateStore.next(initialStoreState); }} {...restProps} > {children}
); }; const noop = () => {}; const TextInput = (props: ComponentPropsWithoutRef<'input'>) => { const { text } = useText(); const { disabled } = useIsDisabled(); return ( ); }; const SpeechToTextButton = ( props: ComponentPropsWithoutRef<'button'> & { options?: UseSpeechToTextOptions; }, ) => { const { setText } = useText(); const { disabled } = useIsDisabled(); const { startListening, stopListening, isListening } = useSpeechToText({ onTranscript: setText, onError: console.error, }); return ( ); }; const SubmitButton = ({ active, ...restProps }: ComponentPropsWithoutRef<'button'> & { active?: boolean }) => { const { disabled } = useIsDisabled(); return ( ); }; const availableModels = [ { platform: 'openai', value: 'gpt-4o-mini', label: 'GPT-4o mini' }, { platform: 'openai', value: 'gpt-4o', label: 'GPT-4o' }, ] as const; const [defaultModel] = availableModels; const defaultPlatformModel = `${defaultModel.platform}|${defaultModel.value}`; const ModelSelect = ( props: ComponentPropsWithoutRef<'select'> & { options?: ReactNode }, ) => { const { options = ( <> {availableModels.map((model) => ( ))} ), ...restProps } = props; const { disabled } = useIsDisabled(); return ( ); }; AIMessageComposer.FileInput = FileInput; AIMessageComposer.TextInput = TextInput; AIMessageComposer.SpeechToTextButton = SpeechToTextButton; AIMessageComposer.SubmitButton = SubmitButton; AIMessageComposer.ModelSelect = ModelSelect; AIMessageComposer.AttachmentPreview = AttachmentPreview;