mirror of
https://github.com/zoo-apps/zoolabs.io.git
synced 2026-10-30 14:56:27 +00:00
79 lines
2.7 KiB
TypeScript
79 lines
2.7 KiB
TypeScript
// One way to talk to the model. If the endpoint is unreachable or refuses,
|
|
// that surfaces as an error — this site never answers on the model's behalf.
|
|
|
|
export type Message = { role: 'system' | 'user' | 'assistant'; content: string }
|
|
|
|
// api.hanzo.ai serves the public anonymous lane at /v1/chat/public with CORS enabled
|
|
// for https://zoolabs.io, requiring no client secrets or billable API keys.
|
|
const API = process.env.NEXT_PUBLIC_ZOO_API ?? 'https://api.hanzo.ai'
|
|
// zen-free, not zen. Both are real and the gateway serves both, but `zen`
|
|
// bills through to an upstream that answers "Insufficient credits" — a 402 the
|
|
// visitor can do nothing about. zen-free answers, and answers well: asked why
|
|
// beluga calves are born grey it gives the camouflage in murky Arctic water,
|
|
// the predators, and the years-long moult to white.
|
|
const MODEL = process.env.NEXT_PUBLIC_ZOO_MODEL ?? 'zen-free'
|
|
|
|
/** Whatever the endpoint said went wrong, in its own words. */
|
|
async function refusal(res: Response): Promise<string> {
|
|
const body = await res.json().catch(() => null)
|
|
return body?.error?.message ?? `${res.status} ${res.statusText}`
|
|
}
|
|
|
|
/** Yields reply text as it arrives. */
|
|
export async function* reply(
|
|
messages: Message[],
|
|
signal?: AbortSignal
|
|
): AsyncGenerator<string> {
|
|
let res: Response
|
|
try {
|
|
res = await fetch(`${API}/v1/chat/public`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ model: MODEL, messages, stream: true }),
|
|
signal,
|
|
})
|
|
if (!res.ok) {
|
|
res = await fetch(`${API}/v1/chat/completions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ model: MODEL, messages, stream: true }),
|
|
signal,
|
|
})
|
|
}
|
|
} catch {
|
|
res = await fetch(`${API}/v1/chat/completions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ model: MODEL, messages, stream: true }),
|
|
signal,
|
|
})
|
|
}
|
|
|
|
if (!res.ok || !res.body) throw new Error(await refusal(res))
|
|
|
|
const reader = res.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let held = ''
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
held += decoder.decode(value, { stream: true })
|
|
|
|
const lines = held.split('\n')
|
|
held = lines.pop() ?? ''
|
|
|
|
for (const line of lines) {
|
|
if (!line.startsWith('data:')) continue
|
|
const payload = line.slice(5).trim()
|
|
if (payload === '[DONE]') return
|
|
try {
|
|
const token = JSON.parse(payload).choices?.[0]?.delta?.content
|
|
if (token) yield token
|
|
} catch {
|
|
// A partial frame; the next read completes it.
|
|
}
|
|
}
|
|
}
|
|
}
|