mirror of
https://github.com/hanzoai/shadcn.git
synced 2026-10-30 18:22:59 +00:00
259 lines
8.4 KiB
TypeScript
259 lines
8.4 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { Cell, Grid } from "@hanzo/ui/grid"
|
|
import { Bot, MessageSquare, Settings, Sparkles, User } from "lucide-react"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
import {
|
|
Avatar,
|
|
AvatarFallback,
|
|
AvatarImage,
|
|
} from "@/registry/default/ui/avatar"
|
|
import { Badge } from "@/registry/default/ui/badge"
|
|
import { Button } from "@/registry/default/ui/button"
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/registry/default/ui/card"
|
|
import { ScrollArea } from "@/registry/default/ui/scroll-area"
|
|
import { Separator } from "@/registry/default/ui/separator"
|
|
import { Textarea } from "@/registry/default/ui/textarea"
|
|
|
|
export interface AIAssistantProps extends Omit<
|
|
React.HTMLAttributes<HTMLDivElement>,
|
|
"onError"
|
|
> {
|
|
provider?: string
|
|
model?: string
|
|
apiKey?: string
|
|
systemPrompt?: string
|
|
tools?: Array<{ name: string; description: string; parameters?: any }>
|
|
onResponse?: (response: any) => void
|
|
onError?: (error: Error) => void
|
|
messages?: Array<{
|
|
id: string
|
|
role: "user" | "assistant" | "system"
|
|
content: string
|
|
timestamp: Date
|
|
}>
|
|
onSendMessage?: (message: string) => void
|
|
isLoading?: boolean
|
|
placeholder?: string
|
|
showAvatar?: boolean
|
|
showTimestamp?: boolean
|
|
}
|
|
|
|
const AIAssistant = React.forwardRef<HTMLDivElement, AIAssistantProps>(
|
|
(
|
|
{
|
|
className,
|
|
children,
|
|
provider = "openai",
|
|
model = "gpt-4",
|
|
apiKey,
|
|
systemPrompt,
|
|
onResponse,
|
|
onError,
|
|
messages = [],
|
|
onSendMessage,
|
|
isLoading = false,
|
|
placeholder = "Ask the AI assistant anything...",
|
|
showAvatar = true,
|
|
showTimestamp = true,
|
|
tools = [],
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
const [input, setInput] = React.useState("")
|
|
const scrollRef = React.useRef<HTMLDivElement>(null)
|
|
|
|
// Auto-scroll to bottom when new messages arrive
|
|
React.useEffect(() => {
|
|
if (scrollRef.current) {
|
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
|
}
|
|
}, [messages])
|
|
|
|
const handleSend = () => {
|
|
if (!input.trim() || isLoading) return
|
|
onSendMessage?.(input)
|
|
setInput("")
|
|
}
|
|
|
|
const handleKeyPress = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault()
|
|
handleSend()
|
|
}
|
|
}
|
|
|
|
return (
|
|
// Header, transcript, composer: three rows, the middle one taking the
|
|
// slack. The transcript scrolls because the 1fr track bounds it, which a
|
|
// column of flex children only does once every one of them agrees to
|
|
// shrink.
|
|
<Grid
|
|
ref={ref}
|
|
columns={1}
|
|
rows="auto minmax(0, 1fr) auto"
|
|
gap={0}
|
|
className={className}
|
|
style={{ height: "100%", width: "100%" }}
|
|
{...props}
|
|
>
|
|
<div className="flex items-center justify-between border-b p-4">
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
|
|
<Sparkles className="h-5 w-5 text-primary" />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold">AI Assistant</h3>
|
|
<p className="text-sm text-muted-foreground">
|
|
{provider} • {model}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{tools.length > 0 && (
|
|
<Badge variant="secondary">{tools.length} tools</Badge>
|
|
)}
|
|
<Button variant="ghost" size="icon">
|
|
<Settings className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<ScrollArea ref={scrollRef} className="p-4">
|
|
<Grid columns={1} gap={16}>
|
|
{messages.length === 0 ? (
|
|
<Grid
|
|
columns={1}
|
|
gap={16}
|
|
className="py-8 text-center"
|
|
style={{ justifyItems: "center" }}
|
|
>
|
|
<MessageSquare className="h-12 w-12 text-muted-foreground/50" />
|
|
<div>
|
|
<h4 className="text-lg font-medium text-muted-foreground">
|
|
Start a conversation
|
|
</h4>
|
|
<p className="text-sm text-muted-foreground">
|
|
Ask me anything and I'll do my best to help!
|
|
</p>
|
|
</div>
|
|
</Grid>
|
|
) : (
|
|
messages.map((message) => {
|
|
const mine = message.role === "user"
|
|
// The avatar track is content-sized and the bubble track takes
|
|
// the rest; which side each sits on is a placement, so the two
|
|
// sides read the same way instead of one being a reversal of
|
|
// the other.
|
|
return (
|
|
<Grid
|
|
key={message.id}
|
|
columns={
|
|
mine ? "minmax(0, 1fr) auto" : "auto minmax(0, 1fr)"
|
|
}
|
|
gap={12}
|
|
>
|
|
{showAvatar && (
|
|
<Cell col={mine ? 2 : 1}>
|
|
<Avatar className="h-8 w-8">
|
|
<AvatarFallback>
|
|
{mine ? (
|
|
<User className="h-4 w-4" />
|
|
) : (
|
|
<Bot className="h-4 w-4" />
|
|
)}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
</Cell>
|
|
)}
|
|
<Cell col={mine ? 1 : 2}>
|
|
<Grid
|
|
columns={1}
|
|
gap={4}
|
|
className="max-w-[80%]"
|
|
style={{
|
|
justifyItems: mine ? "end" : "start",
|
|
marginLeft: mine ? "auto" : undefined,
|
|
}}
|
|
>
|
|
{showTimestamp && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{message.timestamp.toLocaleTimeString()}
|
|
</span>
|
|
)}
|
|
<div
|
|
className={cn(
|
|
"rounded-lg px-3 py-2 text-sm whitespace-pre-wrap",
|
|
mine
|
|
? "bg-primary text-primary-foreground"
|
|
: "bg-muted"
|
|
)}
|
|
>
|
|
{message.content}
|
|
</div>
|
|
</Grid>
|
|
</Cell>
|
|
</Grid>
|
|
)
|
|
})
|
|
)}
|
|
{isLoading && (
|
|
<Grid columns="auto minmax(0, 1fr)" gap={12}>
|
|
<Avatar className="h-8 w-8">
|
|
<AvatarFallback>
|
|
<Bot className="h-4 w-4" />
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="bg-muted w-fit rounded-lg px-3 py-2 text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex gap-1">
|
|
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
|
|
<div className="h-2 w-2 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
|
|
<div className="h-2 w-2 animate-bounce rounded-full bg-current" />
|
|
</div>
|
|
<span>AI is thinking...</span>
|
|
</div>
|
|
</div>
|
|
</Grid>
|
|
)}
|
|
</Grid>
|
|
</ScrollArea>
|
|
|
|
<div className="border-t p-4">
|
|
<Grid columns="minmax(0, 1fr) auto" gap={8}>
|
|
<Textarea
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyPress={handleKeyPress}
|
|
placeholder={placeholder}
|
|
disabled={isLoading}
|
|
className="min-h-[60px] resize-none"
|
|
rows={2}
|
|
/>
|
|
<Button
|
|
onClick={handleSend}
|
|
disabled={!input.trim() || isLoading}
|
|
size="icon"
|
|
>
|
|
<MessageSquare className="h-4 w-4" />
|
|
</Button>
|
|
</Grid>
|
|
</div>
|
|
|
|
{children}
|
|
</Grid>
|
|
)
|
|
}
|
|
)
|
|
AIAssistant.displayName = "AIAssistant"
|
|
|
|
export { AIAssistant }
|