feat: Add intelligent auto-router and enhanced integrations
- Add intelligent-router.sh hook for automatic agent routing - Add AUTO-TRIGGER-SUMMARY.md documentation - Add FINAL-INTEGRATION-SUMMARY.md documentation - Complete Prometheus integration (6 commands + 4 tools) - Complete Dexto integration (12 commands + 5 tools) - Enhanced Ralph with access to all agents - Fix /clawd command (removed disable-model-invocation) - Update hooks.json to v5 with intelligent routing - 291 total skills now available - All 21 commands with automatic routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
60
dexto/packages/webui/components/ui/alert.tsx
Normal file
60
dexto/packages/webui/components/ui/alert.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
destructive:
|
||||
'text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
'text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
34
dexto/packages/webui/components/ui/badge.tsx
Normal file
34
dexto/packages/webui/components/ui/badge.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
|
||||
secondary:
|
||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive:
|
||||
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
|
||||
outline: 'text-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
56
dexto/packages/webui/components/ui/button.tsx
Normal file
56
dexto/packages/webui/components/ui/button.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import * as React from 'react';
|
||||
import { Slot } from '@radix-ui/react-slot';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
||||
outline:
|
||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<'button'> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : 'button';
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
78
dexto/packages/webui/components/ui/card.tsx
Normal file
78
dexto/packages/webui/components/ui/card.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
'bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
'@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn('leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
'col-start-2 row-span-2 row-start-1 self-start justify-self-end',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return <div data-slot="card-content" className={cn('px-6', className)} {...props} />;
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent };
|
||||
27
dexto/packages/webui/components/ui/checkbox.tsx
Normal file
27
dexto/packages/webui/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
'peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
76
dexto/packages/webui/components/ui/collapsible.tsx
Normal file
76
dexto/packages/webui/components/ui/collapsible.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './tooltip';
|
||||
|
||||
interface CollapsibleProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
errorCount?: number;
|
||||
sectionErrors?: string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Collapsible({
|
||||
title,
|
||||
children,
|
||||
defaultOpen = true,
|
||||
open: controlledOpen,
|
||||
onOpenChange,
|
||||
errorCount = 0,
|
||||
sectionErrors = [],
|
||||
className,
|
||||
}: CollapsibleProps) {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
|
||||
|
||||
const isControlled = controlledOpen !== undefined;
|
||||
const isOpen = isControlled ? controlledOpen : uncontrolledOpen;
|
||||
|
||||
const handleToggle = () => {
|
||||
const newOpen = !isOpen;
|
||||
if (isControlled) {
|
||||
onOpenChange?.(newOpen);
|
||||
} else {
|
||||
setUncontrolledOpen(newOpen);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('border border-border rounded-lg overflow-hidden', className)}>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className="w-full flex items-center justify-between px-4 py-3 bg-muted/50 hover:bg-muted transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{title}</span>
|
||||
{errorCount > 0 && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-flex items-center justify-center px-2 py-0.5 text-xs font-medium bg-destructive text-destructive-foreground rounded-full cursor-help">
|
||||
{errorCount} {errorCount === 1 ? 'error' : 'errors'}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-sm">
|
||||
<ul className="space-y-1 text-left">
|
||||
{sectionErrors.map((error, idx) => (
|
||||
<li key={idx}>• {error}</li>
|
||||
))}
|
||||
</ul>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 transition-transform duration-200',
|
||||
isOpen && 'transform rotate-180'
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && <div className="px-4 py-4 space-y-4">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
dexto/packages/webui/components/ui/copy-button.tsx
Normal file
41
dexto/packages/webui/components/ui/copy-button.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState } from 'react';
|
||||
import { Check as CheckIcon, Copy as CopyIcon } from 'lucide-react';
|
||||
import { TooltipIconButton } from '@/components/ui/tooltip-icon-button';
|
||||
|
||||
export type CopyButtonProps = {
|
||||
value: string;
|
||||
tooltip?: string;
|
||||
copiedTooltip?: string;
|
||||
className?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export function CopyButton({
|
||||
value,
|
||||
tooltip = 'Copy',
|
||||
copiedTooltip = 'Copied!',
|
||||
className,
|
||||
size = 12,
|
||||
}: CopyButtonProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const onCopy = () => {
|
||||
navigator.clipboard
|
||||
.writeText(value)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipIconButton
|
||||
tooltip={copied ? copiedTooltip : tooltip}
|
||||
onClick={onCopy}
|
||||
className={className}
|
||||
>
|
||||
{copied ? <CheckIcon size={size} /> : <CopyIcon size={size} />}
|
||||
</TooltipIconButton>
|
||||
);
|
||||
}
|
||||
126
dexto/packages/webui/components/ui/dialog.tsx
Normal file
126
dexto/packages/webui/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import * as React from 'react';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { XIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 backdrop-blur-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface DialogContentProps extends React.ComponentProps<typeof DialogPrimitive.Content> {
|
||||
hideCloseButton?: boolean;
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
hideCloseButton = false,
|
||||
...props
|
||||
}: DialogContentProps) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 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-150 sm:max-w-lg',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{!hideCloseButton && (
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn('text-lg leading-none font-semibold', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn('text-muted-foreground text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
70
dexto/packages/webui/components/ui/dropdown-menu.tsx
Normal file
70
dexto/packages/webui/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import * as React from 'react';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Mirror Popover Root props so consumers can control open state, etc.
|
||||
const DropdownMenu = ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<React.ComponentProps<typeof PopoverPrimitive.Root>>) => (
|
||||
<Popover {...props}>{children}</Popover>
|
||||
);
|
||||
|
||||
const DropdownMenuTrigger = PopoverTrigger;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverContent>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverContent>
|
||||
>(({ className, align = 'end', sideOffset = 8, ...props }, ref) => (
|
||||
<PopoverContent
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-xl bg-popover/95 backdrop-blur-xl p-1.5 text-popover-foreground',
|
||||
'border border-border/40 shadow-lg shadow-black/5',
|
||||
'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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuContent.displayName = 'DropdownMenuContent';
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
disabled?: boolean;
|
||||
}
|
||||
>(({ className, disabled, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-lg px-2.5 py-2 text-sm outline-none transition-all duration-150',
|
||||
'focus:bg-accent/60 focus:text-accent-foreground',
|
||||
disabled && 'pointer-events-none opacity-50',
|
||||
!disabled &&
|
||||
'hover:bg-accent/60 hover:text-accent-foreground cursor-pointer hover:scale-[0.98]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = 'DropdownMenuItem';
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('my-1.5 h-px bg-border/40', className)} {...props} />
|
||||
));
|
||||
DropdownMenuSeparator.displayName = 'DropdownMenuSeparator';
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
};
|
||||
94
dexto/packages/webui/components/ui/form-section.tsx
Normal file
94
dexto/packages/webui/components/ui/form-section.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FormSectionProps {
|
||||
title?: string;
|
||||
description?: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface FormGroupProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface FormRowProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
columns?: 1 | 2 | 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* A card-like section for grouping related form fields.
|
||||
*/
|
||||
export function FormSection({ title, description, children, className }: FormSectionProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('bg-card rounded-lg border border-border p-4 shadow-minimal', className)}
|
||||
>
|
||||
{(title || description) && (
|
||||
<div className="mb-4">
|
||||
{title && <h3 className="text-sm font-semibold text-foreground">{title}</h3>}
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A group of form fields with vertical spacing.
|
||||
*/
|
||||
export function FormGroup({ children, className }: FormGroupProps) {
|
||||
return <div className={cn('space-y-4', className)}>{children}</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row for side-by-side form fields.
|
||||
*/
|
||||
export function FormRow({ children, className, columns = 2 }: FormRowProps) {
|
||||
const gridCols = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 sm:grid-cols-2',
|
||||
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
||||
};
|
||||
|
||||
return <div className={cn('grid gap-4', gridCols[columns], className)}>{children}</div>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A divider line between form sections.
|
||||
*/
|
||||
export function FormDivider({ className }: { className?: string }) {
|
||||
return <hr className={cn('border-t border-border my-4', className)} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* An alert/info box for important messages within forms.
|
||||
*/
|
||||
export function FormAlert({
|
||||
variant = 'info',
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
variant?: 'info' | 'warning' | 'error' | 'success';
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const variants = {
|
||||
info: 'bg-primary/5 border-primary/20 text-foreground',
|
||||
warning: 'bg-yellow-500/10 border-yellow-500/20 text-yellow-700 dark:text-yellow-500',
|
||||
error: 'bg-destructive/10 border-destructive/20 text-destructive',
|
||||
success: 'bg-green-500/10 border-green-500/20 text-green-700 dark:text-green-500',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-lg border px-4 py-3 text-sm', variants[variant], className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
dexto/packages/webui/components/ui/input.tsx
Normal file
21
dexto/packages/webui/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<'input'>) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
|
||||
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
174
dexto/packages/webui/components/ui/key-value-editor.tsx
Normal file
174
dexto/packages/webui/components/ui/key-value-editor.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from './button';
|
||||
import { Input } from './input';
|
||||
import { Label } from './label';
|
||||
import { Plus, Trash2, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
interface KeyValuePair {
|
||||
key: string;
|
||||
value: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface KeyValueEditorProps {
|
||||
label?: string;
|
||||
placeholder?: {
|
||||
key?: string;
|
||||
value?: string;
|
||||
};
|
||||
pairs: KeyValuePair[];
|
||||
onChange: (pairs: KeyValuePair[]) => void;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
keyLabel?: string;
|
||||
valueLabel?: string;
|
||||
maskSensitiveValues?: boolean;
|
||||
}
|
||||
|
||||
const SENSITIVE_KEY_PATTERNS = [
|
||||
/\bapi[_-]?key\b/i,
|
||||
/\bapikey\b/i,
|
||||
/\bsecret\b/i,
|
||||
/\btoken\b/i,
|
||||
/\bpassword\b/i,
|
||||
/\bauthorization\b/i,
|
||||
/\bauth[_-]?token\b/i,
|
||||
/\bbearer\b/i,
|
||||
/\bcredential\b/i,
|
||||
/\bclient[_-]?secret\b/i,
|
||||
];
|
||||
|
||||
export function KeyValueEditor({
|
||||
label = 'Key-Value Pairs',
|
||||
placeholder = { key: 'Key', value: 'Value' },
|
||||
pairs,
|
||||
onChange,
|
||||
disabled = false,
|
||||
className = '',
|
||||
keyLabel = 'Key',
|
||||
valueLabel = 'Value',
|
||||
maskSensitiveValues = true,
|
||||
}: KeyValueEditorProps) {
|
||||
const [visibleValues, setVisibleValues] = useState<Set<string>>(new Set());
|
||||
|
||||
const isSensitiveKey = (key: string): boolean => {
|
||||
if (!maskSensitiveValues || !key) return false;
|
||||
return SENSITIVE_KEY_PATTERNS.some((pattern) => pattern.test(key));
|
||||
};
|
||||
|
||||
const toggleValueVisibility = (id: string) => {
|
||||
setVisibleValues((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const addPair = () => {
|
||||
const newPair: KeyValuePair = {
|
||||
key: '',
|
||||
value: '',
|
||||
id: `kv-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
|
||||
};
|
||||
onChange([...pairs, newPair]);
|
||||
};
|
||||
|
||||
const removePair = (id: string) => {
|
||||
const filteredPairs = pairs.filter((pair) => pair.id !== id);
|
||||
// Allow removing all pairs - don't force an empty pair
|
||||
onChange(filteredPairs);
|
||||
};
|
||||
|
||||
const updatePair = (id: string, field: 'key' | 'value', newValue: string) => {
|
||||
onChange(pairs.map((pair) => (pair.id === id ? { ...pair, [field]: newValue } : pair)));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
{label && <Label className="text-sm font-medium">{label}</Label>}
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* Header row - only show if there are pairs */}
|
||||
{pairs.length > 0 && (
|
||||
<div className="grid grid-cols-12 gap-2 items-center text-xs text-muted-foreground">
|
||||
<div className="col-span-5">{keyLabel}</div>
|
||||
<div className="col-span-6">{valueLabel}</div>
|
||||
<div className="col-span-1"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key-value pair rows */}
|
||||
{pairs.map((pair) => {
|
||||
const isSensitive = isSensitiveKey(pair.key);
|
||||
const isVisible = visibleValues.has(pair.id);
|
||||
|
||||
return (
|
||||
<div key={pair.id} className="grid grid-cols-12 gap-2 items-center">
|
||||
<Input
|
||||
placeholder={placeholder.key}
|
||||
value={pair.key}
|
||||
onChange={(e) => updatePair(pair.id, 'key', e.target.value)}
|
||||
disabled={disabled}
|
||||
className="col-span-5"
|
||||
/>
|
||||
<div className="col-span-6 relative">
|
||||
<Input
|
||||
type={isSensitive && !isVisible ? 'password' : 'text'}
|
||||
placeholder={placeholder.value}
|
||||
value={pair.value}
|
||||
onChange={(e) => updatePair(pair.id, 'value', e.target.value)}
|
||||
disabled={disabled}
|
||||
className="pr-10"
|
||||
/>
|
||||
{isSensitive && pair.value && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleValueVisibility(pair.id)}
|
||||
disabled={disabled}
|
||||
className="absolute right-0 top-0 h-full w-10 p-0 hover:bg-transparent"
|
||||
aria-label={isVisible ? 'Hide value' : 'Show value'}
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removePair(pair.id)}
|
||||
disabled={disabled}
|
||||
className="col-span-1 h-8 w-8 p-0"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Add button */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addPair}
|
||||
disabled={disabled}
|
||||
className="w-full mt-2"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add {keyLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
dexto/packages/webui/components/ui/label-with-tooltip.tsx
Normal file
39
dexto/packages/webui/components/ui/label-with-tooltip.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import { Label } from './label';
|
||||
import { HelpCircle } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip';
|
||||
|
||||
interface LabelWithTooltipProps {
|
||||
htmlFor: string;
|
||||
children: React.ReactNode;
|
||||
tooltip?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LabelWithTooltip({ htmlFor, children, tooltip, className }: LabelWithTooltipProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<Label htmlFor={htmlFor} className={className}>
|
||||
{children}
|
||||
</Label>
|
||||
{tooltip && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="More information"
|
||||
>
|
||||
<HelpCircle className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-xs">
|
||||
<p className="text-sm">{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
dexto/packages/webui/components/ui/label.tsx
Normal file
19
dexto/packages/webui/components/ui/label.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import * as React from 'react';
|
||||
import * as LabelPrimitive from '@radix-ui/react-label';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Label };
|
||||
460
dexto/packages/webui/components/ui/markdown-text.tsx
Normal file
460
dexto/packages/webui/components/ui/markdown-text.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import { memo, useState } from 'react';
|
||||
import { CheckIcon, CopyIcon } from 'lucide-react';
|
||||
|
||||
import { TooltipIconButton } from '@/components/ui/tooltip-icon-button';
|
||||
|
||||
// Helper functions for media validation (copied from MessageList to avoid circular imports)
|
||||
function isValidDataUri(src: string, expectedType?: 'image' | 'video' | 'audio'): boolean {
|
||||
const typePattern = expectedType ? `${expectedType}/` : '[a-z0-9.+-]+/';
|
||||
const dataUriRegex = new RegExp(
|
||||
`^data:${typePattern}[a-z0-9.+-]+;base64,[A-Za-z0-9+/]+={0,2}$`,
|
||||
'i'
|
||||
);
|
||||
return dataUriRegex.test(src);
|
||||
}
|
||||
|
||||
function isSafeHttpUrl(src: string): boolean {
|
||||
try {
|
||||
const url = new URL(src);
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
|
||||
// Check protocol
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block localhost and common local names
|
||||
if (hostname === 'localhost' || hostname === '::1') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for IPv4 addresses
|
||||
const ipv4Regex = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
|
||||
const ipv4Match = hostname.match(ipv4Regex);
|
||||
if (ipv4Match) {
|
||||
const [, a, b, c, d] = ipv4Match.map(Number);
|
||||
|
||||
// Validate IP range (0-255)
|
||||
if (a > 255 || b > 255 || c > 255 || d > 255) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block loopback (127.0.0.0/8)
|
||||
if (a === 127) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block private networks (RFC 1918)
|
||||
// 10.0.0.0/8
|
||||
if (a === 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 172.16.0.0/12
|
||||
if (a === 172 && b >= 16 && b <= 31) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 192.168.0.0/16
|
||||
if (a === 192 && b === 168) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block link-local (169.254.0.0/16)
|
||||
if (a === 169 && b === 254) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block 0.0.0.0
|
||||
if (a === 0 && b === 0 && c === 0 && d === 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for IPv6 addresses
|
||||
if (hostname.includes(':')) {
|
||||
// Block IPv6 loopback
|
||||
if (hostname === '::1' || hostname === '0:0:0:0:0:0:0:1') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block IPv6 unique-local (fc00::/7)
|
||||
if (hostname.startsWith('fc') || hostname.startsWith('fd')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block IPv6 link-local (fe80::/10)
|
||||
if (
|
||||
hostname.startsWith('fe8') ||
|
||||
hostname.startsWith('fe9') ||
|
||||
hostname.startsWith('fea') ||
|
||||
hostname.startsWith('feb')
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeMediaUrl(src: string, expectedType?: 'image' | 'video' | 'audio'): boolean {
|
||||
if (src.startsWith('blob:') || isSafeHttpUrl(src)) return true;
|
||||
if (src.startsWith('data:')) {
|
||||
return expectedType ? isValidDataUri(src, expectedType) : isValidDataUri(src);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isVideoUrl(url: string): boolean {
|
||||
// Check for video file extensions
|
||||
if (url.match(/\.(mp4|webm|mov|m4v|avi|mkv)(\?.*)?$/i)) {
|
||||
return true;
|
||||
}
|
||||
// Check for video MIME types in URL or common video hosting patterns
|
||||
if (url.includes('/video/') || url.includes('video_')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAudioUrl(url: string): boolean {
|
||||
// Check for audio file extensions
|
||||
if (url.match(/\.(mp3|wav|ogg|m4a|aac|flac|wma)(\?.*)?$/i)) {
|
||||
return true;
|
||||
}
|
||||
// Check for audio patterns in URL
|
||||
if (url.includes('/audio/') || url.includes('audio_')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Auto-linkify plain text URLs
|
||||
function linkifyText(text: string): React.ReactNode {
|
||||
// URL regex that matches http(s) URLs
|
||||
const urlRegex = /(https?:\/\/[^\s<]+)/g;
|
||||
const parts = text.split(urlRegex);
|
||||
|
||||
return parts.map((part, index) => {
|
||||
if (part.match(urlRegex)) {
|
||||
// Validate URL safety before rendering as link
|
||||
if (!isSafeHttpUrl(part)) {
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="text-muted-foreground underline decoration-dotted"
|
||||
title="Unsafe URL"
|
||||
>
|
||||
{part}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<a
|
||||
key={index}
|
||||
href={part}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 underline-offset-2 hover:underline hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-medium break-all overflow-wrap-anywhere max-w-full inline"
|
||||
title={part}
|
||||
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
|
||||
>
|
||||
{part}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
// Code block component with copy functionality
|
||||
const CodeBlock = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
[key: string]: any;
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const text = String(children ?? '').replace(/\n$/, '');
|
||||
const isInline = !className;
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className="text-xs px-1.5 py-0.5 bg-muted rounded font-mono break-all overflow-wrap-anywhere"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative group my-4 min-w-0 max-w-full">
|
||||
<TooltipIconButton
|
||||
tooltip={copied ? 'Copied!' : 'Copy code'}
|
||||
onClick={() => {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
})
|
||||
.catch(() => {});
|
||||
}}
|
||||
className="absolute right-2 top-2 z-10 opacity-70 hover:opacity-100 transition-opacity bg-background/80 hover:bg-background"
|
||||
>
|
||||
{copied ? <CheckIcon size={14} /> : <CopyIcon size={14} />}
|
||||
</TooltipIconButton>
|
||||
<pre className="overflow-auto bg-muted p-3 rounded-lg text-sm max-w-full">
|
||||
<code className={className}>{text}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Enhanced markdown component with proper emoji support and spacing
|
||||
const MarkdownTextImpl = ({ children }: { children: string }) => {
|
||||
const blobUrlsRef = React.useRef<Set<string>>(new Set());
|
||||
|
||||
React.useEffect(() => {
|
||||
// Capture the current ref value for cleanup
|
||||
const blobUrls = blobUrlsRef.current;
|
||||
return () => {
|
||||
// Clean up any created blob URLs on unmount
|
||||
blobUrls.forEach((url) => {
|
||||
try {
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// Silently fail if revoke fails
|
||||
}
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="prose max-w-none dark:prose-invert min-w-0 overflow-hidden break-words overflow-wrap-anywhere [&>p]:my-5 [&>p]:leading-7 [&>p]:first:mt-0 [&>p]:last:mb-0 [&>p]:break-words [&>p]:overflow-wrap-anywhere [&>h1]:mb-8 [&>h1]:text-4xl [&>h1]:font-extrabold [&>h1]:tracking-tight [&>h1]:last:mb-0 [&>h1]:break-words [&>h2]:mb-4 [&>h2]:mt-8 [&>h2]:text-3xl [&>h2]:font-semibold [&>h2]:tracking-tight [&>h2]:first:mt-0 [&>h2]:last:mb-0 [&>h2]:break-words [&>h3]:mb-4 [&>h3]:mt-6 [&>h3]:text-2xl [&>h3]:font-semibold [&>h3]:tracking-tight [&>h3]:first:mt-0 [&>h3]:last:mb-0 [&>h3]:break-words [&>h4]:mb-4 [&>h4]:mt-6 [&>h4]:text-xl [&>h4]:font-semibold [&>h4]:tracking-tight [&>h4]:first:mt-0 [&>h4]:last:mb-0 [&>h4]:break-words [&>ul]:my-5 [&>ul]:ml-6 [&>ul]:list-disc [&>ul>li]:mt-2 [&>ol]:my-5 [&>ol]:ml-6 [&>ol]:list-decimal [&>ol>li]:mt-2 [&_ul]:my-5 [&_ul]:ml-6 [&_ul]:list-disc [&_ul>li]:mt-2 [&_ol]:my-5 [&_ol]:ml-6 [&_ol]:list-decimal [&_ol>li]:mt-2 [&>blockquote]:border-l-2 [&>blockquote]:pl-6 [&>blockquote]:italic [&>blockquote]:break-words [&>hr]:my-5 [&>hr]:border-b">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
skipHtml={true}
|
||||
components={{
|
||||
a: ({ href, children, ...props }) => {
|
||||
const url = (href as string | undefined) ?? '';
|
||||
const isHttp = /^https?:\/\//i.test(url);
|
||||
const isAllowed = isHttp; // extend if you want: || url.startsWith('mailto:') || url.startsWith('tel:')
|
||||
if (!isAllowed || !isSafeHttpUrl(url)) {
|
||||
return (
|
||||
<span
|
||||
className="text-muted-foreground underline decoration-dotted"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// Regular link rendering with better overflow handling (no truncation)
|
||||
return (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 underline-offset-2 hover:underline hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 font-medium break-all overflow-wrap-anywhere max-w-full inline"
|
||||
title={url}
|
||||
style={{ wordBreak: 'break-word', overflowWrap: 'anywhere' }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
img: ({ src, alt, ...props }) => {
|
||||
if (!src) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
No media source provided
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Handle Blob sources - validate and convert to string URL
|
||||
let srcString: string | null = null;
|
||||
|
||||
if (typeof src === 'string') {
|
||||
srcString = src;
|
||||
} else if ((src as any) instanceof Blob || (src as any) instanceof File) {
|
||||
// Safe to convert Blob or File to object URL
|
||||
try {
|
||||
const objectUrl = URL.createObjectURL(src as Blob | File);
|
||||
srcString = objectUrl;
|
||||
// Track the URL for cleanup
|
||||
blobUrlsRef.current.add(objectUrl);
|
||||
} catch {
|
||||
// URL.createObjectURL failed, treat as invalid
|
||||
srcString = null;
|
||||
}
|
||||
} else if (
|
||||
typeof src === 'object' &&
|
||||
src !== null &&
|
||||
(src as any) instanceof MediaSource
|
||||
) {
|
||||
// MediaSource objects can also be used with createObjectURL
|
||||
try {
|
||||
const objectUrl = URL.createObjectURL(
|
||||
src as unknown as MediaSource
|
||||
);
|
||||
srcString = objectUrl;
|
||||
// Track the URL for cleanup
|
||||
blobUrlsRef.current.add(objectUrl);
|
||||
} catch {
|
||||
// URL.createObjectURL failed, treat as invalid
|
||||
srcString = null;
|
||||
}
|
||||
} else {
|
||||
// Invalid or unsafe type - not a string, Blob, File, or MediaSource
|
||||
srcString = null;
|
||||
}
|
||||
|
||||
// If we couldn't get a valid source string, show error
|
||||
if (!srcString) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Invalid or unsafe media source
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this is a video URL - render video player
|
||||
if (isVideoUrl(srcString) && isSafeMediaUrl(srcString, 'video')) {
|
||||
return (
|
||||
<div className="my-4 max-w-full overflow-hidden">
|
||||
<video
|
||||
controls
|
||||
src={srcString}
|
||||
className="w-full max-h-[360px] rounded-lg bg-black"
|
||||
preload="metadata"
|
||||
>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
{alt && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{alt}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this is an audio URL - render audio player
|
||||
if (isAudioUrl(srcString) && isSafeMediaUrl(srcString, 'audio')) {
|
||||
return (
|
||||
<div className="my-4 max-w-full overflow-hidden">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg border border-border bg-muted/30">
|
||||
<audio
|
||||
controls
|
||||
src={srcString}
|
||||
className="flex-1 min-w-0 h-10"
|
||||
>
|
||||
Your browser does not support the audio tag.
|
||||
</audio>
|
||||
</div>
|
||||
{alt && (
|
||||
<p className="text-xs text-muted-foreground mt-1">{alt}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default to image rendering
|
||||
if (!isSafeMediaUrl(srcString, 'image')) {
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Invalid or unsafe media source
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<img
|
||||
src={srcString}
|
||||
alt={alt || 'Image'}
|
||||
className="max-w-full max-h-[500px] object-contain rounded-lg border border-border my-4"
|
||||
loading="lazy"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
p: ({ children, ...props }) => {
|
||||
// Auto-linkify plain text URLs in paragraphs
|
||||
if (typeof children === 'string') {
|
||||
return <p {...props}>{linkifyText(children)}</p>;
|
||||
}
|
||||
return <p {...props}>{children}</p>;
|
||||
},
|
||||
table: ({ className, children, ...props }) => (
|
||||
<div className="my-4 overflow-x-auto -mx-1 px-1">
|
||||
<table
|
||||
className={['w-full border-separate border-spacing-0', className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ className, ...props }) => <thead className={className} {...props} />,
|
||||
tr: ({ className, ...props }) => (
|
||||
<tr
|
||||
className={[
|
||||
'm-0 border-b first:border-t',
|
||||
'[&:last-child>td:first-child]:rounded-bl-lg [&:last-child>td:last-child]:rounded-br-lg',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
th: ({ className, ...props }) => (
|
||||
<th
|
||||
className={[
|
||||
'bg-muted text-left font-bold align-top',
|
||||
'px-4 py-2 first:rounded-tl-lg last:rounded-tr-lg',
|
||||
'[&[align=center]]:text-center [&[align=right]]:text-right',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
td: ({ className, ...props }) => (
|
||||
<td
|
||||
className={[
|
||||
'border-b border-l last:border-r text-left align-top',
|
||||
'px-4 py-2 whitespace-normal break-words',
|
||||
'[&[align=center]]:text-center [&[align=right]]:text-right',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
code: CodeBlock,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MarkdownText = memo(MarkdownTextImpl);
|
||||
40
dexto/packages/webui/components/ui/popover.tsx
Normal file
40
dexto/packages/webui/components/ui/popover.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as React from 'react';
|
||||
import * as PopoverPrimitive from '@radix-ui/react-popover';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Popover({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = 'center',
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function PopoverAnchor({ ...props }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
|
||||
15
dexto/packages/webui/components/ui/scroll-area.tsx
Normal file
15
dexto/packages/webui/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ScrollAreaProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
const ScrollArea = React.forwardRef<HTMLDivElement, ScrollAreaProps>(
|
||||
({ className, children, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('relative overflow-auto', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
ScrollArea.displayName = 'ScrollArea';
|
||||
|
||||
export { ScrollArea };
|
||||
168
dexto/packages/webui/components/ui/select.tsx
Normal file
168
dexto/packages/webui/components/ui/select.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import * as React from 'react';
|
||||
import * as SelectPrimitive from '@radix-ui/react-select';
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = 'default',
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: 'sm' | 'default';
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = 'popper',
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn('text-muted-foreground px-2 py-1.5 text-xs', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn('bg-border pointer-events-none -mx-1 my-1 h-px', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
23
dexto/packages/webui/components/ui/separator.tsx
Normal file
23
dexto/packages/webui/components/ui/separator.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
const Separator = React.forwardRef<HTMLDivElement, SeparatorProps>(
|
||||
({ className, orientation = 'horizontal', ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
Separator.displayName = 'Separator';
|
||||
|
||||
export { Separator };
|
||||
7
dexto/packages/webui/components/ui/skeleton.tsx
Normal file
7
dexto/packages/webui/components/ui/skeleton.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('animate-pulse rounded-md bg-muted', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
49
dexto/packages/webui/components/ui/speak-button.tsx
Normal file
49
dexto/packages/webui/components/ui/speak-button.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Volume2 as VolumeIcon, Square as StopIcon } from 'lucide-react';
|
||||
import { TooltipIconButton } from '@/components/ui/tooltip-icon-button';
|
||||
import { speechController } from '@/components/ui/speech-controller';
|
||||
|
||||
export type SpeakButtonProps = {
|
||||
value: string;
|
||||
tooltip?: string;
|
||||
stopTooltip?: string;
|
||||
className?: string;
|
||||
rate?: number;
|
||||
pitch?: number;
|
||||
lang?: string;
|
||||
};
|
||||
|
||||
export function SpeakButton({
|
||||
value,
|
||||
tooltip = 'Speak',
|
||||
stopTooltip = 'Stop',
|
||||
className,
|
||||
rate = 1,
|
||||
pitch = 1,
|
||||
lang,
|
||||
}: SpeakButtonProps) {
|
||||
const [speaking, setSpeaking] = useState(false);
|
||||
const supported = speechController.supported;
|
||||
const hasText = (value ?? '').trim().length > 0;
|
||||
useEffect(() => {
|
||||
setSpeaking(speechController.isSpeaking());
|
||||
return speechController.subscribe(() => setSpeaking(speechController.isSpeaking()));
|
||||
}, []);
|
||||
|
||||
const onClick = () => {
|
||||
if (!supported || !hasText) return;
|
||||
if (speaking) return speechController.stop();
|
||||
speechController.speak(value, { rate, pitch, lang });
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipIconButton
|
||||
tooltip={speaking ? stopTooltip : tooltip}
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
disabled={!supported || !hasText}
|
||||
>
|
||||
{speaking ? <StopIcon size={12} /> : <VolumeIcon size={12} />}
|
||||
</TooltipIconButton>
|
||||
);
|
||||
}
|
||||
226
dexto/packages/webui/components/ui/speech-controller.ts
Normal file
226
dexto/packages/webui/components/ui/speech-controller.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
type Subscriber = () => void;
|
||||
|
||||
class SpeechController {
|
||||
private _speaking = false;
|
||||
private subscribers = new Set<Subscriber>();
|
||||
private utterance: SpeechSynthesisUtterance | null = null;
|
||||
private voices: SpeechSynthesisVoice[] = [];
|
||||
private voiceSubscribers = new Set<Subscriber>();
|
||||
private preferredVoiceName: string | null = null;
|
||||
|
||||
get supported() {
|
||||
return (
|
||||
typeof window !== 'undefined' &&
|
||||
'speechSynthesis' in window &&
|
||||
'SpeechSynthesisUtterance' in window
|
||||
);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
if (this.supported) {
|
||||
try {
|
||||
const v = window.localStorage.getItem('ttsVoiceName');
|
||||
this.preferredVoiceName = v && v.length ? v : null;
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
|
||||
const populate = () => {
|
||||
try {
|
||||
const list = window.speechSynthesis.getVoices() || [];
|
||||
const changed =
|
||||
list.length !== this.voices.length ||
|
||||
list.some((v, i) => v.name !== this.voices[i]?.name);
|
||||
if (changed) {
|
||||
this.voices = list;
|
||||
this.voiceSubscribers.forEach((cb) => cb());
|
||||
}
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
};
|
||||
populate();
|
||||
try {
|
||||
// Cast to any to avoid referencing DOM EventListener identifier at runtime
|
||||
window.speechSynthesis.addEventListener('voiceschanged', populate as any);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isSpeaking() {
|
||||
return this._speaking;
|
||||
}
|
||||
|
||||
subscribe(cb: Subscriber) {
|
||||
this.subscribers.add(cb);
|
||||
return () => {
|
||||
this.subscribers.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
subscribeVoices(cb: Subscriber) {
|
||||
this.voiceSubscribers.add(cb);
|
||||
return () => {
|
||||
this.voiceSubscribers.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
private notify() {
|
||||
for (const cb of this.subscribers) {
|
||||
try {
|
||||
cb();
|
||||
} catch {
|
||||
/* noop: subscriber error */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.supported) return;
|
||||
try {
|
||||
window.speechSynthesis.cancel();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
this.utterance = null;
|
||||
if (this._speaking) {
|
||||
this._speaking = false;
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
speak(text: string, opts?: { rate?: number; pitch?: number; lang?: string }) {
|
||||
if (!this.supported) return;
|
||||
if (!text || !text.trim()) return;
|
||||
|
||||
this.stop();
|
||||
|
||||
const utter = new window.SpeechSynthesisUtterance(text);
|
||||
if (opts?.rate != null) utter.rate = opts.rate;
|
||||
if (opts?.pitch != null) utter.pitch = opts.pitch;
|
||||
const voice = this.resolveVoice();
|
||||
if (voice) {
|
||||
try {
|
||||
utter.voice = voice;
|
||||
if (opts?.lang) utter.lang = opts.lang;
|
||||
else if (voice.lang) utter.lang = voice.lang;
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
} else if (opts?.lang) {
|
||||
utter.lang = opts.lang;
|
||||
}
|
||||
|
||||
utter.onstart = () => {
|
||||
if (this.utterance !== utter) return;
|
||||
this._speaking = true;
|
||||
this.notify();
|
||||
};
|
||||
const end = () => {
|
||||
if (this.utterance !== utter) return; // ignore events from stale utterances
|
||||
this._speaking = false;
|
||||
this.utterance = null;
|
||||
this.notify();
|
||||
};
|
||||
utter.onend = end;
|
||||
utter.onerror = end;
|
||||
|
||||
try {
|
||||
// mark current first to correlate with handlers
|
||||
this.utterance = utter;
|
||||
window.speechSynthesis.speak(utter);
|
||||
if (!this._speaking) {
|
||||
this._speaking = true;
|
||||
this.notify();
|
||||
}
|
||||
} catch {
|
||||
// ensure state resets if speaking fails
|
||||
end();
|
||||
}
|
||||
}
|
||||
|
||||
getVoices() {
|
||||
return this.voices;
|
||||
}
|
||||
setPreferredVoice(name: string | null) {
|
||||
this.preferredVoiceName = name && name.length ? name : null;
|
||||
try {
|
||||
if (this.preferredVoiceName)
|
||||
window.localStorage.setItem('ttsVoiceName', this.preferredVoiceName);
|
||||
else window.localStorage.removeItem('ttsVoiceName');
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
this.voiceSubscribers.forEach((cb) => cb());
|
||||
}
|
||||
getPreferredVoiceName() {
|
||||
return this.preferredVoiceName;
|
||||
}
|
||||
private resolveVoice(): SpeechSynthesisVoice | null {
|
||||
if (!this.voices.length) return null;
|
||||
const byName = this.preferredVoiceName
|
||||
? this.voices.find((v) => v.name === this.preferredVoiceName)
|
||||
: null;
|
||||
if (byName) return byName;
|
||||
|
||||
// Prefer Google UK English Female when available (Chrome typically exposes this voice)
|
||||
const ukFemale = this.voices.find(
|
||||
(v) =>
|
||||
v.name.toLowerCase() === 'google uk english female' ||
|
||||
(v.name.toLowerCase().includes('google uk english female') &&
|
||||
v.lang?.toLowerCase().startsWith('en-gb'))
|
||||
);
|
||||
if (ukFemale) return ukFemale;
|
||||
const score = (v: SpeechSynthesisVoice) => {
|
||||
const n = v.name.toLowerCase();
|
||||
const lang = (v.lang ?? '').toLowerCase();
|
||||
let s = 0;
|
||||
if (lang === 'en' || lang.startsWith('en-')) s += 3;
|
||||
if (n.includes('google')) s += 5;
|
||||
if (n.includes('microsoft') || n.includes('azure')) s += 4;
|
||||
if (n.includes('natural') || n.includes('neural') || n.includes('premium')) s += 4;
|
||||
if (n.includes('siri')) s += 3;
|
||||
if (n.includes('female')) s += 1;
|
||||
return s;
|
||||
};
|
||||
const sorted = [...this.voices].sort((a, b) => score(b) - score(a));
|
||||
return sorted[0] || this.voices[0] || null;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
var __speechController: SpeechController | undefined;
|
||||
}
|
||||
const g = globalThis as typeof globalThis & { __speechController?: SpeechController };
|
||||
export const speechController =
|
||||
g.__speechController ?? (g.__speechController = new SpeechController());
|
||||
|
||||
export function useSpeechVoices(): {
|
||||
voices: SpeechSynthesisVoice[];
|
||||
selected: string | null;
|
||||
setSelected: (name: string | null) => void;
|
||||
} {
|
||||
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>(speechController.getVoices());
|
||||
const [selected, setSelectedState] = useState<string | null>(
|
||||
speechController.getPreferredVoiceName()
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = speechController.subscribeVoices(() => {
|
||||
setVoices(speechController.getVoices());
|
||||
setSelectedState(speechController.getPreferredVoiceName());
|
||||
});
|
||||
return () => unsub();
|
||||
}, []);
|
||||
|
||||
const setSelected = (name: string | null) => {
|
||||
speechController.setPreferredVoice(name);
|
||||
setSelectedState(speechController.getPreferredVoiceName());
|
||||
};
|
||||
|
||||
return { voices, selected, setSelected };
|
||||
}
|
||||
33
dexto/packages/webui/components/ui/speech-reset.tsx
Normal file
33
dexto/packages/webui/components/ui/speech-reset.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function SpeechReset() {
|
||||
useEffect(() => {
|
||||
const cancel = () => {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// Cancel any lingering speech on mount (e.g., after a refresh)
|
||||
cancel();
|
||||
|
||||
// Cancel on page hide/unload as well
|
||||
const onVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') cancel();
|
||||
};
|
||||
|
||||
window.addEventListener('pagehide', cancel);
|
||||
window.addEventListener('beforeunload', cancel);
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pagehide', cancel);
|
||||
window.removeEventListener('beforeunload', cancel);
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
58
dexto/packages/webui/components/ui/speech-voice-select.tsx
Normal file
58
dexto/packages/webui/components/ui/speech-voice-select.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useSpeechVoices } from '@/components/ui/speech-controller';
|
||||
|
||||
type SpeechVoiceSelectProps = {
|
||||
active?: boolean;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export function SpeechVoiceSelect({ active = false, id }: SpeechVoiceSelectProps) {
|
||||
const { voices, selected, setSelected } = useSpeechVoices();
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setReady(false);
|
||||
return;
|
||||
}
|
||||
const idleId = (window as any).requestIdleCallback
|
||||
? (window as any).requestIdleCallback(() => setReady(true))
|
||||
: setTimeout(() => setReady(true), 0);
|
||||
return () => {
|
||||
if ((window as any).cancelIdleCallback && typeof idleId === 'number')
|
||||
(window as any).cancelIdleCallback(idleId);
|
||||
else clearTimeout(idleId as any);
|
||||
};
|
||||
}, [active]);
|
||||
|
||||
const onChange = (val: string) => {
|
||||
const name = val === 'auto' ? null : val;
|
||||
setSelected(name);
|
||||
};
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<Select value={selected ?? 'auto'} onValueChange={onChange}>
|
||||
<SelectTrigger id={id} className="h-8 w-[12rem] text-xs">
|
||||
<SelectValue placeholder="Voice" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto (best available)</SelectItem>
|
||||
{ready &&
|
||||
voices.map((v) => (
|
||||
<SelectItem key={`${v.name}-${v.lang}`} value={v.name}>
|
||||
{v.name} ({v.lang})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
27
dexto/packages/webui/components/ui/switch.tsx
Normal file
27
dexto/packages/webui/components/ui/switch.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from 'react';
|
||||
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:border-transparent data-[state=unchecked]:bg-muted data-[state=unchecked]:border-border',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0'
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
));
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||
|
||||
export { Switch };
|
||||
113
dexto/packages/webui/components/ui/tabs.tsx
Normal file
113
dexto/packages/webui/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface TabsProps {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface TabsListProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
interface TabsTriggerProps {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
icon?: React.ReactNode;
|
||||
badge?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface TabsContentProps {
|
||||
value: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TabsContext = React.createContext<{
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
} | null>(null);
|
||||
|
||||
function useTabsContext() {
|
||||
const context = React.useContext(TabsContext);
|
||||
if (!context) {
|
||||
throw new Error('Tabs components must be used within a Tabs provider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function Tabs({ value, onValueChange, children, className }: TabsProps) {
|
||||
return (
|
||||
<TabsContext.Provider value={{ value, onValueChange }}>
|
||||
<div className={cn('flex flex-col h-full', className)}>{children}</div>
|
||||
</TabsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsList({ children, className }: TabsListProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-4 py-2 border-b border-border bg-muted/30',
|
||||
className
|
||||
)}
|
||||
role="tablist"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsTrigger({
|
||||
value,
|
||||
children,
|
||||
className,
|
||||
disabled,
|
||||
icon,
|
||||
badge,
|
||||
}: TabsTriggerProps) {
|
||||
const { value: selectedValue, onValueChange } = useTabsContext();
|
||||
const isSelected = value === selectedValue;
|
||||
|
||||
return (
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
aria-disabled={disabled}
|
||||
disabled={disabled}
|
||||
onClick={() => onValueChange(value)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium rounded-md transition-all duration-150',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1',
|
||||
isSelected
|
||||
? 'bg-background text-foreground shadow-sm border border-border/50'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50',
|
||||
disabled && 'opacity-50 cursor-not-allowed',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{icon && <span className="flex-shrink-0">{icon}</span>}
|
||||
<span>{children}</span>
|
||||
{badge}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabsContent({ value, children, className }: TabsContentProps) {
|
||||
const { value: selectedValue } = useTabsContext();
|
||||
|
||||
if (value !== selectedValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div role="tabpanel" className={cn('flex-1 overflow-auto animate-fade-in', className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
dexto/packages/webui/components/ui/textarea.tsx
Normal file
18
dexto/packages/webui/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
'border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
34
dexto/packages/webui/components/ui/tooltip-icon-button.tsx
Normal file
34
dexto/packages/webui/components/ui/tooltip-icon-button.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { ComponentPropsWithoutRef, forwardRef } from 'react';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type TooltipIconButtonProps = ComponentPropsWithoutRef<typeof Button> & {
|
||||
tooltip: string;
|
||||
side?: 'top' | 'bottom' | 'left' | 'right';
|
||||
};
|
||||
|
||||
export const TooltipIconButton = forwardRef<HTMLButtonElement, TooltipIconButtonProps>(
|
||||
({ children, tooltip, side = 'bottom', className, ...rest }, ref) => {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
{...rest}
|
||||
className={cn('size-6 p-1', className)}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
<span className="sr-only">{tooltip}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side}>{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TooltipIconButton.displayName = 'TooltipIconButton';
|
||||
55
dexto/packages/webui/components/ui/tooltip.tsx
Normal file
55
dexto/packages/webui/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as React from 'react';
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground border border-border/60 shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-popover fill-popover z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] border-l border-b border-border/60" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
114
dexto/packages/webui/components/ui/ui-resource-renderer.tsx
Normal file
114
dexto/packages/webui/components/ui/ui-resource-renderer.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { UIResourceRenderer } from '@mcp-ui/client';
|
||||
import type { UIResourcePart } from '@dexto/core';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
interface UIResourceRendererWrapperProps {
|
||||
resource: UIResourcePart;
|
||||
/** Callback when the UI resource triggers an action */
|
||||
onAction?: (action: { type: string; payload?: unknown }) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper component that adapts Dexto's UIResourcePart to @mcp-ui/client's UIResourceRenderer.
|
||||
* Renders interactive MCP-UI resources (HTML, external URLs, Remote DOM) in sandboxed iframes.
|
||||
*/
|
||||
export function UIResourceRendererWrapper({ resource, onAction }: UIResourceRendererWrapperProps) {
|
||||
// Map UIResourcePart to the format expected by @mcp-ui/client
|
||||
// MCP SDK uses discriminated unions - either text OR blob, not both
|
||||
// Store metadata in _meta since annotations has a specific schema in MCP SDK
|
||||
const mcpResource = resource.blob
|
||||
? {
|
||||
type: 'resource' as const,
|
||||
resource: {
|
||||
uri: resource.uri,
|
||||
blob: resource.blob,
|
||||
...(resource.mimeType ? { mimeType: resource.mimeType } : {}),
|
||||
_meta: {
|
||||
...(resource.metadata?.title ? { title: resource.metadata.title } : {}),
|
||||
...(resource.metadata?.preferredSize
|
||||
? { preferredSize: resource.metadata.preferredSize }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
: {
|
||||
type: 'resource' as const,
|
||||
resource: {
|
||||
uri: resource.uri,
|
||||
text: resource.content || '',
|
||||
...(resource.mimeType ? { mimeType: resource.mimeType } : {}),
|
||||
_meta: {
|
||||
...(resource.metadata?.title ? { title: resource.metadata.title } : {}),
|
||||
...(resource.metadata?.preferredSize
|
||||
? { preferredSize: resource.metadata.preferredSize }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Handle UI actions from the rendered component
|
||||
const handleUIAction = async (result: { type: string; payload?: unknown }) => {
|
||||
if (onAction) {
|
||||
onAction(result);
|
||||
}
|
||||
// Return undefined to acknowledge the action
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ui-resource-container rounded-lg border border-border overflow-hidden">
|
||||
{resource.metadata?.title && (
|
||||
<div className="px-3 py-2 bg-muted/50 border-b border-border text-xs font-medium text-muted-foreground flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
|
||||
{resource.metadata.title}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="ui-resource-content"
|
||||
style={{
|
||||
width: resource.metadata?.preferredSize?.width
|
||||
? `${resource.metadata.preferredSize.width}px`
|
||||
: '100%',
|
||||
height: resource.metadata?.preferredSize?.height
|
||||
? `${resource.metadata.preferredSize.height}px`
|
||||
: 'auto',
|
||||
minHeight: '100px',
|
||||
maxWidth: '100%',
|
||||
}}
|
||||
>
|
||||
<UIResourceRenderer
|
||||
resource={mcpResource}
|
||||
onUIAction={handleUIAction}
|
||||
htmlProps={{
|
||||
autoResizeIframe: !resource.metadata?.preferredSize?.height,
|
||||
style: {
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback component shown when UI resource rendering fails or is unsupported.
|
||||
*/
|
||||
export function UIResourceFallback({ resource }: { resource: UIResourcePart }) {
|
||||
return (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg border border-border bg-muted/30 text-sm">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex flex-col gap-1 min-w-0">
|
||||
<span className="font-medium text-muted-foreground">Interactive UI Resource</span>
|
||||
<span className="text-xs text-muted-foreground/80 break-all">{resource.uri}</span>
|
||||
<span className="text-xs text-muted-foreground/60">Type: {resource.mimeType}</span>
|
||||
{resource.metadata?.title && (
|
||||
<span className="text-xs text-muted-foreground/60">
|
||||
Title: {resource.metadata.title}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user