@gmickel/gno 0.3.4 → 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 (57) hide show
  1. package/README.md +194 -53
  2. package/assets/badges/license.svg +12 -0
  3. package/assets/badges/npm.svg +13 -0
  4. package/assets/badges/twitter.svg +22 -0
  5. package/assets/badges/website.svg +22 -0
  6. package/package.json +30 -1
  7. package/src/cli/commands/ask.ts +11 -186
  8. package/src/cli/commands/models/pull.ts +9 -4
  9. package/src/cli/commands/serve.ts +19 -0
  10. package/src/cli/program.ts +28 -0
  11. package/src/llm/registry.ts +3 -1
  12. package/src/pipeline/answer.ts +191 -0
  13. package/src/serve/CLAUDE.md +91 -0
  14. package/src/serve/bunfig.toml +2 -0
  15. package/src/serve/context.ts +181 -0
  16. package/src/serve/index.ts +7 -0
  17. package/src/serve/public/app.tsx +56 -0
  18. package/src/serve/public/components/ai-elements/code-block.tsx +176 -0
  19. package/src/serve/public/components/ai-elements/conversation.tsx +98 -0
  20. package/src/serve/public/components/ai-elements/inline-citation.tsx +285 -0
  21. package/src/serve/public/components/ai-elements/loader.tsx +96 -0
  22. package/src/serve/public/components/ai-elements/message.tsx +443 -0
  23. package/src/serve/public/components/ai-elements/prompt-input.tsx +1421 -0
  24. package/src/serve/public/components/ai-elements/sources.tsx +75 -0
  25. package/src/serve/public/components/ai-elements/suggestion.tsx +51 -0
  26. package/src/serve/public/components/preset-selector.tsx +403 -0
  27. package/src/serve/public/components/ui/badge.tsx +46 -0
  28. package/src/serve/public/components/ui/button-group.tsx +82 -0
  29. package/src/serve/public/components/ui/button.tsx +62 -0
  30. package/src/serve/public/components/ui/card.tsx +92 -0
  31. package/src/serve/public/components/ui/carousel.tsx +244 -0
  32. package/src/serve/public/components/ui/collapsible.tsx +31 -0
  33. package/src/serve/public/components/ui/command.tsx +181 -0
  34. package/src/serve/public/components/ui/dialog.tsx +141 -0
  35. package/src/serve/public/components/ui/dropdown-menu.tsx +255 -0
  36. package/src/serve/public/components/ui/hover-card.tsx +42 -0
  37. package/src/serve/public/components/ui/input-group.tsx +167 -0
  38. package/src/serve/public/components/ui/input.tsx +21 -0
  39. package/src/serve/public/components/ui/progress.tsx +28 -0
  40. package/src/serve/public/components/ui/scroll-area.tsx +56 -0
  41. package/src/serve/public/components/ui/select.tsx +188 -0
  42. package/src/serve/public/components/ui/separator.tsx +26 -0
  43. package/src/serve/public/components/ui/table.tsx +114 -0
  44. package/src/serve/public/components/ui/textarea.tsx +18 -0
  45. package/src/serve/public/components/ui/tooltip.tsx +59 -0
  46. package/src/serve/public/globals.css +226 -0
  47. package/src/serve/public/hooks/use-api.ts +112 -0
  48. package/src/serve/public/index.html +13 -0
  49. package/src/serve/public/pages/Ask.tsx +442 -0
  50. package/src/serve/public/pages/Browse.tsx +270 -0
  51. package/src/serve/public/pages/Dashboard.tsx +202 -0
  52. package/src/serve/public/pages/DocView.tsx +302 -0
  53. package/src/serve/public/pages/Search.tsx +335 -0
  54. package/src/serve/routes/api.ts +763 -0
  55. package/src/serve/server.ts +249 -0
  56. package/src/store/sqlite/adapter.ts +47 -0
  57. package/src/store/types.ts +10 -0
@@ -0,0 +1,75 @@
1
+ import { BookIcon, ChevronDownIcon } from 'lucide-react';
2
+ import type { ComponentProps } from 'react';
3
+ import { cn } from '../../lib/utils';
4
+ import {
5
+ Collapsible,
6
+ CollapsibleContent,
7
+ CollapsibleTrigger,
8
+ } from '../ui/collapsible';
9
+
10
+ export type SourcesProps = ComponentProps<'div'>;
11
+
12
+ export const Sources = ({ className, ...props }: SourcesProps) => (
13
+ <Collapsible
14
+ className={cn('not-prose mb-4 text-primary text-xs', className)}
15
+ {...props}
16
+ />
17
+ );
18
+
19
+ export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
20
+ count: number;
21
+ };
22
+
23
+ export const SourcesTrigger = ({
24
+ className,
25
+ count,
26
+ children,
27
+ ...props
28
+ }: SourcesTriggerProps) => (
29
+ <CollapsibleTrigger
30
+ className={cn('flex items-center gap-2', className)}
31
+ {...props}
32
+ >
33
+ {children ?? (
34
+ <>
35
+ <p className="font-medium">Used {count} sources</p>
36
+ <ChevronDownIcon className="h-4 w-4" />
37
+ </>
38
+ )}
39
+ </CollapsibleTrigger>
40
+ );
41
+
42
+ export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
43
+
44
+ export const SourcesContent = ({
45
+ className,
46
+ ...props
47
+ }: SourcesContentProps) => (
48
+ <CollapsibleContent
49
+ className={cn(
50
+ 'mt-3 flex w-fit flex-col gap-2',
51
+ 'data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in',
52
+ className
53
+ )}
54
+ {...props}
55
+ />
56
+ );
57
+
58
+ export type SourceProps = ComponentProps<'a'>;
59
+
60
+ export const Source = ({ href, title, children, ...props }: SourceProps) => (
61
+ <a
62
+ className="flex items-center gap-2"
63
+ href={href}
64
+ rel="noreferrer"
65
+ target="_blank"
66
+ {...props}
67
+ >
68
+ {children ?? (
69
+ <>
70
+ <BookIcon className="h-4 w-4" />
71
+ <span className="block font-medium">{title}</span>
72
+ </>
73
+ )}
74
+ </a>
75
+ );
@@ -0,0 +1,51 @@
1
+ import type { ComponentProps } from 'react';
2
+ import { cn } from '../../lib/utils';
3
+ import { Button } from '../ui/button';
4
+ import { ScrollArea, ScrollBar } from '../ui/scroll-area';
5
+
6
+ export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
7
+
8
+ export const Suggestions = ({
9
+ className,
10
+ children,
11
+ ...props
12
+ }: SuggestionsProps) => (
13
+ <ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
14
+ <div className={cn('flex w-max flex-nowrap items-center gap-2', className)}>
15
+ {children}
16
+ </div>
17
+ <ScrollBar className="hidden" orientation="horizontal" />
18
+ </ScrollArea>
19
+ );
20
+
21
+ export type SuggestionProps = Omit<ComponentProps<typeof Button>, 'onClick'> & {
22
+ suggestion: string;
23
+ onClick?: (suggestion: string) => void;
24
+ };
25
+
26
+ export const Suggestion = ({
27
+ suggestion,
28
+ onClick,
29
+ className,
30
+ variant = 'outline',
31
+ size = 'sm',
32
+ children,
33
+ ...props
34
+ }: SuggestionProps) => {
35
+ const handleClick = () => {
36
+ onClick?.(suggestion);
37
+ };
38
+
39
+ return (
40
+ <Button
41
+ className={cn('cursor-pointer rounded-full px-4', className)}
42
+ onClick={handleClick}
43
+ size={size}
44
+ type="button"
45
+ variant={variant}
46
+ {...props}
47
+ >
48
+ {children || suggestion}
49
+ </Button>
50
+ );
51
+ };
@@ -0,0 +1,403 @@
1
+ import {
2
+ AlertCircle,
3
+ CheckIcon,
4
+ ChevronDownIcon,
5
+ Download,
6
+ Loader2,
7
+ SlidersHorizontal,
8
+ } from 'lucide-react';
9
+ import { useCallback, useEffect, useRef, useState } from 'react';
10
+ import { apiFetch } from '../hooks/use-api';
11
+ import { Button } from './ui/button';
12
+ import {
13
+ DropdownMenu,
14
+ DropdownMenuContent,
15
+ DropdownMenuLabel,
16
+ DropdownMenuRadioGroup,
17
+ DropdownMenuRadioItem,
18
+ DropdownMenuSeparator,
19
+ DropdownMenuTrigger,
20
+ } from './ui/dropdown-menu';
21
+ import { Progress } from './ui/progress';
22
+
23
+ interface Preset {
24
+ id: string;
25
+ name: string;
26
+ embed: string;
27
+ rerank: string;
28
+ gen: string;
29
+ active: boolean;
30
+ }
31
+
32
+ interface PresetsResponse {
33
+ presets: Preset[];
34
+ activePreset: string;
35
+ capabilities: Capabilities;
36
+ }
37
+
38
+ interface Capabilities {
39
+ bm25: boolean;
40
+ vector: boolean;
41
+ hybrid: boolean;
42
+ answer: boolean;
43
+ }
44
+
45
+ interface SetPresetResponse {
46
+ success: boolean;
47
+ activePreset: string;
48
+ capabilities: Capabilities;
49
+ }
50
+
51
+ interface DownloadProgress {
52
+ downloadedBytes: number;
53
+ totalBytes: number;
54
+ percent: number;
55
+ }
56
+
57
+ interface DownloadStatus {
58
+ active: boolean;
59
+ currentType: string | null;
60
+ progress: DownloadProgress | null;
61
+ completed: string[];
62
+ failed: Array<{ type: string; error: string }>;
63
+ startedAt: number | null;
64
+ }
65
+
66
+ // Top-level regex patterns for performance
67
+ const SIZE_REGEX = /~[\d.]+GB/;
68
+ const DESC_REGEX = /\(([^,]+)/;
69
+
70
+ const KB = 1024;
71
+ const MB = KB * 1024;
72
+ const GB = MB * 1024;
73
+
74
+ function extractSize(name: string): string | null {
75
+ const match = name.match(SIZE_REGEX);
76
+ return match ? match[0] : null;
77
+ }
78
+
79
+ function extractDesc(name: string): string | null {
80
+ const match = name.match(DESC_REGEX);
81
+ return match ? match[1].trim() : null;
82
+ }
83
+
84
+ function extractBaseName(name: string): string {
85
+ return name.split('(')[0].trim();
86
+ }
87
+
88
+ function formatBytes(bytes: number): string {
89
+ if (bytes < KB) {
90
+ return `${bytes} B`;
91
+ }
92
+ if (bytes < MB) {
93
+ return `${(bytes / KB).toFixed(1)} KB`;
94
+ }
95
+ if (bytes < GB) {
96
+ return `${(bytes / MB).toFixed(1)} MB`;
97
+ }
98
+ return `${(bytes / GB).toFixed(2)} GB`;
99
+ }
100
+
101
+ function getButtonIcon(
102
+ switching: boolean,
103
+ downloading: boolean,
104
+ hasError: boolean
105
+ ) {
106
+ if (switching) {
107
+ return <Loader2 className="size-3.5 animate-spin text-primary" />;
108
+ }
109
+ if (downloading) {
110
+ return <Loader2 className="size-3.5 animate-spin text-blue-500" />;
111
+ }
112
+ if (hasError) {
113
+ return <AlertCircle className="size-3.5 text-amber-500" />;
114
+ }
115
+ return (
116
+ <SlidersHorizontal className="size-3.5 text-muted-foreground/70 transition-colors group-hover:text-primary" />
117
+ );
118
+ }
119
+
120
+ function getButtonLabel(
121
+ switching: boolean,
122
+ downloading: boolean,
123
+ activePreset: Preset | undefined
124
+ ): string {
125
+ if (switching) {
126
+ return 'Switching...';
127
+ }
128
+ if (downloading) {
129
+ return 'Downloading...';
130
+ }
131
+ return activePreset ? extractBaseName(activePreset.name) : 'Preset';
132
+ }
133
+
134
+ export function PresetSelector() {
135
+ const [presets, setPresets] = useState<Preset[]>([]);
136
+ const [activeId, setActiveId] = useState<string>('');
137
+ const [loading, setLoading] = useState(true);
138
+ const [switching, setSwitching] = useState(false);
139
+ const [error, setError] = useState<string | null>(null);
140
+ const [modelsNeeded, setModelsNeeded] = useState(false);
141
+
142
+ // Download state
143
+ const [downloading, setDownloading] = useState(false);
144
+ const [downloadStatus, setDownloadStatus] = useState<DownloadStatus | null>(
145
+ null
146
+ );
147
+ const pollInterval = useRef<ReturnType<typeof setInterval> | null>(null);
148
+
149
+ // Check capabilities and set modelsNeeded flag
150
+ const checkCapabilities = useCallback((caps: Capabilities) => {
151
+ const missing: string[] = [];
152
+ if (!caps.vector) {
153
+ missing.push('vector search');
154
+ }
155
+ if (!caps.answer) {
156
+ missing.push('AI answers');
157
+ }
158
+
159
+ if (missing.length > 0) {
160
+ setError(`Missing: ${missing.join(', ')}`);
161
+ setModelsNeeded(true);
162
+ } else {
163
+ setError(null);
164
+ setModelsNeeded(false);
165
+ }
166
+ }, []);
167
+
168
+ // Poll download status
169
+ const pollStatus = useCallback(async () => {
170
+ const { data } = await apiFetch<DownloadStatus>('/api/models/status');
171
+ if (data) {
172
+ setDownloadStatus(data);
173
+
174
+ // Download finished
175
+ if (!data.active && downloading) {
176
+ setDownloading(false);
177
+ if (pollInterval.current) {
178
+ clearInterval(pollInterval.current);
179
+ pollInterval.current = null;
180
+ }
181
+
182
+ // Refresh presets to get updated capabilities
183
+ const { data: presetsData } =
184
+ await apiFetch<PresetsResponse>('/api/presets');
185
+ if (presetsData) {
186
+ checkCapabilities(presetsData.capabilities);
187
+ }
188
+
189
+ // Show any failures
190
+ if (data.failed.length > 0) {
191
+ setError(`Failed: ${data.failed.map((f) => f.type).join(', ')}`);
192
+ }
193
+ }
194
+ }
195
+ }, [downloading, checkCapabilities]);
196
+
197
+ // Initial load
198
+ useEffect(() => {
199
+ apiFetch<PresetsResponse>('/api/presets').then(({ data }) => {
200
+ if (data) {
201
+ setPresets(data.presets);
202
+ setActiveId(data.activePreset);
203
+ checkCapabilities(data.capabilities);
204
+ }
205
+ setLoading(false);
206
+ });
207
+
208
+ // Check if download already in progress
209
+ apiFetch<DownloadStatus>('/api/models/status').then(({ data }) => {
210
+ if (data?.active) {
211
+ setDownloading(true);
212
+ setDownloadStatus(data);
213
+ }
214
+ });
215
+ }, [checkCapabilities]);
216
+
217
+ // Start/stop polling
218
+ useEffect(() => {
219
+ if (downloading && !pollInterval.current) {
220
+ pollInterval.current = setInterval(pollStatus, 1000);
221
+ }
222
+ return () => {
223
+ if (pollInterval.current) {
224
+ clearInterval(pollInterval.current);
225
+ pollInterval.current = null;
226
+ }
227
+ };
228
+ }, [downloading, pollStatus]);
229
+
230
+ const activePreset = presets.find((p) => p.id === activeId);
231
+
232
+ if (loading || presets.length === 0) {
233
+ return null;
234
+ }
235
+
236
+ const handleSelect = async (id: string) => {
237
+ if (id === activeId || switching || downloading) {
238
+ return;
239
+ }
240
+
241
+ setSwitching(true);
242
+ setError(null);
243
+
244
+ const { data, error: fetchError } = await apiFetch<SetPresetResponse>(
245
+ '/api/presets',
246
+ {
247
+ method: 'POST',
248
+ headers: { 'Content-Type': 'application/json' },
249
+ body: JSON.stringify({ presetId: id }),
250
+ }
251
+ );
252
+
253
+ setSwitching(false);
254
+
255
+ if (fetchError) {
256
+ setError(fetchError);
257
+ return;
258
+ }
259
+
260
+ if (data?.success) {
261
+ setActiveId(data.activePreset);
262
+ checkCapabilities(data.capabilities);
263
+ }
264
+ };
265
+
266
+ const handleDownload = async () => {
267
+ if (downloading) {
268
+ return;
269
+ }
270
+
271
+ setDownloading(true);
272
+ setError(null);
273
+
274
+ const { error: fetchError } = await apiFetch('/api/models/pull', {
275
+ method: 'POST',
276
+ });
277
+
278
+ if (fetchError) {
279
+ setError(fetchError);
280
+ setDownloading(false);
281
+ return;
282
+ }
283
+
284
+ // Start polling
285
+ pollStatus();
286
+ };
287
+
288
+ return (
289
+ <DropdownMenu>
290
+ <DropdownMenuTrigger asChild>
291
+ <Button
292
+ className="group gap-2 font-normal text-muted-foreground hover:text-foreground"
293
+ disabled={switching}
294
+ size="sm"
295
+ variant="ghost"
296
+ >
297
+ {getButtonIcon(
298
+ switching,
299
+ downloading,
300
+ Boolean(error || modelsNeeded)
301
+ )}
302
+ <span className="hidden sm:inline">
303
+ {getButtonLabel(switching, downloading, activePreset)}
304
+ </span>
305
+ <ChevronDownIcon className="size-3 opacity-50" />
306
+ </Button>
307
+ </DropdownMenuTrigger>
308
+ <DropdownMenuContent align="start" className="w-64 border-border bg-card">
309
+ <DropdownMenuLabel className="font-normal text-muted-foreground text-xs uppercase tracking-wider">
310
+ Model Preset
311
+ </DropdownMenuLabel>
312
+ <DropdownMenuSeparator />
313
+
314
+ {/* Download progress */}
315
+ {downloading && downloadStatus && (
316
+ <>
317
+ <div className="space-y-2 px-2 py-2">
318
+ <div className="flex items-center justify-between text-xs">
319
+ <span className="text-muted-foreground">
320
+ {downloadStatus.currentType || 'Starting...'}
321
+ </span>
322
+ {downloadStatus.progress && (
323
+ <span className="font-mono text-[10px] text-muted-foreground">
324
+ {formatBytes(downloadStatus.progress.downloadedBytes)} /{' '}
325
+ {formatBytes(downloadStatus.progress.totalBytes)}
326
+ </span>
327
+ )}
328
+ </div>
329
+ <Progress value={downloadStatus.progress?.percent ?? 0} />
330
+ {downloadStatus.completed.length > 0 && (
331
+ <div className="text-[10px] text-muted-foreground">
332
+ Done: {downloadStatus.completed.join(', ')}
333
+ </div>
334
+ )}
335
+ </div>
336
+ <DropdownMenuSeparator />
337
+ </>
338
+ )}
339
+
340
+ <DropdownMenuRadioGroup onValueChange={handleSelect} value={activeId}>
341
+ {presets.map((preset) => {
342
+ const baseName = extractBaseName(preset.name);
343
+ const desc = extractDesc(preset.name);
344
+ const size = extractSize(preset.name);
345
+
346
+ return (
347
+ <DropdownMenuRadioItem
348
+ className="cursor-pointer py-2.5"
349
+ disabled={switching || downloading}
350
+ key={preset.id}
351
+ value={preset.id}
352
+ >
353
+ <div className="flex w-full items-center justify-between gap-3">
354
+ <div className="flex flex-col gap-0.5">
355
+ <span className="font-medium">{baseName}</span>
356
+ {desc && (
357
+ <span className="text-muted-foreground text-xs">
358
+ {desc}
359
+ </span>
360
+ )}
361
+ </div>
362
+ <div className="flex items-center gap-2">
363
+ {size && (
364
+ <span className="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
365
+ {size}
366
+ </span>
367
+ )}
368
+ {preset.id === activeId && (
369
+ <CheckIcon className="size-4 text-primary" />
370
+ )}
371
+ </div>
372
+ </div>
373
+ </DropdownMenuRadioItem>
374
+ );
375
+ })}
376
+ </DropdownMenuRadioGroup>
377
+
378
+ {/* Error or download prompt */}
379
+ {(error || modelsNeeded) && !downloading && (
380
+ <>
381
+ <DropdownMenuSeparator />
382
+ <div className="space-y-2 px-2 py-2">
383
+ {error && (
384
+ <div className="text-[10px] text-destructive">{error}</div>
385
+ )}
386
+ {modelsNeeded && (
387
+ <Button
388
+ className="w-full gap-2"
389
+ onClick={handleDownload}
390
+ size="sm"
391
+ variant="outline"
392
+ >
393
+ <Download className="size-3.5" />
394
+ Download Models
395
+ </Button>
396
+ )}
397
+ </div>
398
+ </>
399
+ )}
400
+ </DropdownMenuContent>
401
+ </DropdownMenu>
402
+ );
403
+ }
@@ -0,0 +1,46 @@
1
+ import { Slot } from '@radix-ui/react-slot';
2
+ import { cva, type VariantProps } from 'class-variance-authority';
3
+ import type * as React from 'react';
4
+
5
+ import { cn } from '../../lib/utils';
6
+
7
+ const badgeVariants = cva(
8
+ 'inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border px-2 py-0.5 font-medium text-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3',
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default:
13
+ 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90',
14
+ secondary:
15
+ 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90',
16
+ destructive:
17
+ 'border-transparent bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90',
18
+ outline:
19
+ 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground',
20
+ },
21
+ },
22
+ defaultVariants: {
23
+ variant: 'default',
24
+ },
25
+ }
26
+ );
27
+
28
+ function Badge({
29
+ className,
30
+ variant,
31
+ asChild = false,
32
+ ...props
33
+ }: React.ComponentProps<'span'> &
34
+ VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
35
+ const Comp = asChild ? Slot : 'span';
36
+
37
+ return (
38
+ <Comp
39
+ className={cn(badgeVariants({ variant }), className)}
40
+ data-slot="badge"
41
+ {...props}
42
+ />
43
+ );
44
+ }
45
+
46
+ export { Badge, badgeVariants };
@@ -0,0 +1,82 @@
1
+ import { Slot } from '@radix-ui/react-slot';
2
+ import { cva, type VariantProps } from 'class-variance-authority';
3
+ import { cn } from '../../lib/utils';
4
+ import { Separator } from './separator';
5
+
6
+ const buttonGroupVariants = cva(
7
+ "flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
8
+ {
9
+ variants: {
10
+ orientation: {
11
+ horizontal:
12
+ '[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none',
13
+ vertical:
14
+ 'flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none',
15
+ },
16
+ },
17
+ defaultVariants: {
18
+ orientation: 'horizontal',
19
+ },
20
+ }
21
+ );
22
+
23
+ function ButtonGroup({
24
+ className,
25
+ orientation,
26
+ ...props
27
+ }: React.ComponentProps<'div'> & VariantProps<typeof buttonGroupVariants>) {
28
+ return (
29
+ <div
30
+ className={cn(buttonGroupVariants({ orientation }), className)}
31
+ data-orientation={orientation}
32
+ data-slot="button-group"
33
+ role="group"
34
+ {...props}
35
+ />
36
+ );
37
+ }
38
+
39
+ function ButtonGroupText({
40
+ className,
41
+ asChild = false,
42
+ ...props
43
+ }: React.ComponentProps<'div'> & {
44
+ asChild?: boolean;
45
+ }) {
46
+ const Comp = asChild ? Slot : 'div';
47
+
48
+ return (
49
+ <Comp
50
+ className={cn(
51
+ "flex items-center gap-2 rounded-md border bg-muted px-4 font-medium text-sm shadow-xs [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
52
+ className
53
+ )}
54
+ {...props}
55
+ />
56
+ );
57
+ }
58
+
59
+ function ButtonGroupSeparator({
60
+ className,
61
+ orientation = 'vertical',
62
+ ...props
63
+ }: React.ComponentProps<typeof Separator>) {
64
+ return (
65
+ <Separator
66
+ className={cn(
67
+ '!m-0 relative self-stretch bg-input data-[orientation=vertical]:h-auto',
68
+ className
69
+ )}
70
+ data-slot="button-group-separator"
71
+ orientation={orientation}
72
+ {...props}
73
+ />
74
+ );
75
+ }
76
+
77
+ export {
78
+ ButtonGroup,
79
+ ButtonGroupSeparator,
80
+ ButtonGroupText,
81
+ buttonGroupVariants,
82
+ };