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:
184
src/App.css
Normal file
184
src/App.css
Normal 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
9
src/App.tsx
Normal 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
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal 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
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 |
109
src/components/campaign/CampaignList.tsx
Normal file
109
src/components/campaign/CampaignList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/components/chat/ChatBottombar.tsx
Normal file
60
src/components/chat/ChatBottombar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
src/components/chat/ChatBubble.tsx
Normal file
48
src/components/chat/ChatBubble.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
src/components/chat/ChatHistoryList.tsx
Normal file
40
src/components/chat/ChatHistoryList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
src/components/chat/ChatLayout.tsx
Normal file
61
src/components/chat/ChatLayout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
28
src/components/chat/ChatList.tsx
Normal file
28
src/components/chat/ChatList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
50
src/components/ui/avatar.tsx
Normal file
50
src/components/ui/avatar.tsx
Normal 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 }
|
||||
57
src/components/ui/button.tsx
Normal file
57
src/components/ui/button.tsx
Normal 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 }
|
||||
46
src/components/ui/scroll-area.tsx
Normal file
46
src/components/ui/scroll-area.tsx
Normal 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 }
|
||||
22
src/components/ui/textarea.tsx
Normal file
22
src/components/ui/textarea.tsx
Normal 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
96
src/index.css
Normal 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
6
src/lib/utils.ts
Normal 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
10
src/main.tsx
Normal 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>,
|
||||
)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package com.example.loyalty.agent.client.model;
|
||||
|
||||
public record Campaign(String campaignId, String name, String targetTierId, String rewardPoolId, String status) {}
|
||||
@@ -1,3 +0,0 @@
|
||||
package com.example.loyalty.agent.client.model;
|
||||
|
||||
public record CreateCampaignRequest(String name, String targetTierId, String rewardPoolId) {}
|
||||
@@ -1,3 +0,0 @@
|
||||
package com.example.loyalty.agent.client.model;
|
||||
|
||||
public record MemberTier(String tierId, String tierName, Integer requiredPoints) {}
|
||||
@@ -1,5 +0,0 @@
|
||||
package com.example.loyalty.agent.client.model;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
public record PointPool(String poolId, String poolName, BigDecimal balance) {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
139
src/store/useChatStore.ts
Normal 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)),
|
||||
}))
|
||||
},
|
||||
}))
|
||||
Reference in New Issue
Block a user