AI Components
Prompt inputs with voice, model selection, and attachments.
Fromblocks.so
AI Chat with Voice Input
"use client"
import {
AudioWaveformIcon,
MicIcon,
PaperclipIcon,
PlusIcon,
SearchIcon,
SendIcon,
SparklesIcon,
} from "lucide-react"
import type React from "react"
import { useRef, useState } from "react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
export default function Ai01() {
const [message, setMessage] = useState("")
const [isExpanded, setIsExpanded] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const send = () => {
if (!message.trim()) {
return
}
setMessage("")
setIsExpanded(false)
if (textareaRef.current) {
textareaRef.current.style.height = "auto"
}
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
send()
}
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value)
if (textareaRef.current) {
textareaRef.current.style.height = "auto"
textareaRef.current.style.height = `${textareaRef.current.scrollHeight}px`
}
setIsExpanded(e.target.value.length > 100 || e.target.value.includes("\n"))
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
send()
}
}
return (
<div className="w-full">
<h1 className="mx-auto mb-6 max-w-2xl text-balance text-center font-semibold text-3xl text-foreground leading-tight tracking-tight">
How can I help you today?
</h1>
<form className="group/composer w-full" onSubmit={handleSubmit}>
<input className="sr-only" multiple ref={fileInputRef} type="file" />
<div
className={cn(
"mx-auto w-full max-w-2xl cursor-text overflow-clip bg-background bg-clip-padding p-2.5 shadow-[0_0_0_1px_oklch(0_0_0/0.08),0_1px_2px_-1px_oklch(0_0_0/0.08),0_4px_12px_-2px_oklch(0_0_0/0.08)] dark:bg-muted/50 dark:shadow-[0_0_0_1px_oklch(1_0_0/0.1)]",
isExpanded
? "grid rounded-[28px] [grid-template-areas:'header'_'primary'_'footer'] [grid-template-columns:1fr] [grid-template-rows:auto_1fr_auto]"
: "grid rounded-full [grid-template-areas:'header_header_header'_'leading_primary_trailing'_'._footer_.'] [grid-template-columns:auto_1fr_auto] [grid-template-rows:auto_1fr_auto]"
)}
>
<div
className={cn(
"flex overflow-x-hidden",
isExpanded
? "px-2 pt-1.5 pb-2"
: "-my-2.5 min-h-14 items-center px-1.5"
)}
style={{ gridArea: "primary" }}
>
<div className="max-h-52 flex-1 overflow-auto">
<Textarea
className="scrollbar-thin min-h-0 resize-none rounded-none border-0 p-0 text-base placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0 md:text-base dark:bg-transparent"
onChange={handleTextareaChange}
onKeyDown={handleKeyDown}
placeholder="Ask anything"
ref={textareaRef}
rows={1}
value={message}
/>
</div>
</div>
<div
className={cn("flex items-center", { hidden: isExpanded })}
style={{ gridArea: "leading" }}
>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
aria-label="Add attachments"
className="rounded-full transition-[scale,background-color] duration-150 ease-out active:scale-[0.96]"
size="icon-lg"
type="button"
variant="ghost"
/>
}
>
<PlusIcon
className="size-5 text-muted-foreground"
strokeWidth={1.5}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-64 rounded-[16px] p-1.5"
>
<DropdownMenuGroup className="space-y-1">
<DropdownMenuItem
className="rounded-[10px] px-2.5 py-2"
onClick={() => fileInputRef.current?.click()}
>
<PaperclipIcon
className="opacity-60"
size={20}
strokeWidth={1.5}
/>
Add photos & files
</DropdownMenuItem>
<DropdownMenuItem
className="rounded-[10px] px-2.5 py-2"
onClick={() => {}}
>
<SparklesIcon
className="opacity-60"
size={20}
strokeWidth={1.5}
/>
Agent mode
</DropdownMenuItem>
<DropdownMenuItem
className="rounded-[10px] px-2.5 py-2"
onClick={() => {}}
>
<SearchIcon
className="opacity-60"
size={20}
strokeWidth={1.5}
/>
Deep Research
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div
className="flex items-center gap-2"
style={{ gridArea: isExpanded ? "footer" : "trailing" }}
>
<div className="ms-auto flex items-center gap-1.5">
<Button
aria-label="Record audio message"
className="rounded-full transition-[scale,background-color] duration-150 ease-out active:scale-[0.96]"
size="icon-lg"
type="button"
variant="ghost"
>
<MicIcon
className="size-5 text-muted-foreground"
strokeWidth={1.5}
/>
</Button>
<Button
aria-label="Audio visualization"
className="rounded-full transition-[scale,background-color] duration-150 ease-out active:scale-[0.96]"
size="icon-lg"
type="button"
variant="ghost"
>
<AudioWaveformIcon
className="size-5 text-muted-foreground"
strokeWidth={1.5}
/>
</Button>
<Button
aria-label="Send message"
className="rounded-full transition-[scale,opacity,background-color] duration-150 ease-out active:scale-[0.96] disabled:opacity-40"
disabled={!message.trim()}
size="icon-lg"
type="submit"
>
<SendIcon className="size-4.5" strokeWidth={1.5} />
</Button>
</div>
</div>
</div>
</form>
</div>
)
}npx shadcn@latest add https://blocks.so/r/ai-01.jsonAI Chat with Model Selection
"use client"
import {
ArrowUpIcon,
CloudIcon,
FileTextIcon,
GaugeIcon,
ScanSearchIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useRef, useState } from "react"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
const PROMPTS = [
{
icon: FileTextIcon,
text: "Write documentation",
prompt:
"Write comprehensive documentation for this codebase, including setup instructions, API references, and usage examples.",
},
{
icon: GaugeIcon,
text: "Optimize performance",
prompt:
"Analyze the codebase for performance bottlenecks and suggest optimizations to improve loading times and runtime efficiency.",
},
{
icon: TriangleAlertIcon,
text: "Find and fix 3 bugs",
prompt:
"Scan through the codebase to identify and fix 3 critical bugs, providing detailed explanations for each fix.",
},
]
const MODELS = [
{
value: "gpt-5",
name: "GPT-5",
description: "Most advanced model",
max: true,
},
{
value: "gpt-4o",
name: "GPT-4o",
description: "Fast and capable",
},
{
value: "claude-3.5",
name: "Claude 3.5 Sonnet",
description: "Great for coding tasks",
},
]
export default function Ai02() {
const [inputValue, setInputValue] = useState("")
const [modelValue, setModelValue] = useState(MODELS[0].value)
const selectedModel = MODELS.find((m) => m.value === modelValue) ?? MODELS[0]
const inputRef = useRef<HTMLTextAreaElement>(null)
const handlePromptClick = (prompt: string) => {
if (inputRef.current) {
inputRef.current.value = prompt
setInputValue(prompt)
inputRef.current.focus()
}
}
const maxBadge = (
<span className="inline-flex h-[15px] items-center rounded-[4px] border border-border/80 bg-muted/40 px-[5px]">
<span
className="font-bold text-[9px] uppercase leading-none tracking-[0.06em]"
style={{
background:
"linear-gradient(to right, rgb(129, 161, 193), rgb(125, 124, 155))",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
}}
>
MAX
</span>
</span>
)
return (
<div className="flex w-[calc(42rem-5rem)] flex-col gap-4">
<div className="flex min-h-[120px] cursor-text flex-col rounded-2xl border border-border bg-card shadow-lg">
<div className="relative max-h-[258px] flex-1 overflow-y-auto">
<Textarea
className="min-h-[48.4px] w-full resize-none whitespace-pre-wrap break-words border-0 bg-transparent! p-3 text-[16px] text-foreground shadow-none outline-none transition-[padding] duration-200 ease-in-out focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(e) => setInputValue(e.target.value)}
placeholder="Ask anything"
ref={inputRef}
value={inputValue}
/>
</div>
<div className="flex min-h-[40px] items-center gap-2 p-2 pb-1">
<div className="flex size-7 items-center justify-center rounded-full bg-muted">
<CloudIcon className="size-4 text-muted-foreground" />
</div>
<Select
onValueChange={(value) => value && setModelValue(value)}
value={modelValue}
>
<SelectTrigger
className="h-7 rounded-full border-transparent bg-transparent px-2 font-medium text-foreground text-sm shadow-none transition-[background-color,color] hover:bg-muted aria-expanded:bg-muted dark:bg-transparent dark:hover:bg-muted"
size="sm"
>
<SelectValue>
<span className="flex items-center gap-1.5">
<span>{selectedModel.name}</span>
{selectedModel.max && maxBadge}
</span>
</SelectValue>
</SelectTrigger>
<SelectContent
align="start"
alignItemWithTrigger={false}
className="w-64 p-1"
sideOffset={6}
>
{MODELS.map((model) => (
<SelectItem
className="rounded-md py-1.5"
key={model.value}
value={model.value}
>
<span className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 font-medium">
{model.name}
{model.max && maxBadge}
</span>
<span className="text-muted-foreground text-xs">
{model.description}
</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-1.5">
<Button
aria-label="Attach images"
className="rounded-full text-muted-foreground transition-[background-color,color] hover:text-foreground"
size="icon-sm"
title="Attach images"
variant="ghost"
>
<ScanSearchIcon className="size-4.5" />
</Button>
<Button
aria-label="Send message"
className="rounded-full transition-[background-color,scale,opacity] active:not-disabled:scale-[0.96]"
disabled={!inputValue}
size="icon-sm"
>
<ArrowUpIcon className="size-4" />
</Button>
</div>
</div>
</div>
<div className="flex flex-wrap justify-center gap-2">
{PROMPTS.map((button) => {
const ComponentIcon = button.icon
return (
<Button
className="h-8 gap-1.5 rounded-full border-border bg-background pr-3.5 pl-2.5 font-normal text-foreground text-sm shadow-[0_1px_2px_oklch(0_0_0/0.05)] transition-[background-color,color,scale,box-shadow] duration-150 ease-out hover:bg-muted active:scale-[0.96] dark:bg-muted/40 dark:hover:bg-muted"
key={button.text}
onClick={() => handlePromptClick(button.prompt)}
variant="outline"
>
<ComponentIcon
className="size-4 text-muted-foreground transition-colors duration-150 group-hover/button:text-foreground"
strokeWidth={1.75}
/>
{button.text}
</Button>
)
})}
</div>
</div>
)
}npx shadcn@latest add https://blocks.so/r/ai-02.jsonAI Chat Compact Interface
"use client"
import {
BotIcon,
ChevronDownIcon,
CircleDashedIcon,
CircleDotDashedIcon,
CircleIcon,
CloudIcon,
CodeIcon,
GlobeIcon,
HistoryIcon,
LaptopIcon,
PaperclipIcon,
PlusIcon,
SendIcon,
UserIcon,
WandIcon,
ZapIcon,
} from "lucide-react"
import { useRef, useState } from "react"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
export default function Ai03() {
const [input, setInput] = useState("")
const [selectedModel, setSelectedModel] = useState("Local")
const [selectedAgent, setSelectedAgent] = useState("Agent")
const [selectedPerformance, setSelectedPerformance] = useState("High")
const [autoMode, setAutoMode] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (input.trim()) {
}
}
return (
<div className="w-xl">
<div className="overflow-hidden rounded-2xl border border-border bg-background">
<input
className="sr-only"
multiple
onChange={(e) => {}}
ref={fileInputRef}
type="file"
/>
<div className="grow px-3 pt-3 pb-2">
<form onSubmit={handleSubmit}>
<Textarea
className="max-h-[25vh] min-h-10 w-full resize-none border-0 border-none bg-transparent! p-0 text-foreground text-sm placeholder-muted-foreground shadow-none outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
onChange={(e) => setInput(e.target.value)}
onInput={(e) => {
const target = e.target as HTMLTextAreaElement
target.style.height = "auto"
target.style.height = target.scrollHeight + "px"
}}
placeholder="Ask anything"
rows={1}
value={input}
/>
</form>
</div>
<div className="mb-2 flex items-center justify-between px-2">
<div className="flex items-center gap-1">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
className="h-7 w-7 rounded-full border border-border p-0 hover:bg-accent"
size="sm"
variant="ghost"
/>
}
>
<PlusIcon className="size-3" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="max-w-xs rounded-2xl p-1.5"
>
<DropdownMenuGroup className="space-y-1">
<DropdownMenuItem
className="rounded-[calc(1rem-6px)] text-xs"
onClick={() => fileInputRef.current?.click()}
>
<PaperclipIcon className="opacity-60" size={16} />
Attach Files
</DropdownMenuItem>
<DropdownMenuItem
className="rounded-[calc(1rem-6px)] text-xs"
onClick={() => {}}
>
<CodeIcon className="opacity-60" size={16} />
Code Interpreter
</DropdownMenuItem>
<DropdownMenuItem
className="rounded-[calc(1rem-6px)] text-xs"
onClick={() => {}}
>
<GlobeIcon className="opacity-60" size={16} />
Web Search
</DropdownMenuItem>
<DropdownMenuItem
className="rounded-[calc(1rem-6px)] text-xs"
onClick={() => {}}
>
<HistoryIcon className="opacity-60" size={16} />
Chat History
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<Button
className={cn(
"h-7 rounded-full border border-border px-2 hover:bg-accent ",
{
"border-primary/30 bg-primary/10 text-primary": autoMode,
"text-muted-foreground": !autoMode,
}
)}
onClick={() => setAutoMode(!autoMode)}
size="sm"
variant="ghost"
>
<WandIcon className="size-3" />
<span className="text-xs">Auto</span>
</Button>
</div>
<div>
<Button
className="size-7 rounded-full bg-primary p-0 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!input.trim()}
onClick={handleSubmit}
type="submit"
>
<SendIcon className="size-3 fill-primary" />
</Button>
</div>
</div>
</div>
<div className="flex items-center gap-0 pt-2">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
className="h-6 rounded-full border border-transparent px-2 text-muted-foreground text-xs hover:bg-accent"
size="sm"
variant="ghost"
/>
}
>
<LaptopIcon className="size-3" />
<span>{selectedModel}</span>
<ChevronDownIcon className="size-3" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="max-w-xs rounded-2xl border-border bg-popover p-1.5"
>
<DropdownMenuGroup className="space-y-1">
<DropdownMenuItem
className="rounded-[calc(1rem-6px)] text-xs"
onClick={() => setSelectedModel("Local")}
>
<LaptopIcon className="opacity-60" size={16} />
Local
</DropdownMenuItem>
<DropdownMenuItem
className="rounded-[calc(1rem-6px)] text-xs"
onClick={() => setSelectedModel("Cloud")}
>
<CloudIcon className="opacity-60" size={16} />
Cloud
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
className="h-6 rounded-full border border-transparent px-2 text-muted-foreground text-xs hover:bg-accent"
size="sm"
variant="ghost"
/>
}
>
<UserIcon className="size-3" />
<span>{selectedAgent}</span>
<ChevronDownIcon className="size-3" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="max-w-xs rounded-2xl border-border bg-popover p-1.5"
>
<DropdownMenuGroup className="space-y-1">
<DropdownMenuItem
className="rounded-[calc(1rem-6px)] text-xs"
onClick={() => setSelectedAgent("Agent")}
>
<UserIcon className="opacity-60" size={16} />
Agent
</DropdownMenuItem>
<DropdownMenuItem
className="rounded-[calc(1rem-6px)] text-xs"
onClick={() => setSelectedAgent("Assistant")}
>
<BotIcon className="opacity-60" size={16} />
Assistant
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
className="h-6 rounded-full border border-transparent px-2 text-muted-foreground text-xs hover:bg-accent"
size="sm"
variant="ghost"
/>
}
>
<ZapIcon className="size-3" />
<span>{selectedPerformance}</span>
<ChevronDownIcon className="size-3" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="max-w-xs rounded-2xl border-border bg-popover p-1.5"
>
<DropdownMenuGroup className="space-y-1">
<DropdownMenuItem
className="rounded-[calc(1rem-6px)] text-xs"
onClick={() => setSelectedPerformance("High")}
>
<CircleIcon className="opacity-60" size={16} />
High
</DropdownMenuItem>
<DropdownMenuItem
className="rounded-[calc(1rem-6px)] text-xs"
onClick={() => setSelectedPerformance("Medium")}
>
<CircleDotDashedIcon className="opacity-60" size={16} />
Medium
</DropdownMenuItem>
<DropdownMenuItem
className="rounded-[calc(1rem-6px)] text-xs"
onClick={() => setSelectedPerformance("Low")}
>
<CircleDashedIcon className="opacity-60" size={16} />
Low
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<div className="flex-1" />
</div>
</div>
)
}npx shadcn@latest add https://blocks.so/r/ai-03.jsonAI Chat with File Attachments
"use client"
import {
ArrowUpIcon,
CameraIcon,
CirclePlusIcon,
ClipboardIcon,
FileUpIcon,
HistoryIcon,
LayoutDashboardIcon,
LayoutTemplateIcon,
LinkIcon,
PaperclipIcon,
PlayIcon,
PlusIcon,
SlidersHorizontalIcon,
SparklesIcon,
XIcon,
} from "lucide-react"
import Image from "next/image"
import { useRef, useState } from "react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import { cn } from "@/lib/utils"
interface AttachedFile {
id: string
name: string
file: File
preview?: string
}
function FigmaIcon({
size = 24,
...props
}: React.ComponentProps<"svg"> & { size?: number }) {
return (
<svg
aria-hidden="true"
fill="none"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
viewBox="0 0 24 24"
width={size}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path d="M12 12a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
<path d="M6 6a3 3 0 0 1 3 -3h6a3 3 0 0 1 3 3a3 3 0 0 1 -3 3h-6a3 3 0 0 1 -3 -3" />
<path d="M9 9a3 3 0 0 0 0 6h3m-3 0a3 3 0 1 0 3 3v-15" />
</svg>
)
}
const ACTIONS = [
{ id: "clone-screenshot", icon: CameraIcon, label: "Clone a Screenshot" },
{ id: "import-figma", icon: FigmaIcon, label: "Import from Figma" },
{ id: "upload-project", icon: FileUpIcon, label: "Upload a Project" },
{ id: "landing-page", icon: LayoutDashboardIcon, label: "Landing Page" },
]
export default function Ai04({
onSubmit,
}: {
onSubmit?: (prompt: string) => void
}) {
const [prompt, setPrompt] = useState("")
const [isDragOver, setIsDragOver] = useState(false)
const [attachedFiles, setAttachedFiles] = useState<AttachedFile[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
const [settings, setSettings] = useState({
autoComplete: true,
streaming: false,
showHistory: false,
})
const generateFileId = () => Math.random().toString(36).substring(7)
const processFiles = (files: File[]) => {
for (const file of files) {
const fileId = generateFileId()
const attachedFile: AttachedFile = {
id: fileId,
name: file.name,
file,
}
if (file.type.startsWith("image/")) {
const reader = new FileReader()
reader.onload = () => {
setAttachedFiles((prev) =>
prev.map((f) =>
f.id === fileId ? { ...f, preview: reader.result as string } : f
)
)
}
reader.readAsDataURL(file)
}
setAttachedFiles((prev) => [...prev, attachedFile])
}
}
const submitPrompt = () => {
if (prompt.trim() && onSubmit) {
onSubmit(prompt.trim())
setPrompt("")
}
}
const updateSetting = (key: keyof typeof settings, value: boolean) => {
setSettings((prev) => ({ ...prev, [key]: value }))
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
submitPrompt()
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragOver(false)
const files = Array.from(e.dataTransfer.files)
if (files.length > 0) {
processFiles(files)
}
}
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setPrompt(e.target.value)
}
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault()
submitPrompt()
}
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
processFiles(files)
if (fileInputRef.current) {
fileInputRef.current.value = ""
}
}
const handleRemoveFile = (fileId: string) => {
setAttachedFiles((prev) => prev.filter((file) => file.id !== fileId))
}
return (
<div className="mx-auto flex w-full flex-col gap-4">
<h1 className="text-balance text-pretty text-center font-heading font-semibold text-[29px] text-foreground tracking-tighter sm:text-[32px] md:text-[46px]">
Prompt. Refine. Ship.
</h1>
<h2 className="-my-5 text-balance pb-4 text-center text-muted-foreground text-xl">
Build real, working software just by describing it
</h2>
<div className="relative z-10 mx-auto flex w-full max-w-2xl flex-col content-center">
<form
className="overflow-visible rounded-xl border p-2 transition-colors duration-200 focus-within:border-ring"
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onSubmit={handleSubmit}
>
{attachedFiles.length > 0 && (
<div className="relative mb-2 flex w-fit items-center gap-2 overflow-hidden">
{attachedFiles.map((file) => (
<Badge
className="group relative h-6 max-w-30 cursor-pointer overflow-hidden px-0 text-[13px] transition-colors hover:bg-accent"
key={file.id}
variant="outline"
>
<span className="flex h-full items-center gap-1.5 overflow-hidden pl-1 font-normal">
<div className="relative flex h-4 min-w-4 items-center justify-center">
{file.preview ? (
<Image
alt={file.name}
className="absolute inset-0 h-4 w-4 rounded border object-cover"
height={16}
src={file.preview}
width={16}
/>
) : (
<PaperclipIcon className="opacity-60" size={12} />
)}
</div>
<span className="inline overflow-hidden truncate pr-1.5">
{file.name}
</span>
</span>
<button
className="absolute right-1 z-10 rounded-sm p-0.5 text-muted-foreground opacity-0 focus-visible:bg-accent focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-background group-hover:opacity-100"
onClick={() => handleRemoveFile(file.id)}
type="button"
>
<XIcon size={12} />
</button>
</Badge>
))}
</div>
)}
<Textarea
className="max-h-50 min-h-12 resize-none rounded-none border-none bg-transparent! p-0 text-sm shadow-none focus-visible:border-transparent focus-visible:ring-0"
onChange={handleTextareaChange}
onKeyDown={handleKeyDown}
placeholder="Ask anything"
value={prompt}
/>
<div className="flex items-center gap-1">
<div className="flex items-end gap-0.5 sm:gap-1">
<input
className="sr-only"
multiple
onChange={handleFileSelect}
ref={fileInputRef}
type="file"
/>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
aria-label="Add attachments"
className="ml-[-2px] rounded-md"
size="icon-sm"
type="button"
variant="ghost"
/>
}
>
<PlusIcon size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="max-w-xs rounded-2xl p-1.5"
>
<DropdownMenuGroup className="space-y-1">
<DropdownMenuItem
className="rounded-md text-xs"
onClick={() => fileInputRef.current?.click()}
>
<div className="flex items-center gap-2">
<PaperclipIcon
className="text-muted-foreground"
size={16}
/>
<span>Attach Files</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="rounded-md text-xs">
<div className="flex items-center gap-2">
<LinkIcon className="text-muted-foreground" size={16} />
<span>Import from URL</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="rounded-md text-xs">
<div className="flex items-center gap-2">
<ClipboardIcon
className="text-muted-foreground"
size={16}
/>
<span>Paste from Clipboard</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem className="rounded-md text-xs">
<div className="flex items-center gap-2">
<LayoutTemplateIcon
className="text-muted-foreground"
size={16}
/>
<span>Use Template</span>
</div>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
aria-label="Adjust settings"
className="rounded-md"
size="icon-sm"
type="button"
variant="ghost"
/>
}
>
<SlidersHorizontalIcon size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-48 rounded-2xl p-3"
>
<DropdownMenuGroup className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<SparklesIcon
className="text-muted-foreground"
size={16}
/>
<Label className="text-xs">Auto-complete</Label>
</div>
<Switch
checked={settings.autoComplete}
className="scale-75"
onCheckedChange={(value) =>
updateSetting("autoComplete", value)
}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<PlayIcon className="text-muted-foreground" size={16} />
<Label className="text-xs">Streaming</Label>
</div>
<Switch
checked={settings.streaming}
className="scale-75"
onCheckedChange={(value) =>
updateSetting("streaming", value)
}
/>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<HistoryIcon
className="text-muted-foreground"
size={16}
/>
<Label className="text-xs">Show History</Label>
</div>
<Switch
checked={settings.showHistory}
className="scale-75"
onCheckedChange={(value) =>
updateSetting("showHistory", value)
}
/>
</div>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="ml-auto flex items-center gap-0.5 sm:gap-1">
<Button
aria-label="Send message"
className="rounded-md"
disabled={!prompt.trim()}
size="icon-sm"
type="submit"
variant="default"
>
<ArrowUpIcon size={16} />
</Button>
</div>
</div>
<div
className={cn(
"pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-[inherit] border border-border border-dashed bg-muted text-foreground text-sm transition-opacity duration-200",
isDragOver ? "opacity-100" : "opacity-0"
)}
>
<span className="flex w-full items-center justify-center gap-1 font-medium">
<CirclePlusIcon className="min-w-4" size={16} />
Drop files here to add as attachments
</span>
</div>
</form>
</div>
<div className="mx-auto flex min-h-0 max-w-250 shrink-0 flex-wrap items-center justify-center gap-3">
{ACTIONS.map((action) => (
<Button
className="gap-2 rounded-full"
key={action.id}
size="sm"
variant="outline"
>
<action.icon size={16} />
{action.label}
</Button>
))}
</div>
</div>
)
}npx shadcn@latest add https://blocks.so/r/ai-04.jsonAI Elements Chat
"use client"
// Install AI Elements components:
// npx ai-elements@latest add conversation message prompt-input
import {
PaperclipIcon,
PlusIcon,
SlidersHorizontalIcon,
ZapIcon,
} from "lucide-react"
import type { ChatStatus } from "ai"
import { useState } from "react"
import {
Conversation,
ConversationContent,
ConversationScrollButton,
} from "./components/conversation"
import { Message, MessageContent, MessageResponse } from "./components/message"
import {
PromptInput,
PromptInputButton,
PromptInputFooter,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
} from "./components/prompt-input"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
interface DemoMessage {
id: string
role: "user" | "assistant"
content: string
}
const INITIAL_MESSAGES: DemoMessage[] = [
{
id: "intro",
role: "assistant",
content:
"Hi, I am your workspace assistant. I can draft copy, summarize documents, and turn notes into tasks. What are you working on?",
},
{
id: "question",
role: "user",
content: "Summarize the Q3 launch plan in three bullets.",
},
{
id: "answer",
role: "assistant",
content:
"- **Ship the new onboarding** by Sept 18 with an A/B test on pricing.\n- **Open EU and APAC** with localized checkout.\n- **Launch the partner API** in private beta with 12 partners.",
},
]
const SUGGESTIONS = [
"Draft a launch announcement",
"List open risks",
"Turn this into tasks",
]
const RESPONSES = [
"Here is a quick outline you can reuse:\n\n1. Swap the mock response with a real API call.\n2. Stream tokens into `MessageResponse`.\n3. Keep the layout as-is for a consistent UI.",
"If you want multi-model support, add a small model selector to the prompt footer and pass the selection to your backend.",
"You can also inject tools like file upload or voice input by adding buttons to the prompt footer.",
]
export default function Ai05() {
const [messages, setMessages] = useState<DemoMessage[]>(INITIAL_MESSAGES)
const [inputValue, setInputValue] = useState("")
const [status, setStatus] = useState<ChatStatus>("ready")
const handleSend = (text: string) => {
const trimmed = text.trim()
if (!trimmed || status !== "ready") {
return
}
setMessages((prev) => [
...prev,
{ id: `user-${Date.now()}`, role: "user", content: trimmed },
])
setInputValue("")
setStatus("submitted")
window.setTimeout(() => {
setMessages((prev) => [
...prev,
{
id: `assistant-${Date.now()}`,
role: "assistant",
content: RESPONSES[messages.length % RESPONSES.length],
},
])
setStatus("ready")
}, 1200)
}
const handleReset = () => {
setMessages(INITIAL_MESSAGES.slice(0, 1))
setInputValue("")
setStatus("ready")
}
return (
<div className="w-full px-4">
<div className="mx-auto flex h-[560px] w-full max-w-2xl flex-col overflow-hidden rounded-3xl bg-card shadow-[0_0_0_1px_oklch(0_0_0/0.06),0_1px_2px_oklch(0_0_0/0.04),0_12px_32px_-12px_oklch(0_0_0/0.18)] dark:shadow-[0_0_0_1px_oklch(1_0_0/0.1),0_12px_32px_-12px_oklch(0_0_0/0.6)]">
<header className="flex items-center justify-between gap-4 px-5 py-4">
<div className="flex flex-col">
<span className="font-semibold text-sm leading-tight">
Assistant
</span>
<span className="inline-flex items-center gap-1.5 text-muted-foreground text-xs">
<span className="size-1.5 rounded-full bg-emerald-500" />
Online
</span>
</div>
<div className="flex items-center gap-1">
<Button
className="h-8 gap-1.5 px-2.5 text-xs"
onClick={handleReset}
size="sm"
variant="ghost"
>
<PlusIcon className="size-4" strokeWidth={1.5} />
New chat
</Button>
<Button
aria-label="Settings"
className="size-8"
size="icon"
title="Settings"
variant="ghost"
>
<SlidersHorizontalIcon className="size-4" strokeWidth={1.5} />
</Button>
</div>
</header>
<Conversation className="border-border/60 border-t">
<ConversationContent className="gap-5 px-5 py-5">
{messages.map((message) => (
<Message from={message.role} key={message.id}>
<MessageContent
className={cn(
"leading-relaxed",
message.role === "assistant" && "max-w-prose",
message.role === "user" &&
"group-[.is-user]:rounded-2xl group-[.is-user]:rounded-br-md group-[.is-user]:bg-primary group-[.is-user]:text-primary-foreground"
)}
>
{message.role === "assistant" ? (
<MessageResponse>{message.content}</MessageResponse>
) : (
<p className="whitespace-pre-wrap text-pretty">
{message.content}
</p>
)}
</MessageContent>
</Message>
))}
{status === "submitted" && (
<Message from="assistant">
<output
aria-label="Assistant is typing"
className="flex h-7 items-center gap-1"
>
{[0, 1, 2].map((dot) => (
<span
className="size-1.5 animate-bounce rounded-full bg-muted-foreground/60"
key={dot}
style={{ animationDelay: `${dot * 150}ms` }}
/>
))}
</output>
</Message>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
<div className="flex flex-col gap-3 p-3">
<div className="flex flex-wrap gap-2 px-1">
{SUGGESTIONS.map((suggestion) => (
<button
className="rounded-full border border-border/70 bg-background px-3 py-1.5 text-muted-foreground text-xs transition-[color,background-color,scale] hover:bg-muted hover:text-foreground active:scale-[0.96] disabled:pointer-events-none disabled:opacity-50"
disabled={status !== "ready"}
key={suggestion}
onClick={() => handleSend(suggestion)}
type="button"
>
{suggestion}
</button>
))}
</div>
<PromptInput
className="[&>[data-slot=input-group]]:rounded-xl [&>[data-slot=input-group]]:border-border [&>[data-slot=input-group]]:bg-muted/40 [&>[data-slot=input-group]]:shadow-none"
onSubmit={(message) => handleSend(message.text)}
>
<PromptInputTextarea
onChange={(event) => setInputValue(event.currentTarget.value)}
placeholder="Message Assistant..."
value={inputValue}
/>
<PromptInputFooter>
<PromptInputTools>
<PromptInputButton aria-label="Attach file">
<PaperclipIcon className="size-4" strokeWidth={1.5} />
</PromptInputButton>
<PromptInputButton aria-label="Quick prompt">
<ZapIcon className="size-4" strokeWidth={1.5} />
</PromptInputButton>
</PromptInputTools>
<PromptInputSubmit
className="rounded-full transition-[scale,opacity] active:scale-[0.96]"
disabled={!inputValue.trim() || status !== "ready"}
status={status}
/>
</PromptInputFooter>
</PromptInput>
</div>
</div>
</div>
)
}"use client"
import { ArrowDownIcon } from "lucide-react"
import type { ComponentProps } from "react"
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
export type ConversationProps = ComponentProps<typeof StickToBottom>
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
)
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
)
export type ConversationScrollButtonProps = ComponentProps<typeof Button>
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext()
if (isAtBottom) {
return null
}
return (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
className
)}
onClick={() => scrollToBottom()}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
}"use client"
import type { UIMessage } from "ai"
import type { ComponentProps, HTMLAttributes } from "react"
import { memo } from "react"
import { Streamdown } from "streamdown"
import { cn } from "@/lib/utils"
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"]
}
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[95%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className
)}
{...props}
/>
)
export type MessageContentProps = HTMLAttributes<HTMLDivElement>
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"flex w-fit min-w-0 max-w-full flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"group-[.is-assistant]:text-foreground",
className
)}
{...props}
>
{children}
</div>
)
export type MessageResponseProps = ComponentProps<typeof Streamdown>
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
{...props}
/>
),
(prev, next) => prev.children === next.children
)
MessageResponse.displayName = "MessageResponse""use client"
import type { ChatStatus, FileUIPart } from "ai"
import {
CornerDownLeftIcon,
Loader2Icon,
SquareIcon,
XIcon,
} from "lucide-react"
import {
Children,
type ComponentProps,
type FormEvent,
type HTMLAttributes,
type KeyboardEventHandler,
useState,
} from "react"
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from "@/components/ui/input-group"
import { cn } from "@/lib/utils"
export type PromptInputMessage = {
text: string
files: FileUIPart[]
}
export type PromptInputProps = Omit<
HTMLAttributes<HTMLFormElement>,
"onSubmit"
> & {
onSubmit: (
message: PromptInputMessage,
event: FormEvent<HTMLFormElement>
) => void | Promise<void>
}
export const PromptInput = ({
className,
onSubmit,
children,
...props
}: PromptInputProps) => (
<form
className={cn("w-full", className)}
onSubmit={(event) => {
event.preventDefault()
const data = new FormData(event.currentTarget)
void onSubmit(
{ text: String(data.get("message") ?? ""), files: [] },
event
)
}}
{...props}
>
<InputGroup className="overflow-hidden">{children}</InputGroup>
</form>
)
export type PromptInputTextareaProps = ComponentProps<typeof InputGroupTextarea>
export const PromptInputTextarea = ({
className,
placeholder = "What would you like to know?",
...props
}: PromptInputTextareaProps) => {
const [isComposing, setIsComposing] = useState(false)
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = (event) => {
props.onKeyDown?.(event)
if (
event.defaultPrevented ||
event.key !== "Enter" ||
event.shiftKey ||
isComposing ||
event.nativeEvent.isComposing
) {
return
}
event.preventDefault()
const button = event.currentTarget.form?.querySelector(
'button[type="submit"]'
)
if (!(button instanceof HTMLButtonElement) || !button.disabled) {
event.currentTarget.form?.requestSubmit()
}
}
return (
<InputGroupTextarea
className={cn("field-sizing-content max-h-48 min-h-16", className)}
name="message"
onCompositionEnd={() => setIsComposing(false)}
onCompositionStart={() => setIsComposing(true)}
{...props}
onKeyDown={handleKeyDown}
/>
)
}
export type PromptInputFooterProps = Omit<
ComponentProps<typeof InputGroupAddon>,
"align"
>
export const PromptInputFooter = ({
className,
...props
}: PromptInputFooterProps) => (
<InputGroupAddon
align="block-end"
className={cn("justify-between gap-1", className)}
{...props}
/>
)
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>
export const PromptInputTools = ({
className,
...props
}: PromptInputToolsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props} />
)
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton>
export const PromptInputButton = ({
variant = "ghost",
className,
size,
...props
}: PromptInputButtonProps) => (
<InputGroupButton
className={cn(className)}
size={size ?? (Children.count(props.children) > 1 ? "sm" : "icon-sm")}
type="button"
variant={variant}
{...props}
/>
)
export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
status?: ChatStatus
}
export const PromptInputSubmit = ({
className,
variant = "default",
size = "icon-sm",
status,
children,
...props
}: PromptInputSubmitProps) => {
let icon = <CornerDownLeftIcon className="size-4" />
if (status === "submitted") {
icon = <Loader2Icon className="size-4 animate-spin" />
} else if (status === "streaming") {
icon = <SquareIcon className="size-4" />
} else if (status === "error") {
icon = <XIcon className="size-4" />
}
return (
<InputGroupButton
aria-label="Submit"
className={cn(className)}
size={size}
type="submit"
variant={variant}
{...props}
>
{children ?? icon}
</InputGroupButton>
)
}npx shadcn@latest add https://blocks.so/r/ai-05.json