feat: dynamic model discovery from vercel ai gateway (#1353)
This commit is contained in:
parent
2b0b42d144
commit
b1da86062e
74 changed files with 7426 additions and 2277 deletions
|
|
@ -4,7 +4,7 @@ import { generateText, type UIMessage } from "ai";
|
|||
import { cookies } from "next/headers";
|
||||
import type { VisibilityType } from "@/components/visibility-selector";
|
||||
import { titlePrompt } from "@/lib/ai/prompts";
|
||||
import { myProvider } from "@/lib/ai/providers";
|
||||
import { getTitleModel } from "@/lib/ai/providers";
|
||||
import {
|
||||
deleteMessagesByChatIdAfterTimestamp,
|
||||
getMessageById,
|
||||
|
|
@ -23,7 +23,7 @@ export async function generateTitleFromUserMessage({
|
|||
message: UIMessage;
|
||||
}) {
|
||||
const { text: title } = await generateText({
|
||||
model: myProvider.languageModel("title-model"),
|
||||
model: getTitleModel(),
|
||||
system: titlePrompt,
|
||||
prompt: getTextFromMessage(message),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import type { VisibilityType } from "@/components/visibility-selector";
|
|||
import { entitlementsByUserType } from "@/lib/ai/entitlements";
|
||||
import type { ChatModel } from "@/lib/ai/models";
|
||||
import { type RequestHints, systemPrompt } from "@/lib/ai/prompts";
|
||||
import { myProvider } from "@/lib/ai/providers";
|
||||
import { getLanguageModel } from "@/lib/ai/providers";
|
||||
import { createDocument } from "@/lib/ai/tools/create-document";
|
||||
import { getWeather } from "@/lib/ai/tools/get-weather";
|
||||
import { requestSuggestions } from "@/lib/ai/tools/request-suggestions";
|
||||
|
|
@ -179,21 +179,33 @@ export async function POST(request: Request) {
|
|||
|
||||
const stream = createUIMessageStream({
|
||||
execute: ({ writer: dataStream }) => {
|
||||
const isReasoningModel =
|
||||
selectedChatModel.includes("reasoning") ||
|
||||
selectedChatModel.includes("thinking");
|
||||
|
||||
const result = streamText({
|
||||
model: myProvider.languageModel(selectedChatModel),
|
||||
model: getLanguageModel(selectedChatModel),
|
||||
system: systemPrompt({ selectedChatModel, requestHints }),
|
||||
messages: convertToModelMessages(uiMessages),
|
||||
stopWhen: stepCountIs(5),
|
||||
experimental_activeTools:
|
||||
selectedChatModel === "chat-model-reasoning"
|
||||
? []
|
||||
: [
|
||||
"getWeather",
|
||||
"createDocument",
|
||||
"updateDocument",
|
||||
"requestSuggestions",
|
||||
],
|
||||
experimental_transform: smoothStream({ chunking: "word" }),
|
||||
experimental_activeTools: isReasoningModel
|
||||
? []
|
||||
: [
|
||||
"getWeather",
|
||||
"createDocument",
|
||||
"updateDocument",
|
||||
"requestSuggestions",
|
||||
],
|
||||
experimental_transform: isReasoningModel
|
||||
? undefined
|
||||
: smoothStream({ chunking: "word" }),
|
||||
providerOptions: isReasoningModel
|
||||
? {
|
||||
anthropic: {
|
||||
thinking: { type: "enabled", budgetTokens: 10_000 },
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
tools: {
|
||||
getWeather,
|
||||
createDocument: createDocument({ session, dataStream }),
|
||||
|
|
@ -210,8 +222,7 @@ export async function POST(request: Request) {
|
|||
onFinish: async ({ usage }) => {
|
||||
try {
|
||||
const providers = await getTokenlensCatalog();
|
||||
const modelId =
|
||||
myProvider.languageModel(selectedChatModel).modelId;
|
||||
const modelId = getLanguageModel(selectedChatModel).modelId;
|
||||
if (!modelId) {
|
||||
finalMergedUsage = usage;
|
||||
dataStream.write({
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export const postRequestBodySchema = z.object({
|
|||
role: z.enum(["user"]),
|
||||
parts: z.array(partSchema),
|
||||
}),
|
||||
selectedChatModel: z.enum(["chat-model", "chat-model-reasoning"]),
|
||||
selectedChatModel: z.string(),
|
||||
selectedVisibilityType: z.enum(["public", "private"]),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { streamObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { codePrompt, updateDocumentPrompt } from "@/lib/ai/prompts";
|
||||
import { myProvider } from "@/lib/ai/providers";
|
||||
import { getArtifactModel } from "@/lib/ai/providers";
|
||||
import { createDocumentHandler } from "@/lib/artifacts/server";
|
||||
|
||||
export const codeDocumentHandler = createDocumentHandler<"code">({
|
||||
|
|
@ -10,7 +10,7 @@ export const codeDocumentHandler = createDocumentHandler<"code">({
|
|||
let draftContent = "";
|
||||
|
||||
const { fullStream } = streamObject({
|
||||
model: myProvider.languageModel("artifact-model"),
|
||||
model: getArtifactModel(),
|
||||
system: codePrompt,
|
||||
prompt: title,
|
||||
schema: z.object({
|
||||
|
|
@ -43,7 +43,7 @@ export const codeDocumentHandler = createDocumentHandler<"code">({
|
|||
let draftContent = "";
|
||||
|
||||
const { fullStream } = streamObject({
|
||||
model: myProvider.languageModel("artifact-model"),
|
||||
model: getArtifactModel(),
|
||||
system: updateDocumentPrompt(document.content, "code"),
|
||||
prompt: description,
|
||||
schema: z.object({
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { streamObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { sheetPrompt, updateDocumentPrompt } from "@/lib/ai/prompts";
|
||||
import { myProvider } from "@/lib/ai/providers";
|
||||
import { getArtifactModel } from "@/lib/ai/providers";
|
||||
import { createDocumentHandler } from "@/lib/artifacts/server";
|
||||
|
||||
export const sheetDocumentHandler = createDocumentHandler<"sheet">({
|
||||
|
|
@ -10,7 +10,7 @@ export const sheetDocumentHandler = createDocumentHandler<"sheet">({
|
|||
let draftContent = "";
|
||||
|
||||
const { fullStream } = streamObject({
|
||||
model: myProvider.languageModel("artifact-model"),
|
||||
model: getArtifactModel(),
|
||||
system: sheetPrompt,
|
||||
prompt: title,
|
||||
schema: z.object({
|
||||
|
|
@ -49,7 +49,7 @@ export const sheetDocumentHandler = createDocumentHandler<"sheet">({
|
|||
let draftContent = "";
|
||||
|
||||
const { fullStream } = streamObject({
|
||||
model: myProvider.languageModel("artifact-model"),
|
||||
model: getArtifactModel(),
|
||||
system: updateDocumentPrompt(document.content, "sheet"),
|
||||
prompt: description,
|
||||
schema: z.object({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { smoothStream, streamText } from "ai";
|
||||
import { updateDocumentPrompt } from "@/lib/ai/prompts";
|
||||
import { myProvider } from "@/lib/ai/providers";
|
||||
import { getArtifactModel } from "@/lib/ai/providers";
|
||||
import { createDocumentHandler } from "@/lib/artifacts/server";
|
||||
|
||||
export const textDocumentHandler = createDocumentHandler<"text">({
|
||||
|
|
@ -9,7 +9,7 @@ export const textDocumentHandler = createDocumentHandler<"text">({
|
|||
let draftContent = "";
|
||||
|
||||
const { fullStream } = streamText({
|
||||
model: myProvider.languageModel("artifact-model"),
|
||||
model: getArtifactModel(),
|
||||
system:
|
||||
"Write about the given topic. Markdown is supported. Use headings wherever appropriate.",
|
||||
experimental_transform: smoothStream({ chunking: "word" }),
|
||||
|
|
@ -38,7 +38,7 @@ export const textDocumentHandler = createDocumentHandler<"text">({
|
|||
let draftContent = "";
|
||||
|
||||
const { fullStream } = streamText({
|
||||
model: myProvider.languageModel("artifact-model"),
|
||||
model: getArtifactModel(),
|
||||
system: updateDocumentPrompt(document.content, "text"),
|
||||
experimental_transform: smoothStream({ chunking: "word" }),
|
||||
prompt: description,
|
||||
|
|
|
|||
147
components/ai-elements/artifact.tsx
Normal file
147
components/ai-elements/artifact.tsx
Normal file
|
|
@ -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} />
|
||||
);
|
||||
22
components/ai-elements/canvas.tsx
Normal file
22
components/ai-elements/canvas.tsx
Normal file
|
|
@ -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>
|
||||
);
|
||||
231
components/ai-elements/chain-of-thought.tsx
Normal file
231
components/ai-elements/chain-of-thought.tsx
Normal file
|
|
@ -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";
|
||||
71
components/ai-elements/checkpoint.tsx
Normal file
71
components/ai-elements/checkpoint.tsx
Normal file
|
|
@ -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>
|
||||
);
|
||||
178
components/ai-elements/code-block.tsx
Normal file
178
components/ai-elements/code-block.tsx
Normal file
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
182
components/ai-elements/confirmation.tsx
Normal file
182
components/ai-elements/confirmation.tsx
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
"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
|
||||
// @ts-expect-error state only available in AI SDK v6
|
||||
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 ||
|
||||
// @ts-expect-error state only available in AI SDK v6
|
||||
(state !== "approval-responded" &&
|
||||
// @ts-expect-error state only available in AI SDK v6
|
||||
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 ||
|
||||
// @ts-expect-error state only available in AI SDK v6
|
||||
(state !== "approval-responded" &&
|
||||
// @ts-expect-error state only available in AI SDK v6
|
||||
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
|
||||
// @ts-expect-error state only available in AI SDK v6
|
||||
if (state !== "approval-requested") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-end gap-2 self-end", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type ConfirmationActionProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConfirmationAction = (props: ConfirmationActionProps) => (
|
||||
<Button className="h-8 px-3 text-sm" type="button" {...props} />
|
||||
);
|
||||
28
components/ai-elements/connection.tsx
Normal file
28
components/ai-elements/connection.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { ConnectionLineComponent } from "@xyflow/react";
|
||||
|
||||
const HALF = 0.5;
|
||||
|
||||
export const Connection: ConnectionLineComponent = ({
|
||||
fromX,
|
||||
fromY,
|
||||
toX,
|
||||
toY,
|
||||
}) => (
|
||||
<g>
|
||||
<path
|
||||
className="animated"
|
||||
d={`M${fromX},${fromY} C ${fromX + (toX - fromX) * HALF},${fromY} ${fromX + (toX - fromX) * HALF},${toY} ${toX},${toY}`}
|
||||
fill="none"
|
||||
stroke="var(--color-ring)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<circle
|
||||
cx={toX}
|
||||
cy={toY}
|
||||
fill="#fff"
|
||||
r={3}
|
||||
stroke="var(--color-ring)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
408
components/ai-elements/context.tsx
Normal file
408
components/ai-elements/context.tsx
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
"use client";
|
||||
|
||||
import type { LanguageModelUsage } from "ai";
|
||||
import { type ComponentProps, createContext, useContext } from "react";
|
||||
import { getUsage } from "tokenlens";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PERCENT_MAX = 100;
|
||||
const ICON_RADIUS = 10;
|
||||
const ICON_VIEWBOX = 24;
|
||||
const ICON_CENTER = 12;
|
||||
const ICON_STROKE_WIDTH = 2;
|
||||
|
||||
type ModelId = string;
|
||||
|
||||
type ContextSchema = {
|
||||
usedTokens: number;
|
||||
maxTokens: number;
|
||||
usage?: LanguageModelUsage;
|
||||
modelId?: ModelId;
|
||||
};
|
||||
|
||||
const ContextContext = createContext<ContextSchema | null>(null);
|
||||
|
||||
const useContextValue = () => {
|
||||
const context = useContext(ContextContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Context components must be used within Context");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema;
|
||||
|
||||
export const Context = ({
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
usage,
|
||||
modelId,
|
||||
...props
|
||||
}: ContextProps) => (
|
||||
<ContextContext.Provider
|
||||
value={{
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
usage,
|
||||
modelId,
|
||||
}}
|
||||
>
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
</ContextContext.Provider>
|
||||
);
|
||||
|
||||
const ContextIcon = () => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const circumference = 2 * Math.PI * ICON_RADIUS;
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const dashOffset = circumference * (1 - usedPercent);
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-label="Model context usage"
|
||||
height="20"
|
||||
role="img"
|
||||
style={{ color: "currentcolor" }}
|
||||
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
|
||||
width="20"
|
||||
>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
fill="none"
|
||||
opacity="0.25"
|
||||
r={ICON_RADIUS}
|
||||
stroke="currentColor"
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
/>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
fill="none"
|
||||
opacity="0.7"
|
||||
r={ICON_RADIUS}
|
||||
stroke="currentColor"
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
style={{ transformOrigin: "center", transform: "rotate(-90deg)" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextTriggerProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const renderedPercent = new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(usedPercent);
|
||||
|
||||
return (
|
||||
<HoverCardTrigger asChild>
|
||||
{children ?? (
|
||||
<Button type="button" variant="ghost" {...props}>
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{renderedPercent}
|
||||
</span>
|
||||
<ContextIcon />
|
||||
</Button>
|
||||
)}
|
||||
</HoverCardTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextContentProps = ComponentProps<typeof HoverCardContent>;
|
||||
|
||||
export const ContextContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ContextContentProps) => (
|
||||
<HoverCardContent
|
||||
className={cn("min-w-60 divide-y overflow-hidden p-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ContextContentHeaderProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentHeader = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentHeaderProps) => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const displayPct = new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(usedPercent);
|
||||
const used = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(usedTokens);
|
||||
const total = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(maxTokens);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full space-y-2 p-3", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<p>{displayPct}</p>
|
||||
<p className="font-mono text-muted-foreground">
|
||||
{used} / {total}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Progress className="bg-muted" value={usedPercent * PERCENT_MAX} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextContentBodyProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentBody = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentBodyProps) => (
|
||||
<div className={cn("w-full p-3", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ContextContentFooterProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentFooter = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentFooterProps) => {
|
||||
const { modelId, usage } = useContextValue();
|
||||
const costUSD = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: {
|
||||
input: usage?.inputTokens ?? 0,
|
||||
output: usage?.outputTokens ?? 0,
|
||||
},
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const totalCost = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(costUSD ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-3 bg-secondary p-3 text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<span className="text-muted-foreground">Total cost</span>
|
||||
<span>{totalCost}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextInputUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextInputUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextInputUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const inputTokens = usage?.inputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!inputTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inputCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { input: inputTokens, output: 0 },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const inputCostText = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(inputCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Input</span>
|
||||
<TokensWithCost costText={inputCostText} tokens={inputTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextOutputUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextOutputUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextOutputUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const outputTokens = usage?.outputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!outputTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const outputCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { input: 0, output: outputTokens },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const outputCostText = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(outputCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Output</span>
|
||||
<TokensWithCost costText={outputCostText} tokens={outputTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextReasoningUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextReasoningUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextReasoningUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const reasoningTokens = usage?.reasoningTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!reasoningTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reasoningCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { reasoningTokens },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const reasoningCostText = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(reasoningCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Reasoning</span>
|
||||
<TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextCacheUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextCacheUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextCacheUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const cacheTokens = usage?.cachedInputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!cacheTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { cacheReads: cacheTokens, input: 0, output: 0 },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const cacheCostText = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(cacheCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Cache</span>
|
||||
<TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TokensWithCost = ({
|
||||
tokens,
|
||||
costText,
|
||||
}: {
|
||||
tokens?: number;
|
||||
costText?: string;
|
||||
}) => (
|
||||
<span>
|
||||
{tokens === undefined
|
||||
? "—"
|
||||
: new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(tokens)}
|
||||
{costText ? (
|
||||
<span className="ml-2 text-muted-foreground">• {costText}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
18
components/ai-elements/controls.tsx
Normal file
18
components/ai-elements/controls.tsx
Normal file
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
100
components/ai-elements/conversation.tsx
Normal file
100
components/ai-elements/conversation.tsx
Normal file
|
|
@ -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
components/ai-elements/edge.tsx
Normal file
140
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,
|
||||
};
|
||||
26
components/ai-elements/image.tsx
Normal file
26
components/ai-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: 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}`}
|
||||
/>
|
||||
);
|
||||
287
components/ai-elements/inline-citation.tsx
Normal file
287
components/ai-elements/inline-citation.tsx
Normal file
|
|
@ -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>
|
||||
);
|
||||
96
components/ai-elements/loader.tsx
Normal file
96
components/ai-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>
|
||||
);
|
||||
446
components/ai-elements/message.tsx
Normal file
446
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>
|
||||
);
|
||||
203
components/ai-elements/model-selector.tsx
Normal file
203
components/ai-elements/model-selector.tsx
Normal file
|
|
@ -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} />
|
||||
);
|
||||
71
components/ai-elements/node.tsx
Normal file
71
components/ai-elements/node.tsx
Normal file
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
365
components/ai-elements/open-in-chat.tsx
Normal file
365
components/ai-elements/open-in-chat.tsx
Normal file
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
15
components/ai-elements/panel.tsx
Normal file
15
components/ai-elements/panel.tsx
Normal file
|
|
@ -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
components/ai-elements/plan.tsx
Normal file
142
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>
|
||||
);
|
||||
1427
components/ai-elements/prompt-input.tsx
Normal file
1427
components/ai-elements/prompt-input.tsx
Normal file
File diff suppressed because it is too large
Load diff
275
components/ai-elements/queue.tsx
Normal file
275
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}
|
||||
/>
|
||||
);
|
||||
189
components/ai-elements/reasoning.tsx
Normal file
189
components/ai-elements/reasoning.tsx
Normal file
|
|
@ -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";
|
||||
64
components/ai-elements/shimmer.tsx
Normal file
64
components/ai-elements/shimmer.tsx
Normal file
|
|
@ -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);
|
||||
77
components/ai-elements/sources.tsx
Normal file
77
components/ai-elements/sources.tsx
Normal file
|
|
@ -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>
|
||||
);
|
||||
53
components/ai-elements/suggestion.tsx
Normal file
53
components/ai-elements/suggestion.tsx
Normal file
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
87
components/ai-elements/task.tsx
Normal file
87
components/ai-elements/task.tsx
Normal file
|
|
@ -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>
|
||||
);
|
||||
165
components/ai-elements/tool.tsx
Normal file
165
components/ai-elements/tool.tsx
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
"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",
|
||||
// @ts-expect-error state only available in AI SDK v6
|
||||
"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" />,
|
||||
// @ts-expect-error state only available in AI SDK v6
|
||||
"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>
|
||||
);
|
||||
};
|
||||
16
components/ai-elements/toolbar.tsx
Normal file
16
components/ai-elements/toolbar.tsx
Normal file
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
263
components/ai-elements/web-preview.tsx
Normal file
263
components/ai-elements/web-preview.tsx
Normal file
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -119,22 +119,22 @@ export const ReasoningTrigger = memo(
|
|||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 text-muted-foreground text-xs transition-colors hover:text-foreground",
|
||||
"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-4" />
|
||||
<BrainIcon className="size-3" />
|
||||
{isStreaming || duration === 0 ? (
|
||||
<p>Thinking...</p>
|
||||
<span>Thinking</span>
|
||||
) : (
|
||||
<p>Thought for {duration}s</p>
|
||||
<span>{duration}s</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-3 text-muted-foreground transition-transform",
|
||||
"size-2.5 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
|
|
@ -155,13 +155,17 @@ export const ReasoningContent = memo(
|
|||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-2 text-muted-foreground text-xs",
|
||||
"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}
|
||||
>
|
||||
<Response className="grid gap-2">{children}</Response>
|
||||
<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>
|
||||
)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,106 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import type { Session } from "next-auth";
|
||||
import { startTransition, useMemo, useOptimistic, useState } from "react";
|
||||
import { saveChatModelAsCookie } from "@/app/(chat)/actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { entitlementsByUserType } from "@/lib/ai/entitlements";
|
||||
import { chatModels } from "@/lib/ai/models";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckCircleFillIcon, ChevronDownIcon } from "./icons";
|
||||
|
||||
export function ModelSelector({
|
||||
session,
|
||||
selectedModelId,
|
||||
className,
|
||||
}: {
|
||||
session: Session;
|
||||
selectedModelId: string;
|
||||
} & React.ComponentProps<typeof Button>) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [optimisticModelId, setOptimisticModelId] =
|
||||
useOptimistic(selectedModelId);
|
||||
|
||||
const userType = session.user.type;
|
||||
const { availableChatModelIds } = entitlementsByUserType[userType];
|
||||
|
||||
const availableChatModels = chatModels.filter((chatModel) =>
|
||||
availableChatModelIds.includes(chatModel.id)
|
||||
);
|
||||
|
||||
const selectedChatModel = useMemo(
|
||||
() =>
|
||||
availableChatModels.find(
|
||||
(chatModel) => chatModel.id === optimisticModelId
|
||||
),
|
||||
[optimisticModelId, availableChatModels]
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={setOpen} open={open}>
|
||||
<DropdownMenuTrigger
|
||||
asChild
|
||||
className={cn(
|
||||
"w-fit data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Button
|
||||
className="md:h-[34px] md:px-2"
|
||||
data-testid="model-selector"
|
||||
variant="outline"
|
||||
>
|
||||
{selectedChatModel?.name}
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="min-w-[280px] max-w-[90vw] sm:min-w-[300px]"
|
||||
>
|
||||
{availableChatModels.map((chatModel) => {
|
||||
const { id } = chatModel;
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
data-active={id === optimisticModelId}
|
||||
data-testid={`model-selector-item-${id}`}
|
||||
key={id}
|
||||
onSelect={() => {
|
||||
setOpen(false);
|
||||
|
||||
startTransition(() => {
|
||||
setOptimisticModelId(id);
|
||||
saveChatModelAsCookie(id);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="group/item flex w-full flex-row items-center justify-between gap-2 sm:gap-4"
|
||||
type="button"
|
||||
>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="text-sm sm:text-base">{chatModel.name}</div>
|
||||
<div className="line-clamp-2 text-muted-foreground text-xs">
|
||||
{chatModel.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-foreground opacity-0 group-data-[active=true]/item:opacity-100 dark:text-foreground">
|
||||
<CheckCircleFillIcon />
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,15 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import { Trigger } from "@radix-ui/react-select";
|
||||
import type { UIMessage } from "ai";
|
||||
import equal from "fast-deep-equal";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type Dispatch,
|
||||
memo,
|
||||
type SetStateAction,
|
||||
startTransition,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
|
|
@ -18,34 +17,45 @@ import {
|
|||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useLocalStorage, useWindowSize } from "usehooks-ts";
|
||||
import { saveChatModelAsCookie } from "@/app/(chat)/actions";
|
||||
import { SelectItem } from "@/components/ui/select";
|
||||
import { chatModels } from "@/lib/ai/models";
|
||||
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 type { AppUsage } from "@/lib/usage";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Context } from "./elements/context";
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputModelSelect,
|
||||
PromptInputModelSelectContent,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
PromptInputToolbar,
|
||||
PromptInputTools,
|
||||
} from "./elements/prompt-input";
|
||||
import {
|
||||
ArrowUpIcon,
|
||||
ChevronDownIcon,
|
||||
CpuIcon,
|
||||
PaperclipIcon,
|
||||
StopIcon,
|
||||
} from "./icons";
|
||||
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,
|
||||
|
|
@ -367,7 +377,7 @@ function PureMultimodalInput({
|
|||
/>{" "}
|
||||
<Context {...contextProps} />
|
||||
</div>
|
||||
<PromptInputToolbar className="!border-top-0 border-t-0! p-0 shadow-none dark:border-0 dark:border-transparent!">
|
||||
<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}
|
||||
|
|
@ -430,7 +440,8 @@ function PureAttachmentsButton({
|
|||
status: UseChatHelpers<ChatMessage>["status"];
|
||||
selectedModelId: string;
|
||||
}) {
|
||||
const isReasoningModel = selectedModelId === "chat-model-reasoning";
|
||||
const isReasoningModel =
|
||||
selectedModelId.includes("reasoning") || selectedModelId.includes("think");
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
|
@ -457,52 +468,66 @@ function PureModelSelectorCompact({
|
|||
selectedModelId: string;
|
||||
onModelChange?: (modelId: string) => void;
|
||||
}) {
|
||||
const [optimisticModelId, setOptimisticModelId] = useState(selectedModelId);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setOptimisticModelId(selectedModelId);
|
||||
}, [selectedModelId]);
|
||||
const selectedModel =
|
||||
chatModels.find((m) => m.id === selectedModelId) ??
|
||||
chatModels.find((m) => m.id === DEFAULT_CHAT_MODEL) ??
|
||||
chatModels[0];
|
||||
const [provider] = selectedModel.id.split("/");
|
||||
|
||||
const selectedModel = chatModels.find(
|
||||
(model) => model.id === optimisticModelId
|
||||
);
|
||||
// Provider display names
|
||||
const providerNames: Record<string, string> = {
|
||||
anthropic: "Anthropic",
|
||||
openai: "OpenAI",
|
||||
google: "Google",
|
||||
xai: "xAI",
|
||||
reasoning: "Reasoning",
|
||||
};
|
||||
|
||||
return (
|
||||
<PromptInputModelSelect
|
||||
onValueChange={(modelName) => {
|
||||
const model = chatModels.find((m) => m.name === modelName);
|
||||
if (model) {
|
||||
setOptimisticModelId(model.id);
|
||||
onModelChange?.(model.id);
|
||||
startTransition(() => {
|
||||
saveChatModelAsCookie(model.id);
|
||||
});
|
||||
}
|
||||
}}
|
||||
value={selectedModel?.name}
|
||||
>
|
||||
<Trigger asChild>
|
||||
<Button className="h-8 px-2" variant="ghost">
|
||||
<CpuIcon size={16} />
|
||||
<span className="hidden font-medium text-xs sm:block">
|
||||
{selectedModel?.name}
|
||||
</span>
|
||||
<ChevronDownIcon size={16} />
|
||||
<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>
|
||||
</Trigger>
|
||||
<PromptInputModelSelectContent className="min-w-[260px] p-0">
|
||||
<div className="flex flex-col gap-px">
|
||||
{chatModels.map((model) => (
|
||||
<SelectItem key={model.id} value={model.name}>
|
||||
<div className="truncate font-medium text-xs">{model.name}</div>
|
||||
<div className="mt-px truncate text-[10px] text-muted-foreground leading-tight">
|
||||
{model.description}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
</PromptInputModelSelectContent>
|
||||
</PromptInputModelSelect>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
59
components/ui/alert.tsx
Normal file
59
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 }
|
||||
83
components/ui/button-group.tsx
Normal file
83
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,
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import * as React from "react"
|
||||
import { Slot as SlotPrimitive } from "radix-ui"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
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"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium 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",
|
||||
"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: {
|
||||
|
|
@ -24,6 +24,7 @@ const buttonVariants = cva(
|
|||
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: {
|
||||
|
|
@ -31,26 +32,26 @@ const buttonVariants = cva(
|
|||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? SlotPrimitive.Slot : "button"
|
||||
const Comp = asChild ? SlotPrimitive.Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
|
|
|||
|
|
@ -1,79 +1,99 @@
|
|||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
"font-semibold text-2xl leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
));
|
||||
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}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
<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
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
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,
|
||||
};
|
||||
|
|
|
|||
153
components/ui/command.tsx
Normal file
153
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
components/ui/dialog.tsx
Normal file
122
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 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] 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,
|
||||
}
|
||||
170
components/ui/input-group.tsx
Normal file
170
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,
|
||||
}
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
import type { UserType } from "@/app/(auth)/auth";
|
||||
import type { ChatModel } from "./models";
|
||||
|
||||
type Entitlements = {
|
||||
maxMessagesPerDay: number;
|
||||
availableChatModelIds: ChatModel["id"][];
|
||||
};
|
||||
|
||||
export const entitlementsByUserType: Record<UserType, Entitlements> = {
|
||||
|
|
@ -11,16 +9,14 @@ export const entitlementsByUserType: Record<UserType, Entitlements> = {
|
|||
* For users without an account
|
||||
*/
|
||||
guest: {
|
||||
maxMessagesPerDay: 20,
|
||||
availableChatModelIds: ["chat-model", "chat-model-reasoning"],
|
||||
maxMessagesPerDay: 10,
|
||||
},
|
||||
|
||||
/*
|
||||
* For users with an account
|
||||
*/
|
||||
regular: {
|
||||
maxMessagesPerDay: 100,
|
||||
availableChatModelIds: ["chat-model", "chat-model-reasoning"],
|
||||
maxMessagesPerDay: 50,
|
||||
},
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -1,5 +1,28 @@
|
|||
import type { LanguageModel } from "ai";
|
||||
|
||||
const mockResponses: Record<string, string> = {
|
||||
default: "This is a mock response for testing.",
|
||||
weather: "The weather in San Francisco is sunny and 72°F.",
|
||||
greeting: "Hello! How can I help you today?",
|
||||
};
|
||||
|
||||
function getResponseForPrompt(prompt: unknown): string {
|
||||
const promptStr = JSON.stringify(prompt).toLowerCase();
|
||||
|
||||
if (promptStr.includes("weather") || promptStr.includes("temperature")) {
|
||||
return mockResponses.weather;
|
||||
}
|
||||
if (
|
||||
promptStr.includes("hello") ||
|
||||
promptStr.includes("hi") ||
|
||||
promptStr.includes("hey")
|
||||
) {
|
||||
return mockResponses.greeting;
|
||||
}
|
||||
|
||||
return mockResponses.default;
|
||||
}
|
||||
|
||||
const createMockModel = (): LanguageModel => {
|
||||
return {
|
||||
specificationVersion: "v2",
|
||||
|
|
@ -9,20 +32,116 @@ const createMockModel = (): LanguageModel => {
|
|||
supportedUrls: [],
|
||||
supportsImageUrls: false,
|
||||
supportsStructuredOutputs: false,
|
||||
doGenerate: async ({ prompt }: { prompt: unknown }) => ({
|
||||
rawCall: { rawPrompt: null, rawSettings: {} },
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
|
||||
content: [{ type: "text", text: getResponseForPrompt(prompt) }],
|
||||
warnings: [],
|
||||
}),
|
||||
doStream: ({ prompt }: { prompt: unknown }) => {
|
||||
const response = getResponseForPrompt(prompt);
|
||||
const words = response.split(" ");
|
||||
|
||||
return {
|
||||
stream: new ReadableStream({
|
||||
async start(controller) {
|
||||
for (const word of words) {
|
||||
controller.enqueue({
|
||||
type: "text-delta",
|
||||
textDelta: `${word} `,
|
||||
});
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 10);
|
||||
});
|
||||
}
|
||||
controller.enqueue({
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 10, outputTokens: 20 },
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
rawCall: { rawPrompt: null, rawSettings: {} },
|
||||
};
|
||||
},
|
||||
} as unknown as LanguageModel;
|
||||
};
|
||||
|
||||
const createMockReasoningModel = (): LanguageModel => {
|
||||
return {
|
||||
specificationVersion: "v2",
|
||||
provider: "mock",
|
||||
modelId: "mock-reasoning-model",
|
||||
defaultObjectGenerationMode: "tool",
|
||||
supportedUrls: [],
|
||||
supportsImageUrls: false,
|
||||
supportsStructuredOutputs: false,
|
||||
doGenerate: async () => ({
|
||||
rawCall: { rawPrompt: null, rawSettings: {} },
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 10, outputTokens: 20, totalTokens: 30 },
|
||||
content: [{ type: "text", text: "Hello, world!" }],
|
||||
content: [{ type: "text", text: "This is a reasoned response." }],
|
||||
reasoning: [
|
||||
{ type: "text", text: "Let me think through this step by step..." },
|
||||
],
|
||||
warnings: [],
|
||||
}),
|
||||
doStream: async () => ({
|
||||
doStream: () => ({
|
||||
stream: new ReadableStream({
|
||||
async start(controller) {
|
||||
controller.enqueue({
|
||||
type: "reasoning",
|
||||
textDelta: "Let me think through this step by step... ",
|
||||
});
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 10);
|
||||
});
|
||||
controller.enqueue({
|
||||
type: "text-delta",
|
||||
textDelta: "This is a reasoned response.",
|
||||
});
|
||||
controller.enqueue({
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 10, outputTokens: 20 },
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
rawCall: { rawPrompt: null, rawSettings: {} },
|
||||
}),
|
||||
} as unknown as LanguageModel;
|
||||
};
|
||||
|
||||
const createMockTitleModel = (): LanguageModel => {
|
||||
return {
|
||||
specificationVersion: "v2",
|
||||
provider: "mock",
|
||||
modelId: "mock-title-model",
|
||||
defaultObjectGenerationMode: "tool",
|
||||
supportedUrls: [],
|
||||
supportsImageUrls: false,
|
||||
supportsStructuredOutputs: false,
|
||||
doGenerate: async () => ({
|
||||
rawCall: { rawPrompt: null, rawSettings: {} },
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 5, outputTokens: 5, totalTokens: 10 },
|
||||
content: [{ type: "text", text: "Test Conversation" }],
|
||||
warnings: [],
|
||||
}),
|
||||
doStream: () => ({
|
||||
stream: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue({
|
||||
type: "text-delta",
|
||||
id: "mock-id",
|
||||
delta: "Mock response",
|
||||
textDelta: "Test Conversation",
|
||||
});
|
||||
controller.enqueue({
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 5, outputTokens: 5 },
|
||||
});
|
||||
controller.close();
|
||||
},
|
||||
|
|
@ -33,6 +152,6 @@ const createMockModel = (): LanguageModel => {
|
|||
};
|
||||
|
||||
export const chatModel = createMockModel();
|
||||
export const reasoningModel = createMockModel();
|
||||
export const titleModel = createMockModel();
|
||||
export const reasoningModel = createMockReasoningModel();
|
||||
export const titleModel = createMockTitleModel();
|
||||
export const artifactModel = createMockModel();
|
||||
|
|
|
|||
|
|
@ -1,21 +1,89 @@
|
|||
export const DEFAULT_CHAT_MODEL: string = "chat-model";
|
||||
// Curated list of top models from Vercel AI Gateway
|
||||
export const DEFAULT_CHAT_MODEL = "google/gemini-2.5-flash-lite";
|
||||
|
||||
export type ChatModel = {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const chatModels: ChatModel[] = [
|
||||
// Anthropic
|
||||
{
|
||||
id: "chat-model",
|
||||
name: "Grok Vision",
|
||||
description: "Advanced multimodal model with vision and text capabilities",
|
||||
id: "anthropic/claude-haiku-4.5",
|
||||
name: "Claude Haiku 4.5",
|
||||
provider: "anthropic",
|
||||
description: "Fast and affordable, great for everyday tasks",
|
||||
},
|
||||
{
|
||||
id: "chat-model-reasoning",
|
||||
name: "Grok Reasoning",
|
||||
description:
|
||||
"Uses advanced chain-of-thought reasoning for complex problems",
|
||||
id: "anthropic/claude-sonnet-4.5",
|
||||
name: "Claude Sonnet 4.5",
|
||||
provider: "anthropic",
|
||||
description: "Best balance of speed, intelligence, and cost",
|
||||
},
|
||||
{
|
||||
id: "anthropic/claude-opus-4.5",
|
||||
name: "Claude Opus 4.5",
|
||||
provider: "anthropic",
|
||||
description: "Most capable Anthropic model",
|
||||
},
|
||||
// OpenAI
|
||||
{
|
||||
id: "openai/gpt-4.1-mini",
|
||||
name: "GPT-4.1 Mini",
|
||||
provider: "openai",
|
||||
description: "Fast and cost-effective for simple tasks",
|
||||
},
|
||||
{
|
||||
id: "openai/gpt-5.2",
|
||||
name: "GPT-5.2",
|
||||
provider: "openai",
|
||||
description: "Most capable OpenAI model",
|
||||
},
|
||||
// Google
|
||||
{
|
||||
id: "google/gemini-2.5-flash-lite",
|
||||
name: "Gemini 2.5 Flash Lite",
|
||||
provider: "google",
|
||||
description: "Ultra fast and affordable",
|
||||
},
|
||||
{
|
||||
id: "google/gemini-3-pro-preview",
|
||||
name: "Gemini 3 Pro",
|
||||
provider: "google",
|
||||
description: "Most capable Google model",
|
||||
},
|
||||
// xAI
|
||||
{
|
||||
id: "xai/grok-4.1-fast-non-reasoning",
|
||||
name: "Grok 4.1 Fast",
|
||||
provider: "xai",
|
||||
description: "Fast with 30K context",
|
||||
},
|
||||
// Reasoning models (extended thinking)
|
||||
{
|
||||
id: "anthropic/claude-3.7-sonnet-thinking",
|
||||
name: "Claude 3.7 Sonnet",
|
||||
provider: "reasoning",
|
||||
description: "Extended thinking for complex problems",
|
||||
},
|
||||
{
|
||||
id: "xai/grok-code-fast-1-thinking",
|
||||
name: "Grok Code Fast",
|
||||
provider: "reasoning",
|
||||
description: "Reasoning optimized for code",
|
||||
},
|
||||
];
|
||||
|
||||
// Group models by provider for UI
|
||||
export const modelsByProvider = chatModels.reduce(
|
||||
(acc, model) => {
|
||||
if (!acc[model.provider]) {
|
||||
acc[model.provider] = [];
|
||||
}
|
||||
acc[model.provider].push(model);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, ChatModel[]>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -59,7 +59,11 @@ export const systemPrompt = ({
|
|||
}) => {
|
||||
const requestPrompt = getRequestPromptFromHints(requestHints);
|
||||
|
||||
if (selectedChatModel === "chat-model-reasoning") {
|
||||
// reasoning models don't need artifacts prompt (they can't use tools)
|
||||
if (
|
||||
selectedChatModel.includes("reasoning") ||
|
||||
selectedChatModel.includes("thinking")
|
||||
) {
|
||||
return `${regularPrompt}\n\n${requestPrompt}`;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import {
|
|||
} from "ai";
|
||||
import { isTestEnvironment } from "../constants";
|
||||
|
||||
const THINKING_SUFFIX_REGEX = /-thinking$/;
|
||||
|
||||
export const myProvider = isTestEnvironment
|
||||
? (() => {
|
||||
const {
|
||||
|
|
@ -23,14 +25,38 @@ export const myProvider = isTestEnvironment
|
|||
},
|
||||
});
|
||||
})()
|
||||
: customProvider({
|
||||
languageModels: {
|
||||
"chat-model": gateway.languageModel("xai/grok-2-vision-1212"),
|
||||
"chat-model-reasoning": wrapLanguageModel({
|
||||
model: gateway.languageModel("xai/grok-3-mini"),
|
||||
middleware: extractReasoningMiddleware({ tagName: "think" }),
|
||||
}),
|
||||
"title-model": gateway.languageModel("xai/grok-2-1212"),
|
||||
"artifact-model": gateway.languageModel("xai/grok-2-1212"),
|
||||
},
|
||||
: null;
|
||||
|
||||
export function getLanguageModel(modelId: string) {
|
||||
if (isTestEnvironment && myProvider) {
|
||||
return myProvider.languageModel(modelId);
|
||||
}
|
||||
|
||||
const isReasoningModel =
|
||||
modelId.includes("reasoning") || modelId.endsWith("-thinking");
|
||||
|
||||
if (isReasoningModel) {
|
||||
const gatewayModelId = modelId.replace(THINKING_SUFFIX_REGEX, "");
|
||||
|
||||
return wrapLanguageModel({
|
||||
model: gateway.languageModel(gatewayModelId),
|
||||
middleware: extractReasoningMiddleware({ tagName: "thinking" }),
|
||||
});
|
||||
}
|
||||
|
||||
return gateway.languageModel(modelId);
|
||||
}
|
||||
|
||||
export function getTitleModel() {
|
||||
if (isTestEnvironment && myProvider) {
|
||||
return myProvider.languageModel("title-model");
|
||||
}
|
||||
return gateway.languageModel("anthropic/claude-haiku-4.5");
|
||||
}
|
||||
|
||||
export function getArtifactModel() {
|
||||
if (isTestEnvironment && myProvider) {
|
||||
return myProvider.languageModel("artifact-model");
|
||||
}
|
||||
return gateway.languageModel("anthropic/claude-haiku-4.5");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { getDocumentById, saveSuggestions } from "@/lib/db/queries";
|
|||
import type { Suggestion } from "@/lib/db/schema";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { myProvider } from "../providers";
|
||||
import { getArtifactModel } from "../providers";
|
||||
|
||||
type RequestSuggestionsProps = {
|
||||
session: Session;
|
||||
|
|
@ -38,7 +38,7 @@ export const requestSuggestions = ({
|
|||
>[] = [];
|
||||
|
||||
const { elementStream } = streamObject({
|
||||
model: myProvider.languageModel("artifact-model"),
|
||||
model: getArtifactModel(),
|
||||
system:
|
||||
"You are a help writing assistant. Given a piece of writing, please offer suggestions to improve the piece of writing and describe the change. It is very important for the edits to contain full sentences instead of just words. Max 5 suggestions.",
|
||||
prompt: document.content,
|
||||
|
|
|
|||
19
package.json
19
package.json
|
|
@ -21,7 +21,6 @@
|
|||
"@ai-sdk/gateway": "^2.0.18",
|
||||
"@ai-sdk/provider": "2.0.0",
|
||||
"@ai-sdk/react": "2.0.109",
|
||||
"@ai-sdk/xai": "2.0.39",
|
||||
"@codemirror/lang-javascript": "^6.2.2",
|
||||
"@codemirror/lang-python": "^6.1.6",
|
||||
"@codemirror/state": "^6.5.0",
|
||||
|
|
@ -30,19 +29,30 @@
|
|||
"@icons-pack/react-simple-icons": "^13.7.0",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@opentelemetry/api-logs": "^0.200.0",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@radix-ui/react-visually-hidden": "^1.1.0",
|
||||
"@vercel/analytics": "^1.3.1",
|
||||
"@vercel/blob": "^0.24.1",
|
||||
"@vercel/functions": "^2.0.0",
|
||||
"@vercel/otel": "^1.12.0",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"ai": "5.0.108",
|
||||
"bcrypt-ts": "^5.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"classnames": "^2.5.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"codemirror": "^6.0.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"diff-match-patch": "^1.0.5",
|
||||
|
|
@ -54,7 +64,8 @@
|
|||
"geist": "^1.3.1",
|
||||
"katex": "^0.16.25",
|
||||
"lucide-react": "^0.446.0",
|
||||
"nanoid": "^5.0.8",
|
||||
"motion": "^12.23.26",
|
||||
"nanoid": "^5.1.3",
|
||||
"next": "16.0.10",
|
||||
"next-auth": "5.0.0-beta.25",
|
||||
"next-themes": "^0.3.0",
|
||||
|
|
@ -78,9 +89,9 @@
|
|||
"redis": "^5.0.0",
|
||||
"resumable-stream": "^2.0.0",
|
||||
"server-only": "^0.0.1",
|
||||
"shiki": "^3.12.2",
|
||||
"shiki": "^3.14.0",
|
||||
"sonner": "^1.5.0",
|
||||
"streamdown": "^1.3.0",
|
||||
"streamdown": "^1.4.0",
|
||||
"swr": "^2.2.5",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
|
|
|||
|
|
@ -58,13 +58,6 @@ export default defineConfig({
|
|||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "routes",
|
||||
testMatch: /routes\/.*.test.ts/,
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
|
||||
// {
|
||||
// name: 'firefox',
|
||||
|
|
|
|||
302
pnpm-lock.yaml
generated
302
pnpm-lock.yaml
generated
|
|
@ -17,9 +17,6 @@ importers:
|
|||
'@ai-sdk/react':
|
||||
specifier: 2.0.109
|
||||
version: 2.0.109(react@19.0.1)(zod@3.25.76)
|
||||
'@ai-sdk/xai':
|
||||
specifier: 2.0.39
|
||||
version: 2.0.39(zod@3.25.76)
|
||||
'@codemirror/lang-javascript':
|
||||
specifier: ^6.2.2
|
||||
version: 6.2.3
|
||||
|
|
@ -44,12 +41,39 @@ importers:
|
|||
'@opentelemetry/api-logs':
|
||||
specifier: ^0.200.0
|
||||
version: 0.200.0
|
||||
'@radix-ui/react-collapsible':
|
||||
specifier: ^1.1.12
|
||||
version: 1.1.12(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)
|
||||
'@radix-ui/react-dialog':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)
|
||||
'@radix-ui/react-dropdown-menu':
|
||||
specifier: ^2.1.16
|
||||
version: 2.1.16(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)
|
||||
'@radix-ui/react-hover-card':
|
||||
specifier: ^1.1.15
|
||||
version: 1.1.15(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)
|
||||
'@radix-ui/react-icons':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.2(react@19.0.1)
|
||||
'@radix-ui/react-progress':
|
||||
specifier: ^1.1.8
|
||||
version: 1.1.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)
|
||||
'@radix-ui/react-scroll-area':
|
||||
specifier: ^1.2.10
|
||||
version: 1.2.10(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)
|
||||
'@radix-ui/react-select':
|
||||
specifier: ^2.2.6
|
||||
version: 2.2.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)
|
||||
'@radix-ui/react-separator':
|
||||
specifier: ^1.1.8
|
||||
version: 1.1.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)
|
||||
'@radix-ui/react-slot':
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4(@types/react@18.3.18)(react@19.0.1)
|
||||
'@radix-ui/react-tooltip':
|
||||
specifier: ^1.2.8
|
||||
version: 1.2.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)
|
||||
'@radix-ui/react-use-controllable-state':
|
||||
specifier: ^1.2.2
|
||||
version: 1.2.2(@types/react@18.3.18)(react@19.0.1)
|
||||
|
|
@ -68,6 +92,9 @@ importers:
|
|||
'@vercel/otel':
|
||||
specifier: ^1.12.0
|
||||
version: 1.14.0(@opentelemetry/api-logs@0.200.0)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@1.30.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.0))
|
||||
'@xyflow/react':
|
||||
specifier: ^12.10.0
|
||||
version: 12.10.0(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)
|
||||
ai:
|
||||
specifier: 5.0.108
|
||||
version: 5.0.108(zod@3.25.76)
|
||||
|
|
@ -83,6 +110,9 @@ importers:
|
|||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
cmdk:
|
||||
specifier: ^1.1.1
|
||||
version: 1.1.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)
|
||||
codemirror:
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1
|
||||
|
|
@ -116,8 +146,11 @@ importers:
|
|||
lucide-react:
|
||||
specifier: ^0.446.0
|
||||
version: 0.446.0(react@19.0.1)
|
||||
motion:
|
||||
specifier: ^12.23.26
|
||||
version: 12.23.26(react-dom@19.0.1(react@19.0.1))(react@19.0.1)
|
||||
nanoid:
|
||||
specifier: ^5.0.8
|
||||
specifier: ^5.1.3
|
||||
version: 5.1.3
|
||||
next:
|
||||
specifier: 16.0.10
|
||||
|
|
@ -189,13 +222,13 @@ importers:
|
|||
specifier: ^0.0.1
|
||||
version: 0.0.1
|
||||
shiki:
|
||||
specifier: ^3.12.2
|
||||
specifier: ^3.14.0
|
||||
version: 3.14.0
|
||||
sonner:
|
||||
specifier: ^1.5.0
|
||||
version: 1.7.4(react-dom@19.0.1(react@19.0.1))(react@19.0.1)
|
||||
streamdown:
|
||||
specifier: ^1.3.0
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0(@types/react@18.3.18)(react@19.0.1)
|
||||
swr:
|
||||
specifier: ^2.2.5
|
||||
|
|
@ -279,12 +312,6 @@ packages:
|
|||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/openai-compatible@1.0.28':
|
||||
resolution: {integrity: sha512-yKubDxLYtXyGUzkr9lNStf/lE/I+Okc8tmotvyABhsQHHieLKk6oV5fJeRJxhr67Ejhg+FRnwUOxAmjRoFM4dA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/provider-utils@3.0.18':
|
||||
resolution: {integrity: sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -305,12 +332,6 @@ packages:
|
|||
zod:
|
||||
optional: true
|
||||
|
||||
'@ai-sdk/xai@2.0.39':
|
||||
resolution: {integrity: sha512-EtRRHpPb3J6qY8y9C9p1g3FdF8dl6SocmfyS418g+PesK9/bIAbJYWQStdWpJXF/d9VfzeoOp1IhcBgKotAn+A==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@alloc/quick-lru@5.2.0':
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -1369,6 +1390,15 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-context@1.1.3':
|
||||
resolution: {integrity: sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-dialog@1.1.15':
|
||||
resolution: {integrity: sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==}
|
||||
peerDependencies:
|
||||
|
|
@ -1635,6 +1665,19 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-primitive@2.1.4':
|
||||
resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-progress@1.1.7':
|
||||
resolution: {integrity: sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==}
|
||||
peerDependencies:
|
||||
|
|
@ -1648,6 +1691,19 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-progress@1.1.8':
|
||||
resolution: {integrity: sha512-+gISHcSPUJ7ktBy9RnTqbdKW78bcGke3t6taawyZ71pio1JewwGSJizycs7rLhGTvMJYCQB1DBK4KQsxs7U8dA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-radio-group@1.3.8':
|
||||
resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -1713,6 +1769,19 @@ packages:
|
|||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-separator@1.1.8':
|
||||
resolution: {integrity: sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
'@types/react-dom': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-slider@1.3.6':
|
||||
resolution: {integrity: sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==}
|
||||
peerDependencies:
|
||||
|
|
@ -1744,6 +1813,15 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-slot@1.2.4':
|
||||
resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
|
||||
peerDependencies:
|
||||
'@types/react': '*'
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@radix-ui/react-switch@1.2.6':
|
||||
resolution: {integrity: sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -2497,6 +2575,15 @@ packages:
|
|||
'@vitest/utils@3.2.4':
|
||||
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
|
||||
|
||||
'@xyflow/react@12.10.0':
|
||||
resolution: {integrity: sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
|
||||
'@xyflow/system@0.0.74':
|
||||
resolution: {integrity: sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==}
|
||||
|
||||
acorn-import-attributes@1.9.5:
|
||||
resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -2601,6 +2688,9 @@ packages:
|
|||
class-variance-authority@0.7.1:
|
||||
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
|
||||
|
||||
classcat@5.0.5:
|
||||
resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
|
||||
|
||||
classnames@2.5.1:
|
||||
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
|
||||
|
||||
|
|
@ -2615,6 +2705,12 @@ packages:
|
|||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
cmdk@1.1.1:
|
||||
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
react-dom: ^18 || ^19 || ^19.0.0-rc
|
||||
|
||||
codemirror@6.0.1:
|
||||
resolution: {integrity: sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==}
|
||||
|
||||
|
|
@ -3083,6 +3179,20 @@ packages:
|
|||
react-dom:
|
||||
optional: true
|
||||
|
||||
framer-motion@12.23.26:
|
||||
resolution: {integrity: sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@emotion/is-prop-valid':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
fsevents@2.3.2:
|
||||
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
|
||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||
|
|
@ -3544,9 +3654,29 @@ packages:
|
|||
motion-dom@11.18.1:
|
||||
resolution: {integrity: sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==}
|
||||
|
||||
motion-dom@12.23.23:
|
||||
resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==}
|
||||
|
||||
motion-utils@11.18.1:
|
||||
resolution: {integrity: sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==}
|
||||
|
||||
motion-utils@12.23.6:
|
||||
resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==}
|
||||
|
||||
motion@12.23.26:
|
||||
resolution: {integrity: sha512-Ll8XhVxY8LXMVYTCfme27WH2GjBrCIzY4+ndr5QKxsK+YwCtOi2B/oBi5jcIbik5doXuWT/4KKDOVAZJkeY5VQ==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@emotion/is-prop-valid':
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
ms@2.1.3:
|
||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||
|
||||
|
|
@ -4402,6 +4532,21 @@ packages:
|
|||
zod@4.1.12:
|
||||
resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
|
||||
|
||||
zustand@4.5.7:
|
||||
resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
|
||||
engines: {node: '>=12.7.0'}
|
||||
peerDependencies:
|
||||
'@types/react': '>=16.8'
|
||||
immer: '>=9.0.6'
|
||||
react: '>=16.8'
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
immer:
|
||||
optional: true
|
||||
react:
|
||||
optional: true
|
||||
|
||||
zwitch@2.0.4:
|
||||
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
|
||||
|
||||
|
|
@ -4414,12 +4559,6 @@ snapshots:
|
|||
'@vercel/oidc': 3.0.5
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/openai-compatible@1.0.28(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 2.0.0
|
||||
'@ai-sdk/provider-utils': 3.0.18(zod@3.25.76)
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/provider-utils@3.0.18(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 2.0.0
|
||||
|
|
@ -4441,13 +4580,6 @@ snapshots:
|
|||
optionalDependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/xai@2.0.39(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/openai-compatible': 1.0.28(zod@3.25.76)
|
||||
'@ai-sdk/provider': 2.0.0
|
||||
'@ai-sdk/provider-utils': 3.0.18(zod@3.25.76)
|
||||
zod: 3.25.76
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@antfu/install-pkg@1.1.0':
|
||||
|
|
@ -5261,6 +5393,12 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
|
||||
'@radix-ui/react-context@1.1.3(@types/react@18.3.18)(react@19.0.1)':
|
||||
dependencies:
|
||||
react: 19.0.1
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
|
||||
'@radix-ui/react-dialog@1.1.15(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
|
|
@ -5566,6 +5704,15 @@ snapshots:
|
|||
'@types/react': 18.3.18
|
||||
'@types/react-dom': 18.3.5(@types/react@18.3.18)
|
||||
|
||||
'@radix-ui/react-primitive@2.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-slot': 1.2.4(@types/react@18.3.18)(react@19.0.1)
|
||||
react: 19.0.1
|
||||
react-dom: 19.0.1(react@19.0.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
'@types/react-dom': 18.3.5(@types/react@18.3.18)
|
||||
|
||||
'@radix-ui/react-progress@1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@19.0.1)
|
||||
|
|
@ -5576,6 +5723,16 @@ snapshots:
|
|||
'@types/react': 18.3.18
|
||||
'@types/react-dom': 18.3.5(@types/react@18.3.18)
|
||||
|
||||
'@radix-ui/react-progress@1.1.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-context': 1.1.3(@types/react@18.3.18)(react@19.0.1)
|
||||
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)
|
||||
react: 19.0.1
|
||||
react-dom: 19.0.1(react@19.0.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
'@types/react-dom': 18.3.5(@types/react@18.3.18)
|
||||
|
||||
'@radix-ui/react-radio-group@1.3.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
|
|
@ -5666,6 +5823,15 @@ snapshots:
|
|||
'@types/react': 18.3.18
|
||||
'@types/react-dom': 18.3.5(@types/react@18.3.18)
|
||||
|
||||
'@radix-ui/react-separator@1.1.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)
|
||||
react: 19.0.1
|
||||
react-dom: 19.0.1(react@19.0.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
'@types/react-dom': 18.3.5(@types/react@18.3.18)
|
||||
|
||||
'@radix-ui/react-slider@1.3.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)':
|
||||
dependencies:
|
||||
'@radix-ui/number': 1.1.1
|
||||
|
|
@ -5699,6 +5865,13 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
|
||||
'@radix-ui/react-slot@1.2.4(@types/react@18.3.18)(react@19.0.1)':
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@19.0.1)
|
||||
react: 19.0.1
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
|
||||
'@radix-ui/react-switch@1.2.6(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)':
|
||||
dependencies:
|
||||
'@radix-ui/primitive': 1.1.3
|
||||
|
|
@ -6407,6 +6580,29 @@ snapshots:
|
|||
loupe: 3.2.1
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@xyflow/react@12.10.0(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)':
|
||||
dependencies:
|
||||
'@xyflow/system': 0.0.74
|
||||
classcat: 5.0.5
|
||||
react: 19.0.1
|
||||
react-dom: 19.0.1(react@19.0.1)
|
||||
zustand: 4.5.7(@types/react@18.3.18)(react@19.0.1)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- immer
|
||||
|
||||
'@xyflow/system@0.0.74':
|
||||
dependencies:
|
||||
'@types/d3-drag': 3.0.7
|
||||
'@types/d3-interpolate': 3.0.4
|
||||
'@types/d3-selection': 3.0.11
|
||||
'@types/d3-transition': 3.0.9
|
||||
'@types/d3-zoom': 3.0.8
|
||||
d3-drag: 3.0.0
|
||||
d3-interpolate: 3.0.1
|
||||
d3-selection: 3.0.0
|
||||
d3-zoom: 3.0.0
|
||||
|
||||
acorn-import-attributes@1.9.5(acorn@8.15.0):
|
||||
dependencies:
|
||||
acorn: 8.15.0
|
||||
|
|
@ -6500,6 +6696,8 @@ snapshots:
|
|||
dependencies:
|
||||
clsx: 2.1.1
|
||||
|
||||
classcat@5.0.5: {}
|
||||
|
||||
classnames@2.5.1: {}
|
||||
|
||||
client-only@0.0.1: {}
|
||||
|
|
@ -6508,6 +6706,18 @@ snapshots:
|
|||
|
||||
cluster-key-slot@1.1.2: {}
|
||||
|
||||
cmdk@1.1.1(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1):
|
||||
dependencies:
|
||||
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@19.0.1)
|
||||
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)
|
||||
'@radix-ui/react-id': 1.1.1(@types/react@18.3.18)(react@19.0.1)
|
||||
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)
|
||||
react: 19.0.1
|
||||
react-dom: 19.0.1(react@19.0.1)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
- '@types/react-dom'
|
||||
|
||||
codemirror@6.0.1:
|
||||
dependencies:
|
||||
'@codemirror/autocomplete': 6.18.6
|
||||
|
|
@ -6941,6 +7151,15 @@ snapshots:
|
|||
react: 19.0.1
|
||||
react-dom: 19.0.1(react@19.0.1)
|
||||
|
||||
framer-motion@12.23.26(react-dom@19.0.1(react@19.0.1))(react@19.0.1):
|
||||
dependencies:
|
||||
motion-dom: 12.23.23
|
||||
motion-utils: 12.23.6
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.0.1
|
||||
react-dom: 19.0.1(react@19.0.1)
|
||||
|
||||
fsevents@2.3.2:
|
||||
optional: true
|
||||
|
||||
|
|
@ -7693,8 +7912,22 @@ snapshots:
|
|||
dependencies:
|
||||
motion-utils: 11.18.1
|
||||
|
||||
motion-dom@12.23.23:
|
||||
dependencies:
|
||||
motion-utils: 12.23.6
|
||||
|
||||
motion-utils@11.18.1: {}
|
||||
|
||||
motion-utils@12.23.6: {}
|
||||
|
||||
motion@12.23.26(react-dom@19.0.1(react@19.0.1))(react@19.0.1):
|
||||
dependencies:
|
||||
framer-motion: 12.23.26(react-dom@19.0.1(react@19.0.1))(react@19.0.1)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.0.1
|
||||
react-dom: 19.0.1(react@19.0.1)
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
|
@ -8752,4 +8985,11 @@ snapshots:
|
|||
|
||||
zod@4.1.12: {}
|
||||
|
||||
zustand@4.5.7(@types/react@18.3.18)(react@19.0.1):
|
||||
dependencies:
|
||||
use-sync-external-store: 1.6.0(react@19.0.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.18
|
||||
react: 19.0.1
|
||||
|
||||
zwitch@2.0.4: {}
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
import { expect, test } from "../fixtures";
|
||||
import { ArtifactPage } from "../pages/artifact";
|
||||
import { ChatPage } from "../pages/chat";
|
||||
|
||||
test.describe("Artifacts activity", () => {
|
||||
let chatPage: ChatPage;
|
||||
let artifactPage: ArtifactPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
chatPage = new ChatPage(page);
|
||||
artifactPage = new ArtifactPage(page);
|
||||
|
||||
await chatPage.createNewChat();
|
||||
});
|
||||
|
||||
test("Create a text artifact", async () => {
|
||||
test.fixme();
|
||||
await chatPage.createNewChat();
|
||||
|
||||
await chatPage.sendUserMessage(
|
||||
"Help me write an essay about Silicon Valley"
|
||||
);
|
||||
await artifactPage.isGenerationComplete();
|
||||
|
||||
expect(artifactPage.artifact).toBeVisible();
|
||||
|
||||
const assistantMessage = await chatPage.getRecentAssistantMessage();
|
||||
expect(assistantMessage.content).toBe(
|
||||
"A document was created and is now visible to the user."
|
||||
);
|
||||
|
||||
await chatPage.hasChatIdInUrl();
|
||||
});
|
||||
|
||||
test("Toggle artifact visibility", async () => {
|
||||
test.fixme();
|
||||
await chatPage.createNewChat();
|
||||
|
||||
await chatPage.sendUserMessage(
|
||||
"Help me write an essay about Silicon Valley"
|
||||
);
|
||||
await artifactPage.isGenerationComplete();
|
||||
|
||||
expect(artifactPage.artifact).toBeVisible();
|
||||
|
||||
const assistantMessage = await chatPage.getRecentAssistantMessage();
|
||||
expect(assistantMessage.content).toBe(
|
||||
"A document was created and is now visible to the user."
|
||||
);
|
||||
|
||||
await artifactPage.closeArtifact();
|
||||
await chatPage.isElementNotVisible("artifact");
|
||||
});
|
||||
|
||||
test("Send follow up message after generation", async () => {
|
||||
test.fixme();
|
||||
await chatPage.createNewChat();
|
||||
|
||||
await chatPage.sendUserMessage(
|
||||
"Help me write an essay about Silicon Valley"
|
||||
);
|
||||
await artifactPage.isGenerationComplete();
|
||||
|
||||
expect(artifactPage.artifact).toBeVisible();
|
||||
|
||||
const assistantMessage = await artifactPage.getRecentAssistantMessage();
|
||||
expect(assistantMessage.content).toBe(
|
||||
"A document was created and is now visible to the user."
|
||||
);
|
||||
|
||||
await artifactPage.sendUserMessage("Thanks!");
|
||||
await artifactPage.isGenerationComplete();
|
||||
|
||||
const secondAssistantMessage = await chatPage.getRecentAssistantMessage();
|
||||
expect(secondAssistantMessage.content).toBe("You're welcome!");
|
||||
});
|
||||
});
|
||||
53
tests/e2e/auth.test.ts
Normal file
53
tests/e2e/auth.test.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Authentication", () => {
|
||||
test("can register a new account", async ({ page }) => {
|
||||
const timestamp = Date.now();
|
||||
const email = `test-${timestamp}@example.com`;
|
||||
const password = "testpassword123";
|
||||
|
||||
await page.goto("/register");
|
||||
await page.getByPlaceholder("user@acme.com").fill(email);
|
||||
await page.getByLabel("Password").fill(password);
|
||||
await page.getByRole("button", { name: "Sign Up" }).click();
|
||||
|
||||
await expect(page.getByTestId("toast")).toContainText(
|
||||
"Account created successfully"
|
||||
);
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
|
||||
test("can login with credentials", async ({ page }) => {
|
||||
const timestamp = Date.now();
|
||||
const email = `test-${timestamp}@example.com`;
|
||||
const password = "testpassword123";
|
||||
|
||||
await page.goto("/register");
|
||||
await page.getByPlaceholder("user@acme.com").fill(email);
|
||||
await page.getByLabel("Password").fill(password);
|
||||
await page.getByRole("button", { name: "Sign Up" }).click();
|
||||
await expect(page).toHaveURL("/");
|
||||
|
||||
await page.goto("/login");
|
||||
await page.getByPlaceholder("user@acme.com").fill(email);
|
||||
await page.getByLabel("Password").fill(password);
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
|
||||
test("shows error for invalid credentials", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.getByPlaceholder("user@acme.com").fill("invalid@example.com");
|
||||
await page.getByLabel("Password").fill("wrongpassword");
|
||||
await page.getByRole("button", { name: "Sign In" }).click();
|
||||
|
||||
await expect(page.getByTestId("toast")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can continue as guest", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByTestId("multimodal-input")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { expect, test } from "../fixtures";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { ChatPage } from "../pages/chat";
|
||||
|
||||
test.describe("Chat activity", () => {
|
||||
test.describe("Chat", () => {
|
||||
let chatPage: ChatPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
|
|
@ -9,164 +9,60 @@ test.describe("Chat activity", () => {
|
|||
await chatPage.createNewChat();
|
||||
});
|
||||
|
||||
test("Send a user message and receive response", async () => {
|
||||
await chatPage.sendUserMessage("Why is grass green?");
|
||||
await chatPage.isGenerationComplete();
|
||||
|
||||
const assistantMessage = await chatPage.getRecentAssistantMessage();
|
||||
expect(assistantMessage.content).toContain("It's just green duh!");
|
||||
test("page loads with input ready", async () => {
|
||||
await chatPage.waitForInputToBeReady();
|
||||
});
|
||||
|
||||
test("Redirect to /chat/:id after submitting message", async () => {
|
||||
await chatPage.sendUserMessage("Why is grass green?");
|
||||
test("send button is disabled when input is empty", async () => {
|
||||
await expect(chatPage.sendButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("send button is enabled when input has text", async () => {
|
||||
await chatPage.multimodalInput.fill("Hello");
|
||||
await expect(chatPage.sendButton).toBeEnabled();
|
||||
});
|
||||
|
||||
test("can send a message and receive a response", async () => {
|
||||
await chatPage.sendUserMessage("Hello");
|
||||
await chatPage.isGenerationComplete();
|
||||
|
||||
const assistantMessage = await chatPage.getRecentAssistantMessage();
|
||||
expect(assistantMessage.content).toContain("It's just green duh!");
|
||||
const userContent = await chatPage.getLastUserMessageContent();
|
||||
expect(userContent).toBe("Hello");
|
||||
|
||||
const assistantContent = await chatPage.getLastAssistantMessageContent();
|
||||
expect(assistantContent).toBeTruthy();
|
||||
});
|
||||
|
||||
test("redirects to /chat/:id after sending message", async () => {
|
||||
await chatPage.sendUserMessage("Hello");
|
||||
await chatPage.isGenerationComplete();
|
||||
await chatPage.hasChatIdInUrl();
|
||||
});
|
||||
|
||||
test("Send a user message from suggestion", async () => {
|
||||
await chatPage.sendUserMessageFromSuggestion();
|
||||
await chatPage.isGenerationComplete();
|
||||
|
||||
const assistantMessage = await chatPage.getRecentAssistantMessage();
|
||||
expect(assistantMessage.content).toContain(
|
||||
"With Next.js, you can ship fast!"
|
||||
);
|
||||
test("shows stop button during generation", async () => {
|
||||
await chatPage.multimodalInput.fill("Hello");
|
||||
await chatPage.sendButton.click();
|
||||
await expect(chatPage.stopButton).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test("Toggle between send/stop button based on activity", async () => {
|
||||
await expect(chatPage.sendButton).toBeVisible();
|
||||
await expect(chatPage.sendButton).toBeDisabled();
|
||||
|
||||
await chatPage.sendUserMessage("Why is grass green?");
|
||||
|
||||
await expect(chatPage.sendButton).not.toBeVisible();
|
||||
await expect(chatPage.stopButton).toBeVisible();
|
||||
|
||||
await chatPage.isGenerationComplete();
|
||||
|
||||
await expect(chatPage.stopButton).not.toBeVisible();
|
||||
await expect(chatPage.sendButton).toBeVisible();
|
||||
});
|
||||
|
||||
test("Stop generation during submission", async () => {
|
||||
await chatPage.sendUserMessage("Why is grass green?");
|
||||
await expect(chatPage.stopButton).toBeVisible();
|
||||
test("can stop generation", async () => {
|
||||
await chatPage.multimodalInput.fill("Hello");
|
||||
await chatPage.sendButton.click();
|
||||
await expect(chatPage.stopButton).toBeVisible({ timeout: 5000 });
|
||||
await chatPage.stopButton.click();
|
||||
await expect(chatPage.sendButton).toBeVisible();
|
||||
});
|
||||
|
||||
test("Edit user message and resubmit", async () => {
|
||||
await chatPage.sendUserMessage("Why is grass green?");
|
||||
await chatPage.isGenerationComplete();
|
||||
|
||||
const assistantMessage = await chatPage.getRecentAssistantMessage();
|
||||
expect(assistantMessage.content).toContain("It's just green duh!");
|
||||
|
||||
const userMessage = await chatPage.getRecentUserMessage();
|
||||
await userMessage.edit("Why is the sky blue?");
|
||||
|
||||
await chatPage.isGenerationComplete();
|
||||
|
||||
const updatedAssistantMessage = await chatPage.getRecentAssistantMessage();
|
||||
expect(updatedAssistantMessage.content).toContain("It's just blue duh!");
|
||||
});
|
||||
|
||||
test("Hide suggested actions after sending message", async () => {
|
||||
await chatPage.isElementVisible("suggested-actions");
|
||||
await chatPage.sendUserMessageFromSuggestion();
|
||||
await chatPage.isElementNotVisible("suggested-actions");
|
||||
});
|
||||
|
||||
test("Upload file and send image attachment with message", async () => {
|
||||
await chatPage.addImageAttachment();
|
||||
|
||||
await chatPage.isElementVisible("attachments-preview");
|
||||
await chatPage.isElementVisible("input-attachment-loader");
|
||||
await chatPage.isElementNotVisible("input-attachment-loader");
|
||||
|
||||
await chatPage.sendUserMessage("Who painted this?");
|
||||
|
||||
const userMessage = await chatPage.getRecentUserMessage();
|
||||
expect(userMessage.attachments).toHaveLength(1);
|
||||
|
||||
await chatPage.isGenerationComplete();
|
||||
|
||||
const assistantMessage = await chatPage.getRecentAssistantMessage();
|
||||
expect(assistantMessage.content).toBe("This painting is by Monet!");
|
||||
});
|
||||
|
||||
test("Call weather tool", async () => {
|
||||
await chatPage.sendUserMessage("What's the weather in sf?");
|
||||
await chatPage.isGenerationComplete();
|
||||
|
||||
const assistantMessage = await chatPage.getRecentAssistantMessage();
|
||||
|
||||
expect(assistantMessage.content).toBe(
|
||||
"The current temperature in San Francisco is 17°C."
|
||||
);
|
||||
});
|
||||
|
||||
test("Upvote message", async () => {
|
||||
await chatPage.sendUserMessage("Why is the sky blue?");
|
||||
await chatPage.isGenerationComplete();
|
||||
|
||||
const assistantMessage = await chatPage.getRecentAssistantMessage();
|
||||
await assistantMessage.upvote();
|
||||
await chatPage.isVoteComplete();
|
||||
});
|
||||
|
||||
test("Downvote message", async () => {
|
||||
await chatPage.sendUserMessage("Why is the sky blue?");
|
||||
await chatPage.isGenerationComplete();
|
||||
|
||||
const assistantMessage = await chatPage.getRecentAssistantMessage();
|
||||
await assistantMessage.downvote();
|
||||
await chatPage.isVoteComplete();
|
||||
});
|
||||
|
||||
test("Update vote", async () => {
|
||||
await chatPage.sendUserMessage("Why is the sky blue?");
|
||||
await chatPage.isGenerationComplete();
|
||||
|
||||
const assistantMessage = await chatPage.getRecentAssistantMessage();
|
||||
await assistantMessage.upvote();
|
||||
await chatPage.isVoteComplete();
|
||||
|
||||
await assistantMessage.downvote();
|
||||
await chatPage.isVoteComplete();
|
||||
});
|
||||
|
||||
test("Create message from url query", async ({ page }) => {
|
||||
await page.goto("/?query=Why is the sky blue?");
|
||||
|
||||
await chatPage.isGenerationComplete();
|
||||
|
||||
const userMessage = await chatPage.getRecentUserMessage();
|
||||
expect(userMessage.content).toBe("Why is the sky blue?");
|
||||
|
||||
const assistantMessage = await chatPage.getRecentAssistantMessage();
|
||||
expect(assistantMessage.content).toContain("It's just blue duh!");
|
||||
});
|
||||
|
||||
test("auto-scrolls to bottom after submitting new messages", async () => {
|
||||
test.fixme();
|
||||
await chatPage.sendMultipleMessages(5, (i) => `filling message #${i}`);
|
||||
await chatPage.waitForScrollToBottom();
|
||||
});
|
||||
|
||||
test("scroll button appears when user scrolls up, hides on click", async () => {
|
||||
test.fixme();
|
||||
await chatPage.sendMultipleMessages(5, (i) => `filling message #${i}`);
|
||||
await expect(chatPage.scrollToBottomButton).not.toBeVisible();
|
||||
|
||||
await chatPage.scrollToTop();
|
||||
await expect(chatPage.scrollToBottomButton).toBeVisible();
|
||||
|
||||
await chatPage.scrollToBottomButton.click();
|
||||
await chatPage.waitForScrollToBottom();
|
||||
await expect(chatPage.scrollToBottomButton).not.toBeVisible();
|
||||
await expect(chatPage.sendButton).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Chat - Guest User", () => {
|
||||
test("can use chat as guest", async ({ page }) => {
|
||||
const chatPage = new ChatPage(page);
|
||||
await chatPage.createNewChat();
|
||||
await chatPage.waitForInputToBeReady();
|
||||
await chatPage.sendUserMessage("Hello");
|
||||
await chatPage.isGenerationComplete();
|
||||
|
||||
const content = await chatPage.getLastAssistantMessageContent();
|
||||
expect(content).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
54
tests/e2e/message-actions.test.ts
Normal file
54
tests/e2e/message-actions.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
import { ChatPage } from "../pages/chat";
|
||||
|
||||
test.describe("Message Actions", () => {
|
||||
let chatPage: ChatPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
chatPage = new ChatPage(page);
|
||||
await chatPage.createNewChat();
|
||||
await chatPage.sendUserMessage("Hello");
|
||||
await chatPage.isGenerationComplete();
|
||||
});
|
||||
|
||||
test("can upvote a message", async ({ page }) => {
|
||||
const upvoteButton = page.getByTestId("message-upvote").first();
|
||||
await upvoteButton.click();
|
||||
await expect(upvoteButton).toHaveAttribute("data-state", "active");
|
||||
});
|
||||
|
||||
test("can downvote a message", async ({ page }) => {
|
||||
const downvoteButton = page.getByTestId("message-downvote").first();
|
||||
await downvoteButton.click();
|
||||
await expect(downvoteButton).toHaveAttribute("data-state", "active");
|
||||
});
|
||||
|
||||
test("can copy message content", async ({ page, context }) => {
|
||||
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
|
||||
|
||||
const copyButton = page.getByTestId("message-copy").first();
|
||||
await copyButton.click();
|
||||
|
||||
const clipboardContent = await page.evaluate(() =>
|
||||
navigator.clipboard.readText()
|
||||
);
|
||||
expect(clipboardContent.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("can edit user message", async ({ page }) => {
|
||||
const editButton = page.getByTestId("message-edit-button").first();
|
||||
await editButton.click();
|
||||
|
||||
const editor = page.getByTestId("message-editor");
|
||||
await expect(editor).toBeVisible();
|
||||
await editor.fill("Updated message");
|
||||
|
||||
const sendButton = page.getByTestId("message-editor-send-button");
|
||||
await sendButton.click();
|
||||
|
||||
await chatPage.isGenerationComplete();
|
||||
const userContent = await chatPage.getLastUserMessageContent();
|
||||
expect(userContent).toBe("Updated message");
|
||||
});
|
||||
});
|
||||
|
||||
41
tests/e2e/model-selector.test.ts
Normal file
41
tests/e2e/model-selector.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
import { ChatPage } from "../pages/chat";
|
||||
|
||||
test.describe("Model Selector", () => {
|
||||
let chatPage: ChatPage;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
chatPage = new ChatPage(page);
|
||||
await chatPage.createNewChat();
|
||||
});
|
||||
|
||||
test("displays default model on load", async ({ page }) => {
|
||||
const modelButton = page.locator("button").filter({ hasText: /Gemini|Claude|GPT/i }).first();
|
||||
await expect(modelButton).toBeVisible();
|
||||
});
|
||||
|
||||
test("opens model selector on click", async ({ page }) => {
|
||||
const modelButton = page.locator("button").filter({ hasText: /Gemini|Claude|GPT/i }).first();
|
||||
await modelButton.click();
|
||||
await expect(page.getByPlaceholder("Search models...")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can search for models", async ({ page }) => {
|
||||
const modelButton = page.locator("button").filter({ hasText: /Gemini|Claude|GPT/i }).first();
|
||||
await modelButton.click();
|
||||
await page.getByPlaceholder("Search models...").fill("Claude");
|
||||
await expect(page.getByText("Claude", { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
test("can select a different model", async ({ page }) => {
|
||||
const modelButton = page.locator("button").filter({ hasText: /Gemini|Claude|GPT/i }).first();
|
||||
await modelButton.click();
|
||||
|
||||
const modelOption = page.getByRole("option").first();
|
||||
const modelName = await modelOption.innerText();
|
||||
await modelOption.click();
|
||||
|
||||
await expect(page.locator("button").filter({ hasText: modelName.split("\n")[0] })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import { expect, test } from "../fixtures";
|
||||
import { ChatPage } from "../pages/chat";
|
||||
|
||||
test.describe("chat activity with reasoning", () => {
|
||||
let chatPage: ChatPage;
|
||||
|
||||
test.beforeEach(async ({ curieContext }) => {
|
||||
chatPage = new ChatPage(curieContext.page);
|
||||
await chatPage.createNewChat();
|
||||
});
|
||||
|
||||
test("Curie can send message and generate response with reasoning", async () => {
|
||||
await chatPage.sendUserMessage("Why is the sky blue?");
|
||||
await chatPage.isGenerationComplete();
|
||||
|
||||
const assistantMessage = await chatPage.getRecentAssistantMessage();
|
||||
expect(assistantMessage.content).toBe("It's just blue duh!");
|
||||
|
||||
expect(assistantMessage.reasoning).toBe(
|
||||
"The sky is blue because of rayleigh scattering!"
|
||||
);
|
||||
});
|
||||
|
||||
test("Curie can toggle reasoning visibility", async () => {
|
||||
await chatPage.sendUserMessage("Why is the sky blue?");
|
||||
await chatPage.isGenerationComplete();
|
||||
|
||||
const assistantMessage = await chatPage.getRecentAssistantMessage();
|
||||
const reasoningElement =
|
||||
assistantMessage.element.getByTestId("message-reasoning");
|
||||
expect(reasoningElement).toBeVisible();
|
||||
|
||||
await assistantMessage.toggleReasoningVisibility();
|
||||
await expect(reasoningElement).not.toBeVisible();
|
||||
|
||||
await assistantMessage.toggleReasoningVisibility();
|
||||
await expect(reasoningElement).toBeVisible();
|
||||
});
|
||||
|
||||
test("Curie can edit message and resubmit", async () => {
|
||||
await chatPage.sendUserMessage("Why is the sky blue?");
|
||||
await chatPage.isGenerationComplete();
|
||||
|
||||
const assistantMessage = await chatPage.getRecentAssistantMessage();
|
||||
const reasoningElement =
|
||||
assistantMessage.element.getByTestId("message-reasoning");
|
||||
expect(reasoningElement).toBeVisible();
|
||||
|
||||
const userMessage = await chatPage.getRecentUserMessage();
|
||||
|
||||
const generationCompletePromise = chatPage.isGenerationComplete();
|
||||
await userMessage.edit("Why is grass green?");
|
||||
await generationCompletePromise;
|
||||
|
||||
const updatedAssistantMessage = await chatPage.getRecentAssistantMessage();
|
||||
|
||||
expect(updatedAssistantMessage.content).toBe("It's just green duh!");
|
||||
|
||||
expect(updatedAssistantMessage.reasoning).toBe(
|
||||
"Grass is green because of chlorophyll absorption!"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,209 +0,0 @@
|
|||
import { getMessageByErrorCode } from "@/lib/errors";
|
||||
import { expect, test } from "../fixtures";
|
||||
import { generateRandomTestUser } from "../helpers";
|
||||
import { AuthPage } from "../pages/auth";
|
||||
import { ChatPage } from "../pages/chat";
|
||||
|
||||
test.describe
|
||||
.serial("Guest Session", () => {
|
||||
test("Authenticate as guest user when a new session is loaded", async ({
|
||||
page,
|
||||
}) => {
|
||||
const response = await page.goto("/");
|
||||
|
||||
if (!response) {
|
||||
throw new Error("Failed to load page");
|
||||
}
|
||||
|
||||
let request = response.request();
|
||||
|
||||
const chain: string[] = [];
|
||||
|
||||
while (request) {
|
||||
chain.unshift(request.url());
|
||||
request = request.redirectedFrom();
|
||||
}
|
||||
|
||||
expect(chain).toEqual([
|
||||
"http://localhost:3000/",
|
||||
"http://localhost:3000/api/auth/guest?redirectUrl=http%3A%2F%2Flocalhost%3A3000%2F",
|
||||
"http://localhost:3000/",
|
||||
]);
|
||||
});
|
||||
|
||||
test("Log out is not available for guest users", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const sidebarToggleButton = page.getByTestId("sidebar-toggle-button");
|
||||
await sidebarToggleButton.click();
|
||||
|
||||
const userNavButton = page.getByTestId("user-nav-button");
|
||||
await expect(userNavButton).toBeVisible();
|
||||
|
||||
await userNavButton.click();
|
||||
const userNavMenu = page.getByTestId("user-nav-menu");
|
||||
await expect(userNavMenu).toBeVisible();
|
||||
|
||||
const authMenuItem = page.getByTestId("user-nav-item-auth");
|
||||
await expect(authMenuItem).toContainText("Login to your account");
|
||||
});
|
||||
|
||||
test("Do not authenticate as guest user when an existing non-guest session is active", async ({
|
||||
adaContext,
|
||||
}) => {
|
||||
const response = await adaContext.page.goto("/");
|
||||
|
||||
if (!response) {
|
||||
throw new Error("Failed to load page");
|
||||
}
|
||||
|
||||
let request = response.request();
|
||||
|
||||
const chain: string[] = [];
|
||||
|
||||
while (request) {
|
||||
chain.unshift(request.url());
|
||||
request = request.redirectedFrom();
|
||||
}
|
||||
|
||||
expect(chain).toEqual(["http://localhost:3000/"]);
|
||||
});
|
||||
|
||||
test("Allow navigating to /login as guest user", async ({ page }) => {
|
||||
await page.goto("/login");
|
||||
await page.waitForURL("/login");
|
||||
await expect(page).toHaveURL("/login");
|
||||
});
|
||||
|
||||
test("Allow navigating to /register as guest user", async ({ page }) => {
|
||||
await page.goto("/register");
|
||||
await page.waitForURL("/register");
|
||||
await expect(page).toHaveURL("/register");
|
||||
});
|
||||
|
||||
test("Do not show email in user menu for guest user", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
const sidebarToggleButton = page.getByTestId("sidebar-toggle-button");
|
||||
await sidebarToggleButton.click();
|
||||
|
||||
const userEmail = page.getByTestId("user-email");
|
||||
await expect(userEmail).toContainText("Guest");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe
|
||||
.serial("Login and Registration", () => {
|
||||
let authPage: AuthPage;
|
||||
|
||||
const testUser = generateRandomTestUser();
|
||||
|
||||
test.beforeEach(({ page }) => {
|
||||
authPage = new AuthPage(page);
|
||||
});
|
||||
|
||||
test("Register new account", async () => {
|
||||
await authPage.register(testUser.email, testUser.password);
|
||||
await authPage.expectToastToContain("Account created successfully!");
|
||||
});
|
||||
|
||||
test("Register new account with existing email", async () => {
|
||||
await authPage.register(testUser.email, testUser.password);
|
||||
await authPage.expectToastToContain("Account already exists!");
|
||||
});
|
||||
|
||||
test("Log into account that exists", async ({ page }) => {
|
||||
await authPage.login(testUser.email, testUser.password);
|
||||
|
||||
await page.waitForURL("/");
|
||||
await expect(page.getByPlaceholder("Send a message...")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Display user email in user menu", async ({ page }) => {
|
||||
await authPage.login(testUser.email, testUser.password);
|
||||
|
||||
await page.waitForURL("/");
|
||||
await expect(page.getByPlaceholder("Send a message...")).toBeVisible();
|
||||
|
||||
const userEmail = await page.getByTestId("user-email");
|
||||
await expect(userEmail).toHaveText(testUser.email);
|
||||
});
|
||||
|
||||
test("Log out as non-guest user", async () => {
|
||||
await authPage.logout(testUser.email, testUser.password);
|
||||
});
|
||||
|
||||
test("Do not force create a guest session if non-guest session already exists", async ({
|
||||
page,
|
||||
}) => {
|
||||
await authPage.login(testUser.email, testUser.password);
|
||||
await page.waitForURL("/");
|
||||
|
||||
const userEmail = await page.getByTestId("user-email");
|
||||
await expect(userEmail).toHaveText(testUser.email);
|
||||
|
||||
await page.goto("/api/auth/guest");
|
||||
await page.waitForURL("/");
|
||||
|
||||
const updatedUserEmail = await page.getByTestId("user-email");
|
||||
await expect(updatedUserEmail).toHaveText(testUser.email);
|
||||
});
|
||||
|
||||
test("Log out is available for non-guest users", async ({ page }) => {
|
||||
await authPage.login(testUser.email, testUser.password);
|
||||
await page.waitForURL("/");
|
||||
|
||||
authPage.openSidebar();
|
||||
|
||||
const userNavButton = page.getByTestId("user-nav-button");
|
||||
await expect(userNavButton).toBeVisible();
|
||||
|
||||
await userNavButton.click();
|
||||
const userNavMenu = page.getByTestId("user-nav-menu");
|
||||
await expect(userNavMenu).toBeVisible();
|
||||
|
||||
const authMenuItem = page.getByTestId("user-nav-item-auth");
|
||||
await expect(authMenuItem).toContainText("Sign out");
|
||||
});
|
||||
|
||||
test("Do not navigate to /register for non-guest users", async ({
|
||||
page,
|
||||
}) => {
|
||||
await authPage.login(testUser.email, testUser.password);
|
||||
await page.waitForURL("/");
|
||||
|
||||
await page.goto("/register");
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
|
||||
test("Do not navigate to /login for non-guest users", async ({ page }) => {
|
||||
await authPage.login(testUser.email, testUser.password);
|
||||
await page.waitForURL("/");
|
||||
|
||||
await page.goto("/login");
|
||||
await expect(page).toHaveURL("/");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Entitlements", () => {
|
||||
let chatPage: ChatPage;
|
||||
|
||||
test.beforeEach(({ page }) => {
|
||||
chatPage = new ChatPage(page);
|
||||
});
|
||||
|
||||
test("Guest user cannot send more than 20 messages/day", async () => {
|
||||
test.fixme();
|
||||
await chatPage.createNewChat();
|
||||
|
||||
for (let i = 0; i <= 20; i++) {
|
||||
await chatPage.sendUserMessage("Why is the sky blue?");
|
||||
await chatPage.isGenerationComplete();
|
||||
}
|
||||
|
||||
await chatPage.sendUserMessage("Why is the sky blue?");
|
||||
await chatPage.expectToastToContain(
|
||||
getMessageByErrorCode("rate_limit:chat")
|
||||
);
|
||||
});
|
||||
});
|
||||
85
tests/e2e/sidebar.test.ts
Normal file
85
tests/e2e/sidebar.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
import { ChatPage } from "../pages/chat";
|
||||
|
||||
test.describe("Sidebar", () => {
|
||||
test("can toggle sidebar open and closed", async ({ page }) => {
|
||||
const chatPage = new ChatPage(page);
|
||||
await chatPage.createNewChat();
|
||||
|
||||
const toggleButton = page.getByTestId("sidebar-toggle-button");
|
||||
const sidebar = page.getByTestId("sidebar");
|
||||
|
||||
await toggleButton.click();
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
await toggleButton.click();
|
||||
await expect(sidebar).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("shows chat in history after sending message", async ({ page }) => {
|
||||
const chatPage = new ChatPage(page);
|
||||
await chatPage.createNewChat();
|
||||
await chatPage.sendUserMessage("Test message for history");
|
||||
await chatPage.isGenerationComplete();
|
||||
|
||||
const toggleButton = page.getByTestId("sidebar-toggle-button");
|
||||
await toggleButton.click();
|
||||
|
||||
const historyItem = page.getByTestId("sidebar-chat-item").first();
|
||||
await expect(historyItem).toBeVisible();
|
||||
});
|
||||
|
||||
test("can navigate to chat from history", async ({ page }) => {
|
||||
const chatPage = new ChatPage(page);
|
||||
await chatPage.createNewChat();
|
||||
await chatPage.sendUserMessage("First chat message");
|
||||
await chatPage.isGenerationComplete();
|
||||
|
||||
const firstChatUrl = page.url();
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
const toggleButton = page.getByTestId("sidebar-toggle-button");
|
||||
await toggleButton.click();
|
||||
|
||||
const historyItem = page.getByTestId("sidebar-chat-item").first();
|
||||
await historyItem.click();
|
||||
|
||||
await expect(page).toHaveURL(firstChatUrl);
|
||||
});
|
||||
|
||||
test("can delete chat from history", async ({ page }) => {
|
||||
const chatPage = new ChatPage(page);
|
||||
await chatPage.createNewChat();
|
||||
await chatPage.sendUserMessage("Chat to delete");
|
||||
await chatPage.isGenerationComplete();
|
||||
|
||||
const toggleButton = page.getByTestId("sidebar-toggle-button");
|
||||
await toggleButton.click();
|
||||
|
||||
const historyItem = page.getByTestId("sidebar-chat-item").first();
|
||||
await historyItem.hover();
|
||||
|
||||
const deleteButton = page.getByTestId("sidebar-chat-delete").first();
|
||||
await deleteButton.click();
|
||||
|
||||
const confirmButton = page.getByRole("button", { name: "Delete" });
|
||||
await confirmButton.click();
|
||||
|
||||
await expect(page.getByTestId("toast")).toContainText("deleted");
|
||||
});
|
||||
|
||||
test("can create new chat from sidebar", async ({ page }) => {
|
||||
const chatPage = new ChatPage(page);
|
||||
await chatPage.createNewChat();
|
||||
await chatPage.sendUserMessage("Initial message");
|
||||
await chatPage.isGenerationComplete();
|
||||
|
||||
const newChatButton = page.getByTestId("sidebar-new-chat");
|
||||
await newChatButton.click();
|
||||
|
||||
await expect(page).toHaveURL("/");
|
||||
await expect(page.getByTestId("multimodal-input")).toBeEmpty();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -3,45 +3,19 @@ import { getUnixTime } from "date-fns";
|
|||
import { createAuthenticatedContext, type UserContext } from "./helpers";
|
||||
|
||||
type Fixtures = {
|
||||
adaContext: UserContext;
|
||||
babbageContext: UserContext;
|
||||
curieContext: UserContext;
|
||||
authenticatedContext: UserContext;
|
||||
};
|
||||
|
||||
export const test = baseTest.extend<object, Fixtures>({
|
||||
adaContext: [
|
||||
authenticatedContext: [
|
||||
async ({ browser }, use, workerInfo) => {
|
||||
const ada = await createAuthenticatedContext({
|
||||
const userContext = await createAuthenticatedContext({
|
||||
browser,
|
||||
name: `ada-${workerInfo.workerIndex}-${getUnixTime(new Date())}`,
|
||||
name: `user-${workerInfo.workerIndex}-${getUnixTime(new Date())}`,
|
||||
});
|
||||
|
||||
await use(ada);
|
||||
await ada.context.close();
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
babbageContext: [
|
||||
async ({ browser }, use, workerInfo) => {
|
||||
const babbage = await createAuthenticatedContext({
|
||||
browser,
|
||||
name: `babbage-${workerInfo.workerIndex}-${getUnixTime(new Date())}`,
|
||||
});
|
||||
|
||||
await use(babbage);
|
||||
await babbage.context.close();
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
curieContext: [
|
||||
async ({ browser }, use, workerInfo) => {
|
||||
const curie = await createAuthenticatedContext({
|
||||
browser,
|
||||
name: `curie-${workerInfo.workerIndex}-${getUnixTime(new Date())}`,
|
||||
});
|
||||
|
||||
await use(curie);
|
||||
await curie.context.close();
|
||||
await use(userContext);
|
||||
await userContext.context.close();
|
||||
},
|
||||
{ scope: "worker" },
|
||||
],
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
} from "@playwright/test";
|
||||
import { generateId } from "ai";
|
||||
import { getUnixTime } from "date-fns";
|
||||
import { ChatPage } from "./pages/chat";
|
||||
|
||||
export type UserContext = {
|
||||
context: BrowserContext;
|
||||
|
|
@ -49,10 +48,8 @@ export async function createAuthenticatedContext({
|
|||
"Account created successfully!"
|
||||
);
|
||||
|
||||
const chatPage = new ChatPage(page);
|
||||
await chatPage.createNewChat();
|
||||
await chatPage.chooseModelFromSelector("chat-model-reasoning");
|
||||
await expect(chatPage.getSelectedModel()).resolves.toEqual("Reasoning model");
|
||||
// Wait for redirect to home page
|
||||
await page.waitForURL("/");
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
await context.storageState({ path: storageFile });
|
||||
|
|
|
|||
|
|
@ -1,120 +0,0 @@
|
|||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
export class ArtifactPage {
|
||||
private readonly page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
get artifact() {
|
||||
return this.page.getByTestId("artifact");
|
||||
}
|
||||
|
||||
get sendButton() {
|
||||
return this.artifact.getByTestId("send-button");
|
||||
}
|
||||
|
||||
get stopButton() {
|
||||
return this.page.getByTestId("stop-button");
|
||||
}
|
||||
|
||||
get multimodalInput() {
|
||||
return this.page.getByTestId("multimodal-input");
|
||||
}
|
||||
|
||||
async isGenerationComplete() {
|
||||
const response = await this.page.waitForResponse((currentResponse) =>
|
||||
currentResponse.url().includes("/api/chat")
|
||||
);
|
||||
|
||||
await response.finished();
|
||||
}
|
||||
|
||||
async sendUserMessage(message: string) {
|
||||
await this.artifact.getByTestId("multimodal-input").click();
|
||||
await this.artifact.getByTestId("multimodal-input").fill(message);
|
||||
await this.artifact.getByTestId("send-button").click();
|
||||
}
|
||||
|
||||
async getRecentAssistantMessage() {
|
||||
const messageElements = await this.artifact
|
||||
.getByTestId("message-assistant")
|
||||
.all();
|
||||
const lastMessageElement = messageElements.at(-1);
|
||||
|
||||
if (!lastMessageElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = await lastMessageElement
|
||||
.getByTestId("message-content")
|
||||
.innerText()
|
||||
.catch(() => null);
|
||||
|
||||
const reasoningElement = await lastMessageElement
|
||||
.getByTestId("message-reasoning")
|
||||
.isVisible()
|
||||
.then(async (visible) =>
|
||||
visible
|
||||
? await lastMessageElement
|
||||
.getByTestId("message-reasoning")
|
||||
.innerText()
|
||||
: null
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
return {
|
||||
element: lastMessageElement,
|
||||
content,
|
||||
reasoning: reasoningElement,
|
||||
async toggleReasoningVisibility() {
|
||||
await lastMessageElement
|
||||
.getByTestId("message-reasoning-toggle")
|
||||
.click();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getRecentUserMessage() {
|
||||
const messageElements = await this.artifact
|
||||
.getByTestId("message-user")
|
||||
.all();
|
||||
const lastMessageElement = messageElements.at(-1);
|
||||
|
||||
if (!lastMessageElement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = await lastMessageElement.innerText();
|
||||
|
||||
const hasAttachments = await lastMessageElement
|
||||
.getByTestId("message-attachments")
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
|
||||
const attachments = hasAttachments
|
||||
? await lastMessageElement.getByTestId("message-attachments").all()
|
||||
: [];
|
||||
|
||||
const page = this.artifact;
|
||||
|
||||
return {
|
||||
element: lastMessageElement,
|
||||
content,
|
||||
attachments,
|
||||
async edit(newMessage: string) {
|
||||
await page.getByTestId("message-edit-button").click();
|
||||
await page.getByTestId("message-editor").fill(newMessage);
|
||||
await page.getByTestId("message-editor-send-button").click();
|
||||
await expect(
|
||||
page.getByTestId("message-editor-send-button")
|
||||
).not.toBeVisible();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
closeArtifact() {
|
||||
return this.page.getByTestId("artifact-close-button").click();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "../fixtures";
|
||||
|
||||
export class AuthPage {
|
||||
private readonly page: Page;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
async gotoLogin() {
|
||||
await this.page.goto("/login");
|
||||
await expect(this.page.getByRole("heading")).toContainText("Sign In");
|
||||
}
|
||||
|
||||
async gotoRegister() {
|
||||
await this.page.goto("/register");
|
||||
await expect(this.page.getByRole("heading")).toContainText("Sign Up");
|
||||
}
|
||||
|
||||
async register(email: string, password: string) {
|
||||
await this.gotoRegister();
|
||||
await this.page.getByPlaceholder("user@acme.com").click();
|
||||
await this.page.getByPlaceholder("user@acme.com").fill(email);
|
||||
await this.page.getByLabel("Password").click();
|
||||
await this.page.getByLabel("Password").fill(password);
|
||||
await this.page.getByRole("button", { name: "Sign Up" }).click();
|
||||
}
|
||||
|
||||
async login(email: string, password: string) {
|
||||
await this.gotoLogin();
|
||||
await this.page.getByPlaceholder("user@acme.com").click();
|
||||
await this.page.getByPlaceholder("user@acme.com").fill(email);
|
||||
await this.page.getByLabel("Password").click();
|
||||
await this.page.getByLabel("Password").fill(password);
|
||||
await this.page.getByRole("button", { name: "Sign In" }).click();
|
||||
}
|
||||
|
||||
async logout(email: string, password: string) {
|
||||
await this.login(email, password);
|
||||
await this.page.waitForURL("/");
|
||||
|
||||
await this.openSidebar();
|
||||
|
||||
const userNavButton = this.page.getByTestId("user-nav-button");
|
||||
await expect(userNavButton).toBeVisible();
|
||||
|
||||
await userNavButton.click();
|
||||
const userNavMenu = this.page.getByTestId("user-nav-menu");
|
||||
await expect(userNavMenu).toBeVisible();
|
||||
|
||||
const authMenuItem = this.page.getByTestId("user-nav-item-auth");
|
||||
await expect(authMenuItem).toContainText("Sign out");
|
||||
|
||||
await authMenuItem.click();
|
||||
|
||||
const userEmail = this.page.getByTestId("user-email");
|
||||
await expect(userEmail).toContainText("Guest");
|
||||
}
|
||||
|
||||
async expectToastToContain(text: string) {
|
||||
await expect(this.page.getByTestId("toast")).toContainText(text);
|
||||
}
|
||||
|
||||
async openSidebar() {
|
||||
const sidebarToggleButton = this.page.getByTestId("sidebar-toggle-button");
|
||||
await sidebarToggleButton.click();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,4 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { chatModels } from "@/lib/ai/models";
|
||||
|
||||
const CHAT_ID_REGEX =
|
||||
/^http:\/\/localhost:3000\/chat\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
|
|
@ -25,16 +22,17 @@ export class ChatPage {
|
|||
return this.page.getByTestId("multimodal-input");
|
||||
}
|
||||
|
||||
get scrollContainer() {
|
||||
return this.page.locator(".overflow-y-scroll");
|
||||
get messagesContainer() {
|
||||
return this.page.locator("[data-testid='messages-container']");
|
||||
}
|
||||
|
||||
get scrollToBottomButton() {
|
||||
return this.page.getByTestId("scroll-to-bottom-button");
|
||||
async goto() {
|
||||
await this.page.goto("/");
|
||||
await this.page.waitForLoadState("networkidle");
|
||||
}
|
||||
|
||||
async createNewChat() {
|
||||
await this.page.goto("/");
|
||||
await this.goto();
|
||||
}
|
||||
|
||||
getCurrentURL(): string {
|
||||
|
|
@ -47,219 +45,69 @@ export class ChatPage {
|
|||
await this.sendButton.click();
|
||||
}
|
||||
|
||||
async isGenerationComplete() {
|
||||
const response = await this.page.waitForResponse((currentResponse) =>
|
||||
currentResponse.url().includes("/api/chat")
|
||||
async waitForResponse(timeout = 30_000) {
|
||||
const response = await this.page.waitForResponse(
|
||||
(res) => res.url().includes("/api/chat") && res.status() === 200,
|
||||
{ timeout }
|
||||
);
|
||||
|
||||
await response.finished();
|
||||
}
|
||||
|
||||
async isVoteComplete() {
|
||||
const response = await this.page.waitForResponse((currentResponse) =>
|
||||
currentResponse.url().includes("/api/vote")
|
||||
);
|
||||
|
||||
await response.finished();
|
||||
async isGenerationComplete(timeout = 30_000) {
|
||||
await this.waitForResponse(timeout);
|
||||
await this.page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
async hasChatIdInUrl() {
|
||||
await expect(this.page).toHaveURL(CHAT_ID_REGEX);
|
||||
}
|
||||
|
||||
async sendUserMessageFromSuggestion() {
|
||||
await this.page
|
||||
.getByRole("button", { name: "What are the advantages of" })
|
||||
.click();
|
||||
async getAssistantMessages() {
|
||||
return await this.page.getByTestId("message-assistant").all();
|
||||
}
|
||||
|
||||
async isElementVisible(elementId: string) {
|
||||
await expect(this.page.getByTestId(elementId)).toBeVisible();
|
||||
async getUserMessages() {
|
||||
return await this.page.getByTestId("message-user").all();
|
||||
}
|
||||
|
||||
async isElementNotVisible(elementId: string) {
|
||||
await expect(this.page.getByTestId(elementId)).not.toBeVisible();
|
||||
}
|
||||
|
||||
async addImageAttachment() {
|
||||
this.page.on("filechooser", async (fileChooser) => {
|
||||
const filePath = path.join(
|
||||
process.cwd(),
|
||||
"public",
|
||||
"images",
|
||||
"mouth of the seine, monet.jpg"
|
||||
);
|
||||
const imageBuffer = fs.readFileSync(filePath);
|
||||
|
||||
await fileChooser.setFiles({
|
||||
name: "mouth of the seine, monet.jpg",
|
||||
mimeType: "image/jpeg",
|
||||
buffer: imageBuffer,
|
||||
});
|
||||
});
|
||||
|
||||
await this.page.getByTestId("attachments-button").click();
|
||||
}
|
||||
|
||||
async getSelectedModel() {
|
||||
const modelId = await this.page.getByTestId("model-selector").innerText();
|
||||
return modelId;
|
||||
}
|
||||
|
||||
async chooseModelFromSelector(chatModelId: string) {
|
||||
const chatModel = chatModels.find(
|
||||
(currentChatModel) => currentChatModel.id === chatModelId
|
||||
);
|
||||
|
||||
if (!chatModel) {
|
||||
throw new Error(`Model with id ${chatModelId} not found`);
|
||||
}
|
||||
|
||||
await this.page.getByTestId("model-selector").click();
|
||||
await this.page.getByTestId(`model-selector-item-${chatModelId}`).click();
|
||||
expect(await this.getSelectedModel()).toBe(chatModel.name);
|
||||
}
|
||||
|
||||
async getSelectedVisibility() {
|
||||
const visibilityId = await this.page
|
||||
.getByTestId("visibility-selector")
|
||||
.innerText();
|
||||
return visibilityId;
|
||||
}
|
||||
|
||||
async chooseVisibilityFromSelector(chatVisibility: "public" | "private") {
|
||||
await this.page.getByTestId("visibility-selector").click();
|
||||
await this.page
|
||||
.getByTestId(`visibility-selector-item-${chatVisibility}`)
|
||||
.click();
|
||||
expect(await this.getSelectedVisibility()).toBe(chatVisibility);
|
||||
}
|
||||
|
||||
async getRecentAssistantMessage() {
|
||||
const messageElements = await this.page
|
||||
.getByTestId("message-assistant")
|
||||
.all();
|
||||
const lastMessageElement = messageElements.at(-1);
|
||||
|
||||
if (!lastMessageElement) {
|
||||
async getLastAssistantMessageContent(): Promise<string | null> {
|
||||
const messages = await this.getAssistantMessages();
|
||||
const lastMessage = messages.at(-1);
|
||||
if (!lastMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = await lastMessageElement
|
||||
const content = await lastMessage
|
||||
.getByTestId("message-content")
|
||||
.innerText()
|
||||
.catch(() => null);
|
||||
|
||||
const reasoningElement = await lastMessageElement
|
||||
.getByTestId("message-reasoning")
|
||||
.isVisible()
|
||||
.then(async (visible) =>
|
||||
visible
|
||||
? await lastMessageElement
|
||||
.getByTestId("message-reasoning")
|
||||
.innerText()
|
||||
: null
|
||||
)
|
||||
.catch(() => null);
|
||||
|
||||
return {
|
||||
element: lastMessageElement,
|
||||
content,
|
||||
reasoning: reasoningElement,
|
||||
async toggleReasoningVisibility() {
|
||||
await lastMessageElement
|
||||
.getByTestId("message-reasoning-toggle")
|
||||
.click();
|
||||
},
|
||||
async upvote() {
|
||||
await lastMessageElement.getByTestId("message-upvote").click();
|
||||
},
|
||||
async downvote() {
|
||||
await lastMessageElement.getByTestId("message-downvote").click();
|
||||
},
|
||||
};
|
||||
.innerText();
|
||||
return content;
|
||||
}
|
||||
|
||||
async getRecentUserMessage() {
|
||||
const messageElements = await this.page.getByTestId("message-user").all();
|
||||
const lastMessageElement = messageElements.at(-1);
|
||||
|
||||
if (!lastMessageElement) {
|
||||
throw new Error("No user message found");
|
||||
async getLastUserMessageContent(): Promise<string | null> {
|
||||
const messages = await this.getUserMessages();
|
||||
const lastMessage = messages.at(-1);
|
||||
if (!lastMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = await lastMessageElement
|
||||
const content = await lastMessage
|
||||
.getByTestId("message-content")
|
||||
.innerText()
|
||||
.catch(() => null);
|
||||
.innerText();
|
||||
return content;
|
||||
}
|
||||
|
||||
const hasAttachments = await lastMessageElement
|
||||
.getByTestId("message-attachments")
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
async isElementVisible(testId: string) {
|
||||
await expect(this.page.getByTestId(testId)).toBeVisible();
|
||||
}
|
||||
|
||||
const attachments = hasAttachments
|
||||
? await lastMessageElement.getByTestId("message-attachments").all()
|
||||
: [];
|
||||
|
||||
const page = this.page;
|
||||
|
||||
return {
|
||||
element: lastMessageElement,
|
||||
content,
|
||||
attachments,
|
||||
async edit(newMessage: string) {
|
||||
await page.getByTestId("message-edit-button").click();
|
||||
await page.getByTestId("message-editor").fill(newMessage);
|
||||
await page.getByTestId("message-editor-send-button").click();
|
||||
await expect(
|
||||
page.getByTestId("message-editor-send-button")
|
||||
).not.toBeVisible();
|
||||
},
|
||||
};
|
||||
async isElementNotVisible(testId: string) {
|
||||
await expect(this.page.getByTestId(testId)).not.toBeVisible();
|
||||
}
|
||||
|
||||
async expectToastToContain(text: string) {
|
||||
await expect(this.page.getByTestId("toast")).toContainText(text);
|
||||
}
|
||||
|
||||
async openSideBar() {
|
||||
const sidebarToggleButton = this.page.getByTestId("sidebar-toggle-button");
|
||||
await sidebarToggleButton.click();
|
||||
}
|
||||
|
||||
isScrolledToBottom(): Promise<boolean> {
|
||||
return this.scrollContainer.evaluate(
|
||||
(el) => Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 1
|
||||
);
|
||||
}
|
||||
|
||||
async waitForScrollToBottom(timeout = 5000): Promise<void> {
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
if (await this.isScrolledToBottom()) {
|
||||
return;
|
||||
}
|
||||
await this.page.waitForTimeout(100);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for scroll bottom after ${timeout}ms`);
|
||||
}
|
||||
|
||||
async sendMultipleMessages(
|
||||
count: number,
|
||||
makeMessage: (i: number) => string
|
||||
) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
await this.sendUserMessage(makeMessage(i));
|
||||
await this.isGenerationComplete();
|
||||
}
|
||||
}
|
||||
|
||||
async scrollToTop(): Promise<void> {
|
||||
await this.scrollContainer.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
});
|
||||
async waitForInputToBeReady() {
|
||||
await expect(this.multimodalInput).toBeVisible();
|
||||
await expect(this.sendButton).toBeVisible();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,148 +0,0 @@
|
|||
import type { ModelMessage } from "ai";
|
||||
|
||||
export const TEST_PROMPTS: Record<string, ModelMessage> = {
|
||||
USER_SKY: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Why is the sky blue?" }],
|
||||
},
|
||||
USER_GRASS: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Why is grass green?" }],
|
||||
},
|
||||
USER_THANKS: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Thanks!" }],
|
||||
},
|
||||
USER_NEXTJS: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "What are the advantages of using Next.js?" },
|
||||
],
|
||||
},
|
||||
USER_IMAGE_ATTACHMENT: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "file",
|
||||
mediaType: "...",
|
||||
data: "...",
|
||||
},
|
||||
{
|
||||
type: "text",
|
||||
text: "Who painted this?",
|
||||
},
|
||||
],
|
||||
},
|
||||
USER_TEXT_ARTIFACT: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Help me write an essay about Silicon Valley",
|
||||
},
|
||||
],
|
||||
},
|
||||
CREATE_DOCUMENT_TEXT_CALL: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Essay about Silicon Valley",
|
||||
},
|
||||
],
|
||||
},
|
||||
CREATE_DOCUMENT_TEXT_RESULT: {
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call_123",
|
||||
toolName: "createDocument",
|
||||
output: {
|
||||
type: "json",
|
||||
value: {
|
||||
id: "3ca386a4-40c6-4630-8ed1-84cbd46cc7eb",
|
||||
title: "Essay about Silicon Valley",
|
||||
kind: "text",
|
||||
content: "A document was created and is now visible to the user.",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
GET_WEATHER_CALL: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "What's the weather in sf?",
|
||||
},
|
||||
],
|
||||
},
|
||||
GET_WEATHER_RESULT: {
|
||||
role: "tool",
|
||||
content: [
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call_456",
|
||||
toolName: "getWeather",
|
||||
output: {
|
||||
type: "json",
|
||||
value: {
|
||||
latitude: 37.763_283,
|
||||
longitude: -122.412_86,
|
||||
generationtime_ms: 0.064_492_225_646_972_66,
|
||||
utc_offset_seconds: -25_200,
|
||||
timezone: "America/Los_Angeles",
|
||||
timezone_abbreviation: "GMT-7",
|
||||
elevation: 18,
|
||||
current_units: {
|
||||
time: "iso8601",
|
||||
interval: "seconds",
|
||||
temperature_2m: "°C",
|
||||
},
|
||||
current: {
|
||||
time: "2025-03-10T14:00",
|
||||
interval: 900,
|
||||
temperature_2m: 17,
|
||||
},
|
||||
daily_units: {
|
||||
time: "iso8601",
|
||||
sunrise: "iso8601",
|
||||
sunset: "iso8601",
|
||||
},
|
||||
daily: {
|
||||
time: [
|
||||
"2025-03-10",
|
||||
"2025-03-11",
|
||||
"2025-03-12",
|
||||
"2025-03-13",
|
||||
"2025-03-14",
|
||||
"2025-03-15",
|
||||
"2025-03-16",
|
||||
],
|
||||
sunrise: [
|
||||
"2025-03-10T07:27",
|
||||
"2025-03-11T07:25",
|
||||
"2025-03-12T07:24",
|
||||
"2025-03-13T07:22",
|
||||
"2025-03-14T07:21",
|
||||
"2025-03-15T07:19",
|
||||
"2025-03-16T07:18",
|
||||
],
|
||||
sunset: [
|
||||
"2025-03-10T19:12",
|
||||
"2025-03-11T19:13",
|
||||
"2025-03-12T19:14",
|
||||
"2025-03-13T19:15",
|
||||
"2025-03-14T19:16",
|
||||
"2025-03-15T19:17",
|
||||
"2025-03-16T19:17",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
export const TEST_PROMPTS = {
|
||||
SKY: {
|
||||
MESSAGE: {
|
||||
id: generateUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
role: "user",
|
||||
content: "Why is the sky blue?",
|
||||
parts: [{ type: "text", text: "Why is the sky blue?" }],
|
||||
},
|
||||
OUTPUT_STREAM: [
|
||||
'data: {"type":"start-step"}',
|
||||
'data: {"type":"text-start","id":"STATIC_ID"}',
|
||||
'data: {"type":"text-delta","id":"STATIC_ID","delta":"It\'s "}',
|
||||
'data: {"type":"text-delta","id":"STATIC_ID","delta":"just "}',
|
||||
'data: {"type":"text-delta","id":"STATIC_ID","delta":"blue "}',
|
||||
'data: {"type":"text-delta","id":"STATIC_ID","delta":"duh! "}',
|
||||
'data: {"type":"text-end","id":"STATIC_ID"}',
|
||||
'data: {"type":"finish-step"}',
|
||||
'data: {"type":"finish"}',
|
||||
"data: [DONE]",
|
||||
],
|
||||
},
|
||||
GRASS: {
|
||||
MESSAGE: {
|
||||
id: generateUUID(),
|
||||
createdAt: new Date().toISOString(),
|
||||
role: "user",
|
||||
content: "Why is grass green?",
|
||||
parts: [{ type: "text", text: "Why is grass green?" }],
|
||||
},
|
||||
OUTPUT_STREAM: [
|
||||
'data: {"type":"start-step"}',
|
||||
'data: {"type":"text-start","id":"STATIC_ID"}',
|
||||
'data: {"type":"text-delta","id":"STATIC_ID","delta":"It\'s "}',
|
||||
'data: {"type":"text-delta","id":"STATIC_ID","delta":"just "}',
|
||||
'data: {"type":"text-delta","id":"STATIC_ID","delta":"green "}',
|
||||
'data: {"type":"text-delta","id":"STATIC_ID","delta":"duh! "}',
|
||||
'data: {"type":"text-end","id":"STATIC_ID"}',
|
||||
'data: {"type":"finish-step"}',
|
||||
'data: {"type":"finish"}',
|
||||
"data: [DONE]",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
@ -1,282 +0,0 @@
|
|||
import type { LanguageModelV2StreamPart } from "@ai-sdk/provider";
|
||||
import { generateId, type ModelMessage } from "ai";
|
||||
import { TEST_PROMPTS } from "./basic";
|
||||
|
||||
export function compareMessages(
|
||||
firstMessage: ModelMessage,
|
||||
secondMessage: ModelMessage
|
||||
): boolean {
|
||||
if (firstMessage.role !== secondMessage.role) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!Array.isArray(firstMessage.content) ||
|
||||
!Array.isArray(secondMessage.content)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (firstMessage.content.length !== secondMessage.content.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < firstMessage.content.length; i++) {
|
||||
const item1 = firstMessage.content[i];
|
||||
const item2 = secondMessage.content[i];
|
||||
|
||||
if (item1.type !== item2.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (item1.type === "file" && item2.type === "file") {
|
||||
// if (item1.image.toString() !== item2.image.toString()) return false;
|
||||
// if (item1.mimeType !== item2.mimeType) return false;
|
||||
} else if (item1.type === "text" && item2.type === "text") {
|
||||
if (item1.text !== item2.text) {
|
||||
return false;
|
||||
}
|
||||
} else if (item1.type === "tool-result" && item2.type === "tool-result") {
|
||||
if (item1.toolCallId !== item2.toolCallId) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const textToDeltas = (text: string): LanguageModelV2StreamPart[] => {
|
||||
const id = generateId();
|
||||
|
||||
const deltas = text.split(" ").map((char) => ({
|
||||
id,
|
||||
type: "text-delta" as const,
|
||||
delta: `${char} `,
|
||||
}));
|
||||
|
||||
return [{ id, type: "text-start" }, ...deltas, { id, type: "text-end" }];
|
||||
};
|
||||
|
||||
const reasoningToDeltas = (text: string): LanguageModelV2StreamPart[] => {
|
||||
const id = generateId();
|
||||
|
||||
const deltas = text.split(" ").map((char) => ({
|
||||
id,
|
||||
type: "reasoning-delta" as const,
|
||||
delta: `${char} `,
|
||||
}));
|
||||
|
||||
return [
|
||||
{ id, type: "reasoning-start" },
|
||||
...deltas,
|
||||
{ id, type: "reasoning-end" },
|
||||
];
|
||||
};
|
||||
|
||||
export const getResponseChunksByPrompt = (
|
||||
prompt: ModelMessage[],
|
||||
isReasoningEnabled = false
|
||||
): LanguageModelV2StreamPart[] => {
|
||||
const recentMessage = prompt.at(-1);
|
||||
|
||||
if (!recentMessage) {
|
||||
throw new Error("No recent message found!");
|
||||
}
|
||||
|
||||
if (isReasoningEnabled) {
|
||||
if (compareMessages(recentMessage, TEST_PROMPTS.USER_SKY)) {
|
||||
return [
|
||||
...reasoningToDeltas("The sky is blue because of rayleigh scattering!"),
|
||||
...textToDeltas("It's just blue duh!"),
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 3, outputTokens: 10, totalTokens: 13 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (compareMessages(recentMessage, TEST_PROMPTS.USER_GRASS)) {
|
||||
return [
|
||||
...reasoningToDeltas(
|
||||
"Grass is green because of chlorophyll absorption!"
|
||||
),
|
||||
...textToDeltas("It's just green duh!"),
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 3, outputTokens: 10, totalTokens: 13 },
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (compareMessages(recentMessage, TEST_PROMPTS.USER_THANKS)) {
|
||||
return [
|
||||
...textToDeltas("You're welcome!"),
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 3, outputTokens: 10, totalTokens: 13 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (compareMessages(recentMessage, TEST_PROMPTS.USER_GRASS)) {
|
||||
return [
|
||||
...textToDeltas("It's just green duh!"),
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 3, outputTokens: 10, totalTokens: 13 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (compareMessages(recentMessage, TEST_PROMPTS.USER_SKY)) {
|
||||
return [
|
||||
...textToDeltas("It's just blue duh!"),
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 3, outputTokens: 10, totalTokens: 13 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (compareMessages(recentMessage, TEST_PROMPTS.USER_NEXTJS)) {
|
||||
return [
|
||||
...textToDeltas("With Next.js, you can ship fast!"),
|
||||
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 3, outputTokens: 10, totalTokens: 13 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (compareMessages(recentMessage, TEST_PROMPTS.USER_IMAGE_ATTACHMENT)) {
|
||||
return [
|
||||
...textToDeltas("This painting is by Monet!"),
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 3, outputTokens: 10, totalTokens: 13 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (compareMessages(recentMessage, TEST_PROMPTS.USER_TEXT_ARTIFACT)) {
|
||||
const toolCallId = generateId();
|
||||
|
||||
return [
|
||||
{
|
||||
id: toolCallId,
|
||||
type: "tool-input-start",
|
||||
toolName: "createDocument",
|
||||
},
|
||||
{
|
||||
id: toolCallId,
|
||||
type: "tool-input-delta",
|
||||
delta: JSON.stringify({
|
||||
title: "Essay about Silicon Valley",
|
||||
kind: "text",
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: toolCallId,
|
||||
type: "tool-input-end",
|
||||
},
|
||||
{
|
||||
toolCallId,
|
||||
type: "tool-result",
|
||||
toolName: "createDocument",
|
||||
result: {
|
||||
id: "doc_123",
|
||||
title: "Essay about Silicon Valley",
|
||||
kind: "text",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 3, outputTokens: 10, totalTokens: 13 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (compareMessages(recentMessage, TEST_PROMPTS.CREATE_DOCUMENT_TEXT_CALL)) {
|
||||
return [
|
||||
...textToDeltas(`\n
|
||||
# Silicon Valley: The Epicenter of Innovation
|
||||
|
||||
## Origins and Evolution
|
||||
|
||||
Silicon Valley, nestled in the southern part of the San Francisco Bay Area, emerged as a global technology hub in the late 20th century. Its transformation began in the 1950s when Stanford University encouraged its graduates to start their own companies nearby, leading to the formation of pioneering semiconductor firms that gave the region its name.
|
||||
|
||||
## The Innovation Ecosystem
|
||||
|
||||
What makes Silicon Valley unique is its perfect storm of critical elements: prestigious universities like Stanford and Berkeley, abundant venture capital, a culture that celebrates risk-taking, and a dense network of talented individuals. This ecosystem has consistently nurtured groundbreaking technologies from personal computers to social media platforms to artificial intelligence.
|
||||
|
||||
## Challenges and Criticisms
|
||||
|
||||
Despite its remarkable success, Silicon Valley faces significant challenges including extreme income inequality, housing affordability crises, and questions about technology's impact on society. Critics argue the region has developed a monoculture that sometimes struggles with diversity and inclusion.
|
||||
|
||||
## Future Prospects
|
||||
|
||||
As we move forward, Silicon Valley continues to reinvent itself. While some predict its decline due to remote work trends and competition from other tech hubs, the region's adaptability and innovative spirit suggest it will remain influential in shaping our technological future for decades to come.
|
||||
`),
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 3, outputTokens: 10, totalTokens: 13 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (
|
||||
compareMessages(recentMessage, TEST_PROMPTS.CREATE_DOCUMENT_TEXT_RESULT)
|
||||
) {
|
||||
return [
|
||||
...textToDeltas("A document was created and is now visible to the user."),
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 3, outputTokens: 10, totalTokens: 13 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (compareMessages(recentMessage, TEST_PROMPTS.GET_WEATHER_CALL)) {
|
||||
return [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_456",
|
||||
toolName: "getWeather",
|
||||
input: JSON.stringify({ latitude: 37.7749, longitude: -122.4194 }),
|
||||
},
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 3, outputTokens: 10, totalTokens: 13 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (compareMessages(recentMessage, TEST_PROMPTS.GET_WEATHER_RESULT)) {
|
||||
return [
|
||||
...textToDeltas("The current temperature in San Francisco is 17°C."),
|
||||
{
|
||||
type: "finish",
|
||||
finishReason: "stop",
|
||||
usage: { inputTokens: 3, outputTokens: 10, totalTokens: 13 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [{ id: "6", type: "text-delta", delta: "Unknown test prompt!" }];
|
||||
};
|
||||
|
|
@ -1,366 +0,0 @@
|
|||
import { getMessageByErrorCode } from "@/lib/errors";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { expect, test } from "../fixtures";
|
||||
import { TEST_PROMPTS } from "../prompts/routes";
|
||||
|
||||
const chatIdsCreatedByAda: string[] = [];
|
||||
|
||||
// Helper function to normalize stream data for comparison
|
||||
function normalizeStreamData(lines: string[]): string[] {
|
||||
return lines.map((line) => {
|
||||
if (line.startsWith("data: ")) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6)); // Remove 'data: ' prefix
|
||||
if (data.id) {
|
||||
// Replace dynamic id with a static one for comparison
|
||||
return `data: ${JSON.stringify({ ...data, id: "STATIC_ID" })}`;
|
||||
}
|
||||
return line;
|
||||
} catch {
|
||||
return line; // Return as-is if it's not valid JSON
|
||||
}
|
||||
}
|
||||
return line;
|
||||
});
|
||||
}
|
||||
|
||||
test.describe
|
||||
.serial("/api/chat", () => {
|
||||
test("Ada cannot invoke a chat generation with empty request body", async ({
|
||||
adaContext,
|
||||
}) => {
|
||||
const response = await adaContext.request.post("/api/chat", {
|
||||
data: JSON.stringify({}),
|
||||
});
|
||||
expect(response.status()).toBe(400);
|
||||
|
||||
const { code, message } = await response.json();
|
||||
expect(code).toEqual("bad_request:api");
|
||||
expect(message).toEqual(getMessageByErrorCode("bad_request:api"));
|
||||
});
|
||||
|
||||
test("Ada can invoke chat generation", async ({ adaContext }) => {
|
||||
const chatId = generateUUID();
|
||||
|
||||
const response = await adaContext.request.post("/api/chat", {
|
||||
data: {
|
||||
id: chatId,
|
||||
message: TEST_PROMPTS.SKY.MESSAGE,
|
||||
selectedChatModel: "chat-model",
|
||||
selectedVisibilityType: "private",
|
||||
},
|
||||
});
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const text = await response.text();
|
||||
const lines = text.split("\n");
|
||||
|
||||
const [_, ...rest] = lines;
|
||||
const actualNormalized = normalizeStreamData(rest.filter(Boolean));
|
||||
const expectedNormalized = normalizeStreamData(
|
||||
TEST_PROMPTS.SKY.OUTPUT_STREAM
|
||||
);
|
||||
|
||||
expect(actualNormalized).toEqual(expectedNormalized);
|
||||
|
||||
chatIdsCreatedByAda.push(chatId);
|
||||
});
|
||||
|
||||
test("Babbage cannot append message to Ada's chat", async ({
|
||||
babbageContext,
|
||||
}) => {
|
||||
const [chatId] = chatIdsCreatedByAda;
|
||||
|
||||
const response = await babbageContext.request.post("/api/chat", {
|
||||
data: {
|
||||
id: chatId,
|
||||
message: TEST_PROMPTS.GRASS.MESSAGE,
|
||||
selectedChatModel: "chat-model",
|
||||
selectedVisibilityType: "private",
|
||||
},
|
||||
});
|
||||
expect(response.status()).toBe(403);
|
||||
|
||||
const { code, message } = await response.json();
|
||||
expect(code).toEqual("forbidden:chat");
|
||||
expect(message).toEqual(getMessageByErrorCode("forbidden:chat"));
|
||||
});
|
||||
|
||||
test("Babbage cannot delete Ada's chat", async ({ babbageContext }) => {
|
||||
const [chatId] = chatIdsCreatedByAda;
|
||||
|
||||
const response = await babbageContext.request.delete(
|
||||
`/api/chat?id=${chatId}`
|
||||
);
|
||||
expect(response.status()).toBe(403);
|
||||
|
||||
const { code, message } = await response.json();
|
||||
expect(code).toEqual("forbidden:chat");
|
||||
expect(message).toEqual(getMessageByErrorCode("forbidden:chat"));
|
||||
});
|
||||
|
||||
test("Ada can delete her own chat", async ({ adaContext }) => {
|
||||
const [chatId] = chatIdsCreatedByAda;
|
||||
|
||||
const response = await adaContext.request.delete(
|
||||
`/api/chat?id=${chatId}`
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const deletedChat = await response.json();
|
||||
expect(deletedChat).toMatchObject({ id: chatId });
|
||||
});
|
||||
|
||||
test("Ada cannot resume stream of chat that does not exist", async ({
|
||||
adaContext,
|
||||
}) => {
|
||||
const response = await adaContext.request.get(
|
||||
`/api/chat/${generateUUID()}/stream`
|
||||
);
|
||||
expect(response.status()).toBe(404);
|
||||
});
|
||||
|
||||
test("Ada can resume chat generation", async ({ adaContext }) => {
|
||||
const chatId = generateUUID();
|
||||
|
||||
const firstRequest = adaContext.request.post("/api/chat", {
|
||||
data: {
|
||||
id: chatId,
|
||||
message: {
|
||||
id: generateUUID(),
|
||||
role: "user",
|
||||
content: "Help me write an essay about Silcon Valley",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Help me write an essay about Silicon Valley",
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
selectedChatModel: "chat-model",
|
||||
selectedVisibilityType: "private",
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
const secondRequest = adaContext.request.get(
|
||||
`/api/chat/${chatId}/stream`
|
||||
);
|
||||
|
||||
const [firstResponse, secondResponse] = await Promise.all([
|
||||
firstRequest,
|
||||
secondRequest,
|
||||
]);
|
||||
|
||||
const [firstStatusCode, secondStatusCode] = await Promise.all([
|
||||
firstResponse.status(),
|
||||
secondResponse.status(),
|
||||
]);
|
||||
|
||||
expect(firstStatusCode).toBe(200);
|
||||
expect(secondStatusCode).toBe(200);
|
||||
|
||||
const [firstResponseBody, secondResponseBody] = await Promise.all([
|
||||
await firstResponse.body(),
|
||||
await secondResponse.body(),
|
||||
]);
|
||||
|
||||
expect(firstResponseBody.toString()).toEqual(
|
||||
secondResponseBody.toString()
|
||||
);
|
||||
});
|
||||
|
||||
test("Ada can resume chat generation that has ended during request", async ({
|
||||
adaContext,
|
||||
}) => {
|
||||
const chatId = generateUUID();
|
||||
|
||||
const firstRequest = await adaContext.request.post("/api/chat", {
|
||||
data: {
|
||||
id: chatId,
|
||||
message: {
|
||||
id: generateUUID(),
|
||||
role: "user",
|
||||
content: "Help me write an essay about Silcon Valley",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Help me write an essay about Silicon Valley",
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
selectedChatModel: "chat-model",
|
||||
selectedVisibilityType: "private",
|
||||
},
|
||||
});
|
||||
|
||||
const secondRequest = adaContext.request.get(
|
||||
`/api/chat/${chatId}/stream`
|
||||
);
|
||||
|
||||
const [firstResponse, secondResponse] = await Promise.all([
|
||||
firstRequest,
|
||||
secondRequest,
|
||||
]);
|
||||
|
||||
const [firstStatusCode, secondStatusCode] = await Promise.all([
|
||||
firstResponse.status(),
|
||||
secondResponse.status(),
|
||||
]);
|
||||
|
||||
expect(firstStatusCode).toBe(200);
|
||||
expect(secondStatusCode).toBe(200);
|
||||
|
||||
const [, secondResponseContent] = await Promise.all([
|
||||
firstResponse.text(),
|
||||
secondResponse.text(),
|
||||
]);
|
||||
|
||||
expect(secondResponseContent).toContain("appendMessage");
|
||||
});
|
||||
|
||||
test("Ada cannot resume chat generation that has ended", async ({
|
||||
adaContext,
|
||||
}) => {
|
||||
const chatId = generateUUID();
|
||||
|
||||
const firstResponse = await adaContext.request.post("/api/chat", {
|
||||
data: {
|
||||
id: chatId,
|
||||
message: {
|
||||
id: generateUUID(),
|
||||
role: "user",
|
||||
content: "Help me write an essay about Silcon Valley",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Help me write an essay about Silicon Valley",
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
selectedChatModel: "chat-model",
|
||||
selectedVisibilityType: "private",
|
||||
},
|
||||
});
|
||||
|
||||
const firstStatusCode = firstResponse.status();
|
||||
expect(firstStatusCode).toBe(200);
|
||||
|
||||
await firstResponse.text();
|
||||
await new Promise((resolve) => setTimeout(resolve, 15 * 1000));
|
||||
await new Promise((resolve) => setTimeout(resolve, 15_000));
|
||||
const secondResponse = await adaContext.request.get(
|
||||
`/api/chat/${chatId}/stream`
|
||||
);
|
||||
|
||||
const secondStatusCode = secondResponse.status();
|
||||
expect(secondStatusCode).toBe(200);
|
||||
|
||||
const secondResponseContent = await secondResponse.text();
|
||||
expect(secondResponseContent).toEqual("");
|
||||
});
|
||||
|
||||
test("Babbage cannot resume a private chat generation that belongs to Ada", async ({
|
||||
adaContext,
|
||||
babbageContext,
|
||||
}) => {
|
||||
const chatId = generateUUID();
|
||||
|
||||
const firstRequest = adaContext.request.post("/api/chat", {
|
||||
data: {
|
||||
id: chatId,
|
||||
message: {
|
||||
id: generateUUID(),
|
||||
role: "user",
|
||||
content: "Help me write an essay about Silcon Valley",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Help me write an essay about Silicon Valley",
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
selectedChatModel: "chat-model",
|
||||
selectedVisibilityType: "private",
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
const secondRequest = babbageContext.request.get(
|
||||
`/api/chat/${chatId}/stream`
|
||||
);
|
||||
|
||||
const [firstResponse, secondResponse] = await Promise.all([
|
||||
firstRequest,
|
||||
secondRequest,
|
||||
]);
|
||||
|
||||
const [firstStatusCode, secondStatusCode] = await Promise.all([
|
||||
firstResponse.status(),
|
||||
secondResponse.status(),
|
||||
]);
|
||||
|
||||
expect(firstStatusCode).toBe(200);
|
||||
expect(secondStatusCode).toBe(403);
|
||||
});
|
||||
|
||||
test("Babbage can resume a public chat generation that belongs to Ada", async ({
|
||||
adaContext,
|
||||
babbageContext,
|
||||
}) => {
|
||||
test.fixme();
|
||||
const chatId = generateUUID();
|
||||
|
||||
const firstRequest = adaContext.request.post("/api/chat", {
|
||||
data: {
|
||||
id: chatId,
|
||||
message: {
|
||||
id: generateUUID(),
|
||||
role: "user",
|
||||
content: "Help me write an essay about Silicon Valley",
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Help me write an essay about Silicon Valley",
|
||||
},
|
||||
],
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
selectedChatModel: "chat-model",
|
||||
selectedVisibilityType: "public",
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10 * 1000));
|
||||
|
||||
const secondRequest = babbageContext.request.get(
|
||||
`/api/chat/${chatId}/stream`
|
||||
);
|
||||
|
||||
const [firstResponse, secondResponse] = await Promise.all([
|
||||
firstRequest,
|
||||
secondRequest,
|
||||
]);
|
||||
|
||||
const [firstStatusCode, secondStatusCode] = await Promise.all([
|
||||
firstResponse.status(),
|
||||
secondResponse.status(),
|
||||
]);
|
||||
|
||||
expect(firstStatusCode).toBe(200);
|
||||
expect(secondStatusCode).toBe(200);
|
||||
|
||||
const [firstResponseContent, secondResponseContent] = await Promise.all([
|
||||
firstResponse.text(),
|
||||
secondResponse.text(),
|
||||
]);
|
||||
|
||||
expect(firstResponseContent).toEqual(secondResponseContent);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
import type { Document } from "@/lib/db/schema";
|
||||
import { getMessageByErrorCode } from "@/lib/errors";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import { expect, test } from "../fixtures";
|
||||
|
||||
const documentsCreatedByAda: Document[] = [];
|
||||
|
||||
test.describe
|
||||
.serial("/api/document", () => {
|
||||
test("Ada cannot retrieve a document without specifying an id", async ({
|
||||
adaContext,
|
||||
}) => {
|
||||
const response = await adaContext.request.get("/api/document");
|
||||
expect(response.status()).toBe(400);
|
||||
|
||||
const { code, message } = await response.json();
|
||||
expect(code).toEqual("bad_request:api");
|
||||
expect(message).toEqual(getMessageByErrorCode(code));
|
||||
});
|
||||
|
||||
test("Ada cannot retrieve a document that does not exist", async ({
|
||||
adaContext,
|
||||
}) => {
|
||||
const documentId = generateUUID();
|
||||
|
||||
const response = await adaContext.request.get(
|
||||
`/api/document?id=${documentId}`
|
||||
);
|
||||
expect(response.status()).toBe(404);
|
||||
|
||||
const { code, message } = await response.json();
|
||||
expect(code).toEqual("not_found:document");
|
||||
expect(message).toEqual(getMessageByErrorCode(code));
|
||||
});
|
||||
|
||||
test("Ada can create a document", async ({ adaContext }) => {
|
||||
const documentId = generateUUID();
|
||||
|
||||
const draftDocument = {
|
||||
title: "Ada's Document",
|
||||
kind: "text",
|
||||
content: "Created by Ada",
|
||||
};
|
||||
|
||||
const response = await adaContext.request.post(
|
||||
`/api/document?id=${documentId}`,
|
||||
{
|
||||
data: draftDocument,
|
||||
}
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const [createdDocument] = await response.json();
|
||||
expect(createdDocument).toMatchObject(draftDocument);
|
||||
|
||||
documentsCreatedByAda.push(createdDocument);
|
||||
});
|
||||
|
||||
test("Ada can retrieve a created document", async ({ adaContext }) => {
|
||||
const [document] = documentsCreatedByAda;
|
||||
|
||||
const response = await adaContext.request.get(
|
||||
`/api/document?id=${document.id}`
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const retrievedDocuments = await response.json();
|
||||
expect(retrievedDocuments).toHaveLength(1);
|
||||
|
||||
const [retrievedDocument] = retrievedDocuments;
|
||||
expect(retrievedDocument).toMatchObject(document);
|
||||
});
|
||||
|
||||
test("Ada can save a new version of the document", async ({
|
||||
adaContext,
|
||||
}) => {
|
||||
const [firstDocument] = documentsCreatedByAda;
|
||||
|
||||
const draftDocument = {
|
||||
title: "Ada's Document",
|
||||
kind: "text",
|
||||
content: "Updated by Ada",
|
||||
};
|
||||
|
||||
const response = await adaContext.request.post(
|
||||
`/api/document?id=${firstDocument.id}`,
|
||||
{
|
||||
data: draftDocument,
|
||||
}
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const [createdDocument] = await response.json();
|
||||
expect(createdDocument).toMatchObject(draftDocument);
|
||||
|
||||
documentsCreatedByAda.push(createdDocument);
|
||||
});
|
||||
|
||||
test("Ada can retrieve all versions of her documents", async ({
|
||||
adaContext,
|
||||
}) => {
|
||||
const [firstDocument, secondDocument] = documentsCreatedByAda;
|
||||
|
||||
const response = await adaContext.request.get(
|
||||
`/api/document?id=${firstDocument.id}`
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const retrievedDocuments = await response.json();
|
||||
expect(retrievedDocuments).toHaveLength(2);
|
||||
|
||||
const [firstRetrievedDocument, secondRetrievedDocument] =
|
||||
retrievedDocuments;
|
||||
expect(firstRetrievedDocument).toMatchObject(firstDocument);
|
||||
expect(secondRetrievedDocument).toMatchObject(secondDocument);
|
||||
});
|
||||
|
||||
test("Ada cannot delete a document without specifying an id", async ({
|
||||
adaContext,
|
||||
}) => {
|
||||
const response = await adaContext.request.delete("/api/document");
|
||||
expect(response.status()).toBe(400);
|
||||
|
||||
const { code, message } = await response.json();
|
||||
expect(code).toEqual("bad_request:api");
|
||||
expect(message).toEqual(getMessageByErrorCode(code));
|
||||
});
|
||||
|
||||
test("Ada cannot delete a document without specifying a timestamp", async ({
|
||||
adaContext,
|
||||
}) => {
|
||||
const [firstDocument] = documentsCreatedByAda;
|
||||
|
||||
const response = await adaContext.request.delete(
|
||||
`/api/document?id=${firstDocument.id}`
|
||||
);
|
||||
expect(response.status()).toBe(400);
|
||||
|
||||
const { code, message } = await response.json();
|
||||
expect(code).toEqual("bad_request:api");
|
||||
expect(message).toEqual(getMessageByErrorCode(code));
|
||||
});
|
||||
|
||||
test("Ada can delete a document by specifying id and timestamp", async ({
|
||||
adaContext,
|
||||
}) => {
|
||||
const [firstDocument, secondDocument] = documentsCreatedByAda;
|
||||
|
||||
const response = await adaContext.request.delete(
|
||||
`/api/document?id=${firstDocument.id}×tamp=${firstDocument.createdAt}`
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const deletedDocuments = await response.json();
|
||||
expect(deletedDocuments).toHaveLength(1);
|
||||
|
||||
const [deletedDocument] = deletedDocuments;
|
||||
expect(deletedDocument).toMatchObject(secondDocument);
|
||||
});
|
||||
|
||||
test("Ada can retrieve documents without deleted versions", async ({
|
||||
adaContext,
|
||||
}) => {
|
||||
const [firstDocument] = documentsCreatedByAda;
|
||||
|
||||
const response = await adaContext.request.get(
|
||||
`/api/document?id=${firstDocument.id}`
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const retrievedDocuments = await response.json();
|
||||
expect(retrievedDocuments).toHaveLength(1);
|
||||
|
||||
const [firstRetrievedDocument] = retrievedDocuments;
|
||||
expect(firstRetrievedDocument).toMatchObject(firstDocument);
|
||||
});
|
||||
|
||||
test("Babbage cannot update Ada's document", async ({ babbageContext }) => {
|
||||
const [firstDocument] = documentsCreatedByAda;
|
||||
|
||||
const draftDocument = {
|
||||
title: "Babbage's Document",
|
||||
kind: "text",
|
||||
content: "Created by Babbage",
|
||||
};
|
||||
|
||||
const response = await babbageContext.request.post(
|
||||
`/api/document?id=${firstDocument.id}`,
|
||||
{
|
||||
data: draftDocument,
|
||||
}
|
||||
);
|
||||
expect(response.status()).toBe(403);
|
||||
|
||||
const { code, message } = await response.json();
|
||||
expect(code).toEqual("forbidden:document");
|
||||
expect(message).toEqual(getMessageByErrorCode(code));
|
||||
});
|
||||
|
||||
test("Ada's documents did not get updated", async ({ adaContext }) => {
|
||||
const [firstDocument] = documentsCreatedByAda;
|
||||
|
||||
const response = await adaContext.request.get(
|
||||
`/api/document?id=${firstDocument.id}`
|
||||
);
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const documentsRetrieved = await response.json();
|
||||
expect(documentsRetrieved).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue