mirror of
https://github.com/hanzoai/shadcn.git
synced 2026-11-03 03:18:52 +00:00
456 lines
15 KiB
TypeScript
456 lines
15 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { Grid } from "@hanzo/ui/grid"
|
|
import {
|
|
AlertCircle,
|
|
Check,
|
|
Copy,
|
|
Loader2,
|
|
Send,
|
|
Settings2,
|
|
Sparkles,
|
|
Trash2,
|
|
} from "lucide-react"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
import { Alert, AlertDescription } from "@/registry/default/ui/alert"
|
|
import { Button } from "@/registry/default/ui/button"
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardFooter,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/registry/default/ui/card"
|
|
import { Label } from "@/registry/default/ui/label"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/registry/default/ui/select"
|
|
import { Slider } from "@/registry/default/ui/slider"
|
|
import { Textarea } from "@/registry/default/ui/textarea"
|
|
|
|
// Available AI Models
|
|
const AI_MODELS = [
|
|
{ value: "gpt-4", label: "GPT-4", provider: "OpenAI" },
|
|
{ value: "gpt-4-turbo", label: "GPT-4 Turbo", provider: "OpenAI" },
|
|
{ value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo", provider: "OpenAI" },
|
|
{ value: "claude-3-opus", label: "Claude 3 Opus", provider: "Anthropic" },
|
|
{ value: "claude-3-sonnet", label: "Claude 3 Sonnet", provider: "Anthropic" },
|
|
{ value: "claude-3-haiku", label: "Claude 3 Haiku", provider: "Anthropic" },
|
|
{ value: "llama-3-70b", label: "Llama 3 70B", provider: "Meta" },
|
|
{ value: "llama-3-8b", label: "Llama 3 8B", provider: "Meta" },
|
|
{ value: "mixtral-8x7b", label: "Mixtral 8x7B", provider: "Mistral" },
|
|
{ value: "gemini-pro", label: "Gemini Pro", provider: "Google" },
|
|
] as const
|
|
|
|
type AIModel = (typeof AI_MODELS)[number]["value"]
|
|
|
|
interface Message {
|
|
id: string
|
|
role: "user" | "assistant" | "system"
|
|
content: string
|
|
timestamp: Date
|
|
model?: AIModel
|
|
}
|
|
|
|
interface AIPlaygroundProps {
|
|
className?: string
|
|
defaultModel?: AIModel
|
|
defaultSystemPrompt?: string
|
|
onSendMessage?: (message: string, config: PlaygroundConfig) => Promise<string>
|
|
showSettings?: boolean
|
|
}
|
|
|
|
interface PlaygroundConfig {
|
|
model: AIModel
|
|
temperature: number
|
|
maxTokens: number
|
|
systemPrompt: string
|
|
}
|
|
|
|
export function AIPlayground({
|
|
className,
|
|
defaultModel = "gpt-4",
|
|
defaultSystemPrompt = "You are a helpful AI assistant.",
|
|
onSendMessage,
|
|
showSettings = true,
|
|
}: AIPlaygroundProps) {
|
|
// State management
|
|
const [messages, setMessages] = React.useState<Message[]>([])
|
|
const [input, setInput] = React.useState("")
|
|
const [isLoading, setIsLoading] = React.useState(false)
|
|
const [error, setError] = React.useState<string | null>(null)
|
|
const [copiedId, setCopiedId] = React.useState<string | null>(null)
|
|
const [showConfig, setShowConfig] = React.useState(false)
|
|
|
|
// Configuration state
|
|
const [config, setConfig] = React.useState<PlaygroundConfig>({
|
|
model: defaultModel,
|
|
temperature: 0.7,
|
|
maxTokens: 2000,
|
|
systemPrompt: defaultSystemPrompt,
|
|
})
|
|
|
|
// Refs
|
|
const messagesEndRef = React.useRef<HTMLDivElement>(null)
|
|
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
|
|
|
|
// Auto-scroll to bottom when new messages arrive
|
|
const scrollToBottom = () => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
|
}
|
|
|
|
React.useEffect(() => {
|
|
scrollToBottom()
|
|
}, [messages])
|
|
|
|
// Handle sending a message
|
|
const handleSend = async () => {
|
|
if (!input.trim()) return
|
|
|
|
const userMessage: Message = {
|
|
id: `msg-${Date.now()}`,
|
|
role: "user",
|
|
content: input.trim(),
|
|
timestamp: new Date(),
|
|
}
|
|
|
|
setMessages((prev) => [...prev, userMessage])
|
|
setInput("")
|
|
setIsLoading(true)
|
|
setError(null)
|
|
|
|
try {
|
|
let response: string
|
|
|
|
if (onSendMessage) {
|
|
// Use custom message handler if provided
|
|
response = await onSendMessage(userMessage.content, config)
|
|
} else {
|
|
// Mock response for demo purposes
|
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
response = `This is a mock response from ${config.model}. In a real implementation, this would call your AI API with the following configuration:
|
|
|
|
Model: ${config.model}
|
|
Temperature: ${config.temperature}
|
|
Max Tokens: ${config.maxTokens}
|
|
System Prompt: ${config.systemPrompt}
|
|
|
|
Your message: "${userMessage.content}"`
|
|
}
|
|
|
|
const assistantMessage: Message = {
|
|
id: `msg-${Date.now()}`,
|
|
role: "assistant",
|
|
content: response,
|
|
timestamp: new Date(),
|
|
model: config.model,
|
|
}
|
|
|
|
setMessages((prev) => [...prev, assistantMessage])
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to send message")
|
|
} finally {
|
|
setIsLoading(false)
|
|
// Focus back on textarea
|
|
setTimeout(() => textareaRef.current?.focus(), 100)
|
|
}
|
|
}
|
|
|
|
// Handle copying message content
|
|
const handleCopy = async (message: Message) => {
|
|
try {
|
|
await navigator.clipboard.writeText(message.content)
|
|
setCopiedId(message.id)
|
|
setTimeout(() => setCopiedId(null), 2000)
|
|
} catch {
|
|
setError("Failed to copy to clipboard")
|
|
}
|
|
}
|
|
|
|
// Handle clearing all messages
|
|
const handleClear = () => {
|
|
setMessages([])
|
|
setError(null)
|
|
}
|
|
|
|
// Handle Enter key press
|
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault()
|
|
handleSend()
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Card className={cn("w-full max-w-4xl", className)}>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Sparkles className="h-5 w-5" />
|
|
AI Playground
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Interact with various AI models in real-time
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{messages.length > 0 && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleClear}
|
|
className="h-8"
|
|
>
|
|
<Trash2 className="h-4 w-4 mr-1" />
|
|
Clear
|
|
</Button>
|
|
)}
|
|
{showSettings && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowConfig(!showConfig)}
|
|
className="h-8"
|
|
>
|
|
<Settings2 className="h-4 w-4 mr-1" />
|
|
Settings
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
|
|
<CardContent>
|
|
<Grid columns={1} gap={16}>
|
|
{/* Configuration Panel */}
|
|
{showConfig && (
|
|
<Grid
|
|
columns={1}
|
|
gap={16}
|
|
className="rounded-lg border bg-muted/50 p-4"
|
|
>
|
|
{/* Two controls side by side once there is room for both, one
|
|
above the other when there is not. The floor measures the
|
|
column, so no breakpoint says where that happens. */}
|
|
<Grid columns={{ min: 260, max: 2 }} gap={16}>
|
|
<Grid columns={1} gap={8}>
|
|
<Label htmlFor="model">Model</Label>
|
|
<Select
|
|
value={config.model}
|
|
onValueChange={(value: AIModel) =>
|
|
setConfig((prev) => ({ ...prev, model: value }))
|
|
}
|
|
>
|
|
<SelectTrigger id="model">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{AI_MODELS.map((model) => (
|
|
<SelectItem key={model.value} value={model.value}>
|
|
<Grid columns={1} gap={0}>
|
|
<span>{model.label}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{model.provider}
|
|
</span>
|
|
</Grid>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</Grid>
|
|
|
|
<Grid columns={1} gap={8}>
|
|
<Label htmlFor="max-tokens">
|
|
Max Tokens: {config.maxTokens}
|
|
</Label>
|
|
<Slider
|
|
id="max-tokens"
|
|
min={100}
|
|
max={4000}
|
|
step={100}
|
|
value={[config.maxTokens]}
|
|
onValueChange={(value) =>
|
|
setConfig((prev) => ({ ...prev, maxTokens: value[0] }))
|
|
}
|
|
className="w-full"
|
|
/>
|
|
</Grid>
|
|
</Grid>
|
|
|
|
<Grid columns={1} gap={8}>
|
|
<Label htmlFor="temperature">
|
|
Temperature: {config.temperature.toFixed(1)}
|
|
</Label>
|
|
<Slider
|
|
id="temperature"
|
|
min={0}
|
|
max={2}
|
|
step={0.1}
|
|
value={[config.temperature]}
|
|
onValueChange={(value) =>
|
|
setConfig((prev) => ({ ...prev, temperature: value[0] }))
|
|
}
|
|
className="w-full"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
Lower values make the output more focused and deterministic
|
|
</p>
|
|
</Grid>
|
|
|
|
<Grid columns={1} gap={8}>
|
|
<Label htmlFor="system-prompt">System Prompt</Label>
|
|
<Textarea
|
|
id="system-prompt"
|
|
placeholder="Enter system prompt..."
|
|
value={config.systemPrompt}
|
|
onChange={(e) =>
|
|
setConfig((prev) => ({
|
|
...prev,
|
|
systemPrompt: e.target.value,
|
|
}))
|
|
}
|
|
className="min-h-[80px] resize-none"
|
|
/>
|
|
</Grid>
|
|
</Grid>
|
|
)}
|
|
|
|
{/* Error Alert */}
|
|
{error && (
|
|
<Alert variant="destructive">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>{error}</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Messages Container */}
|
|
<Grid
|
|
columns={1}
|
|
gap={16}
|
|
className="rounded-lg border bg-muted/30 min-h-[300px] max-h-[500px] overflow-y-auto p-4"
|
|
style={{ alignContent: "start" }}
|
|
>
|
|
{messages.length === 0 ? (
|
|
<Grid
|
|
columns={1}
|
|
gap={4}
|
|
className="h-[280px] text-muted-foreground"
|
|
style={{ justifyItems: "center", alignContent: "center" }}
|
|
>
|
|
<Sparkles className="h-8 w-8 opacity-50" />
|
|
<p className="text-sm">Start a conversation with AI</p>
|
|
<p className="text-xs">
|
|
Using {AI_MODELS.find((m) => m.value === config.model)?.label}
|
|
</p>
|
|
</Grid>
|
|
) : (
|
|
<>
|
|
{messages.map((message) => (
|
|
// One full-width track; the bubble is placed at one end of
|
|
// it. `justify-end` on a flex row did the same thing by
|
|
// pushing a sibling that is not there.
|
|
<Grid
|
|
key={message.id}
|
|
columns={1}
|
|
gap={0}
|
|
style={{
|
|
justifyItems: message.role === "user" ? "end" : "start",
|
|
}}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"rounded-lg px-4 py-2 max-w-[80%] relative group",
|
|
message.role === "user"
|
|
? "bg-primary text-primary-foreground"
|
|
: "bg-muted"
|
|
)}
|
|
>
|
|
{message.role === "assistant" && message.model && (
|
|
<div className="text-xs opacity-70 mb-1">
|
|
{
|
|
AI_MODELS.find((m) => m.value === message.model)
|
|
?.label
|
|
}
|
|
</div>
|
|
)}
|
|
<div className="whitespace-pre-wrap break-words">
|
|
{message.content}
|
|
</div>
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<span className="text-xs opacity-50">
|
|
{message.timestamp.toLocaleTimeString()}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleCopy(message)}
|
|
className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
>
|
|
{copiedId === message.id ? (
|
|
<Check className="h-3 w-3" />
|
|
) : (
|
|
<Copy className="h-3 w-3" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</Grid>
|
|
))}
|
|
{isLoading && (
|
|
<div className="w-fit rounded-lg px-4 py-2 bg-muted">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
</div>
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</>
|
|
)}
|
|
</Grid>
|
|
|
|
{/* Input Area */}
|
|
<Grid columns="minmax(0, 1fr) auto" gap={8}>
|
|
<Textarea
|
|
ref={textareaRef}
|
|
placeholder="Type your message..."
|
|
value={input}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
disabled={isLoading}
|
|
className="min-h-[80px] resize-none"
|
|
/>
|
|
<Button
|
|
onClick={handleSend}
|
|
disabled={!input.trim() || isLoading}
|
|
className="h-[80px]"
|
|
>
|
|
{isLoading ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Send className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</Grid>
|
|
</Grid>
|
|
</CardContent>
|
|
|
|
<CardFooter className="text-xs text-muted-foreground">
|
|
<p>
|
|
Model: {AI_MODELS.find((m) => m.value === config.model)?.label} •
|
|
Temperature: {config.temperature} • Max Tokens: {config.maxTokens}
|
|
</p>
|
|
</CardFooter>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// Export additional types for external use
|
|
export type { Message, PlaygroundConfig, AIPlaygroundProps }
|
|
export { AI_MODELS }
|