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:
@@ -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);
|
||||
// 배열 포맷 우선 시도: [{tool, params}, ...] — 모델이 여러 도구를 배열로 출력할 때
|
||||
var jsonArrayCandidate = ExtractFirstJsonArray(stopContent);
|
||||
var jsonObjectCandidates = new List<string>();
|
||||
if (!string.IsNullOrEmpty(jsonArrayCandidate))
|
||||
{
|
||||
try
|
||||
{
|
||||
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 (!string.IsNullOrEmpty(jsonCandidate))
|
||||
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)
|
||||
{
|
||||
if (jsonDoc.RootElement.ValueKind != JsonValueKind.Object) continue;
|
||||
|
||||
var propNames = jsonDoc.RootElement.EnumerateObject().Select(p => p.Name).ToHashSet();
|
||||
string? detectedTool = null;
|
||||
var args = new Dictionary<string, object>();
|
||||
|
||||
// 포맷 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 도구 감지 파싱 실패, Text로 fallback: {Candidate}", jsonCandidate);
|
||||
_logger.LogWarning(ex, "[OllamaController] JSON 도구 감지 파싱 실패: {Candidate}", jsonCandidate);
|
||||
}
|
||||
|
||||
if (HttpContext.RequestAborted.IsCancellationRequested) return;
|
||||
}
|
||||
|
||||
if (toolExecuted)
|
||||
{
|
||||
// 모든 도구 결과를 합쳐서 한 번에 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 "";
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 = '<option value="">-- 모델을 선택하세요 --</option>';
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user