refactor: migrate loyalty-agent and loyalty-mcp-server components to a new modular structure with updated specs and frontend integration.
This commit is contained in:
16
.codegraph/.gitignore
vendored
Normal file
16
.codegraph/.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# CodeGraph data files
|
||||||
|
# These are local to each machine and should not be committed
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.db-wal
|
||||||
|
*.db-shm
|
||||||
|
|
||||||
|
# Cache
|
||||||
|
cache/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Hook markers
|
||||||
|
.dirty
|
||||||
7
.env.example
Normal file
7
.env.example
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
KEYCLOAK_CLIENT_SECRET=your_client_secret_here
|
||||||
|
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
|
||||||
|
|
||||||
|
VITE_AGENT_HTTP_URL=http://localhost:9332
|
||||||
|
VITE_AGENT_WS_URL=ws://localhost:9332
|
||||||
104
.gitignore
vendored
104
.gitignore
vendored
@@ -1,39 +1,67 @@
|
|||||||
HELP.md
|
# Logs
|
||||||
target/
|
logs
|
||||||
!.mvn/wrapper/maven-wrapper.jar
|
|
||||||
!**/src/main/**/target/
|
|
||||||
!**/src/test/**/target/
|
|
||||||
|
|
||||||
### STS ###
|
|
||||||
.apt_generated
|
|
||||||
.classpath
|
|
||||||
.factorypath
|
|
||||||
.project
|
|
||||||
.settings
|
|
||||||
.springBeans
|
|
||||||
.sts4-cache
|
|
||||||
|
|
||||||
### IntelliJ IDEA ###
|
|
||||||
.idea
|
|
||||||
*.iws
|
|
||||||
*.iml
|
|
||||||
*.ipr
|
|
||||||
|
|
||||||
### NetBeans ###
|
|
||||||
/nbproject/private/
|
|
||||||
/nbbuild/
|
|
||||||
/dist/
|
|
||||||
/nbdist/
|
|
||||||
/.nb-gradle/
|
|
||||||
build/
|
|
||||||
!**/src/main/**/build/
|
|
||||||
!**/src/test/**/build/
|
|
||||||
|
|
||||||
### VS Code ###
|
|
||||||
.vscode/
|
|
||||||
|
|
||||||
### Mac OS ###
|
|
||||||
.DS_Store
|
|
||||||
|
|
||||||
### Others ###
|
|
||||||
*.log
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Node & Frontend
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*.local
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
# Java / Maven
|
||||||
|
target/
|
||||||
|
pom.xml.tag
|
||||||
|
pom.xml.releaseBackup
|
||||||
|
pom.xml.versionsBackup
|
||||||
|
pom.xml.next
|
||||||
|
release.properties
|
||||||
|
dependency-reduced-pom.xml
|
||||||
|
buildNumber.properties
|
||||||
|
.mvn/timing.properties
|
||||||
|
.mvn/wrapper/maven-wrapper.jar
|
||||||
|
|
||||||
|
# Compiled class file
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Package Files
|
||||||
|
*.jar
|
||||||
|
*.war
|
||||||
|
*.ear
|
||||||
|
*.zip
|
||||||
|
*.tar.gz
|
||||||
|
*.rar
|
||||||
|
|
||||||
|
# Virtual machine crash logs
|
||||||
|
hs_err_pid*
|
||||||
|
replay_pid*
|
||||||
|
|
||||||
|
# Spring Boot
|
||||||
|
HELP.md
|
||||||
|
token.json
|
||||||
|
|
||||||
|
openspec
|
||||||
|
.agent
|
||||||
|
codegraph
|
||||||
|
log
|
||||||
8
.oxlintrc.json
Normal file
8
.oxlintrc.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["react", "typescript", "oxc"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "error",
|
||||||
|
"react/only-export-components": ["warn", { "allowConstantExport": true }]
|
||||||
|
}
|
||||||
|
}
|
||||||
33
README.md
33
README.md
@@ -1,4 +1,31 @@
|
|||||||
./mvnw package -Pgen
|
# Loyalty Agent Service
|
||||||
|
|
||||||
|
This project consists of a 3-module architecture:
|
||||||
./mvnw compile spring-boot:run
|
1. `loyalty-mcp-server`: Domain MCP tools and operations
|
||||||
|
2. `loyalty-agent`: Orchestration and BFF layer
|
||||||
|
3. Frontend (React/Vite in the root directory)
|
||||||
|
|
||||||
|
## Startup Instructions
|
||||||
|
|
||||||
|
### 1. Prerequisites
|
||||||
|
- Java 21+
|
||||||
|
- Node.js & pnpm
|
||||||
|
- `.env` file created (see `.env.example`)
|
||||||
|
|
||||||
|
### 2. Start Backend Modules
|
||||||
|
You can use the provided startup script:
|
||||||
|
```bash
|
||||||
|
./start-all.sh
|
||||||
|
```
|
||||||
|
Or start them individually in separate terminals:
|
||||||
|
```bash
|
||||||
|
./mvnw spring-boot:run -pl loyalty-mcp-server
|
||||||
|
./mvnw spring-boot:run -pl loyalty-agent
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Start Frontend
|
||||||
|
In a new terminal:
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|||||||
18
components.json
Normal file
18
components.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "tailwind.config.js",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "slate",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
153
context.md
Normal file
153
context.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Frontend: root directory
|
||||||
|
|
||||||
|
- 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`.
|
||||||
|
|
||||||
|
### `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://localhost:11434`.
|
||||||
|
- Default model in `application.yml`: `qwen3.5:2b`.
|
||||||
|
- MCP client connects to `${MCP_SERVER_URL:http://localhost:9331}`.
|
||||||
|
- Agent event types sent to the UI: `TOKEN`, `TOOL_STATUS`, `DONE`, `ERROR`.
|
||||||
|
|
||||||
|
### `loyalty-mcp-server`
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
|
||||||
|
## External Services And Environment
|
||||||
|
|
||||||
|
Create `.env` from `.env.example` for local development.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./start-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvnw spring-boot:run -pl loyalty-mcp-server
|
||||||
|
./mvnw spring-boot:run -pl loyalty-agent
|
||||||
|
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, Lint, And Tests
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
|
||||||
|
```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`.
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
$baseUrl = "http://192.168.99.242:8081"
|
|
||||||
$specs = @(
|
|
||||||
@{ name="attribute"; url="/attribute/v3/api-docs" },
|
|
||||||
@{ name="batch-scheduler"; url="/batch-scheduler/v3/api-docs" },
|
|
||||||
@{ name="catalogue"; url="/catalogue/v3/api-docs" },
|
|
||||||
@{ name="customer"; url="/customer/v3/api-docs" },
|
|
||||||
@{ name="identity"; url="/identity/v3/api-docs" },
|
|
||||||
@{ name="marketing"; url="/marketing/v3/api-docs" },
|
|
||||||
@{ name="master"; url="/master/v3/api-docs" },
|
|
||||||
@{ name="notification"; url="/notification/v3/api-docs" },
|
|
||||||
@{ name="reward"; url="/reward/v3/api-docs" },
|
|
||||||
@{ name="transaction"; url="/transaction/v3/api-docs" }
|
|
||||||
)
|
|
||||||
|
|
||||||
$targetDir = "src/main/resources/specs"
|
|
||||||
if (-not (Test-Path $targetDir)) {
|
|
||||||
New-Item -ItemType Directory -Force -Path $targetDir | Out-Null
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($spec in $specs) {
|
|
||||||
$fullUrl = $baseUrl + $spec.url
|
|
||||||
$outputPath = Join-Path $targetDir ($spec.name + ".json")
|
|
||||||
Write-Host "Downloading $($spec.name) from $fullUrl ..."
|
|
||||||
try {
|
|
||||||
Invoke-WebRequest -Uri $fullUrl -OutFile $outputPath -UseBasicParsing
|
|
||||||
Write-Host "[OK] $($spec.name) saved to $outputPath"
|
|
||||||
} catch {
|
|
||||||
Write-Host "[WARNING] Failed to download from $fullUrl, trying alternative path..."
|
|
||||||
$altUrl = $baseUrl + "/v3/api-docs/" + $spec.name
|
|
||||||
try {
|
|
||||||
Invoke-WebRequest -Uri $altUrl -OutFile $outputPath -UseBasicParsing
|
|
||||||
Write-Host "[OK] $($spec.name) saved to $outputPath (alternative path)"
|
|
||||||
} catch {
|
|
||||||
Write-Host "[ERROR] Failed to download $($spec.name)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Write-Host "Done downloading specs."
|
|
||||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>agent-ui-temp</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
6
loyalty-agent/Dockerfile
Normal file
6
loyalty-agent/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
FROM eclipse-temurin:25-jre-alpine
|
||||||
|
VOLUME /tmp
|
||||||
|
ARG JAR_FILE=target/*.jar
|
||||||
|
COPY ${JAR_FILE} app.jar
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["java","-jar","/app.jar"]
|
||||||
151
loyalty-agent/pom.xml
Normal file
151
loyalty-agent/pom.xml
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.sonpx.loyalty</groupId>
|
||||||
|
<artifactId>loyalty-agent-service-parent</artifactId>
|
||||||
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>loyalty-agent</artifactId>
|
||||||
|
<name>loyalty-agent-service</name>
|
||||||
|
<description>AI Agent Service to orchestrate Loyalty core system</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
|
||||||
|
<!-- Spring AI MCP Client -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.ai</groupId>
|
||||||
|
<artifactId>spring-ai-starter-mcp-client</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<!-- Spring Boot Web -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- WebSocket -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring AI Ollama -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.ai</groupId>
|
||||||
|
<artifactId>spring-ai-starter-model-ollama</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- WebClient (needed for generated API client if library is webclient) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- OAuth2 Client -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-oauth2-client</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Lombok -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>${lombok.version}</version>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- MapStruct -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct</artifactId>
|
||||||
|
<version>1.6.2</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot DevTools -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-devtools</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Springdoc OpenAPI -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
<version>2.7.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Testing -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct-processor</artifactId>
|
||||||
|
<version>1.6.2</version>
|
||||||
|
</path>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>${lombok.version}</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<!-- Add generated sources to compile path even when not running gen profile -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
|
<artifactId>build-helper-maven-plugin</artifactId>
|
||||||
|
<version>3.5.0</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>add-source</id>
|
||||||
|
<phase>generate-sources</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>add-source</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<sources>
|
||||||
|
<source>${project.build.directory}/generated-sources/openapi/src/main/java</source>
|
||||||
|
</sources>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package dev.sonpx.loyalty.agent.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
|
||||||
|
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
||||||
|
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class AgentConfig {
|
||||||
|
|
||||||
|
@Value("${loyalty.core.base-url}")
|
||||||
|
private String coreBaseUrl;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RestClient coreRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
|
||||||
|
return RestClient.builder()
|
||||||
|
.baseUrl(coreBaseUrl)
|
||||||
|
.requestInterceptor((request, body, execution) -> {
|
||||||
|
org.springframework.security.core.Authentication principal =
|
||||||
|
new org.springframework.security.authentication.AnonymousAuthenticationToken(
|
||||||
|
"key", "loyalty-agent-service",
|
||||||
|
org.springframework.security.core.authority.AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
|
||||||
|
OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
|
||||||
|
.withClientRegistrationId("keycloak")
|
||||||
|
.principal(principal)
|
||||||
|
.build();
|
||||||
|
try {
|
||||||
|
OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
|
||||||
|
if (authorizedClient != null && authorizedClient.getAccessToken() != null) {
|
||||||
|
request.getHeaders().setBearerAuth(authorizedClient.getAccessToken().getTokenValue());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
return execution.execute(request, body);
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package dev.sonpx.loyalty.agent.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.config.Customizer;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.csrf(csrf -> csrf.disable())
|
||||||
|
.authorizeHttpRequests(authz -> authz
|
||||||
|
.requestMatchers("/api/v1/agent/**", "/api/v1/conversations/**", "/ws/**", "/error").permitAll()
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
)
|
||||||
|
.oauth2Login(Customizer.withDefaults())
|
||||||
|
.oauth2Client(Customizer.withDefaults());
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package dev.sonpx.loyalty.agent.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
|
||||||
|
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
|
||||||
|
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
|
||||||
|
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSocketMessageBroker
|
||||||
|
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||||||
|
config.enableSimpleBroker("/topic", "/queue");
|
||||||
|
config.setApplicationDestinationPrefixes("/app");
|
||||||
|
config.setUserDestinationPrefix("/user");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||||||
|
registry.addEndpoint("/ws")
|
||||||
|
.setAllowedOriginPatterns("*")
|
||||||
|
.setHandshakeHandler(new org.springframework.web.socket.server.support.DefaultHandshakeHandler() {
|
||||||
|
@Override
|
||||||
|
protected java.security.Principal determineUser(org.springframework.http.server.ServerHttpRequest request, org.springframework.web.socket.WebSocketHandler wsHandler, java.util.Map<String, Object> attributes) {
|
||||||
|
return new java.security.Principal() {
|
||||||
|
private final String id = java.util.UUID.randomUUID().toString();
|
||||||
|
@Override
|
||||||
|
public String getName() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package dev.sonpx.loyalty.agent.controller;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.agent.service.LoyaltyAgentService;
|
||||||
|
import org.springframework.messaging.handler.annotation.Header;
|
||||||
|
import org.springframework.messaging.handler.annotation.MessageMapping;
|
||||||
|
import org.springframework.messaging.handler.annotation.Payload;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.CrossOrigin;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@CrossOrigin(origins = "*")
|
||||||
|
public class AgentController {
|
||||||
|
|
||||||
|
private final LoyaltyAgentService agentService;
|
||||||
|
|
||||||
|
private final org.springframework.web.client.RestClient coreRestClient;
|
||||||
|
|
||||||
|
public AgentController(LoyaltyAgentService agentService, org.springframework.web.client.RestClient coreRestClient) {
|
||||||
|
this.agentService = agentService;
|
||||||
|
this.coreRestClient = coreRestClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ChatRequest(String conversationId, String messageId, String prompt, String activeCampaignId) {}
|
||||||
|
|
||||||
|
@MessageMapping("/chat")
|
||||||
|
public void chat(@Payload ChatRequest request, java.security.Principal principal) {
|
||||||
|
agentService.chat(request, principal.getName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/v1/agent/campaigns")
|
||||||
|
public Object getCampaigns(
|
||||||
|
@org.springframework.web.bind.annotation.RequestParam(defaultValue = "0") int page,
|
||||||
|
@org.springframework.web.bind.annotation.RequestParam(defaultValue = "50") int size,
|
||||||
|
@org.springframework.web.bind.annotation.RequestParam(required = false) String status,
|
||||||
|
@org.springframework.web.bind.annotation.RequestParam(required = false) String name) {
|
||||||
|
|
||||||
|
String url = String.format("/svc/reward/api/campaign/csr/list?page=%d&size=%d", page, size);
|
||||||
|
if (status != null && !status.isEmpty()) url += "&status=" + status;
|
||||||
|
if (name != null && !name.isEmpty()) url += "&name=" + name;
|
||||||
|
|
||||||
|
String response = coreRestClient.get()
|
||||||
|
.uri(url)
|
||||||
|
.retrieve()
|
||||||
|
.body(String.class);
|
||||||
|
return standardizeResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/api/v1/agent/campaigns/{campaignId}/rules")
|
||||||
|
public Object getCampaignRules(@org.springframework.web.bind.annotation.PathVariable("campaignId") String campaignId) {
|
||||||
|
String response = coreRestClient.get()
|
||||||
|
.uri("/svc/reward/api/campaign-rule/csr/list?page=0&size=100&campaignId.equals=" + campaignId)
|
||||||
|
.retrieve()
|
||||||
|
.body(String.class);
|
||||||
|
return standardizeResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object standardizeResponse(String jsonStr) {
|
||||||
|
try {
|
||||||
|
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
|
com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(jsonStr);
|
||||||
|
com.fasterxml.jackson.databind.JsonNode arrayNode = root;
|
||||||
|
if (root.has("content") && root.get("content").isArray()) arrayNode = root.get("content");
|
||||||
|
else if (root.has("data") && root.get("data").isArray()) arrayNode = root.get("data");
|
||||||
|
|
||||||
|
return mapper.convertValue(arrayNode, new com.fasterxml.jackson.core.type.TypeReference<java.util.List<java.util.Map<String, Object>>>() {});
|
||||||
|
} catch (Exception e) {
|
||||||
|
return java.util.List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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.repository.ConversationRepository;
|
||||||
|
import dev.sonpx.loyalty.agent.repository.MessageRepository;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/conversations")
|
||||||
|
@CrossOrigin(origins = "*")
|
||||||
|
public class ConversationController {
|
||||||
|
|
||||||
|
private final ConversationRepository conversationRepository;
|
||||||
|
private final MessageRepository messageRepository;
|
||||||
|
|
||||||
|
public ConversationController(ConversationRepository conversationRepository, MessageRepository messageRepository) {
|
||||||
|
this.conversationRepository = conversationRepository;
|
||||||
|
this.messageRepository = messageRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<Conversation> createConversation() {
|
||||||
|
Conversation conversation = new Conversation();
|
||||||
|
conversation.setId(UUID.randomUUID().toString());
|
||||||
|
conversation.setCreatedAt(Instant.now());
|
||||||
|
conversation.setUpdatedAt(Instant.now());
|
||||||
|
Conversation saved = conversationRepository.save(conversation);
|
||||||
|
return ResponseEntity.ok(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/messages")
|
||||||
|
public ResponseEntity<List<Message>> getMessages(@PathVariable("id") String id) {
|
||||||
|
List<Message> messages = messageRepository.findByConversationIdOrderByCreatedAtAsc(id);
|
||||||
|
return ResponseEntity.ok(messages);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package dev.sonpx.loyalty.agent.domain;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public record AgentEvent(String type, String messageId, String content, String tool, String status) {
|
||||||
|
public static AgentEvent token(String messageId, String content) {
|
||||||
|
return new AgentEvent("TOKEN", messageId, content, null, null);
|
||||||
|
}
|
||||||
|
public static AgentEvent toolStatus(String messageId, String tool, String status) {
|
||||||
|
return new AgentEvent("TOOL_STATUS", messageId, null, tool, status);
|
||||||
|
}
|
||||||
|
public static AgentEvent done(String messageId) {
|
||||||
|
return new AgentEvent("DONE", messageId, null, null, null);
|
||||||
|
}
|
||||||
|
public static AgentEvent error(String messageId, String error) {
|
||||||
|
return new AgentEvent("ERROR", messageId, error, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package dev.sonpx.loyalty.agent.domain;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class Conversation {
|
||||||
|
private String id;
|
||||||
|
private Instant createdAt;
|
||||||
|
private Instant updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.sonpx.loyalty.agent.domain;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class Message {
|
||||||
|
private String id;
|
||||||
|
private String conversationId;
|
||||||
|
private String role;
|
||||||
|
private String content;
|
||||||
|
private Instant createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package dev.sonpx.loyalty.agent.repository;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.agent.domain.Conversation;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface ConversationRepository {
|
||||||
|
Conversation save(Conversation conversation);
|
||||||
|
Optional<Conversation> findById(String id);
|
||||||
|
List<Conversation> findAll();
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package dev.sonpx.loyalty.agent.repository;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.agent.domain.Conversation;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class InMemoryConversationRepository implements ConversationRepository {
|
||||||
|
private final Map<String, Conversation> store = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Conversation save(Conversation conversation) {
|
||||||
|
store.put(conversation.getId(), conversation);
|
||||||
|
return conversation;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<Conversation> findById(String id) {
|
||||||
|
return Optional.ofNullable(store.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Conversation> findAll() {
|
||||||
|
return new ArrayList<>(store.values());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package dev.sonpx.loyalty.agent.repository;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.agent.domain.Message;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class InMemoryMessageRepository implements MessageRepository {
|
||||||
|
private final Map<String, Message> store = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Message save(Message message) {
|
||||||
|
store.put(message.getId(), message);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Message> findByConversationIdOrderByCreatedAtAsc(String conversationId) {
|
||||||
|
return store.values().stream()
|
||||||
|
.filter(m -> conversationId.equals(m.getConversationId()))
|
||||||
|
.sorted(Comparator.comparing(Message::getCreatedAt))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package dev.sonpx.loyalty.agent.repository;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.agent.domain.Message;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface MessageRepository {
|
||||||
|
Message save(Message message);
|
||||||
|
List<Message> findByConversationIdOrderByCreatedAtAsc(String conversationId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package dev.sonpx.loyalty.agent.service;
|
||||||
|
|
||||||
|
import org.springframework.ai.chat.client.ChatClient;
|
||||||
|
import org.springframework.ai.tool.ToolCallbackProvider;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class LoyaltyAgentService {
|
||||||
|
|
||||||
|
private final org.springframework.ai.chat.model.ChatModel chatModel;
|
||||||
|
private final List<ToolCallbackProvider> toolProviders;
|
||||||
|
private final org.springframework.messaging.simp.SimpMessagingTemplate messagingTemplate;
|
||||||
|
|
||||||
|
public LoyaltyAgentService(org.springframework.ai.chat.model.ChatModel chatModel, List<ToolCallbackProvider> toolProviders, org.springframework.messaging.simp.SimpMessagingTemplate messagingTemplate) {
|
||||||
|
this.chatModel = chatModel;
|
||||||
|
this.toolProviders = toolProviders;
|
||||||
|
this.messagingTemplate = messagingTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void chat(dev.sonpx.loyalty.agent.controller.AgentController.ChatRequest request, String sessionId) {
|
||||||
|
String prompt = request.prompt();
|
||||||
|
if (request.activeCampaignId() != null && !request.activeCampaignId().isEmpty()) {
|
||||||
|
prompt = "Context: Active Campaign ID is " + request.activeCampaignId() + ".\n\n" + prompt;
|
||||||
|
}
|
||||||
|
if (request.conversationId() != null && !request.conversationId().isEmpty()) {
|
||||||
|
prompt = "System Context: The current conversationId (session ID) is " + request.conversationId() + ". You MUST provide this conversationId as a parameter to any tool that requires it.\n\n" + prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<org.springframework.ai.tool.ToolCallback> wrappedTools = new java.util.ArrayList<>();
|
||||||
|
for (ToolCallbackProvider provider : toolProviders) {
|
||||||
|
for (org.springframework.ai.tool.ToolCallback tool : provider.getToolCallbacks()) {
|
||||||
|
wrappedTools.add(new org.springframework.ai.tool.ToolCallback() {
|
||||||
|
@Override
|
||||||
|
public org.springframework.ai.tool.definition.ToolDefinition getToolDefinition() {
|
||||||
|
return tool.getToolDefinition();
|
||||||
|
}
|
||||||
|
@Override
|
||||||
|
public String call(String toolInput) {
|
||||||
|
messagingTemplate.convertAndSendToUser(sessionId, "/queue/chat-events", dev.sonpx.loyalty.agent.domain.AgentEvent.toolStatus(request.messageId(), tool.getToolDefinition().name(), "running"));
|
||||||
|
try {
|
||||||
|
return tool.call(toolInput);
|
||||||
|
} finally {
|
||||||
|
messagingTemplate.convertAndSendToUser(sessionId, "/queue/chat-events", dev.sonpx.loyalty.agent.domain.AgentEvent.toolStatus(request.messageId(), tool.getToolDefinition().name(), "done"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatClient chatClient = ChatClient.builder(chatModel)
|
||||||
|
.defaultSystem("""
|
||||||
|
You are an expert Loyalty System AI Assistant.
|
||||||
|
Your primary job is to orchestrate operations for our Loyalty core system.
|
||||||
|
|
||||||
|
CRITICAL RULES:
|
||||||
|
1. DO NOT create a model.client.dev.sonpx.loyalty.agent.Campaign unless you verify that Member Tiers and Point Pools exist first.
|
||||||
|
2. Use the `listMemberTiers` tool to check for valid tier IDs.
|
||||||
|
3. Use the `listPointPools` tool to check for valid pool IDs and ensure they have a sufficient balance.
|
||||||
|
4. Use the `listStaticAttributes` tool when asked about configurations, properties, or static values.
|
||||||
|
5. Only use the `createCampaign` tool once verification is complete.
|
||||||
|
6. Always answer the user in the language they used (e.g., Vietnamese).
|
||||||
|
7. If a tool response contains a `presentation.content` field, you MUST display its content exactly as provided to the user. Do not summarize or alter its formatting. Use the `data` field only for your internal reasoning.
|
||||||
|
8. When asked for information, details, or status of an existing campaign, ALWAYS use the `listCampaigns` tool. DO NOT use `createCampaign` unless explicitly asked to create or add a new one.
|
||||||
|
""")
|
||||||
|
.defaultTools(wrappedTools)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
chatClient.prompt()
|
||||||
|
.user(prompt)
|
||||||
|
.stream()
|
||||||
|
.content()
|
||||||
|
.doOnComplete(() -> messagingTemplate.convertAndSendToUser(sessionId, "/queue/chat-events", dev.sonpx.loyalty.agent.domain.AgentEvent.done(request.messageId())))
|
||||||
|
.subscribe(
|
||||||
|
content -> {
|
||||||
|
if (content != null && !content.isEmpty()) {
|
||||||
|
messagingTemplate.convertAndSendToUser(sessionId, "/queue/chat-events", dev.sonpx.loyalty.agent.domain.AgentEvent.token(request.messageId(), content));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error -> messagingTemplate.convertAndSendToUser(sessionId, "/queue/chat-events", dev.sonpx.loyalty.agent.domain.AgentEvent.error(request.messageId(), error.getMessage()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
loyalty-agent/src/main/resources/application.yml
Normal file
47
loyalty-agent/src/main/resources/application.yml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
server:
|
||||||
|
port: 9332
|
||||||
|
|
||||||
|
spring:
|
||||||
|
mvc:
|
||||||
|
async:
|
||||||
|
request-timeout: 3600000
|
||||||
|
application:
|
||||||
|
name: loyalty-agent-service
|
||||||
|
ai:
|
||||||
|
ollama:
|
||||||
|
base-url: http://localhost:11434
|
||||||
|
chat:
|
||||||
|
options:
|
||||||
|
model: qwen3.5:2b
|
||||||
|
temperature: 0.3
|
||||||
|
mcp:
|
||||||
|
client:
|
||||||
|
request-timeout: 60000
|
||||||
|
enabled: true
|
||||||
|
type: sync
|
||||||
|
sse:
|
||||||
|
connections:
|
||||||
|
loyalty:
|
||||||
|
url: ${MCP_SERVER_URL:http://localhost:9331}
|
||||||
|
security:
|
||||||
|
oauth2:
|
||||||
|
client:
|
||||||
|
registration:
|
||||||
|
keycloak:
|
||||||
|
client-id: ols-cli
|
||||||
|
client-secret: ${KEYCLOAK_CLIENT_SECRET:1jV8NSAeybIqmUK0AWhI8hgdo1q5itj7}
|
||||||
|
authorization-grant-type: client_credentials
|
||||||
|
provider:
|
||||||
|
keycloak:
|
||||||
|
token-uri: ${KEYCLOAK_TOKEN_URI:http://192.168.99.235/auth/realms/ols-cn-sit/protocol/openid-connect/token}
|
||||||
|
|
||||||
|
loyalty:
|
||||||
|
core:
|
||||||
|
base-url: ${LOYALTY_CORE_BASE_URL:http://192.168.99.242:8081}
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: warn
|
||||||
|
org.springframework.ai: INFO
|
||||||
|
org.springframework.web: INFO
|
||||||
|
dev.sonpx.loyalty: DEBUG
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package dev.sonpx.loyalty.agent.controller;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class AgentControllerTest {
|
||||||
|
|
||||||
|
private final AgentController controller = new AgentController(null, null);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testStandardizeResponse_Array() {
|
||||||
|
String json = "[{\"id\":\"1\"}]";
|
||||||
|
Object result = controller.standardizeResponse(json);
|
||||||
|
assertTrue(result instanceof java.util.List);
|
||||||
|
assertEquals(1, ((java.util.List<?>) result).size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testStandardizeResponse_ContentWrapped() {
|
||||||
|
String json = "{\"content\":[{\"id\":\"1\"}], \"page\":0}";
|
||||||
|
Object result = controller.standardizeResponse(json);
|
||||||
|
assertTrue(result instanceof java.util.List);
|
||||||
|
assertEquals(1, ((java.util.List<?>) result).size());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testStandardizeResponse_DataWrapped() {
|
||||||
|
String json = "{\"data\":[{\"id\":\"1\"}]}";
|
||||||
|
Object result = controller.standardizeResponse(json);
|
||||||
|
assertTrue(result instanceof java.util.List);
|
||||||
|
assertEquals(1, ((java.util.List<?>) result).size());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package dev.sonpx.loyalty.agent.domain;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class AgentEventTest {
|
||||||
|
|
||||||
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testTokenEventSerialization() throws Exception {
|
||||||
|
AgentEvent event = AgentEvent.token("msg-123", "Hello");
|
||||||
|
String json = objectMapper.writeValueAsString(event);
|
||||||
|
|
||||||
|
assertTrue(json.contains("\"type\":\"TOKEN\""));
|
||||||
|
assertTrue(json.contains("\"messageId\":\"msg-123\""));
|
||||||
|
assertTrue(json.contains("\"content\":\"Hello\""));
|
||||||
|
assertFalse(json.contains("\"tool\""));
|
||||||
|
assertFalse(json.contains("\"status\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testToolStatusEventSerialization() throws Exception {
|
||||||
|
AgentEvent event = AgentEvent.toolStatus("msg-123", "searchTool", "running");
|
||||||
|
String json = objectMapper.writeValueAsString(event);
|
||||||
|
|
||||||
|
assertTrue(json.contains("\"type\":\"TOOL_STATUS\""));
|
||||||
|
assertTrue(json.contains("\"messageId\":\"msg-123\""));
|
||||||
|
assertTrue(json.contains("\"tool\":\"searchTool\""));
|
||||||
|
assertTrue(json.contains("\"status\":\"running\""));
|
||||||
|
assertFalse(json.contains("\"content\""));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testDoneEventSerialization() throws Exception {
|
||||||
|
AgentEvent event = AgentEvent.done("msg-123");
|
||||||
|
String json = objectMapper.writeValueAsString(event);
|
||||||
|
|
||||||
|
assertTrue(json.contains("\"type\":\"DONE\""));
|
||||||
|
assertTrue(json.contains("\"messageId\":\"msg-123\""));
|
||||||
|
assertFalse(json.contains("\"content\""));
|
||||||
|
}
|
||||||
|
}
|
||||||
6
loyalty-mcp-server/Dockerfile
Normal file
6
loyalty-mcp-server/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
FROM eclipse-temurin:25-jre-alpine
|
||||||
|
VOLUME /tmp
|
||||||
|
ARG JAR_FILE=target/*.jar
|
||||||
|
COPY ${JAR_FILE} app.jar
|
||||||
|
EXPOSE 8081
|
||||||
|
ENTRYPOINT ["java","-jar","/app.jar"]
|
||||||
120
loyalty-mcp-server/pom.xml
Normal file
120
loyalty-mcp-server/pom.xml
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>dev.sonpx.loyalty</groupId>
|
||||||
|
<artifactId>loyalty-agent-service-parent</artifactId>
|
||||||
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<artifactId>loyalty-mcp-server</artifactId>
|
||||||
|
<name>loyalty-mcp-server</name>
|
||||||
|
<description>MCP Server for Loyalty API Tools</description>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Spring Boot Web -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.ai</groupId>
|
||||||
|
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- WebClient (needed for generated API client if library is webclient) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- OAuth2 Client -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-oauth2-client</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Lombok -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>${lombok.version}</version>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- MapStruct -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct</artifactId>
|
||||||
|
<version>1.6.2</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot DevTools -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-devtools</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Springdoc OpenAPI -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
<version>2.7.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Testing -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct-processor</artifactId>
|
||||||
|
<version>1.6.2</version>
|
||||||
|
</path>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>${lombok.version}</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class McpServerApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(McpServerApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.client.api;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.Campaign;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.OperationResult;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.service.annotation.GetExchange;
|
||||||
|
import org.springframework.web.service.annotation.PostExchange;
|
||||||
|
|
||||||
|
public interface CampaignApiClient {
|
||||||
|
|
||||||
|
@GetExchange("/api/campaign/csr/id/generate")
|
||||||
|
String generateCampaignId();
|
||||||
|
|
||||||
|
@PostExchange("/api/campaign/csr/create-draft")
|
||||||
|
OperationResult<Campaign> createCampaignDraft(@RequestBody Campaign request);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.client.api;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.MemberTier;
|
||||||
|
import org.springframework.web.service.annotation.GetExchange;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface MemberTierApiClient {
|
||||||
|
|
||||||
|
@GetExchange("/svc/reward/api/decide-tier-view/list?page=0&size=100&status.in=A")
|
||||||
|
List<MemberTier> listMemberTiers();
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.client.api;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.PointPool;
|
||||||
|
import org.springframework.web.service.annotation.GetExchange;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface PointPoolApiClient {
|
||||||
|
|
||||||
|
@GetExchange("/api/pool-definition/all")
|
||||||
|
List<PointPool> listPointPools();
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.client.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record Campaign(String campaignId, String name, String targetTierId, String rewardPoolId, String status) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.client.model;
|
||||||
|
|
||||||
|
public record CreateCampaignRequest(String campaignId, String name, String targetTierId, String rewardPoolId) {
|
||||||
|
|
||||||
|
public CreateCampaignRequest(String name, String targetTierId, String rewardPoolId) {
|
||||||
|
this(null, name, targetTierId, rewardPoolId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.client.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record MemberTier(
|
||||||
|
@JsonProperty("key") @JsonAlias("tierId") String tierId,
|
||||||
|
@JsonAlias("name") String tierName,
|
||||||
|
Integer requiredPoints) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.client.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record OperationResult<T>(String errorCode, String message, T entity) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.client.model;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public record PointPool(String poolId, String poolName, BigDecimal balance) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.client.model.attribute;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class StaticAttributeDto {
|
||||||
|
private String attributeId;
|
||||||
|
private String attributeType;
|
||||||
|
private String attributeValue;
|
||||||
|
private String status;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.client.model.reward;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonAnySetter;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class CampaignDto {
|
||||||
|
private String campaignId;
|
||||||
|
private String name;
|
||||||
|
private String targetTierId;
|
||||||
|
private String rewardPoolId;
|
||||||
|
private String status;
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
private Map<String, Object> extraData = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
@JsonAnySetter
|
||||||
|
public void addExtraData(String key, Object value) {
|
||||||
|
this.extraData.put(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.client.model.reward;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class CampaignFormulaOneDto {
|
||||||
|
private Double awardPerTxnAmt;
|
||||||
|
private Double txnAmt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.client.model.reward;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonValue;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class CampaignRuleDto {
|
||||||
|
private ReferenceData campaignId;
|
||||||
|
private String ruleId;
|
||||||
|
private String ruleName;
|
||||||
|
private RuleTypeEnum ruleType;
|
||||||
|
private String description;
|
||||||
|
private OffsetDateTime effectiveFrom;
|
||||||
|
private OffsetDateTime effectiveTo;
|
||||||
|
private ReferenceData poolId;
|
||||||
|
private CampaignFormulaOneDto campaignFormulaOne;
|
||||||
|
private List<String> formulaSequence;
|
||||||
|
|
||||||
|
public enum RuleTypeEnum {
|
||||||
|
AWD("AWD"),
|
||||||
|
RED("RED"),
|
||||||
|
ADJ("ADJ"),
|
||||||
|
REP("REP"),
|
||||||
|
MAWD("MAWD"),
|
||||||
|
CEP("CEP");
|
||||||
|
|
||||||
|
private String value;
|
||||||
|
|
||||||
|
RuleTypeEnum(String value) {
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonValue
|
||||||
|
public String getValue() {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return String.valueOf(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static RuleTypeEnum fromValue(String value) {
|
||||||
|
for (RuleTypeEnum b : RuleTypeEnum.values()) {
|
||||||
|
if (b.value.equals(value)) {
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new IllegalArgumentException("Unexpected value '" + value + "'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.client.model.reward;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class OperationResultCampaignRuleDto {
|
||||||
|
private Boolean success;
|
||||||
|
private String errorCode;
|
||||||
|
private String errorMessage;
|
||||||
|
private CampaignRuleDto data;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.client.model.reward;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
public class ReferenceData {
|
||||||
|
private String code;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.config;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.mcp.client.api.CampaignApiClient;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.api.MemberTierApiClient;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.api.PointPoolApiClient;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.authentication.AnonymousAuthenticationToken;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.authority.AuthorityUtils;
|
||||||
|
import org.springframework.security.oauth2.client.OAuth2AuthorizeRequest;
|
||||||
|
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
||||||
|
import org.springframework.security.oauth2.client.OAuth2AuthorizedClientManager;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
import org.springframework.web.client.support.RestClientAdapter;
|
||||||
|
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configures OAuth2-enabled RestClient instances and Spring HTTP interface clients.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class ApiClientConfig {
|
||||||
|
|
||||||
|
@Value("${loyalty.core.base-url}")
|
||||||
|
private String coreBaseUrl;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RestClient loyaltyRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
|
||||||
|
return RestClient.builder()
|
||||||
|
.baseUrl(coreBaseUrl)
|
||||||
|
.requestInterceptor((request, body, execution) -> {
|
||||||
|
Authentication principal =
|
||||||
|
new AnonymousAuthenticationToken(
|
||||||
|
"key", "loyalty-agent-service",
|
||||||
|
AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
|
||||||
|
OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
|
||||||
|
.withClientRegistrationId("keycloak")
|
||||||
|
.principal(principal)
|
||||||
|
.build();
|
||||||
|
try {
|
||||||
|
OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
|
||||||
|
if (authorizedClient != null && authorizedClient.getAccessToken() != null) {
|
||||||
|
request.getHeaders().setBearerAuth(authorizedClient.getAccessToken().getTokenValue());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Failed to authorize loyalty API request", e);
|
||||||
|
}
|
||||||
|
return execution.execute(request, body);
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RestClient rewardRestClient(OAuth2AuthorizedClientManager authorizedClientManager) {
|
||||||
|
return RestClient.builder()
|
||||||
|
.baseUrl(coreBaseUrl + "/svc/reward")
|
||||||
|
.requestInterceptor((request, body, execution) -> {
|
||||||
|
org.springframework.security.core.Authentication principal =
|
||||||
|
new org.springframework.security.authentication.AnonymousAuthenticationToken(
|
||||||
|
"key", "loyalty-agent-service",
|
||||||
|
org.springframework.security.core.authority.AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
|
||||||
|
OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
|
||||||
|
.withClientRegistrationId("keycloak")
|
||||||
|
.principal(principal)
|
||||||
|
.build();
|
||||||
|
try {
|
||||||
|
OAuth2AuthorizedClient authorizedClient = authorizedClientManager.authorize(authorizeRequest);
|
||||||
|
if (authorizedClient != null && authorizedClient.getAccessToken() != null) {
|
||||||
|
request.getHeaders().setBearerAuth(authorizedClient.getAccessToken().getTokenValue());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IllegalStateException("Failed to authorize reward API request", e);
|
||||||
|
}
|
||||||
|
return execution.execute(request, body);
|
||||||
|
})
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public MemberTierApiClient memberTierApiClient(@Qualifier("loyaltyRestClient") RestClient loyaltyRestClient) {
|
||||||
|
return createClient(loyaltyRestClient, MemberTierApiClient.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PointPoolApiClient pointPoolApiClient(@Qualifier("rewardRestClient") RestClient rewardRestClient) {
|
||||||
|
return createClient(rewardRestClient, PointPoolApiClient.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CampaignApiClient campaignApiClient(@Qualifier("rewardRestClient") RestClient rewardRestClient) {
|
||||||
|
return createClient(rewardRestClient, CampaignApiClient.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> T createClient(RestClient restClient, Class<T> clientType) {
|
||||||
|
HttpServiceProxyFactory factory = HttpServiceProxyFactory
|
||||||
|
.builderFor(RestClientAdapter.create(restClient))
|
||||||
|
.build();
|
||||||
|
return factory.createClient(clientType);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.config;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class AsyncTimeoutFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||||
|
throws ServletException, IOException {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
if (request.isAsyncStarted()) {
|
||||||
|
// Set async timeout to 24 hours (86400000 ms) to prevent SSE connections from dropping
|
||||||
|
request.getAsyncContext().setTimeout(86400000L);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.config;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.mcp.client.api.CampaignApiClient;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.api.MemberTierApiClient;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.api.PointPoolApiClient;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.LoyaltyAgentTools;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.reward.CampaignRuleTools;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class McpServerConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public LoyaltyAgentTools loyaltyAgentTools(
|
||||||
|
MemberTierApiClient memberTierApiClient,
|
||||||
|
PointPoolApiClient pointPoolApiClient,
|
||||||
|
CampaignApiClient campaignApiClient,
|
||||||
|
@org.springframework.beans.factory.annotation.Qualifier("loyaltyRestClient") RestClient restClient) {
|
||||||
|
return new LoyaltyAgentTools(memberTierApiClient, pointPoolApiClient, campaignApiClient, restClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public org.springframework.ai.tool.ToolCallbackProvider loyaltyToolsProvider(
|
||||||
|
LoyaltyAgentTools loyaltyAgentTools,
|
||||||
|
CampaignRuleTools campaignRuleTools,
|
||||||
|
dev.sonpx.loyalty.mcp.tool.reward.CampaignTools campaignTools) {
|
||||||
|
return org.springframework.ai.tool.method.MethodToolCallbackProvider.builder()
|
||||||
|
.toolObjects(loyaltyAgentTools, campaignRuleTools, campaignTools)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package dev.sonpx.loyalty.agent.config;
|
package dev.sonpx.loyalty.mcp.config;
|
||||||
|
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.draft;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto;
|
||||||
|
import dev.sonpx.loyalty.mcp.draft.model.DraftSession;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class DependencyValidator {
|
||||||
|
|
||||||
|
public void validateCampaignRequiredFields(CampaignDto campaign) {
|
||||||
|
if (campaign == null) {
|
||||||
|
throw new IllegalArgumentException("Campaign draft is empty");
|
||||||
|
}
|
||||||
|
if (campaign.getCampaignId() == null || campaign.getCampaignId().isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Campaign ID is required");
|
||||||
|
}
|
||||||
|
if (campaign.getName() == null || campaign.getName().isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Campaign name is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void validateRulePrerequisites(DraftSession session, String targetCampaignId, String transactionCode, boolean needsPoolId, String poolId) {
|
||||||
|
if (targetCampaignId == null || targetCampaignId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Target Campaign ID is required to add a rule");
|
||||||
|
}
|
||||||
|
|
||||||
|
// In a real system, we might check if targetCampaignId matches the one in draft,
|
||||||
|
// or check the Core API if the campaign exists.
|
||||||
|
|
||||||
|
if (transactionCode == null || transactionCode.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Transaction Code is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsPoolId && (poolId == null || poolId.isBlank())) {
|
||||||
|
throw new IllegalArgumentException("Pool ID is required for the selected rule flow");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void validateSopPrerequisites(String poolId) {
|
||||||
|
if (poolId == null || poolId.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Existing Pool ID is required to create a Statement Output Pool");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.draft;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.mcp.draft.model.DraftSession;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class DraftSessionManager {
|
||||||
|
|
||||||
|
private final Map<String, DraftSession> sessions = new ConcurrentHashMap<>();
|
||||||
|
private static final Duration TTL = Duration.ofHours(1);
|
||||||
|
|
||||||
|
public DraftSession getSession(String conversationId) {
|
||||||
|
if (conversationId == null) {
|
||||||
|
throw new IllegalArgumentException("conversationId cannot be null");
|
||||||
|
}
|
||||||
|
cleanupExpiredSessions();
|
||||||
|
return sessions.compute(conversationId, (id, session) -> {
|
||||||
|
if (session == null || isExpired(session)) {
|
||||||
|
return new DraftSession(id);
|
||||||
|
}
|
||||||
|
session.setLastUpdated(Instant.now());
|
||||||
|
return session;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateSession(String conversationId, Consumer<DraftSession> updater) {
|
||||||
|
if (conversationId == null) {
|
||||||
|
throw new IllegalArgumentException("conversationId cannot be null");
|
||||||
|
}
|
||||||
|
cleanupExpiredSessions();
|
||||||
|
sessions.compute(conversationId, (id, session) -> {
|
||||||
|
if (session == null || isExpired(session)) {
|
||||||
|
session = new DraftSession(id);
|
||||||
|
}
|
||||||
|
updater.accept(session);
|
||||||
|
session.setLastUpdated(Instant.now());
|
||||||
|
return session;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeSession(String conversationId) {
|
||||||
|
sessions.remove(conversationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isExpired(DraftSession session) {
|
||||||
|
return Instant.now().isAfter(session.getLastUpdated().plus(TTL));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanupExpiredSessions() {
|
||||||
|
sessions.entrySet().removeIf(entry -> isExpired(entry.getValue()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.draft.model;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignRuleDto;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class CampaignDraft {
|
||||||
|
private CampaignDto campaign = new CampaignDto();
|
||||||
|
private List<CampaignRuleDto> rules = new ArrayList<>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.draft.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class DraftSession {
|
||||||
|
private String conversationId;
|
||||||
|
private Instant lastUpdated;
|
||||||
|
private CampaignDraft campaignDraft;
|
||||||
|
private PoolDraft poolDraft;
|
||||||
|
|
||||||
|
public DraftSession(String conversationId) {
|
||||||
|
this.conversationId = conversationId;
|
||||||
|
this.lastUpdated = Instant.now();
|
||||||
|
this.campaignDraft = new CampaignDraft();
|
||||||
|
this.poolDraft = new PoolDraft();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.draft.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class PoolDraft {
|
||||||
|
private Map<String, Object> poolDefinition = new HashMap<>();
|
||||||
|
private Map<String, Object> statementOutputPool = new HashMap<>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.model;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class McpResponseWrapper<T> {
|
||||||
|
private T data;
|
||||||
|
private Presentation presentation;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.model;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class Presentation {
|
||||||
|
private String type; // e.g. "markdown"
|
||||||
|
private String content; // The formatted string
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.tool;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.mcp.client.api.CampaignApiClient;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.api.MemberTierApiClient;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.api.PointPoolApiClient;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.attribute.StaticAttributeDto;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.Campaign;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.CreateCampaignRequest;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.MemberTier;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.OperationResult;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.PointPool;
|
||||||
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.core.ParameterizedTypeReference;
|
||||||
|
|
||||||
|
public class LoyaltyAgentTools {
|
||||||
|
|
||||||
|
private final MemberTierApiClient memberTierApiClient;
|
||||||
|
private final PointPoolApiClient pointPoolApiClient;
|
||||||
|
private final CampaignApiClient campaignApiClient;
|
||||||
|
private final org.springframework.web.client.RestClient restClient;
|
||||||
|
|
||||||
|
public LoyaltyAgentTools(MemberTierApiClient memberTierApiClient, PointPoolApiClient pointPoolApiClient, CampaignApiClient campaignApiClient, org.springframework.web.client.RestClient restClient) {
|
||||||
|
this.memberTierApiClient = memberTierApiClient;
|
||||||
|
this.pointPoolApiClient = pointPoolApiClient;
|
||||||
|
this.campaignApiClient = campaignApiClient;
|
||||||
|
this.restClient = restClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool(description = "List all available Member Tiers. Always use this to verify a Tier exists before creating a Campaign.")
|
||||||
|
public List<MemberTier> listMemberTiers() {
|
||||||
|
return memberTierApiClient.listMemberTiers();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool(description = "List all available Point Pools. Always use this to verify a Pool exists and has sufficient balance before creating a Campaign.")
|
||||||
|
public List<PointPool> listPointPools() {
|
||||||
|
return pointPoolApiClient.listPointPools();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool(description = "Create a new Campaign. ONLY use this when explicitly asked to create or add a new campaign. Do not use this to fetch information about existing campaigns. This requires a valid targetTierId and rewardPoolId, which MUST be verified beforehand using listMemberTiers and listPointPools.")
|
||||||
|
public Campaign createCampaign(CreateCampaignRequest request) {
|
||||||
|
String campaignId = request.campaignId();
|
||||||
|
if (campaignId == null || campaignId.isBlank()) {
|
||||||
|
campaignId = campaignApiClient.generateCampaignId();
|
||||||
|
}
|
||||||
|
|
||||||
|
Campaign draft = new Campaign(
|
||||||
|
campaignId,
|
||||||
|
request.name(),
|
||||||
|
request.targetTierId(),
|
||||||
|
request.rewardPoolId(),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
OperationResult<Campaign> result = campaignApiClient.createCampaignDraft(draft);
|
||||||
|
if (result == null || result.entity() == null) {
|
||||||
|
String message = result != null ? result.message() : "empty response";
|
||||||
|
throw new IllegalStateException("Campaign create-draft did not return an entity: " + message);
|
||||||
|
}
|
||||||
|
return result.entity();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool(description = "List all available Static Attributes. Use this tool when you need to find configurations, properties, or static values like tier types or currencies.")
|
||||||
|
public List<StaticAttributeDto> listStaticAttributes() {
|
||||||
|
try {
|
||||||
|
String url = "/svc/attribute/api/static-attribute/csr/view/list?sort=lastApproveDate,desc&sort=lastUpdateDate,desc&page=0&size=10&status.in=A";
|
||||||
|
return restClient.get()
|
||||||
|
.uri(url)
|
||||||
|
.retrieve()
|
||||||
|
.body(new ParameterizedTypeReference<>() {
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to fetch static attributes", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.tool.model.pool;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
||||||
|
|
||||||
|
public record CreatePoolDefinitionRequest(
|
||||||
|
@JsonPropertyDescription("Conversation ID from the agent")
|
||||||
|
String conversationId,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Pool ID (max 10 chars)")
|
||||||
|
String poolId,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Pool Name")
|
||||||
|
String poolName,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Pool Type (e.g. STANDARD, PROMOTIONAL)")
|
||||||
|
String poolType,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Currency Code")
|
||||||
|
String currencyCode
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.tool.model.pool;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
||||||
|
|
||||||
|
public record CreateSopRequest(
|
||||||
|
@JsonPropertyDescription("Conversation ID from the agent")
|
||||||
|
String conversationId,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Statement Output Pool ID")
|
||||||
|
String sopId,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Reference Pool ID (must exist)")
|
||||||
|
String poolId,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("SOP Name")
|
||||||
|
String name,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Statement Type")
|
||||||
|
String statementType
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.tool.model.reward;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
||||||
|
|
||||||
|
public record AddRuleToCampaignRequest(
|
||||||
|
@JsonPropertyDescription("Conversation ID from the agent")
|
||||||
|
String conversationId,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Target Campaign ID")
|
||||||
|
String targetCampaignId,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Transaction Code to trigger the rule")
|
||||||
|
String transactionCode,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Whether this rule flow requires a Pool ID")
|
||||||
|
boolean needsPoolId,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Pool ID for the rule (if needed)")
|
||||||
|
String poolId,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Rule details")
|
||||||
|
CreateCampaignRuleToolRequest ruleDetails
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.tool.model.reward;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
public record CreateCampaignRuleToolRequest(
|
||||||
|
@JsonPropertyDescription("ID of the campaign this rule belongs to")
|
||||||
|
String campaignId,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Unique identifier for the rule (max 10 chars)")
|
||||||
|
String ruleId,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Name of the rule")
|
||||||
|
String ruleName,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Type of the rule (e.g., AWD for Award, RED for Redemption, ADJ for Adjustment)")
|
||||||
|
String ruleType,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Description of the rule")
|
||||||
|
String description,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Effective start date of the rule")
|
||||||
|
OffsetDateTime effectiveFrom,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Effective end date of the rule")
|
||||||
|
OffsetDateTime effectiveTo,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("ID of the Point Pool to be updated by this rule")
|
||||||
|
String poolId,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Formula One configuration for basic points awarding (points per txn amount)")
|
||||||
|
FormulaOneConfig formulaOne
|
||||||
|
) {
|
||||||
|
public record FormulaOneConfig(
|
||||||
|
@JsonPropertyDescription("Amount of points awarded per transaction amount")
|
||||||
|
Double awardPerTxnAmt,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Required transaction amount to receive the award")
|
||||||
|
Double txnAmt
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.tool.model.reward;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public record ListCampaignRuleToolRequest(
|
||||||
|
String campaignId,
|
||||||
|
Integer page,
|
||||||
|
Integer size
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.tool.model.reward;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
||||||
|
|
||||||
|
public record SubmitCampaignDraftRequest(
|
||||||
|
@JsonPropertyDescription("Conversation ID from the agent")
|
||||||
|
String conversationId
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.tool.model.reward;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
||||||
|
|
||||||
|
public record UpdateCampaignDraftRequest(
|
||||||
|
@JsonPropertyDescription("Conversation ID from the agent")
|
||||||
|
String conversationId,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Campaign ID")
|
||||||
|
String campaignId,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Campaign Name")
|
||||||
|
String name,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Target Tier ID")
|
||||||
|
String targetTierId
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.tool.model.reward;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
public record UpdateCampaignRuleToolRequest(
|
||||||
|
@JsonPropertyDescription("ID of the campaign this rule belongs to")
|
||||||
|
String campaignId,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Unique identifier for the rule (max 10 chars)")
|
||||||
|
String ruleId,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Name of the rule")
|
||||||
|
String ruleName,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Type of the rule (e.g., AWD for Award, RED for Redemption, ADJ for Adjustment)")
|
||||||
|
String ruleType,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Description of the rule")
|
||||||
|
String description,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Effective start date of the rule")
|
||||||
|
OffsetDateTime effectiveFrom,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Effective end date of the rule")
|
||||||
|
OffsetDateTime effectiveTo,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("ID of the Point Pool to be updated by this rule")
|
||||||
|
String poolId,
|
||||||
|
|
||||||
|
@JsonPropertyDescription("Formula One configuration for basic points awarding (points per txn amount)")
|
||||||
|
CreateCampaignRuleToolRequest.FormulaOneConfig formulaOne
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.tool.pool;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.mcp.draft.DependencyValidator;
|
||||||
|
import dev.sonpx.loyalty.mcp.draft.DraftSessionManager;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.model.pool.CreatePoolDefinitionRequest;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.model.pool.CreateSopRequest;
|
||||||
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.core.ParameterizedTypeReference;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class PointPoolTools {
|
||||||
|
|
||||||
|
private final DraftSessionManager draftSessionManager;
|
||||||
|
private final DependencyValidator dependencyValidator;
|
||||||
|
private final RestClient restClient;
|
||||||
|
|
||||||
|
public PointPoolTools(DraftSessionManager draftSessionManager, DependencyValidator dependencyValidator, @Qualifier("rewardRestClient") RestClient restClient) {
|
||||||
|
this.draftSessionManager = draftSessionManager;
|
||||||
|
this.dependencyValidator = dependencyValidator;
|
||||||
|
this.restClient = restClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool(description = "Create a Pool Definition. This builds a PoolDefinitionDto and submits it via Core API.")
|
||||||
|
public Map<String, Object> createPoolDefinition(CreatePoolDefinitionRequest request) {
|
||||||
|
if (request.conversationId() == null || request.conversationId().isBlank()) {
|
||||||
|
throw new IllegalArgumentException("conversationId is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in draft
|
||||||
|
draftSessionManager.updateSession(request.conversationId(), session -> {
|
||||||
|
Map<String, Object> poolDef = session.getPoolDraft().getPoolDefinition();
|
||||||
|
poolDef.put("poolId", request.poolId());
|
||||||
|
poolDef.put("poolName", request.poolName());
|
||||||
|
poolDef.put("poolType", request.poolType());
|
||||||
|
poolDef.put("currencyCode", request.currencyCode());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit via Core API
|
||||||
|
try {
|
||||||
|
return restClient.post()
|
||||||
|
.uri("/api/pool-definition/csr/create-draft")
|
||||||
|
.body(request)
|
||||||
|
.retrieve()
|
||||||
|
.body(new ParameterizedTypeReference<>() {});
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
return Map.of("error", "Failed to create pool definition draft via Core API: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool(description = "Create a Statement Output Pool (SOP). This builds a StatementOutputPoolDto and requires an existing Pool ID.")
|
||||||
|
public Map<String, Object> createSop(CreateSopRequest request) {
|
||||||
|
if (request.conversationId() == null || request.conversationId().isBlank()) {
|
||||||
|
throw new IllegalArgumentException("conversationId is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate prerequisite: existing Pool ID
|
||||||
|
dependencyValidator.validateSopPrerequisites(request.poolId());
|
||||||
|
|
||||||
|
// Store in draft
|
||||||
|
draftSessionManager.updateSession(request.conversationId(), session -> {
|
||||||
|
Map<String, Object> sop = session.getPoolDraft().getStatementOutputPool();
|
||||||
|
sop.put("sopId", request.sopId());
|
||||||
|
sop.put("poolId", request.poolId());
|
||||||
|
sop.put("name", request.name());
|
||||||
|
sop.put("statementType", request.statementType());
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit via Core API
|
||||||
|
try {
|
||||||
|
return restClient.post()
|
||||||
|
.uri("/api/sop/csr/create-draft")
|
||||||
|
.body(request)
|
||||||
|
.retrieve()
|
||||||
|
.body(new ParameterizedTypeReference<>() {});
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
return Map.of("error", "Failed to create Statement Output Pool draft via Core API: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.tool.reward;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignRuleDto;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.reward.ReferenceData;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignFormulaOneDto;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.model.reward.CreateCampaignRuleToolRequest;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.model.reward.UpdateCampaignRuleToolRequest;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class CampaignRuleMapper {
|
||||||
|
|
||||||
|
public CampaignRuleDto toDto(CreateCampaignRuleToolRequest request) {
|
||||||
|
CampaignRuleDto dto = new CampaignRuleDto();
|
||||||
|
|
||||||
|
dto.setCampaignId(toReferenceData(request.campaignId()));
|
||||||
|
dto.setRuleId(request.ruleId());
|
||||||
|
dto.setRuleName(request.ruleName());
|
||||||
|
if (request.ruleType() != null) {
|
||||||
|
dto.setRuleType(CampaignRuleDto.RuleTypeEnum.fromValue(request.ruleType()));
|
||||||
|
}
|
||||||
|
dto.setDescription(request.description());
|
||||||
|
dto.setEffectiveFrom(request.effectiveFrom());
|
||||||
|
dto.setEffectiveTo(request.effectiveTo());
|
||||||
|
dto.setPoolId(toReferenceData(request.poolId()));
|
||||||
|
|
||||||
|
if (request.formulaOne() != null) {
|
||||||
|
CampaignFormulaOneDto formulaOne = new CampaignFormulaOneDto();
|
||||||
|
formulaOne.setAwardPerTxnAmt(request.formulaOne().awardPerTxnAmt());
|
||||||
|
formulaOne.setTxnAmt(request.formulaOne().txnAmt());
|
||||||
|
dto.setCampaignFormulaOne(formulaOne);
|
||||||
|
|
||||||
|
dto.setFormulaSequence(new ArrayList<>());
|
||||||
|
dto.getFormulaSequence().add("1");
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CampaignRuleDto toDto(UpdateCampaignRuleToolRequest request) {
|
||||||
|
CampaignRuleDto dto = new CampaignRuleDto();
|
||||||
|
|
||||||
|
dto.setCampaignId(toReferenceData(request.campaignId()));
|
||||||
|
dto.setRuleId(request.ruleId());
|
||||||
|
dto.setRuleName(request.ruleName());
|
||||||
|
if (request.ruleType() != null) {
|
||||||
|
dto.setRuleType(CampaignRuleDto.RuleTypeEnum.fromValue(request.ruleType()));
|
||||||
|
}
|
||||||
|
dto.setDescription(request.description());
|
||||||
|
dto.setEffectiveFrom(request.effectiveFrom());
|
||||||
|
dto.setEffectiveTo(request.effectiveTo());
|
||||||
|
dto.setPoolId(toReferenceData(request.poolId()));
|
||||||
|
|
||||||
|
if (request.formulaOne() != null) {
|
||||||
|
CampaignFormulaOneDto formulaOne = new CampaignFormulaOneDto();
|
||||||
|
formulaOne.setAwardPerTxnAmt(request.formulaOne().awardPerTxnAmt());
|
||||||
|
formulaOne.setTxnAmt(request.formulaOne().txnAmt());
|
||||||
|
dto.setCampaignFormulaOne(formulaOne);
|
||||||
|
|
||||||
|
dto.setFormulaSequence(new ArrayList<>());
|
||||||
|
dto.getFormulaSequence().add("1");
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReferenceData toReferenceData(String code) {
|
||||||
|
if (code == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ReferenceData ref = new ReferenceData();
|
||||||
|
ref.setCode(code);
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.tool.reward;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignRuleDto;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.reward.OperationResultCampaignRuleDto;
|
||||||
|
import dev.sonpx.loyalty.mcp.draft.DependencyValidator;
|
||||||
|
import dev.sonpx.loyalty.mcp.draft.DraftSessionManager;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.model.reward.AddRuleToCampaignRequest;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.model.reward.CreateCampaignRuleToolRequest;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.model.reward.UpdateCampaignRuleToolRequest;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.model.reward.ListCampaignRuleToolRequest;
|
||||||
|
import dev.sonpx.loyalty.mcp.model.McpResponseWrapper;
|
||||||
|
import dev.sonpx.loyalty.mcp.model.Presentation;
|
||||||
|
import dev.sonpx.loyalty.mcp.util.MarkdownGenerator;
|
||||||
|
|
||||||
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
import org.springframework.web.util.UriComponentsBuilder;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class CampaignRuleTools {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CampaignRuleTools.class);
|
||||||
|
|
||||||
|
private final CampaignRuleMapper campaignRuleMapper;
|
||||||
|
private final RestClient rewardRestClient;
|
||||||
|
private final DraftSessionManager draftSessionManager;
|
||||||
|
private final DependencyValidator dependencyValidator;
|
||||||
|
|
||||||
|
public CampaignRuleTools(
|
||||||
|
CampaignRuleMapper campaignRuleMapper,
|
||||||
|
@Qualifier("rewardRestClient") RestClient rewardRestClient,
|
||||||
|
DraftSessionManager draftSessionManager,
|
||||||
|
DependencyValidator dependencyValidator) {
|
||||||
|
this.campaignRuleMapper = campaignRuleMapper;
|
||||||
|
this.rewardRestClient = rewardRestClient;
|
||||||
|
this.draftSessionManager = draftSessionManager;
|
||||||
|
this.dependencyValidator = dependencyValidator;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool(description = "Add Rule to local Campaign Draft. Validates prerequisites (Transaction Code, Pool ID) before adding.")
|
||||||
|
public Map<String, Object> addRuleToCampaign(AddRuleToCampaignRequest request) {
|
||||||
|
if (request.conversationId() == null || request.conversationId().isBlank()) {
|
||||||
|
throw new IllegalArgumentException("conversationId is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate dependencies
|
||||||
|
dependencyValidator.validateRulePrerequisites(
|
||||||
|
draftSessionManager.getSession(request.conversationId()),
|
||||||
|
request.targetCampaignId(),
|
||||||
|
request.transactionCode(),
|
||||||
|
request.needsPoolId(),
|
||||||
|
request.poolId()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update local draft
|
||||||
|
draftSessionManager.updateSession(request.conversationId(), session -> {
|
||||||
|
CampaignRuleDto rule = campaignRuleMapper.toDto(request.ruleDetails());
|
||||||
|
dev.sonpx.loyalty.mcp.client.model.reward.ReferenceData ref = new dev.sonpx.loyalty.mcp.client.model.reward.ReferenceData();
|
||||||
|
ref.setCode(request.targetCampaignId());
|
||||||
|
rule.setCampaignId(ref);
|
||||||
|
session.getCampaignDraft().getRules().add(rule);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Map.of("status", "Rule added to Campaign draft successfully", "ruleId", request.ruleDetails().ruleId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool(description = "Create a new Campaign Rule for an existing Campaign. Supports basic properties and Formula One configuration for points per transaction amount.")
|
||||||
|
public OperationResultCampaignRuleDto createCampaignRule(CreateCampaignRuleToolRequest request) {
|
||||||
|
CampaignRuleDto dto = campaignRuleMapper.toDto(request);
|
||||||
|
try {
|
||||||
|
return rewardRestClient.post()
|
||||||
|
.uri("/api/campaign-rule/csr/create")
|
||||||
|
.body(dto)
|
||||||
|
.retrieve()
|
||||||
|
.body(OperationResultCampaignRuleDto.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to create Campaign Rule", e);
|
||||||
|
throw new RuntimeException("Failed to create Campaign Rule: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool(description = "Edit an existing Campaign Rule. You must provide all required fields (campaignId, ruleId, ruleName, etc) as this will update the entire rule configuration.")
|
||||||
|
public OperationResultCampaignRuleDto editCampaignRule(UpdateCampaignRuleToolRequest request) {
|
||||||
|
CampaignRuleDto dto = campaignRuleMapper.toDto(request);
|
||||||
|
try {
|
||||||
|
return rewardRestClient.patch()
|
||||||
|
.uri("/api/campaign-rule/csr/edit")
|
||||||
|
.body(dto)
|
||||||
|
.retrieve()
|
||||||
|
.body(OperationResultCampaignRuleDto.class);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to edit Campaign Rule", e);
|
||||||
|
throw new RuntimeException("Failed to edit Campaign Rule: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool(description = "List Campaign Rules, optionally filtered by campaignId. Use this tool to get a list of campaign rules. If the response contains a `presentation.content` field, display its content exactly as provided to the user. Use `data` only for your internal reasoning.")
|
||||||
|
public McpResponseWrapper<List<CampaignRuleDto>> listCampaignRules(
|
||||||
|
@org.springframework.lang.Nullable String campaignId,
|
||||||
|
@org.springframework.lang.Nullable Integer page,
|
||||||
|
@org.springframework.lang.Nullable Integer size) {
|
||||||
|
int pageNum = page != null ? page : 0;
|
||||||
|
int sizeNum = size != null ? size : 20;
|
||||||
|
|
||||||
|
try {
|
||||||
|
String jsonResponse = rewardRestClient.get()
|
||||||
|
.uri(uriBuilder -> {
|
||||||
|
uriBuilder.path("/api/campaign-rule/csr/list")
|
||||||
|
.queryParam("page", pageNum)
|
||||||
|
.queryParam("size", sizeNum);
|
||||||
|
|
||||||
|
if (campaignId != null && !campaignId.isEmpty()) {
|
||||||
|
uriBuilder.queryParam("campaignId.equals", campaignId);
|
||||||
|
}
|
||||||
|
return uriBuilder.build();
|
||||||
|
})
|
||||||
|
.retrieve()
|
||||||
|
.body(String.class);
|
||||||
|
|
||||||
|
// Fix missing timezone in date-time strings from backend (e.g. "2026-06-25T16:16:01" -> "2026-06-25T16:16:01Z")
|
||||||
|
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})\"", "\"$1Z\"");
|
||||||
|
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]+)\"", "\"$1Z\"");
|
||||||
|
|
||||||
|
// Try to map it manually
|
||||||
|
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
|
mapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
|
||||||
|
mapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||||
|
try {
|
||||||
|
CampaignRuleDto[] rules = mapper.readValue(jsonResponse, CampaignRuleDto[].class);
|
||||||
|
return wrapCampaignRules(rules != null ? List.of(rules) : List.of());
|
||||||
|
} catch (Exception parseEx) {
|
||||||
|
System.out.println("Failed to parse as array. Trying to extract from pageable format...");
|
||||||
|
com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(jsonResponse);
|
||||||
|
if (root.has("content")) {
|
||||||
|
CampaignRuleDto[] rules = mapper.treeToValue(root.get("content"), CampaignRuleDto[].class);
|
||||||
|
return wrapCampaignRules(rules != null ? List.of(rules) : List.of());
|
||||||
|
} else if (root.has("data")) {
|
||||||
|
CampaignRuleDto[] rules = mapper.treeToValue(root.get("data"), CampaignRuleDto[].class);
|
||||||
|
return wrapCampaignRules(rules != null ? List.of(rules) : List.of());
|
||||||
|
}
|
||||||
|
throw parseEx;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to list Campaign Rules", e);
|
||||||
|
throw new RuntimeException("Failed to list Campaign Rules: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Tool(description = "List Campaign Rules by Campaign Name. It finds the campaign by name and returns its rules. Use this tool when the user provides a campaign name and asks for its campaign rules. If the response contains a `presentation.content` field, display its content exactly as provided to the user. Use `data` only for your internal reasoning.")
|
||||||
|
public McpResponseWrapper<List<CampaignRuleDto>> listCampaignRulesByCampaignName(String campaignName) {
|
||||||
|
try {
|
||||||
|
String jsonResponse = rewardRestClient.get()
|
||||||
|
.uri(uriBuilder -> uriBuilder
|
||||||
|
.path("/api/campaign/csr/list")
|
||||||
|
.queryParam("name.contains", campaignName)
|
||||||
|
.queryParam("page", 0)
|
||||||
|
.queryParam("size", 1)
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
.retrieve()
|
||||||
|
.body(String.class);
|
||||||
|
|
||||||
|
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})\"", "\"$1Z\"");
|
||||||
|
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]+)\"", "\"$1Z\"");
|
||||||
|
|
||||||
|
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
|
mapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
|
||||||
|
mapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||||
|
dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto[] campaigns = null;
|
||||||
|
try {
|
||||||
|
campaigns = mapper.readValue(jsonResponse, dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto[].class);
|
||||||
|
} catch (Exception parseEx) {
|
||||||
|
com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(jsonResponse);
|
||||||
|
if (root.has("content")) {
|
||||||
|
campaigns = mapper.treeToValue(root.get("content"), dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto[].class);
|
||||||
|
} else if (root.has("data")) {
|
||||||
|
campaigns = mapper.treeToValue(root.get("data"), dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto[].class);
|
||||||
|
} else {
|
||||||
|
throw parseEx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (campaigns == null || campaigns.length == 0) {
|
||||||
|
return wrapCampaignRules(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
|
String campaignId = campaigns[0].getCampaignId();
|
||||||
|
return listCampaignRules(campaignId, 0, 100);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to list Campaign Rules by Campaign Name", e);
|
||||||
|
throw new RuntimeException("Failed to list Campaign Rules by Campaign Name: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private McpResponseWrapper<List<CampaignRuleDto>> wrapCampaignRules(List<CampaignRuleDto> data) {
|
||||||
|
StringBuilder markdown = new StringBuilder();
|
||||||
|
if (data.isEmpty()) {
|
||||||
|
markdown.append("No campaign rules found.");
|
||||||
|
} else {
|
||||||
|
for (CampaignRuleDto r : data) {
|
||||||
|
markdown.append(MarkdownGenerator.generateCampaignRuleMarkdown(r)).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new McpResponseWrapper<>(data, new Presentation("markdown", markdown.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.tool.reward;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto;
|
||||||
|
import dev.sonpx.loyalty.mcp.draft.DependencyValidator;
|
||||||
|
import dev.sonpx.loyalty.mcp.draft.DraftSessionManager;
|
||||||
|
import dev.sonpx.loyalty.mcp.draft.model.DraftSession;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.model.reward.SubmitCampaignDraftRequest;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.model.reward.UpdateCampaignDraftRequest;
|
||||||
|
import dev.sonpx.loyalty.mcp.model.McpResponseWrapper;
|
||||||
|
import dev.sonpx.loyalty.mcp.model.Presentation;
|
||||||
|
import dev.sonpx.loyalty.mcp.util.MarkdownGenerator;
|
||||||
|
import org.springframework.ai.tool.annotation.Tool;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.core.ParameterizedTypeReference;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
import org.springframework.web.util.UriComponentsBuilder;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class CampaignTools {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(CampaignTools.class);
|
||||||
|
|
||||||
|
private final RestClient rewardRestClient;
|
||||||
|
private final DraftSessionManager draftSessionManager;
|
||||||
|
private final DependencyValidator dependencyValidator;
|
||||||
|
|
||||||
|
public CampaignTools(
|
||||||
|
@Qualifier("rewardRestClient") RestClient rewardRestClient,
|
||||||
|
DraftSessionManager draftSessionManager,
|
||||||
|
DependencyValidator dependencyValidator) {
|
||||||
|
this.rewardRestClient = rewardRestClient;
|
||||||
|
this.draftSessionManager = draftSessionManager;
|
||||||
|
this.dependencyValidator = dependencyValidator;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool(description = "List or Search Campaigns, optionally filtered by campaignId, name, or status. ALWAYS use this tool when the user asks for information, details, or status of an existing campaign. If the response contains a `presentation.content` field, display its content exactly as provided to the user. Use `data` only for your internal reasoning.")
|
||||||
|
public McpResponseWrapper<List<CampaignDto>> listCampaigns(
|
||||||
|
@org.springframework.lang.Nullable String campaignId,
|
||||||
|
@org.springframework.lang.Nullable String name,
|
||||||
|
@org.springframework.lang.Nullable String status,
|
||||||
|
@org.springframework.lang.Nullable Integer page,
|
||||||
|
@org.springframework.lang.Nullable Integer size) {
|
||||||
|
int pageNum = page != null ? page : 0;
|
||||||
|
int sizeNum = size != null ? size : 20;
|
||||||
|
|
||||||
|
try {
|
||||||
|
String jsonResponse = rewardRestClient.get()
|
||||||
|
.uri(uriBuilder -> {
|
||||||
|
uriBuilder.path("/api/campaign/csr/list")
|
||||||
|
.queryParam("page", pageNum)
|
||||||
|
.queryParam("size", sizeNum);
|
||||||
|
|
||||||
|
if (campaignId != null && !campaignId.isEmpty()) {
|
||||||
|
uriBuilder.queryParam("campaignId.equals", campaignId);
|
||||||
|
}
|
||||||
|
if (name != null && !name.isEmpty()) {
|
||||||
|
uriBuilder.queryParam("name.contains", name);
|
||||||
|
}
|
||||||
|
if (status != null && !status.isEmpty()) {
|
||||||
|
uriBuilder.queryParam("status.equals", status);
|
||||||
|
}
|
||||||
|
return uriBuilder.build();
|
||||||
|
})
|
||||||
|
.retrieve()
|
||||||
|
.body(String.class);
|
||||||
|
|
||||||
|
// Fix missing timezone in date-time strings from backend (e.g. "2026-06-25T16:16:01" -> "2026-06-25T16:16:01Z")
|
||||||
|
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})\"", "\"$1Z\"");
|
||||||
|
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]+)\"", "\"$1Z\"");
|
||||||
|
|
||||||
|
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||||
|
mapper.registerModule(new com.fasterxml.jackson.datatype.jsr310.JavaTimeModule());
|
||||||
|
mapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||||
|
CampaignDto[] campaigns = null;
|
||||||
|
try {
|
||||||
|
campaigns = mapper.readValue(jsonResponse, CampaignDto[].class);
|
||||||
|
} catch (Exception parseEx) {
|
||||||
|
com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(jsonResponse);
|
||||||
|
if (root.has("content")) {
|
||||||
|
campaigns = mapper.treeToValue(root.get("content"), CampaignDto[].class);
|
||||||
|
} else if (root.has("data")) {
|
||||||
|
campaigns = mapper.treeToValue(root.get("data"), CampaignDto[].class);
|
||||||
|
} else {
|
||||||
|
throw parseEx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<CampaignDto> data = campaigns != null ? List.of(campaigns) : List.of();
|
||||||
|
StringBuilder markdown = new StringBuilder();
|
||||||
|
if (data.isEmpty()) {
|
||||||
|
markdown.append("No campaigns found.");
|
||||||
|
} else {
|
||||||
|
for (CampaignDto c : data) {
|
||||||
|
markdown.append(MarkdownGenerator.generateCampaignMarkdown(c)).append("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new McpResponseWrapper<>(data, new Presentation("markdown", markdown.toString()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to list Campaigns", e);
|
||||||
|
throw new RuntimeException("Failed to list Campaigns: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool(description = "Update local Campaign Draft.")
|
||||||
|
public Map<String, Object> updateCampaignDraft(UpdateCampaignDraftRequest request) {
|
||||||
|
if (request.conversationId() == null || request.conversationId().isBlank()) {
|
||||||
|
throw new IllegalArgumentException("conversationId is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
draftSessionManager.updateSession(request.conversationId(), session -> {
|
||||||
|
CampaignDto campaign = session.getCampaignDraft().getCampaign();
|
||||||
|
if (request.campaignId() != null) campaign.setCampaignId(request.campaignId());
|
||||||
|
if (request.name() != null) campaign.setName(request.name());
|
||||||
|
if (request.targetTierId() != null) campaign.setTargetTierId(request.targetTierId());
|
||||||
|
});
|
||||||
|
|
||||||
|
return Map.of("status", "Campaign draft updated successfully", "campaignId", request.campaignId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Tool(description = "Submit the completed CampaignDraft to Core API.")
|
||||||
|
public Map<String, Object> submitCampaignDraft(SubmitCampaignDraftRequest request) {
|
||||||
|
if (request.conversationId() == null || request.conversationId().isBlank()) {
|
||||||
|
throw new IllegalArgumentException("conversationId is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
DraftSession session = draftSessionManager.getSession(request.conversationId());
|
||||||
|
CampaignDto campaign = session.getCampaignDraft().getCampaign();
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
dependencyValidator.validateCampaignRequiredFields(campaign);
|
||||||
|
|
||||||
|
// Submit via Core API
|
||||||
|
try {
|
||||||
|
return rewardRestClient.post()
|
||||||
|
.uri("/api/campaign/csr/create-draft")
|
||||||
|
.body(campaign)
|
||||||
|
.retrieve()
|
||||||
|
.body(new ParameterizedTypeReference<>() {});
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to submit Campaign draft via Core API", e);
|
||||||
|
return Map.of("error", "Failed to submit Campaign draft via Core API: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.util;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignDto;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignRuleDto;
|
||||||
|
|
||||||
|
public class MarkdownGenerator {
|
||||||
|
|
||||||
|
public static String generateCampaignMarkdown(CampaignDto campaign) {
|
||||||
|
if (campaign == null) {
|
||||||
|
return "No campaign data available.";
|
||||||
|
}
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("### 🏆 Campaign Details\n\n");
|
||||||
|
sb.append("| Field | Value |\n");
|
||||||
|
sb.append("| :--- | :--- |\n");
|
||||||
|
sb.append("| **Campaign ID** | ").append(campaign.getCampaignId()).append(" |\n");
|
||||||
|
sb.append("| **Name** | ").append(campaign.getName()).append(" |\n");
|
||||||
|
sb.append("| **Status** | ").append(campaign.getStatus() != null ? campaign.getStatus() : "N/A").append(" |\n");
|
||||||
|
sb.append("| **Target Tier ID** | ").append(campaign.getTargetTierId() != null ? campaign.getTargetTierId() : "N/A").append(" |\n");
|
||||||
|
sb.append("| **Reward Pool ID** | ").append(campaign.getRewardPoolId() != null ? campaign.getRewardPoolId() : "N/A").append(" |\n");
|
||||||
|
|
||||||
|
if (campaign.getDescription() != null) {
|
||||||
|
sb.append("| **Description** | ").append(campaign.getDescription()).append(" |\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (campaign.getExtraData() != null && !campaign.getExtraData().isEmpty()) {
|
||||||
|
for (java.util.Map.Entry<String, Object> entry : campaign.getExtraData().entrySet()) {
|
||||||
|
String key = entry.getKey();
|
||||||
|
String formattedKey = key.substring(0, 1).toUpperCase() + key.substring(1).replaceAll("([A-Z])", " $1");
|
||||||
|
sb.append("| **").append(formattedKey).append("** | ").append(entry.getValue()).append(" |\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String generateCampaignRuleMarkdown(CampaignRuleDto rule) {
|
||||||
|
if (rule == null) {
|
||||||
|
return "No campaign rule data available.";
|
||||||
|
}
|
||||||
|
|
||||||
|
String campaignCodeStr = (rule.getCampaignId() != null && rule.getCampaignId().getCode() != null) ? rule.getCampaignId().getCode() : "N/A";
|
||||||
|
String poolCodeStr = (rule.getPoolId() != null && rule.getPoolId().getCode() != null) ? rule.getPoolId().getCode() : "N/A";
|
||||||
|
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("### 📜 Campaign Rule Details\n\n");
|
||||||
|
sb.append("| Field | Value |\n");
|
||||||
|
sb.append("| :--- | :--- |\n");
|
||||||
|
sb.append("| **Rule ID** | ").append(rule.getRuleId()).append(" |\n");
|
||||||
|
sb.append("| **Rule Name** | ").append(rule.getRuleName()).append(" |\n");
|
||||||
|
sb.append("| **Rule Type** | ").append(rule.getRuleType() != null ? rule.getRuleType().getValue() : "N/A").append(" |\n");
|
||||||
|
sb.append("| **Campaign ID** | ").append(campaignCodeStr).append(" |\n");
|
||||||
|
sb.append("| **Pool ID** | ").append(poolCodeStr).append(" |\n");
|
||||||
|
sb.append("| **Description** | ").append(rule.getDescription() != null ? rule.getDescription() : "N/A").append(" |\n");
|
||||||
|
sb.append("| **Effective From** | ").append(rule.getEffectiveFrom() != null ? rule.getEffectiveFrom().toString() : "N/A").append(" |\n");
|
||||||
|
sb.append("| **Effective To** | ").append(rule.getEffectiveTo() != null ? rule.getEffectiveTo().toString() : "N/A").append(" |\n");
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
37
loyalty-mcp-server/src/main/resources/application.yml
Normal file
37
loyalty-mcp-server/src/main/resources/application.yml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
server:
|
||||||
|
port: 9331
|
||||||
|
shutdown: immediate
|
||||||
|
|
||||||
|
spring:
|
||||||
|
mvc:
|
||||||
|
async:
|
||||||
|
request-timeout: 3600000
|
||||||
|
application:
|
||||||
|
name: loyalty-mcp-server
|
||||||
|
ai:
|
||||||
|
mcp:
|
||||||
|
server:
|
||||||
|
sse-endpoint: /sse
|
||||||
|
sse-message-endpoint: /message
|
||||||
|
security:
|
||||||
|
oauth2:
|
||||||
|
client:
|
||||||
|
registration:
|
||||||
|
keycloak:
|
||||||
|
client-id: ols-cli
|
||||||
|
client-secret: ${KEYCLOAK_CLIENT_SECRET:1jV8NSAeybIqmUK0AWhI8hgdo1q5itj7}
|
||||||
|
authorization-grant-type: client_credentials
|
||||||
|
provider:
|
||||||
|
keycloak:
|
||||||
|
token-uri: ${KEYCLOAK_TOKEN_URI:http://192.168.99.235/auth/realms/ols-cn-sit/protocol/openid-connect/token}
|
||||||
|
|
||||||
|
loyalty:
|
||||||
|
core:
|
||||||
|
base-url: ${LOYALTY_CORE_BASE_URL:http://192.168.99.242:8081}
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: warn
|
||||||
|
org.springframework.ai: INFO
|
||||||
|
org.springframework.web: INFO
|
||||||
|
dev.sonpx.loyalty: DEBUG
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.test;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignRuleDto;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public class TestParsing {
|
||||||
|
public static void main(String[] args) throws Exception {
|
||||||
|
String jsonResponse = new String(Files.readAllBytes(Paths.get("rules.json")));
|
||||||
|
|
||||||
|
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2})\"", "\"$1Z\"");
|
||||||
|
jsonResponse = jsonResponse.replaceAll("\"([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]+)\"", "\"$1Z\"");
|
||||||
|
|
||||||
|
System.out.println("Transformed JSON: " + jsonResponse.substring(0, Math.min(200, jsonResponse.length())));
|
||||||
|
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
mapper.registerModule(new JavaTimeModule());
|
||||||
|
mapper.configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
CampaignRuleDto[] rules = mapper.readValue(jsonResponse, CampaignRuleDto[].class);
|
||||||
|
System.out.println("Parsed directly as array, length: " + rules.length);
|
||||||
|
} catch (Exception parseEx) {
|
||||||
|
System.out.println("Failed to parse as array: " + parseEx.getMessage());
|
||||||
|
com.fasterxml.jackson.databind.JsonNode root = mapper.readTree(jsonResponse);
|
||||||
|
if (root.has("content")) {
|
||||||
|
CampaignRuleDto[] rules = mapper.treeToValue(root.get("content"), CampaignRuleDto[].class);
|
||||||
|
System.out.println("Parsed from content, length: " + rules.length);
|
||||||
|
} else if (root.has("data")) {
|
||||||
|
CampaignRuleDto[] rules = mapper.treeToValue(root.get("data"), CampaignRuleDto[].class);
|
||||||
|
System.out.println("Parsed from data, length: " + rules.length);
|
||||||
|
} else {
|
||||||
|
throw parseEx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.tool;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.mcp.client.api.CampaignApiClient;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.api.MemberTierApiClient;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.api.PointPoolApiClient;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.Campaign;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.CreateCampaignRequest;
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.MemberTier;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.client.MockRestServiceServer;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
import org.springframework.web.client.support.RestClientAdapter;
|
||||||
|
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.springframework.http.HttpMethod.GET;
|
||||||
|
import static org.springframework.http.HttpMethod.POST;
|
||||||
|
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
|
||||||
|
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||||
|
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||||
|
|
||||||
|
class LoyaltyAgentToolsRealApiTest {
|
||||||
|
|
||||||
|
private MockRestServiceServer loyaltyServer;
|
||||||
|
private MockRestServiceServer rewardServer;
|
||||||
|
private LoyaltyAgentTools tools;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
RestClient.Builder loyaltyRestClientBuilder = RestClient.builder().baseUrl("http://core.test");
|
||||||
|
loyaltyServer = MockRestServiceServer.bindTo(loyaltyRestClientBuilder).build();
|
||||||
|
RestClient loyaltyRestClient = loyaltyRestClientBuilder.build();
|
||||||
|
|
||||||
|
RestClient.Builder rewardRestClientBuilder = RestClient.builder().baseUrl("http://reward.test");
|
||||||
|
rewardServer = MockRestServiceServer.bindTo(rewardRestClientBuilder).build();
|
||||||
|
RestClient rewardRestClient = rewardRestClientBuilder.build();
|
||||||
|
|
||||||
|
MemberTierApiClient memberTierApiClient = createClient(loyaltyRestClient, MemberTierApiClient.class);
|
||||||
|
PointPoolApiClient pointPoolApiClient = createClient(rewardRestClient, PointPoolApiClient.class);
|
||||||
|
CampaignApiClient campaignApiClient = createClient(rewardRestClient, CampaignApiClient.class);
|
||||||
|
tools = new LoyaltyAgentTools(memberTierApiClient, pointPoolApiClient, campaignApiClient, loyaltyRestClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listMemberTiersCallsRealEndpoint() {
|
||||||
|
loyaltyServer.expect(requestTo("http://core.test/svc/reward/api/decide-tier-view/list?page=0&size=100&status.in=A"))
|
||||||
|
.andExpect(method(GET))
|
||||||
|
.andRespond(withSuccess("[{\"key\":\"TIER_GOLD\",\"code\":\"GOLD\",\"name\":\"Gold\"}]", MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
MemberTier tier = tools.listMemberTiers().getFirst();
|
||||||
|
|
||||||
|
assertEquals("TIER_GOLD", tier.tierId());
|
||||||
|
assertEquals("Gold", tier.tierName());
|
||||||
|
loyaltyServer.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listPointPoolsCallsRealEndpoint() {
|
||||||
|
rewardServer.expect(requestTo("http://reward.test/api/pool-definition/all"))
|
||||||
|
.andExpect(method(GET))
|
||||||
|
.andRespond(withSuccess("[{\"poolId\":\"POOL_1\",\"poolName\":\"Main Pool\"}]", MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
assertEquals("POOL_1", tools.listPointPools().getFirst().poolId());
|
||||||
|
rewardServer.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createCampaignGeneratesIdAndUnwrapsOperationResultEntity() {
|
||||||
|
rewardServer.expect(requestTo("http://reward.test/api/campaign/csr/id/generate"))
|
||||||
|
.andExpect(method(GET))
|
||||||
|
.andRespond(withSuccess("CAMP_1", MediaType.TEXT_PLAIN));
|
||||||
|
rewardServer.expect(requestTo("http://reward.test/api/campaign/csr/create-draft"))
|
||||||
|
.andExpect(method(POST))
|
||||||
|
.andRespond(withSuccess(
|
||||||
|
"{\"message\":\"OK\",\"entity\":{\"campaignId\":\"CAMP_1\",\"name\":\"Summer\",\"status\":\"DRAFT\"}}",
|
||||||
|
MediaType.APPLICATION_JSON));
|
||||||
|
|
||||||
|
Campaign campaign = tools.createCampaign(new CreateCampaignRequest("Summer", "TIER_GOLD", "POOL_1"));
|
||||||
|
|
||||||
|
assertEquals("CAMP_1", campaign.campaignId());
|
||||||
|
assertEquals("DRAFT", campaign.status());
|
||||||
|
rewardServer.verify();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <T> T createClient(RestClient restClient, Class<T> clientType) {
|
||||||
|
HttpServiceProxyFactory factory = HttpServiceProxyFactory
|
||||||
|
.builderFor(RestClientAdapter.create(restClient))
|
||||||
|
.build();
|
||||||
|
return factory.createClient(clientType);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.tool;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.mcp.draft.DependencyValidator;
|
||||||
|
import dev.sonpx.loyalty.mcp.draft.DraftSessionManager;
|
||||||
|
import dev.sonpx.loyalty.mcp.draft.model.DraftSession;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.model.pool.CreatePoolDefinitionRequest;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.model.pool.CreateSopRequest;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.model.reward.AddRuleToCampaignRequest;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.model.reward.CreateCampaignRuleToolRequest;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.model.reward.SubmitCampaignDraftRequest;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.model.reward.UpdateCampaignDraftRequest;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.pool.PointPoolTools;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.reward.CampaignRuleMapper;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.reward.CampaignRuleTools;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.reward.CampaignTools;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.client.MockRestServiceServer;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
|
||||||
|
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||||
|
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
|
||||||
|
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||||
|
import static org.springframework.http.HttpMethod.POST;
|
||||||
|
|
||||||
|
class McpOrchestratorTest {
|
||||||
|
|
||||||
|
private DraftSessionManager sessionManager;
|
||||||
|
private DependencyValidator dependencyValidator;
|
||||||
|
private MockRestServiceServer mockServer;
|
||||||
|
|
||||||
|
private PointPoolTools pointPoolTools;
|
||||||
|
private CampaignTools campaignTools;
|
||||||
|
private CampaignRuleTools campaignRuleTools;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
sessionManager = new DraftSessionManager();
|
||||||
|
dependencyValidator = new DependencyValidator();
|
||||||
|
RestClient.Builder restClientBuilder = RestClient.builder().baseUrl("http://reward.test");
|
||||||
|
mockServer = MockRestServiceServer.bindTo(restClientBuilder).build();
|
||||||
|
RestClient restClient = restClientBuilder.build();
|
||||||
|
|
||||||
|
pointPoolTools = new PointPoolTools(sessionManager, dependencyValidator, restClient);
|
||||||
|
campaignTools = new CampaignTools(restClient, sessionManager, dependencyValidator);
|
||||||
|
campaignRuleTools = new CampaignRuleTools(new CampaignRuleMapper(), restClient, sessionManager, dependencyValidator);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testFullDependencyGraphAndErrorPropagation() {
|
||||||
|
String convId = "test-conv-123";
|
||||||
|
|
||||||
|
mockServer.expect(requestTo("http://reward.test/api/pool-definition/csr/create-draft"))
|
||||||
|
.andExpect(method(POST))
|
||||||
|
.andRespond(withSuccess("{\"poolId\":\"POOL-1\"}", MediaType.APPLICATION_JSON));
|
||||||
|
mockServer.expect(requestTo("http://reward.test/api/sop/csr/create-draft"))
|
||||||
|
.andExpect(method(POST))
|
||||||
|
.andRespond(withSuccess("{\"sopId\":\"SOP-1\"}", MediaType.APPLICATION_JSON));
|
||||||
|
mockServer.expect(requestTo("http://reward.test/api/campaign/csr/create-draft"))
|
||||||
|
.andExpect(method(POST))
|
||||||
|
.andRespond(withSuccess("{\"campaignId\":\"CAMP-1\",\"status\":\"DRAFT_CREATED\"}", MediaType.APPLICATION_JSON));
|
||||||
|
mockServer.expect(requestTo("http://reward.test/api/campaign/csr/create-draft"))
|
||||||
|
.andExpect(method(POST))
|
||||||
|
.andRespond(withStatus(HttpStatus.BAD_REQUEST).body("400 Bad Request"));
|
||||||
|
|
||||||
|
// 1. Create Pool Definition
|
||||||
|
CreatePoolDefinitionRequest poolReq = new CreatePoolDefinitionRequest(convId, "POOL-1", "Test Pool", "STANDARD", "USD");
|
||||||
|
Map<String, Object> poolRes = pointPoolTools.createPoolDefinition(poolReq);
|
||||||
|
assertEquals("POOL-1", poolRes.get("poolId"));
|
||||||
|
|
||||||
|
// 2. Create SOP
|
||||||
|
CreateSopRequest sopReq = new CreateSopRequest(convId, "SOP-1", "POOL-1", "Test SOP", "ACCRUAL");
|
||||||
|
Map<String, Object> sopRes = pointPoolTools.createSop(sopReq);
|
||||||
|
assertEquals("SOP-1", sopRes.get("sopId"));
|
||||||
|
|
||||||
|
// 3. Create Campaign Draft
|
||||||
|
UpdateCampaignDraftRequest updateCampReq = new UpdateCampaignDraftRequest(convId, "CAMP-1", "My Campaign", "TIER-1");
|
||||||
|
campaignTools.updateCampaignDraft(updateCampReq);
|
||||||
|
|
||||||
|
// 4. Add Rule to Campaign (Valid)
|
||||||
|
CreateCampaignRuleToolRequest ruleDetails = new CreateCampaignRuleToolRequest("CAMP-1", "RULE-1", "My Rule", "AWD", "Desc", OffsetDateTime.now(), OffsetDateTime.now(), "POOL-1", null);
|
||||||
|
AddRuleToCampaignRequest addRuleReq = new AddRuleToCampaignRequest(convId, "CAMP-1", "TXN-CODE-1", true, "POOL-1", ruleDetails);
|
||||||
|
Map<String, Object> ruleRes = campaignRuleTools.addRuleToCampaign(addRuleReq);
|
||||||
|
assertEquals("RULE-1", ruleRes.get("ruleId"));
|
||||||
|
|
||||||
|
// 5. Submit Campaign Draft
|
||||||
|
SubmitCampaignDraftRequest submitCampReq = new SubmitCampaignDraftRequest(convId);
|
||||||
|
Map<String, Object> submitCampRes = campaignTools.submitCampaignDraft(submitCampReq);
|
||||||
|
assertEquals("CAMP-1", submitCampRes.get("campaignId"));
|
||||||
|
|
||||||
|
// Test Error Propagation: Missing Pool ID for SOP
|
||||||
|
CreateSopRequest badSopReq = new CreateSopRequest(convId, "SOP-2", "", "Bad SOP", "ACCRUAL");
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> pointPoolTools.createSop(badSopReq));
|
||||||
|
|
||||||
|
// Test Error Propagation: Add Rule missing required Pool ID
|
||||||
|
AddRuleToCampaignRequest badRuleReq = new AddRuleToCampaignRequest(convId, "CAMP-1", "TXN-CODE-2", true, "", ruleDetails);
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> campaignRuleTools.addRuleToCampaign(badRuleReq));
|
||||||
|
|
||||||
|
// Test Error propagation from Core API
|
||||||
|
Map<String, Object> errorRes = campaignTools.submitCampaignDraft(submitCampReq);
|
||||||
|
assertTrue(((String)errorRes.get("error")).contains("400 Bad Request"));
|
||||||
|
|
||||||
|
mockServer.verify();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package dev.sonpx.loyalty.mcp.tool.reward;
|
||||||
|
|
||||||
|
import dev.sonpx.loyalty.mcp.client.model.reward.CampaignRuleDto;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.model.reward.CreateCampaignRuleToolRequest;
|
||||||
|
import dev.sonpx.loyalty.mcp.tool.model.reward.UpdateCampaignRuleToolRequest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
class CampaignRuleMapperTest {
|
||||||
|
|
||||||
|
private CampaignRuleMapper mapper;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
mapper = new CampaignRuleMapper();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testToDto_CreateRequest_WithFormulaOne() {
|
||||||
|
CreateCampaignRuleToolRequest request = new CreateCampaignRuleToolRequest(
|
||||||
|
"CAMPAIGN-123",
|
||||||
|
"RULE-1",
|
||||||
|
"My Rule",
|
||||||
|
"AWD",
|
||||||
|
"Description",
|
||||||
|
OffsetDateTime.now(),
|
||||||
|
OffsetDateTime.now().plusDays(10),
|
||||||
|
"POOL-ABC",
|
||||||
|
new CreateCampaignRuleToolRequest.FormulaOneConfig(10.0, 100.0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CampaignRuleDto dto = mapper.toDto(request);
|
||||||
|
|
||||||
|
assertNotNull(dto);
|
||||||
|
assertNotNull(dto.getCampaignId());
|
||||||
|
assertEquals("CAMPAIGN-123", dto.getCampaignId().getCode());
|
||||||
|
assertEquals("RULE-1", dto.getRuleId());
|
||||||
|
assertEquals("My Rule", dto.getRuleName());
|
||||||
|
assertEquals(CampaignRuleDto.RuleTypeEnum.AWD, dto.getRuleType());
|
||||||
|
assertNotNull(dto.getPoolId());
|
||||||
|
assertEquals("POOL-ABC", dto.getPoolId().getCode());
|
||||||
|
assertNotNull(dto.getCampaignFormulaOne());
|
||||||
|
assertEquals(10.0, dto.getCampaignFormulaOne().getAwardPerTxnAmt());
|
||||||
|
assertEquals(100.0, dto.getCampaignFormulaOne().getTxnAmt());
|
||||||
|
assertEquals(1, dto.getFormulaSequence().size());
|
||||||
|
assertEquals("1", dto.getFormulaSequence().get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testToDto_UpdateRequest_WithoutFormulaOne() {
|
||||||
|
UpdateCampaignRuleToolRequest request = new UpdateCampaignRuleToolRequest(
|
||||||
|
"CAMPAIGN-456",
|
||||||
|
"RULE-2",
|
||||||
|
"My Updated Rule",
|
||||||
|
"RED",
|
||||||
|
"Updated Description",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
"POOL-XYZ",
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
CampaignRuleDto dto = mapper.toDto(request);
|
||||||
|
|
||||||
|
assertNotNull(dto);
|
||||||
|
assertNotNull(dto.getCampaignId());
|
||||||
|
assertEquals("CAMPAIGN-456", dto.getCampaignId().getCode());
|
||||||
|
assertEquals("RULE-2", dto.getRuleId());
|
||||||
|
assertEquals("My Updated Rule", dto.getRuleName());
|
||||||
|
assertEquals(CampaignRuleDto.RuleTypeEnum.RED, dto.getRuleType());
|
||||||
|
assertNotNull(dto.getPoolId());
|
||||||
|
assertEquals("POOL-XYZ", dto.getPoolId().getCode());
|
||||||
|
assertNull(dto.getCampaignFormulaOne());
|
||||||
|
assertTrue(dto.getFormulaSequence() == null || dto.getFormulaSequence().isEmpty());
|
||||||
|
}
|
||||||
|
}
|
||||||
41
package.json
Normal file
41
package.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"name": "agent-ui-temp",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "oxlint",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-avatar": "^1.2.2",
|
||||||
|
"@radix-ui/react-scroll-area": "^1.2.14",
|
||||||
|
"@radix-ui/react-slot": "^1.3.0",
|
||||||
|
"@stomp/stompjs": "^7.3.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^1.24.0",
|
||||||
|
"react": "^19.2.7",
|
||||||
|
"react-dom": "^19.2.7",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"tailwind-merge": "^3.6.0",
|
||||||
|
"zustand": "^5.0.14"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
|
"@types/node": "^24.13.2",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
"autoprefixer": "^10.5.4",
|
||||||
|
"oxlint": "^1.71.0",
|
||||||
|
"postcss": "^8.5.19",
|
||||||
|
"tailwindcss": "^3.4.19",
|
||||||
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"typescript": "~6.0.2",
|
||||||
|
"vite": "^8.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
229
plan/master_plan.md
Normal file
229
plan/master_plan.md
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
# Master Plan: Loyalty Agent Service
|
||||||
|
|
||||||
|
_Focus hiện tại: MCP Orchestrator cho Reward Core API, dựa trên `loyalty-mcp-server/src/main/resources/specs/reward.json`._
|
||||||
|
|
||||||
|
Core Reward API đang expose **11 CSR resources chính** theo cùng một pattern Maker-Checker:
|
||||||
|
|
||||||
|
1. `Transaction Category`
|
||||||
|
2. `Transaction Code`
|
||||||
|
3. `Statement Output Pool (SOP)`
|
||||||
|
4. `Pool Definition`
|
||||||
|
5. `Pool Conversion Rate`
|
||||||
|
6. `Currency Rate`
|
||||||
|
7. `Event Maintenance`
|
||||||
|
8. `Deduction Sequences`
|
||||||
|
9. `Counter Definition`
|
||||||
|
10. `Campaign`
|
||||||
|
11. `Campaign Rule`
|
||||||
|
|
||||||
|
Ngoài nhóm CSR trên, spec còn có các endpoint hỗ trợ rất quan trọng cho Agent như `id/generate`, `id/check`, `list`, `count`, `history`, `list-by-ids`, `list-by-codes`, `list-by-pool`, `precision-by-pools`, `find-default`, `exists-default`, `verifyCriteria`, `amount-to-use-view`, `decide-tier-view`.
|
||||||
|
|
||||||
|
Vì Core API không sửa được, Loyalty Agent Service/MCP Server sẽ đóng vai trò **orchestrator + DTO builder + validator**: hướng dẫn user điền thông tin, tra cứu dữ liệu phụ thuộc, build DTO hợp lệ, validate trước, rồi mới gọi endpoint CSR tương ứng.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Reward MCP Orchestrator
|
||||||
|
|
||||||
|
_Mục tiêu: biến Reward OpenAPI thành bộ MCP tools dùng được trong hội thoại, ưu tiên luồng tạo Campaign/Rule/Pool end-to-end._
|
||||||
|
|
||||||
|
### 1.1. Foundation Tools dùng chung
|
||||||
|
|
||||||
|
Các resource CSR có pattern giống nhau, nên cần build một lớp client/tool generator hoặc wrapper dùng chung thay vì viết thủ công từng API.
|
||||||
|
|
||||||
|
- `generate_id(resource)`: gọi `/api/{resource}/csr/id/generate`.
|
||||||
|
- `check_id(resource, id)`: gọi `/api/{resource}/csr/id/check/{id}`.
|
||||||
|
- `get_by_id(resource, id)` hoặc `get_by_record_no(resource, recordNo)`.
|
||||||
|
- `list(resource, criteria, page, size, sort)`.
|
||||||
|
- `count(resource, criteria)`.
|
||||||
|
- `create_draft(resource, dto)`.
|
||||||
|
- `edit_draft(resource, dto)`.
|
||||||
|
- `approve/reject/bulk-approve/bulk-reject` chỉ expose sau, vì đây là hành động nhạy cảm.
|
||||||
|
- `history(resource, criteria)` và `rejection_reason(recordNo)` dùng để giải thích trạng thái Maker-Checker cho user.
|
||||||
|
|
||||||
|
Nguyên tắc: trong giai đoạn đầu, Agent ưu tiên tạo draft, không tự approve.
|
||||||
|
|
||||||
|
### 1.2. DTO Builder & Validation Layer
|
||||||
|
|
||||||
|
MCP Server cần giữ local draft theo conversation/session để user có thể build DTO từng bước.
|
||||||
|
|
||||||
|
- Mỗi draft có `draftId`, `resource`, `dto`, `missingRequiredFields`, `warnings`, `lastCoreValidation`.
|
||||||
|
- Không đưa các field `readOnly` vào request: `status`, `lastUpdateBy`, `lastUpdateDate`, `lastApproveBy`, `lastApproveDate`, `lastUpdateByName`, `lastApproveByName`.
|
||||||
|
- Validate required fields theo schema trước khi gọi Core API.
|
||||||
|
- Validate enum, maxLength/minLength, date/date-time format.
|
||||||
|
- Với field `$ref: ReferenceData`, Agent phải tra cứu hoặc xác nhận object reference, không chỉ nhét string trần nếu Core client yêu cầu object.
|
||||||
|
- Với nested DTO trong `CampaignRuleDto`, build theo template theo `ruleType` thay vì hỏi tất cả field.
|
||||||
|
|
||||||
|
Required fields quan trọng theo spec:
|
||||||
|
|
||||||
|
- `CampaignDto`: `campaignId`, `name`.
|
||||||
|
- `CampaignRuleDto`: `campaignId`, `ruleId`, `ruleName`.
|
||||||
|
- `PoolDefinitionDto`: `poolId`, `poolName`.
|
||||||
|
- `TransactionCodeDto`: `code`, `description`.
|
||||||
|
- `TransactionCategoryDto`: `code`, `name`.
|
||||||
|
- `PoolConversionRateDto`: `code`, `description`.
|
||||||
|
- `CurrencyRateDto`: `pcrCode`, `effectiveFrom`, `effectiveTo`, `buyRate`, `sellRate`.
|
||||||
|
- `EventMaintenanceDto`: `eventId`, `description`, `occursNext`.
|
||||||
|
- `DeductionSequencesDto`: `sequenceId`, `sequenceName`, `effectiveFrom`, `effectiveTo`, `isDefault`.
|
||||||
|
- `CounterDefinitionDto`: `counterId`, `counterName`, `effectiveFrom`, `effectiveTo`, `entity`, `bucketPeriodUnit`, `whatToCount`, `resetType`, `resetValue`, `updateStateWhen`, `lateValuePosting`.
|
||||||
|
|
||||||
|
### 1.3. Dependency Graph thực tế
|
||||||
|
|
||||||
|
Không hard-code rằng Campaign Rule nối trực tiếp với Event. Trong schema hiện tại, `CampaignRuleDto` không có `eventId`; nó nối chủ yếu qua:
|
||||||
|
|
||||||
|
- `campaignId`
|
||||||
|
- `poolId` / `subPoolId`
|
||||||
|
- `campaignTcLinkages`
|
||||||
|
- `campaignCriteria`
|
||||||
|
- `campaignFormula*`
|
||||||
|
- `campaignRuleSchedule`
|
||||||
|
- `marketingRewardRequest`
|
||||||
|
- reward content theo channel `SMS`, `EMAIL`, `NOTIFY`
|
||||||
|
|
||||||
|
Dependency graph đề xuất:
|
||||||
|
|
||||||
|
1. **Reference/Lookup layer**
|
||||||
|
- `amount-to-use-view/list`
|
||||||
|
- `decide-tier-view/list`
|
||||||
|
- transaction code lookup: `/api/transaction-code/list-by-codes`
|
||||||
|
- pool lookup: `/api/pool-definition/list-by-ids`, `/api/pool-definition/precision-by-pools`
|
||||||
|
- deduction sequence default: `/api/deduction-seq/find-default`
|
||||||
|
|
||||||
|
2. **Parameter layer**
|
||||||
|
- `Transaction Category`
|
||||||
|
- `Transaction Code`
|
||||||
|
- `Pool Conversion Rate`
|
||||||
|
- `Currency Rate`
|
||||||
|
|
||||||
|
3. **Pool layer**
|
||||||
|
- `Statement Output Pool`
|
||||||
|
- `Pool Definition`
|
||||||
|
- optional `Counter Definition` nếu rule dùng cap/limit/counter formula
|
||||||
|
|
||||||
|
4. **Campaign layer**
|
||||||
|
- `Campaign`
|
||||||
|
|
||||||
|
5. **Rule layer**
|
||||||
|
- `Campaign Rule`
|
||||||
|
- validate criteria bằng `/api/campaign-rule/campaign-criteria/verify`
|
||||||
|
- kiểm tra liên kết bằng `/api/campaign-rule/list-by-pool`, `/api/campaign-rule/rule-linked-by-transaction-code`, `/api/campaign-rule/find-matching-rules`
|
||||||
|
|
||||||
|
6. **Standalone/scheduled support**
|
||||||
|
- `Event Maintenance`
|
||||||
|
- `Deduction Sequences`
|
||||||
|
|
||||||
|
### 1.4. Scope triển khai Phase 1
|
||||||
|
|
||||||
|
Không làm cả 11 resource ngang nhau ngay. Ưu tiên theo giá trị end-to-end:
|
||||||
|
|
||||||
|
**Milestone 1: Read/Lookup Tools**
|
||||||
|
|
||||||
|
- Implement tool list/search cho `Campaign`, `Campaign Rule`, `Pool Definition`, `Transaction Code`.
|
||||||
|
- Implement `generate_id` và `check_id` cho các resource chính.
|
||||||
|
- Implement helper lookup: `list-by-codes`, `list-by-ids`, `precision-by-pools`, `amount-to-use-view/list`, `decide-tier-view/list`.
|
||||||
|
- Output tool phải trả dữ liệu ngắn gọn để Agent dùng tiếp, không dump DTO lớn.
|
||||||
|
|
||||||
|
**Milestone 2: Pool Setup**
|
||||||
|
|
||||||
|
- Tool tạo draft `PoolConversionRateDto`.
|
||||||
|
- Tool tạo draft `PoolDefinitionDto`.
|
||||||
|
- Tool tạo draft `StatementOutputPoolDto` nếu workflow statement cần SOP.
|
||||||
|
- Validate expiry policy: `FD`, `NE`, `ARD`, `MF`, `QF`, `YF`, `SP`, `AOM`.
|
||||||
|
- Validate pool type: `BPT`, `CR`, `GFT`, `MI`, `LDR`.
|
||||||
|
|
||||||
|
**Milestone 3: Campaign Setup**
|
||||||
|
|
||||||
|
- Tool tạo draft `CampaignDto`.
|
||||||
|
- Agent tự generate/check `campaignId` nếu user chưa có mã.
|
||||||
|
- Giữ template tối thiểu: `campaignId`, `name`, `ownerName`, `description`, `campaignType`, `effectiveFrom`, `effectiveTo`.
|
||||||
|
- Không yêu cầu user nhập `numOfRule`; field này nên derive/hiển thị từ Core nếu có.
|
||||||
|
|
||||||
|
**Milestone 4: Campaign Rule Setup**
|
||||||
|
|
||||||
|
- Tool tạo draft `CampaignRuleDto`.
|
||||||
|
- Template theo `ruleType`: `AWD`, `RED`, `IRED`, `ADJ`, `CEP`, `REP`, `TEP`, `MAWD`.
|
||||||
|
- Với earning/award flow, ưu tiên support:
|
||||||
|
- `campaignId`
|
||||||
|
- `ruleId`, `ruleName`
|
||||||
|
- `poolId`
|
||||||
|
- `campaignTcLinkages`
|
||||||
|
- `campaignCriteria`
|
||||||
|
- `campaignFormulaSetting`
|
||||||
|
- một trong các `campaignFormulaOne/Two/Four/Five/Six/Eight/Ten/Eleven`
|
||||||
|
- `campaignAwardLimits` nếu có cap/counter
|
||||||
|
- Gọi `verifyCriteria` trước khi submit draft nếu user có nhập criteria expression.
|
||||||
|
|
||||||
|
**Milestone 5: Approval Visibility**
|
||||||
|
|
||||||
|
- Tool xem trạng thái draft, history, rejection reason.
|
||||||
|
- Tool giải thích record đang ở trạng thái nào và cần maker/checker làm gì tiếp theo.
|
||||||
|
- Chưa expose approve/reject mặc định; chỉ bật khi có phân quyền và xác nhận rõ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Memory & Conversation State
|
||||||
|
|
||||||
|
_Mục tiêu: Agent nhớ chính xác DTO đang build, các reference đã chọn, và trạng thái Maker-Checker._
|
||||||
|
|
||||||
|
- Lưu conversation memory và local draft state trong DB.
|
||||||
|
- Context locking theo draft: khi user đang build `CampaignRuleDto`, Agent không tự chuyển sang draft khác trừ khi user xác nhận.
|
||||||
|
- Cho phép nested flow có kiểm soát: nếu đang build rule nhưng thiếu pool, Agent có thể tạo sub-draft `PoolDefinitionDto`, hoàn tất xong quay lại rule.
|
||||||
|
- Lưu các reference đã tạo/chọn gần nhất: `campaignId`, `ruleId`, `poolId`, `transactionCode`, `counterId`, `pcrCode`.
|
||||||
|
- Khi user nói "dùng pool vừa tạo", Agent phải resolve từ memory thành reference rõ ràng và hỏi lại nếu có nhiều pool gần đây.
|
||||||
|
- Mỗi draft cần có audit trail: user intent, fields collected, validation result, endpoint đã gọi, response recordNo/status.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: UI/Dashboard hỗ trợ Agent
|
||||||
|
|
||||||
|
_Mục tiêu: giảm hội thoại dài bằng UI chuyên biệt cho DTO lớn._
|
||||||
|
|
||||||
|
- **Draft Inspector:** hiển thị DTO hiện tại, missing fields, warnings, Core response.
|
||||||
|
- **Dependency Viewer:** hiển thị Campaign -> Campaign Rule -> Pool -> Transaction Code/Counter/Formula.
|
||||||
|
- **Interactive Forms:** form động sinh từ schema cho các DTO lớn như `CampaignRuleDto`.
|
||||||
|
- **Criteria Builder:** UI hỗ trợ build và verify `campaignCriteria.criteria`.
|
||||||
|
- **Formula Builder:** UI chọn formula type và chỉ hiện field liên quan.
|
||||||
|
- **Maker-Checker Timeline:** show draft/history/rejection reason để user biết đang chờ bước nào.
|
||||||
|
- Dùng WebSocket hiện tại để sync draft state real-time giữa chat và UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: Auto-Pilot & Simulation
|
||||||
|
|
||||||
|
_Mục tiêu: từ intent tự nhiên tạo được campaign/rule/pool hoàn chỉnh nhưng vẫn kiểm soát được rủi ro._
|
||||||
|
|
||||||
|
- Intent template: "Tạo campaign nhân đôi điểm sinh nhật", "Tạo campaign hoàn tiền 5%", "Tạo rule tặng điểm theo tier".
|
||||||
|
- Agent tự lập execution plan trước khi gọi API: cần tạo/check resource nào, endpoint nào, payload summary nào.
|
||||||
|
- Auto-create draft theo thứ tự phụ thuộc, dừng lại khi thiếu field bắt buộc hoặc Core reject.
|
||||||
|
- Simulation trước approve:
|
||||||
|
- sample transaction
|
||||||
|
- matched transaction code
|
||||||
|
- pool impacted
|
||||||
|
- formula result
|
||||||
|
- cap/limit/counter impact
|
||||||
|
- Human confirmation bắt buộc trước các hành động tạo nhiều draft hoặc gửi approve/reject.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nguyên tắc triển khai
|
||||||
|
|
||||||
|
- OpenAPI spec là source of truth; không đoán DTO field ngoài schema.
|
||||||
|
- Tool output phải ngắn, có cấu trúc, dễ đưa lại vào Agent context.
|
||||||
|
- Mutating tools phải idempotent ở tầng Agent/MCP nếu có thể: có `draftId`, correlation id, request summary.
|
||||||
|
- Mọi create/edit draft đều chạy local validation trước, rồi mới gọi Core.
|
||||||
|
- Với action nhạy cảm (`approve`, `reject`, `bulk-*`, `delete`), bắt buộc xác nhận rõ từ user.
|
||||||
|
- Không để Agent tự tạo/chỉnh quá nhiều resource ngầm nếu chưa trình bày execution plan.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backlog kỹ thuật gần nhất
|
||||||
|
|
||||||
|
- [ ] Parse `reward.json` thành metadata nội bộ: resources, schemas, required fields, enums, readOnly fields, endpoints.
|
||||||
|
- [ ] Implement generic Reward API client cho CSR pattern.
|
||||||
|
- [ ] Implement MCP tools read-only cho Campaign/Rule/Pool/Transaction Code.
|
||||||
|
- [ ] Implement local draft store cho `CampaignDto`, `PoolDefinitionDto`, `CampaignRuleDto`.
|
||||||
|
- [ ] Implement validator dùng OpenAPI schema.
|
||||||
|
- [ ] Implement create-draft flow cho Pool Definition.
|
||||||
|
- [ ] Implement create-draft flow cho Campaign.
|
||||||
|
- [ ] Implement create-draft flow cho Campaign Rule với `verifyCriteria`.
|
||||||
|
- [ ] Add integration tests dùng mock Reward Core API.
|
||||||
|
- [ ] Add UI Draft Inspector sau khi backend flow ổn định.
|
||||||
2649
pnpm-lock.yaml
generated
Normal file
2649
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
317
pom.xml
317
pom.xml
@@ -7,93 +7,28 @@
|
|||||||
<parent>
|
<parent>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
<version>4.1.0</version>
|
<version>4.0.0</version>
|
||||||
<relativePath/> <!-- lookup parent from repository -->
|
<relativePath/> <!-- lookup parent from repository -->
|
||||||
</parent>
|
</parent>
|
||||||
|
|
||||||
<groupId>dev.sonpx.loyalty</groupId>
|
<groupId>dev.sonpx.loyalty</groupId>
|
||||||
<artifactId>loyalty-agent-service</artifactId>
|
<artifactId>loyalty-agent-service-parent</artifactId>
|
||||||
<version>0.0.1-SNAPSHOT</version>
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
<name>loyalty-agent-service</name>
|
<packaging>pom</packaging>
|
||||||
<description>AI Agent Service to orchestrate Loyalty core system</description>
|
<name>loyalty-agent-service-parent</name>
|
||||||
|
<description>Parent POM for Loyalty Agent Service</description>
|
||||||
|
|
||||||
|
<modules>
|
||||||
|
<module>loyalty-agent</module>
|
||||||
|
<module>loyalty-mcp-server</module>
|
||||||
|
</modules>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>25</java.version>
|
<java.version>25</java.version>
|
||||||
<spring-ai.version>2.0.0</spring-ai.version>
|
<spring-ai.version>2.0.0</spring-ai.version>
|
||||||
|
<lombok.version>1.18.38</lombok.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
<dependencies>
|
|
||||||
<!-- Spring Boot Web -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Spring AI Ollama -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.ai</groupId>
|
|
||||||
<artifactId>spring-ai-starter-model-ollama</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- WebClient (needed for generated API client if library is webclient) -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- OAuth2 Client -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-oauth2-client</artifactId>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Lombok -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.projectlombok</groupId>
|
|
||||||
<artifactId>lombok</artifactId>
|
|
||||||
<optional>true</optional>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Springdoc OpenAPI -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springdoc</groupId>
|
|
||||||
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
|
||||||
<version>2.7.0</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Dependencies for generated OpenAPI clients -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.openapitools</groupId>
|
|
||||||
<artifactId>jackson-databind-nullable</artifactId>
|
|
||||||
<version>0.2.6</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>jakarta.annotation</groupId>
|
|
||||||
<artifactId>jakarta.annotation-api</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-validation</artifactId>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.httpcomponents</groupId>
|
|
||||||
<artifactId>httpclient</artifactId>
|
|
||||||
<version>4.5.14</version>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.apache.httpcomponents</groupId>
|
|
||||||
<artifactId>httpmime</artifactId>
|
|
||||||
<version>4.5.14</version>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- Testing -->
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
|
||||||
<scope>test</scope>
|
|
||||||
</dependency>
|
|
||||||
</dependencies>
|
|
||||||
|
|
||||||
<dependencyManagement>
|
<dependencyManagement>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -106,236 +41,6 @@
|
|||||||
</dependencies>
|
</dependencies>
|
||||||
</dependencyManagement>
|
</dependencyManagement>
|
||||||
|
|
||||||
<build>
|
|
||||||
<plugins>
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
|
||||||
<configuration>
|
|
||||||
<excludes>
|
|
||||||
<exclude>
|
|
||||||
<groupId>org.projectlombok</groupId>
|
|
||||||
<artifactId>lombok</artifactId>
|
|
||||||
</exclude>
|
|
||||||
</excludes>
|
|
||||||
</configuration>
|
|
||||||
</plugin>
|
|
||||||
<!-- Add generated sources to compile path even when not running gen profile -->
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.codehaus.mojo</groupId>
|
|
||||||
<artifactId>build-helper-maven-plugin</artifactId>
|
|
||||||
<version>3.5.0</version>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<id>add-source</id>
|
|
||||||
<phase>generate-sources</phase>
|
|
||||||
<goals>
|
|
||||||
<goal>add-source</goal>
|
|
||||||
</goals>
|
|
||||||
<configuration>
|
|
||||||
<sources>
|
|
||||||
<source>${project.build.directory}/generated-sources/openapi/src/main/java</source>
|
|
||||||
</sources>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
|
||||||
</build>
|
|
||||||
|
|
||||||
<profiles>
|
|
||||||
<profile>
|
|
||||||
<id>gen</id>
|
|
||||||
<build>
|
|
||||||
<plugins>
|
|
||||||
|
|
||||||
<!-- OpenAPI Generator Plugin -->
|
|
||||||
<plugin>
|
|
||||||
<groupId>org.openapitools</groupId>
|
|
||||||
<artifactId>openapi-generator-maven-plugin</artifactId>
|
|
||||||
<version>7.23.0</version>
|
|
||||||
<configuration>
|
|
||||||
<skipValidateSpec>true</skipValidateSpec>
|
|
||||||
<typeMappings>
|
|
||||||
<MultiValueMapStringString>org.springframework.util.MultiValueMap</MultiValueMapStringString>
|
|
||||||
</typeMappings>
|
|
||||||
<importMappings>
|
|
||||||
<MultiValueMapStringString>org.springframework.util.MultiValueMap</MultiValueMapStringString>
|
|
||||||
</importMappings>
|
|
||||||
<nameMappings>user_name=user_name_raw,setAttributeId=set_attribute_id_val</nameMappings>
|
|
||||||
</configuration>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<id>generate-attribute</id>
|
|
||||||
<goals><goal>generate</goal></goals>
|
|
||||||
<configuration>
|
|
||||||
<inputSpec>${project.basedir}/src/main/resources/specs/attribute.json</inputSpec>
|
|
||||||
<generatorName>java</generatorName>
|
|
||||||
<library>native</library>
|
|
||||||
<apiPackage>dev.sonpx.loyalty.agent.client.api.attribute</apiPackage>
|
|
||||||
<modelPackage>dev.sonpx.loyalty.agent.client.model.attribute</modelPackage>
|
|
||||||
<generateApiTests>false</generateApiTests>
|
|
||||||
<generateModelTests>false</generateModelTests>
|
|
||||||
<configOptions>
|
|
||||||
<useJakartaEe>true</useJakartaEe>
|
|
||||||
<useSpringBoot3>true</useSpringBoot3>
|
|
||||||
</configOptions>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
|
|
||||||
<execution>
|
|
||||||
<id>generate-catalogue</id>
|
|
||||||
<goals><goal>generate</goal></goals>
|
|
||||||
<configuration>
|
|
||||||
<inputSpec>${project.basedir}/src/main/resources/specs/catalogue.json</inputSpec>
|
|
||||||
<generatorName>java</generatorName>
|
|
||||||
<library>native</library>
|
|
||||||
<apiPackage>dev.sonpx.loyalty.agent.client.api.catalogue</apiPackage>
|
|
||||||
<modelPackage>dev.sonpx.loyalty.agent.client.model.catalogue</modelPackage>
|
|
||||||
<generateApiTests>false</generateApiTests>
|
|
||||||
<generateModelTests>false</generateModelTests>
|
|
||||||
<configOptions>
|
|
||||||
<useJakartaEe>true</useJakartaEe>
|
|
||||||
<useSpringBoot3>true</useSpringBoot3>
|
|
||||||
</configOptions>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
<execution>
|
|
||||||
<id>generate-customer</id>
|
|
||||||
<goals><goal>generate</goal></goals>
|
|
||||||
<configuration>
|
|
||||||
<inputSpec>${project.basedir}/src/main/resources/specs/customer.json</inputSpec>
|
|
||||||
<generatorName>java</generatorName>
|
|
||||||
<library>native</library>
|
|
||||||
<apiPackage>dev.sonpx.loyalty.agent.client.api.customer</apiPackage>
|
|
||||||
<modelPackage>dev.sonpx.loyalty.agent.client.model.customer</modelPackage>
|
|
||||||
<generateApiTests>false</generateApiTests>
|
|
||||||
<generateModelTests>false</generateModelTests>
|
|
||||||
<configOptions>
|
|
||||||
<useJakartaEe>true</useJakartaEe>
|
|
||||||
<useSpringBoot3>true</useSpringBoot3>
|
|
||||||
</configOptions>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
<execution>
|
|
||||||
<id>generate-identity</id>
|
|
||||||
<goals><goal>generate</goal></goals>
|
|
||||||
<configuration>
|
|
||||||
<inputSpec>${project.basedir}/src/main/resources/specs/identity.json</inputSpec>
|
|
||||||
<generatorName>java</generatorName>
|
|
||||||
<library>native</library>
|
|
||||||
<apiPackage>dev.sonpx.loyalty.agent.client.api.identity</apiPackage>
|
|
||||||
<modelPackage>dev.sonpx.loyalty.agent.client.model.identity</modelPackage>
|
|
||||||
<generateApiTests>false</generateApiTests>
|
|
||||||
<generateModelTests>false</generateModelTests>
|
|
||||||
<configOptions>
|
|
||||||
<useJakartaEe>true</useJakartaEe>
|
|
||||||
<useSpringBoot3>true</useSpringBoot3>
|
|
||||||
</configOptions>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
<execution>
|
|
||||||
<id>generate-marketing</id>
|
|
||||||
<goals><goal>generate</goal></goals>
|
|
||||||
<configuration>
|
|
||||||
<inputSpec>${project.basedir}/src/main/resources/specs/marketing.json</inputSpec>
|
|
||||||
<generatorName>java</generatorName>
|
|
||||||
<library>native</library>
|
|
||||||
<apiPackage>dev.sonpx.loyalty.agent.client.api.marketing</apiPackage>
|
|
||||||
<modelPackage>dev.sonpx.loyalty.agent.client.model.marketing</modelPackage>
|
|
||||||
<generateApiTests>false</generateApiTests>
|
|
||||||
<generateModelTests>false</generateModelTests>
|
|
||||||
<configOptions>
|
|
||||||
<useJakartaEe>true</useJakartaEe>
|
|
||||||
<useSpringBoot3>true</useSpringBoot3>
|
|
||||||
</configOptions>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
<execution>
|
|
||||||
<id>generate-master</id>
|
|
||||||
<goals><goal>generate</goal></goals>
|
|
||||||
<configuration>
|
|
||||||
<inputSpec>${project.basedir}/src/main/resources/specs/master.json</inputSpec>
|
|
||||||
<generatorName>java</generatorName>
|
|
||||||
<library>native</library>
|
|
||||||
<apiPackage>dev.sonpx.loyalty.agent.client.api.master</apiPackage>
|
|
||||||
<modelPackage>dev.sonpx.loyalty.agent.client.model.master</modelPackage>
|
|
||||||
<generateApiTests>false</generateApiTests>
|
|
||||||
<generateModelTests>false</generateModelTests>
|
|
||||||
<configOptions>
|
|
||||||
<useJakartaEe>true</useJakartaEe>
|
|
||||||
<useSpringBoot3>true</useSpringBoot3>
|
|
||||||
</configOptions>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
|
|
||||||
<execution>
|
|
||||||
<id>generate-reward</id>
|
|
||||||
<goals><goal>generate</goal></goals>
|
|
||||||
<configuration>
|
|
||||||
<inputSpec>${project.basedir}/src/main/resources/specs/reward.json</inputSpec>
|
|
||||||
<generatorName>java</generatorName>
|
|
||||||
<library>native</library>
|
|
||||||
<apiPackage>dev.sonpx.loyalty.agent.client.api.reward</apiPackage>
|
|
||||||
<modelPackage>dev.sonpx.loyalty.agent.client.model.reward</modelPackage>
|
|
||||||
<generateApiTests>false</generateApiTests>
|
|
||||||
<generateModelTests>false</generateModelTests>
|
|
||||||
<configOptions>
|
|
||||||
<useJakartaEe>true</useJakartaEe>
|
|
||||||
<useSpringBoot3>true</useSpringBoot3>
|
|
||||||
</configOptions>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
<execution>
|
|
||||||
<id>generate-transaction</id>
|
|
||||||
<goals><goal>generate</goal></goals>
|
|
||||||
<configuration>
|
|
||||||
<inputSpec>${project.basedir}/src/main/resources/specs/transaction.json</inputSpec>
|
|
||||||
<generatorName>java</generatorName>
|
|
||||||
<library>native</library>
|
|
||||||
<apiPackage>dev.sonpx.loyalty.agent.client.api.transaction</apiPackage>
|
|
||||||
<modelPackage>dev.sonpx.loyalty.agent.client.model.transaction</modelPackage>
|
|
||||||
<generateApiTests>false</generateApiTests>
|
|
||||||
<generateModelTests>false</generateModelTests>
|
|
||||||
<configOptions>
|
|
||||||
<useJakartaEe>true</useJakartaEe>
|
|
||||||
<useSpringBoot3>true</useSpringBoot3>
|
|
||||||
</configOptions>
|
|
||||||
</configuration>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
</plugin>
|
|
||||||
|
|
||||||
<!-- Replacer plugin to fix missing import of MultiValueMapStringString in openapi generated code -->
|
|
||||||
<plugin>
|
|
||||||
<groupId>com.google.code.maven-replacer-plugin</groupId>
|
|
||||||
<artifactId>replacer</artifactId>
|
|
||||||
<version>1.5.3</version>
|
|
||||||
<executions>
|
|
||||||
<execution>
|
|
||||||
<phase>process-sources</phase>
|
|
||||||
<goals>
|
|
||||||
<goal>replace</goal>
|
|
||||||
</goals>
|
|
||||||
</execution>
|
|
||||||
</executions>
|
|
||||||
<configuration>
|
|
||||||
<includes>
|
|
||||||
<include>${project.basedir}/target/generated-sources/openapi/src/main/java/**/*Api.java</include>
|
|
||||||
</includes>
|
|
||||||
<replacements>
|
|
||||||
<replacement>
|
|
||||||
<token>(?<!\.)MultiValueMapStringString</token>
|
|
||||||
<value>dev.sonpx.loyalty.agent.client.model.transaction.MultiValueMapStringString</value>
|
|
||||||
</replacement>
|
|
||||||
</replacements>
|
|
||||||
</configuration>
|
|
||||||
</plugin>
|
|
||||||
</plugins>
|
|
||||||
</build>
|
|
||||||
</profile>
|
|
||||||
</profiles>
|
|
||||||
|
|
||||||
<repositories>
|
<repositories>
|
||||||
<repository>
|
<repository>
|
||||||
<id>spring-milestones</id>
|
<id>spring-milestones</id>
|
||||||
|
|||||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
public/icons.svg
Normal file
24
public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
184
src/App.css
Normal file
184
src/App.css
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
.counter {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--accent-bg);
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--accent-border);
|
||||||
|
}
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.base,
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
inset-inline: 0;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
width: 170px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework,
|
||||||
|
.vite {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.framework {
|
||||||
|
z-index: 1;
|
||||||
|
top: 34px;
|
||||||
|
height: 28px;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||||
|
scale(1.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vite {
|
||||||
|
z-index: 0;
|
||||||
|
top: 107px;
|
||||||
|
height: 26px;
|
||||||
|
width: auto;
|
||||||
|
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||||
|
scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#center {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
place-content: center;
|
||||||
|
place-items: center;
|
||||||
|
flex-grow: 1;
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 32px 20px 24px;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps {
|
||||||
|
display: flex;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
& > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
padding: 32px;
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#docs {
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#next-steps ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 32px 0 0;
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--text-h);
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--social-bg);
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 12px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: box-shadow 0.3s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.button-icon {
|
||||||
|
height: 18px;
|
||||||
|
width: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
margin-top: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
li {
|
||||||
|
flex: 1 1 calc(50% - 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#spacer {
|
||||||
|
height: 88px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticks {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
&::before,
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4.5px;
|
||||||
|
border: 5px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
left: 0;
|
||||||
|
border-left-color: var(--border);
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
right: 0;
|
||||||
|
border-right-color: var(--border);
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/App.tsx
Normal file
9
src/App.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { ChatLayout } from '@/components/chat/ChatLayout'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<ChatLayout />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
BIN
src/assets/hero.png
Normal file
BIN
src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
1
src/assets/vite.svg
Normal file
1
src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
109
src/components/campaign/CampaignList.tsx
Normal file
109
src/components/campaign/CampaignList.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
interface Campaign {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CampaignList() {
|
||||||
|
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedCampaignId, setSelectedCampaignId] = useState<string | null>(null);
|
||||||
|
const [rules, setRules] = useState<any[]>([]);
|
||||||
|
const [loadingRules, setLoadingRules] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch('http://localhost:9332/api/v1/agent/campaigns')
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data)) setCampaigns(data);
|
||||||
|
else if (data && Array.isArray(data.content)) setCampaigns(data.content);
|
||||||
|
else if (data && Array.isArray(data.data)) setCampaigns(data.data);
|
||||||
|
else setCampaigns([]);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Failed to fetch campaigns', err);
|
||||||
|
setCampaigns([]);
|
||||||
|
})
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectCampaign = (id: string) => {
|
||||||
|
if (selectedCampaignId === id) {
|
||||||
|
setSelectedCampaignId(null);
|
||||||
|
setRules([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedCampaignId(id);
|
||||||
|
setLoadingRules(true);
|
||||||
|
fetch(`http://localhost:9332/api/v1/agent/campaigns/${id}/rules`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (Array.isArray(data)) setRules(data);
|
||||||
|
else if (data && Array.isArray(data.content)) setRules(data.content);
|
||||||
|
else if (data && Array.isArray(data.data)) setRules(data.data);
|
||||||
|
else setRules([]);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('Failed to fetch rules', err);
|
||||||
|
setRules([]);
|
||||||
|
})
|
||||||
|
.finally(() => setLoadingRules(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div className="p-4 text-center text-sm text-muted-foreground">Loading campaigns...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (campaigns.length === 0) {
|
||||||
|
return <div className="p-4 text-center text-sm text-muted-foreground">No campaigns found.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-3 custom-scrollbar">
|
||||||
|
{campaigns.map((camp) => (
|
||||||
|
<div key={camp.id} className="space-y-2">
|
||||||
|
<div
|
||||||
|
onClick={() => handleSelectCampaign(camp.id)}
|
||||||
|
className={`glass-card rounded-xl p-4 text-sm group cursor-pointer relative overflow-hidden transition-all duration-200 ${selectedCampaignId === camp.id ? 'ring-2 ring-primary/50' : ''}`}>
|
||||||
|
<div className="absolute inset-0 bg-primary/5 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
<div className="relative z-10">
|
||||||
|
<div className="font-semibold text-foreground text-base mb-1 group-hover:text-primary transition-colors">{camp.name || 'Unnamed Campaign'}</div>
|
||||||
|
<div className="flex justify-between items-center text-xs text-muted-foreground mt-3">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className={`w-2 h-2 rounded-full ${camp.status === 'A' ? 'bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]' : 'bg-rose-500'}`} />
|
||||||
|
{camp.status === 'A' ? 'Active' : camp.status}
|
||||||
|
</span>
|
||||||
|
<code className="bg-background/50 px-1.5 py-0.5 rounded text-[10px] uppercase">{camp.id}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedCampaignId === camp.id && (
|
||||||
|
<div className="pl-4 pr-2 space-y-2 border-l-2 border-primary/20 ml-2 animate-in slide-in-from-top-2">
|
||||||
|
{loadingRules ? (
|
||||||
|
<div className="text-xs text-muted-foreground italic">Loading rules...</div>
|
||||||
|
) : rules.length === 0 ? (
|
||||||
|
<div className="text-xs text-muted-foreground italic">No rules found.</div>
|
||||||
|
) : (
|
||||||
|
rules.map((rule, idx) => (
|
||||||
|
<div key={rule.id || idx} className="bg-background/40 rounded-lg p-3 text-xs border border-border/50">
|
||||||
|
<div className="font-medium text-foreground mb-1">{rule.name || 'Unnamed Rule'}</div>
|
||||||
|
<div className="text-muted-foreground line-clamp-2">{rule.description || 'No description'}</div>
|
||||||
|
{rule.status && (
|
||||||
|
<div className="mt-2 text-[10px] uppercase tracking-wider text-muted-foreground/80">Status: {rule.status}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
src/components/chat/ChatBottombar.tsx
Normal file
60
src/components/chat/ChatBottombar.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import type { KeyboardEvent } from 'react'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useChatStore } from '@/store/useChatStore'
|
||||||
|
import { SendHorizontal } from 'lucide-react'
|
||||||
|
|
||||||
|
export function ChatBottombar() {
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
const sendMessage = useChatStore((state) => state.sendMessage)
|
||||||
|
const isConnected = useChatStore((state) => state.isConnected)
|
||||||
|
const isConnecting = useChatStore((state) => state.isConnecting)
|
||||||
|
|
||||||
|
const handleSend = () => {
|
||||||
|
if (input.trim() && isConnected) {
|
||||||
|
sendMessage(input.trim())
|
||||||
|
setInput('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-background border-t">
|
||||||
|
<div className="max-w-3xl mx-auto flex items-end gap-2 relative">
|
||||||
|
<Textarea
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
className="min-h-[60px] max-h-[200px] resize-none"
|
||||||
|
disabled={!isConnected}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
className="h-[60px] w-[60px] shrink-0"
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!input.trim() || !isConnected}
|
||||||
|
>
|
||||||
|
<SendHorizontal className="w-6 h-6" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{!isConnected && !isConnecting && (
|
||||||
|
<div className="text-center text-destructive text-sm mt-2">
|
||||||
|
Disconnected from agent server. Retrying...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isConnecting && (
|
||||||
|
<div className="text-center text-muted-foreground text-sm mt-2 animate-pulse">
|
||||||
|
Connecting to agent server...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
48
src/components/chat/ChatBubble.tsx
Normal file
48
src/components/chat/ChatBubble.tsx
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import ReactMarkdown from 'react-markdown'
|
||||||
|
import remarkGfm from 'remark-gfm'
|
||||||
|
import type { Message } from '@/store/useChatStore'
|
||||||
|
|
||||||
|
export function ChatBubble({ message }: { message: Message }) {
|
||||||
|
const isAgent = message.role === 'agent'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex w-full mt-4 space-x-3 max-w-3xl mx-auto", isAgent ? "justify-start" : "justify-end")}>
|
||||||
|
{isAgent && (
|
||||||
|
<Avatar className="w-8 h-8">
|
||||||
|
<AvatarFallback>AG</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-1 max-w-[80%]">
|
||||||
|
{message.toolStatus && (
|
||||||
|
<span className="text-xs text-muted-foreground animate-pulse italic">
|
||||||
|
{message.toolStatus}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"p-3 rounded-md text-sm shadow-sm",
|
||||||
|
isAgent ? "bg-muted text-foreground" : "bg-primary text-primary-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isAgent ? (
|
||||||
|
<div className="prose dark:prose-invert max-w-none text-sm">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||||
|
{message.content}
|
||||||
|
</ReactMarkdown>
|
||||||
|
{message.isStreaming && <span className="inline-block w-2 h-4 bg-current animate-pulse ml-1 align-middle" />}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="whitespace-pre-wrap">{message.content}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!isAgent && (
|
||||||
|
<Avatar className="w-8 h-8">
|
||||||
|
<AvatarFallback>U</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
src/components/chat/ChatHistoryList.tsx
Normal file
40
src/components/chat/ChatHistoryList.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { MessageSquare } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ChatSession {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MOCK_SESSIONS: ChatSession[] = [
|
||||||
|
{ id: '1', title: 'Phân tích Campaign Noel 2026', date: 'Vừa xong' },
|
||||||
|
{ id: '2', title: 'Tại sao Rule giảm giá không hoạt động?', date: '2 giờ trước' },
|
||||||
|
{ id: '3', title: 'Tạo Campaign sinh nhật khách hàng', date: 'Hôm qua' },
|
||||||
|
{ id: '4', title: 'Kiểm tra điểm thưởng KH VIP', date: 'Hôm qua' },
|
||||||
|
{ id: '5', title: 'Lỗi đồng bộ dữ liệu CRM', date: '3 ngày trước' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ChatHistoryList() {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-2 custom-scrollbar">
|
||||||
|
{MOCK_SESSIONS.map((session) => (
|
||||||
|
<div
|
||||||
|
key={session.id}
|
||||||
|
className="p-3 text-sm group cursor-pointer relative overflow-hidden rounded-lg hover:bg-white/5 transition-colors border border-transparent hover:border-white/10"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<MessageSquare className="w-4 h-4 mt-0.5 text-muted-foreground group-hover:text-primary transition-colors shrink-0" />
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<div className="truncate font-medium text-foreground/90 group-hover:text-foreground transition-colors">
|
||||||
|
{session.title}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
{session.date}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user