npm.io
0.2.0 • Published 2d ago

assistant-chat

Licence
MIT
Version
0.2.0
Deps
0
Size
727 kB
Vulns
0
Weekly
0

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 — Enter sends, Shift+Enter inserts 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 (speed property: slow/normal/fast or 0100) — 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-stop intent event)
  • Light/dark theming (light-dark() following the page's color-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)

  1. When the turn starts, push a snapshot with the assistant message containing an empty text part and status: "streaming".
  2. Call appendDelta per chunk (at any rate).
  3. 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

Propertiesmessages: 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.

MethodsappendDelta(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

Slotsslot="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/.