@kitn.ai/ui
Version:
Framework-agnostic, Shadow-DOM web components for building AI chat interfaces — works in React, Vue, Angular, Svelte, or plain HTML. Authored in SolidJS.
1,178 lines (1,143 loc) • 116 kB
JavaScript
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { ListToolsRequestSchema, CallToolRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
function resolveManifestPath() {
const thisFile = fileURLToPath(import.meta.url);
const thisDir = dirname(thisFile);
const sibling = join(thisDir, "custom-elements.json");
if (existsSync(sibling)) {
return sibling;
}
let dir = thisDir;
for (let i = 0; i < 10; i++) {
const candidate = join(dir, "dist", "custom-elements.json");
if (existsSync(candidate)) {
return candidate;
}
const parent = dirname(dir);
if (parent === dir) break;
dir = parent;
}
throw new Error(
`Could not find custom-elements.json. Searched from: ${thisDir}`
);
}
let _manifest;
function getManifest() {
if (!_manifest) {
const path = resolveManifestPath();
const raw = readFileSync(path, "utf-8");
_manifest = JSON.parse(raw);
}
return _manifest;
}
function getDeclarations() {
return getManifest().modules.flatMap((m) => m.declarations ?? []);
}
function getElement(tag) {
return getDeclarations().find((d) => d.tagName === tag);
}
function listElements() {
return getDeclarations().filter((d) => d.tagName).map((d) => d.tagName).sort();
}
const JS_ONLY_TYPE_PATTERNS = /\[\]|\{|Record</;
function isJsOnlyType(typeText) {
return typeText ? JS_ONLY_TYPE_PATTERNS.test(typeText) : false;
}
function formatReference(tag) {
const el = getElement(tag);
if (!el) {
const all = listElements();
const sample = all.slice(0, 5).join(", ");
return `Unknown element: ${tag}
Valid tags include: ${sample} (and ${Math.max(0, all.length - 5)} more).
Call component_reference with no name (or name: "list") to list all ${all.length} elements.`;
}
const lines = [];
lines.push(`## <${tag}>`);
if (el.description) {
lines.push("", el.description.trim());
}
lines.push(
"",
"### AI/UI contract",
'`kai-*` elements accept **array and object data as JavaScript properties** (set in JavaScript via `el.property = value`, not as HTML attributes). Events are native CustomEvents — listen with `el.addEventListener("event-name", handler)` and read `event.detail` for the payload.'
);
const publicFields = (el.members ?? []).filter(
(m) => m.kind === "field" && m.privacy === "public"
);
if (publicFields.length > 0) {
lines.push("", "### Props (JavaScript properties)");
for (const field of publicFields) {
const type = field.type?.text ?? "unknown";
const jsOnly = isJsOnlyType(type);
const desc = field.description?.trim() ?? "";
const note = jsOnly ? " ⚑ set as a JS property, not an HTML attribute" : "";
lines.push(`- **${field.name}** \`${type}\`${note}`);
if (desc) {
lines.push(` ${desc}`);
}
}
}
const attrs = el.attributes ?? [];
if (attrs.length > 0) {
lines.push("", "### Attributes (HTML-safe)");
for (const attr of attrs) {
const type = attr.type?.text ?? "unknown";
const desc = attr.description?.trim() ?? "";
lines.push(`- **${attr.name}** \`${type}\``);
if (desc) {
lines.push(` ${desc}`);
}
}
}
const events = el.events ?? [];
if (events.length > 0) {
lines.push("", "### Events (CustomEvent, listen via addEventListener)");
for (const ev of events) {
const desc = ev.description?.trim() ?? "";
lines.push(`- **${ev.name}** — ${desc}`);
}
}
const cssProps = el.cssProperties ?? [];
if (cssProps.length > 0) {
lines.push("", "### CSS custom properties");
for (const prop of cssProps) {
const desc = prop.description?.trim() ?? "";
const def = prop.default ? ` (default: ${prop.default})` : "";
lines.push(`- **${prop.name}**${def}${desc ? ` — ${desc}` : ""}`);
}
}
const slots = el.slots ?? [];
if (slots.length > 0) {
lines.push(
"",
"### Composition slots",
'Project your own markup into these named regions — add a light-DOM child with a `slot="…"` attribute (e.g. `<div slot="sidebar">…</div>`).'
);
for (const slot of slots) {
const desc = slot.description?.trim() ?? "";
lines.push(`- **${slot.name}**${desc ? ` — ${desc}` : ""}`);
}
}
const parts = el.cssParts ?? [];
if (parts.length > 0) {
lines.push(
"",
"### Styleable parts (`::part`)",
"Restyle these from outside the Shadow DOM via `" + tag + "::part(name) { … }`."
);
for (const part of parts) {
const desc = part.description?.trim() ?? "";
lines.push(`- **${part.name}**${desc ? ` — ${desc}` : ""}`);
if (part.recipe) {
lines.push(" ```css", ` ${part.recipe}`, " ```");
}
}
}
return lines.join("\n");
}
const reference = {
name: "component_reference",
description: "Look up AI/UI (kai-*) web components: their tags, props, events, and usage examples.",
inputSchema: z.object({ name: z.string().optional() }),
handler: async (args) => {
const name = typeof args.name === "string" ? args.name.trim() : void 0;
let text2;
if (!name || name === "list") {
const tags = listElements();
text2 = `AI/UI elements (${tags.length} total):
` + tags.map((t) => ` ${t}`).join("\n") + '\n\nCall component_reference with a specific name (e.g. { name: "kai-chat" }) for full API details.';
} else {
text2 = formatReference(name);
}
return { content: [{ type: "text", text: text2 }] };
}
};
const Category = z.enum(["provider", "gateway", "framework", "harness", "mock"]);
const Language = z.enum(["ts", "python"]);
const StreamFormat = z.enum(["openai-sse", "ai-sdk", "native"]);
const Framework = z.enum(["html", "react", "next", "vue", "svelte", "fastapi", "express", "worker", "tanstack-start"]);
const Placement = z.enum(["side", "full-page", "docked-widget", "inline"]);
z.object({
id: z.string(),
title: z.string(),
category: Category,
language: Language,
streamFormat: StreamFormat,
envVars: z.array(z.string()).default([]),
routeTemplates: z.record(z.string(), z.string()),
// keyed by Framework value → code string
streamMapping: z.string(),
// prose: how the stream maps to messages
runNote: z.string(),
docsSlug: z.string()
});
z.object({
id: z.string(),
title: z.string(),
components: z.array(z.string()),
// kai-* tags, e.g. ['kai-chat', 'kai-sources']
defaultPlacement: Placement,
docsSlug: z.string()
});
const openrouter = {
id: "openrouter",
title: "OpenRouter",
category: "gateway",
language: "ts",
streamFormat: "openai-sse",
envVars: ["OPENROUTER_API_KEY"],
routeTemplates: {
next: `export async function POST(req: Request) {
const { model, messages } = await req.json();
const upstream = await fetch('https://openrouter.ai/api/v1/chat/completions', {
method: 'POST',
headers: { Authorization: \`Bearer \${process.env.OPENROUTER_API_KEY}\`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model, messages, stream: true }),
});
return new Response(upstream.body, { headers: { 'Content-Type': 'text/event-stream' } });
}`
},
streamMapping: "OpenRouter returns OpenAI-format SSE — pipe upstream.body straight to the browser; the Streaming-recipe reader handles it.",
runNote: "Set OPENROUTER_API_KEY. Model ids are vendor/model, e.g. openai/gpt-4o.",
docsSlug: "integrations/connect-any-model"
};
const vercelAiSdk = {
id: "vercel-ai-sdk",
title: "Vercel AI SDK",
category: "framework",
language: "ts",
streamFormat: "ai-sdk",
envVars: ["AI_GATEWAY_API_KEY"],
routeTemplates: {
next: `// app/api/chat/route.ts
import { streamText } from 'ai';
export const maxDuration = 30; // allow long streaming responses
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: 'openai/gpt-4o', // AI Gateway id; needs AI_GATEWAY_API_KEY
messages,
});
const encoder = new TextEncoder();
const sse = new ReadableStream({
async start(controller) {
for await (const delta of result.textStream) {
const chunk = { choices: [{ delta: { content: delta } }] };
controller.enqueue(encoder.encode(\`data: \${JSON.stringify(chunk)}\\n\\n\`));
}
controller.enqueue(encoder.encode('data: [DONE]\\n\\n'));
controller.close();
},
});
return new Response(sse, { headers: { 'Content-Type': 'text/event-stream' } });
}`
},
streamMapping: "The Vercel AI SDK's toUIMessageStreamResponse() and toTextStreamResponse() don't emit OpenAI-format SSE. Wrap result.textStream manually: iterate text deltas and emit data: {choices:[{delta:{content}}]} frames, closing with data: [DONE]. The kai-chat SSE reader handles it.",
runNote: "Set AI_GATEWAY_API_KEY for the AI Gateway (string model id form: creator/model-name). For direct provider access, import its provider package (e.g. @ai-sdk/openai) and set the corresponding key (e.g. OPENAI_API_KEY).",
docsSlug: "integrations/vercel-ai-sdk"
};
const langgraph = {
id: "langgraph",
title: "LangGraph",
category: "framework",
language: "ts",
streamFormat: "openai-sse",
envVars: ["OPENAI_API_KEY"],
routeTemplates: {
next: `// POST /api/chat — stream a compiled LangGraph agent to the browser
import { createReactAgent } from '@langchain/langgraph/prebuilt';
import { ChatOpenAI } from '@langchain/openai';
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
const getWeather = tool(
async ({ city }) => \`It's 18°C and clear in \${city}.\`,
{
name: 'get_weather',
description: 'Get the current weather for a city.',
schema: z.object({ city: z.string() }),
},
);
const agent = createReactAgent({
llm: new ChatOpenAI({ model: 'gpt-4o' }),
tools: [getWeather],
});
export async function POST(req: Request) {
const { messages } = await req.json();
const stream = await agent.stream({ messages }, { streamMode: 'messages' });
const encoder = new TextEncoder();
const body = new ReadableStream({
async start(controller) {
const send = (obj: unknown) =>
controller.enqueue(encoder.encode(\`data: \${JSON.stringify(obj)}\\n\\n\`));
for await (const [chunk] of stream) {
if (typeof chunk.content === 'string' && chunk.content) {
send({ choices: [{ delta: { content: chunk.content } }] });
}
}
controller.enqueue(encoder.encode('data: [DONE]\\n\\n'));
controller.close();
},
});
return new Response(body, { headers: { 'Content-Type': 'text/event-stream' } });
}`
},
streamMapping: "Use graph.stream(input, { streamMode: 'messages' }) to get [messageChunk, metadata] tuples. Extract chunk.content (string) and forward as OpenAI-format SSE frames: data: {choices:[{delta:{content}}]}. Close with data: [DONE]. The kai-chat reader handles it.",
runNote: "Set OPENAI_API_KEY (or the key for your chosen model provider). Install @langchain/langgraph, @langchain/openai, @langchain/core.",
docsSlug: "integrations/langgraph"
};
const cloudflare = {
id: "cloudflare",
title: "Cloudflare AI",
category: "provider",
language: "ts",
streamFormat: "openai-sse",
envVars: ["CF_ACCOUNT_ID", "CF_API_TOKEN"],
routeTemplates: {
next: `// app/api/chat/route.ts — proxy Workers AI, keep the token server-side
export async function POST(req: Request) {
const { messages } = await req.json();
const upstream = await fetch(
\`https://api.cloudflare.com/client/v4/accounts/\${process.env.CF_ACCOUNT_ID}/ai/v1/chat/completions\`,
{
method: 'POST',
headers: {
Authorization: \`Bearer \${process.env.CF_API_TOKEN}\`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: '@cf/meta/llama-3.1-8b-instruct',
messages,
stream: true,
}),
},
);
// Workers AI returns OpenAI-format SSE — pass it straight through.
return new Response(upstream.body, { headers: { 'Content-Type': 'text/event-stream' } });
}`,
worker: `// Worker handler — env.AI is bound in wrangler.toml
// env.AI.run emits Cloudflare-native SSE (data: {"response":"<token>"}).
// The TransformStream below re-frames each chunk to OpenAI-format SSE so
// kai-chat's reader works without any client-side changes.
export default {
async fetch(req: Request, env: Env): Promise<Response> {
const { messages } = await req.json();
const nativeStream = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
messages,
stream: true,
});
// Re-frame Cloudflare-native SSE → OpenAI-format SSE
const { readable, writable } = new TransformStream();
const writer = writable.getWriter();
const encoder = new TextEncoder();
const decoder = new TextDecoder();
(async () => {
const reader = (nativeStream as ReadableStream<Uint8Array>).getReader();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const s = line.trim();
if (!s.startsWith('data:')) continue;
const payload = s.slice(5).trim();
if (payload === '[DONE]') continue;
try {
const { response } = JSON.parse(payload) as { response?: string };
if (response == null) continue;
const openaiChunk = JSON.stringify({ choices: [{ delta: { content: response } }] });
await writer.write(encoder.encode(\`data: \${openaiChunk}\\n\\n\`));
} catch { /* skip malformed lines */ }
}
}
await writer.write(encoder.encode('data: [DONE]\\n\\n'));
await writer.close();
})();
return new Response(readable, { headers: { 'Content-Type': 'text/event-stream' } });
},
};`
},
streamMapping: `Workers AI via the OpenAI-compatible HTTP endpoint returns OpenAI-format SSE — pipe upstream.body straight to the browser; kai-chat's reader handles it. The native env.AI binding streams Cloudflare's own format (data: {"response":"...token..."}); the worker route template re-frames these chunks to OpenAI-format SSE via a TransformStream before returning.`,
runNote: 'Set CF_ACCOUNT_ID and CF_API_TOKEN. Model ids are prefixed with @cf/, e.g. @cf/meta/llama-3.1-8b-instruct. For the AI binding (worker key), add an [ai] block with binding = "AI" in wrangler.toml.',
docsSlug: "integrations/cloudflare-ai"
};
const ollama = {
id: "ollama",
title: "Ollama",
category: "provider",
language: "ts",
streamFormat: "openai-sse",
envVars: [],
routeTemplates: {
next: `// app/api/chat/route.ts — proxy the browser to local Ollama
export async function POST(req: Request) {
const { messages } = await req.json();
const upstream = await fetch('http://localhost:11434/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'llama3.2', messages, stream: true }),
});
// Ollama returns OpenAI-format SSE — stream it straight to the browser.
return new Response(upstream.body, { headers: { 'Content-Type': 'text/event-stream' } });
}`,
html: `<kai-chat id="chat"></kai-chat>
<script type="module">
import '@kitn.ai/ui/elements';
const chat = document.getElementById('chat');
chat.addEventListener('kai-submit', async (e) => {
const history = [...chat.messages, { id: crypto.randomUUID(), role: 'user', content: e.detail.value }];
chat.messages = [...history, { id: crypto.randomUUID(), role: 'assistant', content: '' }];
await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: history }),
});
// stream the reply into the last message — see the Streaming recipe
});
<\/script>`
},
streamMapping: "Ollama's OpenAI-compatible endpoint (http://localhost:11434/v1/chat/completions) returns OpenAI-format SSE — pipe upstream.body straight to the browser; kai-chat's reader handles it. No API key needed; pass any string if a client requires one (Ollama ignores it).",
runNote: "No API key required. Run: ollama serve (starts on 127.0.0.1:11434), then ollama pull <model>. For browser-direct access, set OLLAMA_ORIGINS to allow the page origin; restart Ollama after any env change.",
docsSlug: "integrations/ollama"
};
const mastra = {
id: "mastra",
title: "Mastra",
category: "harness",
language: "ts",
streamFormat: "openai-sse",
envVars: ["MASTRA_URL"],
routeTemplates: {
express: `// POST /api/chat — your server proxies a Mastra agent to the browser
import { MastraClient } from '@mastra/client-js';
const mastra = new MastraClient({ baseUrl: process.env.MASTRA_URL });
const stream = await mastra.getAgent('supportAgent').stream({ messages });
for await (const delta of stream.textStream) {
res.write(\`data: \${JSON.stringify({ choices: [{ delta: { content: delta } }] })}\\n\\n\`);
}
res.write('data: [DONE]\\n\\n');`
},
streamMapping: "Mastra agents speak Vercel AI SDK v5 and expose stream.textStream (async iterable of string deltas). Iterate textStream and emit data: {choices:[{delta:{content}}]} frames; close with data: [DONE]. kai-chat's SSE reader handles it. For tool calls and reasoning, convert the agent to a UI message stream with @mastra/ai-sdk.",
runNote: "Set MASTRA_URL to your Mastra server base URL (mastra dev exposes POST /api/agents/:agentId/stream on port 4111). Install @mastra/client-js.",
docsSlug: "integrations/harnesses"
};
const pi = {
id: "pi",
title: "Pi",
category: "harness",
language: "ts",
streamFormat: "native",
envVars: [],
routeTemplates: {
express: `import { spawn } from 'node:child_process';
// POST /api/chat — bridge a Pi RPC session to the browser as SSE
const pi = spawn('pi', ['--mode', 'rpc', '--no-session']);
// Send the user's turn. Pi commands are { type, message }.
pi.stdin.write(JSON.stringify({ type: 'prompt', message: prompt }) + '\\n');
let buffer = '';
pi.stdout.on('data', (chunk) => {
buffer += chunk.toString();
const lines = buffer.split('\\n');
buffer = lines.pop(); // hold the partial line for the next chunk
for (const line of lines) {
if (!line) continue;
const event = JSON.parse(line);
const part = event.assistantMessageEvent;
if (event.type === 'message_update' && part?.type === 'text_delta') {
res.write(\`data: \${JSON.stringify({ choices: [{ delta: { content: part.delta } }] })}\\n\\n\`);
}
}
});
pi.on('close', () => { res.write('data: [DONE]\\n\\n'); res.end(); });`
},
streamMapping: "Pi runs as a local stdio process in RPC mode (pi --mode rpc --no-session). It emits newline-delimited JSON events on stdout. Map message_update events where assistantMessageEvent.type === 'text_delta' to data: {choices:[{delta:{content:part.delta}}]} SSE frames; send data: [DONE] on close. Split stdout on \\n (not readline, which breaks on Unicode separators U+2028/U+2029). Pi also emits thinking_delta (map to reasoning) and toolcall_* events (map to tool calls).",
runNote: "Pi must be installed locally and available on PATH as 'pi'. No API key is required by the bridge itself; Pi uses its own credentials. Pi runs with full user permissions — sandbox before exposing to a public endpoint. See the RPC reference: https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/rpc.md",
docsSlug: "integrations/harnesses"
};
const pydanticAi = {
id: "pydantic-ai",
title: "Pydantic AI",
category: "framework",
language: "python",
streamFormat: "openai-sse",
envVars: ["OPENAI_API_KEY"],
routeTemplates: {
fastapi: `# main.py
import json
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from pydantic_ai import Agent
agent = Agent('openai:gpt-4o')
app = FastAPI()
app.add_middleware(
CORSMiddleware, allow_origins=['*'], allow_methods=['*'], allow_headers=['*']
)
class Message(BaseModel):
role: str
content: str
class ChatRequest(BaseModel):
messages: list[Message]
async def openai_sse(messages: list[Message]):
prompt = messages[-1].content if messages else ''
async with agent.run_stream(prompt) as result:
async for delta in result.stream_text(delta=True):
chunk = {'choices': [{'delta': {'content': delta}}]}
yield f'data: {json.dumps(chunk)}\\n\\n'
yield 'data: [DONE]\\n\\n'
@app.post('/api/chat')
async def chat(req: ChatRequest):
return StreamingResponse(openai_sse(req.messages), media_type='text/event-stream')`
},
streamMapping: "Pydantic AI's agent.run_stream() yields text deltas via result.stream_text(delta=True). Each delta is re-framed as a data: {choices:[{delta:{content}}]} SSE line and the stream closes with data: [DONE]. The kai-chat reader is unchanged — it sees standard OpenAI-format SSE.",
runNote: "Install: pip install pydantic-ai fastapi uvicorn. Set OPENAI_API_KEY. Run: uvicorn main:app --reload (default port 8000). Point kai-chat at http://localhost:8000/api/chat.",
docsSlug: "integrations/pydantic-ai"
};
const mock = {
id: "mock",
title: "Mock (local preview)",
category: "mock",
language: "ts",
streamFormat: "native",
envVars: [],
routeTemplates: {},
streamMapping: "No backend. onSubmit streams a canned assistant reply client-side, one token at a time, reassigning messages (new array/object reference) per chunk so kai-chat re-renders. Swap integration for a real provider when ready.",
runNote: "No backend or API key needed — replies stream locally for preview. Run the front-end as-is; swap `integration` for a real provider (e.g. openrouter, ollama) when ready.",
docsSlug: "integrations/mock"
};
const archetypes$1 = [
{
id: "drop-in-chat",
title: "Drop-in chat",
components: ["kai-chat"],
defaultPlacement: "full-page",
docsSlug: "examples/drop-in-chat"
},
{
id: "support-widget",
title: "Support widget",
components: ["kai-chat"],
defaultPlacement: "docked-widget",
docsSlug: "examples/support-widget"
},
{
id: "knowledge-base",
title: "Knowledge base / RAG",
components: ["kai-chat", "kai-sources"],
defaultPlacement: "full-page",
docsSlug: "examples/knowledge-base"
},
{
id: "agentic",
title: "Agentic assistant",
components: ["kai-chat", "kai-tool", "kai-reasoning"],
defaultPlacement: "side",
docsSlug: "examples/agentic-assistant"
},
{
id: "workspace",
title: "Agentic workspace",
components: ["kai-chat", "kai-artifact", "kai-resizable"],
defaultPlacement: "side",
docsSlug: "examples/workspace"
},
{
id: "voice",
title: "Voice assistant",
components: ["kai-chat", "kai-voice-input"],
defaultPlacement: "full-page",
docsSlug: "examples/voice-assistant"
}
];
const integrations = [
openrouter,
vercelAiSdk,
langgraph,
cloudflare,
ollama,
mastra,
pi,
pydanticAi,
mock
];
const archetypes = archetypes$1;
function getIntegration(id) {
return integrations.find((i) => i.id === id);
}
function getArchetype(id) {
return archetypes.find((a) => a.id === id);
}
function listIntegrations() {
return integrations;
}
function listArchetypes() {
return archetypes;
}
const text = (s) => ({
content: [{ type: "text", text: s }]
});
const FLEX_FILL = "flex: 1; min-height: 0;";
function placementStyle(placement) {
switch (placement) {
case "full-page":
return {
style: "height: 100dvh; width: 100%; display: flex; flex-direction: column;",
chatFill: FLEX_FILL,
note: "fills the viewport (100dvh)"
};
case "inline":
return {
style: "width: 100%; max-width: 720px; height: 540px; margin: 0 auto; display: flex; flex-direction: column;",
chatFill: FLEX_FILL,
note: "in-flow block (sized by parent, not fixed)"
};
case "side":
return {
style: "position: fixed; top: 0; inset-inline-end: 0; height: 100dvh; width: 380px; border-inline-start: 1px solid var(--kai-color-border); display: flex; flex-direction: column; z-index: 1000;",
chatFill: FLEX_FILL,
note: "full-height side panel, docked to the trailing edge (100dvh)",
altNote: "In-flow alternative (push content instead of overlay): drop `position`/`z-index` and make this a `flex: 0 0 380px` column inside a `display: flex` row at `height: 100dvh`."
};
case "docked-widget":
return {
style: "position: fixed; bottom: 1.5rem; inset-inline-end: 1.5rem; width: 380px; height: 600px; max-height: calc(100dvh - 3rem); border-radius: 16px; overflow: hidden; box-shadow: 0 12px 32px var(--kai-shadow-color, rgba(0,0,0,0.18)); display: flex; flex-direction: column; z-index: 1000;",
chatFill: FLEX_FILL,
note: "fixed, floating bottom-right widget"
};
default:
return {
style: "height: 100dvh; width: 100%; display: flex; flex-direction: column;",
chatFill: FLEX_FILL,
note: "fills the viewport (100dvh)"
};
}
}
const DEFAULT_SUGGESTIONS = ["What's new?", "How can you help?"];
const MOCK_REPLY = "Hi! I'm a local preview — no backend or API key needed. Swap `integration` for a real provider (openrouter, ollama, …) and I'll talk to a real model.";
function jsArray(items) {
return "[" + items.map((s) => JSON.stringify(s)).join(", ") + "]";
}
function mockStreamBody(opts) {
const { pad, read, commitInitial, commitMap, setLoading, strictRoles = false } = opts;
const asConst = strictRoles ? " as const" : "";
const mapBody = `(m.id === assistantId ? { ...m, content: answer } : m)`;
return [
`${pad}const value = e.detail.value.trim();`,
`${pad}if (!value) return;`,
`${pad}const history = [...${read}, { id: crypto.randomUUID(), role: 'user'${asConst}, content: value }];`,
`${pad}const assistantId = crypto.randomUUID();`,
`${pad}${commitInitial(`[...history, { id: assistantId, role: 'assistant'${asConst}, content: '' }]`)}`,
`${pad}${setLoading("true")}`,
`${pad}// No backend: stream a canned reply client-side, one token at a time.`,
`${pad}const reply = ${JSON.stringify(MOCK_REPLY)};`,
`${pad}const tokens = reply.split(/(\\s+)/);`,
`${pad}let answer = '';`,
`${pad}for (const tok of tokens) {`,
`${pad} await new Promise((r) => setTimeout(r, 24));`,
`${pad} answer += tok;`,
`${pad} // new array + object reference per chunk so kai-chat re-renders`,
`${pad} ${commitMap(mapBody)}`,
`${pad}}`,
`${pad}${setLoading("false")}`
].join("\n");
}
function defaultModelFor(integration) {
const routeSrc = Object.values(integration.routeTemplates).join("\n");
if (!routeSrc.includes("model")) return void 0;
const defaults = {
openrouter: "openai/gpt-4o-mini",
ollama: "llama3.2",
"vercel-ai-sdk": "openai/gpt-4o-mini",
cloudflare: "openai/gpt-4o-mini"
};
return defaults[integration.id] ?? "openai/gpt-4o-mini";
}
const MESSAGE_EMBEDDED_TAGS = /* @__PURE__ */ new Set(["kai-tool", "kai-reasoning"]);
const WORKSPACE_STRUCTURAL_TAGS = /* @__PURE__ */ new Set(["kai-resizable", "kai-artifact"]);
function isWorkspace(archetype) {
return archetype.components.includes("kai-resizable") && archetype.components.includes("kai-artifact");
}
const SAMPLE_AGENTIC_MESSAGE = {
id: "sample-assistant",
role: "assistant",
content: "Searched the web for current pricing.",
reasoning: { text: "I should call the search tool to get up-to-date data." },
tools: [
{
type: "search",
state: "output-available",
input: { query: "current pricing" },
output: { results: ["Result A", "Result B"] },
toolCallId: "tc_001"
}
]
};
function componentTags(archetype, chatFill) {
if (isWorkspace(archetype)) {
return [
` <!-- SCAF-14: workspace split — chat pane left, artifact preview right. -->`,
` <!-- kai-resizable needs kai-resizable-item children to render panels. -->`,
` <kai-resizable orientation="horizontal" style="display:block;width:100%;height:100%">`,
` <kai-resizable-item size="40%" min="240px">`,
` <kai-chat id="chat" suggestion-mode="submit" style="${chatFill}"></kai-chat>`,
` </kai-resizable-item>`,
` <kai-resizable-item min="280px">`,
` <!-- Replace src with your artifact URL or set .files for multi-file preview. -->`,
` <kai-artifact id="artifact" src="https://example.com" style="width:100%;height:100%"></kai-artifact>`,
` </kai-resizable-item>`,
` </kai-resizable>`
].join("\n");
}
const companionTags = archetype.components.filter(
(t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t)
);
const hasEmbedded = archetype.components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
const hasStandaloneCompanions = companionTags.length > 0;
const lines = [];
lines.push(` <kai-chat id="chat" suggestion-mode="submit" style="${chatFill}"></kai-chat>`);
if (hasEmbedded) {
lines.push(
` <!-- kai-tool / kai-reasoning render INSIDE the thread, not as siblings.`,
` Seed messages with { tools: [...], reasoning: { text: '...' } } — see the sample in the script below. -->`
);
}
for (const tag of companionTags) {
if (tag === "kai-sources") {
lines.push(
` <!-- Replace sampleSources with your data. -->`,
` <kai-sources id="sources"></kai-sources>`
);
} else {
lines.push(` <${tag}></${tag}>`);
}
}
if (hasStandaloneCompanions) {
lines.push(` <!-- wire data props — see the component_reference MCP tool -->`);
}
return lines.join("\n");
}
function htmlWiring(ctx, archetype) {
const hasEmbedded = archetype.components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
const hasSources = archetype.components.includes("kai-sources");
const seedLines = hasEmbedded ? [
` // SCAF-9: tool calls + reasoning render INSIDE the thread — set them on the message object.`,
` // Replace this sample with real messages from your backend.`,
` chat.messages = [${JSON.stringify(SAMPLE_AGENTIC_MESSAGE, null, 0)}];`,
``
] : [];
const sourcesSetupLines = hasSources ? [
` const sourcesEl = document.getElementById('sources');`,
` // Replace with your real source data (set as a JS property — it's an array).`,
` const sampleSources = [`,
` { href: 'https://example.com/doc1', title: 'Getting started', description: 'Overview of the product.' },`,
` { href: 'https://example.com/doc2', title: 'API reference', description: 'Full API documentation.' },`,
` ];`,
` sourcesEl.sources = sampleSources;`,
``
] : [];
const head = [
` <script type="module">`,
` import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
` import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
``,
` // Guard: module scripts run before the DOM is ready when inlined in <head>.`,
` // DOMContentLoaded fires synchronously when already loaded; otherwise waits.`,
` async function init() {`,
` const chat = document.getElementById('chat');`,
` // SCAF-15: kai-* register via an async dynamic import (SSR-safety), so the`,
` // element may not be upgraded yet. Wait for the upgrade before setting any`,
` // array/object property — values set pre-upgrade are dropped on upgrade.`,
` await customElements.whenDefined('kai-chat');`,
` // suggestions is a JS PROPERTY (arrays can't be HTML attributes)`,
` chat.suggestions = ${jsArray(ctx.suggestions)};`,
` chat.suggestionMode = 'submit';`,
``,
...seedLines.map((l) => l.trim() === "" ? l : ` ${l}`),
...sourcesSetupLines.map((l) => l.trim() === "" ? l : ` ${l}`)
];
const domReadyFooter = [
` }`,
` if (document.readyState === 'loading') {`,
` document.addEventListener('DOMContentLoaded', init);`,
` } else {`,
` init();`,
` }`
];
if (ctx.isMock) {
const body = mockStreamBody({
pad: " ",
read: "chat.messages",
commitInitial: (expr) => `chat.messages = ${expr};`,
// chat.messages is live (no React snapshot) — map over it directly
commitMap: (mapBody) => `chat.messages = chat.messages.map((m) => ${mapBody});`,
setLoading: (v) => `chat.loading = ${v};`
});
return [
...head,
` // No backend: stream a canned reply client-side (no fetch, no API key).`,
` chat.addEventListener('kai-submit', async (e) => {`,
body,
` });`,
...domReadyFooter,
` <\/script>`
].join("\n");
}
const modelLines = ctx.defaultModel ? [
` // SCAF-8: change this model id to any provider/model string you want to use.`,
` const model = '${ctx.defaultModel}';`,
``
] : [];
const bodyPayload = ctx.defaultModel ? `{ model, messages: history.map((m) => ({ role: m.role, content: m.content })) }` : `{ messages: history.map((m) => ({ role: m.role, content: m.content })) }`;
return [
...head,
` chat.addEventListener('kai-submit', async (e) => {`,
` const value = e.detail.value.trim();`,
` if (!value) return;`,
``,
...modelLines,
` // messages is a JS PROPERTY (objects can't be HTML attributes)`,
` const history = [...chat.messages, { id: crypto.randomUUID(), role: 'user', content: value }];`,
` const assistantId = crypto.randomUUID();`,
` chat.messages = [...history, { id: assistantId, role: 'assistant', content: '' }];`,
` chat.loading = true;`,
``,
` const res = await fetch('/api/chat', {`,
` method: 'POST',`,
` headers: { 'Content-Type': 'application/json' },`,
` body: JSON.stringify(${bodyPayload}),`,
` });`,
``,
` // Read the OpenAI-format SSE and stream it into the assistant message.`,
` // This loop is the Streaming recipe — copy its exact body if you need keep-alive handling.`,
` const reader = res.body.getReader();`,
` const decoder = new TextDecoder();`,
` let buffer = '', answer = '';`,
` while (true) {`,
` const { value: chunk, done } = await reader.read();`,
` if (done) break;`,
` buffer += decoder.decode(chunk, { stream: true });`,
` const lines = buffer.split('\\n');`,
` buffer = lines.pop();`,
` for (const line of lines) {`,
` const s = line.trim();`,
` if (!s.startsWith('data:')) continue;`,
` 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;`,
` chat.messages = chat.messages.map((m) => (m.id === assistantId ? { ...m, content: answer } : m));`,
` } catch { /* skip keep-alive lines */ }`,
` }`,
` }`,
` chat.loading = false;`,
` });`,
...domReadyFooter,
` <\/script>`
].join("\n");
}
function renderHtml(archetype, ctx) {
const { p, emptyHint } = ctx;
return [
`<!-- ${archetype.title} — ${p.note} -->`,
...p.altNote ? [`<!-- ${p.altNote} -->`] : [],
`<div style="${p.style}">`,
componentTags(archetype, p.chatFill),
`</div>`,
``,
htmlWiring(ctx, archetype),
``,
` <!-- empty-state hint: ${emptyHint} -->`
].join("\n");
}
function toPascalCase(tag) {
return tag.replace(/^kai-/, "").split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
}
function renderJsx(archetype, ctx, framework) {
const { p, emptyHint, suggestions, isMock, defaultModel } = ctx;
const hasEmbedded = archetype.components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
const workspace = isWorkspace(archetype);
const renderableTags = archetype.components.filter((t) => !MESSAGE_EMBEDDED_TAGS.has(t));
const importTags = workspace ? [.../* @__PURE__ */ new Set([...renderableTags.filter((t) => t !== "kai-resizable"), "kai-resizable", "kai-resizable-item"])] : renderableTags;
const wrapperNames = importTags.map(toPascalCase);
const importList = wrapperNames.join(", ");
const standaloneCompanionTags = archetype.components.filter(
(t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t)
);
const companionJsxLines = [];
if (hasEmbedded) {
companionJsxLines.push(
` {/* kai-tool / kai-reasoning render inside the thread. Tool calls + reasoning`,
` are set on each message object — see the sampleMessages initializer above. */}`
);
}
for (const t of standaloneCompanionTags) {
if (t === "kai-sources") {
companionJsxLines.push(
` {/* Replace sampleSources with your real data. */}`,
` <Sources sources={sampleSources} />`
);
} else {
companionJsxLines.push(` {/* wire data props — see the component_reference MCP tool */}`);
companionJsxLines.push(` <${toPascalCase(t)} />`);
}
}
const companions = companionJsxLines.join("\n");
const chatMessageType = `type ChatMessage = { id: string; role: 'user' | 'assistant'; content: string; reasoning?: { text: string; label?: string }; tools?: { type: string; state: 'input-streaming' | 'input-available' | 'output-available' | 'output-error'; input?: Record<string, unknown>; output?: Record<string, unknown>; toolCallId?: string }[] };`;
const sampleMessagesInit = hasEmbedded ? [
` // SCAF-9: tool calls and reasoning render inside the thread — set them on the message object.`,
` // Replace with real messages streamed from your backend.`,
` const sampleMessages: ChatMessage[] = [${JSON.stringify(SAMPLE_AGENTIC_MESSAGE)}];`,
` const [messages, setMessages] = useState<ChatMessage[]>(sampleMessages);`
].join("\n") : ` const [messages, setMessages] = useState<ChatMessage[]>([]);`;
const sampleSourcesInit = standaloneCompanionTags.includes("kai-sources") ? [
` // Replace sampleSources with your real source data.`,
` const sampleSources = [`,
` { href: 'https://example.com/doc1', title: 'Getting started', description: 'Overview of the product.' },`,
` { href: 'https://example.com/doc2', title: 'API reference', description: 'Full API documentation.' },`,
` ];`
].join("\n") : "";
const modelInit = defaultModel ? ` // SCAF-8: change this to any provider/model string you want to use.
const model = '${defaultModel}';` : "";
const bodyPayload = defaultModel ? `{ model, messages: history.map((m) => ({ role: m.role, content: m.content })) }` : `{ messages: history.map((m) => ({ role: m.role, content: m.content })) }`;
const onSubmitBody = isMock ? mockStreamBody({
pad: " ",
read: "messages",
commitInitial: (expr) => `setMessages(${expr});`,
// functional updater so each token maps over the LATEST array, not the snapshot
commitMap: (mapBody) => `setMessages((prev) => prev.map((m) => ${mapBody}));`,
setLoading: (v) => `setLoading(${v});`,
strictRoles: true
}) : [
` const value = e.detail.value.trim();`,
` if (!value) return;`,
` const history: ChatMessage[] = [...messages, { id: crypto.randomUUID(), role: 'user' as const, content: value }];`,
` const assistantId = crypto.randomUUID();`,
` setMessages([...history, { id: assistantId, role: 'assistant' as const, content: '' }]);`,
` setLoading(true);`,
` const res = await fetch('/api/chat', {`,
` method: 'POST',`,
` headers: { 'Content-Type': 'application/json' },`,
` body: JSON.stringify(${bodyPayload}),`,
` });`,
` // Stream the OpenAI-format SSE into the assistant message — see the Streaming recipe.`,
` const reader = res.body!.getReader();`,
` const decoder = new TextDecoder();`,
` let buffer = '', answer = '';`,
` while (true) {`,
` const { value: chunk, done } = await reader.read();`,
` if (done) break;`,
` buffer += decoder.decode(chunk, { stream: true });`,
` const lines = buffer.split('\\n');`,
` buffer = lines.pop()!;`,
` for (const line of lines) {`,
` const s = line.trim();`,
` if (!s.startsWith('data:')) continue;`,
` 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;`,
` setMessages((ms) => ms.map((m) => (m.id === assistantId ? { ...m, content: answer } : m)));`,
` } catch { /* skip keep-alives */ }`,
` }`,
` }`,
` setLoading(false);`
].join("\n");
const useClientDirective = framework === "next" ? [`'use client';`, ``] : [];
if (framework === "next") {
const dynamicImports = wrapperNames.map(
(name) => `const ${name} = dynamic(() => import('@kitn.ai/ui/react').then((m) => m.${name}), { ssr: false });`
);
const nextConfigNote2 = [];
return [
// 'use client' must be the very first line for Next.js App Router.
...useClientDirective,
`import { useState } from 'react';`,
`import dynamic from 'next/dynamic';`,
`import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
`// kai-* bundle Solid's client runtime → load client-only so SSR/prerender doesn't crash`,
...dynamicImports,
``,
...nextConfigNote2,
`// ${archetype.title} — ${p.note}. empty-state hint: ${emptyHint}`,
...p.altNote ? [`// ${p.altNote}`] : [],
chatMessageType,
``,
`export default function App() {`,
sampleMessagesInit,
` const [loading, setLoading] = useState(false);`,
` const suggestions = ${jsArray(suggestions)};`,
...sampleSourcesInit ? [sampleSourcesInit] : [],
...modelInit ? [modelInit] : [],
``,
` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
onSubmitBody,
` }`,
``,
` return (`,
` <div style={{ ${jsxStyle(p.style)} }}>`,
...workspace ? [
` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
` {/* Resizable needs ResizableItem children to render panels. */}`,
` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
` <ResizableItem size="40%" min="240px">`,
` <Chat`,
` messages={messages}`,
` loading={loading}`,
` suggestions={suggestions}`,
` suggestionMode="submit"`,
` onSubmit={onSubmit}`,
` style={{ ${jsxStyle(p.chatFill)} }}`,
` />`,
` </ResizableItem>`,
` <ResizableItem min="280px">`,
` {/* Replace src with your artifact URL or set files for multi-file preview. */}`,
` <Artifact src="https://example.com" style={{ width: '100%', height: '100%' }} />`,
` </ResizableItem>`,
` </Resizable>`
] : [
` <Chat`,
` messages={messages}`,
` loading={loading}`,
` suggestions={suggestions}`,
` suggestionMode="submit"`,
` onSubmit={onSubmit}`,
` style={{ ${jsxStyle(p.chatFill)} }}`,
` />`,
companions
],
` </div>`,
` );`,
`}`
].filter((l) => l !== "").join("\n");
}
const nextConfigNote = [];
return [
// SCAF-2: 'use client' must be the very first line for Next.js App Router.
...useClientDirective,
// (1) REQUIRED: registers <kai-*> — the react wrappers do NOT auto-register.
// Must come BEFORE importing the wrappers, or <kai-chat> renders empty.
`import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
`import { useState } from 'react';`,
`import { ${importList} } from '@kitn.ai/ui/react';`,
`import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
``,
...nextConfigNote,
`// ${archetype.title} — ${p.note}. empty-state hint: ${emptyHint}`,
...p.altNote ? [`// ${p.altNote}`] : [],
chatMessageType,
``,
`export default function App() {`,
sampleMessagesInit,
` const [loading, setLoading] = useState(false);`,
` const suggestions = ${jsArray(suggestions)};`,
...sampleSourcesInit ? [sampleSourcesInit] : [],
...modelInit ? [modelInit] : [],
``,
` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
onSubmitBody,
` }`,
``,
` return (`,
` <div style={{ ${jsxStyle(p.style)} }}>`,
...workspace ? [
` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
` {/* Resizable needs ResizableItem children to render panels. */}`,
` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
` <ResizableItem size="40%" min="240px">`,
` <Chat`,
` messages={messages}`,
` loading={loading}`,
` suggestions={suggestions}`,
` suggestionMode="submit"`,
` onSubmit={onSubmit}`,
` style={{ ${jsxStyle(p.chatFill)} }}`,
` />`,
` </ResizableItem>`,
` <ResizableItem min="280px">`,
` {/* Replace src with your artifact URL or set files for multi-file preview. */}`,
` <Artifact src="https://example.com" style={{ width: '100%', height: '100%' }} />`,
` </ResizableItem>`,
` </Resizable>`
] : [
` <Chat`,
` messages={messages}`,
` loading={loading}`,
` suggestions={suggestions}`,
` suggestionMode="submit"`,
` onSubmit={onSubmit}`,
` style={{ ${jsxStyle(p.chatFill)} }}`,
` />`,
companions
],
` </div>`,
` );`,
`}`
].filter((l) => l !== "").join("\n");
}
function renderVue(archetype, ctx) {
const { p, emptyHint, suggestions, isMock, defaultModel } = ctx;
const workspace = isWorkspace(archetype);
const standaloneCompanionTags = archetype.components.filter(
(t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t)
);
const hasEmbedded = archetype.components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
const companionLines = [];
if (hasEmbedded) {
companionLines.push(
` <!-- kai-tool / kai-reasoning render INSIDE the thread — set tools/reasoning on each message object. -->`
);
}
for (const t of standaloneCompanionTags) {
if (t === "kai-sources") {
companionLines.push(` <!-- Replace sampleSources with your real data (set as a JS property). -->`);
companionLines.push(` <kai-sources ref="sourcesEl" />`);
} else {
companionLines.push(` <!-- wire data props — see the component_reference MCP tool -->`);
companionLines.push(` <${t} />`);
}
}
const companions = companionLines.join("\n");
const bodyPayload = defaultModel ? `{ model, messages: history.map((m) => ({ role: m.role, content: m.content })) }` : `{ messages: history.map((m) => ({ role: m.role, content: m.content })) }`;
const onSubmitBody = isMock ? mockStreamBody({
pad: " ",
read: "messages.value",
commitInitial: (expr) => `messages.value = ${expr};`,
// messages.value is live — map over it directly
commitMap: (mapBody) => `messages.value = messages.value.map((m) => ${mapBody});`,
setLoading: (v) => `loading.value = ${v};`,
strictRoles: true
}) : [
` const value = e.detail.value.trim();`,
` if (!value) return;`,
` const history: ChatMessage[] = [...messages.value, { id: crypto.randomUUID(), role: 'user' as const, content: value }];`,
` const assistantId = crypto.randomUUID();`,
` messages.value = [...history, { id: assistantId, role: 'assistant' as const, content: '' }];`,
` loading.value = true;`,
` // POST to /api/chat, then stream the OpenAI-format SSE into the`,
` // assistant message (reassign messages.value per chunk) — see the Streaming recipe.`,
...defaultModel ? [
` // SCAF-8: change this to any provider/model string you want to use.`,
` const model = '${defaultModel}';`
] : [],
` const res = await fetch('/api/chat', {`,
` method: 'POST',`,
` headers: { 'Content-Type': 'application/json' },`,
` body: JSON.stringify(${bodyPayload}),`,
` });`,
` // Stream the OpenAI-format SSE — see the Streaming recipe.`,
` const reader = res.body.getReader();`,
` const decoder = new TextDecoder();`,
` let