143 lines
6.4 KiB
TypeScript
143 lines
6.4 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { useState, useRef, useEffect } from "react";
|
||
|
|
import { aiAssistantApi } from "@/lib/api/aiAssistant";
|
||
|
|
import { Send, Loader2, Bot, User, Trash2, Settings2 } from "lucide-react";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Textarea } from "@/components/ui/textarea";
|
||
|
|
import { Card, CardContent } from "@/components/ui/card";
|
||
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
|
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import { toast } from "sonner";
|
||
|
|
|
||
|
|
type ChatMessage = { role: "user" | "assistant"; content: string };
|
||
|
|
type ModelItem = { id: string };
|
||
|
|
|
||
|
|
export default function AiAssistantChatPage() {
|
||
|
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||
|
|
const [input, setInput] = useState("");
|
||
|
|
const [loading, setLoading] = useState(false);
|
||
|
|
const [models, setModels] = useState<ModelItem[]>([]);
|
||
|
|
const [selectedModel, setSelectedModel] = useState("gemini-2.0-flash");
|
||
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
aiAssistantApi.get("/models").then((res) => {
|
||
|
|
const list = (res.data?.data as ModelItem[]) ?? [];
|
||
|
|
setModels(list);
|
||
|
|
if (list.length && !list.some((m) => m.id === selectedModel)) setSelectedModel(list[0].id);
|
||
|
|
}).catch(() => {});
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||
|
|
}, [messages]);
|
||
|
|
|
||
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
||
|
|
e.preventDefault();
|
||
|
|
if (!input.trim() || loading) return;
|
||
|
|
const userMsg: ChatMessage = { role: "user", content: input.trim() };
|
||
|
|
setMessages((prev) => [...prev, userMsg]);
|
||
|
|
setInput("");
|
||
|
|
setLoading(true);
|
||
|
|
try {
|
||
|
|
const res = await aiAssistantApi.post("/chat/completions", {
|
||
|
|
model: selectedModel,
|
||
|
|
messages: [...messages, userMsg].map((m) => ({ role: m.role, content: m.content })),
|
||
|
|
});
|
||
|
|
const content = (res.data as { choices?: Array<{ message?: { content?: string } }> })?.choices?.[0]?.message?.content ?? "";
|
||
|
|
setMessages((prev) => [...prev, { role: "assistant", content }]);
|
||
|
|
} catch (err: unknown) {
|
||
|
|
const msg = err && typeof err === "object" && "response" in err
|
||
|
|
? (err as { response?: { data?: { error?: { message?: string } } } }).response?.data?.error?.message
|
||
|
|
: null;
|
||
|
|
toast.error(msg ?? "AI 응답 실패");
|
||
|
|
setMessages((prev) => prev.slice(0, -1));
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex h-[calc(100vh-8rem)] flex-col">
|
||
|
|
<div className="mb-4 flex items-center justify-between">
|
||
|
|
<div>
|
||
|
|
<h1 className="text-3xl font-bold tracking-tight">AI 채팅</h1>
|
||
|
|
<p className="text-muted-foreground mt-1">AI Assistant와 대화하세요.</p>
|
||
|
|
</div>
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||
|
|
<SelectTrigger className="w-[200px]">
|
||
|
|
<Settings2 className="mr-2 h-4 w-4" />
|
||
|
|
<SelectValue placeholder="모델 선택" />
|
||
|
|
</SelectTrigger>
|
||
|
|
<SelectContent>
|
||
|
|
{models.map((m) => (
|
||
|
|
<SelectItem key={m.id} value={m.id}>{m.id}</SelectItem>
|
||
|
|
))}
|
||
|
|
{models.length === 0 && <SelectItem value="gemini-2.0-flash">gemini-2.0-flash</SelectItem>}
|
||
|
|
</SelectContent>
|
||
|
|
</Select>
|
||
|
|
<Button variant="outline" size="icon" onClick={() => setMessages([])}>
|
||
|
|
<Trash2 className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<Card className="flex flex-1 flex-col overflow-hidden">
|
||
|
|
<ScrollArea className="flex-1 p-4">
|
||
|
|
{messages.length === 0 ? (
|
||
|
|
<div className="flex h-full flex-col items-center justify-center text-center">
|
||
|
|
<div className="bg-primary/10 mb-4 flex h-16 w-16 items-center justify-center rounded-full">
|
||
|
|
<Bot className="text-primary h-8 w-8" />
|
||
|
|
</div>
|
||
|
|
<h3 className="text-lg font-medium">AI Assistant</h3>
|
||
|
|
<p className="text-muted-foreground mt-1 max-w-sm">무엇이든 물어보세요.</p>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-4">
|
||
|
|
{messages.map((msg, i) => (
|
||
|
|
<div key={i} className={cn("flex gap-3", msg.role === "user" && "flex-row-reverse")}>
|
||
|
|
<Avatar className="h-8 w-8 shrink-0">
|
||
|
|
<AvatarFallback className={cn(msg.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted")}>
|
||
|
|
{msg.role === "user" ? <User className="h-4 w-4" /> : <Bot className="h-4 w-4" />}
|
||
|
|
</AvatarFallback>
|
||
|
|
</Avatar>
|
||
|
|
<div className={cn("max-w-[80%] rounded-lg px-4 py-2", msg.role === "user" ? "bg-primary text-primary-foreground" : "bg-muted")}>
|
||
|
|
<p className="whitespace-pre-wrap text-sm">{msg.content}</p>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
{loading && (
|
||
|
|
<div className="flex gap-3">
|
||
|
|
<Avatar className="h-8 w-8 shrink-0">
|
||
|
|
<AvatarFallback className="bg-muted"><Bot className="h-4 w-4" /></AvatarFallback>
|
||
|
|
</Avatar>
|
||
|
|
<div className="rounded-lg bg-muted px-4 py-2"><Loader2 className="h-4 w-4 animate-spin" /></div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<div ref={messagesEndRef} />
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</ScrollArea>
|
||
|
|
<CardContent className="border-t p-4">
|
||
|
|
<form onSubmit={handleSubmit} className="flex gap-2">
|
||
|
|
<Textarea
|
||
|
|
value={input}
|
||
|
|
onChange={(e) => setInput(e.target.value)}
|
||
|
|
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && (e.preventDefault(), handleSubmit(e as unknown as React.FormEvent))}
|
||
|
|
placeholder="메시지 입력 (Shift+Enter 줄바꿈)"
|
||
|
|
className="max-h-[200px] min-h-[60px] resize-none"
|
||
|
|
disabled={loading}
|
||
|
|
/>
|
||
|
|
<Button type="submit" size="icon" className="h-[60px] w-[60px]" disabled={loading || !input.trim()}>
|
||
|
|
{loading ? <Loader2 className="h-5 w-5 animate-spin" /> : <Send className="h-5 w-5" />}
|
||
|
|
</Button>
|
||
|
|
</form>
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|