From 506008c69f942082c661174f7664fe5dd8d12971 Mon Sep 17 00:00:00 2001 From: Son Phung Date: Thu, 23 Jul 2026 19:20:28 +0700 Subject: [PATCH] feat: overhaul chat architecture with persistent JdbcChatMemory, reactive UI components, and improved conversation management --- .agents/skills/sol-thinging/SKILL.md | 113 --- .../skills/sol-thinging/agents/openai.yaml | 4 - context-agent.md | 91 ++ context-fe.md | 82 ++ context-mcp-server.md | 76 ++ context.md | 190 ++-- loyalty-agent/pom.xml | 30 +- .../agent/LoyaltyAgentApplication.java | 2 + .../loyalty/agent/config/FlywayConfig.java | 25 + .../controller/ConversationController.java | 96 ++ .../core/executor/SimpleAgentExecutor.java | 16 +- .../agent/core/executor/ToolInterceptor.java | 106 +- .../core/memory/ConversationKeyResolver.java | 3 +- .../agent/core/memory/InMemoryChatMemory.java | 78 -- .../agent/core/memory/JdbcChatMemory.java | 212 ++++ .../core/orchestrator/PromptBuilder.java | 3 - .../agent/core/workflow/WorkflowExecutor.java | 15 +- .../loyalty/agent/domain/ChatRequest.java | 2 +- .../loyalty/agent/domain/Conversation.java | 14 + .../sonpx/loyalty/agent/domain/Message.java | 15 + .../agent/dto/ConversationSummaryDto.java | 20 + .../UnrecoverableToolExecutionException.java | 17 + .../repository/ConversationRepository.java | 11 +- .../InMemoryConversationRepository.java | 31 - .../repository/InMemoryMessageRepository.java | 30 - .../agent/repository/MessageRepository.java | 6 +- .../agent/service/AgentEventPublisher.java | 6 +- .../service/AgentPersistenceService.java | 109 +++ .../src/main/resources/application.yml | 21 +- .../migration/V1__init_agent_persistence.sql | 28 + .../V2__add_title_to_conversations.sql | 1 + .../core/executor/ToolInterceptorTest.java | 124 +++ .../memory/ConversationKeyResolverTest.java | 9 +- .../agent/core/memory/JdbcChatMemoryTest.java | 182 ++++ .../sonpx/loyalty/mcp/config/AppConfig.java | 9 +- .../reward/dto/CampaignRuleSummaryDto.java | 41 + .../mcp/reward/dto/CampaignSummaryDto.java | 43 + .../sonpx/loyalty/mcp/reward/model/Fw.java | 9 + .../mcp/service/CampaignRuleService.java | 75 +- .../loyalty/mcp/service/CampaignService.java | 25 +- .../loyalty/mcp/tool/CampaignRuleTools.java | 4 +- .../sonpx/loyalty/mcp/tool/CampaignTools.java | 4 +- .../loyalty/mcp/web/rest/RewardResource.java | 5 +- .../src/main/resources/application.yml | 3 + .../config/McpResponseSerializationTest.java | 131 +++ .../mcp/config/McpServerKeepAliveTest.java | 19 + memory_analysis.md | 244 ----- package.json | 4 + pnpm-lock.yaml | 506 +++++++++- sample-data/campaign-rule.json | 906 ++++++++++++++++++ src/App.css | 185 +--- src/components/campaign/CampaignList.tsx | 109 --- src/components/chat/ChatBottombar.tsx | 120 ++- src/components/chat/ChatBubble.tsx | 64 +- src/components/chat/ChatDesktopSidebar.tsx | 134 +++ src/components/chat/ChatHeader.tsx | 91 ++ src/components/chat/ChatHistoryList.tsx | 227 ++++- src/components/chat/ChatInfoPanel.tsx | 41 + src/components/chat/ChatLayout.tsx | 138 ++- src/components/chat/ChatList.tsx | 60 +- src/components/chat/ChatMobileSidebar.tsx | 91 ++ src/components/chat/ChatUserProfile.tsx | 27 + src/components/chat/MarkdownRenderer.tsx | 182 ++++ src/components/chat/ThemeToggle.tsx | 31 + src/components/chat/ToolStatusIndicator.tsx | 30 + src/components/chat/WelcomeScreen.tsx | 93 ++ src/components/ui/badge.tsx | 36 + src/components/ui/resizable.tsx | 43 + src/components/ui/table.tsx | 117 +++ src/components/ui/tooltip.tsx | 28 + src/hooks/useConversations.ts | 175 ++++ src/hooks/useIsMobile.ts | 21 + src/hooks/useTheme.ts | 51 + src/services/StompService.ts | 175 ++++ src/store/useChatStore.ts | 129 +-- src/store/useConnectionStore.ts | 17 + start-all.ps1 | 3 + start-all.sh | 18 +- vite.config.ts | 19 +- 79 files changed, 5058 insertions(+), 1193 deletions(-) delete mode 100644 .agents/skills/sol-thinging/SKILL.md delete mode 100644 .agents/skills/sol-thinging/agents/openai.yaml create mode 100644 context-agent.md create mode 100644 context-fe.md create mode 100644 context-mcp-server.md create mode 100644 loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/FlywayConfig.java delete mode 100644 loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/InMemoryChatMemory.java create mode 100644 loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/JdbcChatMemory.java create mode 100644 loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/dto/ConversationSummaryDto.java create mode 100644 loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/exception/UnrecoverableToolExecutionException.java delete mode 100644 loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/repository/InMemoryConversationRepository.java delete mode 100644 loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/repository/InMemoryMessageRepository.java create mode 100644 loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/AgentPersistenceService.java create mode 100644 loyalty-agent/src/main/resources/db/migration/V1__init_agent_persistence.sql create mode 100644 loyalty-agent/src/main/resources/db/migration/V2__add_title_to_conversations.sql create mode 100644 loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptorTest.java create mode 100644 loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/memory/JdbcChatMemoryTest.java create mode 100644 loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/dto/CampaignRuleSummaryDto.java create mode 100644 loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/dto/CampaignSummaryDto.java create mode 100644 loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/config/McpResponseSerializationTest.java create mode 100644 loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/config/McpServerKeepAliveTest.java delete mode 100644 memory_analysis.md create mode 100644 sample-data/campaign-rule.json delete mode 100644 src/components/campaign/CampaignList.tsx create mode 100644 src/components/chat/ChatDesktopSidebar.tsx create mode 100644 src/components/chat/ChatHeader.tsx create mode 100644 src/components/chat/ChatInfoPanel.tsx create mode 100644 src/components/chat/ChatMobileSidebar.tsx create mode 100644 src/components/chat/ChatUserProfile.tsx create mode 100644 src/components/chat/MarkdownRenderer.tsx create mode 100644 src/components/chat/ThemeToggle.tsx create mode 100644 src/components/chat/ToolStatusIndicator.tsx create mode 100644 src/components/chat/WelcomeScreen.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/resizable.tsx create mode 100644 src/components/ui/table.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/hooks/useConversations.ts create mode 100644 src/hooks/useIsMobile.ts create mode 100644 src/hooks/useTheme.ts create mode 100644 src/services/StompService.ts create mode 100644 src/store/useConnectionStore.ts diff --git a/.agents/skills/sol-thinging/SKILL.md b/.agents/skills/sol-thinging/SKILL.md deleted file mode 100644 index 29c57b8..0000000 --- a/.agents/skills/sol-thinging/SKILL.md +++ /dev/null @@ -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. diff --git a/.agents/skills/sol-thinging/agents/openai.yaml b/.agents/skills/sol-thinging/agents/openai.yaml deleted file mode 100644 index a7a393a..0000000 --- a/.agents/skills/sol-thinging/agents/openai.yaml +++ /dev/null @@ -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." diff --git a/context-agent.md b/context-agent.md new file mode 100644 index 0000000..e172947 --- /dev/null +++ b/context-agent.md @@ -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. diff --git a/context-fe.md b/context-fe.md new file mode 100644 index 0000000..03178e5 --- /dev/null +++ b/context-fe.md @@ -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. diff --git a/context-mcp-server.md b/context-mcp-server.md new file mode 100644 index 0000000..8d0f7de --- /dev/null +++ b/context-mcp-server.md @@ -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` and `OperationResult` 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`: +- `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`**: 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`. diff --git a/context.md b/context.md index f47bdb0..b80def8 100644 --- a/context.md +++ b/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` | diff --git a/loyalty-agent/pom.xml b/loyalty-agent/pom.xml index 6dc5105..cfaee9e 100644 --- a/loyalty-agent/pom.xml +++ b/loyalty-agent/pom.xml @@ -59,6 +59,29 @@ spring-boot-starter-oauth2-client + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.postgresql + postgresql + runtime + + + + + org.flywaydb + flyway-core + + + org.flywaydb + flyway-database-postgresql + + org.projectlombok @@ -82,12 +105,7 @@ true - - - org.springdoc - springdoc-openapi-starter-webmvc-ui - 2.7.0 - + diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/LoyaltyAgentApplication.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/LoyaltyAgentApplication.java index af01d30..9913082 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/LoyaltyAgentApplication.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/LoyaltyAgentApplication.java @@ -10,3 +10,5 @@ public class LoyaltyAgentApplication { SpringApplication.run(LoyaltyAgentApplication.class, args); } } + + diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/FlywayConfig.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/FlywayConfig.java new file mode 100644 index 0000000..d02a57d --- /dev/null +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/config/FlywayConfig.java @@ -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; + } +} diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/controller/ConversationController.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/controller/ConversationController.java index 05654d0..6cefdcb 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/controller/ConversationController.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/controller/ConversationController.java @@ -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> getConversations() { + List conversations = conversationRepository.findAllByOrderByUpdatedAtDesc(); + List dtos = new ArrayList<>(); + + for (Conversation c : conversations) { + List 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 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 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 messages = messageRepository.findByConversationIdOrderByCreatedAtAsc(id); return ResponseEntity.ok(messages); } + + @PutMapping("/{id}") + public ResponseEntity updateTitle( + @PathVariable("id") String id, + @RequestBody Map 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 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 deleteConversation(@PathVariable("id") String id) { + List messages = messageRepository.findByConversationIdOrderByCreatedAtAsc(id); + if (!messages.isEmpty()) { + messageRepository.deleteAll(messages); + } + conversationRepository.deleteById(id); + return ResponseEntity.noContent().build(); + } } + + diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/SimpleAgentExecutor.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/SimpleAgentExecutor.java index 9e0137a..d76500a 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/SimpleAgentExecutor.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/SimpleAgentExecutor.java @@ -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); } }, diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptor.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptor.java index 9591f40..0b021cb 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptor.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptor.java @@ -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> mcpSyncClientsProvider; public List 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 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"); diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/ConversationKeyResolver.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/ConversationKeyResolver.java index ad71887..1132ff7 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/ConversationKeyResolver.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/ConversationKeyResolver.java @@ -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"); } } diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/InMemoryChatMemory.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/InMemoryChatMemory.java deleted file mode 100644 index d0515ca..0000000 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/InMemoryChatMemory.java +++ /dev/null @@ -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> conversationHistory = new ConcurrentHashMap<>(); - private final AgentProperties agentProperties; - - public InMemoryChatMemory(AgentProperties agentProperties) { - this.agentProperties = agentProperties; - } - - @Override - public void add(String conversationId, List 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 messages) { - int chars = messages.stream() - .mapToInt(m -> m.getText() != null ? m.getText().length() : 0) - .sum(); - return chars / agentProperties.getCharsPerToken(); - } - - @Override - public List get(String conversationId) { - return conversationHistory.getOrDefault(conversationId, new ArrayList<>()); - } - - @Override - public void clear(String conversationId) { - conversationHistory.remove(conversationId); - } -} diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/JdbcChatMemory.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/JdbcChatMemory.java new file mode 100644 index 0000000..f063a8f --- /dev/null +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/memory/JdbcChatMemory.java @@ -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 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 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 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 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 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 get(String conversationId) { + List wrappers = loadMessageWrappers(conversationId); + return wrappers.stream().map(w -> w.message).toList(); + } + + private List 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 map = objectMapper.readValue(metadataJson, new TypeReference<>() {}); + if (map.containsKey("toolCalls")) { + List dtos = objectMapper.convertValue(map.get("toolCalls"), new TypeReference<>() {}); + List 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 map = objectMapper.readValue(metadataJson, new TypeReference<>() {}); + if (map.containsKey("responses")) { + List dtos = objectMapper.convertValue(map.get("responses"), new TypeReference<>() {}); + List 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) {} +} diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/PromptBuilder.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/PromptBuilder.java index d2a1277..551e98b 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/PromptBuilder.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/orchestrator/PromptBuilder.java @@ -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()) { diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowExecutor.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowExecutor.java index fdbdc85..b1acb39 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowExecutor.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/core/workflow/WorkflowExecutor.java @@ -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 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); } }, diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/domain/ChatRequest.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/domain/ChatRequest.java index f0a858d..36a2271 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/domain/ChatRequest.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/domain/ChatRequest.java @@ -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) { } diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/domain/Conversation.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/domain/Conversation.java index 1d1a4a4..6ee2244 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/domain/Conversation.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/domain/Conversation.java @@ -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; } diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/domain/Message.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/domain/Message.java index 28d6dbb..8b11b21 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/domain/Message.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/domain/Message.java @@ -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; } diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/dto/ConversationSummaryDto.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/dto/ConversationSummaryDto.java new file mode 100644 index 0000000..2ee5a8c --- /dev/null +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/dto/ConversationSummaryDto.java @@ -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; +} diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/exception/UnrecoverableToolExecutionException.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/exception/UnrecoverableToolExecutionException.java new file mode 100644 index 0000000..04821f3 --- /dev/null +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/exception/UnrecoverableToolExecutionException.java @@ -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); + } +} diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/repository/ConversationRepository.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/repository/ConversationRepository.java index 290c003..1806d5b 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/repository/ConversationRepository.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/repository/ConversationRepository.java @@ -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 findById(String id); - List findAll(); +@Repository +public interface ConversationRepository extends JpaRepository { + List findAllByOrderByUpdatedAtDesc(); } + diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/repository/InMemoryConversationRepository.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/repository/InMemoryConversationRepository.java deleted file mode 100644 index c097f22..0000000 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/repository/InMemoryConversationRepository.java +++ /dev/null @@ -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 store = new ConcurrentHashMap<>(); - - @Override - public Conversation save(Conversation conversation) { - store.put(conversation.getId(), conversation); - return conversation; - } - - @Override - public Optional findById(String id) { - return Optional.ofNullable(store.get(id)); - } - - @Override - public List findAll() { - return new ArrayList<>(store.values()); - } -} diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/repository/InMemoryMessageRepository.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/repository/InMemoryMessageRepository.java deleted file mode 100644 index 2394883..0000000 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/repository/InMemoryMessageRepository.java +++ /dev/null @@ -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 store = new ConcurrentHashMap<>(); - - @Override - public Message save(Message message) { - store.put(message.getId(), message); - return message; - } - - @Override - public List findByConversationIdOrderByCreatedAtAsc(String conversationId) { - return store.values().stream() - .filter(m -> conversationId.equals(m.getConversationId())) - .sorted(Comparator.comparing(Message::getCreatedAt)) - .collect(Collectors.toList()); - } -} diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/repository/MessageRepository.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/repository/MessageRepository.java index e9cb12c..6d74136 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/repository/MessageRepository.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/repository/MessageRepository.java @@ -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 { List findByConversationIdOrderByCreatedAtAsc(String conversationId); } diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/AgentEventPublisher.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/AgentEventPublisher.java index 849140a..b5224a4 100644 --- a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/AgentEventPublisher.java +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/AgentEventPublisher.java @@ -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); } } diff --git a/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/AgentPersistenceService.java b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/AgentPersistenceService.java new file mode 100644 index 0000000..8806d89 --- /dev/null +++ b/loyalty-agent/src/main/java/dev/sonpx/loyalty/agent/service/AgentPersistenceService.java @@ -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; + } +} + diff --git a/loyalty-agent/src/main/resources/application.yml b/loyalty-agent/src/main/resources/application.yml index 384984c..d1db23e 100644 --- a/loyalty-agent/src/main/resources/application.yml +++ b/loyalty-agent/src/main/resources/application.yml @@ -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} + diff --git a/loyalty-agent/src/main/resources/db/migration/V1__init_agent_persistence.sql b/loyalty-agent/src/main/resources/db/migration/V1__init_agent_persistence.sql new file mode 100644 index 0000000..6561368 --- /dev/null +++ b/loyalty-agent/src/main/resources/db/migration/V1__init_agent_persistence.sql @@ -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); diff --git a/loyalty-agent/src/main/resources/db/migration/V2__add_title_to_conversations.sql b/loyalty-agent/src/main/resources/db/migration/V2__add_title_to_conversations.sql new file mode 100644 index 0000000..2a213a4 --- /dev/null +++ b/loyalty-agent/src/main/resources/db/migration/V2__add_title_to_conversations.sql @@ -0,0 +1 @@ +ALTER TABLE conversations ADD COLUMN title VARCHAR(255); diff --git a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptorTest.java b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptorTest.java new file mode 100644 index 0000000..2ebbc87 --- /dev/null +++ b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/executor/ToolInterceptorTest.java @@ -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> 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 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 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 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(); + } +} diff --git a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/memory/ConversationKeyResolverTest.java b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/memory/ConversationKeyResolverTest.java index cb5a24e..01d8199 100644 --- a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/memory/ConversationKeyResolverTest.java +++ b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/memory/ConversationKeyResolverTest.java @@ -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")); } } diff --git a/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/memory/JdbcChatMemoryTest.java b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/memory/JdbcChatMemoryTest.java new file mode 100644 index 0000000..8e0a60a --- /dev/null +++ b/loyalty-agent/src/test/java/dev/sonpx/loyalty/agent/core/memory/JdbcChatMemoryTest.java @@ -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 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 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 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 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 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"); + } +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/config/AppConfig.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/config/AppConfig.java index b2a0bee..e154ae5 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/config/AppConfig.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/config/AppConfig.java @@ -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; } } + diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/dto/CampaignRuleSummaryDto.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/dto/CampaignRuleSummaryDto.java new file mode 100644 index 0000000..788aa9d --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/dto/CampaignRuleSummaryDto.java @@ -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; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/dto/CampaignSummaryDto.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/dto/CampaignSummaryDto.java new file mode 100644 index 0000000..2fb5ac9 --- /dev/null +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/dto/CampaignSummaryDto.java @@ -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; +} diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/model/Fw.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/model/Fw.java index cd4f102..09ac05a 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/model/Fw.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/reward/model/Fw.java @@ -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; } + diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CampaignRuleService.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CampaignRuleService.java index 77e3f5c..dc4f6f5 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CampaignRuleService.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CampaignRuleService.java @@ -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> getAll(CampaignRuleCriteria criteria) { + public Result> getAll(CampaignRuleCriteria criteria) { List rules = campaignRuleClient.getAll(criteria, Pageable.ofSize(10)); - return Result.of(rules, """ + List 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 count(CampaignRuleCriteria criteria) { Long count = campaignRuleClient.count(criteria); return Result.of(count, """ @@ -44,6 +64,7 @@ public class CampaignRuleService { public Result 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> findActiveByIds(String ids) { List idList = java.util.Arrays.asList(ids.split(",\\s*")); List 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 generateId() { String id = campaignRuleClient.generateId(); return Result.of(id, """ @@ -93,3 +163,4 @@ public class CampaignRuleService { """); } } + diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CampaignService.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CampaignService.java index 687044b..584cc91 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CampaignService.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/service/CampaignService.java @@ -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> getAll(CampaignCriteria criteria) { + public Result> getAll(CampaignCriteria criteria) { List campaigns = campaignClient.getAll(criteria, Pageable.ofSize(5)); - return Result.of(campaigns, """ + List 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 count(CampaignCriteria criteria) { Long count = campaignClient.count(criteria); return Result.of(count, """ @@ -91,3 +111,4 @@ public class CampaignService { """); } } + diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignRuleTools.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignRuleTools.java index d93ff03..056e32a 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignRuleTools.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignRuleTools.java @@ -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> searchRules(String search) { + public Result> 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 countRules(String search) { CampaignRuleCriteria criteria = new CampaignRuleCriteria(); diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignTools.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignTools.java index ef681f9..c3f8b77 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignTools.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/tool/CampaignTools.java @@ -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> searchCampaigns(String search) { + public Result> 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 countCampaigns(String search) { CampaignCriteria criteria = new CampaignCriteria(); diff --git a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/web/rest/RewardResource.java b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/web/rest/RewardResource.java index c310793..3812d62 100644 --- a/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/web/rest/RewardResource.java +++ b/loyalty-mcp-server/src/main/java/dev/sonpx/loyalty/mcp/web/rest/RewardResource.java @@ -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>> getAll(CampaignCriteria criteria) { + public ResponseEntity>> getAll(CampaignCriteria criteria) { return ResponseEntity.ok(campaignService.getAll(criteria)); } + } \ No newline at end of file diff --git a/loyalty-mcp-server/src/main/resources/application.yml b/loyalty-mcp-server/src/main/resources/application.yml index 38d0abc..5f41c53 100644 --- a/loyalty-mcp-server/src/main/resources/application.yml +++ b/loyalty-mcp-server/src/main/resources/application.yml @@ -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 diff --git a/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/config/McpResponseSerializationTest.java b/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/config/McpResponseSerializationTest.java new file mode 100644 index 0000000..993625a --- /dev/null +++ b/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/config/McpResponseSerializationTest.java @@ -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"); + } +} diff --git a/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/config/McpServerKeepAliveTest.java b/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/config/McpServerKeepAliveTest.java new file mode 100644 index 0000000..876afc9 --- /dev/null +++ b/loyalty-mcp-server/src/test/java/dev/sonpx/loyalty/mcp/config/McpServerKeepAliveTest.java @@ -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)); + } +} diff --git a/memory_analysis.md b/memory_analysis.md deleted file mode 100644 index 8b61e54..0000000 --- a/memory_analysis.md +++ /dev/null @@ -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 + 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 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` | -| 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 diff --git a/package.json b/package.json index 59863bb..1b0fdc5 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfbe62f..63b864e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/sample-data/campaign-rule.json b/sample-data/campaign-rule.json new file mode 100644 index 0000000..ac6fd13 --- /dev/null +++ b/sample-data/campaign-rule.json @@ -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 + } + } +] diff --git a/src/App.css b/src/App.css index f90339d..8f624a5 100644 --- a/src/App.css +++ b/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 */ diff --git a/src/components/campaign/CampaignList.tsx b/src/components/campaign/CampaignList.tsx deleted file mode 100644 index 95c7877..0000000 --- a/src/components/campaign/CampaignList.tsx +++ /dev/null @@ -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([]); - const [loading, setLoading] = useState(true); - const [selectedCampaignId, setSelectedCampaignId] = useState(null); - const [rules, setRules] = useState([]); - 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
Loading campaigns...
; - } - - if (campaigns.length === 0) { - return
No campaigns found.
; - } - - return ( -
- {campaigns.map((camp) => ( -
-
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' : ''}`}> -
-
-
{camp.name || 'Unnamed Campaign'}
-
- - - {camp.status === 'A' ? 'Active' : camp.status} - - {camp.id} -
-
-
- - {selectedCampaignId === camp.id && ( -
- {loadingRules ? ( -
Loading rules...
- ) : rules.length === 0 ? ( -
No rules found.
- ) : ( - rules.map((rule, idx) => ( -
-
{rule.name || 'Unnamed Rule'}
-
{rule.description || 'No description'}
- {rule.status && ( -
Status: {rule.status}
- )} -
- )) - )} -
- )} -
- ))} -
- ); -} diff --git a/src/components/chat/ChatBottombar.tsx b/src/components/chat/ChatBottombar.tsx index 98dec69..9d446d4 100644 --- a/src/components/chat/ChatBottombar.tsx +++ b/src/components/chat/ChatBottombar.tsx @@ -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) => { @@ -26,35 +30,81 @@ export function ChatBottombar() { } return ( -
-
-