@gmickel/gno 0.3.5 → 0.4.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 (53) hide show
  1. package/README.md +64 -1
  2. package/package.json +30 -1
  3. package/src/cli/commands/ask.ts +11 -186
  4. package/src/cli/commands/models/pull.ts +9 -4
  5. package/src/cli/commands/serve.ts +19 -0
  6. package/src/cli/program.ts +28 -0
  7. package/src/llm/registry.ts +3 -1
  8. package/src/pipeline/answer.ts +191 -0
  9. package/src/serve/CLAUDE.md +91 -0
  10. package/src/serve/bunfig.toml +2 -0
  11. package/src/serve/context.ts +181 -0
  12. package/src/serve/index.ts +7 -0
  13. package/src/serve/public/app.tsx +56 -0
  14. package/src/serve/public/components/ai-elements/code-block.tsx +176 -0
  15. package/src/serve/public/components/ai-elements/conversation.tsx +98 -0
  16. package/src/serve/public/components/ai-elements/inline-citation.tsx +285 -0
  17. package/src/serve/public/components/ai-elements/loader.tsx +96 -0
  18. package/src/serve/public/components/ai-elements/message.tsx +443 -0
  19. package/src/serve/public/components/ai-elements/prompt-input.tsx +1421 -0
  20. package/src/serve/public/components/ai-elements/sources.tsx +75 -0
  21. package/src/serve/public/components/ai-elements/suggestion.tsx +51 -0
  22. package/src/serve/public/components/preset-selector.tsx +403 -0
  23. package/src/serve/public/components/ui/badge.tsx +46 -0
  24. package/src/serve/public/components/ui/button-group.tsx +82 -0
  25. package/src/serve/public/components/ui/button.tsx +62 -0
  26. package/src/serve/public/components/ui/card.tsx +92 -0
  27. package/src/serve/public/components/ui/carousel.tsx +244 -0
  28. package/src/serve/public/components/ui/collapsible.tsx +31 -0
  29. package/src/serve/public/components/ui/command.tsx +181 -0
  30. package/src/serve/public/components/ui/dialog.tsx +141 -0
  31. package/src/serve/public/components/ui/dropdown-menu.tsx +255 -0
  32. package/src/serve/public/components/ui/hover-card.tsx +42 -0
  33. package/src/serve/public/components/ui/input-group.tsx +167 -0
  34. package/src/serve/public/components/ui/input.tsx +21 -0
  35. package/src/serve/public/components/ui/progress.tsx +28 -0
  36. package/src/serve/public/components/ui/scroll-area.tsx +56 -0
  37. package/src/serve/public/components/ui/select.tsx +188 -0
  38. package/src/serve/public/components/ui/separator.tsx +26 -0
  39. package/src/serve/public/components/ui/table.tsx +114 -0
  40. package/src/serve/public/components/ui/textarea.tsx +18 -0
  41. package/src/serve/public/components/ui/tooltip.tsx +59 -0
  42. package/src/serve/public/globals.css +226 -0
  43. package/src/serve/public/hooks/use-api.ts +112 -0
  44. package/src/serve/public/index.html +13 -0
  45. package/src/serve/public/pages/Ask.tsx +442 -0
  46. package/src/serve/public/pages/Browse.tsx +270 -0
  47. package/src/serve/public/pages/Dashboard.tsx +202 -0
  48. package/src/serve/public/pages/DocView.tsx +302 -0
  49. package/src/serve/public/pages/Search.tsx +335 -0
  50. package/src/serve/routes/api.ts +763 -0
  51. package/src/serve/server.ts +249 -0
  52. package/src/store/sqlite/adapter.ts +47 -0
  53. package/src/store/types.ts +10 -0
@@ -0,0 +1,91 @@
1
+ # Web UI (gno serve)
2
+
3
+ Local web server for GNO search and document browsing.
4
+
5
+ ## Architecture
6
+
7
+ Uses same **"Ports without DI"** pattern as CLI/MCP (see root CLAUDE.md):
8
+ - Adapters instantiated directly in `context.ts`
9
+ - Pipeline code receives port interfaces
10
+ - No dependency injection
11
+
12
+ ```
13
+ src/serve/
14
+ ├── server.ts # Bun.serve() entry point
15
+ ├── context.ts # ServerContext with LLM ports
16
+ ├── routes/
17
+ │ └── api.ts # REST API handlers
18
+ └── public/ # React frontend (Bun HTML imports)
19
+ ├── App.tsx # Router
20
+ ├── pages/ # Page components
21
+ ├── components/ # UI components (ShadCN + AI Elements)
22
+ └── hooks/ # Custom hooks (useApi, etc.)
23
+ ```
24
+
25
+ ## Key Patterns
26
+
27
+ ### Ports (interfaces)
28
+ - `EmbeddingPort` - vector embeddings
29
+ - `GenerationPort` - LLM text generation
30
+ - `RerankPort` - cross-encoder reranking
31
+ - `VectorIndexPort` - vector search
32
+
33
+ ### ServerContext
34
+ Created at startup, holds all LLM ports and capabilities:
35
+ ```typescript
36
+ interface ServerContext {
37
+ store: SqliteAdapter;
38
+ config: Config;
39
+ vectorIndex: VectorIndexPort | null;
40
+ embedPort: EmbeddingPort | null;
41
+ genPort: GenerationPort | null;
42
+ rerankPort: RerankPort | null;
43
+ capabilities: { bm25, vector, hybrid, answer };
44
+ }
45
+ ```
46
+
47
+ ### Shared Pipeline Code
48
+ Answer generation uses shared module to stay in sync with CLI:
49
+ - `src/pipeline/answer.ts` - generateGroundedAnswer, processAnswerResult
50
+
51
+ ## API Endpoints
52
+
53
+ | Endpoint | Method | Description |
54
+ |----------|--------|-------------|
55
+ | `/api/health` | GET | Health check |
56
+ | `/api/status` | GET | Index stats, collections |
57
+ | `/api/capabilities` | GET | Available features |
58
+ | `/api/collections` | GET | List collections |
59
+ | `/api/docs` | GET | List documents |
60
+ | `/api/doc` | GET | Get document content |
61
+ | `/api/search` | POST | BM25 search |
62
+ | `/api/query` | POST | Hybrid search |
63
+ | `/api/ask` | POST | AI answer with citations |
64
+ | `/api/presets` | GET | List model presets |
65
+ | `/api/presets` | POST | Switch preset (hot-reload) |
66
+ | `/api/models/status` | GET | Download progress |
67
+ | `/api/models/pull` | POST | Start model download |
68
+
69
+ ## Frontend
70
+
71
+ - **Framework**: React (via Bun HTML imports)
72
+ - **Styling**: Tailwind CSS + ShadCN components
73
+ - **AI Elements**: Conversation, Message, Sources, CodeBlock, Loader
74
+ - **Routing**: Simple hash-free SPA routing in App.tsx
75
+
76
+ ## Development
77
+
78
+ ```bash
79
+ # Start dev server with HMR
80
+ bun run src/serve/index.ts
81
+
82
+ # Or via CLI
83
+ gno serve --port 3000
84
+ ```
85
+
86
+ ## Security
87
+
88
+ - Binds to `127.0.0.1` only (no LAN exposure)
89
+ - CSP headers on all responses
90
+ - CORS protection on POST endpoints
91
+ - No external font/script loading
@@ -0,0 +1,2 @@
1
+ [serve.static]
2
+ plugins = ["bun-plugin-tailwind"]
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Server context for web UI.
3
+ * Manages LLM ports and vector index for hybrid search and AI answers.
4
+ *
5
+ * @module src/serve/context
6
+ */
7
+
8
+ import type { Config } from '../config/types';
9
+ import { LlmAdapter } from '../llm/nodeLlamaCpp/adapter';
10
+ import { getActivePreset } from '../llm/registry';
11
+ import type {
12
+ DownloadProgress,
13
+ EmbeddingPort,
14
+ GenerationPort,
15
+ ModelType,
16
+ RerankPort,
17
+ } from '../llm/types';
18
+ import type { SqliteAdapter } from '../store/sqlite/adapter';
19
+ import { createVectorIndexPort, type VectorIndexPort } from '../store/vector';
20
+
21
+ // ─────────────────────────────────────────────────────────────────────────────
22
+ // Download State (in-memory, single user)
23
+ // ─────────────────────────────────────────────────────────────────────────────
24
+
25
+ export interface DownloadState {
26
+ active: boolean;
27
+ currentType: ModelType | null;
28
+ progress: DownloadProgress | null;
29
+ completed: ModelType[];
30
+ failed: Array<{ type: ModelType; error: string }>;
31
+ startedAt: number | null;
32
+ }
33
+
34
+ /** Global download state for polling */
35
+ export const downloadState: DownloadState = {
36
+ active: false,
37
+ currentType: null,
38
+ progress: null,
39
+ completed: [],
40
+ failed: [],
41
+ startedAt: null,
42
+ };
43
+
44
+ /** Reset download state */
45
+ export function resetDownloadState(): void {
46
+ downloadState.active = false;
47
+ downloadState.currentType = null;
48
+ downloadState.progress = null;
49
+ downloadState.completed = [];
50
+ downloadState.failed = [];
51
+ downloadState.startedAt = null;
52
+ }
53
+
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ // Server Context
56
+ // ─────────────────────────────────────────────────────────────────────────────
57
+
58
+ export interface ServerContext {
59
+ store: SqliteAdapter;
60
+ config: Config;
61
+ vectorIndex: VectorIndexPort | null;
62
+ embedPort: EmbeddingPort | null;
63
+ genPort: GenerationPort | null;
64
+ rerankPort: RerankPort | null;
65
+ capabilities: {
66
+ bm25: boolean;
67
+ vector: boolean;
68
+ hybrid: boolean;
69
+ answer: boolean;
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Initialize server context with LLM ports.
75
+ * Attempts to load models; missing models are logged but don't fail.
76
+ */
77
+ export async function createServerContext(
78
+ store: SqliteAdapter,
79
+ config: Config
80
+ ): Promise<ServerContext> {
81
+ let embedPort: EmbeddingPort | null = null;
82
+ let genPort: GenerationPort | null = null;
83
+ let rerankPort: RerankPort | null = null;
84
+ let vectorIndex: VectorIndexPort | null = null;
85
+
86
+ try {
87
+ const preset = getActivePreset(config);
88
+ const llm = new LlmAdapter(config);
89
+
90
+ // Try to create embedding port
91
+ const embedResult = await llm.createEmbeddingPort(preset.embed);
92
+ if (embedResult.ok) {
93
+ embedPort = embedResult.value;
94
+ const initResult = await embedPort.init();
95
+ if (initResult.ok) {
96
+ // Create vector index
97
+ const dimensions = embedPort.dimensions();
98
+ const db = store.getRawDb();
99
+ const vectorResult = await createVectorIndexPort(db, {
100
+ model: preset.embed,
101
+ dimensions,
102
+ });
103
+ if (vectorResult.ok) {
104
+ vectorIndex = vectorResult.value;
105
+ console.log('Vector search enabled');
106
+ }
107
+ }
108
+ }
109
+
110
+ // Try to create generation port
111
+ const genResult = await llm.createGenerationPort(preset.gen);
112
+ if (genResult.ok) {
113
+ genPort = genResult.value;
114
+ console.log('AI answer generation enabled');
115
+ }
116
+
117
+ // Try to create rerank port
118
+ const rerankResult = await llm.createRerankPort(preset.rerank);
119
+ if (rerankResult.ok) {
120
+ rerankPort = rerankResult.value;
121
+ console.log('Reranking enabled');
122
+ }
123
+ } catch (e) {
124
+ // Log but don't fail - models are optional
125
+ console.log(
126
+ 'LLM initialization skipped:',
127
+ e instanceof Error ? e.message : String(e)
128
+ );
129
+ }
130
+
131
+ const capabilities = {
132
+ bm25: true, // Always available
133
+ vector: vectorIndex?.searchAvailable ?? false,
134
+ hybrid: (vectorIndex?.searchAvailable ?? false) && embedPort !== null,
135
+ answer: genPort !== null,
136
+ };
137
+
138
+ return {
139
+ store,
140
+ config,
141
+ vectorIndex,
142
+ embedPort,
143
+ genPort,
144
+ rerankPort,
145
+ capabilities,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Dispose server context resources.
151
+ * Each port is disposed independently to prevent one failure from blocking others.
152
+ */
153
+ export async function disposeServerContext(ctx: ServerContext): Promise<void> {
154
+ const ports = [
155
+ { name: 'embed', port: ctx.embedPort },
156
+ { name: 'gen', port: ctx.genPort },
157
+ { name: 'rerank', port: ctx.rerankPort },
158
+ ];
159
+
160
+ for (const { name, port } of ports) {
161
+ if (port) {
162
+ try {
163
+ await port.dispose();
164
+ } catch (e) {
165
+ console.error(`Failed to dispose ${name} port:`, e);
166
+ }
167
+ }
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Reload server context with potentially new config.
173
+ * Disposes existing ports and recreates them.
174
+ */
175
+ export async function reloadServerContext(
176
+ ctx: ServerContext,
177
+ newConfig?: Config
178
+ ): Promise<ServerContext> {
179
+ await disposeServerContext(ctx);
180
+ return createServerContext(ctx.store, newConfig ?? ctx.config);
181
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Serve module exports.
3
+ *
4
+ * @module src/serve
5
+ */
6
+
7
+ export { type ServeOptions, type ServeResult, startServer } from './server';
@@ -0,0 +1,56 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+ import Ask from './pages/Ask';
4
+ import Browse from './pages/Browse';
5
+ import Dashboard from './pages/Dashboard';
6
+ import DocView from './pages/DocView';
7
+ import Search from './pages/Search';
8
+ import './globals.css';
9
+
10
+ type Route = '/' | '/search' | '/browse' | '/doc' | '/ask';
11
+ type Navigate = (to: string | number) => void;
12
+
13
+ const routes: Record<Route, React.ComponentType<{ navigate: Navigate }>> = {
14
+ '/': Dashboard,
15
+ '/search': Search,
16
+ '/browse': Browse,
17
+ '/doc': DocView,
18
+ '/ask': Ask,
19
+ };
20
+
21
+ function App() {
22
+ // Track full location (pathname + search) for proper query param handling
23
+ const [location, setLocation] = useState<string>(
24
+ window.location.pathname + window.location.search
25
+ );
26
+
27
+ useEffect(() => {
28
+ const handlePopState = () =>
29
+ setLocation(window.location.pathname + window.location.search);
30
+ window.addEventListener('popstate', handlePopState);
31
+ return () => window.removeEventListener('popstate', handlePopState);
32
+ }, []);
33
+
34
+ const navigate = (to: string | number) => {
35
+ if (typeof to === 'number') {
36
+ // Handle history.go(-1) style navigation
37
+ window.history.go(to);
38
+ return;
39
+ }
40
+ window.history.pushState({}, '', to);
41
+ setLocation(to);
42
+ };
43
+
44
+ // Extract base path for routing (ignore query params)
45
+ const basePath = location.split('?')[0] as Route;
46
+ const Page = routes[basePath] || Dashboard;
47
+
48
+ return <Page navigate={navigate} />;
49
+ }
50
+
51
+ const rootElement = document.getElementById('root');
52
+ if (!rootElement) {
53
+ throw new Error('Root element not found');
54
+ }
55
+ const root = createRoot(rootElement);
56
+ root.render(<App />);
@@ -0,0 +1,176 @@
1
+ import { CheckIcon, CopyIcon } from 'lucide-react';
2
+ import {
3
+ type ComponentProps,
4
+ createContext,
5
+ type HTMLAttributes,
6
+ useContext,
7
+ useEffect,
8
+ useRef,
9
+ useState,
10
+ } from 'react';
11
+ import { type BundledLanguage, codeToHtml, type ShikiTransformer } from 'shiki';
12
+ import { cn } from '../../lib/utils';
13
+ import { Button } from '../ui/button';
14
+
15
+ type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
16
+ code: string;
17
+ language: BundledLanguage;
18
+ showLineNumbers?: boolean;
19
+ };
20
+
21
+ interface CodeBlockContextType {
22
+ code: string;
23
+ }
24
+
25
+ const CodeBlockContext = createContext<CodeBlockContextType>({
26
+ code: '',
27
+ });
28
+
29
+ const lineNumberTransformer: ShikiTransformer = {
30
+ name: 'line-numbers',
31
+ line(node, line) {
32
+ node.children.unshift({
33
+ type: 'element',
34
+ tagName: 'span',
35
+ properties: {
36
+ className: [
37
+ 'inline-block',
38
+ 'min-w-10',
39
+ 'mr-4',
40
+ 'text-right',
41
+ 'select-none',
42
+ 'text-muted-foreground',
43
+ ],
44
+ },
45
+ children: [{ type: 'text', value: String(line) }],
46
+ });
47
+ },
48
+ };
49
+
50
+ export async function highlightCode(
51
+ code: string,
52
+ language: BundledLanguage,
53
+ showLineNumbers = false
54
+ ) {
55
+ const transformers: ShikiTransformer[] = showLineNumbers
56
+ ? [lineNumberTransformer]
57
+ : [];
58
+
59
+ return await Promise.all([
60
+ codeToHtml(code, {
61
+ lang: language,
62
+ theme: 'one-light',
63
+ transformers,
64
+ }),
65
+ codeToHtml(code, {
66
+ lang: language,
67
+ theme: 'one-dark-pro',
68
+ transformers,
69
+ }),
70
+ ]);
71
+ }
72
+
73
+ export const CodeBlock = ({
74
+ code,
75
+ language,
76
+ showLineNumbers = false,
77
+ className,
78
+ children,
79
+ ...props
80
+ }: CodeBlockProps) => {
81
+ const [html, setHtml] = useState<string>('');
82
+ const [darkHtml, setDarkHtml] = useState<string>('');
83
+ const mounted = useRef(false);
84
+
85
+ useEffect(() => {
86
+ highlightCode(code, language, showLineNumbers).then(([light, dark]) => {
87
+ if (!mounted.current) {
88
+ setHtml(light);
89
+ setDarkHtml(dark);
90
+ mounted.current = true;
91
+ }
92
+ });
93
+
94
+ return () => {
95
+ mounted.current = false;
96
+ };
97
+ }, [code, language, showLineNumbers]);
98
+
99
+ return (
100
+ <CodeBlockContext.Provider value={{ code }}>
101
+ <div
102
+ className={cn(
103
+ 'group relative w-full overflow-hidden rounded-md border bg-background text-foreground',
104
+ className
105
+ )}
106
+ {...props}
107
+ >
108
+ <div className="relative">
109
+ <div
110
+ className="overflow-auto dark:hidden [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
111
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
112
+ dangerouslySetInnerHTML={{ __html: html }}
113
+ />
114
+ <div
115
+ className="hidden overflow-auto dark:block [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
116
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
117
+ dangerouslySetInnerHTML={{ __html: darkHtml }}
118
+ />
119
+ {children && (
120
+ <div className="absolute top-2 right-2 flex items-center gap-2">
121
+ {children}
122
+ </div>
123
+ )}
124
+ </div>
125
+ </div>
126
+ </CodeBlockContext.Provider>
127
+ );
128
+ };
129
+
130
+ export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
131
+ onCopy?: () => void;
132
+ onError?: (error: Error) => void;
133
+ timeout?: number;
134
+ };
135
+
136
+ export const CodeBlockCopyButton = ({
137
+ onCopy,
138
+ onError,
139
+ timeout = 2000,
140
+ children,
141
+ className,
142
+ ...props
143
+ }: CodeBlockCopyButtonProps) => {
144
+ const [isCopied, setIsCopied] = useState(false);
145
+ const { code } = useContext(CodeBlockContext);
146
+
147
+ const copyToClipboard = async () => {
148
+ if (typeof window === 'undefined' || !navigator?.clipboard?.writeText) {
149
+ onError?.(new Error('Clipboard API not available'));
150
+ return;
151
+ }
152
+
153
+ try {
154
+ await navigator.clipboard.writeText(code);
155
+ setIsCopied(true);
156
+ onCopy?.();
157
+ setTimeout(() => setIsCopied(false), timeout);
158
+ } catch (error) {
159
+ onError?.(error as Error);
160
+ }
161
+ };
162
+
163
+ const Icon = isCopied ? CheckIcon : CopyIcon;
164
+
165
+ return (
166
+ <Button
167
+ className={cn('shrink-0', className)}
168
+ onClick={copyToClipboard}
169
+ size="icon"
170
+ variant="ghost"
171
+ {...props}
172
+ >
173
+ {children ?? <Icon size={14} />}
174
+ </Button>
175
+ );
176
+ };
@@ -0,0 +1,98 @@
1
+ import { ArrowDownIcon } from 'lucide-react';
2
+ import type { ComponentProps } from 'react';
3
+ import { useCallback } from 'react';
4
+ import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom';
5
+ import { cn } from '../../lib/utils';
6
+ import { Button } from '../ui/button';
7
+
8
+ export type ConversationProps = ComponentProps<typeof StickToBottom>;
9
+
10
+ export const Conversation = ({ className, ...props }: ConversationProps) => (
11
+ <StickToBottom
12
+ className={cn('relative flex-1 overflow-y-hidden', className)}
13
+ initial="smooth"
14
+ resize="smooth"
15
+ role="log"
16
+ {...props}
17
+ />
18
+ );
19
+
20
+ export type ConversationContentProps = ComponentProps<
21
+ typeof StickToBottom.Content
22
+ >;
23
+
24
+ export const ConversationContent = ({
25
+ className,
26
+ ...props
27
+ }: ConversationContentProps) => (
28
+ <StickToBottom.Content
29
+ className={cn('flex flex-col gap-8 p-4', className)}
30
+ {...props}
31
+ />
32
+ );
33
+
34
+ export type ConversationEmptyStateProps = ComponentProps<'div'> & {
35
+ title?: string;
36
+ description?: string;
37
+ icon?: React.ReactNode;
38
+ };
39
+
40
+ export const ConversationEmptyState = ({
41
+ className,
42
+ title = 'No messages yet',
43
+ description = 'Start a conversation to see messages here',
44
+ icon,
45
+ children,
46
+ ...props
47
+ }: ConversationEmptyStateProps) => (
48
+ <div
49
+ className={cn(
50
+ 'flex size-full flex-col items-center justify-center gap-3 p-8 text-center',
51
+ className
52
+ )}
53
+ {...props}
54
+ >
55
+ {children ?? (
56
+ <>
57
+ {icon && <div className="text-muted-foreground">{icon}</div>}
58
+ <div className="space-y-1">
59
+ <h3 className="font-medium text-sm">{title}</h3>
60
+ {description && (
61
+ <p className="text-muted-foreground text-sm">{description}</p>
62
+ )}
63
+ </div>
64
+ </>
65
+ )}
66
+ </div>
67
+ );
68
+
69
+ export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
70
+
71
+ export const ConversationScrollButton = ({
72
+ className,
73
+ ...props
74
+ }: ConversationScrollButtonProps) => {
75
+ const { isAtBottom, scrollToBottom } = useStickToBottomContext();
76
+
77
+ const handleScrollToBottom = useCallback(() => {
78
+ scrollToBottom();
79
+ }, [scrollToBottom]);
80
+
81
+ return (
82
+ !isAtBottom && (
83
+ <Button
84
+ className={cn(
85
+ 'absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full',
86
+ className
87
+ )}
88
+ onClick={handleScrollToBottom}
89
+ size="icon"
90
+ type="button"
91
+ variant="outline"
92
+ {...props}
93
+ >
94
+ <ArrowDownIcon className="size-4" />
95
+ </Button>
96
+ )
97
+ );
98
+ };