hts/packages/isdk/vue/use-chat.ts

264 lines
7.5 KiB
TypeScript

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<Message[]>;
/** The error object of the API request */
error: Ref<undefined | Error>;
/**
* 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<string | null | undefined>;
/**
* 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<string | null | undefined>;
/**
* 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<string>;
/** 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<boolean | undefined>;
/** Additional data added on the server via StreamData */
data: Ref<JSONValue[] | undefined>;
};
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<string, Message[] | undefined> = {};
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<Message[]>(
key,
() => store[key] || initialMessages,
);
const { data: isLoading, mutate: mutateLoading } = useSWRV<boolean>(
`${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<Message[]>;
const error = ref<undefined | Error>(undefined);
// cannot use JSONValue[] in ref because of infinite Typescript recursion:
const streamData = ref<undefined | unknown[]>(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<undefined | JSONValue[]>,
};
}