@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.
Files changed (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +314 -0
  3. package/dist/bash-InADTalH.js +6 -0
  4. package/dist/core-AYMC6_lb.js +5874 -0
  5. package/dist/engine-javascript-vq0WuIJl.js +2643 -0
  6. package/dist/github-dark-dimmed-DUshB20C.js +4 -0
  7. package/dist/github-light-JYsPkUQd.js +4 -0
  8. package/dist/javascript-C25yR2R2.js +6 -0
  9. package/dist/json-DxJze_jm.js +6 -0
  10. package/dist/kitn-chat.es.js +6632 -0
  11. package/dist/tsx-B8rCNbgL.js +6 -0
  12. package/dist/typescript-RycA9KXf.js +6 -0
  13. package/package.json +80 -0
  14. package/src/components/attachments.stories.tsx +304 -0
  15. package/src/components/attachments.tsx +394 -0
  16. package/src/components/chain-of-thought.stories.tsx +212 -0
  17. package/src/components/chain-of-thought.tsx +139 -0
  18. package/src/components/chat-container.stories.tsx +188 -0
  19. package/src/components/chat-container.tsx +78 -0
  20. package/src/components/chat-scope-picker.tsx +47 -0
  21. package/src/components/checkpoint.stories.tsx +103 -0
  22. package/src/components/checkpoint.tsx +81 -0
  23. package/src/components/code-block.stories.tsx +151 -0
  24. package/src/components/code-block.tsx +99 -0
  25. package/src/components/context.stories.tsx +180 -0
  26. package/src/components/context.tsx +323 -0
  27. package/src/components/conversation-item.stories.tsx +126 -0
  28. package/src/components/conversation-item.tsx +18 -0
  29. package/src/components/conversation-list.stories.tsx +134 -0
  30. package/src/components/conversation-list.tsx +100 -0
  31. package/src/components/empty.stories.tsx +435 -0
  32. package/src/components/empty.tsx +166 -0
  33. package/src/components/feedback-bar.stories.tsx +101 -0
  34. package/src/components/feedback-bar.tsx +58 -0
  35. package/src/components/file-upload.stories.tsx +157 -0
  36. package/src/components/file-upload.tsx +161 -0
  37. package/src/components/image.stories.tsx +90 -0
  38. package/src/components/image.tsx +67 -0
  39. package/src/components/loader.stories.tsx +182 -0
  40. package/src/components/loader.tsx +333 -0
  41. package/src/components/markdown.stories.tsx +181 -0
  42. package/src/components/markdown.tsx +81 -0
  43. package/src/components/message-narrow.stories.tsx +330 -0
  44. package/src/components/message-skills.stories.tsx +212 -0
  45. package/src/components/message-skills.tsx +36 -0
  46. package/src/components/message.stories.tsx +282 -0
  47. package/src/components/message.tsx +149 -0
  48. package/src/components/model-switcher.stories.tsx +98 -0
  49. package/src/components/model-switcher.tsx +36 -0
  50. package/src/components/prompt-input.stories.tsx +223 -0
  51. package/src/components/prompt-input.tsx +190 -0
  52. package/src/components/prompt-suggestion.stories.tsx +143 -0
  53. package/src/components/prompt-suggestion.tsx +115 -0
  54. package/src/components/reasoning.stories.tsx +141 -0
  55. package/src/components/reasoning.tsx +157 -0
  56. package/src/components/response-stream.tsx +103 -0
  57. package/src/components/scroll-button.stories.tsx +101 -0
  58. package/src/components/scroll-button.tsx +33 -0
  59. package/src/components/slash-command.stories.tsx +164 -0
  60. package/src/components/slash-command.tsx +223 -0
  61. package/src/components/source.stories.tsx +125 -0
  62. package/src/components/source.tsx +129 -0
  63. package/src/components/text-shimmer.stories.tsx +88 -0
  64. package/src/components/text-shimmer.tsx +37 -0
  65. package/src/components/thinking-bar.stories.tsx +88 -0
  66. package/src/components/thinking-bar.tsx +50 -0
  67. package/src/components/tool.stories.tsx +154 -0
  68. package/src/components/tool.tsx +173 -0
  69. package/src/components/voice-input.stories.tsx +84 -0
  70. package/src/components/voice-input.tsx +103 -0
  71. package/src/elements/chat-types.ts +14 -0
  72. package/src/elements/chat.tsx +111 -0
  73. package/src/elements/compiled.css +2 -0
  74. package/src/elements/conversation-list.tsx +26 -0
  75. package/src/elements/css.ts +5 -0
  76. package/src/elements/default-input.tsx +53 -0
  77. package/src/elements/define.tsx +54 -0
  78. package/src/elements/kitn-chat.stories.tsx +105 -0
  79. package/src/elements/kitn-conversation-list.stories.tsx +177 -0
  80. package/src/elements/kitn-prompt-input.stories.tsx +123 -0
  81. package/src/elements/prompt-input.tsx +39 -0
  82. package/src/elements/register.ts +9 -0
  83. package/src/elements/styles.css +12 -0
  84. package/src/index.ts +128 -0
  85. package/src/primitives/chat-config.tsx +76 -0
  86. package/src/primitives/highlighter.ts +150 -0
  87. package/src/primitives/use-auto-resize.ts +31 -0
  88. package/src/primitives/use-stick-to-bottom.ts +43 -0
  89. package/src/primitives/use-text-stream.ts +112 -0
  90. package/src/primitives/use-voice-recorder.ts +50 -0
  91. package/src/stories/chat-panel-layout.stories.tsx +144 -0
  92. package/src/stories/chat-scene.tsx +570 -0
  93. package/src/stories/checkpoint-restore.stories.tsx +224 -0
  94. package/src/stories/context-usage.stories.tsx +155 -0
  95. package/src/stories/conversation-with-reasoning.stories.tsx +151 -0
  96. package/src/stories/conversation-with-sources.stories.tsx +165 -0
  97. package/src/stories/docs/GettingStarted.mdx +76 -0
  98. package/src/stories/docs/Installation.mdx +48 -0
  99. package/src/stories/docs/Integrations.mdx +110 -0
  100. package/src/stories/docs/Introduction.mdx +29 -0
  101. package/src/stories/docs/Theming.mdx +87 -0
  102. package/src/stories/docs/theme-editor/canvas.tsx +32 -0
  103. package/src/stories/docs/theme-editor/inspector.tsx +66 -0
  104. package/src/stories/docs/theme-editor/presets.test.ts +32 -0
  105. package/src/stories/docs/theme-editor/presets.ts +64 -0
  106. package/src/stories/docs/theme-editor/theme-css.test.ts +19 -0
  107. package/src/stories/docs/theme-editor/theme-css.ts +15 -0
  108. package/src/stories/docs/theme-editor/theme-editor.tsx +145 -0
  109. package/src/stories/docs/theme-tokens.tsx +174 -0
  110. package/src/stories/full-chat.stories.tsx +18 -0
  111. package/src/stories/message-actions.stories.tsx +167 -0
  112. package/src/stories/prompt-input-variants.stories.tsx +179 -0
  113. package/src/stories/streaming-response.stories.tsx +234 -0
  114. package/src/stories/theme-editor.stories.tsx +16 -0
  115. package/src/stories/token-reference.stories.tsx +18 -0
  116. package/src/types.ts +41 -0
  117. package/src/ui/avatar.stories.tsx +104 -0
  118. package/src/ui/avatar.tsx +23 -0
  119. package/src/ui/badge.stories.tsx +87 -0
  120. package/src/ui/badge.tsx +21 -0
  121. package/src/ui/button.stories.tsx +146 -0
  122. package/src/ui/button.tsx +37 -0
  123. package/src/ui/collapsible.tsx +14 -0
  124. package/src/ui/dialog.tsx +21 -0
  125. package/src/ui/dropdown.tsx +26 -0
  126. package/src/ui/hover-card.tsx +48 -0
  127. package/src/ui/resizable.stories.tsx +171 -0
  128. package/src/ui/resizable.tsx +219 -0
  129. package/src/ui/scroll-area.tsx +13 -0
  130. package/src/ui/separator.stories.tsx +82 -0
  131. package/src/ui/separator.tsx +10 -0
  132. package/src/ui/skeleton.stories.tsx +338 -0
  133. package/src/ui/skeleton.tsx +16 -0
  134. package/src/ui/textarea.tsx +21 -0
  135. package/src/ui/tooltip.stories.tsx +75 -0
  136. package/src/ui/tooltip.tsx +22 -0
  137. package/src/utils/cn.ts +6 -0
  138. package/theme.css +115 -0
@@ -0,0 +1,115 @@
1
+ import { type JSX, Show, splitProps } from 'solid-js';
2
+ import { cn } from '../utils/cn';
3
+ import { Button } from '../ui/button';
4
+
5
+ export interface PromptSuggestionProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
6
+ children: JSX.Element | string;
7
+ variant?: 'outline' | 'ghost' | 'default';
8
+ size?: 'sm' | 'md' | 'lg' | 'icon' | 'icon-sm';
9
+ highlight?: string;
10
+ /** Render as a full-width, left-aligned list row (the "suggested questions"
11
+ * idiom) instead of a rounded pill. Wraps long text. Ignored in highlight
12
+ * mode, which is always a list row. */
13
+ block?: boolean;
14
+ }
15
+
16
+ function PromptSuggestion(props: PromptSuggestionProps) {
17
+ const [local, rest] = splitProps(props, ['children', 'variant', 'size', 'class', 'highlight', 'block']);
18
+
19
+ const isHighlightMode = () => local.highlight !== undefined && local.highlight.trim() !== '';
20
+ const content = () => typeof local.children === 'string' ? local.children : '';
21
+
22
+ return (
23
+ <Show
24
+ when={isHighlightMode()}
25
+ fallback={
26
+ <Show
27
+ when={local.block}
28
+ fallback={
29
+ <Button
30
+ variant={local.variant ?? 'outline'}
31
+ size={local.size ?? 'lg'}
32
+ class={cn('rounded-full', local.class)}
33
+ {...rest}
34
+ >
35
+ {local.children}
36
+ </Button>
37
+ }
38
+ >
39
+ <Button
40
+ variant={local.variant ?? 'outline'}
41
+ size={local.size ?? 'md'}
42
+ class={cn(
43
+ 'h-auto w-full cursor-pointer justify-start rounded-xl px-4 py-2.5',
44
+ 'text-left text-sm leading-snug whitespace-normal text-pretty',
45
+ local.class,
46
+ )}
47
+ {...rest}
48
+ >
49
+ {local.children}
50
+ </Button>
51
+ </Show>
52
+ }
53
+ >
54
+ <Show
55
+ when={content()}
56
+ fallback={
57
+ <Button
58
+ variant={local.variant ?? 'ghost'}
59
+ size={local.size ?? 'sm'}
60
+ class={cn(
61
+ 'w-full cursor-pointer justify-start rounded-xl py-2',
62
+ 'hover:bg-accent',
63
+ local.class
64
+ )}
65
+ {...rest}
66
+ >
67
+ {local.children}
68
+ </Button>
69
+ }
70
+ >
71
+ <Button
72
+ variant={local.variant ?? 'ghost'}
73
+ size={local.size ?? 'sm'}
74
+ class={cn(
75
+ 'w-full cursor-pointer justify-start gap-0 rounded-xl py-2',
76
+ 'hover:bg-accent',
77
+ local.class
78
+ )}
79
+ {...rest}
80
+ >
81
+ {renderHighlighted(content(), local.highlight!)}
82
+ </Button>
83
+ </Show>
84
+ </Show>
85
+ );
86
+ }
87
+
88
+ function renderHighlighted(text: string, highlight: string) {
89
+ const trimmed = highlight.trim();
90
+ const textLower = text.toLowerCase();
91
+ const highlightLower = trimmed.toLowerCase();
92
+ const index = textLower.indexOf(highlightLower);
93
+
94
+ if (index === -1) {
95
+ return <span class="text-muted-foreground whitespace-pre-wrap">{text}</span>;
96
+ }
97
+
98
+ const before = text.substring(0, index);
99
+ const matched = text.substring(index, index + highlightLower.length);
100
+ const after = text.substring(index + matched.length);
101
+
102
+ return (
103
+ <>
104
+ <Show when={before}>
105
+ <span class="text-muted-foreground whitespace-pre-wrap">{before}</span>
106
+ </Show>
107
+ <span class="text-primary font-medium whitespace-pre-wrap">{matched}</span>
108
+ <Show when={after}>
109
+ <span class="text-muted-foreground whitespace-pre-wrap">{after}</span>
110
+ </Show>
111
+ </>
112
+ );
113
+ }
114
+
115
+ export { PromptSuggestion };
@@ -0,0 +1,141 @@
1
+ import type { Meta, StoryObj } from 'storybook-solidjs-vite';
2
+ import { fn } from 'storybook/test';
3
+ import { createSignal } from 'solid-js';
4
+ import { Reasoning, ReasoningTrigger, ReasoningContent } from './reasoning';
5
+
6
+ const meta = {
7
+ title: 'Components/Reasoning',
8
+ component: Reasoning,
9
+ tags: ['autodocs'],
10
+ parameters: {
11
+ layout: 'padded',
12
+ docs: {
13
+ description: {
14
+ component: [
15
+ 'A collapsible disclosure for a model\'s thinking/reasoning. Compose `Reasoning` (root) with `ReasoningTrigger` (the toggle) and `ReasoningContent` (the body, with optional markdown).',
16
+ '**When to use:** to surface an assistant\'s chain-of-thought or scratch reasoning that should be available but collapsed by default. It can auto-open while streaming and auto-close when streaming ends.',
17
+ '**How to use:** wrap a trigger and content in `Reasoning`. Leave it uncontrolled, or drive it with `open` + `onOpenChange`. Set `isStreaming` to auto-open during generation. Pass `markdown` on `ReasoningContent` to render a markdown string.',
18
+ '**Placement:** inside or above an assistant message, before the final answer.',
19
+ ].join('\n\n'),
20
+ },
21
+ controls: { exclude: ['use:eventListener'] },
22
+ },
23
+ },
24
+ argTypes: {
25
+ open: {
26
+ control: 'boolean',
27
+ description: 'Controlled open state. Omit for uncontrolled behavior.',
28
+ },
29
+ isStreaming: {
30
+ control: 'boolean',
31
+ description: 'When true, auto-opens the disclosure; auto-closes when it returns to false.',
32
+ table: { defaultValue: { summary: 'false' } },
33
+ },
34
+ onOpenChange: {
35
+ action: 'openChange',
36
+ description: 'Fired with the next open state when the trigger is toggled.',
37
+ table: { category: 'Events' },
38
+ },
39
+ children: { control: false, description: 'Trigger and content composition.' },
40
+ },
41
+ args: {
42
+ isStreaming: false,
43
+ onOpenChange: fn(),
44
+ },
45
+ render: (args) => (
46
+ <Reasoning {...args}>
47
+ <ReasoningTrigger>View reasoning</ReasoningTrigger>
48
+ <ReasoningContent>
49
+ <p>The user is asking about reactive programming. Let me break down the key concepts of signals, effects, and memos in SolidJS.</p>
50
+ </ReasoningContent>
51
+ </Reasoning>
52
+ ),
53
+ } satisfies Meta<typeof Reasoning>;
54
+
55
+ export default meta;
56
+ type Story = StoryObj<typeof meta>;
57
+
58
+ const IMPORT = `import { Reasoning, ReasoningTrigger, ReasoningContent } from '@kitnai/chat';`;
59
+ const src = (code: string) => ({
60
+ parameters: { docs: { source: { code: `${IMPORT}\n\n${code}`, language: 'tsx' } } },
61
+ });
62
+
63
+ /** Interactive playground — toggle `open` / `isStreaming` via the controls. */
64
+ export const Playground: Story = {
65
+ ...src(`<Reasoning>
66
+ <ReasoningTrigger>View reasoning</ReasoningTrigger>
67
+ <ReasoningContent>
68
+ <p>Let me break down signals, effects, and memos in SolidJS.</p>
69
+ </ReasoningContent>
70
+ </Reasoning>`),
71
+ };
72
+
73
+ /** Content rendered from a markdown string. */
74
+ export const WithMarkdown: Story = {
75
+ render: (args: Parameters<NonNullable<Story['render']>>[0]) => (
76
+ <Reasoning {...args}>
77
+ <ReasoningTrigger>View reasoning</ReasoningTrigger>
78
+ <ReasoningContent markdown>
79
+ {'The user wants to understand **reactive primitives**.\n\n- `createSignal` for state\n- `createEffect` for side effects\n- `createMemo` for derived values'}
80
+ </ReasoningContent>
81
+ </Reasoning>
82
+ ),
83
+ ...src(`<Reasoning>
84
+ <ReasoningTrigger>View reasoning</ReasoningTrigger>
85
+ <ReasoningContent markdown>
86
+ {'The user wants **reactive primitives**.\\n\\n- \`createSignal\`\\n- \`createEffect\`\\n- \`createMemo\`'}
87
+ </ReasoningContent>
88
+ </Reasoning>`),
89
+ };
90
+
91
+ /** Controlled via `open` + `onOpenChange`, starting open (showcase). */
92
+ export const Controlled: Story = {
93
+ render: () => {
94
+ const [open, setOpen] = createSignal(true);
95
+ return (
96
+ <Reasoning open={open()} onOpenChange={setOpen}>
97
+ <ReasoningTrigger>Thinking process</ReasoningTrigger>
98
+ <ReasoningContent>
99
+ <p>This is a controlled reasoning component that starts open.</p>
100
+ </ReasoningContent>
101
+ </Reasoning>
102
+ );
103
+ },
104
+ ...src(`const [open, setOpen] = createSignal(true);
105
+
106
+ <Reasoning open={open()} onOpenChange={setOpen}>
107
+ <ReasoningTrigger>Thinking process</ReasoningTrigger>
108
+ <ReasoningContent>
109
+ <p>This is a controlled reasoning component that starts open.</p>
110
+ </ReasoningContent>
111
+ </Reasoning>`),
112
+ };
113
+
114
+ /** Auto-opens while streaming, auto-closes when it ends (showcase). */
115
+ export const Streaming: Story = {
116
+ render: () => {
117
+ const [streaming, setStreaming] = createSignal(true);
118
+ return (
119
+ <div class="space-y-4">
120
+ <button
121
+ class="rounded bg-primary px-3 py-1 text-sm text-primary-foreground"
122
+ onClick={() => setStreaming((s) => !s)}
123
+ >
124
+ {streaming() ? 'Stop streaming' : 'Start streaming'}
125
+ </button>
126
+ <Reasoning isStreaming={streaming()}>
127
+ <ReasoningTrigger>Thinking...</ReasoningTrigger>
128
+ <ReasoningContent>
129
+ <p>Auto-opens during streaming and auto-closes when streaming ends.</p>
130
+ </ReasoningContent>
131
+ </Reasoning>
132
+ </div>
133
+ );
134
+ },
135
+ ...src(`<Reasoning isStreaming={streaming()}>
136
+ <ReasoningTrigger>Thinking...</ReasoningTrigger>
137
+ <ReasoningContent>
138
+ <p>Auto-opens during streaming and auto-closes when streaming ends.</p>
139
+ </ReasoningContent>
140
+ </Reasoning>`),
141
+ };
@@ -0,0 +1,157 @@
1
+ import { type JSX, splitProps, createSignal, createContext, useContext, createEffect, onCleanup, Show } from 'solid-js';
2
+ import { cn } from '../utils/cn';
3
+ import { ChevronDown } from 'lucide-solid';
4
+ import { Markdown } from './markdown';
5
+
6
+ interface ReasoningContextValue {
7
+ isOpen: () => boolean;
8
+ onOpenChange: (open: boolean) => void;
9
+ }
10
+
11
+ const ReasoningContext = createContext<ReasoningContextValue>();
12
+
13
+ function useReasoningContext() {
14
+ const context = useContext(ReasoningContext);
15
+ if (!context) {
16
+ throw new Error('useReasoningContext must be used within a Reasoning provider');
17
+ }
18
+ return context;
19
+ }
20
+
21
+ // --- Reasoning (Root) ---
22
+
23
+ export interface ReasoningProps {
24
+ children: JSX.Element;
25
+ class?: string;
26
+ open?: boolean;
27
+ onOpenChange?: (open: boolean) => void;
28
+ isStreaming?: boolean;
29
+ }
30
+
31
+ function Reasoning(props: ReasoningProps) {
32
+ const [local] = splitProps(props, ['children', 'class', 'open', 'onOpenChange', 'isStreaming']);
33
+ const [internalOpen, setInternalOpen] = createSignal(false);
34
+ const [wasAutoOpened, setWasAutoOpened] = createSignal(false);
35
+
36
+ const isControlled = () => local.open !== undefined;
37
+ const isOpen = () => (isControlled() ? local.open! : internalOpen());
38
+
39
+ const handleOpenChange = (newOpen: boolean) => {
40
+ if (!isControlled()) {
41
+ setInternalOpen(newOpen);
42
+ }
43
+ local.onOpenChange?.(newOpen);
44
+ };
45
+
46
+ createEffect(() => {
47
+ const streaming = local.isStreaming;
48
+ if (streaming && !wasAutoOpened()) {
49
+ if (!isControlled()) setInternalOpen(true);
50
+ setWasAutoOpened(true);
51
+ }
52
+ if (!streaming && wasAutoOpened()) {
53
+ if (!isControlled()) setInternalOpen(false);
54
+ setWasAutoOpened(false);
55
+ }
56
+ });
57
+
58
+ return (
59
+ <ReasoningContext.Provider value={{ isOpen, onOpenChange: handleOpenChange }}>
60
+ <div class={local.class}>{local.children}</div>
61
+ </ReasoningContext.Provider>
62
+ );
63
+ }
64
+
65
+ // --- ReasoningTrigger ---
66
+
67
+ export interface ReasoningTriggerProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {
68
+ children: JSX.Element;
69
+ }
70
+
71
+ function ReasoningTrigger(props: ReasoningTriggerProps) {
72
+ const [local, rest] = splitProps(props, ['children', 'class']);
73
+ const { isOpen, onOpenChange } = useReasoningContext();
74
+
75
+ return (
76
+ <button
77
+ class={cn('flex cursor-pointer items-center gap-2', local.class)}
78
+ onClick={() => onOpenChange(!isOpen())}
79
+ {...rest}
80
+ >
81
+ <span class="text-primary">{local.children}</span>
82
+ <div
83
+ class={cn(
84
+ 'transform transition-transform',
85
+ isOpen() ? 'rotate-180' : ''
86
+ )}
87
+ >
88
+ <ChevronDown class="size-4" />
89
+ </div>
90
+ </button>
91
+ );
92
+ }
93
+
94
+ // --- ReasoningContent ---
95
+
96
+ export interface ReasoningContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
97
+ children: JSX.Element;
98
+ markdown?: boolean;
99
+ contentClass?: string;
100
+ }
101
+
102
+ function ReasoningContent(props: ReasoningContentProps) {
103
+ const [local, rest] = splitProps(props, ['children', 'class', 'contentClass', 'markdown']);
104
+ const { isOpen } = useReasoningContext();
105
+
106
+ let contentRef: HTMLDivElement | undefined;
107
+ let innerRef: HTMLDivElement | undefined;
108
+
109
+ createEffect(() => {
110
+ if (!contentRef || !innerRef) return;
111
+
112
+ const observer = new ResizeObserver(() => {
113
+ if (contentRef && innerRef && isOpen()) {
114
+ contentRef.style.maxHeight = `${innerRef.scrollHeight}px`;
115
+ }
116
+ });
117
+
118
+ observer.observe(innerRef);
119
+
120
+ if (isOpen()) {
121
+ contentRef.style.maxHeight = `${innerRef.scrollHeight}px`;
122
+ } else {
123
+ contentRef.style.maxHeight = '0px';
124
+ }
125
+
126
+ onCleanup(() => observer.disconnect());
127
+ });
128
+
129
+ return (
130
+ <div
131
+ ref={contentRef}
132
+ class={cn(
133
+ 'overflow-hidden transition-[max-height] duration-150 ease-out',
134
+ local.class
135
+ )}
136
+ style={{ 'max-height': '0px' }}
137
+ {...rest}
138
+ >
139
+ <div
140
+ ref={innerRef}
141
+ class={cn(
142
+ // Markdown content is styled by the token-based `.chat-markdown` (see
143
+ // Markdown component), which themes via design tokens — so no Tailwind
144
+ // `prose`/`dark:prose-invert` is needed (those wouldn't follow a scoped theme).
145
+ 'text-muted-foreground',
146
+ local.contentClass
147
+ )}
148
+ >
149
+ <Show when={local.markdown} fallback={local.children}>
150
+ <Markdown content={local.children as string} />
151
+ </Show>
152
+ </div>
153
+ </div>
154
+ );
155
+ }
156
+
157
+ export { Reasoning, ReasoningTrigger, ReasoningContent };
@@ -0,0 +1,103 @@
1
+ import { splitProps, Show, For, createEffect, on } from 'solid-js';
2
+ import { cn } from '../utils/cn';
3
+ import { useTextStream } from '../primitives/use-text-stream';
4
+
5
+ export type Mode = 'typewriter' | 'fade';
6
+
7
+ export interface ResponseStreamProps {
8
+ textStream: string | AsyncIterable<string>;
9
+ mode?: Mode;
10
+ speed?: number;
11
+ class?: string;
12
+ onComplete?: () => void;
13
+ as?: string;
14
+ fadeDuration?: number;
15
+ segmentDelay?: number;
16
+ characterChunkSize?: number;
17
+ }
18
+
19
+ function ResponseStream(props: ResponseStreamProps) {
20
+ const [local] = splitProps(props, [
21
+ 'textStream', 'mode', 'speed', 'class', 'onComplete',
22
+ 'as', 'fadeDuration', 'segmentDelay', 'characterChunkSize',
23
+ ]);
24
+
25
+ const mode = () => local.mode ?? 'typewriter';
26
+ const speed = () => local.speed ?? 20;
27
+
28
+ const stream = useTextStream({
29
+ mode: mode(),
30
+ speed: speed(),
31
+ characterChunkSize: local.characterChunkSize,
32
+ fadeDuration: local.fadeDuration,
33
+ });
34
+
35
+ createEffect(on(
36
+ () => local.textStream,
37
+ (source) => {
38
+ if (source) stream.startStreaming(source);
39
+ }
40
+ ));
41
+
42
+ createEffect(on(
43
+ () => stream.isComplete(),
44
+ (complete) => {
45
+ if (complete) local.onComplete?.();
46
+ }
47
+ ));
48
+
49
+ const fadeStyle = () => {
50
+ const dur = local.fadeDuration ?? Math.round(1000 / Math.sqrt(Math.min(100, Math.max(1, speed()))));
51
+ return `
52
+ @keyframes fadeIn {
53
+ from { opacity: 0; }
54
+ to { opacity: 1; }
55
+ }
56
+ .fade-segment {
57
+ display: inline-block;
58
+ opacity: 0;
59
+ animation: fadeIn ${dur}ms ease-out forwards;
60
+ }
61
+ .fade-segment-space {
62
+ white-space: pre;
63
+ }
64
+ `;
65
+ };
66
+
67
+ const segDelay = () => {
68
+ if (typeof local.segmentDelay === 'number') return Math.max(0, local.segmentDelay);
69
+ const normalizedSpeed = Math.min(100, Math.max(1, speed()));
70
+ return Math.max(1, Math.round(100 / Math.sqrt(normalizedSpeed)));
71
+ };
72
+
73
+ return (
74
+ <div class={local.class}>
75
+ <Show
76
+ when={mode() === 'fade'}
77
+ fallback={<>{stream.displayedText()}</>}
78
+ >
79
+ <style>{fadeStyle()}</style>
80
+ <div class="relative">
81
+ <For each={stream.segments()}>
82
+ {(segment, idx) => {
83
+ const isWhitespace = () => /^\s+$/.test(segment.text);
84
+ return (
85
+ <span
86
+ class={cn(
87
+ 'fade-segment',
88
+ isWhitespace() && 'fade-segment-space'
89
+ )}
90
+ style={{ 'animation-delay': `${idx() * segDelay()}ms` }}
91
+ >
92
+ {segment.text}
93
+ </span>
94
+ );
95
+ }}
96
+ </For>
97
+ </div>
98
+ </Show>
99
+ </div>
100
+ );
101
+ }
102
+
103
+ export { ResponseStream };
@@ -0,0 +1,101 @@
1
+ import type { Meta, StoryObj } from 'storybook-solidjs-vite';
2
+ import { For } from 'solid-js';
3
+ import { ScrollButton } from './scroll-button';
4
+ import { ChatContainerRoot, ChatContainerContent } from './chat-container';
5
+
6
+ /**
7
+ * `ScrollButton` reads scroll state from the surrounding `ChatContainerRoot`
8
+ * context, so every story wraps it in a scrollable container. It is hidden
9
+ * (faded out) while pinned to the bottom and appears once you scroll up.
10
+ */
11
+ function ScrollDemo(props: { variant?: 'outline' | 'ghost' | 'default'; size?: 'sm' | 'md' | 'lg' | 'icon' | 'icon-sm'; class?: string }) {
12
+ return (
13
+ <div class="relative h-64 w-80 overflow-hidden rounded-lg border">
14
+ <ChatContainerRoot class="h-full p-4">
15
+ <ChatContainerContent class="gap-2">
16
+ <For each={Array.from({ length: 20 })}>
17
+ {(_, i) => (
18
+ <div class="rounded-md bg-muted/40 px-3 py-2 text-sm">Message {i() + 1}</div>
19
+ )}
20
+ </For>
21
+ </ChatContainerContent>
22
+ {/* ScrollButton must live INSIDE ChatContainerRoot (it reads that
23
+ context); it's absolutely positioned relative to the outer .relative
24
+ box, so it stays pinned and doesn't scroll with the content. */}
25
+ <div class="absolute inset-x-0 bottom-3 flex justify-center">
26
+ <ScrollButton variant={props.variant} size={props.size} class={props.class} />
27
+ </div>
28
+ </ChatContainerRoot>
29
+ </div>
30
+ );
31
+ }
32
+
33
+ const meta = {
34
+ title: 'Components/ScrollButton',
35
+ component: ScrollButton,
36
+ tags: ['autodocs'],
37
+ parameters: {
38
+ layout: 'padded',
39
+ docs: {
40
+ description: {
41
+ component: [
42
+ 'A floating "scroll to bottom" button wired to the enclosing `ChatContainerRoot`. It calls `scrollToBottom()` on click and animates in/out based on `isAtBottom`.',
43
+ '**When to use:** in a scrollable message log, to let the user jump back to the latest message after scrolling up. It hides itself automatically while pinned to the bottom.',
44
+ '**How to use:** render it inside a `ChatContainerRoot` (it consumes that context). Position it with absolute layout and optionally restyle via `variant`, `size`, and `class`.',
45
+ '**Placement:** overlaid near the bottom-center of the chat message area.',
46
+ ].join('\n\n'),
47
+ },
48
+ controls: { exclude: ['use:eventListener'] },
49
+ },
50
+ },
51
+ argTypes: {
52
+ variant: {
53
+ control: 'select',
54
+ options: ['default', 'ghost', 'outline'],
55
+ description: 'Underlying button visual emphasis.',
56
+ table: { defaultValue: { summary: 'outline' } },
57
+ },
58
+ size: {
59
+ control: 'select',
60
+ options: ['sm', 'md', 'lg', 'icon', 'icon-sm'],
61
+ description: 'Underlying button size preset.',
62
+ table: { defaultValue: { summary: 'sm' } },
63
+ },
64
+ class: {
65
+ control: 'text',
66
+ description: 'Additional classes merged onto the button.',
67
+ },
68
+ },
69
+ args: {
70
+ variant: 'outline',
71
+ size: 'sm',
72
+ },
73
+ render: (args) => <ScrollDemo variant={args.variant} size={args.size} class={args.class} />,
74
+ } satisfies Meta<typeof ScrollButton>;
75
+
76
+ export default meta;
77
+ type Story = StoryObj<typeof meta>;
78
+
79
+ const IMPORT = `import { ScrollButton, ChatContainerRoot, ChatContainerContent } from '@kitnai/chat';`;
80
+ const src = (code: string) => ({
81
+ parameters: { docs: { source: { code: `${IMPORT}\n\n${code}`, language: 'tsx' } } },
82
+ });
83
+
84
+ /** Interactive playground — scroll up to reveal the button; tweak `variant`/`size`. */
85
+ export const Playground: Story = {
86
+ ...src(`<div class="relative">
87
+ <ChatContainerRoot class="h-full overflow-y-auto">
88
+ <ChatContainerContent>{/* messages */}</ChatContainerContent>
89
+ {/* inside the Root (reads its context); absolutely positioned to stay pinned */}
90
+ <div class="absolute inset-x-0 bottom-3 flex justify-center">
91
+ <ScrollButton />
92
+ </div>
93
+ </ChatContainerRoot>
94
+ </div>`),
95
+ };
96
+
97
+ /** Ghost variant overlaid in a chat area (showcase). */
98
+ export const Ghost: Story = {
99
+ args: { variant: 'ghost' },
100
+ ...src(`<ScrollButton variant="ghost" />`),
101
+ };
@@ -0,0 +1,33 @@
1
+ import { cn } from '../utils/cn';
2
+ import { Button } from '../ui/button';
3
+ import { useChatContainer } from './chat-container';
4
+ import { ChevronDown } from 'lucide-solid';
5
+
6
+ export interface ScrollButtonProps {
7
+ class?: string;
8
+ variant?: 'outline' | 'ghost' | 'default';
9
+ size?: 'sm' | 'md' | 'lg' | 'icon' | 'icon-sm';
10
+ }
11
+
12
+ function ScrollButton(props: ScrollButtonProps) {
13
+ const { isAtBottom, scrollToBottom } = useChatContainer();
14
+
15
+ return (
16
+ <Button
17
+ variant={props.variant ?? 'outline'}
18
+ size={props.size ?? 'sm'}
19
+ class={cn(
20
+ 'h-10 w-10 rounded-full transition-all duration-150 ease-out',
21
+ !isAtBottom()
22
+ ? 'translate-y-0 scale-100 opacity-100'
23
+ : 'pointer-events-none translate-y-4 scale-95 opacity-0',
24
+ props.class
25
+ )}
26
+ onClick={() => scrollToBottom()}
27
+ >
28
+ <ChevronDown class="h-5 w-5" />
29
+ </Button>
30
+ );
31
+ }
32
+
33
+ export { ScrollButton };