Skip to content

Svelte 4/5 AI chat component, with stores — @aparte/svelte

@aparte/svelte wraps @aparte/core for Svelte 4 and 5: an ergonomic <AparteChat> component, store factories for state and the client, and a generic <AparteUi> escape hatch.

Terminal window
npm install @aparte/svelte @aparte/core svelte

@aparte/core and svelte are peer dependencies.

The createAparteChat store factory owns the messages store and mirrors the imperative API, so you bind the store and connect the component with bind:this:

<script lang="ts">
import { AparteChat, createAparteChat, type AparteChatImperativeApi } from '@aparte/svelte';
import '@aparte/core/styles.css';
const chat = createAparteChat();
const { messages } = chat;
let comp: AparteChatImperativeApi | null = null;
$: chat.connect(comp);
</script>
<AparteChat
bind:this={comp}
messages={$messages}
centerWhenEmpty
onmessagesChange={(m) => chat.onMessagesChange(m)}
>
<p slot="empty-state">Ask me anything…</p>
</AparteChat>

The user’s message is appended to the thread automatically on send — don’t add it yourself. onmessageSent is optional and fires after that append, for side-effects only (scroll, analytics, a backend call).

Every callback also exists as a component event — on:messagesChange={(e) => chat.onMessagesChange(e.detail)} — which is the Svelte 4 spelling and still works on Svelte 5. Svelte 5 documents createEventDispatcher as deprecated and recommends callback props, so the callbacks are the path written here; both fire for the same occurrence, the callback with the payload itself, the event with it under event.detail.

Slots are named slots: empty-state, composer, above-composer, toolbar (the composer’s bottom row — mode picker, model selector: see The composer toolbar for an example; use <svelte:fragment slot="toolbar"> to project several nodes), and the bubble slot (<div slot="bubble" let:message>) for a fully custom bubble. Every imperative method (streaming, branch/edit, scrollToBottom) is mirrored on the chat store and reachable via bind:this.

The other five are onaction, onmessagesChange, onmessageAppended, ontypingChange and onconversationCreated (as events: on:action, on:messagesChange, … with the payload under event.detail) — the table with all four frameworks side by side is generated from the wrapper source: Wrapper surface.

The wrapper is provider-agnostic. Register a provider + transport once (see Providers) and start an AparteClient with createAparteClient — it bridges composer sends to the model:

<script lang="ts">
import { AparteChat, createAparteChat, createAparteClient, type AparteChatImperativeApi } from '@aparte/svelte';
import { aparteGlobalConfig, AparteDirectTransport } from '@aparte/core';
import { createOpenAICompatProvider, presets } from '@aparte/provider-openai-compat';
aparteGlobalConfig.registerAIProvider(createOpenAICompatProvider(presets.OPENROUTER));
aparteGlobalConfig.setTransport(new AparteDirectTransport({ byok: true }));
const chat = createAparteChat();
createAparteClient(); // streams replies from the configured provider
const { messages } = chat;
let comp: AparteChatImperativeApi | null = null;
$: chat.connect(comp);
</script>
<AparteChat bind:this={comp} messages={$messages} onmessagesChange={(m) => chat.onMessagesChange(m)} />

Pass a per-instance config prop to scope providers/transport to a single <AparteChat> instead of aparteGlobalConfig.

The aparte-* tags are declared through SvelteHTMLElements, so svelte-check covers both their attributes and their on: handlers — no AparteUi needed:

<aparte-select
searchable=""
placeholder="Pick a model"
on:aparte-select-change={(e) => use(e.detail.value)}
>
<aparte-option value="gpt-4o-mini">GPT-4o mini</aparte-option>
</aparte-select>

On Svelte 5 in runes mode, on: on an element is the one form the compiler flags as deprecated (event_directive_deprecated); write the event attribute instead — onaparte-select-change={(e) => …} — which svelte-check types the same way.

Presence attributes take '' to set and null to remove, never false — Svelte stringifies what it sets on a custom element, so searchable={false} would render searchable="false" and an element testing hasAttribute reads that as on. The rules and the full set are on Placing elements, typed.

For an element aparté does not define — one of yours, or a third party’s:

<script lang="ts">
import { AparteUi } from '@aparte/svelte';
</script>
<AparteUi
name="my-token-counter"
props={{ 'data-budget': '8000' }}
onelementEvent={(e) => console.log(e.type, e.detail)}
/>

onelementEvent receives the element’s own CustomEvent. The component event on:elementEvent still fires too, with that event under e.detail — the Svelte 4 spelling.

It mounts any tag name, which is what a foreign element needs and what aparté’s own no longer do.

  • createConversationManager — Svelte stores over the core AparteConversationManager (list / create / archive), for a multi-conversation sidebar.

Vitest — 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.