# @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.
