Template
Improve chat navigation, model selection, and streaming
This commit is contained in:
@@ -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
@@ -1,8 +1,9 @@
|
||||
.DS_Store
|
||||
node_modules/*
|
||||
node_modules/
|
||||
.next
|
||||
next-env.d.ts
|
||||
.env.local
|
||||
.env*
|
||||
!.env.example
|
||||
ai/
|
||||
vercel-ai/
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
+23
-30
@@ -4,16 +4,20 @@ import {
|
||||
stepCountIs,
|
||||
pruneMessages,
|
||||
} from 'ai';
|
||||
import { createOpenRouter } from '@openrouter/ai-sdk-provider';
|
||||
import { tools } from '@/chat.config';
|
||||
import { getChatModel } from '@/services/chat/model';
|
||||
import { getChatInstructions } from '@/services/chat/instructions';
|
||||
import { chatTools } from '@/services/chat/tools';
|
||||
import { isOpenRouterModelId } from '@/lib/openrouter';
|
||||
|
||||
const openrouter = createOpenRouter({
|
||||
apiKey: process.env.OPENROUTER_API_KEY,
|
||||
});
|
||||
export const maxDuration = 300;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
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) {
|
||||
return Response.json(
|
||||
@@ -22,30 +26,11 @@ export async function POST(request: Request) {
|
||||
);
|
||||
}
|
||||
|
||||
const model = openrouter(
|
||||
process.env.OPENROUTER_MODEL || 'openai/gpt-5.6-luna-pro'
|
||||
);
|
||||
const model = getChatModel(modelId);
|
||||
|
||||
const webSearchEnabled = Boolean(process.env.FIRECRAWL_API_KEY);
|
||||
|
||||
const instructions = 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 instructions = getChatInstructions(webSearchEnabled);
|
||||
|
||||
const convertedMessages = await convertToModelMessages(messages);
|
||||
|
||||
@@ -55,12 +40,16 @@ Guidelines:
|
||||
toolCalls: 'none',
|
||||
});
|
||||
|
||||
const result = await streamText({
|
||||
const result = streamText({
|
||||
model,
|
||||
instructions,
|
||||
messages: prunedMessages,
|
||||
tools: webSearchEnabled ? tools : {},
|
||||
abortSignal: request.signal,
|
||||
tools: webSearchEnabled ? chatTools : {},
|
||||
stopWhen: stepCountIs(20),
|
||||
onError: ({ error }) => {
|
||||
if (!request.signal.aborted) console.error('Chat stream error:', error);
|
||||
},
|
||||
providerOptions: {
|
||||
openrouter: {
|
||||
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) {
|
||||
console.error('Chat API error:', error);
|
||||
return new Response(
|
||||
|
||||
@@ -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
@@ -3,9 +3,28 @@ import type { Metadata } from 'next';
|
||||
import { ThemeProvider } from '@/components/theme-provider';
|
||||
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 = {
|
||||
title: 'ChatGPT Agent',
|
||||
description: 'A ChatGPT-powered assistant for clear, useful answers.',
|
||||
applicationName: 'ChatGPT Agent',
|
||||
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({
|
||||
|
||||
+7
-1
@@ -1,5 +1,11 @@
|
||||
import { ChatAgent } from "@/components/chat/ChatAgent";
|
||||
import { getDefaultChatModelId } from "@/services/chat/model";
|
||||
|
||||
export default function ChatPage() {
|
||||
return <ChatAgent webSearchEnabled={Boolean(process.env.FIRECRAWL_API_KEY)} />;
|
||||
return (
|
||||
<ChatAgent
|
||||
webSearchEnabled={Boolean(process.env.FIRECRAWL_API_KEY)}
|
||||
defaultModelId={getDefaultChatModelId()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
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";
|
||||
|
||||
export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
|
||||
@@ -57,7 +57,7 @@ export const ArtifactClose = ({
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <XIcon className="size-4" />}
|
||||
{children ?? <RiCloseLine className="size-4" />}
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
);
|
||||
@@ -92,7 +92,7 @@ export const ArtifactActions = ({
|
||||
export type ArtifactActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
icon?: LucideIcon;
|
||||
icon?: RemixiconComponentType;
|
||||
};
|
||||
|
||||
export const ArtifactAction = ({
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
BrainIcon,
|
||||
ChevronDownIcon,
|
||||
DotIcon,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
RiBrainLine,
|
||||
RiArrowDownSLine,
|
||||
RiCircleFill,
|
||||
type RemixiconComponentType,
|
||||
} from "@remixicon/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, memo, useContext, useMemo } from "react";
|
||||
|
||||
@@ -92,11 +92,11 @@ export const ChainOfThoughtHeader = memo(
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<BrainIcon className="size-4" />
|
||||
<RiBrainLine className="size-4" />
|
||||
<span className="flex-1 text-left">
|
||||
{children ?? "Chain of Thought"}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
@@ -109,7 +109,7 @@ export const ChainOfThoughtHeader = memo(
|
||||
);
|
||||
|
||||
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
|
||||
icon?: LucideIcon;
|
||||
icon?: RemixiconComponentType;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
status?: "complete" | "active" | "pending";
|
||||
@@ -118,7 +118,7 @@ export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
|
||||
export const ChainOfThoughtStep = memo(
|
||||
({
|
||||
className,
|
||||
icon: Icon = DotIcon,
|
||||
icon: Icon = RiCircleFill,
|
||||
label,
|
||||
description,
|
||||
status = "complete",
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BookmarkIcon, type LucideProps } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
import { RiBookmarkLine } from "@remixicon/react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type CheckpointProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
@@ -27,7 +27,9 @@ export const Checkpoint = ({
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CheckpointIconProps = LucideProps;
|
||||
export type CheckpointIconProps = Omit<ComponentProps<typeof RiBookmarkLine>, "children"> & {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const CheckpointIcon = ({
|
||||
className,
|
||||
@@ -35,7 +37,7 @@ export const CheckpointIcon = ({
|
||||
...props
|
||||
}: CheckpointIconProps) =>
|
||||
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> & {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import { RiCheckLine, RiFileCopyLine } from "@remixicon/react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
@@ -162,7 +162,7 @@ export const CodeBlockCopyButton = ({
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
const Icon = isCopied ? RiCheckLine : RiFileCopyLine;
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import { RiArrowDownLine } from "@remixicon/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
@@ -93,7 +93,7 @@ export const ConversationScrollButton = ({
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
<RiArrowDownLine className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "lucide-react";
|
||||
import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
@@ -206,7 +206,7 @@ export const InlineCitationCarouselPrev = ({
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4 text-muted-foreground" />
|
||||
<RiArrowLeftLine className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -233,7 +233,7 @@ export const InlineCitationCarouselNext = ({
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<ArrowRightIcon className="size-4 text-muted-foreground" />
|
||||
<RiArrowRightLine className="size-4 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,11 +14,11 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FileUIPart, UIMessage } from "ai";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
PaperclipIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
RiArrowLeftSLine,
|
||||
RiArrowRightSLine,
|
||||
RiAttachment2,
|
||||
RiCloseLine,
|
||||
} from "@remixicon/react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement, ReactNode } from "react";
|
||||
import {
|
||||
Children,
|
||||
@@ -267,7 +267,7 @@ export const MessageBranchPrevious = ({
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
{children ?? <RiArrowLeftSLine size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -291,7 +291,7 @@ export const MessageBranchNext = ({
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
{children ?? <RiArrowRightSLine size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -338,8 +338,7 @@ export const MessageResponse = memo(
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
||||
)
|
||||
);
|
||||
|
||||
MessageResponse.displayName = "MessageResponse";
|
||||
@@ -390,7 +389,7 @@ export function MessageAttachment({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<RiCloseLine />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
@@ -400,7 +399,7 @@ export function MessageAttachment({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<PaperclipIcon className="size-4" />
|
||||
<RiAttachment2 className="size-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -418,7 +417,7 @@ export function MessageAttachment({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<RiCloseLine />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -11,10 +11,10 @@ import {
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ExternalLinkIcon,
|
||||
MessageCircleIcon,
|
||||
} from "lucide-react";
|
||||
RiArrowDownSLine,
|
||||
RiExternalLinkLine,
|
||||
RiChat3Line,
|
||||
} from "@remixicon/react";
|
||||
import { type ComponentProps, createContext, useContext } from "react";
|
||||
|
||||
const providers = {
|
||||
@@ -140,7 +140,7 @@ const providers = {
|
||||
`https://t3.chat/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: <MessageCircleIcon />,
|
||||
icon: <RiChat3Line />,
|
||||
},
|
||||
v0: {
|
||||
title: "Open in v0",
|
||||
@@ -238,7 +238,7 @@ export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => (
|
||||
{children ?? (
|
||||
<Button type="button" variant="outline">
|
||||
Open in chat
|
||||
<ChevronDownIcon className="size-4" />
|
||||
<RiArrowDownSLine className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
@@ -258,7 +258,7 @@ export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
|
||||
>
|
||||
<span className="shrink-0">{providers.chatgpt.icon}</span>
|
||||
<span className="flex-1">{providers.chatgpt.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
<RiExternalLinkLine className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
@@ -278,7 +278,7 @@ export const OpenInClaude = (props: OpenInClaudeProps) => {
|
||||
>
|
||||
<span className="shrink-0">{providers.claude.icon}</span>
|
||||
<span className="flex-1">{providers.claude.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
<RiExternalLinkLine className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
@@ -298,7 +298,7 @@ export const OpenInT3 = (props: OpenInT3Props) => {
|
||||
>
|
||||
<span className="shrink-0">{providers.t3.icon}</span>
|
||||
<span className="flex-1">{providers.t3.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
<RiExternalLinkLine className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
@@ -318,7 +318,7 @@ export const OpenInScira = (props: OpenInSciraProps) => {
|
||||
>
|
||||
<span className="shrink-0">{providers.scira.icon}</span>
|
||||
<span className="flex-1">{providers.scira.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
<RiExternalLinkLine className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
@@ -338,7 +338,7 @@ export const OpenInv0 = (props: OpenInv0Props) => {
|
||||
>
|
||||
<span className="shrink-0">{providers.v0.icon}</span>
|
||||
<span className="flex-1">{providers.v0.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
<RiExternalLinkLine className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
@@ -358,7 +358,7 @@ export const OpenInCursor = (props: OpenInCursorProps) => {
|
||||
>
|
||||
<span className="shrink-0">{providers.cursor.icon}</span>
|
||||
<span className="flex-1">{providers.cursor.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
<RiExternalLinkLine className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronsUpDownIcon } from "lucide-react";
|
||||
import { RiExpandUpDownLine } from "@remixicon/react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import { Shimmer } from "./shimmer";
|
||||
@@ -135,7 +135,7 @@ export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
<ChevronsUpDownIcon className="size-4" />
|
||||
<RiExpandUpDownLine className="size-4" />
|
||||
<span className="sr-only">Toggle plan</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
@@ -37,14 +37,14 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatStatus, FileUIPart } from "ai";
|
||||
import {
|
||||
CornerDownLeftIcon,
|
||||
ImageIcon,
|
||||
MicIcon,
|
||||
PaperclipIcon,
|
||||
PlusIcon,
|
||||
SquareIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
RiCornerDownLeftLine,
|
||||
RiImageLine,
|
||||
RiMicLine,
|
||||
RiAttachment2,
|
||||
RiAddLine,
|
||||
RiStopFill,
|
||||
RiCloseLine,
|
||||
} from "@remixicon/react";
|
||||
import { Loader } from "@/components/ai-elements/loader";
|
||||
import { nanoid } from "nanoid";
|
||||
import {
|
||||
@@ -319,7 +319,7 @@ export function PromptInputAttachment({
|
||||
/>
|
||||
) : (
|
||||
<div className="flex size-5 items-center justify-center text-muted-foreground">
|
||||
<PaperclipIcon className="size-3" />
|
||||
<RiAttachment2 className="size-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -333,7 +333,7 @@ export function PromptInputAttachment({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<RiCloseLine />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -421,7 +421,7 @@ export const PromptInputActionAddAttachments = ({
|
||||
attachments.openFileDialog();
|
||||
}}
|
||||
>
|
||||
<ImageIcon className="mr-2 size-4" /> {label}
|
||||
<RiImageLine className="mr-2 size-4" /> {label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
@@ -989,7 +989,7 @@ export const PromptInputActionMenuTrigger = ({
|
||||
}: PromptInputActionMenuTriggerProps) => (
|
||||
<DropdownMenuTrigger asChild>
|
||||
<PromptInputButton className={className} {...props}>
|
||||
{children ?? <PlusIcon className="size-4" />}
|
||||
{children ?? <RiAddLine className="size-4" />}
|
||||
</PromptInputButton>
|
||||
</DropdownMenuTrigger>
|
||||
);
|
||||
@@ -1029,14 +1029,14 @@ export const PromptInputSubmit = ({
|
||||
children,
|
||||
...props
|
||||
}: PromptInputSubmitProps) => {
|
||||
let Icon = <CornerDownLeftIcon className="size-4" />;
|
||||
let Icon = <RiCornerDownLeftLine className="size-4" />;
|
||||
|
||||
if (status === "submitted") {
|
||||
Icon = <Loader size={16} />;
|
||||
} else if (status === "streaming") {
|
||||
Icon = <SquareIcon className="size-4" />;
|
||||
Icon = <RiStopFill className="size-4" />;
|
||||
} else if (status === "error") {
|
||||
Icon = <XIcon className="size-4" />;
|
||||
Icon = <RiCloseLine className="size-4" />;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -1208,7 +1208,7 @@ export const PromptInputSpeechButton = ({
|
||||
onClick={toggleListening}
|
||||
{...props}
|
||||
>
|
||||
<MicIcon className="size-4" />
|
||||
<RiMicLine className="size-4" />
|
||||
</PromptInputButton>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, PaperclipIcon } from "lucide-react";
|
||||
import { RiArrowDownSLine, RiAttachment2 } from "@remixicon/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type QueueMessagePart = {
|
||||
@@ -174,7 +174,7 @@ export const QueueItemFile = ({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<PaperclipIcon size={12} />
|
||||
<RiAttachment2 size={12} />
|
||||
<span className="max-w-[100px] truncate">{children}</span>
|
||||
</span>
|
||||
);
|
||||
@@ -241,7 +241,7 @@ export const QueueSectionLabel = ({
|
||||
...props
|
||||
}: QueueSectionLabelProps) => (
|
||||
<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}
|
||||
<span>
|
||||
{count} {label}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import { RiBrainLine, RiArrowDownSLine } from "@remixicon/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
@@ -139,9 +139,9 @@ export const ReasoningTrigger = memo(
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
<RiBrainLine className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, Code } from "lucide-react";
|
||||
import { RiArrowDownSLine, RiCodeLine } from "@remixicon/react";
|
||||
|
||||
import { getStatusBadge } from "./tool";
|
||||
|
||||
@@ -52,11 +52,11 @@ export const SandboxHeader = ({
|
||||
{...props}
|
||||
>
|
||||
<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>
|
||||
{getStatusBadge(state)}
|
||||
</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>
|
||||
);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BookIcon, ChevronDownIcon } from "lucide-react";
|
||||
import { RiBookOpenLine, RiArrowDownSLine } from "@remixicon/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type SourcesProps = ComponentProps<"div">;
|
||||
@@ -35,7 +35,7 @@ export const SourcesTrigger = ({
|
||||
{children ?? (
|
||||
<>
|
||||
<p className="font-medium">Used {count} sources</p>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
<RiArrowDownSLine className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
@@ -69,7 +69,7 @@ export const Source = ({ href, title, children, ...props }: SourceProps) => (
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BookIcon className="h-4 w-4" />
|
||||
<RiBookOpenLine className="h-4 w-4" />
|
||||
<span className="block font-medium">{title}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, SearchIcon } from "lucide-react";
|
||||
import { RiArrowDownSLine, RiSearchLine } from "@remixicon/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
|
||||
export type TaskItemFileProps = ComponentProps<"div">;
|
||||
@@ -60,9 +60,9 @@ export const TaskTrigger = ({
|
||||
<CollapsibleTrigger asChild className={cn("group", className)} {...props}>
|
||||
{children ?? (
|
||||
<div className="flex w-full cursor-pointer items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
|
||||
{icon ?? <SearchIcon className="size-4" />}
|
||||
{icon ?? <RiSearchLine className="size-4" />}
|
||||
<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>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ToolUIPart } from "ai";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
CircleIcon,
|
||||
ClockIcon,
|
||||
WrenchIcon,
|
||||
XCircleIcon,
|
||||
} from "lucide-react";
|
||||
RiCheckboxCircleLine,
|
||||
RiArrowDownSLine,
|
||||
RiCircleLine,
|
||||
RiTimeLine,
|
||||
RiToolsLine,
|
||||
RiCloseCircleLine,
|
||||
} from "@remixicon/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { isValidElement } from "react";
|
||||
import { CodeBlock } from "./code-block";
|
||||
@@ -48,13 +48,13 @@ export const getStatusBadge = (status: ToolUIPart["state"]) => {
|
||||
};
|
||||
|
||||
const icons: Record<ToolUIPart["state"], ReactNode> = {
|
||||
"input-streaming": <CircleIcon className="size-4" />,
|
||||
"input-available": <ClockIcon className="size-4 animate-pulse" />,
|
||||
"approval-requested": <ClockIcon className="size-4 text-yellow-600" />,
|
||||
"approval-responded": <CheckCircleIcon className="size-4 text-blue-600" />,
|
||||
"output-available": <CheckCircleIcon className="size-4 text-green-600" />,
|
||||
"output-error": <XCircleIcon className="size-4 text-red-600" />,
|
||||
"output-denied": <XCircleIcon className="size-4 text-orange-600" />,
|
||||
"input-streaming": <RiCircleLine className="size-4" />,
|
||||
"input-available": <RiTimeLine className="size-4 animate-pulse" />,
|
||||
"approval-requested": <RiTimeLine className="size-4 text-yellow-600" />,
|
||||
"approval-responded": <RiCheckboxCircleLine className="size-4 text-blue-600" />,
|
||||
"output-available": <RiCheckboxCircleLine className="size-4 text-green-600" />,
|
||||
"output-error": <RiCloseCircleLine className="size-4 text-red-600" />,
|
||||
"output-denied": <RiCloseCircleLine className="size-4 text-orange-600" />,
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -80,13 +80,13 @@ export const ToolHeader = ({
|
||||
{...props}
|
||||
>
|
||||
<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">
|
||||
{title ?? type.split("-").slice(1).join("-")}
|
||||
</span>
|
||||
{getStatusBadge(state)}
|
||||
</div>
|
||||
<ChevronDownIcon className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
<RiArrowDownSLine className="size-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import { RiArrowDownSLine } from "@remixicon/react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
@@ -220,7 +220,7 @@ export const WebPreviewConsole = ({
|
||||
variant="ghost"
|
||||
>
|
||||
Console
|
||||
<ChevronDownIcon
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
consoleOpen && "rotate-180"
|
||||
|
||||
+37
-230
@@ -1,20 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import type { ChatStatus } from 'ai';
|
||||
import { ChatInput, type PromptInputMessage } from './ChatInput';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { ChatMessages } from './ChatMessages';
|
||||
import { ChatPrompt, PromptSuggestion } from './ChatPrompt';
|
||||
import { ChatSidebar, type Chat } from './ChatSidebar';
|
||||
import { useChat, UIMessage } from '@ai-sdk/react';
|
||||
import { DefaultChatTransport } from 'ai';
|
||||
import {
|
||||
createChat,
|
||||
deleteChat,
|
||||
getMessages,
|
||||
listChats,
|
||||
saveMessages,
|
||||
} from '@/services/indexed-db/chat-store';
|
||||
import { ChatPrompt } from './ChatPrompt';
|
||||
import { ChatSidebar } from './ChatSidebar';
|
||||
import { useChatSession } from '@/hooks/use-chat-session';
|
||||
import type { PromptSuggestion } from '@/config/suggestions';
|
||||
import { DEFAULT_OPENROUTER_MODEL_ID, type OpenRouterModelId } from '@/lib/openrouter';
|
||||
import {
|
||||
SidebarProvider,
|
||||
SidebarInset,
|
||||
@@ -24,228 +16,32 @@ import {
|
||||
interface ChatAgentProps {
|
||||
suggestions?: PromptSuggestion[];
|
||||
webSearchEnabled?: boolean;
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
defaultModelId?: OpenRouterModelId;
|
||||
}
|
||||
|
||||
export function ChatAgent({
|
||||
suggestions,
|
||||
webSearchEnabled = false,
|
||||
defaultModelId = DEFAULT_OPENROUTER_MODEL_ID,
|
||||
}: 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 {
|
||||
inputValue,
|
||||
setInputValue,
|
||||
modelId,
|
||||
setModelId,
|
||||
chatId,
|
||||
chats,
|
||||
chatMessages,
|
||||
status,
|
||||
messages: chatMessages,
|
||||
setMessages: setChatMessages,
|
||||
sendMessage,
|
||||
stop,
|
||||
error: aiError,
|
||||
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,
|
||||
isPreparingFiles,
|
||||
fetchChats,
|
||||
]
|
||||
);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
stop();
|
||||
}, [stop]);
|
||||
chatStatus,
|
||||
aiError,
|
||||
isPreparingFiles,
|
||||
handleSelectChat,
|
||||
handleNewChat,
|
||||
handleDeleteChat,
|
||||
handleSendMessage,
|
||||
handleStop,
|
||||
} = useChatSession(defaultModelId);
|
||||
|
||||
const hasMessages = chatMessages.length > 0;
|
||||
|
||||
@@ -268,6 +64,9 @@ export function ChatAgent({
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={handleSendMessage}
|
||||
modelId={modelId}
|
||||
onModelChange={setModelId}
|
||||
onStop={isPreparingFiles ? undefined : handleStop}
|
||||
status={chatStatus}
|
||||
suggestions={suggestions}
|
||||
webSearchEnabled={webSearchEnabled}
|
||||
@@ -281,15 +80,23 @@ export function ChatAgent({
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ChatMessages
|
||||
messages={chatMessages}
|
||||
isLoading={status === 'submitted' || status === 'streaming'}
|
||||
status={status}
|
||||
/>
|
||||
</div>
|
||||
<div className="sticky bottom-0 bg-muted p-4">
|
||||
<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
|
||||
value={inputValue}
|
||||
onChange={setInputValue}
|
||||
onSubmit={handleSendMessage}
|
||||
modelId={modelId}
|
||||
onModelChange={setModelId}
|
||||
onStop={isPreparingFiles ? undefined : handleStop}
|
||||
status={chatStatus}
|
||||
/>
|
||||
</div>
|
||||
@@ -301,4 +108,4 @@ export function ChatAgent({
|
||||
);
|
||||
}
|
||||
|
||||
export type { UIMessage as Message };
|
||||
export type { UIMessage as Message } from 'ai';
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
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 {
|
||||
PromptInput,
|
||||
PromptInputActionAddAttachments,
|
||||
@@ -23,6 +25,9 @@ interface ChatInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: (message: PromptInputMessage) => void;
|
||||
modelId: OpenRouterModelId;
|
||||
onModelChange: (modelId: OpenRouterModelId) => void;
|
||||
onStop?: () => void;
|
||||
status?: ChatStatus;
|
||||
placeholder?: string;
|
||||
}
|
||||
@@ -31,9 +36,14 @@ export function ChatInput({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
modelId,
|
||||
onModelChange,
|
||||
onStop,
|
||||
status = 'ready',
|
||||
placeholder,
|
||||
}: ChatInputProps) {
|
||||
const isGenerating = status === 'submitted' || status === 'streaming';
|
||||
|
||||
return (
|
||||
<PromptInput
|
||||
className="[&_[data-slot=input-group]]:bg-background"
|
||||
@@ -55,18 +65,22 @@ export function ChatInput({
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter className="pt-0 pb-2">
|
||||
<PromptInputTools>
|
||||
<PromptInputTools className="min-w-0">
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuTrigger>
|
||||
<PlusIcon className="size-4" />
|
||||
<RiAddLine className="size-4" />
|
||||
</PromptInputActionMenuTrigger>
|
||||
<PromptInputActionMenuContent>
|
||||
<PromptInputActionAddAttachments />
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
<ChatModelSelector value={modelId} onChange={onModelChange} disabled={isGenerating} />
|
||||
</PromptInputTools>
|
||||
<PromptInputSubmit
|
||||
disabled={status === 'streaming'}
|
||||
aria-label={isGenerating ? 'Stop response' : 'Send message'}
|
||||
type={isGenerating ? 'button' : 'submit'}
|
||||
onClick={isGenerating ? onStop : undefined}
|
||||
disabled={isGenerating && !onStop}
|
||||
status={status}
|
||||
/>
|
||||
</PromptInputFooter>
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import type { UIMessage } from "ai";
|
||||
import { isToolUIPart, type ChatStatus, type UIMessage } from "ai";
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
@@ -19,14 +19,14 @@ import {
|
||||
ReasoningTrigger,
|
||||
} from "@/components/ai-elements/reasoning";
|
||||
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 {
|
||||
messages: UIMessage[];
|
||||
isLoading?: boolean;
|
||||
status?: ChatStatus;
|
||||
}
|
||||
|
||||
|
||||
function LoadingIndicator() {
|
||||
return (
|
||||
<Message from="assistant">
|
||||
@@ -37,8 +37,7 @@ function LoadingIndicator() {
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
|
||||
const isToolPart = (type: string) => type === "file" || type.startsWith("tool-");
|
||||
export function ChatMessages({ messages, status = "ready" }: ChatMessagesProps) {
|
||||
const hasReasoningPart = (parts: typeof messages[0]["parts"]) =>
|
||||
parts.some((p) => p.type === "reasoning" && p.text);
|
||||
|
||||
@@ -46,26 +45,31 @@ export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
|
||||
(message) =>
|
||||
message.role === "user" ||
|
||||
getTextContent(message.parts).trim() !== "" ||
|
||||
message.parts.some((p) => isToolPart(p.type)) ||
|
||||
message.parts.some((p) => p.type === "file" || isToolUIPart(p)) ||
|
||||
hasReasoningPart(message.parts)
|
||||
);
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
const hasContent =
|
||||
getTextContent(lastMessage?.parts || []).trim() !== "" ||
|
||||
lastMessage?.parts.some((p) => isToolPart(p.type)) ||
|
||||
lastMessage?.parts.some((p) => p.type === "file" || isToolUIPart(p)) ||
|
||||
hasReasoningPart(lastMessage?.parts || []);
|
||||
const isStreaming = lastMessage?.role === "assistant" && hasContent;
|
||||
const showLoading = isLoading && !isStreaming;
|
||||
const isLoading = status === "submitted" || status === "streaming";
|
||||
const showLoading =
|
||||
isLoading && !(lastMessage?.role === "assistant" && hasContent);
|
||||
|
||||
return (
|
||||
<Conversation>
|
||||
<ConversationContent className="mx-auto max-w-3xl px-4 pb-4">
|
||||
{visibleMessages.map((message) => {
|
||||
const isStreaming =
|
||||
status === "streaming" &&
|
||||
message.role === "assistant" &&
|
||||
message.id === lastMessage?.id;
|
||||
const textContent = getTextContent(message.parts);
|
||||
const reasoningPart = getReasoningPart(message.parts);
|
||||
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 (
|
||||
<Message key={message.id} from={message.role}>
|
||||
{fileParts.length > 0 && (
|
||||
@@ -80,7 +84,9 @@ export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
|
||||
)}
|
||||
|
||||
{message.role === "assistant" && reasoningPart && (
|
||||
<Reasoning isStreaming={reasoningPart.state !== "done"}>
|
||||
<Reasoning
|
||||
isStreaming={isStreaming && reasoningPart.state === "streaming"}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{reasoningPart.text || ""}</ReasoningContent>
|
||||
</Reasoning>
|
||||
@@ -92,7 +98,16 @@ export function ChatMessages({ messages, isLoading }: ChatMessagesProps) {
|
||||
|
||||
{textContent && (
|
||||
<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>
|
||||
)}
|
||||
</Message>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -5,72 +5,40 @@ import { ChatInput, type PromptInputMessage } from './ChatInput';
|
||||
import { Suggestion } from '@/components/ai-elements/suggestion';
|
||||
import { RiOpenaiLine } from '@remixicon/react';
|
||||
import { Shimmer } from '@/components/ai-elements/shimmer';
|
||||
import type { OpenRouterModelId } from '@/lib/openrouter';
|
||||
import {
|
||||
offlineSuggestions,
|
||||
webSearchSuggestions,
|
||||
type PromptSuggestion,
|
||||
} from '@/config/suggestions';
|
||||
|
||||
export interface PromptSuggestion {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
export type { PromptSuggestion } from '@/config/suggestions';
|
||||
|
||||
interface ChatPromptProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: (message: PromptInputMessage) => void;
|
||||
modelId: OpenRouterModelId;
|
||||
onModelChange: (modelId: OpenRouterModelId) => void;
|
||||
onStop?: () => void;
|
||||
status?: ChatStatus;
|
||||
suggestions?: PromptSuggestion[];
|
||||
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({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
modelId,
|
||||
onModelChange,
|
||||
onStop,
|
||||
status = 'ready',
|
||||
suggestions,
|
||||
webSearchEnabled = false,
|
||||
}: ChatPromptProps) {
|
||||
const displayedSuggestions =
|
||||
suggestions ?? (webSearchEnabled ? defaultSuggestions : offlineSuggestions);
|
||||
suggestions ?? (webSearchEnabled ? webSearchSuggestions : offlineSuggestions);
|
||||
|
||||
const handleSuggestionClick = (suggestion: string) => {
|
||||
onChange(suggestion);
|
||||
@@ -99,6 +67,9 @@ export function ChatPrompt({
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onSubmit={onSubmit}
|
||||
modelId={modelId}
|
||||
onModelChange={onModelChange}
|
||||
onStop={onStop}
|
||||
status={status}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { PlusIcon, MoreHorizontalIcon, TrashIcon } from 'lucide-react';
|
||||
import { RiAddLine, RiMoreLine, RiDeleteBinLine } from '@remixicon/react';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SidebarMenuItem,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuAction,
|
||||
useSidebar,
|
||||
} from '@/components/ui/sidebar';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -20,12 +21,10 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ChatSearch } from './ChatSearch';
|
||||
import type { StoredChat } from '@/services/indexed-db/chat-store';
|
||||
|
||||
export interface Chat {
|
||||
id: string;
|
||||
title: string;
|
||||
created_at: string;
|
||||
}
|
||||
export type Chat = StoredChat;
|
||||
|
||||
interface ChatSidebarProps {
|
||||
chats: Chat[];
|
||||
@@ -42,15 +41,29 @@ export function ChatSidebar({
|
||||
onNewChat,
|
||||
onDeleteChat,
|
||||
}: ChatSidebarProps) {
|
||||
const { setOpenMobile } = useSidebar();
|
||||
|
||||
const selectChat = (chatId: string) => {
|
||||
onSelectChat(chatId);
|
||||
setOpenMobile(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<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
|
||||
onClick={onNewChat}
|
||||
onClick={() => {
|
||||
onNewChat();
|
||||
setOpenMobile(false);
|
||||
}}
|
||||
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
|
||||
</Button>
|
||||
</SidebarHeader>
|
||||
@@ -63,23 +76,22 @@ export function ChatSidebar({
|
||||
<SidebarMenuItem key={chat.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={chat.id === activeChatId}
|
||||
onClick={() => onSelectChat(chat.id)}
|
||||
onClick={() => selectChat(chat.id)}
|
||||
tooltip={chat.title}
|
||||
>
|
||||
<span>{chat.title}</span>
|
||||
</SidebarMenuButton>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction showOnHover>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<SidebarMenuAction showOnHover aria-label={`Options for ${chat.title}`}>
|
||||
<RiMoreLine className="size-4" />
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="right" align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDeleteChat(chat.id)}
|
||||
className=""
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
<RiDeleteBinLine className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
export { ChatAgent } from "./ChatAgent";
|
||||
export { ChatInput } from "./ChatInput";
|
||||
export { ChatMessage } from "./ChatMessage";
|
||||
export { ChatMessages } from "./ChatMessages";
|
||||
export { ChatPrompt } from "./ChatPrompt";
|
||||
export { ChatSidebar } from "./ChatSidebar";
|
||||
export type { Chat } from "./ChatSidebar";
|
||||
export type { Message } from "./ChatMessage";
|
||||
export type { UIMessage as Message } from "ai";
|
||||
export type { PromptSuggestion } from "./ChatPrompt";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { UIMessage } from "ai";
|
||||
|
||||
type MessagePart = UIMessage["parts"][number];
|
||||
import type { DynamicToolUIPart, ToolUIPart } from "ai";
|
||||
|
||||
export interface ToolComponentProps {
|
||||
part: MessagePart;
|
||||
part: ToolUIPart | DynamicToolUIPart;
|
||||
toolName: string;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
import * as AccordionPrimitive from "@radix-ui/react-accordion"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { RiArrowDownSLine } from "@remixicon/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -41,7 +41,7 @@ function AccordionTrigger({
|
||||
{...props}
|
||||
>
|
||||
{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.Header>
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
import { RiArrowRightSLine, RiMoreLine } from "@remixicon/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -75,7 +75,7 @@ function BreadcrumbSeparator({
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
{children ?? <RiArrowRightSLine />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -92,7 +92,7 @@ function BreadcrumbEllipsis({
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<RiMoreLine className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react"
|
||||
RiArrowDownSLine,
|
||||
RiArrowLeftSLine,
|
||||
RiArrowRightSLine,
|
||||
} from "@remixicon/react"
|
||||
import {
|
||||
DayPicker,
|
||||
getDefaultClassNames,
|
||||
@@ -145,13 +145,13 @@ function Calendar({
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
<RiArrowLeftSLine className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon
|
||||
<RiArrowRightSLine
|
||||
className={cn("size-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
@@ -159,7 +159,7 @@ function Calendar({
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
<RiArrowDownSLine className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: CalendarDayButton,
|
||||
|
||||
@@ -4,7 +4,7 @@ import * as React from "react"
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
import { RiArrowLeftLine, RiArrowRightLine } from "@remixicon/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
@@ -77,10 +77,10 @@ function Carousel({
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
if (event.key === "RiArrowLeftLine") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
} else if (event.key === "RiArrowRightLine") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
@@ -195,7 +195,7 @@ function CarouselPrevious({
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft />
|
||||
<RiArrowLeftLine />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
@@ -225,7 +225,7 @@ function CarouselNext({
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight />
|
||||
<RiArrowRightLine />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { RiCheckLine } from "@remixicon/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -23,7 +23,7 @@ function Checkbox({
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
<RiCheckLine className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "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 { Button } from "@/components/ui/button"
|
||||
@@ -31,7 +31,7 @@ function ComboboxTrigger({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon
|
||||
<RiArrowDownSLine
|
||||
data-slot="combobox-trigger-icon"
|
||||
className="text-muted-foreground pointer-events-none size-4"
|
||||
/>
|
||||
@@ -47,7 +47,7 @@ function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
<RiCloseLine className="pointer-events-none" />
|
||||
</ComboboxPrimitive.Clear>
|
||||
)
|
||||
}
|
||||
@@ -160,7 +160,7 @@ function ComboboxItem({
|
||||
<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.Item>
|
||||
)
|
||||
@@ -265,7 +265,7 @@ function ComboboxChip({
|
||||
className="-ml-1 opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
<RiCloseLine className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ChipRemove>
|
||||
)}
|
||||
</ComboboxPrimitive.Chip>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import { RiSearchLine } from "@remixicon/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
@@ -69,7 +69,7 @@ function CommandInput({
|
||||
data-slot="command-input-wrapper"
|
||||
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
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
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"
|
||||
|
||||
@@ -72,7 +72,7 @@ function ContextMenuSubTrigger({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
<RiArrowRightSLine className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
@@ -152,7 +152,7 @@ function ContextMenuCheckboxItem({
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
<RiCheckLine className="size-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
@@ -176,7 +176,7 @@ function ContextMenuRadioItem({
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
<RiCircleLine className="size-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
|
||||
@@ -45,7 +45,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
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=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
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"
|
||||
|
||||
@@ -100,7 +100,7 @@ function DropdownMenuCheckboxItem({
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
<RiCheckLine className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
@@ -135,7 +135,7 @@ function DropdownMenuRadioItem({
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
<RiCircleLine className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
@@ -217,7 +217,7 @@ function DropdownMenuSubTrigger({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
<RiArrowRightSLine className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
import { OTPInput, OTPInputContext } from "input-otp"
|
||||
import { MinusIcon } from "lucide-react"
|
||||
import { RiSubtractLine } from "@remixicon/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -69,7 +69,7 @@ function InputOTPSlot({
|
||||
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||
<MinusIcon />
|
||||
<RiSubtractLine />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
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"
|
||||
|
||||
@@ -129,7 +129,7 @@ function MenubarCheckboxItem({
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
<RiCheckLine className="size-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
@@ -153,7 +153,7 @@ function MenubarRadioItem({
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
<RiCircleLine className="size-2 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
@@ -235,7 +235,7 @@ function MenubarSubTrigger({
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
<RiArrowRightSLine className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { RiArrowDownSLine } from "@remixicon/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -24,7 +24,7 @@ function NativeSelect({
|
||||
)}
|
||||
{...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"
|
||||
aria-hidden="true"
|
||||
data-slot="native-select-icon"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { RiArrowDownSLine } from "@remixicon/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -74,7 +74,7 @@ function NavigationMenuTrigger({
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
<RiArrowDownSLine
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react"
|
||||
RiArrowLeftSLine,
|
||||
RiArrowRightSLine,
|
||||
RiMoreLine,
|
||||
} from "@remixicon/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
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)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
<RiArrowLeftSLine />
|
||||
<span className="hidden sm:block">Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
@@ -94,7 +94,7 @@ function PaginationNext({
|
||||
{...props}
|
||||
>
|
||||
<span className="hidden sm:block">Next</span>
|
||||
<ChevronRightIcon />
|
||||
<RiArrowRightSLine />
|
||||
</PaginationLink>
|
||||
)
|
||||
}
|
||||
@@ -110,7 +110,7 @@ function PaginationEllipsis({
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
<RiMoreLine className="size-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { CircleIcon } from "lucide-react"
|
||||
import { RiCircleFill } from "@remixicon/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -36,7 +36,7 @@ function RadioGroupItem({
|
||||
data-slot="radio-group-indicator"
|
||||
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.Item>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
import { RiDraggable } from "@remixicon/react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -46,7 +46,7 @@ function ResizableHandle({
|
||||
>
|
||||
{withHandle && (
|
||||
<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>
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
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"
|
||||
|
||||
@@ -44,7 +44,7 @@ function SelectTrigger({
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
<RiArrowDownSLine className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
@@ -119,7 +119,7 @@ function SelectItem({
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
<RiCheckLine className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
@@ -153,7 +153,7 @@ function SelectScrollUpButton({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon className="size-4" />
|
||||
<RiArrowUpSLine className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
)
|
||||
}
|
||||
@@ -171,7 +171,7 @@ function SelectScrollDownButton({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon className="size-4" />
|
||||
<RiArrowDownSLine className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import * as React from "react"
|
||||
import * as SheetPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { RiCloseLine } from "@remixicon/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -76,7 +76,7 @@ function SheetContent({
|
||||
{children}
|
||||
{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">
|
||||
<XIcon className="size-4" />
|
||||
<RiCloseLine className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
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 { cn } from "@/lib/utils"
|
||||
@@ -273,7 +273,7 @@ function SidebarTrigger({
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<RiLayoutLeftLine className="size-5" aria-hidden="true" />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
)
|
||||
|
||||
+11
-11
@@ -1,12 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
RiCheckboxCircleLine,
|
||||
RiInformationLine,
|
||||
RiLoader4Line,
|
||||
RiCloseCircleLine,
|
||||
RiErrorWarningLine,
|
||||
} from "@remixicon/react"
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
@@ -18,11 +18,11 @@ const Toaster = ({ ...props }: ToasterProps) => {
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
success: <RiCheckboxCircleLine className="size-4" />,
|
||||
info: <RiInformationLine className="size-4" />,
|
||||
warning: <RiErrorWarningLine className="size-4" />,
|
||||
error: <RiCloseCircleLine className="size-4" />,
|
||||
loading: <RiLoader4Line className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
import { RiLoader4Line } from "@remixicon/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
function Spinner({ className, ...props }: React.ComponentProps<typeof RiLoader4Line>) {
|
||||
return (
|
||||
<Loader2Icon
|
||||
<RiLoader4Line
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
className={cn("size-4 animate-spin", className)}
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
+16
-23
@@ -1,30 +1,23 @@
|
||||
const nextConfig = {
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
outputFileTracingRoot: process.cwd(),
|
||||
reactStrictMode: true,
|
||||
images: {
|
||||
images: {
|
||||
unoptimized: true,
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'images.unsplash.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'cdn.shopify.com',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: '**.frontend.co',
|
||||
},
|
||||
],
|
||||
},
|
||||
webpack: (config, { isServer }) => {
|
||||
config.cache = { type: "memory" };
|
||||
if (isServer) {
|
||||
config.optimization = config.optimization || {};
|
||||
config.optimization.splitChunks = false;
|
||||
} return config;
|
||||
}
|
||||
{ protocol: 'https', hostname: 'images.unsplash.com' },
|
||||
{ protocol: 'https', hostname: 'cdn.shopify.com' },
|
||||
{ protocol: 'https', hostname: '**.frontend.co' },
|
||||
],
|
||||
},
|
||||
webpack: (config, { isServer }) => {
|
||||
config.cache = { type: 'memory' };
|
||||
if (isServer) {
|
||||
config.optimization = config.optimization || {};
|
||||
config.optimization.splitChunks = false;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
+9
-14
@@ -1,20 +1,18 @@
|
||||
{
|
||||
"name": "nextjs-app",
|
||||
"name": "nextjs-chatgpt-template",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack",
|
||||
"build": "next build --webpack",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test tests/*.test.mjs"
|
||||
},
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^4.0.9",
|
||||
"@ai-sdk/react": "^4.0.19",
|
||||
"@base-ui/react": "^1.8.0",
|
||||
"@mendable/firecrawl-js": "^4.12.1",
|
||||
"@neondatabase/serverless": "^1.0.2",
|
||||
"@next/swc-wasm-web": "16.2.10",
|
||||
"@openrouter/ai-sdk-provider": "^3.0.0",
|
||||
"@radix-ui/react-accordion": "^1.2.3",
|
||||
@@ -44,6 +42,7 @@
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.6",
|
||||
"@remixicon/react": "^4.9.0",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"ai": "^7.0.18",
|
||||
@@ -51,25 +50,17 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.4",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"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",
|
||||
"lucide-react": "^0.574.0",
|
||||
"motion": "^12.33.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"next": "16.2.10",
|
||||
"next-themes": "^0.4.6",
|
||||
"openai": "^6.17.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.0.0",
|
||||
"react-day-picker": "^9.5.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-is": "^19.3.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.5.8",
|
||||
"recharts": "^3.7.0",
|
||||
"shiki": "^3.22.0",
|
||||
@@ -89,7 +80,11 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react-is": "^19",
|
||||
"jiti": "^2.7.0",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"packageManager": "yarn@4.18.0+sha512.fcb8716fe7cd0eece141ffc18b92193a9df9204c1ba83189c288835223fc0bbe64af473bab0d5e9927a7daeb5caf2bb07eb2787cc9338ca040ea125f2a1f2f7e"
|
||||
"packageManager": "yarn@4.18.0+sha512.fcb8716fe7cd0eece141ffc18b92193a9df9204c1ba83189c288835223fc0bbe64af473bab0d5e9927a7daeb5caf2bb07eb2787cc9338ca040ea125f2a1f2f7e",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}.`;
|
||||
}
|
||||
@@ -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({
|
||||
url: z.string().url().describe('The URL of the web page to fetch'),
|
||||
}),
|
||||
execute: async ({ url }) => {
|
||||
execute: async ({ url }, { abortSignal }) => {
|
||||
try {
|
||||
const response = await fetch('https://api.firecrawl.dev/v1/scrape', {
|
||||
method: 'POST',
|
||||
signal: abortSignal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${process.env.FIRECRAWL_API_KEY!}`,
|
||||
@@ -13,10 +13,11 @@ export const firecrawlWebSearch = tool({
|
||||
.default(3)
|
||||
.describe('Number of results to return (max 10)'),
|
||||
}),
|
||||
execute: async ({ query, limit }) => {
|
||||
execute: async ({ query, limit }, { abortSignal }) => {
|
||||
try {
|
||||
const response = await fetch('https://api.firecrawl.dev/v1/search', {
|
||||
method: 'POST',
|
||||
signal: abortSignal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${process.env.FIRECRAWL_API_KEY!}`,
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -32,18 +32,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 5.0.40
|
||||
resolution: "@ai-sdk/provider-utils@npm:5.0.40"
|
||||
@@ -487,19 +475,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 1.2.4
|
||||
resolution: "@napi-rs/wasm-runtime@npm:1.2.4"
|
||||
@@ -512,13 +487,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 16.2.10
|
||||
resolution: "@next/env@npm:16.2.10"
|
||||
@@ -1751,7 +1719,7 @@ __metadata:
|
||||
languageName: node
|
||||
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
|
||||
resolution: "@radix-ui/react-use-controllable-state@npm:1.2.6"
|
||||
dependencies:
|
||||
@@ -2500,15 +2468,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 7.0.99
|
||||
resolution: "ai@npm:7.0.99"
|
||||
@@ -2531,39 +2490,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 2.0.2
|
||||
resolution: "bail@npm:2.0.2"
|
||||
@@ -2580,16 +2506,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 1.0.30001810
|
||||
resolution: "caniuse-lite@npm:1.0.30001810"
|
||||
@@ -2677,15 +2593,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 2.0.3
|
||||
resolution: "comma-separated-tokens@npm:2.0.3"
|
||||
@@ -2693,15 +2600,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 7.0.6
|
||||
resolution: "cross-spawn@npm:7.0.6"
|
||||
@@ -2879,7 +2777,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"debug@npm:4, debug@npm:^4.0.0":
|
||||
"debug@npm:^4.0.0":
|
||||
version: 4.4.3
|
||||
resolution: "debug@npm:4.4.3"
|
||||
dependencies:
|
||||
@@ -2907,13 +2805,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 2.0.3
|
||||
resolution: "dequal@npm:2.0.3"
|
||||
@@ -2944,119 +2835,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 8.6.0
|
||||
resolution: "embla-carousel-react@npm:8.6.0"
|
||||
@@ -3102,41 +2880,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 1.52.0
|
||||
resolution: "es-toolkit@npm:1.52.0"
|
||||
@@ -3179,19 +2922,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 3.0.2
|
||||
resolution: "extend@npm:3.0.2"
|
||||
@@ -3199,98 +2929,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 1.0.1
|
||||
resolution: "get-nonce@npm:1.0.1"
|
||||
@@ -3298,30 +2936,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 4.2.11
|
||||
resolution: "graceful-fs@npm:4.2.11"
|
||||
@@ -3329,31 +2943,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 8.0.3
|
||||
resolution: "hast-util-from-parse5@npm:8.0.3"
|
||||
@@ -3430,7 +3019,7 @@ __metadata:
|
||||
languageName: node
|
||||
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
|
||||
resolution: "hast-util-to-jsx-runtime@npm:2.3.6"
|
||||
dependencies:
|
||||
@@ -3490,7 +3079,7 @@ __metadata:
|
||||
languageName: node
|
||||
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
|
||||
resolution: "html-url-attributes@npm:3.0.1"
|
||||
checksum: 10c0/496e4908aa8b77665f348b4b03521901794f648b8ac34a581022cd6f2c97934d5c910cd91bc6593bbf2994687549037bc2520fcdc769b31484f29ffdd402acd0
|
||||
@@ -3504,16 +3093,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 11.1.18
|
||||
resolution: "immer@npm:11.1.18"
|
||||
@@ -3733,15 +3312,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 0.30.21
|
||||
resolution: "magic-string@npm:0.30.21"
|
||||
@@ -3767,13 +3337,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 3.0.2
|
||||
resolution: "mdast-util-find-and-replace@npm:3.0.2"
|
||||
@@ -4313,59 +3876,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 2.1.3
|
||||
resolution: "ms@npm:2.1.3"
|
||||
@@ -4461,15 +3971,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nextjs-app@workspace:.":
|
||||
"nextjs-chatgpt-template@workspace:.":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "nextjs-app@workspace:."
|
||||
resolution: "nextjs-chatgpt-template@workspace:."
|
||||
dependencies:
|
||||
"@ai-sdk/openai": "npm:^4.0.9"
|
||||
"@ai-sdk/react": "npm:^4.0.19"
|
||||
"@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"
|
||||
"@openrouter/ai-sdk-provider": "npm:^3.0.0"
|
||||
"@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-group": "npm:^1.1.2"
|
||||
"@radix-ui/react-tooltip": "npm:^1.1.8"
|
||||
"@radix-ui/react-use-controllable-state": "npm:^1.2.6"
|
||||
"@remixicon/react": "npm:^4.9.0"
|
||||
"@tailwindcss/postcss": "npm:^4"
|
||||
"@types/node": "npm:^22"
|
||||
@@ -4511,25 +4019,18 @@ __metadata:
|
||||
clsx: "npm:^2.1.1"
|
||||
cmdk: "npm:^1.0.4"
|
||||
date-fns: "npm:^4.1.0"
|
||||
drizzle-orm: "npm:^0.45.1"
|
||||
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"
|
||||
lucide-react: "npm:^0.574.0"
|
||||
motion: "npm:^12.33.0"
|
||||
jiti: "npm:^2.7.0"
|
||||
nanoid: "npm:^5.1.6"
|
||||
next: "npm:16.2.10"
|
||||
next-themes: "npm:^0.4.6"
|
||||
openai: "npm:^6.17.0"
|
||||
radix-ui: "npm:^1.4.3"
|
||||
react: "npm:^19.0.0"
|
||||
react-day-picker: "npm:^9.5.0"
|
||||
react-dom: "npm:^19.0.0"
|
||||
react-hook-form: "npm:^7.88.0"
|
||||
react-is: "npm:^19.3.0"
|
||||
react-markdown: "npm:^10.1.0"
|
||||
react-resizable-panels: "npm:^4.5.8"
|
||||
recharts: "npm:^3.7.0"
|
||||
shiki: "npm:^3.22.0"
|
||||
@@ -4546,20 +4047,6 @@ __metadata:
|
||||
languageName: unknown
|
||||
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":
|
||||
version: 0.12.2
|
||||
resolution: "oniguruma-parser@npm:0.12.2"
|
||||
@@ -4578,47 +4065,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 4.0.2
|
||||
resolution: "parse-entities@npm:4.0.2"
|
||||
@@ -4693,13 +4139,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 1.6.7
|
||||
resolution: "radix-ui@npm:1.6.7"
|
||||
@@ -4814,28 +4253,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 9.3.0
|
||||
resolution: "react-redux@npm:9.3.0"
|
||||
@@ -5043,7 +4460,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"remark-rehype@npm:^11.0.0, remark-rehype@npm:^11.1.2":
|
||||
"remark-rehype@npm:^11.1.2":
|
||||
version: 11.1.2
|
||||
resolution: "remark-rehype@npm:11.1.2"
|
||||
dependencies:
|
||||
@@ -5377,13 +4794,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 3.0.1
|
||||
resolution: "trim-lines@npm:3.0.1"
|
||||
@@ -5412,13 +4822,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 5.9.3
|
||||
resolution: "typescript@npm:5.9.3"
|
||||
@@ -5636,23 +5039,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 2.0.2
|
||||
resolution: "which@npm:2.0.2"
|
||||
@@ -5664,22 +5050,6 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 4.6.4
|
||||
resolution: "zod@npm:4.6.4"
|
||||
|
||||
Reference in New Issue
Block a user