feat: overhaul chat architecture with persistent JdbcChatMemory, reactive UI components, and improved conversation management
This commit is contained in:
185
src/App.css
185
src/App.css
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 có 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
134
src/components/chat/ChatDesktopSidebar.tsx
Normal file
134
src/components/chat/ChatDesktopSidebar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
src/components/chat/ChatHeader.tsx
Normal file
91
src/components/chat/ChatHeader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 có 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
41
src/components/chat/ChatInfoPanel.tsx
Normal file
41
src/components/chat/ChatInfoPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
91
src/components/chat/ChatMobileSidebar.tsx
Normal file
91
src/components/chat/ChatMobileSidebar.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
27
src/components/chat/ChatUserProfile.tsx
Normal file
27
src/components/chat/ChatUserProfile.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
182
src/components/chat/MarkdownRenderer.tsx
Normal file
182
src/components/chat/MarkdownRenderer.tsx
Normal 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>
|
||||
)
|
||||
})
|
||||
31
src/components/chat/ThemeToggle.tsx
Normal file
31
src/components/chat/ThemeToggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
src/components/chat/ToolStatusIndicator.tsx
Normal file
30
src/components/chat/ToolStatusIndicator.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
93
src/components/chat/WelcomeScreen.tsx
Normal file
93
src/components/chat/WelcomeScreen.tsx
Normal 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ợ lý 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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal 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 }
|
||||
43
src/components/ui/resizable.tsx
Normal file
43
src/components/ui/resizable.tsx
Normal 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
117
src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
28
src/components/ui/tooltip.tsx
Normal file
28
src/components/ui/tooltip.tsx
Normal 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 }
|
||||
175
src/hooks/useConversations.ts
Normal file
175
src/hooks/useConversations.ts
Normal 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
21
src/hooks/useIsMobile.ts
Normal 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
51
src/hooks/useTheme.ts
Normal 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 }
|
||||
}
|
||||
175
src/services/StompService.ts
Normal file
175
src/services/StompService.ts
Normal 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()
|
||||
@@ -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) => {
|
||||
|
||||
17
src/store/useConnectionStore.ts
Normal file
17
src/store/useConnectionStore.ts
Normal 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 }),
|
||||
}))
|
||||
Reference in New Issue
Block a user