feat: overhaul chat architecture with persistent JdbcChatMemory, reactive UI components, and improved conversation management
This commit is contained in:
@@ -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.
|
||||
@@ -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
91
context-agent.md
Normal 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
82
context-fe.md
Normal 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
76
context-mcp-server.md
Normal 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`.
|
||||
190
context.md
190
context.md
@@ -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` |
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,3 +10,5 @@ public class LoyaltyAgentApplication {
|
||||
SpringApplication.run(LoyaltyAgentApplication.class, args);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE conversations ADD COLUMN title VARCHAR(255);
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
506
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
|
||||
906
sample-data/campaign-rule.json
Normal file
906
sample-data/campaign-rule.json
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
185
src/App.css
185
src/App.css
@@ -1,184 +1 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
/* Clean App.css - styling handled by Tailwind and shadcn/ui */
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Campaign {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export function CampaignList() {
|
||||
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedCampaignId, setSelectedCampaignId] = useState<string | null>(null);
|
||||
const [rules, setRules] = useState<any[]>([]);
|
||||
const [loadingRules, setLoadingRules] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('http://localhost:9332/api/v1/agent/campaigns')
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) setCampaigns(data);
|
||||
else if (data && Array.isArray(data.content)) setCampaigns(data.content);
|
||||
else if (data && Array.isArray(data.data)) setCampaigns(data.data);
|
||||
else setCampaigns([]);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch campaigns', err);
|
||||
setCampaigns([]);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSelectCampaign = (id: string) => {
|
||||
if (selectedCampaignId === id) {
|
||||
setSelectedCampaignId(null);
|
||||
setRules([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedCampaignId(id);
|
||||
setLoadingRules(true);
|
||||
fetch(`http://localhost:9332/api/v1/agent/campaigns/${id}/rules`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) setRules(data);
|
||||
else if (data && Array.isArray(data.content)) setRules(data.content);
|
||||
else if (data && Array.isArray(data.data)) setRules(data.data);
|
||||
else setRules([]);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to fetch rules', err);
|
||||
setRules([]);
|
||||
})
|
||||
.finally(() => setLoadingRules(false));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-4 text-center text-sm text-muted-foreground">Loading campaigns...</div>;
|
||||
}
|
||||
|
||||
if (campaigns.length === 0) {
|
||||
return <div className="p-4 text-center text-sm text-muted-foreground">No campaigns found.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar">
|
||||
{campaigns.map((camp) => (
|
||||
<div key={camp.id} className="space-y-2">
|
||||
<div
|
||||
onClick={() => handleSelectCampaign(camp.id)}
|
||||
className={`glass-card rounded-xl p-4 text-sm group cursor-pointer relative overflow-hidden transition-all duration-200 ${selectedCampaignId === camp.id ? 'ring-2 ring-primary/50' : ''}`}>
|
||||
<div className="absolute inset-0 bg-primary/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
<div className="relative z-10">
|
||||
<div className="font-semibold text-foreground text-base mb-1 group-hover:text-primary transition-colors">{camp.name || 'Unnamed Campaign'}</div>
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground mt-3">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={`w-2 h-2 rounded-full ${camp.status === 'A' ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]' : 'bg-rose-500'}`} />
|
||||
{camp.status === 'A' ? 'Active' : camp.status}
|
||||
</span>
|
||||
<code className="bg-background/50 px-1.5 py-0.5 rounded text-[10px] uppercase">{camp.id}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedCampaignId === camp.id && (
|
||||
<div className="pl-4 pr-2 space-y-2 border-l-2 border-primary/20 ml-2 animate-in slide-in-from-top-2">
|
||||
{loadingRules ? (
|
||||
<div className="text-xs text-muted-foreground italic">Loading rules...</div>
|
||||
) : rules.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground italic">No rules found.</div>
|
||||
) : (
|
||||
rules.map((rule, idx) => (
|
||||
<div key={rule.id || idx} className="bg-background/40 rounded-lg p-3 text-xs border border-border/50">
|
||||
<div className="font-medium text-foreground mb-1">{rule.name || 'Unnamed Rule'}</div>
|
||||
<div className="text-muted-foreground line-clamp-2">{rule.description || 'No description'}</div>
|
||||
{rule.status && (
|
||||
<div className="mt-2 text-[10px] uppercase tracking-wider text-muted-foreground/80">Status: {rule.status}</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,20 +2,24 @@ import { useState } from 'react'
|
||||
import type { KeyboardEvent } from 'react'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useChatStore } from '@/store/useChatStore'
|
||||
import { SendHorizontal } from 'lucide-react'
|
||||
import { useConnectionStore } from '@/store/useConnectionStore'
|
||||
import { SendHorizontal, AlertCircle, Clock, Mic } from 'lucide-react'
|
||||
|
||||
export function ChatBottombar() {
|
||||
const [input, setInput] = useState('')
|
||||
const sendMessage = useChatStore((state) => state.sendMessage)
|
||||
const isConnected = useChatStore((state) => state.isConnected)
|
||||
const isConnecting = useChatStore((state) => state.isConnecting)
|
||||
const connectionStatus = useConnectionStore((state) => state.status)
|
||||
const queuedCount = useConnectionStore((state) => state.queuedCount)
|
||||
|
||||
const isQueueFull = queuedCount >= 20
|
||||
const isDisconnected = connectionStatus === 'disconnected' || connectionStatus === 'error'
|
||||
|
||||
const handleSend = () => {
|
||||
if (input.trim() && isConnected) {
|
||||
sendMessage(input.trim())
|
||||
setInput('')
|
||||
}
|
||||
if (!input.trim() || isQueueFull) return
|
||||
sendMessage(input.trim())
|
||||
setInput('')
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
@@ -26,35 +30,81 @@ export function ChatBottombar() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-background border-t">
|
||||
<div className="max-w-3xl mx-auto flex items-end gap-2 relative">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Type your message..."
|
||||
className="min-h-[60px] max-h-[200px] resize-none"
|
||||
disabled={!isConnected}
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-[60px] w-[60px] shrink-0"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || !isConnected}
|
||||
>
|
||||
<SendHorizontal className="w-6 h-6" />
|
||||
</Button>
|
||||
<div className="p-3 bg-transparent">
|
||||
<div className="max-w-3xl mx-auto flex flex-col gap-2">
|
||||
<div className="relative flex items-center gap-2 bg-secondary/60 backdrop-blur-md border border-border/60 rounded-full px-4 py-1.5 shadow-sm focus-within:ring-1 focus-within:ring-primary/30 focus-within:border-primary/40 transition-all">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
isQueueFull
|
||||
? 'Mất kết nối. Hàng chờ đã đầy, vui lòng chờ...'
|
||||
: 'Hỏi Loyalty Agent...'
|
||||
}
|
||||
className="flex-1 min-h-[36px] max-h-[120px] py-1.5 px-1 bg-transparent border-0 focus-visible:ring-0 focus-visible:ring-offset-0 text-sm leading-relaxed text-foreground placeholder:text-muted-foreground/60 resize-none overflow-y-auto"
|
||||
rows={1}
|
||||
disabled={isQueueFull}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
type="button"
|
||||
>
|
||||
<Mic className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">Nhập bằng giọng nói</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm disabled:opacity-30 shrink-0 transition-all active:scale-95"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || isQueueFull}
|
||||
>
|
||||
<SendHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p className="text-xs">Gửi tin nhắn (Enter)</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Offline status or queued indicator */}
|
||||
{isDisconnected && (
|
||||
<div className="flex items-center justify-between text-xs text-destructive bg-destructive/10 border border-destructive/20 px-3 py-1.5 rounded-full shadow-sm">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>Mất kết nối với máy chủ. Đang tự động thử lại...</span>
|
||||
</div>
|
||||
{queuedCount > 0 && (
|
||||
<div className="flex items-center gap-1 text-amber-500 font-medium">
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span>Đã lưu tạm {queuedCount} tin nhắn</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-muted-foreground/70 text-center py-0.5 select-none">
|
||||
Loyalty Agent có thể đưa ra câu trả lời không chính xác. Hãy kiểm tra lại thông tin quan trọng.
|
||||
</p>
|
||||
</div>
|
||||
{!isConnected && !isConnecting && (
|
||||
<div className="text-center text-destructive text-sm mt-2">
|
||||
Disconnected from agent server. Retrying...
|
||||
</div>
|
||||
)}
|
||||
{isConnecting && (
|
||||
<div className="text-center text-muted-foreground text-sm mt-2 animate-pulse">
|
||||
Connecting to agent server...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,48 +1,60 @@
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import type { Message } from '@/store/useChatStore'
|
||||
import { ToolStatusIndicator } from './ToolStatusIndicator'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
import { Sparkles, User } from 'lucide-react'
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
|
||||
export function ChatBubble({ message }: { message: Message }) {
|
||||
interface ChatBubbleProps {
|
||||
message: Message
|
||||
}
|
||||
|
||||
export const ChatBubble = React.memo(function ChatBubble({ message }: ChatBubbleProps) {
|
||||
const isAgent = message.role === 'agent'
|
||||
|
||||
return (
|
||||
<div className={cn("flex w-full mt-4 space-x-3 max-w-3xl mx-auto", isAgent ? "justify-start" : "justify-end")}>
|
||||
{isAgent && (
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarFallback>AG</AvatarFallback>
|
||||
</Avatar>
|
||||
<div
|
||||
className={cn(
|
||||
'flex w-full my-4 max-w-3xl mx-auto items-start gap-3 group animate-in fade-in-50 duration-200',
|
||||
isAgent ? 'justify-start' : 'justify-end'
|
||||
)}
|
||||
<div className="flex flex-col gap-1 max-w-[80%]">
|
||||
{message.toolStatus && (
|
||||
<span className="text-xs text-muted-foreground animate-pulse italic">
|
||||
{message.toolStatus}
|
||||
</span>
|
||||
>
|
||||
{isAgent && (
|
||||
<div className="w-8 h-8 rounded-full bg-primary/10 border border-primary/20 flex items-center justify-center text-primary shrink-0 mt-0.5 shadow-sm">
|
||||
<Sparkles className="w-4 h-4 text-primary animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn('flex flex-col gap-1.5 min-w-0', isAgent ? 'flex-1' : 'max-w-[80%]')}>
|
||||
{isAgent && message.toolStatus && (
|
||||
<ToolStatusIndicator toolName={message.toolStatus} />
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"p-3 rounded-md text-sm shadow-sm",
|
||||
isAgent ? "bg-muted text-foreground" : "bg-primary text-primary-foreground"
|
||||
'text-sm transition-all duration-200 overflow-hidden',
|
||||
isAgent
|
||||
? 'text-foreground leading-relaxed py-1 px-1'
|
||||
: 'bg-secondary border border-border/80 text-secondary-foreground font-normal px-5 py-3 rounded-3xl self-end shadow-sm'
|
||||
)}
|
||||
>
|
||||
{isAgent ? (
|
||||
<div className="prose dark:prose-invert max-w-none text-sm">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{message.content}
|
||||
</ReactMarkdown>
|
||||
{message.isStreaming && <span className="inline-block w-2 h-4 bg-current animate-pulse ml-1 align-middle" />}
|
||||
</div>
|
||||
<MarkdownRenderer content={message.content} isStreaming={message.isStreaming} />
|
||||
) : (
|
||||
<span className="whitespace-pre-wrap">{message.content}</span>
|
||||
<div className="whitespace-pre-wrap leading-relaxed">{message.content}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isAgent && (
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarFallback>U</AvatarFallback>
|
||||
<Avatar className="w-8 h-8 border border-border/80 shrink-0 bg-muted shadow-sm">
|
||||
<AvatarFallback className="bg-muted text-muted-foreground font-semibold text-xs">
|
||||
<User className="w-4 h-4" />
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
134
src/components/chat/ChatDesktopSidebar.tsx
Normal file
134
src/components/chat/ChatDesktopSidebar.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Plus, PanelLeft, Sparkles, Search } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ChatHistoryList } from './ChatHistoryList'
|
||||
import { ChatUserProfile } from './ChatUserProfile'
|
||||
import type { ConversationItem } from '@/hooks/useConversations'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ChatDesktopSidebarProps {
|
||||
isCollapsed: boolean
|
||||
onToggleCollapse: () => void
|
||||
conversations: ConversationItem[]
|
||||
activeId: string | null
|
||||
isLoading?: boolean
|
||||
onSelectConversation: (id: string) => void
|
||||
onCreateNewConversation: () => void
|
||||
onUpdateTitle?: (id: string, newTitle: string) => void
|
||||
onDeleteConversation?: (id: string) => void
|
||||
}
|
||||
|
||||
export function ChatDesktopSidebar({
|
||||
isCollapsed,
|
||||
onToggleCollapse,
|
||||
conversations,
|
||||
activeId,
|
||||
isLoading,
|
||||
onSelectConversation,
|
||||
onCreateNewConversation,
|
||||
onUpdateTitle,
|
||||
onDeleteConversation,
|
||||
}: ChatDesktopSidebarProps) {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'h-full bg-card/70 border-r border-border/60 flex flex-col transition-all duration-300 shrink-0 relative z-10',
|
||||
isCollapsed ? 'w-16' : 'w-64'
|
||||
)}
|
||||
>
|
||||
{/* Sidebar Top: Collapse Toggle & Logo */}
|
||||
<div className="h-14 px-3 flex items-center justify-between border-b border-border/40">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-full text-muted-foreground hover:text-foreground"
|
||||
onClick={onToggleCollapse}
|
||||
>
|
||||
<PanelLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p className="text-xs">{isCollapsed ? 'Mở rộng menu' : 'Thu gọn menu'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{!isCollapsed && (
|
||||
<div className="flex items-center gap-2 pr-2 select-none">
|
||||
<Sparkles className="w-4 h-4 text-primary" />
|
||||
<span className="font-bold text-sm tracking-tight">Loyalty Agent</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Chat Button */}
|
||||
<div className="p-3">
|
||||
<TooltipProvider delayDuration={200}>
|
||||
{isCollapsed ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={onCreateNewConversation}
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="w-10 h-10 rounded-full mx-auto shadow-sm hover:bg-secondary/80 border border-border/60"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
<p className="text-xs font-medium">Trò chuyện mới</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button
|
||||
onClick={onCreateNewConversation}
|
||||
variant="secondary"
|
||||
className="w-full justify-start gap-2.5 rounded-full px-4 py-2.5 shadow-sm font-medium hover:bg-secondary/80 border border-border/60 text-xs"
|
||||
>
|
||||
<Plus className="w-4 h-4 text-primary" />
|
||||
<span>Trò chuyện mới</span>
|
||||
</Button>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
{/* Nav quick items */}
|
||||
{!isCollapsed && (
|
||||
<div className="px-3 py-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-3 rounded-full text-xs font-normal text-muted-foreground hover:text-foreground hover:bg-muted/60 h-9 px-3"
|
||||
>
|
||||
<Search className="w-4 h-4 shrink-0" />
|
||||
<span className="truncate">Tìm kiếm hội thoại</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History Header Label */}
|
||||
{!isCollapsed && (
|
||||
<div className="px-4 pt-3 pb-1 font-semibold text-[11px] uppercase tracking-wider text-muted-foreground/80 select-none">
|
||||
Gần đây
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Conversations List */}
|
||||
<ChatHistoryList
|
||||
conversations={conversations}
|
||||
activeId={activeId}
|
||||
onSelect={onSelectConversation}
|
||||
onUpdateTitle={onUpdateTitle}
|
||||
onDelete={onDeleteConversation}
|
||||
isLoading={isLoading}
|
||||
collapsed={isCollapsed}
|
||||
/>
|
||||
|
||||
{/* User Profile Footer */}
|
||||
<ChatUserProfile collapsed={isCollapsed} />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
91
src/components/chat/ChatHeader.tsx
Normal file
91
src/components/chat/ChatHeader.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Menu, Wifi, WifiOff, Sparkles } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { ThemeToggle } from './ThemeToggle'
|
||||
import type { ConnectionStatus } from '@/store/useConnectionStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ChatHeaderProps {
|
||||
isMobile: boolean
|
||||
isSidebarCollapsed: boolean
|
||||
onOpenMobileSidebar: () => void
|
||||
onExpandDesktopSidebar: () => void
|
||||
connectionStatus: ConnectionStatus
|
||||
onToggleRightPanel: () => void
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
isMobile,
|
||||
isSidebarCollapsed,
|
||||
onOpenMobileSidebar,
|
||||
onExpandDesktopSidebar,
|
||||
connectionStatus,
|
||||
onToggleRightPanel,
|
||||
}: ChatHeaderProps) {
|
||||
return (
|
||||
<header className="h-14 border-b border-border/40 flex items-center justify-between px-4 font-semibold shrink-0 bg-background/80 backdrop-blur-md z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
{(isMobile || isSidebarCollapsed) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 rounded-full text-muted-foreground hover:text-foreground"
|
||||
onClick={isMobile ? onOpenMobileSidebar : onExpandDesktopSidebar}
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Connection Status & Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'gap-1.5 py-0.5 px-2.5 text-[11px] font-normal shadow-sm rounded-full transition-colors',
|
||||
connectionStatus === 'connected'
|
||||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-500'
|
||||
: connectionStatus === 'connecting'
|
||||
? 'border-amber-500/30 bg-amber-500/10 text-amber-500 animate-pulse'
|
||||
: 'border-destructive/30 bg-destructive/10 text-destructive'
|
||||
)}
|
||||
>
|
||||
{connectionStatus === 'connected' ? (
|
||||
<>
|
||||
<Wifi className="w-3 h-3" />
|
||||
<span>Đã kết nối</span>
|
||||
</>
|
||||
) : connectionStatus === 'connecting' ? (
|
||||
<span>Đang kết nối...</span>
|
||||
) : (
|
||||
<>
|
||||
<WifiOff className="w-3 h-3" />
|
||||
<span>Ngoại tuyến</span>
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
|
||||
<ThemeToggle />
|
||||
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full text-muted-foreground hover:text-foreground"
|
||||
onClick={onToggleRightPanel}
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p className="text-xs">Thông tin hệ thống</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +1,203 @@
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { useState } from 'react'
|
||||
import { MessageSquare, Loader2, Pencil, Trash2, Check, X } from 'lucide-react'
|
||||
import type { ConversationItem } from '@/hooks/useConversations'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ChatSession {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
interface ChatHistoryListProps {
|
||||
conversations: ConversationItem[]
|
||||
activeId: string | null
|
||||
onSelect: (id: string) => void
|
||||
onUpdateTitle?: (id: string, newTitle: string) => void
|
||||
onDelete?: (id: string) => void
|
||||
isLoading?: boolean
|
||||
collapsed?: boolean
|
||||
}
|
||||
|
||||
const MOCK_SESSIONS: ChatSession[] = [
|
||||
{ id: '1', title: 'Phân tích Campaign Noel 2026', date: 'Vừa xong' },
|
||||
{ id: '2', title: 'Tại sao Rule giảm giá không hoạt động?', date: '2 giờ trước' },
|
||||
{ id: '3', title: 'Tạo Campaign sinh nhật khách hàng', date: 'Hôm qua' },
|
||||
{ id: '4', title: 'Kiểm tra điểm thưởng KH VIP', date: 'Hôm qua' },
|
||||
{ id: '5', title: 'Lỗi đồng bộ dữ liệu CRM', date: '3 ngày trước' },
|
||||
];
|
||||
export function ChatHistoryList({
|
||||
conversations,
|
||||
activeId,
|
||||
onSelect,
|
||||
onUpdateTitle,
|
||||
onDelete,
|
||||
isLoading,
|
||||
collapsed = false,
|
||||
}: ChatHistoryListProps) {
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editTitle, setEditTitle] = useState<string>('')
|
||||
|
||||
const handleStartEdit = (e: React.MouseEvent, session: ConversationItem) => {
|
||||
e.stopPropagation()
|
||||
setEditingId(session.id)
|
||||
setEditTitle(session.title || 'Cuộc trò chuyện mới')
|
||||
}
|
||||
|
||||
const handleSaveEdit = async (id: string) => {
|
||||
if (editTitle.trim() && onUpdateTitle) {
|
||||
await onUpdateTitle(id, editTitle.trim())
|
||||
}
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation()
|
||||
if (onDelete) {
|
||||
await onDelete(id)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading && conversations.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center p-4 text-muted-foreground text-xs gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin text-primary" />
|
||||
{!collapsed && <span>Đang tải hội thoại...</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (conversations.length === 0) {
|
||||
return (
|
||||
<div className="flex-1 p-4 text-center text-muted-foreground text-xs flex flex-col items-center justify-center gap-2">
|
||||
<MessageSquare className="w-6 h-6 text-muted-foreground/40" />
|
||||
{!collapsed && <span>Chưa có hội thoại nào</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ChatHistoryList() {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-2 custom-scrollbar">
|
||||
{MOCK_SESSIONS.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className="p-3 text-sm group cursor-pointer relative overflow-hidden rounded-lg hover:bg-white/5 transition-colors border border-transparent hover:border-white/10"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<MessageSquare className="w-4 h-4 mt-0.5 text-muted-foreground group-hover:text-primary transition-colors shrink-0" />
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="truncate font-medium text-foreground/90 group-hover:text-foreground transition-colors">
|
||||
{session.title}
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-1 pb-4 px-2 pt-1">
|
||||
{conversations.map((session) => {
|
||||
const isActive = session.id === activeId
|
||||
const isEditing = session.id === editingId
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip key={session.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() => onSelect(session.id)}
|
||||
variant={isActive ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-md mx-auto flex items-center justify-center transition-all',
|
||||
isActive
|
||||
? 'bg-secondary text-foreground shadow-sm font-semibold'
|
||||
: 'hover:bg-muted text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<MessageSquare className={cn('w-4 h-4', isActive && 'text-primary')} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="max-w-[200px]">
|
||||
<p className="font-medium text-xs truncate">{session.title || 'Cuộc trò chuyện mới'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => !isEditing && onSelect(session.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between py-2 px-3 font-normal rounded-md transition-all duration-200 group relative cursor-pointer overflow-hidden',
|
||||
isActive
|
||||
? 'bg-secondary text-foreground shadow-sm font-medium'
|
||||
: 'hover:bg-muted/70 text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 flex-1 min-w-0 overflow-hidden">
|
||||
<MessageSquare
|
||||
className={cn(
|
||||
'w-4 h-4 shrink-0 transition-colors',
|
||||
isActive ? 'text-primary' : 'group-hover:text-primary'
|
||||
)}
|
||||
/>
|
||||
{isEditing ? (
|
||||
<div
|
||||
className="flex items-center gap-1 flex-1 min-w-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveEdit(session.id)
|
||||
if (e.key === 'Escape') setEditingId(null)
|
||||
}}
|
||||
onBlur={() => handleSaveEdit(session.id)}
|
||||
autoFocus
|
||||
className="w-full bg-background border border-input rounded px-2 py-0.5 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveEdit(session.id)}
|
||||
className="p-1 text-primary hover:bg-muted rounded"
|
||||
title="Lưu"
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingId(null)}
|
||||
className="p-1 text-muted-foreground hover:bg-muted rounded"
|
||||
title="Hủy"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="truncate text-xs font-medium leading-tight flex-1">
|
||||
{session.title || 'Cuộc trò chuyện mới'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isEditing && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 inset-y-0 flex items-center justify-end pr-2 pl-12 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none group-hover:pointer-events-auto rounded-r-md",
|
||||
isActive
|
||||
? "bg-gradient-to-l from-secondary via-secondary to-transparent"
|
||||
: "bg-gradient-to-l from-muted via-muted/80 to-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{onUpdateTitle && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleStartEdit(e, session)}
|
||||
className="p-1.5 hover:text-foreground text-muted-foreground transition-colors rounded-md hover:bg-background/60"
|
||||
title="Đổi tên"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => handleDelete(e, session.id)}
|
||||
className="p-1.5 hover:text-destructive text-muted-foreground transition-colors rounded-md hover:bg-background/60"
|
||||
title="Xóa"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{session.date}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
</ScrollArea>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
41
src/components/chat/ChatInfoPanel.tsx
Normal file
41
src/components/chat/ChatInfoPanel.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface ChatInfoPanelProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ChatInfoPanel({ isOpen, onClose }: ChatInfoPanelProps) {
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<aside className="w-72 bg-card/90 border-l border-border/60 p-4 space-y-4 animate-in slide-in-from-right duration-200">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-border/60">
|
||||
<span className="font-semibold text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Thông tin hệ thống
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-full text-muted-foreground"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 text-xs text-muted-foreground">
|
||||
<div className="p-3 rounded-2xl bg-secondary/50 border border-border/60 space-y-1">
|
||||
<div className="font-semibold text-foreground">MCP Tools Connected</div>
|
||||
<p className="text-[11px]">Truy vấn thông tin chiến dịch, khách hàng & điểm thưởng theo thời gian thực.</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 rounded-2xl bg-secondary/50 border border-border/60 space-y-1">
|
||||
<div className="font-semibold text-foreground">STOMP Streaming</div>
|
||||
<p className="text-[11px]">Hỗ trợ phản hồi thời gian thực qua WebSocket stream.</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -1,61 +1,113 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { ChatList } from './ChatList'
|
||||
import { ChatBottombar } from './ChatBottombar'
|
||||
import { ChatMobileSidebar } from './ChatMobileSidebar'
|
||||
import { ChatDesktopSidebar } from './ChatDesktopSidebar'
|
||||
import { ChatHeader } from './ChatHeader'
|
||||
import { ChatInfoPanel } from './ChatInfoPanel'
|
||||
import { useChatStore } from '@/store/useChatStore'
|
||||
import { MessageSquarePlus, PanelRightClose, PanelRight } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
import { CampaignList } from '@/components/campaign/CampaignList'
|
||||
import { ChatHistoryList } from './ChatHistoryList'
|
||||
import { useConnectionStore } from '@/store/useConnectionStore'
|
||||
import { useConversations } from '@/hooks/useConversations'
|
||||
import { useIsMobile } from '@/hooks/useIsMobile'
|
||||
|
||||
export function ChatLayout() {
|
||||
const messages = useChatStore((state) => state.messages)
|
||||
const isRightPanelOpen = useChatStore((state) => state.isRightPanelOpen)
|
||||
const toggleRightPanel = useChatStore((state) => state.toggleRightPanel)
|
||||
const initStomp = useChatStore((state) => state.initStomp)
|
||||
const connectionStatus = useConnectionStore((state) => state.status)
|
||||
const isMobile = useIsMobile()
|
||||
|
||||
const {
|
||||
conversations,
|
||||
activeId,
|
||||
isLoading,
|
||||
selectConversation,
|
||||
createNewConversation,
|
||||
deleteConversation,
|
||||
updateTitle,
|
||||
fetchConversations,
|
||||
} = useConversations()
|
||||
|
||||
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
|
||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false)
|
||||
const [showRightPanel, setShowRightPanel] = useState(false)
|
||||
|
||||
const fetchedRef = useRef<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
initStomp()
|
||||
}, [initStomp])
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-transparent text-foreground overflow-hidden p-4 md:p-6 gap-4">
|
||||
{/* Left Panel: Chat History */}
|
||||
<aside className="w-80 flex-col hidden md:flex glass-panel rounded-2xl overflow-hidden shrink-0">
|
||||
<div className="p-4 border-b border-white/10 dark:border-white/5">
|
||||
<Button variant="outline" className="w-full justify-start gap-2">
|
||||
<MessageSquarePlus className="w-4 h-4" />
|
||||
New Chat
|
||||
</Button>
|
||||
</div>
|
||||
<div className="px-4 pt-4 font-semibold text-sm text-foreground/80">Recent Chats</div>
|
||||
<ChatHistoryList />
|
||||
</aside>
|
||||
useEffect(() => {
|
||||
if (!activeId) return
|
||||
const currentConv = conversations.find(c => c.id === activeId)
|
||||
// Auto-update title when agent starts replying to a new conversation
|
||||
if (currentConv && currentConv.title === 'Cuộc trò chuyện mới' && fetchedRef.current !== activeId) {
|
||||
const firstAgentMsg = messages.find(m => m.role === 'agent')
|
||||
const hasAgentStartedReplying = firstAgentMsg && (firstAgentMsg.content.length > 0 || firstAgentMsg.toolStatus || !firstAgentMsg.isStreaming)
|
||||
|
||||
if (hasAgentStartedReplying) {
|
||||
fetchedRef.current = activeId
|
||||
fetchConversations()
|
||||
}
|
||||
}
|
||||
}, [messages, activeId, conversations, fetchConversations])
|
||||
|
||||
{/* Main Chat Area */}
|
||||
<main className="flex-1 flex flex-col min-w-0 glass-panel rounded-2xl overflow-hidden transition-all duration-300">
|
||||
<header className="h-16 border-b border-white/10 dark:border-white/5 flex items-center justify-between px-6 font-semibold shadow-sm shrink-0 text-lg">
|
||||
<div>Loyalty Agent</div>
|
||||
<Button variant="ghost" size="icon" onClick={toggleRightPanel} className="text-muted-foreground hover:text-foreground">
|
||||
{isRightPanelOpen ? <PanelRightClose className="w-5 h-5" /> : <PanelRight className="w-5 h-5" />}
|
||||
</Button>
|
||||
</header>
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||
<ChatList messages={messages} />
|
||||
<ChatBottombar />
|
||||
return (
|
||||
<div className="flex h-screen w-full bg-background text-foreground overflow-hidden">
|
||||
{/* Mobile Drawer & Sidebar */}
|
||||
<ChatMobileSidebar
|
||||
isOpen={isMobileSidebarOpen}
|
||||
onClose={() => setIsMobileSidebarOpen(false)}
|
||||
conversations={conversations}
|
||||
activeId={activeId}
|
||||
isLoading={isLoading}
|
||||
onSelectConversation={selectConversation}
|
||||
onCreateNewConversation={createNewConversation}
|
||||
onUpdateTitle={updateTitle}
|
||||
onDeleteConversation={deleteConversation}
|
||||
/>
|
||||
|
||||
{/* Desktop Left Sidebar */}
|
||||
{!isMobile && (
|
||||
<ChatDesktopSidebar
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
onToggleCollapse={() => setIsSidebarCollapsed(!isSidebarCollapsed)}
|
||||
conversations={conversations}
|
||||
activeId={activeId}
|
||||
isLoading={isLoading}
|
||||
onSelectConversation={selectConversation}
|
||||
onCreateNewConversation={createNewConversation}
|
||||
onUpdateTitle={updateTitle}
|
||||
onDeleteConversation={deleteConversation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Container */}
|
||||
<main className="flex-1 flex flex-col bg-background relative overflow-hidden">
|
||||
{/* Header */}
|
||||
<ChatHeader
|
||||
isMobile={isMobile}
|
||||
isSidebarCollapsed={isSidebarCollapsed}
|
||||
onOpenMobileSidebar={() => setIsMobileSidebarOpen(true)}
|
||||
onExpandDesktopSidebar={() => setIsSidebarCollapsed(false)}
|
||||
connectionStatus={connectionStatus}
|
||||
onToggleRightPanel={() => setShowRightPanel(!showRightPanel)}
|
||||
/>
|
||||
|
||||
{/* Center Content & Floating Input */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||
<ChatList messages={messages} />
|
||||
<ChatBottombar />
|
||||
</div>
|
||||
|
||||
{/* Right Info Panel */}
|
||||
<ChatInfoPanel
|
||||
isOpen={showRightPanel}
|
||||
onClose={() => setShowRightPanel(false)}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Right Panel: Context / Campaigns */}
|
||||
{isRightPanelOpen && (
|
||||
<aside className="w-80 flex flex-col glass-panel rounded-2xl overflow-hidden shrink-0 animate-in slide-in-from-right-4 duration-300">
|
||||
<div className="px-4 py-4 border-b border-white/10 dark:border-white/5 font-semibold text-sm text-foreground/80 flex items-center justify-between">
|
||||
System Context
|
||||
</div>
|
||||
<div className="px-4 pt-4 font-semibold text-xs uppercase tracking-wider text-muted-foreground">Campaigns</div>
|
||||
<CampaignList />
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,28 +1,66 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
import type { UIEvent } from 'react'
|
||||
import { ChatBubble } from './ChatBubble'
|
||||
|
||||
import { WelcomeScreen } from './WelcomeScreen'
|
||||
import type { Message } from '@/store/useChatStore'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { ArrowDown } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function ChatList({ messages }: { messages: Message[] }) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const [showScrollBottom, setShowScrollBottom] = useState(false)
|
||||
const isAtBottomRef = useRef(true)
|
||||
|
||||
const handleScroll = (e: UIEvent<HTMLDivElement>) => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||
const isAtBottom = distanceFromBottom <= 100
|
||||
isAtBottomRef.current = isAtBottom
|
||||
setShowScrollBottom(!isAtBottom && messages.length > 0)
|
||||
}
|
||||
|
||||
const scrollToBottom = (smooth = true) => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
if (isAtBottomRef.current) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
if (messages.length === 0) {
|
||||
return <WelcomeScreen />
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea className="flex-1 w-full p-4 h-full">
|
||||
<div className="flex flex-col gap-2 pb-4">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 w-full p-4 overflow-y-auto custom-scrollbar relative"
|
||||
>
|
||||
<div className="flex flex-col gap-2 pb-6 max-w-3xl mx-auto">
|
||||
{messages.map((msg) => (
|
||||
<ChatBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-muted-foreground mt-20">
|
||||
Send a message to start chatting with the Loyalty Agent.
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{showScrollBottom && (
|
||||
<div className="sticky bottom-4 left-1/2 -translate-x-1/2 flex justify-center z-10 animate-in fade-in slide-in-from-bottom-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="rounded-full shadow-lg border border-white/10 gap-1.5 bg-secondary/90 hover:bg-secondary text-xs backdrop-blur-md"
|
||||
onClick={() => scrollToBottom(true)}
|
||||
>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
<span>Tin nhắn mới</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
91
src/components/chat/ChatMobileSidebar.tsx
Normal file
91
src/components/chat/ChatMobileSidebar.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Plus, X } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ChatHistoryList } from './ChatHistoryList'
|
||||
import { ChatUserProfile } from './ChatUserProfile'
|
||||
import type { ConversationItem } from '@/hooks/useConversations'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ChatMobileSidebarProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
conversations: ConversationItem[]
|
||||
activeId: string | null
|
||||
isLoading?: boolean
|
||||
onSelectConversation: (id: string) => void
|
||||
onCreateNewConversation: () => void
|
||||
onUpdateTitle?: (id: string, newTitle: string) => void
|
||||
onDeleteConversation?: (id: string) => void
|
||||
}
|
||||
|
||||
export function ChatMobileSidebar({
|
||||
isOpen,
|
||||
onClose,
|
||||
conversations,
|
||||
activeId,
|
||||
isLoading,
|
||||
onSelectConversation,
|
||||
onCreateNewConversation,
|
||||
onUpdateTitle,
|
||||
onDeleteConversation,
|
||||
}: ChatMobileSidebarProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Mobile Drawer Overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 md:hidden animate-in fade-in"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed inset-y-0 left-0 w-72 bg-card border-r border-border z-50 flex flex-col md:hidden transition-transform duration-300 shadow-2xl',
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
)}
|
||||
>
|
||||
<div className="p-4 border-b border-border/60 flex items-center justify-between gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
onCreateNewConversation()
|
||||
onClose()
|
||||
}}
|
||||
variant="secondary"
|
||||
className="w-full justify-start gap-2.5 rounded-full shadow-sm font-medium"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Trò chuyện mới</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="rounded-full"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pt-3 pb-1 font-semibold text-[11px] uppercase tracking-wider text-muted-foreground select-none">
|
||||
Gần đây
|
||||
</div>
|
||||
|
||||
<ChatHistoryList
|
||||
conversations={conversations}
|
||||
activeId={activeId}
|
||||
onSelect={(id: string) => {
|
||||
onSelectConversation(id)
|
||||
onClose()
|
||||
}}
|
||||
onUpdateTitle={onUpdateTitle}
|
||||
onDelete={onDeleteConversation}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<ChatUserProfile className="border-t border-border/60 bg-muted/30" />
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
27
src/components/chat/ChatUserProfile.tsx
Normal file
27
src/components/chat/ChatUserProfile.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ChatUserProfileProps {
|
||||
collapsed?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ChatUserProfile({ collapsed = false, className }: ChatUserProfileProps) {
|
||||
return (
|
||||
<div className={cn('p-3 border-t border-border/40 flex items-center justify-between bg-card/40', className)}>
|
||||
<div className="flex items-center gap-2.5 overflow-hidden">
|
||||
<Avatar className="w-8 h-8 border border-border/80 shrink-0">
|
||||
<AvatarFallback className="bg-primary/20 text-primary text-xs font-bold">
|
||||
SP
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{!collapsed && (
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold truncate leading-tight">Son Phung</p>
|
||||
<p className="text-[10px] text-muted-foreground">Ultra Member</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
182
src/components/chat/MarkdownRenderer.tsx
Normal file
182
src/components/chat/MarkdownRenderer.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { codeToHtml } from 'shiki'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { Check, Copy } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string
|
||||
language: string
|
||||
}
|
||||
|
||||
const CodeBlock = React.memo(function CodeBlock({ code, language }: CodeBlockProps) {
|
||||
const [html, setHtml] = useState<string>('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
const lang = language || 'plaintext'
|
||||
|
||||
codeToHtml(code, {
|
||||
lang: lang,
|
||||
theme: 'github-dark',
|
||||
})
|
||||
.then((highlightedHtml) => {
|
||||
if (isMounted) setHtml(highlightedHtml)
|
||||
})
|
||||
.catch(() => {
|
||||
// Fallback to plaintext if language is unsupported or during streaming syntax errors
|
||||
codeToHtml(code, { lang: 'plaintext', theme: 'github-dark' })
|
||||
.then((fallbackHtml) => {
|
||||
if (isMounted) setHtml(fallbackHtml)
|
||||
})
|
||||
.catch(() => {})
|
||||
})
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [code, language])
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(code)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
if (!html) {
|
||||
return (
|
||||
<div className="relative my-3 rounded-xl border border-border bg-slate-950 p-4 font-mono text-xs text-emerald-400 overflow-x-auto">
|
||||
<div className="flex justify-between items-center pb-2 mb-2 border-b border-white/10 text-[10px] text-muted-foreground font-mono">
|
||||
<span>{language || 'code'}</span>
|
||||
</div>
|
||||
<pre><code>{code}</code></pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative my-3 rounded-xl border border-border/80 overflow-hidden group shadow-md">
|
||||
<div className="flex justify-between items-center px-4 py-1.5 bg-slate-900 border-b border-white/10 text-xs text-muted-foreground font-mono">
|
||||
<span className="text-[11px] font-semibold text-slate-300">{language || 'code'}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground hover:bg-slate-800"
|
||||
onClick={copyToClipboard}
|
||||
title="Copy code"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-emerald-400" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="p-4 text-xs font-mono overflow-x-auto bg-slate-950 [&>pre]:!bg-transparent [&>pre]:!p-0 [&>pre]:!m-0"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string
|
||||
isStreaming?: boolean
|
||||
}
|
||||
|
||||
export const MarkdownRenderer = React.memo(function MarkdownRenderer({
|
||||
content,
|
||||
isStreaming,
|
||||
}: MarkdownRendererProps) {
|
||||
return (
|
||||
<div className="prose dark:prose-invert max-w-none text-sm leading-relaxed text-foreground">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ node, ...props }) => (
|
||||
<h1 className="text-xl font-bold tracking-tight my-3 text-foreground" {...props} />
|
||||
),
|
||||
h2: ({ node, ...props }) => (
|
||||
<h2 className="text-lg font-semibold tracking-tight my-2.5 text-foreground" {...props} />
|
||||
),
|
||||
h3: ({ node, ...props }) => (
|
||||
<h3 className="text-base font-semibold my-2 text-foreground" {...props} />
|
||||
),
|
||||
p: ({ node, ...props }) => (
|
||||
<p className="leading-relaxed my-2 text-foreground/90" {...props} />
|
||||
),
|
||||
ol: ({ node, ...props }) => (
|
||||
<ol className="list-decimal list-outside ml-5 space-y-1.5 my-2" {...props} />
|
||||
),
|
||||
ul: ({ node, ...props }) => (
|
||||
<ul className="list-disc list-outside ml-5 space-y-1.5 my-2" {...props} />
|
||||
),
|
||||
li: ({ node, ...props }) => (
|
||||
<li className="leading-relaxed text-foreground/90 pl-0.5" {...props} />
|
||||
),
|
||||
blockquote: ({ node, ...props }) => (
|
||||
<blockquote className="border-l-4 border-primary/80 pl-4 my-3 italic text-muted-foreground bg-primary/5 py-1 rounded-r-md" {...props} />
|
||||
),
|
||||
strong: ({ node, ...props }) => (
|
||||
<strong className="font-semibold text-primary" {...props} />
|
||||
),
|
||||
table: ({ node, ...props }) => (
|
||||
<div className="overflow-x-auto my-3 rounded-lg border border-border bg-card/60 shadow-sm">
|
||||
<Table className="min-w-full" {...props} />
|
||||
</div>
|
||||
),
|
||||
thead: ({ node, ...props }) => <TableHeader {...props} />,
|
||||
tbody: ({ node, ...props }) => <TableBody {...props} />,
|
||||
tr: ({ node, ...props }) => <TableRow {...props} />,
|
||||
th: ({ node, ...props }) => <TableHead className="font-semibold text-foreground bg-muted/40" {...props} />,
|
||||
td: ({ node, ...props }) => <TableCell className="text-foreground/90" {...props} />,
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
code: ({ node, className, children, ...props }: any) => {
|
||||
const contentStr = String(children || '').replace(/\n$/, '')
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const isMultiLine = contentStr.includes('\n')
|
||||
const isInline = !match && !isMultiLine
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className="bg-primary/10 text-primary border border-primary/20 px-1.5 py-0.5 rounded font-mono text-[0.85em] font-medium inline-baseline mx-0.5"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
code={contentStr}
|
||||
language={match ? match[1] : ''}
|
||||
/>
|
||||
)
|
||||
},
|
||||
a: ({ node, ...props }) => (
|
||||
<a
|
||||
className="text-primary hover:underline font-medium"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-2 h-4 bg-primary animate-pulse ml-1 align-middle rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
31
src/components/chat/ThemeToggle.tsx
Normal file
31
src/components/chat/ThemeToggle.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Sun, Moon } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useTheme } from '@/hooks/useTheme'
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, toggleTheme } = useTheme()
|
||||
const isDark = theme === 'dark'
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
className="h-8 w-8 rounded-full text-muted-foreground hover:text-foreground hover:bg-muted/80 transition-all duration-300 relative overflow-hidden group"
|
||||
aria-label="Chuyển đổi giao diện sáng/tối"
|
||||
>
|
||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all duration-500 ease-in-out dark:-rotate-90 dark:scale-0 text-amber-500" />
|
||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all duration-500 ease-in-out dark:rotate-0 dark:scale-100 text-sky-400" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
<p>{isDark ? 'Chuyển sang giao diện Sáng' : 'Chuyển sang giao diện Tối'}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
30
src/components/chat/ToolStatusIndicator.tsx
Normal file
30
src/components/chat/ToolStatusIndicator.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
interface ToolStatusIndicatorProps {
|
||||
toolName?: string
|
||||
}
|
||||
|
||||
const TOOL_NAME_MAP: Record<string, string> = {
|
||||
searchCampaigns: '🔍 Đang tìm kiếm chiến dịch...',
|
||||
getCampaignDetails: '📋 Đang lấy thông tin chi tiết chiến dịch...',
|
||||
createCampaign: '✨ Đang khởi tạo chiến dịch mới...',
|
||||
executeWorkflow: '⚡ Đang xử lý quy trình...',
|
||||
queryCustomerPoints: '💳 Đang tra cứu điểm thưởng khách hàng...',
|
||||
}
|
||||
|
||||
export function ToolStatusIndicator({ toolName }: ToolStatusIndicatorProps) {
|
||||
if (!toolName) return null
|
||||
|
||||
const displayText = TOOL_NAME_MAP[toolName] || `🔍 Đang xử lý: ${toolName}...`
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="gap-2 text-xs font-normal border-amber-500/30 bg-amber-500/10 text-amber-500 py-1 px-3 w-fit animate-pulse my-0.5 shadow-sm"
|
||||
>
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-amber-500 shrink-0" />
|
||||
<span>{displayText}</span>
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
93
src/components/chat/WelcomeScreen.tsx
Normal file
93
src/components/chat/WelcomeScreen.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { Sparkles, MessageSquare, ShieldCheck, BarChart3, ArrowRight } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useChatStore } from '@/store/useChatStore'
|
||||
|
||||
const ACTION_MATRIX = [
|
||||
{
|
||||
tag: 'QUICK QUERY',
|
||||
tagColor: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
|
||||
title: 'Danh sách chiến dịch',
|
||||
description: 'Tra cứu thông tin các chương trình khuyến mãi và ưu đãi đang chạy.',
|
||||
icon: Sparkles,
|
||||
prompt: 'Cho tôi xem danh sách các chiến dịch khuyến mãi đang diễn ra.',
|
||||
},
|
||||
{
|
||||
tag: 'CAMPAIGN BUILDER',
|
||||
tagColor: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
|
||||
title: 'Tạo campaign mới',
|
||||
description: 'Khởi tạo chiến dịch tích điểm & khuyến mãi mới cho khách hàng.',
|
||||
icon: MessageSquare,
|
||||
prompt: 'Tôi muốn tạo một chiến dịch khuyến mãi tích điểm mới cho khách hàng.',
|
||||
},
|
||||
{
|
||||
tag: 'VIP INSIGHTS',
|
||||
tagColor: 'bg-amber-500/10 text-amber-500 border-amber-500/20',
|
||||
title: 'Tra cứu hạng thẻ VIP',
|
||||
description: 'Kiểm tra điểm thưởng, lịch sử đổi quà và quyền lợi khách hàng VIP.',
|
||||
icon: ShieldCheck,
|
||||
prompt: 'Hãy tra cứu thông tin điểm thưởng và hạng thẻ của khách hàng VIP.',
|
||||
},
|
||||
{
|
||||
tag: 'DATA REPORT',
|
||||
tagColor: 'bg-purple-500/10 text-purple-500 border-purple-500/20',
|
||||
title: 'Báo cáo hiệu suất',
|
||||
description: 'Tổng hợp phân tích hiệu quả chương trình chăm sóc khách hàng.',
|
||||
icon: BarChart3,
|
||||
prompt: 'Phân tích tổng quan hiệu suất chương trình loyalty và tích điểm.',
|
||||
},
|
||||
]
|
||||
|
||||
export function WelcomeScreen() {
|
||||
const sendMessage = useChatStore((s) => s.sendMessage)
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center max-w-3xl mx-auto animate-in fade-in duration-300">
|
||||
|
||||
<h1 className="text-3xl font-extrabold tracking-tight bg-gradient-to-r from-foreground via-foreground/90 to-muted-foreground bg-clip-text text-transparent mb-2">
|
||||
Bắt đầu cuộc trò chuyện mới
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mb-8 max-w-lg leading-relaxed">
|
||||
Chọn một tác vụ studio nhanh bên dưới hoặc nhập yêu cầu để trợ lý AI thực hiện công việc cho bạn.
|
||||
</p>
|
||||
|
||||
{/* Action Matrix 2x2 Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3.5 w-full text-left">
|
||||
{ACTION_MATRIX.map((item, idx) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => sendMessage(item.prompt)}
|
||||
className="group cursor-pointer p-4 rounded-2xl bg-card border border-border/70 hover:border-primary/50 hover:shadow-md hover:bg-secondary/40 transition-all duration-200 flex flex-col justify-between min-h-[140px] relative overflow-hidden"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center justify-between gap-2 mb-2.5">
|
||||
<Badge variant="outline" className={`text-[10px] font-bold tracking-wider uppercase border ${item.tagColor}`}>
|
||||
{item.tag}
|
||||
</Badge>
|
||||
<div className="p-1.5 rounded-lg bg-secondary text-muted-foreground group-hover:text-primary group-hover:bg-primary/10 transition-colors">
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-bold text-foreground group-hover:text-primary transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2 leading-relaxed">
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-border/40 mt-3 flex items-center justify-between text-xs font-medium text-muted-foreground group-hover:text-primary transition-colors">
|
||||
<span>Thử ngay</span>
|
||||
<ArrowRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
43
src/components/ui/resizable.tsx
Normal file
43
src/components/ui/resizable.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { GripVertical } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Group>) => (
|
||||
<ResizablePrimitive.Group
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col data-[orientation=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
||||
const ResizablePanel = ResizablePrimitive.Panel
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||
withHandle?: boolean
|
||||
}) => (
|
||||
<ResizablePrimitive.Separator
|
||||
className={cn(
|
||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90 data-[orientation=vertical]:h-px data-[orientation=vertical]:w-full data-[orientation=vertical]:after:left-0 data-[orientation=vertical]:after:h-1 data-[orientation=vertical]:after:w-full data-[orientation=vertical]:after:-translate-y-1/2 data-[orientation=vertical]:after:translate-x-0 [&[data-orientation=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-7 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-2.5 w-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
)
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
117
src/components/ui/table.tsx
Normal file
117
src/components/ui/table.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last-child:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
28
src/components/ui/tooltip.tsx
Normal file
28
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ComponentRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
175
src/hooks/useConversations.ts
Normal file
175
src/hooks/useConversations.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useChatStore } from '../store/useChatStore'
|
||||
import type { Message } from '../store/useChatStore'
|
||||
|
||||
|
||||
export interface ConversationItem {
|
||||
id: string
|
||||
title: string
|
||||
preview: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
interface BackendMessage {
|
||||
id: string
|
||||
conversationId: string
|
||||
role: string
|
||||
content: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export function useConversations() {
|
||||
const [conversations, setConversations] = useState<ConversationItem[]>([])
|
||||
const [activeId, setActiveId] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const setConversationId = useChatStore((s) => s.setConversationId)
|
||||
const setMessages = useChatStore((s) => s.setMessages)
|
||||
|
||||
const fetchConversations = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/v1/conversations')
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch conversations: ${res.statusText}`)
|
||||
}
|
||||
const data: ConversationItem[] = await res.json()
|
||||
setConversations(data)
|
||||
return data
|
||||
} catch (err: any) {
|
||||
console.error('[useConversations] fetchConversations error:', err)
|
||||
setError(err.message || 'Error fetching conversations')
|
||||
return []
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadMessages = useCallback(async (conversationId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/conversations/${conversationId}/messages`)
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch messages: ${res.statusText}`)
|
||||
}
|
||||
const rawMessages: BackendMessage[] = await res.json()
|
||||
const formattedMessages: Message[] = rawMessages.map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role.toLowerCase() === 'user' ? 'user' : 'agent',
|
||||
content: m.content,
|
||||
createdAt: m.createdAt,
|
||||
}))
|
||||
setMessages(formattedMessages)
|
||||
} catch (err: any) {
|
||||
console.error('[useConversations] loadMessages error:', err)
|
||||
setMessages([])
|
||||
}
|
||||
}, [setMessages])
|
||||
|
||||
const deleteConversation = useCallback(async (id: string) => {
|
||||
try {
|
||||
await fetch(`/api/v1/conversations/${id}`, { method: 'DELETE' })
|
||||
setConversations((prev) => prev.filter((c) => c.id !== id))
|
||||
} catch (err: any) {
|
||||
console.error('[useConversations] deleteConversation error:', err)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const updateTitle = useCallback(async (id: string, newTitle: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/v1/conversations/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: newTitle }),
|
||||
})
|
||||
if (!res.ok) throw new Error('Failed to update title')
|
||||
const updated: ConversationItem = await res.json()
|
||||
setConversations((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, title: updated.title } : c))
|
||||
)
|
||||
return true
|
||||
} catch (err: any) {
|
||||
console.error('[useConversations] updateTitle error:', err)
|
||||
return false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const selectConversation = useCallback(async (id: string) => {
|
||||
const currentMessages = useChatStore.getState().messages
|
||||
if (activeId && activeId !== id && currentMessages.length === 0) {
|
||||
deleteConversation(activeId)
|
||||
}
|
||||
|
||||
setActiveId(id)
|
||||
setConversationId(id)
|
||||
await loadMessages(id)
|
||||
}, [activeId, setConversationId, loadMessages, deleteConversation])
|
||||
|
||||
const createNewConversation = useCallback(async () => {
|
||||
const currentMessages = useChatStore.getState().messages
|
||||
if (activeId && currentMessages.length === 0) {
|
||||
return conversations.find((c) => c.id === activeId) || null
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const res = await fetch('/api/v1/conversations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to create conversation: ${res.statusText}`)
|
||||
}
|
||||
const newConv: ConversationItem = await res.json()
|
||||
const newItem: ConversationItem = {
|
||||
id: newConv.id,
|
||||
title: 'Cuộc trò chuyện mới',
|
||||
preview: '',
|
||||
createdAt: newConv.createdAt,
|
||||
updatedAt: newConv.updatedAt,
|
||||
}
|
||||
setConversations((prev) => [newItem, ...prev])
|
||||
setActiveId(newConv.id)
|
||||
setConversationId(newConv.id)
|
||||
setMessages([])
|
||||
return newItem
|
||||
} catch (err: any) {
|
||||
console.error('[useConversations] createNewConversation error:', err)
|
||||
setError(err.message || 'Error creating conversation')
|
||||
return null
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [activeId, conversations, setConversationId, setMessages])
|
||||
|
||||
const initRef = useRef(false)
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
if (initRef.current) return
|
||||
initRef.current = true
|
||||
|
||||
fetchConversations().then((items) => {
|
||||
if (items && items.length > 0) {
|
||||
selectConversation(items[0].id)
|
||||
} else {
|
||||
createNewConversation()
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
return {
|
||||
conversations,
|
||||
activeId,
|
||||
isLoading,
|
||||
error,
|
||||
fetchConversations,
|
||||
selectConversation,
|
||||
createNewConversation,
|
||||
deleteConversation,
|
||||
updateTitle,
|
||||
}
|
||||
}
|
||||
|
||||
21
src/hooks/useIsMobile.ts
Normal file
21
src/hooks/useIsMobile.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export function useIsMobile(breakpoint = 768) {
|
||||
const [isMobile, setIsMobile] = useState<boolean>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.innerWidth < breakpoint
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
setIsMobile(window.innerWidth < breakpoint)
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [breakpoint])
|
||||
|
||||
return isMobile
|
||||
}
|
||||
51
src/hooks/useTheme.ts
Normal file
51
src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export type Theme = 'dark' | 'light' | 'system'
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const saved = localStorage.getItem('theme') as Theme
|
||||
return saved || 'dark' // Mặc định dark theme cho ứng dụng
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
|
||||
const applyTheme = (targetTheme: Theme) => {
|
||||
root.classList.remove('light', 'dark')
|
||||
|
||||
if (targetTheme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light'
|
||||
root.classList.add(systemTheme)
|
||||
} else {
|
||||
root.classList.add(targetTheme)
|
||||
}
|
||||
}
|
||||
|
||||
applyTheme(theme)
|
||||
localStorage.setItem('theme', theme)
|
||||
}, [theme])
|
||||
|
||||
// System listener
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const handleChange = () => {
|
||||
const root = window.document.documentElement
|
||||
root.classList.remove('light', 'dark')
|
||||
root.classList.add(mediaQuery.matches ? 'dark' : 'light')
|
||||
}
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
return () => mediaQuery.removeEventListener('change', handleChange)
|
||||
}, [theme])
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'))
|
||||
}
|
||||
|
||||
return { theme, setTheme, toggleTheme }
|
||||
}
|
||||
175
src/services/StompService.ts
Normal file
175
src/services/StompService.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Client } from '@stomp/stompjs'
|
||||
import { useConnectionStore } from '../store/useConnectionStore'
|
||||
|
||||
|
||||
export interface ChatEvent {
|
||||
messageId?: string
|
||||
type: 'TOKEN' | 'TOOL_STATUS' | 'DONE' | 'ERROR'
|
||||
content?: string
|
||||
tool?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
interface QueuedMessage {
|
||||
destination: string
|
||||
body: string
|
||||
}
|
||||
|
||||
class StompService {
|
||||
private client: Client | null = null
|
||||
private reconnectAttempt = 0
|
||||
private reconnectTimeoutId: any = null
|
||||
private messageQueue: QueuedMessage[] = []
|
||||
private messageListeners: Array<(event: ChatEvent) => void> = []
|
||||
private maxQueueSize = 20
|
||||
|
||||
private getWsUrl(): string {
|
||||
const host = window.location.hostname || 'localhost'
|
||||
const port = window.location.port ? window.location.port : '9332'
|
||||
// If dev server port 9333 or similar, connect to 9332 if port is 9333, else same port
|
||||
const targetPort = port === '9333' || port === '5173' ? '9332' : port
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${protocol}//${host}:${targetPort}/ws`
|
||||
}
|
||||
|
||||
public connect() {
|
||||
if (this.client && this.client.active) {
|
||||
return
|
||||
}
|
||||
|
||||
useConnectionStore.getState().setStatus('connecting')
|
||||
|
||||
this.client = new Client({
|
||||
brokerURL: this.getWsUrl(),
|
||||
reconnectDelay: 0, // Manual reconnect management for exponential backoff + jitter
|
||||
heartbeatIncoming: 10000,
|
||||
heartbeatOutgoing: 10000,
|
||||
onConnect: () => {
|
||||
console.log('[StompService] Connected to WebSocket')
|
||||
this.reconnectAttempt = 0
|
||||
useConnectionStore.getState().setStatus('connected')
|
||||
|
||||
// Subscribe to user chat events
|
||||
this.client?.subscribe('/user/queue/chat-events', (message) => {
|
||||
try {
|
||||
const event: ChatEvent = JSON.parse(message.body)
|
||||
this.notifyMessageListeners(event)
|
||||
} catch (e) {
|
||||
console.error('[StompService] Failed to parse message body:', e)
|
||||
}
|
||||
})
|
||||
|
||||
// Flush offline queue
|
||||
this.flushQueue()
|
||||
},
|
||||
onWebSocketClose: () => {
|
||||
console.warn('[StompService] WebSocket connection closed')
|
||||
useConnectionStore.getState().setStatus('disconnected')
|
||||
this.scheduleReconnect()
|
||||
},
|
||||
onStompError: (frame) => {
|
||||
console.error('[StompService] STOMP error frame received:', frame.headers['message'])
|
||||
useConnectionStore.getState().setStatus('error')
|
||||
this.scheduleReconnect()
|
||||
},
|
||||
onWebSocketError: (event) => {
|
||||
console.error('[StompService] WebSocket error:', event)
|
||||
useConnectionStore.getState().setStatus('error')
|
||||
}
|
||||
})
|
||||
|
||||
this.client.activate()
|
||||
}
|
||||
|
||||
public disconnect() {
|
||||
if (this.reconnectTimeoutId) {
|
||||
clearTimeout(this.reconnectTimeoutId)
|
||||
this.reconnectTimeoutId = null
|
||||
}
|
||||
if (this.client) {
|
||||
this.client.deactivate()
|
||||
this.client = null
|
||||
}
|
||||
useConnectionStore.getState().setStatus('disconnected')
|
||||
}
|
||||
|
||||
private scheduleReconnect() {
|
||||
if (this.reconnectTimeoutId) return
|
||||
|
||||
// Base exponential backoff: 1s, 2s, 4s, 8s, 16s, capped at 30s
|
||||
const baseDelay = Math.min(1000 * Math.pow(2, this.reconnectAttempt), 30000)
|
||||
// Jitter: ±50% (0.5 to 1.5 multiplier)
|
||||
const jitter = 0.5 + Math.random()
|
||||
const delay = Math.round(baseDelay * jitter)
|
||||
|
||||
this.reconnectAttempt++
|
||||
console.log(`[StompService] Scheduling reconnect attempt #${this.reconnectAttempt} in ${delay}ms`)
|
||||
|
||||
this.reconnectTimeoutId = setTimeout(() => {
|
||||
this.reconnectTimeoutId = null
|
||||
if (this.client) {
|
||||
this.client.activate()
|
||||
} else {
|
||||
this.connect()
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
public publish(destination: string, payload: object): boolean {
|
||||
const body = JSON.stringify(payload)
|
||||
const isConnected = this.client && this.client.connected
|
||||
|
||||
if (isConnected) {
|
||||
this.client!.publish({ destination, body })
|
||||
return true
|
||||
} else {
|
||||
if (this.messageQueue.length >= this.maxQueueSize) {
|
||||
console.warn('[StompService] Queue limit reached (20). Message dropped.')
|
||||
return false
|
||||
}
|
||||
this.messageQueue.push({ destination, body })
|
||||
useConnectionStore.getState().setQueuedCount(this.messageQueue.length)
|
||||
console.log(`[StompService] Message queued offline (${this.messageQueue.length}/${this.maxQueueSize})`)
|
||||
|
||||
// Ensure we are trying to reconnect
|
||||
this.connect()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private flushQueue() {
|
||||
if (!this.client || !this.client.connected) return
|
||||
|
||||
while (this.messageQueue.length > 0) {
|
||||
const msg = this.messageQueue.shift()
|
||||
if (msg) {
|
||||
this.client.publish({ destination: msg.destination, body: msg.body })
|
||||
}
|
||||
}
|
||||
useConnectionStore.getState().setQueuedCount(0)
|
||||
console.log('[StompService] Offline message queue flushed')
|
||||
}
|
||||
|
||||
public onMessage(callback: (event: ChatEvent) => void): () => void {
|
||||
this.messageListeners.push(callback)
|
||||
return () => {
|
||||
this.messageListeners = this.messageListeners.filter((cb) => cb !== callback)
|
||||
}
|
||||
}
|
||||
|
||||
private notifyMessageListeners(event: ChatEvent) {
|
||||
this.messageListeners.forEach((listener) => {
|
||||
try {
|
||||
listener(event)
|
||||
} catch (e) {
|
||||
console.error('[StompService] Error in message listener:', e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public getQueueLength(): number {
|
||||
return this.messageQueue.length
|
||||
}
|
||||
}
|
||||
|
||||
export const stompService = new StompService()
|
||||
@@ -1,5 +1,7 @@
|
||||
import { create } from 'zustand'
|
||||
import { Client } from '@stomp/stompjs'
|
||||
import { stompService } from '../services/StompService'
|
||||
import type { ChatEvent } from '../services/StompService'
|
||||
|
||||
|
||||
export type Role = 'user' | 'agent'
|
||||
|
||||
@@ -9,96 +11,70 @@ export interface Message {
|
||||
content: string
|
||||
isStreaming?: boolean
|
||||
toolStatus?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
interface ChatState {
|
||||
conversationId: string
|
||||
messages: Message[]
|
||||
isRightPanelOpen: boolean
|
||||
isConnected: boolean
|
||||
isConnecting: boolean
|
||||
stompClient: Client | null
|
||||
activeAgentMessageId: string | null
|
||||
setConversationId: (id: string) => void
|
||||
setMessages: (messages: Message[]) => void
|
||||
initStomp: () => void
|
||||
sendMessage: (content: string, activeCampaignId?: string) => void
|
||||
addMessage: (message: Message) => void
|
||||
updateMessage: (id: string, updater: (msg: Message) => Message) => void
|
||||
toggleRightPanel: () => void
|
||||
conversationId: string
|
||||
}
|
||||
|
||||
let isStompInitialized = false
|
||||
|
||||
export const useChatStore = create<ChatState>((set, get) => ({
|
||||
conversationId: '',
|
||||
messages: [],
|
||||
isRightPanelOpen: false,
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
stompClient: null,
|
||||
activeAgentMessageId: null,
|
||||
conversationId: crypto.randomUUID(),
|
||||
toggleRightPanel: () => set((state) => ({ isRightPanelOpen: !state.isRightPanelOpen })),
|
||||
|
||||
setConversationId: (id: string) => set({ conversationId: id }),
|
||||
|
||||
setMessages: (messages: Message[]) => set({ messages }),
|
||||
|
||||
initStomp: () => {
|
||||
if (get().stompClient) return;
|
||||
if (isStompInitialized) return
|
||||
isStompInitialized = true
|
||||
|
||||
set({ isConnecting: true })
|
||||
stompService.connect()
|
||||
stompService.onMessage((event: ChatEvent) => {
|
||||
const msgId = event.messageId
|
||||
if (!msgId) return
|
||||
|
||||
const client = new Client({
|
||||
brokerURL: 'ws://localhost:9332/ws',
|
||||
reconnectDelay: 5000,
|
||||
heartbeatIncoming: 10000,
|
||||
heartbeatOutgoing: 10000,
|
||||
onConnect: () => {
|
||||
set({ isConnected: true, isConnecting: false })
|
||||
|
||||
client.subscribe('/user/queue/chat-events', (message) => {
|
||||
const event = JSON.parse(message.body);
|
||||
const msgId = event.messageId;
|
||||
|
||||
if (!msgId) return;
|
||||
|
||||
if (event.type === 'TOKEN') {
|
||||
get().updateMessage(msgId, (msg) => ({
|
||||
...msg,
|
||||
content: msg.content + event.content
|
||||
}));
|
||||
} else if (event.type === 'TOOL_STATUS') {
|
||||
get().updateMessage(msgId, (msg) => ({
|
||||
...msg,
|
||||
toolStatus: event.status === 'running' ? `Executing ${event.tool}...` : ''
|
||||
}));
|
||||
} else if (event.type === 'DONE') {
|
||||
get().updateMessage(msgId, (msg) => ({
|
||||
...msg,
|
||||
isStreaming: false
|
||||
}));
|
||||
} else if (event.type === 'ERROR') {
|
||||
get().updateMessage(msgId, (msg) => ({
|
||||
...msg,
|
||||
content: msg.content + '\n[ERROR] ' + event.content,
|
||||
isStreaming: false
|
||||
}));
|
||||
}
|
||||
});
|
||||
},
|
||||
onWebSocketClose: () => {
|
||||
set({ isConnected: false, isConnecting: false })
|
||||
},
|
||||
onStompError: (frame) => {
|
||||
console.error('Broker error', frame.headers['message'])
|
||||
set({ isConnected: false, isConnecting: false })
|
||||
if (event.type === 'TOKEN') {
|
||||
get().updateMessage(msgId, (msg) => ({
|
||||
...msg,
|
||||
content: msg.content + (event.content || ''),
|
||||
isStreaming: true
|
||||
}))
|
||||
} else if (event.type === 'TOOL_STATUS') {
|
||||
get().updateMessage(msgId, (msg) => ({
|
||||
...msg,
|
||||
toolStatus: event.status === 'running' ? event.tool || 'running' : ''
|
||||
}))
|
||||
} else if (event.type === 'DONE') {
|
||||
get().updateMessage(msgId, (msg) => ({
|
||||
...msg,
|
||||
isStreaming: false,
|
||||
toolStatus: undefined
|
||||
}))
|
||||
} else if (event.type === 'ERROR') {
|
||||
get().updateMessage(msgId, (msg) => ({
|
||||
...msg,
|
||||
content: msg.content + '\n[ERROR] ' + (event.content || ''),
|
||||
isStreaming: false,
|
||||
toolStatus: undefined
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
client.activate()
|
||||
set({ stompClient: client })
|
||||
},
|
||||
|
||||
sendMessage: (content: string, activeCampaignId?: string) => {
|
||||
const client = get().stompClient
|
||||
if (!client || !client.connected) {
|
||||
console.error('STOMP client is not connected')
|
||||
return
|
||||
}
|
||||
|
||||
const userMessage: Message = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
@@ -116,15 +92,14 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
isStreaming: true
|
||||
})
|
||||
|
||||
client.publish({
|
||||
destination: '/app/chat',
|
||||
body: JSON.stringify({
|
||||
conversationId: get().conversationId,
|
||||
messageId: agentMessageId,
|
||||
prompt: content,
|
||||
activeCampaignId
|
||||
})
|
||||
})
|
||||
const payload = {
|
||||
conversationId: get().conversationId,
|
||||
messageId: agentMessageId,
|
||||
prompt: content,
|
||||
activeCampaignId
|
||||
}
|
||||
|
||||
stompService.publish('/app/chat', payload)
|
||||
},
|
||||
|
||||
addMessage: (message: Message) => {
|
||||
|
||||
17
src/store/useConnectionStore.ts
Normal file
17
src/store/useConnectionStore.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
export type ConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error'
|
||||
|
||||
interface ConnectionState {
|
||||
status: ConnectionStatus
|
||||
queuedCount: number
|
||||
setStatus: (status: ConnectionStatus) => void
|
||||
setQueuedCount: (count: number) => void
|
||||
}
|
||||
|
||||
export const useConnectionStore = create<ConnectionState>((set) => ({
|
||||
status: 'disconnected',
|
||||
queuedCount: 0,
|
||||
setStatus: (status) => set({ status }),
|
||||
setQueuedCount: (count) => set({ queuedCount: count }),
|
||||
}))
|
||||
@@ -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"
|
||||
|
||||
18
start-all.sh
18
start-all.sh
@@ -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=$!
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user