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
|
// Spring AI MCP Server
|
||||||
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
|
implementation 'org.springframework.ai:spring-ai-starter-mcp-server-webmvc'
|
||||||
|
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
|
||||||
// MyBatis & DB
|
// MyBatis & DB
|
||||||
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
|
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
|
||||||
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
|
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-stream-bytes=1048576
|
||||||
mcp.max-response-bytes-from-tool=5242880
|
mcp.max-response-bytes-from-tool=5242880
|
||||||
mcp.tool-registry-sync-interval-millis=5000
|
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>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package io.shinhanlife.axhub.biz.mcp.tool.dto;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @package io.shinhanlife.axhub.biz.mcp.tool.dto
|
||||||
|
* @className WeatherReq
|
||||||
|
* @description 기상 조회 요청 클래스
|
||||||
|
* @author 김형식
|
||||||
|
* @create 2026.07.14
|
||||||
|
* <pre>
|
||||||
|
* ---------- 개정이력 ----------
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ---------- -------- ---------------------------
|
||||||
|
* 2026.07.14 김형식 최초생성
|
||||||
|
*
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public record WeatherReq(
|
||||||
|
@JsonProperty(required = true, value = "city")
|
||||||
|
@JsonPropertyDescription("날씨를 조회할 도시 이름 (예: 서울, 부산, 제주)")
|
||||||
|
String city
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package io.shinhanlife.axhub.biz.mcp.tool.dto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @package io.shinhanlife.axhub.biz.mcp.tool.dto
|
||||||
|
* @className WeatherRes
|
||||||
|
* @description 기상 조회 응답 클래스
|
||||||
|
* @author 김형식
|
||||||
|
* @create 2026.07.14
|
||||||
|
* <pre>
|
||||||
|
* ---------- 개정이력 ----------
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ---------- -------- ---------------------------
|
||||||
|
* 2026.07.14 김형식 최초생성
|
||||||
|
*
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
public record WeatherRes(
|
||||||
|
String city,
|
||||||
|
double temperature,
|
||||||
|
double windSpeed,
|
||||||
|
String reportTime,
|
||||||
|
String summary
|
||||||
|
) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package io.shinhanlife.axhub.biz.mcp.tool.service;
|
||||||
|
|
||||||
|
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpFunction;
|
||||||
|
import io.shinhanlife.axhub.biz.mcp.tool.annotation.McpTool;
|
||||||
|
import io.shinhanlife.axhub.biz.mcp.tool.dto.WeatherReq;
|
||||||
|
import io.shinhanlife.axhub.biz.mcp.tool.dto.WeatherRes;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @package io.shinhanlife.axhub.biz.mcp.tool.service
|
||||||
|
* @className WeatherToolService
|
||||||
|
* @description AX HUB 시스템 처리 클래스
|
||||||
|
* @author 김형식
|
||||||
|
* @create 2026.07.14
|
||||||
|
* <pre>
|
||||||
|
* ---------- 개정이력 ----------
|
||||||
|
* 수정일 수정자 수정내용
|
||||||
|
* ---------- -------- ---------------------------
|
||||||
|
* 2026.07.14 김형식 최초생성
|
||||||
|
*
|
||||||
|
* </pre>
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@McpTool(
|
||||||
|
routingType = "DIRECT",
|
||||||
|
categoryKey = "common"
|
||||||
|
)
|
||||||
|
public class WeatherToolService extends AbstractMcpToolService {
|
||||||
|
|
||||||
|
private final RestClient restClient;
|
||||||
|
|
||||||
|
public WeatherToolService() {
|
||||||
|
this.restClient = RestClient.create();
|
||||||
|
}
|
||||||
|
|
||||||
|
@McpFunction(displayName = "날씨 조회 툴", name = "get_current_weather",
|
||||||
|
description = "특정 도시의 실시간 날씨(기온, 풍속 등)를 오픈 기상 API를 통해 조회합니다.",
|
||||||
|
prompt = "서울 날씨 알려줘, 부산 기온 알려줘 등 실시간 기상 조회",
|
||||||
|
mappingId = "WEATHER_001"
|
||||||
|
)
|
||||||
|
public WeatherRes execute(WeatherReq req) {
|
||||||
|
String city = req.city() != null ? req.city().trim() : "서울";
|
||||||
|
|
||||||
|
// 지역별 위경도 매핑 (간단한 예시)
|
||||||
|
double lat = 37.566;
|
||||||
|
double lon = 126.978;
|
||||||
|
|
||||||
|
if (city.contains("부산")) {
|
||||||
|
lat = 35.179;
|
||||||
|
lon = 129.075;
|
||||||
|
} else if (city.contains("제주")) {
|
||||||
|
lat = 33.499;
|
||||||
|
lon = 126.531;
|
||||||
|
} else if (city.contains("인천")) {
|
||||||
|
lat = 37.456;
|
||||||
|
lon = 126.705;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String url = String.format("https://api.open-meteo.com/v1/forecast?latitude=%f&longitude=%f¤t_weather=true", lat, lon);
|
||||||
|
|
||||||
|
log.info("[WeatherTool] 날씨 조회 요청 URL: {}", url);
|
||||||
|
|
||||||
|
String responseStr = restClient.get()
|
||||||
|
.uri(url)
|
||||||
|
.retrieve()
|
||||||
|
.body(String.class);
|
||||||
|
|
||||||
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
|
JsonNode response = mapper.readTree(responseStr);
|
||||||
|
|
||||||
|
if (response != null && response.has("current_weather")) {
|
||||||
|
JsonNode current = response.get("current_weather");
|
||||||
|
double temp = current.path("temperature").asDouble();
|
||||||
|
double windSpeed = current.path("windspeed").asDouble();
|
||||||
|
String time = current.path("time").asText();
|
||||||
|
|
||||||
|
int weatherCode = current.path("weathercode").asInt();
|
||||||
|
String summary = parseWeatherCode(weatherCode);
|
||||||
|
|
||||||
|
String formattedTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"));
|
||||||
|
|
||||||
|
return new WeatherRes(city, temp, windSpeed, formattedTime, summary);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[WeatherTool] 날씨 API 연동 실패: {}", e.getMessage());
|
||||||
|
return new WeatherRes(city, 0.0, 0.0, "", "날씨 정보를 불러오는데 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WeatherRes(city, 0.0, 0.0, "", "알 수 없는 응답입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String parseWeatherCode(int code) {
|
||||||
|
if (code == 0) return "맑음 (Clear)";
|
||||||
|
if (code >= 1 && code <= 3) return "구름조금/흐림 (Cloudy)";
|
||||||
|
if (code >= 45 && code <= 48) return "안개 (Fog)";
|
||||||
|
if (code >= 51 && code <= 67) return "비/이슬비 (Rain)";
|
||||||
|
if (code >= 71 && code <= 77) return "눈 (Snow)";
|
||||||
|
if (code >= 95) return "뇌우/천둥번개 (Thunderstorm)";
|
||||||
|
return "알 수 없음 (Unknown)";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ subprojects {
|
|||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
maven { url 'https://repo.spring.io/milestone' }
|
||||||
|
maven { url 'https://repo.spring.io/snapshot' }
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencyManagement {
|
dependencyManagement {
|
||||||
|
|||||||
Reference in New Issue
Block a user