Files
wehub-resource-sync adf0d17497
publish / version_or_publish (push) Has been cancelled
storybook-build / changes (push) Has been cancelled
storybook-build / :storybook-build (push) Has been cancelled
Sync Gradio Skills to Hugging Face / sync-skills (push) Has been cancelled
functional / changes (push) Has been cancelled
functional / build-frontend (push) Has been cancelled
functional / functional-test-SSR=false (push) Has been cancelled
functional / functional-reload (push) Has been cancelled
js / changes (push) Has been cancelled
js / js-test (push) Has been cancelled
docs-build / changes (push) Has been cancelled
docs-build / docs-build (push) Has been cancelled
docs-build / website-build (push) Has been cancelled
functional / functional-test-SSR=true (push) Has been cancelled
hygiene / hygiene-test (push) Has been cancelled
python / changes (push) Has been cancelled
python / build (push) Has been cancelled
python / test-ubuntu-latest-flaky (push) Has been cancelled
python / test-ubuntu-latest-not-flaky (push) Has been cancelled
python / test-windows-latest-flaky (push) Has been cancelled
python / test-windows-latest-not-flaky (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:17:32 +08:00

127 lines
3.6 KiB
TypeScript

/**
* Browser-side streaming client for HF Inference text generation.
*
* Routes through `https://router.huggingface.co/v1/chat/completions`, HF's
* OpenAI-compatible unified endpoint. This bypasses our Python `call_model`
* server function for text-generation specifically so tokens stream into
* the canvas as they arrive instead of waiting for the full response.
*
* Other tasks (image, audio, classification, etc.) still go through the
* Python path — only text-generation gets the streaming treatment because
* it's the one task where partial output is meaningful.
*/
const ROUTER_URL = "https://router.huggingface.co/v1/chat/completions";
export interface StreamTextOptions {
modelId: string;
prompt: string;
hfToken?: string;
provider?: string;
maxTokens?: number;
signal?: AbortSignal;
onChunk: (delta: string, accumulated: string) => void;
}
/**
* Stream a text-generation response token-by-token. Returns the final
* accumulated string when the stream ends. `onChunk` fires for each
* incremental delta with both the new delta and the running accumulator.
*
* Throws if the request fails or the stream cannot be opened — the caller
* is expected to surface the error (toast / node status).
*/
export async function stream_text_generation(
opts: StreamTextOptions
): Promise<string> {
const headers: Record<string, string> = {
"Content-Type": "application/json"
};
if (opts.hfToken) headers["Authorization"] = `Bearer ${opts.hfToken}`;
const model =
opts.provider && opts.provider !== "auto"
? `${opts.modelId}:${opts.provider}`
: opts.modelId;
const body = JSON.stringify({
model,
messages: [{ role: "user", content: opts.prompt }],
max_tokens: opts.maxTokens ?? 512,
stream: true
});
const res = await fetch(ROUTER_URL, {
method: "POST",
headers,
body,
signal: opts.signal
});
if (!res.ok || !res.body) {
const detail = await res.text().catch(() => "");
throw new Error(
`HF router ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ""}`
);
}
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
let accumulated = "";
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE frames are separated by blank lines; each `data:` line is a JSON
// chunk or the literal `[DONE]` sentinel.
let nl: number;
while ((nl = buffer.indexOf("\n")) !== -1) {
const line = buffer.slice(0, nl).trim();
buffer = buffer.slice(nl + 1);
if (!line.startsWith("data:")) continue;
const payload = line.slice(5).trim();
if (payload === "[DONE]") return accumulated;
try {
const chunk = JSON.parse(payload);
const delta = chunk?.choices?.[0]?.delta?.content ?? "";
if (delta) {
accumulated += delta;
opts.onChunk(delta, accumulated);
}
} catch {
// Malformed frame — skip; the next one usually recovers.
}
}
}
} finally {
try {
reader.releaseLock();
} catch {
/* noop */
}
}
return accumulated;
}
/**
* Pipeline tags eligible for browser-side streaming. Only text-generation
* variants benefit — image / audio / classification tasks are
* single-shot in nature, so the streaming router endpoint adds no value
* and would in fact be incorrect (it's a chat-completions API).
*/
export function is_streamable_text_task(
pipelineTag: string | undefined
): boolean {
if (!pipelineTag) return false;
return (
pipelineTag === "text-generation" ||
pipelineTag === "text2text-generation" ||
pipelineTag === "conversational"
);
}