mirror of
https://github.com/katanemo/plano.git
synced 2026-06-17 15:25:17 +02:00
demo(vercel-ai-sdk): add UI components (incl. generative tool UIs)
This commit is contained in:
parent
b7af8ab536
commit
719a5bf0af
114 changed files with 18020 additions and 0 deletions
|
|
@ -0,0 +1,147 @@
|
|||
"use client";
|
||||
|
||||
import { type LucideIcon, XIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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} />
|
||||
);
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react";
|
||||
import type { ReactNode } from "react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
type CanvasProps = ReactFlowProps & {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const Canvas = ({ children, ...props }: CanvasProps) => (
|
||||
<ReactFlow
|
||||
deleteKeyCode={["Backspace", "Delete"]}
|
||||
fitView
|
||||
panOnDrag={false}
|
||||
panOnScroll
|
||||
selectionOnDrag={true}
|
||||
zoomOnDoubleClick={false}
|
||||
{...props}
|
||||
>
|
||||
<Background bgColor="var(--sidebar)" />
|
||||
{children}
|
||||
</ReactFlow>
|
||||
);
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import {
|
||||
BrainIcon,
|
||||
ChevronDownIcon,
|
||||
DotIcon,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, memo, useContext, useMemo } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type 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({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
|
||||
const chainOfThoughtContext = useMemo(
|
||||
() => ({ isOpen, setIsOpen }),
|
||||
[isOpen, setIsOpen]
|
||||
);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
|
||||
<div
|
||||
className={cn("not-prose max-w-prose 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";
|
||||
};
|
||||
|
||||
export const ChainOfThoughtStep = memo(
|
||||
({
|
||||
className,
|
||||
icon: Icon = DotIcon,
|
||||
label,
|
||||
description,
|
||||
status = "complete",
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtStepProps) => {
|
||||
const statusStyles = {
|
||||
complete: "text-muted-foreground",
|
||||
active: "text-foreground",
|
||||
pending: "text-muted-foreground/50",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 text-sm",
|
||||
statusStyles[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="-mx-px absolute top-7 bottom-0 left-1/2 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";
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
"use client";
|
||||
|
||||
import { BookmarkIcon, type LucideProps } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
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";
|
||||
|
||||
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,
|
||||
className,
|
||||
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>
|
||||
);
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
"use client";
|
||||
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language: BundledLanguage;
|
||||
showLineNumbers?: boolean;
|
||||
};
|
||||
|
||||
type CodeBlockContextType = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: "",
|
||||
});
|
||||
|
||||
const lineNumberTransformer: ShikiTransformer = {
|
||||
name: "line-numbers",
|
||||
line(node, line) {
|
||||
node.children.unshift({
|
||||
type: "element",
|
||||
tagName: "span",
|
||||
properties: {
|
||||
className: [
|
||||
"inline-block",
|
||||
"min-w-10",
|
||||
"mr-4",
|
||||
"text-right",
|
||||
"select-none",
|
||||
"text-muted-foreground",
|
||||
],
|
||||
},
|
||||
children: [{ type: "text", value: String(line) }],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export async function highlightCode(
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
showLineNumbers = false
|
||||
) {
|
||||
const transformers: ShikiTransformer[] = showLineNumbers
|
||||
? [lineNumberTransformer]
|
||||
: [];
|
||||
|
||||
return await Promise.all([
|
||||
codeToHtml(code, {
|
||||
lang: language,
|
||||
theme: "one-light",
|
||||
transformers,
|
||||
}),
|
||||
codeToHtml(code, {
|
||||
lang: language,
|
||||
theme: "one-dark-pro",
|
||||
transformers,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const [html, setHtml] = useState<string>("");
|
||||
const [darkHtml, setDarkHtml] = useState<string>("");
|
||||
const mounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
highlightCode(code, language, showLineNumbers).then(([light, dark]) => {
|
||||
if (!mounted.current) {
|
||||
setHtml(light);
|
||||
setDarkHtml(dark);
|
||||
mounted.current = true;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, [code, language, showLineNumbers]);
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={{ code }}>
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-full overflow-hidden rounded-md border bg-background text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="overflow-auto dark:hidden [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
<div
|
||||
className="hidden overflow-auto dark:block [&>pre]:m-0 [&>pre]:bg-background! [&>pre]:p-4 [&>pre]:text-foreground! [&>pre]:text-sm [&_code]:font-mono [&_code]:text-sm"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||
dangerouslySetInnerHTML={{ __html: darkHtml }}
|
||||
/>
|
||||
{children && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-2">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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 { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
setTimeout(() => setIsCopied(false), timeout);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
"use client";
|
||||
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
} from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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;
|
||||
|
||||
type 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) => {
|
||||
if (!approval || state === "input-streaming" || state === "input-available") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmationContext.Provider value={{ approval, state }}>
|
||||
<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 type 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 type 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 type 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} />
|
||||
);
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
"use client";
|
||||
|
||||
import { Controls as ControlsPrimitive } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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",
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
140
demos/use_cases/vercel-ai-sdk/components/ai-elements/edge.tsx
Normal file
140
demos/use_cases/vercel-ai-sdk/components/ai-elements/edge.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import {
|
||||
BaseEdge,
|
||||
type EdgeProps,
|
||||
getBezierPath,
|
||||
getSimpleBezierPath,
|
||||
type InternalNode,
|
||||
type Node,
|
||||
Position,
|
||||
useInternalNode,
|
||||
} from "@xyflow/react";
|
||||
|
||||
const Temporary = ({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
}: EdgeProps) => {
|
||||
const [edgePath] = getSimpleBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
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 {
|
||||
sx,
|
||||
sy,
|
||||
tx,
|
||||
ty,
|
||||
sourcePos,
|
||||
targetPos,
|
||||
};
|
||||
};
|
||||
|
||||
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({
|
||||
sourceX: sx,
|
||||
sourceY: sy,
|
||||
sourcePosition: sourcePos,
|
||||
targetX: tx,
|
||||
targetY: ty,
|
||||
targetPosition: targetPos,
|
||||
});
|
||||
|
||||
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 = {
|
||||
Temporary,
|
||||
Animated,
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import type { Experimental_GeneratedImage } from "ai";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ImageProps = Experimental_GeneratedImage & {
|
||||
className?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
export const Image = ({
|
||||
base64,
|
||||
uint8Array,
|
||||
mediaType,
|
||||
...props
|
||||
}: ImageProps) => (
|
||||
// biome-ignore lint/nursery/useImageSize: dynamic base64 content
|
||||
// biome-ignore lint/performance/noImgElement: base64 data URLs require native img
|
||||
<img
|
||||
{...props}
|
||||
alt={props.alt}
|
||||
className={cn(
|
||||
"h-auto max-w-full overflow-hidden rounded-md",
|
||||
props.className
|
||||
)}
|
||||
src={`data:${mediaType};base64,${base64}`}
|
||||
/>
|
||||
);
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Carousel,
|
||||
type CarouselApi,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@/components/ui/carousel";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCount(api.scrollSnapList().length);
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
|
||||
api.on("select", () => {
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
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>
|
||||
);
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type LoaderIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "currentcolor" }}
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
>
|
||||
<title>Loader</title>
|
||||
<g clipPath="url(#clip0_2393_1490)">
|
||||
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path
|
||||
d="M8 16V12"
|
||||
opacity="0.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M3.29773 1.52783L5.64887 4.7639"
|
||||
opacity="0.9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12.7023 1.52783L10.3511 4.7639"
|
||||
opacity="0.1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12.7023 14.472L10.3511 11.236"
|
||||
opacity="0.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M3.29773 14.472L5.64887 11.236"
|
||||
opacity="0.6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M15.6085 5.52783L11.8043 6.7639"
|
||||
opacity="0.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M0.391602 10.472L4.19583 9.23598"
|
||||
opacity="0.7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M15.6085 10.4722L11.8043 9.2361"
|
||||
opacity="0.3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M0.391602 5.52783L4.19583 6.7639"
|
||||
opacity="0.8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2393_1490">
|
||||
<rect fill="white" height="16" width="16" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex animate-spin items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<LoaderIcon size={size} />
|
||||
</div>
|
||||
);
|
||||
446
demos/use_cases/vercel-ai-sdk/components/ai-elements/message.tsx
Normal file
446
demos/use_cases/vercel-ai-sdk/components/ai-elements/message.tsx
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
"use client";
|
||||
|
||||
import type { FileUIPart, UIMessage } from "ai";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
PaperclipIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
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";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
type 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 = (newBranch: number) => {
|
||||
setCurrentBranch(newBranch);
|
||||
onBranchChange?.(newBranch);
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const contextValue: MessageBranchContextType = {
|
||||
currentBranch,
|
||||
totalBranches: branches.length,
|
||||
goToPrevious,
|
||||
goToNext,
|
||||
branches,
|
||||
setBranches,
|
||||
};
|
||||
|
||||
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 = Array.isArray(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 = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const MessageBranchSelector = ({
|
||||
className,
|
||||
from,
|
||||
...props
|
||||
}: MessageBranchSelectorProps) => {
|
||||
const { totalBranches } = useMessageBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup
|
||||
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
|
||||
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,
|
||||
className,
|
||||
...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>;
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
||||
);
|
||||
|
||||
MessageResponse.displayName = "MessageResponse";
|
||||
|
||||
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
data: FileUIPart;
|
||||
className?: string;
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
export function MessageAttachment({
|
||||
data,
|
||||
className,
|
||||
onRemove,
|
||||
...props
|
||||
}: MessageAttachmentProps) {
|
||||
const filename = data.filename || "";
|
||||
const mediaType =
|
||||
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
|
||||
const isImage = mediaType === "image";
|
||||
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative size-24 overflow-hidden rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{isImage ? (
|
||||
<>
|
||||
{/* biome-ignore lint/performance/noImgElement: dynamic user-uploaded images */}
|
||||
<img
|
||||
alt={filename || "attachment"}
|
||||
className="size-full object-cover"
|
||||
height={100}
|
||||
src={data.url}
|
||||
width={100}
|
||||
/>
|
||||
{onRemove && (
|
||||
<Button
|
||||
aria-label="Remove attachment"
|
||||
className="absolute top-2 right-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<PaperclipIcon className="size-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{attachmentLabel}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onRemove && (
|
||||
<Button
|
||||
aria-label="Remove attachment"
|
||||
className="size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageAttachmentsProps = ComponentProps<"div">;
|
||||
|
||||
export function MessageAttachments({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageAttachmentsProps) {
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto flex w-fit flex-wrap items-start gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
import Image from "next/image";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
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";
|
||||
|
||||
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 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 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 = {
|
||||
className?: string;
|
||||
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"
|
||||
| (string & {});
|
||||
};
|
||||
|
||||
export const ModelSelectorLogo = ({
|
||||
provider,
|
||||
className,
|
||||
}: ModelSelectorLogoProps) => (
|
||||
<Image
|
||||
alt={`${provider} logo`}
|
||||
className={cn("size-3 dark:invert", className)}
|
||||
height={12}
|
||||
src={`https://models.dev/logos/${provider}.svg`}
|
||||
unoptimized
|
||||
width={12}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
|
||||
|
||||
export const ModelSelectorLogoGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoGroupProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"-space-x-1 flex shrink-0 items-center [&>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} />
|
||||
);
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { Handle, Position } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
|
@ -0,0 +1,365 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ExternalLinkIcon,
|
||||
MessageCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { type ComponentProps, createContext, useContext } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const providers = {
|
||||
github: {
|
||||
title: "Open in 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>
|
||||
),
|
||||
},
|
||||
scira: {
|
||||
title: "Open in 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>
|
||||
),
|
||||
},
|
||||
chatgpt: {
|
||||
title: "Open in 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>
|
||||
),
|
||||
},
|
||||
claude: {
|
||||
title: "Open in 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>
|
||||
),
|
||||
},
|
||||
t3: {
|
||||
title: "Open in T3 Chat",
|
||||
createUrl: (q: string) =>
|
||||
`https://t3.chat/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: <MessageCircleIcon />,
|
||||
},
|
||||
v0: {
|
||||
title: "Open in 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>
|
||||
),
|
||||
},
|
||||
cursor: {
|
||||
title: "Open in 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>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
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) => (
|
||||
<OpenInContext.Provider value={{ query }}>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { Panel as PanelPrimitive } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
142
demos/use_cases/vercel-ai-sdk/components/ai-elements/plan.tsx
Normal file
142
demos/use_cases/vercel-ai-sdk/components/ai-elements/plan.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronsUpDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
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 { Shimmer } from "./shimmer";
|
||||
|
||||
type 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) => (
|
||||
<PlanContext.Provider value={{ isStreaming }}>
|
||||
<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>
|
||||
);
|
||||
File diff suppressed because it is too large
Load diff
275
demos/use_cases/vercel-ai-sdk/components/ai-elements/queue.tsx
Normal file
275
demos/use_cases/vercel-ai-sdk/components/ai-elements/queue.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDownIcon, PaperclipIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
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";
|
||||
|
||||
export type QueueMessagePart = {
|
||||
type: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
filename?: string;
|
||||
mediaType?: string;
|
||||
};
|
||||
|
||||
export type QueueMessage = {
|
||||
id: string;
|
||||
parts: QueueMessagePart[];
|
||||
};
|
||||
|
||||
export type 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(
|
||||
"wrap-break-word line-clamp-1 grow",
|
||||
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) => (
|
||||
// biome-ignore lint/performance/noImgElement: dynamic blob/data URLs require native img
|
||||
<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("-mb-1 mt-2", 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="group-data-[state=closed]:-rotate-90 size-4 transition-transform" />
|
||||
{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}
|
||||
/>
|
||||
);
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
type 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 = 300;
|
||||
const MS_IN_S = 1000;
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen = true,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
const [duration, setDuration] = useControllableState({
|
||||
prop: durationProp,
|
||||
defaultProp: undefined,
|
||||
});
|
||||
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||
const [startTime, setStartTime] = useState<number | null>(null);
|
||||
|
||||
// Track duration when streaming starts and ends
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
if (startTime === null) {
|
||||
setStartTime(Date.now());
|
||||
}
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
|
||||
setStartTime(null);
|
||||
}
|
||||
}, [isStreaming, startTime, setDuration]);
|
||||
|
||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||
useEffect(() => {
|
||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||
// Add a small delay before closing to allow user to see the content
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosed(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setIsOpen(newOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider
|
||||
value={{ isStreaming, isOpen, setIsOpen, duration }}
|
||||
>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-2", 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 <span>Thought</span>;
|
||||
}
|
||||
return <span>{duration}s</span>;
|
||||
};
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({
|
||||
className,
|
||||
children,
|
||||
getThinkingMessage = defaultGetThinkingMessage,
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning();
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-3" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-2.5 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-1.5 text-[11px] leading-relaxed",
|
||||
"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}
|
||||
>
|
||||
<div className="max-h-48 overflow-y-auto rounded-md border border-border/50 bg-muted/30 p-2.5 text-[11px] **:text-[11px] [&_li]:my-0 [&_ol]:my-1 [&_p]:my-0 [&_ul]:my-1">
|
||||
<Streamdown>{children}</Streamdown>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
);
|
||||
|
||||
Reasoning.displayName = "Reasoning";
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger";
|
||||
ReasoningContent.displayName = "ReasoningContent";
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
"use client";
|
||||
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ElementType,
|
||||
type JSX,
|
||||
memo,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type 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 = motion.create(
|
||||
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={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent);
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
"use client";
|
||||
|
||||
import { BookIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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>
|
||||
);
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
"use client";
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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 = () => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDownIcon, SearchIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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>
|
||||
);
|
||||
163
demos/use_cases/vercel-ai-sdk/components/ai-elements/tool.tsx
Normal file
163
demos/use_cases/vercel-ai-sdk/components/ai-elements/tool.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"use client";
|
||||
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
CircleIcon,
|
||||
ClockIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { isValidElement } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CodeBlock } from "./code-block";
|
||||
|
||||
export type ToolProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Tool = ({ className, ...props }: ToolProps) => (
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4 w-full rounded-md border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolHeaderProps = {
|
||||
title?: string;
|
||||
type: ToolUIPart["type"];
|
||||
state: ToolUIPart["state"];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: ToolUIPart["state"]) => {
|
||||
const labels: Record<ToolUIPart["state"], string> = {
|
||||
"input-streaming": "Pending",
|
||||
"input-available": "Running",
|
||||
"approval-requested": "Awaiting Approval",
|
||||
"approval-responded": "Responded",
|
||||
"output-available": "Completed",
|
||||
"output-error": "Error",
|
||||
"output-denied": "Denied",
|
||||
};
|
||||
|
||||
const icons: Record<ToolUIPart["state"], ReactNode> = {
|
||||
"input-streaming": <CircleIcon className="size-4" />,
|
||||
"input-available": <ClockIcon className="size-4 animate-pulse" />,
|
||||
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
|
||||
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
|
||||
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
|
||||
"output-error": <XCircleIcon className="size-4 text-red-600" />,
|
||||
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge className="gap-1.5 rounded-full text-xs" variant="secondary">
|
||||
{icons[status]}
|
||||
{labels[status]}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolHeader = ({
|
||||
className,
|
||||
title,
|
||||
type,
|
||||
state,
|
||||
...props
|
||||
}: ToolHeaderProps) => (
|
||||
<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 ?? type.split("-").slice(1).join("-")}
|
||||
</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 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolInputProps = ComponentProps<"div"> & {
|
||||
input: ToolUIPart["input"];
|
||||
};
|
||||
|
||||
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
|
||||
<div className={cn("space-y-2 overflow-hidden p-4", 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: ToolUIPart["output"];
|
||||
errorText: ToolUIPart["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 p-4", 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { NodeToolbar, Position } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
|
|
@ -0,0 +1,263 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
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";
|
||||
|
||||
export type 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 = (newUrl: string) => {
|
||||
setUrl(newUrl);
|
||||
onUrlChange?.(newUrl);
|
||||
};
|
||||
|
||||
const contextValue: WebPreviewContextValue = {
|
||||
url,
|
||||
setUrl: handleUrlChange,
|
||||
consoleOpen,
|
||||
setConsoleOpen,
|
||||
};
|
||||
|
||||
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 [inputValue, setInputValue] = useState(url);
|
||||
|
||||
// Sync input value with context URL when it changes externally
|
||||
useEffect(() => {
|
||||
setInputValue(url);
|
||||
}, [url]);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
onChange?.(event);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
const target = event.target as HTMLInputElement;
|
||||
setUrl(target.value);
|
||||
}
|
||||
onKeyDown?.(event);
|
||||
};
|
||||
|
||||
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)}
|
||||
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?: Array<{
|
||||
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, index) => (
|
||||
<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()}-${index}`}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{log.timestamp.toLocaleTimeString()}
|
||||
</span>{" "}
|
||||
{log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
147
demos/use_cases/vercel-ai-sdk/components/app-sidebar.tsx
Normal file
147
demos/use_cases/vercel-ai-sdk/components/app-sidebar.tsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { User } from "next-auth";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { unstable_serialize } from "swr/infinite";
|
||||
import { PlusIcon, TrashIcon } from "@/components/icons";
|
||||
import {
|
||||
getChatHistoryPaginationKey,
|
||||
SidebarHistory,
|
||||
} from "@/components/sidebar-history";
|
||||
import { SidebarUserNav } from "@/components/sidebar-user-nav";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarMenu,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "./ui/alert-dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
export function AppSidebar({ user }: { user: User | undefined }) {
|
||||
const router = useRouter();
|
||||
const { setOpenMobile } = useSidebar();
|
||||
const { mutate } = useSWRConfig();
|
||||
const [showDeleteAllDialog, setShowDeleteAllDialog] = useState(false);
|
||||
|
||||
const handleDeleteAll = () => {
|
||||
const deletePromise = fetch("/api/history", {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
toast.promise(deletePromise, {
|
||||
loading: "Deleting all chats...",
|
||||
success: () => {
|
||||
mutate(unstable_serialize(getChatHistoryPaginationKey));
|
||||
setShowDeleteAllDialog(false);
|
||||
router.replace("/");
|
||||
router.refresh();
|
||||
return "All chats deleted successfully";
|
||||
},
|
||||
error: "Failed to delete all chats",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sidebar className="group-data-[side=left]:border-r-0">
|
||||
<SidebarHeader>
|
||||
<SidebarMenu>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<Link
|
||||
className="flex flex-row items-center gap-3"
|
||||
href="/"
|
||||
onClick={() => {
|
||||
setOpenMobile(false);
|
||||
}}
|
||||
>
|
||||
<span className="cursor-pointer rounded-md px-2 font-semibold text-lg hover:bg-muted">
|
||||
Chatbot
|
||||
</span>
|
||||
</Link>
|
||||
<div className="flex flex-row gap-1">
|
||||
{user && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="h-8 p-1 md:h-fit md:p-2"
|
||||
onClick={() => setShowDeleteAllDialog(true)}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="end" className="hidden md:block">
|
||||
Delete All Chats
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="h-8 p-1 md:h-fit md:p-2"
|
||||
onClick={() => {
|
||||
setOpenMobile(false);
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="end" className="hidden md:block">
|
||||
New Chat
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarHistory user={user} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>{user && <SidebarUserNav user={user} />}</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
<AlertDialog
|
||||
onOpenChange={setShowDeleteAllDialog}
|
||||
open={showDeleteAllDialog}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete all chats?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete all
|
||||
your chats and remove them from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteAll}>
|
||||
Delete All
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
107
demos/use_cases/vercel-ai-sdk/components/artifact-actions.tsx
Normal file
107
demos/use_cases/vercel-ai-sdk/components/artifact-actions.tsx
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { type Dispatch, memo, type SetStateAction, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { artifactDefinitions, type UIArtifact } from "./artifact";
|
||||
import type { ArtifactActionContext } from "./create-artifact";
|
||||
import { Button } from "./ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
type ArtifactActionsProps = {
|
||||
artifact: UIArtifact;
|
||||
handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void;
|
||||
currentVersionIndex: number;
|
||||
isCurrentVersion: boolean;
|
||||
mode: "edit" | "diff";
|
||||
metadata: any;
|
||||
setMetadata: Dispatch<SetStateAction<any>>;
|
||||
};
|
||||
|
||||
function PureArtifactActions({
|
||||
artifact,
|
||||
handleVersionChange,
|
||||
currentVersionIndex,
|
||||
isCurrentVersion,
|
||||
mode,
|
||||
metadata,
|
||||
setMetadata,
|
||||
}: ArtifactActionsProps) {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const artifactDefinition = artifactDefinitions.find(
|
||||
(definition) => definition.kind === artifact.kind
|
||||
);
|
||||
|
||||
if (!artifactDefinition) {
|
||||
throw new Error("Artifact definition not found!");
|
||||
}
|
||||
|
||||
const actionContext: ArtifactActionContext = {
|
||||
content: artifact.content,
|
||||
handleVersionChange,
|
||||
currentVersionIndex,
|
||||
isCurrentVersion,
|
||||
mode,
|
||||
metadata,
|
||||
setMetadata,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-1">
|
||||
{artifactDefinition.actions.map((action) => (
|
||||
<Tooltip key={action.description}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn("h-fit dark:hover:bg-zinc-700", {
|
||||
"p-2": !action.label,
|
||||
"px-2 py-1.5": action.label,
|
||||
})}
|
||||
disabled={
|
||||
isLoading || artifact.status === "streaming"
|
||||
? true
|
||||
: action.isDisabled
|
||||
? action.isDisabled(actionContext)
|
||||
: false
|
||||
}
|
||||
onClick={async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await Promise.resolve(action.onClick(actionContext));
|
||||
} catch (_error) {
|
||||
toast.error("Failed to execute action");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
{action.icon}
|
||||
{action.label}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{action.description}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ArtifactActions = memo(
|
||||
PureArtifactActions,
|
||||
(prevProps, nextProps) => {
|
||||
if (prevProps.artifact.status !== nextProps.artifact.status) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.artifact.content !== nextProps.artifact.content) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { memo } from "react";
|
||||
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
|
||||
import { CrossIcon } from "./icons";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
function PureArtifactCloseButton() {
|
||||
const { setArtifact } = useArtifact();
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="h-fit p-2 dark:hover:bg-zinc-700"
|
||||
data-testid="artifact-close-button"
|
||||
onClick={() => {
|
||||
setArtifact((currentArtifact) =>
|
||||
currentArtifact.status === "streaming"
|
||||
? {
|
||||
...currentArtifact,
|
||||
isVisible: false,
|
||||
}
|
||||
: { ...initialArtifactData, status: "idle" }
|
||||
);
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
<CrossIcon size={18} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export const ArtifactCloseButton = memo(PureArtifactCloseButton, () => true);
|
||||
115
demos/use_cases/vercel-ai-sdk/components/artifact-messages.tsx
Normal file
115
demos/use_cases/vercel-ai-sdk/components/artifact-messages.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import equal from "fast-deep-equal";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { memo } from "react";
|
||||
import { useMessages } from "@/hooks/use-messages";
|
||||
import type { Vote } from "@/lib/db/schema";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import type { UIArtifact } from "./artifact";
|
||||
import { PreviewMessage, ThinkingMessage } from "./message";
|
||||
|
||||
type ArtifactMessagesProps = {
|
||||
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
|
||||
chatId: string;
|
||||
status: UseChatHelpers<ChatMessage>["status"];
|
||||
votes: Vote[] | undefined;
|
||||
messages: ChatMessage[];
|
||||
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
|
||||
isReadonly: boolean;
|
||||
artifactStatus: UIArtifact["status"];
|
||||
};
|
||||
|
||||
function PureArtifactMessages({
|
||||
addToolApprovalResponse,
|
||||
chatId,
|
||||
status,
|
||||
votes,
|
||||
messages,
|
||||
setMessages,
|
||||
regenerate,
|
||||
isReadonly,
|
||||
}: ArtifactMessagesProps) {
|
||||
const {
|
||||
containerRef: messagesContainerRef,
|
||||
endRef: messagesEndRef,
|
||||
onViewportEnter,
|
||||
onViewportLeave,
|
||||
hasSentMessage,
|
||||
} = useMessages({
|
||||
status,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full flex-col items-center gap-4 overflow-y-scroll px-4 pt-20"
|
||||
ref={messagesContainerRef}
|
||||
>
|
||||
{messages.map((message, index) => (
|
||||
<PreviewMessage
|
||||
addToolApprovalResponse={addToolApprovalResponse}
|
||||
chatId={chatId}
|
||||
isLoading={status === "streaming" && index === messages.length - 1}
|
||||
isReadonly={isReadonly}
|
||||
key={message.id}
|
||||
message={message}
|
||||
regenerate={regenerate}
|
||||
requiresScrollPadding={
|
||||
hasSentMessage && index === messages.length - 1
|
||||
}
|
||||
setMessages={setMessages}
|
||||
vote={
|
||||
votes
|
||||
? votes.find((vote) => vote.messageId === message.id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{status === "submitted" &&
|
||||
!messages.some((msg) =>
|
||||
msg.parts?.some(
|
||||
(part) => "state" in part && part.state === "approval-responded"
|
||||
)
|
||||
) && <ThinkingMessage key="thinking" />}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.div
|
||||
className="min-h-[24px] min-w-[24px] shrink-0"
|
||||
onViewportEnter={onViewportEnter}
|
||||
onViewportLeave={onViewportLeave}
|
||||
ref={messagesEndRef}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function areEqual(
|
||||
prevProps: ArtifactMessagesProps,
|
||||
nextProps: ArtifactMessagesProps
|
||||
) {
|
||||
if (
|
||||
prevProps.artifactStatus === "streaming" &&
|
||||
nextProps.artifactStatus === "streaming"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (prevProps.status !== nextProps.status) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.status && nextProps.status) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.messages.length !== nextProps.messages.length) {
|
||||
return false;
|
||||
}
|
||||
if (!equal(prevProps.votes, nextProps.votes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export const ArtifactMessages = memo(PureArtifactMessages, areEqual);
|
||||
532
demos/use_cases/vercel-ai-sdk/components/artifact.tsx
Normal file
532
demos/use_cases/vercel-ai-sdk/components/artifact.tsx
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import { formatDistance } from "date-fns";
|
||||
import equal from "fast-deep-equal";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import {
|
||||
type Dispatch,
|
||||
memo,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { useDebounceCallback, useWindowSize } from "usehooks-ts";
|
||||
import { codeArtifact } from "@/artifacts/code/client";
|
||||
import { imageArtifact } from "@/artifacts/image/client";
|
||||
import { sheetArtifact } from "@/artifacts/sheet/client";
|
||||
import { textArtifact } from "@/artifacts/text/client";
|
||||
import { useArtifact } from "@/hooks/use-artifact";
|
||||
import type { Document, Vote } from "@/lib/db/schema";
|
||||
import type { Attachment, ChatMessage } from "@/lib/types";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { ArtifactActions } from "./artifact-actions";
|
||||
import { ArtifactCloseButton } from "./artifact-close-button";
|
||||
import { ArtifactMessages } from "./artifact-messages";
|
||||
import { MultimodalInput } from "./multimodal-input";
|
||||
import { Toolbar } from "./toolbar";
|
||||
import { useSidebar } from "./ui/sidebar";
|
||||
import { VersionFooter } from "./version-footer";
|
||||
import type { VisibilityType } from "./visibility-selector";
|
||||
|
||||
export const artifactDefinitions = [
|
||||
textArtifact,
|
||||
codeArtifact,
|
||||
imageArtifact,
|
||||
sheetArtifact,
|
||||
];
|
||||
export type ArtifactKind = (typeof artifactDefinitions)[number]["kind"];
|
||||
|
||||
export type UIArtifact = {
|
||||
title: string;
|
||||
documentId: string;
|
||||
kind: ArtifactKind;
|
||||
content: string;
|
||||
isVisible: boolean;
|
||||
status: "streaming" | "idle";
|
||||
boundingBox: {
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
};
|
||||
|
||||
function PureArtifact({
|
||||
addToolApprovalResponse,
|
||||
chatId,
|
||||
input,
|
||||
setInput,
|
||||
status,
|
||||
stop,
|
||||
attachments,
|
||||
setAttachments,
|
||||
sendMessage,
|
||||
messages,
|
||||
setMessages,
|
||||
regenerate,
|
||||
votes,
|
||||
isReadonly,
|
||||
selectedVisibilityType,
|
||||
selectedModelId,
|
||||
}: {
|
||||
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
|
||||
chatId: string;
|
||||
input: string;
|
||||
setInput: Dispatch<SetStateAction<string>>;
|
||||
status: UseChatHelpers<ChatMessage>["status"];
|
||||
stop: UseChatHelpers<ChatMessage>["stop"];
|
||||
attachments: Attachment[];
|
||||
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
|
||||
messages: ChatMessage[];
|
||||
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||
votes: Vote[] | undefined;
|
||||
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
|
||||
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
|
||||
isReadonly: boolean;
|
||||
selectedVisibilityType: VisibilityType;
|
||||
selectedModelId: string;
|
||||
}) {
|
||||
const { artifact, setArtifact, metadata, setMetadata } = useArtifact();
|
||||
|
||||
const {
|
||||
data: documents,
|
||||
isLoading: isDocumentsFetching,
|
||||
mutate: mutateDocuments,
|
||||
} = useSWR<Document[]>(
|
||||
artifact.documentId !== "init" && artifact.status !== "streaming"
|
||||
? `/api/document?id=${artifact.documentId}`
|
||||
: null,
|
||||
fetcher
|
||||
);
|
||||
|
||||
const [mode, setMode] = useState<"edit" | "diff">("edit");
|
||||
const [document, setDocument] = useState<Document | null>(null);
|
||||
const [currentVersionIndex, setCurrentVersionIndex] = useState(-1);
|
||||
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
|
||||
useEffect(() => {
|
||||
if (documents && documents.length > 0) {
|
||||
const mostRecentDocument = documents.at(-1);
|
||||
|
||||
if (mostRecentDocument) {
|
||||
setDocument(mostRecentDocument);
|
||||
setCurrentVersionIndex(documents.length - 1);
|
||||
setArtifact((currentArtifact) => ({
|
||||
...currentArtifact,
|
||||
content: mostRecentDocument.content ?? "",
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [documents, setArtifact]);
|
||||
|
||||
useEffect(() => {
|
||||
mutateDocuments();
|
||||
}, [mutateDocuments]);
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
const [isContentDirty, setIsContentDirty] = useState(false);
|
||||
|
||||
const handleContentChange = useCallback(
|
||||
(updatedContent: string) => {
|
||||
if (!artifact) {
|
||||
return;
|
||||
}
|
||||
|
||||
mutate<Document[]>(
|
||||
`/api/document?id=${artifact.documentId}`,
|
||||
async (currentDocuments) => {
|
||||
if (!currentDocuments) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const currentDocument = currentDocuments.at(-1);
|
||||
|
||||
if (!currentDocument || !currentDocument.content) {
|
||||
setIsContentDirty(false);
|
||||
return currentDocuments;
|
||||
}
|
||||
|
||||
if (currentDocument.content !== updatedContent) {
|
||||
await fetch(`/api/document?id=${artifact.documentId}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: artifact.title,
|
||||
content: updatedContent,
|
||||
kind: artifact.kind,
|
||||
}),
|
||||
});
|
||||
|
||||
setIsContentDirty(false);
|
||||
|
||||
const newDocument = {
|
||||
...currentDocument,
|
||||
content: updatedContent,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
return [...currentDocuments, newDocument];
|
||||
}
|
||||
return currentDocuments;
|
||||
},
|
||||
{ revalidate: false }
|
||||
);
|
||||
},
|
||||
[artifact, mutate]
|
||||
);
|
||||
|
||||
const debouncedHandleContentChange = useDebounceCallback(
|
||||
handleContentChange,
|
||||
2000
|
||||
);
|
||||
|
||||
const saveContent = useCallback(
|
||||
(updatedContent: string, debounce: boolean) => {
|
||||
if (document && updatedContent !== document.content) {
|
||||
setIsContentDirty(true);
|
||||
|
||||
if (debounce) {
|
||||
debouncedHandleContentChange(updatedContent);
|
||||
} else {
|
||||
handleContentChange(updatedContent);
|
||||
}
|
||||
}
|
||||
},
|
||||
[document, debouncedHandleContentChange, handleContentChange]
|
||||
);
|
||||
|
||||
function getDocumentContentById(index: number) {
|
||||
if (!documents) {
|
||||
return "";
|
||||
}
|
||||
if (!documents[index]) {
|
||||
return "";
|
||||
}
|
||||
return documents[index].content ?? "";
|
||||
}
|
||||
|
||||
const handleVersionChange = (type: "next" | "prev" | "toggle" | "latest") => {
|
||||
if (!documents) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type === "latest") {
|
||||
setCurrentVersionIndex(documents.length - 1);
|
||||
setMode("edit");
|
||||
}
|
||||
|
||||
if (type === "toggle") {
|
||||
setMode((currentMode) => (currentMode === "edit" ? "diff" : "edit"));
|
||||
}
|
||||
|
||||
if (type === "prev") {
|
||||
if (currentVersionIndex > 0) {
|
||||
setCurrentVersionIndex((index) => index - 1);
|
||||
}
|
||||
} else if (type === "next" && currentVersionIndex < documents.length - 1) {
|
||||
setCurrentVersionIndex((index) => index + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const [isToolbarVisible, setIsToolbarVisible] = useState(false);
|
||||
|
||||
/*
|
||||
* NOTE: if there are no documents, or if
|
||||
* the documents are being fetched, then
|
||||
* we mark it as the current version.
|
||||
*/
|
||||
|
||||
const isCurrentVersion =
|
||||
documents && documents.length > 0
|
||||
? currentVersionIndex === documents.length - 1
|
||||
: true;
|
||||
|
||||
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||
const isMobile = windowWidth ? windowWidth < 768 : false;
|
||||
|
||||
const artifactDefinition = artifactDefinitions.find(
|
||||
(definition) => definition.kind === artifact.kind
|
||||
);
|
||||
|
||||
if (!artifactDefinition) {
|
||||
throw new Error("Artifact definition not found!");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (artifact.documentId !== "init" && artifactDefinition.initialize) {
|
||||
artifactDefinition.initialize({
|
||||
documentId: artifact.documentId,
|
||||
setMetadata,
|
||||
});
|
||||
}
|
||||
}, [artifact.documentId, artifactDefinition, setMetadata]);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{artifact.isVisible && (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className="fixed top-0 left-0 z-50 flex h-dvh w-dvw flex-row bg-transparent"
|
||||
data-testid="artifact"
|
||||
exit={{ opacity: 0, transition: { delay: 0.4 } }}
|
||||
initial={{ opacity: 1 }}
|
||||
>
|
||||
{!isMobile && (
|
||||
<motion.div
|
||||
animate={{ width: windowWidth, right: 0 }}
|
||||
className="fixed h-dvh bg-background"
|
||||
exit={{
|
||||
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
|
||||
right: 0,
|
||||
}}
|
||||
initial={{
|
||||
width: isSidebarOpen ? windowWidth - 256 : windowWidth,
|
||||
right: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isMobile && (
|
||||
<motion.div
|
||||
animate={{
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
scale: 1,
|
||||
transition: {
|
||||
delay: 0.1,
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
},
|
||||
}}
|
||||
className="relative h-dvh w-[400px] shrink-0 bg-muted dark:bg-background"
|
||||
exit={{
|
||||
opacity: 0,
|
||||
x: 0,
|
||||
scale: 1,
|
||||
transition: { duration: 0 },
|
||||
}}
|
||||
initial={{ opacity: 0, x: 10, scale: 1 }}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{!isCurrentVersion && (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className="absolute top-0 left-0 z-50 h-dvh w-[400px] bg-zinc-900/50"
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div className="flex h-full flex-col items-center justify-between">
|
||||
<ArtifactMessages
|
||||
addToolApprovalResponse={addToolApprovalResponse}
|
||||
artifactStatus={artifact.status}
|
||||
chatId={chatId}
|
||||
isReadonly={isReadonly}
|
||||
messages={messages}
|
||||
regenerate={regenerate}
|
||||
setMessages={setMessages}
|
||||
status={status}
|
||||
votes={votes}
|
||||
/>
|
||||
|
||||
<div className="relative flex w-full flex-row items-end gap-2 px-4 pb-4">
|
||||
<MultimodalInput
|
||||
attachments={attachments}
|
||||
chatId={chatId}
|
||||
className="bg-background dark:bg-muted"
|
||||
input={input}
|
||||
messages={messages}
|
||||
selectedModelId={selectedModelId}
|
||||
selectedVisibilityType={selectedVisibilityType}
|
||||
sendMessage={sendMessage}
|
||||
setAttachments={setAttachments}
|
||||
setInput={setInput}
|
||||
setMessages={setMessages}
|
||||
status={status}
|
||||
stop={stop}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<motion.div
|
||||
animate={
|
||||
isMobile
|
||||
? {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
height: windowHeight,
|
||||
width: windowWidth ? windowWidth : "calc(100dvw)",
|
||||
borderRadius: 0,
|
||||
transition: {
|
||||
delay: 0,
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
duration: 0.8,
|
||||
},
|
||||
}
|
||||
: {
|
||||
opacity: 1,
|
||||
x: 400,
|
||||
y: 0,
|
||||
height: windowHeight,
|
||||
width: windowWidth
|
||||
? windowWidth - 400
|
||||
: "calc(100dvw-400px)",
|
||||
borderRadius: 0,
|
||||
transition: {
|
||||
delay: 0,
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
duration: 0.8,
|
||||
},
|
||||
}
|
||||
}
|
||||
className="fixed flex h-dvh flex-col overflow-y-scroll border-zinc-200 bg-background md:border-l dark:border-zinc-700 dark:bg-muted"
|
||||
exit={{
|
||||
opacity: 0,
|
||||
scale: 0.5,
|
||||
transition: {
|
||||
delay: 0.1,
|
||||
type: "spring",
|
||||
stiffness: 600,
|
||||
damping: 30,
|
||||
},
|
||||
}}
|
||||
initial={
|
||||
isMobile
|
||||
? {
|
||||
opacity: 1,
|
||||
x: artifact.boundingBox.left,
|
||||
y: artifact.boundingBox.top,
|
||||
height: artifact.boundingBox.height,
|
||||
width: artifact.boundingBox.width,
|
||||
borderRadius: 50,
|
||||
}
|
||||
: {
|
||||
opacity: 1,
|
||||
x: artifact.boundingBox.left,
|
||||
y: artifact.boundingBox.top,
|
||||
height: artifact.boundingBox.height,
|
||||
width: artifact.boundingBox.width,
|
||||
borderRadius: 50,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div className="flex flex-row items-start justify-between p-2">
|
||||
<div className="flex flex-row items-start gap-4">
|
||||
<ArtifactCloseButton />
|
||||
|
||||
<div className="flex flex-col">
|
||||
<div className="font-medium">{artifact.title}</div>
|
||||
|
||||
{isContentDirty ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Saving changes...
|
||||
</div>
|
||||
) : document ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{`Updated ${formatDistance(
|
||||
new Date(document.createdAt),
|
||||
new Date(),
|
||||
{
|
||||
addSuffix: true,
|
||||
}
|
||||
)}`}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 h-3 w-32 animate-pulse rounded-md bg-muted-foreground/20" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ArtifactActions
|
||||
artifact={artifact}
|
||||
currentVersionIndex={currentVersionIndex}
|
||||
handleVersionChange={handleVersionChange}
|
||||
isCurrentVersion={isCurrentVersion}
|
||||
metadata={metadata}
|
||||
mode={mode}
|
||||
setMetadata={setMetadata}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h-full max-w-full! items-center overflow-y-scroll bg-background dark:bg-muted">
|
||||
<artifactDefinition.content
|
||||
content={
|
||||
isCurrentVersion
|
||||
? artifact.content
|
||||
: getDocumentContentById(currentVersionIndex)
|
||||
}
|
||||
currentVersionIndex={currentVersionIndex}
|
||||
getDocumentContentById={getDocumentContentById}
|
||||
isCurrentVersion={isCurrentVersion}
|
||||
isInline={false}
|
||||
isLoading={isDocumentsFetching && !artifact.content}
|
||||
metadata={metadata}
|
||||
mode={mode}
|
||||
onSaveContent={saveContent}
|
||||
setMetadata={setMetadata}
|
||||
status={artifact.status}
|
||||
suggestions={[]}
|
||||
title={artifact.title}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{isCurrentVersion && (
|
||||
<Toolbar
|
||||
artifactKind={artifact.kind}
|
||||
isToolbarVisible={isToolbarVisible}
|
||||
sendMessage={sendMessage}
|
||||
setIsToolbarVisible={setIsToolbarVisible}
|
||||
setMessages={setMessages}
|
||||
status={status}
|
||||
stop={stop}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{!isCurrentVersion && (
|
||||
<VersionFooter
|
||||
currentVersionIndex={currentVersionIndex}
|
||||
documents={documents}
|
||||
handleVersionChange={handleVersionChange}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
export const Artifact = memo(PureArtifact, (prevProps, nextProps) => {
|
||||
if (prevProps.status !== nextProps.status) {
|
||||
return false;
|
||||
}
|
||||
if (!equal(prevProps.votes, nextProps.votes)) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.input !== nextProps.input) {
|
||||
return false;
|
||||
}
|
||||
if (!equal(prevProps.messages, nextProps.messages.length)) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
60
demos/use_cases/vercel-ai-sdk/components/auth-form.tsx
Normal file
60
demos/use_cases/vercel-ai-sdk/components/auth-form.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import Form from "next/form";
|
||||
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
export function AuthForm({
|
||||
action,
|
||||
children,
|
||||
defaultEmail = "",
|
||||
}: {
|
||||
action: NonNullable<
|
||||
string | ((formData: FormData) => void | Promise<void>) | undefined
|
||||
>;
|
||||
children: React.ReactNode;
|
||||
defaultEmail?: string;
|
||||
}) {
|
||||
return (
|
||||
<Form action={action} className="flex flex-col gap-4 px-4 sm:px-16">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
className="font-normal text-zinc-600 dark:text-zinc-400"
|
||||
htmlFor="email"
|
||||
>
|
||||
Email Address
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
autoComplete="email"
|
||||
autoFocus
|
||||
className="bg-muted text-md md:text-sm"
|
||||
defaultValue={defaultEmail}
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="user@acme.com"
|
||||
required
|
||||
type="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label
|
||||
className="font-normal text-zinc-600 dark:text-zinc-400"
|
||||
htmlFor="password"
|
||||
>
|
||||
Password
|
||||
</Label>
|
||||
|
||||
<Input
|
||||
className="bg-muted text-md md:text-sm"
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
type="password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
76
demos/use_cases/vercel-ai-sdk/components/chat-header.tsx
Normal file
76
demos/use_cases/vercel-ai-sdk/components/chat-header.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { memo } from "react";
|
||||
import { useWindowSize } from "usehooks-ts";
|
||||
import { SidebarToggle } from "@/components/sidebar-toggle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PlusIcon, VercelIcon } from "./icons";
|
||||
import { useSidebar } from "./ui/sidebar";
|
||||
import { VisibilitySelector, type VisibilityType } from "./visibility-selector";
|
||||
|
||||
function PureChatHeader({
|
||||
chatId,
|
||||
selectedVisibilityType,
|
||||
isReadonly,
|
||||
}: {
|
||||
chatId: string;
|
||||
selectedVisibilityType: VisibilityType;
|
||||
isReadonly: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { open } = useSidebar();
|
||||
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 flex items-center gap-2 bg-background px-2 py-1.5 md:px-2">
|
||||
<SidebarToggle />
|
||||
|
||||
{(!open || windowWidth < 768) && (
|
||||
<Button
|
||||
className="order-2 ml-auto h-8 px-2 md:order-1 md:ml-0 md:h-fit md:px-2"
|
||||
onClick={() => {
|
||||
router.push("/");
|
||||
router.refresh();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
<PlusIcon />
|
||||
<span className="md:sr-only">New Chat</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{!isReadonly && (
|
||||
<VisibilitySelector
|
||||
chatId={chatId}
|
||||
className="order-1 md:order-2"
|
||||
selectedVisibilityType={selectedVisibilityType}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
asChild
|
||||
className="order-3 hidden bg-zinc-900 px-2 text-zinc-50 hover:bg-zinc-800 md:ml-auto md:flex md:h-fit dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
|
||||
>
|
||||
<Link
|
||||
href={"https://vercel.com/templates/next.js/nextjs-ai-chatbot"}
|
||||
rel="noreferrer"
|
||||
target="_noblank"
|
||||
>
|
||||
<VercelIcon size={16} />
|
||||
Deploy with Vercel
|
||||
</Link>
|
||||
</Button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export const ChatHeader = memo(PureChatHeader, (prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.chatId === nextProps.chatId &&
|
||||
prevProps.selectedVisibilityType === nextProps.selectedVisibilityType &&
|
||||
prevProps.isReadonly === nextProps.isReadonly
|
||||
);
|
||||
});
|
||||
291
demos/use_cases/vercel-ai-sdk/components/chat.tsx
Normal file
291
demos/use_cases/vercel-ai-sdk/components/chat.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
"use client";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import useSWR, { useSWRConfig } from "swr";
|
||||
import { unstable_serialize } from "swr/infinite";
|
||||
import { ChatHeader } from "@/components/chat-header";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useArtifactSelector } from "@/hooks/use-artifact";
|
||||
import { useAutoResume } from "@/hooks/use-auto-resume";
|
||||
import { useChatVisibility } from "@/hooks/use-chat-visibility";
|
||||
import type { Vote } from "@/lib/db/schema";
|
||||
import { ChatSDKError } from "@/lib/errors";
|
||||
import type { Attachment, ChatMessage } from "@/lib/types";
|
||||
import { fetcher, fetchWithErrorHandlers, generateUUID } from "@/lib/utils";
|
||||
import { Artifact } from "./artifact";
|
||||
import { useDataStream } from "./data-stream-provider";
|
||||
import { Messages } from "./messages";
|
||||
import { MultimodalInput } from "./multimodal-input";
|
||||
import { getChatHistoryPaginationKey } from "./sidebar-history";
|
||||
import { toast } from "./toast";
|
||||
import type { VisibilityType } from "./visibility-selector";
|
||||
|
||||
export function Chat({
|
||||
id,
|
||||
initialMessages,
|
||||
initialChatModel,
|
||||
initialVisibilityType,
|
||||
isReadonly,
|
||||
autoResume,
|
||||
}: {
|
||||
id: string;
|
||||
initialMessages: ChatMessage[];
|
||||
initialChatModel: string;
|
||||
initialVisibilityType: VisibilityType;
|
||||
isReadonly: boolean;
|
||||
autoResume: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
const { visibilityType } = useChatVisibility({
|
||||
chatId: id,
|
||||
initialVisibilityType,
|
||||
});
|
||||
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
// Handle browser back/forward navigation
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
// When user navigates back/forward, refresh to sync with URL
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
}, [router]);
|
||||
const { setDataStream } = useDataStream();
|
||||
|
||||
const [input, setInput] = useState<string>("");
|
||||
const [showCreditCardAlert, setShowCreditCardAlert] = useState(false);
|
||||
const [currentModelId, setCurrentModelId] = useState(initialChatModel);
|
||||
const currentModelIdRef = useRef(currentModelId);
|
||||
|
||||
useEffect(() => {
|
||||
currentModelIdRef.current = currentModelId;
|
||||
}, [currentModelId]);
|
||||
|
||||
const {
|
||||
messages,
|
||||
setMessages,
|
||||
sendMessage,
|
||||
status,
|
||||
stop,
|
||||
regenerate,
|
||||
resumeStream,
|
||||
addToolApprovalResponse,
|
||||
} = useChat<ChatMessage>({
|
||||
id,
|
||||
messages: initialMessages,
|
||||
experimental_throttle: 100,
|
||||
generateId: generateUUID,
|
||||
// Auto-continue after tool approval (only for APPROVED tools)
|
||||
// Denied tools don't need server continuation - state is saved on next user message
|
||||
sendAutomaticallyWhen: ({ messages: currentMessages }) => {
|
||||
const lastMessage = currentMessages.at(-1);
|
||||
// Only continue if a tool was APPROVED (not denied)
|
||||
const shouldContinue =
|
||||
lastMessage?.parts?.some(
|
||||
(part) =>
|
||||
"state" in part &&
|
||||
part.state === "approval-responded" &&
|
||||
"approval" in part &&
|
||||
(part.approval as { approved?: boolean })?.approved === true
|
||||
) ?? false;
|
||||
return shouldContinue;
|
||||
},
|
||||
transport: new DefaultChatTransport({
|
||||
api: "/api/chat",
|
||||
fetch: fetchWithErrorHandlers,
|
||||
prepareSendMessagesRequest(request) {
|
||||
const lastMessage = request.messages.at(-1);
|
||||
|
||||
// Check if this is a tool approval continuation:
|
||||
// - Last message is NOT a user message (meaning no new user input)
|
||||
// - OR any message has tool parts that were responded to (approved or denied)
|
||||
const isToolApprovalContinuation =
|
||||
lastMessage?.role !== "user" ||
|
||||
request.messages.some((msg) =>
|
||||
msg.parts?.some((part) => {
|
||||
const state = (part as { state?: string }).state;
|
||||
return (
|
||||
state === "approval-responded" || state === "output-denied"
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
body: {
|
||||
id: request.id,
|
||||
// Send all messages for tool approval continuation, otherwise just the last user message
|
||||
...(isToolApprovalContinuation
|
||||
? { messages: request.messages }
|
||||
: { message: lastMessage }),
|
||||
selectedChatModel: currentModelIdRef.current,
|
||||
selectedVisibilityType: visibilityType,
|
||||
...request.body,
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
onData: (dataPart) => {
|
||||
setDataStream((ds) => (ds ? [...ds, dataPart] : []));
|
||||
},
|
||||
onFinish: () => {
|
||||
mutate(unstable_serialize(getChatHistoryPaginationKey));
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error instanceof ChatSDKError) {
|
||||
// Check if it's a credit card error
|
||||
if (
|
||||
error.message?.includes("AI Gateway requires a valid credit card")
|
||||
) {
|
||||
setShowCreditCardAlert(true);
|
||||
} else {
|
||||
toast({
|
||||
type: "error",
|
||||
description: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const query = searchParams.get("query");
|
||||
|
||||
const [hasAppendedQuery, setHasAppendedQuery] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (query && !hasAppendedQuery) {
|
||||
sendMessage({
|
||||
role: "user" as const,
|
||||
parts: [{ type: "text", text: query }],
|
||||
});
|
||||
|
||||
setHasAppendedQuery(true);
|
||||
window.history.replaceState({}, "", `/chat/${id}`);
|
||||
}
|
||||
}, [query, sendMessage, hasAppendedQuery, id]);
|
||||
|
||||
const { data: votes } = useSWR<Vote[]>(
|
||||
messages.length >= 2 ? `/api/vote?chatId=${id}` : null,
|
||||
fetcher
|
||||
);
|
||||
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const isArtifactVisible = useArtifactSelector((state) => state.isVisible);
|
||||
|
||||
useAutoResume({
|
||||
autoResume,
|
||||
initialMessages,
|
||||
resumeStream,
|
||||
setMessages,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="overscroll-behavior-contain flex h-dvh min-w-0 touch-pan-y flex-col bg-background">
|
||||
<ChatHeader
|
||||
chatId={id}
|
||||
isReadonly={isReadonly}
|
||||
selectedVisibilityType={initialVisibilityType}
|
||||
/>
|
||||
|
||||
<Messages
|
||||
addToolApprovalResponse={addToolApprovalResponse}
|
||||
chatId={id}
|
||||
isArtifactVisible={isArtifactVisible}
|
||||
isReadonly={isReadonly}
|
||||
messages={messages}
|
||||
regenerate={regenerate}
|
||||
selectedModelId={initialChatModel}
|
||||
setMessages={setMessages}
|
||||
status={status}
|
||||
votes={votes}
|
||||
/>
|
||||
|
||||
<div className="sticky bottom-0 z-1 mx-auto flex w-full max-w-4xl gap-2 border-t-0 bg-background px-2 pb-3 md:px-4 md:pb-4">
|
||||
{!isReadonly && (
|
||||
<MultimodalInput
|
||||
attachments={attachments}
|
||||
chatId={id}
|
||||
input={input}
|
||||
messages={messages}
|
||||
onModelChange={setCurrentModelId}
|
||||
selectedModelId={currentModelId}
|
||||
selectedVisibilityType={visibilityType}
|
||||
sendMessage={sendMessage}
|
||||
setAttachments={setAttachments}
|
||||
setInput={setInput}
|
||||
setMessages={setMessages}
|
||||
status={status}
|
||||
stop={stop}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Artifact
|
||||
addToolApprovalResponse={addToolApprovalResponse}
|
||||
attachments={attachments}
|
||||
chatId={id}
|
||||
input={input}
|
||||
isReadonly={isReadonly}
|
||||
messages={messages}
|
||||
regenerate={regenerate}
|
||||
selectedModelId={currentModelId}
|
||||
selectedVisibilityType={visibilityType}
|
||||
sendMessage={sendMessage}
|
||||
setAttachments={setAttachments}
|
||||
setInput={setInput}
|
||||
setMessages={setMessages}
|
||||
status={status}
|
||||
stop={stop}
|
||||
votes={votes}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
onOpenChange={setShowCreditCardAlert}
|
||||
open={showCreditCardAlert}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Activate AI Gateway</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This application requires{" "}
|
||||
{process.env.NODE_ENV === "production" ? "the owner" : "you"} to
|
||||
activate Vercel AI Gateway.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
window.open(
|
||||
"https://vercel.com/d?to=%2F%5Bteam%5D%2F%7E%2Fai%3Fmodal%3Dadd-credit-card",
|
||||
"_blank"
|
||||
);
|
||||
window.location.href = "/";
|
||||
}}
|
||||
>
|
||||
Activate
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
121
demos/use_cases/vercel-ai-sdk/components/code-editor.tsx
Normal file
121
demos/use_cases/vercel-ai-sdk/components/code-editor.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
"use client";
|
||||
|
||||
import { python } from "@codemirror/lang-python";
|
||||
import { EditorState, Transaction } from "@codemirror/state";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { basicSetup } from "codemirror";
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
import type { Suggestion } from "@/lib/db/schema";
|
||||
|
||||
type EditorProps = {
|
||||
content: string;
|
||||
onSaveContent: (updatedContent: string, debounce: boolean) => void;
|
||||
status: "streaming" | "idle";
|
||||
isCurrentVersion: boolean;
|
||||
currentVersionIndex: number;
|
||||
suggestions: Suggestion[];
|
||||
};
|
||||
|
||||
function PureCodeEditor({ content, onSaveContent, status }: EditorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current && !editorRef.current) {
|
||||
const startState = EditorState.create({
|
||||
doc: content,
|
||||
extensions: [basicSetup, python(), oneDark],
|
||||
});
|
||||
|
||||
editorRef.current = new EditorView({
|
||||
state: startState,
|
||||
parent: containerRef.current,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.destroy();
|
||||
editorRef.current = null;
|
||||
}
|
||||
};
|
||||
// NOTE: we only want to run this effect once
|
||||
// eslint-disable-next-line
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current) {
|
||||
const updateListener = EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
const transaction = update.transactions.find(
|
||||
(tr) => !tr.annotation(Transaction.remote)
|
||||
);
|
||||
|
||||
if (transaction) {
|
||||
const newContent = update.state.doc.toString();
|
||||
onSaveContent(newContent, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const currentSelection = editorRef.current.state.selection;
|
||||
|
||||
const newState = EditorState.create({
|
||||
doc: editorRef.current.state.doc,
|
||||
extensions: [basicSetup, python(), oneDark, updateListener],
|
||||
selection: currentSelection,
|
||||
});
|
||||
|
||||
editorRef.current.setState(newState);
|
||||
}
|
||||
}, [onSaveContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && content) {
|
||||
const currentContent = editorRef.current.state.doc.toString();
|
||||
|
||||
if (status === "streaming" || currentContent !== content) {
|
||||
const transaction = editorRef.current.state.update({
|
||||
changes: {
|
||||
from: 0,
|
||||
to: currentContent.length,
|
||||
insert: content,
|
||||
},
|
||||
annotations: [Transaction.remote.of(true)],
|
||||
});
|
||||
|
||||
editorRef.current.dispatch(transaction);
|
||||
}
|
||||
}
|
||||
}, [content, status]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="not-prose relative w-full pb-[calc(80dvh)] text-sm"
|
||||
ref={containerRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function areEqual(prevProps: EditorProps, nextProps: EditorProps) {
|
||||
if (prevProps.suggestions !== nextProps.suggestions) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.currentVersionIndex !== nextProps.currentVersionIndex) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.isCurrentVersion !== nextProps.isCurrentVersion) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.status === "streaming" && nextProps.status === "streaming") {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.content !== nextProps.content) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export const CodeEditor = memo(PureCodeEditor, areEqual);
|
||||
193
demos/use_cases/vercel-ai-sdk/components/console.tsx
Normal file
193
demos/use_cases/vercel-ai-sdk/components/console.tsx
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
import {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useArtifactSelector } from "@/hooks/use-artifact";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loader } from "./elements/loader";
|
||||
import { CrossSmallIcon, TerminalWindowIcon } from "./icons";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export type ConsoleOutputContent = {
|
||||
type: "text" | "image";
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type ConsoleOutput = {
|
||||
id: string;
|
||||
status: "in_progress" | "loading_packages" | "completed" | "failed";
|
||||
contents: ConsoleOutputContent[];
|
||||
};
|
||||
|
||||
type ConsoleProps = {
|
||||
consoleOutputs: ConsoleOutput[];
|
||||
setConsoleOutputs: Dispatch<SetStateAction<ConsoleOutput[]>>;
|
||||
};
|
||||
|
||||
export function Console({ consoleOutputs, setConsoleOutputs }: ConsoleProps) {
|
||||
const [height, setHeight] = useState<number>(300);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const consoleEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isArtifactVisible = useArtifactSelector((state) => state.isVisible);
|
||||
|
||||
const minHeight = 100;
|
||||
const maxHeight = 800;
|
||||
|
||||
const startResizing = useCallback(() => {
|
||||
setIsResizing(true);
|
||||
}, []);
|
||||
|
||||
const stopResizing = useCallback(() => {
|
||||
setIsResizing(false);
|
||||
}, []);
|
||||
|
||||
const resize = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (isResizing) {
|
||||
const newHeight = window.innerHeight - e.clientY;
|
||||
if (newHeight >= minHeight && newHeight <= maxHeight) {
|
||||
setHeight(newHeight);
|
||||
}
|
||||
}
|
||||
},
|
||||
[isResizing]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("mousemove", resize);
|
||||
window.addEventListener("mouseup", stopResizing);
|
||||
return () => {
|
||||
window.removeEventListener("mousemove", resize);
|
||||
window.removeEventListener("mouseup", stopResizing);
|
||||
};
|
||||
}, [resize, stopResizing]);
|
||||
|
||||
useEffect(() => {
|
||||
consoleEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isArtifactVisible) {
|
||||
setConsoleOutputs([]);
|
||||
}
|
||||
}, [isArtifactVisible, setConsoleOutputs]);
|
||||
|
||||
return consoleOutputs.length > 0 ? (
|
||||
<>
|
||||
<div
|
||||
aria-label="Resize console"
|
||||
aria-orientation="horizontal"
|
||||
aria-valuemax={maxHeight}
|
||||
aria-valuemin={minHeight}
|
||||
aria-valuenow={height}
|
||||
className="fixed z-50 h-2 w-full cursor-ns-resize"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowUp") {
|
||||
setHeight((prev) => Math.min(prev + 10, maxHeight));
|
||||
} else if (e.key === "ArrowDown") {
|
||||
setHeight((prev) => Math.max(prev - 10, minHeight));
|
||||
}
|
||||
}}
|
||||
onMouseDown={startResizing}
|
||||
role="slider"
|
||||
style={{ bottom: height - 4 }}
|
||||
tabIndex={0}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"fixed bottom-0 z-40 flex w-full flex-col overflow-x-hidden overflow-y-scroll border-zinc-200 border-t bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-900",
|
||||
{
|
||||
"select-none": isResizing,
|
||||
}
|
||||
)}
|
||||
style={{ height }}
|
||||
>
|
||||
<div className="sticky top-0 z-50 flex h-fit w-full flex-row items-center justify-between border-zinc-200 border-b bg-muted px-2 py-1 dark:border-zinc-700">
|
||||
<div className="flex flex-row items-center gap-3 pl-2 text-sm text-zinc-800 dark:text-zinc-50">
|
||||
<div className="text-muted-foreground">
|
||||
<TerminalWindowIcon />
|
||||
</div>
|
||||
<div>Console</div>
|
||||
</div>
|
||||
<Button
|
||||
className="size-fit p-1 hover:bg-zinc-200 dark:hover:bg-zinc-700"
|
||||
onClick={() => setConsoleOutputs([])}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
>
|
||||
<CrossSmallIcon />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{consoleOutputs.map((consoleOutput, index) => (
|
||||
<div
|
||||
className="flex flex-row border-zinc-200 border-b bg-zinc-50 px-4 py-2 font-mono text-sm dark:border-zinc-700 dark:bg-zinc-900"
|
||||
key={consoleOutput.id}
|
||||
>
|
||||
<div
|
||||
className={cn("w-12 shrink-0", {
|
||||
"text-muted-foreground": [
|
||||
"in_progress",
|
||||
"loading_packages",
|
||||
].includes(consoleOutput.status),
|
||||
"text-emerald-500": consoleOutput.status === "completed",
|
||||
"text-red-400": consoleOutput.status === "failed",
|
||||
})}
|
||||
>
|
||||
[{index + 1}]
|
||||
</div>
|
||||
{["in_progress", "loading_packages"].includes(
|
||||
consoleOutput.status
|
||||
) ? (
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="mt-0.5 mb-auto size-fit self-center">
|
||||
<Loader size={16} />
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{consoleOutput.status === "in_progress"
|
||||
? "Initializing..."
|
||||
: consoleOutput.status === "loading_packages"
|
||||
? consoleOutput.contents.map((content) =>
|
||||
content.type === "text" ? content.value : null
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-2 overflow-x-scroll text-zinc-900 dark:text-zinc-50">
|
||||
{consoleOutput.contents.map((content, contentIndex) =>
|
||||
content.type === "image" ? (
|
||||
<picture key={`${consoleOutput.id}-${contentIndex}`}>
|
||||
{/** biome-ignore lint/nursery/useImageSize: "Generated image without explicit size" */}
|
||||
<img
|
||||
alt="output"
|
||||
className="w-full max-w-(--breakpoint-toast-mobile) rounded-md"
|
||||
src={content.value}
|
||||
/>
|
||||
</picture>
|
||||
) : (
|
||||
<div
|
||||
className="w-full whitespace-pre-line break-words"
|
||||
key={`${consoleOutput.id}-${contentIndex}`}
|
||||
>
|
||||
{content.value}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div ref={consoleEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null;
|
||||
}
|
||||
93
demos/use_cases/vercel-ai-sdk/components/create-artifact.tsx
Normal file
93
demos/use_cases/vercel-ai-sdk/components/create-artifact.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import type { DataUIPart } from "ai";
|
||||
import type { ComponentType, Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import type { Suggestion } from "@/lib/db/schema";
|
||||
import type { ChatMessage, CustomUIDataTypes } from "@/lib/types";
|
||||
import type { UIArtifact } from "./artifact";
|
||||
|
||||
export type ArtifactActionContext<M = any> = {
|
||||
content: string;
|
||||
handleVersionChange: (type: "next" | "prev" | "toggle" | "latest") => void;
|
||||
currentVersionIndex: number;
|
||||
isCurrentVersion: boolean;
|
||||
mode: "edit" | "diff";
|
||||
metadata: M;
|
||||
setMetadata: Dispatch<SetStateAction<M>>;
|
||||
};
|
||||
|
||||
type ArtifactAction<M = any> = {
|
||||
icon: ReactNode;
|
||||
label?: string;
|
||||
description: string;
|
||||
onClick: (context: ArtifactActionContext<M>) => Promise<void> | void;
|
||||
isDisabled?: (context: ArtifactActionContext<M>) => boolean;
|
||||
};
|
||||
|
||||
export type ArtifactToolbarContext = {
|
||||
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
|
||||
};
|
||||
|
||||
export type ArtifactToolbarItem = {
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
onClick: (context: ArtifactToolbarContext) => void;
|
||||
};
|
||||
|
||||
type ArtifactContent<M = any> = {
|
||||
title: string;
|
||||
content: string;
|
||||
mode: "edit" | "diff";
|
||||
isCurrentVersion: boolean;
|
||||
currentVersionIndex: number;
|
||||
status: "streaming" | "idle";
|
||||
suggestions: Suggestion[];
|
||||
onSaveContent: (updatedContent: string, debounce: boolean) => void;
|
||||
isInline: boolean;
|
||||
getDocumentContentById: (index: number) => string;
|
||||
isLoading: boolean;
|
||||
metadata: M;
|
||||
setMetadata: Dispatch<SetStateAction<M>>;
|
||||
};
|
||||
|
||||
type InitializeParameters<M = any> = {
|
||||
documentId: string;
|
||||
setMetadata: Dispatch<SetStateAction<M>>;
|
||||
};
|
||||
|
||||
type ArtifactConfig<T extends string, M = any> = {
|
||||
kind: T;
|
||||
description: string;
|
||||
content: ComponentType<ArtifactContent<M>>;
|
||||
actions: ArtifactAction<M>[];
|
||||
toolbar: ArtifactToolbarItem[];
|
||||
initialize?: (parameters: InitializeParameters<M>) => void;
|
||||
onStreamPart: (args: {
|
||||
setMetadata: Dispatch<SetStateAction<M>>;
|
||||
setArtifact: Dispatch<SetStateAction<UIArtifact>>;
|
||||
streamPart: DataUIPart<CustomUIDataTypes>;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export class Artifact<T extends string, M = any> {
|
||||
readonly kind: T;
|
||||
readonly description: string;
|
||||
readonly content: ComponentType<ArtifactContent<M>>;
|
||||
readonly actions: ArtifactAction<M>[];
|
||||
readonly toolbar: ArtifactToolbarItem[];
|
||||
readonly initialize?: (parameters: InitializeParameters) => void;
|
||||
readonly onStreamPart: (args: {
|
||||
setMetadata: Dispatch<SetStateAction<M>>;
|
||||
setArtifact: Dispatch<SetStateAction<UIArtifact>>;
|
||||
streamPart: DataUIPart<CustomUIDataTypes>;
|
||||
}) => void;
|
||||
|
||||
constructor(config: ArtifactConfig<T, M>) {
|
||||
this.kind = config.kind;
|
||||
this.description = config.description;
|
||||
this.content = config.content;
|
||||
this.actions = config.actions || [];
|
||||
this.toolbar = config.toolbar || [];
|
||||
this.initialize = config.initialize || (async () => ({}));
|
||||
this.onStreamPart = config.onStreamPart;
|
||||
}
|
||||
}
|
||||
191
demos/use_cases/vercel-ai-sdk/components/currency-exchange.tsx
Normal file
191
demos/use_cases/vercel-ai-sdk/components/currency-exchange.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
"use client";
|
||||
|
||||
import { format } from "date-fns";
|
||||
import { ArrowRight, TrendingUp } from "lucide-react";
|
||||
|
||||
const CurrencyIcon = ({ currency }: { currency: string }) => {
|
||||
const symbols: Record<string, string> = {
|
||||
USD: "$",
|
||||
EUR: "€",
|
||||
GBP: "£",
|
||||
JPY: "¥",
|
||||
CHF: "₣",
|
||||
CAD: "$",
|
||||
AUD: "$",
|
||||
CNY: "¥",
|
||||
INR: "₹",
|
||||
BRL: "R$",
|
||||
KRW: "₩",
|
||||
MXN: "$",
|
||||
RUB: "₽",
|
||||
ZAR: "R",
|
||||
TRY: "₺",
|
||||
SEK: "kr",
|
||||
NOK: "kr",
|
||||
DKK: "kr",
|
||||
PLN: "zł",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex size-10 items-center justify-center rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 font-bold text-lg text-white shadow-md">
|
||||
{symbols[currency] || currency.substring(0, 1)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type CurrencyExchangeData = {
|
||||
base: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
rates: Record<string, number>;
|
||||
convertedRates: Record<string, number>;
|
||||
};
|
||||
|
||||
const SAMPLE: CurrencyExchangeData = {
|
||||
base: "USD",
|
||||
date: "2026-01-08",
|
||||
amount: 100,
|
||||
rates: {
|
||||
EUR: 0.8565,
|
||||
GBP: 0.7874,
|
||||
JPY: 111.5,
|
||||
CHF: 0.8542,
|
||||
CAD: 1.3456,
|
||||
},
|
||||
convertedRates: {
|
||||
EUR: 85.65,
|
||||
GBP: 78.74,
|
||||
JPY: 11150,
|
||||
CHF: 85.42,
|
||||
CAD: 134.56,
|
||||
},
|
||||
};
|
||||
|
||||
function formatCurrencyValue(value: number): string {
|
||||
if (value >= 1000) {
|
||||
return value.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
export function CurrencyExchange({
|
||||
exchangeData = SAMPLE,
|
||||
}: {
|
||||
exchangeData?: CurrencyExchangeData;
|
||||
}) {
|
||||
const currencies = Object.keys(exchangeData.rates).slice(0, 6);
|
||||
const isSingleConversion = currencies.length === 1;
|
||||
|
||||
return (
|
||||
<div className="relative flex w-full flex-col gap-3 overflow-hidden rounded-2xl bg-gradient-to-br from-emerald-50 via-teal-50 to-cyan-50 p-4 shadow-lg dark:from-emerald-950/40 dark:via-teal-950/40 dark:to-cyan-950/40">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-500/5 to-teal-500/5" />
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="size-4 text-emerald-600 dark:text-emerald-400" />
|
||||
<div className="font-semibold text-emerald-900 text-sm dark:text-emerald-100">
|
||||
Currency Exchange
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-emerald-700 text-xs dark:text-emerald-300">
|
||||
{format(new Date(exchangeData.date), "MMM d, yyyy")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSingleConversion ? (
|
||||
<div className="rounded-xl bg-white/80 p-4 shadow-sm backdrop-blur-sm dark:bg-gray-900/80">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<CurrencyIcon currency={exchangeData.base} />
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 text-sm dark:text-gray-100">
|
||||
{exchangeData.base}
|
||||
</div>
|
||||
<div className="font-light text-2xl text-gray-900 dark:text-gray-100">
|
||||
{formatCurrencyValue(exchangeData.amount)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="size-5 text-emerald-600 dark:text-emerald-400" />
|
||||
<div className="flex items-center gap-3">
|
||||
<CurrencyIcon currency={currencies[0]} />
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-gray-900 text-sm dark:text-gray-100">
|
||||
{currencies[0]}
|
||||
</div>
|
||||
<div className="font-light text-2xl text-emerald-600 dark:text-emerald-400">
|
||||
{formatCurrencyValue(
|
||||
exchangeData.convertedRates[currencies[0]]
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t pt-3 text-center text-gray-600 text-xs dark:text-gray-400">
|
||||
Exchange Rate: 1 {exchangeData.base} ={" "}
|
||||
{exchangeData.rates[currencies[0]].toFixed(4)} {currencies[0]}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<CurrencyIcon currency={exchangeData.base} />
|
||||
<div>
|
||||
<div className="font-medium text-gray-700 text-xs dark:text-gray-300">
|
||||
Base Currency
|
||||
</div>
|
||||
<div className="font-light text-2xl text-gray-900 dark:text-gray-100">
|
||||
{formatCurrencyValue(exchangeData.amount)}{" "}
|
||||
<span className="font-semibold text-lg">
|
||||
{exchangeData.base}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-white/80 p-3 backdrop-blur-sm dark:bg-gray-900/80">
|
||||
<div className="mb-2 font-medium text-gray-700 text-xs dark:text-gray-300">
|
||||
Exchange Rates
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{currencies.map((currency) => (
|
||||
<div
|
||||
className="flex items-center justify-between rounded-lg bg-gradient-to-r from-emerald-50 to-teal-50 p-2.5 dark:from-emerald-950/50 dark:to-teal-950/50"
|
||||
key={currency}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex size-7 items-center justify-center rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 font-semibold text-white text-xs">
|
||||
{currency.substring(0, 1)}
|
||||
</div>
|
||||
<span className="font-medium text-gray-900 text-sm dark:text-gray-100">
|
||||
{currency}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-emerald-600 text-sm dark:text-emerald-400">
|
||||
{formatCurrencyValue(
|
||||
exchangeData.convertedRates[currency]
|
||||
)}
|
||||
</div>
|
||||
<div className="text-gray-500 text-xs dark:text-gray-400">
|
||||
{exchangeData.rates[currency].toFixed(4)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mt-2 text-center text-emerald-700 text-xs dark:text-emerald-300">
|
||||
Powered by Frankfurter API
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { unstable_serialize } from "swr/infinite";
|
||||
import { initialArtifactData, useArtifact } from "@/hooks/use-artifact";
|
||||
import { artifactDefinitions } from "./artifact";
|
||||
import { useDataStream } from "./data-stream-provider";
|
||||
import { getChatHistoryPaginationKey } from "./sidebar-history";
|
||||
|
||||
export function DataStreamHandler() {
|
||||
const { dataStream, setDataStream } = useDataStream();
|
||||
const { mutate } = useSWRConfig();
|
||||
|
||||
const { artifact, setArtifact, setMetadata } = useArtifact();
|
||||
|
||||
useEffect(() => {
|
||||
if (!dataStream?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newDeltas = dataStream.slice();
|
||||
setDataStream([]);
|
||||
|
||||
for (const delta of newDeltas) {
|
||||
// Handle chat title updates
|
||||
if (delta.type === "data-chat-title") {
|
||||
mutate(unstable_serialize(getChatHistoryPaginationKey));
|
||||
continue;
|
||||
}
|
||||
const artifactDefinition = artifactDefinitions.find(
|
||||
(currentArtifactDefinition) =>
|
||||
currentArtifactDefinition.kind === artifact.kind
|
||||
);
|
||||
|
||||
if (artifactDefinition?.onStreamPart) {
|
||||
artifactDefinition.onStreamPart({
|
||||
streamPart: delta,
|
||||
setArtifact,
|
||||
setMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
setArtifact((draftArtifact) => {
|
||||
if (!draftArtifact) {
|
||||
return { ...initialArtifactData, status: "streaming" };
|
||||
}
|
||||
|
||||
switch (delta.type) {
|
||||
case "data-id":
|
||||
return {
|
||||
...draftArtifact,
|
||||
documentId: delta.data,
|
||||
status: "streaming",
|
||||
};
|
||||
|
||||
case "data-title":
|
||||
return {
|
||||
...draftArtifact,
|
||||
title: delta.data,
|
||||
status: "streaming",
|
||||
};
|
||||
|
||||
case "data-kind":
|
||||
return {
|
||||
...draftArtifact,
|
||||
kind: delta.data,
|
||||
status: "streaming",
|
||||
};
|
||||
|
||||
case "data-clear":
|
||||
return {
|
||||
...draftArtifact,
|
||||
content: "",
|
||||
status: "streaming",
|
||||
};
|
||||
|
||||
case "data-finish":
|
||||
return {
|
||||
...draftArtifact,
|
||||
status: "idle",
|
||||
};
|
||||
|
||||
default:
|
||||
return draftArtifact;
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [dataStream, setArtifact, setMetadata, artifact, setDataStream, mutate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
"use client";
|
||||
|
||||
import type { DataUIPart } from "ai";
|
||||
import type React from "react";
|
||||
import { createContext, useContext, useMemo, useState } from "react";
|
||||
import type { CustomUIDataTypes } from "@/lib/types";
|
||||
|
||||
type DataStreamContextValue = {
|
||||
dataStream: DataUIPart<CustomUIDataTypes>[];
|
||||
setDataStream: React.Dispatch<
|
||||
React.SetStateAction<DataUIPart<CustomUIDataTypes>[]>
|
||||
>;
|
||||
};
|
||||
|
||||
const DataStreamContext = createContext<DataStreamContextValue | null>(null);
|
||||
|
||||
export function DataStreamProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [dataStream, setDataStream] = useState<DataUIPart<CustomUIDataTypes>[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
const value = useMemo(() => ({ dataStream, setDataStream }), [dataStream]);
|
||||
|
||||
return (
|
||||
<DataStreamContext.Provider value={value}>
|
||||
{children}
|
||||
</DataStreamContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDataStream() {
|
||||
const context = useContext(DataStreamContext);
|
||||
if (!context) {
|
||||
throw new Error("useDataStream must be used within a DataStreamProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
100
demos/use_cases/vercel-ai-sdk/components/diffview.tsx
Normal file
100
demos/use_cases/vercel-ai-sdk/components/diffview.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import OrderedMap from "orderedmap";
|
||||
import {
|
||||
DOMParser,
|
||||
type MarkSpec,
|
||||
type Node as ProsemirrorNode,
|
||||
Schema,
|
||||
} from "prosemirror-model";
|
||||
import { schema } from "prosemirror-schema-basic";
|
||||
import { addListNodes } from "prosemirror-schema-list";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
import { DiffType, diffEditor } from "@/lib/editor/diff";
|
||||
|
||||
const diffSchema = new Schema({
|
||||
nodes: addListNodes(schema.spec.nodes, "paragraph block*", "block"),
|
||||
marks: OrderedMap.from({
|
||||
...schema.spec.marks.toObject(),
|
||||
diffMark: {
|
||||
attrs: { type: { default: "" } },
|
||||
toDOM(mark) {
|
||||
let className = "";
|
||||
|
||||
switch (mark.attrs.type) {
|
||||
case DiffType.Inserted:
|
||||
className =
|
||||
"bg-green-100 text-green-700 dark:bg-green-500/70 dark:text-green-300";
|
||||
break;
|
||||
case DiffType.Deleted:
|
||||
className =
|
||||
"bg-red-100 line-through text-red-600 dark:bg-red-500/70 dark:text-red-300";
|
||||
break;
|
||||
default:
|
||||
className = "";
|
||||
}
|
||||
return ["span", { class: className }, 0];
|
||||
},
|
||||
} as MarkSpec,
|
||||
}),
|
||||
});
|
||||
|
||||
function computeDiff(oldDoc: ProsemirrorNode, newDoc: ProsemirrorNode) {
|
||||
return diffEditor(diffSchema, oldDoc.toJSON(), newDoc.toJSON());
|
||||
}
|
||||
|
||||
type DiffEditorProps = {
|
||||
oldContent: string;
|
||||
newContent: string;
|
||||
};
|
||||
|
||||
export const DiffView = ({ oldContent, newContent }: DiffEditorProps) => {
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && !viewRef.current) {
|
||||
const parser = DOMParser.fromSchema(diffSchema);
|
||||
|
||||
const oldHtmlContent = renderToString(
|
||||
<Streamdown>{oldContent}</Streamdown>
|
||||
);
|
||||
const newHtmlContent = renderToString(
|
||||
<Streamdown>{newContent}</Streamdown>
|
||||
);
|
||||
|
||||
const oldContainer = document.createElement("div");
|
||||
oldContainer.innerHTML = oldHtmlContent;
|
||||
|
||||
const newContainer = document.createElement("div");
|
||||
newContainer.innerHTML = newHtmlContent;
|
||||
|
||||
const oldDoc = parser.parse(oldContainer);
|
||||
const newDoc = parser.parse(newContainer);
|
||||
|
||||
const diffedDoc = computeDiff(oldDoc, newDoc);
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: diffedDoc,
|
||||
plugins: [],
|
||||
});
|
||||
|
||||
viewRef.current = new EditorView(editorRef.current, {
|
||||
state,
|
||||
editable: () => false,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (viewRef.current) {
|
||||
viewRef.current.destroy();
|
||||
viewRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [oldContent, newContent]);
|
||||
|
||||
return <div className="diff-editor" ref={editorRef} />;
|
||||
};
|
||||
295
demos/use_cases/vercel-ai-sdk/components/document-preview.tsx
Normal file
295
demos/use_cases/vercel-ai-sdk/components/document-preview.tsx
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
"use client";
|
||||
|
||||
import equal from "fast-deep-equal";
|
||||
import {
|
||||
type MouseEvent,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from "react";
|
||||
import useSWR from "swr";
|
||||
import { useArtifact } from "@/hooks/use-artifact";
|
||||
import type { Document } from "@/lib/db/schema";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
import type { ArtifactKind, UIArtifact } from "./artifact";
|
||||
import { CodeEditor } from "./code-editor";
|
||||
import { DocumentToolCall, DocumentToolResult } from "./document";
|
||||
import { InlineDocumentSkeleton } from "./document-skeleton";
|
||||
import { FileIcon, FullscreenIcon, ImageIcon, LoaderIcon } from "./icons";
|
||||
import { ImageEditor } from "./image-editor";
|
||||
import { SpreadsheetEditor } from "./sheet-editor";
|
||||
import { Editor } from "./text-editor";
|
||||
|
||||
type DocumentPreviewProps = {
|
||||
isReadonly: boolean;
|
||||
result?: any;
|
||||
args?: any;
|
||||
};
|
||||
|
||||
export function DocumentPreview({
|
||||
isReadonly,
|
||||
result,
|
||||
args,
|
||||
}: DocumentPreviewProps) {
|
||||
const { artifact, setArtifact } = useArtifact();
|
||||
|
||||
const { data: documents, isLoading: isDocumentsFetching } = useSWR<
|
||||
Document[]
|
||||
>(result ? `/api/document?id=${result.id}` : null, fetcher);
|
||||
|
||||
const previewDocument = useMemo(() => documents?.[0], [documents]);
|
||||
const hitboxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const boundingBox = hitboxRef.current?.getBoundingClientRect();
|
||||
|
||||
if (artifact.documentId && boundingBox) {
|
||||
setArtifact((currentArtifact) => ({
|
||||
...currentArtifact,
|
||||
boundingBox: {
|
||||
left: boundingBox.x,
|
||||
top: boundingBox.y,
|
||||
width: boundingBox.width,
|
||||
height: boundingBox.height,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}, [artifact.documentId, setArtifact]);
|
||||
|
||||
if (artifact.isVisible) {
|
||||
if (result) {
|
||||
return (
|
||||
<DocumentToolResult
|
||||
isReadonly={isReadonly}
|
||||
result={{ id: result.id, title: result.title, kind: result.kind }}
|
||||
type="create"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (args) {
|
||||
return (
|
||||
<DocumentToolCall
|
||||
args={{ title: args.title, kind: args.kind }}
|
||||
isReadonly={isReadonly}
|
||||
type="create"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDocumentsFetching) {
|
||||
return <LoadingSkeleton artifactKind={result.kind ?? args.kind} />;
|
||||
}
|
||||
|
||||
const document: Document | null = previewDocument
|
||||
? previewDocument
|
||||
: artifact.status === "streaming"
|
||||
? {
|
||||
title: artifact.title,
|
||||
kind: artifact.kind,
|
||||
content: artifact.content,
|
||||
id: artifact.documentId,
|
||||
createdAt: new Date(),
|
||||
userId: "noop",
|
||||
}
|
||||
: null;
|
||||
|
||||
if (!document) {
|
||||
return <LoadingSkeleton artifactKind={artifact.kind} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-[450px] cursor-pointer">
|
||||
<HitboxLayer
|
||||
hitboxRef={hitboxRef}
|
||||
result={result}
|
||||
setArtifact={setArtifact}
|
||||
/>
|
||||
<DocumentHeader
|
||||
isStreaming={artifact.status === "streaming"}
|
||||
kind={document.kind}
|
||||
title={document.title}
|
||||
/>
|
||||
<DocumentContent document={document} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LoadingSkeleton = ({ artifactKind }: { artifactKind: ArtifactKind }) => (
|
||||
<div className="w-full max-w-[450px]">
|
||||
<div className="flex h-[57px] flex-row items-center justify-between gap-2 rounded-t-2xl border border-b-0 p-4 dark:border-zinc-700 dark:bg-muted">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<div className="text-muted-foreground">
|
||||
<div className="size-4 animate-pulse rounded-md bg-muted-foreground/20" />
|
||||
</div>
|
||||
<div className="h-4 w-24 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
</div>
|
||||
<div>
|
||||
<FullscreenIcon />
|
||||
</div>
|
||||
</div>
|
||||
{artifactKind === "image" ? (
|
||||
<div className="overflow-y-scroll rounded-b-2xl border border-t-0 bg-muted dark:border-zinc-700">
|
||||
<div className="h-[257px] w-full animate-pulse bg-muted-foreground/20" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-y-scroll rounded-b-2xl border border-t-0 bg-muted p-8 pt-4 dark:border-zinc-700">
|
||||
<InlineDocumentSkeleton />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const PureHitboxLayer = ({
|
||||
hitboxRef,
|
||||
result,
|
||||
setArtifact,
|
||||
}: {
|
||||
hitboxRef: React.RefObject<HTMLDivElement>;
|
||||
result: any;
|
||||
setArtifact: (
|
||||
updaterFn: UIArtifact | ((currentArtifact: UIArtifact) => UIArtifact)
|
||||
) => void;
|
||||
}) => {
|
||||
const handleClick = useCallback(
|
||||
(event: MouseEvent<HTMLElement>) => {
|
||||
const boundingBox = event.currentTarget.getBoundingClientRect();
|
||||
|
||||
setArtifact((artifact) =>
|
||||
artifact.status === "streaming"
|
||||
? { ...artifact, isVisible: true }
|
||||
: {
|
||||
...artifact,
|
||||
title: result.title,
|
||||
documentId: result.id,
|
||||
kind: result.kind,
|
||||
isVisible: true,
|
||||
boundingBox: {
|
||||
left: boundingBox.x,
|
||||
top: boundingBox.y,
|
||||
width: boundingBox.width,
|
||||
height: boundingBox.height,
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
[setArtifact, result]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute top-0 left-0 z-10 size-full rounded-xl"
|
||||
onClick={handleClick}
|
||||
ref={hitboxRef}
|
||||
role="presentation"
|
||||
>
|
||||
<div className="flex w-full items-center justify-end p-4">
|
||||
<div className="absolute top-[13px] right-[9px] rounded-md p-2 hover:bg-zinc-100 dark:hover:bg-zinc-700">
|
||||
<FullscreenIcon />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HitboxLayer = memo(PureHitboxLayer, (prevProps, nextProps) => {
|
||||
if (!equal(prevProps.result, nextProps.result)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const PureDocumentHeader = ({
|
||||
title,
|
||||
kind,
|
||||
isStreaming,
|
||||
}: {
|
||||
title: string;
|
||||
kind: ArtifactKind;
|
||||
isStreaming: boolean;
|
||||
}) => (
|
||||
<div className="flex flex-row items-start justify-between gap-2 rounded-t-2xl border border-b-0 p-4 sm:items-center dark:border-zinc-700 dark:bg-muted">
|
||||
<div className="flex flex-row items-start gap-3 sm:items-center">
|
||||
<div className="text-muted-foreground">
|
||||
{isStreaming ? (
|
||||
<div className="animate-spin">
|
||||
<LoaderIcon />
|
||||
</div>
|
||||
) : kind === "image" ? (
|
||||
<ImageIcon />
|
||||
) : (
|
||||
<FileIcon />
|
||||
)}
|
||||
</div>
|
||||
<div className="-translate-y-1 font-medium sm:translate-y-0">{title}</div>
|
||||
</div>
|
||||
<div className="w-8" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const DocumentHeader = memo(PureDocumentHeader, (prevProps, nextProps) => {
|
||||
if (prevProps.title !== nextProps.title) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.isStreaming !== nextProps.isStreaming) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const DocumentContent = ({ document }: { document: Document }) => {
|
||||
const { artifact } = useArtifact();
|
||||
|
||||
const containerClassName = cn(
|
||||
"h-[257px] overflow-y-scroll rounded-b-2xl border border-t-0 dark:border-zinc-700 dark:bg-muted",
|
||||
{
|
||||
"p-4 sm:px-14 sm:py-16": document.kind === "text",
|
||||
"p-0": document.kind === "code",
|
||||
}
|
||||
);
|
||||
|
||||
const commonProps = {
|
||||
content: document.content ?? "",
|
||||
isCurrentVersion: true,
|
||||
currentVersionIndex: 0,
|
||||
status: artifact.status,
|
||||
saveContent: () => null,
|
||||
suggestions: [],
|
||||
};
|
||||
|
||||
const handleSaveContent = () => null;
|
||||
|
||||
return (
|
||||
<div className={containerClassName}>
|
||||
{document.kind === "text" ? (
|
||||
<Editor {...commonProps} onSaveContent={handleSaveContent} />
|
||||
) : document.kind === "code" ? (
|
||||
<div className="relative flex w-full flex-1">
|
||||
<div className="absolute inset-0">
|
||||
<CodeEditor {...commonProps} onSaveContent={handleSaveContent} />
|
||||
</div>
|
||||
</div>
|
||||
) : document.kind === "sheet" ? (
|
||||
<div className="relative flex size-full flex-1 p-4">
|
||||
<div className="absolute inset-0">
|
||||
<SpreadsheetEditor {...commonProps} />
|
||||
</div>
|
||||
</div>
|
||||
) : document.kind === "image" ? (
|
||||
<ImageEditor
|
||||
content={document.content ?? ""}
|
||||
currentVersionIndex={0}
|
||||
isCurrentVersion={true}
|
||||
isInline={true}
|
||||
status={artifact.status}
|
||||
title={document.title}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
"use client";
|
||||
|
||||
import type { ArtifactKind } from "./artifact";
|
||||
|
||||
export const DocumentSkeleton = ({
|
||||
artifactKind,
|
||||
}: {
|
||||
artifactKind: ArtifactKind;
|
||||
}) => {
|
||||
return artifactKind === "image" ? (
|
||||
<div className="flex h-[calc(100dvh-60px)] w-full flex-col items-center justify-center gap-4">
|
||||
<div className="size-96 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="h-12 w-1/2 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-5 w-full animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-5 w-full animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-5 w-1/3 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-5 w-52 animate-pulse rounded-lg bg-transparent" />
|
||||
<div className="h-8 w-52 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-5 w-2/3 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const InlineDocumentSkeleton = () => {
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="h-4 w-48 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-4 w-3/4 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-4 w-1/2 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-4 w-64 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-4 w-40 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-4 w-36 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
<div className="h-4 w-64 animate-pulse rounded-lg bg-muted-foreground/20" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
161
demos/use_cases/vercel-ai-sdk/components/document.tsx
Normal file
161
demos/use_cases/vercel-ai-sdk/components/document.tsx
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { memo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useArtifact } from "@/hooks/use-artifact";
|
||||
import type { ArtifactKind } from "./artifact";
|
||||
import { FileIcon, LoaderIcon, MessageIcon, PencilEditIcon } from "./icons";
|
||||
|
||||
const getActionText = (
|
||||
type: "create" | "update" | "request-suggestions",
|
||||
tense: "present" | "past"
|
||||
) => {
|
||||
switch (type) {
|
||||
case "create":
|
||||
return tense === "present" ? "Creating" : "Created";
|
||||
case "update":
|
||||
return tense === "present" ? "Updating" : "Updated";
|
||||
case "request-suggestions":
|
||||
return tense === "present"
|
||||
? "Adding suggestions"
|
||||
: "Added suggestions to";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
type DocumentToolResultProps = {
|
||||
type: "create" | "update" | "request-suggestions";
|
||||
result: { id: string; title: string; kind: ArtifactKind };
|
||||
isReadonly: boolean;
|
||||
};
|
||||
|
||||
function PureDocumentToolResult({
|
||||
type,
|
||||
result,
|
||||
isReadonly,
|
||||
}: DocumentToolResultProps) {
|
||||
const { setArtifact } = useArtifact();
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex w-fit cursor-pointer flex-row items-start gap-3 rounded-xl border bg-background px-3 py-2"
|
||||
onClick={(event) => {
|
||||
if (isReadonly) {
|
||||
toast.error(
|
||||
"Viewing files in shared chats is currently not supported."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
|
||||
const boundingBox = {
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
|
||||
setArtifact((currentArtifact) => ({
|
||||
documentId: result.id,
|
||||
kind: result.kind,
|
||||
content: currentArtifact.content,
|
||||
title: result.title,
|
||||
isVisible: true,
|
||||
status: "idle",
|
||||
boundingBox,
|
||||
}));
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
{type === "create" ? (
|
||||
<FileIcon />
|
||||
) : type === "update" ? (
|
||||
<PencilEditIcon />
|
||||
) : type === "request-suggestions" ? (
|
||||
<MessageIcon />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
{`${getActionText(type, "past")} "${result.title}"`}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export const DocumentToolResult = memo(PureDocumentToolResult, () => true);
|
||||
|
||||
type DocumentToolCallProps = {
|
||||
type: "create" | "update" | "request-suggestions";
|
||||
args:
|
||||
| { title: string; kind: ArtifactKind } // for create
|
||||
| { id: string; description: string } // for update
|
||||
| { documentId: string }; // for request-suggestions
|
||||
isReadonly: boolean;
|
||||
};
|
||||
|
||||
function PureDocumentToolCall({
|
||||
type,
|
||||
args,
|
||||
isReadonly,
|
||||
}: DocumentToolCallProps) {
|
||||
const { setArtifact } = useArtifact();
|
||||
|
||||
return (
|
||||
<button
|
||||
className="cursor pointer flex w-fit flex-row items-start justify-between gap-3 rounded-xl border px-3 py-2"
|
||||
onClick={(event) => {
|
||||
if (isReadonly) {
|
||||
toast.error(
|
||||
"Viewing files in shared chats is currently not supported."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
|
||||
const boundingBox = {
|
||||
top: rect.top,
|
||||
left: rect.left,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
|
||||
setArtifact((currentArtifact) => ({
|
||||
...currentArtifact,
|
||||
isVisible: true,
|
||||
boundingBox,
|
||||
}));
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex flex-row items-start gap-3">
|
||||
<div className="mt-1 text-zinc-500">
|
||||
{type === "create" ? (
|
||||
<FileIcon />
|
||||
) : type === "update" ? (
|
||||
<PencilEditIcon />
|
||||
) : type === "request-suggestions" ? (
|
||||
<MessageIcon />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="text-left">
|
||||
{`${getActionText(type, "present")} ${
|
||||
type === "create" && "title" in args && args.title
|
||||
? `"${args.title}"`
|
||||
: type === "update" && "description" in args
|
||||
? `"${args.description}"`
|
||||
: type === "request-suggestions"
|
||||
? "for document"
|
||||
: ""
|
||||
}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-1 animate-spin">{<LoaderIcon />}</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export const DocumentToolCall = memo(PureDocumentToolCall, () => true);
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
"use client";
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const Actions = ({ className, children, ...props }: ActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const Action = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
className,
|
||||
variant = "ghost",
|
||||
size = "sm",
|
||||
...props
|
||||
}: ActionProps) => {
|
||||
const button = (
|
||||
<Button
|
||||
className={cn(
|
||||
"relative size-9 p-1.5 text-muted-foreground hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
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;
|
||||
};
|
||||
215
demos/use_cases/vercel-ai-sdk/components/elements/branch.tsx
Normal file
215
demos/use_cases/vercel-ai-sdk/components/elements/branch.tsx
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
"use client";
|
||||
|
||||
import type { UIMessage } from "ai";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type BranchContextType = {
|
||||
currentBranch: number;
|
||||
totalBranches: number;
|
||||
goToPrevious: () => void;
|
||||
goToNext: () => void;
|
||||
branches: ReactElement[];
|
||||
setBranches: (branches: ReactElement[]) => void;
|
||||
};
|
||||
|
||||
const BranchContext = createContext<BranchContextType | null>(null);
|
||||
|
||||
const useBranch = () => {
|
||||
const context = useContext(BranchContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Branch components must be used within Branch");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type BranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number;
|
||||
onBranchChange?: (branchIndex: number) => void;
|
||||
};
|
||||
|
||||
export const Branch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: BranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||
|
||||
const handleBranchChange = (newBranch: number) => {
|
||||
setCurrentBranch(newBranch);
|
||||
onBranchChange?.(newBranch);
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const contextValue: BranchContextType = {
|
||||
currentBranch,
|
||||
totalBranches: branches.length,
|
||||
goToPrevious,
|
||||
goToNext,
|
||||
branches,
|
||||
setBranches,
|
||||
};
|
||||
|
||||
return (
|
||||
<BranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</BranchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type BranchMessagesProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const BranchMessages = ({ children, ...props }: BranchMessagesProps) => {
|
||||
const { currentBranch, setBranches, branches } = useBranch();
|
||||
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 BranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const BranchSelector = ({
|
||||
className,
|
||||
from,
|
||||
...props
|
||||
}: BranchSelectorProps) => {
|
||||
const { totalBranches } = useBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2 self-end px-10",
|
||||
from === "assistant" ? "justify-start" : "justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type BranchPreviousProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const BranchPrevious = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: BranchPreviousProps) => {
|
||||
const { goToPrevious, totalBranches } = useBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Previous branch"
|
||||
className={cn(
|
||||
"size-7 shrink-0 rounded-full text-muted-foreground transition-colors",
|
||||
"hover:bg-accent hover:text-foreground",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type BranchNextProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const BranchNext = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: BranchNextProps) => {
|
||||
const { goToNext, totalBranches } = useBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Next branch"
|
||||
className={cn(
|
||||
"size-7 shrink-0 rounded-full text-muted-foreground transition-colors",
|
||||
"hover:bg-accent hover:text-foreground",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type BranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const BranchPage = ({ className, ...props }: BranchPageProps) => {
|
||||
const { currentBranch, totalBranches } = useBranch();
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium text-muted-foreground text-xs tabular-nums",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
154
demos/use_cases/vercel-ai-sdk/components/elements/code-block.tsx
Normal file
154
demos/use_cases/vercel-ai-sdk/components/elements/code-block.tsx
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
"use client";
|
||||
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import {
|
||||
oneDark,
|
||||
oneLight,
|
||||
} from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type CodeBlockContextType = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: "",
|
||||
});
|
||||
|
||||
export type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language: string;
|
||||
showLineNumbers?: boolean;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => (
|
||||
<CodeBlockContext.Provider value={{ code }}>
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full overflow-hidden rounded-md border bg-background text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative">
|
||||
<SyntaxHighlighter
|
||||
className="overflow-hidden dark:hidden"
|
||||
codeTagProps={{
|
||||
className: "font-mono text-sm",
|
||||
}}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
background: "hsl(var(--background))",
|
||||
color: "hsl(var(--foreground))",
|
||||
overflowX: "auto",
|
||||
overflowWrap: "break-word",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
language={language}
|
||||
lineNumberStyle={{
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
paddingRight: "1rem",
|
||||
minWidth: "2.5rem",
|
||||
}}
|
||||
showLineNumbers={showLineNumbers}
|
||||
style={oneLight}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
<SyntaxHighlighter
|
||||
className="hidden overflow-hidden dark:block"
|
||||
codeTagProps={{
|
||||
className: "font-mono text-sm",
|
||||
}}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "1rem",
|
||||
fontSize: "0.875rem",
|
||||
background: "hsl(var(--background))",
|
||||
color: "hsl(var(--foreground))",
|
||||
overflowX: "auto",
|
||||
overflowWrap: "break-word",
|
||||
wordBreak: "break-all",
|
||||
}}
|
||||
language={language}
|
||||
lineNumberStyle={{
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
paddingRight: "1rem",
|
||||
minWidth: "2.5rem",
|
||||
}}
|
||||
showLineNumbers={showLineNumbers}
|
||||
style={oneDark}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
{children && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-2">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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 { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (typeof window === "undefined" || !navigator.clipboard.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
setTimeout(() => setIsCopied(false), timeout);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn(
|
||||
"relative flex-1 touch-pan-y overflow-y-auto will-change-scroll",
|
||||
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("p-4", className)} {...props} />
|
||||
);
|
||||
|
||||
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(
|
||||
"-translate-x-1/2 absolute bottom-4 left-1/2 z-10 rounded-full shadow-lg",
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
26
demos/use_cases/vercel-ai-sdk/components/elements/image.tsx
Normal file
26
demos/use_cases/vercel-ai-sdk/components/elements/image.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { Experimental_GeneratedImage } from "ai";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ImageProps = Experimental_GeneratedImage & {
|
||||
className?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
export const Image = ({
|
||||
base64,
|
||||
uint8Array,
|
||||
mediaType,
|
||||
...props
|
||||
}: ImageProps) => (
|
||||
// biome-ignore lint/nursery/useImageSize: "Generated image without explicit size"
|
||||
// biome-ignore lint/performance/noImgElement: "Generated image without explicit size"
|
||||
<img
|
||||
{...props}
|
||||
alt={props.alt}
|
||||
className={cn(
|
||||
"h-auto max-w-full overflow-hidden rounded-md",
|
||||
props.className
|
||||
)}
|
||||
src={`data:${mediaType};base64,${base64}`}
|
||||
/>
|
||||
);
|
||||
|
|
@ -0,0 +1,287 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Carousel,
|
||||
type CarouselApi,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
} from "@/components/ui/carousel";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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.length ? (
|
||||
<>
|
||||
{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);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCount(api.scrollSnapList().length);
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
|
||||
api.on("select", () => {
|
||||
setCurrent(api.selectedScrollSnap() + 1);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
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>
|
||||
);
|
||||
96
demos/use_cases/vercel-ai-sdk/components/elements/loader.tsx
Normal file
96
demos/use_cases/vercel-ai-sdk/components/elements/loader.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import type { HTMLAttributes } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type LoaderIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "currentcolor" }}
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
>
|
||||
<title>Loader</title>
|
||||
<g clipPath="url(#clip0_2393_1490)">
|
||||
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path
|
||||
d="M8 16V12"
|
||||
opacity="0.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M3.29773 1.52783L5.64887 4.7639"
|
||||
opacity="0.9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12.7023 1.52783L10.3511 4.7639"
|
||||
opacity="0.1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12.7023 14.472L10.3511 11.236"
|
||||
opacity="0.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M3.29773 14.472L5.64887 11.236"
|
||||
opacity="0.6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M15.6085 5.52783L11.8043 6.7639"
|
||||
opacity="0.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M0.391602 10.472L4.19583 9.23598"
|
||||
opacity="0.7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M15.6085 10.4722L11.8043 9.2361"
|
||||
opacity="0.3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M0.391602 5.52783L4.19583 6.7639"
|
||||
opacity="0.8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2393_1490">
|
||||
<rect fill="white" height="16" width="16" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex animate-spin items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<LoaderIcon size={size} />
|
||||
</div>
|
||||
);
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import type { UIMessage } from "ai";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full items-end justify-end gap-2 py-4",
|
||||
from === "user" ? "is-user" : "is-assistant flex-row-reverse justify-end",
|
||||
"[&>div]:max-w-[80%]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-2 overflow-hidden rounded-lg px-4 py-3 text-foreground text-sm",
|
||||
"group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground",
|
||||
"group-[.is-assistant]:bg-secondary group-[.is-assistant]:text-foreground",
|
||||
"is-user:dark",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageAvatarProps = ComponentProps<typeof Avatar> & {
|
||||
src: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export const MessageAvatar = ({
|
||||
src,
|
||||
name,
|
||||
className,
|
||||
...props
|
||||
}: MessageAvatarProps) => (
|
||||
<Avatar className={cn("size-8 ring-1 ring-border", className)} {...props}>
|
||||
<AvatarImage alt="" className="my-0" src={src} />
|
||||
<AvatarFallback>{name?.slice(0, 2) || "ME"}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
|
|
@ -0,0 +1,240 @@
|
|||
"use client";
|
||||
|
||||
import type { ChatStatus } from "ai";
|
||||
import { Loader2Icon, SendIcon, SquareIcon, XIcon } from "lucide-react";
|
||||
import type {
|
||||
ComponentProps,
|
||||
HTMLAttributes,
|
||||
KeyboardEventHandler,
|
||||
} from "react";
|
||||
import { Children } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type PromptInputProps = HTMLAttributes<HTMLFormElement>;
|
||||
|
||||
export const PromptInput = ({ className, ...props }: PromptInputProps) => (
|
||||
<form
|
||||
className={cn(
|
||||
"w-full overflow-hidden rounded-xl border bg-background shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PromptInputTextareaProps = ComponentProps<typeof Textarea> & {
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
disableAutoResize?: boolean;
|
||||
resizeOnNewLinesOnly?: boolean;
|
||||
};
|
||||
|
||||
export const PromptInputTextarea = ({
|
||||
onChange,
|
||||
className,
|
||||
placeholder = "What would you like to know?",
|
||||
minHeight = 48,
|
||||
maxHeight = 164,
|
||||
disableAutoResize = false,
|
||||
resizeOnNewLinesOnly = false,
|
||||
...props
|
||||
}: PromptInputTextareaProps) => {
|
||||
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (e) => {
|
||||
if (e.key === "Enter") {
|
||||
// Don't submit if IME composition is in progress
|
||||
if (e.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.shiftKey) {
|
||||
// Allow newline
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit on Enter (without Shift)
|
||||
e.preventDefault();
|
||||
const form = e.currentTarget.form;
|
||||
if (form) {
|
||||
form.requestSubmit();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
className={cn(
|
||||
"w-full resize-none rounded-none border-none p-3 shadow-none outline-hidden ring-0",
|
||||
disableAutoResize
|
||||
? "field-sizing-fixed"
|
||||
: resizeOnNewLinesOnly
|
||||
? "field-sizing-fixed"
|
||||
: "field-sizing-content max-h-[6lh]",
|
||||
"bg-transparent dark:bg-transparent",
|
||||
"focus-visible:ring-0",
|
||||
className
|
||||
)}
|
||||
name="message"
|
||||
onChange={(e) => {
|
||||
onChange?.(e);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type PromptInputToolbarProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PromptInputToolbar = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputToolbarProps) => (
|
||||
<div
|
||||
className={cn("flex items-center justify-between p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const PromptInputTools = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputToolsProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1",
|
||||
"[&_button:first-child]:rounded-bl-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PromptInputButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const PromptInputButton = ({
|
||||
variant = "ghost",
|
||||
className,
|
||||
size,
|
||||
...props
|
||||
}: PromptInputButtonProps) => {
|
||||
const newSize =
|
||||
(size ?? Children.count(props.children) > 1) ? "default" : "icon";
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"shrink-0 gap-1.5 rounded-lg",
|
||||
variant === "ghost" && "text-muted-foreground",
|
||||
newSize === "default" && "px-3",
|
||||
className
|
||||
)}
|
||||
size={newSize}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type PromptInputSubmitProps = ComponentProps<typeof Button> & {
|
||||
status?: ChatStatus;
|
||||
};
|
||||
|
||||
export const PromptInputSubmit = ({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "icon",
|
||||
status,
|
||||
children,
|
||||
...props
|
||||
}: PromptInputSubmitProps) => {
|
||||
let Icon = <SendIcon className="size-4" />;
|
||||
|
||||
if (status === "submitted") {
|
||||
Icon = <Loader2Icon className="size-4 animate-spin" />;
|
||||
} else if (status === "streaming") {
|
||||
Icon = <SquareIcon className="size-4" />;
|
||||
} else if (status === "error") {
|
||||
Icon = <XIcon className="size-4" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("gap-1.5 rounded-lg", className)}
|
||||
size={size}
|
||||
type="submit"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children ?? Icon}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type PromptInputModelSelectProps = ComponentProps<typeof Select>;
|
||||
|
||||
export const PromptInputModelSelect = (props: PromptInputModelSelectProps) => (
|
||||
<Select {...props} />
|
||||
);
|
||||
|
||||
export type PromptInputModelSelectTriggerProps = ComponentProps<
|
||||
typeof SelectTrigger
|
||||
>;
|
||||
|
||||
export const PromptInputModelSelectTrigger = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputModelSelectTriggerProps) => (
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors",
|
||||
"hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground",
|
||||
"h-auto px-2 py-1.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PromptInputModelSelectContentProps = ComponentProps<
|
||||
typeof SelectContent
|
||||
>;
|
||||
|
||||
export const PromptInputModelSelectContent = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputModelSelectContentProps) => (
|
||||
<SelectContent className={cn(className)} {...props} />
|
||||
);
|
||||
|
||||
export type PromptInputModelSelectItemProps = ComponentProps<typeof SelectItem>;
|
||||
|
||||
export const PromptInputModelSelectItem = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputModelSelectItemProps) => (
|
||||
<SelectItem className={cn(className)} {...props} />
|
||||
);
|
||||
|
||||
export type PromptInputModelSelectValueProps = ComponentProps<
|
||||
typeof SelectValue
|
||||
>;
|
||||
|
||||
export const PromptInputModelSelectValue = ({
|
||||
className,
|
||||
...props
|
||||
}: PromptInputModelSelectValueProps) => (
|
||||
<SelectValue className={cn(className)} {...props} />
|
||||
);
|
||||
175
demos/use_cases/vercel-ai-sdk/components/elements/reasoning.tsx
Normal file
175
demos/use_cases/vercel-ai-sdk/components/elements/reasoning.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Response } from "./response";
|
||||
|
||||
type ReasoningContextValue = {
|
||||
isStreaming: boolean;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
duration: number;
|
||||
};
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||
|
||||
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 = 500;
|
||||
const MS_IN_S = 1000;
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen = true,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
const [duration, setDuration] = useControllableState({
|
||||
prop: durationProp,
|
||||
defaultProp: 0,
|
||||
});
|
||||
|
||||
const [hasAutoClosedRef, setHasAutoClosedRef] = useState(false);
|
||||
const [startTime, setStartTime] = useState<number | null>(null);
|
||||
|
||||
// Track duration when streaming starts and ends
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
if (startTime === null) {
|
||||
setStartTime(Date.now());
|
||||
}
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.round((Date.now() - startTime) / MS_IN_S));
|
||||
setStartTime(null);
|
||||
}
|
||||
}, [isStreaming, startTime, setDuration]);
|
||||
|
||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||
useEffect(() => {
|
||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosedRef) {
|
||||
// Add a small delay before closing to allow user to see the content
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosedRef(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosedRef]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setIsOpen(newOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider
|
||||
value={{ isStreaming, isOpen, setIsOpen, duration }}
|
||||
>
|
||||
<Collapsible
|
||||
className={cn("not-prose", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({ className, children, ...props }: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning();
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded-md px-1.5 py-0.5 text-[11px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-3" />
|
||||
{isStreaming || duration === 0 ? (
|
||||
<span>Thinking</span>
|
||||
) : (
|
||||
<span>{duration}s</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-2.5 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-1.5 text-[11px] text-muted-foreground leading-relaxed",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="max-h-48 overflow-y-auto rounded-md border border-border/50 bg-muted/30 p-2.5">
|
||||
<Response className="grid gap-1 text-[11px] **:text-[11px] [&_li]:my-0 [&_ol]:my-1 [&_p]:my-0 [&_ul]:my-1">
|
||||
{children}
|
||||
</Response>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
);
|
||||
|
||||
Reasoning.displayName = "Reasoning";
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger";
|
||||
ReasoningContent.displayName = "ReasoningContent";
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
"use client";
|
||||
|
||||
import { type ComponentProps, memo } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
export const Response = memo(
|
||||
({ className, ...props }: ResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_code]:whitespace-pre-wrap [&_code]:break-words [&_pre]:max-w-full [&_pre]:overflow-x-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
||||
);
|
||||
|
||||
Response.displayName = "Response";
|
||||
74
demos/use_cases/vercel-ai-sdk/components/elements/source.tsx
Normal file
74
demos/use_cases/vercel-ai-sdk/components/elements/source.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"use client";
|
||||
|
||||
import { BookIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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="flex items-center gap-2" {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<p className="font-medium">Used {count} sources</p>
|
||||
<ChevronDownIcon className="size-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-hidden 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="size-4" />
|
||||
<span className="block font-medium">{title}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
"use client";
|
||||
|
||||
import type { ComponentProps } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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 = () => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
94
demos/use_cases/vercel-ai-sdk/components/elements/task.tsx
Normal file
94
demos/use_cases/vercel-ai-sdk/components/elements/task.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDownIcon, SearchIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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(
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
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 cursor-pointer items-center gap-2 text-muted-foreground 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-hidden 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>
|
||||
);
|
||||
153
demos/use_cases/vercel-ai-sdk/components/elements/tool.tsx
Normal file
153
demos/use_cases/vercel-ai-sdk/components/elements/tool.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"use client";
|
||||
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
CircleIcon,
|
||||
ClockIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CodeBlock } from "./code-block";
|
||||
|
||||
export type ToolProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Tool = ({ className, ...props }: ToolProps) => (
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4 w-full rounded-md border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolHeaderProps = {
|
||||
type: ToolUIPart["type"];
|
||||
state: ToolUIPart["state"];
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: ToolUIPart["state"]) => {
|
||||
const labels: Record<ToolUIPart["state"], string> = {
|
||||
"input-streaming": "Pending",
|
||||
"input-available": "Running",
|
||||
"approval-requested": "Pending",
|
||||
"approval-responded": "Approved",
|
||||
"output-available": "Completed",
|
||||
"output-error": "Error",
|
||||
"output-denied": "Denied",
|
||||
};
|
||||
|
||||
const icons: Record<ToolUIPart["state"], ReactNode> = {
|
||||
"input-streaming": <CircleIcon className="size-4" />,
|
||||
"input-available": <ClockIcon className="size-4 animate-pulse" />,
|
||||
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
|
||||
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
|
||||
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
|
||||
"output-error": <XCircleIcon className="size-4 text-red-600" />,
|
||||
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className="flex items-center gap-1 rounded-full text-xs"
|
||||
variant="secondary"
|
||||
>
|
||||
{icons[status]}
|
||||
<span>{labels[status]}</span>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToolHeader = ({
|
||||
className,
|
||||
type,
|
||||
state,
|
||||
...props
|
||||
}: ToolHeaderProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full min-w-0 items-center justify-between gap-2 p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<WrenchIcon className="size-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate font-medium text-sm">{type}</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{getStatusBadge(state)}
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
</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 text-popover-foreground outline-hidden data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ToolInputProps = ComponentProps<"div"> & {
|
||||
input: ToolUIPart["input"];
|
||||
};
|
||||
|
||||
export const ToolInput = ({ className, input, ...props }: ToolInputProps) => (
|
||||
<div className={cn("space-y-2 overflow-hidden p-4", 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: ReactNode;
|
||||
errorText: ToolUIPart["errorText"];
|
||||
};
|
||||
|
||||
export const ToolOutput = ({
|
||||
className,
|
||||
output,
|
||||
errorText,
|
||||
...props
|
||||
}: ToolOutputProps) => {
|
||||
if (!(output || errorText)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2 p-4", 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>{output}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,252 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, useContext, useState } from "react";
|
||||
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";
|
||||
|
||||
export type 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 = (newUrl: string) => {
|
||||
setUrl(newUrl);
|
||||
onUrlChange?.(newUrl);
|
||||
};
|
||||
|
||||
const contextValue: WebPreviewContextValue = {
|
||||
url,
|
||||
setUrl: handleUrlChange,
|
||||
consoleOpen,
|
||||
setConsoleOpen,
|
||||
};
|
||||
|
||||
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="size-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 handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
const target = event.target as HTMLInputElement;
|
||||
setUrl(target.value);
|
||||
}
|
||||
onKeyDown?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="h-8 flex-1 text-sm"
|
||||
onChange={onChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter URL..."
|
||||
value={value ?? url}
|
||||
{...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)}
|
||||
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?: Array<{
|
||||
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-hidden 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, index) => (
|
||||
<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()}-${index}`}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{log.timestamp.toLocaleTimeString()}
|
||||
</span>{" "}
|
||||
{log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
29
demos/use_cases/vercel-ai-sdk/components/greeting.tsx
Normal file
29
demos/use_cases/vercel-ai-sdk/components/greeting.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { motion } from "framer-motion";
|
||||
|
||||
export const Greeting = () => {
|
||||
return (
|
||||
<div
|
||||
className="mx-auto mt-4 flex size-full max-w-3xl flex-col justify-center px-4 md:mt-16 md:px-8"
|
||||
key="overview"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="font-semibold text-xl md:text-2xl"
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
Hello there!
|
||||
</motion.div>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-xl text-zinc-500 md:text-2xl"
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
>
|
||||
How can I help you today?
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1213
demos/use_cases/vercel-ai-sdk/components/icons.tsx
Normal file
1213
demos/use_cases/vercel-ai-sdk/components/icons.tsx
Normal file
File diff suppressed because it is too large
Load diff
49
demos/use_cases/vercel-ai-sdk/components/image-editor.tsx
Normal file
49
demos/use_cases/vercel-ai-sdk/components/image-editor.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import cn from "classnames";
|
||||
import { LoaderIcon } from "./icons";
|
||||
|
||||
type ImageEditorProps = {
|
||||
title: string;
|
||||
content: string;
|
||||
isCurrentVersion: boolean;
|
||||
currentVersionIndex: number;
|
||||
status: string;
|
||||
isInline: boolean;
|
||||
};
|
||||
|
||||
export function ImageEditor({
|
||||
title,
|
||||
content,
|
||||
status,
|
||||
isInline,
|
||||
}: ImageEditorProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex w-full flex-row items-center justify-center", {
|
||||
"h-[calc(100dvh-60px)]": !isInline,
|
||||
"h-[200px]": isInline,
|
||||
})}
|
||||
>
|
||||
{status === "streaming" ? (
|
||||
<div className="flex flex-row items-center gap-4">
|
||||
{!isInline && (
|
||||
<div className="animate-spin">
|
||||
<LoaderIcon />
|
||||
</div>
|
||||
)}
|
||||
<div>Generating Image...</div>
|
||||
</div>
|
||||
) : (
|
||||
<picture>
|
||||
{/** biome-ignore lint/nursery/useImageSize: "Generated image without explicit size" */}
|
||||
<img
|
||||
alt={title}
|
||||
className={cn("h-fit w-full max-w-[800px]", {
|
||||
"p-0 md:p-20": !isInline,
|
||||
})}
|
||||
src={`data:image/png;base64,${content}`}
|
||||
/>
|
||||
</picture>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
demos/use_cases/vercel-ai-sdk/components/message-actions.tsx
Normal file
189
demos/use_cases/vercel-ai-sdk/components/message-actions.tsx
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import equal from "fast-deep-equal";
|
||||
import { memo } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useSWRConfig } from "swr";
|
||||
import { useCopyToClipboard } from "usehooks-ts";
|
||||
import type { Vote } from "@/lib/db/schema";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { Action, Actions } from "./elements/actions";
|
||||
import { CopyIcon, PencilEditIcon, ThumbDownIcon, ThumbUpIcon } from "./icons";
|
||||
|
||||
export function PureMessageActions({
|
||||
chatId,
|
||||
message,
|
||||
vote,
|
||||
isLoading,
|
||||
setMode,
|
||||
}: {
|
||||
chatId: string;
|
||||
message: ChatMessage;
|
||||
vote: Vote | undefined;
|
||||
isLoading: boolean;
|
||||
setMode?: (mode: "view" | "edit") => void;
|
||||
}) {
|
||||
const { mutate } = useSWRConfig();
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const textFromParts = message.parts
|
||||
?.filter((part) => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("\n")
|
||||
.trim();
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!textFromParts) {
|
||||
toast.error("There's no text to copy!");
|
||||
return;
|
||||
}
|
||||
|
||||
await copyToClipboard(textFromParts);
|
||||
toast.success("Copied to clipboard!");
|
||||
};
|
||||
|
||||
// User messages get edit (on hover) and copy actions
|
||||
if (message.role === "user") {
|
||||
return (
|
||||
<Actions className="-mr-0.5 justify-end">
|
||||
<div className="relative">
|
||||
{setMode && (
|
||||
<Action
|
||||
className="-left-10 absolute top-0 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/message:opacity-100"
|
||||
data-testid="message-edit-button"
|
||||
onClick={() => setMode("edit")}
|
||||
tooltip="Edit"
|
||||
>
|
||||
<PencilEditIcon />
|
||||
</Action>
|
||||
)}
|
||||
<Action onClick={handleCopy} tooltip="Copy">
|
||||
<CopyIcon />
|
||||
</Action>
|
||||
</div>
|
||||
</Actions>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Actions className="-ml-0.5">
|
||||
<Action onClick={handleCopy} tooltip="Copy">
|
||||
<CopyIcon />
|
||||
</Action>
|
||||
|
||||
<Action
|
||||
data-testid="message-upvote"
|
||||
disabled={vote?.isUpvoted}
|
||||
onClick={() => {
|
||||
const upvote = fetch("/api/vote", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
chatId,
|
||||
messageId: message.id,
|
||||
type: "up",
|
||||
}),
|
||||
});
|
||||
|
||||
toast.promise(upvote, {
|
||||
loading: "Upvoting Response...",
|
||||
success: () => {
|
||||
mutate<Vote[]>(
|
||||
`/api/vote?chatId=${chatId}`,
|
||||
(currentVotes) => {
|
||||
if (!currentVotes) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const votesWithoutCurrent = currentVotes.filter(
|
||||
(currentVote) => currentVote.messageId !== message.id
|
||||
);
|
||||
|
||||
return [
|
||||
...votesWithoutCurrent,
|
||||
{
|
||||
chatId,
|
||||
messageId: message.id,
|
||||
isUpvoted: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
{ revalidate: false }
|
||||
);
|
||||
|
||||
return "Upvoted Response!";
|
||||
},
|
||||
error: "Failed to upvote response.",
|
||||
});
|
||||
}}
|
||||
tooltip="Upvote Response"
|
||||
>
|
||||
<ThumbUpIcon />
|
||||
</Action>
|
||||
|
||||
<Action
|
||||
data-testid="message-downvote"
|
||||
disabled={vote && !vote.isUpvoted}
|
||||
onClick={() => {
|
||||
const downvote = fetch("/api/vote", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
chatId,
|
||||
messageId: message.id,
|
||||
type: "down",
|
||||
}),
|
||||
});
|
||||
|
||||
toast.promise(downvote, {
|
||||
loading: "Downvoting Response...",
|
||||
success: () => {
|
||||
mutate<Vote[]>(
|
||||
`/api/vote?chatId=${chatId}`,
|
||||
(currentVotes) => {
|
||||
if (!currentVotes) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const votesWithoutCurrent = currentVotes.filter(
|
||||
(currentVote) => currentVote.messageId !== message.id
|
||||
);
|
||||
|
||||
return [
|
||||
...votesWithoutCurrent,
|
||||
{
|
||||
chatId,
|
||||
messageId: message.id,
|
||||
isUpvoted: false,
|
||||
},
|
||||
];
|
||||
},
|
||||
{ revalidate: false }
|
||||
);
|
||||
|
||||
return "Downvoted Response!";
|
||||
},
|
||||
error: "Failed to downvote response.",
|
||||
});
|
||||
}}
|
||||
tooltip="Downvote Response"
|
||||
>
|
||||
<ThumbDownIcon />
|
||||
</Action>
|
||||
</Actions>
|
||||
);
|
||||
}
|
||||
|
||||
export const MessageActions = memo(
|
||||
PureMessageActions,
|
||||
(prevProps, nextProps) => {
|
||||
if (!equal(prevProps.vote, nextProps.vote)) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.isLoading !== nextProps.isLoading) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
112
demos/use_cases/vercel-ai-sdk/components/message-editor.tsx
Normal file
112
demos/use_cases/vercel-ai-sdk/components/message-editor.tsx
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"use client";
|
||||
|
||||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { deleteTrailingMessages } from "@/app/(chat)/actions";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { getTextFromMessage } from "@/lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
|
||||
export type MessageEditorProps = {
|
||||
message: ChatMessage;
|
||||
setMode: Dispatch<SetStateAction<"view" | "edit">>;
|
||||
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
|
||||
};
|
||||
|
||||
export function MessageEditor({
|
||||
message,
|
||||
setMode,
|
||||
setMessages,
|
||||
regenerate,
|
||||
}: MessageEditorProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
|
||||
const [draftContent, setDraftContent] = useState<string>(
|
||||
getTextFromMessage(message)
|
||||
);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
textareaRef.current.style.height = `${textareaRef.current.scrollHeight + 2}px`;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
adjustHeight();
|
||||
}
|
||||
}, [adjustHeight]);
|
||||
|
||||
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setDraftContent(event.target.value);
|
||||
adjustHeight();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Textarea
|
||||
className="w-full resize-none overflow-hidden rounded-xl bg-transparent text-base! outline-hidden"
|
||||
data-testid="message-editor"
|
||||
onChange={handleInput}
|
||||
ref={textareaRef}
|
||||
value={draftContent}
|
||||
/>
|
||||
|
||||
<div className="flex flex-row justify-end gap-2">
|
||||
<Button
|
||||
className="h-fit px-3 py-2"
|
||||
onClick={() => {
|
||||
setMode("view");
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
className="h-fit px-3 py-2"
|
||||
data-testid="message-editor-send-button"
|
||||
disabled={isSubmitting}
|
||||
onClick={async () => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
await deleteTrailingMessages({
|
||||
id: message.id,
|
||||
});
|
||||
|
||||
setMessages((messages) => {
|
||||
const index = messages.findIndex((m) => m.id === message.id);
|
||||
|
||||
if (index !== -1) {
|
||||
const updatedMessage: ChatMessage = {
|
||||
...message,
|
||||
parts: [{ type: "text", text: draftContent }],
|
||||
};
|
||||
|
||||
return [...messages.slice(0, index), updatedMessage];
|
||||
}
|
||||
|
||||
return messages;
|
||||
});
|
||||
|
||||
setMode("view");
|
||||
regenerate();
|
||||
}}
|
||||
variant="default"
|
||||
>
|
||||
{isSubmitting ? "Sending..." : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Reasoning,
|
||||
ReasoningContent,
|
||||
ReasoningTrigger,
|
||||
} from "./elements/reasoning";
|
||||
|
||||
type MessageReasoningProps = {
|
||||
isLoading: boolean;
|
||||
reasoning: string;
|
||||
};
|
||||
|
||||
export function MessageReasoning({
|
||||
isLoading,
|
||||
reasoning,
|
||||
}: MessageReasoningProps) {
|
||||
const [hasBeenStreaming, setHasBeenStreaming] = useState(isLoading);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading) {
|
||||
setHasBeenStreaming(true);
|
||||
}
|
||||
}, [isLoading]);
|
||||
|
||||
return (
|
||||
<Reasoning
|
||||
data-testid="message-reasoning"
|
||||
defaultOpen={hasBeenStreaming}
|
||||
isStreaming={isLoading}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{reasoning}</ReasoningContent>
|
||||
</Reasoning>
|
||||
);
|
||||
}
|
||||
419
demos/use_cases/vercel-ai-sdk/components/message.tsx
Normal file
419
demos/use_cases/vercel-ai-sdk/components/message.tsx
Normal file
|
|
@ -0,0 +1,419 @@
|
|||
"use client";
|
||||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import equal from "fast-deep-equal";
|
||||
import { memo, useState } from "react";
|
||||
import type { Vote } from "@/lib/db/schema";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { cn, sanitizeText } from "@/lib/utils";
|
||||
import { useDataStream } from "./data-stream-provider";
|
||||
import { MessageContent } from "./elements/message";
|
||||
import { Response } from "./elements/response";
|
||||
import {
|
||||
Tool,
|
||||
ToolContent,
|
||||
ToolHeader,
|
||||
ToolInput,
|
||||
} from "./elements/tool";
|
||||
import { SparklesIcon } from "./icons";
|
||||
import { MessageActions } from "./message-actions";
|
||||
import { MessageEditor } from "./message-editor";
|
||||
import { MessageReasoning } from "./message-reasoning";
|
||||
import { PreviewAttachment } from "./preview-attachment";
|
||||
import { Weather } from "./weather";
|
||||
import { CurrencyExchange } from "./currency-exchange";
|
||||
|
||||
const PurePreviewMessage = ({
|
||||
addToolApprovalResponse,
|
||||
chatId,
|
||||
message,
|
||||
vote,
|
||||
isLoading,
|
||||
setMessages,
|
||||
regenerate,
|
||||
isReadonly,
|
||||
requiresScrollPadding: _requiresScrollPadding,
|
||||
}: {
|
||||
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
|
||||
chatId: string;
|
||||
message: ChatMessage;
|
||||
vote: Vote | undefined;
|
||||
isLoading: boolean;
|
||||
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
|
||||
isReadonly: boolean;
|
||||
requiresScrollPadding: boolean;
|
||||
}) => {
|
||||
const [mode, setMode] = useState<"view" | "edit">("view");
|
||||
|
||||
const attachmentsFromMessage = message.parts.filter(
|
||||
(part) => part.type === "file"
|
||||
);
|
||||
|
||||
useDataStream();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group/message fade-in w-full animate-in duration-200"
|
||||
data-role={message.role}
|
||||
data-testid={`message-${message.role}`}
|
||||
>
|
||||
<div
|
||||
className={cn("flex w-full items-start gap-2 md:gap-3", {
|
||||
"justify-end": message.role === "user" && mode !== "edit",
|
||||
"justify-start": message.role === "assistant",
|
||||
})}
|
||||
>
|
||||
{message.role === "assistant" && (
|
||||
<div className="-mt-1 flex size-8 shrink-0 items-center justify-center rounded-full bg-background ring-1 ring-border">
|
||||
<SparklesIcon size={14} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn("flex flex-col", {
|
||||
"gap-2 md:gap-4": message.parts?.some(
|
||||
(p) => p.type === "text" && p.text?.trim()
|
||||
),
|
||||
"w-full":
|
||||
(message.role === "assistant" &&
|
||||
(message.parts?.some(
|
||||
(p) => p.type === "text" && p.text?.trim()
|
||||
) ||
|
||||
message.parts?.some((p) => p.type.startsWith("tool-")))) ||
|
||||
mode === "edit",
|
||||
"max-w-[calc(100%-2.5rem)] sm:max-w-[min(fit-content,80%)]":
|
||||
message.role === "user" && mode !== "edit",
|
||||
})}
|
||||
>
|
||||
{attachmentsFromMessage.length > 0 && (
|
||||
<div
|
||||
className="flex flex-row justify-end gap-2"
|
||||
data-testid={"message-attachments"}
|
||||
>
|
||||
{attachmentsFromMessage.map((attachment) => (
|
||||
<PreviewAttachment
|
||||
attachment={{
|
||||
name: attachment.filename ?? "file",
|
||||
contentType: attachment.mediaType,
|
||||
url: attachment.url,
|
||||
}}
|
||||
key={attachment.url}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.parts?.map((part, index) => {
|
||||
const { type } = part;
|
||||
const key = `message-${message.id}-part-${index}`;
|
||||
|
||||
if (type === "reasoning" && part.text?.trim().length > 0) {
|
||||
return (
|
||||
<MessageReasoning
|
||||
isLoading={isLoading}
|
||||
key={key}
|
||||
reasoning={part.text}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
if (mode === "view") {
|
||||
return (
|
||||
<div key={key}>
|
||||
<MessageContent
|
||||
className={cn({
|
||||
"wrap-break-word w-fit rounded-2xl px-3 py-2 text-right text-white":
|
||||
message.role === "user",
|
||||
"bg-transparent px-0 py-0 text-left":
|
||||
message.role === "assistant",
|
||||
})}
|
||||
data-testid="message-content"
|
||||
style={
|
||||
message.role === "user"
|
||||
? { backgroundColor: "#006cff" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Response>{sanitizeText(part.text)}</Response>
|
||||
</MessageContent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "edit") {
|
||||
return (
|
||||
<div
|
||||
className="flex w-full flex-row items-start gap-3"
|
||||
key={key}
|
||||
>
|
||||
<div className="size-8" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<MessageEditor
|
||||
key={message.id}
|
||||
message={message}
|
||||
regenerate={regenerate}
|
||||
setMessages={setMessages}
|
||||
setMode={setMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "tool-getWeather") {
|
||||
const { toolCallId, state } = part;
|
||||
const approvalId = (part as { approval?: { id: string } })
|
||||
.approval?.id;
|
||||
const isDenied =
|
||||
state === "output-denied" ||
|
||||
(state === "approval-responded" &&
|
||||
(part as { approval?: { approved?: boolean } }).approval
|
||||
?.approved === false);
|
||||
const widthClass = "w-[min(100%,450px)]";
|
||||
|
||||
if (state === "output-available") {
|
||||
return (
|
||||
<div className={widthClass} key={toolCallId}>
|
||||
<Weather weatherAtLocation={part.output} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDenied) {
|
||||
return (
|
||||
<div className={widthClass} key={toolCallId}>
|
||||
<Tool className="w-full" defaultOpen={true}>
|
||||
<ToolHeader
|
||||
state="output-denied"
|
||||
type="tool-getWeather"
|
||||
/>
|
||||
<ToolContent>
|
||||
<div className="px-4 py-3 text-muted-foreground text-sm">
|
||||
Weather lookup was denied.
|
||||
</div>
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "approval-responded") {
|
||||
return (
|
||||
<div className={widthClass} key={toolCallId}>
|
||||
<Tool className="w-full" defaultOpen={true}>
|
||||
<ToolHeader state={state} type="tool-getWeather" />
|
||||
<ToolContent>
|
||||
<ToolInput input={part.input} />
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={widthClass} key={toolCallId}>
|
||||
<Tool className="w-full" defaultOpen={true}>
|
||||
<ToolHeader state={state} type="tool-getWeather" />
|
||||
<ToolContent>
|
||||
{(state === "input-available" ||
|
||||
state === "approval-requested") && (
|
||||
<ToolInput input={part.input} />
|
||||
)}
|
||||
{state === "approval-requested" && approvalId && (
|
||||
<div className="flex items-center justify-end gap-2 border-t px-4 py-3">
|
||||
<button
|
||||
className="rounded-md px-3 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => {
|
||||
addToolApprovalResponse({
|
||||
id: approvalId,
|
||||
approved: false,
|
||||
reason: "User denied weather lookup",
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-primary-foreground text-sm transition-colors hover:bg-primary/90"
|
||||
onClick={() => {
|
||||
addToolApprovalResponse({
|
||||
id: approvalId,
|
||||
approved: true,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Allow
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "tool-getCurrencyExchange") {
|
||||
const { toolCallId, state } = part;
|
||||
const approvalId = (part as { approval?: { id: string } })
|
||||
.approval?.id;
|
||||
const isDenied =
|
||||
state === "output-denied" ||
|
||||
(state === "approval-responded" &&
|
||||
(part as { approval?: { approved?: boolean } }).approval
|
||||
?.approved === false);
|
||||
const widthClass = "w-[min(100%,450px)]";
|
||||
|
||||
if (state === "output-available") {
|
||||
return (
|
||||
<div className={widthClass} key={toolCallId}>
|
||||
<CurrencyExchange exchangeData={part.output} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDenied) {
|
||||
return (
|
||||
<div className={widthClass} key={toolCallId}>
|
||||
<Tool className="w-full" defaultOpen={true}>
|
||||
<ToolHeader
|
||||
state="output-denied"
|
||||
type="tool-getCurrencyExchange"
|
||||
/>
|
||||
<ToolContent>
|
||||
<div className="px-4 py-3 text-muted-foreground text-sm">
|
||||
Currency exchange lookup was denied.
|
||||
</div>
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "approval-responded") {
|
||||
return (
|
||||
<div className={widthClass} key={toolCallId}>
|
||||
<Tool className="w-full" defaultOpen={true}>
|
||||
<ToolHeader
|
||||
state={state}
|
||||
type="tool-getCurrencyExchange"
|
||||
/>
|
||||
<ToolContent>
|
||||
<ToolInput input={part.input} />
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={widthClass} key={toolCallId}>
|
||||
<Tool className="w-full" defaultOpen={true}>
|
||||
<ToolHeader state={state} type="tool-getCurrencyExchange" />
|
||||
<ToolContent>
|
||||
{(state === "input-available" ||
|
||||
state === "approval-requested") && (
|
||||
<ToolInput input={part.input} />
|
||||
)}
|
||||
{state === "approval-requested" && approvalId && (
|
||||
<div className="flex items-center justify-end gap-2 border-t px-4 py-3">
|
||||
<button
|
||||
className="rounded-md px-3 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => {
|
||||
addToolApprovalResponse({
|
||||
id: approvalId,
|
||||
approved: false,
|
||||
reason: "User denied currency exchange lookup",
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-primary-foreground text-sm transition-colors hover:bg-primary/90"
|
||||
onClick={() => {
|
||||
addToolApprovalResponse({
|
||||
id: approvalId,
|
||||
approved: true,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Allow
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
})}
|
||||
|
||||
{!isReadonly && (
|
||||
<MessageActions
|
||||
chatId={chatId}
|
||||
isLoading={isLoading}
|
||||
key={`action-${message.id}`}
|
||||
message={message}
|
||||
setMode={setMode}
|
||||
vote={vote}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PreviewMessage = memo(
|
||||
PurePreviewMessage,
|
||||
(prevProps, nextProps) => {
|
||||
if (
|
||||
prevProps.isLoading === nextProps.isLoading &&
|
||||
prevProps.message.id === nextProps.message.id &&
|
||||
prevProps.requiresScrollPadding === nextProps.requiresScrollPadding &&
|
||||
equal(prevProps.message.parts, nextProps.message.parts) &&
|
||||
equal(prevProps.vote, nextProps.vote)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
export const ThinkingMessage = () => {
|
||||
return (
|
||||
<div
|
||||
className="group/message fade-in w-full animate-in duration-300"
|
||||
data-role="assistant"
|
||||
data-testid="message-assistant-loading"
|
||||
>
|
||||
<div className="flex items-start justify-start gap-3">
|
||||
<div className="-mt-1 flex size-8 shrink-0 items-center justify-center rounded-full bg-background ring-1 ring-border">
|
||||
<div className="animate-pulse">
|
||||
<SparklesIcon size={14} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-2 md:gap-4">
|
||||
<div className="flex items-center gap-1 p-0 text-muted-foreground text-sm">
|
||||
<span className="animate-pulse">Thinking</span>
|
||||
<span className="inline-flex">
|
||||
<span className="animate-bounce [animation-delay:0ms]">.</span>
|
||||
<span className="animate-bounce [animation-delay:150ms]">.</span>
|
||||
<span className="animate-bounce [animation-delay:300ms]">.</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
132
demos/use_cases/vercel-ai-sdk/components/messages.tsx
Normal file
132
demos/use_cases/vercel-ai-sdk/components/messages.tsx
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import equal from "fast-deep-equal";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import { memo } from "react";
|
||||
import { useMessages } from "@/hooks/use-messages";
|
||||
import type { Vote } from "@/lib/db/schema";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { useDataStream } from "./data-stream-provider";
|
||||
import { Greeting } from "./greeting";
|
||||
import { PreviewMessage, ThinkingMessage } from "./message";
|
||||
|
||||
type MessagesProps = {
|
||||
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
|
||||
chatId: string;
|
||||
status: UseChatHelpers<ChatMessage>["status"];
|
||||
votes: Vote[] | undefined;
|
||||
messages: ChatMessage[];
|
||||
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
|
||||
isReadonly: boolean;
|
||||
isArtifactVisible: boolean;
|
||||
selectedModelId: string;
|
||||
};
|
||||
|
||||
function PureMessages({
|
||||
addToolApprovalResponse,
|
||||
chatId,
|
||||
status,
|
||||
votes,
|
||||
messages,
|
||||
setMessages,
|
||||
regenerate,
|
||||
isReadonly,
|
||||
selectedModelId: _selectedModelId,
|
||||
}: MessagesProps) {
|
||||
const {
|
||||
containerRef: messagesContainerRef,
|
||||
endRef: messagesEndRef,
|
||||
isAtBottom,
|
||||
scrollToBottom,
|
||||
hasSentMessage,
|
||||
} = useMessages({
|
||||
status,
|
||||
});
|
||||
|
||||
useDataStream();
|
||||
|
||||
return (
|
||||
<div className="relative flex-1">
|
||||
<div
|
||||
className="absolute inset-0 touch-pan-y overflow-y-auto"
|
||||
ref={messagesContainerRef}
|
||||
>
|
||||
<div className="mx-auto flex min-w-0 max-w-4xl flex-col gap-4 px-2 py-4 md:gap-6 md:px-4">
|
||||
{messages.length === 0 && <Greeting />}
|
||||
|
||||
{messages.map((message, index) => (
|
||||
<PreviewMessage
|
||||
addToolApprovalResponse={addToolApprovalResponse}
|
||||
chatId={chatId}
|
||||
isLoading={
|
||||
status === "streaming" && messages.length - 1 === index
|
||||
}
|
||||
isReadonly={isReadonly}
|
||||
key={message.id}
|
||||
message={message}
|
||||
regenerate={regenerate}
|
||||
requiresScrollPadding={
|
||||
hasSentMessage && index === messages.length - 1
|
||||
}
|
||||
setMessages={setMessages}
|
||||
vote={
|
||||
votes
|
||||
? votes.find((vote) => vote.messageId === message.id)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
|
||||
{status === "submitted" &&
|
||||
!messages.some((msg) =>
|
||||
msg.parts?.some(
|
||||
(part) => "state" in part && part.state === "approval-responded"
|
||||
)
|
||||
) && <ThinkingMessage />}
|
||||
|
||||
<div
|
||||
className="min-h-[24px] min-w-[24px] shrink-0"
|
||||
ref={messagesEndRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
aria-label="Scroll to bottom"
|
||||
className={`-translate-x-1/2 absolute bottom-4 left-1/2 z-10 rounded-full border bg-background p-2 shadow-lg transition-all hover:bg-muted ${
|
||||
isAtBottom
|
||||
? "pointer-events-none scale-0 opacity-0"
|
||||
: "pointer-events-auto scale-100 opacity-100"
|
||||
}`}
|
||||
onClick={() => scrollToBottom("smooth")}
|
||||
type="button"
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const Messages = memo(PureMessages, (prevProps, nextProps) => {
|
||||
if (prevProps.isArtifactVisible && nextProps.isArtifactVisible) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (prevProps.status !== nextProps.status) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.selectedModelId !== nextProps.selectedModelId) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.messages.length !== nextProps.messages.length) {
|
||||
return false;
|
||||
}
|
||||
if (!equal(prevProps.messages, nextProps.messages)) {
|
||||
return false;
|
||||
}
|
||||
if (!equal(prevProps.votes, nextProps.votes)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
558
demos/use_cases/vercel-ai-sdk/components/multimodal-input.tsx
Normal file
558
demos/use_cases/vercel-ai-sdk/components/multimodal-input.tsx
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
"use client";
|
||||
|
||||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import type { UIMessage } from "ai";
|
||||
import equal from "fast-deep-equal";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type Dispatch,
|
||||
memo,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useLocalStorage, useWindowSize } from "usehooks-ts";
|
||||
import {
|
||||
ModelSelector,
|
||||
ModelSelectorContent,
|
||||
ModelSelectorGroup,
|
||||
ModelSelectorInput,
|
||||
ModelSelectorItem,
|
||||
ModelSelectorList,
|
||||
ModelSelectorLogo,
|
||||
ModelSelectorName,
|
||||
ModelSelectorTrigger,
|
||||
} from "@/components/ai-elements/model-selector";
|
||||
import {
|
||||
chatModels,
|
||||
DEFAULT_CHAT_MODEL,
|
||||
modelsByProvider,
|
||||
} from "@/lib/ai/models";
|
||||
import type { Attachment, ChatMessage } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
PromptInputToolbar,
|
||||
PromptInputTools,
|
||||
} from "./elements/prompt-input";
|
||||
import { ArrowUpIcon, PaperclipIcon, StopIcon } from "./icons";
|
||||
import { PreviewAttachment } from "./preview-attachment";
|
||||
import { SuggestedActions } from "./suggested-actions";
|
||||
import { Button } from "./ui/button";
|
||||
import type { VisibilityType } from "./visibility-selector";
|
||||
|
||||
function setCookie(name: string, value: string) {
|
||||
const maxAge = 60 * 60 * 24 * 365; // 1 year
|
||||
// biome-ignore lint/suspicious/noDocumentCookie: needed for client-side cookie setting
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}`;
|
||||
}
|
||||
|
||||
function PureMultimodalInput({
|
||||
chatId,
|
||||
input,
|
||||
setInput,
|
||||
status,
|
||||
stop,
|
||||
attachments,
|
||||
setAttachments,
|
||||
messages,
|
||||
setMessages,
|
||||
sendMessage,
|
||||
className,
|
||||
selectedVisibilityType,
|
||||
selectedModelId,
|
||||
onModelChange,
|
||||
}: {
|
||||
chatId: string;
|
||||
input: string;
|
||||
setInput: Dispatch<SetStateAction<string>>;
|
||||
status: UseChatHelpers<ChatMessage>["status"];
|
||||
stop: () => void;
|
||||
attachments: Attachment[];
|
||||
setAttachments: Dispatch<SetStateAction<Attachment[]>>;
|
||||
messages: UIMessage[];
|
||||
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
|
||||
className?: string;
|
||||
selectedVisibilityType: VisibilityType;
|
||||
selectedModelId: string;
|
||||
onModelChange?: (modelId: string) => void;
|
||||
}) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { width } = useWindowSize();
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "44px";
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
adjustHeight();
|
||||
}
|
||||
}, [adjustHeight]);
|
||||
|
||||
const hasAutoFocused = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!hasAutoFocused.current && width) {
|
||||
const timer = setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
hasAutoFocused.current = true;
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [width]);
|
||||
|
||||
const resetHeight = useCallback(() => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "44px";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const [localStorageInput, setLocalStorageInput] = useLocalStorage(
|
||||
"input",
|
||||
""
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (textareaRef.current) {
|
||||
const domValue = textareaRef.current.value;
|
||||
// Prefer DOM value over localStorage to handle hydration
|
||||
const finalValue = domValue || localStorageInput || "";
|
||||
setInput(finalValue);
|
||||
adjustHeight();
|
||||
}
|
||||
// Only run once after hydration
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [adjustHeight, localStorageInput, setInput]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalStorageInput(input);
|
||||
}, [input, setLocalStorageInput]);
|
||||
|
||||
const handleInput = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInput(event.target.value);
|
||||
};
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploadQueue, setUploadQueue] = useState<string[]>([]);
|
||||
|
||||
const submitForm = useCallback(() => {
|
||||
window.history.pushState({}, "", `/chat/${chatId}`);
|
||||
|
||||
sendMessage({
|
||||
role: "user",
|
||||
parts: [
|
||||
...attachments.map((attachment) => ({
|
||||
type: "file" as const,
|
||||
url: attachment.url,
|
||||
name: attachment.name,
|
||||
mediaType: attachment.contentType,
|
||||
})),
|
||||
{
|
||||
type: "text",
|
||||
text: input,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
setAttachments([]);
|
||||
setLocalStorageInput("");
|
||||
resetHeight();
|
||||
setInput("");
|
||||
|
||||
if (width && width > 768) {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [
|
||||
input,
|
||||
setInput,
|
||||
attachments,
|
||||
sendMessage,
|
||||
setAttachments,
|
||||
setLocalStorageInput,
|
||||
width,
|
||||
chatId,
|
||||
resetHeight,
|
||||
]);
|
||||
|
||||
const uploadFile = useCallback(async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/files/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const { url, pathname, contentType } = data;
|
||||
|
||||
return {
|
||||
url,
|
||||
name: pathname,
|
||||
contentType,
|
||||
};
|
||||
}
|
||||
const { error } = await response.json();
|
||||
toast.error(error);
|
||||
} catch (_error) {
|
||||
toast.error("Failed to upload file, please try again!");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleFileChange = useCallback(
|
||||
async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
|
||||
setUploadQueue(files.map((file) => file.name));
|
||||
|
||||
try {
|
||||
const uploadPromises = files.map((file) => uploadFile(file));
|
||||
const uploadedAttachments = await Promise.all(uploadPromises);
|
||||
const successfullyUploadedAttachments = uploadedAttachments.filter(
|
||||
(attachment) => attachment !== undefined
|
||||
);
|
||||
|
||||
setAttachments((currentAttachments) => [
|
||||
...currentAttachments,
|
||||
...successfullyUploadedAttachments,
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Error uploading files!", error);
|
||||
} finally {
|
||||
setUploadQueue([]);
|
||||
}
|
||||
},
|
||||
[setAttachments, uploadFile]
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (event: ClipboardEvent) => {
|
||||
const items = event.clipboardData?.items;
|
||||
if (!items) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageItems = Array.from(items).filter((item) =>
|
||||
item.type.startsWith("image/")
|
||||
);
|
||||
|
||||
if (imageItems.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent default paste behavior for images
|
||||
event.preventDefault();
|
||||
|
||||
setUploadQueue((prev) => [...prev, "Pasted image"]);
|
||||
|
||||
try {
|
||||
const uploadPromises = imageItems
|
||||
.map((item) => item.getAsFile())
|
||||
.filter((file): file is File => file !== null)
|
||||
.map((file) => uploadFile(file));
|
||||
|
||||
const uploadedAttachments = await Promise.all(uploadPromises);
|
||||
const successfullyUploadedAttachments = uploadedAttachments.filter(
|
||||
(attachment) =>
|
||||
attachment !== undefined &&
|
||||
attachment.url !== undefined &&
|
||||
attachment.contentType !== undefined
|
||||
);
|
||||
|
||||
setAttachments((curr) => [
|
||||
...curr,
|
||||
...(successfullyUploadedAttachments as Attachment[]),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error("Error uploading pasted images:", error);
|
||||
toast.error("Failed to upload pasted image(s)");
|
||||
} finally {
|
||||
setUploadQueue([]);
|
||||
}
|
||||
},
|
||||
[setAttachments, uploadFile]
|
||||
);
|
||||
|
||||
// Add paste event listener to textarea
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.addEventListener("paste", handlePaste);
|
||||
return () => textarea.removeEventListener("paste", handlePaste);
|
||||
}, [handlePaste]);
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex w-full flex-col gap-4", className)}>
|
||||
{messages.length === 0 &&
|
||||
attachments.length === 0 &&
|
||||
uploadQueue.length === 0 && (
|
||||
<SuggestedActions
|
||||
chatId={chatId}
|
||||
selectedVisibilityType={selectedVisibilityType}
|
||||
sendMessage={sendMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<input
|
||||
className="-top-4 -left-4 pointer-events-none fixed size-0.5 opacity-0"
|
||||
multiple
|
||||
onChange={handleFileChange}
|
||||
ref={fileInputRef}
|
||||
tabIndex={-1}
|
||||
type="file"
|
||||
/>
|
||||
|
||||
<PromptInput
|
||||
className="rounded-xl border border-border bg-background p-3 shadow-xs transition-all duration-200 focus-within:border-border hover:border-muted-foreground/50"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
// Only block submission when actively streaming a response
|
||||
// All other states (ready, awaiting-message, etc.) should allow submission
|
||||
if (status === "streaming") {
|
||||
toast.error("Please wait for the model to finish its response!");
|
||||
} else {
|
||||
submitForm();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{(attachments.length > 0 || uploadQueue.length > 0) && (
|
||||
<div
|
||||
className="flex flex-row items-end gap-2 overflow-x-scroll"
|
||||
data-testid="attachments-preview"
|
||||
>
|
||||
{attachments.map((attachment) => (
|
||||
<PreviewAttachment
|
||||
attachment={attachment}
|
||||
key={attachment.url}
|
||||
onRemove={() => {
|
||||
setAttachments((currentAttachments) =>
|
||||
currentAttachments.filter((a) => a.url !== attachment.url)
|
||||
);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{uploadQueue.map((filename) => (
|
||||
<PreviewAttachment
|
||||
attachment={{
|
||||
url: "",
|
||||
name: filename,
|
||||
contentType: "",
|
||||
}}
|
||||
isUploading={true}
|
||||
key={filename}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-row items-start gap-1 sm:gap-2">
|
||||
<PromptInputTextarea
|
||||
className="grow resize-none border-0! border-none! bg-transparent p-2 text-base outline-none ring-0 [-ms-overflow-style:none] [scrollbar-width:none] placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0 [&::-webkit-scrollbar]:hidden"
|
||||
data-testid="multimodal-input"
|
||||
disableAutoResize={true}
|
||||
maxHeight={200}
|
||||
minHeight={44}
|
||||
onChange={handleInput}
|
||||
placeholder="Send a message..."
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={input}
|
||||
/>
|
||||
</div>
|
||||
<PromptInputToolbar className="border-top-0! border-t-0! p-0 shadow-none dark:border-0 dark:border-transparent!">
|
||||
<PromptInputTools className="gap-0 sm:gap-0.5">
|
||||
<AttachmentsButton
|
||||
fileInputRef={fileInputRef}
|
||||
selectedModelId={selectedModelId}
|
||||
status={status}
|
||||
/>
|
||||
<ModelSelectorCompact
|
||||
onModelChange={onModelChange}
|
||||
selectedModelId={selectedModelId}
|
||||
/>
|
||||
</PromptInputTools>
|
||||
|
||||
{status === "submitted" ? (
|
||||
<StopButton setMessages={setMessages} stop={stop} />
|
||||
) : (
|
||||
<PromptInputSubmit
|
||||
className="size-8 rounded-full bg-primary text-primary-foreground transition-colors duration-200 hover:bg-primary/90 disabled:bg-muted disabled:text-muted-foreground"
|
||||
data-testid="send-button"
|
||||
disabled={!input.trim() || uploadQueue.length > 0}
|
||||
status={status}
|
||||
>
|
||||
<ArrowUpIcon size={14} />
|
||||
</PromptInputSubmit>
|
||||
)}
|
||||
</PromptInputToolbar>
|
||||
</PromptInput>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const MultimodalInput = memo(
|
||||
PureMultimodalInput,
|
||||
(prevProps, nextProps) => {
|
||||
if (prevProps.input !== nextProps.input) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.status !== nextProps.status) {
|
||||
return false;
|
||||
}
|
||||
if (!equal(prevProps.attachments, nextProps.attachments)) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.selectedModelId !== nextProps.selectedModelId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
|
||||
function PureAttachmentsButton({
|
||||
fileInputRef,
|
||||
status,
|
||||
selectedModelId,
|
||||
}: {
|
||||
fileInputRef: React.MutableRefObject<HTMLInputElement | null>;
|
||||
status: UseChatHelpers<ChatMessage>["status"];
|
||||
selectedModelId: string;
|
||||
}) {
|
||||
const isReasoningModel =
|
||||
selectedModelId.includes("reasoning") || selectedModelId.includes("think");
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="aspect-square h-8 rounded-lg p-1 transition-colors hover:bg-accent"
|
||||
data-testid="attachments-button"
|
||||
disabled={status !== "ready" || isReasoningModel}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
<PaperclipIcon size={14} style={{ width: 14, height: 14 }} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const AttachmentsButton = memo(PureAttachmentsButton);
|
||||
|
||||
function PureModelSelectorCompact({
|
||||
selectedModelId,
|
||||
onModelChange,
|
||||
}: {
|
||||
selectedModelId: string;
|
||||
onModelChange?: (modelId: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const selectedModel =
|
||||
chatModels.find((m) => m.id === selectedModelId) ??
|
||||
chatModels.find((m) => m.id === DEFAULT_CHAT_MODEL) ??
|
||||
chatModels[0];
|
||||
const [provider] = selectedModel.id.split("/");
|
||||
|
||||
// Provider display names
|
||||
const providerNames: Record<string, string> = {
|
||||
anthropic: "Anthropic",
|
||||
openai: "OpenAI",
|
||||
google: "Google",
|
||||
xai: "xAI",
|
||||
reasoning: "Reasoning",
|
||||
};
|
||||
|
||||
return (
|
||||
<ModelSelector onOpenChange={setOpen} open={open}>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<Button className="h-8 w-[200px] justify-between px-2" variant="ghost">
|
||||
{provider && <ModelSelectorLogo provider={provider} />}
|
||||
<ModelSelectorName>{selectedModel.name}</ModelSelectorName>
|
||||
</Button>
|
||||
</ModelSelectorTrigger>
|
||||
<ModelSelectorContent>
|
||||
<ModelSelectorInput placeholder="Search models..." />
|
||||
<ModelSelectorList>
|
||||
{Object.entries(modelsByProvider).map(
|
||||
([providerKey, providerModels]) => (
|
||||
<ModelSelectorGroup
|
||||
heading={providerNames[providerKey] ?? providerKey}
|
||||
key={providerKey}
|
||||
>
|
||||
{providerModels.map((model) => {
|
||||
const logoProvider = model.id.split("/")[0];
|
||||
return (
|
||||
<ModelSelectorItem
|
||||
key={model.id}
|
||||
onSelect={() => {
|
||||
onModelChange?.(model.id);
|
||||
setCookie("chat-model", model.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
value={model.id}
|
||||
>
|
||||
<ModelSelectorLogo provider={logoProvider} />
|
||||
<ModelSelectorName>{model.name}</ModelSelectorName>
|
||||
{model.id === selectedModel.id && (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
);
|
||||
})}
|
||||
</ModelSelectorGroup>
|
||||
)
|
||||
)}
|
||||
</ModelSelectorList>
|
||||
</ModelSelectorContent>
|
||||
</ModelSelector>
|
||||
);
|
||||
}
|
||||
|
||||
const ModelSelectorCompact = memo(PureModelSelectorCompact);
|
||||
|
||||
function PureStopButton({
|
||||
stop,
|
||||
setMessages,
|
||||
}: {
|
||||
stop: () => void;
|
||||
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
className="size-7 rounded-full bg-foreground p-1 text-background transition-colors duration-200 hover:bg-foreground/90 disabled:bg-muted disabled:text-muted-foreground"
|
||||
data-testid="stop-button"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
stop();
|
||||
setMessages((messages) => messages);
|
||||
}}
|
||||
>
|
||||
<StopIcon size={14} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const StopButton = memo(PureStopButton);
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import Image from "next/image";
|
||||
import type { Attachment } from "@/lib/types";
|
||||
import { Loader } from "./elements/loader";
|
||||
import { CrossSmallIcon } from "./icons";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export const PreviewAttachment = ({
|
||||
attachment,
|
||||
isUploading = false,
|
||||
onRemove,
|
||||
}: {
|
||||
attachment: Attachment;
|
||||
isUploading?: boolean;
|
||||
onRemove?: () => void;
|
||||
}) => {
|
||||
const { name, url, contentType } = attachment;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative size-16 overflow-hidden rounded-lg border bg-muted"
|
||||
data-testid="input-attachment-preview"
|
||||
>
|
||||
{contentType?.startsWith("image") ? (
|
||||
<Image
|
||||
alt={name ?? "An image attachment"}
|
||||
className="size-full object-cover"
|
||||
height={64}
|
||||
src={url}
|
||||
width={64}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-full items-center justify-center text-muted-foreground text-xs">
|
||||
File
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUploading && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center bg-black/50"
|
||||
data-testid="input-attachment-loader"
|
||||
>
|
||||
<Loader size={16} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onRemove && !isUploading && (
|
||||
<Button
|
||||
className="absolute top-0.5 right-0.5 size-4 rounded-full p-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={onRemove}
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
<CrossSmallIcon size={8} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 truncate bg-linear-to-t from-black/80 to-transparent px-1 py-0.5 text-[10px] text-white">
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
140
demos/use_cases/vercel-ai-sdk/components/sheet-editor.tsx
Normal file
140
demos/use_cases/vercel-ai-sdk/components/sheet-editor.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { parse, unparse } from "papaparse";
|
||||
import { memo, useEffect, useMemo, useState } from "react";
|
||||
import DataGrid, { textEditor } from "react-data-grid";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import "react-data-grid/lib/styles.css";
|
||||
|
||||
type SheetEditorProps = {
|
||||
content: string;
|
||||
saveContent: (content: string, isCurrentVersion: boolean) => void;
|
||||
currentVersionIndex: number;
|
||||
isCurrentVersion: boolean;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const MIN_ROWS = 50;
|
||||
const MIN_COLS = 26;
|
||||
|
||||
const PureSpreadsheetEditor = ({ content, saveContent }: SheetEditorProps) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const parseData = useMemo(() => {
|
||||
if (!content) {
|
||||
return new Array(MIN_ROWS).fill(new Array(MIN_COLS).fill(""));
|
||||
}
|
||||
const result = parse<string[]>(content, { skipEmptyLines: true });
|
||||
|
||||
const paddedData = result.data.map((row) => {
|
||||
const paddedRow = [...row];
|
||||
while (paddedRow.length < MIN_COLS) {
|
||||
paddedRow.push("");
|
||||
}
|
||||
return paddedRow;
|
||||
});
|
||||
|
||||
while (paddedData.length < MIN_ROWS) {
|
||||
paddedData.push(new Array(MIN_COLS).fill(""));
|
||||
}
|
||||
|
||||
return paddedData;
|
||||
}, [content]);
|
||||
|
||||
const columns = useMemo(() => {
|
||||
const rowNumberColumn = {
|
||||
key: "rowNumber",
|
||||
name: "",
|
||||
frozen: true,
|
||||
width: 50,
|
||||
renderCell: ({ rowIdx }: { rowIdx: number }) => rowIdx + 1,
|
||||
cellClass: "border-t border-r dark:bg-zinc-950 dark:text-zinc-50",
|
||||
headerCellClass: "border-t border-r dark:bg-zinc-900 dark:text-zinc-50",
|
||||
};
|
||||
|
||||
const dataColumns = Array.from({ length: MIN_COLS }, (_, i) => ({
|
||||
key: i.toString(),
|
||||
name: String.fromCharCode(65 + i),
|
||||
renderEditCell: textEditor,
|
||||
width: 120,
|
||||
cellClass: cn("border-t dark:bg-zinc-950 dark:text-zinc-50", {
|
||||
"border-l": i !== 0,
|
||||
}),
|
||||
headerCellClass: cn("border-t dark:bg-zinc-900 dark:text-zinc-50", {
|
||||
"border-l": i !== 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
return [rowNumberColumn, ...dataColumns];
|
||||
}, []);
|
||||
|
||||
const initialRows = useMemo(() => {
|
||||
return parseData.map((row, rowIndex) => {
|
||||
const rowData: any = {
|
||||
id: rowIndex,
|
||||
rowNumber: rowIndex + 1,
|
||||
};
|
||||
|
||||
columns.slice(1).forEach((col, colIndex) => {
|
||||
rowData[col.key] = row[colIndex] || "";
|
||||
});
|
||||
|
||||
return rowData;
|
||||
});
|
||||
}, [parseData, columns]);
|
||||
|
||||
const [localRows, setLocalRows] = useState(initialRows);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalRows(initialRows);
|
||||
}, [initialRows]);
|
||||
|
||||
const generateCsv = (data: any[][]) => {
|
||||
return unparse(data);
|
||||
};
|
||||
|
||||
const handleRowsChange = (newRows: any[]) => {
|
||||
setLocalRows(newRows);
|
||||
|
||||
const updatedData = newRows.map((row) => {
|
||||
return columns.slice(1).map((col) => row[col.key] || "");
|
||||
});
|
||||
|
||||
const newCsvContent = generateCsv(updatedData);
|
||||
saveContent(newCsvContent, true);
|
||||
};
|
||||
|
||||
return (
|
||||
<DataGrid
|
||||
className={resolvedTheme === "dark" ? "rdg-dark" : "rdg-light"}
|
||||
columns={columns}
|
||||
defaultColumnOptions={{
|
||||
resizable: true,
|
||||
sortable: true,
|
||||
}}
|
||||
enableVirtualization
|
||||
onCellClick={(args) => {
|
||||
if (args.column.key !== "rowNumber") {
|
||||
args.selectCell(true);
|
||||
}
|
||||
}}
|
||||
onRowsChange={handleRowsChange}
|
||||
rows={localRows}
|
||||
style={{ height: "100%" }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function areEqual(prevProps: SheetEditorProps, nextProps: SheetEditorProps) {
|
||||
return (
|
||||
prevProps.currentVersionIndex === nextProps.currentVersionIndex &&
|
||||
prevProps.isCurrentVersion === nextProps.isCurrentVersion &&
|
||||
!(prevProps.status === "streaming" && nextProps.status === "streaming") &&
|
||||
prevProps.content === nextProps.content &&
|
||||
prevProps.saveContent === nextProps.saveContent
|
||||
);
|
||||
}
|
||||
|
||||
export const SpreadsheetEditor = memo(PureSpreadsheetEditor, areEqual);
|
||||
|
|
@ -0,0 +1,120 @@
|
|||
import Link from "next/link";
|
||||
import { memo } from "react";
|
||||
import { useChatVisibility } from "@/hooks/use-chat-visibility";
|
||||
import type { Chat } from "@/lib/db/schema";
|
||||
import {
|
||||
CheckCircleFillIcon,
|
||||
GlobeIcon,
|
||||
LockIcon,
|
||||
MoreHorizontalIcon,
|
||||
ShareIcon,
|
||||
TrashIcon,
|
||||
} from "./icons";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import {
|
||||
SidebarMenuAction,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "./ui/sidebar";
|
||||
|
||||
const PureChatItem = ({
|
||||
chat,
|
||||
isActive,
|
||||
onDelete,
|
||||
setOpenMobile,
|
||||
}: {
|
||||
chat: Chat;
|
||||
isActive: boolean;
|
||||
onDelete: (chatId: string) => void;
|
||||
setOpenMobile: (open: boolean) => void;
|
||||
}) => {
|
||||
const { visibilityType, setVisibilityType } = useChatVisibility({
|
||||
chatId: chat.id,
|
||||
initialVisibilityType: chat.visibility,
|
||||
});
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild isActive={isActive}>
|
||||
<Link href={`/chat/${chat.id}`} onClick={() => setOpenMobile(false)}>
|
||||
<span>{chat.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
|
||||
<DropdownMenu modal={true}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction
|
||||
className="mr-0.5 data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
showOnHover={!isActive}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
<span className="sr-only">More</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end" side="bottom">
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger className="cursor-pointer">
|
||||
<ShareIcon />
|
||||
<span>Share</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuPortal>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer flex-row justify-between"
|
||||
onClick={() => {
|
||||
setVisibilityType("private");
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<LockIcon size={12} />
|
||||
<span>Private</span>
|
||||
</div>
|
||||
{visibilityType === "private" ? (
|
||||
<CheckCircleFillIcon />
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer flex-row justify-between"
|
||||
onClick={() => {
|
||||
setVisibilityType("public");
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<GlobeIcon />
|
||||
<span>Public</span>
|
||||
</div>
|
||||
{visibilityType === "public" ? <CheckCircleFillIcon /> : null}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuSub>
|
||||
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer text-destructive focus:bg-destructive/15 focus:text-destructive dark:text-red-500"
|
||||
onSelect={() => onDelete(chat.id)}
|
||||
>
|
||||
<TrashIcon />
|
||||
<span>Delete</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export const ChatItem = memo(PureChatItem, (prevProps, nextProps) => {
|
||||
if (prevProps.isActive !== nextProps.isActive) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
376
demos/use_cases/vercel-ai-sdk/components/sidebar-history.tsx
Normal file
376
demos/use_cases/vercel-ai-sdk/components/sidebar-history.tsx
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
"use client";
|
||||
|
||||
import { isToday, isYesterday, subMonths, subWeeks } from "date-fns";
|
||||
import { motion } from "framer-motion";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import type { User } from "next-auth";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import type { Chat } from "@/lib/db/schema";
|
||||
import { fetcher } from "@/lib/utils";
|
||||
import { LoaderIcon } from "./icons";
|
||||
import { ChatItem } from "./sidebar-history-item";
|
||||
|
||||
type GroupedChats = {
|
||||
today: Chat[];
|
||||
yesterday: Chat[];
|
||||
lastWeek: Chat[];
|
||||
lastMonth: Chat[];
|
||||
older: Chat[];
|
||||
};
|
||||
|
||||
export type ChatHistory = {
|
||||
chats: Chat[];
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
const groupChatsByDate = (chats: Chat[]): GroupedChats => {
|
||||
const now = new Date();
|
||||
const oneWeekAgo = subWeeks(now, 1);
|
||||
const oneMonthAgo = subMonths(now, 1);
|
||||
|
||||
return chats.reduce(
|
||||
(groups, chat) => {
|
||||
const chatDate = new Date(chat.createdAt);
|
||||
|
||||
if (isToday(chatDate)) {
|
||||
groups.today.push(chat);
|
||||
} else if (isYesterday(chatDate)) {
|
||||
groups.yesterday.push(chat);
|
||||
} else if (chatDate > oneWeekAgo) {
|
||||
groups.lastWeek.push(chat);
|
||||
} else if (chatDate > oneMonthAgo) {
|
||||
groups.lastMonth.push(chat);
|
||||
} else {
|
||||
groups.older.push(chat);
|
||||
}
|
||||
|
||||
return groups;
|
||||
},
|
||||
{
|
||||
today: [],
|
||||
yesterday: [],
|
||||
lastWeek: [],
|
||||
lastMonth: [],
|
||||
older: [],
|
||||
} as GroupedChats
|
||||
);
|
||||
};
|
||||
|
||||
export function getChatHistoryPaginationKey(
|
||||
pageIndex: number,
|
||||
previousPageData: ChatHistory
|
||||
) {
|
||||
if (previousPageData && previousPageData.hasMore === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (pageIndex === 0) {
|
||||
return `/api/history?limit=${PAGE_SIZE}`;
|
||||
}
|
||||
|
||||
const firstChatFromPage = previousPageData.chats.at(-1);
|
||||
|
||||
if (!firstChatFromPage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/api/history?ending_before=${firstChatFromPage.id}&limit=${PAGE_SIZE}`;
|
||||
}
|
||||
|
||||
export function SidebarHistory({ user }: { user: User | undefined }) {
|
||||
const { setOpenMobile } = useSidebar();
|
||||
const pathname = usePathname();
|
||||
const id = pathname?.startsWith("/chat/") ? pathname.split("/")[2] : null;
|
||||
|
||||
const {
|
||||
data: paginatedChatHistories,
|
||||
setSize,
|
||||
isValidating,
|
||||
isLoading,
|
||||
mutate,
|
||||
} = useSWRInfinite<ChatHistory>(getChatHistoryPaginationKey, fetcher, {
|
||||
fallbackData: [],
|
||||
});
|
||||
|
||||
const router = useRouter();
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
|
||||
const hasReachedEnd = paginatedChatHistories
|
||||
? paginatedChatHistories.some((page) => page.hasMore === false)
|
||||
: false;
|
||||
|
||||
const hasEmptyChatHistory = paginatedChatHistories
|
||||
? paginatedChatHistories.every((page) => page.chats.length === 0)
|
||||
: false;
|
||||
|
||||
const handleDelete = () => {
|
||||
const chatToDelete = deleteId;
|
||||
const isCurrentChat = pathname === `/chat/${chatToDelete}`;
|
||||
|
||||
setShowDeleteDialog(false);
|
||||
|
||||
const deletePromise = fetch(`/api/chat?id=${chatToDelete}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
toast.promise(deletePromise, {
|
||||
loading: "Deleting chat...",
|
||||
success: () => {
|
||||
mutate((chatHistories) => {
|
||||
if (chatHistories) {
|
||||
return chatHistories.map((chatHistory) => ({
|
||||
...chatHistory,
|
||||
chats: chatHistory.chats.filter(
|
||||
(chat) => chat.id !== chatToDelete
|
||||
),
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
if (isCurrentChat) {
|
||||
router.replace("/");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
return "Chat deleted successfully";
|
||||
},
|
||||
error: "Failed to delete chat",
|
||||
});
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
|
||||
Login to save and revisit previous chats!
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
|
||||
Today
|
||||
</div>
|
||||
<SidebarGroupContent>
|
||||
<div className="flex flex-col">
|
||||
{[44, 32, 28, 64, 52].map((item) => (
|
||||
<div
|
||||
className="flex h-8 items-center gap-2 rounded-md px-2"
|
||||
key={item}
|
||||
>
|
||||
<div
|
||||
className="h-4 max-w-(--skeleton-width) flex-1 rounded-md bg-sidebar-accent-foreground/10"
|
||||
style={
|
||||
{
|
||||
"--skeleton-width": `${item}%`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasEmptyChatHistory) {
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<div className="flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
|
||||
Your conversations will appear here once you start chatting!
|
||||
</div>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{paginatedChatHistories &&
|
||||
(() => {
|
||||
const chatsFromHistory = paginatedChatHistories.flatMap(
|
||||
(paginatedChatHistory) => paginatedChatHistory.chats
|
||||
);
|
||||
|
||||
const groupedChats = groupChatsByDate(chatsFromHistory);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{groupedChats.today.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
|
||||
Today
|
||||
</div>
|
||||
{groupedChats.today.map((chat) => (
|
||||
<ChatItem
|
||||
chat={chat}
|
||||
isActive={chat.id === id}
|
||||
key={chat.id}
|
||||
onDelete={(chatId) => {
|
||||
setDeleteId(chatId);
|
||||
setShowDeleteDialog(true);
|
||||
}}
|
||||
setOpenMobile={setOpenMobile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedChats.yesterday.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
|
||||
Yesterday
|
||||
</div>
|
||||
{groupedChats.yesterday.map((chat) => (
|
||||
<ChatItem
|
||||
chat={chat}
|
||||
isActive={chat.id === id}
|
||||
key={chat.id}
|
||||
onDelete={(chatId) => {
|
||||
setDeleteId(chatId);
|
||||
setShowDeleteDialog(true);
|
||||
}}
|
||||
setOpenMobile={setOpenMobile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedChats.lastWeek.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
|
||||
Last 7 days
|
||||
</div>
|
||||
{groupedChats.lastWeek.map((chat) => (
|
||||
<ChatItem
|
||||
chat={chat}
|
||||
isActive={chat.id === id}
|
||||
key={chat.id}
|
||||
onDelete={(chatId) => {
|
||||
setDeleteId(chatId);
|
||||
setShowDeleteDialog(true);
|
||||
}}
|
||||
setOpenMobile={setOpenMobile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedChats.lastMonth.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
|
||||
Last 30 days
|
||||
</div>
|
||||
{groupedChats.lastMonth.map((chat) => (
|
||||
<ChatItem
|
||||
chat={chat}
|
||||
isActive={chat.id === id}
|
||||
key={chat.id}
|
||||
onDelete={(chatId) => {
|
||||
setDeleteId(chatId);
|
||||
setShowDeleteDialog(true);
|
||||
}}
|
||||
setOpenMobile={setOpenMobile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupedChats.older.length > 0 && (
|
||||
<div>
|
||||
<div className="px-2 py-1 text-sidebar-foreground/50 text-xs">
|
||||
Older than last month
|
||||
</div>
|
||||
{groupedChats.older.map((chat) => (
|
||||
<ChatItem
|
||||
chat={chat}
|
||||
isActive={chat.id === id}
|
||||
key={chat.id}
|
||||
onDelete={(chatId) => {
|
||||
setDeleteId(chatId);
|
||||
setShowDeleteDialog(true);
|
||||
}}
|
||||
setOpenMobile={setOpenMobile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</SidebarMenu>
|
||||
|
||||
<motion.div
|
||||
onViewportEnter={() => {
|
||||
if (!isValidating && !hasReachedEnd) {
|
||||
setSize((size) => size + 1);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{hasReachedEnd ? (
|
||||
<div className="mt-8 flex w-full flex-row items-center justify-center gap-2 px-2 text-sm text-zinc-500">
|
||||
You have reached the end of your chat history.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8 flex flex-row items-center gap-2 p-2 text-zinc-500 dark:text-zinc-400">
|
||||
<div className="animate-spin">
|
||||
<LoaderIcon />
|
||||
</div>
|
||||
<div>Loading Chats...</div>
|
||||
</div>
|
||||
)}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
<AlertDialog onOpenChange={setShowDeleteDialog} open={showDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete your
|
||||
chat and remove it from our servers.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>
|
||||
Continue
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
35
demos/use_cases/vercel-ai-sdk/components/sidebar-toggle.tsx
Normal file
35
demos/use_cases/vercel-ai-sdk/components/sidebar-toggle.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { ComponentProps } from "react";
|
||||
|
||||
import { type SidebarTrigger, useSidebar } from "@/components/ui/sidebar";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SidebarLeftIcon } from "./icons";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export function SidebarToggle({
|
||||
className,
|
||||
}: ComponentProps<typeof SidebarTrigger>) {
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className={cn("h-8 px-2 md:h-fit md:px-2", className)}
|
||||
data-testid="sidebar-toggle-button"
|
||||
onClick={toggleSidebar}
|
||||
variant="outline"
|
||||
>
|
||||
<SidebarLeftIcon size={16} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="start" className="hidden md:block">
|
||||
Toggle Sidebar
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
115
demos/use_cases/vercel-ai-sdk/components/sidebar-user-nav.tsx
Normal file
115
demos/use_cases/vercel-ai-sdk/components/sidebar-user-nav.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronUp } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { User } from "next-auth";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { guestRegex } from "@/lib/constants";
|
||||
import { LoaderIcon } from "./icons";
|
||||
import { toast } from "./toast";
|
||||
|
||||
export function SidebarUserNav({ user }: { user: User }) {
|
||||
const router = useRouter();
|
||||
const { data, status } = useSession();
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
|
||||
const isGuest = guestRegex.test(data?.user?.email ?? "");
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{status === "loading" ? (
|
||||
<SidebarMenuButton className="h-10 justify-between bg-background data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground">
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="size-6 animate-pulse rounded-full bg-zinc-500/30" />
|
||||
<span className="animate-pulse rounded-md bg-zinc-500/30 text-transparent">
|
||||
Loading auth status
|
||||
</span>
|
||||
</div>
|
||||
<div className="animate-spin text-zinc-500">
|
||||
<LoaderIcon />
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
) : (
|
||||
<SidebarMenuButton
|
||||
className="h-10 bg-background data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
data-testid="user-nav-button"
|
||||
>
|
||||
<Image
|
||||
alt={user.email ?? "User Avatar"}
|
||||
className="rounded-full"
|
||||
height={24}
|
||||
src={`https://avatar.vercel.sh/${user.email}`}
|
||||
width={24}
|
||||
/>
|
||||
<span className="truncate" data-testid="user-email">
|
||||
{isGuest ? "Guest" : user?.email}
|
||||
</span>
|
||||
<ChevronUp className="ml-auto" />
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-(--radix-popper-anchor-width)"
|
||||
data-testid="user-nav-menu"
|
||||
side="top"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
data-testid="user-nav-item-theme"
|
||||
onSelect={() =>
|
||||
setTheme(resolvedTheme === "dark" ? "light" : "dark")
|
||||
}
|
||||
>
|
||||
{`Toggle ${resolvedTheme === "light" ? "dark" : "light"} mode`}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild data-testid="user-nav-item-auth">
|
||||
<button
|
||||
className="w-full cursor-pointer"
|
||||
onClick={() => {
|
||||
if (status === "loading") {
|
||||
toast({
|
||||
type: "error",
|
||||
description:
|
||||
"Checking authentication status, please try again!",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (isGuest) {
|
||||
router.push("/login");
|
||||
} else {
|
||||
signOut({
|
||||
redirectTo: "/",
|
||||
});
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{isGuest ? "Login to your account" : "Sign out"}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
||||
25
demos/use_cases/vercel-ai-sdk/components/sign-out-form.tsx
Normal file
25
demos/use_cases/vercel-ai-sdk/components/sign-out-form.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import Form from "next/form";
|
||||
|
||||
import { signOut } from "@/app/(auth)/auth";
|
||||
|
||||
export const SignOutForm = () => {
|
||||
return (
|
||||
<Form
|
||||
action={async () => {
|
||||
"use server";
|
||||
|
||||
await signOut({
|
||||
redirectTo: "/",
|
||||
});
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<button
|
||||
className="w-full px-1 py-0.5 text-left text-red-500"
|
||||
type="submit"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
38
demos/use_cases/vercel-ai-sdk/components/submit-button.tsx
Normal file
38
demos/use_cases/vercel-ai-sdk/components/submit-button.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
import { LoaderIcon } from "@/components/icons";
|
||||
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export function SubmitButton({
|
||||
children,
|
||||
isSuccessful,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isSuccessful: boolean;
|
||||
}) {
|
||||
const { pending } = useFormStatus();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-disabled={pending || isSuccessful}
|
||||
className="relative"
|
||||
disabled={pending || isSuccessful}
|
||||
type={pending ? "button" : "submit"}
|
||||
>
|
||||
{children}
|
||||
|
||||
{(pending || isSuccessful) && (
|
||||
<span className="absolute right-4 animate-spin">
|
||||
<LoaderIcon />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<output aria-live="polite" className="sr-only">
|
||||
{pending || isSuccessful ? "Loading" : "Submit form"}
|
||||
</output>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
|
||||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { memo } from "react";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { Suggestion } from "./elements/suggestion";
|
||||
import type { VisibilityType } from "./visibility-selector";
|
||||
|
||||
type SuggestedActionsProps = {
|
||||
chatId: string;
|
||||
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
|
||||
selectedVisibilityType: VisibilityType;
|
||||
};
|
||||
|
||||
function PureSuggestedActions({ chatId, sendMessage }: SuggestedActionsProps) {
|
||||
const suggestedActions = [
|
||||
"Get the currency exchange rate for USD to EUR",
|
||||
"What is the weather in San Francisco?",
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid w-full gap-2 sm:grid-cols-2"
|
||||
data-testid="suggested-actions"
|
||||
>
|
||||
{suggestedActions.map((suggestedAction, index) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
key={suggestedAction}
|
||||
transition={{ delay: 0.05 * index }}
|
||||
>
|
||||
<Suggestion
|
||||
className="h-auto w-full whitespace-normal p-3 text-left"
|
||||
onClick={(suggestion) => {
|
||||
window.history.pushState({}, "", `/chat/${chatId}`);
|
||||
sendMessage({
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: suggestion }],
|
||||
});
|
||||
}}
|
||||
suggestion={suggestedAction}
|
||||
>
|
||||
{suggestedAction}
|
||||
</Suggestion>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const SuggestedActions = memo(
|
||||
PureSuggestedActions,
|
||||
(prevProps, nextProps) => {
|
||||
if (prevProps.chatId !== nextProps.chatId) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.selectedVisibilityType !== nextProps.selectedVisibilityType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
);
|
||||
77
demos/use_cases/vercel-ai-sdk/components/suggestion.tsx
Normal file
77
demos/use_cases/vercel-ai-sdk/components/suggestion.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { useWindowSize } from "usehooks-ts";
|
||||
|
||||
import type { UISuggestion } from "@/lib/editor/suggestions";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ArtifactKind } from "./artifact";
|
||||
import { CrossIcon, MessageIcon } from "./icons";
|
||||
import { Button } from "./ui/button";
|
||||
|
||||
export const Suggestion = ({
|
||||
suggestion,
|
||||
onApply,
|
||||
artifactKind,
|
||||
}: {
|
||||
suggestion: UISuggestion;
|
||||
onApply: () => void;
|
||||
artifactKind: ArtifactKind;
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const { width: windowWidth } = useWindowSize();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isExpanded ? (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, y: -20 }}
|
||||
className="-right-12 md:-right-16 absolute z-50 flex w-56 flex-col gap-3 rounded-2xl border bg-background p-3 font-sans text-sm shadow-xl"
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
key={suggestion.id}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 30 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<div className="size-4 rounded-full bg-muted-foreground/25" />
|
||||
<div className="font-medium">Assistant</div>
|
||||
</div>
|
||||
<button
|
||||
className="cursor-pointer text-gray-500 text-xs"
|
||||
onClick={() => {
|
||||
setIsExpanded(false);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<CrossIcon size={12} />
|
||||
</button>
|
||||
</div>
|
||||
<div>{suggestion.description}</div>
|
||||
<Button
|
||||
className="w-fit rounded-full px-3 py-1.5"
|
||||
onClick={onApply}
|
||||
variant="outline"
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
className={cn("cursor-pointer p-1 text-muted-foreground", {
|
||||
"-right-8 absolute": artifactKind === "text",
|
||||
"sticky top-0 right-4": artifactKind === "code",
|
||||
})}
|
||||
onClick={() => {
|
||||
setIsExpanded(true);
|
||||
}}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
>
|
||||
<MessageIcon size={windowWidth && windowWidth < 768 ? 16 : 14} />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
164
demos/use_cases/vercel-ai-sdk/components/text-editor.tsx
Normal file
164
demos/use_cases/vercel-ai-sdk/components/text-editor.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"use client";
|
||||
|
||||
import { exampleSetup } from "prosemirror-example-setup";
|
||||
import { inputRules } from "prosemirror-inputrules";
|
||||
import { EditorState } from "prosemirror-state";
|
||||
import { EditorView } from "prosemirror-view";
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
|
||||
import type { Suggestion } from "@/lib/db/schema";
|
||||
import {
|
||||
documentSchema,
|
||||
handleTransaction,
|
||||
headingRule,
|
||||
} from "@/lib/editor/config";
|
||||
import {
|
||||
buildContentFromDocument,
|
||||
buildDocumentFromContent,
|
||||
createDecorations,
|
||||
} from "@/lib/editor/functions";
|
||||
import {
|
||||
projectWithPositions,
|
||||
suggestionsPlugin,
|
||||
suggestionsPluginKey,
|
||||
} from "@/lib/editor/suggestions";
|
||||
|
||||
type EditorProps = {
|
||||
content: string;
|
||||
onSaveContent: (updatedContent: string, debounce: boolean) => void;
|
||||
status: "streaming" | "idle";
|
||||
isCurrentVersion: boolean;
|
||||
currentVersionIndex: number;
|
||||
suggestions: Suggestion[];
|
||||
};
|
||||
|
||||
function PureEditor({
|
||||
content,
|
||||
onSaveContent,
|
||||
suggestions,
|
||||
status,
|
||||
}: EditorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const editorRef = useRef<EditorView | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerRef.current && !editorRef.current) {
|
||||
const state = EditorState.create({
|
||||
doc: buildDocumentFromContent(content),
|
||||
plugins: [
|
||||
...exampleSetup({ schema: documentSchema, menuBar: false }),
|
||||
inputRules({
|
||||
rules: [
|
||||
headingRule(1),
|
||||
headingRule(2),
|
||||
headingRule(3),
|
||||
headingRule(4),
|
||||
headingRule(5),
|
||||
headingRule(6),
|
||||
],
|
||||
}),
|
||||
suggestionsPlugin,
|
||||
],
|
||||
});
|
||||
|
||||
editorRef.current = new EditorView(containerRef.current, {
|
||||
state,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.destroy();
|
||||
editorRef.current = null;
|
||||
}
|
||||
};
|
||||
// NOTE: we only want to run this effect once
|
||||
// eslint-disable-next-line
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current) {
|
||||
editorRef.current.setProps({
|
||||
dispatchTransaction: (transaction) => {
|
||||
handleTransaction({
|
||||
transaction,
|
||||
editorRef,
|
||||
onSaveContent,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [onSaveContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current && content) {
|
||||
const currentContent = buildContentFromDocument(
|
||||
editorRef.current.state.doc
|
||||
);
|
||||
|
||||
if (status === "streaming") {
|
||||
const newDocument = buildDocumentFromContent(content);
|
||||
|
||||
const transaction = editorRef.current.state.tr.replaceWith(
|
||||
0,
|
||||
editorRef.current.state.doc.content.size,
|
||||
newDocument.content
|
||||
);
|
||||
|
||||
transaction.setMeta("no-save", true);
|
||||
editorRef.current.dispatch(transaction);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentContent !== content) {
|
||||
const newDocument = buildDocumentFromContent(content);
|
||||
|
||||
const transaction = editorRef.current.state.tr.replaceWith(
|
||||
0,
|
||||
editorRef.current.state.doc.content.size,
|
||||
newDocument.content
|
||||
);
|
||||
|
||||
transaction.setMeta("no-save", true);
|
||||
editorRef.current.dispatch(transaction);
|
||||
}
|
||||
}
|
||||
}, [content, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editorRef.current?.state.doc && content) {
|
||||
const projectedSuggestions = projectWithPositions(
|
||||
editorRef.current.state.doc,
|
||||
suggestions
|
||||
).filter(
|
||||
(suggestion) => suggestion.selectionStart && suggestion.selectionEnd
|
||||
);
|
||||
|
||||
const decorations = createDecorations(
|
||||
projectedSuggestions,
|
||||
editorRef.current
|
||||
);
|
||||
|
||||
const transaction = editorRef.current.state.tr;
|
||||
transaction.setMeta(suggestionsPluginKey, { decorations });
|
||||
editorRef.current.dispatch(transaction);
|
||||
}
|
||||
}, [suggestions, content]);
|
||||
|
||||
return (
|
||||
<div className="prose dark:prose-invert relative" ref={containerRef} />
|
||||
);
|
||||
}
|
||||
|
||||
function areEqual(prevProps: EditorProps, nextProps: EditorProps) {
|
||||
return (
|
||||
prevProps.suggestions === nextProps.suggestions &&
|
||||
prevProps.currentVersionIndex === nextProps.currentVersionIndex &&
|
||||
prevProps.isCurrentVersion === nextProps.isCurrentVersion &&
|
||||
!(prevProps.status === "streaming" && nextProps.status === "streaming") &&
|
||||
prevProps.content === nextProps.content &&
|
||||
prevProps.onSaveContent === nextProps.onSaveContent
|
||||
);
|
||||
}
|
||||
|
||||
export const Editor = memo(PureEditor, areEqual);
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
"use client";
|
||||
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import type { ThemeProviderProps } from "next-themes/dist/types";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
75
demos/use_cases/vercel-ai-sdk/components/toast.tsx
Normal file
75
demos/use_cases/vercel-ai-sdk/components/toast.tsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { toast as sonnerToast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckCircleFillIcon, WarningIcon } from "./icons";
|
||||
|
||||
const iconsByType: Record<"success" | "error", ReactNode> = {
|
||||
success: <CheckCircleFillIcon />,
|
||||
error: <WarningIcon />,
|
||||
};
|
||||
|
||||
export function toast(props: Omit<ToastProps, "id">) {
|
||||
return sonnerToast.custom((id) => (
|
||||
<Toast description={props.description} id={id} type={props.type} />
|
||||
));
|
||||
}
|
||||
|
||||
function Toast(props: ToastProps) {
|
||||
const { id, type, description } = props;
|
||||
|
||||
const descriptionRef = useRef<HTMLDivElement>(null);
|
||||
const [multiLine, setMultiLine] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const el = descriptionRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
|
||||
const update = () => {
|
||||
const lineHeight = Number.parseFloat(getComputedStyle(el).lineHeight);
|
||||
const lines = Math.round(el.scrollHeight / lineHeight);
|
||||
setMultiLine(lines > 1);
|
||||
};
|
||||
|
||||
update(); // initial check
|
||||
const ro = new ResizeObserver(update); // re-check on width changes
|
||||
ro.observe(el);
|
||||
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex toast-mobile:w-[356px] w-full justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
"flex toast-mobile:w-fit w-full flex-row gap-3 rounded-lg bg-zinc-100 p-3",
|
||||
multiLine ? "items-start" : "items-center"
|
||||
)}
|
||||
data-testid="toast"
|
||||
key={id}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"data-[type=error]:text-red-600 data-[type=success]:text-green-600",
|
||||
{ "pt-1": multiLine }
|
||||
)}
|
||||
data-type={type}
|
||||
>
|
||||
{iconsByType[type]}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-950" ref={descriptionRef}>
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ToastProps = {
|
||||
id: string | number;
|
||||
type: "success" | "error";
|
||||
description: string;
|
||||
};
|
||||
476
demos/use_cases/vercel-ai-sdk/components/toolbar.tsx
Normal file
476
demos/use_cases/vercel-ai-sdk/components/toolbar.tsx
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
"use client";
|
||||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import cx from "classnames";
|
||||
import {
|
||||
AnimatePresence,
|
||||
motion,
|
||||
useMotionValue,
|
||||
useTransform,
|
||||
} from "framer-motion";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
type Dispatch,
|
||||
memo,
|
||||
type ReactNode,
|
||||
type SetStateAction,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useOnClickOutside } from "usehooks-ts";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { type ArtifactKind, artifactDefinitions } from "./artifact";
|
||||
import type { ArtifactToolbarItem } from "./create-artifact";
|
||||
import { ArrowUpIcon, StopIcon, SummarizeIcon } from "./icons";
|
||||
|
||||
type ToolProps = {
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
selectedTool: string | null;
|
||||
setSelectedTool: Dispatch<SetStateAction<string | null>>;
|
||||
isToolbarVisible?: boolean;
|
||||
setIsToolbarVisible?: Dispatch<SetStateAction<boolean>>;
|
||||
isAnimating: boolean;
|
||||
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
|
||||
onClick: ({
|
||||
sendMessage,
|
||||
}: {
|
||||
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
|
||||
}) => void;
|
||||
};
|
||||
|
||||
const Tool = ({
|
||||
description,
|
||||
icon,
|
||||
selectedTool,
|
||||
setSelectedTool,
|
||||
isToolbarVisible,
|
||||
setIsToolbarVisible,
|
||||
isAnimating,
|
||||
sendMessage,
|
||||
onClick,
|
||||
}: ToolProps) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedTool !== description) {
|
||||
setIsHovered(false);
|
||||
}
|
||||
}, [selectedTool, description]);
|
||||
|
||||
const handleSelect = () => {
|
||||
if (!isToolbarVisible && setIsToolbarVisible) {
|
||||
setIsToolbarVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedTool) {
|
||||
setIsHovered(true);
|
||||
setSelectedTool(description);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedTool !== description) {
|
||||
setSelectedTool(description);
|
||||
} else {
|
||||
setSelectedTool(null);
|
||||
onClick({ sendMessage });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip open={isHovered && !isAnimating}>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.div
|
||||
animate={{ opacity: 1, transition: { delay: 0.1 } }}
|
||||
className={cx("rounded-full p-3", {
|
||||
"bg-primary text-primary-foreground!": selectedTool === description,
|
||||
})}
|
||||
exit={{
|
||||
scale: 0.9,
|
||||
opacity: 0,
|
||||
transition: { duration: 0.1 },
|
||||
}}
|
||||
initial={{ scale: 1, opacity: 0 }}
|
||||
onClick={() => {
|
||||
handleSelect();
|
||||
}}
|
||||
onHoverEnd={() => {
|
||||
if (selectedTool !== description) {
|
||||
setIsHovered(false);
|
||||
}
|
||||
}}
|
||||
onHoverStart={() => {
|
||||
setIsHovered(true);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
handleSelect();
|
||||
}
|
||||
}}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{selectedTool === description ? <ArrowUpIcon /> : icon}
|
||||
</motion.div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="rounded-2xl bg-foreground p-3 px-4 text-background"
|
||||
side="left"
|
||||
sideOffset={16}
|
||||
>
|
||||
{description}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const randomArr = [...new Array(6)].map((_x) => nanoid(5));
|
||||
|
||||
const ReadingLevelSelector = ({
|
||||
setSelectedTool,
|
||||
sendMessage,
|
||||
isAnimating,
|
||||
}: {
|
||||
setSelectedTool: Dispatch<SetStateAction<string | null>>;
|
||||
isAnimating: boolean;
|
||||
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
|
||||
}) => {
|
||||
const LEVELS = [
|
||||
"Elementary",
|
||||
"Middle School",
|
||||
"Keep current level",
|
||||
"High School",
|
||||
"College",
|
||||
"Graduate",
|
||||
];
|
||||
|
||||
const y = useMotionValue(-40 * 2);
|
||||
const dragConstraints = 5 * 40 + 2;
|
||||
const yToLevel = useTransform(y, [0, -dragConstraints], [0, 5]);
|
||||
|
||||
const [currentLevel, setCurrentLevel] = useState(2);
|
||||
const [hasUserSelectedLevel, setHasUserSelectedLevel] =
|
||||
useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = yToLevel.on("change", (latest) => {
|
||||
const level = Math.min(5, Math.max(0, Math.round(Math.abs(latest))));
|
||||
setCurrentLevel(level);
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [yToLevel]);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-center justify-end">
|
||||
{randomArr.map((id) => (
|
||||
<motion.div
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex size-[40px] flex-row items-center justify-center"
|
||||
exit={{ opacity: 0 }}
|
||||
initial={{ opacity: 0 }}
|
||||
key={id}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<div className="size-2 rounded-full bg-muted-foreground/40" />
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
<TooltipProvider>
|
||||
<Tooltip open={!isAnimating}>
|
||||
<TooltipTrigger asChild>
|
||||
<motion.div
|
||||
className={cx(
|
||||
"absolute flex flex-row items-center rounded-full border bg-background p-3",
|
||||
{
|
||||
"bg-primary text-primary-foreground": currentLevel !== 2,
|
||||
"bg-background text-foreground": currentLevel === 2,
|
||||
}
|
||||
)}
|
||||
drag="y"
|
||||
dragConstraints={{ top: -dragConstraints, bottom: 0 }}
|
||||
dragElastic={0}
|
||||
dragMomentum={false}
|
||||
onClick={() => {
|
||||
if (currentLevel !== 2 && hasUserSelectedLevel) {
|
||||
sendMessage({
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Please adjust the reading level to ${LEVELS[currentLevel]} level.`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
setSelectedTool(null);
|
||||
}
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
if (currentLevel === 2) {
|
||||
setSelectedTool(null);
|
||||
} else {
|
||||
setHasUserSelectedLevel(true);
|
||||
}
|
||||
}}
|
||||
onDragStart={() => {
|
||||
setHasUserSelectedLevel(false);
|
||||
}}
|
||||
style={{ y }}
|
||||
transition={{ duration: 0.1 }}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{currentLevel === 2 ? <SummarizeIcon /> : <ArrowUpIcon />}
|
||||
</motion.div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
className="rounded-2xl bg-foreground p-3 px-4 text-background text-sm"
|
||||
side="left"
|
||||
sideOffset={16}
|
||||
>
|
||||
{LEVELS[currentLevel]}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Tools = ({
|
||||
isToolbarVisible,
|
||||
selectedTool,
|
||||
setSelectedTool,
|
||||
sendMessage,
|
||||
isAnimating,
|
||||
setIsToolbarVisible,
|
||||
tools,
|
||||
}: {
|
||||
isToolbarVisible: boolean;
|
||||
selectedTool: string | null;
|
||||
setSelectedTool: Dispatch<SetStateAction<string | null>>;
|
||||
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
|
||||
isAnimating: boolean;
|
||||
setIsToolbarVisible: Dispatch<SetStateAction<boolean>>;
|
||||
tools: ArtifactToolbarItem[];
|
||||
}) => {
|
||||
const [primaryTool, ...secondaryTools] = tools;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="flex flex-col gap-1.5"
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{isToolbarVisible &&
|
||||
secondaryTools.map((secondaryTool) => (
|
||||
<Tool
|
||||
description={secondaryTool.description}
|
||||
icon={secondaryTool.icon}
|
||||
isAnimating={isAnimating}
|
||||
key={secondaryTool.description}
|
||||
onClick={secondaryTool.onClick}
|
||||
selectedTool={selectedTool}
|
||||
sendMessage={sendMessage}
|
||||
setSelectedTool={setSelectedTool}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
<Tool
|
||||
description={primaryTool.description}
|
||||
icon={primaryTool.icon}
|
||||
isAnimating={isAnimating}
|
||||
isToolbarVisible={isToolbarVisible}
|
||||
onClick={primaryTool.onClick}
|
||||
selectedTool={selectedTool}
|
||||
sendMessage={sendMessage}
|
||||
setIsToolbarVisible={setIsToolbarVisible}
|
||||
setSelectedTool={setSelectedTool}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const PureToolbar = ({
|
||||
isToolbarVisible,
|
||||
setIsToolbarVisible,
|
||||
sendMessage,
|
||||
status,
|
||||
stop,
|
||||
setMessages,
|
||||
artifactKind,
|
||||
}: {
|
||||
isToolbarVisible: boolean;
|
||||
setIsToolbarVisible: Dispatch<SetStateAction<boolean>>;
|
||||
status: UseChatHelpers<ChatMessage>["status"];
|
||||
sendMessage: UseChatHelpers<ChatMessage>["sendMessage"];
|
||||
stop: UseChatHelpers<ChatMessage>["stop"];
|
||||
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||
artifactKind: ArtifactKind;
|
||||
}) => {
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const [selectedTool, setSelectedTool] = useState<string | null>(null);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
useOnClickOutside(toolbarRef, () => {
|
||||
setIsToolbarVisible(false);
|
||||
setSelectedTool(null);
|
||||
});
|
||||
|
||||
const startCloseTimer = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setSelectedTool(null);
|
||||
setIsToolbarVisible(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const cancelCloseTimer = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "streaming") {
|
||||
setIsToolbarVisible(false);
|
||||
}
|
||||
}, [status, setIsToolbarVisible]);
|
||||
|
||||
const artifactDefinition = artifactDefinitions.find(
|
||||
(definition) => definition.kind === artifactKind
|
||||
);
|
||||
|
||||
if (!artifactDefinition) {
|
||||
throw new Error("Artifact definition not found!");
|
||||
}
|
||||
|
||||
const toolsByArtifactKind = artifactDefinition.toolbar;
|
||||
|
||||
if (toolsByArtifactKind.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<motion.div
|
||||
animate={
|
||||
isToolbarVisible
|
||||
? selectedTool === "adjust-reading-level"
|
||||
? {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
height: 6 * 43,
|
||||
transition: { delay: 0 },
|
||||
scale: 0.95,
|
||||
}
|
||||
: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
height: toolsByArtifactKind.length * 50,
|
||||
transition: { delay: 0 },
|
||||
scale: 1,
|
||||
}
|
||||
: { opacity: 1, y: 0, height: 54, transition: { delay: 0 } }
|
||||
}
|
||||
className="absolute right-6 bottom-6 flex cursor-pointer flex-col justify-end rounded-full border bg-background p-1.5 shadow-lg"
|
||||
exit={{ opacity: 0, y: -20, transition: { duration: 0.1 } }}
|
||||
initial={{ opacity: 0, y: -20, scale: 1 }}
|
||||
onAnimationComplete={() => {
|
||||
setIsAnimating(false);
|
||||
}}
|
||||
onAnimationStart={() => {
|
||||
setIsAnimating(true);
|
||||
}}
|
||||
onHoverEnd={() => {
|
||||
if (status === "streaming") {
|
||||
return;
|
||||
}
|
||||
|
||||
startCloseTimer();
|
||||
}}
|
||||
onHoverStart={() => {
|
||||
if (status === "streaming") {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelCloseTimer();
|
||||
setIsToolbarVisible(true);
|
||||
}}
|
||||
ref={toolbarRef}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
>
|
||||
{status === "streaming" ? (
|
||||
<motion.div
|
||||
animate={{ scale: 1.4 }}
|
||||
className="p-3"
|
||||
exit={{ scale: 1 }}
|
||||
initial={{ scale: 1 }}
|
||||
key="stop-icon"
|
||||
onClick={() => {
|
||||
stop();
|
||||
setMessages((messages) => messages);
|
||||
}}
|
||||
>
|
||||
<StopIcon />
|
||||
</motion.div>
|
||||
) : selectedTool === "adjust-reading-level" ? (
|
||||
<ReadingLevelSelector
|
||||
isAnimating={isAnimating}
|
||||
key="reading-level-selector"
|
||||
sendMessage={sendMessage}
|
||||
setSelectedTool={setSelectedTool}
|
||||
/>
|
||||
) : (
|
||||
<Tools
|
||||
isAnimating={isAnimating}
|
||||
isToolbarVisible={isToolbarVisible}
|
||||
key="tools"
|
||||
selectedTool={selectedTool}
|
||||
sendMessage={sendMessage}
|
||||
setIsToolbarVisible={setIsToolbarVisible}
|
||||
setSelectedTool={setSelectedTool}
|
||||
tools={toolsByArtifactKind}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const Toolbar = memo(PureToolbar, (prevProps, nextProps) => {
|
||||
if (prevProps.status !== nextProps.status) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.isToolbarVisible !== nextProps.isToolbarVisible) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.artifactKind !== nextProps.artifactKind) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
140
demos/use_cases/vercel-ai-sdk/components/ui/alert-dialog.tsx
Normal file
140
demos/use_cases/vercel-ai-sdk/components/ui/alert-dialog.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
"use client";
|
||||
|
||||
import { AlertDialog as AlertDialogPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root;
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal;
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
));
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
className={cn(
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=open]:animate-in sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
));
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader";
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter";
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
className={cn("font-semibold text-lg", className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName;
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
};
|
||||
59
demos/use_cases/vercel-ai-sdk/components/ui/alert.tsx
Normal file
59
demos/use_cases/vercel-ai-sdk/components/ui/alert.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
50
demos/use_cases/vercel-ai-sdk/components/ui/avatar.tsx
Normal file
50
demos/use_cases/vercel-ai-sdk/components/ui/avatar.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
36
demos/use_cases/vercel-ai-sdk/components/ui/badge.tsx
Normal file
36
demos/use_cases/vercel-ai-sdk/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
83
demos/use_cases/vercel-ai-sdk/components/ui/button-group.tsx
Normal file
83
demos/use_cases/vercel-ai-sdk/components/ui/button-group.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||
vertical:
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted shadow-xs flex items-center gap-2 rounded-md border px-4 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
57
demos/use_cases/vercel-ai-sdk/components/ui/button.tsx
Normal file
57
demos/use_cases/vercel-ai-sdk/components/ui/button.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { Slot as SlotPrimitive } from "radix-ui";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
"icon-sm": "h-8 w-8",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? SlotPrimitive.Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
99
demos/use_cases/vercel-ai-sdk/components/ui/card.tsx
Normal file
99
demos/use_cases/vercel-ai-sdk/components/ui/card.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
className={cn(
|
||||
"font-semibold text-2xl leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className={cn("p-6 pt-0", className)} ref={ref} {...props} />
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
const CardAction = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardAction.displayName = "CardAction";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
CardAction,
|
||||
};
|
||||
262
demos/use_cases/vercel-ai-sdk/components/ui/carousel.tsx
Normal file
262
demos/use_cases/vercel-ai-sdk/components/ui/carousel.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & CarouselProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return
|
||||
}
|
||||
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
}
|
||||
)
|
||||
Carousel.displayName = "Carousel"
|
||||
|
||||
const CarouselContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
CarouselContent.displayName = "CarouselContent"
|
||||
|
||||
const CarouselItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
CarouselItem.displayName = "CarouselItem"
|
||||
|
||||
const CarouselPrevious = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
CarouselPrevious.displayName = "CarouselPrevious"
|
||||
|
||||
const CarouselNext = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
CarouselNext.displayName = "CarouselNext"
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
11
demos/use_cases/vercel-ai-sdk/components/ui/collapsible.tsx
Normal file
11
demos/use_cases/vercel-ai-sdk/components/ui/collapsible.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
"use client"
|
||||
|
||||
import { Collapsible as CollapsiblePrimitive } from "radix-ui"
|
||||
|
||||
const Collapsible = CollapsiblePrimitive.Root
|
||||
|
||||
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
|
||||
|
||||
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
153
demos/use_cases/vercel-ai-sdk/components/ui/command.tsx
Normal file
153
demos/use_cases/vercel-ai-sdk/components/ui/command.tsx
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { type DialogProps } from "@radix-ui/react-dialog"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Command.displayName = CommandPrimitive.displayName
|
||||
|
||||
const CommandDialog = ({ children, ...props }: DialogProps) => {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg">
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandList.displayName = CommandPrimitive.List.displayName
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty
|
||||
ref={ref}
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
122
demos/use_cases/vercel-ai-sdk/components/ui/dialog.tsx
Normal file
122
demos/use_cases/vercel-ai-sdk/components/ui/dialog.tsx
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
200
demos/use_cases/vercel-ai-sdk/components/ui/dropdown-menu.tsx
Normal file
200
demos/use_cases/vercel-ai-sdk/components/ui/dropdown-menu.tsx
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root
|
||||
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
|
||||
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group
|
||||
|
||||
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
|
||||
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub
|
||||
|
||||
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
))
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
))
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
))
|
||||
DropdownMenuCheckboxItem.displayName =
|
||||
DropdownMenuPrimitive.CheckboxItem.displayName
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-2 w-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
))
|
||||
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-semibold",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
|
||||
|
||||
const DropdownMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
29
demos/use_cases/vercel-ai-sdk/components/ui/hover-card.tsx
Normal file
29
demos/use_cases/vercel-ai-sdk/components/ui/hover-card.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { HoverCard as HoverCardPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const HoverCard = HoverCardPrimitive.Root
|
||||
|
||||
const HoverCardTrigger = HoverCardPrimitive.Trigger
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
170
demos/use_cases/vercel-ai-sdk/components/ui/input-group.tsx
Normal file
170
demos/use_cases/vercel-ai-sdk/components/ui/input-group.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
|
||||
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group border-input dark:bg-input/30 shadow-xs relative flex w-full items-center rounded-md border outline-none transition-[color,box-shadow]",
|
||||
"h-9 has-[>textarea]:h-auto",
|
||||
|
||||
// Variants based on alignment.
|
||||
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
|
||||
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
|
||||
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
|
||||
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
|
||||
|
||||
// Focus state.
|
||||
"has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1",
|
||||
|
||||
// Error state.
|
||||
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
|
||||
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
|
||||
"inline-end":
|
||||
"order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]",
|
||||
"block-start":
|
||||
"[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5",
|
||||
"block-end":
|
||||
"[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), className)}
|
||||
onClick={(e) => {
|
||||
if ((e.target as HTMLElement).closest("button")) {
|
||||
return
|
||||
}
|
||||
e.currentTarget.parentElement?.querySelector("input")?.focus()
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"flex items-center gap-2 text-sm shadow-none",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
|
||||
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
|
||||
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
type = "button",
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue