assistant-chat
v0.3.1
Published
Purely visual chat web component (Lit) for any AI model — or fake models. No API calls: the host owns data and transport.
Readme
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)
- Graphics in answers: sanitized inline SVG (charts/diagrams straight from the model) and responsive images — while an SVG streams, a skeleton holds its place and the graphic lands atomically when the tag closes; the reveal typewriter skips the markup
- Interactive charts: the model emits a ```chart fence with declarative JSON (data, never code) and the component renders it natively — bar/line/area/pie/donut/scatter, hover tooltips, legend with series toggling, keyboard navigation — plus a download menu exporting the chart's current state as an interactive HTML page, PNG or PDF (all dependency-free; invalid JSON degrades to a regular code block)
- Pluggable diagram renderers (Mermaid, PlantUML, …):
registerFenceRenderer("mermaid", …)teaches a fence dialect at runtime — the host loads the tool itself (its own dependency or a CDN import), assistant-chat never bundles or fetches it; an unregistered language (or a failed render) degrades to a regular code block - 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 |
| chat-chart-download | { messageId?, partId?, format, spec, hiddenEntries, filename } | cancelable — default generates and downloads the artifact |
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.
Interactive charts — teach your model (via system prompt) to answer with a fenced
chart block containing declarative JSON; the component does the rest:
```chart
{
"type": "bar",
"title": "Revenue by quarter",
"x": ["Q1", "Q2", "Q3", "Q4"],
"series": [
{ "name": "2024", "data": [10, 14, 13, 19] },
{ "name": "2025", "data": [12, 18, 15, 24] }
]
}
```Types: bar (plus "stacked": true), line, area, pie, donut, scatter
(data: [[x, y], …]). Optional: xLabel, yLabel, yMin, yMax, per-series color
(#hex). While the fence streams, a skeleton holds its place; when it closes the chart
mounts with tooltips, a toggleable legend and a download menu (interactive HTML page, PNG,
PDF — always the chart's current state). Listen to chat-chart-download and call
preventDefault() to handle the artifact yourself. Full schema in SPEC.md §4c.
Diagrams via Mermaid (or any other tool) — without shipping it: register a renderer once; the library only ever holds the function, never the diagram tool itself:
import { registerFenceRenderer } from "assistant-chat"
registerFenceRenderer("mermaid", async (source) => {
const { default: mermaid } = await import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs") // or your own `import mermaid from "mermaid"`
mermaid.initialize({ startOnLoad: false, securityLevel: "strict", flowchart: { htmlLabels: false } }) // plain <text>, not <foreignObject> — see SPEC.md §4d
const { svg } = await mermaid.render(crypto.randomUUID(), source)
return { html: svg } // sanitized by the component (same DOMPurify config as the rest of the markdown)
})Now any ```mermaid fence in an answer renders as a diagram — a skeleton while the fence
streams and while the renderer is loading/running, the diagram once it resolves. No renderer
registered, a thrown error, or a declined (null) result all fall back to a regular
highlighted code block. Details in SPEC.md §4d.
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/.
