UNPKG

@ai-sdk/svelte

Version:

[Svelte](https://svelte.dev/) UI components for the [AI SDK](https://ai-sdk.dev/docs):

251 lines (250 loc) • 10.3 kB
import { fillMessageParts, generateId, extractMaxToolInvocationStep, callChatApi, shouldResubmitMessages, prepareAttachmentsForRequest, getMessageParts, updateToolCallResult, isAssistantMessageWithCompletedToolCalls, } from '@ai-sdk/ui-utils'; import { isAbortError } from '@ai-sdk/provider-utils'; import { KeyedChatStore, getChatContext, hasChatContext, } from './chat-context.svelte.js'; import { untrack } from 'svelte'; export class Chat { #options = {}; #api = $derived(this.#options.api ?? '/api/chat'); #generateId = $derived(this.#options.generateId ?? generateId); #maxSteps = $derived(this.#options.maxSteps ?? 1); #streamProtocol = $derived(this.#options.streamProtocol ?? 'data'); #keyedStore = $state(); /** * The id of the chat. If not provided through the constructor, a random ID will be generated * using the provided `generateId` function, or a built-in function if not provided. */ id = $derived(this.#options.id ?? this.#generateId()); #store = $derived(this.#keyedStore.get(this.id)); #abortController; /** * Additional data added on the server via StreamData. * * This is writable, so you can use it to transform or clear the chat data. */ get data() { return this.#store.data; } set data(value) { this.#store.data = value; } /** * Hook status: * * - `submitted`: The message has been sent to the API and we're awaiting the start of the response stream. * - `streaming`: The response is actively streaming in from the API, receiving chunks of data. * - `ready`: The full response has been received and processed; a new user message can be submitted. * - `error`: An error occurred during the API request, preventing successful completion. */ get status() { return this.#store.status; } /** The error object of the API request */ get error() { return this.#store.error; } /** The current value of the input. Writable, so it can be bound to form inputs. */ input = $state(); /** * Current messages in the chat. * * This is writable, which is useful when you want to edit the messages on the client, and then * trigger {@link reload} to regenerate the AI response. */ get messages() { return this.#store.messages; } set messages(value) { untrack(() => (this.#store.messages = fillMessageParts(value))); } constructor(options = {}) { if (hasChatContext()) { this.#keyedStore = getChatContext(); } else { this.#keyedStore = new KeyedChatStore(); } this.#options = options; this.messages = options.initialMessages ?? []; this.input = options.initialInput ?? ''; } /** * Append a user message to the chat list. This triggers the API call to fetch * the assistant's response. * @param message The message to append * @param options Additional options to pass to the API call */ append = async (message, { data, headers, body, experimental_attachments } = {}) => { const attachmentsForRequest = await prepareAttachmentsForRequest(experimental_attachments); const messages = this.messages.concat({ ...message, id: message.id ?? this.#generateId(), createdAt: message.createdAt ?? new Date(), experimental_attachments: attachmentsForRequest.length > 0 ? attachmentsForRequest : undefined, parts: getMessageParts(message), }); return this.#triggerRequest({ messages, headers, body, data }); }; /** * 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 = async ({ data, headers, body } = {}) => { if (this.messages.length === 0) { return; } const lastMessage = this.messages[this.messages.length - 1]; await this.#triggerRequest({ messages: lastMessage.role === 'assistant' ? this.messages.slice(0, -1) : this.messages, headers, body, data, }); }; /** * Abort the current request immediately, keep the generated tokens if any. */ stop = () => { try { this.#abortController?.abort(); } catch { // ignore } finally { this.#store.status = 'ready'; this.#abortController = undefined; } }; /** Form submission handler to automatically reset input and append a user message */ handleSubmit = async (event, options = {}) => { event?.preventDefault?.(); if (!this.input && !options.allowEmptySubmit) return; const attachmentsForRequest = await prepareAttachmentsForRequest(options.experimental_attachments); const messages = this.messages.concat({ id: this.#generateId(), createdAt: new Date(), role: 'user', content: this.input, experimental_attachments: attachmentsForRequest.length > 0 ? attachmentsForRequest : undefined, parts: [{ type: 'text', text: this.input }], }); const chatRequest = { messages, headers: options.headers, body: options.body, data: options.data, }; const request = this.#triggerRequest(chatRequest); this.input = ''; await request; }; addToolResult = async ({ toolCallId, result, }) => { updateToolCallResult({ messages: this.messages, toolCallId, toolResult: result, }); // when the request is ongoing, the auto-submit will be triggered after the request is finished if (this.#store.status === 'submitted' || this.#store.status === 'streaming') { return; } const lastMessage = this.messages[this.messages.length - 1]; if (isAssistantMessageWithCompletedToolCalls(lastMessage)) { await this.#triggerRequest({ messages: this.messages }); } }; #triggerRequest = async (chatRequest) => { this.#store.status = 'submitted'; this.#store.error = undefined; const messages = fillMessageParts(chatRequest.messages); const messageCount = messages.length; const maxStep = extractMaxToolInvocationStep(messages[messages.length - 1]?.toolInvocations); try { const abortController = new AbortController(); this.#abortController = abortController; // Optimistically update messages this.messages = messages; const constructedMessagesPayload = this.#options.sendExtraMessageFields ? messages : messages.map(({ role, content, experimental_attachments, data, annotations, toolInvocations, parts, }) => ({ role, content, ...(experimental_attachments !== undefined && { experimental_attachments, }), ...(data !== undefined && { data }), ...(annotations !== undefined && { annotations }), ...(toolInvocations !== undefined && { toolInvocations }), ...(parts !== undefined && { parts }), })); const existingData = this.data ?? []; await callChatApi({ api: this.#api, body: { id: this.id, messages: constructedMessagesPayload, data: chatRequest.data, ...$state.snapshot(this.#options.body), ...chatRequest.body, }, streamProtocol: this.#streamProtocol, credentials: this.#options.credentials, headers: { ...this.#options.headers, ...chatRequest.headers, }, abortController: () => abortController, restoreMessagesOnFailure: () => { }, onResponse: this.#options.onResponse, onUpdate: ({ message, data, replaceLastMessage }) => { this.#store.status = 'streaming'; this.messages = messages; if (replaceLastMessage) { this.messages[this.messages.length - 1] = message; } else { this.messages.push(message); } if (data?.length) { this.data = existingData; this.data.push(...data); } }, onToolCall: this.#options.onToolCall, onFinish: this.#options.onFinish, generateId: this.#generateId, fetch: this.#options.fetch, // callChatApi calls structuredClone on the message lastMessage: $state.snapshot(this.messages[this.messages.length - 1]), }); this.#abortController = undefined; this.#store.status = 'ready'; } catch (error) { if (isAbortError(error)) { return; } const coalescedError = error instanceof Error ? error : new Error(String(error)); if (this.#options.onError) { this.#options.onError(coalescedError); } this.#store.status = 'error'; this.#store.error = coalescedError; } // auto-submit when all tool calls in the last assistant message have results // and assistant has not answered yet if (shouldResubmitMessages({ originalMaxToolInvocationStep: maxStep, originalMessageCount: messageCount, maxSteps: this.#maxSteps, messages: this.messages, })) { await this.#triggerRequest({ messages: this.messages }); } }; }