@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,094 lines (1,017 loc) • 81.6 kB
text/typescript
import { z } from 'zod';
import type { Tool } from './types';
import { Placement, Framework } from '../../types';
import type { Integration, Archetype } from '../../types';
import {
getArchetype,
getIntegration,
listArchetypes,
listIntegrations,
} from '../../registry';
/**
* scaffold — the keystone tool. Composes a working chat surface from four axes:
*
* useCase (archetype) × integration × placement × framework
*
* and emits three labeled blocks an AI consumer can paste straight in:
* (1) Front-end — the archetype's kai-* components, rendered for the chosen
* framework and sized for the placement, wired with the
* `messages` property + `kai-submit` per the Streaming recipe.
* (2) Backend — the integration's route template for the framework (with a
* language-aware fallback when there's no exact match).
* (3) Run note — how to run it + the env vars to set.
*
* The handler is called directly in tests (bypassing MCP's zod validation), so it
* validates `useCase` + `integration` against the registry itself and returns
* graceful, self-correcting error text when either is unknown.
*/
const text = (s: string) => ({
content: [{ type: 'text' as const, text: s }],
});
// ── placement sizing ──────────────────────────────────────────────────────────
interface PlacementStyle {
/** container style for an HTML/web-components wrapper */
style: string;
/** the chat element's own style — it must FILL the container */
chatFill: string;
/** one-line human description of the layout */
note: string;
/** optional extra comment lines describing an alternative layout form */
altNote?: string;
}
// The chat element must fill its container. In a `display: flex; flex-direction:
// column` shell it's a flex child (`flex: 1; min-height: 0`); in a plain block
// container it fills via `height: 100%`.
const FLEX_FILL = 'flex: 1; min-height: 0;';
const BLOCK_FILL = 'height: 100%; width: 100%;';
function placementStyle(placement: string): PlacementStyle {
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':
// A full-height side panel docked to the trailing (right) edge — overlays
// content. messages/loading/suggestions are real kai-chat props.
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':
// The bottom-right floating bubble — rounded, elevated, fixed size.
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:
// Unknown placement falls back to full-page (full height) rather than the bubble,
// so a future Placement enum member doesn't silently render as a widget.
return {
style: 'height: 100dvh; width: 100%; display: flex; flex-direction: column;',
chatFill: FLEX_FILL,
note: 'fills the viewport (100dvh)',
};
}
}
// ── suggestions + mock streaming ───────────────────────────────────────────────
/** Default starter prompts so the suggestions feature always shows. */
const DEFAULT_SUGGESTIONS = ["What's new?", 'How can you help?'];
/** A canned assistant reply the mock integration streams back token-by-token. */
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.";
/** Render a string[] as a JS array literal (JSON-quoted — keeps apostrophes readable). */
function jsArray(items: string[]): string {
return '[' + items.map((s) => JSON.stringify(s)).join(', ') + ']';
}
/**
* The shared client-side mock stream body, parameterised by how each framework
* commits a messages update. Two operations keep the contract correct:
* - `commitInitial(expr)` appends the user + empty-assistant pair.
* - `commitMap(mapBody)` replaces messages with `prev.map((m) => mapBody)` —
* each framework supplies how `prev` resolves (the React functional updater,
* or the live local variable for html/vue/svelte) so the streamed content is
* applied to the LATEST array, never a stale snapshot.
* Each commit produces a NEW array (and a new object for the streamed message)
* so kai-chat re-renders per chunk.
*
* Indented with `pad` so it drops cleanly into each framework's onSubmit.
*/
function mockStreamBody(opts: {
pad: string;
/** read the current messages array (for building `history`) */
read: string;
/** commit the initial user + empty-assistant pair */
commitInitial: (expr: string) => string;
/** commit a `prev.map(...)` update; `mapBody` is the body of `.map((m) => …)` */
commitMap: (mapBody: string) => string;
/** set loading true/false */
setLoading: (v: 'true' | 'false') => string;
/**
* Emit `as const` on role literals so they narrow to 'user'|'assistant' under
* strict TS. Set to true for TypeScript frameworks (react/next); false for
* plain-JS contexts (html) where `as const` is invalid syntax.
*/
strictRoles?: boolean;
}): string {
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');
}
// ── SCAF-8: per-integration default model ids ─────────────────────────────────
/**
* Return a sensible default model id for integrations whose route forwards a
* `model` field to the upstream provider. The dev can change this const.
* Returns undefined for integrations whose route does not use a model param.
*/
function defaultModelFor(integration: Integration): string | undefined {
// Detect: any route template destructures `model` from the request body.
const routeSrc = Object.values(integration.routeTemplates).join('\n');
if (!routeSrc.includes('model')) return undefined;
const defaults: Record<string, string> = {
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';
}
// ── SCAF-9: message-embedded companion logic ──────────────────────────────────
/**
* Tags that live INSIDE a kai-chat message object (not standalone siblings).
* Rendering them as bare elements is a TS error (required props missing) and
* non-idiomatic — the chat thread carries them on each message.
*/
const MESSAGE_EMBEDDED_TAGS = new Set(['kai-tool', 'kai-reasoning']);
// ── SCAF-14: workspace structural/layout logic ────────────────────────────────
/**
* Tags that participate in the workspace layout structure — kai-resizable is the
* container (needs kai-resizable-item children), kai-artifact is the preview pane.
* Neither should be emitted as a bare sibling of kai-chat — the idiomatic structure
* is a resizable split with chat in one pane and artifact in another.
*/
const WORKSPACE_STRUCTURAL_TAGS = new Set(['kai-resizable', 'kai-artifact']);
/** True when the archetype is the resizable split workspace (chat + artifact). */
function isWorkspace(archetype: Archetype): boolean {
return archetype.components.includes('kai-resizable') && archetype.components.includes('kai-artifact');
}
/**
* A sample assistant message that demonstrates embedded tool + reasoning so the
* agentic archetype renders correctly out of the box.
*/
const SAMPLE_AGENTIC_MESSAGE = {
id: 'sample-assistant',
role: 'assistant' as const,
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' as const,
input: { query: 'current pricing' },
output: { results: ['Result A', 'Result B'] },
toolCallId: 'tc_001',
},
],
};
// ── front-end rendering ───────────────────────────────────────────────────────
interface RenderCtx {
p: PlacementStyle;
emptyHint: string;
suggestions: string[];
/** mock = stream the reply client-side (no fetch, no backend, no key) */
isMock: boolean;
/** SCAF-8: non-undefined when the integration forwards a model param */
defaultModel?: string;
}
/** The kai-* tags for the archetype, in order, as opening/closing markup.
*
* SCAF-9: message-embedded companion types (kai-tool, kai-reasoning) are NOT
* emitted as standalone siblings — they live inside a kai-chat message object.
* Only standalone companions (kai-sources, etc.) are rendered here with sample data.
*
* SCAF-14: workspace structural types (kai-resizable, kai-artifact) are emitted
* as a properly composed split layout — chat in one pane, artifact in the other.
*/
function componentTags(archetype: Archetype, chatFill: string): string {
// SCAF-14: workspace is a structural/layout archetype — emit a runnable split.
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: string[] = [];
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') {
// kai-sources is genuinely standalone — emit with realistic sample data.
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');
}
/** The HTML <script> wiring — mock streams client-side; everything else fetches /api/chat. */
function htmlWiring(ctx: RenderCtx, archetype: Archetype): string {
const hasEmbedded = archetype.components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
const hasSources = archetype.components.includes('kai-sources');
// SCAF-9: seed the sample agentic message so tool+reasoning render immediately.
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}`)),
];
// DOMContentLoaded footer — closes init() and wires it safely.
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');
}
// SCAF-8: include model in the POST body when the integration forwards it.
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: Archetype, ctx: RenderCtx): string {
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');
}
/** Convert a kebab-case custom-element tag to its PascalCase React wrapper name. */
function toPascalCase(tag: string): string {
// e.g. "kai-chat" → "Chat", "kai-sources" → "Sources"
return tag
.replace(/^kai-/, '')
.split('-')
.map((s) => s.charAt(0).toUpperCase() + s.slice(1))
.join('');
}
/** JSX usage for react/next: uses the official @kitn.ai/ui/react wrappers. */
function renderJsx(archetype: Archetype, ctx: RenderCtx, framework: string): string {
const { p, emptyHint, suggestions, isMock, defaultModel } = ctx;
const hasEmbedded = archetype.components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
const workspace = isWorkspace(archetype);
// SCAF-9: exclude message-embedded tags from import list.
// SCAF-14: workspace uses Resizable+ResizableItem+Artifact — keep them in the import list.
const renderableTags = archetype.components.filter((t) => !MESSAGE_EMBEDDED_TAGS.has(t));
// For workspace: replace 'kai-resizable' with 'kai-resizable-item' so we get ResizableItem too.
const importTags = workspace
? [...new Set([...renderableTags.filter((t) => t !== 'kai-resizable'), 'kai-resizable', 'kai-resizable-item'])]
: renderableTags;
const wrapperNames = importTags.map(toPascalCase);
const importList = wrapperNames.join(', ');
// SCAF-9: standalone companion tags (not kai-chat, not message-embedded, not workspace-structural).
const standaloneCompanionTags = archetype.components.filter(
(t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
);
// Build companion JSX: only standalone companions with real props.
// SCAF-14: workspace gets its own structural JSX block (not companion lines).
const companionJsxLines: string[] = [];
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');
// SCAF-4: Inline ChatMessage type for strict-TS consumers; avoids implicit-any on useState/handler.
// SCAF-11: state must be the library's 4-value union (not bare string); reasoning carries optional label.
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 }[] };`;
// SCAF-9: seed sample messages for agentic archetype so tool+reasoning render immediately.
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[]>([]);`;
// SCAF-9: sample sources data for knowledge-base archetype.
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')
: '';
// SCAF-8: model const for integrations that forward model to the upstream provider.
const modelInit = defaultModel
? ` // SCAF-8: change this to any provider/model string you want to use.\n 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 })) }`;
// onSubmit body: mock streams a canned reply client-side; otherwise fetch /api/chat.
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');
// SCAF-2: Next.js App Router requires 'use client' for components that use hooks/interactivity.
const useClientDirective = framework === 'next' ? [`'use client';`, ``] : [];
// SCAF-6: For Next.js ONLY — use next/dynamic with { ssr: false } so the elements
// bundle (which calls delegateEvents(events, doc = window.document) at module-eval)
// never runs on the server during prerender → avoids "window is not defined" crash.
// Plain `react` (Vite) has no SSR and keeps the top-level imports unchanged.
if (framework === 'next') {
// Build dynamic() calls for every renderable wrapper in the archetype.
const dynamicImports = wrapperNames.map(
(name) =>
`const ${name} = dynamic(() => import('@kitn.ai/ui/react').then((m) => m.${name}), { ssr: false });`,
);
// SCAF-2: Next.js config note — @kitn.ai/ui ships compiled entry points; no transpilePackages needed.
const nextConfigNote: string[] = [];
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,
``,
...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');
}
// SCAF-2: Next.js transpilePackages note (needed until @kitn.ai/ui ships prebuilt JS on "." + "./react").
const nextConfigNote: string[] = [];
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');
}
/** Vue: bind messages/suggestions as properties, listen for kai-submit with @. */
function renderVue(archetype: Archetype, ctx: RenderCtx): string {
const { p, emptyHint, suggestions, isMock, defaultModel } = ctx;
// SCAF-9: exclude message-embedded tags from companion rendering.
// SCAF-14: also exclude workspace structural tags (handled by the workspace block below).
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: string[] = [];
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');
// SCAF-8: include model when the integration forwards it.
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 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;`,
` messages.value = messages.value.map((m) => (m.id === assistantId ? { ...m, content: answer } : m));`,
` } catch { /* skip keep-alives */ }`,
` }`,
` }`,
` loading.value = false;`,
].join('\n');
// SCAF-10: ChatMessage type for strict-TS Vue consumers — matches the React SCAF-4 type.
// SCAF-11: state must be the library's 4-value union (not bare string); reasoning carries optional label.
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 }[] };`;
// SCAF-9: sample seeding for agentic archetype.
const sampleSeed = hasEmbedded
? [
`// SCAF-9: tool calls + reasoning render inside the thread — set them on the message object.`,
`// Replace with real messages streamed from your backend.`,
`const messages = ref<ChatMessage[]>([${JSON.stringify(SAMPLE_AGENTIC_MESSAGE)}]);`,
]
: [`const messages = ref<ChatMessage[]>([]);`];
// SCAF-9: sample sources setup.
const sourcesSeed = 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.' },`,
`];`,
`onMounted(() => {`,
` const el = document.querySelector('kai-sources');`,
` if (el) el.sources = sampleSources;`,
`});`,
]
: [];
// SCAF-15: always import onMounted — we re-apply props after the element upgrades
// (the .prop bindings can apply before the async element registration resolves).
const vueImports = `import { ref, onMounted } from 'vue';`;
// SCAF-14: workspace template block — resizable split with chat + artifact panes.
const workspaceTemplate = workspace
? [
` <!-- 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`,
` :messages.prop="messages"`,
` :loading.prop="loading"`,
` :suggestions.prop="suggestions"`,
` suggestion-mode="submit"`,
` style="${p.chatFill}"`,
` @kai-submit="onSubmit"`,
` ></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 src="https://example.com" style="width:100%;height:100%"></kai-artifact>`,
` </kai-resizable-item>`,
` </kai-resizable>`,
]
: [
` <kai-chat`,
` :messages.prop="messages"`,
` :loading.prop="loading"`,
` :suggestions.prop="suggestions"`,
` suggestion-mode="submit"`,
` style="${p.chatFill}"`,
` @kai-submit="onSubmit"`,
` ></kai-chat>`,
companions,
];
return [
`<!-- vue — ${archetype.title} — ${p.note}. empty-state hint: ${emptyHint} -->`,
...(p.altNote ? [`<!-- ${p.altNote} -->`] : []),
`<!-- SCAF-3: vite.config.ts — tell Vue that kai-* are custom elements (not Vue components).`,
` Without this, Vue warns "Unknown custom element" and .prop bindings may misbehave.`,
` import vue from '@vitejs/plugin-vue';`,
` export default { plugins: [vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith('kai-') } } })] }; -->`,
`<script setup lang="ts">`,
`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`,
vueImports,
``,
chatMessageType,
``,
...sampleSeed,
`const loading = ref(false);`,
`const suggestions = ${jsArray(suggestions)};`,
...sourcesSeed,
``,
`// SCAF-15: kai-* register via an async dynamic import (SSR-safety). The .prop`,
`// bindings can apply before the element upgrades, which drops them — re-apply once`,
`// the element is defined so the initial messages/suggestions/loading stick.`,
`onMounted(async () => {`,
` await customElements.whenDefined('kai-chat');`,
` const el = document.querySelector('kai-chat');`,
` if (el) Object.assign(el, { messages: messages.value, loading: loading.value, suggestions });`,
`});`,
``,
`async function onSubmit(e: CustomEvent<{ value: string }>) {`,
onSubmitBody,
`}`,
`</script>`,
``,
`<template>`,
` <div style="${p.style}">`,
...workspaceTemplate,
` </div>`,
`</template>`,
]
.filter((l) => l !== '')
.join('\n');
}
/** Svelte: use bind:this to set array/object properties reactively; on:kai-submit for the event. */
function renderSvelte(archetype: Archetype, ctx: RenderCtx): string {
const { p, emptyHint, suggestions, isMock, defaultModel } = ctx;
// SCAF-9: exclude message-embedded tags from companion rendering.
// SCAF-14: also exclude workspace structural tags (handled by the workspace block below).
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 companionLinesList: string[] = [];
if (hasEmbedded) {
companionLinesList.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') {
companionLinesList.push(` <!-- Replace sampleSources with your real data. -->`);
companionLinesList.push(` <kai-sources bind:this={sourcesEl}></kai-sources>`);
} else {
companionLinesList.push(` <!-- wire data props — see the component_reference MCP tool -->`);
companionLinesList.push(` <${t}></${t}>`);
}
}
const companionLines = companionLinesList.join('\n');
// SCAF-8: include model when the integration forwards it.
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) => `messages = ${expr};`,
// `messages` is a live local — reassign to map over the latest array
commitMap: (mapBody) => `messages = messages.map((m) => ${mapBody});`,
setLoading: (v) => `loading = ${v};`,
strictRoles: true,
})
: [
` const value = e.detail.value.trim();`,
` if (!value) return;`,
` const history = [...messages, { id: crypto.randomUUID(), role: 'user' as const, content: value }];`,
` const assistantId = crypto.randomUUID();`,
` messages = [...history, { id: assistantId, role: 'assistant' as const, content: '' }];`,
` loading = true;`,
...(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 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;`,
` messages = messages.map((m) => (m.id === assistantId ? { ...m, content: answer } : m));`,
` } catch { /* skip keep-alives */ }`,
` }`,
` }`,
` loading = false;`,
].join('\n');
// SCAF-10: ChatMessage type for strict-TS Svelte consumers — matches the React SCAF-4 type.
// SCAF-11: state must be the library's 4-value union (not bare string); reasoning carries optional label.
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 }[] };`;
// SCAF-9: seed sample messages for agentic archetype.
const sampleMessagesInit = hasEmbedded
? [
` // SCAF-9: tool calls + reasoning render INSIDE the thread — set them on the message object.`,
` // Replace with real messages streamed from your backend.`,
` let messages: ChatMessage[] = [${JSON.stringify(SAMPLE_AGENTIC_MESSAGE)}];`,
]
: [` let messages: ChatMessage[] = [];`];
// SCAF-9: sources element ref + sample data.
const sourcesEl = standaloneCompanionTags.includes('kai-sources')
? [` let sourcesEl: HTMLElement | undefined;`]
: [];
const sourcesReactive = 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.' },`,
` ];`,
` $: if (sourcesEl) { sourcesEl.sources = sampleSources; }`,
]
: [];
// SCAF-14: workspace template block — resizable split with chat + artifact panes.
const workspaceMarkup = workspace
? [
` <!-- 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 bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}" on:kai-submit={onSubmit}></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 src="https://example.com" style="width:100%;height:100%"></kai-artifact>`,
` </kai-resizable-item>`,
` </kai-resizable>`,
]
: [
` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}" on:kai-submit={onSubmit}></kai-chat>`,
companionLines,
];
return [
`<!-- svelte — ${archetype.title} — ${p.note}. empty-state hint: ${emptyHint} -->`,
...(p.altNote ? [`<!-- ${p.altNote} -->`] : []),