Improve chat navigation, model selection, and streaming

This commit is contained in:
Rami Bitar
2026-09-16 13:14:55 -04:00
parent 8790cfb1e2
commit dd04493e25
73 changed files with 1272 additions and 1367 deletions
+9
View File
@@ -0,0 +1,9 @@
# Copy this file to .env.local, then run yarn install and yarn dev.
# Required for chat responses. This key is used only by the server.
OPENROUTER_API_KEY=
# Optional initial model ID from lib/openrouter.ts; users can change it in the composer.
OPENROUTER_MODEL=
# Optional: enables web search and page fetching when configured.
FIRECRAWL_API_KEY=
+3 -2
View File
@@ -1,8 +1,9 @@
.DS_Store .DS_Store
node_modules/* node_modules/
.next .next
next-env.d.ts next-env.d.ts
.env.local .env*
!.env.example
ai/ ai/
vercel-ai/ vercel-ai/
tsconfig.tsbuildinfo tsconfig.tsbuildinfo
+1
View File
@@ -0,0 +1 @@
nodeLinker: node-modules
+23 -30
View File
@@ -4,16 +4,20 @@ import {
stepCountIs, stepCountIs,
pruneMessages, pruneMessages,
} from 'ai'; } from 'ai';
import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { getChatModel } from '@/services/chat/model';
import { tools } from '@/chat.config'; import { getChatInstructions } from '@/services/chat/instructions';
import { chatTools } from '@/services/chat/tools';
import { isOpenRouterModelId } from '@/lib/openrouter';
const openrouter = createOpenRouter({ export const maxDuration = 300;
apiKey: process.env.OPENROUTER_API_KEY,
});
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const { messages } = await request.json(); const { messages, modelId } = await request.json();
if (modelId !== undefined && !isOpenRouterModelId(modelId)) {
return Response.json({ error: 'Select a supported model' }, { status: 400 });
}
if (!process.env.OPENROUTER_API_KEY) { if (!process.env.OPENROUTER_API_KEY) {
return Response.json( return Response.json(
@@ -22,30 +26,11 @@ export async function POST(request: Request) {
); );
} }
const model = openrouter( const model = getChatModel(modelId);
process.env.OPENROUTER_MODEL || 'openai/gpt-5.6-luna-pro'
);
const webSearchEnabled = Boolean(process.env.FIRECRAWL_API_KEY); const webSearchEnabled = Boolean(process.env.FIRECRAWL_API_KEY);
const instructions = webSearchEnabled const instructions = getChatInstructions(webSearchEnabled);
? `You are an expert ChatGPT Agent with real-time access to the internet.
Your capabilities:
- Search the web using Firecrawl to find up-to-date information
- Fetch and read full web pages to extract detailed content
- Synthesize information from multiple sources into clear, accurate answers
- Cite your sources inline so the user can verify information
Guidelines:
- Always search the web before answering questions that require current or factual information
- Use multiple searches when needed to get comprehensive answers
- Cross-reference sources for accuracy
- Structure your responses clearly with headings, bullet points, and citations where appropriate
- When fetching pages, extract only the relevant information
- Be transparent about the sources you used
- Today's date is ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}`
: `You are a helpful AI research assistant. Answer clearly and accurately. You do not have live web access, so be transparent when a question requires current information. Today's date is ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}.`;
const convertedMessages = await convertToModelMessages(messages); const convertedMessages = await convertToModelMessages(messages);
@@ -55,12 +40,16 @@ Guidelines:
toolCalls: 'none', toolCalls: 'none',
}); });
const result = await streamText({ const result = streamText({
model, model,
instructions, instructions,
messages: prunedMessages, messages: prunedMessages,
tools: webSearchEnabled ? tools : {}, abortSignal: request.signal,
tools: webSearchEnabled ? chatTools : {},
stopWhen: stepCountIs(20), stopWhen: stepCountIs(20),
onError: ({ error }) => {
if (!request.signal.aborted) console.error('Chat stream error:', error);
},
providerOptions: { providerOptions: {
openrouter: { openrouter: {
reasoning: { reasoning: {
@@ -71,7 +60,11 @@ Guidelines:
}, },
}); });
return result.toUIMessageStreamResponse({ sendReasoning: true }); return result.toUIMessageStreamResponse({
sendReasoning: true,
headers: { 'Cache-Control': 'no-cache, no-transform' },
onError: () => 'The response was interrupted. Please try again.',
});
} catch (error) { } catch (error) {
console.error('Chat API error:', error); console.error('Chat API error:', error);
return new Response( return new Response(
+2
View File
@@ -0,0 +1,2 @@
<!-- Generated from RiOpenaiLine in @remixicon/react. -->
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#000000"></rect><svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="44" height="44" fill="#ffffff" x="10" y="10" class="remixicon "><path d="M11 4C11.6487 4 12.2498 4.20569 12.7411 4.55646C11.4486 5.22914 10.2602 6.07623 8.99996 6.80385L9.99996 8.5359C11.575 7.62654 13.0517 6.48541 14.7608 5.83037C16.1134 5.31198 17.681 5.83976 18.4282 7.13398C18.7526 7.69582 18.8749 8.31917 18.8168 8.92006C17.588 8.13708 16.2602 7.53146 15 6.80385L14 8.5359C15.575 9.44525 17.3016 10.1536 18.7234 11.3061C19.8487 12.2183 20.1754 13.8398 19.4282 15.134C19.1037 15.696 18.6249 16.1137 18.0752 16.3638C18.1385 14.9081 18 13.4553 18 12H16C16 13.8184 16.2498 15.6685 15.9626 17.4758C15.7353 18.9063 14.4944 20 13 20C12.351 20 11.7498 19.7942 11.2584 19.4432C12.5508 18.7701 13.7396 17.9238 15 17.1962L14 15.4641C12.4252 16.3733 10.9478 17.5147 9.23912 18.1696C7.88657 18.688 6.31898 18.1602 5.57176 16.866C5.24729 16.304 5.12493 15.6805 5.18316 15.0794C6.41224 15.8621 7.73964 16.4685 8.99997 17.1962L9.99997 15.4641C8.42517 14.5549 6.69801 13.8462 5.27649 12.6939C4.15128 11.7817 3.82455 10.1602 4.57176 8.86602C4.89624 8.30402 5.37506 7.88628 5.9247 7.63618C5.86145 9.09192 5.99997 10.5447 5.99997 12H7.99997C7.99997 10.1816 7.75013 8.33148 8.03733 6.52422C8.26467 5.09369 9.50554 4 11 4ZM14.7925 3.74171C13.8765 2.67659 12.5176 2 11 2C8.7782 2 6.89621 3.44833 6.24435 5.45243C4.86393 5.71314 3.59851 6.55175 2.83971 7.86602C1.72883 9.79013 2.04213 12.1442 3.4518 13.7107C2.98737 15.0366 3.08092 16.5518 3.83971 17.866C4.95059 19.7901 7.14589 20.6958 9.20742 20.2583C10.1234 21.3234 11.4824 22 13 22C15.2217 22 17.1037 20.5517 17.7556 18.5476C19.136 18.2869 20.4014 17.4482 21.1602 16.134C22.2711 14.2099 21.9578 11.8559 20.5481 10.2893C21.0126 8.96345 20.919 7.44825 20.1602 6.13398C19.0493 4.20987 16.854 3.30419 14.7925 3.74171Z"></path></svg></svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+21 -2
View File
@@ -3,9 +3,28 @@ import type { Metadata } from 'next';
import { ThemeProvider } from '@/components/theme-provider'; import { ThemeProvider } from '@/components/theme-provider';
import './globals.css'; import './globals.css';
const title = 'ChatGPT Agent | AI Chat & Research Assistant';
const description =
'Ask questions, explore ideas, and get clear AI answers with streaming responses, optional web research, file attachments, and saved conversations.';
export const metadata: Metadata = { export const metadata: Metadata = {
title: 'ChatGPT Agent', applicationName: 'ChatGPT Agent',
description: 'A ChatGPT-powered assistant for clear, useful answers.', title: {
default: title,
template: '%s | ChatGPT Agent',
},
description,
openGraph: {
type: 'website',
siteName: 'ChatGPT Agent',
title,
description,
},
twitter: {
card: 'summary',
title,
description,
},
}; };
export default function RootLayout({ export default function RootLayout({
+7 -1
View File
@@ -1,5 +1,11 @@
import { ChatAgent } from "@/components/chat/ChatAgent"; import { ChatAgent } from "@/components/chat/ChatAgent";
import { getDefaultChatModelId } from "@/services/chat/model";
export default function ChatPage() { export default function ChatPage() {
return <ChatAgent webSearchEnabled={Boolean(process.env.FIRECRAWL_API_KEY)} />; return (
<ChatAgent
webSearchEnabled={Boolean(process.env.FIRECRAWL_API_KEY)}
defaultModelId={getDefaultChatModelId()}
/>
);
} }
-14
View File
@@ -1,14 +0,0 @@
import { firecrawlWebFetch } from '@/actions/tools/firecrawl-web-fetch';
import { FirecrawlWebFetchTool } from '@/components/tools/firecrawl-web-fetch-tool';
import { firecrawlWebSearch } from '@/actions/tools/firecrawl-web-search';
import { FirecrawlWebSearchTool } from '@/components/tools/firecrawl-web-search-tool';
export const tools = {
firecrawlWebSearch,
firecrawlWebFetch,
};
export const toolComponents = {
firecrawlWebSearch: FirecrawlWebSearchTool,
firecrawlWebFetch: FirecrawlWebFetchTool,
};
+3 -3
View File
@@ -8,7 +8,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { type LucideIcon, XIcon } from "lucide-react"; import { type RemixiconComponentType, RiCloseLine } from "@remixicon/react";
import type { ComponentProps, HTMLAttributes } from "react"; import type { ComponentProps, HTMLAttributes } from "react";
export type ArtifactProps = HTMLAttributes<HTMLDivElement>; export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
@@ -57,7 +57,7 @@ export const ArtifactClose = ({
variant={variant} variant={variant}
{...props} {...props}
> >
{children ?? <XIcon className="size-4" />} {children ?? <RiCloseLine className="size-4" />}
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</Button> </Button>
); );
@@ -92,7 +92,7 @@ export const ArtifactActions = ({
export type ArtifactActionProps = ComponentProps<typeof Button> & { export type ArtifactActionProps = ComponentProps<typeof Button> & {
tooltip?: string; tooltip?: string;
label?: string; label?: string;
icon?: LucideIcon; icon?: RemixiconComponentType;
}; };
export const ArtifactAction = ({ export const ArtifactAction = ({
+9 -9
View File
@@ -9,11 +9,11 @@ import {
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
BrainIcon, RiBrainLine,
ChevronDownIcon, RiArrowDownSLine,
DotIcon, RiCircleFill,
type LucideIcon, type RemixiconComponentType,
} from "lucide-react"; } from "@remixicon/react";
import type { ComponentProps, ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
import { createContext, memo, useContext, useMemo } from "react"; import { createContext, memo, useContext, useMemo } from "react";
@@ -92,11 +92,11 @@ export const ChainOfThoughtHeader = memo(
)} )}
{...props} {...props}
> >
<BrainIcon className="size-4" /> <RiBrainLine className="size-4" />
<span className="flex-1 text-left"> <span className="flex-1 text-left">
{children ?? "Chain of Thought"} {children ?? "Chain of Thought"}
</span> </span>
<ChevronDownIcon <RiArrowDownSLine
className={cn( className={cn(
"size-4 transition-transform", "size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0" isOpen ? "rotate-180" : "rotate-0"
@@ -109,7 +109,7 @@ export const ChainOfThoughtHeader = memo(
); );
export type ChainOfThoughtStepProps = ComponentProps<"div"> & { export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
icon?: LucideIcon; icon?: RemixiconComponentType;
label: ReactNode; label: ReactNode;
description?: ReactNode; description?: ReactNode;
status?: "complete" | "active" | "pending"; status?: "complete" | "active" | "pending";
@@ -118,7 +118,7 @@ export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
export const ChainOfThoughtStep = memo( export const ChainOfThoughtStep = memo(
({ ({
className, className,
icon: Icon = DotIcon, icon: Icon = RiCircleFill,
label, label,
description, description,
status = "complete", status = "complete",
+6 -4
View File
@@ -8,8 +8,8 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BookmarkIcon, type LucideProps } from "lucide-react"; import { RiBookmarkLine } from "@remixicon/react";
import type { ComponentProps, HTMLAttributes } from "react"; import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
export type CheckpointProps = HTMLAttributes<HTMLDivElement>; export type CheckpointProps = HTMLAttributes<HTMLDivElement>;
@@ -27,7 +27,9 @@ export const Checkpoint = ({
</div> </div>
); );
export type CheckpointIconProps = LucideProps; export type CheckpointIconProps = Omit<ComponentProps<typeof RiBookmarkLine>, "children"> & {
children?: ReactNode;
};
export const CheckpointIcon = ({ export const CheckpointIcon = ({
className, className,
@@ -35,7 +37,7 @@ export const CheckpointIcon = ({
...props ...props
}: CheckpointIconProps) => }: CheckpointIconProps) =>
children ?? ( children ?? (
<BookmarkIcon className={cn("size-4 shrink-0", className)} {...props} /> <RiBookmarkLine className={cn("size-4 shrink-0", className)} {...props} />
); );
export type CheckpointTriggerProps = ComponentProps<typeof Button> & { export type CheckpointTriggerProps = ComponentProps<typeof Button> & {
+2 -2
View File
@@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { CheckIcon, CopyIcon } from "lucide-react"; import { RiCheckLine, RiFileCopyLine } from "@remixicon/react";
import { import {
type ComponentProps, type ComponentProps,
createContext, createContext,
@@ -162,7 +162,7 @@ export const CodeBlockCopyButton = ({
} }
}; };
const Icon = isCopied ? CheckIcon : CopyIcon; const Icon = isCopied ? RiCheckLine : RiFileCopyLine;
return ( return (
<Button <Button
+2 -2
View File
@@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ArrowDownIcon } from "lucide-react"; import { RiArrowDownLine } from "@remixicon/react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { useCallback } from "react"; import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"; import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
@@ -93,7 +93,7 @@ export const ConversationScrollButton = ({
variant="outline" variant="outline"
{...props} {...props}
> >
<ArrowDownIcon className="size-4" /> <RiArrowDownLine className="size-4" />
</Button> </Button>
) )
); );
+3 -3
View File
@@ -13,7 +13,7 @@ import {
HoverCardTrigger, HoverCardTrigger,
} from "@/components/ui/hover-card"; } from "@/components/ui/hover-card";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react"; import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react";
import { import {
type ComponentProps, type ComponentProps,
createContext, createContext,
@@ -206,7 +206,7 @@ export const InlineCitationCarouselPrev = ({
type="button" type="button"
{...props} {...props}
> >
<ArrowLeftIcon className="size-4 text-muted-foreground" /> <RiArrowLeftLine className="size-4 text-muted-foreground" />
</button> </button>
); );
}; };
@@ -233,7 +233,7 @@ export const InlineCitationCarouselNext = ({
type="button" type="button"
{...props} {...props}
> >
<ArrowRightIcon className="size-4 text-muted-foreground" /> <RiArrowRightLine className="size-4 text-muted-foreground" />
</button> </button>
); );
}; };
+11 -12
View File
@@ -14,11 +14,11 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { FileUIPart, UIMessage } from "ai"; import type { FileUIPart, UIMessage } from "ai";
import { import {
ChevronLeftIcon, RiArrowLeftSLine,
ChevronRightIcon, RiArrowRightSLine,
PaperclipIcon, RiAttachment2,
XIcon, RiCloseLine,
} from "lucide-react"; } from "@remixicon/react";
import type { ComponentProps, HTMLAttributes, ReactElement, ReactNode } from "react"; import type { ComponentProps, HTMLAttributes, ReactElement, ReactNode } from "react";
import { import {
Children, Children,
@@ -267,7 +267,7 @@ export const MessageBranchPrevious = ({
variant="ghost" variant="ghost"
{...props} {...props}
> >
{children ?? <ChevronLeftIcon size={14} />} {children ?? <RiArrowLeftSLine size={14} />}
</Button> </Button>
); );
}; };
@@ -291,7 +291,7 @@ export const MessageBranchNext = ({
variant="ghost" variant="ghost"
{...props} {...props}
> >
{children ?? <ChevronRightIcon size={14} />} {children ?? <RiArrowRightSLine size={14} />}
</Button> </Button>
); );
}; };
@@ -338,8 +338,7 @@ export const MessageResponse = memo(
}} }}
{...props} {...props}
/> />
), )
(prevProps, nextProps) => prevProps.children === nextProps.children
); );
MessageResponse.displayName = "MessageResponse"; MessageResponse.displayName = "MessageResponse";
@@ -390,7 +389,7 @@ export function MessageAttachment({
type="button" type="button"
variant="ghost" variant="ghost"
> >
<XIcon /> <RiCloseLine />
<span className="sr-only">Remove</span> <span className="sr-only">Remove</span>
</Button> </Button>
)} )}
@@ -400,7 +399,7 @@ export function MessageAttachment({
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground"> <div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<PaperclipIcon className="size-4" /> <RiAttachment2 className="size-4" />
</div> </div>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
@@ -418,7 +417,7 @@ export function MessageAttachment({
type="button" type="button"
variant="ghost" variant="ghost"
> >
<XIcon /> <RiCloseLine />
<span className="sr-only">Remove</span> <span className="sr-only">Remove</span>
</Button> </Button>
)} )}
+12 -12
View File
@@ -11,10 +11,10 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
ChevronDownIcon, RiArrowDownSLine,
ExternalLinkIcon, RiExternalLinkLine,
MessageCircleIcon, RiChat3Line,
} from "lucide-react"; } from "@remixicon/react";
import { type ComponentProps, createContext, useContext } from "react"; import { type ComponentProps, createContext, useContext } from "react";
const providers = { const providers = {
@@ -140,7 +140,7 @@ const providers = {
`https://t3.chat/new?${new URLSearchParams({ `https://t3.chat/new?${new URLSearchParams({
q, q,
})}`, })}`,
icon: <MessageCircleIcon />, icon: <RiChat3Line />,
}, },
v0: { v0: {
title: "Open in v0", title: "Open in v0",
@@ -238,7 +238,7 @@ export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => (
{children ?? ( {children ?? (
<Button type="button" variant="outline"> <Button type="button" variant="outline">
Open in chat Open in chat
<ChevronDownIcon className="size-4" /> <RiArrowDownSLine className="size-4" />
</Button> </Button>
)} )}
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -258,7 +258,7 @@ export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
> >
<span className="shrink-0">{providers.chatgpt.icon}</span> <span className="shrink-0">{providers.chatgpt.icon}</span>
<span className="flex-1">{providers.chatgpt.title}</span> <span className="flex-1">{providers.chatgpt.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" /> <RiExternalLinkLine className="size-4 shrink-0" />
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>
); );
@@ -278,7 +278,7 @@ export const OpenInClaude = (props: OpenInClaudeProps) => {
> >
<span className="shrink-0">{providers.claude.icon}</span> <span className="shrink-0">{providers.claude.icon}</span>
<span className="flex-1">{providers.claude.title}</span> <span className="flex-1">{providers.claude.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" /> <RiExternalLinkLine className="size-4 shrink-0" />
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>
); );
@@ -298,7 +298,7 @@ export const OpenInT3 = (props: OpenInT3Props) => {
> >
<span className="shrink-0">{providers.t3.icon}</span> <span className="shrink-0">{providers.t3.icon}</span>
<span className="flex-1">{providers.t3.title}</span> <span className="flex-1">{providers.t3.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" /> <RiExternalLinkLine className="size-4 shrink-0" />
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>
); );
@@ -318,7 +318,7 @@ export const OpenInScira = (props: OpenInSciraProps) => {
> >
<span className="shrink-0">{providers.scira.icon}</span> <span className="shrink-0">{providers.scira.icon}</span>
<span className="flex-1">{providers.scira.title}</span> <span className="flex-1">{providers.scira.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" /> <RiExternalLinkLine className="size-4 shrink-0" />
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>
); );
@@ -338,7 +338,7 @@ export const OpenInv0 = (props: OpenInv0Props) => {
> >
<span className="shrink-0">{providers.v0.icon}</span> <span className="shrink-0">{providers.v0.icon}</span>
<span className="flex-1">{providers.v0.title}</span> <span className="flex-1">{providers.v0.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" /> <RiExternalLinkLine className="size-4 shrink-0" />
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>
); );
@@ -358,7 +358,7 @@ export const OpenInCursor = (props: OpenInCursorProps) => {
> >
<span className="shrink-0">{providers.cursor.icon}</span> <span className="shrink-0">{providers.cursor.icon}</span>
<span className="flex-1">{providers.cursor.title}</span> <span className="flex-1">{providers.cursor.title}</span>
<ExternalLinkIcon className="size-4 shrink-0" /> <RiExternalLinkLine className="size-4 shrink-0" />
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>
); );
+2 -2
View File
@@ -16,7 +16,7 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronsUpDownIcon } from "lucide-react"; import { RiExpandUpDownLine } from "@remixicon/react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { createContext, useContext } from "react"; import { createContext, useContext } from "react";
import { Shimmer } from "./shimmer"; import { Shimmer } from "./shimmer";
@@ -135,7 +135,7 @@ export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
variant="ghost" variant="ghost"
{...props} {...props}
> >
<ChevronsUpDownIcon className="size-4" /> <RiExpandUpDownLine className="size-4" />
<span className="sr-only">Toggle plan</span> <span className="sr-only">Toggle plan</span>
</Button> </Button>
</CollapsibleTrigger> </CollapsibleTrigger>
+16 -16
View File
@@ -37,14 +37,14 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ChatStatus, FileUIPart } from "ai"; import type { ChatStatus, FileUIPart } from "ai";
import { import {
CornerDownLeftIcon, RiCornerDownLeftLine,
ImageIcon, RiImageLine,
MicIcon, RiMicLine,
PaperclipIcon, RiAttachment2,
PlusIcon, RiAddLine,
SquareIcon, RiStopFill,
XIcon, RiCloseLine,
} from "lucide-react"; } from "@remixicon/react";
import { Loader } from "@/components/ai-elements/loader"; import { Loader } from "@/components/ai-elements/loader";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { import {
@@ -319,7 +319,7 @@ export function PromptInputAttachment({
/> />
) : ( ) : (
<div className="flex size-5 items-center justify-center text-muted-foreground"> <div className="flex size-5 items-center justify-center text-muted-foreground">
<PaperclipIcon className="size-3" /> <RiAttachment2 className="size-3" />
</div> </div>
)} )}
</div> </div>
@@ -333,7 +333,7 @@ export function PromptInputAttachment({
type="button" type="button"
variant="ghost" variant="ghost"
> >
<XIcon /> <RiCloseLine />
<span className="sr-only">Remove</span> <span className="sr-only">Remove</span>
</Button> </Button>
</div> </div>
@@ -421,7 +421,7 @@ export const PromptInputActionAddAttachments = ({
attachments.openFileDialog(); attachments.openFileDialog();
}} }}
> >
<ImageIcon className="mr-2 size-4" /> {label} <RiImageLine className="mr-2 size-4" /> {label}
</DropdownMenuItem> </DropdownMenuItem>
); );
}; };
@@ -989,7 +989,7 @@ export const PromptInputActionMenuTrigger = ({
}: PromptInputActionMenuTriggerProps) => ( }: PromptInputActionMenuTriggerProps) => (
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<PromptInputButton className={className} {...props}> <PromptInputButton className={className} {...props}>
{children ?? <PlusIcon className="size-4" />} {children ?? <RiAddLine className="size-4" />}
</PromptInputButton> </PromptInputButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
); );
@@ -1029,14 +1029,14 @@ export const PromptInputSubmit = ({
children, children,
...props ...props
}: PromptInputSubmitProps) => { }: PromptInputSubmitProps) => {
let Icon = <CornerDownLeftIcon className="size-4" />; let Icon = <RiCornerDownLeftLine className="size-4" />;
if (status === "submitted") { if (status === "submitted") {
Icon = <Loader size={16} />; Icon = <Loader size={16} />;
} else if (status === "streaming") { } else if (status === "streaming") {
Icon = <SquareIcon className="size-4" />; Icon = <RiStopFill className="size-4" />;
} else if (status === "error") { } else if (status === "error") {
Icon = <XIcon className="size-4" />; Icon = <RiCloseLine className="size-4" />;
} }
return ( return (
@@ -1208,7 +1208,7 @@ export const PromptInputSpeechButton = ({
onClick={toggleListening} onClick={toggleListening}
{...props} {...props}
> >
<MicIcon className="size-4" /> <RiMicLine className="size-4" />
</PromptInputButton> </PromptInputButton>
); );
}; };
+3 -3
View File
@@ -8,7 +8,7 @@ import {
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronDownIcon, PaperclipIcon } from "lucide-react"; import { RiArrowDownSLine, RiAttachment2 } from "@remixicon/react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
export type QueueMessagePart = { export type QueueMessagePart = {
@@ -174,7 +174,7 @@ export const QueueItemFile = ({
)} )}
{...props} {...props}
> >
<PaperclipIcon size={12} /> <RiAttachment2 size={12} />
<span className="max-w-[100px] truncate">{children}</span> <span className="max-w-[100px] truncate">{children}</span>
</span> </span>
); );
@@ -241,7 +241,7 @@ export const QueueSectionLabel = ({
...props ...props
}: QueueSectionLabelProps) => ( }: QueueSectionLabelProps) => (
<span className={cn("flex items-center gap-2", className)} {...props}> <span className={cn("flex items-center gap-2", className)} {...props}>
<ChevronDownIcon className="group-data-[state=closed]:-rotate-90 size-4 transition-transform" /> <RiArrowDownSLine className="group-data-[state=closed]:-rotate-90 size-4 transition-transform" />
{icon} {icon}
<span> <span>
{count} {label} {count} {label}
+3 -3
View File
@@ -7,7 +7,7 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BrainIcon, ChevronDownIcon } from "lucide-react"; import { RiBrainLine, RiArrowDownSLine } from "@remixicon/react";
import type { ComponentProps, ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react"; import { createContext, memo, useContext, useEffect, useState } from "react";
import { Streamdown } from "streamdown"; import { Streamdown } from "streamdown";
@@ -139,9 +139,9 @@ export const ReasoningTrigger = memo(
> >
{children ?? ( {children ?? (
<> <>
<BrainIcon className="size-4" /> <RiBrainLine className="size-4" />
{getThinkingMessage(isStreaming, duration)} {getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon <RiArrowDownSLine
className={cn( className={cn(
"size-4 transition-transform", "size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0" isOpen ? "rotate-180" : "rotate-0"
+3 -3
View File
@@ -15,7 +15,7 @@ import {
TabsTrigger, TabsTrigger,
} from "@/components/ui/tabs"; } from "@/components/ui/tabs";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronDownIcon, Code } from "lucide-react"; import { RiArrowDownSLine, RiCodeLine } from "@remixicon/react";
import { getStatusBadge } from "./tool"; import { getStatusBadge } from "./tool";
@@ -52,11 +52,11 @@ export const SandboxHeader = ({
{...props} {...props}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Code className="size-4 text-muted-foreground" /> <RiCodeLine className="size-4 text-muted-foreground" />
<span className="font-medium text-sm">{title}</span> <span className="font-medium text-sm">{title}</span>
{getStatusBadge(state)} {getStatusBadge(state)}
</div> </div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" /> <RiArrowDownSLine className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger> </CollapsibleTrigger>
); );
+3 -3
View File
@@ -6,7 +6,7 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BookIcon, ChevronDownIcon } from "lucide-react"; import { RiBookOpenLine, RiArrowDownSLine } from "@remixicon/react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
export type SourcesProps = ComponentProps<"div">; export type SourcesProps = ComponentProps<"div">;
@@ -35,7 +35,7 @@ export const SourcesTrigger = ({
{children ?? ( {children ?? (
<> <>
<p className="font-medium">Used {count} sources</p> <p className="font-medium">Used {count} sources</p>
<ChevronDownIcon className="h-4 w-4" /> <RiArrowDownSLine className="h-4 w-4" />
</> </>
)} )}
</CollapsibleTrigger> </CollapsibleTrigger>
@@ -69,7 +69,7 @@ export const Source = ({ href, title, children, ...props }: SourceProps) => (
> >
{children ?? ( {children ?? (
<> <>
<BookIcon className="h-4 w-4" /> <RiBookOpenLine className="h-4 w-4" />
<span className="block font-medium">{title}</span> <span className="block font-medium">{title}</span>
</> </>
)} )}
+3 -3
View File
@@ -6,7 +6,7 @@ import {
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronDownIcon, SearchIcon } from "lucide-react"; import { RiArrowDownSLine, RiSearchLine } from "@remixicon/react";
import type { ComponentProps, ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
export type TaskItemFileProps = ComponentProps<"div">; export type TaskItemFileProps = ComponentProps<"div">;
@@ -60,9 +60,9 @@ export const TaskTrigger = ({
<CollapsibleTrigger asChild className={cn("group", className)} {...props}> <CollapsibleTrigger asChild className={cn("group", className)} {...props}>
{children ?? ( {children ?? (
<div className="flex w-full cursor-pointer items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground"> <div className="flex w-full cursor-pointer items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
{icon ?? <SearchIcon className="size-4" />} {icon ?? <RiSearchLine className="size-4" />}
<p className="text-sm">{title}</p> <p className="text-sm">{title}</p>
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" /> <RiArrowDownSLine className="size-4 transition-transform group-data-[state=open]:rotate-180" />
</div> </div>
)} )}
</CollapsibleTrigger> </CollapsibleTrigger>
+16 -16
View File
@@ -9,13 +9,13 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ToolUIPart } from "ai"; import type { ToolUIPart } from "ai";
import { import {
CheckCircleIcon, RiCheckboxCircleLine,
ChevronDownIcon, RiArrowDownSLine,
CircleIcon, RiCircleLine,
ClockIcon, RiTimeLine,
WrenchIcon, RiToolsLine,
XCircleIcon, RiCloseCircleLine,
} from "lucide-react"; } from "@remixicon/react";
import type { ComponentProps, ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
import { isValidElement } from "react"; import { isValidElement } from "react";
import { CodeBlock } from "./code-block"; import { CodeBlock } from "./code-block";
@@ -48,13 +48,13 @@ export const getStatusBadge = (status: ToolUIPart["state"]) => {
}; };
const icons: Record<ToolUIPart["state"], ReactNode> = { const icons: Record<ToolUIPart["state"], ReactNode> = {
"input-streaming": <CircleIcon className="size-4" />, "input-streaming": <RiCircleLine className="size-4" />,
"input-available": <ClockIcon className="size-4 animate-pulse" />, "input-available": <RiTimeLine className="size-4 animate-pulse" />,
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />, "approval-requested": <RiTimeLine className="size-4 text-yellow-600" />,
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />, "approval-responded": <RiCheckboxCircleLine className="size-4 text-blue-600" />,
"output-available": <CheckCircleIcon className="size-4 text-green-600" />, "output-available": <RiCheckboxCircleLine className="size-4 text-green-600" />,
"output-error": <XCircleIcon className="size-4 text-red-600" />, "output-error": <RiCloseCircleLine className="size-4 text-red-600" />,
"output-denied": <XCircleIcon className="size-4 text-orange-600" />, "output-denied": <RiCloseCircleLine className="size-4 text-orange-600" />,
}; };
return ( return (
@@ -80,13 +80,13 @@ export const ToolHeader = ({
{...props} {...props}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<WrenchIcon className="size-4 text-muted-foreground" /> <RiToolsLine className="size-4 text-muted-foreground" />
<span className="font-medium text-sm"> <span className="font-medium text-sm">
{title ?? type.split("-").slice(1).join("-")} {title ?? type.split("-").slice(1).join("-")}
</span> </span>
{getStatusBadge(state)} {getStatusBadge(state)}
</div> </div>
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" /> <RiArrowDownSLine className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</CollapsibleTrigger> </CollapsibleTrigger>
); );
+2 -2
View File
@@ -14,7 +14,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ChevronDownIcon } from "lucide-react"; import { RiArrowDownSLine } from "@remixicon/react";
import type { ComponentProps, ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
import { createContext, useContext, useEffect, useState } from "react"; import { createContext, useContext, useEffect, useState } from "react";
@@ -220,7 +220,7 @@ export const WebPreviewConsole = ({
variant="ghost" variant="ghost"
> >
Console Console
<ChevronDownIcon <RiArrowDownSLine
className={cn( className={cn(
"h-4 w-4 transition-transform duration-200", "h-4 w-4 transition-transform duration-200",
consoleOpen && "rotate-180" consoleOpen && "rotate-180"
+36 -229
View File
@@ -1,20 +1,12 @@
'use client'; 'use client';
import { useState, useCallback, useRef, useEffect } from 'react'; import { ChatInput } from './ChatInput';
import type { ChatStatus } from 'ai';
import { ChatInput, type PromptInputMessage } from './ChatInput';
import { ChatMessages } from './ChatMessages'; import { ChatMessages } from './ChatMessages';
import { ChatPrompt, PromptSuggestion } from './ChatPrompt'; import { ChatPrompt } from './ChatPrompt';
import { ChatSidebar, type Chat } from './ChatSidebar'; import { ChatSidebar } from './ChatSidebar';
import { useChat, UIMessage } from '@ai-sdk/react'; import { useChatSession } from '@/hooks/use-chat-session';
import { DefaultChatTransport } from 'ai'; import type { PromptSuggestion } from '@/config/suggestions';
import { import { DEFAULT_OPENROUTER_MODEL_ID, type OpenRouterModelId } from '@/lib/openrouter';
createChat,
deleteChat,
getMessages,
listChats,
saveMessages,
} from '@/services/indexed-db/chat-store';
import { import {
SidebarProvider, SidebarProvider,
SidebarInset, SidebarInset,
@@ -24,228 +16,32 @@ import {
interface ChatAgentProps { interface ChatAgentProps {
suggestions?: PromptSuggestion[]; suggestions?: PromptSuggestion[];
webSearchEnabled?: boolean; webSearchEnabled?: boolean;
} defaultModelId?: OpenRouterModelId;
async function convertToDataUrl(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) throw new Error('Could not read the selected file');
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error('Could not encode the selected file'));
}
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
} }
export function ChatAgent({ export function ChatAgent({
suggestions, suggestions,
webSearchEnabled = false, webSearchEnabled = false,
defaultModelId = DEFAULT_OPENROUTER_MODEL_ID,
}: ChatAgentProps) { }: ChatAgentProps) {
const [inputValue, setInputValue] = useState('');
const [chatId, setChatId] = useState<string | null>(null);
const [chats, setChats] = useState<Chat[]>([]);
const chatIdRef = useRef(chatId);
const pendingChatIdRef = useRef<string | null>(null);
chatIdRef.current = chatId;
const fetchChats = useCallback(async () => {
try {
setChats(await listChats());
} catch (error) {
console.error('Failed to fetch chats:', error);
}
}, []);
const transportRef = useRef(
new DefaultChatTransport({
api: '/api/chat',
})
);
const { const {
status, inputValue,
messages: chatMessages, setInputValue,
setMessages: setChatMessages, modelId,
sendMessage, setModelId,
stop, chatId,
error: aiError, chats,
addToolApprovalResponse,
} = useChat({
transport: transportRef.current,
onFinish: async ({ messages }) => {
const activeChatId = pendingChatIdRef.current ?? chatIdRef.current;
if (!activeChatId) return;
try {
await saveMessages(activeChatId, messages);
await fetchChats();
} catch (error) {
console.error('Failed to save messages:', error);
} finally {
if (pendingChatIdRef.current === activeChatId) {
pendingChatIdRef.current = null;
}
}
},
onError: (error) => {
console.error('Chat error:', error);
},
});
const [isPreparingFiles, setIsPreparingFiles] = useState(false);
const chatStatus: ChatStatus = isPreparingFiles
? 'submitted'
: status === 'submitted' || status === 'streaming'
? status
: 'ready';
useEffect(() => {
fetchChats();
}, [fetchChats]);
const handleSelectChat = useCallback(
async (selectedChatId: string) => {
try {
const messages = await getMessages(selectedChatId);
chatIdRef.current = selectedChatId;
setChatId(selectedChatId);
setChatMessages(messages);
} catch (error) {
console.error('Failed to load chat:', error);
}
},
[setChatMessages]
);
const handleNewChat = useCallback(() => {
chatIdRef.current = null;
setChatId(null);
setChatMessages([]);
setInputValue('');
}, [setChatMessages]);
const handleDeleteChat = useCallback(
async (deletedChatId: string) => {
try {
await deleteChat(deletedChatId);
setChats((prev) => prev.filter((c) => c.id !== deletedChatId));
if (chatId === deletedChatId) {
chatIdRef.current = null;
setChatId(null);
setChatMessages([]);
}
} catch (error) {
console.error('Failed to delete chat:', error);
}
},
[chatId, setChatMessages]
);
const handleSendMessage = useCallback(
async (message: PromptInputMessage) => {
const trimmedValue = message.text?.trim();
if (!trimmedValue && !message.files?.length) return;
if (
status === 'submitted' ||
status === 'streaming' ||
isPreparingFiles
)
return;
let activeChatId = chatIdRef.current;
if (!chatIdRef.current) {
const newChatId = crypto.randomUUID();
const title = trimmedValue ? trimmedValue.slice(0, 100) : 'New Chat';
activeChatId = newChatId;
chatIdRef.current = newChatId;
setChatId(newChatId);
try {
await createChat(newChatId, title);
await fetchChats();
} catch (error) {
console.error('Failed to create chat:', error);
}
}
const parts: Array<
| { type: 'text'; text: string }
| { type: 'file'; url: string; mediaType: string; filename: string }
> = [];
if (trimmedValue) {
parts.push({ type: 'text', text: trimmedValue });
}
// PromptInput supplies files as base64 data URLs. Keep them inline so the
// demo never needs a remote storage bucket.
if (message.files?.length) {
setIsPreparingFiles(true);
for (const file of message.files) {
try {
const dataUrl = file.url.startsWith('data:')
? file.url
: await convertToDataUrl(file.url);
parts.push({
type: 'file',
url: dataUrl,
mediaType: file.mediaType || 'application/octet-stream',
filename: file.filename || 'file',
});
} catch (err) {
console.error('File encoding error:', err);
}
}
setIsPreparingFiles(false);
}
if (parts.length === 0) return;
const userMessage: UIMessage = {
id: crypto.randomUUID(),
role: 'user',
parts,
};
setInputValue('');
if (activeChatId) {
try {
await saveMessages(activeChatId, [...chatMessages, userMessage]);
} catch (error) {
console.error('Failed to save user message:', error);
}
}
pendingChatIdRef.current = activeChatId;
await sendMessage(userMessage);
},
[
status,
sendMessage,
chatMessages, chatMessages,
status,
chatStatus,
aiError,
isPreparingFiles, isPreparingFiles,
fetchChats, handleSelectChat,
] handleNewChat,
); handleDeleteChat,
handleSendMessage,
const handleStop = useCallback(() => { handleStop,
stop(); } = useChatSession(defaultModelId);
}, [stop]);
const hasMessages = chatMessages.length > 0; const hasMessages = chatMessages.length > 0;
@@ -268,6 +64,9 @@ export function ChatAgent({
value={inputValue} value={inputValue}
onChange={setInputValue} onChange={setInputValue}
onSubmit={handleSendMessage} onSubmit={handleSendMessage}
modelId={modelId}
onModelChange={setModelId}
onStop={isPreparingFiles ? undefined : handleStop}
status={chatStatus} status={chatStatus}
suggestions={suggestions} suggestions={suggestions}
webSearchEnabled={webSearchEnabled} webSearchEnabled={webSearchEnabled}
@@ -281,15 +80,23 @@ export function ChatAgent({
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<ChatMessages <ChatMessages
messages={chatMessages} messages={chatMessages}
isLoading={status === 'submitted' || status === 'streaming'} status={status}
/> />
</div> </div>
<div className="sticky bottom-0 bg-muted p-4"> <div className="sticky bottom-0 bg-muted p-4">
<div className="mx-auto max-w-3xl"> <div className="mx-auto max-w-3xl">
{aiError && (
<p role="alert" className="mb-3 text-sm text-destructive">
The response could not be completed. Please try again.
</p>
)}
<ChatInput <ChatInput
value={inputValue} value={inputValue}
onChange={setInputValue} onChange={setInputValue}
onSubmit={handleSendMessage} onSubmit={handleSendMessage}
modelId={modelId}
onModelChange={setModelId}
onStop={isPreparingFiles ? undefined : handleStop}
status={chatStatus} status={chatStatus}
/> />
</div> </div>
@@ -301,4 +108,4 @@ export function ChatAgent({
); );
} }
export type { UIMessage as Message }; export type { UIMessage as Message } from 'ai';
+18 -4
View File
@@ -1,7 +1,9 @@
'use client'; 'use client';
import type { ChatStatus } from 'ai'; import type { ChatStatus } from 'ai';
import { PlusIcon } from 'lucide-react'; import { RiAddLine } from '@remixicon/react';
import { ChatModelSelector } from './ChatModelSelector';
import type { OpenRouterModelId } from '@/lib/openrouter';
import { import {
PromptInput, PromptInput,
PromptInputActionAddAttachments, PromptInputActionAddAttachments,
@@ -23,6 +25,9 @@ interface ChatInputProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
onSubmit: (message: PromptInputMessage) => void; onSubmit: (message: PromptInputMessage) => void;
modelId: OpenRouterModelId;
onModelChange: (modelId: OpenRouterModelId) => void;
onStop?: () => void;
status?: ChatStatus; status?: ChatStatus;
placeholder?: string; placeholder?: string;
} }
@@ -31,9 +36,14 @@ export function ChatInput({
value, value,
onChange, onChange,
onSubmit, onSubmit,
modelId,
onModelChange,
onStop,
status = 'ready', status = 'ready',
placeholder, placeholder,
}: ChatInputProps) { }: ChatInputProps) {
const isGenerating = status === 'submitted' || status === 'streaming';
return ( return (
<PromptInput <PromptInput
className="[&_[data-slot=input-group]]:bg-background" className="[&_[data-slot=input-group]]:bg-background"
@@ -55,18 +65,22 @@ export function ChatInput({
/> />
</PromptInputBody> </PromptInputBody>
<PromptInputFooter className="pt-0 pb-2"> <PromptInputFooter className="pt-0 pb-2">
<PromptInputTools> <PromptInputTools className="min-w-0">
<PromptInputActionMenu> <PromptInputActionMenu>
<PromptInputActionMenuTrigger> <PromptInputActionMenuTrigger>
<PlusIcon className="size-4" /> <RiAddLine className="size-4" />
</PromptInputActionMenuTrigger> </PromptInputActionMenuTrigger>
<PromptInputActionMenuContent> <PromptInputActionMenuContent>
<PromptInputActionAddAttachments /> <PromptInputActionAddAttachments />
</PromptInputActionMenuContent> </PromptInputActionMenuContent>
</PromptInputActionMenu> </PromptInputActionMenu>
<ChatModelSelector value={modelId} onChange={onModelChange} disabled={isGenerating} />
</PromptInputTools> </PromptInputTools>
<PromptInputSubmit <PromptInputSubmit
disabled={status === 'streaming'} aria-label={isGenerating ? 'Stop response' : 'Send message'}
type={isGenerating ? 'button' : 'submit'}
onClick={isGenerating ? onStop : undefined}
disabled={isGenerating && !onStop}
status={status} status={status}
/> />
</PromptInputFooter> </PromptInputFooter>
-54
View File
@@ -1,54 +0,0 @@
"use client";
import { cn } from "@/lib/utils";
import { Tool, UIMessage, UIMessagePart } from "ai";
import { MessagePartRenderer } from "./ChatMessageParts";
interface ChatMessageProps {
message: UIMessage;
}
function getTextContent(parts: UIMessagePart<any, any>[]): string {
return parts
.filter((p) => p.type === "text")
.map((p) => p.text || "")
.join("");
}
export function ChatMessage({ message }: ChatMessageProps) {
const isUser = message.role === "user";
return (
<div
className={cn(
"flex w-full",
isUser ? "justify-end" : "justify-start"
)}
>
<div
className={cn(
"max-w-[80%] rounded-2xl px-4 py-3",
isUser
? "bg-primary text-primary-foreground"
: "bg-muted text-foreground"
)}
>
<div
className={cn(
"prose prose-sm max-w-none",
isUser
? "prose-invert prose-p:text-primary-foreground prose-headings:text-primary-foreground prose-strong:text-primary-foreground prose-code:text-primary-foreground"
: "prose-p:text-foreground prose-headings:text-foreground prose-strong:text-foreground"
)}
>
{message.parts.map((part, index) => (
<MessagePartRenderer key={index} part={part} />
))}
</div>
</div>
</div>
);
}
export { getTextContent };
export type { UIMessage as Message };
-96
View File
@@ -1,96 +0,0 @@
"use client";
import type { UIMessage } from "ai";
import {
Tool,
ToolContent,
ToolHeader,
ToolInput,
ToolOutput,
} from "@/components/ai-elements/tool";
import { Loader } from "@/components/ai-elements/loader";
import { toolComponents } from "@/chat.config";
type MessagePart = UIMessage["parts"][number];
type ReasoningPart = Extract<MessagePart, { type: "reasoning" }>;
// Helper functions
export function getTextContent(parts: MessagePart[]): string {
return parts
.filter((p) => p.type === "text")
.map((p) => p.text || "")
.join("");
}
export function getReasoningPart(parts: MessagePart[]): ReasoningPart | undefined {
return parts.find((p): p is ReasoningPart => p.type === "reasoning");
}
function extractToolName(part: MessagePart): string {
if (part.type === "dynamic-tool") {
return (part as any).toolName || "unknown";
}
if (part.type.startsWith("tool-")) {
return part.type.slice(5);
}
return "unknown";
}
function DefaultToolRenderer({ part, toolName }: { part: MessagePart; toolName: string }) {
const { input, output, state, errorText } = part as any;
return (
<Tool>
<ToolHeader
title={toolName}
type={part.type as any}
state={state || "input-streaming"}
/>
<ToolContent>
{input && <ToolInput input={input} />}
<ToolOutput output={output} errorText={errorText} />
</ToolContent>
</Tool>
);
}
function ToolLoadingIndicator({ toolName }: { toolName: string }) {
return (
<div className="mb-4 flex items-center gap-2 text-muted-foreground text-sm">
<Loader size={14} />
<span>Running {toolName}...</span>
</div>
);
}
function RenderTool({ part }: { part: MessagePart }) {
const toolName = extractToolName(part);
const state = (part as any).state;
const isComplete = state === "output-available";
const CustomRenderer = (
toolComponents as Record<
string,
React.ComponentType<{ part: MessagePart; toolName: string }>
>
)[toolName];
if (CustomRenderer) {
return <CustomRenderer part={part} toolName={toolName} />;
}
if (!isComplete && !(part as any).input) {
return <ToolLoadingIndicator toolName={toolName} />;
}
return <DefaultToolRenderer part={part} toolName={toolName} />;
}
export function ToolPartRenderer({ part }: { part: MessagePart }) {
return <RenderTool part={part} />;
}
export function MessagePartRenderer({ part }: { part: MessagePart }) {
if (part.type.startsWith("tool-") || part.type === "dynamic-tool") {
return <RenderTool part={part} />;
}
return null;
}
+28 -13
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import type { UIMessage } from "ai"; import { isToolUIPart, type ChatStatus, type UIMessage } from "ai";
import { import {
Conversation, Conversation,
ConversationContent, ConversationContent,
@@ -19,14 +19,14 @@ import {
ReasoningTrigger, ReasoningTrigger,
} from "@/components/ai-elements/reasoning"; } from "@/components/ai-elements/reasoning";
import { Loader } from "@/components/ai-elements/loader"; import { Loader } from "@/components/ai-elements/loader";
import { ToolPartRenderer, getTextContent, getReasoningPart } from "./ChatMessageParts"; import { ToolPartRenderer } from "@/components/tools/tool-part-renderer";
import { getTextContent, getReasoningPart } from "@/lib/chat/messages";
interface ChatMessagesProps { interface ChatMessagesProps {
messages: UIMessage[]; messages: UIMessage[];
isLoading?: boolean; status?: ChatStatus;
} }
function LoadingIndicator() { function LoadingIndicator() {
return ( return (
<Message from="assistant"> <Message from="assistant">
@@ -37,8 +37,7 @@ function LoadingIndicator() {
); );
} }
export function ChatMessages({ messages, isLoading }: ChatMessagesProps) { export function ChatMessages({ messages, status = "ready" }: ChatMessagesProps) {
const isToolPart = (type: string) => type === "file" || type.startsWith("tool-");
const hasReasoningPart = (parts: typeof messages[0]["parts"]) => const hasReasoningPart = (parts: typeof messages[0]["parts"]) =>
parts.some((p) => p.type === "reasoning" && p.text); parts.some((p) => p.type === "reasoning" && p.text);
@@ -46,26 +45,31 @@ export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
(message) => (message) =>
message.role === "user" || message.role === "user" ||
getTextContent(message.parts).trim() !== "" || getTextContent(message.parts).trim() !== "" ||
message.parts.some((p) => isToolPart(p.type)) || message.parts.some((p) => p.type === "file" || isToolUIPart(p)) ||
hasReasoningPart(message.parts) hasReasoningPart(message.parts)
); );
const lastMessage = messages[messages.length - 1]; const lastMessage = messages[messages.length - 1];
const hasContent = const hasContent =
getTextContent(lastMessage?.parts || []).trim() !== "" || getTextContent(lastMessage?.parts || []).trim() !== "" ||
lastMessage?.parts.some((p) => isToolPart(p.type)) || lastMessage?.parts.some((p) => p.type === "file" || isToolUIPart(p)) ||
hasReasoningPart(lastMessage?.parts || []); hasReasoningPart(lastMessage?.parts || []);
const isStreaming = lastMessage?.role === "assistant" && hasContent; const isLoading = status === "submitted" || status === "streaming";
const showLoading = isLoading && !isStreaming; const showLoading =
isLoading && !(lastMessage?.role === "assistant" && hasContent);
return ( return (
<Conversation> <Conversation>
<ConversationContent className="mx-auto max-w-3xl px-4 pb-4"> <ConversationContent className="mx-auto max-w-3xl px-4 pb-4">
{visibleMessages.map((message) => { {visibleMessages.map((message) => {
const isStreaming =
status === "streaming" &&
message.role === "assistant" &&
message.id === lastMessage?.id;
const textContent = getTextContent(message.parts); const textContent = getTextContent(message.parts);
const reasoningPart = getReasoningPart(message.parts); const reasoningPart = getReasoningPart(message.parts);
const fileParts = message.parts.filter((p) => p.type === "file"); const fileParts = message.parts.filter((p) => p.type === "file");
const toolParts = message.parts.filter((p) => p.type.startsWith("tool-")); const toolParts = message.parts.filter((p) => isToolUIPart(p));
return ( return (
<Message key={message.id} from={message.role}> <Message key={message.id} from={message.role}>
{fileParts.length > 0 && ( {fileParts.length > 0 && (
@@ -80,7 +84,9 @@ export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
)} )}
{message.role === "assistant" && reasoningPart && ( {message.role === "assistant" && reasoningPart && (
<Reasoning isStreaming={reasoningPart.state !== "done"}> <Reasoning
isStreaming={isStreaming && reasoningPart.state === "streaming"}
>
<ReasoningTrigger /> <ReasoningTrigger />
<ReasoningContent>{reasoningPart.text || ""}</ReasoningContent> <ReasoningContent>{reasoningPart.text || ""}</ReasoningContent>
</Reasoning> </Reasoning>
@@ -92,7 +98,16 @@ export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
{textContent && ( {textContent && (
<MessageContent from={message.role}> <MessageContent from={message.role}>
<MessageResponse>{textContent}</MessageResponse> <MessageResponse
mode={message.role === "assistant" ? "streaming" : "static"}
isAnimating={
isStreaming && message.parts.some(
(part) => part.type === "text" && part.state === "streaming"
)
}
>
{textContent}
</MessageResponse>
</MessageContent> </MessageContent>
)} )}
</Message> </Message>
+93
View File
@@ -0,0 +1,93 @@
'use client';
import { useState } from 'react';
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react';
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorEmpty,
ModelSelectorGroup,
ModelSelectorInput,
ModelSelectorItem,
ModelSelectorList,
ModelSelectorName,
ModelSelectorTrigger,
} from '@/components/ai-elements/model-selector';
import { Button } from '@/components/ui/button';
import { openRouterModels, type OpenRouterModelId } from '@/lib/openrouter';
const providers: Record<string, string> = {
anthropic: 'Anthropic',
openai: 'OpenAI',
google: 'Google',
'x-ai': 'SpaceXAI',
'z-ai': 'Z.ai',
moonshotai: 'MoonshotAI',
minimax: 'MiniMax',
deepseek: 'DeepSeek',
meta: 'Meta',
qwen: 'Qwen',
};
const modelGroups = Object.entries(providers).map(([id, name]) => ({
name,
models: openRouterModels.filter((model) => model.id.startsWith(`${id}/`)),
}));
interface ChatModelSelectorProps {
value: OpenRouterModelId;
onChange: (modelId: OpenRouterModelId) => void;
disabled?: boolean;
}
export function ChatModelSelector({ value, onChange, disabled }: ChatModelSelectorProps) {
const [open, setOpen] = useState(false);
const selectedModel = openRouterModels.find((model) => model.id === value);
return (
<ModelSelector open={open} onOpenChange={setOpen}>
<ModelSelectorTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="min-w-0 max-w-[min(16rem,calc(100vw-10rem))] text-muted-foreground"
aria-label={`Select model, current model: ${selectedModel?.name}`}
disabled={disabled}
>
<ModelSelectorName>{selectedModel?.name}</ModelSelectorName>
<RiArrowDownSLine className="size-4 shrink-0" aria-hidden="true" />
</Button>
</ModelSelectorTrigger>
<ModelSelectorContent title="Choose a model" className="max-w-[calc(100%-2rem)] sm:max-w-lg">
<ModelSelectorInput placeholder="Search models..." aria-label="Search models" className="pr-10" />
<ModelSelectorList className="max-h-[min(24rem,60dvh)]">
<ModelSelectorEmpty>No models found.</ModelSelectorEmpty>
{modelGroups.map((group) => (
<ModelSelectorGroup heading={group.name} key={group.name}>
{group.models.map((model) => (
<ModelSelectorItem
key={model.id}
value={model.id}
keywords={[model.name, group.name]}
onSelect={() => {
onChange(model.id);
setOpen(false);
}}
>
<ModelSelectorName>{model.name}</ModelSelectorName>
{model.id === value && (
<>
<RiCheckLine className="ml-auto size-4" aria-hidden="true" />
<span className="sr-only">Selected</span>
</>
)}
</ModelSelectorItem>
))}
</ModelSelectorGroup>
))}
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>
);
}
+17 -46
View File
@@ -5,72 +5,40 @@ import { ChatInput, type PromptInputMessage } from './ChatInput';
import { Suggestion } from '@/components/ai-elements/suggestion'; import { Suggestion } from '@/components/ai-elements/suggestion';
import { RiOpenaiLine } from '@remixicon/react'; import { RiOpenaiLine } from '@remixicon/react';
import { Shimmer } from '@/components/ai-elements/shimmer'; import { Shimmer } from '@/components/ai-elements/shimmer';
import type { OpenRouterModelId } from '@/lib/openrouter';
import {
offlineSuggestions,
webSearchSuggestions,
type PromptSuggestion,
} from '@/config/suggestions';
export interface PromptSuggestion { export type { PromptSuggestion } from '@/config/suggestions';
key: string;
value: string;
}
interface ChatPromptProps { interface ChatPromptProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
onSubmit: (message: PromptInputMessage) => void; onSubmit: (message: PromptInputMessage) => void;
modelId: OpenRouterModelId;
onModelChange: (modelId: OpenRouterModelId) => void;
onStop?: () => void;
status?: ChatStatus; status?: ChatStatus;
suggestions?: PromptSuggestion[]; suggestions?: PromptSuggestion[];
webSearchEnabled?: boolean; webSearchEnabled?: boolean;
} }
const defaultSuggestions: PromptSuggestion[] = [
{
key: 'Latest AI news',
value:
'What are the latest developments in AI in 2026? Search for recent news.',
},
{
key: 'Research a company',
value: 'Research OpenAI: their latest products, funding, and news.',
},
{
key: 'Fact check',
value:
'What is the current population of the world and how is it changing?',
},
{
key: 'Compare topics',
value:
'Compare the latest iPhone vs Samsung Galaxy flagship models with sources.',
},
];
const offlineSuggestions: PromptSuggestion[] = [
{
key: 'Explain a concept',
value: 'Explain retrieval-augmented generation in plain language.',
},
{
key: 'Compare approaches',
value: 'Compare REST and GraphQL, including when to choose each.',
},
{
key: 'Draft an outline',
value: 'Draft an outline for a practical guide to evaluating AI agents.',
},
{
key: 'Brainstorm ideas',
value: 'Brainstorm useful AI agent ideas for a small business.',
},
];
export function ChatPrompt({ export function ChatPrompt({
value, value,
onChange, onChange,
onSubmit, onSubmit,
modelId,
onModelChange,
onStop,
status = 'ready', status = 'ready',
suggestions, suggestions,
webSearchEnabled = false, webSearchEnabled = false,
}: ChatPromptProps) { }: ChatPromptProps) {
const displayedSuggestions = const displayedSuggestions =
suggestions ?? (webSearchEnabled ? defaultSuggestions : offlineSuggestions); suggestions ?? (webSearchEnabled ? webSearchSuggestions : offlineSuggestions);
const handleSuggestionClick = (suggestion: string) => { const handleSuggestionClick = (suggestion: string) => {
onChange(suggestion); onChange(suggestion);
@@ -99,6 +67,9 @@ export function ChatPrompt({
value={value} value={value}
onChange={onChange} onChange={onChange}
onSubmit={onSubmit} onSubmit={onSubmit}
modelId={modelId}
onModelChange={onModelChange}
onStop={onStop}
status={status} status={status}
/> />
</div> </div>
+76
View File
@@ -0,0 +1,76 @@
'use client';
import { useState } from 'react';
import { RiCheckLine, RiSearchLine } from '@remixicon/react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import type { StoredChat } from '@/services/indexed-db/chat-store';
interface ChatSearchProps {
chats: StoredChat[];
activeChatId: string | null;
onSelectChat: (chatId: string) => void;
}
export function ChatSearch({ chats, activeChatId, onSelectChat }: ChatSearchProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="size-7" aria-label="Search chats">
<RiSearchLine className="size-5" aria-hidden="true" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-[calc(100%-2rem)] overflow-hidden p-0 sm:max-w-lg [&>button]:top-2.5">
<DialogTitle className="sr-only">Search chats</DialogTitle>
<DialogDescription className="sr-only">
Search your saved chats by title and select one to continue the conversation.
</DialogDescription>
<Command>
<CommandInput placeholder="Search chats..." aria-label="Search chats" className="pr-10" />
<CommandList className="max-h-[min(24rem,60dvh)]">
<CommandEmpty>
{chats.length === 0 ? 'No chats yet. Start a new chat to get started.' : 'No chats found.'}
</CommandEmpty>
<CommandGroup heading="Recent chats">
{chats.map((chat) => (
<CommandItem
key={chat.id}
value={chat.id}
keywords={[chat.title]}
onSelect={() => {
setOpen(false);
onSelectChat(chat.id);
}}
>
<span className="min-w-0 flex-1 truncate">{chat.title}</span>
{chat.id === activeChatId && (
<>
<RiCheckLine className="size-4" aria-hidden="true" />
<span className="sr-only">Current chat</span>
</>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}
+26 -14
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import { PlusIcon, MoreHorizontalIcon, TrashIcon } from 'lucide-react'; import { RiAddLine, RiMoreLine, RiDeleteBinLine } from '@remixicon/react';
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@@ -12,6 +12,7 @@ import {
SidebarMenuItem, SidebarMenuItem,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuAction, SidebarMenuAction,
useSidebar,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { import {
DropdownMenu, DropdownMenu,
@@ -20,12 +21,10 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { ChatSearch } from './ChatSearch';
import type { StoredChat } from '@/services/indexed-db/chat-store';
export interface Chat { export type Chat = StoredChat;
id: string;
title: string;
created_at: string;
}
interface ChatSidebarProps { interface ChatSidebarProps {
chats: Chat[]; chats: Chat[];
@@ -42,15 +41,29 @@ export function ChatSidebar({
onNewChat, onNewChat,
onDeleteChat, onDeleteChat,
}: ChatSidebarProps) { }: ChatSidebarProps) {
const { setOpenMobile } = useSidebar();
const selectChat = (chatId: string) => {
onSelectChat(chatId);
setOpenMobile(false);
};
return ( return (
<Sidebar> <Sidebar>
<SidebarHeader> <SidebarHeader>
<div className="flex h-7 items-center justify-between gap-2 pl-2">
<span className="text-xl font-semibold tracking-tight">ChatGPT</span>
<ChatSearch chats={chats} activeChatId={activeChatId} onSelectChat={selectChat} />
</div>
<Button <Button
onClick={onNewChat} onClick={() => {
onNewChat();
setOpenMobile(false);
}}
variant="outline" variant="outline"
className="w-full justify-start gap-2" className="mt-2 w-full justify-start gap-2"
> >
<PlusIcon className="size-4" /> <RiAddLine className="size-4" />
New Chat New Chat
</Button> </Button>
</SidebarHeader> </SidebarHeader>
@@ -63,23 +76,22 @@ export function ChatSidebar({
<SidebarMenuItem key={chat.id}> <SidebarMenuItem key={chat.id}>
<SidebarMenuButton <SidebarMenuButton
isActive={chat.id === activeChatId} isActive={chat.id === activeChatId}
onClick={() => onSelectChat(chat.id)} onClick={() => selectChat(chat.id)}
tooltip={chat.title} tooltip={chat.title}
> >
<span>{chat.title}</span> <span>{chat.title}</span>
</SidebarMenuButton> </SidebarMenuButton>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover> <SidebarMenuAction showOnHover aria-label={`Options for ${chat.title}`}>
<MoreHorizontalIcon className="size-4" /> <RiMoreLine className="size-4" />
</SidebarMenuAction> </SidebarMenuAction>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent side="right" align="start"> <DropdownMenuContent side="right" align="start">
<DropdownMenuItem <DropdownMenuItem
onClick={() => onDeleteChat(chat.id)} onClick={() => onDeleteChat(chat.id)}
className=""
> >
<TrashIcon className="size-4" /> <RiDeleteBinLine className="size-4" />
Delete Delete
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
+1 -2
View File
@@ -1,9 +1,8 @@
export { ChatAgent } from "./ChatAgent"; export { ChatAgent } from "./ChatAgent";
export { ChatInput } from "./ChatInput"; export { ChatInput } from "./ChatInput";
export { ChatMessage } from "./ChatMessage";
export { ChatMessages } from "./ChatMessages"; export { ChatMessages } from "./ChatMessages";
export { ChatPrompt } from "./ChatPrompt"; export { ChatPrompt } from "./ChatPrompt";
export { ChatSidebar } from "./ChatSidebar"; export { ChatSidebar } from "./ChatSidebar";
export type { Chat } from "./ChatSidebar"; export type { Chat } from "./ChatSidebar";
export type { Message } from "./ChatMessage"; export type { UIMessage as Message } from "ai";
export type { PromptSuggestion } from "./ChatPrompt"; export type { PromptSuggestion } from "./ChatPrompt";
+12
View File
@@ -0,0 +1,12 @@
'use client';
import type { ComponentType } from 'react';
import { FirecrawlWebFetchTool } from './firecrawl-web-fetch-tool';
import { FirecrawlWebSearchTool } from './firecrawl-web-search-tool';
import type { ToolComponentProps } from './types';
// Add a renderer here for each tool that needs a custom presentation.
export const toolComponents: Record<string, ComponentType<ToolComponentProps> | undefined> = {
firecrawlWebSearch: FirecrawlWebSearchTool,
firecrawlWebFetch: FirecrawlWebFetchTool,
};
+45
View File
@@ -0,0 +1,45 @@
'use client';
import { getToolName } from 'ai';
import {
Tool,
ToolContent,
ToolHeader,
ToolInput,
ToolOutput,
} from '@/components/ai-elements/tool';
import { Loader } from '@/components/ai-elements/loader';
import { toolComponents } from './registry';
import type { ToolComponentProps } from './types';
export function ToolPartRenderer({ part }: Pick<ToolComponentProps, 'part'>) {
const toolName = getToolName(part);
const CustomRenderer = toolComponents[toolName];
if (CustomRenderer) {
return <CustomRenderer part={part} toolName={toolName} />;
}
if (part.state === 'input-streaming' && !part.input) {
return (
<div className="mb-4 flex items-center gap-2 text-muted-foreground text-sm">
<Loader size={14} />
<span>Running {toolName}...</span>
</div>
);
}
return (
<Tool>
<ToolHeader
title={toolName}
type={part.type === 'dynamic-tool' ? `tool-${toolName}` : part.type}
state={part.state}
/>
<ToolContent>
{part.input != null && <ToolInput input={part.input} />}
<ToolOutput output={part.output} errorText={part.errorText} />
</ToolContent>
</Tool>
);
}
+2 -4
View File
@@ -1,8 +1,6 @@
import type { UIMessage } from "ai"; import type { DynamicToolUIPart, ToolUIPart } from "ai";
type MessagePart = UIMessage["parts"][number];
export interface ToolComponentProps { export interface ToolComponentProps {
part: MessagePart; part: ToolUIPart | DynamicToolUIPart;
toolName: string; toolName: string;
} }
+2 -2
View File
@@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion" import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react" import { RiArrowDownSLine } from "@remixicon/react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -41,7 +41,7 @@ function AccordionTrigger({
{...props} {...props}
> >
{children} {children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" /> <RiArrowDownSLine className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger> </AccordionPrimitive.Trigger>
</AccordionPrimitive.Header> </AccordionPrimitive.Header>
) )
+3 -3
View File
@@ -1,6 +1,6 @@
import * as React from "react" import * as React from "react"
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react" import { RiArrowRightSLine, RiMoreLine } from "@remixicon/react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -75,7 +75,7 @@ function BreadcrumbSeparator({
className={cn("[&>svg]:size-3.5", className)} className={cn("[&>svg]:size-3.5", className)}
{...props} {...props}
> >
{children ?? <ChevronRight />} {children ?? <RiArrowRightSLine />}
</li> </li>
) )
} }
@@ -92,7 +92,7 @@ function BreadcrumbEllipsis({
className={cn("flex size-9 items-center justify-center", className)} className={cn("flex size-9 items-center justify-center", className)}
{...props} {...props}
> >
<MoreHorizontal className="size-4" /> <RiMoreLine className="size-4" />
<span className="sr-only">More</span> <span className="sr-only">More</span>
</span> </span>
) )
+7 -7
View File
@@ -2,10 +2,10 @@
import * as React from "react" import * as React from "react"
import { import {
ChevronDownIcon, RiArrowDownSLine,
ChevronLeftIcon, RiArrowLeftSLine,
ChevronRightIcon, RiArrowRightSLine,
} from "lucide-react" } from "@remixicon/react"
import { import {
DayPicker, DayPicker,
getDefaultClassNames, getDefaultClassNames,
@@ -145,13 +145,13 @@ function Calendar({
Chevron: ({ className, orientation, ...props }) => { Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") { if (orientation === "left") {
return ( return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} /> <RiArrowLeftSLine className={cn("size-4", className)} {...props} />
) )
} }
if (orientation === "right") { if (orientation === "right") {
return ( return (
<ChevronRightIcon <RiArrowRightSLine
className={cn("size-4", className)} className={cn("size-4", className)}
{...props} {...props}
/> />
@@ -159,7 +159,7 @@ function Calendar({
} }
return ( return (
<ChevronDownIcon className={cn("size-4", className)} {...props} /> <RiArrowDownSLine className={cn("size-4", className)} {...props} />
) )
}, },
DayButton: CalendarDayButton, DayButton: CalendarDayButton,
+5 -5
View File
@@ -4,7 +4,7 @@ import * as React from "react"
import useEmblaCarousel, { import useEmblaCarousel, {
type UseEmblaCarouselType, type UseEmblaCarouselType,
} from "embla-carousel-react" } from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react" import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -77,10 +77,10 @@ function Carousel({
const handleKeyDown = React.useCallback( const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => { (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") { if (event.key === "RiArrowLeftLine") {
event.preventDefault() event.preventDefault()
scrollPrev() scrollPrev()
} else if (event.key === "ArrowRight") { } else if (event.key === "RiArrowRightLine") {
event.preventDefault() event.preventDefault()
scrollNext() scrollNext()
} }
@@ -195,7 +195,7 @@ function CarouselPrevious({
onClick={scrollPrev} onClick={scrollPrev}
{...props} {...props}
> >
<ArrowLeft /> <RiArrowLeftLine />
<span className="sr-only">Previous slide</span> <span className="sr-only">Previous slide</span>
</Button> </Button>
) )
@@ -225,7 +225,7 @@ function CarouselNext({
onClick={scrollNext} onClick={scrollNext}
{...props} {...props}
> >
<ArrowRight /> <RiArrowRightLine />
<span className="sr-only">Next slide</span> <span className="sr-only">Next slide</span>
</Button> </Button>
) )
+2 -2
View File
@@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox" import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react" import { RiCheckLine } from "@remixicon/react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -23,7 +23,7 @@ function Checkbox({
data-slot="checkbox-indicator" data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none" className="grid place-content-center text-current transition-none"
> >
<CheckIcon className="size-3.5" /> <RiCheckLine className="size-3.5" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root> </CheckboxPrimitive.Root>
) )
+5 -5
View File
@@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react" import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react" import { RiCheckLine, RiArrowDownSLine, RiCloseLine } from "@remixicon/react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -31,7 +31,7 @@ function ComboboxTrigger({
{...props} {...props}
> >
{children} {children}
<ChevronDownIcon <RiArrowDownSLine
data-slot="combobox-trigger-icon" data-slot="combobox-trigger-icon"
className="text-muted-foreground pointer-events-none size-4" className="text-muted-foreground pointer-events-none size-4"
/> />
@@ -47,7 +47,7 @@ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
className={cn(className)} className={cn(className)}
{...props} {...props}
> >
<XIcon className="pointer-events-none" /> <RiCloseLine className="pointer-events-none" />
</ComboboxPrimitive.Clear> </ComboboxPrimitive.Clear>
) )
} }
@@ -160,7 +160,7 @@ function ComboboxItem({
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" /> <span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
} }
> >
<CheckIcon className="pointer-events-none size-4 pointer-coarse:size-5" /> <RiCheckLine className="pointer-events-none size-4 pointer-coarse:size-5" />
</ComboboxPrimitive.ItemIndicator> </ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item> </ComboboxPrimitive.Item>
) )
@@ -265,7 +265,7 @@ function ComboboxChip({
className="-ml-1 opacity-50 hover:opacity-100" className="-ml-1 opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove" data-slot="combobox-chip-remove"
> >
<XIcon className="pointer-events-none" /> <RiCloseLine className="pointer-events-none" />
</ComboboxPrimitive.ChipRemove> </ComboboxPrimitive.ChipRemove>
)} )}
</ComboboxPrimitive.Chip> </ComboboxPrimitive.Chip>
+2 -2
View File
@@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import { Command as CommandPrimitive } from "cmdk" import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react" import { RiSearchLine } from "@remixicon/react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { import {
@@ -69,7 +69,7 @@ function CommandInput({
data-slot="command-input-wrapper" data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3" className="flex h-9 items-center gap-2 border-b px-3"
> >
<SearchIcon className="size-4 shrink-0 opacity-50" /> <RiSearchLine className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input <CommandPrimitive.Input
data-slot="command-input" data-slot="command-input"
className={cn( className={cn(
+4 -4
View File
@@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { RiCheckLine, RiArrowRightSLine, RiCircleLine } from "@remixicon/react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -72,7 +72,7 @@ function ContextMenuSubTrigger({
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto" /> <RiArrowRightSLine className="ml-auto" />
</ContextMenuPrimitive.SubTrigger> </ContextMenuPrimitive.SubTrigger>
) )
} }
@@ -152,7 +152,7 @@ function ContextMenuCheckboxItem({
> >
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator> <ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" /> <RiCheckLine className="size-4" />
</ContextMenuPrimitive.ItemIndicator> </ContextMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
@@ -176,7 +176,7 @@ function ContextMenuRadioItem({
> >
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator> <ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" /> <RiCircleLine className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator> </ContextMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
+1 -1
View File
@@ -45,7 +45,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'relative z-50 grid w-full max-w-lg gap-4 border border-border bg-background p-6 shadow-lg duration-200 sm:rounded-lg', 'relative z-50 grid w-full max-w-lg gap-4 border border-border bg-background p-6 shadow-lg duration-200 rounded-lg',
'data-[state=open]:animate-in data-[state=closed]:animate-out', '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]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
+4 -4
View File
@@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { RiCheckLine, RiArrowRightSLine, RiCircleLine } from "@remixicon/react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -100,7 +100,7 @@ function DropdownMenuCheckboxItem({
> >
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" /> <RiCheckLine className="size-4" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
@@ -135,7 +135,7 @@ function DropdownMenuRadioItem({
> >
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator> <DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" /> <RiCircleLine className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator> </DropdownMenuPrimitive.ItemIndicator>
</span> </span>
{children} {children}
@@ -217,7 +217,7 @@ function DropdownMenuSubTrigger({
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto size-4" /> <RiArrowRightSLine className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger> </DropdownMenuPrimitive.SubTrigger>
) )
} }
+2 -2
View File
@@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp" import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react" import { RiSubtractLine } from "@remixicon/react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -69,7 +69,7 @@ function InputOTPSlot({
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) { function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return ( return (
<div data-slot="input-otp-separator" role="separator" {...props}> <div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon /> <RiSubtractLine />
</div> </div>
) )
} }
+4 -4
View File
@@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar" import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" import { RiCheckLine, RiArrowRightSLine, RiCircleLine } from "@remixicon/react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -129,7 +129,7 @@ function MenubarCheckboxItem({
> >
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator> <MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" /> <RiCheckLine className="size-4" />
</MenubarPrimitive.ItemIndicator> </MenubarPrimitive.ItemIndicator>
</span> </span>
{children} {children}
@@ -153,7 +153,7 @@ function MenubarRadioItem({
> >
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> <span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator> <MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" /> <RiCircleLine className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator> </MenubarPrimitive.ItemIndicator>
</span> </span>
{children} {children}
@@ -235,7 +235,7 @@ function MenubarSubTrigger({
{...props} {...props}
> >
{children} {children}
<ChevronRightIcon className="ml-auto h-4 w-4" /> <RiArrowRightSLine className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger> </MenubarPrimitive.SubTrigger>
) )
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import * as React from "react" import * as React from "react"
import { ChevronDownIcon } from "lucide-react" import { RiArrowDownSLine } from "@remixicon/react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -24,7 +24,7 @@ function NativeSelect({
)} )}
{...props} {...props}
/> />
<ChevronDownIcon <RiArrowDownSLine
className="text-muted-foreground pointer-events-none absolute top-1/2 right-3.5 size-4 -translate-y-1/2 opacity-50 select-none" className="text-muted-foreground pointer-events-none absolute top-1/2 right-3.5 size-4 -translate-y-1/2 opacity-50 select-none"
aria-hidden="true" aria-hidden="true"
data-slot="native-select-icon" data-slot="native-select-icon"
+2 -2
View File
@@ -1,7 +1,7 @@
import * as React from "react" import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu" import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority" import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react" import { RiArrowDownSLine } from "@remixicon/react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -74,7 +74,7 @@ function NavigationMenuTrigger({
{...props} {...props}
> >
{children}{" "} {children}{" "}
<ChevronDownIcon <RiArrowDownSLine
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180" className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true" aria-hidden="true"
/> />
+7 -7
View File
@@ -1,9 +1,9 @@
import * as React from "react" import * as React from "react"
import { import {
ChevronLeftIcon, RiArrowLeftSLine,
ChevronRightIcon, RiArrowRightSLine,
MoreHorizontalIcon, RiMoreLine,
} from "lucide-react" } from "@remixicon/react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { buttonVariants, type Button } from "@/components/ui/button" import { buttonVariants, type Button } from "@/components/ui/button"
@@ -76,7 +76,7 @@ function PaginationPrevious({
className={cn("gap-1 px-2.5 sm:pl-2.5", className)} className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props} {...props}
> >
<ChevronLeftIcon /> <RiArrowLeftSLine />
<span className="hidden sm:block">Previous</span> <span className="hidden sm:block">Previous</span>
</PaginationLink> </PaginationLink>
) )
@@ -94,7 +94,7 @@ function PaginationNext({
{...props} {...props}
> >
<span className="hidden sm:block">Next</span> <span className="hidden sm:block">Next</span>
<ChevronRightIcon /> <RiArrowRightSLine />
</PaginationLink> </PaginationLink>
) )
} }
@@ -110,7 +110,7 @@ function PaginationEllipsis({
className={cn("flex size-9 items-center justify-center", className)} className={cn("flex size-9 items-center justify-center", className)}
{...props} {...props}
> >
<MoreHorizontalIcon className="size-4" /> <RiMoreLine className="size-4" />
<span className="sr-only">More pages</span> <span className="sr-only">More pages</span>
</span> </span>
) )
+2 -2
View File
@@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react" import { RiCircleFill } from "@remixicon/react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -36,7 +36,7 @@ function RadioGroupItem({
data-slot="radio-group-indicator" data-slot="radio-group-indicator"
className="relative flex items-center justify-center" className="relative flex items-center justify-center"
> >
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" /> <RiCircleFill className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator> </RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item> </RadioGroupPrimitive.Item>
) )
+2 -2
View File
@@ -1,7 +1,7 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { GripVerticalIcon } from "lucide-react" import { RiDraggable } from "@remixicon/react"
import * as ResizablePrimitive from "react-resizable-panels" import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -46,7 +46,7 @@ function ResizableHandle({
> >
{withHandle && ( {withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border"> <div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" /> <RiDraggable className="size-2.5" />
</div> </div>
)} )}
</ResizablePrimitive.Separator> </ResizablePrimitive.Separator>
+5 -5
View File
@@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select" import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react" import { RiCheckLine, RiArrowDownSLine, RiArrowUpSLine } from "@remixicon/react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -44,7 +44,7 @@ function SelectTrigger({
> >
{children} {children}
<SelectPrimitive.Icon asChild> <SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" /> <RiArrowDownSLine className="size-4 opacity-50" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
) )
@@ -119,7 +119,7 @@ function SelectItem({
className="absolute right-2 flex size-3.5 items-center justify-center" className="absolute right-2 flex size-3.5 items-center justify-center"
> >
<SelectPrimitive.ItemIndicator> <SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" /> <RiCheckLine className="size-4" />
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
@@ -153,7 +153,7 @@ function SelectScrollUpButton({
)} )}
{...props} {...props}
> >
<ChevronUpIcon className="size-4" /> <RiArrowUpSLine className="size-4" />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
) )
} }
@@ -171,7 +171,7 @@ function SelectScrollDownButton({
)} )}
{...props} {...props}
> >
<ChevronDownIcon className="size-4" /> <RiArrowDownSLine className="size-4" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
) )
} }
+2 -2
View File
@@ -2,7 +2,7 @@
import * as React from "react" import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog" import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react" import { RiCloseLine } from "@remixicon/react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -76,7 +76,7 @@ function SheetContent({
{children} {children}
{showCloseButton && ( {showCloseButton && (
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" /> <RiCloseLine className="size-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
)} )}
+2 -2
View File
@@ -3,7 +3,7 @@
import * as React from "react" import * as React from "react"
import { Slot } from "@radix-ui/react-slot" import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react" import { RiLayoutLeftLine } from "@remixicon/react"
import { useIsMobile } from "@/hooks/use-mobile" import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@@ -273,7 +273,7 @@ function SidebarTrigger({
}} }}
{...props} {...props}
> >
<PanelLeftIcon /> <RiLayoutLeftLine className="size-5" aria-hidden="true" />
<span className="sr-only">Toggle Sidebar</span> <span className="sr-only">Toggle Sidebar</span>
</Button> </Button>
) )
+11 -11
View File
@@ -1,12 +1,12 @@
"use client" "use client"
import { import {
CircleCheckIcon, RiCheckboxCircleLine,
InfoIcon, RiInformationLine,
Loader2Icon, RiLoader4Line,
OctagonXIcon, RiCloseCircleLine,
TriangleAlertIcon, RiErrorWarningLine,
} from "lucide-react" } from "@remixicon/react"
import { useTheme } from "next-themes" import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner" import { Toaster as Sonner, type ToasterProps } from "sonner"
@@ -18,11 +18,11 @@ const Toaster = ({ ...props }: ToasterProps) => {
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps["theme"]}
className="toaster group" className="toaster group"
icons={{ icons={{
success: <CircleCheckIcon className="size-4" />, success: <RiCheckboxCircleLine className="size-4" />,
info: <InfoIcon className="size-4" />, info: <RiInformationLine className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />, warning: <RiErrorWarningLine className="size-4" />,
error: <OctagonXIcon className="size-4" />, error: <RiCloseCircleLine className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />, loading: <RiLoader4Line className="size-4 animate-spin" />,
}} }}
style={ style={
{ {
+3 -3
View File
@@ -1,10 +1,10 @@
import { Loader2Icon } from "lucide-react" import { RiLoader4Line } from "@remixicon/react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) { function Spinner({ className, ...props }: React.ComponentProps<typeof RiLoader4Line>) {
return ( return (
<Loader2Icon <RiLoader4Line
role="status" role="status"
aria-label="Loading" aria-label="Loading"
className={cn("size-4 animate-spin", className)} className={cn("size-4 animate-spin", className)}
+45
View File
@@ -0,0 +1,45 @@
export interface PromptSuggestion {
key: string;
value: string;
}
export const webSearchSuggestions: PromptSuggestion[] = [
{
key: 'Latest AI news',
value:
'What are the latest developments in AI? Search for recent news.',
},
{
key: 'Research a company',
value: 'Research OpenAI: their latest products, funding, and news.',
},
{
key: 'Fact check',
value:
'What is the current population of the world and how is it changing?',
},
{
key: 'Compare topics',
value:
'Compare the latest iPhone vs Samsung Galaxy flagship models with sources.',
},
];
export const offlineSuggestions: PromptSuggestion[] = [
{
key: 'Explain a concept',
value: 'Explain retrieval-augmented generation in plain language.',
},
{
key: 'Compare approaches',
value: 'Compare REST and GraphQL, including when to choose each.',
},
{
key: 'Draft an outline',
value: 'Draft an outline for a practical guide to evaluating AI agents.',
},
{
key: 'Brainstorm ideas',
value: 'Brainstorm useful AI agent ideas for a small business.',
},
];
+254
View File
@@ -0,0 +1,254 @@
'use client';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport, type ChatStatus, type UIMessage } from 'ai';
import { DEFAULT_OPENROUTER_MODEL_ID, type OpenRouterModelId } from '@/lib/openrouter';
import type { PromptInputMessage } from '@/components/ai-elements/prompt-input';
import {
createChat,
deleteChat,
getMessages,
listChats,
saveMessages,
type StoredChat,
} from '@/services/indexed-db/chat-store';
async function convertToDataUrl(url: string): Promise<string> {
const response = await fetch(url);
if (!response.ok) throw new Error('Could not read the selected file');
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result === 'string') {
resolve(reader.result);
} else {
reject(new Error('Could not encode the selected file'));
}
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
export function useChatSession(defaultModelId: OpenRouterModelId = DEFAULT_OPENROUTER_MODEL_ID) {
const [modelId, setModelId] = useState<OpenRouterModelId>(defaultModelId);
const [inputValue, setInputValue] = useState('');
const [chatId, setChatId] = useState<string | null>(null);
const [chats, setChats] = useState<StoredChat[]>([]);
const chatIdRef = useRef(chatId);
const pendingChatIdRef = useRef<string | null>(null);
chatIdRef.current = chatId;
const fetchChats = useCallback(async () => {
try {
setChats(await listChats());
} catch (error) {
console.error('Failed to fetch chats:', error);
}
}, []);
const transportRef = useRef(
new DefaultChatTransport({
api: '/api/chat',
})
);
const {
status,
messages: chatMessages,
setMessages: setChatMessages,
sendMessage,
stop,
error: aiError,
} = useChat({
transport: transportRef.current,
onFinish: async ({ messages }) => {
const activeChatId = pendingChatIdRef.current ?? chatIdRef.current;
if (!activeChatId) return;
try {
await saveMessages(activeChatId, messages);
await fetchChats();
} catch (error) {
console.error('Failed to save messages:', error);
} finally {
if (pendingChatIdRef.current === activeChatId) {
pendingChatIdRef.current = null;
}
}
},
onError: (error) => {
console.error('Chat error:', error);
},
});
const [isPreparingFiles, setIsPreparingFiles] = useState(false);
const chatStatus: ChatStatus = isPreparingFiles
? 'submitted'
: status === 'submitted' || status === 'streaming'
? status
: 'ready';
useEffect(() => {
fetchChats();
}, [fetchChats]);
const handleSelectChat = useCallback(
async (selectedChatId: string) => {
try {
const messages = await getMessages(selectedChatId);
chatIdRef.current = selectedChatId;
setChatId(selectedChatId);
setChatMessages(messages);
} catch (error) {
console.error('Failed to load chat:', error);
}
},
[setChatMessages]
);
const handleNewChat = useCallback(() => {
chatIdRef.current = null;
setChatId(null);
setChatMessages([]);
setInputValue('');
}, [setChatMessages]);
const handleDeleteChat = useCallback(
async (deletedChatId: string) => {
try {
await deleteChat(deletedChatId);
setChats((prev) => prev.filter((c) => c.id !== deletedChatId));
if (chatId === deletedChatId) {
chatIdRef.current = null;
setChatId(null);
setChatMessages([]);
}
} catch (error) {
console.error('Failed to delete chat:', error);
}
},
[chatId, setChatMessages]
);
const handleSendMessage = useCallback(
async (message: PromptInputMessage) => {
const trimmedValue = message.text?.trim();
if (!trimmedValue && !message.files?.length) return;
if (
status === 'submitted' ||
status === 'streaming' ||
isPreparingFiles
)
return;
let activeChatId = chatIdRef.current;
if (!chatIdRef.current) {
const newChatId = crypto.randomUUID();
const title = trimmedValue ? trimmedValue.slice(0, 100) : 'New Chat';
activeChatId = newChatId;
chatIdRef.current = newChatId;
setChatId(newChatId);
try {
await createChat(newChatId, title);
await fetchChats();
} catch (error) {
console.error('Failed to create chat:', error);
}
}
const parts: Array<
| { type: 'text'; text: string }
| { type: 'file'; url: string; mediaType: string; filename: string }
> = [];
if (trimmedValue) {
parts.push({ type: 'text', text: trimmedValue });
}
// PromptInput supplies files as base64 data URLs. Keep them inline so the
// demo never needs a remote storage bucket.
if (message.files?.length) {
setIsPreparingFiles(true);
for (const file of message.files) {
try {
const dataUrl = file.url.startsWith('data:')
? file.url
: await convertToDataUrl(file.url);
parts.push({
type: 'file',
url: dataUrl,
mediaType: file.mediaType || 'application/octet-stream',
filename: file.filename || 'file',
});
} catch (err) {
console.error('File encoding error:', err);
}
}
setIsPreparingFiles(false);
}
if (parts.length === 0) return;
const userMessage: UIMessage = {
id: crypto.randomUUID(),
role: 'user',
parts,
};
setInputValue('');
if (activeChatId) {
try {
await saveMessages(activeChatId, [...chatMessages, userMessage]);
} catch (error) {
console.error('Failed to save user message:', error);
}
}
pendingChatIdRef.current = activeChatId;
await sendMessage(userMessage, { body: { modelId } });
},
[
status,
sendMessage,
chatMessages,
isPreparingFiles,
fetchChats,
modelId,
]
);
const handleStop = useCallback(() => {
stop();
}, [stop]);
return {
inputValue,
setInputValue,
modelId,
setModelId,
chatId,
chats,
chatMessages,
status,
chatStatus,
aiError,
isPreparingFiles,
handleSelectChat,
handleNewChat,
handleDeleteChat,
handleSendMessage,
handleStop,
};
}
+15
View File
@@ -0,0 +1,15 @@
import type { UIMessage } from "ai";
type MessagePart = UIMessage["parts"][number];
type ReasoningPart = Extract<MessagePart, { type: "reasoning" }>;
export function getTextContent(parts: MessagePart[]): string {
return parts
.filter((p) => p.type === "text")
.map((p) => p.text || "")
.join("");
}
export function getReasoningPart(parts: MessagePart[]): ReasoningPart | undefined {
return parts.find((p): p is ReasoningPart => p.type === "reasoning");
}
+30
View File
@@ -0,0 +1,30 @@
export const openRouterModels = [
{ id: 'anthropic/claude-opus-5', name: 'Claude Opus 5' },
{ id: 'anthropic/claude-opus-5-fast', name: 'Claude Opus 5 (Fast)' },
{ id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5' },
{ id: 'anthropic/claude-fable-5', name: 'Claude Fable 5' },
{ id: 'openai/gpt-6-astra-pro', name: 'GPT-6 Astra Pro' },
{ id: 'openai/gpt-5.6-luna', name: 'GPT-5.6 Luna' },
{ id: 'openai/gpt-5.6-luna-pro', name: 'GPT-5.6 Luna Pro' },
{ id: 'openai/gpt-5.6-sol', name: 'GPT-5.6 Sol' },
{ id: 'openai/gpt-5.6-sol-pro', name: 'GPT-5.6 Sol Pro' },
{ id: 'openai/gpt-5.6-terra', name: 'GPT-5.6 Terra' },
{ id: 'openai/gpt-5.6-terra-pro', name: 'GPT-5.6 Terra Pro' },
{ id: 'google/gemini-3.8-flash', name: 'Gemini 3.8 Flash' },
{ id: 'x-ai/grok-4.6', name: 'Grok 4.6' },
{ id: 'z-ai/glm-5.3', name: 'GLM 5.3' },
{ id: 'moonshotai/kimi-k3', name: 'Kimi K3' },
{ id: 'minimax/minimax-m3', name: 'MiniMax M3' },
{ id: 'deepseek/deepseek-v4-pro', name: 'DeepSeek V4 Pro 0423' },
{ id: 'deepseek/deepseek-v4-flash-0731', name: 'DeepSeek V4 Flash 0731' },
{ id: 'meta/muse-spark-1.1', name: 'Muse Spark 1.1' },
{ id: 'qwen/qwen3.8-max', name: 'Qwen3.8 Max' },
] as const;
export type OpenRouterModelId = (typeof openRouterModels)[number]['id'];
export const DEFAULT_OPENROUTER_MODEL_ID: OpenRouterModelId = 'openai/gpt-5.6-luna-pro';
export function isOpenRouterModelId(value: unknown): value is OpenRouterModelId {
return typeof value === 'string' && openRouterModels.some((model) => model.id === value);
}
+7 -14
View File
@@ -1,30 +1,23 @@
/** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
outputFileTracingRoot: process.cwd(), outputFileTracingRoot: process.cwd(),
reactStrictMode: true, reactStrictMode: true,
images: { images: {
unoptimized: true, unoptimized: true,
remotePatterns: [ remotePatterns: [
{ { protocol: 'https', hostname: 'images.unsplash.com' },
protocol: 'https', { protocol: 'https', hostname: 'cdn.shopify.com' },
hostname: 'images.unsplash.com', { protocol: 'https', hostname: '**.frontend.co' },
},
{
protocol: 'https',
hostname: 'cdn.shopify.com',
},
{
protocol: 'https',
hostname: '**.frontend.co',
},
], ],
}, },
webpack: (config, { isServer }) => { webpack: (config, { isServer }) => {
config.cache = { type: "memory" }; config.cache = { type: 'memory' };
if (isServer) { if (isServer) {
config.optimization = config.optimization || {}; config.optimization = config.optimization || {};
config.optimization.splitChunks = false; config.optimization.splitChunks = false;
} return config;
} }
return config;
},
}; };
export default nextConfig; export default nextConfig;
+9 -14
View File
@@ -1,20 +1,18 @@
{ {
"name": "nextjs-app", "name": "nextjs-chatgpt-template",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --webpack", "dev": "next dev --webpack",
"build": "next build --webpack", "build": "next build --webpack",
"start": "next start", "start": "next start",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"test": "node --test tests/*.test.mjs"
}, },
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@ai-sdk/openai": "^4.0.9",
"@ai-sdk/react": "^4.0.19", "@ai-sdk/react": "^4.0.19",
"@base-ui/react": "^1.8.0", "@base-ui/react": "^1.8.0",
"@mendable/firecrawl-js": "^4.12.1",
"@neondatabase/serverless": "^1.0.2",
"@next/swc-wasm-web": "16.2.10", "@next/swc-wasm-web": "16.2.10",
"@openrouter/ai-sdk-provider": "^3.0.0", "@openrouter/ai-sdk-provider": "^3.0.0",
"@radix-ui/react-accordion": "^1.2.3", "@radix-ui/react-accordion": "^1.2.3",
@@ -44,6 +42,7 @@
"@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-toggle": "^1.1.2",
"@radix-ui/react-toggle-group": "^1.1.2", "@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-tooltip": "^1.1.8",
"@radix-ui/react-use-controllable-state": "^1.2.6",
"@remixicon/react": "^4.9.0", "@remixicon/react": "^4.9.0",
"@xyflow/react": "^12.10.0", "@xyflow/react": "^12.10.0",
"ai": "^7.0.18", "ai": "^7.0.18",
@@ -51,25 +50,17 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.4", "cmdk": "^1.0.4",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"drizzle-orm": "^0.45.1",
"embla-carousel-react": "^8.5.2", "embla-carousel-react": "^8.5.2",
"exa-js": "^2.4.0",
"framer-motion": "^12.0.0",
"gql-tag": "^1.0.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"lucide-react": "^0.574.0",
"motion": "^12.33.0",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"next": "16.2.10", "next": "16.2.10",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"openai": "^6.17.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.0.0", "react": "^19.0.0",
"react-day-picker": "^9.5.0", "react-day-picker": "^9.5.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.88.0", "react-hook-form": "^7.88.0",
"react-is": "^19.3.0", "react-is": "^19.3.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.5.8", "react-resizable-panels": "^4.5.8",
"recharts": "^3.7.0", "recharts": "^3.7.0",
"shiki": "^3.22.0", "shiki": "^3.22.0",
@@ -89,7 +80,11 @@
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"@types/react-is": "^19", "@types/react-is": "^19",
"jiti": "^2.7.0",
"typescript": "^5.7.3" "typescript": "^5.7.3"
}, },
"packageManager": "yarn@4.18.0+sha512.fcb8716fe7cd0eece141ffc18b92193a9df9204c1ba83189c288835223fc0bbe64af473bab0d5e9927a7daeb5caf2bb07eb2787cc9338ca040ea125f2a1f2f7e" "packageManager": "yarn@4.18.0+sha512.fcb8716fe7cd0eece141ffc18b92193a9df9204c1ba83189c288835223fc0bbe64af473bab0d5e9927a7daeb5caf2bb07eb2787cc9338ca040ea125f2a1f2f7e",
"engines": {
"node": ">=22"
}
} }
+27
View File
@@ -0,0 +1,27 @@
export function getChatInstructions(webSearchEnabled: boolean) {
const today = new Date().toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
});
return webSearchEnabled
? `You are an expert ChatGPT Agent with real-time access to the internet.
Your capabilities:
- Search the web using Firecrawl to find up-to-date information
- Fetch and read full web pages to extract detailed content
- Synthesize information from multiple sources into clear, accurate answers
- Cite your sources inline so the user can verify information
Guidelines:
- Always search the web before answering questions that require current or factual information
- Use multiple searches when needed to get comprehensive answers
- Cross-reference sources for accuracy
- Structure your responses clearly with headings, bullet points, and citations where appropriate
- When fetching pages, extract only the relevant information
- Be transparent about the sources you used
- Today's date is ${today}`
: `You are a helpful AI research assistant. Answer clearly and accurately. You do not have live web access, so be transparent when a question requires current information. Today's date is ${today}.`;
}
+19
View File
@@ -0,0 +1,19 @@
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import {
DEFAULT_OPENROUTER_MODEL_ID,
isOpenRouterModelId,
type OpenRouterModelId,
} from '@/lib/openrouter';
export function getDefaultChatModelId(): OpenRouterModelId {
const configuredModel = process.env.OPENROUTER_MODEL;
return isOpenRouterModelId(configuredModel) ? configuredModel : DEFAULT_OPENROUTER_MODEL_ID;
}
export function getChatModel(modelId: OpenRouterModelId = getDefaultChatModelId()) {
const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
});
return openrouter(modelId);
}
@@ -7,10 +7,11 @@ export const firecrawlWebFetch = tool({
inputSchema: z.object({ inputSchema: z.object({
url: z.string().url().describe('The URL of the web page to fetch'), url: z.string().url().describe('The URL of the web page to fetch'),
}), }),
execute: async ({ url }) => { execute: async ({ url }, { abortSignal }) => {
try { try {
const response = await fetch('https://api.firecrawl.dev/v1/scrape', { const response = await fetch('https://api.firecrawl.dev/v1/scrape', {
method: 'POST', method: 'POST',
signal: abortSignal,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.FIRECRAWL_API_KEY!}`, Authorization: `Bearer ${process.env.FIRECRAWL_API_KEY!}`,
@@ -13,10 +13,11 @@ export const firecrawlWebSearch = tool({
.default(3) .default(3)
.describe('Number of results to return (max 10)'), .describe('Number of results to return (max 10)'),
}), }),
execute: async ({ query, limit }) => { execute: async ({ query, limit }, { abortSignal }) => {
try { try {
const response = await fetch('https://api.firecrawl.dev/v1/search', { const response = await fetch('https://api.firecrawl.dev/v1/search', {
method: 'POST', method: 'POST',
signal: abortSignal,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.FIRECRAWL_API_KEY!}`, Authorization: `Bearer ${process.env.FIRECRAWL_API_KEY!}`,
+8
View File
@@ -0,0 +1,8 @@
import { firecrawlWebFetch } from './firecrawl-web-fetch';
import { firecrawlWebSearch } from './firecrawl-web-search';
// Keep these keys stable: saved conversations reference tool names.
export const chatTools = {
firecrawlWebSearch,
firecrawlWebFetch,
};
+237
View File
@@ -0,0 +1,237 @@
import assert from 'node:assert/strict';
import { setTimeout as delay } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { test } from 'node:test';
import { Chat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { createJiti } from 'jiti';
const jiti = createJiti(import.meta.url, {
alias: { '@': fileURLToPath(new URL('../', import.meta.url)) },
jsx: true,
});
async function waitFor(predicate) {
const deadline = Date.now() + 3000;
while (!predicate()) {
assert.ok(Date.now() < deadline, 'Expected streaming update before timeout');
await delay(5);
}
}
function textOf(chat) {
return chat.messages.at(-1)?.parts
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('');
}
async function setup(t) {
const environment = {
OPENROUTER_API_KEY: process.env.OPENROUTER_API_KEY,
FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY,
OPENROUTER_MODEL: process.env.OPENROUTER_MODEL,
};
process.env.OPENROUTER_API_KEY = 'streaming-test-key';
delete process.env.FIRECRAWL_API_KEY;
t.after(() => {
for (const [key, value] of Object.entries(environment)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
let controller;
let upstreamSignal;
let response;
let upstreamRequests = 0;
let upstreamModel;
let closed = false;
const encoder = new TextEncoder();
const { POST } = await jiti.import('../app/api/chat/route.ts');
// Exercise the real route, OpenRouter adapter, and client transport. Only the
// external provider is replaced, so no credentials or network are required.
t.mock.method(globalThis, 'fetch', async (url, init) => {
assert.equal(String(url), 'https://openrouter.ai/api/v1/chat/completions');
assert.equal(JSON.parse(init.body).stream, true);
upstreamModel = JSON.parse(init.body).model;
upstreamRequests += 1;
upstreamSignal = init.signal;
return new Response(new ReadableStream({
start(value) {
controller = value;
upstreamSignal?.addEventListener('abort', () => {
if (!closed) {
closed = true;
controller.error(new DOMException('Aborted', 'AbortError'));
}
}, { once: true });
},
cancel() { closed = true; },
}), { headers: { 'Content-Type': 'text/event-stream' } });
});
const chat = new Chat({
transport: new DefaultChatTransport({
api: 'http://localhost/api/chat',
fetch: async (url, init) => {
response = await POST(new Request(url, init));
return response;
},
}),
});
t.after(async () => {
await chat.stop();
if (controller && !closed) {
closed = true;
controller.close();
}
});
function event(data) {
controller.enqueue(encoder.encode(`data: ${JSON.stringify(data)}\n\n`));
}
function chunk(delta, finishReason = null) {
event({
id: 'test-generation',
object: 'chat.completion.chunk',
created: 1,
model: 'test-model',
choices: [{ index: 0, delta, finish_reason: finishReason }],
});
}
return {
chat,
get response() { return response; },
get upstreamRequests() { return upstreamRequests; },
get upstreamModel() { return upstreamModel; },
get upstreamSignal() { return upstreamSignal; },
ready: () => waitFor(() => Boolean(controller)),
chunk,
finish() {
chunk({}, 'stop');
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
closed = true;
controller.close();
},
fail() {
event({ error: { code: 502, message: 'Provider disconnected' } });
closed = true;
controller.close();
},
};
}
test('streams reasoning and text to the client before the provider finishes', async (t) => {
const stream = await setup(t);
let completed = false;
const sending = stream.chat.sendMessage({ text: 'Hello' }).then(() => {
completed = true;
});
await stream.ready();
assert.match(stream.response.headers.get('content-type'), /text\/event-stream/);
assert.match(stream.response.headers.get('cache-control'), /no-transform/);
assert.equal(stream.response.headers.get('x-accel-buffering'), 'no');
stream.chunk({ reasoning: 'Thinking through the answer.' });
await waitFor(() => stream.chat.messages.at(-1)?.parts.some(
(part) => part.type === 'reasoning' && part.text === 'Thinking through the answer.'
));
stream.chunk({ content: 'Hello' });
await waitFor(() => textOf(stream.chat) === 'Hello');
assert.equal(stream.chat.status, 'streaming');
assert.equal(completed, false);
stream.chunk({ content: ' world' });
await waitFor(() => textOf(stream.chat) === 'Hello world');
assert.equal(completed, false);
stream.finish();
await sending;
assert.equal(stream.chat.status, 'ready');
assert.equal(textOf(stream.chat), 'Hello world');
assert.equal(stream.upstreamRequests, 1);
});
for (const withPartialText of [false, true]) {
test(`stop aborts the upstream request ${withPartialText ? 'after a text chunk' : 'before the first token'}`, async (t) => {
const stream = await setup(t);
const errors = t.mock.method(console, 'error', () => {});
const sending = stream.chat.sendMessage({ text: 'Write a long response' });
await stream.ready();
if (withPartialText) {
stream.chunk({ content: 'Partial answer' });
await waitFor(() => textOf(stream.chat) === 'Partial answer');
}
await stream.chat.stop();
await sending;
assert.equal(stream.upstreamSignal.aborted, true);
assert.equal(stream.chat.status, 'ready');
assert.equal(stream.chat.error, undefined);
if (withPartialText) assert.equal(textOf(stream.chat), 'Partial answer');
assert.equal(stream.upstreamRequests, 1);
assert.equal(errors.mock.callCount(), 0);
});
}
test('a mid-stream provider error preserves partial text and reaches the client', async (t) => {
const stream = await setup(t);
t.mock.method(console, 'error', () => {});
const sending = stream.chat.sendMessage({ text: 'Hello' });
await stream.ready();
stream.chunk({ content: 'Partial answer' });
await waitFor(() => textOf(stream.chat) === 'Partial answer');
stream.fail();
await sending;
assert.equal(stream.chat.status, 'error');
assert.equal(stream.chat.error.message, 'The response was interrupted. Please try again.');
assert.equal(textOf(stream.chat), 'Partial answer');
});
test('missing credentials return an error without contacting the provider', async (t) => {
const stream = await setup(t);
delete process.env.OPENROUTER_API_KEY;
await stream.chat.sendMessage({ text: 'Hello' });
assert.equal(stream.response.status, 503);
assert.equal(stream.chat.status, 'error');
assert.equal(stream.upstreamRequests, 0);
});
test('changing the selected model routes each message to that OpenRouter model', async (t) => {
const stream = await setup(t);
for (const [index, modelId] of ['anthropic/claude-opus-5', 'google/gemini-3.8-flash'].entries()) {
const sending = stream.chat.sendMessage({ text: `Message ${index + 1}` }, { body: { modelId } });
await waitFor(() => stream.upstreamRequests === index + 1);
assert.equal(stream.upstreamModel, modelId);
stream.chunk({ content: 'Response from the selected model.' });
stream.finish();
await sending;
assert.equal(stream.chat.status, 'ready');
}
});
test('requests without a selection use the configured catalog model', async (t) => {
const stream = await setup(t);
process.env.OPENROUTER_MODEL = 'deepseek/deepseek-v4-flash-0731';
const sending = stream.chat.sendMessage({ text: 'Hello' });
await stream.ready();
assert.equal(stream.upstreamModel, 'deepseek/deepseek-v4-flash-0731');
stream.finish();
await sending;
});
for (const modelId of ['unknown/model', null, { id: 'anthropic/claude-opus-5' }]) {
test(`rejects an invalid model selection (${JSON.stringify(modelId)}) without contacting OpenRouter`, async (t) => {
const stream = await setup(t);
await stream.chat.sendMessage({ text: 'Hello' }, { body: { modelId } });
assert.equal(stream.response.status, 400);
assert.equal(stream.chat.status, 'error');
assert.equal(stream.upstreamRequests, 0);
});
}
+9 -639
View File
@@ -32,18 +32,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@ai-sdk/openai@npm:^4.0.9":
version: 4.0.66
resolution: "@ai-sdk/openai@npm:4.0.66"
dependencies:
"@ai-sdk/provider": "npm:4.0.14"
"@ai-sdk/provider-utils": "npm:5.0.40"
peerDependencies:
zod: ^3.25.76 || ^4.1.8
checksum: 10c0/ed38f484cf9fb627f7ff9e77d7f5fc7071d6f1582b03a768d50306398de40157d79d0727c45f1a792b6989b970ea38df1cbc46cb96a0636c0c3e58794f4c59a4
languageName: node
linkType: hard
"@ai-sdk/provider-utils@npm:5.0.40": "@ai-sdk/provider-utils@npm:5.0.40":
version: 5.0.40 version: 5.0.40
resolution: "@ai-sdk/provider-utils@npm:5.0.40" resolution: "@ai-sdk/provider-utils@npm:5.0.40"
@@ -487,19 +475,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@mendable/firecrawl-js@npm:^4.12.1":
version: 4.39.0
resolution: "@mendable/firecrawl-js@npm:4.39.0"
dependencies:
axios: "npm:1.18.0"
firecrawl: "npm:4.30.1"
typescript-event-target: "npm:^1.1.1"
zod: "npm:^3.23.8"
zod-to-json-schema: "npm:^3.23.0"
checksum: 10c0/88262fb0bc66406ea1c04b57f216116dff358179a7a1f85963e26bbbe87dbe3eb1451b63d62a9ff617a7bea0c41908ccf51d44ae6a62f44edf3fe9a6fc6d0f9d
languageName: node
linkType: hard
"@napi-rs/wasm-runtime@npm:^1.1.4": "@napi-rs/wasm-runtime@npm:^1.1.4":
version: 1.2.4 version: 1.2.4
resolution: "@napi-rs/wasm-runtime@npm:1.2.4" resolution: "@napi-rs/wasm-runtime@npm:1.2.4"
@@ -512,13 +487,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@neondatabase/serverless@npm:^1.0.2":
version: 1.1.0
resolution: "@neondatabase/serverless@npm:1.1.0"
checksum: 10c0/ba24391bd53f94b76a817c9bfd09f91259024119b51e64192f2f97f627253d4370699e051d73918352e71fb93ae4575177ca1f5b38f2c27fcf5dc1adda70c677
languageName: node
linkType: hard
"@next/env@npm:16.2.10": "@next/env@npm:16.2.10":
version: 16.2.10 version: 16.2.10
resolution: "@next/env@npm:16.2.10" resolution: "@next/env@npm:16.2.10"
@@ -1751,7 +1719,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@radix-ui/react-use-controllable-state@npm:1.2.6": "@radix-ui/react-use-controllable-state@npm:1.2.6, @radix-ui/react-use-controllable-state@npm:^1.2.6":
version: 1.2.6 version: 1.2.6
resolution: "@radix-ui/react-use-controllable-state@npm:1.2.6" resolution: "@radix-ui/react-use-controllable-state@npm:1.2.6"
dependencies: dependencies:
@@ -2500,15 +2468,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"agent-base@npm:6":
version: 6.0.2
resolution: "agent-base@npm:6.0.2"
dependencies:
debug: "npm:4"
checksum: 10c0/dc4f757e40b5f3e3d674bc9beb4f1048f4ee83af189bae39be99f57bf1f48dde166a8b0a5342a84b5944ee8e6ed1e5a9d801858f4ad44764e84957122fe46261
languageName: node
linkType: hard
"ai@npm:7.0.99, ai@npm:^7.0.18": "ai@npm:7.0.99, ai@npm:^7.0.18":
version: 7.0.99 version: 7.0.99
resolution: "ai@npm:7.0.99" resolution: "ai@npm:7.0.99"
@@ -2531,39 +2490,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"async-function@npm:^1.0.0":
version: 1.0.0
resolution: "async-function@npm:1.0.0"
checksum: 10c0/669a32c2cb7e45091330c680e92eaeb791bc1d4132d827591e499cd1f776ff5a873e77e5f92d0ce795a8d60f10761dec9ddfe7225a5de680f5d357f67b1aac73
languageName: node
linkType: hard
"async-generator-function@npm:^1.0.0":
version: 1.0.0
resolution: "async-generator-function@npm:1.0.0"
checksum: 10c0/2c50ef856c543ad500d8d8777d347e3c1ba623b93e99c9263ecc5f965c1b12d2a140e2ab6e43c3d0b85366110696f28114649411cbcd10b452a92a2318394186
languageName: node
linkType: hard
"asynckit@npm:^0.4.0":
version: 0.4.0
resolution: "asynckit@npm:0.4.0"
checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d
languageName: node
linkType: hard
"axios@npm:1.18.0":
version: 1.18.0
resolution: "axios@npm:1.18.0"
dependencies:
follow-redirects: "npm:^1.16.0"
form-data: "npm:^4.0.5"
https-proxy-agent: "npm:^5.0.1"
proxy-from-env: "npm:^2.1.0"
checksum: 10c0/f88aae13fd9dd395dea3053c7299178fee62f436f20424a1d6d79ec11ffb7656ba664b4630defbadab2c387b6ea07e29afb9f28b26d8e0e49d45be766983290d
languageName: node
linkType: hard
"bail@npm:^2.0.0": "bail@npm:^2.0.0":
version: 2.0.2 version: 2.0.2
resolution: "bail@npm:2.0.2" resolution: "bail@npm:2.0.2"
@@ -2580,16 +2506,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2":
version: 1.0.2
resolution: "call-bind-apply-helpers@npm:1.0.2"
dependencies:
es-errors: "npm:^1.3.0"
function-bind: "npm:^1.1.2"
checksum: 10c0/47bd9901d57b857590431243fea704ff18078b16890a6b3e021e12d279bbf211d039155e27d7566b374d49ee1f8189344bac9833dec7a20cdec370506361c938
languageName: node
linkType: hard
"caniuse-lite@npm:^1.0.30001579": "caniuse-lite@npm:^1.0.30001579":
version: 1.0.30001810 version: 1.0.30001810
resolution: "caniuse-lite@npm:1.0.30001810" resolution: "caniuse-lite@npm:1.0.30001810"
@@ -2677,15 +2593,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"combined-stream@npm:^1.0.8":
version: 1.0.8
resolution: "combined-stream@npm:1.0.8"
dependencies:
delayed-stream: "npm:~1.0.0"
checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5
languageName: node
linkType: hard
"comma-separated-tokens@npm:^2.0.0": "comma-separated-tokens@npm:^2.0.0":
version: 2.0.3 version: 2.0.3
resolution: "comma-separated-tokens@npm:2.0.3" resolution: "comma-separated-tokens@npm:2.0.3"
@@ -2693,15 +2600,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"cross-fetch@npm:~4.1.0":
version: 4.1.0
resolution: "cross-fetch@npm:4.1.0"
dependencies:
node-fetch: "npm:^2.7.0"
checksum: 10c0/628b134ea27cfcada67025afe6ef1419813fffc5d63d175553efa75a2334522d450300a0f3f0719029700da80e96327930709d5551cf6deb39bb62f1d536642e
languageName: node
linkType: hard
"cross-spawn@npm:^7.0.6": "cross-spawn@npm:^7.0.6":
version: 7.0.6 version: 7.0.6
resolution: "cross-spawn@npm:7.0.6" resolution: "cross-spawn@npm:7.0.6"
@@ -2879,7 +2777,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"debug@npm:4, debug@npm:^4.0.0": "debug@npm:^4.0.0":
version: 4.4.3 version: 4.4.3
resolution: "debug@npm:4.4.3" resolution: "debug@npm:4.4.3"
dependencies: dependencies:
@@ -2907,13 +2805,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"delayed-stream@npm:~1.0.0":
version: 1.0.0
resolution: "delayed-stream@npm:1.0.0"
checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19
languageName: node
linkType: hard
"dequal@npm:^2.0.0, dequal@npm:^2.0.3": "dequal@npm:^2.0.0, dequal@npm:^2.0.3":
version: 2.0.3 version: 2.0.3
resolution: "dequal@npm:2.0.3" resolution: "dequal@npm:2.0.3"
@@ -2944,119 +2835,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"dotenv@npm:~16.4.7":
version: 16.4.7
resolution: "dotenv@npm:16.4.7"
checksum: 10c0/be9f597e36a8daf834452daa1f4cc30e5375a5968f98f46d89b16b983c567398a330580c88395069a77473943c06b877d1ca25b4afafcdd6d4adb549e8293462
languageName: node
linkType: hard
"drizzle-orm@npm:^0.45.1":
version: 0.45.2
resolution: "drizzle-orm@npm:0.45.2"
peerDependencies:
"@aws-sdk/client-rds-data": ">=3"
"@cloudflare/workers-types": ">=4"
"@electric-sql/pglite": ">=0.2.0"
"@libsql/client": ">=0.10.0"
"@libsql/client-wasm": ">=0.10.0"
"@neondatabase/serverless": ">=0.10.0"
"@op-engineering/op-sqlite": ">=2"
"@opentelemetry/api": ^1.4.1
"@planetscale/database": ">=1.13"
"@prisma/client": "*"
"@tidbcloud/serverless": "*"
"@types/better-sqlite3": "*"
"@types/pg": "*"
"@types/sql.js": "*"
"@upstash/redis": ">=1.34.7"
"@vercel/postgres": ">=0.8.0"
"@xata.io/client": "*"
better-sqlite3: ">=7"
bun-types: "*"
expo-sqlite: ">=14.0.0"
gel: ">=2"
knex: "*"
kysely: "*"
mysql2: ">=2"
pg: ">=8"
postgres: ">=3"
sql.js: ">=1"
sqlite3: ">=5"
peerDependenciesMeta:
"@aws-sdk/client-rds-data":
optional: true
"@cloudflare/workers-types":
optional: true
"@electric-sql/pglite":
optional: true
"@libsql/client":
optional: true
"@libsql/client-wasm":
optional: true
"@neondatabase/serverless":
optional: true
"@op-engineering/op-sqlite":
optional: true
"@opentelemetry/api":
optional: true
"@planetscale/database":
optional: true
"@prisma/client":
optional: true
"@tidbcloud/serverless":
optional: true
"@types/better-sqlite3":
optional: true
"@types/pg":
optional: true
"@types/sql.js":
optional: true
"@upstash/redis":
optional: true
"@vercel/postgres":
optional: true
"@xata.io/client":
optional: true
better-sqlite3:
optional: true
bun-types:
optional: true
expo-sqlite:
optional: true
gel:
optional: true
knex:
optional: true
kysely:
optional: true
mysql2:
optional: true
pg:
optional: true
postgres:
optional: true
prisma:
optional: true
sql.js:
optional: true
sqlite3:
optional: true
checksum: 10c0/deb4a0230a79b1ff8c61365808bdb06a78e9e32171625f1c61d791dec445fad5cd9bc4ce2e6175b0ff1be7fb913310b2fd07b94a2cbe8027581b7d99469b8060
languageName: node
linkType: hard
"dunder-proto@npm:^1.0.1":
version: 1.0.1
resolution: "dunder-proto@npm:1.0.1"
dependencies:
call-bind-apply-helpers: "npm:^1.0.1"
es-errors: "npm:^1.3.0"
gopd: "npm:^1.2.0"
checksum: 10c0/199f2a0c1c16593ca0a145dbf76a962f8033ce3129f01284d48c45ed4e14fea9bbacd7b3610b6cdc33486cef20385ac054948fefc6272fcce645c09468f93031
languageName: node
linkType: hard
"embla-carousel-react@npm:^8.5.2": "embla-carousel-react@npm:^8.5.2":
version: 8.6.0 version: 8.6.0
resolution: "embla-carousel-react@npm:8.6.0" resolution: "embla-carousel-react@npm:8.6.0"
@@ -3102,41 +2880,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"es-define-property@npm:^1.0.1":
version: 1.0.1
resolution: "es-define-property@npm:1.0.1"
checksum: 10c0/3f54eb49c16c18707949ff25a1456728c883e81259f045003499efba399c08bad00deebf65cccde8c0e07908c1a225c9d472b7107e558f2a48e28d530e34527c
languageName: node
linkType: hard
"es-errors@npm:^1.3.0":
version: 1.3.0
resolution: "es-errors@npm:1.3.0"
checksum: 10c0/0a61325670072f98d8ae3b914edab3559b6caa980f08054a3b872052640d91da01d38df55df797fcc916389d77fc92b8d5906cf028f4db46d7e3003abecbca85
languageName: node
linkType: hard
"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1":
version: 1.1.2
resolution: "es-object-atoms@npm:1.1.2"
dependencies:
es-errors: "npm:^1.3.0"
checksum: 10c0/1772861f094f739d6f41b579cfb9a18579daffeb434552a370a5fbef50a32d22227e27b63fdbb757b7ddd429d1b42fe52ccae7966d9302a2ec221b6f1b41bbc4
languageName: node
linkType: hard
"es-set-tostringtag@npm:^2.1.0":
version: 2.1.0
resolution: "es-set-tostringtag@npm:2.1.0"
dependencies:
es-errors: "npm:^1.3.0"
get-intrinsic: "npm:^1.2.6"
has-tostringtag: "npm:^1.0.2"
hasown: "npm:^2.0.2"
checksum: 10c0/ef2ca9ce49afe3931cb32e35da4dcb6d86ab02592cfc2ce3e49ced199d9d0bb5085fc7e73e06312213765f5efa47cc1df553a6a5154584b21448e9fb8355b1af
languageName: node
linkType: hard
"es-toolkit@npm:^1.39.3": "es-toolkit@npm:^1.39.3":
version: 1.52.0 version: 1.52.0
resolution: "es-toolkit@npm:1.52.0" resolution: "es-toolkit@npm:1.52.0"
@@ -3179,19 +2922,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"exa-js@npm:^2.4.0":
version: 2.19.0
resolution: "exa-js@npm:2.19.0"
dependencies:
cross-fetch: "npm:~4.1.0"
dotenv: "npm:~16.4.7"
openai: "npm:^5.0.1"
zod: "npm:^3.22.0"
zod-to-json-schema: "npm:^3.20.0"
checksum: 10c0/baaf37a4a1d3fffbe162b5dcff3df9f09d8a3597cf4016e43d10fcc4bb90e810ab22bda92a3144fa235fefeb2ad83bbd5746a89487a9d755ac6205ebd6a1d7f4
languageName: node
linkType: hard
"extend@npm:^3.0.0": "extend@npm:^3.0.0":
version: 3.0.2 version: 3.0.2
resolution: "extend@npm:3.0.2" resolution: "extend@npm:3.0.2"
@@ -3199,98 +2929,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"firecrawl@npm:4.30.1":
version: 4.30.1
resolution: "firecrawl@npm:4.30.1"
dependencies:
axios: "npm:1.18.0"
typescript-event-target: "npm:^1.1.1"
zod: "npm:^3.23.8"
zod-to-json-schema: "npm:^3.23.0"
checksum: 10c0/52dfc34cfb4363e7217ff57171308069689ef2de3d894d191d221964b05cfbb1410b2d1bd407f9875f865e9471a6580085b956d02a4cc38a28bdce79244eaeb4
languageName: node
linkType: hard
"follow-redirects@npm:^1.16.0":
version: 1.16.0
resolution: "follow-redirects@npm:1.16.0"
peerDependenciesMeta:
debug:
optional: true
checksum: 10c0/a1e2900163e6f1b4d1ed5c221b607f41decbab65534c63fe7e287e40a5d552a6496e7d9d7d976fa4ba77b4c51c11e5e9f683f10b43011ea11e442ff128d0e181
languageName: node
linkType: hard
"form-data@npm:^4.0.5":
version: 4.0.6
resolution: "form-data@npm:4.0.6"
dependencies:
asynckit: "npm:^0.4.0"
combined-stream: "npm:^1.0.8"
es-set-tostringtag: "npm:^2.1.0"
hasown: "npm:^2.0.4"
mime-types: "npm:^2.1.35"
checksum: 10c0/43947a77bf0ff45c6ceed789778982d47a3f3e720a74b71721174ebf3310a5f1a8be1d6b38a3ee3688e8a18a2c4273073ec0844cd37efda3eaf46d41c9c318ff
languageName: node
linkType: hard
"framer-motion@npm:^12.0.0, framer-motion@npm:^12.43.0":
version: 12.43.0
resolution: "framer-motion@npm:12.43.0"
dependencies:
motion-dom: "npm:^12.43.0"
motion-utils: "npm:^12.39.0"
tslib: "npm:^2.4.0"
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
checksum: 10c0/cce3c1daf8f53da4d9299e6bdd2a1e9f08a30e13d84ce1e468cf25285f9ec2a900c812e0aa3dce79e7453a2a3cf328a717451beef9bf0a6175a3d3794116e36a
languageName: node
linkType: hard
"function-bind@npm:^1.1.2":
version: 1.1.2
resolution: "function-bind@npm:1.1.2"
checksum: 10c0/d8680ee1e5fcd4c197e4ac33b2b4dce03c71f4d91717292785703db200f5c21f977c568d28061226f9b5900cbcd2c84463646134fd5337e7925e0942bc3f46d5
languageName: node
linkType: hard
"generator-function@npm:^2.0.0":
version: 2.0.1
resolution: "generator-function@npm:2.0.1"
checksum: 10c0/8a9f59df0f01cfefafdb3b451b80555e5cf6d76487095db91ac461a0e682e4ff7a9dbce15f4ecec191e53586d59eece01949e05a4b4492879600bbbe8e28d6b8
languageName: node
linkType: hard
"get-intrinsic@npm:^1.2.6":
version: 1.3.1
resolution: "get-intrinsic@npm:1.3.1"
dependencies:
async-function: "npm:^1.0.0"
async-generator-function: "npm:^1.0.0"
call-bind-apply-helpers: "npm:^1.0.2"
es-define-property: "npm:^1.0.1"
es-errors: "npm:^1.3.0"
es-object-atoms: "npm:^1.1.1"
function-bind: "npm:^1.1.2"
generator-function: "npm:^2.0.0"
get-proto: "npm:^1.0.1"
gopd: "npm:^1.2.0"
has-symbols: "npm:^1.1.0"
hasown: "npm:^2.0.2"
math-intrinsics: "npm:^1.1.0"
checksum: 10c0/9f4ab0cf7efe0fd2c8185f52e6f637e708f3a112610c88869f8f041bb9ecc2ce44bf285dfdbdc6f4f7c277a5b88d8e94a432374d97cca22f3de7fc63795deb5d
languageName: node
linkType: hard
"get-nonce@npm:^1.0.0": "get-nonce@npm:^1.0.0":
version: 1.0.1 version: 1.0.1
resolution: "get-nonce@npm:1.0.1" resolution: "get-nonce@npm:1.0.1"
@@ -3298,30 +2936,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"get-proto@npm:^1.0.1":
version: 1.0.1
resolution: "get-proto@npm:1.0.1"
dependencies:
dunder-proto: "npm:^1.0.1"
es-object-atoms: "npm:^1.0.0"
checksum: 10c0/9224acb44603c5526955e83510b9da41baf6ae73f7398875fba50edc5e944223a89c4a72b070fcd78beb5f7bdda58ecb6294adc28f7acfc0da05f76a2399643c
languageName: node
linkType: hard
"gopd@npm:^1.2.0":
version: 1.2.0
resolution: "gopd@npm:1.2.0"
checksum: 10c0/50fff1e04ba2b7737c097358534eacadad1e68d24cccee3272e04e007bed008e68d2614f3987788428fd192a5ae3889d08fb2331417e4fc4a9ab366b2043cead
languageName: node
linkType: hard
"gql-tag@npm:^1.0.1":
version: 1.0.1
resolution: "gql-tag@npm:1.0.1"
checksum: 10c0/6acf28755f0d2143d1f76d75955b48369386c38ae0928f2b6cc8e1ea1ff5d0e58adc718e9a4ce0831f91a7761ece6c1a46f5817b2af0dbd4289960c0a4073eeb
languageName: node
linkType: hard
"graceful-fs@npm:^4.2.4": "graceful-fs@npm:^4.2.4":
version: 4.2.11 version: 4.2.11
resolution: "graceful-fs@npm:4.2.11" resolution: "graceful-fs@npm:4.2.11"
@@ -3329,31 +2943,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0":
version: 1.1.0
resolution: "has-symbols@npm:1.1.0"
checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e
languageName: node
linkType: hard
"has-tostringtag@npm:^1.0.2":
version: 1.0.2
resolution: "has-tostringtag@npm:1.0.2"
dependencies:
has-symbols: "npm:^1.0.3"
checksum: 10c0/a8b166462192bafe3d9b6e420a1d581d93dd867adb61be223a17a8d6dad147aa77a8be32c961bb2f27b3ef893cae8d36f564ab651f5e9b7938ae86f74027c48c
languageName: node
linkType: hard
"hasown@npm:^2.0.2, hasown@npm:^2.0.4":
version: 2.0.4
resolution: "hasown@npm:2.0.4"
dependencies:
function-bind: "npm:^1.1.2"
checksum: 10c0/2d8de939e270b70618f8cebb69746620db10617dbb495bc66ddad326955ea24d3ca4af133aff3eb7c1853e0218f867bc2b050ec26fe02e3aea58f880ffc5e506
languageName: node
linkType: hard
"hast-util-from-parse5@npm:^8.0.0": "hast-util-from-parse5@npm:^8.0.0":
version: 8.0.3 version: 8.0.3
resolution: "hast-util-from-parse5@npm:8.0.3" resolution: "hast-util-from-parse5@npm:8.0.3"
@@ -3430,7 +3019,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"hast-util-to-jsx-runtime@npm:^2.0.0, hast-util-to-jsx-runtime@npm:^2.3.6": "hast-util-to-jsx-runtime@npm:^2.3.6":
version: 2.3.6 version: 2.3.6
resolution: "hast-util-to-jsx-runtime@npm:2.3.6" resolution: "hast-util-to-jsx-runtime@npm:2.3.6"
dependencies: dependencies:
@@ -3490,7 +3079,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"html-url-attributes@npm:^3.0.0, html-url-attributes@npm:^3.0.1": "html-url-attributes@npm:^3.0.1":
version: 3.0.1 version: 3.0.1
resolution: "html-url-attributes@npm:3.0.1" resolution: "html-url-attributes@npm:3.0.1"
checksum: 10c0/496e4908aa8b77665f348b4b03521901794f648b8ac34a581022cd6f2c97934d5c910cd91bc6593bbf2994687549037bc2520fcdc769b31484f29ffdd402acd0 checksum: 10c0/496e4908aa8b77665f348b4b03521901794f648b8ac34a581022cd6f2c97934d5c910cd91bc6593bbf2994687549037bc2520fcdc769b31484f29ffdd402acd0
@@ -3504,16 +3093,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"https-proxy-agent@npm:^5.0.1":
version: 5.0.1
resolution: "https-proxy-agent@npm:5.0.1"
dependencies:
agent-base: "npm:6"
debug: "npm:4"
checksum: 10c0/6dd639f03434003577c62b27cafdb864784ef19b2de430d8ae2a1d45e31c4fd60719e5637b44db1a88a046934307da7089e03d6089ec3ddacc1189d8de8897d1
languageName: node
linkType: hard
"immer@npm:^11.0.0, immer@npm:^11.1.8": "immer@npm:^11.0.0, immer@npm:^11.1.8":
version: 11.1.18 version: 11.1.18
resolution: "immer@npm:11.1.18" resolution: "immer@npm:11.1.18"
@@ -3733,15 +3312,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"lucide-react@npm:^0.574.0":
version: 0.574.0
resolution: "lucide-react@npm:0.574.0"
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
checksum: 10c0/bfb9ed34520657faae130a00247dd35516ab77bf00eb1b2c6995bcbb6919de8edde3546f29c85b5cc6bd1c0f5dd7ea9147c7777f0d7e75208aee19997eb0f752
languageName: node
linkType: hard
"magic-string@npm:^0.30.21": "magic-string@npm:^0.30.21":
version: 0.30.21 version: 0.30.21
resolution: "magic-string@npm:0.30.21" resolution: "magic-string@npm:0.30.21"
@@ -3767,13 +3337,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"math-intrinsics@npm:^1.1.0":
version: 1.1.0
resolution: "math-intrinsics@npm:1.1.0"
checksum: 10c0/7579ff94e899e2f76ab64491d76cf606274c874d8f2af4a442c016bd85688927fcfca157ba6bf74b08e9439dc010b248ce05b96cc7c126a354c3bae7fcb48b7f
languageName: node
linkType: hard
"mdast-util-find-and-replace@npm:^3.0.0": "mdast-util-find-and-replace@npm:^3.0.0":
version: 3.0.2 version: 3.0.2
resolution: "mdast-util-find-and-replace@npm:3.0.2" resolution: "mdast-util-find-and-replace@npm:3.0.2"
@@ -4313,59 +3876,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"mime-db@npm:1.52.0":
version: 1.52.0
resolution: "mime-db@npm:1.52.0"
checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa
languageName: node
linkType: hard
"mime-types@npm:^2.1.35":
version: 2.1.35
resolution: "mime-types@npm:2.1.35"
dependencies:
mime-db: "npm:1.52.0"
checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2
languageName: node
linkType: hard
"motion-dom@npm:^12.43.0":
version: 12.43.0
resolution: "motion-dom@npm:12.43.0"
dependencies:
motion-utils: "npm:^12.39.0"
checksum: 10c0/ef7d3f6eaa6cfeb1a939847a8b2581d119f2efc054948921ece0e2b1091f0969c15ca7707b87a46dddac7f156a714878f168e7b9f6ea189eeb7d5d9fbbb99d26
languageName: node
linkType: hard
"motion-utils@npm:^12.39.0":
version: 12.39.0
resolution: "motion-utils@npm:12.39.0"
checksum: 10c0/6d7a2a2cc0797b72410a666a9cc1c201c8e39bf9669670464e433fe1e72af5f0217154c869867b34fbadf3664cf222c0d022bbc4eed7927f201ae971918e7440
languageName: node
linkType: hard
"motion@npm:^12.33.0":
version: 12.43.0
resolution: "motion@npm:12.43.0"
dependencies:
framer-motion: "npm:^12.43.0"
tslib: "npm:^2.4.0"
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
checksum: 10c0/15544ecc71d356661a959c78c4f84b3ca91728f5d17265b68912d2bbcf86f20409ebf5acf2f6c82e0c0425d1024267b04abd5b786177a3694bf706f7a3a529d6
languageName: node
linkType: hard
"ms@npm:^2.1.3": "ms@npm:^2.1.3":
version: 2.1.3 version: 2.1.3
resolution: "ms@npm:2.1.3" resolution: "ms@npm:2.1.3"
@@ -4461,15 +3971,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"nextjs-app@workspace:.": "nextjs-chatgpt-template@workspace:.":
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "nextjs-app@workspace:." resolution: "nextjs-chatgpt-template@workspace:."
dependencies: dependencies:
"@ai-sdk/openai": "npm:^4.0.9"
"@ai-sdk/react": "npm:^4.0.19" "@ai-sdk/react": "npm:^4.0.19"
"@base-ui/react": "npm:^1.8.0" "@base-ui/react": "npm:^1.8.0"
"@mendable/firecrawl-js": "npm:^4.12.1"
"@neondatabase/serverless": "npm:^1.0.2"
"@next/swc-wasm-web": "npm:16.2.10" "@next/swc-wasm-web": "npm:16.2.10"
"@openrouter/ai-sdk-provider": "npm:^3.0.0" "@openrouter/ai-sdk-provider": "npm:^3.0.0"
"@radix-ui/react-accordion": "npm:^1.2.3" "@radix-ui/react-accordion": "npm:^1.2.3"
@@ -4499,6 +4006,7 @@ __metadata:
"@radix-ui/react-toggle": "npm:^1.1.2" "@radix-ui/react-toggle": "npm:^1.1.2"
"@radix-ui/react-toggle-group": "npm:^1.1.2" "@radix-ui/react-toggle-group": "npm:^1.1.2"
"@radix-ui/react-tooltip": "npm:^1.1.8" "@radix-ui/react-tooltip": "npm:^1.1.8"
"@radix-ui/react-use-controllable-state": "npm:^1.2.6"
"@remixicon/react": "npm:^4.9.0" "@remixicon/react": "npm:^4.9.0"
"@tailwindcss/postcss": "npm:^4" "@tailwindcss/postcss": "npm:^4"
"@types/node": "npm:^22" "@types/node": "npm:^22"
@@ -4511,25 +4019,18 @@ __metadata:
clsx: "npm:^2.1.1" clsx: "npm:^2.1.1"
cmdk: "npm:^1.0.4" cmdk: "npm:^1.0.4"
date-fns: "npm:^4.1.0" date-fns: "npm:^4.1.0"
drizzle-orm: "npm:^0.45.1"
embla-carousel-react: "npm:^8.5.2" embla-carousel-react: "npm:^8.5.2"
exa-js: "npm:^2.4.0"
framer-motion: "npm:^12.0.0"
gql-tag: "npm:^1.0.1"
input-otp: "npm:^1.4.2" input-otp: "npm:^1.4.2"
lucide-react: "npm:^0.574.0" jiti: "npm:^2.7.0"
motion: "npm:^12.33.0"
nanoid: "npm:^5.1.6" nanoid: "npm:^5.1.6"
next: "npm:16.2.10" next: "npm:16.2.10"
next-themes: "npm:^0.4.6" next-themes: "npm:^0.4.6"
openai: "npm:^6.17.0"
radix-ui: "npm:^1.4.3" radix-ui: "npm:^1.4.3"
react: "npm:^19.0.0" react: "npm:^19.0.0"
react-day-picker: "npm:^9.5.0" react-day-picker: "npm:^9.5.0"
react-dom: "npm:^19.0.0" react-dom: "npm:^19.0.0"
react-hook-form: "npm:^7.88.0" react-hook-form: "npm:^7.88.0"
react-is: "npm:^19.3.0" react-is: "npm:^19.3.0"
react-markdown: "npm:^10.1.0"
react-resizable-panels: "npm:^4.5.8" react-resizable-panels: "npm:^4.5.8"
recharts: "npm:^3.7.0" recharts: "npm:^3.7.0"
shiki: "npm:^3.22.0" shiki: "npm:^3.22.0"
@@ -4546,20 +4047,6 @@ __metadata:
languageName: unknown languageName: unknown
linkType: soft linkType: soft
"node-fetch@npm:^2.7.0":
version: 2.7.0
resolution: "node-fetch@npm:2.7.0"
dependencies:
whatwg-url: "npm:^5.0.0"
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
checksum: 10c0/b55786b6028208e6fbe594ccccc213cab67a72899c9234eb59dba51062a299ea853210fcf526998eaa2867b0963ad72338824450905679ff0fa304b8c5093ae8
languageName: node
linkType: hard
"oniguruma-parser@npm:^0.12.2": "oniguruma-parser@npm:^0.12.2":
version: 0.12.2 version: 0.12.2
resolution: "oniguruma-parser@npm:0.12.2" resolution: "oniguruma-parser@npm:0.12.2"
@@ -4578,47 +4065,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"openai@npm:^5.0.1":
version: 5.23.2
resolution: "openai@npm:5.23.2"
peerDependencies:
ws: ^8.18.0
zod: ^3.23.8
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
bin:
openai: bin/cli
checksum: 10c0/8ee37f37c64fd04a61622c4782d231e8f0245e2d85956d833e43d30946edbf07d5276cd74e09aa5d23006de8da43dad312246a9dd7ca6ca6d65d983fc976ffa6
languageName: node
linkType: hard
"openai@npm:^6.17.0":
version: 6.49.0
resolution: "openai@npm:6.49.0"
peerDependencies:
"@aws-sdk/credential-provider-node": ">=3.972.0 <4"
"@smithy/hash-node": ">=4.3.0 <5"
"@smithy/signature-v4": ">=5.4.0 <6"
ws: ^8.18.0
zod: ^3.25 || ^4.0
peerDependenciesMeta:
"@aws-sdk/credential-provider-node":
optional: true
"@smithy/hash-node":
optional: true
"@smithy/signature-v4":
optional: true
ws:
optional: true
zod:
optional: true
checksum: 10c0/c9e748e8a813f948ed925949e857d758505d246d7ec7e939f79cc2ab3383083ccdefff953479afb9dcb0f634cfddb0d442fcf02b6203cc9d6b7c95d3ddbc20ef
languageName: node
linkType: hard
"parse-entities@npm:^4.0.0": "parse-entities@npm:^4.0.0":
version: 4.0.2 version: 4.0.2
resolution: "parse-entities@npm:4.0.2" resolution: "parse-entities@npm:4.0.2"
@@ -4693,13 +4139,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"proxy-from-env@npm:^2.1.0":
version: 2.1.0
resolution: "proxy-from-env@npm:2.1.0"
checksum: 10c0/ed01729fd4d094eab619cd7e17ce3698b3413b31eb102c4904f9875e677cd207392795d5b4adee9cec359dfd31c44d5ad7595a3a3ad51c40250e141512281c58
languageName: node
linkType: hard
"radix-ui@npm:^1.4.3": "radix-ui@npm:^1.4.3":
version: 1.6.7 version: 1.6.7
resolution: "radix-ui@npm:1.6.7" resolution: "radix-ui@npm:1.6.7"
@@ -4814,28 +4253,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"react-markdown@npm:^10.1.0":
version: 10.1.0
resolution: "react-markdown@npm:10.1.0"
dependencies:
"@types/hast": "npm:^3.0.0"
"@types/mdast": "npm:^4.0.0"
devlop: "npm:^1.0.0"
hast-util-to-jsx-runtime: "npm:^2.0.0"
html-url-attributes: "npm:^3.0.0"
mdast-util-to-hast: "npm:^13.0.0"
remark-parse: "npm:^11.0.0"
remark-rehype: "npm:^11.0.0"
unified: "npm:^11.0.0"
unist-util-visit: "npm:^5.0.0"
vfile: "npm:^6.0.0"
peerDependencies:
"@types/react": ">=18"
react: ">=18"
checksum: 10c0/4a5dc7d15ca6d05e9ee95318c1904f83b111a76f7588c44f50f1d54d4c97193b84e4f64c4b592057c989228238a2590306cedd0c4d398e75da49262b2b5ae1bf
languageName: node
linkType: hard
"react-redux@npm:8.x.x || 9.x.x": "react-redux@npm:8.x.x || 9.x.x":
version: 9.3.0 version: 9.3.0
resolution: "react-redux@npm:9.3.0" resolution: "react-redux@npm:9.3.0"
@@ -5043,7 +4460,7 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"remark-rehype@npm:^11.0.0, remark-rehype@npm:^11.1.2": "remark-rehype@npm:^11.1.2":
version: 11.1.2 version: 11.1.2
resolution: "remark-rehype@npm:11.1.2" resolution: "remark-rehype@npm:11.1.2"
dependencies: dependencies:
@@ -5377,13 +4794,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tr46@npm:~0.0.3":
version: 0.0.3
resolution: "tr46@npm:0.0.3"
checksum: 10c0/047cb209a6b60c742f05c9d3ace8fa510bff609995c129a37ace03476a9b12db4dbf975e74600830ef0796e18882b2381fb5fb1f6b4f96b832c374de3ab91a11
languageName: node
linkType: hard
"trim-lines@npm:^3.0.0": "trim-lines@npm:^3.0.0":
version: 3.0.1 version: 3.0.1
resolution: "trim-lines@npm:3.0.1" resolution: "trim-lines@npm:3.0.1"
@@ -5412,13 +4822,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"typescript-event-target@npm:^1.1.1":
version: 1.1.2
resolution: "typescript-event-target@npm:1.1.2"
checksum: 10c0/8aebaf92c8a288242dd977c99061a75a37f65a6863298ba8d58ee77a62d5bb67ee301125563f52cd3a3be408055f205bf5faa6a273c6d79d167bdc60752cb2aa
languageName: node
linkType: hard
"typescript@npm:^5.7.3": "typescript@npm:^5.7.3":
version: 5.9.3 version: 5.9.3
resolution: "typescript@npm:5.9.3" resolution: "typescript@npm:5.9.3"
@@ -5636,23 +5039,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"webidl-conversions@npm:^3.0.0":
version: 3.0.1
resolution: "webidl-conversions@npm:3.0.1"
checksum: 10c0/5612d5f3e54760a797052eb4927f0ddc01383550f542ccd33d5238cfd65aeed392a45ad38364970d0a0f4fea32e1f4d231b3d8dac4a3bdd385e5cf802ae097db
languageName: node
linkType: hard
"whatwg-url@npm:^5.0.0":
version: 5.0.0
resolution: "whatwg-url@npm:5.0.0"
dependencies:
tr46: "npm:~0.0.3"
webidl-conversions: "npm:^3.0.0"
checksum: 10c0/1588bed84d10b72d5eec1d0faa0722ba1962f1821e7539c535558fb5398d223b0c50d8acab950b8c488b4ba69043fd833cc2697056b167d8ad46fac3995a55d5
languageName: node
linkType: hard
"which@npm:^2.0.1": "which@npm:^2.0.1":
version: 2.0.2 version: 2.0.2
resolution: "which@npm:2.0.2" resolution: "which@npm:2.0.2"
@@ -5664,22 +5050,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"zod-to-json-schema@npm:^3.20.0, zod-to-json-schema@npm:^3.23.0":
version: 3.25.2
resolution: "zod-to-json-schema@npm:3.25.2"
peerDependencies:
zod: ^3.25.28 || ^4
checksum: 10c0/dd300554393903022487688af14fbda5c719ba8179702bb55b3aa86318830467f0f7beb7d654036975ac963dc4843b72e59636448bfff9a0608f277bb6a14939
languageName: node
linkType: hard
"zod@npm:^3.22.0, zod@npm:^3.23.8":
version: 3.25.76
resolution: "zod@npm:3.25.76"
checksum: 10c0/5718ec35e3c40b600316c5b4c5e4976f7fee68151bc8f8d90ec18a469be9571f072e1bbaace10f1e85cf8892ea12d90821b200e980ab46916a6166a4260a983c
languageName: node
linkType: hard
"zod@npm:^4.3.5": "zod@npm:^4.3.5":
version: 4.6.4 version: 4.6.4
resolution: "zod@npm:4.6.4" resolution: "zod@npm:4.6.4"