Add AI-powered chatbot for media outlets

Integrate an AI chatbot feature allowing users to interact with media outlets, fetch chat history, and generate AI responses using OpenAI.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 9a264234-c5d7-4dcc-adf3-a954b149b30d
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/3df548ff-50ae-432f-9be4-25d34eccc983/9a264234-c5d7-4dcc-adf3-a954b149b30d/d35d7YU
This commit is contained in:
kimjaehyeon0101
2025-10-15 02:06:25 +00:00
parent a125b37579
commit fb1d150554
9 changed files with 353 additions and 6 deletions

View File

@ -0,0 +1,134 @@
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { ScrollArea } from "@/components/ui/scroll-area";
import { MessageCircle, Send } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient";
import type { ChatMessage, MediaOutlet } from "@shared/schema";
interface ChatbotModalProps {
outlet: MediaOutlet;
isOpen: boolean;
onClose: () => void;
}
export default function ChatbotModal({ outlet, isOpen, onClose }: ChatbotModalProps) {
const [message, setMessage] = useState("");
const { data: messages = [], isLoading } = useQuery<ChatMessage[]>({
queryKey: [`/api/media-outlets/${outlet.slug}/chat`],
enabled: isOpen
});
const sendMessageMutation = useMutation({
mutationFn: async (content: string) => {
return await apiRequest("POST", `/api/media-outlets/${outlet.slug}/chat`, { content });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [`/api/media-outlets/${outlet.slug}/chat`] });
setMessage("");
}
});
const handleSend = () => {
if (message.trim()) {
sendMessageMutation.mutate(message.trim());
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const getChatbotName = () => {
if (outlet.category === 'people') {
return outlet.name;
} else if (outlet.category === 'topics') {
return `${outlet.name} 전문가`;
} else {
return `${outlet.name} 대표`;
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[600px] h-[600px] flex flex-col" data-testid="chatbot-modal">
<DialogHeader>
<DialogTitle className="flex items-center gap-2" data-testid="chatbot-title">
<MessageCircle className="h-5 w-5" />
{getChatbotName()}
</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 pr-4" data-testid="chat-messages-container">
<div className="space-y-4">
{isLoading ? (
<div className="text-center text-muted-foreground py-8">
...
</div>
) : messages.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
<MessageCircle className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p> !</p>
<p className="text-sm mt-2">
{getChatbotName()} .
</p>
</div>
) : (
messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
data-testid={`chat-message-${msg.id}`}
>
<div
className={`max-w-[80%] rounded-lg px-4 py-2 ${
msg.role === 'user'
? 'bg-primary text-primary-foreground'
: 'bg-muted'
}`}
>
<p className="text-sm whitespace-pre-wrap break-words">{msg.content}</p>
</div>
</div>
))
)}
{sendMessageMutation.isPending && (
<div className="flex justify-start">
<div className="max-w-[80%] rounded-lg px-4 py-2 bg-muted">
<p className="text-sm text-muted-foreground"> ...</p>
</div>
</div>
)}
</div>
</ScrollArea>
<div className="flex gap-2 pt-4 border-t">
<Textarea
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="메시지를 입력하세요..."
className="min-h-[60px] resize-none"
disabled={sendMessageMutation.isPending}
data-testid="chat-input"
/>
<Button
onClick={handleSend}
disabled={!message.trim() || sendMessageMutation.isPending}
size="icon"
className="h-[60px] w-[60px]"
data-testid="button-send-message"
>
<Send className="h-4 w-4" />
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input";
import { Card, CardContent } from "@/components/ui/card";
import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Search, Settings, User, LogOut, Grid, List, Info } from "lucide-react";
import { Search, Settings, User, LogOut, Grid, List, Info, MessageCircle } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { useToast } from "@/hooks/use-toast";
import { queryClient } from "@/lib/queryClient";
@ -14,6 +14,7 @@ import ArticleCard from "@/components/ArticleCard";
import Footer from "@/components/Footer";
import LoginModal from "@/components/LoginModal";
import SearchModal from "@/components/SearchModal";
import ChatbotModal from "@/components/ChatbotModal";
import type { MediaOutlet, Article } from "@shared/schema";
export default function MediaOutlet() {
@ -22,6 +23,7 @@ export default function MediaOutlet() {
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
const [isLoginModalOpen, setIsLoginModalOpen] = useState(false);
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
const [isChatbotOpen, setIsChatbotOpen] = useState(false);
const [enlargedImage, setEnlargedImage] = useState<string | null>(null);
const { user, isAuthenticated } = useAuth();
const { toast } = useToast();
@ -271,6 +273,16 @@ export default function MediaOutlet() {
Community
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setIsChatbotOpen(true)}
data-testid="button-chatbot"
>
<MessageCircle className="h-4 w-4 mr-1" />
Chat
</Button>
<Button
variant="ghost"
size="sm"
@ -415,6 +427,15 @@ export default function MediaOutlet() {
onClose={() => setIsSearchModalOpen(false)}
/>
{/* Chatbot Modal */}
{outlet && (
<ChatbotModal
outlet={outlet}
isOpen={isChatbotOpen}
onClose={() => setIsChatbotOpen(false)}
/>
)}
{/* Image Enlargement Dialog */}
<Dialog open={!!enlargedImage} onOpenChange={() => setEnlargedImage(null)}>
<DialogContent className="max-w-3xl p-0 bg-transparent border-none">