@sero-ai/ui 0.1.0 → 0.2.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.
@@ -0,0 +1,50 @@
1
+ import type { ComponentProps } from 'react';
2
+ import { Languages } from 'lucide-react';
3
+ import { cn } from '../../lib/utils';
4
+
5
+ export type VoiceSelectorAccentValue =
6
+ | 'american'
7
+ | 'british'
8
+ | 'australian'
9
+ | 'canadian'
10
+ | 'irish'
11
+ | 'scottish'
12
+ | 'indian'
13
+ | 'south-african'
14
+ | 'new-zealand'
15
+ | 'spanish'
16
+ | 'french'
17
+ | 'german'
18
+ | 'italian'
19
+ | 'portuguese'
20
+ | 'brazilian'
21
+ | 'mexican'
22
+ | 'argentinian'
23
+ | 'japanese'
24
+ | 'chinese'
25
+ | 'korean'
26
+ | 'russian'
27
+ | 'arabic'
28
+ | 'dutch'
29
+ | 'swedish'
30
+ | 'norwegian'
31
+ | 'danish'
32
+ | 'finnish'
33
+ | 'polish'
34
+ | 'turkish'
35
+ | 'greek'
36
+ | string;
37
+
38
+ export type VoiceSelectorAccentProps = ComponentProps<'span'> & {
39
+ value?: VoiceSelectorAccentValue;
40
+ };
41
+
42
+ export const VoiceSelectorAccent = ({
43
+ className,
44
+ children,
45
+ ...props
46
+ }: VoiceSelectorAccentProps) => (
47
+ <span className={cn('text-muted-foreground text-xs', className)} {...props}>
48
+ {children ?? <Languages className="size-4" />}
49
+ </span>
50
+ );
@@ -3,6 +3,10 @@
3
3
  import type { ComponentProps, ReactNode } from "react";
4
4
 
5
5
  import { useControllableState } from "@radix-ui/react-use-controllable-state";
6
+ import {
7
+ VoiceSelectorAccent,
8
+ type VoiceSelectorAccentProps,
9
+ } from "./voice-selector-accent";
6
10
  import { Button } from "../ui/button";
7
11
  import {
8
12
  Command,
@@ -238,181 +242,8 @@ export const VoiceSelectorGender = ({
238
242
  );
239
243
  };
240
244
 
241
- export type VoiceSelectorAccentProps = ComponentProps<"span"> & {
242
- value?:
243
- | "american"
244
- | "british"
245
- | "australian"
246
- | "canadian"
247
- | "irish"
248
- | "scottish"
249
- | "indian"
250
- | "south-african"
251
- | "new-zealand"
252
- | "spanish"
253
- | "french"
254
- | "german"
255
- | "italian"
256
- | "portuguese"
257
- | "brazilian"
258
- | "mexican"
259
- | "argentinian"
260
- | "japanese"
261
- | "chinese"
262
- | "korean"
263
- | "russian"
264
- | "arabic"
265
- | "dutch"
266
- | "swedish"
267
- | "norwegian"
268
- | "danish"
269
- | "finnish"
270
- | "polish"
271
- | "turkish"
272
- | "greek"
273
- | string;
274
- };
275
-
276
- export const VoiceSelectorAccent = ({
277
- className,
278
- value,
279
- children,
280
- ...props
281
- }: VoiceSelectorAccentProps) => {
282
- let emoji: string | null = null;
283
-
284
- switch (value) {
285
- case "american": {
286
- emoji = "🇺🇸";
287
- break;
288
- }
289
- case "british": {
290
- emoji = "🇬🇧";
291
- break;
292
- }
293
- case "australian": {
294
- emoji = "🇦🇺";
295
- break;
296
- }
297
- case "canadian": {
298
- emoji = "🇨🇦";
299
- break;
300
- }
301
- case "irish": {
302
- emoji = "🇮🇪";
303
- break;
304
- }
305
- case "scottish": {
306
- emoji = "🏴󠁧󠁢󠁳󠁣󠁴󠁿";
307
- break;
308
- }
309
- case "indian": {
310
- emoji = "🇮🇳";
311
- break;
312
- }
313
- case "south-african": {
314
- emoji = "🇿🇦";
315
- break;
316
- }
317
- case "new-zealand": {
318
- emoji = "🇳🇿";
319
- break;
320
- }
321
- case "spanish": {
322
- emoji = "🇪🇸";
323
- break;
324
- }
325
- case "french": {
326
- emoji = "🇫🇷";
327
- break;
328
- }
329
- case "german": {
330
- emoji = "🇩🇪";
331
- break;
332
- }
333
- case "italian": {
334
- emoji = "🇮🇹";
335
- break;
336
- }
337
- case "portuguese": {
338
- emoji = "🇵🇹";
339
- break;
340
- }
341
- case "brazilian": {
342
- emoji = "🇧🇷";
343
- break;
344
- }
345
- case "mexican": {
346
- emoji = "🇲🇽";
347
- break;
348
- }
349
- case "argentinian": {
350
- emoji = "🇦🇷";
351
- break;
352
- }
353
- case "japanese": {
354
- emoji = "🇯🇵";
355
- break;
356
- }
357
- case "chinese": {
358
- emoji = "🇨🇳";
359
- break;
360
- }
361
- case "korean": {
362
- emoji = "🇰🇷";
363
- break;
364
- }
365
- case "russian": {
366
- emoji = "🇷🇺";
367
- break;
368
- }
369
- case "arabic": {
370
- emoji = "🇸🇦";
371
- break;
372
- }
373
- case "dutch": {
374
- emoji = "🇳🇱";
375
- break;
376
- }
377
- case "swedish": {
378
- emoji = "🇸🇪";
379
- break;
380
- }
381
- case "norwegian": {
382
- emoji = "🇳🇴";
383
- break;
384
- }
385
- case "danish": {
386
- emoji = "🇩🇰";
387
- break;
388
- }
389
- case "finnish": {
390
- emoji = "🇫🇮";
391
- break;
392
- }
393
- case "polish": {
394
- emoji = "🇵🇱";
395
- break;
396
- }
397
- case "turkish": {
398
- emoji = "🇹🇷";
399
- break;
400
- }
401
- case "greek": {
402
- emoji = "🇬🇷";
403
- break;
404
- }
405
- default: {
406
- emoji = null;
407
- }
408
- }
409
-
410
- return (
411
- <span className={cn("text-muted-foreground text-xs", className)} {...props}>
412
- {children ?? emoji}
413
- </span>
414
- );
415
- };
245
+ export { VoiceSelectorAccent };
246
+ export type { VoiceSelectorAccentProps };
416
247
 
417
248
  export type VoiceSelectorAgeProps = ComponentProps<"span">;
418
249
 
@@ -0,0 +1,231 @@
1
+ import { useCallback, useMemo, useRef, useState } from 'react';
2
+ import { Check, ChevronDown, Sparkles, X } from 'lucide-react';
3
+ import {
4
+ filterModelGroups,
5
+ findGroup,
6
+ findModel,
7
+ modelKey,
8
+ parseModelKey,
9
+ type SharedAvailableModelGroup,
10
+ type SharedModelInfo,
11
+ } from '@sero-ai/common';
12
+ import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
13
+ import { SearchInput } from '../ui/search-input';
14
+ import { cn } from '../../lib/utils';
15
+
16
+ interface AvailableModelPickerProps<
17
+ TModel extends SharedModelInfo,
18
+ TGroup extends SharedAvailableModelGroup<TModel>,
19
+ > {
20
+ groups: TGroup[];
21
+ value: string;
22
+ onChange: (value: string) => void;
23
+ placeholder?: string;
24
+ searchPlaceholder?: string;
25
+ emptyLabel?: string;
26
+ noModelsLabel?: string;
27
+ allowClear?: boolean;
28
+ disabled?: boolean;
29
+ className?: string;
30
+ }
31
+
32
+ export function AvailableModelPicker<
33
+ TModel extends SharedModelInfo,
34
+ TGroup extends SharedAvailableModelGroup<TModel>,
35
+ >({
36
+ groups,
37
+ value,
38
+ onChange,
39
+ placeholder = 'Choose a model',
40
+ searchPlaceholder = 'Search models…',
41
+ emptyLabel = 'No matching models',
42
+ noModelsLabel = 'No models available',
43
+ allowClear = false,
44
+ disabled = false,
45
+ className,
46
+ }: AvailableModelPickerProps<TModel, TGroup>) {
47
+ const [open, setOpen] = useState(false);
48
+ const [query, setQuery] = useState('');
49
+ const inputRef = useRef<HTMLInputElement>(null);
50
+
51
+ const selected = useMemo(() => {
52
+ const parsed = parseModelKey(value);
53
+ if (!parsed) {
54
+ return value
55
+ ? { group: null, model: null, fallbackLabel: value }
56
+ : { group: null, model: null, fallbackLabel: null };
57
+ }
58
+
59
+ const group = findGroup(groups, parsed.provider, parsed.modelId) ?? null;
60
+ const model = findModel(groups, parsed.provider, parsed.modelId) ?? null;
61
+ return {
62
+ group,
63
+ model,
64
+ fallbackLabel: model ? null : value,
65
+ };
66
+ }, [groups, value]);
67
+
68
+ const filteredGroups = useMemo(
69
+ () => filterModelGroups(groups, query),
70
+ [groups, query],
71
+ );
72
+
73
+ const totalResults = useMemo(
74
+ () => filteredGroups.reduce((count, group) => count + group.models.length, 0),
75
+ [filteredGroups],
76
+ );
77
+
78
+ const handleOpenChange = useCallback((nextOpen: boolean) => {
79
+ setOpen(nextOpen);
80
+ if (!nextOpen) return;
81
+ setQuery('');
82
+ requestAnimationFrame(() => inputRef.current?.focus());
83
+ }, []);
84
+
85
+ const handleSelect = useCallback((provider: string, modelId: string) => {
86
+ onChange(modelKey(provider, modelId));
87
+ setOpen(false);
88
+ setQuery('');
89
+ }, [onChange]);
90
+
91
+ const handleClear = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
92
+ event.stopPropagation();
93
+ onChange('');
94
+ setOpen(false);
95
+ setQuery('');
96
+ }, [onChange]);
97
+
98
+ return (
99
+ <Popover open={open} onOpenChange={handleOpenChange}>
100
+ <PopoverTrigger asChild disabled={disabled}>
101
+ <div
102
+ role="button"
103
+ tabIndex={disabled ? -1 : 0}
104
+ className={cn(
105
+ 'flex w-full items-center gap-2 rounded-md border border-input bg-background px-3 py-2 text-left text-sm transition-colors',
106
+ 'hover:bg-secondary/50 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
107
+ disabled && 'cursor-not-allowed opacity-50',
108
+ className,
109
+ )}
110
+ >
111
+ {selected.group && selected.model ? (
112
+ <>
113
+ <img
114
+ src={selected.group.logo}
115
+ alt={selected.group.displayName}
116
+ className="size-4 shrink-0 rounded-sm dark:invert"
117
+ />
118
+ <div className="min-w-0 flex-1">
119
+ <div className="flex items-center gap-1.5">
120
+ <span className="truncate font-medium text-foreground">{selected.model.name}</span>
121
+ {selected.model.reasoning ? (
122
+ <Sparkles className="size-3 shrink-0 text-amber-500/70" />
123
+ ) : null}
124
+ </div>
125
+ <div className="truncate text-[11px] text-muted-foreground">
126
+ {selected.group.displayName}
127
+ </div>
128
+ </div>
129
+ </>
130
+ ) : selected.fallbackLabel ? (
131
+ <span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground">
132
+ {selected.fallbackLabel}
133
+ </span>
134
+ ) : (
135
+ <span className="min-w-0 flex-1 truncate text-muted-foreground">
136
+ {placeholder}
137
+ </span>
138
+ )}
139
+
140
+ {allowClear && value ? (
141
+ <button
142
+ type="button"
143
+ onClick={handleClear}
144
+ className="rounded p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
145
+ title="Clear selection"
146
+ >
147
+ <X className="size-3.5" />
148
+ </button>
149
+ ) : null}
150
+
151
+ <ChevronDown className="size-4 shrink-0 text-muted-foreground" />
152
+ </div>
153
+ </PopoverTrigger>
154
+
155
+ <PopoverContent
156
+ side="bottom"
157
+ align="start"
158
+ sideOffset={4}
159
+ className="w-[var(--radix-popover-trigger-width)] overflow-hidden rounded-xl border-border/60 bg-background p-0 shadow-xl"
160
+ onWheel={(event) => event.stopPropagation()}
161
+ >
162
+ <SearchInput
163
+ ref={inputRef}
164
+ value={query}
165
+ onChange={(event) => setQuery(event.target.value)}
166
+ placeholder={searchPlaceholder}
167
+ containerClassName="border-b border-border/40"
168
+ />
169
+
170
+ <div className="max-h-[280px] overflow-y-auto py-1">
171
+ {groups.length === 0 ? (
172
+ <div className="px-3 py-4 text-center text-xs text-muted-foreground">
173
+ {noModelsLabel}
174
+ </div>
175
+ ) : totalResults === 0 ? (
176
+ <div className="px-3 py-4 text-center text-xs text-muted-foreground">
177
+ {emptyLabel}
178
+ </div>
179
+ ) : (
180
+ filteredGroups.map((group, index) => (
181
+ <div key={group.provider}>
182
+ {index > 0 ? <div className="mx-3 border-t border-border/30" /> : null}
183
+ <div className="flex items-center gap-2 px-3 pb-1 pt-2">
184
+ <img
185
+ src={group.logo}
186
+ alt={group.displayName}
187
+ className="size-3.5 shrink-0 rounded-sm dark:invert"
188
+ />
189
+ <span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
190
+ {group.displayName}
191
+ </span>
192
+ </div>
193
+ <div className="px-1">
194
+ {group.models.map((model) => {
195
+ const nextValue = modelKey(model.provider, model.modelId);
196
+ const isSelected = nextValue === value;
197
+ return (
198
+ <button
199
+ key={nextValue}
200
+ type="button"
201
+ onClick={() => handleSelect(model.provider, model.modelId)}
202
+ className={cn(
203
+ 'flex w-full items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-xs transition-colors',
204
+ isSelected
205
+ ? 'bg-secondary text-foreground'
206
+ : 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
207
+ )}
208
+ >
209
+ <div className="flex size-4 shrink-0 items-center justify-center">
210
+ {isSelected ? (
211
+ <Check className="size-3.5 text-emerald-500" />
212
+ ) : (
213
+ <div className="size-1.5 rounded-full bg-border" />
214
+ )}
215
+ </div>
216
+ <span className="min-w-0 flex-1 truncate font-medium">{model.name}</span>
217
+ {model.reasoning ? (
218
+ <Sparkles className="size-3 shrink-0 text-amber-500/60" />
219
+ ) : null}
220
+ </button>
221
+ );
222
+ })}
223
+ </div>
224
+ </div>
225
+ ))
226
+ )}
227
+ </div>
228
+ </PopoverContent>
229
+ </Popover>
230
+ );
231
+ }
@@ -0,0 +1,33 @@
1
+ import { TriangleAlert } from 'lucide-react';
2
+ import { formatModelValidationWarning, type ModelValidationWarning } from '@sero-ai/common';
3
+ import { cn } from '../../lib/utils';
4
+
5
+ interface ModelWarningListProps {
6
+ warnings: ModelValidationWarning[];
7
+ className?: string;
8
+ }
9
+
10
+ export function ModelWarningList({ warnings, className }: ModelWarningListProps) {
11
+ if (warnings.length === 0) return null;
12
+
13
+ return (
14
+ <div className={cn('space-y-2', className)}>
15
+ {warnings.map((warning, index) => (
16
+ <div
17
+ key={`${warning.code}:${index}`}
18
+ className={cn(
19
+ 'rounded-lg border px-3 py-2 text-xs',
20
+ warning.severity === 'info'
21
+ ? 'border-primary/20 bg-primary/5 text-primary'
22
+ : 'border-amber-500/20 bg-amber-500/5 text-amber-700 dark:text-amber-300',
23
+ )}
24
+ >
25
+ <div className="flex items-start gap-2">
26
+ <TriangleAlert className="mt-0.5 size-3.5 shrink-0" />
27
+ <span>{formatModelValidationWarning(warning)}</span>
28
+ </div>
29
+ </div>
30
+ ))}
31
+ </div>
32
+ );
33
+ }
@@ -0,0 +1,55 @@
1
+ import {
2
+ THINKING_LABELS,
3
+ THINKING_LEVELS,
4
+ type ThinkingLevel,
5
+ } from '@sero-ai/common';
6
+ import { cn } from '../../lib/utils';
7
+
8
+ interface ThinkingLevelPickerProps {
9
+ value: string;
10
+ onChange: (value: ThinkingLevel) => void;
11
+ availableLevels?: readonly string[];
12
+ disabled?: boolean;
13
+ showUnsupported?: boolean;
14
+ className?: string;
15
+ }
16
+
17
+ export function ThinkingLevelPicker({
18
+ value,
19
+ onChange,
20
+ availableLevels,
21
+ disabled = false,
22
+ showUnsupported = true,
23
+ className,
24
+ }: ThinkingLevelPickerProps) {
25
+ const allowed = new Set<string>(availableLevels ?? THINKING_LEVELS);
26
+ const levels = showUnsupported || !availableLevels
27
+ ? THINKING_LEVELS
28
+ : THINKING_LEVELS.filter((level) => allowed.has(level));
29
+
30
+ return (
31
+ <div className={cn('grid grid-cols-6 gap-1 rounded-lg border border-border/50 bg-muted/20 p-1', className)}>
32
+ {levels.map((level) => {
33
+ const isActive = value === level;
34
+ const isAvailable = allowed.has(level) || !availableLevels;
35
+ return (
36
+ <button
37
+ key={level}
38
+ type="button"
39
+ disabled={disabled || !isAvailable}
40
+ onClick={() => onChange(level)}
41
+ className={cn(
42
+ 'rounded-md px-2 py-1.5 text-[11px] font-medium transition-colors',
43
+ isActive
44
+ ? 'bg-background text-foreground shadow-sm'
45
+ : 'text-muted-foreground hover:bg-background/70 hover:text-foreground',
46
+ (!isAvailable || disabled) && 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
47
+ )}
48
+ >
49
+ {THINKING_LABELS[level]}
50
+ </button>
51
+ );
52
+ })}
53
+ </div>
54
+ );
55
+ }
@@ -58,7 +58,7 @@ function AlertDialogContent({
58
58
  data-slot="alert-dialog-content"
59
59
  data-size={size}
60
60
  className={cn(
61
- "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
61
+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 group/alert-dialog-content fixed top-[50%] left-[50%] z-50 grid w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg",
62
62
  className
63
63
  )}
64
64
  {...props}
@@ -73,7 +73,7 @@ function CommandInput({
73
73
  <CommandPrimitive.Input
74
74
  data-slot="command-input"
75
75
  className={cn(
76
- "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
76
+ "placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent pl-1.5 py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
77
77
  className
78
78
  )}
79
79
  {...props}
@@ -61,7 +61,7 @@ function DialogContent({
61
61
  <DialogPrimitive.Content
62
62
  data-slot="dialog-content"
63
63
  className={cn(
64
- "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
64
+ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
65
65
  className
66
66
  )}
67
67
  {...props}
@@ -0,0 +1,25 @@
1
+ import { ShieldAlert } from 'lucide-react';
2
+ import { cn } from '../../lib/utils';
3
+
4
+ export interface PluginSafetyDisclaimerProps {
5
+ className?: string;
6
+ }
7
+
8
+ export function PluginSafetyDisclaimer({ className }: PluginSafetyDisclaimerProps) {
9
+ return (
10
+ <div
11
+ role="note"
12
+ className={cn(
13
+ 'flex shrink-0 items-start gap-2 border-t border-[var(--status-warning-border)] bg-[var(--status-warning-faint)] px-4 py-2.5 text-[11px] leading-5 text-[var(--text-secondary)]',
14
+ className,
15
+ )}
16
+ >
17
+ <ShieldAlert className="mt-0.5 size-3.5 shrink-0 text-[var(--status-warning)]" />
18
+ <p>
19
+ <span className="font-medium text-[var(--status-warning)]">Heads up:</span>{' '}
20
+ Plugins run with full access to this workspace and can execute code on your device.
21
+ Only install plugins from sources you trust.
22
+ </p>
23
+ </div>
24
+ );
25
+ }
@@ -0,0 +1,40 @@
1
+ import * as React from "react"
2
+ import { SearchIcon } from "lucide-react"
3
+
4
+ import { cn } from "../../lib/utils"
5
+
6
+ interface SearchInputProps extends React.ComponentProps<"input"> {
7
+ containerClassName?: string
8
+ endAdornment?: React.ReactNode
9
+ iconClassName?: string
10
+ }
11
+
12
+ const SearchInput = React.forwardRef<HTMLInputElement, SearchInputProps>(
13
+ ({ containerClassName, className, endAdornment, iconClassName, ...props }, ref) => {
14
+ return (
15
+ <div
16
+ data-slot="search-input"
17
+ className={cn("flex items-center gap-2 px-3", containerClassName)}
18
+ >
19
+ <SearchIcon
20
+ data-slot="search-input-icon"
21
+ className={cn("text-muted-foreground size-3.5 shrink-0", iconClassName)}
22
+ />
23
+ <input
24
+ ref={ref}
25
+ data-slot="search-input-control"
26
+ className={cn(
27
+ "placeholder:text-muted-foreground h-9 w-full min-w-0 bg-transparent pl-1.5 text-xs text-foreground outline-none disabled:cursor-not-allowed disabled:opacity-50",
28
+ className
29
+ )}
30
+ {...props}
31
+ />
32
+ {endAdornment}
33
+ </div>
34
+ )
35
+ }
36
+ )
37
+
38
+ SearchInput.displayName = "SearchInput"
39
+
40
+ export { SearchInput }