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 = { 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 [ ` `, ` `, ` `, ` `, ` `, ` `, ` `, ` `, ` `, ` `, ` `, ].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(` `); if (hasEmbedded) { lines.push( ` `, ); } for (const tag of companionTags) { if (tag === 'kai-sources') { // kai-sources is genuinely standalone — emit with realistic sample data. lines.push( ` `, ` `, ); } else { lines.push(` <${tag}>`); } } if (hasStandaloneCompanions) { lines.push(` `); } return lines.join('\n'); } /** The HTML `, ].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, ` `, ].join('\n'); } function renderHtml(archetype: Archetype, ctx: RenderCtx): string { const { p, emptyHint } = ctx; return [ ``, ...(p.altNote ? [``] : []), `
`, componentTags(archetype, p.chatFill), `
`, ``, htmlWiring(ctx, archetype), ``, ` `, ].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. */}`, ` `, ); } 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; output?: Record; 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(sampleMessages);`, ].join('\n') : ` const [messages, setMessages] = useState([]);`; // 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 (`, `
`, ...(workspace ? [ ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`, ` {/* Resizable needs ResizableItem children to render panels. */}`, ` `, ` `, ` `, ` `, ` `, ` {/* Replace src with your artifact URL or set files for multi-file preview. */}`, ` `, ` `, ` `, ] : [ ` `, companions, ]), `
`, ` );`, `}`, ] .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 — the react wrappers do NOT auto-register. // Must come BEFORE importing the wrappers, or renders empty. `import '@kitn.ai/ui/elements'; // registers — 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 (`, `
`, ...(workspace ? [ ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`, ` {/* Resizable needs ResizableItem children to render panels. */}`, ` `, ` `, ` `, ` `, ` `, ` {/* Replace src with your artifact URL or set files for multi-file preview. */}`, ` `, ` `, ` `, ] : [ ` `, companions, ]), `
`, ` );`, `}`, ] .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( ` `, ); } for (const t of standaloneCompanionTags) { if (t === 'kai-sources') { companionLines.push(` `); companionLines.push(` `); } else { companionLines.push(` `); 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; output?: Record; 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([${JSON.stringify(SAMPLE_AGENTIC_MESSAGE)}]);`, ] : [`const messages = ref([]);`]; // 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 ? [ ` `, ` `, ` `, ` `, `
`, ` `, ` `, ` `, ` `, ` `, ` `, ] : [ ` `, companions, ]; return [ ``, ...(p.altNote ? [``] : []), ``, ``, ``, ``, ] .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( ` `, ); } for (const t of standaloneCompanionTags) { if (t === 'kai-sources') { companionLinesList.push(` `); companionLinesList.push(` `); } else { companionLinesList.push(` `); companionLinesList.push(` <${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; output?: Record; 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 ? [ ` `, ` `, ` `, ` `, ` `, ` `, ` `, ` `, ` `, ` `, ` `, ] : [ ` `, companionLines, ]; return [ ``, ...(p.altNote ? [``] : []), ``, ``, ``, `
`, ...workspaceMarkup, `
`, ] .filter((l, i, arr) => !(l === '' && arr[i - 1] === '' && i === arr.length - 1)) .join('\n'); } /** * TanStack Start: emits a file-based route (`src/routes/chat.tsx`) that renders * the Chat surface client-only via `ssr: false` on `createFileRoute`. * * Verified pattern: `ssr: false` prevents the Solid-based web-component runtime * from running on the server — no `window is not defined` crash, no hydration * mismatch. The library is SSR-import-safe (customElements.define is guarded), * so the import itself is safe; only the *render* needs to be client-only. * * Scaffold command (official TanStack CLI, non-interactive): * npx @tanstack/cli@latest create --framework react --no-git --package-manager npm -y * * After scaffolding, run `npm install @kitn.ai/ui`, then drop this file into * `src/routes/chat.tsx`. Start the dev server with `npm run dev` (port 3000). * Build: `npm run build`; preview: `npm run preview` (or `node dist/server/server.js`). * Note: `npm start` does NOT exist in TanStack Start projects — use `npm run dev` / `npm run preview`. * * Backend: TanStack Start supports server-side routes via `createServerFn` in * `src/server/`. For a chat API, place the route in `src/server/chat.ts` and call * it from your route component. The emitted scaffold uses `fetch('/api/chat')` as a * placeholder pointing at a standard route — swap for your TanStack server function * or an external endpoint. */ function renderTanstackStart(archetype: Archetype, ctx: RenderCtx): string { const { p, emptyHint, suggestions, isMock, defaultModel } = ctx; // TanStack Start is React — reuse all the React composition logic: // same ChatMessage type, same state/loading/suggestions, same mock stream body, // same real-backend SSE streaming. The ONLY delta from plain `react` is: // 1. `import { createFileRoute } from '@tanstack/react-router'` instead of no-op router import // 2. `export const Route = createFileRoute('/chat')({ ssr: false, component: ChatPage })` // 3. The page function is named `ChatPage` (not `App`) — no export-default clash with createFileRoute // 4. No `import '@kitn.ai/ui/elements'` needed as a top-level import (same as next's dynamic approach // is not needed here — the library is SSR-import-safe, but we include elements for safety) 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 ? [...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: 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. */}`, ` `, ); } 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; output?: Record; 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(sampleMessages);`, ].join('\n') : ` const [messages, setMessages] = useState([]);`; 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.\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 })) }`; const onSubmitBody = isMock ? mockStreamBody({ pad: ' ', read: 'messages', commitInitial: (expr) => `setMessages(${expr});`, 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'); // File path guidance for TanStack Start (file-based routing) const filePathNote = [ `// TanStack Start route file — save as: src/routes/chat.tsx`, `// Scaffold command: npx @tanstack/cli@latest create --framework react --no-git --package-manager npm -y`, `// Then: npm install @kitn.ai/ui`, `// Dev: npm run dev (port 3000) Build: npm run build Preview: npm run preview`, `// Note: there is no 'npm start' script — use 'npm run dev' or 'npm run preview'.`, `// Backend: place TanStack server functions in src/server/chat.ts (createServerFn).`, ``, ]; return [ ...filePathNote, // TanStack Start uses @tanstack/react-router's createFileRoute `import { createFileRoute } from '@tanstack/react-router'`, `import { useState } from 'react'`, // Elements registration: the library is SSR-import-safe; top-level import is safe here `import '@kitn.ai/ui/elements'; // registers — required, must come first`, `import { ${importList} } from '@kitn.ai/ui/react'`, `import '@kitn.ai/ui/theme.tokens.css' // compiled token defaults`, ``, `// ${archetype.title} — ${p.note}. empty-state hint: ${emptyHint}`, ...(p.altNote ? [`// ${p.altNote}`] : []), chatMessageType, ``, `// ssr: false keeps the Solid-based web component client-only.`, `// Server HTML for /chat omits → no hydration mismatch.`, `export const Route = createFileRoute('/chat')({`, ` ssr: false,`, ` component: ChatPage,`, `})`, ``, `function ChatPage() {`, sampleMessagesInit, ` const [loading, setLoading] = useState(false);`, ` const suggestions = ${jsArray(suggestions)};`, ...(sampleSourcesInit ? [sampleSourcesInit] : []), ...(modelInit ? [modelInit] : []), ``, ` async function onSubmit(e: CustomEvent<{ value: string }>) {`, onSubmitBody, ` }`, ``, ` return (`, `
`, ...(workspace ? [ ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`, ` {/* Resizable needs ResizableItem children to render panels. */}`, ` `, ` `, ` `, ` `, ` `, ` {/* Replace src with your artifact URL or set files for multi-file preview. */}`, ` `, ` `, ` `, ] : [ ` `, companions, ]), `
`, ` );`, `}`, ] .filter((l) => l !== '') .join('\n'); } /** Translate an inline CSS string into JSX style-object entries. */ function jsxStyle(style: string): string { return style .split(';') .map((d) => d.trim()) .filter(Boolean) .map((d) => { const [prop, ...rest] = d.split(':'); const camel = prop.trim().replace(/-([a-z])/g, (_, c) => c.toUpperCase()); return `${camel}: '${rest.join(':').trim()}'`; }) .join(', '); } function renderFrontend( framework: string, archetype: Archetype, placement: string, emptyHint: string, suggestions: string[], isMock: boolean, defaultModel?: string, ): string { const ctx: RenderCtx = { p: placementStyle(placement), emptyHint, suggestions, isMock, defaultModel }; switch (framework) { case 'react': case 'next': return renderJsx(archetype, ctx, framework); case 'vue': return renderVue(archetype, ctx); case 'svelte': return renderSvelte(archetype, ctx); case 'tanstack-start': return renderTanstackStart(archetype, ctx); case 'html': default: // html, and any backend-only framework (fastapi/express/worker) gets the // framework-agnostic web-components surface. return renderHtml(archetype, ctx); } } // ── backend route selection (with language-aware fallback) ───────────────────── interface RouteChoice { framework: string; template: string; runtime: string; exact: boolean; } const RUNTIME_LABEL: Record = { next: 'Next.js route handler (Node/Edge)', express: 'Express handler (Node)', worker: 'Cloudflare Worker', fastapi: 'FastAPI (Python)', html: 'browser-direct (no server route)', 'tanstack-start': 'TanStack Start server function (Node)', }; /** Prefer the language's canonical server framework when there's no exact match. */ function preferredKeyFor(integration: Integration): string[] { return integration.language === 'python' ? ['fastapi'] : ['next', 'express', 'worker']; } function chooseRoute(integration: Integration, framework: string): RouteChoice | undefined { const templates = integration.routeTemplates; // 1. exact match if (templates[framework]) { return { framework, template: templates[framework], runtime: RUNTIME_LABEL[framework] ?? framework, exact: true }; } // 2. language-canonical fallback (python → fastapi; ts → next/express/worker) for (const key of preferredKeyFor(integration)) { if (templates[key]) { return { framework: key, template: templates[key], runtime: RUNTIME_LABEL[key] ?? key, exact: false }; } } // 3. anything usable that isn't a pure front-end snippet for (const [key, template] of Object.entries(templates)) { if (key === 'html') continue; return { framework: key, template, runtime: RUNTIME_LABEL[key] ?? key, exact: false }; } return undefined; } // ── compose ─────────────────────────────────────────────────────────────────── function compose( archetype: Archetype, integration: Integration, placement: string, framework: string, suggestions: string[], audience?: string, ): string { const audienceHint = audience ? `tuned for ${audience} — keep the empty state and tone audience-appropriate` : 'add an empty-state prompt that fits your product'; const isMock = integration.id === 'mock'; // SCAF-8: compute the default model only for non-mock integrations that forward model. const defaultModel = isMock ? undefined : defaultModelFor(integration); const frontend = renderFrontend(framework, archetype, placement, audienceHint, suggestions, isMock, defaultModel); const route = isMock ? undefined : chooseRoute(integration, framework); const header = [ `# AI/UI scaffold — ${archetype.title} × ${integration.title}`, `combo: ${archetype.id} × ${integration.id} × ${placement} × ${framework}`, `stream: ${integration.streamMapping}`, ].join('\n'); const block1 = [ `=== (1) FRONT-END (${framework}, ${placementStyle(placement).note}) ===`, ``, frontend, ].join('\n'); const block2Parts: string[] = [`=== (2) BACKEND ROUTE ===`, ``]; if (isMock) { // The mock integration has no backend — the front-end streams locally. block2Parts.push( `# No backend or API key needed — replies stream locally for preview (see the`, `# front-end onSubmit above). Swap \`integration\` for a real provider (openrouter,`, `# ollama, vercel-ai-sdk, …) when ready, and this block becomes its route handler.`, ); } else if (route) { if (!route.exact) { block2Parts.push( `# Note: ${integration.title} has no template for "${framework}". Emitting its native`, `# ${route.runtime} route instead (matches the integration's ${integration.language} language).`, ); // Honest warning: a Next.js/server route will NOT run inside a Vite SPA. if (framework === 'react') { block2Parts.push( `#`, `# WARNING: this is a Next.js route handler — it will NOT run in a Vite SPA`, `# (a Vite \`react\` app has no /api routes). To make the front-end above work, either:`, `# • use Next.js (framework: "next"), or`, `# • add a Vite dev-server middleware/proxy to a server, or`, `# • run a separate server (framework: "express" | "worker"), or`, `# • use integration: "mock" for a zero-config local stream (no backend, no key).`, ); } block2Parts.push(``); } else { block2Parts.push(`# Runtime: ${route.runtime}`, ``); } block2Parts.push(route.template); } else { block2Parts.push( `# ${integration.title} ships no usable backend route template. See its docs`, `# (${integration.docsSlug}) — wire the route by hand following the streamMapping above.`, ); } const block2 = block2Parts.join('\n'); const envLines = integration.envVars.length ? integration.envVars.map((v) => ` - ${v}`).join('\n') : ' (none)'; const block3 = [ `=== (3) RUN NOTE ===`, ``, integration.runNote, ``, `Env vars to set:`, envLines, ].join('\n'); // SCAF-16: loading-options note — inform consumers about the two opt-in load modes // (per-element tree-shaking + autoloader) without changing the default import above. // Leads with "the default is right" rather than a size headline; the debug tool // carries the full KB breakdown for developers who ask for it. const block4 = [ `=== LOADING OPTIONS ===`, ``, `The scaffold uses \`import '@kitn.ai/ui/elements'\` (register-all) — the right`, `default: it registers every kai-* element and is SSR-safe, so leave it as is.`, `Two opt-in modes load less if a page only ever uses a few elements:`, ``, ` Per-element (bundler apps): import '@kitn.ai/ui/elements/'`, ` Registers just that element; your bundler tree-shakes the rest away.`, ` Example: import '@kitn.ai/ui/elements/chat' (client-only — not for SSR)`, ``, ` Autoloader (no-build / CDN pages): a