UNPKG

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