@fine-dev/fine-js
Version:
Javascript client for Fine BaaS
279 lines (238 loc) • 9.13 kB
text/typescript
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
}
}
}
}
}