diff --git a/axhub-gateway/build.gradle b/axhub-gateway/build.gradle index c725784..5ec9863 100644 --- a/axhub-gateway/build.gradle +++ b/axhub-gateway/build.gradle @@ -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' diff --git a/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/presentation/ChatController.java b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/presentation/ChatController.java new file mode 100644 index 0000000..d937fff --- /dev/null +++ b/axhub-gateway/src/main/java/io/shinhanlife/axhub/biz/mcp/gateway/presentation/ChatController.java @@ -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 request) { + String message = request.getOrDefault("message", "").trim(); + log.info("[Real AI Chat] 사용자의 메시지 수신: {}", message); + + SseEmitter emitter = new SseEmitter(120000L); // 120 seconds timeout + + try { + List 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 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 payload = new HashMap<>(); + payload.put("jsonrpc", "2.0"); + payload.put("method", "tools/call"); + payload.put("id", UUID.randomUUID().toString()); + + Map params = new HashMap<>(); + params.put("name", metadata.getName()); + + if (toolInput != null && !toolInput.trim().isEmpty()) { + Map 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() + "\"}"; + } + } + } +} diff --git a/axhub-gateway/src/main/resources/application.properties b/axhub-gateway/src/main/resources/application.properties index d17f91f..3e95252 100644 --- a/axhub-gateway/src/main/resources/application.properties +++ b/axhub-gateway/src/main/resources/application.properties @@ -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 + diff --git a/axhub-gateway/src/main/resources/static/chat.html b/axhub-gateway/src/main/resources/static/chat.html new file mode 100644 index 0000000..3d4d15f --- /dev/null +++ b/axhub-gateway/src/main/resources/static/chat.html @@ -0,0 +1,232 @@ + + + + + + AX HUB - ChatClient + + + + + + + +
+ + +
+
+
+ +
+
+

AX HUB Assistant

+

Powered by Mock Engine & MCP Tools

+
+
+
+ + + + + Online +
+
+ + +
+ + +
+
+ +
+
+ Assistant +
+ 안녕하세요! 저는 AX HUB Assistant입니다.
+ 채팅을 통해 MCP(도구)들을 직접 호출해보세요.

+ 💡 예시: "오늘 서울 날씨 어때?" +
+
+
+ +
+ + +
+
+ + +
+
+ +
+ + + + + + + diff --git a/axhub-tool-other/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/WeatherReq.java b/axhub-tool-other/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/WeatherReq.java new file mode 100644 index 0000000..4576c21 --- /dev/null +++ b/axhub-tool-other/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/WeatherReq.java @@ -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 + *
+ * ---------- 개정이력 ----------
+ * 수정일      수정자    수정내용
+ * ---------- -------- ---------------------------
+ * 2026.07.14  김형식    최초생성
+ * 
+ * 
+ */ +public record WeatherReq( + @JsonProperty(required = true, value = "city") + @JsonPropertyDescription("날씨를 조회할 도시 이름 (예: 서울, 부산, 제주)") + String city +) { +} diff --git a/axhub-tool-other/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/WeatherRes.java b/axhub-tool-other/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/WeatherRes.java new file mode 100644 index 0000000..913c4d7 --- /dev/null +++ b/axhub-tool-other/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/dto/WeatherRes.java @@ -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 + *
+ * ---------- 개정이력 ----------
+ * 수정일      수정자    수정내용
+ * ---------- -------- ---------------------------
+ * 2026.07.14  김형식    최초생성
+ * 
+ * 
+ */ +public record WeatherRes( + String city, + double temperature, + double windSpeed, + String reportTime, + String summary +) { +} diff --git a/axhub-tool-other/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/WeatherToolService.java b/axhub-tool-other/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/WeatherToolService.java new file mode 100644 index 0000000..0b187f2 --- /dev/null +++ b/axhub-tool-other/src/main/java/io/shinhanlife/axhub/biz/mcp/tool/service/WeatherToolService.java @@ -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 + *
+ * ---------- 개정이력 ----------
+ * 수정일      수정자    수정내용
+ * ---------- -------- ---------------------------
+ * 2026.07.14  김형식    최초생성
+ * 
+ * 
+ */ +@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)"; + } +} diff --git a/build.gradle b/build.gradle index a5d5626..9e0a217 100644 --- a/build.gradle +++ b/build.gradle @@ -19,6 +19,8 @@ subprojects { repositories { mavenCentral() + maven { url 'https://repo.spring.io/milestone' } + maven { url 'https://repo.spring.io/snapshot' } } dependencyManagement {