feat: overhaul chat architecture with persistent JdbcChatMemory, reactive UI components, and improved conversation management

This commit is contained in:
2026-07-23 19:20:28 +07:00
parent 2bbbff1ec9
commit 506008c69f
79 changed files with 5058 additions and 1193 deletions

View File

@@ -1,184 +1 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
/* Clean App.css - styling handled by Tailwind and shadcn/ui */

View File

@@ -1,109 +0,0 @@
import { useEffect, useState } from 'react';
interface Campaign {
id: string;
name: string;
status: string;
startDate?: string;
endDate?: string;
}
export function CampaignList() {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [loading, setLoading] = useState(true);
const [selectedCampaignId, setSelectedCampaignId] = useState<string | null>(null);
const [rules, setRules] = useState<any[]>([]);
const [loadingRules, setLoadingRules] = useState(false);
useEffect(() => {
fetch('http://localhost:9332/api/v1/agent/campaigns')
.then((res) => res.json())
.then((data) => {
if (Array.isArray(data)) setCampaigns(data);
else if (data && Array.isArray(data.content)) setCampaigns(data.content);
else if (data && Array.isArray(data.data)) setCampaigns(data.data);
else setCampaigns([]);
})
.catch((err) => {
console.error('Failed to fetch campaigns', err);
setCampaigns([]);
})
.finally(() => setLoading(false));
}, []);
const handleSelectCampaign = (id: string) => {
if (selectedCampaignId === id) {
setSelectedCampaignId(null);
setRules([]);
return;
}
setSelectedCampaignId(id);
setLoadingRules(true);
fetch(`http://localhost:9332/api/v1/agent/campaigns/${id}/rules`)
.then((res) => res.json())
.then((data) => {
if (Array.isArray(data)) setRules(data);
else if (data && Array.isArray(data.content)) setRules(data.content);
else if (data && Array.isArray(data.data)) setRules(data.data);
else setRules([]);
})
.catch((err) => {
console.error('Failed to fetch rules', err);
setRules([]);
})
.finally(() => setLoadingRules(false));
};
if (loading) {
return <div className="p-4 text-center text-sm text-muted-foreground">Loading campaigns...</div>;
}
if (campaigns.length === 0) {
return <div className="p-4 text-center text-sm text-muted-foreground">No campaigns found.</div>;
}
return (
<div className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar">
{campaigns.map((camp) => (
<div key={camp.id} className="space-y-2">
<div
onClick={() => handleSelectCampaign(camp.id)}
className={`glass-card rounded-xl p-4 text-sm group cursor-pointer relative overflow-hidden transition-all duration-200 ${selectedCampaignId === camp.id ? 'ring-2 ring-primary/50' : ''}`}>
<div className="absolute inset-0 bg-primary/5 opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="relative z-10">
<div className="font-semibold text-foreground text-base mb-1 group-hover:text-primary transition-colors">{camp.name || 'Unnamed Campaign'}</div>
<div className="flex justify-between items-center text-xs text-muted-foreground mt-3">
<span className="flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${camp.status === 'A' ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]' : 'bg-rose-500'}`} />
{camp.status === 'A' ? 'Active' : camp.status}
</span>
<code className="bg-background/50 px-1.5 py-0.5 rounded text-[10px] uppercase">{camp.id}</code>
</div>
</div>
</div>
{selectedCampaignId === camp.id && (
<div className="pl-4 pr-2 space-y-2 border-l-2 border-primary/20 ml-2 animate-in slide-in-from-top-2">
{loadingRules ? (
<div className="text-xs text-muted-foreground italic">Loading rules...</div>
) : rules.length === 0 ? (
<div className="text-xs text-muted-foreground italic">No rules found.</div>
) : (
rules.map((rule, idx) => (
<div key={rule.id || idx} className="bg-background/40 rounded-lg p-3 text-xs border border-border/50">
<div className="font-medium text-foreground mb-1">{rule.name || 'Unnamed Rule'}</div>
<div className="text-muted-foreground line-clamp-2">{rule.description || 'No description'}</div>
{rule.status && (
<div className="mt-2 text-[10px] uppercase tracking-wider text-muted-foreground/80">Status: {rule.status}</div>
)}
</div>
))
)}
</div>
)}
</div>
))}
</div>
);
}

View File

@@ -2,20 +2,24 @@ import { useState } from 'react'
import type { KeyboardEvent } from 'react'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useChatStore } from '@/store/useChatStore'
import { SendHorizontal } from 'lucide-react'
import { useConnectionStore } from '@/store/useConnectionStore'
import { SendHorizontal, AlertCircle, Clock, Mic } from 'lucide-react'
export function ChatBottombar() {
const [input, setInput] = useState('')
const sendMessage = useChatStore((state) => state.sendMessage)
const isConnected = useChatStore((state) => state.isConnected)
const isConnecting = useChatStore((state) => state.isConnecting)
const connectionStatus = useConnectionStore((state) => state.status)
const queuedCount = useConnectionStore((state) => state.queuedCount)
const isQueueFull = queuedCount >= 20
const isDisconnected = connectionStatus === 'disconnected' || connectionStatus === 'error'
const handleSend = () => {
if (input.trim() && isConnected) {
sendMessage(input.trim())
setInput('')
}
if (!input.trim() || isQueueFull) return
sendMessage(input.trim())
setInput('')
}
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
@@ -26,35 +30,81 @@ export function ChatBottombar() {
}
return (
<div className="p-4 bg-background border-t">
<div className="max-w-3xl mx-auto flex items-end gap-2 relative">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message..."
className="min-h-[60px] max-h-[200px] resize-none"
disabled={!isConnected}
/>
<Button
size="icon"
className="h-[60px] w-[60px] shrink-0"
onClick={handleSend}
disabled={!input.trim() || !isConnected}
>
<SendHorizontal className="w-6 h-6" />
</Button>
<div className="p-3 bg-transparent">
<div className="max-w-3xl mx-auto flex flex-col gap-2">
<div className="relative flex items-center gap-2 bg-secondary/60 backdrop-blur-md border border-border/60 rounded-full px-4 py-1.5 shadow-sm focus-within:ring-1 focus-within:ring-primary/30 focus-within:border-primary/40 transition-all">
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
isQueueFull
? 'Mất kết nối. Hàng chờ đã đầy, vui lòng chờ...'
: 'Hỏi Loyalty Agent...'
}
className="flex-1 min-h-[36px] max-h-[120px] py-1.5 px-1 bg-transparent border-0 focus-visible:ring-0 focus-visible:ring-offset-0 text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/60 resize-none overflow-y-auto"
rows={1}
disabled={isQueueFull}
/>
<div className="flex items-center gap-1 shrink-0">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
type="button"
>
<Mic className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">Nhập bằng giọng nói</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
className="h-8 w-8 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm disabled:opacity-30 shrink-0 transition-all active:scale-95"
onClick={handleSend}
disabled={!input.trim() || isQueueFull}
>
<SendHorizontal className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs">Gửi tin nhắn (Enter)</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
{/* Offline status or queued indicator */}
{isDisconnected && (
<div className="flex items-center justify-between text-xs text-destructive bg-destructive/10 border border-destructive/20 px-3 py-1.5 rounded-full shadow-sm">
<div className="flex items-center gap-1.5">
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
<span>Mất kết nối với máy chủ. Đang tự đng thử lại...</span>
</div>
{queuedCount > 0 && (
<div className="flex items-center gap-1 text-amber-500 font-medium">
<Clock className="w-3.5 h-3.5" />
<span>Đã lưu tạm {queuedCount} tin nhắn</span>
</div>
)}
</div>
)}
<p className="text-[11px] text-muted-foreground/70 text-center py-0.5 select-none">
Loyalty Agent thể đưa ra câu trả lời không chính xác. Hãy kiểm tra lại thông tin quan trọng.
</p>
</div>
{!isConnected && !isConnecting && (
<div className="text-center text-destructive text-sm mt-2">
Disconnected from agent server. Retrying...
</div>
)}
{isConnecting && (
<div className="text-center text-muted-foreground text-sm mt-2 animate-pulse">
Connecting to agent server...
</div>
)}
</div>
)
}

View File

@@ -1,48 +1,60 @@
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import React from 'react'
import { cn } from '@/lib/utils'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { Message } from '@/store/useChatStore'
import { ToolStatusIndicator } from './ToolStatusIndicator'
import { MarkdownRenderer } from './MarkdownRenderer'
import { Sparkles, User } from 'lucide-react'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
export function ChatBubble({ message }: { message: Message }) {
interface ChatBubbleProps {
message: Message
}
export const ChatBubble = React.memo(function ChatBubble({ message }: ChatBubbleProps) {
const isAgent = message.role === 'agent'
return (
<div className={cn("flex w-full mt-4 space-x-3 max-w-3xl mx-auto", isAgent ? "justify-start" : "justify-end")}>
{isAgent && (
<Avatar className="w-8 h-8">
<AvatarFallback>AG</AvatarFallback>
</Avatar>
<div
className={cn(
'flex w-full my-4 max-w-3xl mx-auto items-start gap-3 group animate-in fade-in-50 duration-200',
isAgent ? 'justify-start' : 'justify-end'
)}
<div className="flex flex-col gap-1 max-w-[80%]">
{message.toolStatus && (
<span className="text-xs text-muted-foreground animate-pulse italic">
{message.toolStatus}
</span>
>
{isAgent && (
<div className="w-8 h-8 rounded-full bg-primary/10 border border-primary/20 flex items-center justify-center text-primary shrink-0 mt-0.5 shadow-sm">
<Sparkles className="w-4 h-4 text-primary animate-pulse" />
</div>
)}
<div className={cn('flex flex-col gap-1.5 min-w-0', isAgent ? 'flex-1' : 'max-w-[80%]')}>
{isAgent && message.toolStatus && (
<ToolStatusIndicator toolName={message.toolStatus} />
)}
<div
className={cn(
"p-3 rounded-md text-sm shadow-sm",
isAgent ? "bg-muted text-foreground" : "bg-primary text-primary-foreground"
'text-sm transition-all duration-200 overflow-hidden',
isAgent
? 'text-foreground leading-relaxed py-1 px-1'
: 'bg-secondary border border-border/80 text-secondary-foreground font-normal px-5 py-3 rounded-3xl self-end shadow-sm'
)}
>
{isAgent ? (
<div className="prose dark:prose-invert max-w-none text-sm">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{message.content}
</ReactMarkdown>
{message.isStreaming && <span className="inline-block w-2 h-4 bg-current animate-pulse ml-1 align-middle" />}
</div>
<MarkdownRenderer content={message.content} isStreaming={message.isStreaming} />
) : (
<span className="whitespace-pre-wrap">{message.content}</span>
<div className="whitespace-pre-wrap leading-relaxed">{message.content}</div>
)}
</div>
</div>
{!isAgent && (
<Avatar className="w-8 h-8">
<AvatarFallback>U</AvatarFallback>
<Avatar className="w-8 h-8 border border-border/80 shrink-0 bg-muted shadow-sm">
<AvatarFallback className="bg-muted text-muted-foreground font-semibold text-xs">
<User className="w-4 h-4" />
</AvatarFallback>
</Avatar>
)}
</div>
)
}
})

View File

@@ -0,0 +1,134 @@
import { Plus, PanelLeft, Sparkles, Search } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { ChatHistoryList } from './ChatHistoryList'
import { ChatUserProfile } from './ChatUserProfile'
import type { ConversationItem } from '@/hooks/useConversations'
import { cn } from '@/lib/utils'
interface ChatDesktopSidebarProps {
isCollapsed: boolean
onToggleCollapse: () => void
conversations: ConversationItem[]
activeId: string | null
isLoading?: boolean
onSelectConversation: (id: string) => void
onCreateNewConversation: () => void
onUpdateTitle?: (id: string, newTitle: string) => void
onDeleteConversation?: (id: string) => void
}
export function ChatDesktopSidebar({
isCollapsed,
onToggleCollapse,
conversations,
activeId,
isLoading,
onSelectConversation,
onCreateNewConversation,
onUpdateTitle,
onDeleteConversation,
}: ChatDesktopSidebarProps) {
return (
<aside
className={cn(
'h-full bg-card/70 border-r border-border/60 flex flex-col transition-all duration-300 shrink-0 relative z-10',
isCollapsed ? 'w-16' : 'w-64'
)}
>
{/* Sidebar Top: Collapse Toggle & Logo */}
<div className="h-14 px-3 flex items-center justify-between border-b border-border/40">
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-full text-muted-foreground hover:text-foreground"
onClick={onToggleCollapse}
>
<PanelLeft className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs">{isCollapsed ? 'Mở rộng menu' : 'Thu gọn menu'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{!isCollapsed && (
<div className="flex items-center gap-2 pr-2 select-none">
<Sparkles className="w-4 h-4 text-primary" />
<span className="font-bold text-sm tracking-tight">Loyalty Agent</span>
</div>
)}
</div>
{/* New Chat Button */}
<div className="p-3">
<TooltipProvider delayDuration={200}>
{isCollapsed ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={onCreateNewConversation}
variant="secondary"
size="icon"
className="w-10 h-10 rounded-full mx-auto shadow-sm hover:bg-secondary/80 border border-border/60"
>
<Plus className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs font-medium">Trò chuyện mới</p>
</TooltipContent>
</Tooltip>
) : (
<Button
onClick={onCreateNewConversation}
variant="secondary"
className="w-full justify-start gap-2.5 rounded-full px-4 py-2.5 shadow-sm font-medium hover:bg-secondary/80 border border-border/60 text-xs"
>
<Plus className="w-4 h-4 text-primary" />
<span>Trò chuyện mới</span>
</Button>
)}
</TooltipProvider>
</div>
{/* Nav quick items */}
{!isCollapsed && (
<div className="px-3 py-1">
<Button
variant="ghost"
className="w-full justify-start gap-3 rounded-full text-xs font-normal text-muted-foreground hover:text-foreground hover:bg-muted/60 h-9 px-3"
>
<Search className="w-4 h-4 shrink-0" />
<span className="truncate">Tìm kiếm hội thoại</span>
</Button>
</div>
)}
{/* History Header Label */}
{!isCollapsed && (
<div className="px-4 pt-3 pb-1 font-semibold text-[11px] uppercase tracking-wider text-muted-foreground/80 select-none">
Gần đây
</div>
)}
{/* Conversations List */}
<ChatHistoryList
conversations={conversations}
activeId={activeId}
onSelect={onSelectConversation}
onUpdateTitle={onUpdateTitle}
onDelete={onDeleteConversation}
isLoading={isLoading}
collapsed={isCollapsed}
/>
{/* User Profile Footer */}
<ChatUserProfile collapsed={isCollapsed} />
</aside>
)
}

View File

@@ -0,0 +1,91 @@
import { Menu, Wifi, WifiOff, Sparkles } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { ThemeToggle } from './ThemeToggle'
import type { ConnectionStatus } from '@/store/useConnectionStore'
import { cn } from '@/lib/utils'
interface ChatHeaderProps {
isMobile: boolean
isSidebarCollapsed: boolean
onOpenMobileSidebar: () => void
onExpandDesktopSidebar: () => void
connectionStatus: ConnectionStatus
onToggleRightPanel: () => void
}
export function ChatHeader({
isMobile,
isSidebarCollapsed,
onOpenMobileSidebar,
onExpandDesktopSidebar,
connectionStatus,
onToggleRightPanel,
}: ChatHeaderProps) {
return (
<header className="h-14 border-b border-border/40 flex items-center justify-between px-4 font-semibold shrink-0 bg-background/80 backdrop-blur-md z-10">
<div className="flex items-center gap-3">
{(isMobile || isSidebarCollapsed) && (
<Button
variant="ghost"
size="icon"
className="h-9 w-9 rounded-full text-muted-foreground hover:text-foreground"
onClick={isMobile ? onOpenMobileSidebar : onExpandDesktopSidebar}
>
<Menu className="w-5 h-5" />
</Button>
)}
</div>
{/* Connection Status & Actions */}
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={cn(
'gap-1.5 py-0.5 px-2.5 text-[11px] font-normal shadow-sm rounded-full transition-colors',
connectionStatus === 'connected'
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-500'
: connectionStatus === 'connecting'
? 'border-amber-500/30 bg-amber-500/10 text-amber-500 animate-pulse'
: 'border-destructive/30 bg-destructive/10 text-destructive'
)}
>
{connectionStatus === 'connected' ? (
<>
<Wifi className="w-3 h-3" />
<span>Đã kết nối</span>
</>
) : connectionStatus === 'connecting' ? (
<span>Đang kết nối...</span>
) : (
<>
<WifiOff className="w-3 h-3" />
<span>Ngoại tuyến</span>
</>
)}
</Badge>
<ThemeToggle />
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 rounded-full text-muted-foreground hover:text-foreground"
onClick={onToggleRightPanel}
>
<Sparkles className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">Thông tin hệ thống</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</header>
)
}

View File

@@ -1,40 +1,203 @@
import { MessageSquare } from 'lucide-react';
import { useState } from 'react'
import { MessageSquare, Loader2, Pencil, Trash2, Check, X } from 'lucide-react'
import type { ConversationItem } from '@/hooks/useConversations'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
interface ChatSession {
id: string;
title: string;
date: string;
interface ChatHistoryListProps {
conversations: ConversationItem[]
activeId: string | null
onSelect: (id: string) => void
onUpdateTitle?: (id: string, newTitle: string) => void
onDelete?: (id: string) => void
isLoading?: boolean
collapsed?: boolean
}
const MOCK_SESSIONS: ChatSession[] = [
{ id: '1', title: 'Phân tích Campaign Noel 2026', date: 'Vừa xong' },
{ id: '2', title: 'Tại sao Rule giảm giá không hoạt động?', date: '2 giờ trước' },
{ id: '3', title: 'Tạo Campaign sinh nhật khách hàng', date: 'Hôm qua' },
{ id: '4', title: 'Kiểm tra điểm thưởng KH VIP', date: 'Hôm qua' },
{ id: '5', title: 'Lỗi đồng bộ dữ liệu CRM', date: '3 ngày trước' },
];
export function ChatHistoryList({
conversations,
activeId,
onSelect,
onUpdateTitle,
onDelete,
isLoading,
collapsed = false,
}: ChatHistoryListProps) {
const [editingId, setEditingId] = useState<string | null>(null)
const [editTitle, setEditTitle] = useState<string>('')
const handleStartEdit = (e: React.MouseEvent, session: ConversationItem) => {
e.stopPropagation()
setEditingId(session.id)
setEditTitle(session.title || 'Cuộc trò chuyện mới')
}
const handleSaveEdit = async (id: string) => {
if (editTitle.trim() && onUpdateTitle) {
await onUpdateTitle(id, editTitle.trim())
}
setEditingId(null)
}
const handleDelete = async (e: React.MouseEvent, id: string) => {
e.stopPropagation()
if (onDelete) {
await onDelete(id)
}
}
if (isLoading && conversations.length === 0) {
return (
<div className="flex-1 flex items-center justify-center p-4 text-muted-foreground text-xs gap-2">
<Loader2 className="w-4 h-4 animate-spin text-primary" />
{!collapsed && <span>Đang tải hội thoại...</span>}
</div>
)
}
if (conversations.length === 0) {
return (
<div className="flex-1 p-4 text-center text-muted-foreground text-xs flex flex-col items-center justify-center gap-2">
<MessageSquare className="w-6 h-6 text-muted-foreground/40" />
{!collapsed && <span>Chưa hội thoại nào</span>}
</div>
)
}
export function ChatHistoryList() {
return (
<div className="flex-1 overflow-y-auto p-4 space-y-2 custom-scrollbar">
{MOCK_SESSIONS.map((session) => (
<div
key={session.id}
className="p-3 text-sm group cursor-pointer relative overflow-hidden rounded-lg hover:bg-white/5 transition-colors border border-transparent hover:border-white/10"
>
<div className="flex items-start gap-3">
<MessageSquare className="w-4 h-4 mt-0.5 text-muted-foreground group-hover:text-primary transition-colors shrink-0" />
<div className="flex-1 overflow-hidden">
<div className="truncate font-medium text-foreground/90 group-hover:text-foreground transition-colors">
{session.title}
<TooltipProvider delayDuration={200}>
<ScrollArea className="flex-1">
<div className="space-y-1 pb-4 px-2 pt-1">
{conversations.map((session) => {
const isActive = session.id === activeId
const isEditing = session.id === editingId
if (collapsed) {
return (
<Tooltip key={session.id}>
<TooltipTrigger asChild>
<Button
onClick={() => onSelect(session.id)}
variant={isActive ? 'secondary' : 'ghost'}
size="icon"
className={cn(
'w-10 h-10 rounded-md mx-auto flex items-center justify-center transition-all',
isActive
? 'bg-secondary text-foreground shadow-sm font-semibold'
: 'hover:bg-muted text-muted-foreground hover:text-foreground'
)}
>
<MessageSquare className={cn('w-4 h-4', isActive && 'text-primary')} />
</Button>
</TooltipTrigger>
<TooltipContent side="right" className="max-w-[200px]">
<p className="font-medium text-xs truncate">{session.title || 'Cuộc trò chuyện mới'}</p>
</TooltipContent>
</Tooltip>
)
}
return (
<div
key={session.id}
onClick={() => !isEditing && onSelect(session.id)}
className={cn(
'w-full flex items-center justify-between py-2 px-3 font-normal rounded-md transition-all duration-200 group relative cursor-pointer overflow-hidden',
isActive
? 'bg-secondary text-foreground shadow-sm font-medium'
: 'hover:bg-muted/70 text-muted-foreground hover:text-foreground'
)}
>
<div className="flex items-center gap-2.5 flex-1 min-w-0 overflow-hidden">
<MessageSquare
className={cn(
'w-4 h-4 shrink-0 transition-colors',
isActive ? 'text-primary' : 'group-hover:text-primary'
)}
/>
{isEditing ? (
<div
className="flex items-center gap-1 flex-1 min-w-0"
onClick={(e) => e.stopPropagation()}
>
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') handleSaveEdit(session.id)
if (e.key === 'Escape') setEditingId(null)
}}
onBlur={() => handleSaveEdit(session.id)}
autoFocus
className="w-full bg-background border border-input rounded px-2 py-0.5 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
/>
<button
type="button"
onClick={() => handleSaveEdit(session.id)}
className="p-1 text-primary hover:bg-muted rounded"
title="Lưu"
>
<Check className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => setEditingId(null)}
className="p-1 text-muted-foreground hover:bg-muted rounded"
title="Hủy"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
) : (
<div className="truncate text-xs font-medium leading-tight flex-1">
{session.title || 'Cuộc trò chuyện mới'}
</div>
)}
</div>
{!isEditing && (
<div
className={cn(
"absolute right-0 inset-y-0 flex items-center justify-end pr-2 pl-12 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none group-hover:pointer-events-auto rounded-r-md",
isActive
? "bg-gradient-to-l from-secondary via-secondary to-transparent"
: "bg-gradient-to-l from-muted via-muted/80 to-transparent"
)}
>
<div className="flex items-center gap-0.5">
{onUpdateTitle && (
<button
type="button"
onClick={(e) => handleStartEdit(e, session)}
className="p-1.5 hover:text-foreground text-muted-foreground transition-colors rounded-md hover:bg-background/60"
title="Đổi tên"
>
<Pencil className="w-3.5 h-3.5" />
</button>
)}
{onDelete && (
<button
type="button"
onClick={(e) => handleDelete(e, session.id)}
className="p-1.5 hover:text-destructive text-muted-foreground transition-colors rounded-md hover:bg-background/60"
title="Xóa"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
)}
</div>
<div className="text-xs text-muted-foreground mt-1">
{session.date}
</div>
</div>
</div>
)
})}
</div>
))}
</div>
);
</ScrollArea>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,41 @@
import { X } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface ChatInfoPanelProps {
isOpen: boolean
onClose: () => void
}
export function ChatInfoPanel({ isOpen, onClose }: ChatInfoPanelProps) {
if (!isOpen) return null
return (
<aside className="w-72 bg-card/90 border-l border-border/60 p-4 space-y-4 animate-in slide-in-from-right duration-200">
<div className="flex items-center justify-between pb-3 border-b border-border/60">
<span className="font-semibold text-xs uppercase tracking-wider text-muted-foreground">
Thông tin hệ thống
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full text-muted-foreground"
onClick={onClose}
>
<X className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3 text-xs text-muted-foreground">
<div className="p-3 rounded-2xl bg-secondary/50 border border-border/60 space-y-1">
<div className="font-semibold text-foreground">MCP Tools Connected</div>
<p className="text-[11px]">Truy vấn thông tin chiến dịch, khách hàng & điểm thưởng theo thời gian thực.</p>
</div>
<div className="p-3 rounded-2xl bg-secondary/50 border border-border/60 space-y-1">
<div className="font-semibold text-foreground">STOMP Streaming</div>
<p className="text-[11px]">Hỗ trợ phản hồi thời gian thực qua WebSocket stream.</p>
</div>
</div>
</aside>
)
}

View File

@@ -1,61 +1,113 @@
import { useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { ChatList } from './ChatList'
import { ChatBottombar } from './ChatBottombar'
import { ChatMobileSidebar } from './ChatMobileSidebar'
import { ChatDesktopSidebar } from './ChatDesktopSidebar'
import { ChatHeader } from './ChatHeader'
import { ChatInfoPanel } from './ChatInfoPanel'
import { useChatStore } from '@/store/useChatStore'
import { MessageSquarePlus, PanelRightClose, PanelRight } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { CampaignList } from '@/components/campaign/CampaignList'
import { ChatHistoryList } from './ChatHistoryList'
import { useConnectionStore } from '@/store/useConnectionStore'
import { useConversations } from '@/hooks/useConversations'
import { useIsMobile } from '@/hooks/useIsMobile'
export function ChatLayout() {
const messages = useChatStore((state) => state.messages)
const isRightPanelOpen = useChatStore((state) => state.isRightPanelOpen)
const toggleRightPanel = useChatStore((state) => state.toggleRightPanel)
const initStomp = useChatStore((state) => state.initStomp)
const connectionStatus = useConnectionStore((state) => state.status)
const isMobile = useIsMobile()
const {
conversations,
activeId,
isLoading,
selectConversation,
createNewConversation,
deleteConversation,
updateTitle,
fetchConversations,
} = useConversations()
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false)
const [showRightPanel, setShowRightPanel] = useState(false)
const fetchedRef = useRef<string | null>(null)
useEffect(() => {
initStomp()
}, [initStomp])
return (
<div className="flex h-screen bg-transparent text-foreground overflow-hidden p-4 md:p-6 gap-4">
{/* Left Panel: Chat History */}
<aside className="w-80 flex-col hidden md:flex glass-panel rounded-2xl overflow-hidden shrink-0">
<div className="p-4 border-b border-white/10 dark:border-white/5">
<Button variant="outline" className="w-full justify-start gap-2">
<MessageSquarePlus className="w-4 h-4" />
New Chat
</Button>
</div>
<div className="px-4 pt-4 font-semibold text-sm text-foreground/80">Recent Chats</div>
<ChatHistoryList />
</aside>
useEffect(() => {
if (!activeId) return
const currentConv = conversations.find(c => c.id === activeId)
// Auto-update title when agent starts replying to a new conversation
if (currentConv && currentConv.title === 'Cuộc trò chuyện mới' && fetchedRef.current !== activeId) {
const firstAgentMsg = messages.find(m => m.role === 'agent')
const hasAgentStartedReplying = firstAgentMsg && (firstAgentMsg.content.length > 0 || firstAgentMsg.toolStatus || !firstAgentMsg.isStreaming)
if (hasAgentStartedReplying) {
fetchedRef.current = activeId
fetchConversations()
}
}
}, [messages, activeId, conversations, fetchConversations])
{/* Main Chat Area */}
<main className="flex-1 flex flex-col min-w-0 glass-panel rounded-2xl overflow-hidden transition-all duration-300">
<header className="h-16 border-b border-white/10 dark:border-white/5 flex items-center justify-between px-6 font-semibold shadow-sm shrink-0 text-lg">
<div>Loyalty Agent</div>
<Button variant="ghost" size="icon" onClick={toggleRightPanel} className="text-muted-foreground hover:text-foreground">
{isRightPanelOpen ? <PanelRightClose className="w-5 h-5" /> : <PanelRight className="w-5 h-5" />}
</Button>
</header>
<div className="flex-1 flex flex-col overflow-hidden relative">
<ChatList messages={messages} />
<ChatBottombar />
return (
<div className="flex h-screen w-full bg-background text-foreground overflow-hidden">
{/* Mobile Drawer & Sidebar */}
<ChatMobileSidebar
isOpen={isMobileSidebarOpen}
onClose={() => setIsMobileSidebarOpen(false)}
conversations={conversations}
activeId={activeId}
isLoading={isLoading}
onSelectConversation={selectConversation}
onCreateNewConversation={createNewConversation}
onUpdateTitle={updateTitle}
onDeleteConversation={deleteConversation}
/>
{/* Desktop Left Sidebar */}
{!isMobile && (
<ChatDesktopSidebar
isCollapsed={isSidebarCollapsed}
onToggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
conversations={conversations}
activeId={activeId}
isLoading={isLoading}
onSelectConversation={selectConversation}
onCreateNewConversation={createNewConversation}
onUpdateTitle={updateTitle}
onDeleteConversation={deleteConversation}
/>
)}
{/* Main Container */}
<main className="flex-1 flex flex-col bg-background relative overflow-hidden">
{/* Header */}
<ChatHeader
isMobile={isMobile}
isSidebarCollapsed={isSidebarCollapsed}
onOpenMobileSidebar={() => setIsMobileSidebarOpen(true)}
onExpandDesktopSidebar={() => setIsSidebarCollapsed(false)}
connectionStatus={connectionStatus}
onToggleRightPanel={() => setShowRightPanel(!showRightPanel)}
/>
{/* Center Content & Floating Input */}
<div className="flex-1 flex overflow-hidden">
<div className="flex-1 flex flex-col overflow-hidden relative">
<ChatList messages={messages} />
<ChatBottombar />
</div>
{/* Right Info Panel */}
<ChatInfoPanel
isOpen={showRightPanel}
onClose={() => setShowRightPanel(false)}
/>
</div>
</main>
{/* Right Panel: Context / Campaigns */}
{isRightPanelOpen && (
<aside className="w-80 flex flex-col glass-panel rounded-2xl overflow-hidden shrink-0 animate-in slide-in-from-right-4 duration-300">
<div className="px-4 py-4 border-b border-white/10 dark:border-white/5 font-semibold text-sm text-foreground/80 flex items-center justify-between">
System Context
</div>
<div className="px-4 pt-4 font-semibold text-xs uppercase tracking-wider text-muted-foreground">Campaigns</div>
<CampaignList />
</aside>
)}
</div>
)
}

View File

@@ -1,28 +1,66 @@
import { useRef, useEffect } from 'react'
import { useRef, useEffect, useState } from 'react'
import type { UIEvent } from 'react'
import { ChatBubble } from './ChatBubble'
import { WelcomeScreen } from './WelcomeScreen'
import type { Message } from '@/store/useChatStore'
import { ScrollArea } from '@/components/ui/scroll-area'
import { ArrowDown } from 'lucide-react'
import { Button } from '@/components/ui/button'
export function ChatList({ messages }: { messages: Message[] }) {
const scrollRef = useRef<HTMLDivElement>(null)
const bottomRef = useRef<HTMLDivElement>(null)
const [showScrollBottom, setShowScrollBottom] = useState(false)
const isAtBottomRef = useRef(true)
const handleScroll = (e: UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
const isAtBottom = distanceFromBottom <= 100
isAtBottomRef.current = isAtBottom
setShowScrollBottom(!isAtBottom && messages.length > 0)
}
const scrollToBottom = (smooth = true) => {
bottomRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' })
}
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
if (isAtBottomRef.current) {
scrollToBottom()
}
}, [messages])
if (messages.length === 0) {
return <WelcomeScreen />
}
return (
<ScrollArea className="flex-1 w-full p-4 h-full">
<div className="flex flex-col gap-2 pb-4">
<div
ref={scrollRef}
onScroll={handleScroll}
className="flex-1 w-full p-4 overflow-y-auto custom-scrollbar relative"
>
<div className="flex flex-col gap-2 pb-6 max-w-3xl mx-auto">
{messages.map((msg) => (
<ChatBubble key={msg.id} message={msg} />
))}
{messages.length === 0 && (
<div className="text-center text-muted-foreground mt-20">
Send a message to start chatting with the Loyalty Agent.
</div>
)}
<div ref={bottomRef} />
</div>
</ScrollArea>
{showScrollBottom && (
<div className="sticky bottom-4 left-1/2 -translate-x-1/2 flex justify-center z-10 animate-in fade-in slide-in-from-bottom-2">
<Button
size="sm"
variant="secondary"
className="rounded-full shadow-lg border border-white/10 gap-1.5 bg-secondary/90 hover:bg-secondary text-xs backdrop-blur-md"
onClick={() => scrollToBottom(true)}
>
<ArrowDown className="w-3.5 h-3.5" />
<span>Tin nhắn mới</span>
</Button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,91 @@
import { Plus, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { ChatHistoryList } from './ChatHistoryList'
import { ChatUserProfile } from './ChatUserProfile'
import type { ConversationItem } from '@/hooks/useConversations'
import { cn } from '@/lib/utils'
interface ChatMobileSidebarProps {
isOpen: boolean
onClose: () => void
conversations: ConversationItem[]
activeId: string | null
isLoading?: boolean
onSelectConversation: (id: string) => void
onCreateNewConversation: () => void
onUpdateTitle?: (id: string, newTitle: string) => void
onDeleteConversation?: (id: string) => void
}
export function ChatMobileSidebar({
isOpen,
onClose,
conversations,
activeId,
isLoading,
onSelectConversation,
onCreateNewConversation,
onUpdateTitle,
onDeleteConversation,
}: ChatMobileSidebarProps) {
return (
<>
{/* Mobile Drawer Overlay */}
{isOpen && (
<div
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 md:hidden animate-in fade-in"
onClick={onClose}
/>
)}
{/* Mobile Sidebar */}
<aside
className={cn(
'fixed inset-y-0 left-0 w-72 bg-card border-r border-border z-50 flex flex-col md:hidden transition-transform duration-300 shadow-2xl',
isOpen ? 'translate-x-0' : '-translate-x-full'
)}
>
<div className="p-4 border-b border-border/60 flex items-center justify-between gap-2">
<Button
onClick={() => {
onCreateNewConversation()
onClose()
}}
variant="secondary"
className="w-full justify-start gap-2.5 rounded-full shadow-sm font-medium"
>
<Plus className="w-4 h-4" />
<span>Trò chuyện mới</span>
</Button>
<Button
variant="ghost"
size="icon"
className="rounded-full"
onClick={onClose}
>
<X className="w-5 h-5" />
</Button>
</div>
<div className="px-4 pt-3 pb-1 font-semibold text-[11px] uppercase tracking-wider text-muted-foreground select-none">
Gần đây
</div>
<ChatHistoryList
conversations={conversations}
activeId={activeId}
onSelect={(id: string) => {
onSelectConversation(id)
onClose()
}}
onUpdateTitle={onUpdateTitle}
onDelete={onDeleteConversation}
isLoading={isLoading}
/>
<ChatUserProfile className="border-t border-border/60 bg-muted/30" />
</aside>
</>
)
}

View File

@@ -0,0 +1,27 @@
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { cn } from '@/lib/utils'
interface ChatUserProfileProps {
collapsed?: boolean
className?: string
}
export function ChatUserProfile({ collapsed = false, className }: ChatUserProfileProps) {
return (
<div className={cn('p-3 border-t border-border/40 flex items-center justify-between bg-card/40', className)}>
<div className="flex items-center gap-2.5 overflow-hidden">
<Avatar className="w-8 h-8 border border-border/80 shrink-0">
<AvatarFallback className="bg-primary/20 text-primary text-xs font-bold">
SP
</AvatarFallback>
</Avatar>
{!collapsed && (
<div className="min-w-0">
<p className="text-xs font-semibold truncate leading-tight">Son Phung</p>
<p className="text-[10px] text-muted-foreground">Ultra Member</p>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,182 @@
import React, { useState, useEffect } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import { codeToHtml } from 'shiki'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Check, Copy } from 'lucide-react'
import { Button } from '@/components/ui/button'
interface CodeBlockProps {
code: string
language: string
}
const CodeBlock = React.memo(function CodeBlock({ code, language }: CodeBlockProps) {
const [html, setHtml] = useState<string>('')
const [copied, setCopied] = useState(false)
useEffect(() => {
let isMounted = true
const lang = language || 'plaintext'
codeToHtml(code, {
lang: lang,
theme: 'github-dark',
})
.then((highlightedHtml) => {
if (isMounted) setHtml(highlightedHtml)
})
.catch(() => {
// Fallback to plaintext if language is unsupported or during streaming syntax errors
codeToHtml(code, { lang: 'plaintext', theme: 'github-dark' })
.then((fallbackHtml) => {
if (isMounted) setHtml(fallbackHtml)
})
.catch(() => {})
})
return () => {
isMounted = false
}
}, [code, language])
const copyToClipboard = () => {
navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (!html) {
return (
<div className="relative my-3 rounded-xl border border-border bg-slate-950 p-4 font-mono text-xs text-emerald-400 overflow-x-auto">
<div className="flex justify-between items-center pb-2 mb-2 border-b border-white/10 text-[10px] text-muted-foreground font-mono">
<span>{language || 'code'}</span>
</div>
<pre><code>{code}</code></pre>
</div>
)
}
return (
<div className="relative my-3 rounded-xl border border-border/80 overflow-hidden group shadow-md">
<div className="flex justify-between items-center px-4 py-1.5 bg-slate-900 border-b border-white/10 text-xs text-muted-foreground font-mono">
<span className="text-[11px] font-semibold text-slate-300">{language || 'code'}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground hover:bg-slate-800"
onClick={copyToClipboard}
title="Copy code"
>
{copied ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
</Button>
</div>
<div
className="p-4 text-xs font-mono overflow-x-auto bg-slate-950 [&>pre]:!bg-transparent [&>pre]:!p-0 [&>pre]:!m-0"
dangerouslySetInnerHTML={{ __html: html }}
/>
</div>
)
})
interface MarkdownRendererProps {
content: string
isStreaming?: boolean
}
export const MarkdownRenderer = React.memo(function MarkdownRenderer({
content,
isStreaming,
}: MarkdownRendererProps) {
return (
<div className="prose dark:prose-invert max-w-none text-sm leading-relaxed text-foreground">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
h1: ({ node, ...props }) => (
<h1 className="text-xl font-bold tracking-tight my-3 text-foreground" {...props} />
),
h2: ({ node, ...props }) => (
<h2 className="text-lg font-semibold tracking-tight my-2.5 text-foreground" {...props} />
),
h3: ({ node, ...props }) => (
<h3 className="text-base font-semibold my-2 text-foreground" {...props} />
),
p: ({ node, ...props }) => (
<p className="leading-relaxed my-2 text-foreground/90" {...props} />
),
ol: ({ node, ...props }) => (
<ol className="list-decimal list-outside ml-5 space-y-1.5 my-2" {...props} />
),
ul: ({ node, ...props }) => (
<ul className="list-disc list-outside ml-5 space-y-1.5 my-2" {...props} />
),
li: ({ node, ...props }) => (
<li className="leading-relaxed text-foreground/90 pl-0.5" {...props} />
),
blockquote: ({ node, ...props }) => (
<blockquote className="border-l-4 border-primary/80 pl-4 my-3 italic text-muted-foreground bg-primary/5 py-1 rounded-r-md" {...props} />
),
strong: ({ node, ...props }) => (
<strong className="font-semibold text-primary" {...props} />
),
table: ({ node, ...props }) => (
<div className="overflow-x-auto my-3 rounded-lg border border-border bg-card/60 shadow-sm">
<Table className="min-w-full" {...props} />
</div>
),
thead: ({ node, ...props }) => <TableHeader {...props} />,
tbody: ({ node, ...props }) => <TableBody {...props} />,
tr: ({ node, ...props }) => <TableRow {...props} />,
th: ({ node, ...props }) => <TableHead className="font-semibold text-foreground bg-muted/40" {...props} />,
td: ({ node, ...props }) => <TableCell className="text-foreground/90" {...props} />,
pre: ({ children }) => <>{children}</>,
code: ({ node, className, children, ...props }: any) => {
const contentStr = String(children || '').replace(/\n$/, '')
const match = /language-(\w+)/.exec(className || '')
const isMultiLine = contentStr.includes('\n')
const isInline = !match && !isMultiLine
if (isInline) {
return (
<code
className="bg-primary/10 text-primary border border-primary/20 px-1.5 py-0.5 rounded font-mono text-[0.85em] font-medium inline-baseline mx-0.5"
{...props}
>
{children}
</code>
)
}
return (
<CodeBlock
code={contentStr}
language={match ? match[1] : ''}
/>
)
},
a: ({ node, ...props }) => (
<a
className="text-primary hover:underline font-medium"
target="_blank"
rel="noopener noreferrer"
{...props}
/>
),
}}
>
{content}
</ReactMarkdown>
{isStreaming && (
<span className="inline-block w-2 h-4 bg-primary animate-pulse ml-1 align-middle rounded-full" />
)}
</div>
)
})

View File

@@ -0,0 +1,31 @@
import { Sun, Moon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useTheme } from '@/hooks/useTheme'
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme()
const isDark = theme === 'dark'
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={toggleTheme}
className="h-8 w-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted/80 transition-all duration-300 relative overflow-hidden group"
aria-label="Chuyển đổi giao diện sáng/tối"
>
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all duration-500 ease-in-out dark:-rotate-90 dark:scale-0 text-amber-500" />
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all duration-500 ease-in-out dark:rotate-0 dark:scale-100 text-sky-400" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
<p>{isDark ? 'Chuyển sang giao diện Sáng' : 'Chuyển sang giao diện Tối'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}

View File

@@ -0,0 +1,30 @@
import { Badge } from '@/components/ui/badge'
import { Loader2 } from 'lucide-react'
interface ToolStatusIndicatorProps {
toolName?: string
}
const TOOL_NAME_MAP: Record<string, string> = {
searchCampaigns: '🔍 Đang tìm kiếm chiến dịch...',
getCampaignDetails: '📋 Đang lấy thông tin chi tiết chiến dịch...',
createCampaign: '✨ Đang khởi tạo chiến dịch mới...',
executeWorkflow: '⚡ Đang xử lý quy trình...',
queryCustomerPoints: '💳 Đang tra cứu điểm thưởng khách hàng...',
}
export function ToolStatusIndicator({ toolName }: ToolStatusIndicatorProps) {
if (!toolName) return null
const displayText = TOOL_NAME_MAP[toolName] || `🔍 Đang xử lý: ${toolName}...`
return (
<Badge
variant="outline"
className="gap-2 text-xs font-normal border-amber-500/30 bg-amber-500/10 text-amber-500 py-1 px-3 w-fit animate-pulse my-0.5 shadow-sm"
>
<Loader2 className="w-3.5 h-3.5 animate-spin text-amber-500 shrink-0" />
<span>{displayText}</span>
</Badge>
)
}

View File

@@ -0,0 +1,93 @@
import { Sparkles, MessageSquare, ShieldCheck, BarChart3, ArrowRight } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { useChatStore } from '@/store/useChatStore'
const ACTION_MATRIX = [
{
tag: 'QUICK QUERY',
tagColor: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
title: 'Danh sách chiến dịch',
description: 'Tra cứu thông tin các chương trình khuyến mãi và ưu đãi đang chạy.',
icon: Sparkles,
prompt: 'Cho tôi xem danh sách các chiến dịch khuyến mãi đang diễn ra.',
},
{
tag: 'CAMPAIGN BUILDER',
tagColor: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
title: 'Tạo campaign mới',
description: 'Khởi tạo chiến dịch tích điểm & khuyến mãi mới cho khách hàng.',
icon: MessageSquare,
prompt: 'Tôi muốn tạo một chiến dịch khuyến mãi tích điểm mới cho khách hàng.',
},
{
tag: 'VIP INSIGHTS',
tagColor: 'bg-amber-500/10 text-amber-500 border-amber-500/20',
title: 'Tra cứu hạng thẻ VIP',
description: 'Kiểm tra điểm thưởng, lịch sử đổi quà và quyền lợi khách hàng VIP.',
icon: ShieldCheck,
prompt: 'Hãy tra cứu thông tin điểm thưởng và hạng thẻ của khách hàng VIP.',
},
{
tag: 'DATA REPORT',
tagColor: 'bg-purple-500/10 text-purple-500 border-purple-500/20',
title: 'Báo cáo hiệu suất',
description: 'Tổng hợp phân tích hiệu quả chương trình chăm sóc khách hàng.',
icon: BarChart3,
prompt: 'Phân tích tổng quan hiệu suất chương trình loyalty và tích điểm.',
},
]
export function WelcomeScreen() {
const sendMessage = useChatStore((s) => s.sendMessage)
return (
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center max-w-3xl mx-auto animate-in fade-in duration-300">
<h1 className="text-3xl font-extrabold tracking-tight bg-gradient-to-r from-foreground via-foreground/90 to-muted-foreground bg-clip-text text-transparent mb-2">
Bắt đu cuộc trò chuyện mới
</h1>
<p className="text-sm text-muted-foreground mb-8 max-w-lg leading-relaxed">
Chọn một tác vụ studio nhanh bên dưới hoặc nhập yêu cầu đ trợ AI thực hiện công việc cho bạn.
</p>
{/* Action Matrix 2x2 Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3.5 w-full text-left">
{ACTION_MATRIX.map((item, idx) => {
const Icon = item.icon
return (
<div
key={idx}
onClick={() => sendMessage(item.prompt)}
className="group cursor-pointer p-4 rounded-2xl bg-card border border-border/70 hover:border-primary/50 hover:shadow-md hover:bg-secondary/40 transition-all duration-200 flex flex-col justify-between min-h-[140px] relative overflow-hidden"
>
<div>
<div className="flex items-center justify-between gap-2 mb-2.5">
<Badge variant="outline" className={`text-[10px] font-bold tracking-wider uppercase border ${item.tagColor}`}>
{item.tag}
</Badge>
<div className="p-1.5 rounded-lg bg-secondary text-muted-foreground group-hover:text-primary group-hover:bg-primary/10 transition-colors">
<Icon className="w-4 h-4" />
</div>
</div>
<h3 className="text-sm font-bold text-foreground group-hover:text-primary transition-colors">
{item.title}
</h3>
<p className="text-xs text-muted-foreground mt-1 line-clamp-2 leading-relaxed">
{item.description}
</p>
</div>
<div className="pt-3 border-t border-border/40 mt-3 flex items-center justify-between text-xs font-medium text-muted-foreground group-hover:text-primary transition-colors">
<span>Thử ngay</span>
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform" />
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,43 @@
import { GripVertical } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Group>) => (
<ResizablePrimitive.Group
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col data-[orientation=vertical]:flex-col",
className
)}
{...props}
/>
)
const ResizablePanel = ResizablePrimitive.Panel
const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.Separator
className={cn(
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90 data-[orientation=vertical]:h-px data-[orientation=vertical]:w-full data-[orientation=vertical]:after:left-0 data-[orientation=vertical]:after:h-1 data-[orientation=vertical]:after:w-full data-[orientation=vertical]:after:-translate-y-1/2 data-[orientation=vertical]:after:translate-x-0 [&[data-orientation=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="z-10 flex h-7 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.Separator>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }

117
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last-child:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,28 @@
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ComponentRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -0,0 +1,175 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { useChatStore } from '../store/useChatStore'
import type { Message } from '../store/useChatStore'
export interface ConversationItem {
id: string
title: string
preview: string
createdAt: string
updatedAt: string
}
interface BackendMessage {
id: string
conversationId: string
role: string
content: string
createdAt: string
}
export function useConversations() {
const [conversations, setConversations] = useState<ConversationItem[]>([])
const [activeId, setActiveId] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const setConversationId = useChatStore((s) => s.setConversationId)
const setMessages = useChatStore((s) => s.setMessages)
const fetchConversations = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const res = await fetch('/api/v1/conversations')
if (!res.ok) {
throw new Error(`Failed to fetch conversations: ${res.statusText}`)
}
const data: ConversationItem[] = await res.json()
setConversations(data)
return data
} catch (err: any) {
console.error('[useConversations] fetchConversations error:', err)
setError(err.message || 'Error fetching conversations')
return []
} finally {
setIsLoading(false)
}
}, [])
const loadMessages = useCallback(async (conversationId: string) => {
try {
const res = await fetch(`/api/v1/conversations/${conversationId}/messages`)
if (!res.ok) {
throw new Error(`Failed to fetch messages: ${res.statusText}`)
}
const rawMessages: BackendMessage[] = await res.json()
const formattedMessages: Message[] = rawMessages.map((m) => ({
id: m.id,
role: m.role.toLowerCase() === 'user' ? 'user' : 'agent',
content: m.content,
createdAt: m.createdAt,
}))
setMessages(formattedMessages)
} catch (err: any) {
console.error('[useConversations] loadMessages error:', err)
setMessages([])
}
}, [setMessages])
const deleteConversation = useCallback(async (id: string) => {
try {
await fetch(`/api/v1/conversations/${id}`, { method: 'DELETE' })
setConversations((prev) => prev.filter((c) => c.id !== id))
} catch (err: any) {
console.error('[useConversations] deleteConversation error:', err)
}
}, [])
const updateTitle = useCallback(async (id: string, newTitle: string) => {
try {
const res = await fetch(`/api/v1/conversations/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: newTitle }),
})
if (!res.ok) throw new Error('Failed to update title')
const updated: ConversationItem = await res.json()
setConversations((prev) =>
prev.map((c) => (c.id === id ? { ...c, title: updated.title } : c))
)
return true
} catch (err: any) {
console.error('[useConversations] updateTitle error:', err)
return false
}
}, [])
const selectConversation = useCallback(async (id: string) => {
const currentMessages = useChatStore.getState().messages
if (activeId && activeId !== id && currentMessages.length === 0) {
deleteConversation(activeId)
}
setActiveId(id)
setConversationId(id)
await loadMessages(id)
}, [activeId, setConversationId, loadMessages, deleteConversation])
const createNewConversation = useCallback(async () => {
const currentMessages = useChatStore.getState().messages
if (activeId && currentMessages.length === 0) {
return conversations.find((c) => c.id === activeId) || null
}
setIsLoading(true)
try {
const res = await fetch('/api/v1/conversations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (!res.ok) {
throw new Error(`Failed to create conversation: ${res.statusText}`)
}
const newConv: ConversationItem = await res.json()
const newItem: ConversationItem = {
id: newConv.id,
title: 'Cuộc trò chuyện mới',
preview: '',
createdAt: newConv.createdAt,
updatedAt: newConv.updatedAt,
}
setConversations((prev) => [newItem, ...prev])
setActiveId(newConv.id)
setConversationId(newConv.id)
setMessages([])
return newItem
} catch (err: any) {
console.error('[useConversations] createNewConversation error:', err)
setError(err.message || 'Error creating conversation')
return null
} finally {
setIsLoading(false)
}
}, [activeId, conversations, setConversationId, setMessages])
const initRef = useRef(false)
// Initial load
useEffect(() => {
if (initRef.current) return
initRef.current = true
fetchConversations().then((items) => {
if (items && items.length > 0) {
selectConversation(items[0].id)
} else {
createNewConversation()
}
})
}, [])
return {
conversations,
activeId,
isLoading,
error,
fetchConversations,
selectConversation,
createNewConversation,
deleteConversation,
updateTitle,
}
}

21
src/hooks/useIsMobile.ts Normal file
View File

@@ -0,0 +1,21 @@
import { useState, useEffect } from 'react'
export function useIsMobile(breakpoint = 768) {
const [isMobile, setIsMobile] = useState<boolean>(() => {
if (typeof window !== 'undefined') {
return window.innerWidth < breakpoint
}
return false
})
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth < breakpoint)
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [breakpoint])
return isMobile
}

51
src/hooks/useTheme.ts Normal file
View File

@@ -0,0 +1,51 @@
import { useState, useEffect } from 'react'
export type Theme = 'dark' | 'light' | 'system'
export function useTheme() {
const [theme, setTheme] = useState<Theme>(() => {
const saved = localStorage.getItem('theme') as Theme
return saved || 'dark' // Mặc định dark theme cho ứng dụng
})
useEffect(() => {
const root = window.document.documentElement
const applyTheme = (targetTheme: Theme) => {
root.classList.remove('light', 'dark')
if (targetTheme === 'system') {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
root.classList.add(systemTheme)
} else {
root.classList.add(targetTheme)
}
}
applyTheme(theme)
localStorage.setItem('theme', theme)
}, [theme])
// System listener
useEffect(() => {
if (theme !== 'system') return
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
const handleChange = () => {
const root = window.document.documentElement
root.classList.remove('light', 'dark')
root.classList.add(mediaQuery.matches ? 'dark' : 'light')
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [theme])
const toggleTheme = () => {
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))
}
return { theme, setTheme, toggleTheme }
}

View File

@@ -0,0 +1,175 @@
import { Client } from '@stomp/stompjs'
import { useConnectionStore } from '../store/useConnectionStore'
export interface ChatEvent {
messageId?: string
type: 'TOKEN' | 'TOOL_STATUS' | 'DONE' | 'ERROR'
content?: string
tool?: string
status?: string
}
interface QueuedMessage {
destination: string
body: string
}
class StompService {
private client: Client | null = null
private reconnectAttempt = 0
private reconnectTimeoutId: any = null
private messageQueue: QueuedMessage[] = []
private messageListeners: Array<(event: ChatEvent) => void> = []
private maxQueueSize = 20
private getWsUrl(): string {
const host = window.location.hostname || 'localhost'
const port = window.location.port ? window.location.port : '9332'
// If dev server port 9333 or similar, connect to 9332 if port is 9333, else same port
const targetPort = port === '9333' || port === '5173' ? '9332' : port
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${protocol}//${host}:${targetPort}/ws`
}
public connect() {
if (this.client && this.client.active) {
return
}
useConnectionStore.getState().setStatus('connecting')
this.client = new Client({
brokerURL: this.getWsUrl(),
reconnectDelay: 0, // Manual reconnect management for exponential backoff + jitter
heartbeatIncoming: 10000,
heartbeatOutgoing: 10000,
onConnect: () => {
console.log('[StompService] Connected to WebSocket')
this.reconnectAttempt = 0
useConnectionStore.getState().setStatus('connected')
// Subscribe to user chat events
this.client?.subscribe('/user/queue/chat-events', (message) => {
try {
const event: ChatEvent = JSON.parse(message.body)
this.notifyMessageListeners(event)
} catch (e) {
console.error('[StompService] Failed to parse message body:', e)
}
})
// Flush offline queue
this.flushQueue()
},
onWebSocketClose: () => {
console.warn('[StompService] WebSocket connection closed')
useConnectionStore.getState().setStatus('disconnected')
this.scheduleReconnect()
},
onStompError: (frame) => {
console.error('[StompService] STOMP error frame received:', frame.headers['message'])
useConnectionStore.getState().setStatus('error')
this.scheduleReconnect()
},
onWebSocketError: (event) => {
console.error('[StompService] WebSocket error:', event)
useConnectionStore.getState().setStatus('error')
}
})
this.client.activate()
}
public disconnect() {
if (this.reconnectTimeoutId) {
clearTimeout(this.reconnectTimeoutId)
this.reconnectTimeoutId = null
}
if (this.client) {
this.client.deactivate()
this.client = null
}
useConnectionStore.getState().setStatus('disconnected')
}
private scheduleReconnect() {
if (this.reconnectTimeoutId) return
// Base exponential backoff: 1s, 2s, 4s, 8s, 16s, capped at 30s
const baseDelay = Math.min(1000 * Math.pow(2, this.reconnectAttempt), 30000)
// Jitter: ±50% (0.5 to 1.5 multiplier)
const jitter = 0.5 + Math.random()
const delay = Math.round(baseDelay * jitter)
this.reconnectAttempt++
console.log(`[StompService] Scheduling reconnect attempt #${this.reconnectAttempt} in ${delay}ms`)
this.reconnectTimeoutId = setTimeout(() => {
this.reconnectTimeoutId = null
if (this.client) {
this.client.activate()
} else {
this.connect()
}
}, delay)
}
public publish(destination: string, payload: object): boolean {
const body = JSON.stringify(payload)
const isConnected = this.client && this.client.connected
if (isConnected) {
this.client!.publish({ destination, body })
return true
} else {
if (this.messageQueue.length >= this.maxQueueSize) {
console.warn('[StompService] Queue limit reached (20). Message dropped.')
return false
}
this.messageQueue.push({ destination, body })
useConnectionStore.getState().setQueuedCount(this.messageQueue.length)
console.log(`[StompService] Message queued offline (${this.messageQueue.length}/${this.maxQueueSize})`)
// Ensure we are trying to reconnect
this.connect()
return true
}
}
private flushQueue() {
if (!this.client || !this.client.connected) return
while (this.messageQueue.length > 0) {
const msg = this.messageQueue.shift()
if (msg) {
this.client.publish({ destination: msg.destination, body: msg.body })
}
}
useConnectionStore.getState().setQueuedCount(0)
console.log('[StompService] Offline message queue flushed')
}
public onMessage(callback: (event: ChatEvent) => void): () => void {
this.messageListeners.push(callback)
return () => {
this.messageListeners = this.messageListeners.filter((cb) => cb !== callback)
}
}
private notifyMessageListeners(event: ChatEvent) {
this.messageListeners.forEach((listener) => {
try {
listener(event)
} catch (e) {
console.error('[StompService] Error in message listener:', e)
}
})
}
public getQueueLength(): number {
return this.messageQueue.length
}
}
export const stompService = new StompService()

View File

@@ -1,5 +1,7 @@
import { create } from 'zustand'
import { Client } from '@stomp/stompjs'
import { stompService } from '../services/StompService'
import type { ChatEvent } from '../services/StompService'
export type Role = 'user' | 'agent'
@@ -9,96 +11,70 @@ export interface Message {
content: string
isStreaming?: boolean
toolStatus?: string
createdAt?: string
}
interface ChatState {
conversationId: string
messages: Message[]
isRightPanelOpen: boolean
isConnected: boolean
isConnecting: boolean
stompClient: Client | null
activeAgentMessageId: string | null
setConversationId: (id: string) => void
setMessages: (messages: Message[]) => void
initStomp: () => void
sendMessage: (content: string, activeCampaignId?: string) => void
addMessage: (message: Message) => void
updateMessage: (id: string, updater: (msg: Message) => Message) => void
toggleRightPanel: () => void
conversationId: string
}
let isStompInitialized = false
export const useChatStore = create<ChatState>((set, get) => ({
conversationId: '',
messages: [],
isRightPanelOpen: false,
isConnected: false,
isConnecting: false,
stompClient: null,
activeAgentMessageId: null,
conversationId: crypto.randomUUID(),
toggleRightPanel: () => set((state) => ({ isRightPanelOpen: !state.isRightPanelOpen })),
setConversationId: (id: string) => set({ conversationId: id }),
setMessages: (messages: Message[]) => set({ messages }),
initStomp: () => {
if (get().stompClient) return;
if (isStompInitialized) return
isStompInitialized = true
set({ isConnecting: true })
stompService.connect()
stompService.onMessage((event: ChatEvent) => {
const msgId = event.messageId
if (!msgId) return
const client = new Client({
brokerURL: 'ws://localhost:9332/ws',
reconnectDelay: 5000,
heartbeatIncoming: 10000,
heartbeatOutgoing: 10000,
onConnect: () => {
set({ isConnected: true, isConnecting: false })
client.subscribe('/user/queue/chat-events', (message) => {
const event = JSON.parse(message.body);
const msgId = event.messageId;
if (!msgId) return;
if (event.type === 'TOKEN') {
get().updateMessage(msgId, (msg) => ({
...msg,
content: msg.content + event.content
}));
} else if (event.type === 'TOOL_STATUS') {
get().updateMessage(msgId, (msg) => ({
...msg,
toolStatus: event.status === 'running' ? `Executing ${event.tool}...` : ''
}));
} else if (event.type === 'DONE') {
get().updateMessage(msgId, (msg) => ({
...msg,
isStreaming: false
}));
} else if (event.type === 'ERROR') {
get().updateMessage(msgId, (msg) => ({
...msg,
content: msg.content + '\n[ERROR] ' + event.content,
isStreaming: false
}));
}
});
},
onWebSocketClose: () => {
set({ isConnected: false, isConnecting: false })
},
onStompError: (frame) => {
console.error('Broker error', frame.headers['message'])
set({ isConnected: false, isConnecting: false })
if (event.type === 'TOKEN') {
get().updateMessage(msgId, (msg) => ({
...msg,
content: msg.content + (event.content || ''),
isStreaming: true
}))
} else if (event.type === 'TOOL_STATUS') {
get().updateMessage(msgId, (msg) => ({
...msg,
toolStatus: event.status === 'running' ? event.tool || 'running' : ''
}))
} else if (event.type === 'DONE') {
get().updateMessage(msgId, (msg) => ({
...msg,
isStreaming: false,
toolStatus: undefined
}))
} else if (event.type === 'ERROR') {
get().updateMessage(msgId, (msg) => ({
...msg,
content: msg.content + '\n[ERROR] ' + (event.content || ''),
isStreaming: false,
toolStatus: undefined
}))
}
})
client.activate()
set({ stompClient: client })
},
sendMessage: (content: string, activeCampaignId?: string) => {
const client = get().stompClient
if (!client || !client.connected) {
console.error('STOMP client is not connected')
return
}
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
@@ -116,15 +92,14 @@ export const useChatStore = create<ChatState>((set, get) => ({
isStreaming: true
})
client.publish({
destination: '/app/chat',
body: JSON.stringify({
conversationId: get().conversationId,
messageId: agentMessageId,
prompt: content,
activeCampaignId
})
})
const payload = {
conversationId: get().conversationId,
messageId: agentMessageId,
prompt: content,
activeCampaignId
}
stompService.publish('/app/chat', payload)
},
addMessage: (message: Message) => {

View File

@@ -0,0 +1,17 @@
import { create } from 'zustand'
export type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error'
interface ConnectionState {
status: ConnectionStatus
queuedCount: number
setStatus: (status: ConnectionStatus) => void
setQueuedCount: (count: number) => void
}
export const useConnectionStore = create<ConnectionState>((set) => ({
status: 'disconnected',
queuedCount: 0,
setStatus: (status) => set({ status }),
setQueuedCount: (count) => set({ queuedCount: count }),
}))