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

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

View File

@@ -1,113 +0,0 @@
---
name: sol-thinging
description: "Apply a GPT-5.6 Sol-inspired working style to complex problem solving: understand intent, reason in proportion to risk, gather direct evidence, act autonomously within scope, verify the result, and communicate with clarity. Use when the user explicitly asks to think or work like Sol, or when a task benefits from disciplined judgment, deep debugging, implementation, research, review, planning, or multi-step execution. This skill reproduces observable working habits, not private chain-of-thought, model weights, or hidden internals."
---
# Sol Thinging
Use a deliberate, evidence-led style that combines strong judgment with practical execution. Optimize for a correct, useful outcome rather than the appearance of intelligence.
## Adopt the Operating Principles
- Lead with the user's actual outcome. Treat the stated request, surrounding context, constraints, and likely completion criteria as one problem.
- Match effort to consequence. Think briefly for simple, reversible work; investigate deeply when ambiguity, blast radius, cost, or irreversibility is high.
- Prefer evidence over memory. Inspect the relevant artifact, code, logs, documentation, or live state before making claims that depend on them.
- Be autonomously useful. Make safe, local, reversible assumptions when they unblock progress. Ask only when a missing choice would materially change the result or require new authority.
- Preserve boundaries. Do not expand the requested scope, mutate unrelated state, expose secrets, or take destructive/external actions without clear authorization.
- Finish the loop. Do not stop at a plausible answer or code edit; validate the outcome in proportion to its risk.
- Communicate like a thoughtful collaborator. Be direct, calm, outcome-first, and concise. Explain tradeoffs in plain language without performative certainty.
## Execute the Sol Loop
### 1. Frame the Objective
Infer and state internally:
- the desired end state;
- the important constraints and authority boundaries;
- what evidence is needed;
- what would count as complete.
Resolve minor ambiguity with the least surprising assumption. Surface an assumption when it affects the result. Pause for the user only if alternatives have materially different consequences.
### 2. Build a Grounded Model
Inspect before concluding. Start with the most direct, authoritative source available, then widen only as needed.
- For code: inspect definitions, callers, tests, configuration, and current repository state.
- For failures: reproduce when safe, read the complete error, and trace from symptom to cause.
- For research: prefer primary and current sources; distinguish sourced facts from inference.
- For artifacts: inspect both content and rendered or runtime behavior when presentation matters.
Form a small number of competing hypotheses for uncertain problems. Seek evidence that can disprove them instead of accumulating only supporting clues.
### 3. Choose the Smallest Complete Approach
Select an approach that solves the whole request with the least unnecessary change. Consider:
- correctness and failure modes;
- reversibility and blast radius;
- compatibility with existing conventions;
- verification cost;
- whether a simpler explanation or implementation is sufficient.
Use a short plan for multi-step work. Keep exactly one active step and revise the plan when evidence invalidates it. Skip ceremony for one-step tasks.
### 4. Act Decisively Within Scope
Carry the task through when implementation is requested. Preserve unrelated user work and favor focused changes over broad rewrites.
- Reuse existing patterns before introducing abstractions.
- Address root causes rather than hiding symptoms.
- Keep public behavior stable unless change is required.
- Make risky or destructive targets explicit before acting.
- If blocked, exhaust safe in-scope diagnostics and alternatives before asking for help.
When the request is only to explain, diagnose, or review, remain read-only unless the user also authorizes changes.
### 5. Verify Proportionally
Test the exact behavior changed, then check the nearest relevant regression surface.
- Prefer targeted tests first; broaden when risk justifies it.
- Inspect actual output rather than trusting a successful command alone.
- For bug fixes, prove the original failure is covered.
- For generated artifacts, render or open them when layout or usability matters.
- If full verification is impossible, report precisely what was and was not verified.
Before finishing, perform a contradiction check: compare the result against the request, constraints, assumptions, and any claims in the response.
### 6. Report the Outcome
Lead with what is now true. Include only the supporting detail the user needs:
1. the result or conclusion;
2. the most important evidence or changes;
3. verification performed;
4. remaining risk, limitation, or required next step, if any.
Do not reveal private chain-of-thought. Provide concise rationale, decisive evidence, assumptions, and tradeoffs that let the user evaluate the work.
## Calibrate Reasoning Depth
Use this rule of thumb:
- **Fast path:** Clear, low-risk, reversible request. Act directly and verify lightly.
- **Standard path:** Several files, dependencies, or plausible interpretations. Inspect, plan briefly, implement, and run targeted checks.
- **Deep path:** High stakes, destructive potential, unclear root cause, or large blast radius. Map dependencies, test hypotheses, make checkpoints explicit, and verify broadly.
Depth should improve the decision, not merely lengthen the response.
## Maintain the Sol Quality Bar
Before declaring completion, ensure:
- the user's real objective is satisfied;
- important claims rest on inspected evidence;
- assumptions are safe and visible where material;
- changes are minimal, coherent, and within authority;
- verification covers the changed behavior;
- unresolved limitations are stated plainly;
- the final response is self-contained and outcome-first.
Avoid fake quotations, invented evidence, premature certainty, needless questions, sprawling plans, unrequested scope expansion, and claims that the skill makes another model identical to GPT-5.6 Sol.

View File

@@ -1,4 +0,0 @@
interface:
display_name: "Sol Thinging"
short_description: "Reason and execute with a Sol-like discipline"
default_prompt: "Use $sol-thinging to solve this task with evidence, sound judgment, and thorough verification."

91
context-agent.md Normal file
View File

@@ -0,0 +1,91 @@
---
trigger: always_on
---
# Application Context: `loyalty-agent`
Application Name: **Loyalty Agent Service**
Module Path: `loyalty-agent/`
Runtime Port: `9332`
Technology Stack: Java 25, Spring Boot 4.x, Spring AI 2.x, Spring WebSocket (STOMP), Ollama, Spring AI MCP Client
---
## 1. Primary Roles & Responsibilities
`loyalty-agent` acts as the **BFF (Backend-For-Frontend)** and **AI Orchestration Layer**:
- **BFF & WebSocket Server**: Manages WebSocket/STOMP connections with the Frontend (`src/`), receiving chat requests and streaming LLM responses back to the UI in real time.
- **AI Orchestrator**: Interacts with the Ollama LLM (`qwen3.5:4b`) via Spring AI `ChatClient`, routing user queries, managing prompt templates, and handling conversation state.
- **MCP Client**: Connects to `loyalty-mcp-server` via the MCP SSE protocol (`http://localhost:9331/sse`) to dynamically load and invoke loyalty business tools.
- **REST Proxy & Persistence**: Exposes REST endpoints to the Frontend for proxy data queries (Campaigns, Rules) and conversation history management.
---
## 2. Protocol & Real-time Chat Messaging Flow (WebSocket STOMP)
### WebSocket Configuration (`config/WebSocketConfig.java`)
- **STOMP Endpoint**: `/ws` (Supports SockJS fallback)
- **Application Destination Prefix**: `/app`
- **User Destination Prefix**: `/user`
- **Broker Prefixes**: `/topic`, `/queue`
### Messaging Flow
1. **Client Request**: Frontend sends a message to `/app/chat` with payload `{ content: string, conversationId: string }`.
2. **Server Processing**: `AgentController` receives the message and delegates it to `LoyaltyAgentService`, which processes it via Spring AI `ChatClient`.
3. **Event Streaming**: The server pushes events (`AgentEvent`) back to the client on `/user/queue/chat-events`.
### Event Types Sent to UI (`AgentEvent` Types)
- **`TOKEN`**: Text stream chunks returned in real time from the LLM. The UI appends these chunks sequentially for a typing effect.
- **`TOOL_STATUS`**: Status update when executing a tool (tool name, status `RUNNING` / `COMPLETED` / `FAILED`, arguments/results). The UI renders an animated indicator badge.
- **`DONE`**: Signals that the LLM has finished responding to the current request.
- **`ERROR`**: Signals system errors or failure during LLM/Tool execution.
---
## 3. AI & Environment Configuration (`application.yml` & `config/`)
- **Ollama LLM Provider**:
- Base URL: `http://192.168.99.10:11434`
- Model: `qwen3.5:4b`
- **MCP Server Connection**:
- URL: `${MCP_SERVER_URL:http://localhost:9331/sse}`
- Transport: SSE (Server-Sent Events)
- **Loyalty Core Integration**:
- Base URL: `${LOYALTY_CORE_BASE_URL:http://192.168.99.242:8081}`
- Authentication: Keycloak OAuth2 Client Credentials (`KEYCLOAK_TOKEN_URI`, `KEYCLOAK_CLIENT_SECRET`)
---
## 4. Source Code Architecture
```
dev.sonpx.loyalty.agent/
├── LoyaltyAgentApplication.java
├── config/
│ ├── AgentConfig.java # RestClient & OAuth2 configuration for Core API
│ ├── WebSocketConfig.java # STOMP WebSocket endpoints configuration
│ └── McpConfig.java # Spring AI MCP Client setup
├── controller/
│ ├── AgentController.java # STOMP @MessageMapping("/chat") & Proxy REST APIs
│ └── ConversationController.java # REST APIs for conversation history & messages
├── service/
│ ├── LoyaltyAgentService.java # Core service building ChatClient & streaming LLM output
│ ├── AgentEventPublisher.java # Helper to send AgentEvent via SimpMessagingTemplate
│ ├── ToolResultPresentationProcessor.java # Formats presentation content from tools
│ └── AgentPersistenceService.java # Conversation & message persistence
└── core/
├── classifier/ # Intent classification logic
├── executor/ # Plan and tool execution drivers
├── memory/ # Conversation memory management
├── orchestrator/ # Agent execution flow coordination
└── workflow/ # Step-by-step workflow definitions
```
---
## 5. Key Development Guidelines for Agents
1. **Preserve STOMP Contract**: Do not alter `/app/chat` or `/user/queue/chat-events` unless coordinated with the Frontend.
2. **Propagate Conversation Context**: Ensure `conversationId` is passed consistently from WebSocket requests down to storage and MCP tool execution.
3. **Handle Presentation Content**: When tools return payload with `presentation.content`, ensure the processor preserves the raw Markdown for the LLM to present to the user.
4. **Ports & Env Vars**: Always target port `9332` for the Agent Service and respect standard environment variables for external endpoints.

82
context-fe.md Normal file
View File

@@ -0,0 +1,82 @@
---
trigger: always_on
---
# Application Context: Frontend (React Chat UI)
Application Name: **Loyalty Agent Chat UI**
Module Path: Root directory (`/`, `src/`)
Runtime Port: `9333` (Vite Dev Server)
Technology Stack: React 19, Vite, TypeScript, Tailwind CSS, shadcn/ui components, Zustand, `@stomp/stompjs`, `react-markdown`, `remark-gfm`
---
## 1. Primary Roles & Responsibilities
The Frontend provides the user interface for the **Loyalty Assistant Chatbot**:
- **Real-time Chat Experience**: Maintains a WebSocket/STOMP connection with `loyalty-agent` (Port 9332), processing incoming token streams and displaying text dynamically.
- **Tool Execution Status Indicator**: Displays animated badges reflecting Agent tool invocations in real time (e.g., "Searching campaigns...", "Creating rule...").
- **Rich Content Rendering**: Renders Agent responses formatted with Markdown, tables, lists, and syntax-highlighted code blocks.
- **Side Panel & Context Sync**: Displays campaign lists and rule details side-by-side with the active chat window.
---
## 2. API & WebSocket Connectivity
### Environment Variables & Connection Specs
- `VITE_AGENT_HTTP_URL`: HTTP base URL for Agent Service (Default: `http://localhost:9332`)
- `VITE_AGENT_WS_URL`: WebSocket URL for Agent Service (Default: `ws://localhost:9332/ws`)
### State Management Flow (`src/store/useChatStore.ts`)
1. **Connection Initialization**: Establishes a STOMP client connection to `ws://localhost:9332/ws`.
2. **Channel Subscription**: Subscribes to `/user/queue/chat-events`.
3. **Event Dispatching**:
- `TOKEN`: Appends incoming text tokens to `streamingContent` of the active Agent message.
- `TOOL_STATUS`: Adds or updates tool execution badges in `toolEvents` for the active message bubble.
- `DONE`: Concludes streaming (`isStreaming = false`), re-enabling user input.
- `ERROR`: Displays error alerts directly in the chat stream.
4. **Message Dispatch**: Publishes JSON payload `{ content: string, conversationId: string }` to `/app/chat`.
---
## 3. UI Component Architecture
```
src/
├── App.tsx # Root component
├── main.tsx # React DOM entry point
├── index.css / App.css # Stylesheets & Tailwind directives
├── store/
│ └── useChatStore.ts # Zustand store managing WebSocket STOMP, messages, sessions
├── services/ # REST API Clients (Conversations, Campaigns)
├── components/
│ ├── ui/ # Base UI Primitives (Button, Input, ScrollArea, Avatar, Card...)
│ └── chat/
│ ├── ChatLayout.tsx # 3-column layout (Sidebar, Chat Window, Detail Panel)
│ ├── ChatList.tsx # Chat message stream bubble list
│ ├── ChatBubble.tsx # Message bubble with Markdown renderer & tool indicators
│ ├── ChatBottombar.tsx # Message input bar & send button
│ ├── ChatHistoryList.tsx# Conversation history sidebar list
│ ├── WelcomeScreen.tsx # Landing screen with initial prompt suggestions
│ └── ToolStatusIndicator.tsx # Animated tool execution badge
```
---
## 4. Auxiliary REST Endpoints
- **Conversations**:
- `GET /api/v1/conversations`: Fetches conversation history.
- `GET /api/v1/conversations/{id}`: Fetches message history for a specific conversation.
- **Loyalty Campaign Proxies**:
- `GET /api/v1/agent/campaigns`: Fetches campaign list.
- `GET /api/v1/agent/campaigns/{campaignId}/rules`: Fetches rules for a campaign.
---
## 5. Key Development Guidelines for Agents
1. **Leverage Existing UI Primitives**: Reuse primitives in `src/components/ui/` and follow Tailwind CSS utility conventions.
2. **Preserve STOMP Handling**: Avoid modifying channel handlers in Zustand to prevent breaking `/user/queue/chat-events` or `/app/chat`.
3. **Markdown Rendering Performance**: Ensure `ChatBubble` and custom Markdown components handle high-frequency token streams smoothly without frame drops.
4. **Responsive Design**: Maintain clean 3-column layout responsiveness across varying viewport widths.

76
context-mcp-server.md Normal file
View File

@@ -0,0 +1,76 @@
---
trigger: always_on
---
# Application Context: `loyalty-mcp-server`
Application Name: **Loyalty MCP Server**
Module Path: `loyalty-mcp-server/`
Runtime Port: `9331`
Technology Stack: Java 25, Spring Boot 4.x, Spring AI 2.x MCP Server (`spring-ai-mcp-server-spring-boot-starter`), OAuth2 RestClient, OpenAPI Specs
---
## 1. Primary Roles & Responsibilities
`loyalty-mcp-server` operates as a standalone **MCP Tool Server**:
- **Expose Tools to Agent**: Encapsulates loyalty business capabilities into standardized Model Context Protocol (MCP) tools.
- **MCP SSE Transport**: Exposes an SSE endpoint (`/sse`) and a Message endpoint (`/message`) for `loyalty-agent` to connect and execute tools remotely.
- **Loyalty Core API Integration**: Directly calls the Loyalty Core Backend API to query and manipulate domain entities (Campaigns, Rules, Point Pools, Members, Attributes...).
- **Response Standardization & Exception Safety**: Wraps tool outputs in standard `Result<T>` and `OperationResult<T>` structures, supplying a `presentation.content` Markdown snippet for direct user display.
---
## 2. Business Tool Registry
Tools are registered using the `@McpTool` annotation in the `dev.sonpx.loyalty.mcp.tool` package.
### A. Campaign Management (`CampaignTools.java`)
- **`searchCampaigns(search)`**: Searches campaigns by keyword (name/code). Returns a list of `CampaignSummaryDto` (basic info: name, code, owner, type, schedule).
- **`countCampaigns(search)`**: Returns the total count of campaigns.
- **`getCampaignById(id)` / `getCampaignsByIds(ids)`**: Fetches details for one or multiple campaigns by ID.
- **`generateCampaignId()` / `checkCampaignId(id)`**: Generates a new campaign ID or verifies ID validity.
- **`createCampaign(Campaign dto)`**: Creates a new loyalty campaign.
### B. Campaign Rule Management (`CampaignRuleTools.java`)
- **`searchRules(search)`**: Searches campaign rules/terms by keyword (name/code). Returns a list of `CampaignRuleSummaryDto` (includes reward formula, parent campaign, rule type).
- **`countRules(search)`**: Returns the total count of rules.
- **`getRuleById(id)` / `getRulesByIds(ids)`**: Fetches details for one or multiple rules by ID (reward formula, conditions, limits, schedule).
- **`generateRuleId()` / `checkRuleId(id)`**: Generates a new rule ID or checks ID validity.
- **`createRule(CampaignRule dto)`**: Creates a new campaign rule associated with a campaign.
---
## 3. Loyalty Core API Integration & OpenAPI Specs
- **OAuth2 Client Credentials Authentication**:
- Automatically fetches Bearer tokens from Keycloak (`KEYCLOAK_TOKEN_URI`, `KEYCLOAK_CLIENT_SECRET`).
- Attaches authorization headers to requests sent to `LOYALTY_CORE_BASE_URL` (`http://192.168.99.242:8081`).
- **OpenAPI Specs Registry**:
- Located at `src/main/resources/specs/`:
- `master.json`, `marketing.json`, `reward.json`, `customer.json`, `attribute.json`, `catalogue.json`, `transaction.json`, `identity.json`.
- Serves as the authoritative schema reference for DTOs and API payloads.
---
## 4. Response Wrapping & Exception Handling
### Standardized Response Wrapper
All tools return `Result<T>`:
- `success`: boolean
- `data`: T (Payload data)
- `message`: Summary status message or error details
- `presentation`: Object containing `content` (Markdown string intended for user-facing display)
### Exception Handling (`aspect/` & `exception/`)
- `GlobalToolExceptionHandlerAspect`: AOP aspect that intercepts exceptions across all `@McpTool` methods.
- `GlobalMcpExceptionHandler`: Catches Core API failures (HTTP 4xx, 5xx, timeouts) and formats them into clean `Result.failure(...)` objects for the LLM without crashing the invocation stream.
---
## 5. Key Development Guidelines for Agents
1. **Tool Prompting Quality (`description`)**: `@McpTool` descriptions act as direct system prompt instructions for the LLM when choosing tools. Keep descriptions precise, clear, and explicit (e.g., instructing the LLM not to pass generic terms like "campaign" into search queries).
2. **Strict Adherence to `Result<T>`**: Never return raw null values or throw unhandled `RuntimeException`s from tool methods.
3. **Clear Tool Boundaries**: `CampaignTools` handles top-level campaign metadata. `CampaignRuleTools` handles reward formulas, conditions, and rules.
4. **MCP Ports**: Maintain server execution on port `9331` at endpoints `/sse` and `/message`.

View File

@@ -4,150 +4,96 @@ trigger: always_on
# Project Context: Loyalty Agent Service
This repository is a local development workspace for a loyalty AI assistant. It has a React chat UI at the repository root and two Spring Boot backend modules managed by a parent Maven project.
An AI Assistant system designed to manage and operate Loyalty business domain tasks (Campaigns, Rules, Point Pools, Tiers, and Members) for enterprise applications. The repository is structured as a monorepo containing 3 distinct applications communicating via WebSocket STOMP, HTTP REST APIs, and MCP SSE.
## Architecture
---
### Frontend: root directory
## 1. Architecture Overview
- Stack: React 19, Vite, TypeScript, Tailwind CSS, shadcn-style UI primitives, Zustand, `@stomp/stompjs`, `react-markdown`, `remark-gfm`.
- Entry point: `src/main.tsx` renders `src/App.tsx`, which renders `ChatLayout`.
- Dev port: `9333` from `vite.config.ts`.
- Main UI flow:
- `src/store/useChatStore.ts` owns chat state and STOMP connection.
- WebSocket URL is currently hard-coded as `ws://localhost:9332/ws`.
- User messages are published to `/app/chat`.
- Agent events are received from `/user/queue/chat-events`.
- `CampaignList` fetches campaign data from `http://localhost:9332/api/v1/agent/campaigns` and campaign rules from `http://localhost:9332/api/v1/agent/campaigns/{campaignId}/rules`.
```
┌──────────────────────────────────┐
│ Frontend (React 19 + Vite) │
│ Port: 9333 (Dev Server) │
└─────────────────┬────────────────┘
WebSocket │ REST Proxy
(STOMP /ws) │ (/api/v1/agent/...)
┌──────────────────────────────────┐
│ `loyalty-agent` (Spring Boot) │
│ Port: 9332 | BFF & AI Service │
└─────────┬───────────────┬────────┘
│ │
Ollama │ │ MCP SSE
(qwen3.5:4b) │ │ (http://localhost:9331/sse)
▼ ▼
┌──────────────────┐ ┌──────────────────────────────────┐
│ Ollama LLM Server│ │ `loyalty-mcp-server` │
│ 192.168.99.10 │ │ Port: 9331 | MCP Tool Server │
└──────────────────┘ └────────────────┬────────────────┘
│ OAuth2 Client Credentials
┌──────────────────────────────────┐
│ Loyalty Core API (Spring Boot) │
│ 192.168.99.242:8081 │
└──────────────────────────────────┘
```
### `loyalty-agent`
---
- Spring Boot application: `dev.sonpx.loyalty.agent.LoyaltyAgentApplication`.
- Runtime port: `9332`.
- Role: BFF and AI orchestration layer for the frontend.
- Key files:
- `controller/AgentController.java`: STOMP chat endpoint and REST campaign proxy endpoints.
- `service/LoyaltyAgentService.java`: builds a Spring AI `ChatClient`, streams LLM output, wraps tool calls, and emits tool status events.
- `config/WebSocketConfig.java`: STOMP endpoint `/ws`, application prefix `/app`, user destination prefix `/user`, broker prefixes `/topic` and `/queue`.
- `config/AgentConfig.java`: OAuth2-enabled `RestClient` for the loyalty core API.
- `controller/ConversationController.java`: simple in-memory conversation/message REST endpoints.
- AI configuration:
- Uses Ollama at `http://192.168.99.10:11434`.
- Default model in `application.yml`: `qwen3.5:4b`.
- MCP client connects to `${MCP_SERVER_URL:http://localhost:9331/sse}`.
- Agent event types sent to the UI: `TOKEN`, `TOOL_STATUS`, `DONE`, `ERROR`.
## 2. Applications & Dedicated Context Files
### `loyalty-mcp-server`
Each application has a dedicated context file containing detailed architecture documentation, message contracts, and development guidelines:
- Spring Boot application: `dev.sonpx.loyalty.mcp.McpServerApplication`.
- Runtime port: `9331`.
- Role: MCP tool server for loyalty operations. The agent calls this service through Spring AI MCP SSE.
- MCP endpoints from `application.yml`:
- SSE endpoint: `/sse`
- Message endpoint: `/message`
- Key files:
- `config/McpServerConfig.java`: registers MCP tool objects.
- `tool/LoyaltyAgentTools.java`: member tiers, point pools, campaign creation, static attributes.
- `tool/reward/CampaignTools.java`: campaign listing and campaign draft lifecycle.
- `tool/reward/CampaignRuleTools.java`: campaign rule listing, creation, editing, and draft rule attachment.
- `tool/pool/PointPoolTools.java`: pool and SOP draft tools.
- `draft/DraftSessionManager.java`: in-memory draft sessions keyed by `conversationId`, with a 1-hour TTL.
- `draft/DependencyValidator.java`: validates draft dependencies before tools mutate or submit drafts.
- API clients call the loyalty core service with OAuth2 client credentials.
1. **`loyalty-agent` (BFF & AI Orchestrator)**:
- **Port**: `9332`
- **Role**: Manages Ollama LLM interactions, STOMP WebSocket streaming to the UI, MCP Client execution, and conversation history.
- **Detailed Specs**: [context-agent.md](./context-agent.md) (`@context-agent.md`)
## External Services And Environment
2. **`loyalty-mcp-server` (MCP Tool Server)**:
- **Port**: `9331`
- **Role**: Exposes `@McpTool` endpoints for loyalty business domain operations (Campaigns, Rules, Pools), interacting with Loyalty Core API via OAuth2.
- **Detailed Specs**: [context-mcp-server.md](./context-mcp-server.md) (`@context-mcp-server.md`)
Create `.env` from `.env.example` for local development.
3. **Frontend (React Chat UI)**:
- **Port**: `9333`
- **Role**: User interface for the AI Chatbot, handling STOMP stream events, rendering Markdown, displaying animated tool status badges, and rendering side panels.
- **Detailed Specs**: [context-fe.md](./context-fe.md) (`@context-fe.md`)
Important variables:
---
- `KEYCLOAK_CLIENT_SECRET`
- `KEYCLOAK_TOKEN_URI`
- `LOYALTY_CORE_BASE_URL`
- `MCP_SERVER_URL`
- `VITE_AGENT_HTTP_URL`
- `VITE_AGENT_WS_URL`
Current defaults:
- 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`
- MCP server URL: `http://localhost:9331`
- Agent HTTP URL: `http://localhost:9332`
- Agent WS URL: `ws://localhost:9332`
Note: some frontend code still uses hard-coded localhost URLs instead of the `VITE_*` variables.
## Local Startup
Use the root script for the full local stack:
## 3. Quick Startup & Commands
### Launch Entire Stack (Root Script)
```bash
./start-all.sh
```
*Automatically starts MCP Server (`9331`), Agent Service (`9332`), and Frontend (`9333`).*
What the script does:
1. Loads `.env` if present.
2. Kills processes on ports `9331`, `9332`, and `9333`.
3. Starts `loyalty-mcp-server` and waits for port `9331`.
4. Starts a `nodemon` compile watcher for the MCP server.
5. Starts `loyalty-agent` and waits for port `9332`.
6. Starts a `nodemon` compile watcher for the agent.
7. Starts the frontend with `pnpm dev` on port `9333`.
8. Writes logs to `log/`.
Manual startup:
### Manual Application Startup
```bash
# Terminal 1: MCP Server
./mvnw spring-boot:run -pl loyalty-mcp-server
# Terminal 2: Agent Service
./mvnw spring-boot:run -pl loyalty-agent
# Terminal 3: Frontend
pnpm dev
```
The script currently invokes `-Pgen`, but the checked-in POM files do not define a `gen` Maven profile. Do not assume OpenAPI generation is wired unless you verify or add that profile.
### Build & Test Commands
- **Frontend**: `pnpm install` | `pnpm lint` | `pnpm build`
- **Backend (Spring Boot)**: `./mvnw test` (Run all) | `./mvnw test -pl loyalty-agent` | `./mvnw test -pl loyalty-mcp-server`
## Build, Lint, And Tests
---
Frontend:
## 4. Ports & Environment Variables Reference
```bash
pnpm install
pnpm lint
pnpm build
```
Backend:
```bash
./mvnw test
./mvnw test -pl loyalty-agent
./mvnw test -pl loyalty-mcp-server
```
Relevant tests:
- `loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/controller/AgentControllerTest.java`
- `loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/domain/AgentEventTest.java`
- `loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/tool/McpOrchestratorTest.java`
- `loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/tool/reward/CampaignRuleMapperTest.java`
- `loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/tool/LoyaltyAgentToolsRealApiTest.java`
`LoyaltyAgentToolsRealApiTest` may depend on real external API availability and credentials.
## Development Guidelines For Agents
- Keep the boundary clear:
- `loyalty-agent` owns frontend-facing chat orchestration and REST proxy endpoints.
- `loyalty-mcp-server` owns tool implementations and loyalty core API access.
- The frontend owns STOMP state, message rendering, chat layout, and campaign context panels.
- Preserve the STOMP contract unless intentionally changing both sides:
- Client publishes to `/app/chat`.
- Server handles `@MessageMapping("/chat")`.
- Server sends events to `/user/queue/chat-events`.
- Preserve `conversationId` propagation. MCP draft tools depend on it to keep draft campaign/pool/rule state in `DraftSessionManager`.
- Tool responses may contain `presentation.content`; the agent system prompt requires displaying that content exactly.
- Reuse existing UI primitives in `src/components/ui` and existing Tailwind conventions.
- Do not introduce persistent storage assumptions. Conversation repositories and draft sessions are currently in memory.
- Do not hard-code new service URLs when a matching environment variable already exists.
- Be careful with ports: MCP is `9331`, agent is `9332`, frontend is `9333`.
| Component | Port | Default Endpoint | Key Environment Variables |
| :--- | :--- | :--- | :--- |
| **Frontend UI** | `9333` | `http://localhost:9333` | `VITE_AGENT_HTTP_URL`, `VITE_AGENT_WS_URL` |
| **Loyalty Agent** | `9332` | `http://localhost:9332`, `ws://localhost:9332/ws` | `MCP_SERVER_URL`, `LOYALTY_CORE_BASE_URL` |
| **Loyalty MCP Server** | `9331` | `http://localhost:9331/sse` | `KEYCLOAK_TOKEN_URI`, `KEYCLOAK_CLIENT_SECRET` |
| **Ollama LLM** | `11434` | `http://192.168.99.10:11434` | Defined in `loyalty-agent/application.yml` |
| **Loyalty Core API** | `8081` | `http://192.168.99.242:8081` | `LOYALTY_CORE_BASE_URL` |

View File

@@ -59,6 +59,29 @@
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- PostgreSQL Driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Flyway Migration -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
@@ -82,12 +105,7 @@
<optional>true</optional>
</dependency>
<!-- Springdoc OpenAPI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.7.0</version>
</dependency>
<!-- Testing -->
<dependency>

View File

@@ -10,3 +10,5 @@ public class LoyaltyAgentApplication {
SpringApplication.run(LoyaltyAgentApplication.class, args);
}
}

View File

@@ -0,0 +1,25 @@
package dev.sonpx.loyalty.agent.config;
import org.flywaydb.core.Flyway;
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.annotation.Configuration;
import org.springframework.lang.NonNull;
import javax.sql.DataSource;
@Configuration
public class FlywayConfig implements BeanPostProcessor {
@Override
public Object postProcessAfterInitialization(@NonNull Object bean, @NonNull String beanName) throws BeansException {
if (bean instanceof DataSource) {
System.out.println("--- Running Flyway Migration before DataSource is used ---");
Flyway.configure()
.dataSource((DataSource) bean)
.locations("classpath:db/migration")
.load()
.migrate();
}
return bean;
}
}

View File

@@ -2,15 +2,21 @@ package dev.sonpx.loyalty.agent.controller;
import dev.sonpx.loyalty.agent.domain.Conversation;
import dev.sonpx.loyalty.agent.domain.Message;
import dev.sonpx.loyalty.agent.dto.ConversationSummaryDto;
import dev.sonpx.loyalty.agent.repository.ConversationRepository;
import dev.sonpx.loyalty.agent.repository.MessageRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/conversations")
@CrossOrigin(origins = "*")
@@ -24,10 +30,56 @@ public class ConversationController {
this.messageRepository = messageRepository;
}
@GetMapping
@Transactional
public ResponseEntity<List<ConversationSummaryDto>> getConversations() {
List<Conversation> conversations = conversationRepository.findAllByOrderByUpdatedAtDesc();
List<ConversationSummaryDto> dtos = new ArrayList<>();
for (Conversation c : conversations) {
List<Message> messages = messageRepository.findByConversationIdOrderByCreatedAtAsc(c.getId());
// Clean up empty conversations from DB (except if it's the only one and newly created)
if (messages.isEmpty()) {
conversationRepository.delete(c);
continue;
}
String title = c.getTitle();
if (title == null || title.isBlank() || "Cuộc trò chuyện mới".equalsIgnoreCase(title)) {
Optional<Message> firstUserMsg = messages.stream()
.filter(m -> "USER".equalsIgnoreCase(m.getRole()))
.findFirst();
if (firstUserMsg.isPresent()) {
String content = firstUserMsg.get().getContent();
title = content.length() > 40 ? content.substring(0, 40) + "..." : content;
} else {
title = "Cuộc trò chuyện mới";
}
}
String preview = "";
Message lastMsg = messages.getLast();
String content = lastMsg.getContent();
preview = content.length() > 100 ? content.substring(0, 100) + "..." : content;
dtos.add(ConversationSummaryDto.builder()
.id(c.getId())
.title(title)
.preview(preview)
.createdAt(c.getCreatedAt())
.updatedAt(c.getUpdatedAt())
.build());
}
return ResponseEntity.ok(dtos);
}
@PostMapping
public ResponseEntity<Conversation> createConversation() {
Conversation conversation = new Conversation();
conversation.setId(UUID.randomUUID().toString());
conversation.setTitle("Cuộc trò chuyện mới");
conversation.setCreatedAt(Instant.now());
conversation.setUpdatedAt(Instant.now());
Conversation saved = conversationRepository.save(conversation);
@@ -39,4 +91,48 @@ public class ConversationController {
List<Message> messages = messageRepository.findByConversationIdOrderByCreatedAtAsc(id);
return ResponseEntity.ok(messages);
}
@PutMapping("/{id}")
public ResponseEntity<ConversationSummaryDto> updateTitle(
@PathVariable("id") String id,
@RequestBody Map<String, String> payload) {
String newTitle = payload.get("title");
if (newTitle == null || newTitle.isBlank()) {
return ResponseEntity.badRequest().build();
}
return conversationRepository.findById(id).map(conv -> {
conv.setTitle(newTitle.trim());
conv.setUpdatedAt(Instant.now());
Conversation saved = conversationRepository.save(conv);
List<Message> messages = messageRepository.findByConversationIdOrderByCreatedAtAsc(id);
String preview = "";
if (!messages.isEmpty()) {
String content = messages.getLast().getContent();
preview = content.length() > 100 ? content.substring(0, 100) + "..." : content;
}
return ResponseEntity.ok(ConversationSummaryDto.builder()
.id(saved.getId())
.title(saved.getTitle())
.preview(preview)
.createdAt(saved.getCreatedAt())
.updatedAt(saved.getUpdatedAt())
.build());
}).orElseGet(() -> ResponseEntity.notFound().build());
}
@DeleteMapping("/{id}")
@Transactional
public ResponseEntity<Void> deleteConversation(@PathVariable("id") String id) {
List<Message> messages = messageRepository.findByConversationIdOrderByCreatedAtAsc(id);
if (!messages.isEmpty()) {
messageRepository.deleteAll(messages);
}
conversationRepository.deleteById(id);
return ResponseEntity.noContent().build();
}
}

View File

@@ -5,6 +5,7 @@ import dev.sonpx.loyalty.agent.core.memory.AgentMemoryManager;
import dev.sonpx.loyalty.agent.core.orchestrator.PromptBuilder;
import dev.sonpx.loyalty.agent.domain.ChatRequest;
import dev.sonpx.loyalty.agent.service.AgentEventPublisher;
import dev.sonpx.loyalty.agent.service.AgentPersistenceService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.tool.ToolCallback;
@@ -34,19 +35,22 @@ public class SimpleAgentExecutor {
private final AgentEventPublisher eventPublisher;
private final AgentMemoryManager memoryManager;
private final PromptBuilder promptBuilder;
private final AgentPersistenceService persistenceService;
public SimpleAgentExecutor(@Qualifier("queryAgentChatClient") ChatClient queryChatClient,
@Qualifier("chatAgentChatClient") ChatClient chatChatClient,
ToolInterceptor toolInterceptor,
AgentEventPublisher eventPublisher,
AgentMemoryManager memoryManager,
PromptBuilder promptBuilder) {
PromptBuilder promptBuilder,
AgentPersistenceService persistenceService) {
this.queryChatClient = queryChatClient;
this.chatChatClient = chatChatClient;
this.toolInterceptor = toolInterceptor;
this.eventPublisher = eventPublisher;
this.memoryManager = memoryManager;
this.promptBuilder = promptBuilder;
this.persistenceService = persistenceService;
}
/**
@@ -63,6 +67,9 @@ public class SimpleAgentExecutor {
log.info("SimpleAgent executing with tools for conversation: {}, connection: {}, intent: {}", conversationKey, connectionSessionId, intent);
log.debug("System prompt: {}", systemPrompt);
persistenceService.saveUserMessage(conversationKey, request.messageId(), request.prompt());
StringBuilder responseAccumulator = new StringBuilder();
queryChatClient.prompt()
.advisors(memoryManager.getAdvisor())
.advisors(a -> a
@@ -75,11 +82,13 @@ public class SimpleAgentExecutor {
.content()
.doOnComplete(() -> {
log.info("SimpleAgent execution completed for conversation: {}, connection: {}", conversationKey, connectionSessionId);
persistenceService.saveAssistantMessage(conversationKey, responseAccumulator.toString());
eventPublisher.done(connectionSessionId, request.messageId());
})
.subscribe(
content -> {
if (!content.isEmpty()) {
responseAccumulator.append(content);
eventPublisher.token(connectionSessionId, request.messageId(), content);
}
},
@@ -101,6 +110,9 @@ public class SimpleAgentExecutor {
log.info("DirectChat executing for conversation: {}, connection: {}", conversationKey, connectionSessionId);
persistenceService.saveUserMessage(conversationKey, request.messageId(), request.prompt());
StringBuilder responseAccumulator = new StringBuilder();
chatChatClient.prompt()
.advisors(memoryManager.getAdvisor())
.advisors(a -> a
@@ -112,11 +124,13 @@ public class SimpleAgentExecutor {
.content()
.doOnComplete(() -> {
log.info("DirectChat completed for conversation: {}, connection: {}", conversationKey, connectionSessionId);
persistenceService.saveAssistantMessage(conversationKey, responseAccumulator.toString());
eventPublisher.done(connectionSessionId, request.messageId());
})
.subscribe(
content -> {
if (!content.isEmpty()) {
responseAccumulator.append(content);
eventPublisher.token(connectionSessionId, request.messageId(), content);
}
},

View File

@@ -1,24 +1,32 @@
package dev.sonpx.loyalty.agent.core.executor;
import dev.sonpx.loyalty.agent.core.config.AgentProperties;
import dev.sonpx.loyalty.agent.exception.UnrecoverableToolExecutionException;
import dev.sonpx.loyalty.agent.service.AgentEventPublisher;
import dev.sonpx.loyalty.agent.service.ToolResultPresentationProcessor;
import io.modelcontextprotocol.client.McpSyncClient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.stereotype.Component;
import java.io.EOFException;
import java.net.ConnectException;
import java.net.SocketException;
import java.nio.channels.ClosedChannelException;
import java.util.ArrayList;
import java.util.List;
/**
* Wraps MCP tool callbacks with:
* 1. Event publishing (tool running/done status)
* 2. Error sanitization (hides stack traces from LLM)
* 3. Result truncation (prevents context window overflow)
* 4. Result presentation processing (agent_instruction handling)
* 2. Error sanitization & Circuit breaker for unrecoverable errors
* 3. Auto-reconnection on dropped SSE/MCP connection
* 4. Result truncation (prevents context window overflow)
* 5. Result presentation processing (agent_instruction handling)
*/
@Slf4j
@Component
@@ -29,6 +37,7 @@ public class ToolInterceptor {
private final AgentEventPublisher eventPublisher;
private final ToolResultPresentationProcessor toolResultProcessor;
private final AgentProperties agentProperties;
private final ObjectProvider<List<McpSyncClient>> mcpSyncClientsProvider;
public List<ToolCallback> getWrappedTools(String sessionId, String messageId) {
return getWrappedTools(sessionId, messageId, AgentToolScope.ALL);
@@ -61,9 +70,16 @@ public class ToolInterceptor {
String toolName = getToolDefinition().name();
eventPublisher.toolRunning(sessionId, messageId, toolName);
try {
String result = tool.call(toolInput);
String result = executeWithRetry(tool, toolInput, toolName);
// Sanitize error stack traces — don't leak internals to LLM
// Unrecoverable raw system error responses (e.g. 404 HTML, connection reset)
if (result != null && isUnrecoverableErrorResponse(result)) {
log.error("Tool {} returned unrecoverable system error response: {}", toolName, result);
throw new UnrecoverableToolExecutionException(
"Tool " + toolName + " returned unrecoverable system error");
}
// Sanitize standard business/internal errors — don't leak stack traces to LLM
if (result != null && isErrorResponse(result)) {
log.warn("Tool {} returned error response, sanitizing", toolName);
return "{\"error\": \"Lỗi hệ thống khi gọi tool. Không thể xử lý yêu cầu. Vui lòng thử lại sau.\"}";
@@ -74,9 +90,12 @@ public class ToolInterceptor {
// Truncate if result is too large for the model's context window
return truncateResult(processed, toolName);
} catch (UnrecoverableToolExecutionException e) {
throw e;
} catch (Exception e) {
log.error("Tool {} threw exception: {}", toolName, e.getMessage(), e);
return "{\"error\": \"Lỗi hệ thống khi gọi tool. Không thể xử lý yêu cầu. Vui lòng thử lại sau.\"}";
log.error("Tool {} threw unrecoverable exception: {}", toolName, e.getMessage(), e);
throw new UnrecoverableToolExecutionException(
"Lỗi hệ thống không thể phục hồi khi gọi tool '" + toolName + "': " + e.getMessage(), e);
} finally {
eventPublisher.toolDone(sessionId, messageId, toolName);
}
@@ -84,6 +103,79 @@ public class ToolInterceptor {
};
}
private String executeWithRetry(ToolCallback tool, String toolInput, String toolName) throws Exception {
try {
return tool.call(toolInput);
} catch (Exception e) {
if (isConnectionOrSessionError(e)) {
log.warn("Detected MCP connection/session drop on tool {}: {}. Attempting auto-reconnect and retry...",
toolName, e.getMessage());
attemptReconnect();
try {
return tool.call(toolInput);
} catch (Exception retryEx) {
log.error("Retry after reconnect failed for tool {}: {}", toolName, retryEx.getMessage());
throw new UnrecoverableToolExecutionException("Không thể khôi phục kết nối MCP khi gọi tool " + toolName, retryEx);
}
}
throw e;
}
}
private void attemptReconnect() {
if (mcpSyncClientsProvider != null) {
List<McpSyncClient> clients = mcpSyncClientsProvider.getIfAvailable();
if (clients != null && !clients.isEmpty()) {
for (McpSyncClient client : clients) {
try {
log.info("Re-initializing MCP Sync Client session...");
client.initialize();
} catch (Exception ex) {
log.warn("Failed to re-initialize MCP Sync Client: {}", ex.getMessage());
}
}
}
}
}
public boolean isConnectionOrSessionError(Throwable t) {
if (t == null) {
return false;
}
Throwable current = t;
while (current != null) {
if (current instanceof EOFException
|| current instanceof ConnectException
|| current instanceof SocketException
|| current instanceof ClosedChannelException) {
return true;
}
String msg = current.getMessage();
if (msg != null) {
String lower = msg.toLowerCase();
if (lower.contains("404")
|| lower.contains("session not found")
|| lower.contains("session invalid")
|| lower.contains("connection reset")
|| lower.contains("connection refused")
|| lower.contains("stream closed")) {
return true;
}
}
current = current.getCause();
}
return false;
}
private boolean isUnrecoverableErrorResponse(String result) {
if (result == null) return false;
String lower = result.toLowerCase();
return lower.contains("404 session not found")
|| lower.contains("http status 404")
|| lower.contains("connection refused")
|| lower.contains("eofexception");
}
private boolean isErrorResponse(String result) {
return (result.contains("java.lang.") && result.contains("Exception") && result.contains("org.springframework."))
|| result.contains("HTTP Status 500 Internal Server Error");

View File

@@ -6,6 +6,7 @@ import org.springframework.util.StringUtils;
/**
* Chooses the stable key used by memory and workflow state.
* Requires explicit conversationId from ChatRequest.
*/
@Component
public class ConversationKeyResolver {
@@ -14,6 +15,6 @@ public class ConversationKeyResolver {
if (request != null && StringUtils.hasText(request.conversationId())) {
return request.conversationId();
}
return connectionSessionId;
throw new IllegalArgumentException("conversationId is required in ChatRequest");
}
}

View File

@@ -1,78 +0,0 @@
package dev.sonpx.loyalty.agent.core.memory;
import dev.sonpx.loyalty.agent.core.config.AgentProperties;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.Message;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* In-memory chat memory with token-aware truncation.
*
* Improvements over original:
* - Configurable max tokens via AgentProperties
* - Vietnamese-aware token estimation (chars/2 instead of chars/4)
* - Preserves system messages during truncation
*/
@Component
public class InMemoryChatMemory implements ChatMemory {
private final Map<String, List<Message>> conversationHistory = new ConcurrentHashMap<>();
private final AgentProperties agentProperties;
public InMemoryChatMemory(AgentProperties agentProperties) {
this.agentProperties = agentProperties;
}
@Override
public void add(String conversationId, List<Message> messages) {
conversationHistory.compute(conversationId, (id, history) -> {
if (history == null) {
history = new ArrayList<>();
}
history.addAll(messages);
// Truncation: remove oldest non-system messages when over token limit
while (history.size() > 1 && estimateTokens(history) > agentProperties.getMaxMemoryTokens()) {
boolean removed = false;
for (int i = 0; i < history.size(); i++) {
if (!(history.get(i) instanceof org.springframework.ai.chat.messages.SystemMessage)) {
history.remove(i);
removed = true;
break;
}
}
// If only system messages remain, stop to avoid infinite loop
if (!removed) {
break;
}
}
return history;
});
}
/**
* Estimate token count for a list of messages.
* Uses configurable chars-per-token ratio (default 2 for Vietnamese text).
*/
private int estimateTokens(List<Message> messages) {
int chars = messages.stream()
.mapToInt(m -> m.getText() != null ? m.getText().length() : 0)
.sum();
return chars / agentProperties.getCharsPerToken();
}
@Override
public List<Message> get(String conversationId) {
return conversationHistory.getOrDefault(conversationId, new ArrayList<>());
}
@Override
public void clear(String conversationId) {
conversationHistory.remove(conversationId);
}
}

View File

@@ -0,0 +1,212 @@
package dev.sonpx.loyalty.agent.core.memory;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.sonpx.loyalty.agent.core.config.AgentProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* JDBC-backed ChatMemory for Spring AI with PostgreSQL persistence,
* Vietnamese token estimation, tool result truncation, and polymorphic message serialization.
*/
@Slf4j
@Component
public class JdbcChatMemory implements ChatMemory {
private static final int MAX_TOOL_RESULT_LENGTH = 4000;
private final JdbcTemplate jdbcTemplate;
private final AgentProperties agentProperties;
private final ObjectMapper objectMapper;
public JdbcChatMemory(JdbcTemplate jdbcTemplate, AgentProperties agentProperties, ObjectMapper objectMapper) {
this.jdbcTemplate = jdbcTemplate;
this.agentProperties = agentProperties;
this.objectMapper = objectMapper;
}
@Override
@Transactional
public void add(String conversationId, List<Message> messages) {
if (messages == null || messages.isEmpty()) {
return;
}
Long currentMaxSeq = jdbcTemplate.queryForObject(
"SELECT COALESCE(MAX(sequence_num), 0) FROM chat_memory_messages WHERE conversation_id = ?",
Long.class,
conversationId
);
long seq = (currentMaxSeq != null ? currentMaxSeq : 0);
for (Message msg : messages) {
seq++;
saveMessage(conversationId, seq, msg);
}
// Apply token-aware truncation
truncateIfExceedsTokenLimit(conversationId);
}
private void saveMessage(String conversationId, long sequenceNum, Message msg) {
String id = UUID.randomUUID().toString();
String messageType = msg.getMessageType().name();
String content = msg.getText();
String metadataJson = null;
if (msg instanceof AssistantMessage assistantMsg && assistantMsg.hasToolCalls()) {
try {
List<ToolCallDto> dtos = assistantMsg.getToolCalls().stream()
.map(tc -> new ToolCallDto(tc.id(), tc.type(), tc.name(), tc.arguments()))
.toList();
metadataJson = objectMapper.writeValueAsString(Map.of("toolCalls", dtos));
} catch (Exception e) {
log.error("Failed to serialize AssistantMessage toolCalls for conversation: {}", conversationId, e);
}
} else if (msg instanceof ToolResponseMessage toolMsg) {
try {
List<ToolResponseDto> dtos = toolMsg.getResponses().stream()
.map(tr -> {
String resData = tr.responseData();
if (resData != null && resData.length() > MAX_TOOL_RESULT_LENGTH) {
resData = resData.substring(0, MAX_TOOL_RESULT_LENGTH) + "\n... [truncated]";
}
return new ToolResponseDto(tr.id(), tr.name(), resData);
})
.toList();
metadataJson = objectMapper.writeValueAsString(Map.of("responses", dtos));
} catch (Exception e) {
log.error("Failed to serialize ToolResponseMessage responses for conversation: {}", conversationId, e);
}
}
jdbcTemplate.update(
"INSERT INTO chat_memory_messages (id, conversation_id, sequence_num, message_type, content, metadata, created_at) " +
"VALUES (?, ?, ?, ?, ?, ?::jsonb, ?)",
id, conversationId, sequenceNum, messageType, content, metadataJson, Timestamp.from(Instant.now())
);
}
private void truncateIfExceedsTokenLimit(String conversationId) {
List<MessageWrapper> history = loadMessageWrappers(conversationId);
int maxTokens = agentProperties.getMaxMemoryTokens();
int charsPerToken = agentProperties.getCharsPerToken();
while (history.size() > 1 && estimateTokensWrappers(history, charsPerToken) > maxTokens) {
int removeIndex = -1;
for (int i = 0; i < history.size(); i++) {
if (!(history.get(i).message instanceof SystemMessage)) {
removeIndex = i;
break;
}
}
if (removeIndex == -1) {
break; // Only system messages remain
}
MessageWrapper toRemove = history.remove(removeIndex);
jdbcTemplate.update("DELETE FROM chat_memory_messages WHERE id = ?", toRemove.id);
}
}
private int estimateTokensWrappers(List<MessageWrapper> history, int charsPerToken) {
int totalChars = history.stream()
.mapToInt(w -> w.message.getText() != null ? w.message.getText().length() : 0)
.sum();
return totalChars / (charsPerToken > 0 ? charsPerToken : 2);
}
@Override
public List<Message> get(String conversationId) {
List<MessageWrapper> wrappers = loadMessageWrappers(conversationId);
return wrappers.stream().map(w -> w.message).toList();
}
private List<MessageWrapper> loadMessageWrappers(String conversationId) {
String sql = "SELECT id, message_type, content, metadata FROM chat_memory_messages " +
"WHERE conversation_id = ? ORDER BY sequence_num ASC";
return jdbcTemplate.query(sql, (rs, rowNum) -> {
String id = rs.getString("id");
String messageTypeStr = rs.getString("message_type");
String content = rs.getString("content");
String metadataJson = rs.getString("metadata");
MessageType type = MessageType.valueOf(messageTypeStr);
Message message = deserializeMessage(type, content, metadataJson);
return new MessageWrapper(id, message);
}, conversationId);
}
private Message deserializeMessage(MessageType type, String content, String metadataJson) {
return switch (type) {
case USER -> new UserMessage(content != null ? content : "");
case SYSTEM -> new SystemMessage(content != null ? content : "");
case ASSISTANT -> {
if (metadataJson != null && !metadataJson.isBlank()) {
try {
Map<String, Object> map = objectMapper.readValue(metadataJson, new TypeReference<>() {});
if (map.containsKey("toolCalls")) {
List<ToolCallDto> dtos = objectMapper.convertValue(map.get("toolCalls"), new TypeReference<>() {});
List<AssistantMessage.ToolCall> toolCalls = dtos.stream()
.map(d -> new AssistantMessage.ToolCall(d.id(), d.type(), d.name(), d.arguments()))
.toList();
yield AssistantMessage.builder()
.content(content != null ? content : "")
.toolCalls(toolCalls)
.build();
}
} catch (Exception e) {
log.error("Error deserializing AssistantMessage metadata: {}", metadataJson, e);
}
}
yield new AssistantMessage(content != null ? content : "");
}
case TOOL -> {
if (metadataJson != null && !metadataJson.isBlank()) {
try {
Map<String, Object> map = objectMapper.readValue(metadataJson, new TypeReference<>() {});
if (map.containsKey("responses")) {
List<ToolResponseDto> dtos = objectMapper.convertValue(map.get("responses"), new TypeReference<>() {});
List<ToolResponseMessage.ToolResponse> responses = dtos.stream()
.map(d -> new ToolResponseMessage.ToolResponse(d.id(), d.name(), d.responseData()))
.toList();
yield ToolResponseMessage.builder()
.responses(responses)
.build();
}
} catch (Exception e) {
log.error("Error deserializing ToolResponseMessage metadata: {}", metadataJson, e);
}
}
yield ToolResponseMessage.builder().responses(List.of()).build();
}
};
}
@Override
@Transactional
public void clear(String conversationId) {
jdbcTemplate.update("DELETE FROM chat_memory_messages WHERE conversation_id = ?", conversationId);
}
private record MessageWrapper(String id, Message message) {}
private record ToolCallDto(String id, String type, String name, String arguments) {}
private record ToolResponseDto(String id, String name, String responseData) {}
}

View File

@@ -70,9 +70,6 @@ public class PromptBuilder {
+ request.conversationId()
+ ". You MUST provide this conversationId as a parameter to any tool that requires it.");
}
if (StringUtils.hasText(request.activeCampaignId())) {
context.add("Context: Active Campaign ID is " + request.activeCampaignId() + ".");
}
String prompt = request.prompt() == null ? "" : request.prompt();
if (context.isEmpty()) {

View File

@@ -43,6 +43,7 @@ public class WorkflowExecutor {
private final AgentEventPublisher eventPublisher;
private final AgentMemoryManager memoryManager;
private final WorkflowDataExtractor dataExtractor;
private final dev.sonpx.loyalty.agent.service.AgentPersistenceService persistenceService;
/** Active workflows per session */
private final Map<String, WorkflowState> activeWorkflows = new ConcurrentHashMap<>();
@@ -51,12 +52,14 @@ public class WorkflowExecutor {
ToolInterceptor toolInterceptor,
AgentEventPublisher eventPublisher,
AgentMemoryManager memoryManager,
WorkflowDataExtractor dataExtractor) {
WorkflowDataExtractor dataExtractor,
dev.sonpx.loyalty.agent.service.AgentPersistenceService persistenceService) {
this.chatClient = chatClient;
this.toolInterceptor = toolInterceptor;
this.eventPublisher = eventPublisher;
this.memoryManager = memoryManager;
this.dataExtractor = dataExtractor;
this.persistenceService = persistenceService;
}
/**
@@ -95,11 +98,6 @@ public class WorkflowExecutor {
log.info("Starting {} workflow for conversation: {}, connection: {}", intent, conversationKey, connectionSessionId);
// Inject activeCampaignId if available (useful for rule creation)
if (request.activeCampaignId() != null && !request.activeCampaignId().isBlank()) {
state.putData("Campaign ID", request.activeCampaignId());
}
// Build the initial prompt based on workflow type
String systemPrompt = getPhasePrompt(state);
String userPrompt = request.prompt() != null ? request.prompt() : "Bắt đầu tạo";
@@ -261,6 +259,9 @@ public class WorkflowExecutor {
String connectionSessionId, String conversationKey,
String messageId, boolean withTools,
AgentToolScope toolScope, Runnable onComplete, Runnable onError) {
persistenceService.saveUserMessage(conversationKey, messageId, userPrompt);
StringBuilder responseAccumulator = new StringBuilder();
var builder = chatClient.prompt()
.advisors(memoryManager.getAdvisor())
.advisors(a -> a
@@ -277,6 +278,7 @@ public class WorkflowExecutor {
builder.stream()
.content()
.doOnComplete(() -> {
persistenceService.saveAssistantMessage(conversationKey, responseAccumulator.toString());
if (onComplete != null) {
onComplete.run();
}
@@ -285,6 +287,7 @@ public class WorkflowExecutor {
.subscribe(
content -> {
if (!content.isEmpty()) {
responseAccumulator.append(content);
eventPublisher.token(connectionSessionId, messageId, content);
}
},

View File

@@ -1,4 +1,4 @@
package dev.sonpx.loyalty.agent.domain;
public record ChatRequest(String conversationId, String messageId, String prompt, String activeCampaignId) {
public record ChatRequest(String conversationId, String messageId, String prompt) {
}

View File

@@ -1,5 +1,9 @@
package dev.sonpx.loyalty.agent.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@@ -11,8 +15,18 @@ import java.time.Instant;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "conversations")
public class Conversation {
@Id
private String id;
@Column(name = "title")
private String title;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
}

View File

@@ -1,5 +1,9 @@
package dev.sonpx.loyalty.agent.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@@ -11,10 +15,21 @@ import java.time.Instant;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "messages")
public class Message {
@Id
private String id;
@Column(name = "conversation_id", nullable = false)
private String conversationId;
@Column(name = "role", nullable = false)
private String role;
@Column(name = "content", nullable = false, columnDefinition = "TEXT")
private String content;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
}

View File

@@ -0,0 +1,20 @@
package dev.sonpx.loyalty.agent.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.Instant;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ConversationSummaryDto {
private String id;
private String title;
private String preview;
private Instant createdAt;
private Instant updatedAt;
}

View File

@@ -0,0 +1,17 @@
package dev.sonpx.loyalty.agent.exception;
/**
* Exception thrown when a tool execution encounters an unrecoverable system or connection error.
* Throwing this exception aborts the Spring AI ChatClient stream immediately,
* preventing small LLMs from entering an infinite ReAct retry loop.
*/
public class UnrecoverableToolExecutionException extends RuntimeException {
public UnrecoverableToolExecutionException(String message) {
super(message);
}
public UnrecoverableToolExecutionException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -1,12 +1,13 @@
package dev.sonpx.loyalty.agent.repository;
import dev.sonpx.loyalty.agent.domain.Conversation;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.List;
public interface ConversationRepository {
Conversation save(Conversation conversation);
Optional<Conversation> findById(String id);
List<Conversation> findAll();
@Repository
public interface ConversationRepository extends JpaRepository<Conversation, String> {
List<Conversation> findAllByOrderByUpdatedAtDesc();
}

View File

@@ -1,31 +0,0 @@
package dev.sonpx.loyalty.agent.repository;
import dev.sonpx.loyalty.agent.domain.Conversation;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
@Repository
public class InMemoryConversationRepository implements ConversationRepository {
private final Map<String, Conversation> store = new ConcurrentHashMap<>();
@Override
public Conversation save(Conversation conversation) {
store.put(conversation.getId(), conversation);
return conversation;
}
@Override
public Optional<Conversation> findById(String id) {
return Optional.ofNullable(store.get(id));
}
@Override
public List<Conversation> findAll() {
return new ArrayList<>(store.values());
}
}

View File

@@ -1,30 +0,0 @@
package dev.sonpx.loyalty.agent.repository;
import dev.sonpx.loyalty.agent.domain.Message;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
@Repository
public class InMemoryMessageRepository implements MessageRepository {
private final Map<String, Message> store = new ConcurrentHashMap<>();
@Override
public Message save(Message message) {
store.put(message.getId(), message);
return message;
}
@Override
public List<Message> findByConversationIdOrderByCreatedAtAsc(String conversationId) {
return store.values().stream()
.filter(m -> conversationId.equals(m.getConversationId()))
.sorted(Comparator.comparing(Message::getCreatedAt))
.collect(Collectors.toList());
}
}

View File

@@ -1,10 +1,12 @@
package dev.sonpx.loyalty.agent.repository;
import dev.sonpx.loyalty.agent.domain.Message;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
public interface MessageRepository {
Message save(Message message);
@Repository
public interface MessageRepository extends JpaRepository<Message, String> {
List<Message> findByConversationIdOrderByCreatedAtAsc(String conversationId);
}

View File

@@ -36,7 +36,11 @@ public class AgentEventPublisher {
}
private void send(String sessionId, AgentEvent event) {
log.debug("Publishing STOMP event type={} for session={}", event.type(), sessionId);
if ("TOKEN".equals(event.type())) {
log.trace("Publishing STOMP event type={} for session={}", event.type(), sessionId);
} else {
log.debug("Publishing STOMP event type={} for session={}", event.type(), sessionId);
}
messagingTemplate.convertAndSendToUser(sessionId, CHAT_EVENTS_DESTINATION, event);
}
}

View File

@@ -0,0 +1,109 @@
package dev.sonpx.loyalty.agent.service;
import dev.sonpx.loyalty.agent.domain.Conversation;
import dev.sonpx.loyalty.agent.domain.Message;
import dev.sonpx.loyalty.agent.repository.ConversationRepository;
import dev.sonpx.loyalty.agent.repository.MessageRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.UUID;
@Slf4j
@Service
public class AgentPersistenceService {
private final ConversationRepository conversationRepository;
private final MessageRepository messageRepository;
private final ChatClient chatChatClient;
public AgentPersistenceService(
ConversationRepository conversationRepository,
MessageRepository messageRepository,
@Qualifier("chatAgentChatClient") ChatClient chatChatClient) {
this.conversationRepository = conversationRepository;
this.messageRepository = messageRepository;
this.chatChatClient = chatChatClient;
}
public void saveUserMessage(String conversationId, String userMessageId, String content) {
try {
ensureConversationExists(conversationId, content);
Message message = Message.builder()
.id(userMessageId != null ? userMessageId : UUID.randomUUID().toString())
.conversationId(conversationId)
.role("user")
.content(content != null ? content : "")
.createdAt(Instant.now())
.build();
messageRepository.save(message);
} catch (Exception e) {
log.error("Failed to save user message for conversation: {}", conversationId, e);
}
}
public void saveAssistantMessage(String conversationId, String content) {
if (content == null || content.isBlank()) {
return;
}
try {
ensureConversationExists(conversationId, null);
Message message = Message.builder()
.id(UUID.randomUUID().toString())
.conversationId(conversationId)
.role("assistant")
.content(content)
.createdAt(Instant.now())
.build();
messageRepository.save(message);
} catch (Exception e) {
log.error("Failed to save assistant message for conversation: {}", conversationId, e);
}
}
private void ensureConversationExists(String conversationId, String userPromptForTitle) {
Conversation conversation = conversationRepository.findById(conversationId)
.orElseGet(() -> Conversation.builder()
.id(conversationId)
.title("Cuộc trò chuyện mới")
.createdAt(Instant.now())
.updatedAt(Instant.now())
.build());
conversation.setUpdatedAt(Instant.now());
if (userPromptForTitle != null && !userPromptForTitle.isBlank()) {
if (conversation.getTitle() == null || conversation.getTitle().isBlank() || "Cuộc trò chuyện mới".equalsIgnoreCase(conversation.getTitle())) {
String generatedTitle = generateTitleWithAgent(userPromptForTitle);
conversation.setTitle(generatedTitle);
}
}
conversationRepository.save(conversation);
}
private String generateTitleWithAgent(String content) {
try {
String prompt = "Hãy tạo 1 tiêu đề cực kỳ ngắn gọn (3 đến 6 từ), chính xác và súc tích bằng tiếng Việt đại diện cho chủ đề của tin nhắn sau. Không dùng dấu ngoặc kép, không giải thích dài dòng, chỉ trả về duy nhất tiêu đề:\n\n" + content;
String generatedTitle = chatChatClient.prompt()
.user(prompt)
.call()
.content();
if (generatedTitle != null && !generatedTitle.isBlank()) {
generatedTitle = generatedTitle.replaceAll("^[\"']|[\"']$", "").trim();
if (generatedTitle.length() > 60) {
generatedTitle = generatedTitle.substring(0, 60);
}
return generatedTitle;
}
} catch (Exception e) {
log.warn("Failed to generate title using Agent for content, falling back: {}", e.getMessage());
}
return content.length() > 40 ? content.substring(0, 40) + "..." : content;
}
}

View File

@@ -13,6 +13,23 @@ spring:
request-timeout: 3600000
application:
name: loyalty-agent-service
datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://192.168.99.242:5433/loyalty_agent}
username: ${SPRING_DATASOURCE_USERNAME:postgres}
password: ${SPRING_DATASOURCE_PASSWORD:Sonpx@1234}
driver-class-name: org.postgresql.Driver
jpa:
open-in-view: false
hibernate:
ddl-auto: validate
show-sql: false
properties:
hibernate:
format_sql: true
flyway:
enabled: true
baseline-on-migrate: true
locations: classpath:db/migration
ai:
ollama:
base-url: http://192.168.99.10:11434
@@ -35,6 +52,7 @@ logging:
pattern:
console: "%d{HH:mm:ss.SSS} %5p --- %-40.40logger{39} : %m%n"
level:
org.flywaydb: DEBUG
org.springframework.ai: INFO
dev.sonpx.loyalty: DEBUG
root: warn
@@ -56,6 +74,7 @@ agent:
creator:
base-url: ${CREATOR_OLLAMA_BASE_URL:http://192.168.99.10:11434}
model: ${CREATOR_MODEL:qwen3.5:4b}
temperature: ${CREATOR_MODEL_TEMPERATURE:0.2}
temperature: ${CREATOR_MODEL_TEMPERATURE:0.3}
num-ctx: ${CREATOR_MODEL_NUM_CTX:32768}
disable-thinking: ${CREATOR_MODEL_DISABLE_THINKING:false}

View File

@@ -0,0 +1,28 @@
CREATE TABLE conversations (
id VARCHAR(255) PRIMARY KEY,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL
);
CREATE TABLE messages (
id VARCHAR(255) PRIMARY KEY,
conversation_id VARCHAR(255) NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
role VARCHAR(50) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL
);
CREATE INDEX idx_messages_conversation_created ON messages(conversation_id, created_at);
CREATE TABLE chat_memory_messages (
id VARCHAR(255) PRIMARY KEY,
conversation_id VARCHAR(255) NOT NULL,
sequence_num BIGINT NOT NULL,
message_type VARCHAR(50) NOT NULL,
content TEXT,
metadata JSONB,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
CONSTRAINT uk_chat_memory_conversation_seq UNIQUE (conversation_id, sequence_num)
);
CREATE INDEX idx_chat_memory_conversation_created ON chat_memory_messages(conversation_id, created_at);

View File

@@ -0,0 +1 @@
ALTER TABLE conversations ADD COLUMN title VARCHAR(255);

View File

@@ -0,0 +1,124 @@
package dev.sonpx.loyalty.agent.core.executor;
import dev.sonpx.loyalty.agent.core.config.AgentProperties;
import dev.sonpx.loyalty.agent.exception.UnrecoverableToolExecutionException;
import dev.sonpx.loyalty.agent.service.AgentEventPublisher;
import dev.sonpx.loyalty.agent.service.ToolResultPresentationProcessor;
import io.modelcontextprotocol.client.McpSyncClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.beans.factory.ObjectProvider;
import java.io.EOFException;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
public class ToolInterceptorTest {
@Mock
private ToolCallbackProvider toolCallbackProvider;
@Mock
private AgentEventPublisher eventPublisher;
@Mock
private ToolResultPresentationProcessor toolResultProcessor;
@Mock
private AgentProperties agentProperties;
@Mock
private ObjectProvider<List<McpSyncClient>> mcpSyncClientsProvider;
@Mock
private McpSyncClient mcpSyncClient;
@Mock
private ToolCallback mockTool;
@Mock
private ToolDefinition mockToolDefinition;
private ToolInterceptor toolInterceptor;
@BeforeEach
void setUp() {
lenient().when(mockToolDefinition.name()).thenReturn("test_tool");
lenient().when(mockTool.getToolDefinition()).thenReturn(mockToolDefinition);
lenient().when(toolCallbackProvider.getToolCallbacks()).thenReturn(new ToolCallback[]{mockTool});
toolInterceptor = new ToolInterceptor(
List.of(toolCallbackProvider),
eventPublisher,
toolResultProcessor,
agentProperties,
mcpSyncClientsProvider
);
}
@Test
void testSuccessfulToolExecution() {
when(mockTool.call("input")).thenReturn("{\"status\":\"ok\"}");
when(toolResultProcessor.process("{\"status\":\"ok\"}")).thenReturn("{\"status\":\"ok\"}");
when(agentProperties.getMaxToolResultChars()).thenReturn(1000);
List<ToolCallback> wrapped = toolInterceptor.getWrappedTools("session1", "msg1");
assertThat(wrapped).hasSize(1);
String response = wrapped.get(0).call("input");
assertThat(response).isEqualTo("{\"status\":\"ok\"}");
verify(eventPublisher).toolRunning("session1", "msg1", "test_tool");
verify(eventPublisher).toolDone("session1", "msg1", "test_tool");
}
@Test
void testAutoReconnectAndRetryOnConnectionDrop() {
when(mcpSyncClientsProvider.getIfAvailable()).thenReturn(List.of(mcpSyncClient));
when(mockTool.call("input"))
.thenThrow(new RuntimeException("404 Session Not Found"))
.thenReturn("{\"recovered\":true}");
when(toolResultProcessor.process("{\"recovered\":true}")).thenReturn("{\"recovered\":true}");
when(agentProperties.getMaxToolResultChars()).thenReturn(1000);
List<ToolCallback> wrapped = toolInterceptor.getWrappedTools("session1", "msg1");
String response = wrapped.get(0).call("input");
assertThat(response).isEqualTo("{\"recovered\":true}");
verify(mcpSyncClient, times(1)).initialize();
verify(mockTool, times(2)).call("input");
}
@Test
void testUnrecoverableErrorThrowsCircuitBreakerException() {
when(mcpSyncClientsProvider.getIfAvailable()).thenReturn(List.of(mcpSyncClient));
when(mockTool.call("input")).thenThrow(new RuntimeException(new EOFException("Connection reset by peer")));
List<ToolCallback> wrapped = toolInterceptor.getWrappedTools("session1", "msg1");
assertThatThrownBy(() -> wrapped.get(0).call("input"))
.isInstanceOf(UnrecoverableToolExecutionException.class)
.hasMessageContaining("Không thể khôi phục kết nối MCP khi gọi tool test_tool");
verify(eventPublisher).toolDone("session1", "msg1", "test_tool");
}
@Test
void testIsConnectionOrSessionErrorDetection() {
assertThat(toolInterceptor.isConnectionOrSessionError(new EOFException("EOF"))).isTrue();
assertThat(toolInterceptor.isConnectionOrSessionError(new RuntimeException("HTTP 404 Not Found"))).isTrue();
assertThat(toolInterceptor.isConnectionOrSessionError(new RuntimeException("Session not found"))).isTrue();
assertThat(toolInterceptor.isConnectionOrSessionError(new IllegalArgumentException("Invalid input"))).isFalse();
}
}

View File

@@ -1,6 +1,7 @@
package dev.sonpx.loyalty.agent.core.memory;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import dev.sonpx.loyalty.agent.domain.ChatRequest;
import org.junit.jupiter.api.Test;
@@ -11,15 +12,15 @@ class ConversationKeyResolverTest {
@Test
void usesConversationIdWhenPresent() {
ChatRequest request = new ChatRequest("conversation-1", "message-1", "hello", null);
ChatRequest request = new ChatRequest("conversation-1", "message-1", "hello");
assertEquals("conversation-1", resolver.resolve(request, "connection-1"));
}
@Test
void fallsBackToConnectionSessionId() {
ChatRequest request = new ChatRequest(" ", "message-1", "hello", null);
void throwsExceptionWhenConversationIdMissing() {
ChatRequest request = new ChatRequest(" ", "message-1", "hello");
assertEquals("connection-1", resolver.resolve(request, "connection-1"));
assertThrows(IllegalArgumentException.class, () -> resolver.resolve(request, "connection-1"));
}
}

View File

@@ -0,0 +1,182 @@
package dev.sonpx.loyalty.agent.core.memory;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.sonpx.loyalty.agent.core.config.AgentProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class JdbcChatMemoryTest {
@Mock
private JdbcTemplate jdbcTemplate;
@Mock
private AgentProperties agentProperties;
private final ObjectMapper objectMapper = new ObjectMapper();
private JdbcChatMemory jdbcChatMemory;
@BeforeEach
void setUp() {
lenient().when(agentProperties.getMaxMemoryTokens()).thenReturn(1000);
lenient().when(agentProperties.getCharsPerToken()).thenReturn(2);
jdbcChatMemory = new JdbcChatMemory(jdbcTemplate, agentProperties, objectMapper);
}
@Test
@DisplayName("Should save UserMessage and SystemMessage to database")
void testAddUserAndSystemMessage() {
when(jdbcTemplate.queryForObject(anyString(), eq(Long.class), eq("conv-1"))).thenReturn(0L);
List<Message> messages = List.of(
new SystemMessage("System prompt"),
new UserMessage("Hello AI")
);
jdbcChatMemory.add("conv-1", messages);
verify(jdbcTemplate, times(2)).update(
contains("INSERT INTO chat_memory_messages"),
anyString(), eq("conv-1"), anyLong(), anyString(), any(), any(), any()
);
}
@Test
@DisplayName("Should serialize AssistantMessage tool calls into metadata JSON")
void testAddAssistantMessageWithToolCalls() {
when(jdbcTemplate.queryForObject(anyString(), eq(Long.class), eq("conv-2"))).thenReturn(0L);
AssistantMessage.ToolCall toolCall = new AssistantMessage.ToolCall("call-123", "function", "get_campaign", "{\"id\":1}");
AssistantMessage assistantMessage = AssistantMessage.builder()
.content("Checking campaign...")
.toolCalls(List.of(toolCall))
.build();
jdbcChatMemory.add("conv-2", List.of(assistantMessage));
ArgumentCaptor<String> metadataCaptor = ArgumentCaptor.forClass(String.class);
verify(jdbcTemplate).update(
contains("INSERT INTO chat_memory_messages"),
anyString(), eq("conv-2"), eq(1L), eq("ASSISTANT"), eq("Checking campaign..."), metadataCaptor.capture(), any()
);
String metadataJson = metadataCaptor.getValue();
assertNotNull(metadataJson);
assertTrue(metadataJson.contains("call-123"));
assertTrue(metadataJson.contains("get_campaign"));
}
@Test
@DisplayName("Should truncate ToolResponseMessage when response data exceeds 4000 chars")
void testAddToolResponseMessageTruncation() {
when(jdbcTemplate.queryForObject(anyString(), eq(Long.class), eq("conv-3"))).thenReturn(0L);
String longResponseData = "A".repeat(5000);
ToolResponseMessage.ToolResponse toolResponse = new ToolResponseMessage.ToolResponse("call-123", "get_campaign", longResponseData);
ToolResponseMessage toolResponseMessage = ToolResponseMessage.builder()
.responses(List.of(toolResponse))
.build();
jdbcChatMemory.add("conv-3", List.of(toolResponseMessage));
ArgumentCaptor<String> metadataCaptor = ArgumentCaptor.forClass(String.class);
verify(jdbcTemplate).update(
contains("INSERT INTO chat_memory_messages"),
anyString(), eq("conv-3"), eq(1L), eq("TOOL"), eq(""), metadataCaptor.capture(), any()
);
String metadataJson = metadataCaptor.getValue();
assertNotNull(metadataJson);
assertTrue(metadataJson.contains("... [truncated]"));
assertFalse(metadataJson.contains(longResponseData));
}
@Test
@DisplayName("Should deserialize AssistantMessage with tool calls correctly on get()")
@SuppressWarnings("unchecked")
void testGetAssistantMessageWithToolCalls() {
String metadataJson = "{\"toolCalls\":[{\"id\":\"call-99\",\"type\":\"function\",\"name\":\"check_rule\",\"arguments\":\"{\\\"ruleId\\\":5}\"}]}";
when(jdbcTemplate.query(anyString(), any(RowMapper.class), eq("conv-4")))
.thenAnswer(invocation -> {
RowMapper<?> mapper = invocation.getArgument(1);
java.sql.ResultSet rs = mock(java.sql.ResultSet.class);
when(rs.getString("id")).thenReturn("msg-1");
when(rs.getString("message_type")).thenReturn("ASSISTANT");
when(rs.getString("content")).thenReturn("Found rule");
when(rs.getString("metadata")).thenReturn(metadataJson);
Object wrapper = mapper.mapRow(rs, 0);
return List.of(wrapper);
});
List<Message> messages = jdbcChatMemory.get("conv-4");
assertEquals(1, messages.size());
assertTrue(messages.get(0) instanceof AssistantMessage);
AssistantMessage assistantMsg = (AssistantMessage) messages.get(0);
assertEquals("Found rule", assistantMsg.getText());
assertTrue(assistantMsg.hasToolCalls());
assertEquals(1, assistantMsg.getToolCalls().size());
assertEquals("call-99", assistantMsg.getToolCalls().get(0).id());
assertEquals("check_rule", assistantMsg.getToolCalls().get(0).name());
}
@Test
@DisplayName("Should deserialize ToolResponseMessage with responses correctly on get()")
@SuppressWarnings("unchecked")
void testGetToolResponseMessage() {
String metadataJson = "{\"responses\":[{\"id\":\"call-99\",\"name\":\"check_rule\",\"responseData\":\"{\\\"success\\\":true}\"}]}";
when(jdbcTemplate.query(anyString(), any(RowMapper.class), eq("conv-5")))
.thenAnswer(invocation -> {
RowMapper<?> mapper = invocation.getArgument(1);
java.sql.ResultSet rs = mock(java.sql.ResultSet.class);
when(rs.getString("id")).thenReturn("msg-2");
when(rs.getString("message_type")).thenReturn("TOOL");
when(rs.getString("content")).thenReturn("");
when(rs.getString("metadata")).thenReturn(metadataJson);
Object wrapper = mapper.mapRow(rs, 0);
return List.of(wrapper);
});
List<Message> messages = jdbcChatMemory.get("conv-5");
assertEquals(1, messages.size());
assertTrue(messages.get(0) instanceof ToolResponseMessage);
ToolResponseMessage toolMsg = (ToolResponseMessage) messages.get(0);
assertEquals(1, toolMsg.getResponses().size());
assertEquals("call-99", toolMsg.getResponses().get(0).id());
assertEquals("{\"success\":true}", toolMsg.getResponses().get(0).responseData());
}
@Test
@DisplayName("Should clear chat memory messages for a conversation")
void testClear() {
jdbcChatMemory.clear("conv-6");
verify(jdbcTemplate).update("DELETE FROM chat_memory_messages WHERE conversation_id = ?", "conv-6");
}
}

View File

@@ -1,5 +1,6 @@
package dev.sonpx.loyalty.mcp.config;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@@ -11,7 +12,11 @@ import org.springframework.context.annotation.Configuration;
public class AppConfig {
@Bean
public ObjectMapper objectMapper() {
return new ObjectMapper();
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.findAndRegisterModules();
mapper.setDefaultPropertyInclusion(JsonInclude.Include.NON_EMPTY);
return mapper;
}
}

View File

@@ -0,0 +1,41 @@
package dev.sonpx.loyalty.mcp.reward.dto;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import dev.sonpx.loyalty.mcp.reward.enums.RuleType;
import dev.sonpx.loyalty.mcp.reward.model.ReferenceData;
import java.io.Serializable;
import java.time.LocalDateTime;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CampaignRuleSummaryDto implements Serializable {
@JsonPropertyDescription("Mã định danh của rule")
private String ruleId;
@JsonPropertyDescription("Tên của rule")
private String ruleName;
@JsonPropertyDescription("Loại rule: RED, REP, AWD, ADJ, MAWD, CEP")
private RuleType ruleType;
@JsonPropertyDescription("Tham chiếu đến Campaign chứa rule này")
private ReferenceData campaignId;
@JsonPropertyDescription("Thời gian bắt đầu có hiệu lực của rule")
private LocalDateTime effectiveFrom;
@JsonPropertyDescription("Thời gian kết thúc hiệu lực của rule")
private LocalDateTime effectiveTo;
@JsonPropertyDescription("Tham chiếu đến Pool chính")
private ReferenceData poolId;
}

View File

@@ -0,0 +1,43 @@
package dev.sonpx.loyalty.mcp.reward.dto;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import dev.sonpx.loyalty.mcp.reward.enums.CampaignType;
import java.io.Serializable;
import java.time.LocalDate;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CampaignSummaryDto implements Serializable {
@JsonPropertyDescription("Mã chiến dịch")
private String campaignId;
@JsonPropertyDescription("Tên hiển thị của chiến dịch")
private String name;
@JsonPropertyDescription("Tên người hoặc phòng ban sở hữu chiến dịch")
private String ownerName;
@JsonPropertyDescription("Loại chiến dịch (POINT, VOUCHER...)")
private CampaignType campaignType;
@JsonPropertyDescription("Ngày bắt đầu có hiệu lực")
private LocalDate effectiveFrom;
@JsonPropertyDescription("Ngày kết thúc hiệu lực")
private LocalDate effectiveTo;
@JsonPropertyDescription("Số lượng quy tắc (rules) trong chiến dịch")
private Integer numOfRule;
@JsonPropertyDescription("Mô tả chi tiết")
private String description;
}

View File

@@ -2,6 +2,7 @@
package dev.sonpx.loyalty.mcp.reward.model;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.io.Serializable;
import java.time.Instant;
@@ -15,27 +16,35 @@ import lombok.Setter;
@Setter
public abstract class Fw implements Serializable {
@JsonIgnore
@JsonFormat(shape = JsonFormat.Shape.STRING)
protected Long recordNo;
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
protected String status;
@JsonIgnore
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
protected String lastUpdateBy;
@JsonIgnore
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
protected Instant lastUpdateDate;
@JsonIgnore
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
protected String lastApproveBy;
@JsonIgnore
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
protected Instant lastApproveDate;
@JsonIgnore
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
protected String lastUpdateByName;
@JsonIgnore
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
protected String lastApproveByName;
}

View File

@@ -4,6 +4,8 @@ import dev.sonpx.loyalty.mcp.model.OperationResult;
import dev.sonpx.loyalty.mcp.model.Result;
import dev.sonpx.loyalty.mcp.reward.client.CampaignRuleClient;
import dev.sonpx.loyalty.mcp.reward.criteria.CampaignRuleCriteria;
import dev.sonpx.loyalty.mcp.reward.dto.CampaignRuleSummaryDto;
import dev.sonpx.loyalty.mcp.reward.model.CampaignFormulaOne;
import dev.sonpx.loyalty.mcp.reward.model.CampaignRule;
import java.util.List;
import java.util.Map;
@@ -22,9 +24,12 @@ public class CampaignRuleService {
private final CampaignRuleClient campaignRuleClient;
public Result<List<CampaignRule>> getAll(CampaignRuleCriteria criteria) {
public Result<List<CampaignRuleSummaryDto>> getAll(CampaignRuleCriteria criteria) {
List<CampaignRule> rules = campaignRuleClient.getAll(criteria, Pageable.ofSize(10));
return Result.of(rules, """
List<CampaignRuleSummaryDto> summaries = rules != null
? rules.stream().map(this::toSummaryDto).toList()
: List.of();
return Result.of(summaries, """
Trả lời tự nhiên bằng tiếng Việt. Không dùng markdown table.
Hiển thị mỗi rule theo dạng danh sách đánh số:
**[ruleName]** (`[ruleId]`) — Chiến dịch: [campaignId.description hoặc campaignId.code], Loại: [ruleType - dùng tên tiếng Việt], Hiệu lực: [effectiveFrom] → [effectiveTo]
@@ -34,6 +39,21 @@ public class CampaignRuleService {
""");
}
public CampaignRuleSummaryDto toSummaryDto(CampaignRule rule) {
if (rule == null) {
return null;
}
return CampaignRuleSummaryDto.builder()
.ruleId(rule.getRuleId())
.ruleName(rule.getRuleName())
.ruleType(rule.getRuleType())
.campaignId(rule.getCampaignId())
.effectiveFrom(rule.getEffectiveFrom())
.effectiveTo(rule.getEffectiveTo())
.poolId(rule.getPoolId())
.build();
}
public Result<Long> count(CampaignRuleCriteria criteria) {
Long count = campaignRuleClient.count(criteria);
return Result.of(count, """
@@ -44,6 +64,7 @@ public class CampaignRuleService {
public Result<CampaignRule> findActiveById(String id) {
CampaignRule rule = campaignRuleClient.findActiveById(id);
sanitizeFormulas(rule);
return Result.of(rule, """
Trả lời tự nhiên bằng tiếng Việt. Trình bày chi tiết rule một cách rõ ràng:
- Thông tin cơ bản: Tên, Mã Rule, Loại rule (tên tiếng Việt), Chiến dịch liên kết
@@ -60,6 +81,9 @@ public class CampaignRuleService {
public Result<List<CampaignRule>> findActiveByIds(String ids) {
List<String> idList = java.util.Arrays.asList(ids.split(",\\s*"));
List<CampaignRule> rules = campaignRuleClient.findActiveByIds(idList);
if (rules != null) {
rules.forEach(this::sanitizeFormulas);
}
return Result.of(rules, """
Trả lời tự nhiên bằng tiếng Việt. Hiển thị danh sách rule theo dạng đánh số,
mỗi rule ghi: Tên, Mã Rule, Chiến dịch, Loại rule, Trạng thái.
@@ -67,6 +91,52 @@ public class CampaignRuleService {
""");
}
public void sanitizeFormulas(CampaignRule rule) {
if (rule == null) {
return;
}
if (rule.getCampaignFormulaOne() != null && isFormulaOneEmpty(rule.getCampaignFormulaOne())) {
rule.setCampaignFormulaOne(null);
}
if (rule.getCampaignFormulaTwo() != null && rule.getCampaignFormulaTwo().getFixedAmt() == null) {
rule.setCampaignFormulaTwo(null);
}
if (rule.getCampaignFormulaFour() != null && (rule.getCampaignFormulaFour().getTiers() == null || rule.getCampaignFormulaFour().getTiers().isEmpty())) {
rule.setCampaignFormulaFour(null);
}
if (rule.getCampaignFormulaSix() != null && (rule.getCampaignFormulaSix().getTiers() == null || rule.getCampaignFormulaSix().getTiers().isEmpty())) {
rule.setCampaignFormulaSix(null);
}
if (rule.getCampaignFormulaFives() != null && rule.getCampaignFormulaFives().isEmpty()) {
rule.setCampaignFormulaFives(null);
}
if (rule.getCampaignFormulaEights() != null && rule.getCampaignFormulaEights().isEmpty()) {
rule.setCampaignFormulaEights(null);
}
if (rule.getCampaignFormulaTens() != null && rule.getCampaignFormulaTens().isEmpty()) {
rule.setCampaignFormulaTens(null);
}
if (rule.getCampaignFormulaElevens() != null && rule.getCampaignFormulaElevens().isEmpty()) {
rule.setCampaignFormulaElevens(null);
}
if (rule.getCampaignAwardLimits() != null && rule.getCampaignAwardLimits().isEmpty()) {
rule.setCampaignAwardLimits(null);
}
if (rule.getCampaignTcLinkages() != null && rule.getCampaignTcLinkages().isEmpty()) {
rule.setCampaignTcLinkages(null);
}
if (rule.getCampaignContributors() != null && rule.getCampaignContributors().isEmpty()) {
rule.setCampaignContributors(null);
}
}
private boolean isFormulaOneEmpty(CampaignFormulaOne f) {
return f.getAwardPerTxnAmt() == null
&& f.getTxnAmt() == null
&& f.getAwardPerTxnAmtAfter() == null
&& f.getTxnAmtAfter() == null;
}
public Result<String> generateId() {
String id = campaignRuleClient.generateId();
return Result.of(id, """
@@ -93,3 +163,4 @@ public class CampaignRuleService {
""");
}
}

View File

@@ -4,6 +4,7 @@ import dev.sonpx.loyalty.mcp.model.OperationResult;
import dev.sonpx.loyalty.mcp.model.Result;
import dev.sonpx.loyalty.mcp.reward.client.CampaignClient;
import dev.sonpx.loyalty.mcp.reward.criteria.CampaignCriteria;
import dev.sonpx.loyalty.mcp.reward.dto.CampaignSummaryDto;
import dev.sonpx.loyalty.mcp.reward.model.Campaign;
import java.util.List;
import lombok.RequiredArgsConstructor;
@@ -21,9 +22,12 @@ public class CampaignService {
private final CampaignClient campaignClient;
public Result<List<Campaign>> getAll(CampaignCriteria criteria) {
public Result<List<CampaignSummaryDto>> getAll(CampaignCriteria criteria) {
List<Campaign> campaigns = campaignClient.getAll(criteria, Pageable.ofSize(5));
return Result.of(campaigns, """
List<CampaignSummaryDto> summaries = campaigns != null
? campaigns.stream().map(this::toSummaryDto).toList()
: List.of();
return Result.of(summaries, """
Trả lời tự nhiên bằng tiếng Việt. Không dùng markdown table.
Hiển thị mỗi chiến dịch theo dạng danh sách đánh số:
**[Tên]** (`[campaignId]`) — Owner: [ownerName hoặc "Chưa có"], Loại: [campaignType], Hiệu lực: [effectiveFrom] → [effectiveTo], Số rule: [numOfRule]
@@ -33,6 +37,22 @@ public class CampaignService {
""");
}
public CampaignSummaryDto toSummaryDto(Campaign campaign) {
if (campaign == null) {
return null;
}
return CampaignSummaryDto.builder()
.campaignId(campaign.getCampaignId())
.name(campaign.getName())
.ownerName(campaign.getOwnerName())
.campaignType(campaign.getCampaignType())
.effectiveFrom(campaign.getEffectiveFrom())
.effectiveTo(campaign.getEffectiveTo())
.numOfRule(campaign.getNumOfRule())
.description(campaign.getDescription())
.build();
}
public Result<Long> count(CampaignCriteria criteria) {
Long count = campaignClient.count(criteria);
return Result.of(count, """
@@ -91,3 +111,4 @@ public class CampaignService {
""");
}
}

View File

@@ -3,6 +3,7 @@ package dev.sonpx.loyalty.mcp.tool;
import dev.sonpx.loyalty.mcp.model.OperationResult;
import dev.sonpx.loyalty.mcp.model.Result;
import dev.sonpx.loyalty.mcp.reward.criteria.CampaignRuleCriteria;
import dev.sonpx.loyalty.mcp.reward.dto.CampaignRuleSummaryDto;
import dev.sonpx.loyalty.mcp.reward.model.CampaignRule;
import dev.sonpx.loyalty.mcp.service.CampaignRuleService;
import dev.sonpx.loyalty.mcp.util.SearchUtils;
@@ -26,12 +27,13 @@ public class CampaignRuleTools {
- QUAN TRỌNG: Khi người dùng hỏi lấy danh sách chung ("danh sách rule", "xem thể lệ"), KHÔNG truyền param `search` (để null hoặc rỗng ""). Tuyệt đối KHÔNG truyền các từ chung như "rule", "thể lệ", "danh sách" vào `search`.
- KHÔNG dùng tool này khi người dùng chỉ hỏi về thông tin cơ bản chiến dịch (tên, owner, loại) → hãy dùng tool searchCampaigns.
Hiển thị tối đa 10 bản ghi.""")
public Result<List<CampaignRule>> searchRules(String search) {
public Result<List<CampaignRuleSummaryDto>> searchRules(String search) {
CampaignRuleCriteria criteria = new CampaignRuleCriteria();
criteria.setSearch(SearchUtils.sanitizeSearch(search));
return campaignRuleService.getAll(criteria);
}
@McpTool(description = "Đếm số lượng RULE/THỂ LỆ, không phải chiến dịch. Để search = null nếu đếm tất cả.")
public Result<Long> countRules(String search) {
CampaignRuleCriteria criteria = new CampaignRuleCriteria();

View File

@@ -3,6 +3,7 @@ package dev.sonpx.loyalty.mcp.tool;
import dev.sonpx.loyalty.mcp.model.OperationResult;
import dev.sonpx.loyalty.mcp.model.Result;
import dev.sonpx.loyalty.mcp.reward.criteria.CampaignCriteria;
import dev.sonpx.loyalty.mcp.reward.dto.CampaignSummaryDto;
import dev.sonpx.loyalty.mcp.reward.model.Campaign;
import dev.sonpx.loyalty.mcp.service.CampaignService;
import dev.sonpx.loyalty.mcp.util.SearchUtils;
@@ -25,12 +26,13 @@ public class CampaignTools {
- Param `search` (String): từ khóa tìm kiếm theo tên hoặc mã chiến dịch cụ thể.
- QUAN TRỌNG: Khi người dùng hỏi lấy danh sách chung ("danh sách chiến dịch", "xem các chiến dịch"), KHÔNG truyền param `search` (để null hoặc rỗng ""). Tuyệt đối KHÔNG truyền các từ chung như "chiến dịch", "danh sách", "campaign" vào `search`.
- KHÔNG dùng tool này khi người dùng hỏi về: rule, thể lệ, quy tắc, điều kiện, công thức thưởng → hãy dùng tool searchRules.""")
public Result<List<Campaign>> searchCampaigns(String search) {
public Result<List<CampaignSummaryDto>> searchCampaigns(String search) {
CampaignCriteria criteria = new CampaignCriteria();
criteria.setSearch(SearchUtils.sanitizeSearch(search));
return campaignService.getAll(criteria);
}
@McpTool(description = "Đếm số lượng CHIẾN DỊCH (Campaign), không phải rule. Để search = null nếu đếm tất cả.")
public Result<Long> countCampaigns(String search) {
CampaignCriteria criteria = new CampaignCriteria();

View File

@@ -2,7 +2,7 @@ package dev.sonpx.loyalty.mcp.web.rest;
import dev.sonpx.loyalty.mcp.model.Result;
import dev.sonpx.loyalty.mcp.reward.criteria.CampaignCriteria;
import dev.sonpx.loyalty.mcp.reward.model.Campaign;
import dev.sonpx.loyalty.mcp.reward.dto.CampaignSummaryDto;
import dev.sonpx.loyalty.mcp.service.CampaignService;
import java.util.List;
import lombok.RequiredArgsConstructor;
@@ -22,8 +22,9 @@ public class RewardResource {
private final CampaignService campaignService;
@GetMapping
public ResponseEntity<Result<List<Campaign>>> getAll(CampaignCriteria criteria) {
public ResponseEntity<Result<List<CampaignSummaryDto>>> getAll(CampaignCriteria criteria) {
return ResponseEntity.ok(campaignService.getAll(criteria));
}
}

View File

@@ -13,6 +13,7 @@ spring:
server:
sse-endpoint: /sse
sse-message-endpoint: /message
keep-alive-interval: 30s
security:
oauth2:
client:
@@ -35,4 +36,6 @@ logging:
level:
root: warn
org.springframework.ai: INFO
org.springframework.context.support.PostProcessorRegistrationDelegate: ERROR
org.springframework.security.oauth2.client.registration.ClientRegistration: ERROR
dev.sonpx.loyalty: DEBUG

View File

@@ -0,0 +1,131 @@
package dev.sonpx.loyalty.mcp.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.sonpx.loyalty.mcp.reward.dto.CampaignRuleSummaryDto;
import dev.sonpx.loyalty.mcp.reward.dto.CampaignSummaryDto;
import dev.sonpx.loyalty.mcp.reward.enums.CampaignType;
import dev.sonpx.loyalty.mcp.reward.enums.RuleType;
import dev.sonpx.loyalty.mcp.reward.model.Campaign;
import dev.sonpx.loyalty.mcp.reward.model.CampaignRule;
import dev.sonpx.loyalty.mcp.reward.model.ReferenceData;
import dev.sonpx.loyalty.mcp.service.CampaignRuleService;
import dev.sonpx.loyalty.mcp.service.CampaignService;
import java.time.LocalDate;
import java.time.LocalDateTime;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
public class McpResponseSerializationTest {
private ObjectMapper objectMapper;
private CampaignService campaignService;
private CampaignRuleService campaignRuleService;
@BeforeEach
void setUp() {
AppConfig config = new AppConfig();
objectMapper = config.objectMapper();
campaignService = new CampaignService(null);
campaignRuleService = new CampaignRuleService(null);
}
@Test
void testFwAuditFieldsIgnoredAndNullsOmittedInCampaign() throws Exception {
Campaign campaign = new Campaign();
campaign.setCampaignId("CMP01");
campaign.setName("Happy Birthday");
campaign.setOwnerName("Marketing");
campaign.setCampaignType(CampaignType.BASE);
campaign.setRecordNo(100L);
campaign.setLastUpdateBy("admin");
campaign.setLastUpdateByName("Admin User");
campaign.setLastApproveBy("checker");
campaign.setLastApproveByName("Checker User");
String json = objectMapper.writeValueAsString(campaign);
// Audit fields must be ignored
assertThat(json).doesNotContain("recordNo");
assertThat(json).doesNotContain("lastUpdateBy");
assertThat(json).doesNotContain("lastUpdateByName");
assertThat(json).doesNotContain("lastApproveBy");
assertThat(json).doesNotContain("lastApproveByName");
assertThat(json).doesNotContain("lastUpdateDate");
assertThat(json).doesNotContain("lastApproveDate");
// Null fields (effectiveFrom, effectiveTo, etc.) must be omitted
assertThat(json).doesNotContain("effectiveFrom");
assertThat(json).doesNotContain("effectiveTo");
assertThat(json).doesNotContain("noOfCustomerTarget");
// Essential fields present
assertThat(json).contains("\"campaignId\":\"CMP01\"");
assertThat(json).contains("\"name\":\"Happy Birthday\"");
}
@Test
void testCampaignSummaryDtoMappingAndSerialization() throws Exception {
Campaign campaign = new Campaign();
campaign.setCampaignId("CMP02");
campaign.setName("Summer Sale");
campaign.setOwnerName("Sales");
campaign.setCampaignType(CampaignType.TACTICAL);
campaign.setEffectiveFrom(LocalDate.of(2026, 6, 1));
campaign.setEffectiveTo(LocalDate.of(2026, 8, 31));
campaign.setNumOfRule(3);
CampaignSummaryDto dto = campaignService.toSummaryDto(campaign);
String json = objectMapper.writeValueAsString(dto);
assertThat(json).contains("\"campaignId\":\"CMP02\"");
assertThat(json).contains("\"name\":\"Summer Sale\"");
assertThat(json).contains("\"ownerName\":\"Sales\"");
assertThat(json).contains("\"numOfRule\":3");
assertThat(json).doesNotContain("description"); // Null field omitted
assertThat(json).doesNotContain("targetAtv");
}
@Test
void testCampaignRuleSummaryDtoMappingAndSerialization() throws Exception {
CampaignRule rule = new CampaignRule();
rule.setRuleId("R01");
rule.setRuleName("Bonus Point Rule");
rule.setRuleType(RuleType.AWARD);
rule.setCampaignId(new ReferenceData("CMP01", "Happy Birthday Campaign"));
rule.setPoolId(new ReferenceData("POOL01", "Main Point Pool"));
rule.setEffectiveFrom(LocalDateTime.of(2026, 1, 1, 0, 0));
CampaignRuleSummaryDto dto = campaignRuleService.toSummaryDto(rule);
String json = objectMapper.writeValueAsString(dto);
assertThat(json).contains("\"ruleId\":\"R01\"");
assertThat(json).contains("\"ruleName\":\"Bonus Point Rule\"");
assertThat(json).contains("\"ruleType\":\"AWD\"");
assertThat(json).contains("\"code\":\"CMP01\"");
assertThat(json).doesNotContain("effectiveTo"); // Null field omitted
assertThat(json).doesNotContain("campaignFormulaOne");
assertThat(json).doesNotContain("campaignFormulaSetting");
}
@Test
void testSanitizeFormulasInCampaignRule() throws Exception {
CampaignRule rule = new CampaignRule();
rule.setRuleId("R02");
rule.setRuleName("Double Points");
// Formulas with all nulls
rule.setCampaignFormulaOne(new dev.sonpx.loyalty.mcp.reward.model.CampaignFormulaOne());
rule.setCampaignFormulaTwo(new dev.sonpx.loyalty.mcp.reward.model.CampaignFormulaTwo());
campaignRuleService.sanitizeFormulas(rule);
assertThat(rule.getCampaignFormulaOne()).isNull();
assertThat(rule.getCampaignFormulaTwo()).isNull();
String json = objectMapper.writeValueAsString(rule);
assertThat(json).doesNotContain("campaignFormulaOne");
assertThat(json).doesNotContain("campaignFormulaTwo");
}
}

View File

@@ -0,0 +1,19 @@
package dev.sonpx.loyalty.mcp.config;
import org.junit.jupiter.api.Test;
import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerSseProperties;
import java.time.Duration;
import static org.assertj.core.api.Assertions.assertThat;
class McpServerKeepAliveTest {
@Test
void testKeepAlivePropertiesSetting() {
McpServerSseProperties properties = new McpServerSseProperties();
properties.setKeepAliveInterval(Duration.ofSeconds(30));
assertThat(properties.getKeepAliveInterval()).isEqualTo(Duration.ofSeconds(30));
}
}

View File

@@ -1,244 +0,0 @@
# Memory & Tool Architecture Plan v3
*Cập nhật sau review: Approach C vẫn là hướng đúng, nhưng bản production nên là **Approach C+**. Trọng tâm không chỉ là dynamic tool loading, mà là policy catalog rõ ràng, router multi-label, DTO nhỏ, memory budget theo model, và state có TTL/idempotency.*
---
## 1. Kết luận ngắn
**Không nên load toàn bộ tools cho mọi request.** Khi domain Loyalty mở rộng từ prototype 14 tools lên 80+ tools, tool schema sẽ ăn phần lớn context window, nhất là các create tool dùng DTO sâu như `CampaignRule`.
**Hướng nên làm:** Dynamic Tool Loading + Domain Knowledge Registry, nhưng không filter bằng substring `contains`. Cần chuyển thành capability policy rõ ràng:
- Router phân loại `Action + Set<Domain> + confidence + workflow phase`
- Tool policy catalog khai báo domain, operation, risk, phase, aliases
- Per-request capability bundle chỉ expose tools cần thiết
- Create/update tools dùng DTO nhỏ theo từng bước, không expose full domain model
- Memory budget theo model context window, không dùng một con số 8K chung
- Workflow/draft state có TTL, cleanup, idempotency, optimistic locking nếu chạy production
---
## 2. Evidence hiện tại trong repo
| Evidence | Ý nghĩa |
|----------|---------|
| `loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/classifier/IntentClassifier.java` đang là rule-based, single-label | Dễ drop tool cần thiết khi user hỏi vừa campaign vừa rule, hoặc query phụ trong workflow |
| `loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/AgentToolScope.java` đang suy capability từ tool name | Mở rộng bằng `contains("campaign")`/`contains("rule")` sẽ dễ false positive/false negative |
| `loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/SimpleAgentExecutor.java` hiện gọi tools bằng `READ_ONLY` | Chưa có routing scope theo domain/action ở runtime |
| `loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/InMemoryChatMemory.java` dùng estimate token và remove từng message | Có thể cắt lệch một turn user/assistant/tool, không phải whole-turn eviction |
| `loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/config/AgentProperties.java` mặc định `maxMemoryTokens = 8000` | Không an toàn cho mọi agent vì chat model có context 8K, query/creator model 32K |
| `loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowExecutor.java` dùng `ConcurrentHashMap` active workflows | Có cleanup khi success/cancel, nhưng chưa có TTL nền và chưa bền khi restart |
| Không thấy `loyalty-mcp-server/.../draft/DraftSessionManager.java` | Ghi chú cũ trong `context.md`/plan về DraftSessionManager 1h TTL là stale, không được coi là fact |
| `loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/model/CampaignRule.java` có nhiều field và nested DTO | Full `createRule(CampaignRule dto)` làm tool schema rất lớn |
---
## 3. Problem statement đúng
### Prototype hiện tại
14 MCP tools vẫn fit context khá thoải mái. Vấn đề memory chưa phải bottleneck lớn nhất.
### Khi scale full domain
Nếu expose 80+ tools, đặc biệt có nhiều create/update tool với nested DTO:
```text
32K context
- System prompt: ~400 tokens
- 80 tool definitions: ~20K-30K tokens
- Conversation memory: ~4K-8K tokens
- Tool results/response: ~3K-5K tokens
=> Dễ overflow hoặc làm model 4B chọn sai tool
```
Vì vậy bài toán chính là **tool selection + schema size**, không chỉ conversation memory.
---
## 4. Target Architecture: Approach C+
```mermaid
flowchart TB
U["User message"] --> R["Intent router"]
R --> D["Action + domains + confidence + phase"]
D --> P["ToolPolicyCatalog"]
P --> B["Capability bundle"]
B --> L["LLM call with selected tools"]
D --> K["DomainKnowledgeRegistry"]
K --> L
L --> T["MCP tools"]
L --> M["Chat memory"]
W["Workflow state"] --> D
W --> B
```
### Core rule
Mỗi request chỉ được thấy tools đúng domain/action/phase. Unknown intent không được load `ALL`; chỉ load discovery/read-only tools có rủi ro thấp.
---
## 5. Implementation Plan
### Phase 1: Policy catalog + safer routing
1. Tạo `ToolPolicyCatalog`
- Mỗi tool khai báo: `name`, `domains`, `operation`, `risk`, `workflowPhases`, `aliases`, `requiresConfirmation`
- Default deny nếu tool chưa có policy
- Không suy domain bằng substring tool name
2. Nâng `IntentClassifier`
- Output không chỉ là một enum intent
- Dùng structure kiểu:
```java
record RoutingDecision(
Action action,
Set<Domain> domains,
double confidence,
WorkflowPhase phase,
boolean sideQuery
) {}
```
3. Update `SimpleAgentExecutor`
- Thay `AgentToolScope.READ_ONLY` cố định bằng capability bundle sinh từ `RoutingDecision`
- Nếu confidence thấp: load discovery/read-only tools, không load create/update/delete tools
4. Test cần có
- Contract test: mọi `@McpTool` phải có policy hoặc bị reject có chủ đích
- Routing test: campaign query, rule query, campaign+rule query, unknown query, create workflow side-query
- Safety test: unknown/create-like prompt không được expose create tool khi confidence thấp
### Phase 2: Shrink tool schemas
1. Không expose full model như `CampaignRule` cho create/update
- Tạo command DTO nhỏ: `CreateRuleDraftCommand`, `SetRuleFormulaCommand`, `SetRuleCriteriaCommand`, `SubmitRuleCommand`
- Chia theo workflow phase để mỗi step chỉ cần schema nhỏ
2. Formula nên chọn theo type
- Không load cả 8 formula schemas cùng lúc nếu user đang chọn một formula type
- Dùng discriminated union hoặc staged tool: chọn formula type trước, load schema cụ thể sau
3. Test cần có
- Đo token/schema size trước-sau cho selected tools
- Golden tests cho create rule flow
- Test validation: thiếu required field phải hỏi lại, không tự bịa value
### Phase 3: Memory budget theo model
1. Tách budget theo agent/model
- Chat 8K context không nên giữ `maxMemoryTokens = 8000`
- Query/creator 32K có thể giữ lớn hơn, nhưng phải trừ tool schema + result budget
2. Sửa eviction theo whole turn
- Không remove lẻ từng message nếu nó làm mất cặp user/assistant/tool result
- Giữ system message, prune theo turn hoặc summary
3. Test cần có
- Memory không vượt budget sau nhiều turn
- Tool-call/result message không bị cắt lệch khiến transcript invalid
### Phase 4: Workflow/draft persistence
1. Không dựa vào `ConcurrentHashMap` cho production
- Thêm TTL nền và cleanup định kỳ nếu vẫn single-node
- Nếu multi-node: dùng DB/Redis/shared store
2. State cần có
- `conversationId`, `workflowId`, `version`, `expiresAt`, `owner`, `idempotencyKey`
- Optimistic locking/CAS để tránh double submit hoặc update đè
3. Test cần có
- Expired workflow bị cleanup
- Duplicate submit dùng cùng idempotency key không tạo hai record
- Hai request song song không overwrite state sai
### Phase 5: DomainKnowledgeRegistry
1. Bắt đầu bằng static registry có version/source
- Mỗi snippet ghi rõ domain, source file/spec, owner, last reviewed date
- Inject theo domain/action, không inject toàn bộ
2. Khi nào mới cần RAG
- Tổng domain knowledge thực sự vượt khả năng maintain bằng static snippets
- Knowledge thay đổi thường xuyên
- Có nhu cầu search tài liệu dài/không cấu trúc
RAG không giải quyết tool bloat. RAG chỉ giải quyết domain knowledge retrieval.
---
## 6. Edge Cases cần cover
| Edge case | Risk | Guardrail |
|-----------|------|-----------|
| User hỏi nhiều domain trong một câu: "campaign X có rule nào?" | Single-label router chỉ load campaign hoặc rule | Router trả `Set<Domain>` |
| User đang create flow nhưng hỏi phụ: "có campaign nào active?" | Scope create thiếu read-only tools cần thiết | `sideQuery=true` cho phép bounded read-only fallback |
| Tool name trùng hoặc ambiguous: `campaignRule` chứa cả campaign và rule | Substring filter load sai tools | ToolPolicyCatalog explicit |
| Unknown intent nhưng prompt có từ "tạo" | Lỡ expose create tool | Unknown chỉ discovery/read-only; create cần confidence + workflow phase |
| Create tool schema quá lớn | Context overflow, model chọn sai field | Staged command DTOs, schema measurement gate |
| Memory 8K trên model context 8K | Không còn chỗ cho prompt/tools/response | Per-model budget formula |
| Prune lẻ message | Mất tool result hoặc assistant turn, transcript invalid | Whole-turn eviction |
| Restart service giữa workflow | Mất active workflow | Persist state trước production |
| User submit hai lần | Double create | Idempotency key + CAS/version |
| Tool result bị truncate giữa JSON | Model nhận JSON invalid | Truncate structured/result-aware, không substring raw JSON |
| DomainKnowledgeRegistry stale | Bot trả sai nghiệp vụ | Version/source/owner/review date + tests |
---
## 7. Notes for implementation
- Ưu tiên Spring AI built-in tool filtering/resolver nếu đang dùng được trong version hiện tại; chỉ tự build filter layer khi cần thêm policy/risk/phase.
- Không dùng `ALL` trong production path trừ admin/debug mode có guard riêng.
- Tool policy phải fail closed: tool mới thêm mà chưa khai báo policy thì test fail.
- Với destructive hoặc state-changing tools, cần confirmation/risk flag riêng, không chỉ dựa vào intent.
- Nên log `routingDecision`, selected tool names, schema token estimate, latency, tool-call success/failure để benchmark.
- Quyết định RAG/multi-agent phải dựa trên số liệu: selected tool count, schema token size, tool accuracy, latency, prompt overflow rate.
- Tất cả con số token hiện tại là estimate. Trước khi implement rộng, cần đo schema thực tế bằng tokenizer/model target.
---
## 8. Roadmap cập nhật
```mermaid
gantt
title Memory & Tool Architecture Roadmap v3
dateFormat YYYY-MM-DD
section Phase 1: Routing Safety
ToolPolicyCatalog + fail-closed tests :p1a, 2026-07-23, 2d
RoutingDecision multi-domain/action :p1b, after p1a, 2d
Runtime capability bundle :p1c, after p1b, 1d
section Phase 2: Schema Reduction
Create/update command DTOs :p2a, after p1c, 3d
Formula staged loading :p2b, after p2a, 2d
Schema token measurement gate :p2c, after p2b, 1d
section Phase 3: Memory
Per-model memory budgets :p3a, after p1c, 1d
Whole-turn eviction :p3b, after p3a, 2d
section Phase 4: Workflow Production
TTL cleanup :p4a, after p3b, 1d
Persistent workflow store :p4b, after p4a, 3d
Idempotency + optimistic locking :p4c, after p4b, 2d
section Phase 5: Knowledge
Versioned DomainKnowledgeRegistry :p5a, after p2c, 2d
Benchmark RAG/multi-agent necessity :p5b, after p5a, 2d
```
---
## 9. Final verdict
Approach C là đúng cho prototype và giai đoạn scale gần, nhưng chưa đủ để gọi là production best practice nếu chỉ implement bằng `contains()` và single-intent routing.
Best practice nên chốt là **Approach C+**:
1. Dynamic tool loading theo policy explicit
2. Router multi-label theo action/domain/phase
3. Small command DTOs thay vì full domain DTO
4. Memory budget theo model + whole-turn eviction
5. Workflow state có TTL/persistence/idempotency
6. Domain knowledge có version/source, chưa cần RAG cho tới khi có số liệu chứng minh

View File

@@ -13,14 +13,18 @@
"@radix-ui/react-avatar": "^1.2.2",
"@radix-ui/react-scroll-area": "^1.2.14",
"@radix-ui/react-slot": "^1.3.0",
"@radix-ui/react-tooltip": "^1.2.13",
"@stomp/stompjs": "^7.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.42.2",
"lucide-react": "^1.24.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.12.2",
"remark-gfm": "^4.0.1",
"shiki": "^4.3.1",
"tailwind-merge": "^3.6.0",
"zustand": "^5.0.14"
},

506
pnpm-lock.yaml generated
View File

@@ -17,6 +17,9 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.3.0
version: 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-tooltip':
specifier: ^1.2.13
version: 1.2.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@stomp/stompjs':
specifier: ^7.3.0
version: 7.3.0
@@ -26,6 +29,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
framer-motion:
specifier: ^12.42.2
version: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
lucide-react:
specifier: ^1.24.0
version: 1.24.0(react@19.2.7)
@@ -38,9 +44,15 @@ importers:
react-markdown:
specifier: ^10.1.0
version: 10.1.0(@types/react@19.2.17)(react@19.2.7)
react-resizable-panels:
specifier: ^4.12.2
version: 4.12.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
remark-gfm:
specifier: ^4.0.1
version: 4.0.1
shiki:
specifier: ^4.3.1
version: 4.3.1
tailwind-merge:
specifier: ^3.6.0
version: 3.6.0
@@ -100,6 +112,21 @@ packages:
'@emnapi/wasi-threads@1.2.2':
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
'@floating-ui/core@1.8.0':
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
'@floating-ui/dom@1.8.0':
resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
'@floating-ui/react-dom@2.1.9':
resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@jridgewell/gen-mapping@0.3.13':
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
@@ -262,6 +289,22 @@ packages:
'@radix-ui/primitive@1.1.5':
resolution: {integrity: sha512-d86WIWFYNtGA0H/d8exstrTRTp7eWJYlYJbtNofxr/3ljupZYn6EFDG/Qgu/0Kc8v7yMUxySagqJsL1+PdYjWg==}
'@radix-ui/primitive@1.1.6':
resolution: {integrity: sha512-w9hl+724uYEgCGR3bhuRepjBtrNB/6gkhCnAf58Ke+SLbHPPQqVZZB59z60roB+5H+nh3nWTcdJhQdFMEydWmw==}
'@radix-ui/react-arrow@1.1.12':
resolution: {integrity: sha512-ltXCE0glRomMZ9+u10d9o1Go+edqa1aLxufH59JRNNM3Yz1uvaeNWSaS1HeVh1X64agtdBG5JA1W1I6ySqWiwA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-avatar@1.2.2':
resolution: {integrity: sha512-sST0qh8GzOB7besQ3tMLWLyngnRuSk0gc/Hm+667KYKQFCt6Y6ZXv25WlqM7dIDK54ULCh5+CHmk4LIolzfz+A==}
peerDependencies:
@@ -302,6 +345,54 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-dismissable-layer@1.1.16':
resolution: {integrity: sha512-t45h68IjFx0ccBnPJqk0X6ecv69LkCFWd6DNCFQX56mUnVEXZbNOLCH/u9fHlAjFZ1RrFdl8/m4zev7B7NyhXQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-id@1.1.2':
resolution: {integrity: sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-popper@1.3.4':
resolution: {integrity: sha512-PXnCa3XgTQk0FegMctxgqJXtFLZe4IFJdbUkB7jKSCKEpb6utEO4S9Vog/pkyCfEPdzM331gvE4xpztmBAfMng==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.14':
resolution: {integrity: sha512-REwjAGPMa3J9oyDE4cuWkZbwnCbbyky66NurquQklXMSDn67cl6oGFx2gO7KZhPtFNbNw9xTWNrti3VIhgluYw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.7':
resolution: {integrity: sha512-zBZ4QM5XG3JRanDmqXYf3MD6th4AFXFmgU6KNMFzUaV6F3uw9I5/zjMUvFriSEn5ewo1nxuibvyxJdmLlDcslA==}
peerDependencies:
@@ -315,6 +406,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.8':
resolution: {integrity: sha512-0hhyrQdXMaATgq4ammLG9+iPqsXxzZkgTSIxdrJHdfLnXO4Uo5L7BoO3/Xf0AEaettadGZWGGJMw6ujzQvIpGA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.7':
resolution: {integrity: sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==}
peerDependencies:
@@ -350,6 +454,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-tooltip@1.2.13':
resolution: {integrity: sha512-56XPNYGMnGBcPyiBTaEXB7IGPybbsdNkFgSv90SCrHkXnu2Av1HhsyZMegzXlTu/QHA3V6/l22GZCv9iEoiqmQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-use-callback-ref@1.1.2':
resolution: {integrity: sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==}
peerDependencies:
@@ -359,6 +476,24 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-controllable-state@1.2.4':
resolution: {integrity: sha512-cx2DixxmSfjCcEoRvDvy1NLd6SWK94XFcEEOZUcharUlXbmahFQGKCfwdKZL2ub34iIwOPOEFVF80xb+yfLYiA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-effect-event@0.0.3':
resolution: {integrity: sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-is-hydrated@0.1.1':
resolution: {integrity: sha512-qwOiz4Tjo8CNnrOLAYUMXeZwDzXgXpvK4TKQPmWLECM9XoWvA6+0Z2/7Ag3A4ivjS4ovbLJPbskkxioFyBhr8A==}
peerDependencies:
@@ -377,6 +512,40 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.2':
resolution: {integrity: sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-size@1.1.2':
resolution: {integrity: sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-visually-hidden@1.2.8':
resolution: {integrity: sha512-FjsQEpkNBJJYiPSat6jh2LGKLPX2jAoDVS3AZSBNX3cOUoEGhw/f+z2FCY8Cf1NkoYIbytJ1f4mlWPQpR+MjVg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.2':
resolution: {integrity: sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==}
'@rolldown/binding-android-arm64@1.1.5':
resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -475,6 +644,37 @@ packages:
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@shikijs/core@4.3.1':
resolution: {integrity: sha512-ANMDxuaPsNMdDC1m4vfvhlDmJweMwkE5XitTwrq2rWHx5jM+dlm4MmHt2PP6t0uejfR77SuhrhJ0zEijIF/uhA==}
engines: {node: '>=20'}
'@shikijs/engine-javascript@4.3.1':
resolution: {integrity: sha512-JBItcnPuYq7jVJdZo/vMj94r+szT7XEjHFX+mvFDGSEIbVAXAGyHAHzhbWzpGOwYidCZrErJLLgn2PVeiokHnQ==}
engines: {node: '>=20'}
'@shikijs/engine-oniguruma@4.3.1':
resolution: {integrity: sha512-OXyNMzg0pews+msMj4cHeqT4xiYKKvbnn6VbdAXxfoFl3SSx4fJTc8FadECuc5/H9p3BzhNAoAUXKwAu9rWYhg==}
engines: {node: '>=20'}
'@shikijs/langs@4.3.1':
resolution: {integrity: sha512-m0l9nsDqgBHvbZbk7A0/kXz/impK3uB/c6rAn6Gpg/uPtdZRQ+alsN/17MU5thb68XTj/4DxkZAotrM0GGSpDQ==}
engines: {node: '>=20'}
'@shikijs/primitive@4.3.1':
resolution: {integrity: sha512-CXQRQOYy1leqQ8ceTeJdmXv/bsUY++6QyLpXJ94LZAAYj5X2SKRdc5ipguv4NPyGVKItB2PPwUpRNe0Sjh5S1A==}
engines: {node: '>=20'}
'@shikijs/themes@4.3.1':
resolution: {integrity: sha512-dgpoJ4WqNi2yTmizQHBJ5zcX6j2lE6icN/0yt4l1kkf16jrY/pwPLoTb1ETsWMz0OBLf9ZNvwmxft+cH+N9qSA==}
engines: {node: '>=20'}
'@shikijs/types@4.3.1':
resolution: {integrity: sha512-CHFxE0jztBIZRHH6gxXE7DXUCFXjReEGxZ/j0rfSLGKZuwp2xBYycEP14875DSa9KLL/6700oxIq6oO6ef9K2g==}
engines: {node: '>=20'}
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
'@stomp/stompjs@7.3.0':
resolution: {integrity: sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==}
@@ -696,6 +896,20 @@ packages:
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
framer-motion@12.42.2:
resolution: {integrity: sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -716,6 +930,9 @@ packages:
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
engines: {node: '>= 0.4'}
hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
hast-util-to-jsx-runtime@2.3.6:
resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==}
@@ -725,6 +942,9 @@ packages:
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
@@ -997,6 +1217,12 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
motion-dom@12.42.2:
resolution: {integrity: sha512-5gIMWLp/PycBtJRJWRgjxke5n8dlvkSn2DrYW+tr3XcqAZY1xZh6BJyooJXCM8wdfM7wfMjkBJNLge1CKPUIRA==}
motion-utils@12.39.0:
resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1024,6 +1250,12 @@ packages:
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
engines: {node: '>= 6'}
oniguruma-parser@0.12.2:
resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==}
oniguruma-to-es@4.3.6:
resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==}
oxlint@1.74.0:
resolution: {integrity: sha512-odGl2s2x5IOJoj3A0v1k0PGBXVFBZeZ2+AK/+K2MJur7Ghi3bkyX5NuLUWHKqa4js1wjep3hJeuTQJOlr+4+dA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1130,6 +1362,12 @@ packages:
'@types/react': '>=18'
react: '>=18'
react-resizable-panels@4.12.2:
resolution: {integrity: sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
react@19.2.7:
resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
engines: {node: '>=0.10.0'}
@@ -1141,6 +1379,15 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
regex-recursion@6.0.2:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
regex-utilities@2.3.0:
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
regex@6.1.0:
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
remark-gfm@4.0.1:
resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==}
@@ -1173,6 +1420,10 @@ packages:
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
shiki@4.3.1:
resolution: {integrity: sha512-oR+qDVi2OjX1tmDpyv+3KviX01KzO6Af+0NNnKnsp9491UEGz2YpxTuJboS/6VhYpTdqzmuJBuiTlrAWWJAssw==}
engines: {node: '>=20'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -1363,6 +1614,23 @@ snapshots:
tslib: 2.8.1
optional: true
'@floating-ui/core@1.8.0':
dependencies:
'@floating-ui/utils': 0.2.12
'@floating-ui/dom@1.8.0':
dependencies:
'@floating-ui/core': 1.8.0
'@floating-ui/utils': 0.2.12
'@floating-ui/react-dom@2.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@floating-ui/dom': 1.8.0
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
'@floating-ui/utils@0.2.12': {}
'@jridgewell/gen-mapping@0.3.13':
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -1459,6 +1727,17 @@ snapshots:
'@radix-ui/primitive@1.1.5': {}
'@radix-ui/primitive@1.1.6': {}
'@radix-ui/react-arrow@1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-avatar@1.2.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
@@ -1490,6 +1769,54 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-dismissable-layer@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.6
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-id@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-popper@1.3.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@floating-ui/react-dom': 2.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-arrow': 1.1.12(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-callback-ref': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-rect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-size': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/rect': 1.1.2
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-portal@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-presence@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
@@ -1499,6 +1826,15 @@ snapshots:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-presence@1.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-primitive@2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
@@ -1532,12 +1868,49 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-tooltip@1.2.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.6
'@radix-ui/react-compose-refs': 1.1.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-context': 1.2.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-dismissable-layer': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-id': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-popper': 1.3.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-portal': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-presence': 1.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@radix-ui/react-slot': 1.3.0(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-controllable-state': 1.2.4(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-visually-hidden': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/react-use-callback-ref@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies:
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-use-controllable-state@1.2.4(@types/react@19.2.17)(react@19.2.7)':
dependencies:
'@radix-ui/primitive': 1.1.6
'@radix-ui/react-use-effect-event': 0.0.3(@types/react@19.2.17)(react@19.2.7)
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-use-effect-event@0.0.3(@types/react@19.2.17)(react@19.2.7)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-use-is-hydrated@0.1.1(@types/react@19.2.17)(react@19.2.7)':
dependencies:
react: 19.2.7
@@ -1550,6 +1923,31 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-use-rect@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies:
'@radix-ui/rect': 1.1.2
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-use-size@1.1.2(@types/react@19.2.17)(react@19.2.7)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.2(@types/react@19.2.17)(react@19.2.7)
react: 19.2.7
optionalDependencies:
'@types/react': 19.2.17
'@radix-ui/react-visually-hidden@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@radix-ui/react-primitive': 2.1.7(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
optionalDependencies:
'@types/react': 19.2.17
'@types/react-dom': 19.2.3(@types/react@19.2.17)
'@radix-ui/rect@1.1.2': {}
'@rolldown/binding-android-arm64@1.1.5':
optional: true
@@ -1601,6 +1999,46 @@ snapshots:
'@rolldown/pluginutils@1.0.1': {}
'@shikijs/core@4.3.1':
dependencies:
'@shikijs/primitive': 4.3.1
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@4.3.1':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.6
'@shikijs/engine-oniguruma@4.3.1':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@shikijs/langs@4.3.1':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/primitive@4.3.1':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
'@shikijs/themes@4.3.1':
dependencies:
'@shikijs/types': 4.3.1
'@shikijs/types@4.3.1':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
'@shikijs/vscode-textmate@10.0.2': {}
'@stomp/stompjs@7.3.0': {}
'@tailwindcss/typography@0.5.20(tailwindcss@3.4.19)':
@@ -1786,6 +2224,15 @@ snapshots:
fraction.js@5.3.4: {}
framer-motion@12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
motion-dom: 12.42.2
motion-utils: 12.39.0
tslib: 2.8.1
optionalDependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
fsevents@2.3.3:
optional: true
@@ -1803,6 +2250,20 @@ snapshots:
dependencies:
function-bind: 1.1.2
hast-util-to-html@9.0.5:
dependencies:
'@types/hast': 3.0.5
'@types/unist': 3.0.3
ccount: 2.0.1
comma-separated-tokens: 2.0.3
hast-util-whitespace: 3.0.0
html-void-elements: 3.0.0
mdast-util-to-hast: 13.2.1
property-information: 7.2.0
space-separated-tokens: 2.0.2
stringify-entities: 4.0.4
zwitch: 2.0.4
hast-util-to-jsx-runtime@2.3.6:
dependencies:
'@types/estree': 1.0.9
@@ -1829,6 +2290,8 @@ snapshots:
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
inline-style-parser@0.2.7: {}
is-alphabetical@2.0.1: {}
@@ -2274,6 +2737,12 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.2
motion-dom@12.42.2:
dependencies:
motion-utils: 12.39.0
motion-utils@12.39.0: {}
ms@2.1.3: {}
mz@2.7.0:
@@ -2292,6 +2761,14 @@ snapshots:
object-hash@3.0.0: {}
oniguruma-parser@0.12.2: {}
oniguruma-to-es@4.3.6:
dependencies:
oniguruma-parser: 0.12.2
regex: 6.1.0
regex-recursion: 6.0.2
oxlint@1.74.0:
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.74.0
@@ -2405,6 +2882,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
react-resizable-panels@4.12.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
dependencies:
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
react@19.2.7: {}
read-cache@1.0.0:
@@ -2415,6 +2897,16 @@ snapshots:
dependencies:
picomatch: 2.3.2
regex-recursion@6.0.2:
dependencies:
regex-utilities: 2.3.0
regex-utilities@2.3.0: {}
regex@6.1.0:
dependencies:
regex-utilities: 2.3.0
remark-gfm@4.0.1:
dependencies:
'@types/mdast': 4.0.4
@@ -2485,6 +2977,17 @@ snapshots:
scheduler@0.27.0: {}
shiki@4.3.1:
dependencies:
'@shikijs/core': 4.3.1
'@shikijs/engine-javascript': 4.3.1
'@shikijs/engine-oniguruma': 4.3.1
'@shikijs/langs': 4.3.1
'@shikijs/themes': 4.3.1
'@shikijs/types': 4.3.1
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.5
source-map-js@1.2.1: {}
space-separated-tokens@2.0.2: {}
@@ -2571,8 +3074,7 @@ snapshots:
ts-interface-checker@0.1.13: {}
tslib@2.8.1:
optional: true
tslib@2.8.1: {}
typescript@6.0.3: {}

View File

@@ -0,0 +1,906 @@
[
{
"attrApplyFirstFlag": false,
"campaignAwardLimits": null,
"campaignContributors": null,
"campaignCriteria": null,
"campaignFormulaEights": null,
"campaignFormulaElevens": null,
"campaignFormulaFives": null,
"campaignFormulaFour": null,
"campaignFormulaOne": null,
"campaignFormulaSetting": {
"amountToUse": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"applyCapValueAfter": null,
"capAwardCounterId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"capAwardType": null,
"capAwardValue": null,
"lastApproveBy": null,
"lastApproveByName": null,
"lastApproveDate": null,
"lastUpdateBy": null,
"lastUpdateByName": null,
"lastUpdateDate": null,
"nParam": null,
"recordNo": null,
"roundingType": null,
"ruleId": null,
"ruleRecordNo": null,
"status": null
},
"campaignFormulaSix": null,
"campaignFormulaTens": null,
"campaignFormulaTwo": null,
"campaignId": {
"attributes": {},
"code": "LACO",
"description": "Happy Birthday!",
"notFound": false,
"recordStatus": null
},
"campaignRuleSchedule": null,
"campaignTcLinkages": null,
"channel": null,
"counterExtractRequest": null,
"dayOfMonth": null,
"description": "This campaign targets students aged 18 to 22. Members earn 10 reward points\nfor every eligible transaction. In addition, customers receive 10% bonus points for each eligible\ntransaction with a minimum spend of USD 1,000. All reward points and bonus points earned from this\ncampaign will be credited to the POLS pool. The campaign aims to encourage spending, increase\ncustomer engagement, and reward loyal student members.",
"effectiveFrom": "2026-07-13T00:00:00",
"effectivePeriodIsBase": "TD",
"effectiveTo": "2026-07-13T21:56:36",
"expiryPolicy": null,
"expiryPolicyParam": null,
"fixedDate": null,
"formulaSequence": ["f2", "f4"],
"itemCode": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"lastApproveBy": null,
"lastApproveByName": null,
"lastApproveDate": null,
"lastUpdateBy": "olscnadmin",
"lastUpdateByName": "olscnadmin",
"lastUpdateDate": "2026-07-13T14:57:24.630881Z",
"marketingRewardRequest": null,
"messageTemplateId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"monthOfYear": null,
"notUpdatePool": false,
"poolId": {
"attributes": { "precision": "2", "poolType": "BPT" },
"code": "POLS",
"description": "Default Redemption Pools",
"notFound": false,
"recordStatus": null
},
"recordNo": "864520761618609777",
"redemptionExtract": null,
"rewardContentEmail": null,
"rewardContentNotification": null,
"rewardContentSms": null,
"ruleId": "F7H3",
"ruleName": "happy birthday",
"ruleType": "AWD",
"segApplyFirstFlag": false,
"status": "C",
"stopIfCriteriaMet": false,
"subPoolId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
}
},
{
"attrApplyFirstFlag": false,
"campaignAwardLimits": null,
"campaignContributors": null,
"campaignCriteria": null,
"campaignFormulaEights": null,
"campaignFormulaElevens": null,
"campaignFormulaFives": null,
"campaignFormulaFour": null,
"campaignFormulaOne": null,
"campaignFormulaSetting": {
"amountToUse": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"applyCapValueAfter": null,
"capAwardCounterId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"capAwardType": null,
"capAwardValue": null,
"lastApproveBy": null,
"lastApproveByName": null,
"lastApproveDate": null,
"lastUpdateBy": null,
"lastUpdateByName": null,
"lastUpdateDate": null,
"nParam": null,
"recordNo": null,
"roundingType": null,
"ruleId": null,
"ruleRecordNo": null,
"status": null
},
"campaignFormulaSix": null,
"campaignFormulaTens": null,
"campaignFormulaTwo": null,
"campaignId": {
"attributes": {},
"code": "LACO",
"description": "Happy Birthday!",
"notFound": false,
"recordStatus": null
},
"campaignRuleSchedule": null,
"campaignTcLinkages": null,
"channel": null,
"counterExtractRequest": null,
"dayOfMonth": null,
"description": null,
"effectiveFrom": "2026-06-29T15:55:13",
"effectivePeriodIsBase": null,
"effectiveTo": "2029-06-29T15:55:14",
"expiryPolicy": null,
"expiryPolicyParam": null,
"fixedDate": null,
"formulaSequence": ["f2"],
"itemCode": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"lastApproveBy": null,
"lastApproveByName": null,
"lastApproveDate": null,
"lastUpdateBy": "olscnadmin",
"lastUpdateByName": "olscnadmin",
"lastUpdateDate": "2026-07-13T08:38:20.406699Z",
"marketingRewardRequest": null,
"messageTemplateId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"monthOfYear": null,
"notUpdatePool": false,
"poolId": {
"attributes": { "precision": "2", "poolType": "BPT" },
"code": "POLS",
"description": "Default Redemption Pools",
"notFound": false,
"recordStatus": null
},
"recordNo": "864425504017101772",
"redemptionExtract": null,
"rewardContentEmail": null,
"rewardContentNotification": null,
"rewardContentSms": null,
"ruleId": "FFN6",
"ruleName": "Happy Birthday",
"ruleType": "MAWD",
"segApplyFirstFlag": false,
"status": "E",
"stopIfCriteriaMet": false,
"subPoolId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
}
},
{
"attrApplyFirstFlag": false,
"campaignAwardLimits": null,
"campaignContributors": null,
"campaignCriteria": null,
"campaignFormulaEights": null,
"campaignFormulaElevens": null,
"campaignFormulaFives": null,
"campaignFormulaFour": null,
"campaignFormulaOne": null,
"campaignFormulaSetting": {
"amountToUse": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"applyCapValueAfter": null,
"capAwardCounterId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"capAwardType": null,
"capAwardValue": null,
"lastApproveBy": null,
"lastApproveByName": null,
"lastApproveDate": null,
"lastUpdateBy": null,
"lastUpdateByName": null,
"lastUpdateDate": null,
"nParam": null,
"recordNo": null,
"roundingType": null,
"ruleId": null,
"ruleRecordNo": null,
"status": null
},
"campaignFormulaSix": null,
"campaignFormulaTens": null,
"campaignFormulaTwo": null,
"campaignId": {
"attributes": {},
"code": "LACO",
"description": "Happy Birthday!",
"notFound": false,
"recordStatus": null
},
"campaignRuleSchedule": null,
"campaignTcLinkages": null,
"channel": null,
"counterExtractRequest": null,
"dayOfMonth": null,
"description": null,
"effectiveFrom": "2026-08-08T00:00:00",
"effectivePeriodIsBase": "TD",
"effectiveTo": "2029-12-31T00:00:00",
"expiryPolicy": null,
"expiryPolicyParam": null,
"fixedDate": null,
"formulaSequence": ["f2"],
"itemCode": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"lastApproveBy": null,
"lastApproveByName": null,
"lastApproveDate": null,
"lastUpdateBy": "olscnadmin",
"lastUpdateByName": "olscnadmin",
"lastUpdateDate": "2026-07-12T10:41:20.913223Z",
"marketingRewardRequest": null,
"messageTemplateId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"monthOfYear": null,
"notUpdatePool": false,
"poolId": {
"attributes": { "precision": "2", "poolType": "BPT" },
"code": "POLS",
"description": "Default Redemption Pools",
"notFound": false,
"recordStatus": null
},
"recordNo": "864094072241533843",
"redemptionExtract": null,
"rewardContentEmail": null,
"rewardContentNotification": null,
"rewardContentSms": null,
"ruleId": "7M5B",
"ruleName": "Happy Birthday VIP Bonus",
"ruleType": "AWD",
"segApplyFirstFlag": false,
"status": "C",
"stopIfCriteriaMet": false,
"subPoolId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
}
},
{
"attrApplyFirstFlag": false,
"campaignAwardLimits": null,
"campaignContributors": null,
"campaignCriteria": null,
"campaignFormulaEights": null,
"campaignFormulaElevens": null,
"campaignFormulaFives": null,
"campaignFormulaFour": null,
"campaignFormulaOne": null,
"campaignFormulaSetting": {
"amountToUse": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"applyCapValueAfter": null,
"capAwardCounterId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"capAwardType": null,
"capAwardValue": null,
"lastApproveBy": null,
"lastApproveByName": null,
"lastApproveDate": null,
"lastUpdateBy": null,
"lastUpdateByName": null,
"lastUpdateDate": null,
"nParam": null,
"recordNo": null,
"roundingType": null,
"ruleId": null,
"ruleRecordNo": null,
"status": null
},
"campaignFormulaSix": null,
"campaignFormulaTens": null,
"campaignFormulaTwo": null,
"campaignId": {
"attributes": {},
"code": "LACO",
"description": "Happy Birthday!",
"notFound": false,
"recordStatus": null
},
"campaignRuleSchedule": null,
"campaignTcLinkages": null,
"channel": null,
"counterExtractRequest": null,
"dayOfMonth": null,
"description": "create a campaign rule with Rule Name VVIP Birthday Reward Campaign, Campaign ID: Happy Birthday!, Post Transaction Code is POS PURCHASE, Log Transaction store is One Loyalty System, Rule Type is Marketing Award. Run Schedule is Schedule, One Time, 2026-06-01, Time: 00:00. Start Date is 2026-06-01, End Date is 2026-06-30.",
"effectiveFrom": "2026-06-01T00:00:00",
"effectivePeriodIsBase": null,
"effectiveTo": "2026-06-30T00:00:00",
"expiryPolicy": null,
"expiryPolicyParam": null,
"fixedDate": null,
"formulaSequence": ["f2"],
"itemCode": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"lastApproveBy": null,
"lastApproveByName": null,
"lastApproveDate": null,
"lastUpdateBy": "olscnadmin",
"lastUpdateByName": "olscnadmin",
"lastUpdateDate": "2026-07-12T08:57:13.215065Z",
"marketingRewardRequest": null,
"messageTemplateId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"monthOfYear": null,
"notUpdatePool": false,
"poolId": {
"attributes": { "precision": "2", "poolType": "BPT" },
"code": "POLS",
"description": "Default Redemption Pools",
"notFound": false,
"recordStatus": null
},
"recordNo": "864067867496823883",
"redemptionExtract": null,
"rewardContentEmail": null,
"rewardContentNotification": null,
"rewardContentSms": null,
"ruleId": "JCK6",
"ruleName": "VVIP Birthday Reward Campaign",
"ruleType": "MAWD",
"segApplyFirstFlag": false,
"status": "C",
"stopIfCriteriaMet": false,
"subPoolId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
}
},
{
"attrApplyFirstFlag": false,
"campaignAwardLimits": null,
"campaignContributors": null,
"campaignCriteria": null,
"campaignFormulaEights": null,
"campaignFormulaElevens": null,
"campaignFormulaFives": null,
"campaignFormulaFour": null,
"campaignFormulaOne": null,
"campaignFormulaSetting": {
"amountToUse": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"applyCapValueAfter": null,
"capAwardCounterId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"capAwardType": null,
"capAwardValue": null,
"lastApproveBy": null,
"lastApproveByName": null,
"lastApproveDate": null,
"lastUpdateBy": null,
"lastUpdateByName": null,
"lastUpdateDate": null,
"nParam": null,
"recordNo": null,
"roundingType": null,
"ruleId": null,
"ruleRecordNo": null,
"status": null
},
"campaignFormulaSix": null,
"campaignFormulaTens": null,
"campaignFormulaTwo": null,
"campaignId": {
"attributes": {},
"code": "LACO",
"description": "Happy Birthday!",
"notFound": false,
"recordStatus": null
},
"campaignRuleSchedule": null,
"campaignTcLinkages": null,
"channel": null,
"counterExtractRequest": null,
"dayOfMonth": null,
"description": "Happy Birthday! [LACO]",
"effectiveFrom": "2026-06-01T00:00:00",
"effectivePeriodIsBase": null,
"effectiveTo": "2026-06-30T00:00:00",
"expiryPolicy": null,
"expiryPolicyParam": null,
"fixedDate": null,
"formulaSequence": ["f2"],
"itemCode": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"lastApproveBy": null,
"lastApproveByName": null,
"lastApproveDate": null,
"lastUpdateBy": "olscnadmin",
"lastUpdateByName": "olscnadmin",
"lastUpdateDate": "2026-07-12T08:51:01.210945Z",
"marketingRewardRequest": null,
"messageTemplateId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"monthOfYear": null,
"notUpdatePool": false,
"poolId": {
"attributes": { "precision": "2", "poolType": "BPT" },
"code": "POLS",
"description": "Default Redemption Pools",
"notFound": false,
"recordStatus": null
},
"recordNo": "864066307198957523",
"redemptionExtract": null,
"rewardContentEmail": null,
"rewardContentNotification": null,
"rewardContentSms": null,
"ruleId": "TGR1",
"ruleName": "Happy Birthday VIP Bonus",
"ruleType": "MAWD",
"segApplyFirstFlag": false,
"status": "C",
"stopIfCriteriaMet": false,
"subPoolId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
}
},
{
"attrApplyFirstFlag": false,
"campaignAwardLimits": null,
"campaignContributors": null,
"campaignCriteria": null,
"campaignFormulaEights": null,
"campaignFormulaElevens": null,
"campaignFormulaFives": null,
"campaignFormulaFour": null,
"campaignFormulaOne": null,
"campaignFormulaSetting": {
"amountToUse": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"applyCapValueAfter": null,
"capAwardCounterId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"capAwardType": null,
"capAwardValue": null,
"lastApproveBy": null,
"lastApproveByName": null,
"lastApproveDate": null,
"lastUpdateBy": null,
"lastUpdateByName": null,
"lastUpdateDate": null,
"nParam": null,
"recordNo": null,
"roundingType": null,
"ruleId": null,
"ruleRecordNo": null,
"status": null
},
"campaignFormulaSix": null,
"campaignFormulaTens": null,
"campaignFormulaTwo": null,
"campaignId": {
"attributes": {},
"code": "LACO",
"description": "Happy Birthday!",
"notFound": false,
"recordStatus": null
},
"campaignRuleSchedule": null,
"campaignTcLinkages": null,
"channel": null,
"counterExtractRequest": null,
"dayOfMonth": null,
"description": null,
"effectiveFrom": "2026-06-29T15:55:13",
"effectivePeriodIsBase": null,
"effectiveTo": "2029-06-29T15:55:14",
"expiryPolicy": null,
"expiryPolicyParam": null,
"fixedDate": null,
"formulaSequence": ["f2"],
"itemCode": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"lastApproveBy": "olscnadmin",
"lastApproveByName": "olscnadmin",
"lastApproveDate": "2026-07-11T09:32:16.994044Z",
"lastUpdateBy": "olscnadmin",
"lastUpdateByName": "olscnadmin",
"lastUpdateDate": "2026-07-11T09:31:55.902868Z",
"marketingRewardRequest": null,
"messageTemplateId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"monthOfYear": null,
"notUpdatePool": false,
"poolId": {
"attributes": { "precision": "2", "poolType": "BPT" },
"code": "POLS",
"description": "Default Redemption Pools",
"notFound": false,
"recordStatus": null
},
"recordNo": "863714215057833839",
"redemptionExtract": null,
"rewardContentEmail": null,
"rewardContentNotification": null,
"rewardContentSms": null,
"ruleId": "FFN6",
"ruleName": "Happy Birthday",
"ruleType": "MAWD",
"segApplyFirstFlag": false,
"status": "A",
"stopIfCriteriaMet": false,
"subPoolId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
}
},
{
"attrApplyFirstFlag": false,
"campaignAwardLimits": null,
"campaignContributors": null,
"campaignCriteria": null,
"campaignFormulaEights": null,
"campaignFormulaElevens": null,
"campaignFormulaFives": null,
"campaignFormulaFour": null,
"campaignFormulaOne": null,
"campaignFormulaSetting": {
"amountToUse": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"applyCapValueAfter": null,
"capAwardCounterId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"capAwardType": null,
"capAwardValue": null,
"lastApproveBy": null,
"lastApproveByName": null,
"lastApproveDate": null,
"lastUpdateBy": null,
"lastUpdateByName": null,
"lastUpdateDate": null,
"nParam": null,
"recordNo": null,
"roundingType": null,
"ruleId": null,
"ruleRecordNo": null,
"status": null
},
"campaignFormulaSix": null,
"campaignFormulaTens": null,
"campaignFormulaTwo": null,
"campaignId": {
"attributes": {},
"code": "OLSCN",
"description": "CN Campaign",
"notFound": false,
"recordStatus": null
},
"campaignRuleSchedule": null,
"campaignTcLinkages": null,
"channel": null,
"counterExtractRequest": null,
"dayOfMonth": null,
"description": null,
"effectiveFrom": "2026-05-19T00:00:00",
"effectivePeriodIsBase": null,
"effectiveTo": "2026-05-31T00:00:00",
"expiryPolicy": null,
"expiryPolicyParam": null,
"fixedDate": null,
"formulaSequence": ["f2", "f8"],
"itemCode": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"lastApproveBy": null,
"lastApproveByName": null,
"lastApproveDate": null,
"lastUpdateBy": "olscnadmin",
"lastUpdateByName": "olscnadmin",
"lastUpdateDate": "2026-06-17T09:25:40.778701Z",
"marketingRewardRequest": null,
"messageTemplateId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"monthOfYear": null,
"notUpdatePool": false,
"poolId": {
"attributes": { "precision": "2", "poolType": "BPT" },
"code": "OLSP",
"description": "OLS Point Reward",
"notFound": false,
"recordStatus": null
},
"recordNo": "855015332904974259",
"redemptionExtract": null,
"rewardContentEmail": null,
"rewardContentNotification": null,
"rewardContentSms": null,
"ruleId": "FD41",
"ruleName": "Happy Birthday",
"ruleType": "MAWD",
"segApplyFirstFlag": false,
"status": "E",
"stopIfCriteriaMet": false,
"subPoolId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
}
},
{
"attrApplyFirstFlag": false,
"campaignAwardLimits": null,
"campaignContributors": null,
"campaignCriteria": null,
"campaignFormulaEights": null,
"campaignFormulaElevens": null,
"campaignFormulaFives": null,
"campaignFormulaFour": null,
"campaignFormulaOne": null,
"campaignFormulaSetting": {
"amountToUse": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"applyCapValueAfter": null,
"capAwardCounterId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"capAwardType": null,
"capAwardValue": null,
"lastApproveBy": null,
"lastApproveByName": null,
"lastApproveDate": null,
"lastUpdateBy": null,
"lastUpdateByName": null,
"lastUpdateDate": null,
"nParam": null,
"recordNo": null,
"roundingType": null,
"ruleId": null,
"ruleRecordNo": null,
"status": null
},
"campaignFormulaSix": null,
"campaignFormulaTens": null,
"campaignFormulaTwo": null,
"campaignId": {
"attributes": {},
"code": "OLSCN",
"description": "CN Campaign",
"notFound": false,
"recordStatus": null
},
"campaignRuleSchedule": null,
"campaignTcLinkages": null,
"channel": null,
"counterExtractRequest": null,
"dayOfMonth": null,
"description": null,
"effectiveFrom": "2026-05-19T00:00:00",
"effectivePeriodIsBase": null,
"effectiveTo": "2026-05-31T00:00:00",
"expiryPolicy": null,
"expiryPolicyParam": null,
"fixedDate": null,
"formulaSequence": ["f2"],
"itemCode": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"lastApproveBy": "olscnadmin",
"lastApproveByName": "olscnadmin",
"lastApproveDate": "2026-05-19T03:46:31.372274Z",
"lastUpdateBy": "olscnadmin",
"lastUpdateByName": "olscnadmin",
"lastUpdateDate": "2026-05-19T03:46:26.746977Z",
"marketingRewardRequest": null,
"messageTemplateId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
},
"monthOfYear": null,
"notUpdatePool": false,
"poolId": {
"attributes": { "precision": "2", "poolType": "BPT" },
"code": "OLSP",
"description": "OLS Point Reward",
"notFound": false,
"recordStatus": null
},
"recordNo": "844420713795680794",
"redemptionExtract": null,
"rewardContentEmail": null,
"rewardContentNotification": null,
"rewardContentSms": null,
"ruleId": "FD41",
"ruleName": "Happy Birthday",
"ruleType": "MAWD",
"segApplyFirstFlag": false,
"status": "A",
"stopIfCriteriaMet": false,
"subPoolId": {
"attributes": {},
"code": null,
"description": null,
"notFound": false,
"recordStatus": null
}
}
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

@@ -31,6 +31,9 @@ foreach ($port in 9331, 9332, 9333) {
}
}
Get-Process | Where-Object { $_.CommandLine -like "*nodemon*$ProjectRoot*" } | Stop-Process -Force -ErrorAction SilentlyContinue
Get-Process | Where-Object { $_.CommandLine -like "*MavenWrapperMain*$ProjectRoot*" } | Stop-Process -Force -ErrorAction SilentlyContinue
Write-Host "=========================================="
Write-Host "Starting Loyalty Agent Services"

View File

@@ -21,10 +21,22 @@ for PORT in 9331 9332 9333; do
PID=$(lsof -t -i:$PORT)
if [ -n "$PID" ]; then
echo "Killing process on port $PORT (PID: $PID)..."
kill -9 $PID
kill -9 $PID 2>/dev/null || true
fi
done
NODEMON_PIDS=$(pgrep -f "nodemon.*$PROJECT_ROOT" || true)
if [ -n "$NODEMON_PIDS" ]; then
echo "Killing existing nodemon processes for $PROJECT_ROOT..."
pkill -9 -f "nodemon.*$PROJECT_ROOT" 2>/dev/null || true
fi
MAVEN_PIDS=$(pgrep -f "MavenWrapperMain.*$PROJECT_ROOT" || true)
if [ -n "$MAVEN_PIDS" ]; then
echo "Killing existing Maven wrapper processes for $PROJECT_ROOT..."
pkill -9 -f "MavenWrapperMain.*$PROJECT_ROOT" 2>/dev/null || true
fi
echo "=========================================="
echo "Starting Loyalty Agent Services"
echo "=========================================="
@@ -45,7 +57,7 @@ echo "loyalty-mcp-server is up!"
# Start nodemon watcher for MCP Server
echo "Starting nodemon watcher for loyalty-mcp-server..."
npx -y nodemon --watch src -e java,xml,yml,yaml,properties --exec "../mvnw compile" > "$LOG_DIR/loyalty-mcp-server-nodemon.log" 2>&1 &
npx -y nodemon --watch src -e java,xml,yml,yaml,properties --exec "$PROJECT_ROOT/mvnw compile" > "$LOG_DIR/loyalty-mcp-server-nodemon.log" 2>&1 &
MCP_NODEMON_PID=$!
@@ -65,7 +77,7 @@ echo "loyalty-agent is up!"
# Start nodemon watcher for Agent Service
echo "Starting nodemon watcher for loyalty-agent..."
npx -y nodemon --watch src -e java,xml,yml,yaml,properties --exec "../mvnw compile" > "$LOG_DIR/loyalty-agent-nodemon.log" 2>&1 &
npx -y nodemon --watch src -e java,xml,yml,yaml,properties --exec "$PROJECT_ROOT/mvnw compile" > "$LOG_DIR/loyalty-agent-nodemon.log" 2>&1 &
AGENT_NODEMON_PID=$!

View File

@@ -5,7 +5,24 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: { port: 9333 },
server: {
port: 9333,
proxy: {
'/api': {
target: 'http://localhost:9332',
changeOrigin: true,
},
'/ws': {
target: 'http://localhost:9332',
ws: true,
changeOrigin: true,
},
},
},
build: {
outDir: path.resolve(__dirname, './loyalty-agent/src/main/resources/static'),
emptyOutDir: true,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),