UNPKG

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