Initial Commit
This commit is contained in:
commit
73c88cf4c5
3
.env.example
Normal file
3
.env.example
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
OPENAI_API_KEY=sk-****
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-api****
|
||||||
|
TAVILY_API_KEY=tvly-****
|
||||||
3
.eslintrc.json
Normal file
3
.eslintrc.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"extends": "next/core-web-vitals"
|
||||||
|
}
|
||||||
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal 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
89
app/api/chat/route.ts
Normal 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
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
88
app/globals.css
Normal file
88
app/globals.css
Normal 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
37
app/layout.tsx
Normal 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
BIN
app/opengraph-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
438
app/page.tsx
Normal file
438
app/page.tsx
Normal 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
BIN
app/twitter-image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
17
components.json
Normal file
17
components.json
Normal 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
36
components/ui/badge.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"inline-flex items-center rounded-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
57
components/ui/button.tsx
Normal 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
76
components/ui/card.tsx
Normal 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 }
|
||||||
29
components/ui/hover-card.tsx
Normal file
29
components/ui/hover-card.tsx
Normal 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
25
components/ui/input.tsx
Normal 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 }
|
||||||
48
components/ui/scroll-area.tsx
Normal file
48
components/ui/scroll-area.tsx
Normal 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 }
|
||||||
15
components/ui/skeleton.tsx
Normal file
15
components/ui/skeleton.tsx
Normal 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
6
lib/utils.ts
Normal 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
4
next.config.mjs
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
44
package.json
Normal file
44
package.json
Normal 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
3845
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
8
postcss.config.mjs
Normal file
8
postcss.config.mjs
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
84
tailwind.config.ts
Normal file
84
tailwind.config.ts
Normal 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
27
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user