import swrv from 'swrv'; import type { Ref } from 'vue'; import { ref, unref } from 'vue'; import { callChatApi } from '../shared/call-chat-api'; import { generateId as generateIdFunc } from '../shared/generate-id'; import { processChatStream } from '../shared/process-chat-stream'; import type { ChatRequest, ChatRequestOptions, CreateMessage, JSONValue, Message, UseChatOptions, } from '../shared/types'; export type { CreateMessage, Message, UseChatOptions }; export type UseChatHelpers = { /** Current messages in the chat */ messages: Ref; /** The error object of the API request */ error: Ref; /** * Append a user message to the chat list. This triggers the API call to fetch * the assistant's response. */ append: ( message: Message | CreateMessage, chatRequestOptions?: ChatRequestOptions, ) => Promise; /** * Reload the last AI chat response for the given chat history. If the last * message isn't from the assistant, it will request the API to generate a * new response. */ reload: ( chatRequestOptions?: ChatRequestOptions, ) => Promise; /** * Abort the current request immediately, keep the generated tokens if any. */ stop: () => void; /** * Update the `messages` state locally. This is useful when you want to * edit the messages on the client, and then trigger the `reload` method * manually to regenerate the AI response. */ setMessages: (messages: Message[]) => void; /** The current value of the input */ input: Ref; /** Form submission handler to automatically reset input and append a user message */ handleSubmit: (e: any, chatRequestOptions?: ChatRequestOptions) => void; /** Whether the API request is in progress */ isLoading: Ref; /** Additional data added on the server via StreamData */ data: Ref; }; let uniqueId = 0; // @ts-expect-error - some issues with the default export of useSWRV const useSWRV = (swrv.default as typeof import('swrv')['default']) || swrv; const store: Record = {}; export function useChat({ api = '/api/chat', id, initialMessages = [], initialInput = '', sendExtraMessageFields, experimental_onFunctionCall, onResponse, onFinish, onError, credentials, headers, body, generateId = generateIdFunc, }: UseChatOptions = {}): UseChatHelpers { // Generate a unique ID for the chat if not provided. const chatId = id || `chat-${uniqueId++}`; const key = `${api}|${chatId}`; const { data: messagesData, mutate: originalMutate } = useSWRV( key, () => store[key] || initialMessages, ); const { data: isLoading, mutate: mutateLoading } = useSWRV( `${chatId}-loading`, null, ); isLoading.value ??= false; // Force the `data` to be `initialMessages` if it's `undefined`. messagesData.value ??= initialMessages; const mutate = (data?: Message[]) => { store[key] = data; return originalMutate(); }; // Because of the `initialData` option, the `data` will never be `undefined`. const messages = messagesData as Ref; const error = ref(undefined); // cannot use JSONValue[] in ref because of infinite Typescript recursion: const streamData = ref(undefined); let abortController: AbortController | null = null; async function triggerRequest( messagesSnapshot: Message[], { options, data }: ChatRequestOptions = {}, ) { try { error.value = undefined; mutateLoading(() => true); abortController = new AbortController(); // Do an optimistic update to the chat state to show the updated messages // immediately. const previousMessages = messagesData.value; mutate(messagesSnapshot); let chatRequest: ChatRequest = { messages: messagesSnapshot, options, data, }; await processChatStream({ getStreamedResponse: async () => { const existingData = (streamData.value ?? []) as JSONValue[]; return await callChatApi({ api, messages: sendExtraMessageFields ? chatRequest.messages : chatRequest.messages.map( ({ role, content, name, function_call }) => ({ role, content, ...(name !== undefined && { name }), ...(function_call !== undefined && { function_call: function_call, }), }), ), body: { data: chatRequest.data, ...unref(body), // Use unref to unwrap the ref value ...options?.body, }, headers: { ...headers, ...options?.headers, }, abortController: () => abortController, credentials, onResponse, onUpdate(merged, data) { mutate([...chatRequest.messages, ...merged]); streamData.value = [...existingData, ...(data ?? [])]; }, onFinish(message) { // workaround: sometimes the last chunk is not shown in the UI. // push it twice to make sure it's displayed. mutate([...chatRequest.messages, message]); onFinish?.(message); }, restoreMessagesOnFailure() { // Restore the previous messages if the request fails. mutate(previousMessages); }, generateId, }); }, experimental_onFunctionCall, updateChatRequest(newChatRequest) { chatRequest = newChatRequest; }, getCurrentMessages: () => messages.value, }); abortController = null; } catch (err) { // Ignore abort errors as they are expected. if ((err as any).name === 'AbortError') { abortController = null; return null; } if (onError && err instanceof Error) { onError(err); } error.value = err as Error; } finally { mutateLoading(() => false); } } const append: UseChatHelpers['append'] = async (message, options) => { if (!message.id) { message.id = generateId(); } return triggerRequest(messages.value.concat(message as Message), options); }; const reload: UseChatHelpers['reload'] = async options => { const messagesSnapshot = messages.value; if (messagesSnapshot.length === 0) return null; const lastMessage = messagesSnapshot[messagesSnapshot.length - 1]; if (lastMessage.role === 'assistant') { return triggerRequest(messagesSnapshot.slice(0, -1), options); } return triggerRequest(messagesSnapshot, options); }; const stop = () => { if (abortController) { abortController.abort(); abortController = null; } }; const setMessages = (messages: Message[]) => { mutate(messages); }; const input = ref(initialInput); const handleSubmit = (e: any, options: ChatRequestOptions = {}) => { e.preventDefault(); const inputValue = input.value; if (!inputValue) return; append( { content: inputValue, role: 'user', }, options, ); input.value = ''; }; return { messages, append, error, reload, stop, setMessages, input, handleSubmit, isLoading, data: streamData as Ref, }; }