Web interface (React/TS, shadcn, pure OpenAI-API client) under web/ (#23)

This commit is contained in:
ZacharyZcR
2026-07-10 16:07:29 +08:00
committed by GitHub
parent 8f99e12b5e
commit a2942b2172
21 changed files with 2810 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest"
import { extractSSE } from "./api"
describe("extractSSE", () => {
it("keeps an incomplete frame for the next network chunk", () => {
const parsed = extractSSE('data: {"choices":[]}\n\ndata: {"cho')
expect(parsed.data).toEqual(['{"choices":[]}'])
expect(parsed.rest).toBe('data: {"cho')
})
it("supports CRLF and multiple data frames", () => {
const parsed = extractSSE("data: one\r\n\r\ndata: two\r\n\r\n")
expect(parsed.data).toEqual(["one", "two"])
expect(parsed.rest).toBe("")
})
})
+104
View File
@@ -0,0 +1,104 @@
export type ChatRole = "system" | "user" | "assistant"
export interface ChatMessage {
id: string
role: ChatRole
content: string
}
interface OpenAIError {
error?: { message?: string }
}
function endpoint(baseUrl: string, path: string) {
return `${baseUrl.replace(/\/+$/, "")}/${path.replace(/^\/+/, "")}`
}
function headers(apiKey: string) {
return {
"Content-Type": "application/json",
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
}
}
async function responseError(response: Response) {
const fallback = `${response.status} ${response.statusText}`
try {
const body = (await response.json()) as OpenAIError
return body.error?.message || fallback
} catch {
return fallback
}
}
export async function listModels(baseUrl: string, apiKey: string, signal?: AbortSignal) {
const response = await fetch(endpoint(baseUrl, "models"), { headers: headers(apiKey), signal })
if (!response.ok) throw new Error(await responseError(response))
const body = (await response.json()) as { data?: Array<{ id: string }> }
return (body.data || []).map((model) => model.id)
}
export function extractSSE(buffer: string) {
const frames = buffer.split(/\r?\n\r?\n/)
const rest = frames.pop() || ""
const data = frames.flatMap((frame) =>
frame
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trimStart()),
)
return { data, rest }
}
interface StreamChatOptions {
baseUrl: string
apiKey: string
model: string
messages: ChatMessage[]
temperature: number
maxTokens: number
enableThinking: boolean
signal: AbortSignal
onDelta: (text: string) => void
}
export async function streamChat(options: StreamChatOptions) {
const response = await fetch(endpoint(options.baseUrl, "chat/completions"), {
method: "POST",
headers: headers(options.apiKey),
signal: options.signal,
body: JSON.stringify({
model: options.model,
messages: options.messages.map(({ role, content }) => ({ role, content })),
temperature: options.temperature,
max_completion_tokens: options.maxTokens,
enable_thinking: options.enableThinking,
stream: true,
stream_options: { include_usage: true },
}),
})
if (!response.ok) throw new Error(await responseError(response))
if (!response.body) throw new Error("The server returned an empty stream.")
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ""
const consume = (data: string) => {
if (data === "[DONE]") return
const event = JSON.parse(data) as {
choices?: Array<{ delta?: { content?: string } }>
}
const text = event.choices?.[0]?.delta?.content
if (text) options.onDelta(text)
}
while (true) {
const { value, done } = await reader.read()
buffer += decoder.decode(value, { stream: !done })
const parsed = extractSSE(buffer)
buffer = parsed.rest
parsed.data.forEach(consume)
if (done) break
}
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}