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.
- package/README.md +311 -0
- package/dist/index.js +4433 -0
- package/dist/templates/angular/_gitignore +7 -0
- package/dist/templates/angular/angular.json +53 -0
- package/dist/templates/angular/package.json +30 -0
- package/dist/templates/angular/src/app/app.config.ts +11 -0
- package/dist/templates/angular/src/app/app.css +36 -0
- package/dist/templates/angular/src/app/app.html +51 -0
- package/dist/templates/angular/src/app/app.ts +78 -0
- package/dist/templates/angular/src/app/components/composer/composer.css +31 -0
- package/dist/templates/angular/src/app/components/composer/composer.html +18 -0
- package/dist/templates/angular/src/app/components/composer/composer.ts +81 -0
- package/dist/templates/angular/src/app/components/icons/moon-icon/moon-icon.css +3 -0
- package/dist/templates/angular/src/app/components/icons/moon-icon/moon-icon.html +12 -0
- package/dist/templates/angular/src/app/components/icons/moon-icon/moon-icon.ts +15 -0
- package/dist/templates/angular/src/app/components/icons/sun-icon/sun-icon.css +3 -0
- package/dist/templates/angular/src/app/components/icons/sun-icon/sun-icon.html +14 -0
- package/dist/templates/angular/src/app/components/icons/sun-icon/sun-icon.ts +13 -0
- package/dist/templates/angular/src/app/components/sidebar/sidebar.css +24 -0
- package/dist/templates/angular/src/app/components/sidebar/sidebar.html +12 -0
- package/dist/templates/angular/src/app/components/sidebar/sidebar.ts +43 -0
- package/dist/templates/angular/src/app/components/theme-toggle/theme-toggle.html +13 -0
- package/dist/templates/angular/src/app/components/theme-toggle/theme-toggle.ts +21 -0
- package/dist/templates/angular/src/app/components/thread-view/thread-view.css +6 -0
- package/dist/templates/angular/src/app/components/thread-view/thread-view.html +6 -0
- package/dist/templates/angular/src/app/components/thread-view/thread-view.ts +45 -0
- package/dist/templates/angular/src/app/state/chat.store.ts +47 -0
- package/dist/templates/angular/src/app/state/conversations.store.ts +42 -0
- package/dist/templates/angular/src/app/state/voice-input.ts +57 -0
- package/dist/templates/angular/src/app/types.ts +2 -0
- package/dist/templates/angular/src/chat-data.ts +106 -0
- package/dist/templates/angular/src/index.html +16 -0
- package/dist/templates/angular/src/main.ts +16 -0
- package/dist/templates/angular/src/styles.css +10 -0
- package/dist/templates/angular/tsconfig.app.json +9 -0
- package/dist/templates/angular/tsconfig.json +22 -0
- package/dist/templates/nextjs/_gitignore +11 -0
- package/dist/templates/nextjs/app/chat-data.ts +125 -0
- package/dist/templates/nextjs/app/components/Composer.tsx +88 -0
- package/dist/templates/nextjs/app/components/HydrationBadge.tsx +68 -0
- package/dist/templates/nextjs/app/components/Sidebar.tsx +48 -0
- package/dist/templates/nextjs/app/components/ThemeToggle.tsx +27 -0
- package/dist/templates/nextjs/app/components/ThreadView.tsx +48 -0
- package/dist/templates/nextjs/app/components/icons/MoonIcon.tsx +25 -0
- package/dist/templates/nextjs/app/components/icons/SunIcon.tsx +25 -0
- package/dist/templates/nextjs/app/components/icons/index.ts +2 -0
- package/dist/templates/nextjs/app/globals.css +153 -0
- package/dist/templates/nextjs/app/hooks/index.ts +1 -0
- package/dist/templates/nextjs/app/hooks/useConversations.ts +42 -0
- package/dist/templates/nextjs/app/layout.tsx +50 -0
- package/dist/templates/nextjs/app/page.tsx +17 -0
- package/dist/templates/nextjs/app/theme.ts +10 -0
- package/dist/templates/nextjs/app/workspace.tsx +148 -0
- package/dist/templates/nextjs/next.config.mjs +22 -0
- package/dist/templates/nextjs/package.json +23 -0
- package/dist/templates/nextjs/postcss.config.mjs +16 -0
- package/dist/templates/nextjs/tsconfig.json +23 -0
- package/dist/templates/react/_gitignore +6 -0
- package/dist/templates/react/index.html +16 -0
- package/dist/templates/react/package.json +25 -0
- package/dist/templates/react/src/App.tsx +108 -0
- package/dist/templates/react/src/chat-data.ts +106 -0
- package/dist/templates/react/src/components/Composer.tsx +87 -0
- package/dist/templates/react/src/components/Sidebar.tsx +38 -0
- package/dist/templates/react/src/components/ThemeToggle.tsx +22 -0
- package/dist/templates/react/src/components/ThreadView.tsx +40 -0
- package/dist/templates/react/src/components/icons/MoonIcon.tsx +25 -0
- package/dist/templates/react/src/components/icons/SunIcon.tsx +25 -0
- package/dist/templates/react/src/components/icons/index.ts +2 -0
- package/dist/templates/react/src/hooks/index.ts +1 -0
- package/dist/templates/react/src/hooks/useConversations.ts +37 -0
- package/dist/templates/react/src/index.css +111 -0
- package/dist/templates/react/src/main.tsx +15 -0
- package/dist/templates/react/src/vite-env.d.ts +1 -0
- package/dist/templates/react/tsconfig.app.json +28 -0
- package/dist/templates/react/tsconfig.json +7 -0
- package/dist/templates/react/tsconfig.node.json +25 -0
- package/dist/templates/react/vite.config.ts +11 -0
- package/dist/templates/solid/_gitignore +6 -0
- package/dist/templates/solid/index.html +13 -0
- package/dist/templates/solid/package.json +23 -0
- package/dist/templates/solid/src/App.tsx +151 -0
- package/dist/templates/solid/src/chat-data.ts +234 -0
- package/dist/templates/solid/src/components/Composer.tsx +90 -0
- package/dist/templates/solid/src/components/Sidebar.tsx +36 -0
- package/dist/templates/solid/src/components/ThemeToggle.tsx +25 -0
- package/dist/templates/solid/src/components/ThreadView.tsx +122 -0
- package/dist/templates/solid/src/components/icons/index.tsx +84 -0
- package/dist/templates/solid/src/index.tsx +5 -0
- package/dist/templates/solid/src/lib/chat.ts +64 -0
- package/dist/templates/solid/src/lib/conversations.ts +54 -0
- package/dist/templates/solid/src/lib/types.ts +2 -0
- package/dist/templates/solid/src/styles.css +11 -0
- package/dist/templates/solid/tsconfig.json +18 -0
- package/dist/templates/solid/vite.config.ts +10 -0
- package/dist/templates/svelte/_gitignore +6 -0
- package/dist/templates/svelte/index.html +16 -0
- package/dist/templates/svelte/package.json +23 -0
- package/dist/templates/svelte/src/App.svelte +116 -0
- package/dist/templates/svelte/src/app.d.ts +20 -0
- package/dist/templates/svelte/src/chat-data.ts +106 -0
- package/dist/templates/svelte/src/components/Composer.svelte +102 -0
- package/dist/templates/svelte/src/components/Sidebar.svelte +61 -0
- package/dist/templates/svelte/src/components/ThemeToggle.svelte +45 -0
- package/dist/templates/svelte/src/components/ThreadView.svelte +49 -0
- package/dist/templates/svelte/src/components/icons/MoonIcon.svelte +24 -0
- package/dist/templates/svelte/src/components/icons/SunIcon.svelte +24 -0
- package/dist/templates/svelte/src/index.css +111 -0
- package/dist/templates/svelte/src/lib/chat.svelte.ts +52 -0
- package/dist/templates/svelte/src/lib/conversations.svelte.ts +46 -0
- package/dist/templates/svelte/src/lib/types.ts +2 -0
- package/dist/templates/svelte/src/lib/voiceInput.ts +57 -0
- package/dist/templates/svelte/src/main.ts +18 -0
- package/dist/templates/svelte/svelte.config.js +8 -0
- package/dist/templates/svelte/tsconfig.app.json +28 -0
- package/dist/templates/svelte/tsconfig.json +7 -0
- package/dist/templates/svelte/tsconfig.node.json +25 -0
- package/dist/templates/svelte/vite.config.ts +15 -0
- package/dist/templates/tanstack-start/_gitignore +14 -0
- package/dist/templates/tanstack-start/package.json +28 -0
- package/dist/templates/tanstack-start/serve.mjs +129 -0
- package/dist/templates/tanstack-start/serve.traversal.test.mjs +445 -0
- package/dist/templates/tanstack-start/src/chat-data.ts +124 -0
- package/dist/templates/tanstack-start/src/components/Composer.tsx +87 -0
- package/dist/templates/tanstack-start/src/components/HydrationBadge.tsx +63 -0
- package/dist/templates/tanstack-start/src/components/Sidebar.tsx +43 -0
- package/dist/templates/tanstack-start/src/components/ThemeToggle.tsx +26 -0
- package/dist/templates/tanstack-start/src/components/ThreadView.tsx +48 -0
- package/dist/templates/tanstack-start/src/components/icons/MoonIcon.tsx +25 -0
- package/dist/templates/tanstack-start/src/components/icons/SunIcon.tsx +25 -0
- package/dist/templates/tanstack-start/src/components/icons/index.ts +2 -0
- package/dist/templates/tanstack-start/src/hooks/index.ts +1 -0
- package/dist/templates/tanstack-start/src/hooks/useConversations.ts +42 -0
- package/dist/templates/tanstack-start/src/router.tsx +16 -0
- package/dist/templates/tanstack-start/src/routes/__root.tsx +62 -0
- package/dist/templates/tanstack-start/src/routes/index.tsx +135 -0
- package/dist/templates/tanstack-start/src/styles.css +153 -0
- package/dist/templates/tanstack-start/src/theme.ts +10 -0
- package/dist/templates/tanstack-start/tsconfig.json +16 -0
- package/dist/templates/tanstack-start/vite.config.ts +28 -0
- package/dist/templates/vanilla/_gitignore +6 -0
- package/dist/templates/vanilla/index.html +16 -0
- package/dist/templates/vanilla/package.json +19 -0
- package/dist/templates/vanilla/src/chat-data.ts +106 -0
- package/dist/templates/vanilla/src/index.css +111 -0
- package/dist/templates/vanilla/src/main.ts +53 -0
- package/dist/templates/vanilla/src/state.ts +119 -0
- package/dist/templates/vanilla/src/view.ts +222 -0
- package/dist/templates/vanilla/src/vite-env.d.ts +1 -0
- package/dist/templates/vanilla/src/voice-input.ts +55 -0
- package/dist/templates/vanilla/tsconfig.json +24 -0
- package/dist/templates/vanilla/vite.config.ts +14 -0
- package/dist/templates/vue/_gitignore +6 -0
- package/dist/templates/vue/index.html +16 -0
- package/dist/templates/vue/package.json +23 -0
- package/dist/templates/vue/src/App.vue +104 -0
- package/dist/templates/vue/src/chat-data.ts +106 -0
- package/dist/templates/vue/src/components/Composer.vue +96 -0
- package/dist/templates/vue/src/components/Sidebar.vue +48 -0
- package/dist/templates/vue/src/components/ThemeToggle.vue +27 -0
- package/dist/templates/vue/src/components/ThreadView.vue +50 -0
- package/dist/templates/vue/src/components/icons/MoonIcon.vue +24 -0
- package/dist/templates/vue/src/components/icons/SunIcon.vue +24 -0
- package/dist/templates/vue/src/composables/index.ts +3 -0
- package/dist/templates/vue/src/composables/useChat.ts +47 -0
- package/dist/templates/vue/src/composables/useConversations.ts +42 -0
- package/dist/templates/vue/src/composables/useVoiceInput.ts +57 -0
- package/dist/templates/vue/src/index.css +111 -0
- package/dist/templates/vue/src/main.ts +18 -0
- package/dist/templates/vue/src/types.ts +4 -0
- package/dist/templates/vue/src/vite-env.d.ts +1 -0
- package/dist/templates/vue/tsconfig.app.json +29 -0
- package/dist/templates/vue/tsconfig.json +7 -0
- package/dist/templates/vue/tsconfig.node.json +25 -0
- package/dist/templates/vue/vite.config.ts +24 -0
- package/package.json +63 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { signal, type WritableSignal } from '@angular/core';
|
|
2
|
+
import type { ChatMessage } from '@kitn.ai/ui';
|
|
3
|
+
import {
|
|
4
|
+
appendMessage,
|
|
5
|
+
createAssistantStream,
|
|
6
|
+
onStreamSettled,
|
|
7
|
+
type SetMessages,
|
|
8
|
+
type AssistantStream,
|
|
9
|
+
} from '@kitn.ai/ui/state';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Angular port of the kit's `useKaiChat` (a React hook), built on the SAME
|
|
13
|
+
* framework-neutral state core (`@kitn.ai/ui/state`) the React hook uses. It owns
|
|
14
|
+
* the message array + the `loading` flag and exposes ergonomic ops. Swap
|
|
15
|
+
* `mockResponse(text)` for a real `fetch` to ship a real app.
|
|
16
|
+
*
|
|
17
|
+
* `messages` is a plain Angular signal: every op assigns a BRAND-NEW array
|
|
18
|
+
* reference (`messages.set(updater(prev))`), which is exactly what re-renders
|
|
19
|
+
* `<kai-thread>` when the template re-binds `[messages]` — mutating the array in
|
|
20
|
+
* place does not. Signals also keep the array plain (no proxy), so what reaches the
|
|
21
|
+
* element's `messages` property is a clean `ChatMessage[]`.
|
|
22
|
+
*/
|
|
23
|
+
export interface ChatStore {
|
|
24
|
+
messages: WritableSignal<ChatMessage[]>;
|
|
25
|
+
loading: WritableSignal<boolean>;
|
|
26
|
+
append: (msg: ChatMessage) => void;
|
|
27
|
+
setMessages: (next: ChatMessage[]) => void;
|
|
28
|
+
streamAssistant: (init?: Partial<ChatMessage>) => AssistantStream;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createChat(initialMessages: ChatMessage[] = []): ChatStore {
|
|
32
|
+
const messages = signal<ChatMessage[]>([...initialMessages]);
|
|
33
|
+
const loading = signal(false);
|
|
34
|
+
|
|
35
|
+
// The one universal contract the state core drives: a functional-updater setter
|
|
36
|
+
// (React setState shape) that assigns a fresh array each time.
|
|
37
|
+
const set: SetMessages = (updater) => messages.set(updater(messages()));
|
|
38
|
+
|
|
39
|
+
const append = (msg: ChatMessage) => set((prev) => appendMessage(prev, msg));
|
|
40
|
+
const setMessages = (next: ChatMessage[]) => messages.set([...next]);
|
|
41
|
+
const streamAssistant = (init?: Partial<ChatMessage>): AssistantStream => {
|
|
42
|
+
loading.set(true);
|
|
43
|
+
return onStreamSettled(createAssistantStream(set, init), () => loading.set(false));
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return { messages, loading, append, setMessages, streamAssistant };
|
|
47
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { signal, type WritableSignal } from '@angular/core';
|
|
2
|
+
import { THREADS, newId, type Conversation } from '../../chat-data';
|
|
3
|
+
import type { ChatStore } from './chat.store';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Owns the conversation list + which one is active, plus the in-memory `THREADS`
|
|
7
|
+
* stash: on select/new we swap the OPEN thread out to `THREADS` and swap the picked
|
|
8
|
+
* one in. Takes the `createChat` store so it can read the live messages when
|
|
9
|
+
* stashing and reset them when loading another thread. Mirrors the Vue/React
|
|
10
|
+
* `useConversations`.
|
|
11
|
+
*/
|
|
12
|
+
export interface ConversationsStore {
|
|
13
|
+
conversations: WritableSignal<Conversation[]>;
|
|
14
|
+
activeId: WritableSignal<string>;
|
|
15
|
+
selectConversation: (id: string) => void;
|
|
16
|
+
newChat: () => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createConversations(chat: ChatStore, initial: Conversation[]): ConversationsStore {
|
|
20
|
+
const conversations = signal<Conversation[]>([...initial]);
|
|
21
|
+
const activeId = signal(initial[0].id);
|
|
22
|
+
|
|
23
|
+
const selectConversation = (id: string) => {
|
|
24
|
+
THREADS[activeId()] = chat.messages(); // stash the open thread
|
|
25
|
+
activeId.set(id);
|
|
26
|
+
chat.setMessages(THREADS[id] ?? []); // load the picked one
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const newChat = () => {
|
|
30
|
+
const id = newId();
|
|
31
|
+
THREADS[activeId()] = chat.messages();
|
|
32
|
+
THREADS[id] = [];
|
|
33
|
+
conversations.set([
|
|
34
|
+
{ id, title: 'New conversation', scope: { type: 'collection' }, messageCount: 0, lastMessageAt: new Date().toISOString(), updatedAt: new Date().toISOString() },
|
|
35
|
+
...conversations(),
|
|
36
|
+
]);
|
|
37
|
+
activeId.set(id);
|
|
38
|
+
chat.setMessages([]);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return { conversations, activeId, selectConversation, newChat };
|
|
42
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/** Minimal shape of a Web Speech recognition result event — enough to read the
|
|
2
|
+
* first transcript. The full SpeechRecognition API isn't 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
|
+
/**
|
|
25
|
+
* Web Speech mic → one-shot transcript. Chromium-only (`webkitSpeechRecognition`);
|
|
26
|
+
* `supported` is false elsewhere so the caller can degrade gracefully. Vue port of
|
|
27
|
+
* the kit's `useVoiceInput` (a React hook). It hands the raw transcript to
|
|
28
|
+
* `onTranscript` and lets the caller decide what to do with it (the composer mic
|
|
29
|
+
* appends it to the live text). No composer or DOM knowledge.
|
|
30
|
+
*
|
|
31
|
+
* @param onTranscript called with the final recognized transcript.
|
|
32
|
+
* @returns `supported` (Chromium-only availability) and a `start` that begins one
|
|
33
|
+
* recognition pass (a no-op when unsupported).
|
|
34
|
+
*/
|
|
35
|
+
export function useVoiceInput(onTranscript: (transcript: string) => void): {
|
|
36
|
+
supported: boolean;
|
|
37
|
+
start: () => void;
|
|
38
|
+
} {
|
|
39
|
+
// Latest-callback ref: keeps `start` stable while always calling the current
|
|
40
|
+
// `onTranscript`, mirroring the React hook (no stale closure).
|
|
41
|
+
const cbRef = { current: onTranscript };
|
|
42
|
+
cbRef.current = onTranscript;
|
|
43
|
+
|
|
44
|
+
const supported = getSpeechRecognition() !== undefined;
|
|
45
|
+
|
|
46
|
+
const start = () => {
|
|
47
|
+
const Recognition = getSpeechRecognition();
|
|
48
|
+
if (!Recognition) return;
|
|
49
|
+
const rec = new Recognition();
|
|
50
|
+
rec.lang = 'en-US';
|
|
51
|
+
rec.interimResults = false;
|
|
52
|
+
rec.onresult = (event) => cbRef.current(event.results[0][0].transcript);
|
|
53
|
+
rec.start();
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return { supported, start };
|
|
57
|
+
}
|
|
@@ -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.ts`." }] },
|
|
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 = this.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 });
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<title>@kitn.ai/ui Angular example</title>
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
9
|
+
html, body, app-root { height: 100%; }
|
|
10
|
+
app-root { display: block; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<app-root></app-root>
|
|
15
|
+
</body>
|
|
16
|
+
</html>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import '@kitn.ai/ui/elements'; // registers the kai-* custom elements (async)
|
|
2
|
+
import { bootstrapApplication } from '@angular/platform-browser';
|
|
3
|
+
import { App } from './app/app';
|
|
4
|
+
import { appConfig } from './app/app.config';
|
|
5
|
+
|
|
6
|
+
// The kai-* elements register ASYNCHRONOUSLY. Angular sets array/object DOM
|
|
7
|
+
// properties ([conversations], [messages], [triggers], …) the moment it stamps a
|
|
8
|
+
// <kai-*> tag; if that happens before the element upgrades, the property write hits
|
|
9
|
+
// a plain, not-yet-upgraded node and is clobbered by the element's empty defaults on
|
|
10
|
+
// upgrade. Raw web-component consumers have no upgrade-race guard (unlike the React
|
|
11
|
+
// wrappers), so we gate bootstrap on `whenDefined` for every tag we use — Angular
|
|
12
|
+
// then binds against UPGRADED elements. Attributes survive upgrade; properties do not.
|
|
13
|
+
const TAGS = ['kai-resizable', 'kai-resizable-item', 'kai-conversations', 'kai-thread', 'kai-prompt-input', 'kai-button'];
|
|
14
|
+
Promise.all(TAGS.map((t) => customElements.whenDefined(t)))
|
|
15
|
+
.then(() => bootstrapApplication(App, appConfig))
|
|
16
|
+
.catch((err) => console.error(err));
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compileOnSave": false,
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"strict": true,
|
|
5
|
+
"noImplicitOverride": true,
|
|
6
|
+
"noImplicitReturns": true,
|
|
7
|
+
"noFallthroughCasesInSwitch": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"isolatedModules": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"experimentalDecorators": true,
|
|
12
|
+
"importHelpers": true,
|
|
13
|
+
"target": "ES2022",
|
|
14
|
+
"module": "preserve"
|
|
15
|
+
},
|
|
16
|
+
"angularCompilerOptions": {
|
|
17
|
+
"enableI18nLegacyMessageIdFormat": false,
|
|
18
|
+
"strictInjectionParameters": true,
|
|
19
|
+
"strictInputAccessModifiers": true,
|
|
20
|
+
"strictTemplates": true
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Self-contained sample data + the kit's shared mock responder — no backend.
|
|
2
|
+
//
|
|
3
|
+
// THIS MODULE IS EVALUATED TWICE: once on the server while the page is being
|
|
4
|
+
// prerendered, and once in the browser. Everything in it is therefore plain data
|
|
5
|
+
// and pure functions — nothing here touches `window`, `document` or
|
|
6
|
+
// `customElements`, which is the rule for any module a Server Component's tree
|
|
7
|
+
// reaches, client island or not.
|
|
8
|
+
//
|
|
9
|
+
// The date helper below is the one thing that produces a DIFFERENT value on each
|
|
10
|
+
// of those two evaluations, and it is safe for a specific reason worth knowing:
|
|
11
|
+
// `CONVERSATIONS` is only ever handed to `<kai-conversations>` as a JS PROPERTY,
|
|
12
|
+
// and the React wrappers assign properties in a layout effect, which never runs
|
|
13
|
+
// on the server. So no timestamp reaches the server-rendered HTML and there is
|
|
14
|
+
// nothing for hydration to compare. Render one of these into JSX and that stops
|
|
15
|
+
// being true — that is the shape of a real hydration mismatch.
|
|
16
|
+
import type { MessagePart } from '@kitn.ai/ui';
|
|
17
|
+
import { createMockResponder } from '@kitn.ai/ui/state';
|
|
18
|
+
|
|
19
|
+
export interface Conversation {
|
|
20
|
+
id: string;
|
|
21
|
+
title: string;
|
|
22
|
+
scope: { type: 'document' | 'collection' };
|
|
23
|
+
messageCount: number;
|
|
24
|
+
lastMessageAt: string;
|
|
25
|
+
updatedAt: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface Msg {
|
|
29
|
+
id: string;
|
|
30
|
+
role: 'user' | 'assistant';
|
|
31
|
+
parts: MessagePart[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function newId(): string {
|
|
35
|
+
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
36
|
+
? crypto.randomUUID()
|
|
37
|
+
: 'id-' + Math.random().toString(36).slice(2);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const iso = (daysAgo = 0) => new Date(Date.now() - daysAgo * 86_400_000).toISOString();
|
|
41
|
+
|
|
42
|
+
export const CONVERSATIONS: Conversation[] = [
|
|
43
|
+
{ id: 'c1', title: 'Server components', scope: { type: 'collection' }, messageCount: 2, lastMessageAt: iso(0), updatedAt: iso(0) },
|
|
44
|
+
{ id: 'c2', title: 'Composing your own chat', scope: { type: 'collection' }, messageCount: 2, lastMessageAt: iso(0), updatedAt: iso(0) },
|
|
45
|
+
{ id: 'c3', title: 'Theming + dark mode', scope: { type: 'collection' }, messageCount: 2, lastMessageAt: iso(1), updatedAt: iso(1) },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export const THREADS: Record<string, Msg[]> = {
|
|
49
|
+
c1: [
|
|
50
|
+
{ id: 'c1u', role: 'user', parts: [{ type: 'text', text: 'Where does the "use client" directive go, and how do the kai-* elements survive the App Router?' }] },
|
|
51
|
+
{ id: 'c1a', role: 'assistant', parts: [{ type: 'text', text: "`app/layout.tsx` and `app/page.tsx` are **Server Components** — neither carries a directive. `app/workspace.tsx` does, because IT uses hooks and event handlers. That is the standard RSC rule, not a kit requirement: the React wrappers ship their own `'use client'` banner, so a Server Component can render one directly.\n\nThe elements themselves server-render as **bare tags**. Each wrapper registers its element inside a layout effect, which never runs on the server, so the prerendered HTML contains `<kai-thread></kai-thread>` and nothing inside it. After hydration the wrapper defines the element and assigns `messages` as a live JS **property** — arrays go across unstringified, so nothing has to be serialized into an attribute.\n\nView source on this page and you will find the empty tags. Then watch this reply stream in: that is the same property channel still working, one new array reference per chunk." }] },
|
|
52
|
+
],
|
|
53
|
+
c2: [
|
|
54
|
+
{ id: 'c2u', role: 'user', parts: [{ type: 'text', text: 'How do I build my own chat instead of dropping in `<kai-chat>`?' }] },
|
|
55
|
+
{ id: 'c2a', role: 'assistant', parts: [{ type: 'text', text: "Put a `<Thread>` in a scroll area, a `<PromptInput>` below it, and let `useKaiChat` own the message array and the streaming. That's exactly what this app does — read `app/workspace.tsx`." }] },
|
|
56
|
+
],
|
|
57
|
+
c3: [
|
|
58
|
+
{ id: 'c3u', role: 'user', parts: [{ type: 'text', text: 'How does dark mode work?' }] },
|
|
59
|
+
{ id: 'c3a', role: 'assistant', parts: [{ type: 'text', text: "Each element takes a `theme` prop (`light` / `dark` / `auto`). Drive it from client state — toggle it with the button top-right. The kit's `--color-*` tokens flip under a `.dark` class for your own surrounding chrome." }] },
|
|
60
|
+
],
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const SUGGESTIONS = [
|
|
64
|
+
'Where does "use client" go?',
|
|
65
|
+
'How do I install it?',
|
|
66
|
+
'Show me some markdown',
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
// Rich entity triggers for the prompt input: typing `/` opens the skills menu,
|
|
70
|
+
// `@` opens the agents menu. Each selection inserts an atomic pill. Set on
|
|
71
|
+
// <PromptInput> as the `triggers` JS property (a TriggerDef[]).
|
|
72
|
+
export const TRIGGERS = [
|
|
73
|
+
{
|
|
74
|
+
char: '/',
|
|
75
|
+
kind: 'skill',
|
|
76
|
+
items: [
|
|
77
|
+
{ id: 'summarize', label: 'Summarize', description: 'Summarize the thread', promptText: 'Summarize the thread.' },
|
|
78
|
+
{ id: 'translate', label: 'Translate', description: 'Translate to English', promptText: 'Translate to English.' },
|
|
79
|
+
{ id: 'rewrite', label: 'Rewrite', description: 'Rewrite for clarity', promptText: 'Rewrite this for clarity.' },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
char: '@',
|
|
84
|
+
kind: 'agent',
|
|
85
|
+
items: [
|
|
86
|
+
{ id: 'researcher', label: 'Researcher', description: 'Deep web research', group: 'Agents' },
|
|
87
|
+
{ id: 'coder', label: 'Coder', description: 'Writes and edits code', group: 'Agents' },
|
|
88
|
+
{ id: 'designer', label: 'Designer', description: 'UI and visual design', group: 'Agents' },
|
|
89
|
+
],
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
// The replies the mock cycles through, one per turn. Markdown-rich on purpose:
|
|
94
|
+
// the thread renders markdown, code blocks and blockquotes, so the canned replies
|
|
95
|
+
// exercise that instead of only proving text arrives.
|
|
96
|
+
//
|
|
97
|
+
// The first one says it is a mock in words. That is the WEAKEST of the responder's
|
|
98
|
+
// tells and the only one a real model could imitate, which is exactly why it is
|
|
99
|
+
// not the only one — the rest are on the wire, below.
|
|
100
|
+
const MOCK_REPLIES = [
|
|
101
|
+
"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 route handler) to ship a real app.",
|
|
102
|
+
"Install it:\n\n```bash\nnpm install @kitn.ai/ui\n```\n\nThen import the wrappers + tokens:\n\n```tsx\nimport { Thread, PromptInput } from '@kitn.ai/ui/react';\nimport '@kitn.ai/ui/theme.tokens.css';\n```",
|
|
103
|
+
"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.",
|
|
104
|
+
"Nothing about this thread came from the server. Next prerendered the **empty** `<kai-thread>` tag, the browser hydrated it, and every chunk you are reading now arrived as a new `messages` array assigned to that element as a JS property.",
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The kit's own mock responder, shared with the `kai` MCP scaffolder and
|
|
109
|
+
* `create-kai` so there is ONE implementation of this and not eight.
|
|
110
|
+
*
|
|
111
|
+
* It yields canned SSE frames in the OpenAI chat-completions shape, which
|
|
112
|
+
* `readOpenAIStream` parses exactly as it parses a real provider's. So this
|
|
113
|
+
* no-backend preview runs the kit's REAL streaming path — the SSE reader, the
|
|
114
|
+
* part folding, all of it — rather than a hand-rolled loop that merely resembles
|
|
115
|
+
* it. Going live then changes one expression, not the handler.
|
|
116
|
+
*
|
|
117
|
+
* It also cannot be mistaken for a real turn: the stream opens with a `: kai-mock`
|
|
118
|
+
* SSE comment, every frame carries a `_kai_mock` field, `model` reports as
|
|
119
|
+
* `kai-mock` (no provider serves that, so an echoed mock frame is rejected
|
|
120
|
+
* upstream rather than quietly believed), and usage is all zeros.
|
|
121
|
+
*
|
|
122
|
+
* MODULE scope, not per-send: the responder owns the cursor into the replies
|
|
123
|
+
* above, so rebuilding it each turn would answer with the first one forever.
|
|
124
|
+
*/
|
|
125
|
+
export const mockResponse = createMockResponder({ replies: MOCK_REPLIES });
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { useCallback, useRef } from 'react';
|
|
2
|
+
import { PromptInput, useVoiceInput } from '@kitn.ai/ui/react';
|
|
3
|
+
import type { PromptInputProps } from '@kitn.ai/ui/react';
|
|
4
|
+
import type { Theme } from '../theme';
|
|
5
|
+
|
|
6
|
+
interface ComposerProps {
|
|
7
|
+
theme: Theme;
|
|
8
|
+
loading: boolean;
|
|
9
|
+
suggestions: string[];
|
|
10
|
+
triggers: PromptInputProps['triggers'];
|
|
11
|
+
onSubmit: (value: string) => void;
|
|
12
|
+
onSuggestionClick: (value: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The bottom composer. Wraps `<kai-prompt-input>` and owns the input-DOM concerns:
|
|
17
|
+
* the element ref, a live text mirror, clear-on-submit, and seeding voice.
|
|
18
|
+
*
|
|
19
|
+
* The prompt input stays UNCONTROLLED so the `/`+`@` trigger menus keep a live
|
|
20
|
+
* caret. Assigning a plain STRING `value` flips the element into controlled mode,
|
|
21
|
+
* which re-applies the property and collapses the shadow-DOM selection — that
|
|
22
|
+
* disables the caret-anchored menu. So we NEVER push a string: clear-on-submit
|
|
23
|
+
* goes through the element's `clear()` method, and voice seeds a ComposerDoc (a
|
|
24
|
+
* non-string value is a one-time seed that leaves the input uncontrolled). We keep
|
|
25
|
+
* the live text mirrored into `textRef` (from `onValueChange`) so voice can append.
|
|
26
|
+
*
|
|
27
|
+
* SERVER-RENDERING NOTE. `useVoiceInput` reports `supported: false` on the server
|
|
28
|
+
* (there is no `window.SpeechRecognition` there) and true in Chromium, so that
|
|
29
|
+
* flag is read ONLY inside the click handler and never during render. Branching
|
|
30
|
+
* render output on it would put a different tree in the prerendered HTML than the
|
|
31
|
+
* client builds on hydration, which is the textbook hydration mismatch — and one
|
|
32
|
+
* `next build` would minify into a numbered React error rather than explain.
|
|
33
|
+
*/
|
|
34
|
+
export function Composer({ theme, loading, suggestions, triggers, onSubmit, onSuggestionClick }: ComposerProps) {
|
|
35
|
+
// Ref to the underlying <kai-prompt-input> element for the imperative pushes
|
|
36
|
+
// (clear() on submit, ComposerDoc seed from voice); textRef mirrors the live text.
|
|
37
|
+
const promptRef = useRef<HTMLElement>(null);
|
|
38
|
+
const textRef = useRef('');
|
|
39
|
+
|
|
40
|
+
const { supported: voiceSupported, start: startVoice } = useVoiceInput((transcript) => {
|
|
41
|
+
const next = (textRef.current ? textRef.current + ' ' : '') + transcript;
|
|
42
|
+
textRef.current = next;
|
|
43
|
+
const el = promptRef.current as (HTMLElement & { value?: unknown }) | null;
|
|
44
|
+
if (el) el.value = [{ type: 'text', text: next }];
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const handleVoice = useCallback(() => {
|
|
48
|
+
// Mic is Chromium-only; degrade gracefully everywhere else.
|
|
49
|
+
if (!voiceSupported) {
|
|
50
|
+
alert('Voice input needs a Chromium browser.');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
startVoice();
|
|
54
|
+
}, [voiceSupported, startVoice]);
|
|
55
|
+
|
|
56
|
+
const handleSubmit = useCallback(
|
|
57
|
+
(value: string) => {
|
|
58
|
+
// Clear our own uncontrolled input first — reset the mirror + call the
|
|
59
|
+
// element's clear() method (uncontrolled-safe; never a string assignment) —
|
|
60
|
+
// then hand the text up. The workspace owns append + streaming.
|
|
61
|
+
textRef.current = '';
|
|
62
|
+
(promptRef.current as (HTMLElement & { clear?: () => void }) | null)?.clear?.();
|
|
63
|
+
onSubmit(value);
|
|
64
|
+
},
|
|
65
|
+
[onSubmit],
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="composer">
|
|
70
|
+
<PromptInput
|
|
71
|
+
ref={promptRef}
|
|
72
|
+
theme={theme}
|
|
73
|
+
placeholder="Message the demo…"
|
|
74
|
+
loading={loading}
|
|
75
|
+
suggestions={suggestions}
|
|
76
|
+
triggers={triggers}
|
|
77
|
+
voice
|
|
78
|
+
onVoice={handleVoice}
|
|
79
|
+
onValueChange={(e) => { textRef.current = e.detail.value; }}
|
|
80
|
+
onSubmit={(e) => handleSubmit(e.detail.value)}
|
|
81
|
+
onSuggestionClick={(e) => onSuggestionClick(e.detail.value)}
|
|
82
|
+
/>
|
|
83
|
+
<p className="composer-hint">
|
|
84
|
+
Type <kbd>/</kbd> for skills · <kbd>@</kbd> for agents
|
|
85
|
+
</p>
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* THE SERVER-RENDERING PROOF, ON THE PAGE.
|
|
5
|
+
*
|
|
6
|
+
* This starter's original job was to show that a `kai-*` element survives Next's
|
|
7
|
+
* prerender + hydration, and this badge is what makes that visible without opening
|
|
8
|
+
* devtools. It is worth keeping alongside the working chat because HYDRATION
|
|
9
|
+
* FAILURE LOOKS LIKE SUCCESS: Next can emit perfectly correct HTML and then throw
|
|
10
|
+
* during hydration, leaving a page that reads exactly right and does nothing. A
|
|
11
|
+
* screenshot cannot tell those apart. This badge can, because the only way it
|
|
12
|
+
* turns green is for client JavaScript to have run.
|
|
13
|
+
*
|
|
14
|
+
* IT IS WORTH MORE ON NEXT THAN ANYWHERE ELSE, because a production React build
|
|
15
|
+
* MINIFIES hydration errors into a numbered link — so `next build && next start`
|
|
16
|
+
* shows you a silently dead page, and only `next dev` logs the mismatch in words.
|
|
17
|
+
* The badge is the same signal without the dev-only console.
|
|
18
|
+
*
|
|
19
|
+
* HOW IT AVOIDS BEING THE BUG IT REPORTS. The state starts `null` — the same value
|
|
20
|
+
* on the server and on React's first client render — so the two trees agree and
|
|
21
|
+
* hydration has nothing to reconcile. Only afterwards does the effect (which never
|
|
22
|
+
* runs on the server) look at `customElements` and re-render. Reading
|
|
23
|
+
* `customElements` during render instead would produce "none" in the server HTML
|
|
24
|
+
* and "5" in the client tree, i.e. a hydration mismatch inside the hydration
|
|
25
|
+
* detector.
|
|
26
|
+
*
|
|
27
|
+
* The tags are polled on a rAF loop rather than checked once: each React wrapper
|
|
28
|
+
* registers its own element from a layout effect and the element modules load as
|
|
29
|
+
* dynamic imports, so the definitions land over the next few frames rather than
|
|
30
|
+
* all at once.
|
|
31
|
+
*/
|
|
32
|
+
const TAGS = ['kai-button', 'kai-conversations', 'kai-thread', 'kai-prompt-input', 'kai-resizable'] as const;
|
|
33
|
+
|
|
34
|
+
export function HydrationBadge() {
|
|
35
|
+
const [defined, setDefined] = useState<number | null>(null);
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
let raf = 0;
|
|
39
|
+
const check = () => {
|
|
40
|
+
const n = TAGS.filter((t) => !!customElements.get(t)).length;
|
|
41
|
+
setDefined(n);
|
|
42
|
+
if (n < TAGS.length) raf = requestAnimationFrame(check);
|
|
43
|
+
};
|
|
44
|
+
check();
|
|
45
|
+
return () => cancelAnimationFrame(raf);
|
|
46
|
+
}, []);
|
|
47
|
+
|
|
48
|
+
const hydrated = defined === TAGS.length;
|
|
49
|
+
return (
|
|
50
|
+
<span
|
|
51
|
+
className="hydration"
|
|
52
|
+
data-testid="registration-status"
|
|
53
|
+
data-state={hydrated ? 'hydrated' : 'server'}
|
|
54
|
+
title={
|
|
55
|
+
hydrated
|
|
56
|
+
? `Client JS ran: all ${TAGS.length} kai-* elements are defined in customElements.`
|
|
57
|
+
: 'Server-rendered markup. The kai-* tags are still bare — no client JS has registered them yet.'
|
|
58
|
+
}
|
|
59
|
+
>
|
|
60
|
+
<span className="hydration-dot" aria-hidden />
|
|
61
|
+
{defined === null
|
|
62
|
+
? 'server-rendered · elements not yet registered'
|
|
63
|
+
: hydrated
|
|
64
|
+
? `hydrated · ${defined}/${TAGS.length} kai-* elements registered`
|
|
65
|
+
: `hydrating · ${defined}/${TAGS.length} kai-* elements registered`}
|
|
66
|
+
</span>
|
|
67
|
+
);
|
|
68
|
+
}
|