Skip to content

Angular AI chat component, standalone — @aparte/angular

@aparte/angular wraps @aparte/core for Angular 19: an ergonomic <aparte-chat> standalone component, services for the client and conversations, a typed directive for every element, and a generic <aparte-ui> escape hatch.

Terminal window
npm install @aparte/angular @aparte/core @angular/core @angular/common rxjs

@aparte/core, @angular/core, @angular/common and rxjs are peer dependencies.

The components are standalone — import them directly, no NgModule:

import { Component } from '@angular/core';
import { AparteChatComponent, type AparteMessage } from '@aparte/angular';
import '@aparte/core/styles.css';
@Component({
standalone: true,
imports: [AparteChatComponent],
template: `
<aparte-chat centerWhenEmpty (messagesChange)="messages = $event">
<p slot="empty-state">Ask me anything…</p>
</aparte-chat>
`,
})
export class Chat {
// The chat owns its thread. Observe it via (messagesChange) — do NOT push it
// back through [messages]: the user's message is appended for you on send, so
// re-adding it in a (messageSent) handler double-counts it.
messages: AparteMessage[] = [];
}

Slots are content projection by attribute: [slot='empty-state'], [slot='composer'], [slot='above-composer'], [slot='toolbar'] — the last one being the composer’s bottom row, with an example under The composer toolbar. For a fully custom bubble, pass a template instead:

<aparte-chat [messages]="messages" [bubbleTemplate]="tpl"></aparte-chat>
<ng-template #tpl let-message>
<div class="my-bubble">{{ message.content }}</div>
</ng-template>

Outputs: messageSent, messagesChange, messageAppended, action, typingChange, conversationCreated — the same six on all four wrappers, with the payloads and the other three syntaxes side by side in the generated Wrapper surface. The imperative API (streaming, branch/edit, scrollToBottom, getViewport) is on the component instance — grab it with a @ViewChild. injectTokenStream takes the cross-wrapper AsyncIterable<string> — the exact call that works on React/Vue/Svelte — or an RxJS Observable<string> (the Angular-idiomatic shape); everything else mirrors the other wrappers.

The wrapper is provider-agnostic. provideAparte() registers your providers and client options at bootstrap and starts the client (autoConnect, on by default) — composer sends stream replies with zero extra wiring:

main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { AparteDirectTransport, aparteGlobalConfig } from '@aparte/core';
import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat';
import { provideAparte } from '@aparte/angular';
aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true }));
bootstrapApplication(App, {
providers: [
provideAparte({
providers: [createOpenAICompatProvider(presets.OPENROUTER)],
clientOptions: { /* AparteClientOptions */ },
}),
],
});

That’s it — no lifecycle wiring in your components. To own the client lifecycle yourself, pass autoConnect: false and use the service:

// only with autoConnect: false — the manual escape hatch
import { inject } from '@angular/core';
import { AparteAiService } from '@aparte/angular';
export class Chat {
private ai = inject(AparteAiService);
ngOnInit() { this.ai.connect(); } // idempotent — safe even if already connected
ngOnDestroy() { this.ai.disconnect(); }
}

provideAparte() is config sugar and fully optional — the components work without it, and you can call aparteGlobalConfig.* directly exactly like the React/Vue/Svelte wrappers do. Its plugins slots take objects or loader functions you supply, and locale takes an AparteLocale object (e.g. locale: fr from @aparte/locale-fr) — none of them take package-name strings — so this package stays a leaf with no plugin catalog. Pass a per-instance [config] to scope providers/transport to a single <aparte-chat> instead of aparteGlobalConfig.

Every element has a standalone directive whose selector is the tag, so you write the real element with typed Inputs and one Output per event — and no CUSTOM_ELEMENTS_SCHEMA, which would switch template checking off for every unknown tag in the file. Import the ones you use, or APARTE_ELEMENT_DIRECTIVES for all of them:

import { AparteSelectDirective } from '@aparte/angular';
// then: @Component({ imports: [AparteSelectDirective], … })
@if (showPicker) {
<aparte-select
[searchable]="true"
placeholder="Pick a model"
(selectChange)="use($event.value)"
></aparte-select>
}

The @if is the point: the element is really in the template, so control flow and content projection reach it. Full set and the rules on Placing elements, typed.

For an element aparté does not define — one of yours, or a third party’s — mount it generically. It forwards the interactive aparté events by default; pass [events] to listen to others:

<aparte-ui
name="my-token-counter"
[props]="{ 'data-budget': '8000', '--glow-speed': '4s' }"
(elementEvent)="onEvent($event)"
/>

This used to be how you placed a model selector. It still works, and it is still the only way to mount a tag aparté knows nothing about — but for aparté’s own elements the directive above gives you type checking, one output per event, and an element the template can actually wrap.

  • ConversationManagerService — signal-based view over the core AparteConversationManager (list / create / archive), for a multi-conversation sidebar.

Vitest, Karma — every runner — executes on Node, so @aparte/core resolves to its DOM-free entry and no <aparte-*> element upgrades under jsdom: the tag stays a plain HTMLElement and every assertion about it fails for a reason nothing explains. Alias the specifier to @aparte/core/browser, the entry with the elements in it.