@kitn.ai/chat
Version:
Framework-agnostic, Shadow-DOM web components for building AI chat interfaces — works in React, Vue, Angular, Svelte, or plain HTML. Authored in SolidJS.
395 lines (296 loc) • 16.5 kB
Markdown
# @kitn.ai/chat
Framework-agnostic web components for building AI chat interfaces — message threads, prompt inputs, streaming responses, markdown + code rendering, reasoning/tool panels, attachments, and a conversation sidebar. Drop them into any app: React, Vue, Angular, Svelte, or plain HTML.
It can be consumed two ways:
1. **As framework-agnostic web components** *(primary)* — drop `<kc-chat>`, `<kc-conversations>`, and `<kc-prompt-input>` into any project (React, Vue, Angular, Svelte, plain HTML). Each is fully style-isolated via Shadow DOM, and the rendering runtime is bundled in, so the host needs nothing.
2. **As native SolidJS components** — the kit is authored in SolidJS, so SolidJS apps can import the components directly for full compositional control. (This is a convenience for SolidJS users, not a requirement — the web components work everywhere.)
## Highlights
- **~50 composable components** across three layers: headless primitives → accessible UI primitives (built in-house, WCAG 2.1 AA — no third-party UI dependency) → AI feature components.
- **Shadow-DOM web components** — zero CSS conflicts in any host. The host's styles can't leak in; the kit's Tailwind can't leak out.
- **Lightweight by design** — a markdown-only `<kc-chat>` is **~110 KB gzip** (one file). Syntax highlighting (Shiki) is loaded **on demand, per-language, with no WASM** — and never loads at all if you don't render code.
- **Tailwind v4** design tokens — rebrand by overriding `--color-*` custom properties.
## Install
```bash
npm install @kitn.ai/chat
```
SolidJS consumers also need `solid-js` (a peer dependency):
```bash
npm install solid-js
```
## Quick start
### Option A — Web components (any framework / plain HTML)
Build the bundle, then import it as a side-effect (it registers the custom elements):
```bash
npm run build # emits dist/kitn-chat.es.js
```
```html
<body style="height: 100vh; margin: 0;">
<kc-chat style="display:block; height:100%;"></kc-chat>
<script type="module">
import '@kitn.ai/chat/elements';
const chat = document.querySelector('kc-chat');
// Rich data is set as JS properties (not HTML attributes)
chat.messages = [
{ id: '1', role: 'assistant', content: 'Hello! How can I help?' },
];
// Events are CustomEvents dispatched on the element (they do not bubble)
chat.addEventListener('submit', (e) => {
console.log('user sent:', e.detail.value);
});
</script>
</body>
```
The element bundle is **ES-module only** and loads via `<script type="module">` in every modern browser. See **[docs/web-components.md](docs/web-components.md)** for the full element API (every property, event, and the `ChatMessage` schema).
#### Or load from a CDN (no build, no npm)
The element bundle is a self-contained ES module — load it directly from [jsDelivr](https://www.jsdelivr.com/package/npm/@kitn.ai/chat) or [unpkg](https://unpkg.com/browse/@kitn.ai/chat/), no install or bundler required:
```html
<script type="module">
import 'https://cdn.jsdelivr.net/npm/@kitn.ai/chat/dist/kitn-chat.es.js';
// …or unpkg: import 'https://unpkg.com/@kitn.ai/chat/dist/kitn-chat.es.js';
</script>
<kc-chat></kc-chat>
```
The URLs above track the **latest** release — handy for trying things out. **For production, pin an exact version** (e.g. `@kitn.ai/chat@0.4.0/dist/kitn-chat.es.js`): pinned URLs are immutable and cached far more aggressively, and — since this package is pre-1.0 — pinning shields you from breaking changes in a future minor release. SolidJS and the kit's CSS are bundled in, and the lazy code-highlighting chunks load from the same CDN on demand. To override design tokens, also include `theme.css`:
```html
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@kitn.ai/chat/theme.css">
```
### Option B — SolidJS components
```tsx
import {
ChatConfig, ChatContainer, ChatContainerContent,
Message, MessageContent,
PromptInput, PromptInputTextarea, PromptInputActions,
} from '@kitn.ai/chat';
import '@kitn.ai/chat/theme.css';
function App() {
const [input, setInput] = createSignal('');
return (
<ChatConfig proseSize="sm">
<ChatContainer class="h-full">
<ChatContainerContent class="space-y-4 p-4">
<Message>
<MessageContent markdown>{`## Hi\n\nAsk me anything.`}</MessageContent>
</Message>
</ChatContainerContent>
</ChatContainer>
<PromptInput value={input()} onValueChange={setInput} onSubmit={() => setInput('')}>
<PromptInputTextarea placeholder="Ask anything..." />
<PromptInputActions>{/* your buttons */}</PromptInputActions>
</PromptInput>
</ChatConfig>
);
}
```
The SolidJS entry (`.`) is the kit's raw source (`src/index.ts`) — your bundler compiles it, so it tree-shakes to just what you import.
## Integrations
The components are deliberately **transport-agnostic**: `<kc-chat>` just renders the `messages` array you give it and emits a `submit` event when the user sends. You own the request, the streaming, and any extras like text-to-speech. The patterns below use the web component, but the same wiring applies to the SolidJS API.
### Streaming responses from OpenRouter
[OpenRouter](https://openrouter.ai) exposes an OpenAI-compatible streaming API (Server-Sent Events). On `submit`, append the user message + an empty assistant message, then grow the assistant message as tokens arrive.
> **Security:** never ship your API key to the browser. In production, point `fetch` at your own backend endpoint that proxies to OpenRouter and injects the key. The parsing below is identical either way.
```html
<kc-chat id="chat" style="display:block; height:100vh;"></kc-chat>
<script type="module">
import '@kitn.ai/chat/elements';
const chat = document.getElementById('chat');
chat.messages = [];
chat.addEventListener('submit', async (e) => {
const text = e.detail.value.trim();
if (!text) return;
// 1. Show the user message immediately
const history = [...chat.messages, { id: crypto.randomUUID(), role: 'user', content: text }];
chat.messages = history;
chat.value = ''; // clear the input
chat.loading = true;
// 2. Add an empty assistant message we'll stream into
const assistantId = crypto.randomUUID();
chat.messages = [...history, { id: assistantId, role: 'assistant', content: '' }];
try {
// In production, replace this URL with your own proxy endpoint.
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${OPENROUTER_API_KEY}`, // server-side in production!
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'anthropic/claude-sonnet-4',
stream: true,
messages: history.map((m) => ({ role: m.role, content: m.content })),
}),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let answer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE frames are separated by newlines; each data line is JSON
const lines = buffer.split('\n');
buffer = lines.pop(); // keep the partial last line
for (const line of lines) {
const s = line.trim();
if (!s.startsWith('data:')) continue; // skip ": keep-alive" comments
const payload = s.slice(5).trim();
if (payload === '[DONE]') continue;
try {
const delta = JSON.parse(payload).choices?.[0]?.delta?.content;
if (!delta) continue;
answer += delta;
// Replace the assistant message with a NEW object so the row re-renders
chat.messages = chat.messages.map((m) =>
m.id === assistantId ? { ...m, content: answer } : m
);
} catch { /* ignore non-JSON keep-alive lines */ }
}
}
} catch (err) {
chat.messages = chat.messages.map((m) =>
m.id === assistantId ? { ...m, content: '⚠️ ' + err.message } : m
);
} finally {
chat.loading = false;
}
});
</script>
```
Key point: reassign `chat.messages` with a **new array containing a new object** for the streaming message on each chunk — that's what triggers the re-render. Mutating the existing object in place won't update the view.
### Text-to-speech (TTS)
#### Option 1 — Browser-native (zero dependencies)
The Web Speech API speaks text with no network call. Speak each assistant reply once it finishes streaming — call `speak(answer)` right before `chat.loading = false` in the example above:
```js
function speak(text) {
if (!('speechSynthesis' in window)) return;
const utter = new SpeechSynthesisUtterance(text);
utter.lang = 'en-US';
utter.rate = 1;
speechSynthesis.cancel(); // stop anything already playing
speechSynthesis.speak(utter);
}
```
To speak *as it streams*, flush complete sentences instead of waiting for the end:
```js
let spokenUpTo = 0;
function speakIncremental(fullText) {
const lastBreak = fullText.lastIndexOf('. ', fullText.length);
if (lastBreak > spokenUpTo) {
speak(fullText.slice(spokenUpTo, lastBreak + 1));
spokenUpTo = lastBreak + 1;
}
}
// call speakIncremental(answer) inside the streaming loop
```
#### Option 2 — Cloud TTS (OpenAI, ElevenLabs, …)
For higher-quality voices, have your backend call a TTS API and return audio, then play it. Keep the provider key on the server.
```js
async function speakCloud(text) {
const res = await fetch('/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text, voice: 'alloy' }),
});
const audio = new Audio(URL.createObjectURL(await res.blob()));
audio.play();
}
```
```js
// Example backend (Node) — /api/tts proxying OpenAI's speech endpoint
// const r = await openai.audio.speech.create({ model: 'gpt-4o-mini-tts', voice, input: text });
// res.setHeader('Content-Type', 'audio/mpeg');
// Readable.fromWeb(r.body).pipe(res);
```
You can trigger either option from the streaming completion (auto-read replies) or from a button you render alongside each message.
> **Speech-to-text** (the other direction) is already built in — the kit ships a `VoiceInput` component for capturing microphone input. See Storybook (`npm run dev`).
## Code highlighting (optional, on-demand)
Syntax highlighting uses [Shiki](https://shiki.style) and is wired to be as light as possible:
- Nothing loads until a fenced code block actually renders.
- Only the core, the **JavaScript regex engine (no WASM)**, the one theme, and the one language grammar needed are fetched — each a small lazy chunk.
- Default languages: `javascript`/`js`, `typescript`/`ts`, `tsx`, `json`, `bash`/`sh`. Add more or turn it off:
```js
import { configureCodeHighlighting } from '@kitn.ai/chat/elements'; // or '@kitn.ai/chat'
configureCodeHighlighting({
languages: { python: () => import('@shikijs/langs/python') },
});
// or disable entirely — no Shiki ever loads:
configureCodeHighlighting({ enabled: false });
```
Per element: `<kc-chat codeHighlight={false}>` renders code as plain text.
## Theming
Visual appearance is driven by `--color-*` CSS custom properties in `theme.css`. Because inherited CSS pierces the Shadow DOM boundary, overriding tokens on `:root` rebrands the components — even the web-component ones:
```css
:root {
--color-background: #0f0f0f;
--color-primary: #7c3aed;
--color-muted: #1e1e1e;
}
```
For SolidJS usage, import `@kitn.ai/chat/theme.css` once. For web components the kit's CSS is injected into each shadow root automatically; only `theme.css` (design tokens) is optional to include.
## For AI agents / LLMs
The package ships [llmstxt.org](https://llmstxt.org)-style files so coding agents (Claude Code, Copilot, Cursor, Codex) can wire up the components correctly:
- **[`llms.txt`](./llms.txt)** — dense orientation: install, the property-vs-attribute rule, the two-layer architecture, theming, and framework wiring.
- **[`llms-full.txt`](./llms-full.txt)** — the above plus a generated props/events reference for every `kitn-*` element, a streaming recipe, and a build runbook.
Both are auto-generated from `dist/custom-elements.json` during `npm run build` (so they never drift) and are published in the npm package — find them at `node_modules/@kitn.ai/chat/llms.txt` after install.
> **#1 thing agents get wrong:** array/object data (`messages`, `models`, `context`, …) must be set as **JS properties**, not HTML attributes. Only scalars (`placeholder`, `loading`, `theme`) work as attributes.
## Development
```bash
npm install # install dependencies
npm run dev # Storybook dev server at http://localhost:6006 (component playground)
npm test # run the test suite (Vitest: jsdom unit tests + Storybook browser tests)
npm run build # build the web-component bundle into dist/
npm run build-storybook # static Storybook build
```
Storybook is the primary way to explore and develop components in isolation.
### Project structure
```
src/
primitives/ Headless logic hooks + ChatConfig + on-demand highlighter
ui/ Accessible UI primitives (Button, Dropdown, Tooltip, HoverCard, … built in-house, no third-party UI deps)
components/ AI feature components (Message, PromptInput, Markdown, Tool, …)
elements/ Web-component facades + defineKitnElement wrapper + Vite lib entry
stories/ Composed example stories (full chat app, layouts)
theme.css Design tokens (--color-*), animations, markdown styles
docs/
web-components.md Full web-component API reference
```
The web-component layer wraps a few coarse facades over the SolidJS components; the SolidJS API stays the source of truth and is unchanged by it.
## Examples
A set of runnable examples and a hosted component playground are included in the repo. See [`examples/README.md`](examples/README.md) for full details.
### Composable showcase
The composable showcase demonstrates every individual element in one page. Build the package first, then serve from the repo root:
```bash
npm run build # produces dist/kitn-chat.es.js
npm run examples # static server at http://localhost:8000
```
Then open: **http://localhost:8000/examples/composable/index.html**
### Storybook
Storybook is the primary component playground for development:
```bash
npm run dev # dev server at http://localhost:6006
```
The published docs are deployed to GitHub Pages:
**https://kitn-ai.github.io/chat/**
### Framework example apps
The `examples/react`, `examples/solid`, `examples/angular`, and `examples/vue` directories are full Vite apps — install their dependencies and run the local dev server:
```bash
cd examples/react && npm install && npm run dev
# or
cd examples/solid && npm install && npm run dev
# or
cd examples/angular && npm install && npm run dev
# or
cd examples/vue && npm install && npm run dev
```
- `examples/react` — uses the generated React wrappers from `@kitn.ai/chat/react`
- `examples/solid` — uses the raw SolidJS component API
- `examples/angular` — uses the web components natively via Angular's `[prop]` / `(event)` bindings with `CUSTOM_ELEMENTS_SCHEMA` (no wrappers needed)
- `examples/vue` — uses the web components natively via Vue's `:prop.prop` modifier and `@event` bindings; `isCustomElement` in `vite.config.ts` prevents Vue treating `kitn-*` tags as Vue components
### Docs and reference
- **[docs/web-components.md](docs/web-components.md)** — full element API: every property, event, and the `ChatMessage` schema.
- **[llms.txt](llms.txt)** / **[llms-full.txt](llms-full.txt)** — dense machine-readable references for AI coding agents.
## Bundle size
| Scenario | Loaded |
|---|--:|
| `<kc-chat>`, markdown only (no code blocks) | **~110 KB gzip** (~413 KB raw), one file |
| + a code block | adds Shiki core + JS engine + that language + theme, lazily |
| Highlighting disabled | Shiki never loads |
The build is ES-module only — a UMD/IIFE build can't code-split and would inline every lazy chunk into one multi-MB file, so it's intentionally omitted.