feat: OCI 배포 듀얼 LLM 스위칭 및 신규 툴 DTO 의존성 주입 병합
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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, "'");
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user