forked from kimhyungsik/ax_hub_mcp_tool
feat: integrate Spring AI ChatClient with dynamic ToolCallback for MCP tools
This commit is contained in:
@@ -10,7 +10,7 @@ dependencies {
|
||||
|
||||
// Spring AI MCP Server
|
||||
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
|
||||
|
||||
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
|
||||
// MyBatis & DB
|
||||
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
|
||||
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package io.shinhanlife.axhub.biz.mcp.gateway.presentation;
|
||||
|
||||
import io.shinhanlife.axhub.biz.mcp.gateway.dto.ToolMetadata;
|
||||
import io.shinhanlife.axhub.biz.mcp.gateway.registry.RedisRegistryService;
|
||||
import io.shinhanlife.axhub.biz.mcp.gateway.service.ExecuteService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.client.ChatClient;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.ai.tool.definition.ToolDefinition;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/chat")
|
||||
@RequiredArgsConstructor
|
||||
public class ChatController {
|
||||
|
||||
private final ExecuteService executeService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final RedisRegistryService registryService;
|
||||
private final ChatClient.Builder chatClientBuilder;
|
||||
|
||||
@PostMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter chatStream(@RequestBody Map<String, String> request) {
|
||||
String message = request.getOrDefault("message", "").trim();
|
||||
log.info("[Real AI Chat] 사용자의 메시지 수신: {}", message);
|
||||
|
||||
SseEmitter emitter = new SseEmitter(120000L); // 120 seconds timeout
|
||||
|
||||
try {
|
||||
List<ToolCallback> callbacks = new ArrayList<>();
|
||||
for (ToolMetadata meta : registryService.getAllTools()) {
|
||||
if (Boolean.TRUE.equals(meta.getVisible())) {
|
||||
callbacks.add(new DynamicMcpToolCallback(meta, executeService, objectMapper));
|
||||
}
|
||||
}
|
||||
|
||||
ChatClient chatClient = chatClientBuilder
|
||||
.defaultSystem("You are AX HUB Assistant, a highly capable enterprise AI agent. You must use the provided tools to answer user questions when necessary. Always answer politely in Korean.")
|
||||
.build();
|
||||
|
||||
Flux<String> responseStream = chatClient.prompt()
|
||||
.user(message)
|
||||
.tools((Object[]) callbacks.toArray(new ToolCallback[0])) // Spring AI 2.0 uses tools()
|
||||
.stream()
|
||||
.content();
|
||||
|
||||
responseStream.subscribe(
|
||||
chunk -> {
|
||||
try {
|
||||
if (chunk != null) {
|
||||
emitter.send(chunk);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
},
|
||||
error -> {
|
||||
log.error("[Real AI Chat] 스트리밍 중 에러 발생", error);
|
||||
try {
|
||||
emitter.send("\n[에러 발생: " + error.getMessage() + "]");
|
||||
} catch (Exception ignore) {}
|
||||
emitter.completeWithError(error);
|
||||
},
|
||||
() -> {
|
||||
log.info("[Real AI Chat] 스트리밍 완료");
|
||||
emitter.complete();
|
||||
}
|
||||
);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[Real AI Chat] 초기화 중 에러 발생", e);
|
||||
try {
|
||||
emitter.send("초기화 중 에러가 발생했습니다: " + e.getMessage());
|
||||
} catch (Exception ignore) {}
|
||||
emitter.completeWithError(e);
|
||||
}
|
||||
|
||||
return emitter;
|
||||
}
|
||||
|
||||
private static class DynamicMcpToolCallback implements ToolCallback {
|
||||
private final ToolMetadata metadata;
|
||||
private final ExecuteService executeService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ToolDefinition toolDefinition;
|
||||
|
||||
public DynamicMcpToolCallback(ToolMetadata metadata, ExecuteService executeService, ObjectMapper objectMapper) {
|
||||
this.metadata = metadata;
|
||||
this.executeService = executeService;
|
||||
this.objectMapper = objectMapper;
|
||||
|
||||
String schema = "{\"type\":\"object\",\"properties\":{}}";
|
||||
try {
|
||||
if (metadata.getParametersSchema() != null) {
|
||||
schema = objectMapper.writeValueAsString(metadata.getParametersSchema());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Schema parsing error for tool: {}", metadata.getName());
|
||||
}
|
||||
|
||||
this.toolDefinition = ToolDefinition.builder()
|
||||
.name(metadata.getName().replaceAll("[^a-zA-Z0-9_-]", "_"))
|
||||
.description(metadata.getDescription() != null ? metadata.getDescription() : "No description provided")
|
||||
.inputSchema(schema)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ToolDefinition getToolDefinition() {
|
||||
return this.toolDefinition;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String call(String toolInput) {
|
||||
log.info("[Function Calling] LLM이 '{}' 툴을 호출했습니다. 입력값: {}", metadata.getName(), toolInput);
|
||||
try {
|
||||
Map<String, Object> payload = new HashMap<>();
|
||||
payload.put("jsonrpc", "2.0");
|
||||
payload.put("method", "tools/call");
|
||||
payload.put("id", UUID.randomUUID().toString());
|
||||
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("name", metadata.getName());
|
||||
|
||||
if (toolInput != null && !toolInput.trim().isEmpty()) {
|
||||
Map<String, Object> arguments = objectMapper.readValue(toolInput, Map.class);
|
||||
params.put("arguments", arguments);
|
||||
} else {
|
||||
params.put("arguments", new HashMap<>());
|
||||
}
|
||||
|
||||
payload.put("params", params);
|
||||
|
||||
Object result = executeService.execute(payload, "default");
|
||||
String jsonResult = objectMapper.writeValueAsString(result);
|
||||
log.info("[Function Calling] '{}' 툴 실행 완료. 결과: {}", metadata.getName(), jsonResult);
|
||||
return jsonResult;
|
||||
} catch (Exception e) {
|
||||
log.error("[Function Calling] '{}' 툴 실행 중 에러", metadata.getName(), e);
|
||||
return "{\"error\": \"" + e.getMessage() + "\"}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,3 +40,10 @@ mcp.max-pages-per-call=3
|
||||
mcp.max-stream-bytes=1048576
|
||||
mcp.max-response-bytes-from-tool=5242880
|
||||
mcp.tool-registry-sync-interval-millis=5000
|
||||
|
||||
# Spring AI OpenAI / Gemini API Config
|
||||
spring.ai.openai.api-key=AIzaSyB4fAgAjF9lL4CiiH4yMevD9qE6wMyWZO0
|
||||
spring.ai.openai.base-url=https://generativelanguage.googleapis.com/v1beta/openai/
|
||||
spring.ai.openai.chat.options.model=gemini-flash-latest
|
||||
spring.ai.openai.chat.options.temperature=0.3
|
||||
|
||||
|
||||
232
axhub-gateway/src/main/resources/static/chat.html
Normal file
232
axhub-gateway/src/main/resources/static/chat.html
Normal file
@@ -0,0 +1,232 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AX HUB - ChatClient</title>
|
||||
<script src="https://unpkg.com/@tailwindcss/browser@4"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; background-color: #0f1115; color: #e2e8f0; }
|
||||
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
||||
|
||||
.message-enter { animation: fadeInUp 0.3s ease-out forwards; }
|
||||
@keyframes fadeInUp {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.typing-dot { animation: typing 1.4s infinite ease-in-out; fill: #94a3b8; }
|
||||
.typing-dot:nth-child(1) { animation-delay: 0s; }
|
||||
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
|
||||
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
|
||||
@keyframes typing {
|
||||
0%, 100% { transform: translateY(0); }
|
||||
50% { transform: translateY(-3px); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-screen flex flex-col items-center justify-center p-4">
|
||||
|
||||
<!-- Chat Container -->
|
||||
<div class="w-full max-w-3xl h-[85vh] flex flex-col bg-[#16181d] rounded-2xl shadow-2xl overflow-hidden border border-white/5 relative">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="px-6 py-4 border-b border-white/5 flex items-center justify-between bg-[#16181d] z-10">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-sm font-semibold tracking-wide text-white">AX HUB Assistant</h1>
|
||||
<p class="text-[11px] text-slate-500">Powered by Mock Engine & MCP Tools</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="relative flex h-2.5 w-2.5">
|
||||
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-emerald-500"></span>
|
||||
</span>
|
||||
<span class="text-xs text-slate-400">Online</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Area -->
|
||||
<div id="chat-box" class="flex-1 overflow-y-auto p-6 flex flex-col gap-6 scrollbar-hide scroll-smooth">
|
||||
|
||||
<!-- System Greeting -->
|
||||
<div class="flex gap-4 message-enter">
|
||||
<div class="w-8 h-8 rounded-full bg-emerald-500/10 flex-shrink-0 flex items-center justify-center mt-1 border border-emerald-500/20">
|
||||
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"></path></svg>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 max-w-[80%]">
|
||||
<span class="text-xs text-slate-500 ml-1 font-medium">Assistant</span>
|
||||
<div class="bg-[#1e2128] px-5 py-3.5 rounded-2xl rounded-tl-sm text-sm text-slate-200 leading-relaxed border border-white/5 shadow-sm">
|
||||
안녕하세요! 저는 AX HUB Assistant입니다. <br>
|
||||
채팅을 통해 MCP(도구)들을 직접 호출해보세요. <br><br>
|
||||
💡 <strong>예시:</strong> "오늘 서울 날씨 어때?"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Input Area -->
|
||||
<div class="p-4 bg-[#16181d] border-t border-white/5 relative">
|
||||
<div class="relative flex items-center w-full max-w-full mx-auto group">
|
||||
<input type="text" id="chat-input" placeholder="메시지를 입력하세요... (예: 서울 날씨 어때?)"
|
||||
class="w-full bg-[#1e2128] text-slate-200 text-sm px-5 py-4 rounded-xl border border-white/5 focus:outline-none focus:border-emerald-500/50 focus:ring-1 focus:ring-emerald-500/50 transition-all placeholder-slate-600 pr-12 shadow-inner"
|
||||
onkeypress="handleKeyPress(event)">
|
||||
<button onclick="sendMessage()" class="absolute right-2 p-2 rounded-lg bg-emerald-500 hover:bg-emerald-400 text-white transition-colors flex items-center justify-center shadow-lg shadow-emerald-500/20">
|
||||
<svg class="w-4 h-4 translate-x-[1px]" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 로딩 인디케이터 템플릿 -->
|
||||
<template id="loading-template">
|
||||
<div class="flex gap-4 message-enter" id="loading-indicator">
|
||||
<div class="w-8 h-8 rounded-full bg-emerald-500/10 flex-shrink-0 flex items-center justify-center mt-1 border border-emerald-500/20">
|
||||
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"></path></svg>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 max-w-[80%]">
|
||||
<span class="text-xs text-slate-500 ml-1 font-medium">Assistant</span>
|
||||
<div class="bg-[#1e2128] px-5 py-4 rounded-2xl rounded-tl-sm border border-white/5 flex items-center gap-1 shadow-sm h-[48px]">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle class="typing-dot" cx="4" cy="12" r="3"/>
|
||||
<circle class="typing-dot" cx="12" cy="12" r="3"/>
|
||||
<circle class="typing-dot" cx="20" cy="12" r="3"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
const chatBox = document.getElementById('chat-box');
|
||||
const chatInput = document.getElementById('chat-input');
|
||||
const loadingTemplate = document.getElementById('loading-template');
|
||||
let isLoading = false;
|
||||
|
||||
function handleKeyPress(e) {
|
||||
if (e.key === 'Enter') {
|
||||
sendMessage();
|
||||
}
|
||||
}
|
||||
|
||||
function appendUserMessage(text) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'flex gap-4 justify-end message-enter';
|
||||
div.innerHTML = `
|
||||
<div class="flex flex-col gap-1 max-w-[80%] items-end">
|
||||
<span class="text-xs text-slate-500 mr-1 font-medium">You</span>
|
||||
<div class="bg-emerald-500/10 px-5 py-3.5 rounded-2xl rounded-tr-sm text-sm text-emerald-50 leading-relaxed border border-emerald-500/20 shadow-sm whitespace-pre-wrap">${escapeHtml(text)}</div>
|
||||
</div>
|
||||
`;
|
||||
chatBox.appendChild(div);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function appendBotMessageContainer() {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'flex gap-4 message-enter';
|
||||
div.innerHTML = `
|
||||
<div class="w-8 h-8 rounded-full bg-emerald-500/10 flex-shrink-0 flex items-center justify-center mt-1 border border-emerald-500/20">
|
||||
<svg class="w-4 h-4 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"></path></svg>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 max-w-[80%]">
|
||||
<span class="text-xs text-slate-500 ml-1 font-medium">Assistant</span>
|
||||
<div class="bg-[#1e2128] px-5 py-3.5 rounded-2xl rounded-tl-sm text-sm text-slate-200 leading-relaxed border border-white/5 shadow-sm whitespace-pre-wrap bot-text"></div>
|
||||
</div>
|
||||
`;
|
||||
chatBox.appendChild(div);
|
||||
scrollToBottom();
|
||||
return div.querySelector('.bot-text');
|
||||
}
|
||||
|
||||
function showLoading() {
|
||||
const clone = loadingTemplate.content.cloneNode(true);
|
||||
chatBox.appendChild(clone);
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
const loadingIndicator = document.getElementById('loading-indicator');
|
||||
if (loadingIndicator) {
|
||||
loadingIndicator.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
chatBox.scrollTo({ top: chatBox.scrollHeight, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function escapeHtml(unsafe) {
|
||||
return unsafe
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = chatInput.value.trim();
|
||||
if (!text || isLoading) return;
|
||||
|
||||
// 1. 유저 메시지 추가
|
||||
appendUserMessage(text);
|
||||
chatInput.value = '';
|
||||
isLoading = true;
|
||||
|
||||
// 2. 로딩 애니메이션
|
||||
showLoading();
|
||||
|
||||
try {
|
||||
// 3. 백엔드 통신 (SSE Stream)
|
||||
const response = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: text })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Network response was not ok');
|
||||
|
||||
// 4. 로딩 숨기고 봇 응답 컨테이너 생성
|
||||
hideLoading();
|
||||
const textContainer = appendBotMessageContainer();
|
||||
|
||||
// 5. 스트림 읽기
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
const lines = chunk.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data:')) {
|
||||
const data = line.substring(5);
|
||||
textContainer.innerHTML += escapeHtml(data);
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
hideLoading();
|
||||
const textContainer = appendBotMessageContainer();
|
||||
textContainer.innerHTML = "서버와의 통신에 실패했습니다. Gateway가 켜져있는지 확인해주세요.";
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user