@kitn.ai/chat
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,357 lines (1,209 loc) • 49.7 kB
text/typescript
import type { ExampleUsage, StoryUsage } from './types';
/** Basic Input — a text box with a send button that enables once you type. */
const basic: StoryUsage = {
intro:
'A complete prompt box from one element. `<kc-prompt-input>` renders the textarea and send button; bind `value`, handle `valuechange` on every keystroke, and `submit` (Enter or the send button) gives you `{ value, attachments }`. (The live demo composes the SolidJS `PromptInput` primitives.)',
snippets: {
html: `<!-- Register the elements once (CDN or bundler) -->
<script type="module">
import 'https://cdn.jsdelivr.net/npm/@kitn.ai/chat/dist/kitn-chat.es.js';
</script>
<kc-prompt-input id="prompt" placeholder="Ask anything..."></kc-prompt-input>
<script type="module">
const prompt = document.getElementById('prompt');
let value = '';
prompt.addEventListener('kc-value-change', (e) => {
value = e.detail.value; // track on every keystroke
});
prompt.addEventListener('kc-submit', (e) => {
const { value, attachments } = e.detail;
console.log(value, attachments);
});
</script>`,
react: `import { useState } from 'react';
import { PromptInput } from '@kitn.ai/chat/react';
export function Prompt() {
const [value, setValue] = useState('');
return (
<PromptInput
placeholder="Ask anything..."
onValueChange={(e) => setValue(e.detail.value)}
onSubmit={(e) => {
const { value, attachments } = e.detail;
console.log(value, attachments);
}}
/>
);
}`,
vue: `<script setup>
import '@kitn.ai/chat/elements'; // register once (e.g. in main.ts)
import { ref } from 'vue';
const value = ref('');
function onValueChange(e) {
value.value = e.detail.value; // track on every keystroke
}
function onSubmit(e) {
const { value, attachments } = e.detail;
console.log(value, attachments);
}
</script>
<template>
<kc-prompt-input
placeholder="Ask anything..."
@kc-value-change="onValueChange"
@kc-submit="onSubmit"
/>
</template>`,
svelte: `<script>
import '@kitn.ai/chat/elements'; // register once
let value = '';
function onValueChange(e) {
value = e.detail.value; // track on every keystroke
}
function onSubmit(e) {
const { value, attachments } = e.detail;
console.log(value, attachments);
}
</script>
<kc-prompt-input
placeholder="Ask anything..."
on:kc-value-change={onValueChange}
on:kc-submit={onSubmit}
/>`,
angular: `// main.ts: import '@kitn.ai/chat/elements' before bootstrapApplication,
// and add CUSTOM_ELEMENTS_SCHEMA to the component/module.
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
@Component({
selector: 'app-prompt',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: \`
<kc-prompt-input
placeholder="Ask anything..."
(kc-value-change)="onValueChange($event)"
(kc-submit)="onSubmit($event)"
></kc-prompt-input>
\`,
})
export class PromptComponent {
value = '';
onValueChange(e: CustomEvent<{ value: string }>) {
this.value = e.detail.value; // track on every keystroke
}
onSubmit(e: CustomEvent<{ value: string; attachments: unknown[] }>) {
const { value, attachments } = e.detail;
console.log(value, attachments);
}
}`,
solid: `import { createSignal } from 'solid-js';
import { PromptInput, PromptInputTextarea, PromptInputActions, Button } from '@kitn.ai/chat';
import { ArrowUp } from 'lucide-solid';
export function Prompt() {
const [value, setValue] = createSignal('');
return (
<PromptInput value={value()} onValueChange={setValue} onSubmit={() => setValue('')}>
<PromptInputTextarea placeholder="Ask anything..." />
<PromptInputActions class="justify-end">
<Button variant="default" size="icon-sm" class="rounded-full" disabled={!value()} aria-label="Send message">
<ArrowUp class="size-4" />
</Button>
</PromptInputActions>
</PromptInput>
);
}`,
},
};
/** With Suggestion Chips — starter prompts above the input. */
const suggestions: StoryUsage = {
intro:
'Show starter prompts above the input. Pass a `suggestions` array (as a PROPERTY) and pick `suggestionMode` — `"submit"` (default) sends the prompt immediately, `"fill"` just drops it into the box and fires `kc-suggestion-click`. (The demo groups its chips with the SolidJS `PromptSuggestion` primitive, which the element renders as one flat row.)',
snippets: {
html: `<script type="module">
import 'https://cdn.jsdelivr.net/npm/@kitn.ai/chat/dist/kitn-chat.es.js';
</script>
<kc-prompt-input
id="prompt"
placeholder="Ask about this document..."
suggestion-mode="fill"
></kc-prompt-input>
<script type="module">
const prompt = document.getElementById('prompt');
// Arrays must be set as a PROPERTY (attributes only take strings).
prompt.suggestions = [
'Summarize this document',
'What are the key takeaways?',
'Create an outline',
];
prompt.addEventListener('kc-suggestion-click', (e) => {
console.log(e.detail.value); // fires when suggestion-mode="fill"
});
prompt.addEventListener('kc-submit', (e) => console.log(e.detail.value));
</script>`,
react: `import { PromptInput } from '@kitn.ai/chat/react';
const SUGGESTIONS = [
'Summarize this document',
'What are the key takeaways?',
'Create an outline',
];
export function Prompt() {
return (
<PromptInput
placeholder="Ask about this document..."
// Arrays are passed straight through as a property.
suggestions={SUGGESTIONS}
suggestionMode="fill"
onSuggestionClick={(e) => console.log(e.detail.value)}
onSubmit={(e) => console.log(e.detail.value)}
/>
);
}`,
vue: `<script setup>
import '@kitn.ai/chat/elements';
const suggestions = [
'Summarize this document',
'What are the key takeaways?',
'Create an outline',
];
function onSuggestionClick(e) { console.log(e.detail.value); }
function onSubmit(e) { console.log(e.detail.value); }
</script>
<template>
<!-- .prop binds the array as a property -->
<kc-prompt-input
placeholder="Ask about this document..."
:suggestions.prop="suggestions"
suggestion-mode="fill"
@kc-suggestion-click="onSuggestionClick"
@kc-submit="onSubmit"
/>
</template>`,
svelte: `<script>
import '@kitn.ai/chat/elements';
let el;
const suggestions = [
'Summarize this document',
'What are the key takeaways?',
'Create an outline',
];
// Arrays must be set as a property (attributes only take strings).
$: if (el) el.suggestions = suggestions;
function onSuggestionClick(e) { console.log(e.detail.value); }
function onSubmit(e) { console.log(e.detail.value); }
</script>
<kc-prompt-input
bind:this={el}
placeholder="Ask about this document..."
suggestion-mode="fill"
on:kc-suggestion-click={onSuggestionClick}
on:kc-submit={onSubmit}
/>`,
angular: `import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
@Component({
selector: 'app-prompt',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: \`
<kc-prompt-input
placeholder="Ask about this document..."
[suggestions]="suggestions"
suggestion-mode="fill"
(kc-suggestion-click)="onSuggestionClick($event)"
(kc-submit)="onSubmit($event)"
></kc-prompt-input>
\`,
})
export class PromptComponent {
// [suggestions] binds the array as a property.
suggestions = [
'Summarize this document',
'What are the key takeaways?',
'Create an outline',
];
onSuggestionClick(e: CustomEvent<{ value: string }>) { console.log(e.detail.value); }
onSubmit(e: CustomEvent<{ value: string }>) { console.log(e.detail.value); }
}`,
solid: `import { createSignal, For } from 'solid-js';
import { PromptInput, PromptInputTextarea, PromptInputActions, PromptSuggestion, Button } from '@kitn.ai/chat';
import { ArrowUp } from 'lucide-solid';
const GROUPS = [
{ label: 'Get started', items: ['Summarize this document', 'What are the key takeaways?', 'Create an outline'] },
{ label: 'Go deeper', items: ['Compare with similar approaches', 'What are the tradeoffs?', 'Find contradictions'] },
];
export function Prompt() {
const [value, setValue] = createSignal('');
return (
<div class="space-y-4">
<For each={GROUPS}>
{(group) => (
<div class="space-y-2">
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wider">{group.label}</span>
<div class="flex flex-wrap gap-2">
<For each={group.items}>
{(item) => <PromptSuggestion onClick={() => setValue(item)}>{item}</PromptSuggestion>}
</For>
</div>
</div>
)}
</For>
<PromptInput value={value()} onValueChange={setValue} onSubmit={() => setValue('')}>
<PromptInputTextarea placeholder="Ask about this document..." />
<PromptInputActions class="justify-end">
<Button variant="default" size="icon-sm" class="rounded-full" disabled={!value()} aria-label="Send message">
<ArrowUp class="size-4" />
</Button>
</PromptInputActions>
</PromptInput>
</div>
);
}`,
},
};
/** With Action Buttons — toolbar buttons beside the input. */
const actionButtons: StoryUsage = {
intro:
'Add toolbar buttons beside the input. `<kc-prompt-input>` has built-in Search and Voice buttons — enable `search` and `voice`, then handle the `search` / `voice` events; attaching files is built in (the paperclip, emitted on `submit` as `attachments`). For extra custom buttons, place `<kc-action id icon tooltip>` children inside `<kc-prompt-input>` — the element reads them as invisible data carriers and renders a ghost icon button per entry in the left toolbar; clicking fires a `kc-toolbar-action` CustomEvent with `detail.action` = the action id. This is the same `<kc-action>` descriptor element that `<kc-message>` uses (composition symmetry). The Solid tab shows a custom Sparkles button composed directly with the `PromptInput` primitives (the full-control equivalent).',
snippets: {
html: `<script type="module">
import 'https://cdn.jsdelivr.net/npm/@kitn.ai/chat/dist/kitn-chat.es.js';
</script>
<!-- Built-in buttons: search (Globe) and voice (Mic). -->
<!-- Custom toolbar buttons: compose <kc-action> children. -->
<kc-prompt-input id="prompt" placeholder="Message..." search voice>
<kc-action id="attach" icon="paperclip" tooltip="Attach"></kc-action>
<kc-action id="sparkles" icon="star" tooltip="AI suggestions"></kc-action>
</kc-prompt-input>
<script type="module">
const prompt = document.getElementById('prompt');
prompt.addEventListener('kc-search', () => console.log('search clicked'));
prompt.addEventListener('kc-voice', () => console.log('voice clicked'));
// kc-toolbar-action fires when any <kc-action> toolbar button is clicked.
prompt.addEventListener('kc-toolbar-action', (e) => console.log('toolbar action:', e.detail.action));
prompt.addEventListener('kc-submit', (e) => {
const { value, attachments } = e.detail; // attachments from the paperclip
console.log(value, attachments);
});
</script>`,
react: `import { PromptInput } from '@kitn.ai/chat/react';
export function Prompt() {
return (
<PromptInput
placeholder="Message..."
search
voice
onSearch={() => console.log('search clicked')}
onVoice={() => console.log('voice clicked')}
onSubmit={(e) => {
const { value, attachments } = e.detail; // attachments from the paperclip
console.log(value, attachments);
}}
/>
);
}`,
vue: `<script setup>
import '@kitn.ai/chat/elements';
function onSearch() { console.log('search clicked'); }
function onVoice() { console.log('voice clicked'); }
function onSubmit(e) {
const { value, attachments } = e.detail; // attachments from the paperclip
console.log(value, attachments);
}
</script>
<template>
<kc-prompt-input
placeholder="Message..."
search
voice
@kc-search="onSearch"
@kc-voice="onVoice"
@kc-submit="onSubmit"
/>
</template>`,
svelte: `<script>
import '@kitn.ai/chat/elements';
function onSearch() { console.log('search clicked'); }
function onVoice() { console.log('voice clicked'); }
function onSubmit(e) {
const { value, attachments } = e.detail; // attachments from the paperclip
console.log(value, attachments);
}
</script>
<kc-prompt-input
placeholder="Message..."
search
voice
on:kc-search={onSearch}
on:kc-voice={onVoice}
on:kc-submit={onSubmit}
/>`,
angular: `import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
@Component({
selector: 'app-prompt',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: \`
<kc-prompt-input
placeholder="Message..."
[search]="true"
[voice]="true"
(kc-search)="onSearch()"
(kc-voice)="onVoice()"
(kc-submit)="onSubmit($event)"
></kc-prompt-input>
\`,
})
export class PromptComponent {
onSearch() { console.log('search clicked'); }
onVoice() { console.log('voice clicked'); }
onSubmit(e: CustomEvent<{ value: string; attachments: unknown[] }>) {
const { value, attachments } = e.detail; // attachments from the paperclip
console.log(value, attachments);
}
}`,
solid: `import { createSignal } from 'solid-js';
import { PromptInput, PromptInputTextarea, PromptInputActions, Button } from '@kitn.ai/chat';
import { ArrowUp, Paperclip, Globe, Mic, Sparkles } from 'lucide-solid';
export function Prompt() {
const [value, setValue] = createSignal('');
return (
<PromptInput value={value()} onValueChange={setValue} onSubmit={() => setValue('')}>
<PromptInputTextarea placeholder="Message..." />
<PromptInputActions class="justify-between">
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon-sm" aria-label="Attach file"><Paperclip class="size-4 text-muted-foreground" /></Button>
<Button variant="ghost" size="icon-sm" aria-label="Search the web"><Globe class="size-4 text-muted-foreground" /></Button>
<Button variant="ghost" size="icon-sm" aria-label="Voice input"><Mic class="size-4 text-muted-foreground" /></Button>
{/* Sparkles: a custom button composed directly. For the element, use
<kc-action id="sparkles" icon="star" tooltip="AI suggestions"> instead. */}
<Button variant="ghost" size="icon-sm" aria-label="AI suggestions"><Sparkles class="size-4 text-muted-foreground" /></Button>
</div>
<Button variant="default" size="icon-sm" class="rounded-full" disabled={!value()} aria-label="Send message">
<ArrowUp class="size-4" />
</Button>
</PromptInputActions>
</PromptInput>
);
}`,
},
};
/** Streaming / Loading State — disabled while a reply streams in. */
const streaming: StoryUsage = {
intro:
'Block input while a reply streams. Set `loading` to show the streaming state and stop accepting submits, and `disabled` to make the box fully non-interactive. Add `stoppable` to get a built-in Stop button that fires `kc-stop` — listen for that event and call `controller.abort()` on your fetch/SSE. (The demo composes the SolidJS `PromptInput` + `Loader` primitives to show the typing/dots indicators and a stop button.)',
snippets: {
html: `<script type="module">
import 'https://cdn.jsdelivr.net/npm/@kitn.ai/chat/dist/kitn-chat.es.js';
</script>
<kc-prompt-input id="prompt" placeholder="Generating response..." loading disabled></kc-prompt-input>
<script type="module">
const prompt = document.getElementById('prompt');
// When the reply finishes, clear the flags to re-enable.
function onDone() {
prompt.loading = false;
prompt.disabled = false;
}
</script>`,
react: `import { PromptInput } from '@kitn.ai/chat/react';
export function Prompt({ isStreaming }: { isStreaming: boolean }) {
return (
<PromptInput
placeholder={isStreaming ? 'Generating response...' : 'Ask anything...'}
loading={isStreaming}
disabled={isStreaming}
/>
);
}`,
vue: `<script setup>
import '@kitn.ai/chat/elements';
import { ref } from 'vue';
const isStreaming = ref(true);
</script>
<template>
<kc-prompt-input
placeholder="Generating response..."
:loading="isStreaming"
:disabled="isStreaming"
/>
</template>`,
svelte: `<script>
import '@kitn.ai/chat/elements';
let isStreaming = true;
</script>
<kc-prompt-input
placeholder="Generating response..."
loading={isStreaming}
disabled={isStreaming}
/>`,
angular: `import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
@Component({
selector: 'app-prompt',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: \`
<kc-prompt-input
placeholder="Generating response..."
[loading]="isStreaming"
[disabled]="isStreaming"
></kc-prompt-input>
\`,
})
export class PromptComponent {
isStreaming = true;
}`,
solid: `import { PromptInput, PromptInputTextarea, PromptInputActions, Loader, Button } from '@kitn.ai/chat';
import { Square } from 'lucide-solid';
export function Prompt() {
return (
<PromptInput disabled isLoading>
<PromptInputTextarea placeholder="Generating response..." />
<PromptInputActions class="justify-between">
<div class="flex items-center gap-2">
<Loader variant="typing" size="sm" />
<span class="text-xs text-foreground">Generating...</span>
</div>
<Button variant="outline" size="icon-sm" class="rounded-full" aria-label="Stop">
<Square class="size-3" />
</Button>
</PromptInputActions>
</PromptInput>
);
}`,
},
};
/** With Model Selector — a model picker alongside the input.
*
* INSIGHT: `ModelSwitcher` only renders when `models.length > 1`. Passing a
* single-item array hides it entirely — so the render path for free/pro tiers
* (single model) is already handled without conditional code.
*/
const modelSelector: StoryUsage = {
intro:
'Put a model picker beside the input. `<kc-prompt-input>` doesn\'t expose a model-switcher prop — pair it with the standalone `<kc-model-switcher>` element (bind `models` and `currentModel`, handle `modelchange`) and lay them out side by side. (The demo composes the SolidJS `PromptInput` + `ModelSwitcher` primitives in the actions row.)',
snippets: {
html: `<script type="module">
import 'https://cdn.jsdelivr.net/npm/@kitn.ai/chat/dist/kitn-chat.es.js';
</script>
<div style="display:flex; flex-direction:column; gap:0.5rem">
<kc-model-switcher id="models"></kc-model-switcher>
<kc-prompt-input id="prompt" placeholder="Ask anything..."></kc-prompt-input>
</div>
<script type="module">
const models = document.getElementById('models');
// Arrays must be set as a PROPERTY.
models.models = [
{ id: 'claude-4', name: 'Claude 4 Opus', provider: 'Anthropic' },
{ id: 'claude-4-sonnet', name: 'Claude 4 Sonnet', provider: 'Anthropic' },
{ id: 'gemini-2', name: 'Gemini 2.5 Pro', provider: 'Google' },
];
models.currentModel = 'claude-4';
models.addEventListener('kc-model-change', (e) => console.log(e.detail));
const prompt = document.getElementById('prompt');
prompt.addEventListener('kc-submit', (e) => console.log(e.detail.value));
</script>`,
react: `import { useState } from 'react';
import { PromptInput, ModelSwitcher } from '@kitn.ai/chat/react';
const MODELS = [
{ id: 'claude-4', name: 'Claude 4 Opus', provider: 'Anthropic' },
{ id: 'claude-4-sonnet', name: 'Claude 4 Sonnet', provider: 'Anthropic' },
{ id: 'gemini-2', name: 'Gemini 2.5 Pro', provider: 'Google' },
];
export function Prompt() {
const [modelId, setModelId] = useState('claude-4');
return (
<div className="flex flex-col gap-2">
<ModelSwitcher models={MODELS} currentModel={modelId} onModelChange={(e) => setModelId(e.detail.modelId)} />
<PromptInput placeholder="Ask anything..." onSubmit={(e) => console.log(e.detail.value)} />
</div>
);
}`,
vue: `<script setup>
import '@kitn.ai/chat/elements';
import { ref } from 'vue';
const modelId = ref('claude-4');
const models = [
{ id: 'claude-4', name: 'Claude 4 Opus', provider: 'Anthropic' },
{ id: 'claude-4-sonnet', name: 'Claude 4 Sonnet', provider: 'Anthropic' },
{ id: 'gemini-2', name: 'Gemini 2.5 Pro', provider: 'Google' },
];
function onModelChange(e) { modelId.value = e.detail.modelId; }
</script>
<template>
<div style="display:flex; flex-direction:column; gap:0.5rem">
<kc-model-switcher :models.prop="models" :current-model="modelId" @kc-model-change="onModelChange" />
<kc-prompt-input placeholder="Ask anything..." @kc-submit="(e) => console.log(e.detail.value)" />
</div>
</template>`,
svelte: `<script>
import '@kitn.ai/chat/elements';
let el;
let modelId = 'claude-4';
const models = [
{ id: 'claude-4', name: 'Claude 4 Opus', provider: 'Anthropic' },
{ id: 'claude-4-sonnet', name: 'Claude 4 Sonnet', provider: 'Anthropic' },
{ id: 'gemini-2', name: 'Gemini 2.5 Pro', provider: 'Google' },
];
// Arrays must be set as a property.
$: if (el) el.models = models;
function onModelChange(e) { modelId = e.detail.modelId; }
</script>
<div style="display:flex; flex-direction:column; gap:0.5rem">
<kc-model-switcher bind:this={el} current-model={modelId} on:kc-model-change={onModelChange} />
<kc-prompt-input placeholder="Ask anything..." on:kc-submit={(e) => console.log(e.detail.value)} />
</div>`,
angular: `import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
@Component({
selector: 'app-prompt',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: \`
<div style="display:flex; flex-direction:column; gap:0.5rem">
<kc-model-switcher [models]="models" [currentModel]="modelId" (kc-model-change)="onModelChange($event)"></kc-model-switcher>
<kc-prompt-input placeholder="Ask anything..." (kc-submit)="onSubmit($event)"></kc-prompt-input>
</div>
\`,
})
export class PromptComponent {
modelId = 'claude-4';
// [models] binds the array as a property.
models = [
{ id: 'claude-4', name: 'Claude 4 Opus', provider: 'Anthropic' },
{ id: 'claude-4-sonnet', name: 'Claude 4 Sonnet', provider: 'Anthropic' },
{ id: 'gemini-2', name: 'Gemini 2.5 Pro', provider: 'Google' },
];
onModelChange(e: CustomEvent<{ modelId: string }>) { this.modelId = e.detail.modelId; }
onSubmit(e: CustomEvent<{ value: string }>) { console.log(e.detail.value); }
}`,
solid: `import { createSignal } from 'solid-js';
import { PromptInput, PromptInputTextarea, PromptInputActions, ModelSwitcher, Button } from '@kitn.ai/chat';
import type { ModelOption } from '@kitn.ai/chat';
import { ArrowUp, Paperclip } from 'lucide-solid';
const MODELS: ModelOption[] = [
{ id: 'claude-4', name: 'Claude 4 Opus', provider: 'Anthropic' },
{ id: 'claude-4-sonnet', name: 'Claude 4 Sonnet', provider: 'Anthropic' },
{ id: 'gemini-2', name: 'Gemini 2.5 Pro', provider: 'Google' },
];
export function Prompt() {
const [value, setValue] = createSignal('');
const [modelId, setModelId] = createSignal('claude-4');
return (
<PromptInput value={value()} onValueChange={setValue} onSubmit={() => setValue('')}>
<PromptInputTextarea placeholder="Ask anything..." />
<PromptInputActions class="justify-between">
<ModelSwitcher models={MODELS} currentModelId={modelId()} onModelChange={setModelId} />
<div class="flex items-center gap-1">
<Button variant="ghost" size="icon-sm" aria-label="Attach file"><Paperclip class="size-4 text-muted-foreground" /></Button>
<Button variant="default" size="icon-sm" class="rounded-full" disabled={!value()} aria-label="Send message">
<ArrowUp class="size-4" />
</Button>
</div>
</PromptInputActions>
</PromptInput>
);
}`,
},
};
/** With File Attachments — staged files rendered above the textarea. */
const withFileAttachments: StoryUsage = {
intro:
'The `kc-prompt-input` element has a built-in paperclip: clicking it opens a file picker, previews appear above the textarea (removable chips), and `kc-submit` always carries `{ value, attachments: AttachmentData[] }` — even when the array is empty. To pre-populate staged files, set `prompt.attachments = [...]` as a JS **property** after mount; the element then manages its own attachment state from there. The Solid demo wires the `Attachments`/`Attachment`/`AttachmentPreview`/`AttachmentInfo`/`AttachmentRemove` primitives manually for full control — use the element if you want the paperclip UX for free.',
snippets: {
html: `<script type="module">
import 'https://cdn.jsdelivr.net/npm/@kitn.ai/chat/dist/kitn-chat.es.js';
</script>
<!-- The element renders the paperclip, file picker, and removable chips automatically. -->
<kc-prompt-input id="prompt" placeholder="Describe or ask about the attached files..."></kc-prompt-input>
<script type="module">
const prompt = document.getElementById('prompt');
// Pre-populate staged files (must be a JS property, not an attribute).
prompt.attachments = [
{ id: 'a1', type: 'file', filename: 'architecture.pdf', mediaType: 'application/pdf' },
{ id: 'a2', type: 'file', filename: 'screenshot.png', mediaType: 'image/png' },
];
// kc-submit always includes the current staged attachments.
prompt.addEventListener('kc-submit', (e) => {
const { value, attachments } = e.detail;
// attachments is always AttachmentData[] — empty array when no files staged.
console.log(value, attachments);
});
</script>`,
react: `import { useRef } from 'react';
import { PromptInput } from '@kitn.ai/chat/react';
const SEED = [
{ id: 'a1', type: 'file', filename: 'architecture.pdf', mediaType: 'application/pdf' },
{ id: 'a2', type: 'file', filename: 'screenshot.png', mediaType: 'image/png' },
];
export function Prompt() {
const ref = useRef(null);
// Set the attachments property after mount.
// useEffect(() => { if (ref.current) ref.current.attachments = SEED; }, []);
return (
<PromptInput
ref={ref}
placeholder="Describe or ask about the attached files..."
onSubmit={(e) => {
// attachments is always AttachmentData[] — may be empty
const { value, attachments } = e.detail;
console.log(value, attachments);
}}
/>
);
}`,
vue: `<script setup>
import '@kitn.ai/chat/elements';
import { ref, onMounted } from 'vue';
const promptEl = ref(null);
// Set the attachments property after mount.
onMounted(() => {
if (promptEl.value) {
promptEl.value.attachments = [
{ id: 'a1', type: 'file', filename: 'architecture.pdf', mediaType: 'application/pdf' },
];
}
});
function onSubmit(e) {
const { value, attachments } = e.detail; // attachments always present
console.log(value, attachments);
}
</script>
<template>
<kc-prompt-input
ref="promptEl"
placeholder="Describe or ask about the attached files..."
@kc-submit="onSubmit"
/>
</template>`,
svelte: `<script>
import '@kitn.ai/chat/elements';
import { onMount } from 'svelte';
let promptEl;
// Set the attachments property after mount.
onMount(() => {
promptEl.attachments = [
{ id: 'a1', type: 'file', filename: 'architecture.pdf', mediaType: 'application/pdf' },
];
});
function onSubmit(e) {
const { value, attachments } = e.detail; // attachments always present
console.log(value, attachments);
}
</script>
<kc-prompt-input
bind:this={promptEl}
placeholder="Describe or ask about the attached files..."
on:kc-submit={onSubmit}
/>`,
angular: `import { Component, CUSTOM_ELEMENTS_SCHEMA, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
@Component({
selector: 'app-prompt',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: \`
<kc-prompt-input
#prompt
placeholder="Describe or ask about the attached files..."
(kc-submit)="onSubmit($event)"
></kc-prompt-input>
\`,
})
export class PromptComponent implements AfterViewInit {
@ViewChild('prompt') promptRef!: ElementRef;
// Set attachments as a property after mount.
ngAfterViewInit() {
this.promptRef.nativeElement.attachments = [
{ id: 'a1', type: 'file', filename: 'architecture.pdf', mediaType: 'application/pdf' },
];
}
onSubmit(e: CustomEvent<{ value: string; attachments: unknown[] }>) {
const { value, attachments } = e.detail; // attachments always present
console.log(value, attachments);
}
}`,
solid: `import { createSignal, For, Show } from 'solid-js';
import {
PromptInput, PromptInputTextarea, PromptInputActions, Button,
Attachments, Attachment, AttachmentPreview, AttachmentInfo, AttachmentRemove,
} from '@kitn.ai/chat';
import type { AttachmentData } from '@kitn.ai/chat';
import { ArrowUp, Paperclip } from 'lucide-solid';
// The Solid PromptInput primitives don't wire the paperclip for you — that's
// done by DefaultPromptInput (used internally by the kc-prompt-input element).
// Compose the Attachments primitives manually when you need full control.
export function Prompt() {
const [value, setValue] = createSignal('');
const [attachments, setAttachments] = createSignal<AttachmentData[]>([
{ id: 'a1', type: 'file', filename: 'architecture.pdf', mediaType: 'application/pdf' },
{ id: 'a2', type: 'file', filename: 'screenshot.png', mediaType: 'image/png' },
]);
let fileInput: HTMLInputElement | undefined;
const addFiles = (files: FileList | null) => {
if (!files?.length) return;
setAttachments((prev) => [
...prev,
...Array.from(files).map((f) => ({
id: crypto.randomUUID(),
type: 'file' as const,
filename: f.name,
mediaType: f.type || undefined,
url: f.type.startsWith('image/') ? URL.createObjectURL(f) : undefined,
})),
]);
};
const removeAttachment = (id: string) =>
setAttachments((prev) => prev.filter((a) => a.id !== id));
const handleSubmit = () => {
// kc-submit emits { value, attachments } — mirror that shape here
console.log('submit', { value: value(), attachments: attachments() });
setValue('');
setAttachments([]);
};
return (
<>
<input
ref={fileInput}
type="file"
multiple
class="hidden"
onChange={(e) => { addFiles(e.currentTarget.files); e.currentTarget.value = ''; }}
/>
<PromptInput value={value()} onValueChange={setValue} onSubmit={handleSubmit}>
<Show when={attachments().length > 0}>
<div class="px-3 pt-3">
<Attachments variant="inline">
<For each={attachments()}>
{(att) => (
<Attachment data={att} onRemove={() => removeAttachment(att.id)}>
<AttachmentPreview />
<AttachmentInfo />
<AttachmentRemove />
</Attachment>
)}
</For>
</Attachments>
</div>
</Show>
<PromptInputTextarea placeholder="Describe or ask about the attached files..." class="pt-3 pl-4" />
<PromptInputActions class="justify-between">
<Button variant="ghost" size="icon-sm" aria-label="Attach file" onClick={() => fileInput?.click()}>
<Paperclip class="size-4 text-muted-foreground" />
</Button>
{/* send enabled when there's text OR staged attachments */}
<Button
variant="default"
size="icon-sm"
class="rounded-full"
disabled={!value() && attachments().length === 0}
aria-label="Send message"
>
<ArrowUp class="size-4" />
</Button>
</PromptInputActions>
</PromptInput>
</>
);
}`,
},
};
/** Full Example — everything combined: model switcher, grouped suggestions,
* streaming state with a Stop button, and a send button that enables on input.
*
* GOTCHAS compiled from the source:
* - Submit payload: `kc-submit` always emits `{ value: string; attachments: AttachmentData[] }`.
* Even when no files are staged, `attachments` is an empty array — never `undefined`.
* - Enter vs Shift+Enter: `PromptInputTextarea` intercepts `Enter` (no Shift) and calls
* `onSubmit`; `Shift+Enter` inserts a newline. This is wired at the primitive level
* (`handleKeyDown` in prompt-input.tsx line 147), not configurable.
* - `loading` vs `disabled` on the element: `loading` alone blocks submit but keeps the
* box visually interactive; `disabled` makes it fully non-interactive (opacity + no focus).
* Use both together for the streaming state.
* - `isLoading` on the Solid primitive vs `loading` on the element: the Solid
* `PromptInput` prop is `isLoading`; the web component attribute is `loading` (kebab).
* - Stop button: add `stoppable` to `kc-prompt-input` and listen for `kc-stop` — the element
* fires it when the Stop button is clicked. Call `controller.abort()` in your handler to
* cancel the fetch/SSE. When composing Solid primitives, wire the Square button yourself
* (see FullExample). The element does the toggling for you; the consumer still owns the abort.
* - `ModelSwitcher` only renders when `models.length > 1` — a single-model list hides it.
* - `suggestions` is a JS property, not an attribute — arrays must be set on the element
* reference (not via an HTML attribute string). In the web-component tab note the
* `.prop` binding for Vue, `$:` for Svelte, `[prop]` for Angular.
* - `suggestionMode="submit"` (default) immediately dispatches `kc-submit` when a chip
* is clicked; `suggestionMode="fill"` drops the text into the box and fires
* `kc-suggestion-click` instead (so the user can edit before sending).
*/
const fullExample: StoryUsage = {
intro:
'Everything combined: model switcher, grouped suggestion chips, streaming state (with a Stop button), and a send button that enables once you type. Simulates the idle → streaming → idle loop you\'d wire to a real fetch/SSE call. Key gotchas: `kc-submit` always emits `{ value, attachments }` (attachments may be empty); Enter submits, Shift+Enter newlines; `loading` blocks submit while `disabled` kills focus too — use both while streaming; add `stoppable` to enable the built-in Stop button — it fires `kc-stop` when clicked; call `controller.abort()` in your handler to cancel the stream. When composing Solid primitives (the `PromptInput` + `PromptInputActions` pattern), wire the Square button yourself as shown in the Full Example story.',
snippets: {
html: `<script type="module">
import 'https://cdn.jsdelivr.net/npm/@kitn.ai/chat/dist/kitn-chat.es.js';
</script>
<div style="max-width:42rem; padding:1rem; display:flex; flex-direction:column; gap:1rem">
<kc-model-switcher id="models"></kc-model-switcher>
<kc-prompt-input id="prompt" placeholder="Ask anything..." suggestion-mode="fill"></kc-prompt-input>
<button id="stop" style="display:none">Stop</button>
</div>
<script type="module">
const models = document.getElementById('models');
const prompt = document.getElementById('prompt');
const stopBtn = document.getElementById('stop');
let controller;
// Arrays must be set as JS properties, not attributes.
models.models = [
{ id: 'claude-4-opus', name: 'Claude 4 Opus', provider: 'Anthropic' },
{ id: 'claude-4-sonnet', name: 'Claude 4 Sonnet', provider: 'Anthropic' },
];
models.currentModel = 'claude-4-opus';
models.addEventListener('kc-model-change', (e) => { models.currentModel = e.detail.modelId; });
prompt.suggestions = ['Summarize this document', 'What are the key takeaways?'];
// suggestion-mode="fill" lets the user edit before submitting.
prompt.addEventListener('kc-suggestion-click', (e) => {
// User clicked a chip: value is now in the box; wait for them to hit Enter.
});
prompt.addEventListener('kc-submit', async (e) => {
const { value, attachments } = e.detail; // attachments always present (may be [])
prompt.loading = true;
prompt.disabled = true;
stopBtn.style.display = 'inline';
controller = new AbortController();
try {
// Replace with your real streaming fetch:
await fetch('/api/chat', { method: 'POST', body: JSON.stringify({ value }), signal: controller.signal });
} catch (err) {
if (err.name !== 'AbortError') throw err;
} finally {
prompt.loading = false;
prompt.disabled = false;
stopBtn.style.display = 'none';
}
});
// With stoppable set, kc-stop fires when the built-in Stop button is clicked.
// prompt.addEventListener('kc-stop', () => controller?.abort());
// OR compose your own stop button outside the element:
stopBtn.addEventListener('click', () => controller?.abort());
</script>`,
react: `import { useState, useRef } from 'react';
import { PromptInput, ModelSwitcher } from '@kitn.ai/chat/react';
const MODELS = [
{ id: 'claude-4-opus', name: 'Claude 4 Opus', provider: 'Anthropic' },
{ id: 'claude-4-sonnet', name: 'Claude 4 Sonnet', provider: 'Anthropic' },
];
const SUGGESTIONS = ['Summarize this document', 'What are the key takeaways?'];
export function Prompt() {
const [modelId, setModelId] = useState('claude-4-opus');
const [streaming, setStreaming] = useState(false);
const controllerRef = useRef<AbortController | null>(null);
const handleSubmit = async (e: CustomEvent<{ value: string; attachments: unknown[] }>) => {
// attachments always present, may be empty array
const { value, attachments } = e.detail;
setStreaming(true);
controllerRef.current = new AbortController();
try {
await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ value, modelId, attachments }),
signal: controllerRef.current.signal,
});
} catch (err: unknown) {
if ((err as Error).name !== 'AbortError') throw err;
} finally {
setStreaming(false);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', maxWidth: '42rem' }}>
<ModelSwitcher
models={MODELS}
currentModel={modelId}
onModelChange={(e) => setModelId(e.detail.modelId)}
/>
{/* suggestions passed as array property; suggestionMode="fill" lets user edit first */}
<PromptInput
suggestions={SUGGESTIONS}
suggestionMode="fill"
loading={streaming}
disabled={streaming}
placeholder={streaming ? 'Generating...' : 'Ask anything...'}
onSubmit={handleSubmit}
/>
{streaming && (
{/* With stoppable + onStop, kc-prompt-input renders the Stop button for you.
Here we compose it manually for illustration. */}
<button onClick={() => controllerRef.current?.abort()}>Stop</button>
)}
</div>
);
}`,
vue: `<script setup>
import '@kitn.ai/chat/elements';
import { ref } from 'vue';
const MODELS = [
{ id: 'claude-4-opus', name: 'Claude 4 Opus', provider: 'Anthropic' },
{ id: 'claude-4-sonnet', name: 'Claude 4 Sonnet', provider: 'Anthropic' },
];
const SUGGESTIONS = ['Summarize this document', 'What are the key takeaways?'];
const modelId = ref('claude-4-opus');
const streaming = ref(false);
let controller = null;
function onModelChange(e) { modelId.value = e.detail.modelId; }
async function onSubmit(e) {
const { value, attachments } = e.detail; // attachments always present
streaming.value = true;
controller = new AbortController();
try {
await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ value, modelId: modelId.value, attachments }),
signal: controller.signal,
});
} catch (err) {
if (err.name !== 'AbortError') throw err;
} finally {
streaming.value = false;
}
}
function stop() { controller?.abort(); }
</script>
<template>
<div style="display:flex; flex-direction:column; gap:1rem; max-width:42rem">
<kc-model-switcher
:models.prop="MODELS"
:current-model="modelId"
@kc-model-change="onModelChange"
/>
<!-- suggestion-mode="fill" lets the user edit before sending -->
<kc-prompt-input
:suggestions.prop="SUGGESTIONS"
suggestion-mode="fill"
:loading="streaming"
:disabled="streaming"
:placeholder="streaming ? 'Generating...' : 'Ask anything...'"
@kc-submit="onSubmit"
/>
<!-- Add stoppable + listen for kc-stop to use the built-in Stop button.
Here we compose it manually outside the element for illustration. -->
<button v-if="streaming" @click="stop">Stop</button>
</div>
</template>`,
svelte: `<script>
import '@kitn.ai/chat/elements';
const MODELS = [
{ id: 'claude-4-opus', name: 'Claude 4 Opus', provider: 'Anthropic' },
{ id: 'claude-4-sonnet', name: 'Claude 4 Sonnet', provider: 'Anthropic' },
];
const SUGGESTIONS = ['Summarize this document', 'What are the key takeaways?'];
let modelEl;
let modelId = 'claude-4-opus';
let streaming = false;
let controller;
// Arrays must be set as properties (not attributes).
$: if (modelEl) { modelEl.models = MODELS; modelEl.currentModel = modelId; }
function onModelChange(e) { modelId = e.detail.modelId; }
async function onSubmit(e) {
const { value, attachments } = e.detail; // attachments always present
streaming = true;
controller = new AbortController();
try {
await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ value, modelId, attachments }),
signal: controller.signal,
});
} catch (err) {
if (err.name !== 'AbortError') throw err;
} finally {
streaming = false;
}
}
function stop() { controller?.abort(); }
</script>
<div style="display:flex; flex-direction:column; gap:1rem; max-width:42rem">
<kc-model-switcher bind:this={modelEl} on:kc-model-change={onModelChange} />
<!-- suggestion-mode="fill" lets the user edit before sending -->
<kc-prompt-input
suggestions={SUGGESTIONS}
suggestion-mode="fill"
loading={streaming}
disabled={streaming}
placeholder={streaming ? 'Generating...' : 'Ask anything...'}
on:kc-submit={onSubmit}
/>
<!-- Add stoppable + listen for kc-stop to use the built-in Stop button.
Here we compose it manually outside the element for illustration. -->
{#if streaming}
<button on:click={stop}>Stop</button>
{/if}
</div>`,
angular: `import { Component, CUSTOM_ELEMENTS_SCHEMA, signal, ViewChild, ElementRef, AfterViewInit } from '@angular/core';
const MODELS = [
{ id: 'claude-4-opus', name: 'Claude 4 Opus', provider: 'Anthropic' },
{ id: 'claude-4-sonnet', name: 'Claude 4 Sonnet', provider: 'Anthropic' },
];
const SUGGESTIONS = ['Summarize this document', 'What are the key takeaways?'];
@Component({
selector: 'app-prompt',
standalone: true,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
template: \`
<div style="display:flex; flex-direction:column; gap:1rem; max-width:42rem">
<kc-model-switcher
#models
[models]="MODELS"
[currentModel]="modelId()"
(kc-model-change)="onModelChange($event)"
></kc-model-switcher>
<!-- suggestion-mode="fill" lets the user edit before sending -->
<kc-prompt-input
#prompt
[suggestions]="SUGGESTIONS"
suggestion-mode="fill"
[loading]="streaming()"
[disabled]="streaming()"
[placeholder]="streaming() ? 'Generating...' : 'Ask anything...'"
(kc-submit)="onSubmit($event)"
></kc-prompt-input>
<!-- Stop is NOT built in — compose it yourself -->
@if (streaming()) {
<button (click)="stop()">Stop</button>
}
</div>
\`,
})
export class PromptComponent {
MODELS = MODELS;
SUGGESTIONS = SUGGESTIONS;
modelId = signal('claude-4-opus');
streaming = signal(false);
private controller: AbortController | null = null;
onModelChange(e: CustomEvent<{ modelId: string }>) { this.modelId.set(e.detail.modelId); }
async onSubmit(e: CustomEvent<{ value: string; attachments: unknown[] }>) {
const { value, attachments } = e.detail; // attachments always present
this.streaming.set(true);
this.controller = new AbortController();
try {
await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ value, modelId: this.modelId(), attachments }),
signal: this.controller.signal,
});
} catch (err: unknown) {
if ((err as Error).name !== 'AbortError') throw err;
} finally {
this.streaming.set(false);
}
}
stop() { this.controller?.abort(); }
}`,
solid: `import { createSignal, For, Show } from 'solid-js';
import { PromptInput, PromptInputTextarea, PromptInputActions, PromptSuggestion, ModelSwitcher, Loader, Button } from '@kitn.ai/chat';
import type { ModelOption } from '@kitn.ai/chat';
import { ArrowUp, Square } from 'lucide-solid';
const MODELS: ModelOption[] = [
{ id: 'claude-4-opus', name: 'Claude 4 Opus', provider: 'Anthropic' },
{ id: 'claude-4-sonnet', name: 'Claude 4 Sonnet', provider: 'Anthropic' },
];
const SUGGESTION_GROUPS = [
{ label: 'Get started', items: ['Summarize this document', 'What are the key takeaways?'] },
{ label: 'Go deeper', items: ['Compare with similar approaches', 'Find contradictions'] },
];
export function Prompt() {
const [value, setValue] = createSignal('');
const [modelId, setModelId] = createSignal('claude-4-opus');
const [streaming, setStreaming] = createSignal(false);
let controller: AbortController | undefined;
const handleSubmit = async () => {
if (!value().trim()) return;
setStreaming(true);
setValue('');
controller = new AbortController();
try {
// Replace with your real SSE / streaming fetch.
await fetch('/api/chat', {
method: 'POST',
body: JSON.stringify({ value: value(), modelId: modelId() }),
signal: controller.signal,
});
} catch (err: unknown) {
if ((err as Error).name !== 'AbortError') throw err;
} finally {
setStreaming(false);
}
};
const handleStop = () => controller?.abort();
return (
<div class="flex flex-col gap-4 max-w-2xl">
<Show when={!streaming()}>
<For each={SUGGESTION_GROUPS}>
{(group) => (
<div class="space-y-2">
<span class="text-xs font-medium text-muted-foreground uppercase tracking-wider">{group.label}</span>
<div class="flex flex-wrap gap-2">
<For each={group.items}>
{/* suggestionMode="fill" (default "submit") — here we fill manually */}
{(item) => <PromptSuggestion onClick={() => setValue(item)}>{item}</PromptSuggestion>}
</For>
</div>
</div>
)}
</For>
</Show>
<PromptInput
value={value()}
onValueChange={setValue}
onSubmit={handleSubmit}
disabled={streaming()}
isLoading={streaming()} // Note: Solid prop is isLoading; element attribute is loading
>
<PromptInputTextarea placeholder={streaming() ? 'Generating...' : 'Ask anything...'} />
<PromptInputActions class="justify-between">
<Show
when={streaming()}
fallback={
// ModelSwitcher renders nothing when models.length <= 1
<ModelSwitcher models={MODELS} currentModelId={modelId()} onModelChange={setModelId} />
}
>
<div class="flex items-center gap-2">
<Loader variant="typing" size="sm" />
<span class="text-xs text-foreground">Generating…</span>
</div>
</Show>
<Show
when={streaming()}
fallback={
// Enter (no Shift) also submits — the send button is a redundant affordance
<Button variant="default" size="icon-sm" class="rounded-full" disabled={!value()} aria-label="Send message" onClick={handleSubmit}>
<ArrowUp class="size-4" />
</Button>
}
>
{/* Solid primitive: wire the Stop button yourself inside PromptInputActions.
With the kc-prompt-input element, add stoppable and listen for kc-stop instead. */}
<Button variant="outline" size="icon-sm" class="rounded-full" aria-label="Stop generation" onClick={handleStop}>
<Square class="size-3" />
</Button>
</Show>
</PromptInputActions>
</PromptInput>
</div>
);
}`,
},
};
/**
* Example: Prompt Input Variants — a complete prompt box (text, suggestions,
* action buttons, streaming, model selector, file attachments) built from the
* `kc-prompt-input` element. Per-story: the Usage tab shows the snippet for
* the story you're on; the example-level fields below are the fallback.
*/
const promptInputVariants: ExampleUsage = {
title: 'Examples/Prompt Input Variants',
...basic, // example-level fallback = the headline "Basic Input"
stories: {
'Basic Input': basic,
'With Suggestion Chips': suggestions,
'With Action Buttons': actionButtons,
'Streaming / Loading State': streaming,
'With Model Selector': modelSelector,
'With File Attachments': withFileAttachments,
'Full Example': fullExample,
},
};
export default promptInputVariants;