create-kai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. package/README.md +311 -0
  2. package/dist/index.js +4433 -0
  3. package/dist/templates/angular/_gitignore +7 -0
  4. package/dist/templates/angular/angular.json +53 -0
  5. package/dist/templates/angular/package.json +30 -0
  6. package/dist/templates/angular/src/app/app.config.ts +11 -0
  7. package/dist/templates/angular/src/app/app.css +36 -0
  8. package/dist/templates/angular/src/app/app.html +51 -0
  9. package/dist/templates/angular/src/app/app.ts +78 -0
  10. package/dist/templates/angular/src/app/components/composer/composer.css +31 -0
  11. package/dist/templates/angular/src/app/components/composer/composer.html +18 -0
  12. package/dist/templates/angular/src/app/components/composer/composer.ts +81 -0
  13. package/dist/templates/angular/src/app/components/icons/moon-icon/moon-icon.css +3 -0
  14. package/dist/templates/angular/src/app/components/icons/moon-icon/moon-icon.html +12 -0
  15. package/dist/templates/angular/src/app/components/icons/moon-icon/moon-icon.ts +15 -0
  16. package/dist/templates/angular/src/app/components/icons/sun-icon/sun-icon.css +3 -0
  17. package/dist/templates/angular/src/app/components/icons/sun-icon/sun-icon.html +14 -0
  18. package/dist/templates/angular/src/app/components/icons/sun-icon/sun-icon.ts +13 -0
  19. package/dist/templates/angular/src/app/components/sidebar/sidebar.css +24 -0
  20. package/dist/templates/angular/src/app/components/sidebar/sidebar.html +12 -0
  21. package/dist/templates/angular/src/app/components/sidebar/sidebar.ts +43 -0
  22. package/dist/templates/angular/src/app/components/theme-toggle/theme-toggle.html +13 -0
  23. package/dist/templates/angular/src/app/components/theme-toggle/theme-toggle.ts +21 -0
  24. package/dist/templates/angular/src/app/components/thread-view/thread-view.css +6 -0
  25. package/dist/templates/angular/src/app/components/thread-view/thread-view.html +6 -0
  26. package/dist/templates/angular/src/app/components/thread-view/thread-view.ts +45 -0
  27. package/dist/templates/angular/src/app/state/chat.store.ts +47 -0
  28. package/dist/templates/angular/src/app/state/conversations.store.ts +42 -0
  29. package/dist/templates/angular/src/app/state/voice-input.ts +57 -0
  30. package/dist/templates/angular/src/app/types.ts +2 -0
  31. package/dist/templates/angular/src/chat-data.ts +106 -0
  32. package/dist/templates/angular/src/index.html +16 -0
  33. package/dist/templates/angular/src/main.ts +16 -0
  34. package/dist/templates/angular/src/styles.css +10 -0
  35. package/dist/templates/angular/tsconfig.app.json +9 -0
  36. package/dist/templates/angular/tsconfig.json +22 -0
  37. package/dist/templates/nextjs/_gitignore +11 -0
  38. package/dist/templates/nextjs/app/chat-data.ts +125 -0
  39. package/dist/templates/nextjs/app/components/Composer.tsx +88 -0
  40. package/dist/templates/nextjs/app/components/HydrationBadge.tsx +68 -0
  41. package/dist/templates/nextjs/app/components/Sidebar.tsx +48 -0
  42. package/dist/templates/nextjs/app/components/ThemeToggle.tsx +27 -0
  43. package/dist/templates/nextjs/app/components/ThreadView.tsx +48 -0
  44. package/dist/templates/nextjs/app/components/icons/MoonIcon.tsx +25 -0
  45. package/dist/templates/nextjs/app/components/icons/SunIcon.tsx +25 -0
  46. package/dist/templates/nextjs/app/components/icons/index.ts +2 -0
  47. package/dist/templates/nextjs/app/globals.css +153 -0
  48. package/dist/templates/nextjs/app/hooks/index.ts +1 -0
  49. package/dist/templates/nextjs/app/hooks/useConversations.ts +42 -0
  50. package/dist/templates/nextjs/app/layout.tsx +50 -0
  51. package/dist/templates/nextjs/app/page.tsx +17 -0
  52. package/dist/templates/nextjs/app/theme.ts +10 -0
  53. package/dist/templates/nextjs/app/workspace.tsx +148 -0
  54. package/dist/templates/nextjs/next.config.mjs +22 -0
  55. package/dist/templates/nextjs/package.json +23 -0
  56. package/dist/templates/nextjs/postcss.config.mjs +16 -0
  57. package/dist/templates/nextjs/tsconfig.json +23 -0
  58. package/dist/templates/react/_gitignore +6 -0
  59. package/dist/templates/react/index.html +16 -0
  60. package/dist/templates/react/package.json +25 -0
  61. package/dist/templates/react/src/App.tsx +108 -0
  62. package/dist/templates/react/src/chat-data.ts +106 -0
  63. package/dist/templates/react/src/components/Composer.tsx +87 -0
  64. package/dist/templates/react/src/components/Sidebar.tsx +38 -0
  65. package/dist/templates/react/src/components/ThemeToggle.tsx +22 -0
  66. package/dist/templates/react/src/components/ThreadView.tsx +40 -0
  67. package/dist/templates/react/src/components/icons/MoonIcon.tsx +25 -0
  68. package/dist/templates/react/src/components/icons/SunIcon.tsx +25 -0
  69. package/dist/templates/react/src/components/icons/index.ts +2 -0
  70. package/dist/templates/react/src/hooks/index.ts +1 -0
  71. package/dist/templates/react/src/hooks/useConversations.ts +37 -0
  72. package/dist/templates/react/src/index.css +111 -0
  73. package/dist/templates/react/src/main.tsx +15 -0
  74. package/dist/templates/react/src/vite-env.d.ts +1 -0
  75. package/dist/templates/react/tsconfig.app.json +28 -0
  76. package/dist/templates/react/tsconfig.json +7 -0
  77. package/dist/templates/react/tsconfig.node.json +25 -0
  78. package/dist/templates/react/vite.config.ts +11 -0
  79. package/dist/templates/solid/_gitignore +6 -0
  80. package/dist/templates/solid/index.html +13 -0
  81. package/dist/templates/solid/package.json +23 -0
  82. package/dist/templates/solid/src/App.tsx +151 -0
  83. package/dist/templates/solid/src/chat-data.ts +234 -0
  84. package/dist/templates/solid/src/components/Composer.tsx +90 -0
  85. package/dist/templates/solid/src/components/Sidebar.tsx +36 -0
  86. package/dist/templates/solid/src/components/ThemeToggle.tsx +25 -0
  87. package/dist/templates/solid/src/components/ThreadView.tsx +122 -0
  88. package/dist/templates/solid/src/components/icons/index.tsx +84 -0
  89. package/dist/templates/solid/src/index.tsx +5 -0
  90. package/dist/templates/solid/src/lib/chat.ts +64 -0
  91. package/dist/templates/solid/src/lib/conversations.ts +54 -0
  92. package/dist/templates/solid/src/lib/types.ts +2 -0
  93. package/dist/templates/solid/src/styles.css +11 -0
  94. package/dist/templates/solid/tsconfig.json +18 -0
  95. package/dist/templates/solid/vite.config.ts +10 -0
  96. package/dist/templates/svelte/_gitignore +6 -0
  97. package/dist/templates/svelte/index.html +16 -0
  98. package/dist/templates/svelte/package.json +23 -0
  99. package/dist/templates/svelte/src/App.svelte +116 -0
  100. package/dist/templates/svelte/src/app.d.ts +20 -0
  101. package/dist/templates/svelte/src/chat-data.ts +106 -0
  102. package/dist/templates/svelte/src/components/Composer.svelte +102 -0
  103. package/dist/templates/svelte/src/components/Sidebar.svelte +61 -0
  104. package/dist/templates/svelte/src/components/ThemeToggle.svelte +45 -0
  105. package/dist/templates/svelte/src/components/ThreadView.svelte +49 -0
  106. package/dist/templates/svelte/src/components/icons/MoonIcon.svelte +24 -0
  107. package/dist/templates/svelte/src/components/icons/SunIcon.svelte +24 -0
  108. package/dist/templates/svelte/src/index.css +111 -0
  109. package/dist/templates/svelte/src/lib/chat.svelte.ts +52 -0
  110. package/dist/templates/svelte/src/lib/conversations.svelte.ts +46 -0
  111. package/dist/templates/svelte/src/lib/types.ts +2 -0
  112. package/dist/templates/svelte/src/lib/voiceInput.ts +57 -0
  113. package/dist/templates/svelte/src/main.ts +18 -0
  114. package/dist/templates/svelte/svelte.config.js +8 -0
  115. package/dist/templates/svelte/tsconfig.app.json +28 -0
  116. package/dist/templates/svelte/tsconfig.json +7 -0
  117. package/dist/templates/svelte/tsconfig.node.json +25 -0
  118. package/dist/templates/svelte/vite.config.ts +15 -0
  119. package/dist/templates/tanstack-start/_gitignore +14 -0
  120. package/dist/templates/tanstack-start/package.json +28 -0
  121. package/dist/templates/tanstack-start/serve.mjs +129 -0
  122. package/dist/templates/tanstack-start/serve.traversal.test.mjs +445 -0
  123. package/dist/templates/tanstack-start/src/chat-data.ts +124 -0
  124. package/dist/templates/tanstack-start/src/components/Composer.tsx +87 -0
  125. package/dist/templates/tanstack-start/src/components/HydrationBadge.tsx +63 -0
  126. package/dist/templates/tanstack-start/src/components/Sidebar.tsx +43 -0
  127. package/dist/templates/tanstack-start/src/components/ThemeToggle.tsx +26 -0
  128. package/dist/templates/tanstack-start/src/components/ThreadView.tsx +48 -0
  129. package/dist/templates/tanstack-start/src/components/icons/MoonIcon.tsx +25 -0
  130. package/dist/templates/tanstack-start/src/components/icons/SunIcon.tsx +25 -0
  131. package/dist/templates/tanstack-start/src/components/icons/index.ts +2 -0
  132. package/dist/templates/tanstack-start/src/hooks/index.ts +1 -0
  133. package/dist/templates/tanstack-start/src/hooks/useConversations.ts +42 -0
  134. package/dist/templates/tanstack-start/src/router.tsx +16 -0
  135. package/dist/templates/tanstack-start/src/routes/__root.tsx +62 -0
  136. package/dist/templates/tanstack-start/src/routes/index.tsx +135 -0
  137. package/dist/templates/tanstack-start/src/styles.css +153 -0
  138. package/dist/templates/tanstack-start/src/theme.ts +10 -0
  139. package/dist/templates/tanstack-start/tsconfig.json +16 -0
  140. package/dist/templates/tanstack-start/vite.config.ts +28 -0
  141. package/dist/templates/vanilla/_gitignore +6 -0
  142. package/dist/templates/vanilla/index.html +16 -0
  143. package/dist/templates/vanilla/package.json +19 -0
  144. package/dist/templates/vanilla/src/chat-data.ts +106 -0
  145. package/dist/templates/vanilla/src/index.css +111 -0
  146. package/dist/templates/vanilla/src/main.ts +53 -0
  147. package/dist/templates/vanilla/src/state.ts +119 -0
  148. package/dist/templates/vanilla/src/view.ts +222 -0
  149. package/dist/templates/vanilla/src/vite-env.d.ts +1 -0
  150. package/dist/templates/vanilla/src/voice-input.ts +55 -0
  151. package/dist/templates/vanilla/tsconfig.json +24 -0
  152. package/dist/templates/vanilla/vite.config.ts +14 -0
  153. package/dist/templates/vue/_gitignore +6 -0
  154. package/dist/templates/vue/index.html +16 -0
  155. package/dist/templates/vue/package.json +23 -0
  156. package/dist/templates/vue/src/App.vue +104 -0
  157. package/dist/templates/vue/src/chat-data.ts +106 -0
  158. package/dist/templates/vue/src/components/Composer.vue +96 -0
  159. package/dist/templates/vue/src/components/Sidebar.vue +48 -0
  160. package/dist/templates/vue/src/components/ThemeToggle.vue +27 -0
  161. package/dist/templates/vue/src/components/ThreadView.vue +50 -0
  162. package/dist/templates/vue/src/components/icons/MoonIcon.vue +24 -0
  163. package/dist/templates/vue/src/components/icons/SunIcon.vue +24 -0
  164. package/dist/templates/vue/src/composables/index.ts +3 -0
  165. package/dist/templates/vue/src/composables/useChat.ts +47 -0
  166. package/dist/templates/vue/src/composables/useConversations.ts +42 -0
  167. package/dist/templates/vue/src/composables/useVoiceInput.ts +57 -0
  168. package/dist/templates/vue/src/index.css +111 -0
  169. package/dist/templates/vue/src/main.ts +18 -0
  170. package/dist/templates/vue/src/types.ts +4 -0
  171. package/dist/templates/vue/src/vite-env.d.ts +1 -0
  172. package/dist/templates/vue/tsconfig.app.json +29 -0
  173. package/dist/templates/vue/tsconfig.json +7 -0
  174. package/dist/templates/vue/tsconfig.node.json +25 -0
  175. package/dist/templates/vue/vite.config.ts +24 -0
  176. package/package.json +63 -0
@@ -0,0 +1,222 @@
1
+ import type { ChatMessage } from '@kitn.ai/ui';
2
+ import { partsToText } from '@kitn.ai/ui/state';
3
+ import type { AppState, Theme } from './state';
4
+ import { SUGGESTIONS, TRIGGERS, type Conversation } from './chat-data';
5
+ import { createVoiceInput } from './voice-input';
6
+
7
+ /**
8
+ * The whole view: builds the hand-composed `kai-*` workspace imperatively, wires
9
+ * every element's CustomEvents, and returns a `render(state)` that syncs app state
10
+ * onto element properties. There is no framework here, so this file owns the two
11
+ * cross-framework gotchas directly:
12
+ *
13
+ * 1. UPGRADE RACE. main.ts `await customElements.whenDefined(...)` BEFORE calling
14
+ * createView, so every element created below is already upgraded and the
15
+ * array/object PROPERTIES we set (messages, conversations, triggers) land.
16
+ * 2. BOOLEAN FLAGS as PROPERTIES. `promptInput.voice = true`, not a bare `voice`
17
+ * attribute (the facade's flag() reads a value-less attribute as false).
18
+ *
19
+ * The composed split is <kai-resizable>/<kai-resizable-item> (sidebar | main), the
20
+ * sidebar is <kai-conversations>, the messages are a <kai-thread>, and the composer
21
+ * is a <kai-prompt-input>. Scalars (theme, placeholder, active-id) go on as
22
+ * attributes; arrays/objects go on as properties.
23
+ */
24
+
25
+ // Custom-element property shapes we set from render (typed so we avoid `any`).
26
+ type ConversationsEl = HTMLElement & { groups?: unknown[]; conversations?: Conversation[]; collapsed?: boolean };
27
+ type ThreadEl = HTMLElement & { messages?: ChatMessage[] };
28
+ type PromptEl = HTMLElement & {
29
+ value?: unknown;
30
+ clear?: () => void;
31
+ loading?: boolean;
32
+ suggestions?: string[];
33
+ triggers?: unknown[];
34
+ voice?: boolean;
35
+ };
36
+ type ItemEl = HTMLElement & { collapsed?: boolean };
37
+
38
+ export interface ViewCallbacks {
39
+ onConversationSelect(id: string): void;
40
+ onNewChat(): void;
41
+ onToggleSidebar(): void;
42
+ onShowSidebar(): void;
43
+ onToggleTheme(): void;
44
+ onSubmit(value: string): void;
45
+ onSuggestion(value: string): void;
46
+ }
47
+
48
+ export interface View {
49
+ render(state: AppState): void;
50
+ }
51
+
52
+ /** Tiny hyperscript: create an element, set string attributes, append children. */
53
+ function el(tag: string, attrs: Record<string, string> = {}, children: (Node | string)[] = []): HTMLElement {
54
+ const node = document.createElement(tag);
55
+ for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
56
+ node.append(...children);
57
+ return node;
58
+ }
59
+
60
+ /** Parse an inline SVG string to a live node (moon/sun aren't in the kit's icons). */
61
+ function svgIcon(markup: string): SVGElement {
62
+ const tpl = document.createElement('template');
63
+ tpl.innerHTML = markup.trim();
64
+ return tpl.content.firstElementChild as SVGElement;
65
+ }
66
+
67
+ const MOON_SVG =
68
+ '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" slot="icon" aria-hidden="true"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/></svg>';
69
+ const SUN_SVG =
70
+ '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" slot="icon" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/></svg>';
71
+
72
+ // Stable references so re-setting them on every render is a no-op (only a NEW
73
+ // array reference re-renders a facade). Streaming re-runs render each chunk.
74
+ const NO_SUGGESTIONS: string[] = [];
75
+
76
+ export function createView(root: HTMLElement, callbacks: ViewCallbacks): View {
77
+ // ── Build the DOM (elements are already upgraded — see gotcha 1) ────────────
78
+ const conversations = el('kai-conversations') as ConversationsEl;
79
+ const aside = el('aside', { class: 'sidebar' }, [conversations]);
80
+ const sidebarItem = el('kai-resizable-item', { size: '280px', min: '220px', max: '420px' }, [aside]) as ItemEl;
81
+
82
+ const showSidebarBtn = el('kai-button', { variant: 'ghost', size: 'icon', icon: 'panel-left', label: 'Show sidebar' });
83
+ const brand = el('span', { class: 'brand' });
84
+ brand.textContent = '@kitn.ai/ui · composed chat';
85
+ const themeToggleBtn = el('kai-button', { variant: 'ghost', size: 'icon', label: 'Toggle light/dark theme' });
86
+ const barLeft = el('div', { class: 'bar-left' }, [showSidebarBtn, brand]);
87
+ const bar = el('header', { class: 'bar' }, [barLeft, themeToggleBtn]);
88
+
89
+ const thread = el('kai-thread', { class: 'thread' }) as ThreadEl;
90
+
91
+ const promptInput = el('kai-prompt-input', { placeholder: 'Message the demo…' }) as PromptEl;
92
+ const hint = el('p', { class: 'composer-hint' });
93
+ hint.innerHTML = 'Type <kbd>/</kbd> for skills · <kbd>@</kbd> for agents';
94
+ const composer = el('div', { class: 'composer' }, [promptInput, hint]);
95
+
96
+ const main = el('main', { class: 'main' }, [bar, thread, composer]);
97
+ const mainItem = el('kai-resizable-item', {}, [main]) as ItemEl;
98
+
99
+ const resizable = el('kai-resizable', { orientation: 'horizontal' }, [sidebarItem, mainItem]);
100
+ const app = el('div', { class: 'app' }, [resizable]);
101
+ root.append(app);
102
+
103
+ // Static properties (never change) — set once. `groups` is always empty (we feed
104
+ // a flat `conversations`); the composer's triggers + voice flag are constant.
105
+ conversations.groups = [];
106
+ promptInput.triggers = TRIGGERS;
107
+ promptInput.voice = true; // gotcha 2: a truthy PROPERTY, not a bare attribute
108
+
109
+ // Persistent theme-toggle glyphs, swapped by theme (avoids re-parsing per render).
110
+ const moonIcon = svgIcon(MOON_SVG);
111
+ const sunIcon = svgIcon(SUN_SVG);
112
+
113
+ // ── Composer state (kept uncontrolled so the /+@ trigger menus keep a caret) ──
114
+ // liveText mirrors the input text (from kai-value-change) so voice can append to
115
+ // it. We NEVER assign a plain string `value` (that flips the element into
116
+ // controlled mode and breaks the caret-anchored menus); voice seeds a ComposerDoc
117
+ // and clear-on-submit goes through the element's clear() method.
118
+ let liveText = '';
119
+ const voice = createVoiceInput((transcript) => {
120
+ const next = (liveText ? liveText + ' ' : '') + transcript;
121
+ liveText = next;
122
+ promptInput.value = [{ type: 'text', text: next }]; // ComposerDoc seed, not a string
123
+ });
124
+
125
+ // ── Wire events (non-bubbling kai-* CustomEvents; kai-button uses native click) ──
126
+ conversations.addEventListener('kai-conversation-select', (e) =>
127
+ callbacks.onConversationSelect((e as CustomEvent<{ id: string }>).detail.id),
128
+ );
129
+ conversations.addEventListener('kai-new-chat', () => callbacks.onNewChat());
130
+ conversations.addEventListener('kai-toggle-sidebar', () => callbacks.onToggleSidebar());
131
+
132
+ showSidebarBtn.addEventListener('click', () => callbacks.onShowSidebar());
133
+ themeToggleBtn.addEventListener('click', () => callbacks.onToggleTheme());
134
+
135
+ thread.addEventListener('kai-message-action', (e) => {
136
+ const detail = (e as CustomEvent<{ messageId: string; action: string }>).detail;
137
+ if (detail.action !== 'speak') return;
138
+ // `copy` (and the feedback votes) are handled inside the element; the custom
139
+ // `speak` action is ours — read the message text aloud via speech synthesis.
140
+ const m = currentMessages.find((x) => x.id === detail.messageId);
141
+ if (!m) return;
142
+ window.speechSynthesis.cancel();
143
+ window.speechSynthesis.speak(new SpeechSynthesisUtterance(partsToText(m.parts)));
144
+ });
145
+
146
+ promptInput.addEventListener('kai-voice', () => {
147
+ if (!voice.supported) {
148
+ alert('Voice input needs a Chromium browser.');
149
+ return;
150
+ }
151
+ voice.start();
152
+ });
153
+ promptInput.addEventListener('kai-value-change', (e) => {
154
+ liveText = (e as CustomEvent<{ value: string }>).detail.value;
155
+ });
156
+ promptInput.addEventListener('kai-submit', (e) => {
157
+ // Reset the mirror + clear the uncontrolled input (uncontrolled-safe: clear()
158
+ // fires kai-value-change internally, never a string assignment), then hand the
159
+ // text up. main.ts owns append + streaming.
160
+ liveText = '';
161
+ promptInput.clear?.();
162
+ callbacks.onSubmit((e as CustomEvent<{ value: string }>).detail.value);
163
+ });
164
+ promptInput.addEventListener('kai-suggestion-click', (e) =>
165
+ callbacks.onSuggestion((e as CustomEvent<{ value: string }>).detail.value),
166
+ );
167
+
168
+ // ── Render: sync state onto element props ───────────────────────────────────
169
+ let currentMessages: ChatMessage[] = [];
170
+ let renderedTheme: Theme | null = null;
171
+
172
+ // Bake the per-message actions onto assistant turns, memoized on the messages
173
+ // reference so a theme-only render doesn't churn <kai-thread>. A NEW array from a
174
+ // streaming chunk produces a new baked array, which re-renders the thread.
175
+ let lastMessages: ChatMessage[] | null = null;
176
+ let lastBaked: ChatMessage[] = [];
177
+ function bakeActions(messages: ChatMessage[]): ChatMessage[] {
178
+ if (messages === lastMessages) return lastBaked;
179
+ lastMessages = messages;
180
+ lastBaked = messages.map((m): ChatMessage =>
181
+ m.role === 'assistant'
182
+ ? { ...m, actions: ['copy', { id: 'speak', label: 'Read aloud', icon: 'volume-2' }] }
183
+ : m,
184
+ );
185
+ return lastBaked;
186
+ }
187
+
188
+ function render(state: AppState): void {
189
+ const { theme } = state;
190
+ app.classList.toggle('dark', theme === 'dark');
191
+
192
+ // theme on the group AND every item so slotted chrome inherits the right tokens
193
+ for (const node of [resizable, sidebarItem, mainItem, conversations, thread, promptInput, showSidebarBtn, themeToggleBtn]) {
194
+ node.setAttribute('theme', theme);
195
+ }
196
+
197
+ // Sidebar collapse: the item's `collapsed` prop drops the panel + its divider,
198
+ // and the conversations rail's `collapsed` is CONTROLLED to stay in sync.
199
+ sidebarItem.collapsed = state.collapsed;
200
+ conversations.conversations = state.conversations;
201
+ conversations.setAttribute('active-id', state.activeId);
202
+ conversations.collapsed = state.collapsed;
203
+
204
+ // The reopen button only shows while collapsed (mirrors the React/Vue v-if).
205
+ showSidebarBtn.style.display = state.collapsed ? '' : 'none';
206
+
207
+ // Swap the toggle glyph only when the theme actually changes.
208
+ if (theme !== renderedTheme) {
209
+ themeToggleBtn.replaceChildren(theme === 'light' ? moonIcon : sunIcon);
210
+ renderedTheme = theme;
211
+ }
212
+
213
+ currentMessages = state.messages;
214
+ thread.messages = bakeActions(state.messages);
215
+
216
+ promptInput.loading = state.loading;
217
+ // Suggestions only in the near-empty state (mirrors the React/Vue computed).
218
+ promptInput.suggestions = state.messages.length <= 1 ? SUGGESTIONS : NO_SUGGESTIONS;
219
+ }
220
+
221
+ return { render };
222
+ }
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />
@@ -0,0 +1,55 @@
1
+ /** Minimal shape of a Web Speech recognition result event, enough to read the
2
+ * first transcript. The full SpeechRecognition API is not in TS's lib.dom yet. */
3
+ interface SpeechResultEvent {
4
+ results: ArrayLike<ArrayLike<{ transcript: string }>>;
5
+ }
6
+ interface SpeechRecognitionLike {
7
+ lang: string;
8
+ interimResults: boolean;
9
+ onresult: ((event: SpeechResultEvent) => void) | null;
10
+ start(): void;
11
+ }
12
+ type SpeechRecognitionCtor = new () => SpeechRecognitionLike;
13
+
14
+ /** Resolve the Chromium (`webkitSpeechRecognition`) or standard constructor, if any. */
15
+ function getSpeechRecognition(): SpeechRecognitionCtor | undefined {
16
+ if (typeof window === 'undefined') return undefined;
17
+ const w = window as unknown as {
18
+ SpeechRecognition?: SpeechRecognitionCtor;
19
+ webkitSpeechRecognition?: SpeechRecognitionCtor;
20
+ };
21
+ return w.SpeechRecognition ?? w.webkitSpeechRecognition;
22
+ }
23
+
24
+ export interface VoiceInput {
25
+ /** Chromium-only availability. False elsewhere so the caller can degrade. */
26
+ supported: boolean;
27
+ /** Begin one recognition pass (a no-op when unsupported). */
28
+ start: () => void;
29
+ }
30
+
31
+ /**
32
+ * Web Speech mic to one-shot transcript. Chromium-only (`webkitSpeechRecognition`);
33
+ * `supported` is false elsewhere so the caller can degrade gracefully. Framework
34
+ * ports (the React `useVoiceInput` hook, the Vue composable) share this exact
35
+ * logic. It hands the raw transcript to `onTranscript` and lets the caller decide
36
+ * what to do with it (the composer mic appends it to the live text). No composer
37
+ * or DOM knowledge.
38
+ *
39
+ * @param onTranscript called with the final recognized transcript.
40
+ */
41
+ export function createVoiceInput(onTranscript: (transcript: string) => void): VoiceInput {
42
+ const supported = getSpeechRecognition() !== undefined;
43
+
44
+ const start = () => {
45
+ const Recognition = getSpeechRecognition();
46
+ if (!Recognition) return;
47
+ const rec = new Recognition();
48
+ rec.lang = 'en-US';
49
+ rec.interimResults = false;
50
+ rec.onresult = (event) => onTranscript(event.results[0][0].transcript);
51
+ rec.start();
52
+ };
53
+
54
+ return { supported, start };
55
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+
9
+ /* Bundler mode */
10
+ "moduleResolution": "bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "isolatedModules": true,
13
+ "moduleDetection": "force",
14
+ "noEmit": true,
15
+
16
+ /* Linting */
17
+ "strict": true,
18
+ "noUnusedLocals": true,
19
+ "noUnusedParameters": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedSideEffectImports": true
22
+ },
23
+ "include": ["src"]
24
+ }
@@ -0,0 +1,14 @@
1
+ import { defineConfig } from 'vite';
2
+
3
+ // `@kitn.ai/ui` is linked into this example via `workspace:*`, so it resolves
4
+ // through node_modules + the package's `exports` map exactly like a published
5
+ // consumer would (no aliases, no source stubs). Build the kit first (`nx build ui`).
6
+ //
7
+ // No framework plugin here: the browser upgrades the `kai-*` custom elements
8
+ // natively, so unlike the React/Vue examples there is nothing for a template
9
+ // compiler to learn about the tags. This is the "pure web components, zero
10
+ // framework" showcase.
11
+ // https://vite.dev/config/
12
+ export default defineConfig({
13
+ server: { port: 5176 },
14
+ });
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ dist/
3
+ .env
4
+ .env.local
5
+ *.log
6
+ .DS_Store
@@ -0,0 +1,16 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>@kitn.ai/ui Vue example</title>
7
+ <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ html, body, #app { height: 100%; }
10
+ </style>
11
+ </head>
12
+ <body>
13
+ <div id="app"></div>
14
+ <script type="module" src="/src/main.ts"></script>
15
+ </body>
16
+ </html>
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@kitn.ai/ui-example-vue",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vue-tsc -b && vite build",
9
+ "preview": "vite preview",
10
+ "typecheck": "vue-tsc -b"
11
+ },
12
+ "dependencies": {
13
+ "@kitn.ai/ui": "workspace:*",
14
+ "vue": "^3.5.13"
15
+ },
16
+ "devDependencies": {
17
+ "@types/node": "^22.19.21",
18
+ "@vitejs/plugin-vue": "^5.2.1",
19
+ "typescript": "~5.7.2",
20
+ "vite": "^6.0.0",
21
+ "vue-tsc": "^2.2.0"
22
+ }
23
+ }
@@ -0,0 +1,104 @@
1
+ <script setup lang="ts">
2
+ import { computed, ref } from 'vue';
3
+ import { readOpenAIStream } from '@kitn.ai/ui/wire';
4
+ import { CONVERSATIONS, THREADS, SUGGESTIONS, TRIGGERS, newId, mockResponse } from './chat-data';
5
+ import type { Theme } from './types';
6
+ import { useChat, useConversations } from './composables';
7
+ import Sidebar from './components/Sidebar.vue';
8
+ import ThreadView from './components/ThreadView.vue';
9
+ import Composer from './components/Composer.vue';
10
+ import ThemeToggle from './components/ThemeToggle.vue';
11
+
12
+ /**
13
+ * A mini chat workspace COMPOSED BY HAND from @kitn.ai/ui's individual elements —
14
+ * the Vue mirror of `examples/react`. It shows how the raw `kai-*` web components
15
+ * fit together (vs. dropping in one batteries-included `<kai-chat>`/`<kai-workspace>`):
16
+ *
17
+ * <kai-resizable>/<kai-resizable-item> — the draggable sidebar | main split (the
18
+ * divider is the kit's default `line` hairline)
19
+ * <kai-conversations> — the sidebar list (fed `conversations`, emits select/new)
20
+ * <kai-thread> — the scrolling message list (stick-to-bottom built in)
21
+ * <kai-prompt-input> — the composer at the bottom
22
+ *
23
+ * The pieces are split into `components/` (the UI subcomponents + the example's own
24
+ * moon/sun icons) and `composables/` (`useChat` owns the message array + streaming,
25
+ * `useConversations` the conversation stash, `useVoiceInput` the mic). Everything
26
+ * else is plain Vue refs. Swap `mockResponse(text)` for a real `fetch` to ship.
27
+ */
28
+ const theme = ref<Theme>('dark');
29
+ const collapsed = ref(false);
30
+ const chat = useChat(THREADS[CONVERSATIONS[0].id] ?? []);
31
+ const { messages, loading } = chat;
32
+ const { conversations, activeId, selectConversation, newChat } = useConversations(chat, CONVERSATIONS);
33
+
34
+ const suggestions = computed(() => (messages.value.length <= 1 ? SUGGESTIONS : []));
35
+
36
+ async function send(raw: string) {
37
+ const text = raw.trim();
38
+ if (!text) return;
39
+ // The Composer already cleared its own input; here we just append the user
40
+ // message and stream the (mock) assistant reply.
41
+ chat.append({ id: newId(), role: 'user', parts: [{ type: 'text', text }] });
42
+ const stream = chat.streamAssistant();
43
+ // NO BACKEND AND NO PROVIDER. mockResponse() yields canned SSE frames that go
44
+ // through the SAME reader a real model's response would, so this preview
45
+ // exercises the real path. To go live, only this one expression changes —
46
+ // `mockResponse(text)` becomes a POST to your route, with
47
+ // toOpenAIMessages(messages.value) as the body. The line below stays.
48
+ await readOpenAIStream(mockResponse(text), stream);
49
+ stream.done();
50
+ }
51
+ </script>
52
+
53
+ <template>
54
+ <div class="app" :class="{ dark: theme === 'dark' }">
55
+ <!-- <kai-resizable> owns the sidebar width + the divider. The handle defaults to
56
+ the `line` hairline (transparent at rest, tinting on hover/drag); collapsing
57
+ the sidebar maps to <kai-resizable-item :collapsed>. Pass `theme` to the group
58
+ AND every item so slotted chrome inherits the right light/dark tokens. -->
59
+ <kai-resizable :theme="theme" orientation="horizontal">
60
+ <kai-resizable-item :theme="theme" size="280px" min="220px" max="420px" :collapsed.prop="collapsed">
61
+ <Sidebar
62
+ :theme="theme"
63
+ :conversations="conversations"
64
+ :active-id="activeId"
65
+ :collapsed="collapsed"
66
+ @select="selectConversation"
67
+ @new-chat="newChat"
68
+ @toggle="collapsed = !collapsed"
69
+ />
70
+ </kai-resizable-item>
71
+
72
+ <kai-resizable-item :theme="theme">
73
+ <main class="main">
74
+ <header class="bar">
75
+ <div class="bar-left">
76
+ <kai-button
77
+ v-if="collapsed"
78
+ :theme="theme"
79
+ variant="ghost"
80
+ size="icon"
81
+ icon="panel-left"
82
+ label="Show sidebar"
83
+ @click="collapsed = false"
84
+ ></kai-button>
85
+ <span class="brand">@kitn.ai/ui · composed chat</span>
86
+ </div>
87
+ <ThemeToggle :theme="theme" @toggle="theme = theme === 'light' ? 'dark' : 'light'" />
88
+ </header>
89
+
90
+ <ThreadView :theme="theme" :messages="messages" />
91
+
92
+ <Composer
93
+ :theme="theme"
94
+ :loading="loading"
95
+ :suggestions="suggestions"
96
+ :triggers="TRIGGERS"
97
+ @submit="send"
98
+ @suggestion="send"
99
+ />
100
+ </main>
101
+ </kai-resizable-item>
102
+ </kai-resizable>
103
+ </div>
104
+ </template>
@@ -0,0 +1,106 @@
1
+ // Self-contained sample data + the kit's shared mock responder, no backend.
2
+ import type { MessagePart } from '@kitn.ai/ui';
3
+ import { createMockResponder } from '@kitn.ai/ui/state';
4
+
5
+ export interface Conversation {
6
+ id: string;
7
+ title: string;
8
+ scope: { type: 'document' | 'collection' };
9
+ messageCount: number;
10
+ lastMessageAt: string;
11
+ updatedAt: string;
12
+ }
13
+ export interface Msg {
14
+ id: string;
15
+ role: 'user' | 'assistant';
16
+ parts: MessagePart[];
17
+ }
18
+
19
+ export function newId(): string {
20
+ return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
21
+ ? crypto.randomUUID()
22
+ : 'id-' + Math.random().toString(36).slice(2);
23
+ }
24
+
25
+ const iso = (daysAgo = 0) => new Date(Date.now() - daysAgo * 86_400_000).toISOString();
26
+
27
+ export const CONVERSATIONS: Conversation[] = [
28
+ { id: 'c1', title: 'Getting started', scope: { type: 'collection' }, messageCount: 2, lastMessageAt: iso(0), updatedAt: iso(0) },
29
+ { id: 'c2', title: 'Composing your own chat', scope: { type: 'collection' }, messageCount: 2, lastMessageAt: iso(0), updatedAt: iso(0) },
30
+ { id: 'c3', title: 'Theming + dark mode', scope: { type: 'collection' }, messageCount: 2, lastMessageAt: iso(1), updatedAt: iso(1) },
31
+ ];
32
+
33
+ export const THREADS: Record<string, Msg[]> = {
34
+ c1: [
35
+ { id: 'c1u', role: 'user', parts: [{ type: 'text', text: 'What is @kitn.ai/ui?' }] },
36
+ { id: 'c1a', role: 'assistant', parts: [{ type: 'text', text: '**@kitn.ai/ui** is a set of framework-agnostic web components for AI chat UIs: message threads, streaming, markdown, tool panels, and more. This demo composes its `<kai-conversations>`, `<kai-thread>`, and `<kai-prompt-input>` elements by hand.' }] },
37
+ ],
38
+ c2: [
39
+ { id: 'c2u', role: 'user', parts: [{ type: 'text', text: 'How do I build my own chat instead of dropping in `<kai-chat>`?' }] },
40
+ { id: 'c2a', role: 'assistant', parts: [{ type: 'text', text: "Feed your `messages` to a `<kai-thread>`, put a `<kai-prompt-input>` below, and wire `kai-submit` to append + stream. That's exactly what this example does. Read `App.vue`." }] },
41
+ ],
42
+ c3: [
43
+ { id: 'c3u', role: 'user', parts: [{ type: 'text', text: 'How does dark mode work?' }] },
44
+ { id: 'c3a', role: 'assistant', parts: [{ type: 'text', text: "Each element takes a `theme` prop (`light` / `dark` / `auto`). Drive it from app state. Toggle it with the button top-right. The kit's `--color-*` tokens flip under a `.dark` class for your own surrounding chrome." }] },
45
+ ],
46
+ };
47
+
48
+ export const SUGGESTIONS = ['What is @kitn.ai/ui?', 'How do I install it?', 'Show me some markdown'];
49
+
50
+ // Rich entity triggers for the prompt input: typing `/` opens the skills menu,
51
+ // `@` opens the agents menu. Each selection inserts an atomic pill. Set on
52
+ // <kai-prompt-input> as the `triggers` JS property (a TriggerDef[]).
53
+ export const TRIGGERS = [
54
+ {
55
+ char: '/',
56
+ kind: 'skill',
57
+ items: [
58
+ { id: 'summarize', label: 'Summarize', description: 'Summarize the thread', promptText: 'Summarize the thread.' },
59
+ { id: 'translate', label: 'Translate', description: 'Translate to English', promptText: 'Translate to English.' },
60
+ { id: 'rewrite', label: 'Rewrite', description: 'Rewrite for clarity', promptText: 'Rewrite this for clarity.' },
61
+ ],
62
+ },
63
+ {
64
+ char: '@',
65
+ kind: 'agent',
66
+ items: [
67
+ { id: 'researcher', label: 'Researcher', description: 'Deep web research', group: 'Agents' },
68
+ { id: 'coder', label: 'Coder', description: 'Writes and edits code', group: 'Agents' },
69
+ { id: 'designer', label: 'Designer', description: 'UI and visual design', group: 'Agents' },
70
+ ],
71
+ },
72
+ ];
73
+
74
+ // The replies the mock cycles through, one per turn. Markdown-rich on purpose:
75
+ // the thread renders markdown, code blocks and blockquotes, so the canned
76
+ // replies exercise that instead of only proving text arrives.
77
+ //
78
+ // The first one says it is a mock in words. That is the WEAKEST of the
79
+ // responder's tells and the only one a real model could imitate, which is
80
+ // exactly why it is not the only one — the rest are on the wire, below.
81
+ const MOCK_REPLIES = [
82
+ "This reply streams token-by-token from the kit's own mock responder. No API key, no backend, no provider was contacted. Replace `mockResponse(text)` with a real model call (Anthropic, OpenAI, your own endpoint) to ship a real app.",
83
+ "Install it:\n\n```bash\nnpm install @kitn.ai/ui\n```\n\nThen register the elements + load the tokens:\n\n```ts\nimport '@kitn.ai/ui/elements';\nimport '@kitn.ai/ui/theme.tokens.css';\n```\n\nNow drop `<kai-thread>` and `<kai-prompt-input>` into your template.",
84
+ "I render **bold**, *italic*, `inline code`, lists, and code blocks:\n\n```ts\nconst stream = chat.streamAssistant();\nawait readOpenAIStream(mockResponse(text), stream);\nstream.done();\n```\n\n> Blockquotes too.",
85
+ "This chat is **composed by hand**: `<kai-conversations>` for the sidebar, a `<kai-thread>` for the messages, and `<kai-prompt-input>` at the bottom, all wired with plain reactive state. Swap `mockResponse(text)` for your own model call and you're shipping.",
86
+ ];
87
+
88
+ /**
89
+ * The kit's own mock responder, shared with the `kai` MCP scaffolder and
90
+ * `create-kai` so there is ONE implementation of this and not seven.
91
+ *
92
+ * It yields canned SSE frames in the OpenAI chat-completions shape, which
93
+ * `readOpenAIStream` parses exactly as it parses a real provider's. So this
94
+ * no-backend preview runs the kit's REAL streaming path — the SSE reader, the
95
+ * part folding, all of it — rather than a hand-rolled loop that merely
96
+ * resembles it. Going live then changes one expression, not the handler.
97
+ *
98
+ * It also cannot be mistaken for a real turn: the stream opens with a
99
+ * `: kai-mock` SSE comment, every frame carries a `_kai_mock` field, `model`
100
+ * reports as `kai-mock` (no provider serves that, so an echoed mock frame is
101
+ * rejected upstream rather than quietly believed), and usage is all zeros.
102
+ *
103
+ * MODULE scope, not per-send: the responder owns the cursor into the replies
104
+ * above, so rebuilding it each turn would answer with the first one forever.
105
+ */
106
+ export const mockResponse = createMockResponder({ replies: MOCK_REPLIES });