Plain signals — no AI library
End-to-end streaming chat using only Angular signals, fetch, and ReadableStream. No Hashbrown, no Vercel AI SDK, no NgRx. Use this as a reference when wiring ShadNG to a custom backend.
What this shows
ShadNG components consume signal-driven inputs for state, value, and attachments. The library doesn't import any AI framework — you bring your own state. This example uses a backend that returns server-sent events (SSE), streamed chunk by chunk into a signal.
Component
import {
ChangeDetectionStrategy,
Component,
signal,
} from '@angular/core';
import {
PromptInput,
PromptInputState,
PromptInputSubmit,
PromptInputSubmitEvent,
PromptInputTextarea,
PromptInputToolbar,
} from '@shadng/prompt-input';
@Component({
selector: 'app-chat',
imports: [PromptInput, PromptInputTextarea, PromptInputToolbar, PromptInputSubmit],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="flex flex-col gap-4">
<pre class="whitespace-pre-wrap rounded-md bg-muted p-4 text-sm">{{ response() }}</pre>
<prompt-input
[(value)]="prompt"
[state]="state()"
(submitted)="onSubmit($event)"
(canceled)="onCancel()"
(retried)="onSubmit({ value: prompt(), attachments: [], preventDefault: noop })"
>
<prompt-input-textarea placeholder="Ask anything…" />
<prompt-input-toolbar>
<prompt-input-submit />
</prompt-input-toolbar>
</prompt-input>
</div>
`,
})
export class ChatComponent {
prompt = signal('');
state = signal<PromptInputState>('ready');
response = signal('');
private controller: AbortController | null = null;
protected readonly noop = () => undefined;
async onSubmit({ value }: PromptInputSubmitEvent) {
this.state.set('submitted');
this.response.set('');
this.controller = new AbortController();
try {
const res = await fetch('/api/chat', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ prompt: value }),
signal: this.controller.signal,
});
if (!res.ok || !res.body) {
throw new Error('Network error');
}
this.state.set('streaming');
const reader = res.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value: chunk, done } = await reader.read();
if (done) break;
this.response.update((prev) => prev + decoder.decode(chunk));
}
this.state.set('ready');
this.prompt.set('');
} catch (err) {
if ((err as Error).name === 'AbortError') {
this.state.set('ready');
return;
}
this.state.set('error');
}
}
onCancel() {
this.controller?.abort();
}
}Backend (any framework)
The server endpoint streams chunks of plain text:
// Express example — same pattern works on any framework.
app.post('/api/chat', async (req, res) => {
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Transfer-Encoding', 'chunked');
const stream = await yourLlm.stream(req.body.prompt);
for await (const chunk of stream) {
res.write(chunk);
}
res.end();
});What's happening
- State machine. The container's
stateinput drives the submit button visual and the aria-live announcements. We move throughready → submitted → streaming → readyon success, orerroron failure. - Submit handler. On
(submitted)we POST to the backend and read the response body viaReadableStream. Each chunk appended to theresponse()signal — your message renderer reads from there. - Cancel. On
(canceled)(Esc or Stop button click during streaming) we abort the underlyingAbortController. - Retry. On
(retried)(Submit click in error state) we re-fire the same request.
Adapting to Hashbrown or Vercel AI SDK
Replace the manual fetch + ReadableStream with the library's helpers, but keep the same state signal driving the container. Hashbrown's chatResource() exposes a status() signal that maps 1:1 to our four states. Vercel AI SDK's Angular adapter is similar.
Dedicated integration docs for Hashbrown and Vercel AI SDK are planned for v0.1.1.