feat: OCI 배포 듀얼 LLM 스위칭 및 신규 툴 DTO 의존성 주입 병합

This commit is contained in:
hjgram
2026-07-30 06:19:06 +09:00
parent 752aae4dde
commit 204dd04d5f
28 changed files with 1059 additions and 21 deletions

View File

@@ -11,6 +11,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'
implementation 'org.springframework.ai:spring-ai-openai:2.0.0'
// MyBatis & DB
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'

View File

@@ -26,6 +26,7 @@ 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.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import reactor.core.publisher.Flux;
@@ -60,21 +61,90 @@ public class ChatController {
Map<String, Object> resultMap = (Map<String, Object>) toolsResponse.getResult();
if (resultMap.containsKey("tools")) {
List<ToolMetadata> allTools = (List<ToolMetadata>) resultMap.get("tools");
Set<String> addedToolNames = new HashSet<>();
for (ToolMetadata meta : allTools) {
if (Boolean.TRUE.equals(meta.getVisible())) {
callbacks.add(new DynamicMcpToolCallback(meta, executeService, objectMapper, effectiveTenantId));
if (Boolean.TRUE.equals(meta.getVisible()) && meta.getName() != null) {
String cleanName = meta.getName().replaceAll("[^a-zA-Z0-9_-]", "_");
if (!addedToolNames.contains(cleanName)) {
callbacks.add(new DynamicMcpToolCallback(meta, executeService, objectMapper, effectiveTenantId));
addedToolNames.add(cleanName);
}
}
}
}
}
ChatClient chatClient = chatClientBuilder
String selectedModel = request.getOrDefault("model", "gemini-flash-latest").trim();
if (selectedModel.isEmpty()) {
selectedModel = "gemini-flash-latest";
}
// 1. Google Gemini 무료 티어 직접 API 연동 (사용량 및 컴파일 충돌 차단용 Short-circuit)
if (selectedModel.equals("gemini-flash-latest")) {
String geminiKey = "AQ.Ab8RN6KFZggsQf8iooY1v_3h3vp2TIjiYB54dV4Yay3vVKEMtg";
String geminiUrl = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions";
Map<String, Object> body = new HashMap<>();
body.put("model", "gemini-1.5-flash");
body.put("stream", true);
body.put("messages", List.of(Map.of("role", "user", "content", message)));
try {
java.net.http.HttpClient httpClient = java.net.http.HttpClient.newHttpClient();
java.net.http.HttpRequest httpRequest = java.net.http.HttpRequest.newBuilder()
.uri(java.net.URI.create(geminiUrl))
.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + geminiKey)
.POST(java.net.http.HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
httpClient.sendAsync(httpRequest, java.net.http.HttpResponse.BodyHandlers.ofLines())
.thenAccept(response -> {
response.body().forEach(line -> {
try {
if (line.startsWith("data: ")) {
String jsonStr = line.substring(6).trim();
if (!jsonStr.equals("[DONE]")) {
JsonNode node = objectMapper.readTree(jsonStr);
String text = node.path("choices").path(0).path("delta").path("content").asText("");
if (!text.isEmpty()) {
emitter.send(text);
}
}
}
} catch (Exception e) {
log.error("Gemini stream parse error: {}", e.getMessage());
}
});
emitter.complete();
})
.exceptionally(ex -> {
log.error("Gemini stream connection failed", ex);
try {
emitter.send("\n\n⚠ **Gemini API 호출에 실패했습니다.** (" + ex.getMessage() + ")");
} catch (Exception ignored) {}
emitter.completeWithError(ex);
return null;
});
log.info("[Real AI Chat] 구글 제미나이 다이렉트 API 스트리밍 개시 완료!");
return emitter;
} catch (Exception e) {
log.error("Gemini direct API setup error", e);
emitter.completeWithError(e);
return emitter;
}
}
// 2. OpenRouter 무료 모델 처리 (Spring AI 빌드된 ChatClient 및 MCP 툴 호출 완벽 지원!)
ChatClient activeChatClient = 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()
Flux<String> responseStream = activeChatClient.prompt()
.user(message)
.tools((Object[]) callbacks.toArray(new ToolCallback[0])) // Spring AI 2.0 uses tools()
.options(org.springframework.ai.openai.OpenAiChatOptions.builder()
.model(selectedModel))
.stream()
.content();

View File

@@ -10,11 +10,11 @@ spring:
timeout-per-shutdown-phase: 20s
ai:
openai:
api-key: AQ.Ab8RN6KFZggsQf8iooY1v_3h3vp2TIjiYB54dV4Yay3vVKEMtg
base-url: https://generativelanguage.googleapis.com/v1beta/openai/
api-key: ${OPENROUTER_API_KEY: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}
base-url: https://openrouter.ai/api/v1
chat:
options:
model: gemini-flash-latest
model: google/gemma-4-31b-it:free
temperature: 0.3
server:

View File

@@ -43,12 +43,23 @@
<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 class="flex items-center gap-4">
<!-- 모델 선택 Select Box 신설 -->
<select id="model-select" class="bg-[#1e2128] text-slate-300 text-xs px-3 py-1.5 rounded-lg border border-white/10 focus:outline-none focus:border-emerald-500/50 cursor-pointer">
<option value="gemini-flash-latest">Gemini 1.5 Flash (기본 - Google)</option>
<option value="google/gemma-4-31b-it:free">Gemma 4 31B (무료 - OpenRouter)</option>
<option value="inclusionai/ling-3.0-flash:free">Ling 3.0 Flash (무료 - OpenRouter)</option>
<option value="openai/gpt-oss-20b:free">GPT-OSS 20B (무료 - OpenRouter)</option>
<option value="cohere/north-mini-code:free">Cohere North Mini (무료 - OpenRouter)</option>
</select>
<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>
</div>
@@ -173,14 +184,12 @@
.replace(/'/g, "&#039;");
}
function removeMarkdownEmphasis(text) {
return text.replace(/\*\*/g, '');
}
async function sendMessage() {
const text = chatInput.value.trim();
if (!text || isLoading) return;
const selectedModel = document.getElementById('model-select').value;
// 1. 유저 메시지 추가
appendUserMessage(text);
chatInput.value = '';
@@ -197,7 +206,7 @@
'Content-Type': 'application/json',
'X-Agent-Id': 'TESTER-DEV' // 권한 통과를 위한 테스트 Agent ID
},
body: JSON.stringify({ message: text })
body: JSON.stringify({ message: text, model: selectedModel })
});
if (!response.ok) throw new Error('Network response was not ok');
@@ -209,7 +218,6 @@
// 5. 스트림 읽기
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let responseText = '';
while (true) {
const { done, value } = await reader.read();
@@ -220,8 +228,7 @@
for (const line of lines) {
if (line.startsWith('data:')) {
const data = line.substring(5);
responseText += data;
textContainer.textContent = removeMarkdownEmphasis(responseText);
textContainer.innerHTML += escapeHtml(data);
scrollToBottom();
}
}