fix: LLM 채팅 모델 선택 및 텍스트 도구 호출 파싱 수정

- Program.cs: vLLM 클라이언트 포트 8001 → 8000 (현재 구동 모델과 일치)
- llmchat.js:
  - paneInit에서 llm-type-select 초기값을 llmType(localStorage)으로 동기화 (HTML 기본값 ollama 보정)
  - llmLoadModels(): vLLM 타입 시 llm-model.json 모델을 항상 드롭다운에 추가·선택 (갱신 버튼 포함)
  - llmOnTypeChange(): async로 변경, await llmLoadModels() 후 llmLoadConfigToUI() 호출
- OllamaController.cs:
  - 텍스트 도구 호출 감지: "params" 키 추가 (기존 parameters/arguments 에 params/args 병용)
  - 배열 포맷 [{tool,params},{...}] 지원 — ExtractFirstJsonArray() 신규, 원소별 순차 실행 후 합산 결과 전달
  - ExtractBalanced() 공통 메서드로 Object/Array 추출 로직 통합

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-05-27 09:46:22 +09:00
parent 3926a33418
commit 0eb598d411
3 changed files with 110 additions and 35 deletions

View File

@@ -950,29 +950,56 @@ public class OllamaController : ControllerBase
if (choice.TryGetProperty("message", out var stopMsgEl) && stopMsgEl.TryGetProperty("content", out var stopCntEl)) if (choice.TryGetProperty("message", out var stopMsgEl) && stopMsgEl.TryGetProperty("content", out var stopCntEl))
stopContent = stopCntEl.GetString() ?? ""; stopContent = stopCntEl.GetString() ?? "";
// 모델 무관: content 안에서 첫 번째 완전한 JSON 객체를 추출 (앞에 thinking 토큰, 설명 등이 붙어도 동작) // 배열 포맷 우선 시도: [{tool, params}, ...] — 모델이 여러 도구를 배열로 출력할 때
var jsonCandidate = ExtractFirstJsonObject(stopContent); var jsonArrayCandidate = ExtractFirstJsonArray(stopContent);
var jsonObjectCandidates = new List<string>();
if (!string.IsNullOrEmpty(jsonCandidate)) if (!string.IsNullOrEmpty(jsonArrayCandidate))
{ {
bool toolExecuted = false;
try try
{ {
using var jsonDoc = JsonDocument.Parse(jsonCandidate); using var arrDoc = JsonDocument.Parse(jsonArrayCandidate);
if (jsonDoc.RootElement.ValueKind == JsonValueKind.Object) 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<string>();
var toolNames = new List<string>();
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(); var propNames = jsonDoc.RootElement.EnumerateObject().Select(p => p.Name).ToHashSet();
string? detectedTool = null; string? detectedTool = null;
var args = new Dictionary<string, object>(); var args = new Dictionary<string, object>();
// 포맷 1: {"tool"|"tool_name": "toolName", "parameters"|"arguments": {...}} // 포맷 1: {"tool"|"tool_name": "...", "parameters"|"arguments"|"params"|"args": {...}}
if ((propNames.Contains("tool") || propNames.Contains("tool_name")) && if (propNames.Contains("tool") || propNames.Contains("tool_name"))
(propNames.Contains("parameters") || propNames.Contains("arguments")))
{ {
var toolKey = propNames.Contains("tool") ? "tool" : "tool_name"; var toolKey = propNames.Contains("tool") ? "tool" : "tool_name";
detectedTool = jsonDoc.RootElement.GetProperty(toolKey).GetString(); detectedTool = jsonDoc.RootElement.GetProperty(toolKey).GetString();
var paramsKey = propNames.Contains("parameters") ? "parameters" : "arguments"; // params 키 우선순위: parameters > arguments > params > args
if (jsonDoc.RootElement.TryGetProperty(paramsKey, out var paramsEl) && paramsEl.ValueKind == JsonValueKind.Object) 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()) 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")) && else if ((propNames.Contains("function") || propNames.Contains("name")) &&
(propNames.Contains("args") || propNames.Contains("input"))) (propNames.Contains("args") || propNames.Contains("input")))
{ {
@@ -1031,10 +1058,9 @@ public class OllamaController : ControllerBase
detectedTool, args, HttpContext.RequestAborted, detectedTool, args, HttpContext.RequestAborted,
onTimeoutExtended: info => EmitToolExtending(pseudoId, info.ToolName, info.SoftTimeoutSec, info.ExtendedTimeoutSec)); onTimeoutExtended: info => EmitToolExtending(pseudoId, info.ToolName, info.SoftTimeoutSec, info.ExtendedTimeoutSec));
await EmitToolResult(pseudoId, detectedTool, ok: true, payload: toolResult); await EmitToolResult(pseudoId, detectedTool, ok: true, payload: toolResult);
messages.Add(new { role = "assistant", content = stopContent }); toolResults.Add($"[{detectedTool} 결과]\n{toolResult}");
messages.Add(new { role = "user", content = $"[{detectedTool} 실행 결과]\n{toolResult}\n\n위 결과를 바탕으로 사용자의 질문에 자연어로 답변해주세요." }); toolNames.Add(detectedTool);
toolExecuted = true; toolExecuted = true;
continue;
} }
catch (Exception ex) 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 호출 없이) // 첫 번째 비스트리밍 응답의 content를 직접 전달 (두 번째 LLM 호출 없이)
@@ -1147,6 +1183,18 @@ public class OllamaController : ControllerBase
{ {
var start = content.IndexOf('{'); var start = content.IndexOf('{');
if (start < 0) return ""; 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; var depth = 0;
bool inString = false; bool inString = false;
bool escaped = false; bool escaped = false;
@@ -1158,8 +1206,8 @@ public class OllamaController : ControllerBase
if (c == '"') { inString = !inString; continue; } if (c == '"') { inString = !inString; continue; }
if (!inString) if (!inString)
{ {
if (c == '{') depth++; if (c == open) depth++;
else if (c == '}') { depth--; if (depth == 0) return content.Substring(start, i - start + 1); } else if (c == close) { depth--; if (depth == 0) return content.Substring(start, i - start + 1); }
} }
} }
return ""; return "";

View File

@@ -156,7 +156,7 @@ builder.Services.AddHttpClient("Ollama", c =>
// ── vLLM HttpClient (OpenAI-compatible) ────────────────────────────────────── // ── vLLM HttpClient (OpenAI-compatible) ──────────────────────────────────────
builder.Services.AddHttpClient("Vllm", c => builder.Services.AddHttpClient("Vllm", c =>
{ {
c.BaseAddress = new Uri("http://localhost:8001"); c.BaseAddress = new Uri("http://localhost:8000");
c.Timeout = TimeSpan.FromSeconds(1800); c.Timeout = TimeSpan.FromSeconds(1800);
}).SetHandlerLifetime(Timeout.InfiniteTimeSpan); }).SetHandlerLifetime(Timeout.InfiniteTimeSpan);

View File

@@ -7,7 +7,11 @@ let llmSessions = JSON.parse(localStorage.getItem('llmSessions') || '[]');
let llmActiveSessionId = localStorage.getItem('llmActiveSessionId') || ''; let llmActiveSessionId = localStorage.getItem('llmActiveSessionId') || '';
let llmAbortController = null; let llmAbortController = null;
let llmIsStreaming = false; 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 llmUseTools = localStorage.getItem('llmUseTools') === 'true';
let llmAgentMode = localStorage.getItem('llmAgentMode') === 'true'; let llmAgentMode = localStorage.getItem('llmAgentMode') === 'true';
let llmMcpTools = []; 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(); llmRenderSessionList();
llmLoadActiveSession(); llmLoadActiveSession();
llmLoadModels(); await llmLoadModels(); // 모델 목록 먼저 채운 뒤
llmLoadConfigToUI(); llmLoadConfigToUI(); // llm-model.json 값 반영
llmLoadMcpTools(); llmLoadMcpTools();
}; };
@@ -494,8 +502,6 @@ async function llmLoadModels() {
const sel = document.getElementById('llm-model-select'); const sel = document.getElementById('llm-model-select');
if (!sel) return; if (!sel) return;
const currentVal = sel.value;
try { try {
const d = await api('GET', `${prefix}/models`); const d = await api('GET', `${prefix}/models`);
sel.innerHTML = '<option value="">-- 모델을 선택하세요 --</option>'; sel.innerHTML = '<option value="">-- 모델을 선택하세요 --</option>';
@@ -507,9 +513,22 @@ async function llmLoadModels() {
opt.textContent = m; opt.textContent = m;
sel.appendChild(opt); 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'); const dot = document.getElementById('llm-conn-status');
@@ -518,7 +537,6 @@ async function llmLoadModels() {
dot.title = d.success ? `${label} 연결됨` : `${label} 연결 실패`; dot.title = d.success ? `${label} 연결됨` : `${label} 연결 실패`;
} }
} catch (e) { } catch (e) {
if (currentVal) sel.value = currentVal;
const dot = document.getElementById('llm-conn-status'); const dot = document.getElementById('llm-conn-status');
if (dot) { if (dot) {
dot.className = 'llm-conn-dot error'; 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; llmType = document.getElementById('llm-type-select').value;
localStorage.setItem('llmType', llmType); localStorage.setItem('llmType', llmType);
llmLoadModels(); await llmLoadModels(); // 목록 채운 뒤
llmLoadConfigToUI(); // llm-model.json 값 반영 (vLLM 전환 시도 포함)
llmLoadMcpTools(); llmLoadMcpTools();
} }
@@ -967,7 +986,15 @@ function llmLoadConfigToUI() {
api('GET', '/api/llm/config').then(d => { api('GET', '/api/llm/config').then(d => {
if (d.success && d.vllmModel) { if (d.success && d.vllmModel) {
const sel = document.getElementById('llm-model-select'); 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(() => {}); }).catch(() => {});
} }