@kitnai/chat 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/LICENSE +21 -0
- package/README.md +314 -0
- package/dist/bash-InADTalH.js +6 -0
- package/dist/core-AYMC6_lb.js +5874 -0
- package/dist/engine-javascript-vq0WuIJl.js +2643 -0
- package/dist/github-dark-dimmed-DUshB20C.js +4 -0
- package/dist/github-light-JYsPkUQd.js +4 -0
- package/dist/javascript-C25yR2R2.js +6 -0
- package/dist/json-DxJze_jm.js +6 -0
- package/dist/kitn-chat.es.js +6632 -0
- package/dist/tsx-B8rCNbgL.js +6 -0
- package/dist/typescript-RycA9KXf.js +6 -0
- package/package.json +80 -0
- package/src/components/attachments.stories.tsx +304 -0
- package/src/components/attachments.tsx +394 -0
- package/src/components/chain-of-thought.stories.tsx +212 -0
- package/src/components/chain-of-thought.tsx +139 -0
- package/src/components/chat-container.stories.tsx +188 -0
- package/src/components/chat-container.tsx +78 -0
- package/src/components/chat-scope-picker.tsx +47 -0
- package/src/components/checkpoint.stories.tsx +103 -0
- package/src/components/checkpoint.tsx +81 -0
- package/src/components/code-block.stories.tsx +151 -0
- package/src/components/code-block.tsx +99 -0
- package/src/components/context.stories.tsx +180 -0
- package/src/components/context.tsx +323 -0
- package/src/components/conversation-item.stories.tsx +126 -0
- package/src/components/conversation-item.tsx +18 -0
- package/src/components/conversation-list.stories.tsx +134 -0
- package/src/components/conversation-list.tsx +100 -0
- package/src/components/empty.stories.tsx +435 -0
- package/src/components/empty.tsx +166 -0
- package/src/components/feedback-bar.stories.tsx +101 -0
- package/src/components/feedback-bar.tsx +58 -0
- package/src/components/file-upload.stories.tsx +157 -0
- package/src/components/file-upload.tsx +161 -0
- package/src/components/image.stories.tsx +90 -0
- package/src/components/image.tsx +67 -0
- package/src/components/loader.stories.tsx +182 -0
- package/src/components/loader.tsx +333 -0
- package/src/components/markdown.stories.tsx +181 -0
- package/src/components/markdown.tsx +81 -0
- package/src/components/message-narrow.stories.tsx +330 -0
- package/src/components/message-skills.stories.tsx +212 -0
- package/src/components/message-skills.tsx +36 -0
- package/src/components/message.stories.tsx +282 -0
- package/src/components/message.tsx +149 -0
- package/src/components/model-switcher.stories.tsx +98 -0
- package/src/components/model-switcher.tsx +36 -0
- package/src/components/prompt-input.stories.tsx +223 -0
- package/src/components/prompt-input.tsx +190 -0
- package/src/components/prompt-suggestion.stories.tsx +143 -0
- package/src/components/prompt-suggestion.tsx +115 -0
- package/src/components/reasoning.stories.tsx +141 -0
- package/src/components/reasoning.tsx +157 -0
- package/src/components/response-stream.tsx +103 -0
- package/src/components/scroll-button.stories.tsx +101 -0
- package/src/components/scroll-button.tsx +33 -0
- package/src/components/slash-command.stories.tsx +164 -0
- package/src/components/slash-command.tsx +223 -0
- package/src/components/source.stories.tsx +125 -0
- package/src/components/source.tsx +129 -0
- package/src/components/text-shimmer.stories.tsx +88 -0
- package/src/components/text-shimmer.tsx +37 -0
- package/src/components/thinking-bar.stories.tsx +88 -0
- package/src/components/thinking-bar.tsx +50 -0
- package/src/components/tool.stories.tsx +154 -0
- package/src/components/tool.tsx +173 -0
- package/src/components/voice-input.stories.tsx +84 -0
- package/src/components/voice-input.tsx +103 -0
- package/src/elements/chat-types.ts +14 -0
- package/src/elements/chat.tsx +111 -0
- package/src/elements/compiled.css +2 -0
- package/src/elements/conversation-list.tsx +26 -0
- package/src/elements/css.ts +5 -0
- package/src/elements/default-input.tsx +53 -0
- package/src/elements/define.tsx +54 -0
- package/src/elements/kitn-chat.stories.tsx +105 -0
- package/src/elements/kitn-conversation-list.stories.tsx +177 -0
- package/src/elements/kitn-prompt-input.stories.tsx +123 -0
- package/src/elements/prompt-input.tsx +39 -0
- package/src/elements/register.ts +9 -0
- package/src/elements/styles.css +12 -0
- package/src/index.ts +128 -0
- package/src/primitives/chat-config.tsx +76 -0
- package/src/primitives/highlighter.ts +150 -0
- package/src/primitives/use-auto-resize.ts +31 -0
- package/src/primitives/use-stick-to-bottom.ts +43 -0
- package/src/primitives/use-text-stream.ts +112 -0
- package/src/primitives/use-voice-recorder.ts +50 -0
- package/src/stories/chat-panel-layout.stories.tsx +144 -0
- package/src/stories/chat-scene.tsx +570 -0
- package/src/stories/checkpoint-restore.stories.tsx +224 -0
- package/src/stories/context-usage.stories.tsx +155 -0
- package/src/stories/conversation-with-reasoning.stories.tsx +151 -0
- package/src/stories/conversation-with-sources.stories.tsx +165 -0
- package/src/stories/docs/GettingStarted.mdx +76 -0
- package/src/stories/docs/Installation.mdx +48 -0
- package/src/stories/docs/Integrations.mdx +110 -0
- package/src/stories/docs/Introduction.mdx +29 -0
- package/src/stories/docs/Theming.mdx +87 -0
- package/src/stories/docs/theme-editor/canvas.tsx +32 -0
- package/src/stories/docs/theme-editor/inspector.tsx +66 -0
- package/src/stories/docs/theme-editor/presets.test.ts +32 -0
- package/src/stories/docs/theme-editor/presets.ts +64 -0
- package/src/stories/docs/theme-editor/theme-css.test.ts +19 -0
- package/src/stories/docs/theme-editor/theme-css.ts +15 -0
- package/src/stories/docs/theme-editor/theme-editor.tsx +145 -0
- package/src/stories/docs/theme-tokens.tsx +174 -0
- package/src/stories/full-chat.stories.tsx +18 -0
- package/src/stories/message-actions.stories.tsx +167 -0
- package/src/stories/prompt-input-variants.stories.tsx +179 -0
- package/src/stories/streaming-response.stories.tsx +234 -0
- package/src/stories/theme-editor.stories.tsx +16 -0
- package/src/stories/token-reference.stories.tsx +18 -0
- package/src/types.ts +41 -0
- package/src/ui/avatar.stories.tsx +104 -0
- package/src/ui/avatar.tsx +23 -0
- package/src/ui/badge.stories.tsx +87 -0
- package/src/ui/badge.tsx +21 -0
- package/src/ui/button.stories.tsx +146 -0
- package/src/ui/button.tsx +37 -0
- package/src/ui/collapsible.tsx +14 -0
- package/src/ui/dialog.tsx +21 -0
- package/src/ui/dropdown.tsx +26 -0
- package/src/ui/hover-card.tsx +48 -0
- package/src/ui/resizable.stories.tsx +171 -0
- package/src/ui/resizable.tsx +219 -0
- package/src/ui/scroll-area.tsx +13 -0
- package/src/ui/separator.stories.tsx +82 -0
- package/src/ui/separator.tsx +10 -0
- package/src/ui/skeleton.stories.tsx +338 -0
- package/src/ui/skeleton.tsx +16 -0
- package/src/ui/textarea.tsx +21 -0
- package/src/ui/tooltip.stories.tsx +75 -0
- package/src/ui/tooltip.tsx +22 -0
- package/src/utils/cn.ts +6 -0
- package/theme.css +115 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// On-demand code highlighter built on Shiki's fine-grained core.
|
|
2
|
+
//
|
|
3
|
+
// Nothing here loads until `highlight()` is first called with highlighting
|
|
4
|
+
// enabled — so a component set that never renders a code block ships and runs
|
|
5
|
+
// with ZERO Shiki bytes. When a code block does appear, only the core, the
|
|
6
|
+
// JavaScript regex engine (no WASM), the one theme, and the one language grammar
|
|
7
|
+
// it needs are dynamically imported, each as its own small lazy chunk.
|
|
8
|
+
//
|
|
9
|
+
// Hosts extend or disable this via `configureCodeHighlighting()`.
|
|
10
|
+
|
|
11
|
+
import type { HighlighterCore } from 'shiki/core';
|
|
12
|
+
|
|
13
|
+
type Loader = () => Promise<unknown>;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Minimal default language set — each a separate lazy chunk, loaded only on use.
|
|
17
|
+
* Kept deliberately small; hosts add more via `configureCodeHighlighting({ languages })`.
|
|
18
|
+
*/
|
|
19
|
+
const DEFAULT_LANGUAGES: Record<string, Loader> = {
|
|
20
|
+
javascript: () => import('@shikijs/langs/javascript'),
|
|
21
|
+
typescript: () => import('@shikijs/langs/typescript'),
|
|
22
|
+
tsx: () => import('@shikijs/langs/tsx'),
|
|
23
|
+
json: () => import('@shikijs/langs/json'),
|
|
24
|
+
bash: () => import('@shikijs/langs/bash'),
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const DEFAULT_THEMES: Record<string, Loader> = {
|
|
28
|
+
'github-dark-dimmed': () => import('@shikijs/themes/github-dark-dimmed'),
|
|
29
|
+
'github-light': () => import('@shikijs/themes/github-light'),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const DEFAULT_ALIASES: Record<string, string> = {
|
|
33
|
+
js: 'javascript',
|
|
34
|
+
ts: 'typescript',
|
|
35
|
+
sh: 'bash',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const FALLBACK_THEME = 'github-dark-dimmed';
|
|
39
|
+
|
|
40
|
+
export interface CodeHighlightingOptions {
|
|
41
|
+
/** Turn highlighting on/off globally. When false, code renders as plain text. */
|
|
42
|
+
enabled?: boolean;
|
|
43
|
+
/** Register/override language loaders, e.g. `{ ruby: () => import('@shikijs/langs/ruby') }`. */
|
|
44
|
+
languages?: Record<string, Loader>;
|
|
45
|
+
/** Register/override theme loaders. */
|
|
46
|
+
themes?: Record<string, Loader>;
|
|
47
|
+
/** Map short names to canonical language keys, e.g. `{ vue: 'html' }`. */
|
|
48
|
+
aliases?: Record<string, string>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let enabled = true;
|
|
52
|
+
let langLoaders: Record<string, Loader> = { ...DEFAULT_LANGUAGES };
|
|
53
|
+
let themeLoaders: Record<string, Loader> = { ...DEFAULT_THEMES };
|
|
54
|
+
let aliases: Record<string, string> = { ...DEFAULT_ALIASES };
|
|
55
|
+
|
|
56
|
+
let highlighterPromise: Promise<HighlighterCore> | null = null;
|
|
57
|
+
const loadedLangs = new Set<string>();
|
|
58
|
+
const loadedThemes = new Set<string>();
|
|
59
|
+
|
|
60
|
+
function getHighlighter(): Promise<HighlighterCore> {
|
|
61
|
+
if (!highlighterPromise) {
|
|
62
|
+
highlighterPromise = (async () => {
|
|
63
|
+
const [{ createHighlighterCore }, { createJavaScriptRegexEngine }] = await Promise.all([
|
|
64
|
+
import('shiki/core'),
|
|
65
|
+
import('shiki/engine/javascript'),
|
|
66
|
+
]);
|
|
67
|
+
return createHighlighterCore({
|
|
68
|
+
themes: [],
|
|
69
|
+
langs: [],
|
|
70
|
+
engine: createJavaScriptRegexEngine(),
|
|
71
|
+
});
|
|
72
|
+
})();
|
|
73
|
+
}
|
|
74
|
+
return highlighterPromise;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveLang(lang: string): string {
|
|
78
|
+
return aliases[lang] ?? lang;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function ensureLang(hl: HighlighterCore, lang: string): Promise<boolean> {
|
|
82
|
+
const name = resolveLang(lang);
|
|
83
|
+
if (loadedLangs.has(name)) return true;
|
|
84
|
+
const loader = langLoaders[name];
|
|
85
|
+
if (!loader) return false;
|
|
86
|
+
await hl.loadLanguage(loader() as never);
|
|
87
|
+
loadedLangs.add(name);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function ensureTheme(hl: HighlighterCore, theme: string): Promise<boolean> {
|
|
92
|
+
if (loadedThemes.has(theme)) return true;
|
|
93
|
+
const loader = themeLoaders[theme];
|
|
94
|
+
if (!loader) return false;
|
|
95
|
+
await hl.loadTheme(loader() as never);
|
|
96
|
+
loadedThemes.add(theme);
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function escapeHtml(s: string): string {
|
|
101
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function plain(code: string): string {
|
|
105
|
+
return `<pre><code>${escapeHtml(code)}</code></pre>`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Highlight `code` as `lang` with `theme`, returning HTML. Loads only what's
|
|
110
|
+
* needed, on demand. Falls back to escaped plain `<pre><code>` when highlighting
|
|
111
|
+
* is disabled, the language has no registered loader, or anything fails.
|
|
112
|
+
*/
|
|
113
|
+
export async function highlight(code: string, lang: string, theme: string): Promise<string> {
|
|
114
|
+
if (!enabled || !code) return plain(code);
|
|
115
|
+
try {
|
|
116
|
+
const hl = await getHighlighter();
|
|
117
|
+
const hasLang = await ensureLang(hl, lang);
|
|
118
|
+
if (!hasLang) return plain(code);
|
|
119
|
+
const useTheme = (await ensureTheme(hl, theme))
|
|
120
|
+
? theme
|
|
121
|
+
: (await ensureTheme(hl, FALLBACK_THEME)) ? FALLBACK_THEME : null;
|
|
122
|
+
if (!useTheme) return plain(code);
|
|
123
|
+
return hl.codeToHtml(code, { lang: resolveLang(lang), theme: useTheme });
|
|
124
|
+
} catch {
|
|
125
|
+
return plain(code);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Register additional languages/themes/aliases, or disable highlighting entirely. */
|
|
130
|
+
export function configureCodeHighlighting(options: CodeHighlightingOptions): void {
|
|
131
|
+
if (options.enabled !== undefined) enabled = options.enabled;
|
|
132
|
+
if (options.languages) langLoaders = { ...langLoaders, ...options.languages };
|
|
133
|
+
if (options.themes) themeLoaders = { ...themeLoaders, ...options.themes };
|
|
134
|
+
if (options.aliases) aliases = { ...aliases, ...options.aliases };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function isCodeHighlightingEnabled(): boolean {
|
|
138
|
+
return enabled;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Test helper — reset the singleton and registries to defaults. */
|
|
142
|
+
export function __resetCodeHighlightingForTests(): void {
|
|
143
|
+
enabled = true;
|
|
144
|
+
langLoaders = { ...DEFAULT_LANGUAGES };
|
|
145
|
+
themeLoaders = { ...DEFAULT_THEMES };
|
|
146
|
+
aliases = { ...DEFAULT_ALIASES };
|
|
147
|
+
highlighterPromise = null;
|
|
148
|
+
loadedLangs.clear();
|
|
149
|
+
loadedThemes.clear();
|
|
150
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { onCleanup } from 'solid-js';
|
|
2
|
+
|
|
3
|
+
interface UseAutoResizeOptions {
|
|
4
|
+
maxHeight?: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function useAutoResize(options: UseAutoResizeOptions = {}) {
|
|
8
|
+
let textareaEl: HTMLTextAreaElement | undefined;
|
|
9
|
+
|
|
10
|
+
function resize() {
|
|
11
|
+
if (!textareaEl) return;
|
|
12
|
+
textareaEl.style.height = 'auto';
|
|
13
|
+
const scrollHeight = textareaEl.scrollHeight;
|
|
14
|
+
if (options.maxHeight && scrollHeight > options.maxHeight) {
|
|
15
|
+
textareaEl.style.height = `${options.maxHeight}px`;
|
|
16
|
+
textareaEl.style.overflowY = 'auto';
|
|
17
|
+
} else {
|
|
18
|
+
textareaEl.style.height = `${scrollHeight}px`;
|
|
19
|
+
textareaEl.style.overflowY = 'hidden';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ref(el: HTMLTextAreaElement) {
|
|
24
|
+
textareaEl = el;
|
|
25
|
+
el.addEventListener('input', resize);
|
|
26
|
+
requestAnimationFrame(resize);
|
|
27
|
+
onCleanup(() => el.removeEventListener('input', resize));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return { ref, resize };
|
|
31
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { createSignal, onCleanup } from 'solid-js';
|
|
2
|
+
|
|
3
|
+
const SCROLL_THRESHOLD = 50;
|
|
4
|
+
|
|
5
|
+
export function useStickToBottom() {
|
|
6
|
+
const [isAtBottom, setIsAtBottom] = createSignal(true);
|
|
7
|
+
let containerEl: HTMLElement | undefined;
|
|
8
|
+
let shouldStick = true;
|
|
9
|
+
|
|
10
|
+
function checkIfAtBottom() {
|
|
11
|
+
if (!containerEl) return;
|
|
12
|
+
const { scrollTop, scrollHeight, clientHeight } = containerEl;
|
|
13
|
+
const atBottom = scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD;
|
|
14
|
+
setIsAtBottom(atBottom);
|
|
15
|
+
shouldStick = atBottom;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function scrollToBottom(behavior: ScrollBehavior = 'smooth') {
|
|
19
|
+
if (!containerEl) return;
|
|
20
|
+
containerEl.scrollTo({ top: containerEl.scrollHeight, behavior });
|
|
21
|
+
shouldStick = true;
|
|
22
|
+
setIsAtBottom(true);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function onNewContent() {
|
|
26
|
+
if (shouldStick) {
|
|
27
|
+
requestAnimationFrame(() => scrollToBottom('instant'));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function ref(el: HTMLElement) {
|
|
32
|
+
containerEl = el;
|
|
33
|
+
el.addEventListener('scroll', checkIfAtBottom, { passive: true });
|
|
34
|
+
const observer = new MutationObserver(onNewContent);
|
|
35
|
+
observer.observe(el, { childList: true, subtree: true, characterData: true });
|
|
36
|
+
onCleanup(() => {
|
|
37
|
+
el.removeEventListener('scroll', checkIfAtBottom);
|
|
38
|
+
observer.disconnect();
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { ref, isAtBottom, scrollToBottom };
|
|
43
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createSignal, onCleanup } from 'solid-js';
|
|
2
|
+
|
|
3
|
+
export interface UseTextStreamOptions {
|
|
4
|
+
mode: 'typewriter' | 'fade';
|
|
5
|
+
speed?: number;
|
|
6
|
+
characterChunkSize?: number;
|
|
7
|
+
fadeDuration?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface TextStreamSegment {
|
|
11
|
+
text: string;
|
|
12
|
+
index: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function useTextStream(options: UseTextStreamOptions) {
|
|
16
|
+
const speed = options.speed ?? 20;
|
|
17
|
+
const chunkSize = options.characterChunkSize ?? 3;
|
|
18
|
+
|
|
19
|
+
const [displayedText, setDisplayedText] = createSignal('');
|
|
20
|
+
const [isComplete, setIsComplete] = createSignal(true);
|
|
21
|
+
const [segments, setSegments] = createSignal<TextStreamSegment[]>([]);
|
|
22
|
+
|
|
23
|
+
let fullText = '';
|
|
24
|
+
let charIndex = 0;
|
|
25
|
+
let intervalId: ReturnType<typeof setInterval> | undefined;
|
|
26
|
+
let isPaused = false;
|
|
27
|
+
let asyncIterator: AsyncIterator<string> | undefined;
|
|
28
|
+
|
|
29
|
+
function clearInterval_() {
|
|
30
|
+
if (intervalId !== undefined) {
|
|
31
|
+
clearInterval(intervalId);
|
|
32
|
+
intervalId = undefined;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function typewriterTick() {
|
|
37
|
+
if (isPaused) return;
|
|
38
|
+
if (charIndex >= fullText.length) {
|
|
39
|
+
if (!asyncIterator) {
|
|
40
|
+
clearInterval_();
|
|
41
|
+
setIsComplete(true);
|
|
42
|
+
}
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const end = Math.min(charIndex + chunkSize, fullText.length);
|
|
46
|
+
charIndex = end;
|
|
47
|
+
setDisplayedText(fullText.slice(0, charIndex));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function consumeAsyncIterable(source: AsyncIterable<string>) {
|
|
51
|
+
asyncIterator = source[Symbol.asyncIterator]();
|
|
52
|
+
try {
|
|
53
|
+
while (true) {
|
|
54
|
+
const { value, done } = await asyncIterator.next();
|
|
55
|
+
if (done) break;
|
|
56
|
+
if (value) {
|
|
57
|
+
fullText += value;
|
|
58
|
+
setSegments((prev) => [...prev, { text: value, index: prev.length }]);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} finally {
|
|
62
|
+
asyncIterator = undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function startStreaming(source: string | AsyncIterable<string>) {
|
|
67
|
+
reset();
|
|
68
|
+
setIsComplete(false);
|
|
69
|
+
|
|
70
|
+
if (typeof source === 'string') {
|
|
71
|
+
fullText = source;
|
|
72
|
+
if (options.mode === 'typewriter') {
|
|
73
|
+
intervalId = setInterval(typewriterTick, speed);
|
|
74
|
+
} else {
|
|
75
|
+
// Deliver all segments at once — CSS animation-delay handles staggered fade-in
|
|
76
|
+
const words = source.split(/(\s+)/).filter(Boolean);
|
|
77
|
+
setSegments(words.map((text, index) => ({ text, index })));
|
|
78
|
+
setDisplayedText(source);
|
|
79
|
+
// isComplete fires after animation has time to play
|
|
80
|
+
// (segments count * delay per segment from ResponseStream)
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
if (options.mode === 'typewriter') {
|
|
84
|
+
intervalId = setInterval(typewriterTick, speed);
|
|
85
|
+
}
|
|
86
|
+
consumeAsyncIterable(source).then(() => {
|
|
87
|
+
if (options.mode === 'fade') {
|
|
88
|
+
setDisplayedText(fullText);
|
|
89
|
+
setIsComplete(true);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function pause() { isPaused = true; }
|
|
96
|
+
function resume() { isPaused = false; }
|
|
97
|
+
|
|
98
|
+
function reset() {
|
|
99
|
+
clearInterval_();
|
|
100
|
+
fullText = '';
|
|
101
|
+
charIndex = 0;
|
|
102
|
+
isPaused = false;
|
|
103
|
+
asyncIterator = undefined;
|
|
104
|
+
setDisplayedText('');
|
|
105
|
+
setIsComplete(true);
|
|
106
|
+
setSegments([]);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
onCleanup(() => clearInterval_());
|
|
110
|
+
|
|
111
|
+
return { displayedText, isComplete, segments, startStreaming, pause, resume, reset };
|
|
112
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createSignal, onCleanup } from 'solid-js';
|
|
2
|
+
|
|
3
|
+
export interface UseVoiceRecorderOptions {
|
|
4
|
+
mimeType?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function useVoiceRecorder(options: UseVoiceRecorderOptions = {}) {
|
|
8
|
+
const mimeType = options.mimeType ?? 'audio/webm;codecs=opus';
|
|
9
|
+
const [isRecording, setIsRecording] = createSignal(false);
|
|
10
|
+
const [error, setError] = createSignal<string | null>(null);
|
|
11
|
+
|
|
12
|
+
let mediaRecorder: MediaRecorder | undefined;
|
|
13
|
+
let chunks: Blob[] = [];
|
|
14
|
+
let resolveBlob: ((blob: Blob) => void) | undefined;
|
|
15
|
+
|
|
16
|
+
async function start(): Promise<Blob> {
|
|
17
|
+
setError(null);
|
|
18
|
+
chunks = [];
|
|
19
|
+
try {
|
|
20
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
21
|
+
mediaRecorder = new MediaRecorder(stream, { mimeType });
|
|
22
|
+
mediaRecorder.ondataavailable = (e) => {
|
|
23
|
+
if (e.data.size > 0) chunks.push(e.data);
|
|
24
|
+
};
|
|
25
|
+
mediaRecorder.onstop = () => {
|
|
26
|
+
const blob = new Blob(chunks, { type: mimeType });
|
|
27
|
+
stream.getTracks().forEach((t) => t.stop());
|
|
28
|
+
setIsRecording(false);
|
|
29
|
+
resolveBlob?.(blob);
|
|
30
|
+
};
|
|
31
|
+
mediaRecorder.start();
|
|
32
|
+
setIsRecording(true);
|
|
33
|
+
return new Promise<Blob>((resolve) => { resolveBlob = resolve; });
|
|
34
|
+
} catch (err) {
|
|
35
|
+
setError(err instanceof Error ? err.message : 'Microphone access denied');
|
|
36
|
+
setIsRecording(false);
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function stop() {
|
|
42
|
+
if (mediaRecorder && mediaRecorder.state === 'recording') {
|
|
43
|
+
mediaRecorder.stop();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
onCleanup(() => stop());
|
|
48
|
+
|
|
49
|
+
return { isRecording, error, start, stop };
|
|
50
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from 'storybook-solidjs-vite';
|
|
2
|
+
import { createSignal } from 'solid-js';
|
|
3
|
+
import { Message, MessageAvatar, MessageContent, MessageActions } from '../components/message';
|
|
4
|
+
import { ChatContainer } from '../components/chat-container';
|
|
5
|
+
import { ChatConfig } from '../primitives/chat-config';
|
|
6
|
+
import { PromptInput, PromptInputTextarea, PromptInputActions } from '../components/prompt-input';
|
|
7
|
+
import { Button } from '../ui/button';
|
|
8
|
+
import { ScrollButton } from '../components/scroll-button';
|
|
9
|
+
import { Copy, ArrowUp } from 'lucide-solid';
|
|
10
|
+
|
|
11
|
+
const meta: Meta = {
|
|
12
|
+
title: 'Patterns/Chat Panel Layout',
|
|
13
|
+
parameters: { layout: 'centered' },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default meta;
|
|
17
|
+
type Story = StoryObj;
|
|
18
|
+
|
|
19
|
+
const assistantMsg1 = `The document is a transcript of a YouTube video by Cole Medin, where he discusses building a self-evolving memory system for coding agents using Large Language Models (LLMs), inspired by Andre Karpathy's ideas on LLM knowledge bases.`;
|
|
20
|
+
|
|
21
|
+
const assistantMsg2 = `Short answer: **it has to be a separate browser window** (or tab/iframe), not just a normal component in your application.
|
|
22
|
+
|
|
23
|
+
Here's why:
|
|
24
|
+
|
|
25
|
+
- The OAuth flow requires redirecting to the provider's login page
|
|
26
|
+
- The callback URL needs to be a real URL the browser can navigate to
|
|
27
|
+
- Security policies prevent embedding auth pages in iframes`;
|
|
28
|
+
|
|
29
|
+
const userMsg1 = 'What is this video about?';
|
|
30
|
+
const userMsg2 = 'And from what I understand, does that need to be another browser window pop-up that happens, or can I just have a component that does that in my application?';
|
|
31
|
+
|
|
32
|
+
function CopyButton(props: { text: string }) {
|
|
33
|
+
return (
|
|
34
|
+
<button onClick={() => navigator.clipboard.writeText(props.text)}>
|
|
35
|
+
<Copy size={14} />
|
|
36
|
+
</button>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Reference layout — uses variant props, no custom class overrides.
|
|
42
|
+
*/
|
|
43
|
+
export const ChatGPTStyle: Story = {
|
|
44
|
+
render: () => {
|
|
45
|
+
const [input, setInput] = createSignal('');
|
|
46
|
+
return (
|
|
47
|
+
<ChatConfig proseSize="base">
|
|
48
|
+
<div
|
|
49
|
+
style={{ width: '420px', height: '700px' }}
|
|
50
|
+
class="flex flex-col overflow-hidden rounded-lg bg-card"
|
|
51
|
+
>
|
|
52
|
+
{/* Header */}
|
|
53
|
+
<div class="px-3 py-2.5 bg-muted/30 text-sm font-semibold text-foreground flex-shrink-0 flex items-center justify-between">
|
|
54
|
+
<span>Chat Panel</span>
|
|
55
|
+
<span class="text-xs text-muted-foreground">GPT-4o Mini</span>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
{/* Messages */}
|
|
59
|
+
<ChatContainer class="flex-1 min-w-0 px-4 py-3 space-y-4">
|
|
60
|
+
{/* Assistant message 1 — with avatar */}
|
|
61
|
+
<Message>
|
|
62
|
+
<MessageAvatar src="" alt="AI" fallback="AI" />
|
|
63
|
+
<div class="flex w-full flex-col min-w-0">
|
|
64
|
+
<MessageContent markdown class="bg-transparent p-0 pt-1.5">
|
|
65
|
+
{assistantMsg1}
|
|
66
|
+
</MessageContent>
|
|
67
|
+
<MessageActions>
|
|
68
|
+
<CopyButton text={assistantMsg1} />
|
|
69
|
+
</MessageActions>
|
|
70
|
+
</div>
|
|
71
|
+
</Message>
|
|
72
|
+
|
|
73
|
+
{/* User message 1 */}
|
|
74
|
+
<Message class="group flex-col items-end">
|
|
75
|
+
<MessageContent class="bg-muted text-primary max-w-[85%] rounded-xl px-4 py-2 mr-1">
|
|
76
|
+
{userMsg1}
|
|
77
|
+
</MessageContent>
|
|
78
|
+
<MessageActions class="opacity-0 group-hover:opacity-100 transition-opacity duration-150">
|
|
79
|
+
<CopyButton text={userMsg1} />
|
|
80
|
+
</MessageActions>
|
|
81
|
+
</Message>
|
|
82
|
+
|
|
83
|
+
{/* Assistant message 2 — with avatar, markdown */}
|
|
84
|
+
<Message>
|
|
85
|
+
<MessageAvatar src="" alt="AI" fallback="AI" />
|
|
86
|
+
<div class="flex w-full flex-col min-w-0">
|
|
87
|
+
<MessageContent markdown class="bg-transparent p-0 pt-1.5">
|
|
88
|
+
{assistantMsg2}
|
|
89
|
+
</MessageContent>
|
|
90
|
+
<MessageActions>
|
|
91
|
+
<CopyButton text={assistantMsg2} />
|
|
92
|
+
</MessageActions>
|
|
93
|
+
</div>
|
|
94
|
+
</Message>
|
|
95
|
+
|
|
96
|
+
{/* User message 2 — longer */}
|
|
97
|
+
<Message class="group flex-col items-end">
|
|
98
|
+
<MessageContent class="bg-muted text-primary max-w-[85%] rounded-xl px-4 py-2 mr-1">
|
|
99
|
+
{userMsg2}
|
|
100
|
+
</MessageContent>
|
|
101
|
+
<MessageActions class="opacity-0 group-hover:opacity-100 transition-opacity duration-150">
|
|
102
|
+
<CopyButton text={userMsg2} />
|
|
103
|
+
</MessageActions>
|
|
104
|
+
</Message>
|
|
105
|
+
|
|
106
|
+
{/* Assistant message 3 — with avatar, short */}
|
|
107
|
+
<Message>
|
|
108
|
+
<MessageAvatar src="" alt="AI" fallback="AI" />
|
|
109
|
+
<div class="flex w-full flex-col min-w-0">
|
|
110
|
+
<MessageContent markdown class="bg-transparent p-0 pt-1.5">
|
|
111
|
+
Got it — let me know if you have more questions!
|
|
112
|
+
</MessageContent>
|
|
113
|
+
<MessageActions>
|
|
114
|
+
<CopyButton text="Got it — let me know if you have more questions!" />
|
|
115
|
+
</MessageActions>
|
|
116
|
+
</div>
|
|
117
|
+
</Message>
|
|
118
|
+
<ScrollButton />
|
|
119
|
+
</ChatContainer>
|
|
120
|
+
|
|
121
|
+
{/* Input */}
|
|
122
|
+
<div class="px-3 pb-3 pt-1 flex-shrink-0">
|
|
123
|
+
<PromptInput
|
|
124
|
+
value={input()}
|
|
125
|
+
onValueChange={setInput}
|
|
126
|
+
onSubmit={() => setInput('')}
|
|
127
|
+
>
|
|
128
|
+
<PromptInputTextarea placeholder="Ask about this page..." class="min-h-[44px] pt-3 pl-4" />
|
|
129
|
+
<PromptInputActions class="mt-2 flex w-full items-center justify-end gap-2 px-3 pb-3">
|
|
130
|
+
<Button
|
|
131
|
+
size="icon-sm"
|
|
132
|
+
class="rounded-full"
|
|
133
|
+
disabled={!input().trim()}
|
|
134
|
+
>
|
|
135
|
+
<ArrowUp class="size-4" />
|
|
136
|
+
</Button>
|
|
137
|
+
</PromptInputActions>
|
|
138
|
+
</PromptInput>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
</ChatConfig>
|
|
142
|
+
);
|
|
143
|
+
},
|
|
144
|
+
};
|