assistant-chat
assistant-chat
A purely visual chat web component (Lit 3) for connecting to any AI
model — or fake models. It is a 100% visual layer: the component never makes API
calls. The host app owns data and transport; <assistant-chat> renders state and emits intent
events.
Streaming architecture based on opencode patterns (dual snapshot+delta channel, conditional pacing, block-level markdown projection with caching, incomplete markdown healed with remend).
Features (v1)
- Autosizing composer —
Entersends,Shift+Enterinserts a newline, IME guard - User message bubble (plain text, line breaks preserved) with copy and rewind conversation
- Assistant answers rendered via streaming (XSS-safe markdown through DOMPurify, fences/links healed mid-stream, highlight.js code highlighting)
- Native reveal speed (
speedproperty:slow/normal/fastor0–100) — the component types the answer at a set pace, smoothing bursty streams - Citations: inline markers (
[1]) + "Sources" footer with clickable pills - "Thinking…" indicator, empty state, error card
- Bottom scroll anchoring while streaming + "jump to bottom" button
- Morphing send/stop button (
chat-stopintent event) - Light/dark theming (
light-dark()following the page'scolor-scheme), CSS custom property tokens, CSS Shadow Parts, built-in i18n (en/pt/es/fr/de)
Quick start
npm install # dependencies
npm run dev # demo (/demo/) and docs (/docs/) at http://localhost:5173
npm run build # publishable dist/: bundles + d.ts + custom-elements.json + package.json
npm test # adapter tests (SSE wire-format mocks)
Distribution (same model as the data-grid): npm run build produces a ready-to-publish
package in dist/ — assistant-chat.js (Rollup, self-contained minified ESM), assistant-chat.min.js
(esbuild, for no-bundler pages), .d.ts types, custom-elements.json and a flat
package.json. Publish with npm publish ./dist. Without a bundler, an import map is
enough (examples/standalone.html):
<script type="importmap">
{ "imports": { "assistant-chat": "./assistant-chat.min.js" } }
</script>
<script type="module">
import { attachChatHost } from "assistant-chat" // identical to the npm import
</script>
<assistant-chat></assistant-chat>
<script type="module">
import { attachChatHost } from "assistant-chat"
const chat = document.querySelector("assistant-chat")
// ANY answer source: fake model, fetch/SSE, WebSocket… you decide.
attachChatHost(chat, async ({ text, messages, reply, signal }) => {
for (const word of `You said: ${text}`.split(" ")) {
reply.append(word + " ")
await new Promise((r) => setTimeout(r, 30))
}
reply.setCitations([{ id: "1", title: "Source", url: "https://…", marker: "[1]" }])
reply.done() // or reply.error("message")
}, { onRewind: "truncate" })
</script>
OpenAI and Anthropic patterns, directly — the adapters parse the APIs' streaming result (the fetch is yours; the library never calls the API):
import { openAIStream, anthropicStream, pipeToReply } from "assistant-chat"
attachChatHost(chat, async ({ messages, reply, signal }) => {
const res = await fetch(/* your OpenAI-compatible or Anthropic endpoint */)
await pipeToReply(openAIStream(res), reply) // or anthropicStream(res)
})
anthropicStream converts citations_delta into component citations automatically.
Runnable documentation (mocks of the real wire formats): run npm run dev and open
/docs/.
Without the attachChatHost sugar, the primitive API is just this:
chat.messages = [...] // snapshot channel (idempotent replace)
chat.appendDelta({ messageId, partId, delta: "…" }) // live channel (append-only, droppable)
chat.addEventListener("chat-send", (e) => e.detail.text)
chat.addEventListener("chat-rewind", (e) => e.detail.messageId)
Streaming protocol (dual channel)
- When the turn starts, push a snapshot with the assistant message containing an empty
text part and
status: "streaming". - Call
appendDeltaper chunk (at any rate). - When it finishes (or on any resync/replay/error), push the final snapshot with the full
text and
status: "complete".
A missed delta is healed by the next snapshot; a stale snapshot (a prefix of the live text)
does not clobber the accumulated text (the preserveDelta rule); replays are no-ops. Ids
must sort lexicographically in creation order (use newId()).
API
Properties — messages: ChatMessage[], busy?: boolean (derived by default),
rewindMessageId?: string (dims id >= value), speed?: "slow"|"normal"|"fast"|number
(native reveal typewriter, default "normal"; numbers 0 slowest → 100 fastest≈instant — the
host feeds text, the component paces the on-screen reveal), language?: string (default:
context → <html lang> → browser; built-in en/pt/es/fr/de translations, extensible via
setChatTranslation; setChatLanguage(lang) sets the subtree language through context),
attributes no-rewind, no-copy.
Methods — appendDelta(d), scrollToBottom(behavior?), focusComposer(), setDraft(text).
Events (all bubbles + composed):
| Event | detail | Notes |
|---|---|---|
chat-send |
{ text } |
cancelable — preventDefault() keeps the draft |
chat-stop |
{ messageId | null } |
the composer button morphs into stop while streaming |
chat-rewind |
{ messageId } |
pure intent; the host mutates state |
chat-copy |
{ messageId, text, ok } |
informational (clipboard already written) |
chat-citation-click |
{ messageId, partId, citation } |
cancelable — default opens citation.url |
Slots — slot="empty" (empty state), slot="composer" (replaces the composer).
Parts — ::part(container), ::part(content), ::part(user-message),
::part(agent-message), ::part(composer), ::part(composer-textarea),
::part(composer-button), ::part(citation), ::part(queue), ::part(queue-item) to
restyle layout, messages, the default composer, citation pills and the send queue straight
from the page, no tokens needed.
Send queue — messages typed while an answer is generating are queued as pills above the composer (RTL-safe, inline-end aligned) and auto-sent one per turn as it finishes. Each queued item can be removed before it's sent.
Citations — data on the part (citations: [{ id, title?, url?, snippet?, marker? }]),
never markdown syntax. With marker: "[1]" the literal becomes an inline chip; without it,
the source only shows in the footer.
Theming
The component is transparent: outer border, background and theme belong to the host
page — text inherits the page color and the internal tokens follow the color-scheme
THE PAGE defines (e.g. html { color-scheme: dark }). Fine-tuning via tokens:
html { color-scheme: light dark; } /* the theme is yours, not the component's */
assistant-chat {
background: #fff; /* background/border: regular page CSS */
border: 1px solid #e5e7eb;
--assistant-chat-accent: #7c3aed; /* internal tokens (bubble, code, pills…) */
--assistant-chat-user-bubble-bg: #ede9fe;
--assistant-chat-radius: 14px;
/* …see SPEC.md §8 for the full table */
}
Documentation
SPEC.md — the full contract: data model, streaming pipeline, pacing contract,
citations, rewind, theming, a11y and v2 seams. Runnable docs with live examples: /docs/.