ERP-node/frontend/app/(main)/admin/aiAssistant/chat/page.tsx

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>
);
}