initial commit
This commit is contained in:
141
components/ai-elements/agent.tsx
Normal file
141
components/ai-elements/agent.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Tool } from "ai";
|
||||
import { BotIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { memo } from "react";
|
||||
|
||||
import { CodeBlock } from "./code-block";
|
||||
|
||||
export type AgentProps = ComponentProps<"div">;
|
||||
|
||||
export const Agent = memo(({ className, ...props }: AgentProps) => (
|
||||
<div
|
||||
className={cn("not-prose w-full rounded-md border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
export type AgentHeaderProps = ComponentProps<"div"> & {
|
||||
name: string;
|
||||
model?: string;
|
||||
};
|
||||
|
||||
export const AgentHeader = memo(
|
||||
({ className, name, model, ...props }: AgentHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<BotIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{name}</span>
|
||||
{model && (
|
||||
<Badge className="font-mono text-xs" variant="secondary">
|
||||
{model}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export type AgentContentProps = ComponentProps<"div">;
|
||||
|
||||
export const AgentContent = memo(
|
||||
({ className, ...props }: AgentContentProps) => (
|
||||
<div className={cn("space-y-4 p-4 pt-0", className)} {...props} />
|
||||
)
|
||||
);
|
||||
|
||||
export type AgentInstructionsProps = ComponentProps<"div"> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const AgentInstructions = memo(
|
||||
({ className, children, ...props }: AgentInstructionsProps) => (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
<span className="font-medium text-muted-foreground text-sm">
|
||||
Instructions
|
||||
</span>
|
||||
<div className="rounded-md bg-muted/50 p-3 text-muted-foreground text-sm">
|
||||
<p>{children}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export type AgentToolsProps = ComponentProps<typeof Accordion>;
|
||||
|
||||
export const AgentTools = memo(({ className, ...props }: AgentToolsProps) => (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
<span className="font-medium text-muted-foreground text-sm">Tools</span>
|
||||
<Accordion className="rounded-md border" {...props} />
|
||||
</div>
|
||||
));
|
||||
|
||||
export type AgentToolProps = ComponentProps<typeof AccordionItem> & {
|
||||
tool: Tool;
|
||||
};
|
||||
|
||||
export const AgentTool = memo(
|
||||
({ className, tool, value, ...props }: AgentToolProps) => {
|
||||
const schema =
|
||||
"jsonSchema" in tool && tool.jsonSchema
|
||||
? tool.jsonSchema
|
||||
: tool.inputSchema;
|
||||
|
||||
return (
|
||||
<AccordionItem
|
||||
className={cn("border-b last:border-b-0", className)}
|
||||
value={value}
|
||||
{...props}
|
||||
>
|
||||
<AccordionTrigger className="px-3 py-2 text-sm hover:no-underline">
|
||||
{tool.description ?? "No description"}
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-3 pb-3">
|
||||
<div className="rounded-md bg-muted/50">
|
||||
<CodeBlock code={JSON.stringify(schema, null, 2)} language="json" />
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type AgentOutputProps = ComponentProps<"div"> & {
|
||||
schema: string;
|
||||
};
|
||||
|
||||
export const AgentOutput = memo(
|
||||
({ className, schema, ...props }: AgentOutputProps) => (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
<span className="font-medium text-muted-foreground text-sm">
|
||||
Output Schema
|
||||
</span>
|
||||
<div className="rounded-md bg-muted/50">
|
||||
<CodeBlock code={schema} language="typescript" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
Agent.displayName = "Agent";
|
||||
AgentHeader.displayName = "AgentHeader";
|
||||
AgentContent.displayName = "AgentContent";
|
||||
AgentInstructions.displayName = "AgentInstructions";
|
||||
AgentTools.displayName = "AgentTools";
|
||||
AgentTool.displayName = "AgentTool";
|
||||
AgentOutput.displayName = "AgentOutput";
|
||||
148
components/ai-elements/artifact.tsx
Normal file
148
components/ai-elements/artifact.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { XIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
|
||||
export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Artifact = ({ className, ...props }: ArtifactProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden rounded-lg border bg-background shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b bg-muted/50 px-4 py-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactCloseProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ArtifactClose = ({
|
||||
className,
|
||||
children,
|
||||
size = "sm",
|
||||
variant = "ghost",
|
||||
...props
|
||||
}: ArtifactCloseProps) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-8 p-0 text-muted-foreground hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <XIcon className="size-4" />}
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
|
||||
<p
|
||||
className={cn("font-medium text-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const ArtifactDescription = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactDescriptionProps) => (
|
||||
<p className={cn("text-muted-foreground text-sm", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactActions = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ArtifactActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
icon?: LucideIcon;
|
||||
};
|
||||
|
||||
export const ArtifactAction = ({
|
||||
tooltip,
|
||||
label,
|
||||
icon: Icon,
|
||||
children,
|
||||
className,
|
||||
size = "sm",
|
||||
variant = "ghost",
|
||||
...props
|
||||
}: ArtifactActionProps) => {
|
||||
const button = (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-8 p-0 text-muted-foreground hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{Icon ? <Icon className="size-4" /> : children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
export type ArtifactContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactContentProps) => (
|
||||
<div className={cn("flex-1 overflow-auto p-4", className)} {...props} />
|
||||
);
|
||||
426
components/ai-elements/attachments.tsx
Normal file
426
components/ai-elements/attachments.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FileUIPart, SourceDocumentUIPart } from "ai";
|
||||
import {
|
||||
FileTextIcon,
|
||||
GlobeIcon,
|
||||
ImageIcon,
|
||||
Music2Icon,
|
||||
PaperclipIcon,
|
||||
VideoIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo } from "react";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentData =
|
||||
| (FileUIPart & { id: string })
|
||||
| (SourceDocumentUIPart & { id: string });
|
||||
|
||||
export type AttachmentMediaCategory =
|
||||
| "image"
|
||||
| "video"
|
||||
| "audio"
|
||||
| "document"
|
||||
| "source"
|
||||
| "unknown";
|
||||
|
||||
export type AttachmentVariant = "grid" | "inline" | "list";
|
||||
|
||||
const mediaCategoryIcons: Record<AttachmentMediaCategory, typeof ImageIcon> = {
|
||||
audio: Music2Icon,
|
||||
document: FileTextIcon,
|
||||
image: ImageIcon,
|
||||
source: GlobeIcon,
|
||||
unknown: PaperclipIcon,
|
||||
video: VideoIcon,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Utility Functions
|
||||
// ============================================================================
|
||||
|
||||
export const getMediaCategory = (
|
||||
data: AttachmentData
|
||||
): AttachmentMediaCategory => {
|
||||
if (data.type === "source-document") {
|
||||
return "source";
|
||||
}
|
||||
|
||||
const mediaType = data.mediaType ?? "";
|
||||
|
||||
if (mediaType.startsWith("image/")) {
|
||||
return "image";
|
||||
}
|
||||
if (mediaType.startsWith("video/")) {
|
||||
return "video";
|
||||
}
|
||||
if (mediaType.startsWith("audio/")) {
|
||||
return "audio";
|
||||
}
|
||||
if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) {
|
||||
return "document";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
export const getAttachmentLabel = (data: AttachmentData): string => {
|
||||
if (data.type === "source-document") {
|
||||
return data.title || data.filename || "Source";
|
||||
}
|
||||
|
||||
const category = getMediaCategory(data);
|
||||
return data.filename || (category === "image" ? "Image" : "Attachment");
|
||||
};
|
||||
|
||||
const renderAttachmentImage = (
|
||||
url: string,
|
||||
filename: string | undefined,
|
||||
isGrid: boolean
|
||||
) =>
|
||||
isGrid ? (
|
||||
<img
|
||||
alt={filename || "Image"}
|
||||
className="size-full object-cover"
|
||||
height={96}
|
||||
src={url}
|
||||
width={96}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
alt={filename || "Image"}
|
||||
className="size-full rounded object-cover"
|
||||
height={20}
|
||||
src={url}
|
||||
width={20}
|
||||
/>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Contexts
|
||||
// ============================================================================
|
||||
|
||||
interface AttachmentsContextValue {
|
||||
variant: AttachmentVariant;
|
||||
}
|
||||
|
||||
const AttachmentsContext = createContext<AttachmentsContextValue | null>(null);
|
||||
|
||||
interface AttachmentContextValue {
|
||||
data: AttachmentData;
|
||||
mediaCategory: AttachmentMediaCategory;
|
||||
onRemove?: () => void;
|
||||
variant: AttachmentVariant;
|
||||
}
|
||||
|
||||
const AttachmentContext = createContext<AttachmentContextValue | null>(null);
|
||||
|
||||
// ============================================================================
|
||||
// Hooks
|
||||
// ============================================================================
|
||||
|
||||
export const useAttachmentsContext = () =>
|
||||
useContext(AttachmentsContext) ?? { variant: "grid" as const };
|
||||
|
||||
export const useAttachmentContext = () => {
|
||||
const ctx = useContext(AttachmentContext);
|
||||
if (!ctx) {
|
||||
throw new Error("Attachment components must be used within <Attachment>");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Attachments - Container
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentsProps = HTMLAttributes<HTMLDivElement> & {
|
||||
variant?: AttachmentVariant;
|
||||
};
|
||||
|
||||
export const Attachments = ({
|
||||
variant = "grid",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AttachmentsProps) => {
|
||||
const contextValue = useMemo(() => ({ variant }), [variant]);
|
||||
|
||||
return (
|
||||
<AttachmentsContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start",
|
||||
variant === "list" ? "flex-col gap-2" : "flex-wrap gap-2",
|
||||
variant === "grid" && "ml-auto w-fit",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AttachmentsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Attachment - Item
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
data: AttachmentData;
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
export const Attachment = ({
|
||||
data,
|
||||
onRemove,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AttachmentProps) => {
|
||||
const { variant } = useAttachmentsContext();
|
||||
const mediaCategory = getMediaCategory(data);
|
||||
|
||||
const contextValue = useMemo<AttachmentContextValue>(
|
||||
() => ({ data, mediaCategory, onRemove, variant }),
|
||||
[data, mediaCategory, onRemove, variant]
|
||||
);
|
||||
|
||||
return (
|
||||
<AttachmentContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative",
|
||||
variant === "grid" && "size-24 overflow-hidden rounded-lg",
|
||||
variant === "inline" && [
|
||||
"flex h-8 cursor-pointer select-none items-center gap-1.5",
|
||||
"rounded-md border border-border px-1.5",
|
||||
"font-medium text-sm transition-all",
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
],
|
||||
variant === "list" && [
|
||||
"flex w-full items-center gap-3 rounded-lg border p-3",
|
||||
"hover:bg-accent/50",
|
||||
],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AttachmentContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentPreview - Media preview
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentPreviewProps = HTMLAttributes<HTMLDivElement> & {
|
||||
fallbackIcon?: ReactNode;
|
||||
};
|
||||
|
||||
export const AttachmentPreview = ({
|
||||
fallbackIcon,
|
||||
className,
|
||||
...props
|
||||
}: AttachmentPreviewProps) => {
|
||||
const { data, mediaCategory, variant } = useAttachmentContext();
|
||||
|
||||
const iconSize = variant === "inline" ? "size-3" : "size-4";
|
||||
|
||||
const renderIcon = (Icon: typeof ImageIcon) => (
|
||||
<Icon className={cn(iconSize, "text-muted-foreground")} />
|
||||
);
|
||||
|
||||
const renderContent = () => {
|
||||
if (mediaCategory === "image" && data.type === "file" && data.url) {
|
||||
return renderAttachmentImage(data.url, data.filename, variant === "grid");
|
||||
}
|
||||
|
||||
if (mediaCategory === "video" && data.type === "file" && data.url) {
|
||||
return <video className="size-full object-cover" muted src={data.url} />;
|
||||
}
|
||||
|
||||
const Icon = mediaCategoryIcons[mediaCategory];
|
||||
return fallbackIcon ?? renderIcon(Icon);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center overflow-hidden",
|
||||
variant === "grid" && "size-full bg-muted",
|
||||
variant === "inline" && "size-5 rounded bg-background",
|
||||
variant === "list" && "size-12 rounded bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentInfo - Name and type display
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentInfoProps = HTMLAttributes<HTMLDivElement> & {
|
||||
showMediaType?: boolean;
|
||||
};
|
||||
|
||||
export const AttachmentInfo = ({
|
||||
showMediaType = false,
|
||||
className,
|
||||
...props
|
||||
}: AttachmentInfoProps) => {
|
||||
const { data, variant } = useAttachmentContext();
|
||||
const label = getAttachmentLabel(data);
|
||||
|
||||
if (variant === "grid") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("min-w-0 flex-1", className)} {...props}>
|
||||
<span className="block truncate">{label}</span>
|
||||
{showMediaType && data.mediaType && (
|
||||
<span className="block truncate text-muted-foreground text-xs">
|
||||
{data.mediaType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentRemove - Remove button
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentRemoveProps = ComponentProps<typeof Button> & {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const AttachmentRemove = ({
|
||||
label = "Remove",
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AttachmentRemoveProps) => {
|
||||
const { onRemove, variant } = useAttachmentContext();
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onRemove?.();
|
||||
},
|
||||
[onRemove]
|
||||
);
|
||||
|
||||
if (!onRemove) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
variant === "grid" && [
|
||||
"absolute top-2 right-2 size-6 rounded-full p-0",
|
||||
"bg-background/80 backdrop-blur-sm",
|
||||
"opacity-0 transition-opacity group-hover:opacity-100",
|
||||
"hover:bg-background",
|
||||
"[&>svg]:size-3",
|
||||
],
|
||||
variant === "inline" && [
|
||||
"size-5 rounded p-0",
|
||||
"opacity-0 transition-opacity group-hover:opacity-100",
|
||||
"[&>svg]:size-2.5",
|
||||
],
|
||||
variant === "list" && ["size-8 shrink-0 rounded p-0", "[&>svg]:size-4"],
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <XIcon />}
|
||||
<span className="sr-only">{label}</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentHoverCard - Hover preview
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>;
|
||||
|
||||
export const AttachmentHoverCard = ({
|
||||
openDelay = 0,
|
||||
closeDelay = 0,
|
||||
...props
|
||||
}: AttachmentHoverCardProps) => (
|
||||
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
|
||||
);
|
||||
|
||||
export type AttachmentHoverCardTriggerProps = ComponentProps<
|
||||
typeof HoverCardTrigger
|
||||
>;
|
||||
|
||||
export const AttachmentHoverCardTrigger = (
|
||||
props: AttachmentHoverCardTriggerProps
|
||||
) => <HoverCardTrigger {...props} />;
|
||||
|
||||
export type AttachmentHoverCardContentProps = ComponentProps<
|
||||
typeof HoverCardContent
|
||||
>;
|
||||
|
||||
export const AttachmentHoverCardContent = ({
|
||||
align = "start",
|
||||
className,
|
||||
...props
|
||||
}: AttachmentHoverCardContentProps) => (
|
||||
<HoverCardContent
|
||||
align={align}
|
||||
className={cn("w-auto p-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// AttachmentEmpty - Empty state
|
||||
// ============================================================================
|
||||
|
||||
export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const AttachmentEmpty = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AttachmentEmptyProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center p-4 text-muted-foreground text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? "No attachments"}
|
||||
</div>
|
||||
);
|
||||
231
components/ai-elements/audio-player.tsx
Normal file
231
components/ai-elements/audio-player.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ButtonGroup,
|
||||
ButtonGroupText,
|
||||
} from "@/components/ui/button-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Experimental_SpeechResult as SpeechResult } from "ai";
|
||||
import {
|
||||
MediaControlBar,
|
||||
MediaController,
|
||||
MediaDurationDisplay,
|
||||
MediaMuteButton,
|
||||
MediaPlayButton,
|
||||
MediaSeekBackwardButton,
|
||||
MediaSeekForwardButton,
|
||||
MediaTimeDisplay,
|
||||
MediaTimeRange,
|
||||
MediaVolumeRange,
|
||||
} from "media-chrome/react";
|
||||
import type { ComponentProps, CSSProperties } from "react";
|
||||
|
||||
export type AudioPlayerProps = Omit<
|
||||
ComponentProps<typeof MediaController>,
|
||||
"audio"
|
||||
>;
|
||||
|
||||
export const AudioPlayer = ({
|
||||
children,
|
||||
style,
|
||||
...props
|
||||
}: AudioPlayerProps) => (
|
||||
<MediaController
|
||||
audio
|
||||
data-slot="audio-player"
|
||||
style={
|
||||
{
|
||||
"--media-background-color": "transparent",
|
||||
"--media-button-icon-height": "1rem",
|
||||
"--media-button-icon-width": "1rem",
|
||||
"--media-control-background": "transparent",
|
||||
"--media-control-hover-background": "var(--color-accent)",
|
||||
"--media-control-padding": "0",
|
||||
"--media-font": "var(--font-sans)",
|
||||
"--media-font-size": "10px",
|
||||
"--media-icon-color": "currentColor",
|
||||
"--media-preview-time-background": "var(--color-background)",
|
||||
"--media-preview-time-border-radius": "var(--radius-md)",
|
||||
"--media-preview-time-text-shadow": "none",
|
||||
"--media-primary-color": "var(--color-primary)",
|
||||
"--media-range-bar-color": "var(--color-primary)",
|
||||
"--media-range-track-background": "var(--color-secondary)",
|
||||
"--media-secondary-color": "var(--color-secondary)",
|
||||
"--media-text-color": "var(--color-foreground)",
|
||||
"--media-tooltip-arrow-display": "none",
|
||||
"--media-tooltip-background": "var(--color-background)",
|
||||
"--media-tooltip-border-radius": "var(--radius-md)",
|
||||
...style,
|
||||
} as CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</MediaController>
|
||||
);
|
||||
|
||||
export type AudioPlayerElementProps = Omit<ComponentProps<"audio">, "src"> &
|
||||
(
|
||||
| {
|
||||
data: SpeechResult["audio"];
|
||||
}
|
||||
| {
|
||||
src: string;
|
||||
}
|
||||
);
|
||||
|
||||
export const AudioPlayerElement = ({ ...props }: AudioPlayerElementProps) => (
|
||||
// oxlint-disable-next-line eslint-plugin-jsx-a11y(media-has-caption) -- audio player captions are provided by consumer
|
||||
<audio
|
||||
data-slot="audio-player-element"
|
||||
slot="media"
|
||||
src={
|
||||
"src" in props
|
||||
? props.src
|
||||
: `data:${props.data.mediaType};base64,${props.data.base64}`
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type AudioPlayerControlBarProps = ComponentProps<typeof MediaControlBar>;
|
||||
|
||||
export const AudioPlayerControlBar = ({
|
||||
children,
|
||||
...props
|
||||
}: AudioPlayerControlBarProps) => (
|
||||
<MediaControlBar data-slot="audio-player-control-bar" {...props}>
|
||||
<ButtonGroup orientation="horizontal">{children}</ButtonGroup>
|
||||
</MediaControlBar>
|
||||
);
|
||||
|
||||
export type AudioPlayerPlayButtonProps = ComponentProps<typeof MediaPlayButton>;
|
||||
|
||||
export const AudioPlayerPlayButton = ({
|
||||
className,
|
||||
...props
|
||||
}: AudioPlayerPlayButtonProps) => (
|
||||
<Button asChild size="icon-sm" variant="outline">
|
||||
<MediaPlayButton
|
||||
className={cn("bg-transparent", className)}
|
||||
data-slot="audio-player-play-button"
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
|
||||
export type AudioPlayerSeekBackwardButtonProps = ComponentProps<
|
||||
typeof MediaSeekBackwardButton
|
||||
>;
|
||||
|
||||
export const AudioPlayerSeekBackwardButton = ({
|
||||
seekOffset = 10,
|
||||
...props
|
||||
}: AudioPlayerSeekBackwardButtonProps) => (
|
||||
<Button asChild size="icon-sm" variant="outline">
|
||||
<MediaSeekBackwardButton
|
||||
data-slot="audio-player-seek-backward-button"
|
||||
seekOffset={seekOffset}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
|
||||
export type AudioPlayerSeekForwardButtonProps = ComponentProps<
|
||||
typeof MediaSeekForwardButton
|
||||
>;
|
||||
|
||||
export const AudioPlayerSeekForwardButton = ({
|
||||
seekOffset = 10,
|
||||
...props
|
||||
}: AudioPlayerSeekForwardButtonProps) => (
|
||||
<Button asChild size="icon-sm" variant="outline">
|
||||
<MediaSeekForwardButton
|
||||
data-slot="audio-player-seek-forward-button"
|
||||
seekOffset={seekOffset}
|
||||
{...props}
|
||||
/>
|
||||
</Button>
|
||||
);
|
||||
|
||||
export type AudioPlayerTimeDisplayProps = ComponentProps<
|
||||
typeof MediaTimeDisplay
|
||||
>;
|
||||
|
||||
export const AudioPlayerTimeDisplay = ({
|
||||
className,
|
||||
...props
|
||||
}: AudioPlayerTimeDisplayProps) => (
|
||||
<ButtonGroupText asChild className="bg-transparent">
|
||||
<MediaTimeDisplay
|
||||
className={cn("tabular-nums", className)}
|
||||
data-slot="audio-player-time-display"
|
||||
{...props}
|
||||
/>
|
||||
</ButtonGroupText>
|
||||
);
|
||||
|
||||
export type AudioPlayerTimeRangeProps = ComponentProps<typeof MediaTimeRange>;
|
||||
|
||||
export const AudioPlayerTimeRange = ({
|
||||
className,
|
||||
...props
|
||||
}: AudioPlayerTimeRangeProps) => (
|
||||
<ButtonGroupText asChild className="bg-transparent">
|
||||
<MediaTimeRange
|
||||
className={cn("", className)}
|
||||
data-slot="audio-player-time-range"
|
||||
{...props}
|
||||
/>
|
||||
</ButtonGroupText>
|
||||
);
|
||||
|
||||
export type AudioPlayerDurationDisplayProps = ComponentProps<
|
||||
typeof MediaDurationDisplay
|
||||
>;
|
||||
|
||||
export const AudioPlayerDurationDisplay = ({
|
||||
className,
|
||||
...props
|
||||
}: AudioPlayerDurationDisplayProps) => (
|
||||
<ButtonGroupText asChild className="bg-transparent">
|
||||
<MediaDurationDisplay
|
||||
className={cn("tabular-nums", className)}
|
||||
data-slot="audio-player-duration-display"
|
||||
{...props}
|
||||
/>
|
||||
</ButtonGroupText>
|
||||
);
|
||||
|
||||
export type AudioPlayerMuteButtonProps = ComponentProps<typeof MediaMuteButton>;
|
||||
|
||||
export const AudioPlayerMuteButton = ({
|
||||
className,
|
||||
...props
|
||||
}: AudioPlayerMuteButtonProps) => (
|
||||
<ButtonGroupText asChild className="bg-transparent">
|
||||
<MediaMuteButton
|
||||
className={cn("", className)}
|
||||
data-slot="audio-player-mute-button"
|
||||
{...props}
|
||||
/>
|
||||
</ButtonGroupText>
|
||||
);
|
||||
|
||||
export type AudioPlayerVolumeRangeProps = ComponentProps<
|
||||
typeof MediaVolumeRange
|
||||
>;
|
||||
|
||||
export const AudioPlayerVolumeRange = ({
|
||||
className,
|
||||
...props
|
||||
}: AudioPlayerVolumeRangeProps) => (
|
||||
<ButtonGroupText asChild className="bg-transparent">
|
||||
<MediaVolumeRange
|
||||
className={cn("", className)}
|
||||
data-slot="audio-player-volume-range"
|
||||
{...props}
|
||||
/>
|
||||
</ButtonGroupText>
|
||||
);
|
||||
26
components/ai-elements/canvas.tsx
Normal file
26
components/ai-elements/canvas.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { ReactFlowProps } from "@xyflow/react";
|
||||
import { Background, ReactFlow } from "@xyflow/react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
type CanvasProps = ReactFlowProps & {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const deleteKeyCode = ["Backspace", "Delete"];
|
||||
|
||||
export const Canvas = ({ children, ...props }: CanvasProps) => (
|
||||
<ReactFlow
|
||||
deleteKeyCode={deleteKeyCode}
|
||||
fitView
|
||||
panOnDrag={false}
|
||||
panOnScroll
|
||||
selectionOnDrag={true}
|
||||
zoomOnDoubleClick={false}
|
||||
{...props}
|
||||
>
|
||||
<Background bgColor="var(--sidebar)" />
|
||||
{children}
|
||||
</ReactFlow>
|
||||
);
|
||||
222
components/ai-elements/chain-of-thought.tsx
Normal file
222
components/ai-elements/chain-of-thought.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { BrainIcon, ChevronDownIcon, DotIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, memo, useContext, useMemo } from "react";
|
||||
|
||||
interface ChainOfThoughtContextValue {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useChainOfThought = () => {
|
||||
const context = useContext(ChainOfThoughtContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"ChainOfThought components must be used within ChainOfThought"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ChainOfThoughtProps = ComponentProps<"div"> & {
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const ChainOfThought = memo(
|
||||
({
|
||||
className,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
prop: open,
|
||||
});
|
||||
|
||||
const chainOfThoughtContext = useMemo(
|
||||
() => ({ isOpen, setIsOpen }),
|
||||
[isOpen, setIsOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
|
||||
<div className={cn("not-prose w-full space-y-4", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</ChainOfThoughtContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ChainOfThoughtHeaderProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
>;
|
||||
|
||||
export const ChainOfThoughtHeader = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
|
||||
const { isOpen, setIsOpen } = useChainOfThought();
|
||||
|
||||
return (
|
||||
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<BrainIcon className="size-4" />
|
||||
<span className="flex-1 text-left">
|
||||
{children ?? "Chain of Thought"}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
|
||||
icon?: LucideIcon;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
status?: "complete" | "active" | "pending";
|
||||
};
|
||||
|
||||
const stepStatusStyles = {
|
||||
active: "text-foreground",
|
||||
complete: "text-muted-foreground",
|
||||
pending: "text-muted-foreground/50",
|
||||
};
|
||||
|
||||
export const ChainOfThoughtStep = memo(
|
||||
({
|
||||
className,
|
||||
icon: Icon = DotIcon,
|
||||
label,
|
||||
description,
|
||||
status = "complete",
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtStepProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 text-sm",
|
||||
stepStatusStyles[status],
|
||||
"fade-in-0 slide-in-from-top-2 animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative mt-0.5">
|
||||
<Icon className="size-4" />
|
||||
<div className="absolute top-7 bottom-0 left-1/2 -mx-px w-px bg-border" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 overflow-hidden">
|
||||
<div>{label}</div>
|
||||
{description && (
|
||||
<div className="text-muted-foreground text-xs">{description}</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
|
||||
|
||||
export const ChainOfThoughtSearchResults = memo(
|
||||
({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
|
||||
<div
|
||||
className={cn("flex flex-wrap items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
|
||||
|
||||
export const ChainOfThoughtSearchResult = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
|
||||
<Badge
|
||||
className={cn("gap-1 px-2 py-0.5 font-normal text-xs", className)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
)
|
||||
);
|
||||
|
||||
export type ChainOfThoughtContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
>;
|
||||
|
||||
export const ChainOfThoughtContent = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtContentProps) => {
|
||||
const { isOpen } = useChainOfThought();
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen}>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-2 space-y-3",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export const ChainOfThoughtImage = memo(
|
||||
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
|
||||
<div className={cn("mt-2 space-y-2", className)} {...props}>
|
||||
<div className="relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg bg-muted p-3">
|
||||
{children}
|
||||
</div>
|
||||
{caption && <p className="text-muted-foreground text-xs">{caption}</p>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
ChainOfThought.displayName = "ChainOfThought";
|
||||
ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader";
|
||||
ChainOfThoughtStep.displayName = "ChainOfThoughtStep";
|
||||
ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults";
|
||||
ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult";
|
||||
ChainOfThoughtContent.displayName = "ChainOfThoughtContent";
|
||||
ChainOfThoughtImage.displayName = "ChainOfThoughtImage";
|
||||
71
components/ai-elements/checkpoint.tsx
Normal file
71
components/ai-elements/checkpoint.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LucideProps } from "lucide-react";
|
||||
import { BookmarkIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
|
||||
export type CheckpointProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Checkpoint = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CheckpointProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-0.5 overflow-hidden text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Separator />
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CheckpointIconProps = LucideProps;
|
||||
|
||||
export const CheckpointIcon = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CheckpointIconProps) =>
|
||||
children ?? (
|
||||
<BookmarkIcon className={cn("size-4 shrink-0", className)} {...props} />
|
||||
);
|
||||
|
||||
export type CheckpointTriggerProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
export const CheckpointTrigger = ({
|
||||
children,
|
||||
variant = "ghost",
|
||||
size = "sm",
|
||||
tooltip,
|
||||
...props
|
||||
}: CheckpointTriggerProps) =>
|
||||
tooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="start" side="bottom">
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
562
components/ai-elements/code-block.tsx
Normal file
562
components/ai-elements/code-block.tsx
Normal file
@@ -0,0 +1,562 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type {
|
||||
BundledLanguage,
|
||||
BundledTheme,
|
||||
HighlighterGeneric,
|
||||
ThemedToken,
|
||||
} from "shiki";
|
||||
import { createHighlighter } from "shiki";
|
||||
|
||||
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
|
||||
const isUnderline = (fontStyle: number | undefined) =>
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
fontStyle && fontStyle & 4;
|
||||
|
||||
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
|
||||
interface KeyedToken {
|
||||
token: ThemedToken;
|
||||
key: string;
|
||||
}
|
||||
interface KeyedLine {
|
||||
tokens: KeyedToken[];
|
||||
key: string;
|
||||
}
|
||||
|
||||
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
|
||||
lines.map((line, lineIdx) => ({
|
||||
key: `line-${lineIdx}`,
|
||||
tokens: line.map((token, tokenIdx) => ({
|
||||
key: `line-${lineIdx}-${tokenIdx}`,
|
||||
token,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Token rendering component
|
||||
const TokenSpan = ({ token }: { token: ThemedToken }) => (
|
||||
<span
|
||||
className="dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)]"
|
||||
style={
|
||||
{
|
||||
backgroundColor: token.bgColor,
|
||||
color: token.color,
|
||||
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
|
||||
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
|
||||
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
|
||||
...token.htmlStyle,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{token.content}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Line number styles using CSS counters
|
||||
const LINE_NUMBER_CLASSES = cn(
|
||||
"block",
|
||||
"before:content-[counter(line)]",
|
||||
"before:inline-block",
|
||||
"before:[counter-increment:line]",
|
||||
"before:w-8",
|
||||
"before:mr-4",
|
||||
"before:text-right",
|
||||
"before:text-muted-foreground/50",
|
||||
"before:font-mono",
|
||||
"before:select-none"
|
||||
);
|
||||
|
||||
// Line rendering component
|
||||
const LineSpan = ({
|
||||
keyedLine,
|
||||
showLineNumbers,
|
||||
}: {
|
||||
keyedLine: KeyedLine;
|
||||
showLineNumbers: boolean;
|
||||
}) => (
|
||||
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
|
||||
{keyedLine.tokens.length === 0
|
||||
? "\n"
|
||||
: keyedLine.tokens.map(({ token, key }) => (
|
||||
<TokenSpan key={key} token={token} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Types
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language: BundledLanguage;
|
||||
showLineNumbers?: boolean;
|
||||
};
|
||||
|
||||
interface TokenizedCode {
|
||||
tokens: ThemedToken[][];
|
||||
fg: string;
|
||||
bg: string;
|
||||
}
|
||||
|
||||
interface CodeBlockContextType {
|
||||
code: string;
|
||||
}
|
||||
|
||||
// Context
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: "",
|
||||
});
|
||||
|
||||
// Highlighter cache (singleton per language)
|
||||
const highlighterCache = new Map<
|
||||
string,
|
||||
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
|
||||
>();
|
||||
|
||||
// Token cache
|
||||
const tokensCache = new Map<string, TokenizedCode>();
|
||||
|
||||
// Subscribers for async token updates
|
||||
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
|
||||
|
||||
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
|
||||
const start = code.slice(0, 100);
|
||||
const end = code.length > 100 ? code.slice(-100) : "";
|
||||
return `${language}:${code.length}:${start}:${end}`;
|
||||
};
|
||||
|
||||
const getHighlighter = (
|
||||
language: BundledLanguage
|
||||
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
|
||||
const cached = highlighterCache.get(language);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const highlighterPromise = createHighlighter({
|
||||
langs: [language],
|
||||
themes: ["github-light", "github-dark"],
|
||||
});
|
||||
|
||||
highlighterCache.set(language, highlighterPromise);
|
||||
return highlighterPromise;
|
||||
};
|
||||
|
||||
// Create raw tokens for immediate display while highlighting loads
|
||||
const createRawTokens = (code: string): TokenizedCode => ({
|
||||
bg: "transparent",
|
||||
fg: "inherit",
|
||||
tokens: code.split("\n").map((line) =>
|
||||
line === ""
|
||||
? []
|
||||
: [
|
||||
{
|
||||
color: "inherit",
|
||||
content: line,
|
||||
} as ThemedToken,
|
||||
]
|
||||
),
|
||||
});
|
||||
|
||||
// Synchronous highlight with callback for async results
|
||||
export const highlightCode = (
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
|
||||
callback?: (result: TokenizedCode) => void
|
||||
): TokenizedCode | null => {
|
||||
const tokensCacheKey = getTokensCacheKey(code, language);
|
||||
|
||||
// Return cached result if available
|
||||
const cached = tokensCache.get(tokensCacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Subscribe callback if provided
|
||||
if (callback) {
|
||||
if (!subscribers.has(tokensCacheKey)) {
|
||||
subscribers.set(tokensCacheKey, new Set());
|
||||
}
|
||||
subscribers.get(tokensCacheKey)?.add(callback);
|
||||
}
|
||||
|
||||
// Start highlighting in background - fire-and-forget async pattern
|
||||
getHighlighter(language)
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
|
||||
.then((highlighter) => {
|
||||
const availableLangs = highlighter.getLoadedLanguages();
|
||||
const langToUse = availableLangs.includes(language) ? language : "text";
|
||||
|
||||
const result = highlighter.codeToTokens(code, {
|
||||
lang: langToUse,
|
||||
themes: {
|
||||
dark: "github-dark",
|
||||
light: "github-light",
|
||||
},
|
||||
});
|
||||
|
||||
const tokenized: TokenizedCode = {
|
||||
bg: result.bg ?? "transparent",
|
||||
fg: result.fg ?? "inherit",
|
||||
tokens: result.tokens,
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
tokensCache.set(tokensCacheKey, tokenized);
|
||||
|
||||
// Notify all subscribers
|
||||
const subs = subscribers.get(tokensCacheKey);
|
||||
if (subs) {
|
||||
for (const sub of subs) {
|
||||
sub(tokenized);
|
||||
}
|
||||
subscribers.delete(tokensCacheKey);
|
||||
}
|
||||
})
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
|
||||
.catch((error) => {
|
||||
console.error("Failed to highlight code:", error);
|
||||
subscribers.delete(tokensCacheKey);
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const CodeBlockBody = memo(
|
||||
({
|
||||
tokenized,
|
||||
showLineNumbers,
|
||||
className,
|
||||
}: {
|
||||
tokenized: TokenizedCode;
|
||||
showLineNumbers: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
const preStyle = useMemo(
|
||||
() => ({
|
||||
backgroundColor: tokenized.bg,
|
||||
color: tokenized.fg,
|
||||
}),
|
||||
[tokenized.bg, tokenized.fg]
|
||||
);
|
||||
|
||||
const keyedLines = useMemo(
|
||||
() => addKeysToTokens(tokenized.tokens),
|
||||
[tokenized.tokens]
|
||||
);
|
||||
|
||||
return (
|
||||
<pre
|
||||
className={cn(
|
||||
"dark:!bg-[var(--shiki-dark-bg)] dark:!text-[var(--shiki-dark)] m-0 p-4 text-sm",
|
||||
className
|
||||
)}
|
||||
style={preStyle}
|
||||
>
|
||||
<code
|
||||
className={cn(
|
||||
"font-mono text-sm",
|
||||
showLineNumbers && "[counter-increment:line_0] [counter-reset:line]"
|
||||
)}
|
||||
>
|
||||
{keyedLines.map((keyedLine) => (
|
||||
<LineSpan
|
||||
key={keyedLine.key}
|
||||
keyedLine={keyedLine}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.tokenized === nextProps.tokenized &&
|
||||
prevProps.showLineNumbers === nextProps.showLineNumbers &&
|
||||
prevProps.className === nextProps.className
|
||||
);
|
||||
|
||||
CodeBlockBody.displayName = "CodeBlockBody";
|
||||
|
||||
export const CodeBlockContainer = ({
|
||||
className,
|
||||
language,
|
||||
style,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
|
||||
className
|
||||
)}
|
||||
data-language={language}
|
||||
style={{
|
||||
containIntrinsicSize: "auto 200px",
|
||||
contentVisibility: "auto",
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CodeBlockHeader = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CodeBlockTitle = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex items-center gap-2", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CodeBlockFilename = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span className={cn("font-mono", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export const CodeBlockActions = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CodeBlockContent = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
}: {
|
||||
code: string;
|
||||
language: BundledLanguage;
|
||||
showLineNumbers?: boolean;
|
||||
}) => {
|
||||
// Memoized raw tokens for immediate display
|
||||
const rawTokens = useMemo(() => createRawTokens(code), [code]);
|
||||
|
||||
// Synchronous cache lookup — avoids setState in effect for cached results
|
||||
const syncTokens = useMemo(
|
||||
() => highlightCode(code, language) ?? rawTokens,
|
||||
[code, language, rawTokens]
|
||||
);
|
||||
|
||||
// Async highlighting result (populated after shiki loads)
|
||||
const [asyncTokens, setAsyncTokens] = useState<TokenizedCode | null>(null);
|
||||
const asyncKeyRef = useRef({ code, language });
|
||||
|
||||
// Invalidate stale async tokens synchronously during render
|
||||
if (
|
||||
asyncKeyRef.current.code !== code ||
|
||||
asyncKeyRef.current.language !== language
|
||||
) {
|
||||
asyncKeyRef.current = { code, language };
|
||||
setAsyncTokens(null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
highlightCode(code, language, (result) => {
|
||||
if (!cancelled) {
|
||||
setAsyncTokens(result);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, language]);
|
||||
|
||||
const tokenized = asyncTokens ?? syncTokens;
|
||||
|
||||
return (
|
||||
<div className="relative overflow-auto">
|
||||
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const contextValue = useMemo(() => ({ code }), [code]);
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={contextValue}>
|
||||
<CodeBlockContainer className={className} language={language} {...props}>
|
||||
{children}
|
||||
<CodeBlockContent
|
||||
code={code}
|
||||
language={language}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
</CodeBlockContainer>
|
||||
</CodeBlockContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const CodeBlockCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isCopied) {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(
|
||||
() => setIsCopied(false),
|
||||
timeout
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [code, onCopy, onError, timeout, isCopied]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
|
||||
|
||||
export const CodeBlockLanguageSelector = (
|
||||
props: CodeBlockLanguageSelectorProps
|
||||
) => <Select {...props} />;
|
||||
|
||||
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
|
||||
typeof SelectTrigger
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorTrigger = ({
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockLanguageSelectorTriggerProps) => (
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"h-7 border-none bg-transparent px-2 text-xs shadow-none",
|
||||
className
|
||||
)}
|
||||
size="sm"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type CodeBlockLanguageSelectorValueProps = ComponentProps<
|
||||
typeof SelectValue
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorValue = (
|
||||
props: CodeBlockLanguageSelectorValueProps
|
||||
) => <SelectValue {...props} />;
|
||||
|
||||
export type CodeBlockLanguageSelectorContentProps = ComponentProps<
|
||||
typeof SelectContent
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorContent = ({
|
||||
align = "end",
|
||||
...props
|
||||
}: CodeBlockLanguageSelectorContentProps) => (
|
||||
<SelectContent align={align} {...props} />
|
||||
);
|
||||
|
||||
export type CodeBlockLanguageSelectorItemProps = ComponentProps<
|
||||
typeof SelectItem
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorItem = (
|
||||
props: CodeBlockLanguageSelectorItemProps
|
||||
) => <SelectItem {...props} />;
|
||||
458
components/ai-elements/commit.tsx
Normal file
458
components/ai-elements/commit.tsx
Normal file
@@ -0,0 +1,458 @@
|
||||
"use client";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
FileIcon,
|
||||
GitCommitIcon,
|
||||
MinusIcon,
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export type CommitProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Commit = ({ className, children, ...props }: CommitProps) => (
|
||||
<Collapsible
|
||||
className={cn("rounded-lg border bg-background", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
);
|
||||
|
||||
export type CommitHeaderProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const CommitHeader = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitHeaderProps) => (
|
||||
<CollapsibleTrigger asChild {...props}>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex cursor-pointer items-center justify-between gap-4 p-3 text-left transition-colors hover:opacity-80",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type CommitHashProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const CommitHash = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitHashProps) => (
|
||||
<span className={cn("font-mono text-xs", className)} {...props}>
|
||||
<GitCommitIcon className="mr-1 inline-block size-3" />
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type CommitMessageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const CommitMessage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitMessageProps) => (
|
||||
<span className={cn("font-medium text-sm", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type CommitMetadataProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitMetadata = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitMetadataProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-muted-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitSeparatorProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const CommitSeparator = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitSeparatorProps) => (
|
||||
<span className={className} {...props}>
|
||||
{children ?? "•"}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type CommitInfoProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitInfo = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitInfoProps) => (
|
||||
<div className={cn("flex flex-1 flex-col", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitAuthorProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitAuthor = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitAuthorProps) => (
|
||||
<div className={cn("flex items-center", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitAuthorAvatarProps = ComponentProps<typeof Avatar> & {
|
||||
initials: string;
|
||||
};
|
||||
|
||||
export const CommitAuthorAvatar = ({
|
||||
initials,
|
||||
className,
|
||||
...props
|
||||
}: CommitAuthorAvatarProps) => (
|
||||
<Avatar className={cn("size-8", className)} {...props}>
|
||||
<AvatarFallback className="text-xs">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
|
||||
export type CommitTimestampProps = HTMLAttributes<HTMLTimeElement> & {
|
||||
date: Date;
|
||||
};
|
||||
|
||||
const relativeTimeFormat = new Intl.RelativeTimeFormat("en", {
|
||||
numeric: "auto",
|
||||
});
|
||||
|
||||
const formatRelativeDate = (date: Date) => {
|
||||
const days = Math.round(
|
||||
(date.getTime() - Date.now()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
return relativeTimeFormat.format(days, "day");
|
||||
};
|
||||
|
||||
export const CommitTimestamp = ({
|
||||
date,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitTimestampProps) => {
|
||||
const [formatted, setFormatted] = useState("");
|
||||
|
||||
const updateFormatted = useCallback(() => {
|
||||
setFormatted(formatRelativeDate(date));
|
||||
}, [date]);
|
||||
|
||||
useEffect(() => {
|
||||
updateFormatted();
|
||||
}, [updateFormatted]);
|
||||
|
||||
return (
|
||||
<time
|
||||
className={cn("text-xs", className)}
|
||||
dateTime={date.toISOString()}
|
||||
{...props}
|
||||
>
|
||||
{children ?? formatted}
|
||||
</time>
|
||||
);
|
||||
};
|
||||
|
||||
export type CommitActionsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const handleActionsClick = (e: React.MouseEvent) => e.stopPropagation();
|
||||
const handleActionsKeyDown = (e: React.KeyboardEvent) => e.stopPropagation();
|
||||
|
||||
export const CommitActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitActionsProps) => (
|
||||
<div
|
||||
className={cn("flex items-center gap-1", className)}
|
||||
onClick={handleActionsClick}
|
||||
onKeyDown={handleActionsKeyDown}
|
||||
role="group"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
hash: string;
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const CommitCopyButton = ({
|
||||
hash,
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CommitCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isCopied) {
|
||||
await navigator.clipboard.writeText(hash);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(
|
||||
() => setIsCopied(false),
|
||||
timeout
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [hash, onCopy, onError, timeout, isCopied]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("size-7 shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type CommitContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const CommitContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitContentProps) => (
|
||||
<CollapsibleContent className={cn("border-t p-3", className)} {...props}>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
);
|
||||
|
||||
export type CommitFilesProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitFiles = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFilesProps) => (
|
||||
<div className={cn("space-y-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitFileProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitFile = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded px-2 py-1 text-sm hover:bg-muted/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitFileInfoProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitFileInfo = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileInfoProps) => (
|
||||
<div className={cn("flex min-w-0 items-center gap-2", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
const fileStatusStyles = {
|
||||
added: "text-green-600 dark:text-green-400",
|
||||
deleted: "text-red-600 dark:text-red-400",
|
||||
modified: "text-yellow-600 dark:text-yellow-400",
|
||||
renamed: "text-blue-600 dark:text-blue-400",
|
||||
};
|
||||
|
||||
const fileStatusLabels = {
|
||||
added: "A",
|
||||
deleted: "D",
|
||||
modified: "M",
|
||||
renamed: "R",
|
||||
};
|
||||
|
||||
export type CommitFileStatusProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
status: "added" | "modified" | "deleted" | "renamed";
|
||||
};
|
||||
|
||||
export const CommitFileStatus = ({
|
||||
status,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileStatusProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium font-mono text-xs",
|
||||
fileStatusStyles[status],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? fileStatusLabels[status]}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type CommitFileIconProps = ComponentProps<typeof FileIcon>;
|
||||
|
||||
export const CommitFileIcon = ({
|
||||
className,
|
||||
...props
|
||||
}: CommitFileIconProps) => (
|
||||
<FileIcon
|
||||
className={cn("size-3.5 shrink-0 text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type CommitFilePathProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const CommitFilePath = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFilePathProps) => (
|
||||
<span className={cn("truncate font-mono text-xs", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type CommitFileChangesProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const CommitFileChanges = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileChangesProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1 font-mono text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CommitFileAdditionsProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const CommitFileAdditions = ({
|
||||
count,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileAdditionsProps) => {
|
||||
if (count <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("text-green-600 dark:text-green-400", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<PlusIcon className="inline-block size-3" />
|
||||
{count}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type CommitFileDeletionsProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const CommitFileDeletions = ({
|
||||
count,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CommitFileDeletionsProps) => {
|
||||
if (count <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("text-red-600 dark:text-red-400", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<MinusIcon className="inline-block size-3" />
|
||||
{count}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
174
components/ai-elements/confirmation.tsx
Normal file
174
components/ai-elements/confirmation.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
type ToolUIPartApproval =
|
||||
| {
|
||||
id: string;
|
||||
approved?: never;
|
||||
reason?: never;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
approved: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
approved: true;
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
approved: true;
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
approved: false;
|
||||
reason?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
interface ConfirmationContextValue {
|
||||
approval: ToolUIPartApproval;
|
||||
state: ToolUIPart["state"];
|
||||
}
|
||||
|
||||
const ConfirmationContext = createContext<ConfirmationContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useConfirmation = () => {
|
||||
const context = useContext(ConfirmationContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Confirmation components must be used within Confirmation");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ConfirmationProps = ComponentProps<typeof Alert> & {
|
||||
approval?: ToolUIPartApproval;
|
||||
state: ToolUIPart["state"];
|
||||
};
|
||||
|
||||
export const Confirmation = ({
|
||||
className,
|
||||
approval,
|
||||
state,
|
||||
...props
|
||||
}: ConfirmationProps) => {
|
||||
const contextValue = useMemo(() => ({ approval, state }), [approval, state]);
|
||||
|
||||
if (!approval || state === "input-streaming" || state === "input-available") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmationContext.Provider value={contextValue}>
|
||||
<Alert className={cn("flex flex-col gap-2", className)} {...props} />
|
||||
</ConfirmationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type ConfirmationTitleProps = ComponentProps<typeof AlertDescription>;
|
||||
|
||||
export const ConfirmationTitle = ({
|
||||
className,
|
||||
...props
|
||||
}: ConfirmationTitleProps) => (
|
||||
<AlertDescription className={cn("inline", className)} {...props} />
|
||||
);
|
||||
|
||||
export interface ConfirmationRequestProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const ConfirmationRequest = ({ children }: ConfirmationRequestProps) => {
|
||||
const { state } = useConfirmation();
|
||||
|
||||
// Only show when approval is requested
|
||||
if (state !== "approval-requested") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export interface ConfirmationAcceptedProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const ConfirmationAccepted = ({
|
||||
children,
|
||||
}: ConfirmationAcceptedProps) => {
|
||||
const { approval, state } = useConfirmation();
|
||||
|
||||
// Only show when approved and in response states
|
||||
if (
|
||||
!approval?.approved ||
|
||||
(state !== "approval-responded" &&
|
||||
state !== "output-denied" &&
|
||||
state !== "output-available")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export interface ConfirmationRejectedProps {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const ConfirmationRejected = ({
|
||||
children,
|
||||
}: ConfirmationRejectedProps) => {
|
||||
const { approval, state } = useConfirmation();
|
||||
|
||||
// Only show when rejected and in response states
|
||||
if (
|
||||
approval?.approved !== false ||
|
||||
(state !== "approval-responded" &&
|
||||
state !== "output-denied" &&
|
||||
state !== "output-available")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export type ConfirmationActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const ConfirmationActions = ({
|
||||
className,
|
||||
...props
|
||||
}: ConfirmationActionsProps) => {
|
||||
const { state } = useConfirmation();
|
||||
|
||||
// Only show when approval is requested
|
||||
if (state !== "approval-requested") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-end gap-2 self-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type ConfirmationActionProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConfirmationAction = (props: ConfirmationActionProps) => (
|
||||
<Button className="h-8 px-3 text-sm" type="button" {...props} />
|
||||
);
|
||||
28
components/ai-elements/connection.tsx
Normal file
28
components/ai-elements/connection.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { ConnectionLineComponent } from "@xyflow/react";
|
||||
|
||||
const HALF = 0.5;
|
||||
|
||||
export const Connection: ConnectionLineComponent = ({
|
||||
fromX,
|
||||
fromY,
|
||||
toX,
|
||||
toY,
|
||||
}) => (
|
||||
<g>
|
||||
<path
|
||||
className="animated"
|
||||
d={`M${fromX},${fromY} C ${fromX + (toX - fromX) * HALF},${fromY} ${fromX + (toX - fromX) * HALF},${toY} ${toX},${toY}`}
|
||||
fill="none"
|
||||
stroke="var(--color-ring)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<circle
|
||||
cx={toX}
|
||||
cy={toY}
|
||||
fill="#fff"
|
||||
r={3}
|
||||
stroke="var(--color-ring)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
409
components/ai-elements/context.tsx
Normal file
409
components/ai-elements/context.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LanguageModelUsage } from "ai";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
import { getUsage } from "tokenlens";
|
||||
|
||||
const PERCENT_MAX = 100;
|
||||
const ICON_RADIUS = 10;
|
||||
const ICON_VIEWBOX = 24;
|
||||
const ICON_CENTER = 12;
|
||||
const ICON_STROKE_WIDTH = 2;
|
||||
|
||||
type ModelId = string;
|
||||
|
||||
interface ContextSchema {
|
||||
usedTokens: number;
|
||||
maxTokens: number;
|
||||
usage?: LanguageModelUsage;
|
||||
modelId?: ModelId;
|
||||
}
|
||||
|
||||
const ContextContext = createContext<ContextSchema | null>(null);
|
||||
|
||||
const useContextValue = () => {
|
||||
const context = useContext(ContextContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Context components must be used within Context");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema;
|
||||
|
||||
export const Context = ({
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
usage,
|
||||
modelId,
|
||||
...props
|
||||
}: ContextProps) => {
|
||||
const contextValue = useMemo(
|
||||
() => ({ maxTokens, modelId, usage, usedTokens }),
|
||||
[maxTokens, modelId, usage, usedTokens]
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextContext.Provider value={contextValue}>
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
</ContextContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const ContextIcon = () => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const circumference = 2 * Math.PI * ICON_RADIUS;
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const dashOffset = circumference * (1 - usedPercent);
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-label="Model context usage"
|
||||
height="20"
|
||||
role="img"
|
||||
style={{ color: "currentcolor" }}
|
||||
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
|
||||
width="20"
|
||||
>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
fill="none"
|
||||
opacity="0.25"
|
||||
r={ICON_RADIUS}
|
||||
stroke="currentColor"
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
/>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
fill="none"
|
||||
opacity="0.7"
|
||||
r={ICON_RADIUS}
|
||||
stroke="currentColor"
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
style={{ transform: "rotate(-90deg)", transformOrigin: "center" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextTriggerProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const renderedPercent = new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 1,
|
||||
style: "percent",
|
||||
}).format(usedPercent);
|
||||
|
||||
return (
|
||||
<HoverCardTrigger asChild>
|
||||
{children ?? (
|
||||
<Button type="button" variant="ghost" {...props}>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{renderedPercent}
|
||||
</span>
|
||||
<ContextIcon />
|
||||
</Button>
|
||||
)}
|
||||
</HoverCardTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextContentProps = ComponentProps<typeof HoverCardContent>;
|
||||
|
||||
export const ContextContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ContextContentProps) => (
|
||||
<HoverCardContent
|
||||
className={cn("min-w-60 divide-y overflow-hidden p-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ContextContentHeaderProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentHeader = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentHeaderProps) => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const displayPct = new Intl.NumberFormat("en-US", {
|
||||
maximumFractionDigits: 1,
|
||||
style: "percent",
|
||||
}).format(usedPercent);
|
||||
const used = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(usedTokens);
|
||||
const total = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(maxTokens);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full space-y-2 p-3", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<p>{displayPct}</p>
|
||||
<p className="font-mono text-muted-foreground">
|
||||
{used} / {total}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Progress className="bg-muted" value={usedPercent * PERCENT_MAX} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextContentBodyProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentBody = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentBodyProps) => (
|
||||
<div className={cn("w-full p-3", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ContextContentFooterProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentFooter = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentFooterProps) => {
|
||||
const { modelId, usage } = useContextValue();
|
||||
const costUSD = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: {
|
||||
input: usage?.inputTokens ?? 0,
|
||||
output: usage?.outputTokens ?? 0,
|
||||
},
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const totalCost = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
style: "currency",
|
||||
}).format(costUSD ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<span className="text-muted-foreground">Total cost</span>
|
||||
<span>{totalCost}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TokensWithCost = ({
|
||||
tokens,
|
||||
costText,
|
||||
}: {
|
||||
tokens?: number;
|
||||
costText?: string;
|
||||
}) => (
|
||||
<span>
|
||||
{tokens === undefined
|
||||
? "—"
|
||||
: new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(tokens)}
|
||||
{costText ? (
|
||||
<span className="ml-2 text-muted-foreground">• {costText}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type ContextInputUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextInputUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextInputUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const inputTokens = usage?.inputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!inputTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inputCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { input: inputTokens, output: 0 },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const inputCostText = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
style: "currency",
|
||||
}).format(inputCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Input</span>
|
||||
<TokensWithCost costText={inputCostText} tokens={inputTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextOutputUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextOutputUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextOutputUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const outputTokens = usage?.outputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!outputTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const outputCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { input: 0, output: outputTokens },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const outputCostText = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
style: "currency",
|
||||
}).format(outputCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Output</span>
|
||||
<TokensWithCost costText={outputCostText} tokens={outputTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextReasoningUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextReasoningUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextReasoningUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const reasoningTokens = usage?.reasoningTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!reasoningTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reasoningCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { reasoningTokens },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const reasoningCostText = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
style: "currency",
|
||||
}).format(reasoningCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Reasoning</span>
|
||||
<TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextCacheUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextCacheUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextCacheUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const cacheTokens = usage?.cachedInputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!cacheTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { cacheReads: cacheTokens, input: 0, output: 0 },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const cacheCostText = new Intl.NumberFormat("en-US", {
|
||||
currency: "USD",
|
||||
style: "currency",
|
||||
}).format(cacheCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Cache</span>
|
||||
<TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
18
components/ai-elements/controls.tsx
Normal file
18
components/ai-elements/controls.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Controls as ControlsPrimitive } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type ControlsProps = ComponentProps<typeof ControlsPrimitive>;
|
||||
|
||||
export const Controls = ({ className, ...props }: ControlsProps) => (
|
||||
<ControlsPrimitive
|
||||
className={cn(
|
||||
"gap-px overflow-hidden rounded-md border bg-card p-1 shadow-none!",
|
||||
"[&>button]:rounded-md [&>button]:border-none! [&>button]:bg-transparent! [&>button]:hover:bg-secondary!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
168
components/ai-elements/conversation.tsx
Normal file
168
components/ai-elements/conversation.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { UIMessage } from "ai";
|
||||
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn("relative flex-1 overflow-y-hidden", className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>;
|
||||
|
||||
export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn("flex flex-col gap-8 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title = "No messages yet",
|
||||
description = "Start a conversation to see messages here",
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
return (
|
||||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full dark:bg-background dark:hover:bg-muted",
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const getMessageText = (message: UIMessage): string =>
|
||||
message.parts
|
||||
.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("");
|
||||
|
||||
export type ConversationDownloadProps = Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
"onClick"
|
||||
> & {
|
||||
messages: UIMessage[];
|
||||
filename?: string;
|
||||
formatMessage?: (message: UIMessage, index: number) => string;
|
||||
};
|
||||
|
||||
const defaultFormatMessage = (message: UIMessage): string => {
|
||||
const roleLabel =
|
||||
message.role.charAt(0).toUpperCase() + message.role.slice(1);
|
||||
return `**${roleLabel}:** ${getMessageText(message)}`;
|
||||
};
|
||||
|
||||
export const messagesToMarkdown = (
|
||||
messages: UIMessage[],
|
||||
formatMessage: (
|
||||
message: UIMessage,
|
||||
index: number
|
||||
) => string = defaultFormatMessage
|
||||
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
|
||||
|
||||
export const ConversationDownload = ({
|
||||
messages,
|
||||
filename = "conversation.md",
|
||||
formatMessage = defaultFormatMessage,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ConversationDownloadProps) => {
|
||||
const handleDownload = useCallback(() => {
|
||||
const markdown = messagesToMarkdown(messages, formatMessage);
|
||||
const blob = new Blob([markdown], { type: "text/markdown" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [messages, filename, formatMessage]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute top-4 right-4 rounded-full dark:bg-background dark:hover:bg-muted",
|
||||
className
|
||||
)}
|
||||
onClick={handleDownload}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <DownloadIcon className="size-4" />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
143
components/ai-elements/edge.tsx
Normal file
143
components/ai-elements/edge.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { EdgeProps, InternalNode, Node } from "@xyflow/react";
|
||||
import {
|
||||
BaseEdge,
|
||||
getBezierPath,
|
||||
getSimpleBezierPath,
|
||||
Position,
|
||||
useInternalNode,
|
||||
} from "@xyflow/react";
|
||||
|
||||
const Temporary = ({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
}: EdgeProps) => {
|
||||
const [edgePath] = getSimpleBezierPath({
|
||||
sourcePosition,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetPosition,
|
||||
targetX,
|
||||
targetY,
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
className="stroke-1 stroke-ring"
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={{
|
||||
strokeDasharray: "5, 5",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getHandleCoordsByPosition = (
|
||||
node: InternalNode<Node>,
|
||||
handlePosition: Position
|
||||
) => {
|
||||
// Choose the handle type based on position - Left is for target, Right is for source
|
||||
const handleType = handlePosition === Position.Left ? "target" : "source";
|
||||
|
||||
const handle = node.internals.handleBounds?.[handleType]?.find(
|
||||
(h) => h.position === handlePosition
|
||||
);
|
||||
|
||||
if (!handle) {
|
||||
return [0, 0] as const;
|
||||
}
|
||||
|
||||
let offsetX = handle.width / 2;
|
||||
let offsetY = handle.height / 2;
|
||||
|
||||
// this is a tiny detail to make the markerEnd of an edge visible.
|
||||
// The handle position that gets calculated has the origin top-left, so depending which side we are using, we add a little offset
|
||||
// when the handlePosition is Position.Right for example, we need to add an offset as big as the handle itself in order to get the correct position
|
||||
switch (handlePosition) {
|
||||
case Position.Left: {
|
||||
offsetX = 0;
|
||||
break;
|
||||
}
|
||||
case Position.Right: {
|
||||
offsetX = handle.width;
|
||||
break;
|
||||
}
|
||||
case Position.Top: {
|
||||
offsetY = 0;
|
||||
break;
|
||||
}
|
||||
case Position.Bottom: {
|
||||
offsetY = handle.height;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Invalid handle position: ${handlePosition}`);
|
||||
}
|
||||
}
|
||||
|
||||
const x = node.internals.positionAbsolute.x + handle.x + offsetX;
|
||||
const y = node.internals.positionAbsolute.y + handle.y + offsetY;
|
||||
|
||||
return [x, y] as const;
|
||||
};
|
||||
|
||||
const getEdgeParams = (
|
||||
source: InternalNode<Node>,
|
||||
target: InternalNode<Node>
|
||||
) => {
|
||||
const sourcePos = Position.Right;
|
||||
const [sx, sy] = getHandleCoordsByPosition(source, sourcePos);
|
||||
const targetPos = Position.Left;
|
||||
const [tx, ty] = getHandleCoordsByPosition(target, targetPos);
|
||||
|
||||
return {
|
||||
sourcePos,
|
||||
sx,
|
||||
sy,
|
||||
targetPos,
|
||||
tx,
|
||||
ty,
|
||||
};
|
||||
};
|
||||
|
||||
const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => {
|
||||
const sourceNode = useInternalNode(source);
|
||||
const targetNode = useInternalNode(target);
|
||||
|
||||
if (!(sourceNode && targetNode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
|
||||
sourceNode,
|
||||
targetNode
|
||||
);
|
||||
|
||||
const [edgePath] = getBezierPath({
|
||||
sourcePosition: sourcePos,
|
||||
sourceX: sx,
|
||||
sourceY: sy,
|
||||
targetPosition: targetPos,
|
||||
targetX: tx,
|
||||
targetY: ty,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge id={id} markerEnd={markerEnd} path={edgePath} style={style} />
|
||||
<circle fill="var(--primary)" r="4">
|
||||
<animateMotion dur="2s" path={edgePath} repeatCount="indefinite" />
|
||||
</circle>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Edge = {
|
||||
Animated,
|
||||
Temporary,
|
||||
};
|
||||
324
components/ai-elements/environment-variables.tsx
Normal file
324
components/ai-elements/environment-variables.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CopyIcon, EyeIcon, EyeOffIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface EnvironmentVariablesContextType {
|
||||
showValues: boolean;
|
||||
setShowValues: (show: boolean) => void;
|
||||
}
|
||||
|
||||
// Default noop for context default value
|
||||
// oxlint-disable-next-line eslint(no-empty-function)
|
||||
const noop = () => {};
|
||||
|
||||
const EnvironmentVariablesContext =
|
||||
createContext<EnvironmentVariablesContextType>({
|
||||
setShowValues: noop,
|
||||
showValues: false,
|
||||
});
|
||||
|
||||
export type EnvironmentVariablesProps = HTMLAttributes<HTMLDivElement> & {
|
||||
showValues?: boolean;
|
||||
defaultShowValues?: boolean;
|
||||
onShowValuesChange?: (show: boolean) => void;
|
||||
};
|
||||
|
||||
export const EnvironmentVariables = ({
|
||||
showValues: controlledShowValues,
|
||||
defaultShowValues = false,
|
||||
onShowValuesChange,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariablesProps) => {
|
||||
const [internalShowValues, setInternalShowValues] =
|
||||
useState(defaultShowValues);
|
||||
const showValues = controlledShowValues ?? internalShowValues;
|
||||
|
||||
const setShowValues = useCallback(
|
||||
(show: boolean) => {
|
||||
setInternalShowValues(show);
|
||||
onShowValuesChange?.(show);
|
||||
},
|
||||
[onShowValuesChange]
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ setShowValues, showValues }),
|
||||
[setShowValues, showValues]
|
||||
);
|
||||
|
||||
return (
|
||||
<EnvironmentVariablesContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("rounded-lg border bg-background", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</EnvironmentVariablesContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type EnvironmentVariablesHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const EnvironmentVariablesHeader = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariablesHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b px-4 py-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type EnvironmentVariablesTitleProps = HTMLAttributes<HTMLHeadingElement>;
|
||||
|
||||
export const EnvironmentVariablesTitle = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariablesTitleProps) => (
|
||||
<h3 className={cn("font-medium text-sm", className)} {...props}>
|
||||
{children ?? "Environment Variables"}
|
||||
</h3>
|
||||
);
|
||||
|
||||
export type EnvironmentVariablesToggleProps = ComponentProps<typeof Switch>;
|
||||
|
||||
export const EnvironmentVariablesToggle = ({
|
||||
className,
|
||||
...props
|
||||
}: EnvironmentVariablesToggleProps) => {
|
||||
const { showValues, setShowValues } = useContext(EnvironmentVariablesContext);
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{showValues ? <EyeIcon size={14} /> : <EyeOffIcon size={14} />}
|
||||
</span>
|
||||
<Switch
|
||||
aria-label="Toggle value visibility"
|
||||
checked={showValues}
|
||||
onCheckedChange={setShowValues}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type EnvironmentVariablesContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const EnvironmentVariablesContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariablesContentProps) => (
|
||||
<div className={cn("divide-y", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface EnvironmentVariableContextType {
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const EnvironmentVariableContext =
|
||||
createContext<EnvironmentVariableContextType>({
|
||||
name: "",
|
||||
value: "",
|
||||
});
|
||||
|
||||
export type EnvironmentVariableGroupProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const EnvironmentVariableGroup = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariableGroupProps) => (
|
||||
<div className={cn("flex items-center gap-2", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type EnvironmentVariableNameProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const EnvironmentVariableName = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariableNameProps) => {
|
||||
const { name } = useContext(EnvironmentVariableContext);
|
||||
|
||||
return (
|
||||
<span className={cn("font-mono text-sm", className)} {...props}>
|
||||
{children ?? name}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type EnvironmentVariableValueProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const EnvironmentVariableValue = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariableValueProps) => {
|
||||
const { value } = useContext(EnvironmentVariableContext);
|
||||
const { showValues } = useContext(EnvironmentVariablesContext);
|
||||
|
||||
const displayValue = showValues
|
||||
? value
|
||||
: "•".repeat(Math.min(value.length, 20));
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"font-mono text-muted-foreground text-sm",
|
||||
!showValues && "select-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? displayValue}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type EnvironmentVariableProps = HTMLAttributes<HTMLDivElement> & {
|
||||
name: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
export const EnvironmentVariable = ({
|
||||
name,
|
||||
value,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariableProps) => {
|
||||
const envVarContextValue = useMemo(() => ({ name, value }), [name, value]);
|
||||
|
||||
return (
|
||||
<EnvironmentVariableContext.Provider value={envVarContextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-4 px-4 py-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<EnvironmentVariableName />
|
||||
</div>
|
||||
<EnvironmentVariableValue />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</EnvironmentVariableContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type EnvironmentVariableCopyButtonProps = ComponentProps<
|
||||
typeof Button
|
||||
> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
copyFormat?: "name" | "value" | "export";
|
||||
};
|
||||
|
||||
export const EnvironmentVariableCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
copyFormat = "value",
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: EnvironmentVariableCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
const { name, value } = useContext(EnvironmentVariableContext);
|
||||
|
||||
const getTextToCopy = useCallback((): string => {
|
||||
const formatMap = {
|
||||
export: () => `export ${name}="${value}"`,
|
||||
name: () => name,
|
||||
value: () => value,
|
||||
};
|
||||
return formatMap[copyFormat]();
|
||||
}, [name, value, copyFormat]);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(getTextToCopy());
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [getTextToCopy, onCopy, onError, timeout]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("size-6 shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={12} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type EnvironmentVariableRequiredProps = ComponentProps<typeof Badge>;
|
||||
|
||||
export const EnvironmentVariableRequired = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: EnvironmentVariableRequiredProps) => (
|
||||
<Badge className={cn("text-xs", className)} variant="secondary" {...props}>
|
||||
{children ?? "Required"}
|
||||
</Badge>
|
||||
);
|
||||
304
components/ai-elements/file-tree.tsx
Normal file
304
components/ai-elements/file-tree.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
FileIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
} from "lucide-react";
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface FileTreeContextType {
|
||||
expandedPaths: Set<string>;
|
||||
togglePath: (path: string) => void;
|
||||
selectedPath?: string;
|
||||
onSelect?: (path: string) => void;
|
||||
}
|
||||
|
||||
// Default noop for context default value
|
||||
// oxlint-disable-next-line eslint(no-empty-function)
|
||||
const noop = () => {};
|
||||
|
||||
const FileTreeContext = createContext<FileTreeContextType>({
|
||||
// oxlint-disable-next-line eslint-plugin-unicorn(no-new-builtin)
|
||||
expandedPaths: new Set(),
|
||||
togglePath: noop,
|
||||
});
|
||||
|
||||
export type FileTreeProps = Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> & {
|
||||
expanded?: Set<string>;
|
||||
defaultExpanded?: Set<string>;
|
||||
selectedPath?: string;
|
||||
onSelect?: (path: string) => void;
|
||||
onExpandedChange?: (expanded: Set<string>) => void;
|
||||
};
|
||||
|
||||
export const FileTree = ({
|
||||
expanded: controlledExpanded,
|
||||
defaultExpanded = new Set(),
|
||||
selectedPath,
|
||||
onSelect,
|
||||
onExpandedChange,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeProps) => {
|
||||
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
|
||||
const expandedPaths = controlledExpanded ?? internalExpanded;
|
||||
|
||||
const togglePath = useCallback(
|
||||
(path: string) => {
|
||||
const newExpanded = new Set(expandedPaths);
|
||||
if (newExpanded.has(path)) {
|
||||
newExpanded.delete(path);
|
||||
} else {
|
||||
newExpanded.add(path);
|
||||
}
|
||||
setInternalExpanded(newExpanded);
|
||||
onExpandedChange?.(newExpanded);
|
||||
},
|
||||
[expandedPaths, onExpandedChange]
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ expandedPaths, onSelect, selectedPath, togglePath }),
|
||||
[expandedPaths, onSelect, selectedPath, togglePath]
|
||||
);
|
||||
|
||||
return (
|
||||
<FileTreeContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-background font-mono text-sm",
|
||||
className
|
||||
)}
|
||||
role="tree"
|
||||
{...props}
|
||||
>
|
||||
<div className="p-2">{children}</div>
|
||||
</div>
|
||||
</FileTreeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type FileTreeIconProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const FileTreeIcon = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeIconProps) => (
|
||||
<span className={cn("shrink-0", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type FileTreeNameProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const FileTreeName = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeNameProps) => (
|
||||
<span className={cn("truncate", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
interface FileTreeFolderContextType {
|
||||
path: string;
|
||||
name: string;
|
||||
isExpanded: boolean;
|
||||
}
|
||||
|
||||
const FileTreeFolderContext = createContext<FileTreeFolderContextType>({
|
||||
isExpanded: false,
|
||||
name: "",
|
||||
path: "",
|
||||
});
|
||||
|
||||
export type FileTreeFolderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
path: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const FileTreeFolder = ({
|
||||
path,
|
||||
name,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeFolderProps) => {
|
||||
const { expandedPaths, togglePath, selectedPath, onSelect } =
|
||||
useContext(FileTreeContext);
|
||||
const isExpanded = expandedPaths.has(path);
|
||||
const isSelected = selectedPath === path;
|
||||
|
||||
const handleOpenChange = useCallback(() => {
|
||||
togglePath(path);
|
||||
}, [togglePath, path]);
|
||||
|
||||
const handleSelect = useCallback(() => {
|
||||
onSelect?.(path);
|
||||
}, [onSelect, path]);
|
||||
|
||||
const folderContextValue = useMemo(
|
||||
() => ({ isExpanded, name, path }),
|
||||
[isExpanded, name, path]
|
||||
);
|
||||
|
||||
return (
|
||||
<FileTreeFolderContext.Provider value={folderContextValue}>
|
||||
<Collapsible onOpenChange={handleOpenChange} open={isExpanded}>
|
||||
<div
|
||||
className={cn("", className)}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center gap-1 rounded px-2 py-1 text-left transition-colors hover:bg-muted/50",
|
||||
isSelected && "bg-muted"
|
||||
)}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
className="flex shrink-0 cursor-pointer items-center border-none bg-transparent p-0"
|
||||
type="button"
|
||||
>
|
||||
<ChevronRightIcon
|
||||
className={cn(
|
||||
"size-4 shrink-0 text-muted-foreground transition-transform",
|
||||
isExpanded && "rotate-90"
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left"
|
||||
onClick={handleSelect}
|
||||
type="button"
|
||||
>
|
||||
<FileTreeIcon>
|
||||
{isExpanded ? (
|
||||
<FolderOpenIcon className="size-4 text-blue-500" />
|
||||
) : (
|
||||
<FolderIcon className="size-4 text-blue-500" />
|
||||
)}
|
||||
</FileTreeIcon>
|
||||
<FileTreeName>{name}</FileTreeName>
|
||||
</button>
|
||||
</div>
|
||||
<CollapsibleContent>
|
||||
<div className="ml-4 border-l pl-2">{children}</div>
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</FileTreeFolderContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
interface FileTreeFileContextType {
|
||||
path: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const FileTreeFileContext = createContext<FileTreeFileContextType>({
|
||||
name: "",
|
||||
path: "",
|
||||
});
|
||||
|
||||
export type FileTreeFileProps = HTMLAttributes<HTMLDivElement> & {
|
||||
path: string;
|
||||
name: string;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export const FileTreeFile = ({
|
||||
path,
|
||||
name,
|
||||
icon,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeFileProps) => {
|
||||
const { selectedPath, onSelect } = useContext(FileTreeContext);
|
||||
const isSelected = selectedPath === path;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect?.(path);
|
||||
}, [onSelect, path]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
onSelect?.(path);
|
||||
}
|
||||
},
|
||||
[onSelect, path]
|
||||
);
|
||||
|
||||
const fileContextValue = useMemo(() => ({ name, path }), [name, path]);
|
||||
|
||||
return (
|
||||
<FileTreeFileContext.Provider value={fileContextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-1 rounded px-2 py-1 transition-colors hover:bg-muted/50",
|
||||
isSelected && "bg-muted",
|
||||
className
|
||||
)}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="treeitem"
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{/* Spacer for alignment */}
|
||||
<span className="size-4 shrink-0" />
|
||||
<FileTreeIcon>
|
||||
{icon ?? <FileIcon className="size-4 text-muted-foreground" />}
|
||||
</FileTreeIcon>
|
||||
<FileTreeName>{name}</FileTreeName>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</FileTreeFileContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type FileTreeActionsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
const stopPropagation = (e: React.SyntheticEvent) => e.stopPropagation();
|
||||
|
||||
export const FileTreeActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: FileTreeActionsProps) => (
|
||||
<div
|
||||
className={cn("ml-auto flex items-center gap-1", className)}
|
||||
onClick={stopPropagation}
|
||||
onKeyDown={stopPropagation}
|
||||
role="group"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
24
components/ai-elements/image.tsx
Normal file
24
components/ai-elements/image.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Experimental_GeneratedImage } from "ai";
|
||||
|
||||
export type ImageProps = Experimental_GeneratedImage & {
|
||||
className?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
export const Image = ({
|
||||
base64,
|
||||
uint8Array: _uint8Array,
|
||||
mediaType,
|
||||
...props
|
||||
}: ImageProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={props.alt}
|
||||
className={cn(
|
||||
"h-auto max-w-full overflow-hidden rounded-md",
|
||||
props.className
|
||||
)}
|
||||
src={`data:${mediaType};base64,${base64}`}
|
||||
/>
|
||||
);
|
||||
296
components/ai-elements/inline-citation.tsx
Normal file
296
components/ai-elements/inline-citation.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { CarouselApi } from "@/components/ui/carousel";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@/components/ui/carousel";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export type InlineCitationProps = ComponentProps<"span">;
|
||||
|
||||
export const InlineCitation = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationProps) => (
|
||||
<span
|
||||
className={cn("group inline items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationTextProps = ComponentProps<"span">;
|
||||
|
||||
export const InlineCitationText = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationTextProps) => (
|
||||
<span
|
||||
className={cn("transition-colors group-hover:bg-accent", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCardProps = ComponentProps<typeof HoverCard>;
|
||||
|
||||
export const InlineCitationCard = (props: InlineCitationCardProps) => (
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
);
|
||||
|
||||
export type InlineCitationCardTriggerProps = ComponentProps<typeof Badge> & {
|
||||
sources: string[];
|
||||
};
|
||||
|
||||
export const InlineCitationCardTrigger = ({
|
||||
sources,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCardTriggerProps) => (
|
||||
<HoverCardTrigger asChild>
|
||||
<Badge
|
||||
className={cn("ml-1 rounded-full", className)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
>
|
||||
{sources[0] ? (
|
||||
<>
|
||||
{new URL(sources[0]).hostname}{" "}
|
||||
{sources.length > 1 && `+${sources.length - 1}`}
|
||||
</>
|
||||
) : (
|
||||
"unknown"
|
||||
)}
|
||||
</Badge>
|
||||
</HoverCardTrigger>
|
||||
);
|
||||
|
||||
export type InlineCitationCardBodyProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCardBody = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCardBodyProps) => (
|
||||
<HoverCardContent className={cn("relative w-80 p-0", className)} {...props} />
|
||||
);
|
||||
|
||||
const CarouselApiContext = createContext<CarouselApi | undefined>(undefined);
|
||||
|
||||
const useCarouselApi = () => {
|
||||
const context = useContext(CarouselApiContext);
|
||||
return context;
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselProps = ComponentProps<typeof Carousel>;
|
||||
|
||||
export const InlineCitationCarousel = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: InlineCitationCarouselProps) => {
|
||||
const [api, setApi] = useState<CarouselApi>();
|
||||
|
||||
return (
|
||||
<CarouselApiContext.Provider value={api}>
|
||||
<Carousel className={cn("w-full", className)} setApi={setApi} {...props}>
|
||||
{children}
|
||||
</Carousel>
|
||||
</CarouselApiContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselContentProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCarouselContent = (
|
||||
props: InlineCitationCarouselContentProps
|
||||
) => <CarouselContent {...props} />;
|
||||
|
||||
export type InlineCitationCarouselItemProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCarouselItem = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselItemProps) => (
|
||||
<CarouselItem
|
||||
className={cn("w-full space-y-2 p-4 pl-8", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCarouselHeaderProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCarouselHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between gap-2 rounded-t-md bg-secondary p-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type InlineCitationCarouselIndexProps = ComponentProps<"div">;
|
||||
|
||||
export const InlineCitationCarouselIndex = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselIndexProps) => {
|
||||
const api = useCarouselApi();
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const syncState = useCallback(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
setCount(api.scrollSnapList().length);
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncState();
|
||||
|
||||
api.on("select", syncState);
|
||||
|
||||
return () => {
|
||||
api.off("select", syncState);
|
||||
};
|
||||
}, [api, syncState]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-end px-3 py-1 text-muted-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? `${current}/${count}`}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselPrevProps = ComponentProps<"button">;
|
||||
|
||||
export const InlineCitationCarouselPrev = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselPrevProps) => {
|
||||
const api = useCarouselApi();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (api) {
|
||||
api.scrollPrev();
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Previous"
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationCarouselNextProps = ComponentProps<"button">;
|
||||
|
||||
export const InlineCitationCarouselNext = ({
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationCarouselNextProps) => {
|
||||
const api = useCarouselApi();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (api) {
|
||||
api.scrollNext();
|
||||
}
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Next"
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<ArrowRightIcon className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export type InlineCitationSourceProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
url?: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export const InlineCitationSource = ({
|
||||
title,
|
||||
url,
|
||||
description,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: InlineCitationSourceProps) => (
|
||||
<div className={cn("space-y-1", className)} {...props}>
|
||||
{title && (
|
||||
<h4 className="truncate font-medium text-sm leading-tight">{title}</h4>
|
||||
)}
|
||||
{url && (
|
||||
<p className="truncate break-all text-muted-foreground text-xs">{url}</p>
|
||||
)}
|
||||
{description && (
|
||||
<p className="line-clamp-3 text-muted-foreground text-sm leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type InlineCitationQuoteProps = ComponentProps<"blockquote">;
|
||||
|
||||
export const InlineCitationQuote = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: InlineCitationQuoteProps) => (
|
||||
<blockquote
|
||||
className={cn(
|
||||
"border-muted border-l-2 pl-3 text-muted-foreground text-sm italic",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
);
|
||||
310
components/ai-elements/jsx-preview.tsx
Normal file
310
components/ai-elements/jsx-preview.tsx
Normal file
@@ -0,0 +1,310 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type { TProps as JsxParserProps } from "react-jsx-parser";
|
||||
import JsxParser from "react-jsx-parser";
|
||||
|
||||
interface JSXPreviewContextValue {
|
||||
jsx: string;
|
||||
processedJsx: string;
|
||||
isStreaming: boolean;
|
||||
error: Error | null;
|
||||
setError: (error: Error | null) => void;
|
||||
setLastGoodJsx: (jsx: string) => void;
|
||||
components: JsxParserProps["components"];
|
||||
bindings: JsxParserProps["bindings"];
|
||||
onErrorProp?: (error: Error) => void;
|
||||
}
|
||||
|
||||
const JSXPreviewContext = createContext<JSXPreviewContextValue | null>(null);
|
||||
|
||||
const TAG_REGEX = /<\/?([a-zA-Z][a-zA-Z0-9]*)\s*([^>]*?)(\/)?>/;
|
||||
|
||||
export const useJSXPreview = () => {
|
||||
const context = useContext(JSXPreviewContext);
|
||||
if (!context) {
|
||||
throw new Error("JSXPreview components must be used within JSXPreview");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const matchJsxTag = (code: string) => {
|
||||
if (code.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const match = code.match(TAG_REGEX);
|
||||
|
||||
if (!match || match.index === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [fullMatch, tagName, attributes, selfClosing] = match;
|
||||
|
||||
let type: "self-closing" | "closing" | "opening";
|
||||
if (selfClosing) {
|
||||
type = "self-closing";
|
||||
} else if (fullMatch.startsWith("</")) {
|
||||
type = "closing";
|
||||
} else {
|
||||
type = "opening";
|
||||
}
|
||||
|
||||
return {
|
||||
attributes: attributes.trim(),
|
||||
endIndex: match.index + fullMatch.length,
|
||||
startIndex: match.index,
|
||||
tag: fullMatch,
|
||||
tagName,
|
||||
type,
|
||||
};
|
||||
};
|
||||
|
||||
const stripIncompleteTag = (text: string) => {
|
||||
// Find the last '<' that isn't part of a complete tag
|
||||
const lastOpen = text.lastIndexOf("<");
|
||||
if (lastOpen === -1) {
|
||||
return text;
|
||||
}
|
||||
|
||||
const afterOpen = text.slice(lastOpen);
|
||||
// If there's no closing '>' after the last '<', it's an incomplete tag
|
||||
if (!afterOpen.includes(">")) {
|
||||
return text.slice(0, lastOpen);
|
||||
}
|
||||
|
||||
return text;
|
||||
};
|
||||
|
||||
const completeJsxTag = (code: string) => {
|
||||
const stack: string[] = [];
|
||||
let result = "";
|
||||
let currentPosition = 0;
|
||||
|
||||
while (currentPosition < code.length) {
|
||||
const match = matchJsxTag(code.slice(currentPosition));
|
||||
if (!match) {
|
||||
// No more tags found, strip any trailing incomplete tag
|
||||
result += stripIncompleteTag(code.slice(currentPosition));
|
||||
break;
|
||||
}
|
||||
const { tagName, type, endIndex } = match;
|
||||
|
||||
// Include any text content before this tag
|
||||
result += code.slice(currentPosition, currentPosition + endIndex);
|
||||
|
||||
if (type === "opening") {
|
||||
stack.push(tagName);
|
||||
} else if (type === "closing") {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
currentPosition += endIndex;
|
||||
}
|
||||
|
||||
return (
|
||||
result +
|
||||
stack
|
||||
.toReversed()
|
||||
.map((tag) => `</${tag}>`)
|
||||
.join("")
|
||||
);
|
||||
};
|
||||
|
||||
export type JSXPreviewProps = ComponentProps<"div"> & {
|
||||
jsx: string;
|
||||
isStreaming?: boolean;
|
||||
components?: JsxParserProps["components"];
|
||||
bindings?: JsxParserProps["bindings"];
|
||||
onError?: (error: Error) => void;
|
||||
};
|
||||
|
||||
export const JSXPreview = memo(
|
||||
({
|
||||
jsx,
|
||||
isStreaming = false,
|
||||
components,
|
||||
bindings,
|
||||
onError,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: JSXPreviewProps) => {
|
||||
const [prevJsx, setPrevJsx] = useState(jsx);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [_lastGoodJsx, setLastGoodJsx] = useState("");
|
||||
|
||||
// Clear error when jsx changes (derived state pattern)
|
||||
if (jsx !== prevJsx) {
|
||||
setPrevJsx(jsx);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const processedJsx = useMemo(
|
||||
() => (isStreaming ? completeJsxTag(jsx) : jsx),
|
||||
[jsx, isStreaming]
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
bindings,
|
||||
components,
|
||||
error,
|
||||
isStreaming,
|
||||
jsx,
|
||||
onErrorProp: onError,
|
||||
processedJsx,
|
||||
setError,
|
||||
setLastGoodJsx,
|
||||
}),
|
||||
[
|
||||
bindings,
|
||||
components,
|
||||
error,
|
||||
isStreaming,
|
||||
jsx,
|
||||
onError,
|
||||
processedJsx,
|
||||
setError,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<JSXPreviewContext.Provider value={contextValue}>
|
||||
<div className={cn("relative", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</JSXPreviewContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
JSXPreview.displayName = "JSXPreview";
|
||||
|
||||
export type JSXPreviewContentProps = Omit<ComponentProps<"div">, "children">;
|
||||
|
||||
export const JSXPreviewContent = memo(
|
||||
({ className, ...props }: JSXPreviewContentProps) => {
|
||||
const {
|
||||
processedJsx,
|
||||
isStreaming,
|
||||
components,
|
||||
bindings,
|
||||
setError,
|
||||
setLastGoodJsx,
|
||||
onErrorProp,
|
||||
} = useJSXPreview();
|
||||
const errorReportedRef = useRef<string | null>(null);
|
||||
const lastGoodJsxRef = useRef("");
|
||||
const [hadError, setHadError] = useState(false);
|
||||
|
||||
// Reset error tracking when jsx changes
|
||||
useEffect(() => {
|
||||
errorReportedRef.current = null;
|
||||
setHadError(false);
|
||||
}, [processedJsx]);
|
||||
|
||||
const handleError = useCallback(
|
||||
(err: Error) => {
|
||||
// Prevent duplicate error reports for the same jsx
|
||||
if (errorReportedRef.current === processedJsx) {
|
||||
return;
|
||||
}
|
||||
errorReportedRef.current = processedJsx;
|
||||
|
||||
// During streaming, suppress errors and fall back to last good JSX
|
||||
if (isStreaming) {
|
||||
setHadError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err);
|
||||
onErrorProp?.(err);
|
||||
},
|
||||
[processedJsx, isStreaming, onErrorProp, setError]
|
||||
);
|
||||
|
||||
// Track the last JSX that rendered without error
|
||||
useEffect(() => {
|
||||
if (!errorReportedRef.current) {
|
||||
lastGoodJsxRef.current = processedJsx;
|
||||
setLastGoodJsx(processedJsx);
|
||||
}
|
||||
}, [processedJsx, setLastGoodJsx]);
|
||||
|
||||
// During streaming, if the current JSX errored, re-render with last good version
|
||||
const displayJsx =
|
||||
isStreaming && hadError ? lastGoodJsxRef.current : processedJsx;
|
||||
|
||||
return (
|
||||
<div className={cn("jsx-preview-content", className)} {...props}>
|
||||
<JsxParser
|
||||
bindings={bindings}
|
||||
components={components}
|
||||
jsx={displayJsx}
|
||||
onError={handleError}
|
||||
renderInWrapper={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
JSXPreviewContent.displayName = "JSXPreviewContent";
|
||||
|
||||
export type JSXPreviewErrorProps = ComponentProps<"div"> & {
|
||||
children?: ReactNode | ((error: Error) => ReactNode);
|
||||
};
|
||||
|
||||
const renderChildren = (
|
||||
children: ReactNode | ((error: Error) => ReactNode),
|
||||
error: Error
|
||||
): ReactNode => {
|
||||
if (typeof children === "function") {
|
||||
return children(error);
|
||||
}
|
||||
return children;
|
||||
};
|
||||
|
||||
export const JSXPreviewError = memo(
|
||||
({ className, children, ...props }: JSXPreviewErrorProps) => {
|
||||
const { error } = useJSXPreview();
|
||||
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ? (
|
||||
renderChildren(children, error)
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="size-4 shrink-0" />
|
||||
<span>{error.message}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
JSXPreviewError.displayName = "JSXPreviewError";
|
||||
360
components/ai-elements/message.tsx
Normal file
360
components/ai-elements/message.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ButtonGroup,
|
||||
ButtonGroupText,
|
||||
} from "@/components/ui/button-group";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import { code } from "@streamdown/code";
|
||||
import { math } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import type { UIMessage } from "ai";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full max-w-[95%] flex-col gap-2",
|
||||
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"is-user:dark flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||
"group-[.is-assistant]:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const MessageAction = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
variant = "ghost",
|
||||
size = "icon-sm",
|
||||
...props
|
||||
}: MessageActionProps) => {
|
||||
const button = (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
interface MessageBranchContextType {
|
||||
currentBranch: number;
|
||||
totalBranches: number;
|
||||
goToPrevious: () => void;
|
||||
goToNext: () => void;
|
||||
branches: ReactElement[];
|
||||
setBranches: (branches: ReactElement[]) => void;
|
||||
}
|
||||
|
||||
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useMessageBranch = () => {
|
||||
const context = useContext(MessageBranchContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"MessageBranch components must be used within MessageBranch"
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number;
|
||||
onBranchChange?: (branchIndex: number) => void;
|
||||
};
|
||||
|
||||
export const MessageBranch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||
|
||||
const handleBranchChange = useCallback(
|
||||
(newBranch: number) => {
|
||||
setCurrentBranch(newBranch);
|
||||
onBranchChange?.(newBranch);
|
||||
},
|
||||
[onBranchChange]
|
||||
);
|
||||
|
||||
const goToPrevious = useCallback(() => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
handleBranchChange(newBranch);
|
||||
}, [currentBranch, branches.length, handleBranchChange]);
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
handleBranchChange(newBranch);
|
||||
}, [currentBranch, branches.length, handleBranchChange]);
|
||||
|
||||
const contextValue = useMemo<MessageBranchContextType>(
|
||||
() => ({
|
||||
branches,
|
||||
currentBranch,
|
||||
goToNext,
|
||||
goToPrevious,
|
||||
setBranches,
|
||||
totalBranches: branches.length,
|
||||
}),
|
||||
[branches, currentBranch, goToNext, goToPrevious]
|
||||
);
|
||||
|
||||
return (
|
||||
<MessageBranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</MessageBranchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageBranchContent = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchContentProps) => {
|
||||
const { currentBranch, setBranches, branches } = useMessageBranch();
|
||||
const childrenArray = useMemo(
|
||||
() => (Array.isArray(children) ? children : [children]),
|
||||
[children]
|
||||
);
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray);
|
||||
}
|
||||
}, [childrenArray, branches, setBranches]);
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||
index === currentBranch ? "block" : "hidden"
|
||||
)}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
{branch}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
|
||||
|
||||
export const MessageBranchSelector = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchSelectorProps) => {
|
||||
const { totalBranches } = useMessageBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup
|
||||
className={cn(
|
||||
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
|
||||
className
|
||||
)}
|
||||
orientation="horizontal"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchPrevious = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchPreviousProps) => {
|
||||
const { goToPrevious, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Previous branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchNextProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchNext = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchNextProps) => {
|
||||
const { goToNext, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Next branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const MessageBranchPage = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchPageProps) => {
|
||||
const { currentBranch, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<ButtonGroupText
|
||||
className={cn(
|
||||
"border-none bg-transparent text-muted-foreground shadow-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
</ButtonGroupText>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
const streamdownPlugins = { cjk, code, math, mermaid };
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className
|
||||
)}
|
||||
plugins={streamdownPlugins}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.children === nextProps.children &&
|
||||
nextProps.isAnimating === prevProps.isAnimating
|
||||
);
|
||||
|
||||
MessageResponse.displayName = "MessageResponse";
|
||||
|
||||
export type MessageToolbarProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageToolbar = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageToolbarProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center justify-between gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
375
components/ai-elements/mic-selector.tsx
Normal file
375
components/ai-elements/mic-selector.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronsUpDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
const deviceIdRegex = /\(([\da-fA-F]{4}:[\da-fA-F]{4})\)$/;
|
||||
|
||||
interface MicSelectorContextType {
|
||||
data: MediaDeviceInfo[];
|
||||
value: string | undefined;
|
||||
onValueChange?: (value: string) => void;
|
||||
open: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
width: number;
|
||||
setWidth?: (width: number) => void;
|
||||
}
|
||||
|
||||
const MicSelectorContext = createContext<MicSelectorContextType>({
|
||||
data: [],
|
||||
onOpenChange: undefined,
|
||||
onValueChange: undefined,
|
||||
open: false,
|
||||
setWidth: undefined,
|
||||
value: undefined,
|
||||
width: 200,
|
||||
});
|
||||
|
||||
export const useAudioDevices = () => {
|
||||
const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasPermission, setHasPermission] = useState(false);
|
||||
|
||||
const loadDevicesWithoutPermission = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const deviceList = await navigator.mediaDevices.enumerateDevices();
|
||||
const audioInputs = deviceList.filter(
|
||||
(device) => device.kind === "audioinput"
|
||||
);
|
||||
|
||||
setDevices(audioInputs);
|
||||
} catch (caughtError) {
|
||||
const message =
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "Failed to get audio devices";
|
||||
|
||||
setError(message);
|
||||
console.error("Error getting audio devices:", message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadDevicesWithPermission = useCallback(async () => {
|
||||
if (loading) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const tempStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
});
|
||||
|
||||
for (const track of tempStream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
|
||||
const deviceList = await navigator.mediaDevices.enumerateDevices();
|
||||
const audioInputs = deviceList.filter(
|
||||
(device) => device.kind === "audioinput"
|
||||
);
|
||||
|
||||
setDevices(audioInputs);
|
||||
setHasPermission(true);
|
||||
} catch (caughtError) {
|
||||
const message =
|
||||
caughtError instanceof Error
|
||||
? caughtError.message
|
||||
: "Failed to get audio devices";
|
||||
|
||||
setError(message);
|
||||
console.error("Error getting audio devices:", message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [loading]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDevicesWithoutPermission();
|
||||
}, [loadDevicesWithoutPermission]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleDeviceChange = () => {
|
||||
if (hasPermission) {
|
||||
loadDevicesWithPermission();
|
||||
} else {
|
||||
loadDevicesWithoutPermission();
|
||||
}
|
||||
};
|
||||
|
||||
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
|
||||
|
||||
return () => {
|
||||
navigator.mediaDevices.removeEventListener(
|
||||
"devicechange",
|
||||
handleDeviceChange
|
||||
);
|
||||
};
|
||||
}, [hasPermission, loadDevicesWithPermission, loadDevicesWithoutPermission]);
|
||||
|
||||
return {
|
||||
devices,
|
||||
error,
|
||||
hasPermission,
|
||||
loadDevices: loadDevicesWithPermission,
|
||||
loading,
|
||||
};
|
||||
};
|
||||
|
||||
export type MicSelectorProps = ComponentProps<typeof Popover> & {
|
||||
defaultValue?: string;
|
||||
value?: string | undefined;
|
||||
onValueChange?: (value: string | undefined) => void;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const MicSelector = ({
|
||||
defaultValue,
|
||||
value: controlledValue,
|
||||
onValueChange: controlledOnValueChange,
|
||||
defaultOpen = false,
|
||||
open: controlledOpen,
|
||||
onOpenChange: controlledOnOpenChange,
|
||||
...props
|
||||
}: MicSelectorProps) => {
|
||||
const [value, onValueChange] = useControllableState<string | undefined>({
|
||||
defaultProp: defaultValue,
|
||||
onChange: controlledOnValueChange,
|
||||
prop: controlledValue,
|
||||
});
|
||||
const [open, onOpenChange] = useControllableState({
|
||||
defaultProp: defaultOpen,
|
||||
onChange: controlledOnOpenChange,
|
||||
prop: controlledOpen,
|
||||
});
|
||||
const [width, setWidth] = useState(200);
|
||||
const { devices, loading, hasPermission, loadDevices } = useAudioDevices();
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !hasPermission && !loading) {
|
||||
loadDevices();
|
||||
}
|
||||
}, [open, hasPermission, loading, loadDevices]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
data: devices,
|
||||
onOpenChange,
|
||||
onValueChange,
|
||||
open,
|
||||
setWidth,
|
||||
value,
|
||||
width,
|
||||
}),
|
||||
[devices, onOpenChange, onValueChange, open, setWidth, value, width]
|
||||
);
|
||||
|
||||
return (
|
||||
<MicSelectorContext.Provider value={contextValue}>
|
||||
<Popover {...props} onOpenChange={onOpenChange} open={open} />
|
||||
</MicSelectorContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type MicSelectorTriggerProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MicSelectorTrigger = ({
|
||||
children,
|
||||
...props
|
||||
}: MicSelectorTriggerProps) => {
|
||||
const { setWidth } = useContext(MicSelectorContext);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Create a ResizeObserver to detect width changes
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const newWidth = (entry.target as HTMLElement).offsetWidth;
|
||||
if (newWidth) {
|
||||
setWidth?.(newWidth);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (ref.current) {
|
||||
resizeObserver.observe(ref.current);
|
||||
}
|
||||
|
||||
// Clean up the observer when component unmounts
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [setWidth]);
|
||||
|
||||
return (
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" {...props} ref={ref}>
|
||||
{children}
|
||||
<ChevronsUpDownIcon
|
||||
className="shrink-0 text-muted-foreground"
|
||||
size={16}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export type MicSelectorContentProps = ComponentProps<typeof Command> & {
|
||||
popoverOptions?: ComponentProps<typeof PopoverContent>;
|
||||
};
|
||||
|
||||
export const MicSelectorContent = ({
|
||||
className,
|
||||
popoverOptions,
|
||||
...props
|
||||
}: MicSelectorContentProps) => {
|
||||
const { width, onValueChange, value } = useContext(MicSelectorContext);
|
||||
|
||||
return (
|
||||
<PopoverContent
|
||||
className={cn("p-0", className)}
|
||||
style={{ width }}
|
||||
{...popoverOptions}
|
||||
>
|
||||
<Command onValueChange={onValueChange} value={value} {...props} />
|
||||
</PopoverContent>
|
||||
);
|
||||
};
|
||||
|
||||
export type MicSelectorInputProps = ComponentProps<typeof CommandInput> & {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
export const MicSelectorInput = ({ ...props }: MicSelectorInputProps) => (
|
||||
<CommandInput placeholder="Search microphones..." {...props} />
|
||||
);
|
||||
|
||||
export type MicSelectorListProps = Omit<
|
||||
ComponentProps<typeof CommandList>,
|
||||
"children"
|
||||
> & {
|
||||
children: (devices: MediaDeviceInfo[]) => ReactNode;
|
||||
};
|
||||
|
||||
export const MicSelectorList = ({
|
||||
children,
|
||||
...props
|
||||
}: MicSelectorListProps) => {
|
||||
const { data } = useContext(MicSelectorContext);
|
||||
|
||||
return <CommandList {...props}>{children(data)}</CommandList>;
|
||||
};
|
||||
|
||||
export type MicSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
|
||||
|
||||
export const MicSelectorEmpty = ({
|
||||
children = "No microphone found.",
|
||||
...props
|
||||
}: MicSelectorEmptyProps) => <CommandEmpty {...props}>{children}</CommandEmpty>;
|
||||
|
||||
export type MicSelectorItemProps = ComponentProps<typeof CommandItem>;
|
||||
|
||||
export const MicSelectorItem = (props: MicSelectorItemProps) => {
|
||||
const { onValueChange, onOpenChange } = useContext(MicSelectorContext);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(currentValue: string) => {
|
||||
onValueChange?.(currentValue);
|
||||
onOpenChange?.(false);
|
||||
},
|
||||
[onValueChange, onOpenChange]
|
||||
);
|
||||
|
||||
return <CommandItem onSelect={handleSelect} {...props} />;
|
||||
};
|
||||
|
||||
export type MicSelectorLabelProps = ComponentProps<"span"> & {
|
||||
device: MediaDeviceInfo;
|
||||
};
|
||||
|
||||
export const MicSelectorLabel = ({
|
||||
device,
|
||||
className,
|
||||
...props
|
||||
}: MicSelectorLabelProps) => {
|
||||
const matches = device.label.match(deviceIdRegex);
|
||||
|
||||
if (!matches) {
|
||||
return (
|
||||
<span className={className} {...props}>
|
||||
{device.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const [, deviceId] = matches;
|
||||
const name = device.label.replace(deviceIdRegex, "");
|
||||
|
||||
return (
|
||||
<span className={className} {...props}>
|
||||
<span>{name}</span>
|
||||
<span className="text-muted-foreground"> ({deviceId})</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type MicSelectorValueProps = ComponentProps<"span">;
|
||||
|
||||
export const MicSelectorValue = ({
|
||||
className,
|
||||
...props
|
||||
}: MicSelectorValueProps) => {
|
||||
const { data, value } = useContext(MicSelectorContext);
|
||||
const currentDevice = data.find((d) => d.deviceId === value);
|
||||
|
||||
if (!currentDevice) {
|
||||
return (
|
||||
<span className={cn("flex-1 text-left", className)} {...props}>
|
||||
Select microphone...
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MicSelectorLabel
|
||||
className={cn("flex-1 text-left", className)}
|
||||
device={currentDevice}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
213
components/ai-elements/model-selector.tsx
Normal file
213
components/ai-elements/model-selector.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
|
||||
export type ModelSelectorProps = ComponentProps<typeof Dialog>;
|
||||
|
||||
export const ModelSelector = (props: ModelSelectorProps) => (
|
||||
<Dialog {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
|
||||
|
||||
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
||||
<DialogTrigger {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||
title?: ReactNode;
|
||||
};
|
||||
|
||||
export const ModelSelectorContent = ({
|
||||
className,
|
||||
children,
|
||||
title = "Model Selector",
|
||||
...props
|
||||
}: ModelSelectorContentProps) => (
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
className={cn(
|
||||
"outline! border-none! p-0 outline-border! outline-solid!",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
|
||||
|
||||
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
||||
<CommandDialog {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
|
||||
|
||||
export const ModelSelectorInput = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorInputProps) => (
|
||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
|
||||
|
||||
export const ModelSelectorList = (props: ModelSelectorListProps) => (
|
||||
<CommandList {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
|
||||
|
||||
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
|
||||
<CommandEmpty {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>;
|
||||
|
||||
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
||||
<CommandGroup {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
|
||||
|
||||
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
||||
<CommandItem {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
|
||||
|
||||
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
||||
<CommandShortcut {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorSeparatorProps = ComponentProps<
|
||||
typeof CommandSeparator
|
||||
>;
|
||||
|
||||
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
||||
<CommandSeparator {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoProps = Omit<
|
||||
ComponentProps<"img">,
|
||||
"src" | "alt"
|
||||
> & {
|
||||
provider:
|
||||
| "moonshotai-cn"
|
||||
| "lucidquery"
|
||||
| "moonshotai"
|
||||
| "zai-coding-plan"
|
||||
| "alibaba"
|
||||
| "xai"
|
||||
| "vultr"
|
||||
| "nvidia"
|
||||
| "upstage"
|
||||
| "groq"
|
||||
| "github-copilot"
|
||||
| "mistral"
|
||||
| "vercel"
|
||||
| "nebius"
|
||||
| "deepseek"
|
||||
| "alibaba-cn"
|
||||
| "google-vertex-anthropic"
|
||||
| "venice"
|
||||
| "chutes"
|
||||
| "cortecs"
|
||||
| "github-models"
|
||||
| "togetherai"
|
||||
| "azure"
|
||||
| "baseten"
|
||||
| "huggingface"
|
||||
| "opencode"
|
||||
| "fastrouter"
|
||||
| "google"
|
||||
| "google-vertex"
|
||||
| "cloudflare-workers-ai"
|
||||
| "inception"
|
||||
| "wandb"
|
||||
| "openai"
|
||||
| "zhipuai-coding-plan"
|
||||
| "perplexity"
|
||||
| "openrouter"
|
||||
| "zenmux"
|
||||
| "v0"
|
||||
| "iflowcn"
|
||||
| "synthetic"
|
||||
| "deepinfra"
|
||||
| "zhipuai"
|
||||
| "submodel"
|
||||
| "zai"
|
||||
| "inference"
|
||||
| "requesty"
|
||||
| "morph"
|
||||
| "lmstudio"
|
||||
| "anthropic"
|
||||
| "aihubmix"
|
||||
| "fireworks-ai"
|
||||
| "modelscope"
|
||||
| "llama"
|
||||
| "scaleway"
|
||||
| "amazon-bedrock"
|
||||
| "cerebras"
|
||||
// oxlint-disable-next-line typescript-eslint(ban-types) -- intentional pattern for autocomplete-friendly string union
|
||||
| (string & {});
|
||||
};
|
||||
|
||||
export const ModelSelectorLogo = ({
|
||||
provider,
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={`${provider} logo`}
|
||||
className={cn("size-3 dark:invert", className)}
|
||||
height={12}
|
||||
src={`https://models.dev/logos/${provider}.svg`}
|
||||
width={12}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
|
||||
|
||||
export const ModelSelectorLogoGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoGroupProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorNameProps = ComponentProps<"span">;
|
||||
|
||||
export const ModelSelectorName = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorNameProps) => (
|
||||
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
||||
);
|
||||
71
components/ai-elements/node.tsx
Normal file
71
components/ai-elements/node.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type NodeProps = ComponentProps<typeof Card> & {
|
||||
handles: {
|
||||
target: boolean;
|
||||
source: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const Node = ({ handles, className, ...props }: NodeProps) => (
|
||||
<Card
|
||||
className={cn(
|
||||
"node-container relative size-full h-auto w-sm gap-0 rounded-md p-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{handles.target && <Handle position={Position.Left} type="target" />}
|
||||
{handles.source && <Handle position={Position.Right} type="source" />}
|
||||
{props.children}
|
||||
</Card>
|
||||
);
|
||||
|
||||
export type NodeHeaderProps = ComponentProps<typeof CardHeader>;
|
||||
|
||||
export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn("gap-0.5 rounded-t-md border-b bg-secondary p-3!", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type NodeTitleProps = ComponentProps<typeof CardTitle>;
|
||||
|
||||
export const NodeTitle = (props: NodeTitleProps) => <CardTitle {...props} />;
|
||||
|
||||
export type NodeDescriptionProps = ComponentProps<typeof CardDescription>;
|
||||
|
||||
export const NodeDescription = (props: NodeDescriptionProps) => (
|
||||
<CardDescription {...props} />
|
||||
);
|
||||
|
||||
export type NodeActionProps = ComponentProps<typeof CardAction>;
|
||||
|
||||
export const NodeAction = (props: NodeActionProps) => <CardAction {...props} />;
|
||||
|
||||
export type NodeContentProps = ComponentProps<typeof CardContent>;
|
||||
|
||||
export const NodeContent = ({ className, ...props }: NodeContentProps) => (
|
||||
<CardContent className={cn("p-3", className)} {...props} />
|
||||
);
|
||||
|
||||
export type NodeFooterProps = ComponentProps<typeof CardFooter>;
|
||||
|
||||
export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
|
||||
<CardFooter
|
||||
className={cn("rounded-b-md border-t bg-secondary p-3!", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
370
components/ai-elements/open-in-chat.tsx
Normal file
370
components/ai-elements/open-in-chat.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ExternalLinkIcon,
|
||||
MessageCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
const providers = {
|
||||
chatgpt: {
|
||||
createUrl: (prompt: string) =>
|
||||
`https://chatgpt.com/?${new URLSearchParams({
|
||||
hints: "search",
|
||||
prompt,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>OpenAI</title>
|
||||
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z" />
|
||||
</svg>
|
||||
),
|
||||
title: "Open in ChatGPT",
|
||||
},
|
||||
claude: {
|
||||
createUrl: (q: string) =>
|
||||
`https://claude.ai/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 12 12"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Claude</title>
|
||||
<path
|
||||
clipRule="evenodd"
|
||||
d="M2.3545 7.9775L4.7145 6.654L4.7545 6.539L4.7145 6.475H4.6L4.205 6.451L2.856 6.4145L1.6865 6.366L0.5535 6.305L0.268 6.2445L0 5.892L0.0275 5.716L0.2675 5.5555L0.6105 5.5855L1.3705 5.637L2.5095 5.716L3.3355 5.7645L4.56 5.892H4.7545L4.782 5.8135L4.715 5.7645L4.6635 5.716L3.4845 4.918L2.2085 4.074L1.5405 3.588L1.1785 3.3425L0.9965 3.1115L0.9175 2.6075L1.2455 2.2465L1.686 2.2765L1.7985 2.307L2.245 2.65L3.199 3.388L4.4445 4.3045L4.627 4.4565L4.6995 4.405L4.709 4.3685L4.627 4.2315L3.9495 3.0085L3.2265 1.7635L2.9045 1.2475L2.8195 0.938C2.78711 0.819128 2.76965 0.696687 2.7675 0.5735L3.1415 0.067L3.348 0L3.846 0.067L4.056 0.249L4.366 0.956L4.867 2.0705L5.6445 3.5855L5.8725 4.0345L5.994 4.4505L6.0395 4.578H6.1185V4.505L6.1825 3.652L6.301 2.6045L6.416 1.257L6.456 0.877L6.644 0.422L7.0175 0.176L7.3095 0.316L7.5495 0.6585L7.516 0.8805L7.373 1.806L7.0935 3.2575L6.9115 4.2285H7.0175L7.139 4.1075L7.6315 3.4545L8.4575 2.4225L8.8225 2.0125L9.2475 1.5605L9.521 1.345H10.0375L10.4175 1.9095L10.2475 2.4925L9.7155 3.166L9.275 3.737L8.643 4.587L8.248 5.267L8.2845 5.322L8.3785 5.312L9.8065 5.009L10.578 4.869L11.4985 4.7115L11.915 4.9055L11.9605 5.103L11.7965 5.5065L10.812 5.7495L9.6575 5.9805L7.938 6.387L7.917 6.402L7.9415 6.4325L8.716 6.5055L9.047 6.5235H9.858L11.368 6.636L11.763 6.897L12 7.216L11.9605 7.4585L11.353 7.7685L10.533 7.574L8.6185 7.119L7.9625 6.9545H7.8715V7.0095L8.418 7.5435L9.421 8.4485L10.6755 9.6135L10.739 9.9025L10.578 10.13L10.408 10.1055L9.3055 9.277L8.88 8.9035L7.917 8.0935H7.853V8.1785L8.075 8.503L9.2475 10.2635L9.3085 10.8035L9.2235 10.98L8.9195 11.0865L8.5855 11.0255L7.8985 10.063L7.191 8.9795L6.6195 8.008L6.5495 8.048L6.2125 11.675L6.0545 11.86L5.69 12L5.3865 11.7695L5.2255 11.396L5.3865 10.658L5.581 9.696L5.7385 8.931L5.8815 7.981L5.9665 7.665L5.9605 7.644L5.8905 7.653L5.1735 8.6365L4.0835 10.109L3.2205 11.0315L3.0135 11.1135L2.655 10.9285L2.6885 10.5975L2.889 10.303L4.083 8.785L4.803 7.844L5.268 7.301L5.265 7.222H5.2375L2.066 9.28L1.501 9.353L1.2575 9.125L1.288 8.752L1.4035 8.6305L2.3575 7.9745L2.3545 7.9775Z"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
title: "Open in Claude",
|
||||
},
|
||||
cursor: {
|
||||
createUrl: (text: string) => {
|
||||
const url = new URL("https://cursor.com/link/prompt");
|
||||
url.searchParams.set("text", text);
|
||||
return url.toString();
|
||||
},
|
||||
icon: (
|
||||
<svg
|
||||
version="1.1"
|
||||
viewBox="0 0 466.73 532.09"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Cursor</title>
|
||||
<path
|
||||
d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
title: "Open in Cursor",
|
||||
},
|
||||
github: {
|
||||
createUrl: (url: string) => url,
|
||||
icon: (
|
||||
<svg fill="currentColor" role="img" viewBox="0 0 24 24">
|
||||
<title>GitHub</title>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
),
|
||||
title: "Open in GitHub",
|
||||
},
|
||||
scira: {
|
||||
createUrl: (q: string) =>
|
||||
`https://scira.ai/?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="none"
|
||||
height="934"
|
||||
viewBox="0 0 910 934"
|
||||
width="910"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Scira AI</title>
|
||||
<path
|
||||
d="M647.664 197.775C569.13 189.049 525.5 145.419 516.774 66.8849C508.048 145.419 464.418 189.049 385.884 197.775C464.418 206.501 508.048 250.131 516.774 328.665C525.5 250.131 569.13 206.501 647.664 197.775Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<path
|
||||
d="M516.774 304.217C510.299 275.491 498.208 252.087 480.335 234.214C462.462 216.341 439.058 204.251 410.333 197.775C439.059 191.3 462.462 179.209 480.335 161.336C498.208 143.463 510.299 120.06 516.774 91.334C523.25 120.059 535.34 143.463 553.213 161.336C571.086 179.209 594.49 191.3 623.216 197.775C594.49 204.251 571.086 216.341 553.213 234.214C535.34 252.087 523.25 275.491 516.774 304.217Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<path
|
||||
d="M857.5 508.116C763.259 497.644 710.903 445.288 700.432 351.047C689.961 445.288 637.605 497.644 543.364 508.116C637.605 518.587 689.961 570.943 700.432 665.184C710.903 570.943 763.259 518.587 857.5 508.116Z"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="20"
|
||||
/>
|
||||
<path
|
||||
d="M700.432 615.957C691.848 589.05 678.575 566.357 660.383 548.165C642.191 529.973 619.499 516.7 592.593 508.116C619.499 499.533 642.191 486.258 660.383 468.066C678.575 449.874 691.848 427.181 700.432 400.274C709.015 427.181 722.289 449.874 740.481 468.066C758.673 486.258 781.365 499.533 808.271 508.116C781.365 516.7 758.673 529.973 740.481 548.165C722.289 566.357 709.015 589.05 700.432 615.957Z"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="20"
|
||||
/>
|
||||
<path
|
||||
d="M889.949 121.237C831.049 114.692 798.326 81.9698 791.782 23.0692C785.237 81.9698 752.515 114.692 693.614 121.237C752.515 127.781 785.237 160.504 791.782 219.404C798.326 160.504 831.049 127.781 889.949 121.237Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<path
|
||||
d="M791.782 196.795C786.697 176.937 777.869 160.567 765.16 147.858C752.452 135.15 736.082 126.322 716.226 121.237C736.082 116.152 752.452 107.324 765.16 94.6152C777.869 81.9065 786.697 65.5368 791.782 45.6797C796.867 65.5367 805.695 81.9066 818.403 94.6152C831.112 107.324 847.481 116.152 867.338 121.237C847.481 126.322 831.112 135.15 818.403 147.858C805.694 160.567 796.867 176.937 791.782 196.795Z"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<path
|
||||
d="M760.632 764.337C720.719 814.616 669.835 855.1 611.872 882.692C553.91 910.285 490.404 924.255 426.213 923.533C362.022 922.812 298.846 907.419 241.518 878.531C184.19 849.643 134.228 808.026 95.4548 756.863C56.6815 705.7 30.1238 646.346 17.8129 583.343C5.50207 520.339 7.76433 455.354 24.4266 393.359C41.089 331.364 71.7099 274.001 113.947 225.658C156.184 177.315 208.919 139.273 268.117 114.442"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="30"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
title: "Open in Scira",
|
||||
},
|
||||
t3: {
|
||||
createUrl: (q: string) =>
|
||||
`https://t3.chat/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: <MessageCircleIcon />,
|
||||
title: "Open in T3 Chat",
|
||||
},
|
||||
v0: {
|
||||
createUrl: (q: string) =>
|
||||
`https://v0.app?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 147 70"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>v0</title>
|
||||
<path d="M56 50.2031V14H70V60.1562C70 65.5928 65.5928 70 60.1562 70C57.5605 70 54.9982 68.9992 53.1562 67.1573L0 14H19.7969L56 50.2031Z" />
|
||||
<path d="M147 56H133V23.9531L100.953 56H133V70H96.6875C85.8144 70 77 61.1856 77 50.3125V14H91V46.1562L123.156 14H91V0H127.312C138.186 0 147 8.81439 147 19.6875V56Z" />
|
||||
</svg>
|
||||
),
|
||||
title: "Open in v0",
|
||||
},
|
||||
};
|
||||
|
||||
const OpenInContext = createContext<{ query: string } | undefined>(undefined);
|
||||
|
||||
const useOpenInContext = () => {
|
||||
const context = useContext(OpenInContext);
|
||||
if (!context) {
|
||||
throw new Error("OpenIn components must be used within an OpenIn provider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type OpenInProps = ComponentProps<typeof DropdownMenu> & {
|
||||
query: string;
|
||||
};
|
||||
|
||||
export const OpenIn = ({ query, ...props }: OpenInProps) => {
|
||||
const contextValue = useMemo(() => ({ query }), [query]);
|
||||
|
||||
return (
|
||||
<OpenInContext.Provider value={contextValue}>
|
||||
<DropdownMenu {...props} />
|
||||
</OpenInContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInContentProps = ComponentProps<typeof DropdownMenuContent>;
|
||||
|
||||
export const OpenInContent = ({ className, ...props }: OpenInContentProps) => (
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={cn("w-[240px]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type OpenInItemProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInItem = (props: OpenInItemProps) => (
|
||||
<DropdownMenuItem {...props} />
|
||||
);
|
||||
|
||||
export type OpenInLabelProps = ComponentProps<typeof DropdownMenuLabel>;
|
||||
|
||||
export const OpenInLabel = (props: OpenInLabelProps) => (
|
||||
<DropdownMenuLabel {...props} />
|
||||
);
|
||||
|
||||
export type OpenInSeparatorProps = ComponentProps<typeof DropdownMenuSeparator>;
|
||||
|
||||
export const OpenInSeparator = (props: OpenInSeparatorProps) => (
|
||||
<DropdownMenuSeparator {...props} />
|
||||
);
|
||||
|
||||
export type OpenInTriggerProps = ComponentProps<typeof DropdownMenuTrigger>;
|
||||
|
||||
export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => (
|
||||
<DropdownMenuTrigger {...props} asChild>
|
||||
{children ?? (
|
||||
<Button type="button" variant="outline">
|
||||
Open in chat
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
);
|
||||
|
||||
export type OpenInChatGPTProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.chatgpt.createUrl(query)}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.chatgpt.icon}</span>
|
||||
<span className="flex-1">{providers.chatgpt.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInClaudeProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInClaude = (props: OpenInClaudeProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.claude.createUrl(query)}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.claude.icon}</span>
|
||||
<span className="flex-1">{providers.claude.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInT3Props = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInT3 = (props: OpenInT3Props) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.t3.createUrl(query)}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.t3.icon}</span>
|
||||
<span className="flex-1">{providers.t3.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInSciraProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInScira = (props: OpenInSciraProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.scira.createUrl(query)}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.scira.icon}</span>
|
||||
<span className="flex-1">{providers.scira.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInv0Props = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInv0 = (props: OpenInv0Props) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.v0.createUrl(query)}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.v0.icon}</span>
|
||||
<span className="flex-1">{providers.v0.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInCursorProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInCursor = (props: OpenInCursorProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.cursor.createUrl(query)}
|
||||
rel="noopener"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.cursor.icon}</span>
|
||||
<span className="flex-1">{providers.cursor.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
239
components/ai-elements/package-info.tsx
Normal file
239
components/ai-elements/package-info.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowRightIcon, MinusIcon, PackageIcon, PlusIcon } from "lucide-react";
|
||||
import type { HTMLAttributes } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
type ChangeType = "major" | "minor" | "patch" | "added" | "removed";
|
||||
|
||||
interface PackageInfoContextType {
|
||||
name: string;
|
||||
currentVersion?: string;
|
||||
newVersion?: string;
|
||||
changeType?: ChangeType;
|
||||
}
|
||||
|
||||
const PackageInfoContext = createContext<PackageInfoContextType>({
|
||||
name: "",
|
||||
});
|
||||
|
||||
export type PackageInfoHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PackageInfoHeader = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoHeaderProps) => (
|
||||
<div
|
||||
className={cn("flex items-center justify-between gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type PackageInfoNameProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PackageInfoName = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoNameProps) => {
|
||||
const { name } = useContext(PackageInfoContext);
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)} {...props}>
|
||||
<PackageIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium font-mono text-sm">{children ?? name}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const changeTypeStyles: Record<ChangeType, string> = {
|
||||
added: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||||
major: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
||||
minor:
|
||||
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
|
||||
patch: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||
removed: "bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400",
|
||||
};
|
||||
|
||||
const changeTypeIcons: Record<ChangeType, React.ReactNode> = {
|
||||
added: <PlusIcon className="size-3" />,
|
||||
major: <ArrowRightIcon className="size-3" />,
|
||||
minor: <ArrowRightIcon className="size-3" />,
|
||||
patch: <ArrowRightIcon className="size-3" />,
|
||||
removed: <MinusIcon className="size-3" />,
|
||||
};
|
||||
|
||||
export type PackageInfoChangeTypeProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PackageInfoChangeType = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoChangeTypeProps) => {
|
||||
const { changeType } = useContext(PackageInfoContext);
|
||||
|
||||
if (!changeType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={cn(
|
||||
"gap-1 text-xs capitalize",
|
||||
changeTypeStyles[changeType],
|
||||
className
|
||||
)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
>
|
||||
{changeTypeIcons[changeType]}
|
||||
{children ?? changeType}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export type PackageInfoVersionProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PackageInfoVersion = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoVersionProps) => {
|
||||
const { currentVersion, newVersion } = useContext(PackageInfoContext);
|
||||
|
||||
if (!(currentVersion || newVersion)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2 flex items-center gap-2 font-mono text-muted-foreground text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{currentVersion && <span>{currentVersion}</span>}
|
||||
{currentVersion && newVersion && (
|
||||
<ArrowRightIcon className="size-3" />
|
||||
)}
|
||||
{newVersion && (
|
||||
<span className="font-medium text-foreground">{newVersion}</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type PackageInfoProps = HTMLAttributes<HTMLDivElement> & {
|
||||
name: string;
|
||||
currentVersion?: string;
|
||||
newVersion?: string;
|
||||
changeType?: ChangeType;
|
||||
};
|
||||
|
||||
export const PackageInfo = ({
|
||||
name,
|
||||
currentVersion,
|
||||
newVersion,
|
||||
changeType,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoProps) => {
|
||||
const contextValue = useMemo(
|
||||
() => ({ changeType, currentVersion, name, newVersion }),
|
||||
[changeType, currentVersion, name, newVersion]
|
||||
);
|
||||
|
||||
return (
|
||||
<PackageInfoContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("rounded-lg border bg-background p-4", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<PackageInfoHeader>
|
||||
<PackageInfoName />
|
||||
{changeType && <PackageInfoChangeType />}
|
||||
</PackageInfoHeader>
|
||||
{(currentVersion || newVersion) && <PackageInfoVersion />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PackageInfoContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type PackageInfoDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const PackageInfoDescription = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoDescriptionProps) => (
|
||||
<p className={cn("mt-2 text-muted-foreground text-sm", className)} {...props}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
export type PackageInfoContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PackageInfoContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoContentProps) => (
|
||||
<div className={cn("mt-3 border-t pt-3", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type PackageInfoDependenciesProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PackageInfoDependencies = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoDependenciesProps) => (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
<span className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Dependencies
|
||||
</span>
|
||||
<div className="space-y-1">{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export type PackageInfoDependencyProps = HTMLAttributes<HTMLDivElement> & {
|
||||
name: string;
|
||||
version?: string;
|
||||
};
|
||||
|
||||
export const PackageInfoDependency = ({
|
||||
name,
|
||||
version,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PackageInfoDependencyProps) => (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<span className="font-mono text-muted-foreground">{name}</span>
|
||||
{version && <span className="font-mono text-xs">{version}</span>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
15
components/ai-elements/panel.tsx
Normal file
15
components/ai-elements/panel.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Panel as PanelPrimitive } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
type PanelProps = ComponentProps<typeof PanelPrimitive>;
|
||||
|
||||
export const Panel = ({ className, ...props }: PanelProps) => (
|
||||
<PanelPrimitive
|
||||
className={cn(
|
||||
"m-4 overflow-hidden rounded-md border bg-card p-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
306
components/ai-elements/persona.tsx
Normal file
306
components/ai-elements/persona.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { RiveParameters } from "@rive-app/react-webgl2";
|
||||
import {
|
||||
useRive,
|
||||
useStateMachineInput,
|
||||
useViewModel,
|
||||
useViewModelInstance,
|
||||
useViewModelInstanceColor,
|
||||
} from "@rive-app/react-webgl2";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
// Delays Rive initialization by one frame so that React Strict Mode's
|
||||
// immediate unmount cycle never creates a WebGL2 context. Only the
|
||||
// second (real) mount will initialise, avoiding context exhaustion.
|
||||
const useStrictModeSafeInit = () => {
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => setReady(true));
|
||||
return () => {
|
||||
cancelAnimationFrame(id);
|
||||
setReady(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return ready;
|
||||
};
|
||||
|
||||
export type PersonaState =
|
||||
| "idle"
|
||||
| "listening"
|
||||
| "thinking"
|
||||
| "speaking"
|
||||
| "asleep";
|
||||
|
||||
interface PersonaProps {
|
||||
state: PersonaState;
|
||||
onLoad?: RiveParameters["onLoad"];
|
||||
onLoadError?: RiveParameters["onLoadError"];
|
||||
onReady?: () => void;
|
||||
onPause?: RiveParameters["onPause"];
|
||||
onPlay?: RiveParameters["onPlay"];
|
||||
onStop?: RiveParameters["onStop"];
|
||||
className?: string;
|
||||
variant?: keyof typeof sources;
|
||||
}
|
||||
|
||||
// The state machine name is always 'default' for Elements AI visuals
|
||||
const stateMachine = "default";
|
||||
|
||||
const sources = {
|
||||
command: {
|
||||
dynamicColor: true,
|
||||
hasModel: true,
|
||||
source:
|
||||
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/command-2.0.riv",
|
||||
},
|
||||
glint: {
|
||||
dynamicColor: true,
|
||||
hasModel: true,
|
||||
source:
|
||||
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/glint-2.0.riv",
|
||||
},
|
||||
halo: {
|
||||
dynamicColor: true,
|
||||
hasModel: true,
|
||||
source:
|
||||
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/halo-2.0.riv",
|
||||
},
|
||||
mana: {
|
||||
dynamicColor: false,
|
||||
hasModel: true,
|
||||
source:
|
||||
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/mana-2.0.riv",
|
||||
},
|
||||
obsidian: {
|
||||
dynamicColor: true,
|
||||
hasModel: true,
|
||||
source:
|
||||
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/obsidian-2.0.riv",
|
||||
},
|
||||
opal: {
|
||||
dynamicColor: false,
|
||||
hasModel: false,
|
||||
source:
|
||||
"https://ejiidnob33g9ap1r.public.blob.vercel-storage.com/orb-1.2.riv",
|
||||
},
|
||||
};
|
||||
|
||||
const getCurrentTheme = (): "light" | "dark" => {
|
||||
if (typeof window !== "undefined") {
|
||||
if (document.documentElement.classList.contains("dark")) {
|
||||
return "dark";
|
||||
}
|
||||
if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) {
|
||||
return "dark";
|
||||
}
|
||||
}
|
||||
return "light";
|
||||
};
|
||||
|
||||
const useTheme = (enabled: boolean) => {
|
||||
const [theme, setTheme] = useState<"light" | "dark">(getCurrentTheme);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip if not enabled (avoids unnecessary observers for non-dynamic-color variants)
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Watch for classList changes
|
||||
const observer = new MutationObserver(() => {
|
||||
setTheme(getCurrentTheme());
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributeFilter: ["class"],
|
||||
attributes: true,
|
||||
});
|
||||
|
||||
// Watch for OS-level theme changes
|
||||
let mql: MediaQueryList | null = null;
|
||||
const handleMediaChange = () => {
|
||||
setTheme(getCurrentTheme());
|
||||
};
|
||||
|
||||
if (window.matchMedia) {
|
||||
mql = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
mql.addEventListener("change", handleMediaChange);
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (mql) {
|
||||
mql.removeEventListener("change", handleMediaChange);
|
||||
}
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return theme;
|
||||
};
|
||||
|
||||
interface PersonaWithModelProps {
|
||||
rive: ReturnType<typeof useRive>["rive"];
|
||||
source: (typeof sources)[keyof typeof sources];
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const PersonaWithModel = memo(
|
||||
({ rive, source, children }: PersonaWithModelProps) => {
|
||||
const theme = useTheme(source.dynamicColor);
|
||||
const viewModel = useViewModel(rive, { useDefault: true });
|
||||
const viewModelInstance = useViewModelInstance(viewModel, {
|
||||
rive,
|
||||
useDefault: true,
|
||||
});
|
||||
const viewModelInstanceColor = useViewModelInstanceColor(
|
||||
"color",
|
||||
viewModelInstance
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(viewModelInstanceColor && source.dynamicColor)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [r, g, b] = theme === "dark" ? [255, 255, 255] : [0, 0, 0];
|
||||
viewModelInstanceColor.setRgb(r, g, b);
|
||||
}, [viewModelInstanceColor, theme, source.dynamicColor]);
|
||||
|
||||
return children;
|
||||
}
|
||||
);
|
||||
|
||||
PersonaWithModel.displayName = "PersonaWithModel";
|
||||
|
||||
interface PersonaWithoutModelProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const PersonaWithoutModel = memo(
|
||||
({ children }: PersonaWithoutModelProps) => children
|
||||
);
|
||||
|
||||
PersonaWithoutModel.displayName = "PersonaWithoutModel";
|
||||
|
||||
export const Persona: FC<PersonaProps> = memo(
|
||||
({
|
||||
variant = "obsidian",
|
||||
state = "idle",
|
||||
onLoad,
|
||||
onLoadError,
|
||||
onReady,
|
||||
onPause,
|
||||
onPlay,
|
||||
onStop,
|
||||
className,
|
||||
}) => {
|
||||
const source = sources[variant];
|
||||
|
||||
if (!source) {
|
||||
throw new Error(`Invalid variant: ${variant}`);
|
||||
}
|
||||
|
||||
// Stabilize callbacks to prevent useRive from reinitializing
|
||||
const callbacksRef = useRef({
|
||||
onLoad,
|
||||
onLoadError,
|
||||
onPause,
|
||||
onPlay,
|
||||
onReady,
|
||||
onStop,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
callbacksRef.current = {
|
||||
onLoad,
|
||||
onLoadError,
|
||||
onPause,
|
||||
onPlay,
|
||||
onReady,
|
||||
onStop,
|
||||
};
|
||||
}, [onLoad, onLoadError, onPause, onPlay, onReady, onStop]);
|
||||
|
||||
const stableCallbacks = useMemo(
|
||||
() => ({
|
||||
onLoad: ((loadedRive) =>
|
||||
callbacksRef.current.onLoad?.(
|
||||
loadedRive
|
||||
)) as RiveParameters["onLoad"],
|
||||
onLoadError: ((err) =>
|
||||
callbacksRef.current.onLoadError?.(
|
||||
err
|
||||
)) as RiveParameters["onLoadError"],
|
||||
onPause: ((event) =>
|
||||
callbacksRef.current.onPause?.(event)) as RiveParameters["onPause"],
|
||||
onPlay: ((event) =>
|
||||
callbacksRef.current.onPlay?.(event)) as RiveParameters["onPlay"],
|
||||
onReady: () => callbacksRef.current.onReady?.(),
|
||||
onStop: ((event) =>
|
||||
callbacksRef.current.onStop?.(event)) as RiveParameters["onStop"],
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
// Delay initialisation by one frame to avoid creating (and leaking)
|
||||
// a WebGL2 context during React Strict Mode's first throw-away mount.
|
||||
const ready = useStrictModeSafeInit();
|
||||
|
||||
const { rive, RiveComponent } = useRive(
|
||||
ready
|
||||
? {
|
||||
autoplay: true,
|
||||
onLoad: stableCallbacks.onLoad,
|
||||
onLoadError: stableCallbacks.onLoadError,
|
||||
onPause: stableCallbacks.onPause,
|
||||
onPlay: stableCallbacks.onPlay,
|
||||
onRiveReady: stableCallbacks.onReady,
|
||||
onStop: stableCallbacks.onStop,
|
||||
src: source.source,
|
||||
stateMachines: stateMachine,
|
||||
}
|
||||
: null
|
||||
);
|
||||
|
||||
const listeningInput = useStateMachineInput(
|
||||
rive,
|
||||
stateMachine,
|
||||
"listening"
|
||||
);
|
||||
const thinkingInput = useStateMachineInput(rive, stateMachine, "thinking");
|
||||
const speakingInput = useStateMachineInput(rive, stateMachine, "speaking");
|
||||
const asleepInput = useStateMachineInput(rive, stateMachine, "asleep");
|
||||
|
||||
// Rive state machine inputs are mutable objects that must be set via direct
|
||||
// property assignment — this is the intended Rive API, not a React anti-pattern.
|
||||
useEffect(() => {
|
||||
if (listeningInput) {
|
||||
listeningInput.value = state === "listening";
|
||||
}
|
||||
if (thinkingInput) {
|
||||
thinkingInput.value = state === "thinking";
|
||||
}
|
||||
if (speakingInput) {
|
||||
speakingInput.value = state === "speaking";
|
||||
}
|
||||
if (asleepInput) {
|
||||
asleepInput.value = state === "asleep";
|
||||
}
|
||||
}, [state, listeningInput, thinkingInput, speakingInput, asleepInput]);
|
||||
|
||||
const Component = source.hasModel ? PersonaWithModel : PersonaWithoutModel;
|
||||
|
||||
return (
|
||||
<Component rive={rive} source={source}>
|
||||
<RiveComponent className={cn("size-16 shrink-0", className)} />
|
||||
</Component>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Persona.displayName = "Persona";
|
||||
147
components/ai-elements/plan.tsx
Normal file
147
components/ai-elements/plan.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronsUpDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
interface PlanContextValue {
|
||||
isStreaming: boolean;
|
||||
}
|
||||
|
||||
const PlanContext = createContext<PlanContextValue | null>(null);
|
||||
|
||||
const usePlan = () => {
|
||||
const context = useContext(PlanContext);
|
||||
if (!context) {
|
||||
throw new Error("Plan components must be used within Plan");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type PlanProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
};
|
||||
|
||||
export const Plan = ({
|
||||
className,
|
||||
isStreaming = false,
|
||||
children,
|
||||
...props
|
||||
}: PlanProps) => {
|
||||
const contextValue = useMemo(() => ({ isStreaming }), [isStreaming]);
|
||||
|
||||
return (
|
||||
<PlanContext.Provider value={contextValue}>
|
||||
<Collapsible asChild data-slot="plan" {...props}>
|
||||
<Card className={cn("shadow-none", className)}>{children}</Card>
|
||||
</Collapsible>
|
||||
</PlanContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanHeaderProps = ComponentProps<typeof CardHeader>;
|
||||
|
||||
export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn("flex items-start justify-between", className)}
|
||||
data-slot="plan-header"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PlanTitleProps = Omit<
|
||||
ComponentProps<typeof CardTitle>,
|
||||
"children"
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
|
||||
const { isStreaming } = usePlan();
|
||||
|
||||
return (
|
||||
<CardTitle data-slot="plan-title" {...props}>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardTitle>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanDescriptionProps = Omit<
|
||||
ComponentProps<typeof CardDescription>,
|
||||
"children"
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const PlanDescription = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PlanDescriptionProps) => {
|
||||
const { isStreaming } = usePlan();
|
||||
|
||||
return (
|
||||
<CardDescription
|
||||
className={cn("text-balance", className)}
|
||||
data-slot="plan-description"
|
||||
{...props}
|
||||
>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardDescription>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanActionProps = ComponentProps<typeof CardAction>;
|
||||
|
||||
export const PlanAction = (props: PlanActionProps) => (
|
||||
<CardAction data-slot="plan-action" {...props} />
|
||||
);
|
||||
|
||||
export type PlanContentProps = ComponentProps<typeof CardContent>;
|
||||
|
||||
export const PlanContent = (props: PlanContentProps) => (
|
||||
<CollapsibleContent asChild>
|
||||
<CardContent data-slot="plan-content" {...props} />
|
||||
</CollapsibleContent>
|
||||
);
|
||||
|
||||
export type PlanFooterProps = ComponentProps<"div">;
|
||||
|
||||
export const PlanFooter = (props: PlanFooterProps) => (
|
||||
<CardFooter data-slot="plan-footer" {...props} />
|
||||
);
|
||||
|
||||
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
className={cn("size-8", className)}
|
||||
data-slot="plan-trigger"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
<ChevronsUpDownIcon className="size-4" />
|
||||
<span className="sr-only">Toggle plan</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
1463
components/ai-elements/prompt-input.tsx
Normal file
1463
components/ai-elements/prompt-input.tsx
Normal file
File diff suppressed because it is too large
Load Diff
274
components/ai-elements/queue.tsx
Normal file
274
components/ai-elements/queue.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, PaperclipIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export interface QueueMessagePart {
|
||||
type: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
filename?: string;
|
||||
mediaType?: string;
|
||||
}
|
||||
|
||||
export interface QueueMessage {
|
||||
id: string;
|
||||
parts: QueueMessagePart[];
|
||||
}
|
||||
|
||||
export interface QueueTodo {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: "pending" | "completed";
|
||||
}
|
||||
|
||||
export type QueueItemProps = ComponentProps<"li">;
|
||||
|
||||
export const QueueItem = ({ className, ...props }: QueueItemProps) => (
|
||||
<li
|
||||
className={cn(
|
||||
"group flex flex-col gap-1 rounded-md px-3 py-1 text-sm transition-colors hover:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemIndicatorProps = ComponentProps<"span"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemIndicator = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemIndicatorProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 inline-block size-2.5 rounded-full border",
|
||||
completed
|
||||
? "border-muted-foreground/20 bg-muted-foreground/10"
|
||||
: "border-muted-foreground/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemContentProps = ComponentProps<"span"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemContent = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemContentProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-1 grow break-words",
|
||||
completed
|
||||
? "text-muted-foreground/50 line-through"
|
||||
: "text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemDescriptionProps = ComponentProps<"div"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemDescription = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemDescriptionProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-6 text-xs",
|
||||
completed
|
||||
? "text-muted-foreground/40 line-through"
|
||||
: "text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const QueueItemActions = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemActionsProps) => (
|
||||
<div className={cn("flex gap-1", className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueItemActionProps = Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
"variant" | "size"
|
||||
>;
|
||||
|
||||
export const QueueItemAction = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemActionProps) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-auto rounded p-1 text-muted-foreground opacity-0 transition-opacity hover:bg-muted-foreground/10 hover:text-foreground group-hover:opacity-100",
|
||||
className
|
||||
)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemAttachmentProps = ComponentProps<"div">;
|
||||
|
||||
export const QueueItemAttachment = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemAttachmentProps) => (
|
||||
<div className={cn("mt-1 flex flex-wrap gap-2", className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueItemImageProps = ComponentProps<"img">;
|
||||
|
||||
export const QueueItemImage = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemImageProps) => (
|
||||
<img
|
||||
alt=""
|
||||
className={cn("h-8 w-8 rounded border object-cover", className)}
|
||||
height={32}
|
||||
width={32}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemFileProps = ComponentProps<"span">;
|
||||
|
||||
export const QueueItemFile = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemFileProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded border bg-muted px-2 py-1 text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<PaperclipIcon size={12} />
|
||||
<span className="max-w-[100px] truncate">{children}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
export type QueueListProps = ComponentProps<typeof ScrollArea>;
|
||||
|
||||
export const QueueList = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueListProps) => (
|
||||
<ScrollArea className={cn("mt-2 -mb-1", className)} {...props}>
|
||||
<div className="max-h-40 pr-4">
|
||||
<ul>{children}</ul>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
// QueueSection - collapsible section container
|
||||
export type QueueSectionProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const QueueSection = ({
|
||||
className,
|
||||
defaultOpen = true,
|
||||
...props
|
||||
}: QueueSectionProps) => (
|
||||
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
|
||||
);
|
||||
|
||||
// QueueSectionTrigger - section header/trigger
|
||||
export type QueueSectionTriggerProps = ComponentProps<"button">;
|
||||
|
||||
export const QueueSectionTrigger = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionTriggerProps) => (
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"group flex w-full items-center justify-between rounded-md bg-muted/40 px-3 py-2 text-left font-medium text-muted-foreground text-sm transition-colors hover:bg-muted",
|
||||
className
|
||||
)}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
// QueueSectionLabel - label content with icon and count
|
||||
export type QueueSectionLabelProps = ComponentProps<"span"> & {
|
||||
count?: number;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const QueueSectionLabel = ({
|
||||
count,
|
||||
label,
|
||||
icon,
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionLabelProps) => (
|
||||
<span className={cn("flex items-center gap-2", className)} {...props}>
|
||||
<ChevronDownIcon className="size-4 transition-transform group-data-[state=closed]:-rotate-90" />
|
||||
{icon}
|
||||
<span>
|
||||
{count} {label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
// QueueSectionContent - collapsible content area
|
||||
export type QueueSectionContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
>;
|
||||
|
||||
export const QueueSectionContent = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionContentProps) => (
|
||||
<CollapsibleContent className={cn(className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueProps = ComponentProps<"div">;
|
||||
|
||||
export const Queue = ({ className, ...props }: QueueProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 rounded-xl border border-border bg-background px-3 pt-2 pb-2 shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
226
components/ai-elements/reasoning.tsx
Normal file
226
components/ai-elements/reasoning.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import { code } from "@streamdown/code";
|
||||
import { math } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
interface ReasoningContextValue {
|
||||
isStreaming: boolean;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
duration: number | undefined;
|
||||
}
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||
|
||||
export const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext);
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be used within Reasoning");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000;
|
||||
const MS_IN_S = 1000;
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
|
||||
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
|
||||
const isExplicitlyClosed = defaultOpen === false;
|
||||
|
||||
const [isOpen, setIsOpen] = useControllableState<boolean>({
|
||||
defaultProp: resolvedDefaultOpen,
|
||||
onChange: onOpenChange,
|
||||
prop: open,
|
||||
});
|
||||
const [duration, setDuration] = useControllableState<number | undefined>({
|
||||
defaultProp: undefined,
|
||||
prop: durationProp,
|
||||
});
|
||||
|
||||
const hasEverStreamedRef = useRef(isStreaming);
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
|
||||
// Track when streaming starts and compute duration
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
hasEverStreamedRef.current = true;
|
||||
if (startTimeRef.current === null) {
|
||||
startTimeRef.current = Date.now();
|
||||
}
|
||||
} else if (startTimeRef.current !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
|
||||
startTimeRef.current = null;
|
||||
}
|
||||
}, [isStreaming, setDuration]);
|
||||
|
||||
// Auto-open when streaming starts (unless explicitly closed)
|
||||
useEffect(() => {
|
||||
if (isStreaming && !isOpen && !isExplicitlyClosed) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
|
||||
|
||||
// Auto-close when streaming ends (once only, and only if it ever streamed)
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasEverStreamedRef.current &&
|
||||
!isStreaming &&
|
||||
isOpen &&
|
||||
!hasAutoClosed
|
||||
) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosed(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
setIsOpen(newOpen);
|
||||
},
|
||||
[setIsOpen]
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ duration, isOpen, isStreaming, setIsOpen }),
|
||||
[duration, isOpen, isStreaming, setIsOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider value={contextValue}>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
|
||||
};
|
||||
|
||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
||||
}
|
||||
if (duration === undefined) {
|
||||
return <p>Thought for a few seconds</p>;
|
||||
}
|
||||
return <p>Thought for {duration} seconds</p>;
|
||||
};
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({
|
||||
className,
|
||||
children,
|
||||
getThinkingMessage = defaultGetThinkingMessage,
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning();
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
const streamdownPlugins = { cjk, code, math, mermaid };
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-4 text-sm",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
);
|
||||
|
||||
Reasoning.displayName = "Reasoning";
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger";
|
||||
ReasoningContent.displayName = "ReasoningContent";
|
||||
132
components/ai-elements/sandbox.tsx
Normal file
132
components/ai-elements/sandbox.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import { ChevronDownIcon, Code } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
import { getStatusBadge } from "./tool";
|
||||
|
||||
export type SandboxRootProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Sandbox = ({ className, ...props }: SandboxRootProps) => (
|
||||
<Collapsible
|
||||
className={cn(
|
||||
"not-prose group mb-4 w-full overflow-hidden rounded-md border",
|
||||
className
|
||||
)}
|
||||
defaultOpen
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export interface SandboxHeaderProps {
|
||||
title?: string;
|
||||
state: ToolUIPart["state"];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const SandboxHeader = ({
|
||||
className,
|
||||
title,
|
||||
state,
|
||||
...props
|
||||
}: SandboxHeaderProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{title}</span>
|
||||
{getStatusBadge(state)}
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type SandboxContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const SandboxContent = ({
|
||||
className,
|
||||
...props
|
||||
}: SandboxContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SandboxTabsProps = ComponentProps<typeof Tabs>;
|
||||
|
||||
export const SandboxTabs = ({ className, ...props }: SandboxTabsProps) => (
|
||||
<Tabs className={cn("w-full gap-0", className)} {...props} />
|
||||
);
|
||||
|
||||
export type SandboxTabsBarProps = ComponentProps<"div">;
|
||||
|
||||
export const SandboxTabsBar = ({
|
||||
className,
|
||||
...props
|
||||
}: SandboxTabsBarProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center border-border border-t border-b",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SandboxTabsListProps = ComponentProps<typeof TabsList>;
|
||||
|
||||
export const SandboxTabsList = ({
|
||||
className,
|
||||
...props
|
||||
}: SandboxTabsListProps) => (
|
||||
<TabsList
|
||||
className={cn("h-auto rounded-none border-0 bg-transparent p-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SandboxTabsTriggerProps = ComponentProps<typeof TabsTrigger>;
|
||||
|
||||
export const SandboxTabsTrigger = ({
|
||||
className,
|
||||
...props
|
||||
}: SandboxTabsTriggerProps) => (
|
||||
<TabsTrigger
|
||||
className={cn(
|
||||
"rounded-none border-0 border-transparent border-b-2 px-4 py-2 font-medium text-muted-foreground text-sm transition-colors data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-foreground data-[state=active]:shadow-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SandboxTabContentProps = ComponentProps<typeof TabsContent>;
|
||||
|
||||
export const SandboxTabContent = ({
|
||||
className,
|
||||
...props
|
||||
}: SandboxTabContentProps) => (
|
||||
<TabsContent className={cn("mt-0 text-sm", className)} {...props} />
|
||||
);
|
||||
471
components/ai-elements/schema-display.tsx
Normal file
471
components/ai-elements/schema-display.tsx
Normal file
@@ -0,0 +1,471 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
interface SchemaParameter {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
location?: "path" | "query" | "header";
|
||||
}
|
||||
|
||||
interface SchemaProperty {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
properties?: SchemaProperty[];
|
||||
items?: SchemaProperty;
|
||||
}
|
||||
|
||||
interface SchemaDisplayContextType {
|
||||
method: HttpMethod;
|
||||
path: string;
|
||||
description?: string;
|
||||
parameters?: SchemaParameter[];
|
||||
requestBody?: SchemaProperty[];
|
||||
responseBody?: SchemaProperty[];
|
||||
}
|
||||
|
||||
const SchemaDisplayContext = createContext<SchemaDisplayContextType>({
|
||||
method: "GET",
|
||||
path: "",
|
||||
});
|
||||
|
||||
const methodStyles: Record<HttpMethod, string> = {
|
||||
DELETE: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400",
|
||||
GET: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400",
|
||||
PATCH:
|
||||
"bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400",
|
||||
POST: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400",
|
||||
PUT: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400",
|
||||
};
|
||||
|
||||
export type SchemaDisplayHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const SchemaDisplayHeader = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayHeaderProps) => (
|
||||
<div
|
||||
className={cn("flex items-center gap-3 border-b px-4 py-3", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type SchemaDisplayMethodProps = ComponentProps<typeof Badge>;
|
||||
|
||||
export const SchemaDisplayMethod = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayMethodProps) => {
|
||||
const { method } = useContext(SchemaDisplayContext);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className={cn("font-mono text-xs", methodStyles[method], className)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
>
|
||||
{children ?? method}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayPathProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const SchemaDisplayPath = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayPathProps) => {
|
||||
const { path } = useContext(SchemaDisplayContext);
|
||||
|
||||
// Highlight path parameters
|
||||
const highlightedPath = path.replaceAll(
|
||||
/\{([^}]+)\}/g,
|
||||
'<span class="text-blue-600 dark:text-blue-400">{$1}</span>'
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("font-mono text-sm", className)}
|
||||
// oxlint-disable-next-line eslint-plugin-react(no-danger)
|
||||
dangerouslySetInnerHTML={{ __html: children ?? highlightedPath }}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayDescriptionProps =
|
||||
HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const SchemaDisplayDescription = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayDescriptionProps) => {
|
||||
const { description } = useContext(SchemaDisplayContext);
|
||||
|
||||
return (
|
||||
<p
|
||||
className={cn(
|
||||
"border-b px-4 py-3 text-muted-foreground text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? description}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const SchemaDisplayContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayContentProps) => (
|
||||
<div className={cn("divide-y", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type SchemaDisplayParameterProps = HTMLAttributes<HTMLDivElement> &
|
||||
SchemaParameter;
|
||||
|
||||
export const SchemaDisplayParameter = ({
|
||||
name,
|
||||
type,
|
||||
required,
|
||||
description,
|
||||
location,
|
||||
className,
|
||||
...props
|
||||
}: SchemaDisplayParameterProps) => (
|
||||
<div className={cn("px-4 py-3 pl-10", className)} {...props}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm">{name}</span>
|
||||
<Badge className="text-xs" variant="outline">
|
||||
{type}
|
||||
</Badge>
|
||||
{location && (
|
||||
<Badge className="text-xs" variant="secondary">
|
||||
{location}
|
||||
</Badge>
|
||||
)}
|
||||
{required && (
|
||||
<Badge
|
||||
className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
|
||||
variant="secondary"
|
||||
>
|
||||
required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mt-1 text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type SchemaDisplayParametersProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const SchemaDisplayParameters = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayParametersProps) => {
|
||||
const { parameters } = useContext(SchemaDisplayContext);
|
||||
|
||||
return (
|
||||
<Collapsible className={cn(className)} defaultOpen {...props}>
|
||||
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50">
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
||||
<span className="font-medium text-sm">Parameters</span>
|
||||
<Badge className="ml-auto text-xs" variant="secondary">
|
||||
{parameters?.length}
|
||||
</Badge>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="divide-y border-t">
|
||||
{children ??
|
||||
parameters?.map((param) => (
|
||||
<SchemaDisplayParameter key={param.name} {...param} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayPropertyProps = HTMLAttributes<HTMLDivElement> &
|
||||
SchemaProperty & {
|
||||
depth?: number;
|
||||
};
|
||||
|
||||
export const SchemaDisplayProperty = ({
|
||||
name,
|
||||
type,
|
||||
required,
|
||||
description,
|
||||
properties,
|
||||
items,
|
||||
depth = 0,
|
||||
className,
|
||||
...props
|
||||
}: SchemaDisplayPropertyProps) => {
|
||||
const hasChildren = properties || items;
|
||||
const paddingLeft = 40 + depth * 16;
|
||||
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<Collapsible defaultOpen={depth < 2}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-2 py-3 text-left transition-colors hover:bg-muted/50",
|
||||
className
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
>
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
||||
<span className="font-mono text-sm">{name}</span>
|
||||
<Badge className="text-xs" variant="outline">
|
||||
{type}
|
||||
</Badge>
|
||||
{required && (
|
||||
<Badge
|
||||
className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
|
||||
variant="secondary"
|
||||
>
|
||||
required
|
||||
</Badge>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
{description && (
|
||||
<p
|
||||
className="pb-2 text-muted-foreground text-sm"
|
||||
style={{ paddingLeft: paddingLeft + 24 }}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
<CollapsibleContent>
|
||||
<div className="divide-y border-t">
|
||||
{properties?.map((prop) => (
|
||||
<SchemaDisplayProperty
|
||||
key={prop.name}
|
||||
{...prop}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
{items && (
|
||||
<SchemaDisplayProperty
|
||||
{...items}
|
||||
depth={depth + 1}
|
||||
name={`${name}[]`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("py-3 pr-4", className)}
|
||||
style={{ paddingLeft }}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Spacer for alignment */}
|
||||
<span className="size-4" />
|
||||
<span className="font-mono text-sm">{name}</span>
|
||||
<Badge className="text-xs" variant="outline">
|
||||
{type}
|
||||
</Badge>
|
||||
{required && (
|
||||
<Badge
|
||||
className="bg-red-100 text-red-700 text-xs dark:bg-red-900/30 dark:text-red-400"
|
||||
variant="secondary"
|
||||
>
|
||||
required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="mt-1 pl-6 text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayRequestProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const SchemaDisplayRequest = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayRequestProps) => {
|
||||
const { requestBody } = useContext(SchemaDisplayContext);
|
||||
|
||||
return (
|
||||
<Collapsible className={cn(className)} defaultOpen {...props}>
|
||||
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50">
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
||||
<span className="font-medium text-sm">Request Body</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t">
|
||||
{children ??
|
||||
requestBody?.map((prop) => (
|
||||
<SchemaDisplayProperty key={prop.name} {...prop} depth={0} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayResponseProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const SchemaDisplayResponse = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayResponseProps) => {
|
||||
const { responseBody } = useContext(SchemaDisplayContext);
|
||||
|
||||
return (
|
||||
<Collapsible className={cn(className)} defaultOpen {...props}>
|
||||
<CollapsibleTrigger className="group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50">
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
||||
<span className="font-medium text-sm">Response</span>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<div className="border-t">
|
||||
{children ??
|
||||
responseBody?.map((prop) => (
|
||||
<SchemaDisplayProperty key={prop.name} {...prop} depth={0} />
|
||||
))}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayProps = HTMLAttributes<HTMLDivElement> & {
|
||||
method: HttpMethod;
|
||||
path: string;
|
||||
description?: string;
|
||||
parameters?: SchemaParameter[];
|
||||
requestBody?: SchemaProperty[];
|
||||
responseBody?: SchemaProperty[];
|
||||
};
|
||||
|
||||
export const SchemaDisplay = ({
|
||||
method,
|
||||
path,
|
||||
description,
|
||||
parameters,
|
||||
requestBody,
|
||||
responseBody,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayProps) => {
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
description,
|
||||
method,
|
||||
parameters,
|
||||
path,
|
||||
requestBody,
|
||||
responseBody,
|
||||
}),
|
||||
[description, method, parameters, path, requestBody, responseBody]
|
||||
);
|
||||
|
||||
return (
|
||||
<SchemaDisplayContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-lg border bg-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<SchemaDisplayHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<SchemaDisplayMethod />
|
||||
<SchemaDisplayPath />
|
||||
</div>
|
||||
</SchemaDisplayHeader>
|
||||
{description && <SchemaDisplayDescription />}
|
||||
<SchemaDisplayContent>
|
||||
{parameters && parameters.length > 0 && (
|
||||
<SchemaDisplayParameters />
|
||||
)}
|
||||
{requestBody && requestBody.length > 0 && (
|
||||
<SchemaDisplayRequest />
|
||||
)}
|
||||
{responseBody && responseBody.length > 0 && (
|
||||
<SchemaDisplayResponse />
|
||||
)}
|
||||
</SchemaDisplayContent>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SchemaDisplayContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type SchemaDisplayBodyProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const SchemaDisplayBody = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayBodyProps) => (
|
||||
<div className={cn("divide-y", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type SchemaDisplayExampleProps = HTMLAttributes<HTMLPreElement>;
|
||||
|
||||
export const SchemaDisplayExample = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SchemaDisplayExampleProps) => (
|
||||
<pre
|
||||
className={cn(
|
||||
"mx-4 mb-4 overflow-auto rounded-md bg-muted p-4 font-mono text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
77
components/ai-elements/shimmer.tsx
Normal file
77
components/ai-elements/shimmer.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { MotionProps } from "motion/react";
|
||||
import { motion } from "motion/react";
|
||||
import type { CSSProperties, ElementType, JSX } from "react";
|
||||
import { memo, useMemo } from "react";
|
||||
|
||||
type MotionHTMLProps = MotionProps & Record<string, unknown>;
|
||||
|
||||
// Cache motion components at module level to avoid creating during render
|
||||
const motionComponentCache = new Map<
|
||||
keyof JSX.IntrinsicElements,
|
||||
React.ComponentType<MotionHTMLProps>
|
||||
>();
|
||||
|
||||
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
|
||||
let component = motionComponentCache.get(element);
|
||||
if (!component) {
|
||||
component = motion.create(element);
|
||||
motionComponentCache.set(element, component);
|
||||
}
|
||||
return component;
|
||||
};
|
||||
|
||||
export interface TextShimmerProps {
|
||||
children: string;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
spread?: number;
|
||||
}
|
||||
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = getMotionComponent(
|
||||
Component as keyof JSX.IntrinsicElements
|
||||
);
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread]
|
||||
);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
className={cn(
|
||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||
className
|
||||
)}
|
||||
initial={{ backgroundPosition: "100% center" }}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
duration,
|
||||
ease: "linear",
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent);
|
||||
145
components/ai-elements/snippet.tsx
Normal file
145
components/ai-elements/snippet.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
InputGroupText,
|
||||
} from "@/components/ui/input-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface SnippetContextType {
|
||||
code: string;
|
||||
}
|
||||
|
||||
const SnippetContext = createContext<SnippetContextType>({
|
||||
code: "",
|
||||
});
|
||||
|
||||
export type SnippetProps = ComponentProps<typeof InputGroup> & {
|
||||
code: string;
|
||||
};
|
||||
|
||||
export const Snippet = ({
|
||||
code,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SnippetProps) => {
|
||||
const contextValue = useMemo(() => ({ code }), [code]);
|
||||
|
||||
return (
|
||||
<SnippetContext.Provider value={contextValue}>
|
||||
<InputGroup className={cn("font-mono", className)} {...props}>
|
||||
{children}
|
||||
</InputGroup>
|
||||
</SnippetContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type SnippetAddonProps = ComponentProps<typeof InputGroupAddon>;
|
||||
|
||||
export const SnippetAddon = (props: SnippetAddonProps) => (
|
||||
<InputGroupAddon {...props} />
|
||||
);
|
||||
|
||||
export type SnippetTextProps = ComponentProps<typeof InputGroupText>;
|
||||
|
||||
export const SnippetText = ({ className, ...props }: SnippetTextProps) => (
|
||||
<InputGroupText
|
||||
className={cn("pl-2 font-normal text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SnippetInputProps = Omit<
|
||||
ComponentProps<typeof InputGroupInput>,
|
||||
"readOnly" | "value"
|
||||
>;
|
||||
|
||||
export const SnippetInput = ({ className, ...props }: SnippetInputProps) => {
|
||||
const { code } = useContext(SnippetContext);
|
||||
|
||||
return (
|
||||
<InputGroupInput
|
||||
className={cn("text-foreground", className)}
|
||||
readOnly
|
||||
value={code}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type SnippetCopyButtonProps = ComponentProps<typeof InputGroupButton> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const SnippetCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: SnippetCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
const { code } = useContext(SnippetContext);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isCopied) {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(
|
||||
() => setIsCopied(false),
|
||||
timeout
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [code, onCopy, onError, timeout, isCopied]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<InputGroupButton
|
||||
aria-label="Copy"
|
||||
className={className}
|
||||
onClick={copyToClipboard}
|
||||
size="icon-sm"
|
||||
title="Copy"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon className="size-3.5" size={14} />}
|
||||
</InputGroupButton>
|
||||
);
|
||||
};
|
||||
77
components/ai-elements/sources.tsx
Normal file
77
components/ai-elements/sources.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BookIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type SourcesProps = ComponentProps<"div">;
|
||||
|
||||
export const Sources = ({ className, ...props }: SourcesProps) => (
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4 text-primary text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const SourcesTrigger = ({
|
||||
className,
|
||||
count,
|
||||
children,
|
||||
...props
|
||||
}: SourcesTriggerProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<p className="font-medium">Used {count} sources</p>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const SourcesContent = ({
|
||||
className,
|
||||
...props
|
||||
}: SourcesContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-3 flex w-fit flex-col gap-2",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SourceProps = ComponentProps<"a">;
|
||||
|
||||
export const Source = ({ href, title, children, ...props }: SourceProps) => (
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BookIcon className="h-4 w-4" />
|
||||
<span className="block font-medium">{title}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
323
components/ai-elements/speech-input.tsx
Normal file
323
components/ai-elements/speech-input.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MicIcon, SquareIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
interface SpeechRecognition extends EventTarget {
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
lang: string;
|
||||
start(): void;
|
||||
stop(): void;
|
||||
onstart: ((this: SpeechRecognition, ev: Event) => void) | null;
|
||||
onend: ((this: SpeechRecognition, ev: Event) => void) | null;
|
||||
onresult:
|
||||
| ((this: SpeechRecognition, ev: SpeechRecognitionEvent) => void)
|
||||
| null;
|
||||
onerror:
|
||||
| ((this: SpeechRecognition, ev: SpeechRecognitionErrorEvent) => void)
|
||||
| null;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionEvent extends Event {
|
||||
results: SpeechRecognitionResultList;
|
||||
resultIndex: number;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionResultList {
|
||||
readonly length: number;
|
||||
item(index: number): SpeechRecognitionResult;
|
||||
[index: number]: SpeechRecognitionResult;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionResult {
|
||||
readonly length: number;
|
||||
item(index: number): SpeechRecognitionAlternative;
|
||||
[index: number]: SpeechRecognitionAlternative;
|
||||
isFinal: boolean;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionAlternative {
|
||||
transcript: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionErrorEvent extends Event {
|
||||
error: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
SpeechRecognition: new () => SpeechRecognition;
|
||||
webkitSpeechRecognition: new () => SpeechRecognition;
|
||||
}
|
||||
}
|
||||
|
||||
type SpeechInputMode = "speech-recognition" | "media-recorder" | "none";
|
||||
|
||||
export type SpeechInputProps = ComponentProps<typeof Button> & {
|
||||
onTranscriptionChange?: (text: string) => void;
|
||||
/**
|
||||
* Callback for when audio is recorded using MediaRecorder fallback.
|
||||
* This is called in browsers that don't support the Web Speech API (Firefox, Safari).
|
||||
* The callback receives an audio Blob that should be sent to a transcription service.
|
||||
* Return the transcribed text, which will be passed to onTranscriptionChange.
|
||||
*/
|
||||
onAudioRecorded?: (audioBlob: Blob) => Promise<string>;
|
||||
lang?: string;
|
||||
};
|
||||
|
||||
const detectSpeechInputMode = (): SpeechInputMode => {
|
||||
if (typeof window === "undefined") {
|
||||
return "none";
|
||||
}
|
||||
|
||||
if ("SpeechRecognition" in window || "webkitSpeechRecognition" in window) {
|
||||
return "speech-recognition";
|
||||
}
|
||||
|
||||
if ("MediaRecorder" in window && "mediaDevices" in navigator) {
|
||||
return "media-recorder";
|
||||
}
|
||||
|
||||
return "none";
|
||||
};
|
||||
|
||||
export const SpeechInput = ({
|
||||
className,
|
||||
onTranscriptionChange,
|
||||
onAudioRecorded,
|
||||
lang = "en-US",
|
||||
...props
|
||||
}: SpeechInputProps) => {
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [mode] = useState<SpeechInputMode>(detectSpeechInputMode);
|
||||
const [isRecognitionReady, setIsRecognitionReady] = useState(false);
|
||||
const recognitionRef = useRef<SpeechRecognition | null>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const onTranscriptionChangeRef = useRef<
|
||||
SpeechInputProps["onTranscriptionChange"]
|
||||
>(onTranscriptionChange);
|
||||
const onAudioRecordedRef =
|
||||
useRef<SpeechInputProps["onAudioRecorded"]>(onAudioRecorded);
|
||||
|
||||
// Keep refs in sync
|
||||
onTranscriptionChangeRef.current = onTranscriptionChange;
|
||||
onAudioRecordedRef.current = onAudioRecorded;
|
||||
|
||||
// Initialize Speech Recognition when mode is speech-recognition
|
||||
useEffect(() => {
|
||||
if (mode !== "speech-recognition") {
|
||||
return;
|
||||
}
|
||||
|
||||
const SpeechRecognition =
|
||||
window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
const speechRecognition = new SpeechRecognition();
|
||||
|
||||
speechRecognition.continuous = true;
|
||||
speechRecognition.interimResults = true;
|
||||
speechRecognition.lang = lang;
|
||||
|
||||
const handleStart = () => {
|
||||
setIsListening(true);
|
||||
};
|
||||
|
||||
const handleEnd = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
const handleResult = (event: Event) => {
|
||||
const speechEvent = event as SpeechRecognitionEvent;
|
||||
let finalTranscript = "";
|
||||
|
||||
for (
|
||||
let i = speechEvent.resultIndex;
|
||||
i < speechEvent.results.length;
|
||||
i += 1
|
||||
) {
|
||||
const result = speechEvent.results[i];
|
||||
if (result.isFinal) {
|
||||
finalTranscript += result[0]?.transcript ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
if (finalTranscript) {
|
||||
onTranscriptionChangeRef.current?.(finalTranscript);
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setIsListening(false);
|
||||
};
|
||||
|
||||
speechRecognition.addEventListener("start", handleStart);
|
||||
speechRecognition.addEventListener("end", handleEnd);
|
||||
speechRecognition.addEventListener("result", handleResult);
|
||||
speechRecognition.addEventListener("error", handleError);
|
||||
|
||||
recognitionRef.current = speechRecognition;
|
||||
setIsRecognitionReady(true);
|
||||
|
||||
return () => {
|
||||
speechRecognition.removeEventListener("start", handleStart);
|
||||
speechRecognition.removeEventListener("end", handleEnd);
|
||||
speechRecognition.removeEventListener("result", handleResult);
|
||||
speechRecognition.removeEventListener("error", handleError);
|
||||
speechRecognition.stop();
|
||||
recognitionRef.current = null;
|
||||
setIsRecognitionReady(false);
|
||||
};
|
||||
}, [mode, lang]);
|
||||
|
||||
// Cleanup MediaRecorder and stream on unmount
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (mediaRecorderRef.current?.state === "recording") {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
if (streamRef.current) {
|
||||
for (const track of streamRef.current.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Start MediaRecorder recording
|
||||
const startMediaRecorder = useCallback(async () => {
|
||||
if (!onAudioRecordedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
streamRef.current = stream;
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
audioChunksRef.current = [];
|
||||
|
||||
const handleDataAvailable = (event: BlobEvent) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunksRef.current.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
for (const track of stream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
streamRef.current = null;
|
||||
|
||||
const audioBlob = new Blob(audioChunksRef.current, {
|
||||
type: "audio/webm",
|
||||
});
|
||||
|
||||
if (audioBlob.size > 0 && onAudioRecordedRef.current) {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
const transcript = await onAudioRecordedRef.current(audioBlob);
|
||||
if (transcript) {
|
||||
onTranscriptionChangeRef.current?.(transcript);
|
||||
}
|
||||
} catch {
|
||||
// Error handling delegated to the onAudioRecorded caller
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
setIsListening(false);
|
||||
for (const track of stream.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
streamRef.current = null;
|
||||
};
|
||||
|
||||
mediaRecorder.addEventListener("dataavailable", handleDataAvailable);
|
||||
mediaRecorder.addEventListener("stop", handleStop);
|
||||
mediaRecorder.addEventListener("error", handleError);
|
||||
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
mediaRecorder.start();
|
||||
setIsListening(true);
|
||||
} catch {
|
||||
setIsListening(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Stop MediaRecorder recording
|
||||
const stopMediaRecorder = useCallback(() => {
|
||||
if (mediaRecorderRef.current?.state === "recording") {
|
||||
mediaRecorderRef.current.stop();
|
||||
}
|
||||
setIsListening(false);
|
||||
}, []);
|
||||
|
||||
const toggleListening = useCallback(() => {
|
||||
if (mode === "speech-recognition" && recognitionRef.current) {
|
||||
if (isListening) {
|
||||
recognitionRef.current.stop();
|
||||
} else {
|
||||
recognitionRef.current.start();
|
||||
}
|
||||
} else if (mode === "media-recorder") {
|
||||
if (isListening) {
|
||||
stopMediaRecorder();
|
||||
} else {
|
||||
startMediaRecorder();
|
||||
}
|
||||
}
|
||||
}, [mode, isListening, startMediaRecorder, stopMediaRecorder]);
|
||||
|
||||
// Determine if button should be disabled
|
||||
const isDisabled =
|
||||
mode === "none" ||
|
||||
(mode === "speech-recognition" && !isRecognitionReady) ||
|
||||
(mode === "media-recorder" && !onAudioRecorded) ||
|
||||
isProcessing;
|
||||
|
||||
return (
|
||||
<div className="relative inline-flex items-center justify-center">
|
||||
{/* Animated pulse rings */}
|
||||
{isListening &&
|
||||
[0, 1, 2].map((index) => (
|
||||
<div
|
||||
className="absolute inset-0 animate-ping rounded-full border-2 border-red-400/30"
|
||||
key={index}
|
||||
style={{
|
||||
animationDelay: `${index * 0.3}s`,
|
||||
animationDuration: "2s",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Main record button */}
|
||||
<Button
|
||||
className={cn(
|
||||
"relative z-10 rounded-full transition-all duration-300",
|
||||
isListening
|
||||
? "bg-destructive text-white hover:bg-destructive/80 hover:text-white"
|
||||
: "bg-primary text-primary-foreground hover:bg-primary/80 hover:text-primary-foreground",
|
||||
className
|
||||
)}
|
||||
disabled={isDisabled}
|
||||
onClick={toggleListening}
|
||||
{...props}
|
||||
>
|
||||
{isProcessing && <Spinner />}
|
||||
{!isProcessing && isListening && <SquareIcon className="size-4" />}
|
||||
{!(isProcessing || isListening) && <MicIcon className="size-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
528
components/ai-elements/stack-trace.tsx
Normal file
528
components/ai-elements/stack-trace.tsx
Normal file
@@ -0,0 +1,528 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckIcon,
|
||||
ChevronDownIcon,
|
||||
CopyIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
// Regex patterns for parsing stack traces
|
||||
const STACK_FRAME_WITH_PARENS_REGEX = /^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/;
|
||||
const STACK_FRAME_WITHOUT_FN_REGEX = /^at\s+(.+):(\d+):(\d+)$/;
|
||||
const ERROR_TYPE_REGEX = /^(\w+Error|Error):\s*(.*)$/;
|
||||
const AT_PREFIX_REGEX = /^at\s+/;
|
||||
|
||||
interface StackFrame {
|
||||
raw: string;
|
||||
functionName: string | null;
|
||||
filePath: string | null;
|
||||
lineNumber: number | null;
|
||||
columnNumber: number | null;
|
||||
isInternal: boolean;
|
||||
}
|
||||
|
||||
interface ParsedStackTrace {
|
||||
errorType: string | null;
|
||||
errorMessage: string;
|
||||
frames: StackFrame[];
|
||||
raw: string;
|
||||
}
|
||||
|
||||
interface StackTraceContextValue {
|
||||
trace: ParsedStackTrace;
|
||||
raw: string;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
onFilePathClick?: (filePath: string, line?: number, column?: number) => void;
|
||||
}
|
||||
|
||||
const StackTraceContext = createContext<StackTraceContextValue | null>(null);
|
||||
|
||||
const useStackTrace = () => {
|
||||
const context = useContext(StackTraceContext);
|
||||
if (!context) {
|
||||
throw new Error("StackTrace components must be used within StackTrace");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
const parseStackFrame = (line: string): StackFrame => {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Pattern: at functionName (filePath:line:column)
|
||||
const withParensMatch = trimmed.match(STACK_FRAME_WITH_PARENS_REGEX);
|
||||
if (withParensMatch) {
|
||||
const [, functionName, filePath, lineNum, colNum] = withParensMatch;
|
||||
const isInternal =
|
||||
filePath.includes("node_modules") ||
|
||||
filePath.startsWith("node:") ||
|
||||
filePath.includes("internal/");
|
||||
return {
|
||||
columnNumber: colNum ? Number.parseInt(colNum, 10) : null,
|
||||
filePath: filePath ?? null,
|
||||
functionName: functionName ?? null,
|
||||
isInternal,
|
||||
lineNumber: lineNum ? Number.parseInt(lineNum, 10) : null,
|
||||
raw: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
// Pattern: at filePath:line:column (no function name)
|
||||
const withoutFnMatch = trimmed.match(STACK_FRAME_WITHOUT_FN_REGEX);
|
||||
if (withoutFnMatch) {
|
||||
const [, filePath, lineNum, colNum] = withoutFnMatch;
|
||||
const isInternal =
|
||||
(filePath?.includes("node_modules") ?? false) ||
|
||||
(filePath?.startsWith("node:") ?? false) ||
|
||||
(filePath?.includes("internal/") ?? false);
|
||||
return {
|
||||
columnNumber: colNum ? Number.parseInt(colNum, 10) : null,
|
||||
filePath: filePath ?? null,
|
||||
functionName: null,
|
||||
isInternal,
|
||||
lineNumber: lineNum ? Number.parseInt(lineNum, 10) : null,
|
||||
raw: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: unparseable line
|
||||
return {
|
||||
columnNumber: null,
|
||||
filePath: null,
|
||||
functionName: null,
|
||||
isInternal: trimmed.includes("node_modules") || trimmed.includes("node:"),
|
||||
lineNumber: null,
|
||||
raw: trimmed,
|
||||
};
|
||||
};
|
||||
|
||||
const parseStackTrace = (trace: string): ParsedStackTrace => {
|
||||
const lines = trace.split("\n").filter((line) => line.trim());
|
||||
|
||||
if (lines.length === 0) {
|
||||
return {
|
||||
errorMessage: trace,
|
||||
errorType: null,
|
||||
frames: [],
|
||||
raw: trace,
|
||||
};
|
||||
}
|
||||
|
||||
const firstLine = lines[0].trim();
|
||||
let errorType: string | null = null;
|
||||
let errorMessage = firstLine;
|
||||
|
||||
// Try to extract error type from "ErrorType: message" format
|
||||
const errorMatch = firstLine.match(ERROR_TYPE_REGEX);
|
||||
if (errorMatch) {
|
||||
const [, type, msg] = errorMatch;
|
||||
errorType = type;
|
||||
errorMessage = msg || "";
|
||||
}
|
||||
|
||||
// Parse stack frames (lines starting with "at")
|
||||
const frames = lines
|
||||
.slice(1)
|
||||
.filter((line) => line.trim().startsWith("at "))
|
||||
.map(parseStackFrame);
|
||||
|
||||
return {
|
||||
errorMessage,
|
||||
errorType,
|
||||
frames,
|
||||
raw: trace,
|
||||
};
|
||||
};
|
||||
|
||||
export type StackTraceProps = ComponentProps<"div"> & {
|
||||
trace: string;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
onFilePathClick?: (filePath: string, line?: number, column?: number) => void;
|
||||
};
|
||||
|
||||
export const StackTrace = memo(
|
||||
({
|
||||
trace,
|
||||
className,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
onFilePathClick,
|
||||
children,
|
||||
...props
|
||||
}: StackTraceProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
prop: open,
|
||||
});
|
||||
|
||||
const parsedTrace = useMemo(() => parseStackTrace(trace), [trace]);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
isOpen,
|
||||
onFilePathClick,
|
||||
raw: trace,
|
||||
setIsOpen,
|
||||
trace: parsedTrace,
|
||||
}),
|
||||
[parsedTrace, trace, isOpen, setIsOpen, onFilePathClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<StackTraceContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"not-prose w-full overflow-hidden rounded-lg border bg-background font-mono text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</StackTraceContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceHeaderProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const StackTraceHeader = memo(
|
||||
({ className, children, ...props }: StackTraceHeaderProps) => {
|
||||
const { isOpen, setIsOpen } = useStackTrace();
|
||||
|
||||
return (
|
||||
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
|
||||
<CollapsibleTrigger asChild {...props}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-3 p-3 text-left transition-colors hover:bg-muted/50",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceErrorProps = ComponentProps<"div">;
|
||||
|
||||
export const StackTraceError = memo(
|
||||
({ className, children, ...props }: StackTraceErrorProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 items-center gap-2 overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<AlertTriangleIcon className="size-4 shrink-0 text-destructive" />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export type StackTraceErrorTypeProps = ComponentProps<"span">;
|
||||
|
||||
export const StackTraceErrorType = memo(
|
||||
({ className, children, ...props }: StackTraceErrorTypeProps) => {
|
||||
const { trace } = useStackTrace();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("shrink-0 font-semibold text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? trace.errorType}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceErrorMessageProps = ComponentProps<"span">;
|
||||
|
||||
export const StackTraceErrorMessage = memo(
|
||||
({ className, children, ...props }: StackTraceErrorMessageProps) => {
|
||||
const { trace } = useStackTrace();
|
||||
|
||||
return (
|
||||
<span className={cn("truncate text-foreground", className)} {...props}>
|
||||
{children ?? trace.errorMessage}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceActionsProps = ComponentProps<"div">;
|
||||
|
||||
const handleActionsClick = (e: React.MouseEvent) => e.stopPropagation();
|
||||
const handleActionsKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
export const StackTraceActions = memo(
|
||||
({ className, children, ...props }: StackTraceActionsProps) => (
|
||||
<div
|
||||
className={cn("flex shrink-0 items-center gap-1", className)}
|
||||
onClick={handleActionsClick}
|
||||
onKeyDown={handleActionsKeyDown}
|
||||
role="group"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
export type StackTraceCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const StackTraceCopyButton = memo(
|
||||
({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: StackTraceCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
const { raw } = useStackTrace();
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(raw);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(
|
||||
() => setIsCopied(false),
|
||||
timeout
|
||||
);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [raw, onCopy, onError, timeout]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("size-7", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceExpandButtonProps = ComponentProps<"div">;
|
||||
|
||||
export const StackTraceExpandButton = memo(
|
||||
({ className, ...props }: StackTraceExpandButtonProps) => {
|
||||
const { isOpen } = useStackTrace();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex size-7 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 text-muted-foreground transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
maxHeight?: number;
|
||||
};
|
||||
|
||||
export const StackTraceContent = memo(
|
||||
({
|
||||
className,
|
||||
maxHeight = 400,
|
||||
children,
|
||||
...props
|
||||
}: StackTraceContentProps) => {
|
||||
const { isOpen } = useStackTrace();
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen}>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"overflow-auto border-t bg-muted/30",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
style={{ maxHeight }}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type StackTraceFramesProps = ComponentProps<"div"> & {
|
||||
showInternalFrames?: boolean;
|
||||
};
|
||||
|
||||
interface FilePathButtonProps {
|
||||
frame: StackFrame;
|
||||
onFilePathClick?: (
|
||||
filePath: string,
|
||||
lineNumber?: number,
|
||||
columnNumber?: number
|
||||
) => void;
|
||||
}
|
||||
|
||||
const FilePathButton = memo(
|
||||
({ frame, onFilePathClick }: FilePathButtonProps) => {
|
||||
const handleClick = useCallback(() => {
|
||||
if (frame.filePath) {
|
||||
onFilePathClick?.(
|
||||
frame.filePath,
|
||||
frame.lineNumber ?? undefined,
|
||||
frame.columnNumber ?? undefined
|
||||
);
|
||||
}
|
||||
}, [frame, onFilePathClick]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"underline decoration-dotted hover:text-primary",
|
||||
onFilePathClick && "cursor-pointer"
|
||||
)}
|
||||
disabled={!onFilePathClick}
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
>
|
||||
{frame.filePath}
|
||||
{frame.lineNumber !== null && `:${frame.lineNumber}`}
|
||||
{frame.columnNumber !== null && `:${frame.columnNumber}`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
FilePathButton.displayName = "FilePathButton";
|
||||
|
||||
export const StackTraceFrames = memo(
|
||||
({
|
||||
className,
|
||||
showInternalFrames = true,
|
||||
...props
|
||||
}: StackTraceFramesProps) => {
|
||||
const { trace, onFilePathClick } = useStackTrace();
|
||||
|
||||
const framesToShow = showInternalFrames
|
||||
? trace.frames
|
||||
: trace.frames.filter((f) => !f.isInternal);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1 p-3", className)} {...props}>
|
||||
{framesToShow.map((frame) => (
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs",
|
||||
frame.isInternal
|
||||
? "text-muted-foreground/50"
|
||||
: "text-foreground/90"
|
||||
)}
|
||||
key={frame.raw}
|
||||
>
|
||||
<span className="text-muted-foreground">at </span>
|
||||
{frame.functionName && (
|
||||
<span className={frame.isInternal ? "" : "text-foreground"}>
|
||||
{frame.functionName}{" "}
|
||||
</span>
|
||||
)}
|
||||
{frame.filePath && (
|
||||
<>
|
||||
<span className="text-muted-foreground">(</span>
|
||||
<FilePathButton
|
||||
frame={frame}
|
||||
onFilePathClick={onFilePathClick}
|
||||
/>
|
||||
<span className="text-muted-foreground">)</span>
|
||||
</>
|
||||
)}
|
||||
{!(frame.filePath || frame.functionName) && (
|
||||
<span>{frame.raw.replace(AT_PREFIX_REGEX, "")}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{framesToShow.length === 0 && (
|
||||
<div className="text-muted-foreground text-xs">No stack frames</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
StackTrace.displayName = "StackTrace";
|
||||
StackTraceHeader.displayName = "StackTraceHeader";
|
||||
StackTraceError.displayName = "StackTraceError";
|
||||
StackTraceErrorType.displayName = "StackTraceErrorType";
|
||||
StackTraceErrorMessage.displayName = "StackTraceErrorMessage";
|
||||
StackTraceActions.displayName = "StackTraceActions";
|
||||
StackTraceCopyButton.displayName = "StackTraceCopyButton";
|
||||
StackTraceExpandButton.displayName = "StackTraceExpandButton";
|
||||
StackTraceContent.displayName = "StackTraceContent";
|
||||
StackTraceFrames.displayName = "StackTraceFrames";
|
||||
57
components/ai-elements/suggestion.tsx
Normal file
57
components/ai-elements/suggestion.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ScrollArea,
|
||||
ScrollBar,
|
||||
} from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
|
||||
|
||||
export const Suggestions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SuggestionsProps) => (
|
||||
<ScrollArea className="w-full overflow-x-auto whitespace-nowrap" {...props}>
|
||||
<div className={cn("flex w-max flex-nowrap items-center gap-2", className)}>
|
||||
{children}
|
||||
</div>
|
||||
<ScrollBar className="hidden" orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
|
||||
suggestion: string;
|
||||
onClick?: (suggestion: string) => void;
|
||||
};
|
||||
|
||||
export const Suggestion = ({
|
||||
suggestion,
|
||||
onClick,
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "sm",
|
||||
children,
|
||||
...props
|
||||
}: SuggestionProps) => {
|
||||
const handleClick = useCallback(() => {
|
||||
onClick?.(suggestion);
|
||||
}, [onClick, suggestion]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("cursor-pointer rounded-full px-4", className)}
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children || suggestion}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
87
components/ai-elements/task.tsx
Normal file
87
components/ai-elements/task.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, SearchIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type TaskItemFileProps = ComponentProps<"div">;
|
||||
|
||||
export const TaskItemFile = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskItemFileProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TaskItemProps = ComponentProps<"div">;
|
||||
|
||||
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
|
||||
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TaskProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Task = ({
|
||||
defaultOpen = true,
|
||||
className,
|
||||
...props
|
||||
}: TaskProps) => (
|
||||
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
|
||||
);
|
||||
|
||||
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const TaskTrigger = ({
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
...props
|
||||
}: TaskTriggerProps) => (
|
||||
<CollapsibleTrigger asChild className={cn("group", className)} {...props}>
|
||||
{children ?? (
|
||||
<div className="flex w-full cursor-pointer items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
|
||||
<SearchIcon className="size-4" />
|
||||
<p className="text-sm">{title}</p>
|
||||
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const TaskContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="mt-4 space-y-2 border-muted border-l-2 pl-4">
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
);
|
||||
273
components/ai-elements/terminal.tsx
Normal file
273
components/ai-elements/terminal.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Ansi from "ansi-to-react";
|
||||
import { CheckIcon, CopyIcon, TerminalIcon, Trash2Icon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
interface TerminalContextType {
|
||||
output: string;
|
||||
isStreaming: boolean;
|
||||
autoScroll: boolean;
|
||||
onClear?: () => void;
|
||||
}
|
||||
|
||||
const TerminalContext = createContext<TerminalContextType>({
|
||||
autoScroll: true,
|
||||
isStreaming: false,
|
||||
output: "",
|
||||
});
|
||||
|
||||
export type TerminalHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TerminalHeader = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-zinc-800 border-b px-4 py-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TerminalTitleProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TerminalTitle = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalTitleProps) => (
|
||||
<div
|
||||
className={cn("flex items-center gap-2 text-sm text-zinc-400", className)}
|
||||
{...props}
|
||||
>
|
||||
<TerminalIcon className="size-4" />
|
||||
{children ?? "Terminal"}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TerminalStatusProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TerminalStatus = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalStatusProps) => {
|
||||
const { isStreaming } = useContext(TerminalContext);
|
||||
|
||||
if (!isStreaming) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center gap-2 text-xs text-zinc-400", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type TerminalActionsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TerminalActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TerminalCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const TerminalCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TerminalCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
const { output } = useContext(TerminalContext);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(output);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(() => setIsCopied(false), timeout);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [output, onCopy, onError, timeout]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-7 shrink-0 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100",
|
||||
className
|
||||
)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type TerminalClearButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const TerminalClearButton = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TerminalClearButtonProps) => {
|
||||
const { onClear } = useContext(TerminalContext);
|
||||
|
||||
if (!onClear) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"size-7 shrink-0 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100",
|
||||
className
|
||||
)}
|
||||
onClick={onClear}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Trash2Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type TerminalContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TerminalContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalContentProps) => {
|
||||
const { output, isStreaming, autoScroll } = useContext(TerminalContext);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoScroll && containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
}, [output, autoScroll]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"max-h-96 overflow-auto p-4 font-mono text-sm leading-relaxed",
|
||||
className
|
||||
)}
|
||||
ref={containerRef}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<pre className="whitespace-pre-wrap break-words">
|
||||
<Ansi>{output}</Ansi>
|
||||
{isStreaming && (
|
||||
<span className="ml-0.5 inline-block h-4 w-2 animate-pulse bg-zinc-100" />
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type TerminalProps = HTMLAttributes<HTMLDivElement> & {
|
||||
output: string;
|
||||
isStreaming?: boolean;
|
||||
autoScroll?: boolean;
|
||||
onClear?: () => void;
|
||||
};
|
||||
|
||||
export const Terminal = ({
|
||||
output,
|
||||
isStreaming = false,
|
||||
autoScroll = true,
|
||||
onClear,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TerminalProps) => {
|
||||
const contextValue = useMemo(
|
||||
() => ({ autoScroll, isStreaming, onClear, output }),
|
||||
[autoScroll, isStreaming, onClear, output]
|
||||
);
|
||||
|
||||
return (
|
||||
<TerminalContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col overflow-hidden rounded-lg border bg-zinc-950 text-zinc-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<TerminalHeader>
|
||||
<TerminalTitle />
|
||||
<div className="flex items-center gap-1">
|
||||
<TerminalStatus />
|
||||
<TerminalActions>
|
||||
<TerminalCopyButton />
|
||||
{onClear && <TerminalClearButton />}
|
||||
</TerminalActions>
|
||||
</div>
|
||||
</TerminalHeader>
|
||||
<TerminalContent />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TerminalContext.Provider>
|
||||
);
|
||||
};
|
||||
496
components/ai-elements/test-results.tsx
Normal file
496
components/ai-elements/test-results.tsx
Normal file
@@ -0,0 +1,496 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CheckCircle2Icon,
|
||||
ChevronRightIcon,
|
||||
CircleDotIcon,
|
||||
CircleIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import { createContext, useContext, useMemo } from "react";
|
||||
|
||||
type TestStatus = "passed" | "failed" | "skipped" | "running";
|
||||
|
||||
interface TestResultsSummary {
|
||||
passed: number;
|
||||
failed: number;
|
||||
skipped: number;
|
||||
total: number;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
interface TestResultsContextType {
|
||||
summary?: TestResultsSummary;
|
||||
}
|
||||
|
||||
const TestResultsContext = createContext<TestResultsContextType>({});
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
if (ms < 1000) {
|
||||
return `${ms}ms`;
|
||||
}
|
||||
return `${(ms / 1000).toFixed(2)}s`;
|
||||
};
|
||||
|
||||
export type TestResultsHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TestResultsHeader = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b px-4 py-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TestResultsDurationProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const TestResultsDuration = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsDurationProps) => {
|
||||
const { summary } = useContext(TestResultsContext);
|
||||
|
||||
if (!summary?.duration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn("text-muted-foreground text-sm", className)} {...props}>
|
||||
{children ?? formatDuration(summary.duration)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestResultsSummaryProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TestResultsSummary = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsSummaryProps) => {
|
||||
const { summary } = useContext(TestResultsContext);
|
||||
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-3", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<Badge
|
||||
className="gap-1 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
|
||||
variant="secondary"
|
||||
>
|
||||
<CheckCircle2Icon className="size-3" />
|
||||
{summary.passed} passed
|
||||
</Badge>
|
||||
{summary.failed > 0 && (
|
||||
<Badge
|
||||
className="gap-1 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
|
||||
variant="secondary"
|
||||
>
|
||||
<XCircleIcon className="size-3" />
|
||||
{summary.failed} failed
|
||||
</Badge>
|
||||
)}
|
||||
{summary.skipped > 0 && (
|
||||
<Badge
|
||||
className="gap-1 bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
|
||||
variant="secondary"
|
||||
>
|
||||
<CircleIcon className="size-3" />
|
||||
{summary.skipped} skipped
|
||||
</Badge>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestResultsProps = HTMLAttributes<HTMLDivElement> & {
|
||||
summary?: TestResultsSummary;
|
||||
};
|
||||
|
||||
export const TestResults = ({
|
||||
summary,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsProps) => {
|
||||
const contextValue = useMemo(() => ({ summary }), [summary]);
|
||||
|
||||
return (
|
||||
<TestResultsContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("rounded-lg border bg-background", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ??
|
||||
(summary && (
|
||||
<TestResultsHeader>
|
||||
<TestResultsSummary />
|
||||
<TestResultsDuration />
|
||||
</TestResultsHeader>
|
||||
))}
|
||||
</div>
|
||||
</TestResultsContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestResultsProgressProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TestResultsProgress = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsProgressProps) => {
|
||||
const { summary } = useContext(TestResultsContext);
|
||||
|
||||
if (!summary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const passedPercent = (summary.passed / summary.total) * 100;
|
||||
const failedPercent = (summary.failed / summary.total) * 100;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<div className="flex h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="bg-green-500 transition-all"
|
||||
style={{ width: `${passedPercent}%` }}
|
||||
/>
|
||||
<div
|
||||
className="bg-red-500 transition-all"
|
||||
style={{ width: `${failedPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-muted-foreground text-xs">
|
||||
<span>
|
||||
{summary.passed}/{summary.total} tests passed
|
||||
</span>
|
||||
<span>{passedPercent.toFixed(0)}%</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestResultsContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TestResultsContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestResultsContentProps) => (
|
||||
<div className={cn("space-y-2 p-4", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
interface TestSuiteContextType {
|
||||
name: string;
|
||||
status: TestStatus;
|
||||
}
|
||||
|
||||
const TestSuiteContext = createContext<TestSuiteContextType>({
|
||||
name: "",
|
||||
status: "passed",
|
||||
});
|
||||
|
||||
const statusStyles: Record<TestStatus, string> = {
|
||||
failed: "text-red-600 dark:text-red-400",
|
||||
passed: "text-green-600 dark:text-green-400",
|
||||
running: "text-blue-600 dark:text-blue-400",
|
||||
skipped: "text-yellow-600 dark:text-yellow-400",
|
||||
};
|
||||
|
||||
const statusIcons: Record<TestStatus, React.ReactNode> = {
|
||||
failed: <XCircleIcon className="size-4" />,
|
||||
passed: <CheckCircle2Icon className="size-4" />,
|
||||
running: <CircleDotIcon className="size-4 animate-pulse" />,
|
||||
skipped: <CircleIcon className="size-4" />,
|
||||
};
|
||||
|
||||
const TestStatusIcon = ({ status }: { status: TestStatus }) => (
|
||||
<span className={cn("shrink-0", statusStyles[status])}>
|
||||
{statusIcons[status]}
|
||||
</span>
|
||||
);
|
||||
|
||||
export type TestSuiteProps = ComponentProps<typeof Collapsible> & {
|
||||
name: string;
|
||||
status: TestStatus;
|
||||
};
|
||||
|
||||
export const TestSuite = ({
|
||||
name,
|
||||
status,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestSuiteProps) => {
|
||||
const contextValue = useMemo(() => ({ name, status }), [name, status]);
|
||||
|
||||
return (
|
||||
<TestSuiteContext.Provider value={contextValue}>
|
||||
<Collapsible className={cn("rounded-lg border", className)} {...props}>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</TestSuiteContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestSuiteNameProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const TestSuiteName = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestSuiteNameProps) => {
|
||||
const { name, status } = useContext(TestSuiteContext);
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
|
||||
<TestStatusIcon status={status} />
|
||||
<span className="font-medium text-sm">{children ?? name}</span>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestSuiteStatsProps = HTMLAttributes<HTMLDivElement> & {
|
||||
passed?: number;
|
||||
failed?: number;
|
||||
skipped?: number;
|
||||
};
|
||||
|
||||
export const TestSuiteStats = ({
|
||||
passed = 0,
|
||||
failed = 0,
|
||||
skipped = 0,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestSuiteStatsProps) => (
|
||||
<div
|
||||
className={cn("ml-auto flex items-center gap-2 text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{passed > 0 && (
|
||||
<span className="text-green-600 dark:text-green-400">
|
||||
{passed} passed
|
||||
</span>
|
||||
)}
|
||||
{failed > 0 && (
|
||||
<span className="text-red-600 dark:text-red-400">
|
||||
{failed} failed
|
||||
</span>
|
||||
)}
|
||||
{skipped > 0 && (
|
||||
<span className="text-yellow-600 dark:text-yellow-400">
|
||||
{skipped} skipped
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TestSuiteContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const TestSuiteContent = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestSuiteContentProps) => (
|
||||
<CollapsibleContent className={cn("border-t", className)} {...props}>
|
||||
<div className="divide-y">{children}</div>
|
||||
</CollapsibleContent>
|
||||
);
|
||||
|
||||
interface TestContextType {
|
||||
name: string;
|
||||
status: TestStatus;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
const TestContext = createContext<TestContextType>({
|
||||
name: "",
|
||||
status: "passed",
|
||||
});
|
||||
|
||||
export type TestNameProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const TestName = ({ className, children, ...props }: TestNameProps) => {
|
||||
const { name } = useContext(TestContext);
|
||||
|
||||
return (
|
||||
<span className={cn("flex-1", className)} {...props}>
|
||||
{children ?? name}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestDurationProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const TestDuration = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestDurationProps) => {
|
||||
const { duration } = useContext(TestContext);
|
||||
|
||||
if (duration === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-muted-foreground text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? `${duration}ms`}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestStatusProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const TestStatus = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestStatusProps) => {
|
||||
const { status } = useContext(TestContext);
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("shrink-0", statusStyles[status], className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? statusIcons[status]}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestProps = HTMLAttributes<HTMLDivElement> & {
|
||||
name: string;
|
||||
status: TestStatus;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
export const Test = ({
|
||||
name,
|
||||
status,
|
||||
duration,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestProps) => {
|
||||
const contextValue = useMemo(
|
||||
() => ({ duration, name, status }),
|
||||
[duration, name, status]
|
||||
);
|
||||
|
||||
return (
|
||||
<TestContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("flex items-center gap-2 px-4 py-2 text-sm", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<TestStatus />
|
||||
<TestName />
|
||||
{duration !== undefined && <TestDuration />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TestContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type TestErrorProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const TestError = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestErrorProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-2 rounded-md bg-red-50 p-3 dark:bg-red-900/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TestErrorMessageProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const TestErrorMessage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestErrorMessageProps) => (
|
||||
<p
|
||||
className={cn(
|
||||
"font-medium text-red-700 text-sm dark:text-red-400",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
|
||||
export type TestErrorStackProps = HTMLAttributes<HTMLPreElement>;
|
||||
|
||||
export const TestErrorStack = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TestErrorStackProps) => (
|
||||
<pre
|
||||
className={cn(
|
||||
"mt-2 overflow-auto font-mono text-red-600 text-xs dark:text-red-400",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
173
components/ai-elements/tool.tsx
Normal file
173
components/ai-elements/tool.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { DynamicToolUIPart, ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
CircleIcon,
|
||||
ClockIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { isValidElement } from "react";
|
||||
|
||||
import { CodeBlock } from "./code-block";
|
||||
|
||||
export type ToolProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Tool = ({ className, ...props }: ToolProps) => (
|
||||
<Collapsible
|
||||
className={cn("group not-prose mb-4 w-full rounded-md border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolPart = ToolUIPart | DynamicToolUIPart;
|
||||
|
||||
export type ToolHeaderProps = {
|
||||
title?: string;
|
||||
className?: string;
|
||||
} & (
|
||||
| { type: ToolUIPart["type"]; state: ToolUIPart["state"]; toolName?: never }
|
||||
| {
|
||||
type: DynamicToolUIPart["type"];
|
||||
state: DynamicToolUIPart["state"];
|
||||
toolName: string;
|
||||
}
|
||||
);
|
||||
|
||||
const statusLabels: Record<ToolPart["state"], string> = {
|
||||
"approval-requested": "Awaiting Approval",
|
||||
"approval-responded": "Responded",
|
||||
"input-available": "Running",
|
||||
"input-streaming": "Pending",
|
||||
"output-available": "Completed",
|
||||
"output-denied": "Denied",
|
||||
"output-error": "Error",
|
||||
};
|
||||
|
||||
const statusIcons: Record<ToolPart["state"], ReactNode> = {
|
||||
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
|
||||
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
|
||||
"input-available": <ClockIcon className="size-4 animate-pulse" />,
|
||||
"input-streaming": <CircleIcon className="size-4" />,
|
||||
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
|
||||
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
|
||||
"output-error": <XCircleIcon className="size-4 text-red-600" />,
|
||||
};
|
||||
|
||||
export const getStatusBadge = (status: ToolPart["state"]) => (
|
||||
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
|
||||
{statusIcons[status]}
|
||||
{statusLabels[status]}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
export const ToolHeader = ({
|
||||
className,
|
||||
title,
|
||||
type,
|
||||
state,
|
||||
toolName,
|
||||
...props
|
||||
}: ToolHeaderProps) => {
|
||||
const derivedName =
|
||||
type === "dynamic-tool" ? toolName : type.split("-").slice(1).join("-");
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-4 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<WrenchIcon className="size-4 text-muted-foreground" />
|
||||
<span className="font-medium text-sm">{title ?? derivedName}</span>
|
||||
{getStatusBadge(state)}
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export type ToolContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const ToolContent = ({ className, ...props }: ToolContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 space-y-4 p-4 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolInputProps = ComponentProps<"div"> & {
|
||||
input: ToolPart["input"];
|
||||
};
|
||||
|
||||
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
|
||||
<div className={cn("space-y-2 overflow-hidden", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
Parameters
|
||||
</h4>
|
||||
<div className="rounded-md bg-muted/50">
|
||||
<CodeBlock code={JSON.stringify(input, null, 2)} language="json" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ToolOutputProps = ComponentProps<"div"> & {
|
||||
output: ToolPart["output"];
|
||||
errorText: ToolPart["errorText"];
|
||||
};
|
||||
|
||||
export const ToolOutput = ({
|
||||
className,
|
||||
output,
|
||||
errorText,
|
||||
...props
|
||||
}: ToolOutputProps) => {
|
||||
if (!(output || errorText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let Output = <div>{output as ReactNode}</div>;
|
||||
|
||||
if (typeof output === "object" && !isValidElement(output)) {
|
||||
Output = (
|
||||
<CodeBlock code={JSON.stringify(output, null, 2)} language="json" />
|
||||
);
|
||||
} else if (typeof output === "string") {
|
||||
Output = <CodeBlock code={output} language="json" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)} {...props}>
|
||||
<h4 className="font-medium text-muted-foreground text-xs uppercase tracking-wide">
|
||||
{errorText ? "Error" : "Result"}
|
||||
</h4>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto rounded-md text-xs [&_table]:w-full",
|
||||
errorText
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-muted/50 text-foreground"
|
||||
)}
|
||||
>
|
||||
{errorText && <div>{errorText}</div>}
|
||||
{Output}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
16
components/ai-elements/toolbar.tsx
Normal file
16
components/ai-elements/toolbar.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NodeToolbar, Position } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
type ToolbarProps = ComponentProps<typeof NodeToolbar>;
|
||||
|
||||
export const Toolbar = ({ className, ...props }: ToolbarProps) => (
|
||||
<NodeToolbar
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-sm border bg-background p-1.5",
|
||||
className
|
||||
)}
|
||||
position={Position.Bottom}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
125
components/ai-elements/transcription.tsx
Normal file
125
components/ai-elements/transcription.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Experimental_TranscriptionResult as TranscriptionResult } from "ai";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo } from "react";
|
||||
|
||||
type TranscriptionSegment = TranscriptionResult["segments"][number];
|
||||
|
||||
interface TranscriptionContextValue {
|
||||
segments: TranscriptionSegment[];
|
||||
currentTime: number;
|
||||
onTimeUpdate: (time: number) => void;
|
||||
onSeek?: (time: number) => void;
|
||||
}
|
||||
|
||||
const TranscriptionContext = createContext<TranscriptionContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useTranscription = () => {
|
||||
const context = useContext(TranscriptionContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"Transcription components must be used within Transcription"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type TranscriptionProps = Omit<ComponentProps<"div">, "children"> & {
|
||||
segments: TranscriptionSegment[];
|
||||
currentTime?: number;
|
||||
onSeek?: (time: number) => void;
|
||||
children: (segment: TranscriptionSegment, index: number) => ReactNode;
|
||||
};
|
||||
|
||||
export const Transcription = ({
|
||||
segments,
|
||||
currentTime: externalCurrentTime,
|
||||
onSeek,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: TranscriptionProps) => {
|
||||
const [currentTime, setCurrentTime] = useControllableState({
|
||||
defaultProp: 0,
|
||||
onChange: onSeek,
|
||||
prop: externalCurrentTime,
|
||||
});
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ currentTime, onSeek, onTimeUpdate: setCurrentTime, segments }),
|
||||
[currentTime, onSeek, setCurrentTime, segments]
|
||||
);
|
||||
|
||||
return (
|
||||
<TranscriptionContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap gap-1 text-sm leading-relaxed",
|
||||
className
|
||||
)}
|
||||
data-slot="transcription"
|
||||
{...props}
|
||||
>
|
||||
{segments
|
||||
.filter((segment) => segment.text.trim())
|
||||
.map((segment, index) => children(segment, index))}
|
||||
</div>
|
||||
</TranscriptionContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type TranscriptionSegmentProps = ComponentProps<"button"> & {
|
||||
segment: TranscriptionSegment;
|
||||
index: number;
|
||||
};
|
||||
|
||||
export const TranscriptionSegment = ({
|
||||
segment,
|
||||
index,
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: TranscriptionSegmentProps) => {
|
||||
const { currentTime, onSeek } = useTranscription();
|
||||
|
||||
const isActive =
|
||||
currentTime >= segment.startSecond && currentTime < segment.endSecond;
|
||||
const isPast = currentTime >= segment.endSecond;
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (onSeek) {
|
||||
onSeek(segment.startSecond);
|
||||
}
|
||||
onClick?.(event);
|
||||
},
|
||||
[onSeek, segment.startSecond, onClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"inline text-left",
|
||||
isActive && "text-primary",
|
||||
isPast && "text-muted-foreground",
|
||||
!(isActive || isPast) && "text-muted-foreground/60",
|
||||
onSeek && "cursor-pointer hover:text-foreground",
|
||||
!onSeek && "cursor-default",
|
||||
className
|
||||
)}
|
||||
data-active={isActive}
|
||||
data-index={index}
|
||||
data-slot="transcription-segment"
|
||||
onClick={handleClick}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
{segment.text}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
524
components/ai-elements/voice-selector.tsx
Normal file
524
components/ai-elements/voice-selector.tsx
Normal file
@@ -0,0 +1,524 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
CircleSmallIcon,
|
||||
MarsIcon,
|
||||
MarsStrokeIcon,
|
||||
NonBinaryIcon,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
TransgenderIcon,
|
||||
VenusAndMarsIcon,
|
||||
VenusIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useMemo } from "react";
|
||||
|
||||
interface VoiceSelectorContextValue {
|
||||
value: string | undefined;
|
||||
setValue: (value: string | undefined) => void;
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const VoiceSelectorContext = createContext<VoiceSelectorContextValue | null>(
|
||||
null
|
||||
);
|
||||
|
||||
export const useVoiceSelector = () => {
|
||||
const context = useContext(VoiceSelectorContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"VoiceSelector components must be used within VoiceSelector"
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type VoiceSelectorProps = ComponentProps<typeof Dialog> & {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
onValueChange?: (value: string | undefined) => void;
|
||||
};
|
||||
|
||||
export const VoiceSelector = ({
|
||||
value: valueProp,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
open: openProp,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
...props
|
||||
}: VoiceSelectorProps) => {
|
||||
const [value, setValue] = useControllableState({
|
||||
defaultProp: defaultValue,
|
||||
onChange: onValueChange,
|
||||
prop: valueProp,
|
||||
});
|
||||
|
||||
const [open, setOpen] = useControllableState({
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
prop: openProp,
|
||||
});
|
||||
|
||||
const voiceSelectorContext = useMemo(
|
||||
() => ({ open, setOpen, setValue, value }),
|
||||
[value, setValue, open, setOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<VoiceSelectorContext.Provider value={voiceSelectorContext}>
|
||||
<Dialog onOpenChange={setOpen} open={open} {...props}>
|
||||
{children}
|
||||
</Dialog>
|
||||
</VoiceSelectorContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type VoiceSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
|
||||
|
||||
export const VoiceSelectorTrigger = (props: VoiceSelectorTriggerProps) => (
|
||||
<DialogTrigger {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||
title?: ReactNode;
|
||||
};
|
||||
|
||||
export const VoiceSelectorContent = ({
|
||||
className,
|
||||
children,
|
||||
title = "Voice Selector",
|
||||
...props
|
||||
}: VoiceSelectorContentProps) => (
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
className={cn("p-0", className)}
|
||||
{...props}
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
export type VoiceSelectorDialogProps = ComponentProps<typeof CommandDialog>;
|
||||
|
||||
export const VoiceSelectorDialog = (props: VoiceSelectorDialogProps) => (
|
||||
<CommandDialog {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorInputProps = ComponentProps<typeof CommandInput>;
|
||||
|
||||
export const VoiceSelectorInput = ({
|
||||
className,
|
||||
...props
|
||||
}: VoiceSelectorInputProps) => (
|
||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorListProps = ComponentProps<typeof CommandList>;
|
||||
|
||||
export const VoiceSelectorList = (props: VoiceSelectorListProps) => (
|
||||
<CommandList {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
|
||||
|
||||
export const VoiceSelectorEmpty = (props: VoiceSelectorEmptyProps) => (
|
||||
<CommandEmpty {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorGroupProps = ComponentProps<typeof CommandGroup>;
|
||||
|
||||
export const VoiceSelectorGroup = (props: VoiceSelectorGroupProps) => (
|
||||
<CommandGroup {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorItemProps = ComponentProps<typeof CommandItem>;
|
||||
|
||||
export const VoiceSelectorItem = ({
|
||||
className,
|
||||
...props
|
||||
}: VoiceSelectorItemProps) => (
|
||||
<CommandItem className={cn("px-4 py-2", className)} {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
|
||||
|
||||
export const VoiceSelectorShortcut = (props: VoiceSelectorShortcutProps) => (
|
||||
<CommandShortcut {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorSeparatorProps = ComponentProps<
|
||||
typeof CommandSeparator
|
||||
>;
|
||||
|
||||
export const VoiceSelectorSeparator = (props: VoiceSelectorSeparatorProps) => (
|
||||
<CommandSeparator {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorGenderProps = ComponentProps<"span"> & {
|
||||
value?:
|
||||
| "male"
|
||||
| "female"
|
||||
| "transgender"
|
||||
| "androgyne"
|
||||
| "non-binary"
|
||||
| "intersex";
|
||||
};
|
||||
|
||||
export const VoiceSelectorGender = ({
|
||||
className,
|
||||
value,
|
||||
children,
|
||||
...props
|
||||
}: VoiceSelectorGenderProps) => {
|
||||
let icon: ReactNode | null = null;
|
||||
|
||||
switch (value) {
|
||||
case "male": {
|
||||
icon = <MarsIcon className="size-4" />;
|
||||
break;
|
||||
}
|
||||
case "female": {
|
||||
icon = <VenusIcon className="size-4" />;
|
||||
break;
|
||||
}
|
||||
case "transgender": {
|
||||
icon = <TransgenderIcon className="size-4" />;
|
||||
break;
|
||||
}
|
||||
case "androgyne": {
|
||||
icon = <MarsStrokeIcon className="size-4" />;
|
||||
break;
|
||||
}
|
||||
case "non-binary": {
|
||||
icon = <NonBinaryIcon className="size-4" />;
|
||||
break;
|
||||
}
|
||||
case "intersex": {
|
||||
icon = <VenusAndMarsIcon className="size-4" />;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
icon = <CircleSmallIcon className="size-4" />;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn("text-muted-foreground text-xs", className)} {...props}>
|
||||
{children ?? icon}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type VoiceSelectorAccentProps = ComponentProps<"span"> & {
|
||||
value?:
|
||||
| "american"
|
||||
| "british"
|
||||
| "australian"
|
||||
| "canadian"
|
||||
| "irish"
|
||||
| "scottish"
|
||||
| "indian"
|
||||
| "south-african"
|
||||
| "new-zealand"
|
||||
| "spanish"
|
||||
| "french"
|
||||
| "german"
|
||||
| "italian"
|
||||
| "portuguese"
|
||||
| "brazilian"
|
||||
| "mexican"
|
||||
| "argentinian"
|
||||
| "japanese"
|
||||
| "chinese"
|
||||
| "korean"
|
||||
| "russian"
|
||||
| "arabic"
|
||||
| "dutch"
|
||||
| "swedish"
|
||||
| "norwegian"
|
||||
| "danish"
|
||||
| "finnish"
|
||||
| "polish"
|
||||
| "turkish"
|
||||
| "greek"
|
||||
| string;
|
||||
};
|
||||
|
||||
export const VoiceSelectorAccent = ({
|
||||
className,
|
||||
value,
|
||||
children,
|
||||
...props
|
||||
}: VoiceSelectorAccentProps) => {
|
||||
let emoji: string | null = null;
|
||||
|
||||
switch (value) {
|
||||
case "american": {
|
||||
emoji = "🇺🇸";
|
||||
break;
|
||||
}
|
||||
case "british": {
|
||||
emoji = "🇬🇧";
|
||||
break;
|
||||
}
|
||||
case "australian": {
|
||||
emoji = "🇦🇺";
|
||||
break;
|
||||
}
|
||||
case "canadian": {
|
||||
emoji = "🇨🇦";
|
||||
break;
|
||||
}
|
||||
case "irish": {
|
||||
emoji = "🇮🇪";
|
||||
break;
|
||||
}
|
||||
case "scottish": {
|
||||
emoji = "🏴";
|
||||
break;
|
||||
}
|
||||
case "indian": {
|
||||
emoji = "🇮🇳";
|
||||
break;
|
||||
}
|
||||
case "south-african": {
|
||||
emoji = "🇿🇦";
|
||||
break;
|
||||
}
|
||||
case "new-zealand": {
|
||||
emoji = "🇳🇿";
|
||||
break;
|
||||
}
|
||||
case "spanish": {
|
||||
emoji = "🇪🇸";
|
||||
break;
|
||||
}
|
||||
case "french": {
|
||||
emoji = "🇫🇷";
|
||||
break;
|
||||
}
|
||||
case "german": {
|
||||
emoji = "🇩🇪";
|
||||
break;
|
||||
}
|
||||
case "italian": {
|
||||
emoji = "🇮🇹";
|
||||
break;
|
||||
}
|
||||
case "portuguese": {
|
||||
emoji = "🇵🇹";
|
||||
break;
|
||||
}
|
||||
case "brazilian": {
|
||||
emoji = "🇧🇷";
|
||||
break;
|
||||
}
|
||||
case "mexican": {
|
||||
emoji = "🇲🇽";
|
||||
break;
|
||||
}
|
||||
case "argentinian": {
|
||||
emoji = "🇦🇷";
|
||||
break;
|
||||
}
|
||||
case "japanese": {
|
||||
emoji = "🇯🇵";
|
||||
break;
|
||||
}
|
||||
case "chinese": {
|
||||
emoji = "🇨🇳";
|
||||
break;
|
||||
}
|
||||
case "korean": {
|
||||
emoji = "🇰🇷";
|
||||
break;
|
||||
}
|
||||
case "russian": {
|
||||
emoji = "🇷🇺";
|
||||
break;
|
||||
}
|
||||
case "arabic": {
|
||||
emoji = "🇸🇦";
|
||||
break;
|
||||
}
|
||||
case "dutch": {
|
||||
emoji = "🇳🇱";
|
||||
break;
|
||||
}
|
||||
case "swedish": {
|
||||
emoji = "🇸🇪";
|
||||
break;
|
||||
}
|
||||
case "norwegian": {
|
||||
emoji = "🇳🇴";
|
||||
break;
|
||||
}
|
||||
case "danish": {
|
||||
emoji = "🇩🇰";
|
||||
break;
|
||||
}
|
||||
case "finnish": {
|
||||
emoji = "🇫🇮";
|
||||
break;
|
||||
}
|
||||
case "polish": {
|
||||
emoji = "🇵🇱";
|
||||
break;
|
||||
}
|
||||
case "turkish": {
|
||||
emoji = "🇹🇷";
|
||||
break;
|
||||
}
|
||||
case "greek": {
|
||||
emoji = "🇬🇷";
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
emoji = null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn("text-muted-foreground text-xs", className)} {...props}>
|
||||
{children ?? emoji}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
export type VoiceSelectorAgeProps = ComponentProps<"span">;
|
||||
|
||||
export const VoiceSelectorAge = ({
|
||||
className,
|
||||
...props
|
||||
}: VoiceSelectorAgeProps) => (
|
||||
<span
|
||||
className={cn("text-muted-foreground text-xs tabular-nums", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type VoiceSelectorNameProps = ComponentProps<"span">;
|
||||
|
||||
export const VoiceSelectorName = ({
|
||||
className,
|
||||
...props
|
||||
}: VoiceSelectorNameProps) => (
|
||||
<span
|
||||
className={cn("flex-1 truncate text-left font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type VoiceSelectorDescriptionProps = ComponentProps<"span">;
|
||||
|
||||
export const VoiceSelectorDescription = ({
|
||||
className,
|
||||
...props
|
||||
}: VoiceSelectorDescriptionProps) => (
|
||||
<span className={cn("text-muted-foreground text-xs", className)} {...props} />
|
||||
);
|
||||
|
||||
export type VoiceSelectorAttributesProps = ComponentProps<"div">;
|
||||
|
||||
export const VoiceSelectorAttributes = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: VoiceSelectorAttributesProps) => (
|
||||
<div className={cn("flex items-center text-xs", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type VoiceSelectorBulletProps = ComponentProps<"span">;
|
||||
|
||||
export const VoiceSelectorBullet = ({
|
||||
className,
|
||||
...props
|
||||
}: VoiceSelectorBulletProps) => (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn("select-none text-border", className)}
|
||||
{...props}
|
||||
>
|
||||
•
|
||||
</span>
|
||||
);
|
||||
|
||||
export type VoiceSelectorPreviewProps = Omit<
|
||||
ComponentProps<"button">,
|
||||
"children"
|
||||
> & {
|
||||
playing?: boolean;
|
||||
loading?: boolean;
|
||||
onPlay?: () => void;
|
||||
};
|
||||
|
||||
export const VoiceSelectorPreview = ({
|
||||
className,
|
||||
playing,
|
||||
loading,
|
||||
onPlay,
|
||||
onClick,
|
||||
...props
|
||||
}: VoiceSelectorPreviewProps) => {
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.stopPropagation();
|
||||
onClick?.(event);
|
||||
onPlay?.();
|
||||
},
|
||||
[onClick, onPlay]
|
||||
);
|
||||
|
||||
let icon = <PlayIcon className="size-3" />;
|
||||
|
||||
if (loading) {
|
||||
icon = <Spinner className="size-3" />;
|
||||
} else if (playing) {
|
||||
icon = <PauseIcon className="size-3" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label={playing ? "Pause preview" : "Play preview"}
|
||||
className={cn("size-6", className)}
|
||||
disabled={loading}
|
||||
onClick={handleClick}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
281
components/ai-elements/web-preview.tsx
Normal file
281
components/ai-elements/web-preview.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
export interface WebPreviewContextValue {
|
||||
url: string;
|
||||
setUrl: (url: string) => void;
|
||||
consoleOpen: boolean;
|
||||
setConsoleOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null);
|
||||
|
||||
const useWebPreview = () => {
|
||||
const context = useContext(WebPreviewContext);
|
||||
if (!context) {
|
||||
throw new Error("WebPreview components must be used within a WebPreview");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type WebPreviewProps = ComponentProps<"div"> & {
|
||||
defaultUrl?: string;
|
||||
onUrlChange?: (url: string) => void;
|
||||
};
|
||||
|
||||
export const WebPreview = ({
|
||||
className,
|
||||
children,
|
||||
defaultUrl = "",
|
||||
onUrlChange,
|
||||
...props
|
||||
}: WebPreviewProps) => {
|
||||
const [url, setUrl] = useState(defaultUrl);
|
||||
const [consoleOpen, setConsoleOpen] = useState(false);
|
||||
|
||||
const handleUrlChange = useCallback(
|
||||
(newUrl: string) => {
|
||||
setUrl(newUrl);
|
||||
onUrlChange?.(newUrl);
|
||||
},
|
||||
[onUrlChange]
|
||||
);
|
||||
|
||||
const contextValue = useMemo<WebPreviewContextValue>(
|
||||
() => ({
|
||||
consoleOpen,
|
||||
setConsoleOpen,
|
||||
setUrl: handleUrlChange,
|
||||
url,
|
||||
}),
|
||||
[consoleOpen, handleUrlChange, url]
|
||||
);
|
||||
|
||||
return (
|
||||
<WebPreviewContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col rounded-lg border bg-card",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</WebPreviewContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewNavigationProps = ComponentProps<"div">;
|
||||
|
||||
export const WebPreviewNavigation = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewNavigationProps) => (
|
||||
<div
|
||||
className={cn("flex items-center gap-1 border-b p-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
export const WebPreviewNavigationButton = ({
|
||||
onClick,
|
||||
disabled,
|
||||
tooltip,
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewNavigationButtonProps) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="h-8 w-8 p-0 hover:text-foreground"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
export type WebPreviewUrlProps = ComponentProps<typeof Input>;
|
||||
|
||||
export const WebPreviewUrl = ({
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: WebPreviewUrlProps) => {
|
||||
const { url, setUrl } = useWebPreview();
|
||||
const [prevUrl, setPrevUrl] = useState(url);
|
||||
const [inputValue, setInputValue] = useState(url);
|
||||
|
||||
// Sync input value with context URL when it changes externally (derived state pattern)
|
||||
if (url !== prevUrl) {
|
||||
setPrevUrl(url);
|
||||
setInputValue(url);
|
||||
}
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
onChange?.(event);
|
||||
};
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
const target = event.target as HTMLInputElement;
|
||||
setUrl(target.value);
|
||||
}
|
||||
onKeyDown?.(event);
|
||||
},
|
||||
[setUrl, onKeyDown]
|
||||
);
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="h-8 flex-1 text-sm"
|
||||
onChange={onChange ?? handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter URL..."
|
||||
value={value ?? inputValue}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewBodyProps = ComponentProps<"iframe"> & {
|
||||
loading?: ReactNode;
|
||||
};
|
||||
|
||||
export const WebPreviewBody = ({
|
||||
className,
|
||||
loading,
|
||||
src,
|
||||
...props
|
||||
}: WebPreviewBodyProps) => {
|
||||
const { url } = useWebPreview();
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<iframe
|
||||
className={cn("size-full", className)}
|
||||
// oxlint-disable-next-line eslint-plugin-react(iframe-missing-sandbox)
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
|
||||
src={(src ?? url) || undefined}
|
||||
title="Preview"
|
||||
{...props}
|
||||
/>
|
||||
{loading}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewConsoleProps = ComponentProps<"div"> & {
|
||||
logs?: {
|
||||
level: "log" | "warn" | "error";
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
}[];
|
||||
};
|
||||
|
||||
export const WebPreviewConsole = ({
|
||||
className,
|
||||
logs = [],
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewConsoleProps) => {
|
||||
const { consoleOpen, setConsoleOpen } = useWebPreview();
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
className={cn("border-t bg-muted/50 font-mono text-sm", className)}
|
||||
onOpenChange={setConsoleOpen}
|
||||
open={consoleOpen}
|
||||
{...props}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
className="flex w-full items-center justify-between p-4 text-left font-medium hover:bg-muted/50"
|
||||
variant="ghost"
|
||||
>
|
||||
Console
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
consoleOpen && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"px-4 pb-4",
|
||||
"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 outline-none data-[state=closed]:animate-out data-[state=open]:animate-in"
|
||||
)}
|
||||
>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-muted-foreground">No console output</p>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs",
|
||||
log.level === "error" && "text-destructive",
|
||||
log.level === "warn" && "text-yellow-600",
|
||||
log.level === "log" && "text-foreground"
|
||||
)}
|
||||
key={`${log.timestamp.getTime()}-${log.level}-${log.message}`}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{log.timestamp.toLocaleTimeString()}
|
||||
</span>{" "}
|
||||
{log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user