refactor: migrate loyalty-agent and loyalty-mcp-server components to a new modular structure with updated specs and frontend integration.

This commit is contained in:
2026-07-19 21:43:44 +07:00
parent c3861d6476
commit cda93365ca
128 changed files with 12721 additions and 6343 deletions

184
src/App.css Normal file
View File

@@ -0,0 +1,184 @@
.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);
}
}

9
src/App.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { ChatLayout } from '@/components/chat/ChatLayout'
function App() {
return (
<ChatLayout />
)
}
export default App

BIN
src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

1
src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

1
src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

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

View File

@@ -0,0 +1,60 @@
import { useState } from 'react'
import type { KeyboardEvent } from 'react'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { useChatStore } from '@/store/useChatStore'
import { SendHorizontal } 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 handleSend = () => {
if (input.trim() && isConnected) {
sendMessage(input.trim())
setInput('')
}
}
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
}
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>
{!isConnected && !isConnecting && (
<div className="text-center text-destructive text-sm mt-2">
Disconnected from agent server. Retrying...
</div>
)}
{isConnecting && (
<div className="text-center text-muted-foreground text-sm mt-2 animate-pulse">
Connecting to agent server...
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,48 @@
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { cn } from '@/lib/utils'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import type { Message } from '@/store/useChatStore'
export function ChatBubble({ message }: { message: Message }) {
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="flex flex-col gap-1 max-w-[80%]">
{message.toolStatus && (
<span className="text-xs text-muted-foreground animate-pulse italic">
{message.toolStatus}
</span>
)}
<div
className={cn(
"p-3 rounded-md text-sm shadow-sm",
isAgent ? "bg-muted text-foreground" : "bg-primary text-primary-foreground"
)}
>
{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>
) : (
<span className="whitespace-pre-wrap">{message.content}</span>
)}
</div>
</div>
{!isAgent && (
<Avatar className="w-8 h-8">
<AvatarFallback>U</AvatarFallback>
</Avatar>
)}
</div>
)
}

View File

@@ -0,0 +1,40 @@
import { MessageSquare } from 'lucide-react';
interface ChatSession {
id: string;
title: string;
date: string;
}
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() {
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}
</div>
<div className="text-xs text-muted-foreground mt-1">
{session.date}
</div>
</div>
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { useEffect } from 'react'
import { ChatList } from './ChatList'
import { ChatBottombar } from './ChatBottombar'
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'
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)
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>
{/* 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 />
</div>
</main>
{/* Right Panel: Context / Campaigns */}
{isRightPanelOpen && (
<aside className="w-80 flex flex-col glass-panel rounded-2xl overflow-hidden shrink-0 animate-in slide-in-from-right-4 duration-300">
<div className="px-4 py-4 border-b border-white/10 dark:border-white/5 font-semibold text-sm text-foreground/80 flex items-center justify-between">
System Context
</div>
<div className="px-4 pt-4 font-semibold text-xs uppercase tracking-wider text-muted-foreground">Campaigns</div>
<CampaignList />
</aside>
)}
</div>
)
}

View File

@@ -0,0 +1,28 @@
import { useRef, useEffect } from 'react'
import { ChatBubble } from './ChatBubble'
import type { Message } from '@/store/useChatStore'
import { ScrollArea } from '@/components/ui/scroll-area'
export function ChatList({ messages }: { messages: Message[] }) {
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages])
return (
<ScrollArea className="flex-1 w-full p-4 h-full">
<div className="flex flex-col gap-2 pb-4">
{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>
)
}

View File

@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

96
src/index.css Normal file
View File

@@ -0,0 +1,96 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 180 30% 98%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 171 77% 35%; /* Teal/Emerald */
--primary-foreground: 210 40% 98%;
--secondary: 180 15% 90%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 180 15% 90%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 171 77% 35%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 180 15% 85%;
--input: 180 15% 85%;
--ring: 171 77% 35%;
--radius: 1rem; /* 16px border radius for glassmorphism */
}
.dark {
--background: 222 47% 7%;
--foreground: 210 40% 98%;
--card: 222 47% 9%;
--card-foreground: 210 40% 98%;
--popover: 222 47% 9%;
--popover-foreground: 210 40% 98%;
--primary: 171 77% 45%; /* Vibrant Teal */
--primary-foreground: 210 40% 98%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 171 77% 45%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground font-sans;
/* Soft mesh gradient background */
background-image:
radial-gradient(at 0% 0%, hsla(171, 77%, 35%, 0.15) 0px, transparent 50%),
radial-gradient(at 100% 0%, hsla(222, 47%, 50%, 0.15) 0px, transparent 50%),
radial-gradient(at 100% 100%, hsla(280, 50%, 50%, 0.15) 0px, transparent 50%);
background-attachment: fixed;
}
.dark body {
background-image:
radial-gradient(at 0% 0%, hsla(171, 77%, 45%, 0.15) 0px, transparent 50%),
radial-gradient(at 100% 0%, hsla(222, 47%, 50%, 0.15) 0px, transparent 50%),
radial-gradient(at 100% 100%, hsla(280, 50%, 50%, 0.15) 0px, transparent 50%);
}
}
/* Glassmorphism utility classes */
@layer utilities {
.glass-panel {
@apply bg-background/60 backdrop-blur-xl border border-white/20 dark:border-white/10 shadow-lg;
}
.glass-card {
@apply bg-card/60 backdrop-blur-md border border-white/20 dark:border-white/10 shadow-sm hover:shadow-md transition-shadow;
}
/* Custom thin scrollbar */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
height: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
@apply bg-border rounded-full;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
@apply bg-primary/50;
}
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

10
src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -1,12 +0,0 @@
package dev.sonpx.loyalty.agent;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class LoyaltyAgentApplication {
public static void main(String[] args) {
SpringApplication.run(LoyaltyAgentApplication.class, args);
}
}

View File

@@ -1,19 +0,0 @@
package dev.sonpx.loyalty.agent.client.api;
import com.example.loyalty.agent.client.model.Campaign;
import com.example.loyalty.agent.client.model.CreateCampaignRequest;
import java.util.UUID;
public class CampaignApi {
public Campaign createCampaign(CreateCampaignRequest request) {
// Mock behavior
return new Campaign(
UUID.randomUUID().toString(),
request.name(),
request.targetTierId(),
request.rewardPoolId(),
"ACTIVE"
);
}
}

View File

@@ -1,12 +0,0 @@
package dev.sonpx.loyalty.agent.client.api;
import com.example.loyalty.agent.client.model.MemberTier;
import java.util.List;
public class MemberTierApi {
public List<MemberTier> listMemberTiers() {
// Mock data to ensure it compiles and works before OpenAPI generation
return List.of(new MemberTier("TIER_GOLD", "Gold", 1000));
}
}

View File

@@ -1,13 +0,0 @@
package dev.sonpx.loyalty.agent.client.api;
import com.example.loyalty.agent.client.model.PointPool;
import java.math.BigDecimal;
import java.util.List;
public class PointPoolApi {
public List<PointPool> listPointPools() {
// Mock data
return List.of(new PointPool("POOL_1", "Main Pool", new BigDecimal("1000000")));
}
}

View File

@@ -1,3 +0,0 @@
package com.example.loyalty.agent.client.model;
public record Campaign(String campaignId, String name, String targetTierId, String rewardPoolId, String status) {}

View File

@@ -1,3 +0,0 @@
package com.example.loyalty.agent.client.model;
public record CreateCampaignRequest(String name, String targetTierId, String rewardPoolId) {}

View File

@@ -1,3 +0,0 @@
package com.example.loyalty.agent.client.model;
public record MemberTier(String tierId, String tierName, Integer requiredPoints) {}

View File

@@ -1,5 +0,0 @@
package com.example.loyalty.agent.client.model;
import java.math.BigDecimal;
public record PointPool(String poolId, String poolName, BigDecimal balance) {}

View File

@@ -1,105 +0,0 @@
package dev.sonpx.loyalty.agent.config;
import dev.sonpx.loyalty.agent.client.api.CampaignApi;
import dev.sonpx.loyalty.agent.client.api.MemberTierApi;
import dev.sonpx.loyalty.agent.client.api.PointPoolApi;
import dev.sonpx.loyalty.agent.client.api.ApiClient;
import dev.sonpx.loyalty.agent.client.api.attribute.StaticAttributeResourceApi;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
/**
* Configures the generated API clients.
* This class mocks the configuration that will wrap the actual classes
* generated by openapi-generator-maven-plugin.
*/
@Configuration
public class ApiClientConfig {
@Value("${loyalty.core.base-url}")
private String coreBaseUrl;
private ApiClient createApiClient(OAuth2AuthorizedClientManager authorizedClientManager, String servicePrefix) {
ApiClient apiClient = new ApiClient();
apiClient.updateBaseUri(coreBaseUrl + servicePrefix);
apiClient.setRequestInterceptor(builder -> {
System.out.println("Executing API Client Interceptor for keycloak...");
org.springframework.security.core.Authentication principal =
new org.springframework.security.authentication.AnonymousAuthenticationToken(
"key", "loyalty-agent-service",
org.springframework.security.core.authority.AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
.withClientRegistrationId("keycloak")
.principal(principal)
.build();
try {
OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
if (authorizedClient != null && authorizedClient.getAccessToken() != null) {
System.out.println("Successfully fetched OAuth2 Token! Token length: " + authorizedClient.getAccessToken().getTokenValue().length());
builder.header("Authorization", "Bearer " + authorizedClient.getAccessToken().getTokenValue());
} else {
System.out.println("WARNING: authorizedClient is null or accessToken is null!");
}
} catch (Exception e) {
System.out.println("ERROR during OAuth2 authorize: " + e.getMessage());
e.printStackTrace();
}
});
return apiClient;
}
@Bean
public StaticAttributeResourceApi staticAttributeResourceApi(OAuth2AuthorizedClientManager authorizedClientManager) {
return new StaticAttributeResourceApi(createApiClient(authorizedClientManager, "/svc/attribute"));
}
@Bean
public org.springframework.web.client.RestClient loyaltyRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
return org.springframework.web.client.RestClient.builder()
.baseUrl(coreBaseUrl + "/svc/attribute")
.requestInterceptor((request, body, execution) -> {
org.springframework.security.core.Authentication principal =
new org.springframework.security.authentication.AnonymousAuthenticationToken(
"key", "loyalty-agent-service",
org.springframework.security.core.authority.AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
.withClientRegistrationId("keycloak")
.principal(principal)
.build();
try {
OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
if (authorizedClient != null && authorizedClient.getAccessToken() != null) {
request.getHeaders().setBearerAuth(authorizedClient.getAccessToken().getTokenValue());
}
} catch (Exception e) {
e.printStackTrace();
}
return execution.execute(request, body);
})
.build();
}
@Bean
public MemberTierApi memberTierApi() {
// In reality: return new MemberTierApi(apiClient());
return new MemberTierApi();
}
@Bean
public PointPoolApi pointPoolApi() {
// In reality: return new PointPoolApi(apiClient());
return new PointPoolApi();
}
@Bean
public CampaignApi campaignApi() {
// In reality: return new CampaignApi(apiClient());
return new CampaignApi();
}
}

View File

@@ -1,64 +0,0 @@
package dev.sonpx.loyalty.agent.config;
import dev.sonpx.loyalty.agent.client.api.CampaignApi;
import dev.sonpx.loyalty.agent.client.api.MemberTierApi;
import dev.sonpx.loyalty.agent.client.api.PointPoolApi;
import dev.sonpx.loyalty.agent.client.api.attribute.StaticAttributeResourceApi;
import dev.sonpx.loyalty.agent.client.model.attribute.StaticAttributeViewCriteria;
import dev.sonpx.loyalty.agent.client.model.attribute.StaticAttributeDto;
import dev.sonpx.loyalty.agent.client.model.attribute.StringFilter;
import dev.sonpx.loyalty.agent.client.model.attribute.Pageable;
import com.example.loyalty.agent.client.model.Campaign;
import com.example.loyalty.agent.client.model.CreateCampaignRequest;
import com.example.loyalty.agent.client.model.MemberTier;
import com.example.loyalty.agent.client.model.PointPool;
import org.springframework.ai.tool.annotation.Tool;
import java.util.Arrays;
import java.util.List;
public class LoyaltyAgentTools {
private final MemberTierApi memberTierApi;
private final PointPoolApi pointPoolApi;
private final CampaignApi campaignApi;
private final StaticAttributeResourceApi staticAttributeApi;
private final org.springframework.web.client.RestClient restClient;
public LoyaltyAgentTools(MemberTierApi memberTierApi, PointPoolApi pointPoolApi, CampaignApi campaignApi, StaticAttributeResourceApi staticAttributeApi, org.springframework.web.client.RestClient restClient) {
this.memberTierApi = memberTierApi;
this.pointPoolApi = pointPoolApi;
this.campaignApi = campaignApi;
this.staticAttributeApi = staticAttributeApi;
this.restClient = restClient;
}
@Tool(description = "List all available Member Tiers. Always use this to verify a Tier exists before creating a Campaign.")
public List<MemberTier> listMemberTiers() {
return memberTierApi.listMemberTiers();
}
@Tool(description = "List all available Point Pools. Always use this to verify a Pool exists and has sufficient balance before creating a Campaign.")
public List<PointPool> listPointPools() {
return pointPoolApi.listPointPools();
}
@Tool(description = "Create a new Campaign. This requires a valid targetTierId and rewardPoolId, which MUST be verified beforehand using listMemberTiers and listPointPools.")
public Campaign createCampaign(CreateCampaignRequest request) {
return campaignApi.createCampaign(request);
}
@Tool(description = "List all available Static Attributes. Use this tool when you need to find configurations, properties, or static values like tier types or currencies.")
public List<StaticAttributeDto> listStaticAttributes() {
try {
String url = "/api/static-attribute/csr/view/list?sort=lastApproveDate,desc&sort=lastUpdateDate,desc&page=0&size=10&status.in=A";
return restClient.get()
.uri(url)
.retrieve()
.body(new org.springframework.core.ParameterizedTypeReference<List<StaticAttributeDto>>() {});
} catch (Exception e) {
e.printStackTrace();
throw new RuntimeException("Failed to fetch static attributes", e);
}
}
}

View File

@@ -1,44 +0,0 @@
package dev.sonpx.loyalty.agent.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.oauth2.client.AuthorizedClientServiceOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProvider;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientProviderBuilder;
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
return http.build();
}
@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientService authorizedClientService) {
OAuth2AuthorizedClientProvider authorizedClientProvider =
OAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials()
.build();
AuthorizedClientServiceOAuth2AuthorizedClientManager authorizedClientManager =
new AuthorizedClientServiceOAuth2AuthorizedClientManager(
clientRegistrationRepository, authorizedClientService);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
return authorizedClientManager;
}
}

View File

@@ -1,27 +0,0 @@
package dev.sonpx.loyalty.agent.controller;
import dev.sonpx.loyalty.agent.service.LoyaltyAgentService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/agent")
public class AgentController {
private final LoyaltyAgentService agentService;
public AgentController(LoyaltyAgentService agentService) {
this.agentService = agentService;
}
public record ChatRequest(String prompt) {}
public record ChatResponse(String answer) {}
@PostMapping("/chat")
public ChatResponse chat(@RequestBody ChatRequest request) {
String answer = agentService.chat(request.prompt());
return new ChatResponse(answer);
}
}

View File

@@ -1,48 +0,0 @@
package dev.sonpx.loyalty.agent.service;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
import dev.sonpx.loyalty.agent.client.api.CampaignApi;
import dev.sonpx.loyalty.agent.client.api.MemberTierApi;
import dev.sonpx.loyalty.agent.client.api.PointPoolApi;
import dev.sonpx.loyalty.agent.client.api.attribute.StaticAttributeResourceApi;
import dev.sonpx.loyalty.agent.config.LoyaltyAgentTools;
@Service
public class LoyaltyAgentService {
private final ChatClient chatClient;
public LoyaltyAgentService(ChatClient.Builder chatClientBuilder,
MemberTierApi memberTierApi, PointPoolApi pointPoolApi,
CampaignApi campaignApi, StaticAttributeResourceApi staticAttributeApi,
org.springframework.web.client.RestClient loyaltyRestClient) {
LoyaltyAgentTools tools = new LoyaltyAgentTools(memberTierApi, pointPoolApi, campaignApi, staticAttributeApi, loyaltyRestClient);
this.chatClient = chatClientBuilder
.defaultSystem("""
You are an expert Loyalty System AI Assistant.
Your primary job is to orchestrate operations for our Loyalty core system.
CRITICAL RULES:
1. DO NOT create a model.client.dev.sonpx.loyalty.agent.Campaign unless you verify that Member Tiers and Point Pools exist first.
2. Use the `listMemberTiers` tool to check for valid tier IDs.
3. Use the `listPointPools` tool to check for valid pool IDs and ensure they have a sufficient balance.
4. Use the `listStaticAttributes` tool when asked about configurations, properties, or static values.
5. Only use the `createCampaign` tool once verification is complete.
6. IMPORTANT: You must use the native tool calling capability to execute tools. NEVER output raw JSON tool calls as your text response. After receiving the tool's result, provide a natural language summary to the user.
7. Always answer the user in the language they used (e.g., Vietnamese).
""")
.defaultTools(tools)
.build();
}
public String chat(String userMessage) {
return this.chatClient.prompt()
.user(userMessage)
.call()
.content();
}
}

View File

@@ -1,34 +0,0 @@
server:
port: 8085
spring:
application:
name: loyalty-agent-service
ai:
ollama:
base-url: http://192.168.10.112:11434
chat:
options:
model: qwen3.5:9b
temperature: 0.3
security:
oauth2:
client:
registration:
keycloak:
client-id: ols-cli
client-secret: 1jV8NSAeybIqmUK0AWhI8hgdo1q5itj7
authorization-grant-type: client_credentials
provider:
keycloak:
token-uri: http://192.168.99.235/auth/realms/ols-cn-sit/protocol/openid-connect/token
loyalty:
core:
base-url: http://192.168.99.242:8081
logging:
level:
org.springframework.ai: DEBUG
org.springframework.web: DEBUG
dev.sonpx.loyalty: DEBUG

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

139
src/store/useChatStore.ts Normal file
View File

@@ -0,0 +1,139 @@
import { create } from 'zustand'
import { Client } from '@stomp/stompjs'
export type Role = 'user' | 'agent'
export interface Message {
id: string
role: Role
content: string
isStreaming?: boolean
toolStatus?: string
}
interface ChatState {
messages: Message[]
isRightPanelOpen: boolean
isConnected: boolean
isConnecting: boolean
stompClient: Client | null
activeAgentMessageId: string | null
initStomp: () => void
sendMessage: (content: string, activeCampaignId?: string) => void
addMessage: (message: Message) => void
updateMessage: (id: string, updater: (msg: Message) => Message) => void
toggleRightPanel: () => void
conversationId: string
}
export const useChatStore = create<ChatState>((set, get) => ({
messages: [],
isRightPanelOpen: false,
isConnected: false,
isConnecting: false,
stompClient: null,
activeAgentMessageId: null,
conversationId: crypto.randomUUID(),
toggleRightPanel: () => set((state) => ({ isRightPanelOpen: !state.isRightPanelOpen })),
initStomp: () => {
if (get().stompClient) return;
set({ isConnecting: true })
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 })
}
})
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',
content,
}
get().addMessage(userMessage)
const agentMessageId = crypto.randomUUID()
set({ activeAgentMessageId: agentMessageId })
get().addMessage({
id: agentMessageId,
role: 'agent',
content: '',
isStreaming: true
})
client.publish({
destination: '/app/chat',
body: JSON.stringify({
conversationId: get().conversationId,
messageId: agentMessageId,
prompt: content,
activeCampaignId
})
})
},
addMessage: (message: Message) => {
set((state) => ({ messages: [...state.messages, message] }))
},
updateMessage: (id: string, updater: (msg: Message) => Message) => {
set((state) => ({
messages: state.messages.map((m) => (m.id === id ? updater(m) : m)),
}))
},
}))