Monorepo of npm packages for a Lit 3 chat UI: markdown, optional fenced-block renderers (charts, KPI, forms, Mermaid), reasoning blocks, and streaming. Recommended: install @bndynet/ichat and use <i-chat> — one tag bundles the message list and default composer (<i-chat-input>). Chart/KPI/form/Mermaid fences come from @bndynet/ichat-renderers; register them once with registerCodeRenderer from @bndynet/ichat (see Custom renderers). Lower-level packages exist if you compose the list and input yourself.
Looking for the full design & reference docs? They live in
docs/— see the Documentation section below.
| Package | Description |
|---|---|
@bndynet/ichat |
Default. <i-chat> — messages + input. Exports registerCodeRenderer, re-exports rendererRegistry, StreamingController, types, and ChatMessages for advanced use. |
@bndynet/ichat-messages |
Message list only (<i-chat-messages>, markdown pipeline, BlockRenderer, streaming). Use if you do not want <i-chat>. |
@bndynet/ichat-input |
Composer only (<i-chat-input>). |
@bndynet/ichat-renderers |
Lightweight fenced-block renderers: KPI cards, interactive forms. No heavy deps. |
@bndynet/ichat-renderer-chart |
Chart fences (bar, line, area, pie, gauge) via @bndynet/icharts. |
@bndynet/ichat-renderer-katex |
LaTeX math: $inline$ and $$display$$ via KaTeX. |
@bndynet/ichat-renderer-mermaid |
Mermaid diagram fences with theme-aware dark/light mode. |
The first three packages are the built-in baseline — markdown, parts, tool calls, progress, to-dos, virtual scrolling and slots all work with no extra install. The last four are extensions: each adds fenced-block or math rendering and has to be installed and imported on its own. The demo sidebar is grouped along the same line.
Zero-config install: all third-party deps (
lit,markdown-it,dompurify,highlight.js,morphdom,@lit-labs/virtualizer,katex,mermaid,@bndynet/icharts) are auto-installed by npm — no manual peer-dependency hunting.
Chat + all extension renderers:
npm install @bndynet/ichat @bndynet/ichat-renderers @bndynet/ichat-renderer-chart @bndynet/ichat-renderer-katex @bndynet/ichat-renderer-mermaidChat only (markdown + highlighting, no chart/KPI/form/Mermaid):
npm install @bndynet/ichatMessage list only (custom composer):
npm install @bndynet/ichat-messagesDrop in <i-chat> and wire one streaming response with createRunController(). The controller owns the assistant message for you: it creates the streaming placeholder, takes the deltas, and moves the message to its terminal state.
<script type="module">
import "@bndynet/ichat";
</script>
<i-chat id="chat"></i-chat>
<script type="module">
import { textPart } from "@bndynet/ichat";
const chat = document.getElementById("chat");
let run = null;
chat.addEventListener("send", async (e) => {
chat.addMessage({
id: crypto.randomUUID(),
role: "self",
parts: [textPart(e.detail.content)],
timestamp: Date.now(),
});
run = chat.createRunController();
run.start([textPart("", { id: "body", status: "streaming" })]);
try {
// Replace with your AI provider — paste-and-run quickstart:
// https://github.com/bndynet/ichat/blob/main/docs/backend-quickstart.md
for await (const chunk of streamAssistantReply(e.detail.content, {
signal: run.signal,
})) {
run.appendText("body", chunk);
}
run.complete();
} catch (error) {
run.fail(error instanceof Error ? error.message : String(error));
}
});
chat.addEventListener("cancel", () => run?.cancel("*— Response stopped —*"));
</script>That is the whole integration. run.signal is aborted as soon as the run ends, and complete() / fail() / cancel() are no-ops once the run is terminal — a cancelled run whose request then throws stays cancelled, so you never need finally bookkeeping to unlock the composer. See the ChatRunController API for the full lifecycle, or Manual streaming if you prefer to drive the message store yourself.
<i-chat> also emits streaming-change (e.detail.streaming) and busy-change (e.detail.busy) if another part of your UI needs to mirror the assistant streaming state or the composer's submission lock.
Chart, KPI, form, Mermaid, and math fences live in separate packages. Import them at startup or lazily when their UI is first needed:
import "@bndynet/ichat-renderers";
import "@bndynet/ichat-renderer-chart";
import "@bndynet/ichat-renderer-mermaid";Extension registration is global and remains available after components mount. Block Renderers, Part Renderers, and Markdown Plugins registered later affect newly added or subsequently updated content; existing rendered content is not refreshed automatically. Registering the same object again is a no-op; a different object with the same name/id produces a warning and keeps the first registration.
Custom fenced renderers are sanitised by default. The official renderer
packages opt into the audited trusted: true streaming path internally, so no
extra security or performance configuration is required for normal use.
When the user first opens a chat, load historical messages as completed content:
import { normalizeHistoryMessages } from "@bndynet/ichat";
const history = await fetchHistory();
chat.messages = normalizeHistoryMessages(history.messages);normalizeHistoryMessages() (from @bndynet/ichat-messages, re-exported by @bndynet/ichat) sanitises messages loaded from your backend — it sets streaming: false, marks interrupted messages as cancelled, converts any persisted status: 'streaming' | 'pending' parts to 'complete', and removes empty placeholder messages. Pass interruptedStatus / removeEmptyMessages options to customise the behaviour.
A message body is an ordered array of typed parts (there is no plain content string — see Message model). Use addMessage, updateMessage, appendPart, updatePart, tryUpdateToolCall, tryUpdateTodoItem, removeMessage, replyMessage, clearReplyMessage, clear, and updateProgressStep on the same <i-chat> element (see the <i-chat> API).
React users: see the dedicated React integration guide — ref binding, props on React 19 vs ≤ 18, event listening, controlled mode, TypeScript declaration merging, and Next.js/SSR.
By default <i-chat> owns its own message state. Listen to messages-change for side effects like logging or persistence:
chat.addEventListener("messages-change", (e) => {
console.log(e.detail.reason, e.detail.messages);
});When you need a single source of truth shared across multiple components (e.g. a chat panel + a sidebar + an export view all reading the same message list), use controlled mode:
// Vue example — messages lives in a reactive store
chat.messageMode = "controlled";
chat.addEventListener("messages-change", (e) => {
if (e.detail.committed) return;
messages.value = e.detail.messages; // framework propagation may be async
});Controlled messages-change events are cancelable proposals. Unless the host
calls e.preventDefault(), sequential imperative updates continue from the
latest proposal while Vue, React, or another framework propagates the new
property asynchronously. Assign e.detail.messages directly when accepting a
proposal; cloning an older queued proposal is treated as an intentional external
history replacement. Call e.preventDefault() in the handler to reject a
proposal.
Rejecting a proposal also holds back the ChatRunController that produced it: a
rejected start() leaves the run idle and a rejected
complete() / fail() / cancel() leaves it streaming, so the run never
disagrees with your message array. Inspect the returned ChatMutationOutcome
({ changed, accepted }) if you want to retry or report the rejection — see
Rejected proposals for when rejecting
is the right tool and when to undo an accepted write instead.
In uncontrolled mode chat.messages is immediately up-to-date after any mutation — just read it. Controlled mode is opt-in and only needed when an external framework must own the array.
Method mapping — parse your backend stream into these calls. Event names are yours to define; the lib only provides the methods:
| Scenario | Method |
|---|---|
| Start assistant response | run.start([textPart('', { status: 'streaming' })]) |
| Append text delta | run.appendText(partId, delta) |
| Append structured part (tool-call, reasoning…) | run.appendPart(part) |
| Update an existing part | run.updatePart(partId, patch) |
| Apply a raw part-update payload | chat.tryApplyMessagePartUpdateEvent(rawEvent) |
| Apply a raw todo-update payload | chat.tryApplyTodoItemUpdateEvent(rawEvent) |
| Stream completed | run.complete() |
| Stream error | run.fail(error) |
| Cancel (abort fetch) | run.cancel(hint) → aborts run.signal, marks the message cancelled |
ChatRunController is a thin wrapper over the message store — you can drive the same lifecycle with addMessage / updatePart / updateMessage directly. Reach for this when your own object already owns the abort and cleanup logic, or when you need per-part statuses the controller does not infer. You then take over three rules the controller enforces for you:
- Create the assistant placeholder before the first network
await, not after the first token arrives. - Always clear
streaming— on success, error, and cancellation. Skipping it in any branch leaves the composer locked for good. - Move every part off
'streaming'/'pending'in the same branches. A part left mid-stream keeps the text renderer on its streaming path: the terminal sanitized render never runs, async block renderers stay unresolved, and the Markdown cache is never populated — none of which is visible on screen.
Full hand-written streaming loop
import { textPart } from "@bndynet/ichat";
const chat = document.getElementById("chat");
let activeStream = null;
chat.addEventListener("send", async (e) => {
const text = e.detail.content;
const assistantId = crypto.randomUUID();
const bodyPartId = "body";
const stream = {
assistantId,
bodyPartId,
abort: new AbortController(),
cancelled: false,
};
activeStream = stream;
chat.addMessage({
id: crypto.randomUUID(),
role: "self",
parts: [textPart(text)],
timestamp: Date.now(),
});
// `chat.busy` starts while the submission is preprocessed, then
// `streaming: true` keeps the composer locked and switches Send -> Cancel
// until the response finishes.
chat.addMessage({
id: assistantId,
role: "assistant",
parts: [textPart("", { id: bodyPartId, status: "streaming" })],
streaming: true,
timestamp: Date.now(),
});
let answer = "";
try {
// TODO: Replace this with your fetch/SSE/WebSocket/SDK adapter.
// It should yield text chunks and respect the AbortSignal when possible.
for await (const chunk of streamAssistantReply(text, {
signal: stream.abort.signal,
})) {
if (stream.abort.signal.aborted) break;
answer += chunk;
chat.updatePart(assistantId, bodyPartId, {
text: answer,
status: "streaming",
});
}
if (stream.abort.signal.aborted) {
chat.updatePart(assistantId, bodyPartId, { status: "cancelled" });
chat.updateMessage(assistantId, { cancelled: true });
} else {
chat.updatePart(assistantId, bodyPartId, { status: "complete" });
}
} catch (error) {
if (stream.abort.signal.aborted) {
chat.updatePart(assistantId, bodyPartId, { status: "cancelled" });
chat.updateMessage(assistantId, { cancelled: true });
} else {
chat.updatePart(assistantId, bodyPartId, { status: "error" });
chat.updateMessage(assistantId, {
error: error instanceof Error ? error.message : String(error),
});
}
} finally {
chat.updateMessage(assistantId, { streaming: false });
if (activeStream === stream) {
activeStream = null;
}
}
});
chat.addEventListener("cancel", () => {
if (!activeStream || activeStream.cancelled) return;
activeStream.cancelled = true;
activeStream.abort.abort();
chat.cancelMessage(activeStream.assistantId, "*— Response stopped —*");
});highlight.js is not bundled — install it separately if you need code highlighting:
npm install highlight.jsPass your own pre-configured instance via config.highlightJs (only the languages you register are included):
import hljs from "highlight.js/lib/core";
import ts from "highlight.js/lib/languages/typescript";
hljs.registerLanguage("typescript", ts);
chat.config = { ...chat.config, highlightJs: hljs };When config.highlightJs is not set, code blocks render as plain <pre><code> without syntax highlighting — no error, no crash.
Intercept or transform messages with middleware, or package reusable logic as plugins:
// Middleware — transform content before send
chat.use({
name: "trim",
beforeSend: (content) => content.trim(),
});
// Plugin — packaged logic with install/teardown
chat.use({
name: "logger",
install(chat) {
const onSend = (e) => console.log("send:", e.detail.content);
chat.addEventListener("send", onSend);
return () => chat.removeEventListener("send", onSend);
},
});Message/error hooks apply to both direct methods and ChatRunController.
When afterMessageAdded rewrites a run placeholder ID, run.messageId and the
messageId in the start() outcome expose the effective ID. If middleware
drops the placeholder, start() returns
{ started: false, reason: "middleware-dropped" } and the run remains idle.
For pages without a bundler, use the @bndynet/ichat global IIFE build — it bundles all dependencies (lit, markdown-it, dompurify, highlight.js, morphdom, ichat-messages, ichat-input) into one self-contained file (~623KB). The global object is iChat (e.g. iChat.Chat, iChat.registerCodeRenderer, …).
<script src="https://unpkg.com/@bndynet/ichat/dist/ichat.global.js"></script>Note: renderer packages (
ichat-renderer-chart,ichat-renderer-katex,ichat-renderer-mermaid,ichat-renderers) do not ship global builds. For script-tag usage with renderers, use an import map or a bundler.
The demo app registers @bndynet/ichat-renderers in apps/demo/bootstrap.ts. When developing this monorepo, run npm run dev from the repo root: it starts watchers for all packages and the chat-demo app under apps/demo/. The dev server URL and port are printed in the terminal (Vite defaults, often http://localhost:5173/).
<i-chat>shell — default textarea + send/cancel, or replace the footer withslot="input"(<i-chat>API)ChatRunController— Full response lifecycle (start/stream/complete/fail/cancel) with built-inAbortController- Middleware & plugins —
chat.use()forChatMiddlewarehooks andChatPluginlifecycle (<i-chat>API) - Configurable syntax highlighting — optional
config.highlightJsinjection; falls back to plain<pre><code>when omitted - Voice input (default composer) — microphone button uses Web Speech API when available; hidden automatically on unsupported browsers (Composer & interaction)
- Lit 3 Web Components — works with any framework or vanilla HTML
- Markdown —
markdown-it+highlight.js, sanitized with DOMPurify - Extensible fenced blocks —
registerCodeRendererfrom@bndynet/ichat, orrendererRegistry+BlockRendererfor lower-level control (Custom renderers) - Extensible
x-*parts —registerPartRenderermaps custom part types to a Web Component or HTML string; pair with genericChat<TExtraParts>for full type-checking of custom part data (Parts) - Structured
parts[]body — every message body is an ordered list of typed parts (text,reasoning,tool-call,todo,file,source, customx-*); parts stream and update independently (Message model) - Reasoning parts — collapsible “thinking” UI + streaming (Parts)
- Tool calls — first-class
tool-callparts with a state machine, rich nested results, and human-in-the-loop approval (Parts) - Streaming typewriter — progressive reveal and cursor state on streaming
textparts (Composer & interaction) - Virtual scrolling —
config.virtualScrollkeeps only visible rows in the DOM for long histories;'auto'by default (engages above 500 messages), lazily loaded with a regular-list fallback (<i-chat>API) - Reply blocks — quote previews under a message via
replyMessage/clearReplyMessage(Composer & interaction) - Slots — avatars, actions, empty state (
<i-chat>API) - Progress —
[status]markdown lists rendered as vertical progress blocks (Progress) - Todo panel — structured, collapsible plans with item IDs, live status updates, and user actions (Todo panel)
- Theming — 12 base CSS custom properties; all components derive from them automatically (Theming)
- Localization & RTL — single
config.labelsdictionary, plurals, and automatic RTL mirroring (Localization) - TypeScript — declaration files for public API
Detailed design and reference docs live in docs/:
| Doc | Covers |
|---|---|
| React integration | Ref binding, props (React 19 vs ≤ 18), event listening, controlled mode, TS declaration merging, Next.js/SSR |
| Backend quickstart | Copy-paste adapters for OpenAI, Anthropic, Ollama, and custom SSE/WebSocket backends |
| Message model | Roles (ChatMessageRole), ChatMessage fields, the parts[] body, factories, streaming/updating |
<i-chat> API |
Properties, methods, events, slots, confirmations, highlight.js, ChatRunController, middleware |
| Parts | reasoning, tool-call, file, source, and x-* custom parts |
| Custom renderers | registerCodeRenderer + the chart / KPI / form / Mermaid extension renderers |
| Progress | [status] lists, block IDs, programmatic updates |
| Todo panel | Structured items, collapse behavior, status events, updates |
| Theming | 12 base tokens, derivation, light/dark contract, Mermaid tokens, full CSS reference |
| Localization (i18n) | config.locale / config.labels, plurals (makeDaysAgo), RTL |
| Composer & interaction | Streaming, reply blocks, voice input |
Clone, install, build, run the static demo:
npm install
npm run build # workspace order: chat-messages, chat-input, chat-renderers, chat, apps/demo
npm run test # 24 unit tests (pure helpers)
npm run dev # concurrent watch on all packages + chat-demo dev server (see root `package.json`)| Script | Description |
|---|---|
npm run build |
Builds all workspaces in dependency order (ends with apps/demo) |
npm run test |
Runs 24 unit tests for pure helpers |
npm run test:coverage |
Runs tests with Node.js coverage report |
npm run dev |
Watch mode for packages and the Vue demo app (chat-demo) |
npm run start |
Alias for npm run dev (see root package.json) |
To run only the demo app (after a successful npm run build): npm run dev -w chat-demo. Preview production build: npm run preview -w chat-demo.
Layout:
apps/demo/
docs/ # Design & reference docs
packages/chat-messages/
packages/chat-input/
packages/chat/ # @bndynet/ichat — <i-chat> shell (messages + input); registerCodeRenderer API
packages/chat-renderers/ # Optional fenced blocks; consumed by apps that call registerCodeRenderer
MIT