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

@ -38,6 +38,10 @@ externalPort = 3003
localPort = 41425 localPort = 41425
externalPort = 6000 externalPort = 6000
[[ports]]
localPort = 41825
externalPort = 6800
[[ports]] [[ports]]
localPort = 43349 localPort = 43349
externalPort = 3000 externalPort = 3000
@ -50,7 +54,7 @@ externalPort = 4200
PORT = "5000" PORT = "5000"
[agent] [agent]
integrations = ["javascript_object_storage:1.0.0", "javascript_log_in_with_replit:1.0.0", "javascript_database:1.0.0"] integrations = ["javascript_object_storage:1.0.0", "javascript_log_in_with_replit:1.0.0", "javascript_database:1.0.0", "javascript_openai_ai_integrations:1.0.0"]
[workflows] [workflows]
runButton = "Project" runButton = "Project"

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

29
package-lock.json generated
View File

@ -58,6 +58,7 @@
"memorystore": "^1.6.7", "memorystore": "^1.6.7",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"openai": "^6.3.0",
"openid-client": "^6.8.1", "openid-client": "^6.8.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",
@ -6345,6 +6346,27 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/openai": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-6.3.0.tgz",
"integrity": "sha512-E6vOGtZvdcb4yXQ5jXvDlUG599OhIkb/GjBLZXS+qk0HF+PJReIldEc9hM8Ft81vn+N6dRdFRb7BZNK8bbvXrw==",
"license": "Apache-2.0",
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.25 || ^4.0"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/openid-client": { "node_modules/openid-client": {
"version": "6.8.1", "version": "6.8.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.1.tgz", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.1.tgz",
@ -8735,9 +8757,10 @@
} }
}, },
"node_modules/zod": { "node_modules/zod": {
"version": "3.24.2", "version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.2.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@ -60,6 +60,7 @@
"memorystore": "^1.6.7", "memorystore": "^1.6.7",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"openai": "^6.3.0",
"openid-client": "^6.8.1", "openid-client": "^6.8.1",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-local": "^1.0.0", "passport-local": "^1.0.0",

54
server/chatbot.ts Normal file
View File

@ -0,0 +1,54 @@
import OpenAI from "openai";
// This is using Replit's AI Integrations service, which provides OpenAI-compatible API access without requiring your own OpenAI API key.
// the newest OpenAI model is "gpt-5" which was released August 7, 2025. do not change this unless explicitly requested by the user
const openai = new OpenAI({
baseURL: process.env.AI_INTEGRATIONS_OPENAI_BASE_URL,
apiKey: process.env.AI_INTEGRATIONS_OPENAI_API_KEY
});
interface ChatbotPersonality {
systemPrompt: string;
name: string;
}
export function getChatbotPersonality(outletName: string, outletCategory: string, outletDescription?: string): ChatbotPersonality {
const categoryContext = {
people: `You are an AI assistant representing ${outletName}. ${outletDescription ? outletDescription : 'You speak from their perspective, embodying their personality, values, and communication style.'} Engage with users as if you are ${outletName} themselves, sharing insights, opinions, and knowledge from their unique perspective.`,
topics: `You are an AI expert on ${outletName}. ${outletDescription ? outletDescription : 'You provide in-depth knowledge and analysis about this topic.'} Answer questions with expertise and provide thoughtful commentary from various angles on this subject.`,
companies: `You are an AI representative of ${outletName}. ${outletDescription ? outletDescription : 'You speak on behalf of the company, sharing information about its mission, products, and values.'} Engage professionally while representing the company's interests and perspective.`
};
const systemPrompt = categoryContext[outletCategory as keyof typeof categoryContext] ||
`You are an AI assistant for ${outletName}. Provide helpful and informative responses.`;
return {
systemPrompt: systemPrompt + '\n\nKeep responses conversational, engaging, and concise (2-4 paragraphs maximum). Use Korean language for communication.',
name: outletName
};
}
export async function generateChatbotResponse(
messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>,
outletName: string,
outletCategory: string,
outletDescription?: string
): Promise<string> {
const personality = getChatbotPersonality(outletName, outletCategory, outletDescription);
const chatMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: 'system', content: personality.systemPrompt },
...messages.map(msg => ({
role: msg.role as 'user' | 'assistant',
content: msg.content
}))
];
const completion = await openai.chat.completions.create({
model: 'gpt-5',
messages: chatMessages,
max_completion_tokens: 8192,
});
return completion.choices[0]?.message?.content || '죄송합니다. 응답을 생성할 수 없습니다.';
}

View File

@ -2,7 +2,8 @@ import type { Express } from "express";
import { createServer, type Server } from "http"; import { createServer, type Server } from "http";
import { storage } from "./storage"; import { storage } from "./storage";
import { setupAuth, isAuthenticated } from "./simpleAuth"; import { setupAuth, isAuthenticated } from "./simpleAuth";
import { insertArticleSchema, insertMediaOutletRequestSchema, insertBidSchema, insertCommentSchema, insertPredictionBetSchema, insertCommunityPostSchema, insertCommunityReplySchema } from "@shared/schema"; import { insertArticleSchema, insertMediaOutletRequestSchema, insertBidSchema, insertCommentSchema, insertPredictionBetSchema, insertCommunityPostSchema, insertCommunityReplySchema, insertChatMessageSchema } from "@shared/schema";
import { generateChatbotResponse } from "./chatbot";
export async function registerRoutes(app: Express): Promise<Server> { export async function registerRoutes(app: Express): Promise<Server> {
// Auth middleware // Auth middleware
@ -582,6 +583,77 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
}); });
// Chatbot routes
app.get('/api/media-outlets/:slug/chat', isAuthenticated, async (req: any, res) => {
try {
const outlet = await storage.getMediaOutletBySlug(req.params.slug);
if (!outlet) {
return res.status(404).json({ message: "Media outlet not found" });
}
const userId = req.user.claims?.sub || req.user.id;
const messages = await storage.getChatMessages(outlet.id, userId);
res.json(messages);
} catch (error) {
console.error("Error fetching chat messages:", error);
res.status(500).json({ message: "Failed to fetch chat messages" });
}
});
app.post('/api/media-outlets/:slug/chat', isAuthenticated, async (req: any, res) => {
try {
const outlet = await storage.getMediaOutletBySlug(req.params.slug);
if (!outlet) {
return res.status(404).json({ message: "Media outlet not found" });
}
const userId = req.user.claims?.sub || req.user.id;
const { content } = req.body;
if (!content || typeof content !== 'string') {
return res.status(400).json({ message: "Invalid message content" });
}
// Save user message
const userMessage = insertChatMessageSchema.parse({
mediaOutletId: outlet.id,
userId,
role: 'user',
content
});
await storage.createChatMessage(userMessage);
// Get chat history
const chatHistory = await storage.getChatMessages(outlet.id, userId);
const messages = chatHistory.map(msg => ({
role: msg.role as 'user' | 'assistant',
content: msg.content
}));
// Generate AI response
const aiResponse = await generateChatbotResponse(
messages,
outlet.name,
outlet.category,
outlet.description || undefined
);
// Save assistant message
const assistantMessage = insertChatMessageSchema.parse({
mediaOutletId: outlet.id,
userId,
role: 'assistant',
content: aiResponse
});
const savedAssistantMessage = await storage.createChatMessage(assistantMessage);
res.status(201).json(savedAssistantMessage);
} catch (error) {
console.error("Error processing chat message:", error);
res.status(500).json({ message: "Failed to process chat message" });
}
});
const httpServer = createServer(app); const httpServer = createServer(app);
return httpServer; return httpServer;
} }

View File

@ -10,6 +10,7 @@ import {
predictionBets, predictionBets,
communityPosts, communityPosts,
communityReplies, communityReplies,
chatMessages,
type User, type User,
type UpsertUser, type UpsertUser,
type MediaOutlet, type MediaOutlet,
@ -32,6 +33,8 @@ import {
type InsertCommunityPost, type InsertCommunityPost,
type CommunityReply, type CommunityReply,
type InsertCommunityReply, type InsertCommunityReply,
type ChatMessage,
type InsertChatMessage,
} from "@shared/schema"; } from "@shared/schema";
import { db } from "./db"; import { db } from "./db";
import { eq, desc, asc, and, ilike, sql } from "drizzle-orm"; import { eq, desc, asc, and, ilike, sql } from "drizzle-orm";
@ -97,6 +100,10 @@ export interface IStorage {
getRepliesByPost(postId: string): Promise<CommunityReply[]>; getRepliesByPost(postId: string): Promise<CommunityReply[]>;
createCommunityReply(reply: InsertCommunityReply): Promise<CommunityReply>; createCommunityReply(reply: InsertCommunityReply): Promise<CommunityReply>;
// Chatbot operations
getChatMessages(mediaOutletId: string, userId: string): Promise<ChatMessage[]>;
createChatMessage(message: InsertChatMessage): Promise<ChatMessage>;
// Analytics operations // Analytics operations
getAnalytics(): Promise<{ getAnalytics(): Promise<{
totalArticles: number; totalArticles: number;
@ -473,6 +480,20 @@ export class DatabaseStorage implements IStorage {
return newReply; return newReply;
} }
// Chatbot operations
async getChatMessages(mediaOutletId: string, userId: string): Promise<ChatMessage[]> {
return await db
.select()
.from(chatMessages)
.where(and(eq(chatMessages.mediaOutletId, mediaOutletId), eq(chatMessages.userId, userId)))
.orderBy(asc(chatMessages.createdAt));
}
async createChatMessage(message: InsertChatMessage): Promise<ChatMessage> {
const [newMessage] = await db.insert(chatMessages).values(message).returning();
return newMessage;
}
// Analytics operations // Analytics operations
async getAnalytics(): Promise<{ async getAnalytics(): Promise<{
totalArticles: number; totalArticles: number;

View File

@ -180,6 +180,16 @@ export const communityReplies = pgTable("community_replies", {
updatedAt: timestamp("updated_at").defaultNow(), updatedAt: timestamp("updated_at").defaultNow(),
}); });
// Chatbot messages for media outlets
export const chatMessages = pgTable("chat_messages", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
mediaOutletId: varchar("media_outlet_id").notNull(),
userId: varchar("user_id").notNull(),
role: varchar("role").notNull(), // "user" or "assistant"
content: text("content").notNull(),
createdAt: timestamp("created_at").defaultNow(),
});
// Insert schemas // Insert schemas
export const insertUserSchema = createInsertSchema(users).omit({ export const insertUserSchema = createInsertSchema(users).omit({
id: true, id: true,
@ -245,6 +255,11 @@ export const insertCommunityReplySchema = createInsertSchema(communityReplies).o
updatedAt: true, updatedAt: true,
}); });
export const insertChatMessageSchema = createInsertSchema(chatMessages).omit({
id: true,
createdAt: true,
});
// Types // Types
export type UpsertUser = typeof users.$inferInsert; export type UpsertUser = typeof users.$inferInsert;
export type User = typeof users.$inferSelect; export type User = typeof users.$inferSelect;
@ -268,3 +283,5 @@ export type InsertCommunityPost = z.infer<typeof insertCommunityPostSchema>;
export type CommunityPost = typeof communityPosts.$inferSelect; export type CommunityPost = typeof communityPosts.$inferSelect;
export type InsertCommunityReply = z.infer<typeof insertCommunityReplySchema>; export type InsertCommunityReply = z.infer<typeof insertCommunityReplySchema>;
export type CommunityReply = typeof communityReplies.$inferSelect; export type CommunityReply = typeof communityReplies.$inferSelect;
export type InsertChatMessage = z.infer<typeof insertChatMessageSchema>;
export type ChatMessage = typeof chatMessages.$inferSelect;