diff --git a/src/Web/Controllers/OllamaController.cs b/src/Web/Controllers/OllamaController.cs index 3a4c19b..a1b211e 100644 --- a/src/Web/Controllers/OllamaController.cs +++ b/src/Web/Controllers/OllamaController.cs @@ -950,29 +950,56 @@ public class OllamaController : ControllerBase if (choice.TryGetProperty("message", out var stopMsgEl) && stopMsgEl.TryGetProperty("content", out var stopCntEl)) stopContent = stopCntEl.GetString() ?? ""; - // 모델 무관: content 안에서 첫 번째 완전한 JSON 객체를 추출 (앞에 thinking 토큰, 설명 등이 붙어도 동작) - var jsonCandidate = ExtractFirstJsonObject(stopContent); - - if (!string.IsNullOrEmpty(jsonCandidate)) + // 배열 포맷 우선 시도: [{tool, params}, ...] — 모델이 여러 도구를 배열로 출력할 때 + var jsonArrayCandidate = ExtractFirstJsonArray(stopContent); + var jsonObjectCandidates = new List(); + if (!string.IsNullOrEmpty(jsonArrayCandidate)) { - bool toolExecuted = false; try { - using var jsonDoc = JsonDocument.Parse(jsonCandidate); - if (jsonDoc.RootElement.ValueKind == JsonValueKind.Object) + using var arrDoc = JsonDocument.Parse(jsonArrayCandidate); + if (arrDoc.RootElement.ValueKind == JsonValueKind.Array) + foreach (var elem in arrDoc.RootElement.EnumerateArray()) + if (elem.ValueKind == JsonValueKind.Object) + jsonObjectCandidates.Add(elem.GetRawText()); + } + catch { } + } + // 배열이 없거나 파싱 실패 → 단일 객체 추출 + if (jsonObjectCandidates.Count == 0) + { + var single = ExtractFirstJsonObject(stopContent); + if (!string.IsNullOrEmpty(single)) jsonObjectCandidates.Add(single); + } + + if (jsonObjectCandidates.Count > 0) + { + bool toolExecuted = false; + var toolResults = new List(); + var toolNames = new List(); + + foreach (var jsonCandidate in jsonObjectCandidates) + { + try { + using var jsonDoc = JsonDocument.Parse(jsonCandidate); + if (jsonDoc.RootElement.ValueKind != JsonValueKind.Object) continue; + var propNames = jsonDoc.RootElement.EnumerateObject().Select(p => p.Name).ToHashSet(); string? detectedTool = null; var args = new Dictionary(); - // 포맷 1: {"tool"|"tool_name": "toolName", "parameters"|"arguments": {...}} - if ((propNames.Contains("tool") || propNames.Contains("tool_name")) && - (propNames.Contains("parameters") || propNames.Contains("arguments"))) + // 포맷 1: {"tool"|"tool_name": "...", "parameters"|"arguments"|"params"|"args": {...}} + if (propNames.Contains("tool") || propNames.Contains("tool_name")) { var toolKey = propNames.Contains("tool") ? "tool" : "tool_name"; detectedTool = jsonDoc.RootElement.GetProperty(toolKey).GetString(); - var paramsKey = propNames.Contains("parameters") ? "parameters" : "arguments"; - if (jsonDoc.RootElement.TryGetProperty(paramsKey, out var paramsEl) && paramsEl.ValueKind == JsonValueKind.Object) + // params 키 우선순위: parameters > arguments > params > args + var paramsKey = propNames.Contains("parameters") ? "parameters" + : propNames.Contains("arguments") ? "arguments" + : propNames.Contains("params") ? "params" + : propNames.Contains("args") ? "args" : null; + if (paramsKey != null && jsonDoc.RootElement.TryGetProperty(paramsKey, out var paramsEl) && paramsEl.ValueKind == JsonValueKind.Object) { foreach (var prop in paramsEl.EnumerateObject()) { @@ -983,7 +1010,7 @@ public class OllamaController : ControllerBase } } } - // 포맷 2: {"function": "toolName", "args": {...}} 또는 {"name": "toolName", "input": {...}} + // 포맷 2: {"function": "...", "args"|"input": {...}} 또는 {"name": "...", "input"|"args": {...}} else if ((propNames.Contains("function") || propNames.Contains("name")) && (propNames.Contains("args") || propNames.Contains("input"))) { @@ -1031,10 +1058,9 @@ public class OllamaController : ControllerBase detectedTool, args, HttpContext.RequestAborted, onTimeoutExtended: info => EmitToolExtending(pseudoId, info.ToolName, info.SoftTimeoutSec, info.ExtendedTimeoutSec)); await EmitToolResult(pseudoId, detectedTool, ok: true, payload: toolResult); - messages.Add(new { role = "assistant", content = stopContent }); - messages.Add(new { role = "user", content = $"[{detectedTool} 실행 결과]\n{toolResult}\n\n위 결과를 바탕으로 사용자의 질문에 자연어로 답변해주세요." }); + toolResults.Add($"[{detectedTool} 결과]\n{toolResult}"); + toolNames.Add(detectedTool); toolExecuted = true; - continue; } catch (Exception ex) { @@ -1043,12 +1069,22 @@ public class OllamaController : ControllerBase } } } + catch (Exception ex) + { + _logger.LogWarning(ex, "[OllamaController] JSON 도구 감지 파싱 실패: {Candidate}", jsonCandidate); + } + + if (HttpContext.RequestAborted.IsCancellationRequested) return; } - catch (Exception ex) + + if (toolExecuted) { - _logger.LogWarning(ex, "[OllamaController] JSON 도구 감지 파싱 실패, Text로 fallback: {Candidate}", jsonCandidate); + // 모든 도구 결과를 합쳐서 한 번에 LLM에 전달 + var combinedResults = string.Join("\n\n", toolResults); + messages.Add(new { role = "assistant", content = stopContent }); + messages.Add(new { role = "user", content = $"{combinedResults}\n\n위 결과를 바탕으로 사용자의 질문에 자연어로 답변해주세요." }); + continue; } - if (toolExecuted) continue; } // 첫 번째 비스트리밍 응답의 content를 직접 전달 (두 번째 LLM 호출 없이) @@ -1147,6 +1183,18 @@ public class OllamaController : ControllerBase { var start = content.IndexOf('{'); if (start < 0) return ""; + return ExtractBalanced(content, start, '{', '}'); + } + + private static string ExtractFirstJsonArray(string content) + { + var start = content.IndexOf('['); + if (start < 0) return ""; + return ExtractBalanced(content, start, '[', ']'); + } + + private static string ExtractBalanced(string content, int start, char open, char close) + { var depth = 0; bool inString = false; bool escaped = false; @@ -1158,8 +1206,8 @@ public class OllamaController : ControllerBase if (c == '"') { inString = !inString; continue; } if (!inString) { - if (c == '{') depth++; - else if (c == '}') { depth--; if (depth == 0) return content.Substring(start, i - start + 1); } + if (c == open) depth++; + else if (c == close) { depth--; if (depth == 0) return content.Substring(start, i - start + 1); } } } return ""; diff --git a/src/Web/Program.cs b/src/Web/Program.cs index 92355ca..fad7ec4 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -156,7 +156,7 @@ builder.Services.AddHttpClient("Ollama", c => // ── vLLM HttpClient (OpenAI-compatible) ────────────────────────────────────── builder.Services.AddHttpClient("Vllm", c => { - c.BaseAddress = new Uri("http://localhost:8001"); + c.BaseAddress = new Uri("http://localhost:8000"); c.Timeout = TimeSpan.FromSeconds(1800); }).SetHandlerLifetime(Timeout.InfiniteTimeSpan); diff --git a/src/Web/wwwroot/js/llmchat.js b/src/Web/wwwroot/js/llmchat.js index 96f5bd3..e838a3a 100644 --- a/src/Web/wwwroot/js/llmchat.js +++ b/src/Web/wwwroot/js/llmchat.js @@ -7,7 +7,11 @@ let llmSessions = JSON.parse(localStorage.getItem('llmSessions') || '[]'); let llmActiveSessionId = localStorage.getItem('llmActiveSessionId') || ''; let llmAbortController = null; let llmIsStreaming = false; -let llmType = localStorage.getItem('llmType') || 'ollama'; +let llmType = localStorage.getItem('llmType') || 'vllm'; +if (llmType === 'ollama') { + llmType = 'vllm'; + localStorage.setItem('llmType', 'vllm'); +} let llmUseTools = localStorage.getItem('llmUseTools') === 'true'; let llmAgentMode = localStorage.getItem('llmAgentMode') === 'true'; let llmMcpTools = []; @@ -33,11 +37,15 @@ function llmUseChip(btn) { } // ── 초기화 (탭 진입 시) ────────────────────────────── -paneInit.llmchat = function() { +paneInit.llmchat = async function() { + // llm-type-select 를 JS 변수(llmType)와 동기화 (HTML 기본값 ollama 보정) + const typeSel = document.getElementById('llm-type-select'); + if (typeSel) typeSel.value = llmType; + llmRenderSessionList(); llmLoadActiveSession(); - llmLoadModels(); - llmLoadConfigToUI(); + await llmLoadModels(); // 모델 목록 먼저 채운 뒤 + llmLoadConfigToUI(); // llm-model.json 값 반영 llmLoadMcpTools(); }; @@ -494,8 +502,6 @@ async function llmLoadModels() { const sel = document.getElementById('llm-model-select'); if (!sel) return; - const currentVal = sel.value; - try { const d = await api('GET', `${prefix}/models`); sel.innerHTML = ''; @@ -507,9 +513,22 @@ async function llmLoadModels() { opt.textContent = m; sel.appendChild(opt); }); - if (currentVal && [...sel.options].some(o => o.value === currentVal)) { - sel.value = currentVal; - } + } + + // vLLM: llm-model.json 을 항상 단일 선택 기준으로 반영 + if (llmType === 'vllm') { + try { + const cfg = await api('GET', '/api/llm/config'); + if (cfg.success && cfg.vllmModel) { + if (![...sel.options].some(o => o.value === cfg.vllmModel)) { + const opt = document.createElement('option'); + opt.value = cfg.vllmModel; + opt.textContent = cfg.vllmModel; + sel.appendChild(opt); + } + sel.value = cfg.vllmModel; + } + } catch (_) {} } const dot = document.getElementById('llm-conn-status'); @@ -518,7 +537,6 @@ async function llmLoadModels() { dot.title = d.success ? `${label} 연결됨` : `${label} 연결 실패`; } } catch (e) { - if (currentVal) sel.value = currentVal; const dot = document.getElementById('llm-conn-status'); if (dot) { dot.className = 'llm-conn-dot error'; @@ -527,10 +545,11 @@ async function llmLoadModels() { } } -function llmOnTypeChange() { +async function llmOnTypeChange() { llmType = document.getElementById('llm-type-select').value; localStorage.setItem('llmType', llmType); - llmLoadModels(); + await llmLoadModels(); // 목록 채운 뒤 + llmLoadConfigToUI(); // llm-model.json 값 반영 (vLLM 전환 시도 포함) llmLoadMcpTools(); } @@ -967,7 +986,15 @@ function llmLoadConfigToUI() { api('GET', '/api/llm/config').then(d => { if (d.success && d.vllmModel) { const sel = document.getElementById('llm-model-select'); - if (sel && !sel.value) sel.value = d.vllmModel; + if (!sel) return; + // llm-model.json 값이 드롭다운 옵션에 없으면 직접 추가 + if (![...sel.options].some(o => o.value === d.vllmModel)) { + const opt = document.createElement('option'); + opt.value = d.vllmModel; + opt.textContent = d.vllmModel; + sel.appendChild(opt); + } + sel.value = d.vllmModel; } }).catch(() => {}); }