UNPKG

@fine-dev/fine-js

Version:

Javascript client for Fine BaaS

279 lines (238 loc) 9.13 kB
import { Fetch } from "./types" export type AIConfig = { baseUrl: string fetch: Fetch } export type AIThreadData = { id: string object: "thread" createdAt: number metadata: Record<string, unknown> } export type AIMessageContent = { type: "text"; text: string } | { type: "image"; imageUrl: string } export type AIMessageAttachment = { data: string; name: string } export type AIMessageData = { id: string threadId: string status: "in_progress" | "completed" | "incomplete" content: AIMessageContent role: "assistant" | "user" assistantId?: string | null createdAt: number completedAt?: number | null metadata: Record<string, unknown> attachments?: AIMessageAttachment[] } type MessageRunResult = | { runId: string status: "completed" object: "thread.run" threadId: string assistantId: string messageId: string content: string } | { runId: string status: "failed" error: string } | { error: string } type ListOrSingle<T> = T | T[] async function fileToAttachment(file: File) { return new Promise<AIMessageAttachment>((resolve, reject) => { const reader = new FileReader() reader.addEventListener("load", () => resolve({ data: reader.result as string, name: file.name })) reader.addEventListener("error", () => reject(reader.error)) reader.readAsDataURL(file) }) } export default class FineAIClient { private config: AIConfig constructor({ baseUrl, headers, fetch: customFetch }: { baseUrl: string; fetch?: Fetch; headers?: HeadersInit }) { this.config = { baseUrl: baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl, fetch(input, init) { if (!customFetch) customFetch = fetch.bind(window) return customFetch(input, { ...init, headers, credentials: "include" }) } } } public get threads() { return this.config.fetch(`${this.config.baseUrl}/threads`).then(async (response) => { if (!response.ok) throw new Error(`Failed to fetch threads: ${await response.text()}`) return response .json() .then(({ data }: { data: AIThreadData[] }) => data.map((t) => new FineAIThread(t.id, this.config, t))) }) } public thread(threadId: string) { return new FineAIThread(threadId, this.config) } public message(assistantId: string, content: ListOrSingle<string | AIMessageContent>) { return new FineAIMessage(this.config, assistantId, content) } } class FineAIThread { constructor(public id: string, private config: AIConfig, private threadData?: AIThreadData) {} public get data() { return ( this.threadData ?? this.makeThreadRequest({ errorText: `Failed to fetch thread ${this.id}` }).then( (result) => (this.threadData = result as AIThreadData) ) ) } public get messages() { return this.makeThreadRequest<{ data: AIMessageData }>({ errorText: `Failed to fetch messages for thread ${this.id}`, endpoint: "/messages" }).then(({ data }) => data) } public async update(metadata: Record<string, unknown>) { return this.makeThreadRequest({ errorText: `Failed to update thread ${this.id}`, method: "POST", body: JSON.stringify({ metadata }) }).then((result) => (this.threadData = result as AIThreadData)) } public async delete() { return this.makeThreadRequest({ errorText: `Failed to delete thread ${this.id}`, method: "DELETE" }).then(() => (this.threadData = undefined)) } public message(assistantId: string, content: ListOrSingle<string | AIMessageContent>) { return new FineAIMessage(this.config, assistantId, content, this.id) } private makeThreadRequest<RT = unknown>({ errorText, endpoint, ...options }: Omit<RequestInit, "headers"> & { errorText: string; endpoint?: string }) { return this.config .fetch(`${this.config.baseUrl}/threads/${this.id}${endpoint ?? ""}`, options) .then<RT>(async (response) => { if (!response.ok) throw new Error(`${errorText}: ${await response.text()}`) else return response.json() }) .catch((error) => { console.error(error) throw new Error(`${errorText}: ${error}`) }) } } type AIStreamEvent = | { type: "runStarted"; runId: string; threadId: string; messageId: string } | { type: "runError"; runId: string; error: string } | { type: "contentChunk"; chunk: string } | { type: "runCompleted"; runId: string; fullResponse: string } type StreamCallback = (event: AIStreamEvent) => void class FineAIMessage { private content: AIMessageContent[] private metadata: Record<string, unknown> | undefined private attachments: Promise<AIMessageAttachment>[] | undefined constructor( private config: AIConfig, private assistantId: string, content: ListOrSingle<string | AIMessageContent>, private threadId?: string ) { if (typeof content === "string") { this.content = [{ type: "text", text: content }] } else if (!Array.isArray(content)) { this.content = [content] } else { this.content = content.map((c) => (typeof c === "string" ? { type: "text", text: c } : c)) } } public setMetadata(metadata: Record<string, unknown>) { this.metadata = metadata return this } public attach(files: File | File[]) { if (!Array.isArray(files)) files = [files] for (const file of files) if (file.size / 1_048_576 > 10) throw new Error(`Attachment ${file.name} is too large. Maximum size is 10MB.`) this.attachments ||= [] this.attachments.push(...files.map(fileToAttachment)) return this } public send() { return this.prepareBody(false) .then((body) => this.config.fetch(`${this.config.baseUrl}/run`, { method: "POST", body })) .then<MessageRunResult>(async (response) => { if (!response.ok) throw new Error(`Failed to send message: ${await response.text()}`) else return response.json() }) } public async stream(callback: StreamCallback) { let runId = "" try { const response = await this.config.fetch(`${this.config.baseUrl}/run`, { method: "POST", body: await this.prepareBody(true) }) const reader = response.body!.getReader() this.readStream(reader, (event) => { if ("runId" in event) runId = event.runId callback(event) }) return reader } catch (error) { callback({ type: "runError", runId, error: error instanceof Error ? error.message : typeof error === "string" ? error : JSON.stringify(error) }) throw new Error(`Failed to send message stream: ${error}`) } } private async prepareBody(stream: boolean) { return JSON.stringify({ assistantId: this.assistantId, messages: [ { role: "user", content: this.content, attachments: this.attachments ? await Promise.all(this.attachments) : undefined } ], threadId: this.threadId, metadata: this.metadata, stream }) } private async readStream( reader: ReadableStreamDefaultReader<Uint8Array<ArrayBufferLike>>, callback: StreamCallback ) { const decoder = new TextDecoder("utf-8") let bufferedChunks = "" while (true) { const { value, done } = await reader.read() if (done) break const str = decoder.decode(value) bufferedChunks += str if (!bufferedChunks) continue const events = bufferedChunks.split("\n\n") for (let i = 0; i < events.length; i++) { const event = events[i] if (!event.trim()) continue const data = event.slice("data: ".length) try { const result = JSON.parse(data) as AIStreamEvent bufferedChunks = "" callback(result) } catch (error) { // Failed to parse JSON. This is probably an incomplete chunk, so we buffer it and wait for the next chunk bufferedChunks = events.slice(i).join("\n\n") continue } } } } }