UNPKG

@kitn.ai/ui

Version:

Framework-agnostic, Shadow-DOM web components for building AI chat interfaces — works in React, Vue, Angular, Svelte, or plain HTML. Authored in SolidJS.

93 lines (85 loc) 4.05 kB
// src/state/stream.ts import type { ChatMessage } from '../elements/chat-types'; import type { ToolPart } from '../components/tool-types'; /** The one universal contract: a functional-updater setter (React setState shape). */ export type SetMessages = (updater: (prev: ChatMessage[]) => ChatMessage[]) => void; /** A fluent builder for one in-flight assistant message. Owns no state. */ export interface AssistantStream { readonly id: string; appendText(delta: string): AssistantStream; setText(content: string): AssistantStream; appendReasoning(delta: string, label?: string): AssistantStream; setReasoning(text: string, label?: string): AssistantStream; upsertTool(tool: ToolPart): AssistantStream; updateTool(toolCallId: string, patch: Partial<ToolPart>): AssistantStream; patch(patch: Partial<ChatMessage>): AssistantStream; done(final?: Partial<ChatMessage>): void; abort(reason?: string): void; } function newId(): string { if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID(); return 'kai-' + Math.random().toString(36).slice(2); } /** Start an assistant message and drive it through `set`. New refs on every mutation. */ export function createAssistantStream(set: SetMessages, init: Partial<ChatMessage> = {}): AssistantStream { const id = init.id ?? newId(); const seed: ChatMessage = { id, role: 'assistant', content: '', ...init }; set((prev) => [...prev, seed]); const mutate = (fn: (m: ChatMessage) => ChatMessage) => set((prev) => prev.map((m) => (m.id === id ? fn(m) : m))); const stream: AssistantStream = { id, appendText(delta) { mutate((m) => ({ ...m, content: m.content + delta })); return stream; }, setText(content) { mutate((m) => ({ ...m, content })); return stream; }, appendReasoning(delta, label) { mutate((m) => ({ ...m, reasoning: { text: (m.reasoning?.text ?? '') + delta, label: label ?? m.reasoning?.label } })); return stream; }, setReasoning(text, label) { mutate((m) => ({ ...m, reasoning: { text, label: label ?? m.reasoning?.label } })); return stream; }, upsertTool(tool) { mutate((m) => { const tools = m.tools ? m.tools.slice() : []; const i = tool.toolCallId != null ? tools.findIndex((t) => t.toolCallId === tool.toolCallId) : tools.findIndex((t) => t.type === tool.type); if (i === -1) tools.push(tool); else tools[i] = tool; return { ...m, tools }; }); return stream; }, updateTool(toolCallId, patch) { mutate((m) => ({ ...m, tools: (m.tools ?? []).map((t) => (t.toolCallId === toolCallId ? { ...t, ...patch } : t)) })); return stream; }, patch(patch) { mutate((m) => ({ ...m, ...patch })); return stream; }, done(final) { if (final) mutate((m) => ({ ...m, ...final })); }, abort(reason) { if (reason) { mutate((m) => ({ ...m, tools: (m.tools ?? []).map((t) => ({ ...t, state: 'output-error' as const, errorText: reason })) })); } else { set((prev) => prev.filter((m) => m.id !== id)); } }, }; return stream; } /** Wrap a stream so `onSettle` fires on done/abort (used to toggle a `loading` flag). * Preserves the fluent chain by returning the wrapper from every mutator. */ export function onStreamSettled(inner: AssistantStream, onSettle: () => void): AssistantStream { const wrapper: AssistantStream = { id: inner.id, appendText(d) { inner.appendText(d); return wrapper; }, setText(c) { inner.setText(c); return wrapper; }, appendReasoning(d, l) { inner.appendReasoning(d, l); return wrapper; }, setReasoning(t, l) { inner.setReasoning(t, l); return wrapper; }, upsertTool(t) { inner.upsertTool(t); return wrapper; }, updateTool(tcId, p) { inner.updateTool(tcId, p); return wrapper; }, patch(p) { inner.patch(p); return wrapper; }, done(final) { inner.done(final); onSettle(); }, abort(reason) { inner.abort(reason); onSettle(); }, }; return wrapper; }