Initial Commit

This commit is contained in:
zaidmukaddam 2024-08-07 18:59:45 +05:30
commit 73c88cf4c5
25 changed files with 5015 additions and 0 deletions

3
.env.example Normal file
View File

@ -0,0 +1,3 @@
OPENAI_API_KEY=sk-****
ANTHROPIC_API_KEY=sk-ant-api****
TAVILY_API_KEY=tvly-****

3
.eslintrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

89
app/api/chat/route.ts Normal file
View File

@ -0,0 +1,89 @@
import { openai } from "@ai-sdk/openai";
import { anthropic } from '@ai-sdk/anthropic'
import { convertToCoreMessages, streamText } from "ai";
import { z } from "zod";
// Allow streaming responses up to 30 seconds
export const maxDuration = 30;
export async function POST(req: Request) {
const { messages, model } = await req.json();
let ansmodel;
if (model === "claude-3-5-sonnet-20240620") {
ansmodel = anthropic("claude-3-5-sonnet-20240620")
} else {
ansmodel = openai(model)
}
const result = await streamText({
model: ansmodel,
messages: convertToCoreMessages(messages),
system:
"You are an AI web search engine that helps users find information on the internet." +
"You use the 'web_search' tool to search for information on the internet." +
"Once you have found the information, you provide the user with the information you found in brief like a news paper detail." +
"The detail should be 3-5 paragraphs in 10-12 sentences, some time pointers, each with citations in the [Text](link) format always!" +
"Citations can be inline of the text like this: Hey there! [Google](https://google.com) is a search engine." +
"The current date is: " +
new Date()
.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "2-digit",
weekday: "short",
})
.replace(/(\w+), (\w+) (\d+), (\d+)/, "$4-$2-$3 ($1)") +
"Never use the heading format in your response!." +
"You always have to call the 'web_search' tool to get the information, no need to do a chain of thoughts.",
tools: {
web_search: {
description: 'Search the web for information with the given query, max results and search depth.',
parameters: z.object({
query: z.string()
.describe('The search query to look up on the web.'),
maxResults: z.number()
.describe('The maximum number of results to return. Default to be used is 10.'),
searchDepth: // use basic | advanced
z.enum(['basic', 'advanced'])
.describe('The search depth to use for the search. Default is basic.')
}),
execute: async ({ query, maxResults, searchDepth }: { query: string, maxResults: number, searchDepth: 'basic' | 'advanced' }) => {
const apiKey = process.env.TAVILY_API_KEY
const response = await fetch('https://api.tavily.com/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
api_key: apiKey,
query,
max_results: maxResults < 5 ? 5 : maxResults,
search_depth: searchDepth,
include_images: true,
include_answers: true
})
})
const data = await response.json()
let context = data.results.map((obj: { url: any; content: any; title: any; raw_content: any; }) => {
return {
url: obj.url,
title: obj.title,
content: obj.content,
raw_content: obj.raw_content
}
})
return {
results: context
}
}
},
}
});
return result.toAIStreamResponse();
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

88
app/globals.css Normal file
View File

@ -0,0 +1,88 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Instrument+Serif:wght@400&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--font-serif: "Instrument Serif", serif;
--font-sans: "Inter", sans-serif;
}
body {
font-family: var(--font-sans), sans-serif;
}
h1 {
font-family: var(--font-serif);
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

37
app/layout.tsx Normal file
View File

@ -0,0 +1,37 @@
import "./globals.css";
import { Metadata } from "next";
import { Toaster } from "sonner";
import { Inter, Instrument_Serif } from 'next/font/google';
import { Analytics } from "@vercel/analytics/react";
export const metadata: Metadata = {
metadataBase: new URL("https://miniperplx.za16.co"),
title: "MiniPerplx",
description: "MiniPerplx is a minimalistic AI-powered search engine that helps you find information on the internet.",
};
const inter = Inter({
weight: "variable",
subsets: ["latin"],
})
const instrumentSerif = Instrument_Serif({
weight: "400",
subsets: ["latin"],
})
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${inter.className} ${instrumentSerif.className}`}>
<Toaster position="top-center" richColors />
{children}
<Analytics />
</body>
</html>
);
}

BIN
app/opengraph-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

438
app/page.tsx Normal file
View File

@ -0,0 +1,438 @@
/* eslint-disable @next/next/no-img-element */
"use client";
import React, { useRef, useCallback, useState, useEffect, ReactNode } from 'react';
import { useChat } from 'ai/react';
import { ToolInvocation } from 'ai';
import { toast } from 'sonner';
import { motion, AnimatePresence } from 'framer-motion';
import {
SearchIcon,
LinkIcon,
Check,
Loader2,
ChevronDown,
ChevronUp,
FastForward,
Sparkles,
ArrowRight
} from 'lucide-react';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from "@/components/ui/hover-card";
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
export default function Home() {
const inputRef = useRef<HTMLInputElement>(null);
const [lastSubmittedQuery, setLastSubmittedQuery] = useState("");
const [hasSubmitted, setHasSubmitted] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const [showToolResults, setShowToolResults] = useState<{ [key: number]: boolean }>({});
const [isModelSelectorOpen, setIsModelSelectorOpen] = useState(false);
const [selectedModel, setSelectedModel] = useState('Speed');
const { isLoading, input, messages, setInput, append, handleSubmit, setMessages } = useChat({
api: '/api/chat',
body: {
model: selectedModel === 'Speed' ? 'gpt-4o-mini' : selectedModel === 'Quality (GPT)' ? 'gpt-4o' : 'claude-3-5-sonnet-20240620',
},
maxToolRoundtrips: 1,
onError: (error) => {
console.error("Chat error:", error);
toast.error("An error occurred. Please try again.");
},
});
const models = [
{ name: 'Speed', description: 'High speed, but lower quality.', details: '(OpenAI/GPT-4o-mini)', icon: FastForward },
{ name: 'Quality (GPT)', description: 'Speed and quality, balanced.', details: '(OpenAI/GPT | Optimized)', icon: Sparkles },
{ name: 'Quality (Claude)', description: 'High quality generation.', details: '(Anthropic/Claude-3.5-Sonnet)', icon: Sparkles },
];
const renderToolInvocation = (toolInvocation: ToolInvocation, index: number) => {
const args = JSON.parse(JSON.stringify(toolInvocation.args));
const result = 'result' in toolInvocation ? JSON.parse(JSON.stringify(toolInvocation.result)) : null;
return (
<Card key={index} className="mb-4 border border-muted">
<CardHeader>
<CardTitle className="flex items-center justify-between gap-2 flex-wrap">
<div className='flex items-center gap-2'>
{result ? <Check className="h-5 w-5 text-green-500" /> : <Loader2 className="h-5 w-5 text-primary animate-spin" />}
<span className="text-sm sm:text-base">{result ? 'Used' : 'Using'} {toolInvocation.toolName === 'web_search' ? 'Web Search' : toolInvocation.toolName}</span>
</div>
<Button
onClick={() => setShowToolResults(prev => ({ ...prev, [index]: !prev[index] }))}
className='ml-2 text-xs sm:text-sm'
variant="secondary"
>
{showToolResults[index] ? 'Hide Results' : 'Show Results'}
{showToolResults[index] ? <ChevronUp className="ml-2 h-4 w-4" /> : <ChevronDown className="ml-2 h-4 w-4" />}
</Button>
</CardTitle>
</CardHeader>
<CardContent>
{args?.query && (
<Badge variant="secondary" className="mb-2 text-xs sm:text-sm">
<SearchIcon className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
{args.query}
</Badge>
)}
{showToolResults[index] && result && (
<ScrollArea className="h-[200px] sm:h-[300px] w-full rounded-md border border-muted p-2 sm:p-4 mt-2">
{result.results.map((item: any, itemIndex: number) => (
<div key={itemIndex} className="mb-4 pb-4 border-b last:border-b-0">
<h3 className="text-sm sm:text-lg font-semibold mb-1 text-secondary-foreground">{item.title}</h3>
<p className="text-xs sm:text-sm text-muted-foreground mb-1">{item.content}</p>
<a
href={item.url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-500 hover:underline flex items-center"
>
<LinkIcon className="h-3 w-3 mr-1" />
<span className="truncate">{item.url}</span>
</a>
</div>
))}
</ScrollArea>
)}
</CardContent>
</Card>
);
};
const renderCitation = (citationText: string, citationLink: string, index: number) => {
const faviconUrl = `https://www.google.com/s2/favicons?domain=${new URL(citationLink).hostname}`;
return (
<HoverCard key={index}>
<HoverCardTrigger asChild>
<span className="cursor-help text-blue-500 hover:underline">
{citationText}
<sup>[{index + 1}]</sup>
</span>
</HoverCardTrigger>
<HoverCardContent className="flex items-center gap-2 p-2 max-w-xs bg-card text-card-foreground">
<img src={faviconUrl} alt="Favicon" className="w-4 h-4 flex-shrink-0" />
<a href={citationLink} target="_blank" rel="noopener noreferrer" className="text-sm text-blue-500 hover:underline truncate">
{citationLink}
</a>
</HoverCardContent>
</HoverCard>
);
};
const renderMarkdown = (content: string) => {
const citationRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
const boldRegex = /\*\*(.*?)\*\*/g; // Bold
const italicRegex = /\*(.*?)\*/g; // Italic
const unorderedListRegex = /^-\s+(.*)$/gm; // Unordered list
const orderedListRegex = /^\d+\.\s+(.*)$/gm; // Ordered list
const headingRegex = /^(#{1,6})\s+(.*)$/gm; // Headings
const parts: (string | ReactNode)[] = [];
let lastIndex = 0;
let match;
// Replace bold and italic
content = content
.replace(boldRegex, '<strong>$1</strong>')
.replace(italicRegex, '<em>$1</em>');
// Replace unordered and ordered lists
content = content
.replace(unorderedListRegex, '<li class="list-disc ml-6">$1</li>')
.replace(orderedListRegex, '<li class="list-decimal ml-6">$1</li>');
// Replace headings
content = content.replace(headingRegex, (match, hashes, headingText) => {
const level = hashes.length; // Determine heading level
return `<h${level} class="text-${level === 1 ? '3xl' : level === 2 ? '2xl' : 'xl'} font-bold mb-1">${headingText}</h${level}>`;
});
// Add list wrapping
const wrappedContent = content.split(/(<li.*?<\/li>)/g).map((item, index) => {
if (item.startsWith('<li')) {
return `<ul>${item}</ul>`;
}
return item;
}).join('');
// Parse citations and add to parts
while ((match = citationRegex.exec(wrappedContent)) !== null) {
// Add text before the citation
if (match.index > lastIndex) {
parts.push(wrappedContent.slice(lastIndex, match.index));
}
const citationText = match[1];
const citationLink = match[2];
parts.push(renderCitation(citationText, citationLink, parts.length)); // Adjusting index for key
lastIndex = match.index + match[0].length;
}
// Add any remaining text after the last citation
if (lastIndex < wrappedContent.length) {
parts.push(wrappedContent.slice(lastIndex));
}
return (
<span>
{parts.map((part, index) => {
if (typeof part === 'string') {
const lines = part.split('\n');
return lines.map((line, lineIndex) => (
<React.Fragment key={`${index}-${lineIndex}`}>
<span dangerouslySetInnerHTML={{ __html: line }} />
{lineIndex < lines.length - 1 && <br />}
</React.Fragment>
));
}
return <React.Fragment key={index}>{part}</React.Fragment>; // Render citations
})}
</span>
);
};
useEffect(() => {
if (bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: "smooth" });
}
}, [messages]);
const handleExampleClick = useCallback(async (query: string) => {
setLastSubmittedQuery(query.trim());
setHasSubmitted(true);
await append({
content: query.trim(),
role: 'user'
});
}, [append]);
const handleFormSubmit = useCallback((e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (input.trim()) {
setMessages([]);
setLastSubmittedQuery(input.trim());
handleSubmit(e);
setHasSubmitted(true);
setShowToolResults({});
} else {
toast.error("Please enter a search query.");
}
}, [input, setMessages, handleSubmit]);
const exampleQueries = [
"Best programming languages in 2024",
"How to build a responsive website",
"Latest trends in AI technology",
"OpenAI GPT-4o mini"
];
return (
<div className="flex flex-col font-sans items-center min-h-screen p-2 sm:p-4 bg-background text-foreground transition-all duration-500">
<div className={`w-full max-w-xl sm:max-w-2xl space-y-4 sm:space-y-6 ${hasSubmitted ? 'mt-16 sm:mt-20' : 'mt-[15vh] sm:mt-[20vh]'}`}>
<motion.div
initial={false}
animate={hasSubmitted ? { scale: 1.2 } : { scale: 1 }}
transition={{ duration: 0.5 }}
className="text-center"
>
<h1 className="text-3xl sm:text-4xl mb-4 sm:mb-8 text-primary font-serif">MiniPerplx</h1>
{!hasSubmitted &&
<h2 className='text-xl sm:text-2xl font-serif text-balance text-center mb-2'>
A minimalistic AI-powered search engine that helps you find information on the internet.
</h2>
}
</motion.div>
<AnimatePresence>
{!hasSubmitted && (
<motion.div
initial={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.5 }}
>
<div className="relative mb-4">
<button
onClick={() => setIsModelSelectorOpen(!isModelSelectorOpen)}
className={`flex items-center font-semibold ${models.find((model) => model.name === selectedModel)?.name.includes('Quality') ? 'text-purple-500' : 'text-green-500'} focus:outline-none focus:ring-0 `}
>
{selectedModel === 'Speed' && <FastForward className="w-5 h-5 mr-2" />}
{(selectedModel === 'Quality (GPT)' || selectedModel === 'Quality (Claude)') && <Sparkles className="w-5 h-5 mr-2" />}
{selectedModel}
<ChevronDown className={`w-5 h-5 ml-2 transform transition-transform ${isModelSelectorOpen ? 'rotate-180' : ''}`} />
</button>
{isModelSelectorOpen && (
<div className="absolute top-full left-0 mt-2 w-fit bg-white border border-gray-200 rounded-md shadow-lg z-10">
{models.map((model) => (
<button
key={model.name}
onClick={() => {
setSelectedModel(model.name);
setIsModelSelectorOpen(false);
}}
className="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center"
>
<model.icon className={`w-5 h-5 mr-3 ${model.name.includes('Quality') ? 'text-purple-500' : 'text-green-500'}`} />
<div>
<div className="font-semibold flex items-center">
{model.name}
{selectedModel === model.name && (
<span
className={`ml-2 text-xs text-white px-2 py-0.5 rounded-full ${model.name.includes('Quality') ? 'bg-purple-500' : 'bg-green-500'}`}
>
Selected
</span>
)}
</div>
<div className="text-sm text-gray-500">{model.description}</div>
<div className="text-xs text-gray-400">{model.details}</div>
</div>
</button>
))}
</div>
)}
</div>
<form onSubmit={handleFormSubmit} className="flex items-center space-x-2 mb-4 sm:mb-6">
<div className="relative flex-1">
<Input
ref={inputRef}
name="search"
placeholder="Ask a question..."
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={isLoading}
className="w-full h-10 py-3 px-4 bg-gray-100 rounded-full pr-12 focus:outline-none focus:ring-2 focus:ring-green-500 text-sm sm:text-base"
/>
<Button
size="icon"
type="submit"
variant="ghost"
disabled={isLoading}
className="absolute right-2 top-1/2 transform -translate-y-1/2 bg-transparent hover:bg-transparent"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12H19M19 12L12 5M19 12L12 19" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Button>
</div>
</form>
<div className="flex flex-col gap-2 text-left items-start justify-start">
{exampleQueries.map((query, index) => (
<button
key={index}
onClick={() => handleExampleClick(query)}
className="mb-1 hover:underline flex flex-row"
>
<ArrowRight className="w-5 h-5 mr-1" />
{query}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{hasSubmitted && (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 50 }}
transition={{ duration: 0.5 }}
className="flex items-center space-x-2 mb-4"
>
<p className="text-lg sm:text-2xl font-medium font-serif">{lastSubmittedQuery}</p>
<Badge
variant="secondary"
className={`text-xs sm:text-sm ${selectedModel.includes('Quality') ? 'bg-purple-500 hover:bg-purple-500' : 'bg-green-500 hover:bg-green-500'} text-white`}
>
{selectedModel === 'Speed' && <FastForward className="w-4 h-4 mr-1" />}
{selectedModel === 'Quality (GPT)' && <Sparkles className="w-4 h-4 mr-1" />}
{selectedModel === 'Quality (Claude)' && <Sparkles className="w-4 h-4 mr-1" />}
{selectedModel}
</Badge>
</motion.div>
)}
</AnimatePresence>
{messages.length > 0 && (
<div className="space-y-4 sm:space-y-6">
{messages.map((message, index) => (
<React.Fragment key={index}>
{message.role === 'assistant' && message.content && (
<Card className="bg-card text-card-foreground border border-muted !mb-20 sm:!mb-16">
<CardContent className="p-3 sm:p-4">
<h2 className="text-lg sm:text-xl font-semibold mb-2">Answer</h2>
<div className="text-sm sm:text-base">
{renderMarkdown(message.content)}
</div>
</CardContent>
</Card>
)}
{message.toolInvocations?.map((toolInvocation: ToolInvocation, toolIndex: number) => (
<React.Fragment key={toolIndex}>
{renderToolInvocation(toolInvocation, toolIndex)}
</React.Fragment>
))}
</React.Fragment>
))}
<div ref={bottomRef} />
</div>
)}
</div>
<AnimatePresence>
{hasSubmitted && (
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 50 }}
transition={{ duration: 0.5 }}
className="fixed bottom-4 transform -translate-x-1/2 w-full max-w-[90%] sm:max-w-md md:max-w-2xl mt-3"
>
<form onSubmit={handleFormSubmit} className="flex items-center space-x-2">
<div className="relative flex-1">
<Input
ref={inputRef}
name="search"
placeholder="Ask a new question..."
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={isLoading}
className="w-full h-10 py-3 px-4 bg-gray-100 rounded-full pr-12 focus:outline-none focus:ring-2 focus:ring-green-500 text-sm"
/>
<Button
size="icon"
type="submit"
variant="ghost"
disabled={isLoading}
className="absolute right-2 top-1/2 transform -translate-y-1/2 bg-transparent hover:bg-transparent"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5 12H19M19 12L12 5M19 12L12 19" stroke="#9CA3AF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Button>
</div>
</form>
</motion.div>
)}
</AnimatePresence>
</div>
);
}

BIN
app/twitter-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

17
components.json Normal file
View File

@ -0,0 +1,17 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "app/(preview)/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

36
components/ui/badge.tsx Normal file
View File

@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

57
components/ui/button.tsx Normal file
View File

@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

76
components/ui/card.tsx Normal file
View File

@ -0,0 +1,76 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }

25
components/ui/input.tsx Normal file
View File

@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@ -0,0 +1,15 @@
import { cn } from "@/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }

6
lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

4
next.config.mjs Normal file
View File

@ -0,0 +1,4 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
export default nextConfig;

44
package.json Normal file
View File

@ -0,0 +1,44 @@
{
"name": "miniperplx",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@ai-sdk/anthropic": "^0.0.37",
"@ai-sdk/openai": "^0.0.40",
"@radix-ui/react-hover-card": "^1.1.1",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",
"@tailwindcss/typography": "^0.5.13",
"@vercel/analytics": "^1.3.1",
"ai": "latest",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"framer-motion": "^11.3.19",
"lucide-react": "^0.424.0",
"next": "14.2.5",
"react": "^18",
"react-dom": "^18",
"sonner": "^1.5.0",
"tailwind-merge": "^2.4.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^18",
"@types/react-dom": "^18",
"eslint": "^8",
"eslint-config-next": "14.2.5",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
}
}

3845
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

8
postcss.config.mjs Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: {
tailwindcss: {},
},
};
export default config;

84
tailwind.config.ts Normal file
View File

@ -0,0 +1,84 @@
import type { Config } from "tailwindcss"
const config = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
prefix: "",
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
serif: ['var(--font-serif)', 'serif'],
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
} satisfies Config
export default config

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}