feat: Added suggestQuestions feature and improve overall UI

This commit is contained in:
zaidmukaddam 2024-08-09 22:56:25 +05:30
parent d2a3efcae4
commit 2783bf7d68
4 changed files with 210 additions and 82 deletions

35
app/actions.ts Normal file
View File

@ -0,0 +1,35 @@
'use server';
import { generateObject } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';
export interface Message {
role: 'user' | 'assistant';
content: string;
}
export async function suggestQuestions(history: Message[]) {
'use server';
const { object } = await generateObject({
model: openai('gpt-4o-mini'),
temperature: 0,
system:
`You are a search engine query generator. You 'have' to create 3 questions for the search engine based on the message history which has been provided to you.
The questions should be open-ended and should encourage further discussion while maintaining the whole context. Limit it to 5-10 words per question.
Always put the user input's context is some way so that the next search knows what to search for exactly.
Never use pronouns in the questions as they blur the context.`,
messages: history,
schema: z.object({
questions: z.array(
z.string()
)
.describe('The generated questions based on the message history.')
}),
});
return {
questions: object.questions
};
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,22 +1,32 @@
/* eslint-disable @next/next/no-img-element */ /* eslint-disable @next/next/no-img-element */
"use client"; "use client";
import React, { useRef, useCallback, useState, useEffect, ReactNode, useMemo } from 'react'; import
React,
{
useRef,
useCallback,
useState,
useEffect,
ReactNode,
useMemo
} from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { useChat } from 'ai/react'; import { useChat } from 'ai/react';
import { ToolInvocation } from 'ai'; import { ToolInvocation } from 'ai';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import Image from 'next/image';
import { suggestQuestions, Message } from './actions';
import { import {
SearchIcon, SearchIcon,
LinkIcon,
Loader2,
ChevronDown, ChevronDown,
FastForward, FastForward,
Sparkles, Sparkles,
ArrowRight, ArrowRight,
Globe Globe,
AlignLeft
} from 'lucide-react'; } from 'lucide-react';
import { import {
HoverCard, HoverCard,
@ -33,7 +43,6 @@ import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
export default function Home() { export default function Home() {
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
@ -41,7 +50,7 @@ export default function Home() {
const [hasSubmitted, setHasSubmitted] = useState(false); const [hasSubmitted, setHasSubmitted] = useState(false);
const [isAnimating, setIsAnimating] = useState(false); const [isAnimating, setIsAnimating] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null); const bottomRef = useRef<HTMLDivElement>(null);
const [showToolResults, setShowToolResults] = useState<{ [key: number]: boolean }>({}); const [suggestedQuestions, setSuggestedQuestions] = useState<string[]>([]);
const [isModelSelectorOpen, setIsModelSelectorOpen] = useState(false); const [isModelSelectorOpen, setIsModelSelectorOpen] = useState(false);
const [selectedModel, setSelectedModel] = useState('Speed'); const [selectedModel, setSelectedModel] = useState('Speed');
const [showExamples, setShowExamples] = useState(false) const [showExamples, setShowExamples] = useState(false)
@ -52,6 +61,14 @@ export default function Home() {
model: selectedModel === 'Speed' ? 'gpt-4o-mini' : selectedModel === 'Quality (GPT)' ? 'gpt-4o' : 'claude-3-5-sonnet-20240620', model: selectedModel === 'Speed' ? 'gpt-4o-mini' : selectedModel === 'Quality (GPT)' ? 'gpt-4o' : 'claude-3-5-sonnet-20240620',
}, },
maxToolRoundtrips: 1, maxToolRoundtrips: 1,
onFinish: async (message, { finishReason }) => {
if (finishReason === 'stop') {
const newHistory: Message[] = [{ role: "user", content: lastSubmittedQuery, }, { role: "assistant", content: message.content }];
const { questions } = await suggestQuestions(newHistory);
setSuggestedQuestions(questions);
}
setIsAnimating(false);
},
onError: (error) => { onError: (error) => {
console.error("Chat error:", error); console.error("Chat error:", error);
toast.error("An error occurred. Please try again."); toast.error("An error occurred. Please try again.");
@ -69,64 +86,91 @@ export default function Home() {
const result = 'result' in toolInvocation ? JSON.parse(JSON.stringify(toolInvocation.result)) : null; const result = 'result' in toolInvocation ? JSON.parse(JSON.stringify(toolInvocation.result)) : null;
return ( return (
<div>
{!result ? (
<div className="flex items-center justify-between w-full">
<div
className='flex items-center gap-2'
>
<Globe className="h-5 w-5 text-neutral-700 animate-spin" />
<span className="text-neutral-700 text-lg">Searching the web...</span>
</div>
<div className="flex space-x-1">
{[0, 1, 2].map((index) => (
<motion.div
key={index}
className="w-2 h-2 bg-muted-foreground rounded-full"
initial={{ opacity: 0.3 }}
animate={{ opacity: 1 }}
transition={{
repeat: Infinity,
duration: 0.8,
delay: index * 0.2,
repeatType: "reverse",
}}
/>
))}
</div>
</div>
) :
<Accordion type="single" collapsible className="w-full mt-4"> <Accordion type="single" collapsible className="w-full mt-4">
<AccordionItem value={`item-${index}`}> <AccordionItem value={`item-${index}`} className='border-none'>
<AccordionTrigger className="hover:no-underline"> <AccordionTrigger className="hover:no-underline">
<div className="flex items-center justify-between w-full"> <div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2 text-sm sm:text-base">
<Globe className="h-5 w-5 text-primary" />
<span>Web Search</span>
</div>
{!result && (
<div className="flex items-center gap-2 "> <div className="flex items-center gap-2 ">
<Loader2 className="h-5 w-5 text-primary animate-spin" /> <Globe className="h-5 w-5 text-primary" />
<span className="text-sm text-muted-foreground">Searching the web...</span> <h2 className='text-base font-semibold'>Web Search Results Found</h2>
</div> </div>
)}
{result && ( {result && (
<Badge variant="secondary" className='mr-1'>{result.results.length} results</Badge> <Badge variant="secondary" className='mr-1 rounded-full'>{result.results.length} results</Badge>
)} )}
</div> </div>
</AccordionTrigger> </AccordionTrigger>
<AccordionContent> <AccordionContent>
{args?.query && ( {args?.query && (
<Badge variant="secondary" className="mb-2 text-xs sm:text-sm"> <Badge variant="secondary" className="mb-2 text-xs sm:text-sm font-light rounded-full">
<SearchIcon className="h-3 w-3 sm:h-4 sm:w-4 mr-1" /> <SearchIcon className="h-3 w-3 sm:h-4 sm:w-4 mr-1" />
{args.query} {args.query}
</Badge> </Badge>
)} )}
{result && ( {result && (
<ScrollArea className="h-[300px] w-full rounded-md"> <div className="flex flex-row gap-4 overflow-x-scroll">
<div className="grid grid-cols-2 gap-4">
{result.results.map((item: any, itemIndex: number) => ( {result.results.map((item: any, itemIndex: number) => (
<Card key={itemIndex} className="flex flex-col h-full shadow-none"> <Card key={itemIndex} className="flex flex-col !size-40 shadow-none !p-0 !m-0">
<CardHeader className="pb-2"> <CardHeader className="pb-2 p-1">
{/* favicon here */} <Image
<img src={`https://www.google.com/s2/favicons?domain=${new URL(item.url).hostname}`} alt="Favicon" className="w-5 h-5 flex-shrink-0 rounded-full" /> width={48}
height={48}
unoptimized
quality={100}
src={`https://www.google.com/s2/favicons?domain=${new URL(item.url).hostname}`}
alt="Favicon"
className="w-5 h-5 flex-shrink-0 rounded-full"
/>
<CardTitle className="text-sm font-semibold line-clamp-2">{item.title}</CardTitle> <CardTitle className="text-sm font-semibold line-clamp-2">{item.title}</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex-grow"> <CardContent className="flex-grow p-1 pb-0">
<p className="text-xs text-muted-foreground line-clamp-3">{item.content}</p> <p className="text-xs text-muted-foreground line-clamp-3">{item.content}</p>
</CardContent> </CardContent>
<div className="px-6 py-2 bg-muted rounded-b-xl"> <div className="px-1 py-2 bg-muted rounded-b-xl">
<a <a
href={item.url} href={item.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-xs text-primary hover:underline flex items-center" className="text-xs text-primary flex items-center"
> >
<span className="truncate">{item.url}</span> <span className="ml-1 truncate hover:underline">{item.url}</span>
</a> </a>
</div> </div>
</Card> </Card>
))} ))}
</div> </div>
</ScrollArea>
)} )}
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
</Accordion> </Accordion>}
</div>
); );
}; };
@ -136,12 +180,12 @@ export default function Home() {
return ( return (
<HoverCard key={index}> <HoverCard key={index}>
<HoverCardTrigger asChild> <HoverCardTrigger asChild>
<span className="cursor-help text-primary py-0.5 px-2 m-0 bg-secondary rounded-full"> <span className="cursor-help text-sm text-primary py-0.5 px-1.5 m-0 bg-secondary rounded-full">
{index + 1} {index + 1}
</span> </span>
</HoverCardTrigger> </HoverCardTrigger>
<HoverCardContent className="flex items-center gap-1 !p-0 !px-0.5 max-w-xs bg-card text-card-foreground !m-0 h-6 rounded-xl"> <HoverCardContent className="flex items-center gap-1 !p-0 !px-0.5 max-w-xs bg-card text-card-foreground !m-0 h-6 rounded-xl">
<img src={faviconUrl} alt="Favicon" className="w-4 h-4 flex-shrink-0 rounded-full" /> <Image src={faviconUrl} alt="Favicon" width={16} height={16} className="w-4 h-4 flex-shrink-0 rounded-full" />
<a href={citationLink} target="_blank" rel="noopener noreferrer" className="text-sm text-primary no-underline truncate"> <a href={citationLink} target="_blank" rel="noopener noreferrer" className="text-sm text-primary no-underline truncate">
{citationLink} {citationLink}
</a> </a>
@ -164,7 +208,7 @@ export default function Home() {
text, text,
link, link,
})); }));
}, [content]); // Recompute only if content changes }, [content]);
return ( return (
<ReactMarkdown <ReactMarkdown
@ -196,11 +240,13 @@ export default function Home() {
if (bottomRef.current) { if (bottomRef.current) {
bottomRef.current.scrollIntoView({ behavior: "smooth" }); bottomRef.current.scrollIntoView({ behavior: "smooth" });
} }
}, [messages]); }, [messages, suggestedQuestions]);
const handleExampleClick = useCallback(async (query: string) => { const handleExampleClick = useCallback(async (query: string) => {
setLastSubmittedQuery(query.trim()); setLastSubmittedQuery(query.trim());
setHasSubmitted(true); setHasSubmitted(true);
setSuggestedQuestions([]);
setIsAnimating(true);
await append({ await append({
content: query.trim(), content: query.trim(),
role: 'user' role: 'user'
@ -212,19 +258,31 @@ export default function Home() {
if (input.trim()) { if (input.trim()) {
setMessages([]); setMessages([]);
setLastSubmittedQuery(input.trim()); setLastSubmittedQuery(input.trim());
handleSubmit(e);
setHasSubmitted(true); setHasSubmitted(true);
setIsAnimating(true); setIsAnimating(true);
setShowToolResults({}); setSuggestedQuestions([]);
handleSubmit(e);
} else { } else {
toast.error("Please enter a search query."); toast.error("Please enter a search query.");
} }
}, [input, setMessages, handleSubmit]); }, [input, setMessages, handleSubmit]);
const handleSuggestedQuestionClick = useCallback(async (question: string) => {
setMessages([]);
setLastSubmittedQuery(question.trim());
setHasSubmitted(true);
setSuggestedQuestions([]);
setIsAnimating(true);
await append({
content: question.trim(),
role: 'user'
});
}, [append, setMessages]);
const exampleQueries = [ const exampleQueries = [
"Best programming languages in 2024", "Meta Llama 3.1 405B",
"How to build a responsive website", "Latest on Paris Olympics",
"Latest trends in AI technology", "What is Github Models?",
"OpenAI GPT-4o mini" "OpenAI GPT-4o mini"
]; ];
@ -271,7 +329,7 @@ export default function Home() {
setSelectedModel(model.name); setSelectedModel(model.name);
setIsModelSelectorOpen(false); setIsModelSelectorOpen(false);
}} }}
className="w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center" className={`w-full text-left px-4 py-2 hover:bg-gray-100 flex items-center ${models.indexOf(model) === 0 ? 'rounded-t-md' : models.indexOf(model) === models.length - 1 ? 'rounded-b-md' : ''}`}
> >
<model.icon className={`w-5 h-5 mr-3 ${model.name.includes('Quality') ? 'text-purple-500' : 'text-green-500'}`} /> <model.icon className={`w-5 h-5 mr-3 ${model.name.includes('Quality') ? 'text-purple-500' : 'text-green-500'}`} />
<div> <div>
@ -303,7 +361,7 @@ export default function Home() {
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
disabled={isLoading} disabled={isLoading}
className="w-full min-h-12 py-3 px-4 bg-muted border border-input rounded-full pr-12 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 text-sm sm:text-base" className="w-full min-h-12 py-3 px-4 bg-muted border border-input rounded-full pr-12 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-200 focus-visible:ring-offset-2 text-sm sm:text-base"
onFocus={() => setShowExamples(true)} onFocus={() => setShowExamples(true)}
onBlur={() => setShowExamples(false)} onBlur={() => setShowExamples(false)}
/> />
@ -385,15 +443,15 @@ export default function Home() {
<div key={index}> <div key={index}>
{message.role === 'assistant' && message.content && ( {message.role === 'assistant' && message.content && (
<div <div
className='!mb-20 sm:!mb-18' className={`${suggestedQuestions.length === 0 ? '!mb-20 sm:!mb-18' : ''}`}
> >
<div <div
className='flex items-center gap-2 mb-2' className='flex items-center gap-2 mb-2'
> >
<Sparkles className="size-4 sm:size-5 text-primary" /> <Sparkles className="size-5 text-primary" />
<h2 className="text-lg font-semibold">Answer</h2> <h2 className="text-base font-semibold">Answer</h2>
</div> </div>
<div className="text-sm"> <div className="">
<MarkdownRenderer content={message.content} /> <MarkdownRenderer content={message.content} />
</div> </div>
</div> </div>
@ -405,8 +463,34 @@ export default function Home() {
))} ))}
</div> </div>
))} ))}
<div ref={bottomRef} /> {suggestedQuestions.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
transition={{ duration: 0.5 }}
className="w-full max-w-xl sm:max-w-2xl !mb-20 !sm:mb-18"
>
<div className="flex items-center gap-2 mb-4">
<AlignLeft className="w-5 h-5 text-primary" />
<h2 className="font-semibold text-base">Suggested questions</h2>
</div> </div>
<div className="space-y-2 flex flex-col">
{suggestedQuestions.map((question, index) => (
<Button
key={index}
variant="ghost"
className="w-fit font-light rounded-2xl p-1 justify-start text-left h-auto py-2 px-4 bg-neutral-100 text-neutral-950 hover:bg-muted-foreground/10 whitespace-normal"
onClick={() => handleSuggestedQuestionClick(question)}
>
{question}
</Button>
))}
</div>
</motion.div>
)}
</div>
<div ref={bottomRef} />
</div> </div>
<AnimatePresence> <AnimatePresence>
@ -416,7 +500,7 @@ export default function Home() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 50 }} exit={{ opacity: 0, y: 50 }}
transition={{ duration: 0.5 }} 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" className="fixed bottom-4 transform -translate-x-1/2 w-full max-w-xl md:max-w-2xl mt-3"
> >
<form onSubmit={handleFormSubmit} className="flex items-center space-x-2"> <form onSubmit={handleFormSubmit} className="flex items-center space-x-2">
<div className="relative flex-1"> <div className="relative flex-1">
@ -427,7 +511,7 @@ export default function Home() {
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
disabled={isLoading} disabled={isLoading}
className="w-full min-h-12 py-3 px-4 bg-muted border border-input rounded-full pr-12 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 text-sm sm:text-base" className="w-full min-h-12 py-3 px-4 bg-muted border border-input rounded-full pr-12 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-neutral-200 focus-visible:ring-offset-2 text-sm sm:text-base"
/> />
<Button <Button
type="submit" type="submit"

View File

@ -1,4 +1,13 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = {}; const nextConfig = {
images: {
remotePatterns: [{
protocol: 'https',
hostname: 'www.google.com',
port: '',
pathname: '/s2/favicons',
}]
}
};
export default nextConfig; export default nextConfig;