AI Chat
A chat page that streams replies token by token: Chat, ChatMessageList and PromptInput over a SvelteKit endpoint relaying a model stream as server-sent events. The client posts the history, reads the response as a ReadableStream, and cancels mid-stream with an AbortController.
Live preview
ChatPage.svelte
Start the conversation
Send a message to watch a reply stream in.
<script lang="ts">
import {
Badge,
Card,
Chat,
ChatMessageList,
type ChatMessageData,
PromptInput
} from '@urbicon-ui/blocks';
let messages = $state<ChatMessageData[]>([]);
let busy = $state(false);
let controller: AbortController | undefined;
let idSeq = 0;
const nextId = () => 'm-' + ++idSeq;
// In-place patch: replace one message by id — never mutate the array element,
// or the streaming re-render loses its identity.
function patch(id: string, next: Partial<ChatMessageData>) {
messages = messages.map((m) => (m.id === id ? { ...m, ...next } : m));
}
// Flatten a message to the wire shape the endpoint expects (text parts only).
function toWire(m: ChatMessageData) {
const content = m.parts
.filter((p) => p.type === 'text')
.map((p) => (p as { text: string }).text)
.join('');
return { role: m.role, content };
}
async function send({ text }: { text: string; attachments: unknown[] }) {
messages = [
...messages,
{ id: nextId(), role: 'user', parts: [{ type: 'text', text }], status: 'complete' }
];
const assistantId = nextId();
// Snapshot BEFORE the empty assistant turn — and drop text-less turns: a
// stream stopped before its first token leaves an empty assistant message,
// and the Messages API rejects empty content (the chat would stay wedged).
const history = messages.map(toWire).filter((m) => m.content.length > 0);
messages = [
...messages,
{ id: assistantId, role: 'assistant', parts: [{ type: 'text', text: '' }], status: 'streaming' }
];
busy = true;
controller = new AbortController();
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ messages: history }),
signal: controller.signal
});
if (!res.body) throw new Error('no stream');
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let answer = '';
let failed = false;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE frames are separated by a blank line.
const frames = buffer.split('\n\n');
buffer = frames.pop() ?? '';
for (const frame of frames) {
const lines = frame.split('\n');
const event = lines.find((l) => l.startsWith('event: '))?.slice(7);
const raw = lines.find((l) => l.startsWith('data: '))?.slice(6);
if (!event || !raw) continue;
const data = JSON.parse(raw);
if (event === 'token') {
answer += data.text;
patch(assistantId, { parts: [{ type: 'text', text: answer }] });
} else if (event === 'error') {
// The stream still ends normally after this frame — remember the
// failure so the final settle below cannot overwrite it.
failed = true;
patch(assistantId, { status: 'error' });
}
}
}
if (!failed) patch(assistantId, { status: 'complete' });
} catch (err) {
// AbortError → the user pressed Stop; anything else is a real failure.
patch(assistantId, { status: (err as Error).name === 'AbortError' ? 'aborted' : 'error' });
} finally {
busy = false;
controller = undefined;
}
}
function stop() {
controller?.abort();
}
function regenerate(message: ChatMessageData) {
if (busy) return;
// Drop the assistant turn (and the user turn before it) and re-send.
const idx = messages.findIndex((m) => m.id === message.id);
const prior = messages[idx - 1];
if (prior?.role !== 'user') return;
const text = toWire(prior).content;
messages = messages.slice(0, idx - 1);
send({ text, attachments: [] });
}
</script>
<!-- The chat needs a bounded box: Chat pins header and composer and scrolls
only the log, so the height must come from the host. Here that host is an
elevated Card — padding="none" hands the edges to the chat, overflow-hidden
clips the log to the card's rounding, and the content slot runs full
height. Centre it in your page's own layout and swap h-[34rem] for the
height it should fill there. -->
<Card
variant="elevated"
padding="none"
class="h-[34rem] max-w-3xl overflow-hidden"
slotClasses={{ content: 'h-full' }}
>
<Chat>
{#snippet header()}
<div class="flex items-center gap-2 px-4 py-2.5">
<span class="text-text-primary text-sm font-medium">AI Assistant</span>
<Badge intent={busy ? 'primary' : 'neutral'} variant="soft" size="sm">
{busy ? 'streaming' : 'idle'}
</Badge>
</div>
{/snippet}
<ChatMessageList
{messages}
onRegenerate={regenerate}
onRetry={regenerate}
emptyTitle="Start the conversation"
emptyDescription="Send a message to watch a reply stream in."
/>
{#snippet composer()}
<div class="p-3">
<PromptInput {busy} placeholder="Ask anything…" onSubmit={send} onStop={stop} />
</div>
{/snippet}
</Chat>
</Card>The SSE endpoint
src/routes/api/chat/+server.ts
send: it takes the history as a POST body and answers with the token, done and error frames the reader above parses.import Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC_API_KEY } from '$env/static/private';
import type { RequestHandler } from './$types';
const client = new Anthropic({ apiKey: ANTHROPIC_API_KEY });
// The client posts the flattened history. Any LLM stream works here — swap the
// SDK call for your provider; only the token-forwarding shape below matters.
interface WireMessage {
role: 'user' | 'assistant';
content: string;
}
export const POST: RequestHandler = async ({ request }) => {
const { messages } = (await request.json()) as { messages: WireMessage[] };
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
const send = (event: string, data: unknown) =>
controller.enqueue(
encoder.encode('event: ' + event + '\ndata: ' + JSON.stringify(data) + '\n\n')
);
try {
const run = client.messages.stream({
model: 'claude-opus-4-8',
max_tokens: 4096,
messages
});
// Forward the client's abort straight through to the model stream.
request.signal.addEventListener('abort', () => run.abort());
run.on('text', (delta) => send('token', { text: delta }));
await run.finalMessage();
send('done', {});
} catch (err) {
send('error', { message: err instanceof Error ? err.message : 'stream failed' });
} finally {
controller.close();
}
}
});
return new Response(stream, {
headers: {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive'
}
});
};Two decisions
POST and a stream reader, not EventSource
The conversation travels as the request body, and EventSource can only GET, so the client posts with fetch and parses the SSE frames from res.body itself. The same choice wires Stop end to
end: controller.abort() cancels the fetch, request.signal fires in the route, and the route hands
the abort on to the model stream.
Why not createStreamHandler
@urbicon-ui/auth ships createStreamHandler, and it is tempting here. It is
the wrong shape: a GET-only notification fan-out on an SSEManager, pushing events to every subscribed
client. A chat relay is the opposite, one POST carrying the history answered by one
stream, so the route is written by hand.