AX Agent 진행 시간·글로우 경로 정리 및 최근 로컬 변경 일괄 반영

- AX Agent 스트리밍 경과 시간을 공용 helper로 통일해 비정상적인 수천만 시간 표시를 방지함

- 채팅 입력창 글로우를 런처와 같은 표시/숨김 중심의 얇은 외곽 글로우로 정리하고 런처 글로우 설정은 일반 설정에 유지함

- README와 DEVELOPMENT 문서를 2026-04-08 12:02 (KST) 기준으로 갱신하고 Release 빌드 경고 0 / 오류 0을 확인함
This commit is contained in:
2026-04-08 23:20:53 +09:00
parent 6e99837a4c
commit 1b4a2bfb1c
24 changed files with 1103 additions and 173 deletions

View File

@@ -79,6 +79,7 @@ public partial class App : System.Windows.Application
WorkflowLogService.IsEnabled = settings.Settings.Llm.EnableDetailedLog;
WorkflowLogService.RetentionDays = settings.Settings.Llm.DetailedLogRetentionDays > 0
? settings.Settings.Llm.DetailedLogRetentionDays : 3;
WorkflowLogService.IsRawLogEnabled = settings.Settings.Llm.EnableRawLlmLog;
// ─── 대화 보관/디스크 정리 (제품화 하드닝) ───────────────────────────
try

View File

@@ -1003,6 +1003,10 @@ public class LlmSettings
[JsonPropertyName("detailedLogRetentionDays")]
public int DetailedLogRetentionDays { get; set; } = 3;
/// <summary>LLM에 보낸 요청 JSON과 돌아온 응답 원문을 모두 기록합니다. 디버깅용이며 파일 크기가 클 수 있습니다.</summary>
[JsonPropertyName("enableRawLlmLog")]
public bool EnableRawLlmLog { get; set; } = false;
/// <summary>에이전트 메모리 (지속적 학습) 활성화. 기본 true.</summary>
[JsonPropertyName("enableAgentMemory")]
public bool EnableAgentMemory { get; set; } = true;

View File

@@ -539,14 +539,15 @@ public partial class AgentLoopService
sendMessages = [.. messages, new ChatMessage
{
Role = "user",
Content = "[TOOL_REQUIRED] 지금 즉시 도구를 1개 이상 호출하세요. 텍스트만 반환하면 거부됩니다. " +
"Call at least one tool RIGHT NOW. Text-only response is rejected."
Content = "[TOOL_REQUIRED] 지금 즉시 <tool_call> 형식으로 도구를 호출하세요. 텍스트만 반환하면 거부됩니다.\n" +
"Output format:\n<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>"
}];
}
// 워크플로우 상세 로그: LLM 요청
llmCallSw.Restart();
var (_, currentModel) = _llm.GetCurrentModelInfo();
WorkflowLogService.SetCallContext(_conversationId, _currentRunId, iteration);
WorkflowLogService.LogLlmRequest(_conversationId, _currentRunId, iteration,
currentModel, sendMessages.Count, activeTools.Count, forceFirst);
var streamedTextPreview = new StringBuilder();
@@ -807,17 +808,14 @@ public partial class AgentLoopService
"[System:ToolCallRequired] " +
"⚠ 경고: 이전 응답에서 도구를 호출하지 않았습니다. " +
"텍스트 설명만 반환하는 것은 허용되지 않습니다. " +
"지금 즉시 도구를 1개 이상 호출하세요. " +
"할 말이 있다면 도구 호출 이후에 하세요 — 도구 호출 전 설명 금지. " +
"한 응답에서 여러 도구를 동시에 호출할 수 있고, 그렇게 해야 합니다. " +
$"지금 사용 가능한 도구: {activeToolPreview}",
"지금 즉시 아래 형식으로 도구를 호출하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"사용 가능한 도구: {activeToolPreview}",
_ =>
"[System:ToolCallRequired] " +
"🚨 최종 경고: 도구를 계속 호출하지 않고 있습니다. 이것이 마지막 기회입니다. " +
"지금 응답은 반드시 도구 호출만 포함해야 합니다. 텍스트는 한 글자도 쓰지 마세요. " +
"작업을 완료하려면 도구를 호출하는 것 외에 다른 방법이 없습니다. " +
"도구 이름을 모른다면 아래 목록에서 골라 즉시 호출하세요. " +
"여러 도구를 한꺼번에 호출할 수 있습니다 — 지금 그렇게 하세요. " +
"텍스트는 한 글자도 쓰지 마세요. 반드시 아래 형식으로 도구를 호출하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"반드시 사용해야 할 도구 목록: {activeToolPreview}"
};
messages.Add(new ChatMessage { Role = "user", Content = recoveryContent });
@@ -850,15 +848,13 @@ public partial class AgentLoopService
{
1 =>
"[System:ToolCallRequired] 계획을 세웠지만 도구를 호출하지 않았습니다. " +
"계획은 이미 수립되었으므로 지금 당장 실행 단계로 넘어가세요. " +
"텍스트 설명 없이 계획의 첫 번째 단계를 도구(tool call)로 즉시 실행하세요. " +
"한 응답에서 여러 도구를 동시에 호출할 수 있습니다. " +
"지금 당장 실행하세요. 아래 형식으로 도구를 호출하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"사용 가능한 도구: {planToolList}",
_ =>
"[System:ToolCallRequired] 🚨 도구 호출 없이 계획만 반복하고 있습니다. " +
"이제 계획 설명은 완전히 금지됩니다. 오직 도구 호출만 하세요. " +
"지금 이 응답에 텍스트를 포함하지 마세요. 도구만 호출하세요. " +
"독립적인 작업은 한 번에 여러 도구를 병렬 호출하세요. " +
"텍스트를 한 글자도 쓰지 마세요. 오직 아래 형식의 도구 호출만 출력하세요:\n" +
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
$"사용 가능한 도구: {planToolList}"
};
messages.Add(new ChatMessage { Role = "user", Content = planRecoveryContent });
@@ -884,7 +880,8 @@ public partial class AgentLoopService
messages.Add(new ChatMessage { Role = "user",
Content = "html_create 도구를 호출하지 않았습니다. " +
"document_plan 결과의 body 골격을 바탕으로 각 섹션에 충분한 내용을 채워서 " +
"html_create 도구를 지금 즉시 호출하세요. 설명 없이 도구 호출하세요." });
"지금 즉시 아래 형식으로 호출하세요:\n" +
"<tool_call>\n{\"name\": \"html_create\", \"arguments\": {\"file_name\": \"...\", \"html_body\": \"...\"}}\n</tool_call>" });
EmitEvent(AgentEventType.Thinking, "", $"html_create 미호출 재시도 {postDocumentPlanRetry}/{documentPlanRetryMax}...");
continue; // 루프 재시작
}
@@ -1475,6 +1472,26 @@ public partial class AgentLoopService
{
failedToolHistogram.TryGetValue(effectiveCall.ToolName, out var failedCount);
failedToolHistogram[effectiveCall.ToolName] = failedCount + 1;
// 같은 도구가 5회 이상 실패하면 해당 도구를 포기하고 LLM에 알림
if (failedCount + 1 >= 5)
{
var abortMsg = $"도구 '{effectiveCall.ToolName}'이(가) {failedCount + 1}회 실패했습니다. 이 도구를 더 이상 호출하지 마세요. 다른 방법을 시도하거나 사용자에게 결과를 보고하세요.";
EmitEvent(AgentEventType.Error, effectiveCall.ToolName, abortMsg);
messages.Add(LlmService.CreateToolResultMessage(
effectiveCall.ToolId, effectiveCall.ToolName, abortMsg));
messages.Add(new ChatMessage { Role = "user", Content = abortMsg });
continue;
}
// 전체 실패 횟수가 총 도구 호출의 60% 이상이면 조기 중단
var totalFails = failedToolHistogram.Values.Sum();
if (totalToolCalls > 6 && totalFails > totalToolCalls * 0.6)
{
EmitEvent(AgentEventType.Error, "",
$"전체 도구 호출 중 실패율이 높아 작업을 중단합니다 (실패 {totalFails}/{totalToolCalls})");
return $"도구 실행 실패율이 높아 작업을 중단했습니다. {totalFails}개 실패 / {totalToolCalls}개 호출. 요청을 다시 시도하거나 작업 방식을 변경해 주세요.";
}
}
// UI 스레드가 이벤트를 렌더링할 시간 확보

View File

@@ -36,17 +36,16 @@ public class ClipboardTool : IAgentTool
Required = ["action"],
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var action = args.GetProperty("action").GetString() ?? "";
try
{
// 클립보드는 STA 스레드에서만 접근 가능
ToolResult? result = null;
Application.Current.Dispatcher.Invoke(() =>
// 클립보드는 STA 스레드에서만 접근 가능 — InvokeAsync로 UI 스레드 블로킹 방지
var result = await Application.Current.Dispatcher.InvokeAsync(() =>
{
result = action switch
return action switch
{
"read" => ReadClipboard(),
"write" => WriteClipboard(args),
@@ -55,11 +54,11 @@ public class ClipboardTool : IAgentTool
_ => ToolResult.Fail($"Unknown action: {action}"),
};
});
return Task.FromResult(result ?? ToolResult.Fail("클립보드 접근 실패"));
return result ?? ToolResult.Fail("클립보드 접근 실패");
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"클립보드 오류: {ex.Message}"));
return ToolResult.Fail($"클립보드 오류: {ex.Message}");
}
}

View File

@@ -43,7 +43,7 @@ public class NotifyTool : IAgentTool
Required = ["title", "message"],
};
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
{
var title = args.GetProperty("title").GetString() ?? "알림";
var message = args.GetProperty("message").GetString() ?? "";
@@ -51,15 +51,16 @@ public class NotifyTool : IAgentTool
try
{
Application.Current.Dispatcher.Invoke(() =>
// InvokeAsync로 변경 — Dispatcher.Invoke는 UI 스레드가 _convLock 대기 중일 때 데드락 발생
await Application.Current.Dispatcher.InvokeAsync(() =>
{
ShowToast(title, message, level);
});
return Task.FromResult(ToolResult.Ok($"✓ Notification sent: [{level}] {title}"));
return ToolResult.Ok($"✓ Notification sent: [{level}] {title}");
}
catch (Exception ex)
{
return Task.FromResult(ToolResult.Fail($"알림 전송 실패: {ex.Message}"));
return ToolResult.Fail($"알림 전송 실패: {ex.Message}");
}
}

View File

@@ -527,6 +527,9 @@ public partial class LlmService
url = endpoint.TrimEnd('/') + "/v1/chat/completions";
var json = JsonSerializer.Serialize(body);
// Raw 요청 로깅 (상세 로그 활성 시)
WorkflowLogService.LogLlmRawRequestFromContext(url, json);
using var req = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
@@ -546,6 +549,7 @@ public partial class LlmService
LogService.Warn("[ToolUse] IBM 배포형 경로에서 tool_choice가 거부되어 대체 강제 전략으로 재시도합니다.");
var fallbackBody = BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false);
var fallbackJson = JsonSerializer.Serialize(fallbackBody);
WorkflowLogService.LogLlmRawRequestFromContext(url, fallbackJson);
using var retryReq = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(fallbackJson, Encoding.UTF8, "application/json")
@@ -574,29 +578,35 @@ public partial class LlmService
/// 2. Qwen3 &lt;tool_call&gt;\n{"name":"...", "arguments":{...}}\n&lt;/tool_call&gt;
/// 3. 여러 건의 연속 tool_call 태그
/// </summary>
// ── 텍스트 폴백 파싱용 정규식 (static 캐싱 — 매 호출 재생성 방지) ──
private static readonly System.Text.RegularExpressions.Regex ToolCallTagRegex = new(
@"<\s*tool_call\s*>\s*([\s\S]*?)\s*<\s*/\s*tool_call\s*>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly System.Text.RegularExpressions.Regex ToolCallFunctionRegex = new(
@"✿FUNCTION✿\s*(\w+)\s*\n\s*(\{[\s\S]*?\})\s*(?:✿|$)",
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);
private static readonly System.Text.RegularExpressions.Regex ToolCallJsonRegex = new(
@"\{\s*""name""\s*:\s*""(\w+)""\s*,\s*""arguments""\s*:\s*(\{[\s\S]*?\})\s*\}",
System.Text.RegularExpressions.RegexOptions.Compiled);
private static List<ContentBlock> TryExtractToolCallsFromText(string text)
{
var results = new List<ContentBlock>();
if (string.IsNullOrWhiteSpace(text)) return results;
// 패턴 1: <tool_call>...</tool_call> 태그 (Qwen 계열 기본 출력)
var tagPattern = new System.Text.RegularExpressions.Regex(
@"<\s*tool_call\s*>\s*(\{[\s\S]*?\})\s*<\s*/\s*tool_call\s*>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match m in tagPattern.Matches(text))
foreach (System.Text.RegularExpressions.Match m in ToolCallTagRegex.Matches(text))
{
var block = TryParseToolCallJson(m.Groups[1].Value);
if (block != null) results.Add(block);
}
// 패턴 2: ✿FUNCTION✿ 또는 <|tool_call|> (일부 Qwen 변형)
// 패턴 2: ✿FUNCTION✿ (일부 Qwen 변형)
if (results.Count == 0)
{
var fnPattern = new System.Text.RegularExpressions.Regex(
@"✿FUNCTION✿\s*(\w+)\s*\n\s*(\{[\s\S]*?\})\s*(?:✿|$)",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match m in fnPattern.Matches(text))
foreach (System.Text.RegularExpressions.Match m in ToolCallFunctionRegex.Matches(text))
{
var block = TryParseToolCallJsonWithName(m.Groups[1].Value, m.Groups[2].Value);
if (block != null) results.Add(block);
@@ -606,9 +616,7 @@ public partial class LlmService
// 패턴 3: JSON 객체가 직접 출력된 경우 ({"name":"tool_name","arguments":{...}})
if (results.Count == 0)
{
var jsonPattern = new System.Text.RegularExpressions.Regex(
@"\{\s*""name""\s*:\s*""(\w+)""\s*,\s*""arguments""\s*:\s*(\{[\s\S]*?\})\s*\}");
foreach (System.Text.RegularExpressions.Match m in jsonPattern.Matches(text))
foreach (System.Text.RegularExpressions.Match m in ToolCallJsonRegex.Matches(text))
{
var block = TryParseToolCallJsonWithName(m.Groups[1].Value, m.Groups[2].Value);
if (block != null) results.Add(block);
@@ -623,6 +631,12 @@ public partial class LlmService
{
try
{
json = json.Trim();
// <tool_call> 태그 내용에서 JSON 객체 부분만 추출 (앞뒤 비-JSON 텍스트 제거)
var braceStart = json.IndexOf('{');
var braceEnd = json.LastIndexOf('}');
if (braceStart >= 0 && braceEnd > braceStart)
json = json[braceStart..(braceEnd + 1)];
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var name = root.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
@@ -642,7 +656,11 @@ public partial class LlmService
ToolInput = args,
};
}
catch { return null; }
catch (Exception ex)
{
Services.LogService.Debug($"[ToolUse] tool_call JSON 파싱 실패: {ex.Message} | 원본: {(json.Length > 200 ? json[..200] + "" : json)}");
return null;
}
}
/// <summary>이름과 arguments JSON이 별도로 주어진 경우.</summary>
@@ -660,7 +678,11 @@ public partial class LlmService
ToolInput = doc.RootElement.Clone(),
};
}
catch { return null; }
catch (Exception ex)
{
Services.LogService.Debug($"[ToolUse] tool_call JSON 파싱 실패 (name={name}): {ex.Message}");
return null;
}
}
private object BuildOpenAiToolBody(List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, bool forceToolCall = false)
@@ -818,10 +840,28 @@ public partial class LlmService
string.Equals(executionPolicy.Key, "tool_call_strict", StringComparison.OrdinalIgnoreCase);
var msgs = new List<object>();
// 시스템 프롬프트
// 시스템 프롬프트 + IBM/vLLM 도구 호출 가이드 주입
var systemPrompt = messages.FirstOrDefault(m => m.Role == "system")?.Content ?? _systemPrompt;
if (!string.IsNullOrWhiteSpace(systemPrompt))
msgs.Add(new { role = "system", content = systemPrompt });
// tools 이름 목록을 시스템 프롬프트에 직접 삽입 → Qwen이 도구 이름을 확실히 인식
var toolNameList = string.Join(", ", tools.Select(t => t.Name));
var toolCallGuidance =
"\n\n[Tool Calling Instructions]\n" +
"You have access to the following tools: " + toolNameList + "\n" +
"When the user's request requires action, you MUST call a tool. NEVER describe what you would do — call the tool directly.\n\n" +
"To call a tool, output EXACTLY this format (one per tool):\n" +
"<tool_call>\n" +
"{\"name\": \"TOOL_NAME\", \"arguments\": {\"param1\": \"value1\"}}\n" +
"</tool_call>\n\n" +
"Rules:\n" +
"- You MUST call at least one tool for every user request that requires action.\n" +
"- Do NOT explain what you plan to do. Do NOT say \"I will\" or \"Let me\". Just output <tool_call> immediately.\n" +
"- If multiple tools are needed, output multiple <tool_call> blocks.\n" +
"- After receiving tool results, use them to answer the user.\n";
var fullSystemPrompt = string.IsNullOrWhiteSpace(systemPrompt)
? toolCallGuidance.TrimStart()
: systemPrompt + toolCallGuidance;
msgs.Add(new { role = "system", content = fullSystemPrompt });
foreach (var m in messages)
{
@@ -893,7 +933,7 @@ public partial class LlmService
msgs.Add(new
{
role = "user",
content = "[TOOL_ONLY] 설명하지 말고 지금 즉시 tools 중 하나 이상을 호출하세요. 평문 응답 금지. 도구 호출만 하세요."
content = "[TOOL_ONLY] 설명하지 말고 지금 즉시 <tool_call> 형식으로 도구를 호출하세요. 평문 응답은 거부됩니다.\nExample: <tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}\n</tool_call>"
});
}
@@ -923,8 +963,9 @@ public partial class LlmService
}).ToArray();
// IBM watsonx: parameters 래퍼 사용, model 필드 없음
// tool_choice: "required" 지원 여부는 배포 버전마다 다를 수 있으므로
// forceToolCall=true일 때 추가하되, 오류 시 상위에서 ToolCallNotSupportedException으로 폴백됨
// tool_choice / tool_choice_option: IBM 버전에 따라 필드명이 다를 수 있으므로 양쪽 모두 전송
// 오류 시 상위에서 ToolCallNotSupportedException으로 폴백됨
// Qwen3.5 thinking 모드 비활성화: 활성화되면 content=null, reasoning_content에만 출력됨
if (forceToolCall && useToolChoice)
{
return new
@@ -932,11 +973,13 @@ public partial class LlmService
messages = msgs,
tools = toolDefs,
tool_choice = "required",
tool_choice_option = "required",
parameters = new
{
temperature = ResolveToolTemperature(),
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
}
},
chat_template_kwargs = new { enable_thinking = false },
};
}
@@ -948,7 +991,8 @@ public partial class LlmService
{
temperature = ResolveToolTemperature(),
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
}
},
chat_template_kwargs = new { enable_thinking = false },
};
}
@@ -969,19 +1013,28 @@ public partial class LlmService
{
var blocks = new List<ContentBlock>();
var textBuilder = new StringBuilder();
var rawSseBuilder = WorkflowLogService.IsRawLogEnabled ? new StringBuilder() : null;
var rawSw = System.Diagnostics.Stopwatch.StartNew();
await foreach (var evt in StreamOpenAiToolEventsAsync(resp, usesIbmDeploymentApi, prefetchToolCallAsync, ct).WithCancellation(ct))
{
if (evt.Kind == ToolStreamEventKind.TextDelta && !string.IsNullOrWhiteSpace(evt.Text))
{
textBuilder.Append(evt.Text);
rawSseBuilder?.Append("[text] ").AppendLine(evt.Text);
}
else if (evt.Kind == ToolStreamEventKind.ToolCallReady && evt.ToolCall != null)
{
blocks.Add(evt.ToolCall);
rawSseBuilder?.Append("[tool_call] ").Append(evt.ToolCall.ToolName)
.Append(' ').AppendLine(evt.ToolCall.ToolInput?.GetRawText() ?? "{}");
}
}
// Raw 응답 로깅
if (rawSseBuilder != null && rawSseBuilder.Length > 0)
WorkflowLogService.LogLlmRawResponseFromContext(rawSseBuilder.ToString(), rawSw.ElapsedMilliseconds);
var text = textBuilder.ToString().Trim();
var result = new List<ContentBlock>();
if (!string.IsNullOrWhiteSpace(text))
@@ -1037,6 +1090,7 @@ public partial class LlmService
else
url = endpoint.TrimEnd('/') + "/v1/chat/completions";
var json = JsonSerializer.Serialize(body);
WorkflowLogService.LogLlmRawRequestFromContext(url, json);
using var req = new HttpRequestMessage(HttpMethod.Post, url)
{
@@ -1052,6 +1106,7 @@ public partial class LlmService
{
var fallbackBody = BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false);
var fallbackJson = JsonSerializer.Serialize(fallbackBody);
WorkflowLogService.LogLlmRawRequestFromContext(url, fallbackJson);
using var retryReq = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(fallbackJson, Encoding.UTF8, "application/json")
@@ -1082,6 +1137,35 @@ public partial class LlmService
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync,
[EnumeratorCancellation] CancellationToken ct)
{
// Ollama stream:false 등 비-SSE 응답 감지: Content-Type에 text/event-stream이 없으면 전체 JSON으로 처리
var contentType = resp.Content.Headers.ContentType?.MediaType ?? "";
if (!contentType.Contains("event-stream", StringComparison.OrdinalIgnoreCase)
&& !contentType.Contains("octet-stream", StringComparison.OrdinalIgnoreCase))
{
// 비-SSE 전체 JSON 응답 (Ollama stream:false 등)
var rawJson = await resp.Content.ReadAsStringAsync(ct);
var respJson = ExtractJsonFromSseIfNeeded(rawJson);
var trimmed = respJson.TrimStart();
if (trimmed.StartsWith('{') || trimmed.StartsWith('['))
{
using var doc = JsonDocument.Parse(respJson);
TryParseOpenAiUsage(doc.RootElement);
if (TryExtractMessageToolBlocks(doc.RootElement, out var msgText, out var directBlocks))
{
if (!string.IsNullOrWhiteSpace(msgText))
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, msgText);
foreach (var block in directBlocks)
{
if (prefetchToolCallAsync != null)
block.PrefetchedExecutionTask = prefetchToolCallAsync(block);
yield return new ToolStreamEvent(ToolStreamEventKind.ToolCallReady, ToolCall: block);
}
}
yield return new ToolStreamEvent(ToolStreamEventKind.Completed);
yield break;
}
}
using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
@@ -1174,12 +1258,26 @@ public partial class LlmService
var firstChoice = choicesEl[0];
if (firstChoice.TryGetProperty("delta", out var deltaEl))
{
var emittedContent = false;
if (deltaEl.TryGetProperty("content", out var contentEl) &&
contentEl.ValueKind == JsonValueKind.String)
{
var chunk = contentEl.GetString();
if (!string.IsNullOrEmpty(chunk))
{
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, chunk);
emittedContent = true;
}
}
// Qwen3.5 thinking 모드 폴백: content가 비어있거나 없으면 reasoning_content 사용
// else if가 아닌 독립 if — content 키가 존재하되 빈 문자열("")인 경우도 커버
if (!emittedContent &&
deltaEl.TryGetProperty("reasoning_content", out var reasoningEl) &&
reasoningEl.ValueKind == JsonValueKind.String)
{
var reasoningChunk = reasoningEl.GetString();
if (!string.IsNullOrEmpty(reasoningChunk))
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, reasoningChunk);
}
if (deltaEl.TryGetProperty("tool_calls", out var toolCallsEl) &&
@@ -1274,6 +1372,18 @@ public partial class LlmService
consumed = true;
}
}
// Qwen3.5 thinking 모드 폴백: content가 비어있으면 reasoning_content 사용
if (!consumed &&
message.TryGetProperty("reasoning_content", out var reasoningContentEl) &&
reasoningContentEl.ValueKind == JsonValueKind.String)
{
var reasoningText = reasoningContentEl.GetString();
if (!string.IsNullOrWhiteSpace(reasoningText))
{
text = reasoningText;
consumed = true;
}
}
if (message.TryGetProperty("tool_calls", out var toolCallsEl) &&
toolCallsEl.ValueKind == JsonValueKind.Array)
@@ -1325,6 +1435,22 @@ public partial class LlmService
if (!(json.StartsWith('{') || json.StartsWith('[')))
return false;
// 빠른 사전 검사: 중괄호/대괄호 균형이 맞지 않으면 파싱 시도 불필요
int depth = 0;
bool inString = false;
bool escape = false;
for (int i = 0; i < json.Length; i++)
{
var ch = json[i];
if (escape) { escape = false; continue; }
if (ch == '\\' && inString) { escape = true; continue; }
if (ch == '"') { inString = !inString; continue; }
if (inString) continue;
if (ch is '{' or '[') depth++;
else if (ch is '}' or ']') depth--;
}
if (depth != 0) return false;
try
{
using var _ = JsonDocument.Parse(json);
@@ -1362,6 +1488,12 @@ public partial class LlmService
return null;
}
}
else if (!forceEmit)
{
// 스트리밍 중 이름만 도착하고 arguments가 아직 비어 있는 경우
// → 후속 청크에서 arguments가 올 수 있으므로 조기 방출하지 않음
return null;
}
var block = new ContentBlock
{

View File

@@ -453,7 +453,9 @@ public partial class LlmService : IDisposable
{
temperature = ResolveTemperature(),
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
}
},
// Qwen3.5 thinking 모드 비활성화: 활성화되면 content=null, reasoning_content에만 출력됨
chat_template_kwargs = new { enable_thinking = false },
};
}
@@ -462,9 +464,22 @@ public partial class LlmService : IDisposable
if (root.TryGetProperty("choices", out var choices) && choices.ValueKind == JsonValueKind.Array && choices.GetArrayLength() > 0)
{
var message = choices[0].TryGetProperty("message", out var choiceMessage) ? choiceMessage : default;
if (message.ValueKind == JsonValueKind.Object &&
message.TryGetProperty("content", out var content))
return content.GetString() ?? "";
if (message.ValueKind == JsonValueKind.Object)
{
if (message.TryGetProperty("content", out var content))
{
var text = content.GetString();
if (!string.IsNullOrEmpty(text))
return text;
}
// Qwen3.5 thinking 모드 폴백
if (message.TryGetProperty("reasoning_content", out var reasoning))
{
var text = reasoning.GetString();
if (!string.IsNullOrEmpty(text))
return text;
}
}
}
if (root.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array && results.GetArrayLength() > 0)
@@ -855,10 +870,14 @@ public partial class LlmService : IDisposable
if (doc.RootElement.TryGetProperty("choices", out var ch) && ch.GetArrayLength() > 0)
{
var first = ch[0];
if (first.TryGetProperty("delta", out var delta)
&& delta.TryGetProperty("content", out var cnt))
if (first.TryGetProperty("delta", out var delta))
{
var txt = cnt.GetString();
string? txt = null;
if (delta.TryGetProperty("content", out var cnt))
txt = cnt.GetString();
// Qwen3.5 thinking 모드 폴백: content가 비어있으면 reasoning_content 사용
if (string.IsNullOrEmpty(txt) && delta.TryGetProperty("reasoning_content", out var rc))
txt = rc.GetString();
if (!string.IsNullOrEmpty(txt)) { sb.Append(txt); collectingChunks = true; }
}
else if (first.TryGetProperty("message", out _))
@@ -965,6 +984,8 @@ public partial class LlmService : IDisposable
var delta = ibmChoices[0].GetProperty("delta");
if (delta.TryGetProperty("content", out var c))
text = c.GetString();
if (string.IsNullOrEmpty(text) && delta.TryGetProperty("reasoning_content", out var rc))
text = rc.GetString();
}
}
else
@@ -975,6 +996,8 @@ public partial class LlmService : IDisposable
var delta = choices[0].GetProperty("delta");
if (delta.TryGetProperty("content", out var c))
text = c.GetString();
if (string.IsNullOrEmpty(text) && delta.TryGetProperty("reasoning_content", out var rc2))
text = rc2.GetString();
}
}
}

View File

@@ -53,7 +53,7 @@ internal sealed class PerformanceMonitorService
if (_timer != null)
return;
_timer = new System.Threading.Timer(_ => Sample(), null, 0, 2000);
_timer = new System.Threading.Timer(_ => Sample(), null, 0, 5000);
}
public void StopPolling()

View File

@@ -33,7 +33,7 @@ internal sealed class ServerStatusService
if (_timer != null)
return;
_timer = new System.Threading.Timer(async _ => await CheckAllAsync(), null, 0, 15000);
_timer = new System.Threading.Timer(async _ => await CheckAllAsync(), null, 0, 60000);
}
public void Stop()

View File

@@ -162,13 +162,44 @@ public class SettingsService
return string.Compare(current, target, StringComparison.Ordinal) < 0;
}
private static readonly object _saveLock = new();
public void Save()
{
EnsureDirectories();
NormalizeRuntimeSettings();
var json = JsonSerializer.Serialize(_settings, JsonOptions);
var encrypted = CryptoService.PortableEncrypt(json);
File.WriteAllText(SettingsPath, encrypted);
// 임시 파일에 쓰고 교체 (atomic write) — 동시 읽기 충돌 방지
lock (_saveLock)
{
var tmpPath = SettingsPath + ".tmp";
for (int attempt = 0; attempt < 3; attempt++)
{
try
{
File.WriteAllText(tmpPath, encrypted);
// File.Move with overwrite (atomic on NTFS)
File.Move(tmpPath, SettingsPath, overwrite: true);
break;
}
catch (IOException) when (attempt < 2)
{
Thread.Sleep(50 * (attempt + 1));
}
catch (Exception ex) when (attempt < 2)
{
LogService.Warn($"settings.dat 저장 재시도 {attempt + 1}/3: {ex.Message}");
Thread.Sleep(50 * (attempt + 1));
}
finally
{
// 임시 파일 잔여 방지
try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch { }
}
}
}
SettingsChanged?.Invoke(this, EventArgs.Empty);
}

View File

@@ -39,10 +39,42 @@ public static class WorkflowLogService
/// <summary>보관 기간 (일). 이 기간이 지난 로그는 자동 삭제됩니다.</summary>
public static int RetentionDays { get; set; } = 3;
/// <summary>상세 워크플로우 이벤트를 기록합니다.</summary>
public static void Log(WorkflowLogEntry entry)
// ─── LlmService 등 하위 계층에서 사용할 현재 컨텍스트 ───
// AgentLoopService가 LLM 호출 직전에 설정하고, 완료 후 리셋합니다.
// AsyncLocal: async/await 전후로 올바르게 전파됨 (ThreadStatic은 continuation 스레드에서 유실)
private static readonly AsyncLocal<string?> _ctxConversationId = new();
private static readonly AsyncLocal<string?> _ctxRunId = new();
private static readonly AsyncLocal<int> _ctxIteration = new();
/// <summary>현재 LLM 호출 컨텍스트를 설정합니다 (AgentLoopService에서 호출).</summary>
public static void SetCallContext(string conversationId, string runId, int iteration)
{
if (!IsEnabled) return;
_ctxConversationId.Value = conversationId;
_ctxRunId.Value = runId;
_ctxIteration.Value = iteration;
}
/// <summary>Raw LLM 통신 로깅 활성화 여부 (요청 JSON + 응답 원문).</summary>
public static bool IsRawLogEnabled { get; set; }
/// <summary>현재 컨텍스트를 사용하여 raw 요청을 기록합니다 (LlmService에서 호출).</summary>
public static void LogLlmRawRequestFromContext(string url, string requestBody)
{
if (!IsRawLogEnabled || string.IsNullOrEmpty(_ctxConversationId.Value)) return;
LogLlmRawRequest(_ctxConversationId.Value!, _ctxRunId.Value ?? "", _ctxIteration.Value, url, requestBody);
}
/// <summary>현재 컨텍스트를 사용하여 raw 응답을 기록합니다 (LlmService에서 호출).</summary>
public static void LogLlmRawResponseFromContext(string rawResponse, long elapsedMs)
{
if (!IsRawLogEnabled || string.IsNullOrEmpty(_ctxConversationId.Value)) return;
LogLlmRawResponse(_ctxConversationId.Value!, _ctxRunId.Value ?? "", _ctxIteration.Value, rawResponse, elapsedMs);
}
/// <summary>상세 워크플로우 이벤트를 기록합니다.</summary>
public static void Log(WorkflowLogEntry entry, bool bypassEnabledCheck = false)
{
if (!bypassEnabledCheck && !IsEnabled) return;
try
{
var dayDir = Path.Combine(WorkflowDir, DateTime.Now.ToString("yyyy-MM-dd"));
@@ -82,6 +114,42 @@ public static class WorkflowLogService
});
}
/// <summary>LLM에 보낸 실제 HTTP 요청 body (raw JSON)를 기록합니다.</summary>
public static void LogLlmRawRequest(string conversationId, string runId, int iteration,
string url, string requestBody)
{
Log(new WorkflowLogEntry
{
ConversationId = conversationId,
RunId = runId,
EventType = "llm_raw_request",
Iteration = iteration,
Details = new Dictionary<string, object?>
{
["url"] = url,
["body"] = requestBody,
}
}, bypassEnabledCheck: true);
}
/// <summary>LLM이 돌려준 raw 응답 텍스트를 기록합니다 (SSE 전체 or 단일 JSON).</summary>
public static void LogLlmRawResponse(string conversationId, string runId, int iteration,
string rawResponse, long elapsedMs)
{
Log(new WorkflowLogEntry
{
ConversationId = conversationId,
RunId = runId,
EventType = "llm_raw_response",
Iteration = iteration,
ElapsedMs = elapsedMs,
Details = new Dictionary<string, object?>
{
["raw"] = Truncate(rawResponse, 20000),
}
}, bypassEnabledCheck: true);
}
/// <summary>LLM 응답을 기록합니다.</summary>
public static void LogLlmResponse(string conversationId, string runId, int iteration,
string? textResponse, int toolCallCount, int inputTokens, int outputTokens, long elapsedMs)

View File

@@ -421,6 +421,13 @@ public class SettingsViewModel : INotifyPropertyChanged
set { _detailedLogRetentionDays = Math.Clamp(value, 1, 30); OnPropertyChanged(); }
}
private bool _enableRawLlmLog;
public bool EnableRawLlmLog
{
get => _enableRawLlmLog;
set { _enableRawLlmLog = value; OnPropertyChanged(); }
}
private bool _enableAgentMemory;
public bool EnableAgentMemory
{
@@ -1172,6 +1179,7 @@ public class SettingsViewModel : INotifyPropertyChanged
_enableAuditLog = llm.EnableAuditLog;
_enableDetailedLog = llm.EnableDetailedLog;
_detailedLogRetentionDays = llm.DetailedLogRetentionDays > 0 ? llm.DetailedLogRetentionDays : 3;
_enableRawLlmLog = llm.EnableRawLlmLog;
_enableAgentMemory = llm.EnableAgentMemory;
_enableProjectRules = llm.EnableProjectRules;
_maxMemoryEntries = llm.MaxMemoryEntries;
@@ -1618,6 +1626,7 @@ public class SettingsViewModel : INotifyPropertyChanged
s.Llm.EnableAuditLog = _enableAuditLog;
s.Llm.EnableDetailedLog = _enableDetailedLog;
s.Llm.DetailedLogRetentionDays = _detailedLogRetentionDays;
s.Llm.EnableRawLlmLog = _enableRawLlmLog;
s.Llm.EnableAgentMemory = _enableAgentMemory;
s.Llm.EnableProjectRules = _enableProjectRules;
s.Llm.MaxMemoryEntries = _maxMemoryEntries;
@@ -1808,6 +1817,7 @@ public class SettingsViewModel : INotifyPropertyChanged
// 워크플로우 상세 로그 설정 즉시 반영
WorkflowLogService.IsEnabled = _enableDetailedLog;
WorkflowLogService.RetentionDays = _detailedLogRetentionDays > 0 ? _detailedLogRetentionDays : 3;
WorkflowLogService.IsRawLogEnabled = _enableRawLlmLog;
SaveCompleted?.Invoke(this, EventArgs.Empty);
}

View File

@@ -0,0 +1,195 @@
using System.Threading.Channels;
using System.Windows;
using System.Windows.Threading;
using AxCopilot.Models;
using AxCopilot.Services;
using AxCopilot.Services.Agent;
namespace AxCopilot.Views;
/// <summary>
/// 에이전트 이벤트의 무거운 작업(대화 변이·저장)을 백그라운드 스레드에서 처리합니다.
/// UI 스레드는 시각적 피드백만 담당하여 채팅창 멈춤을 방지합니다.
/// </summary>
public partial class ChatWindow
{
/// <summary>백그라운드 처리 대상 이벤트 큐 아이템.</summary>
private readonly record struct AgentEventWorkItem(
AgentEvent Event,
string EventTab,
string ActiveTab,
bool ShouldRender);
private readonly Channel<AgentEventWorkItem> _agentEventChannel =
Channel.CreateUnbounded<AgentEventWorkItem>(
new UnboundedChannelOptions { SingleReader = true, AllowSynchronousContinuations = false });
private Task? _agentEventProcessorTask;
/// <summary>백그라운드 이벤트 프로세서를 시작합니다. 창 초기화 시 한 번 호출됩니다.</summary>
private void StartAgentEventProcessor()
{
_agentEventProcessorTask = Task.Run(ProcessAgentEventsAsync);
}
/// <summary>백그라운드 이벤트 프로세서를 종료합니다. 창 닫기 시 호출됩니다.</summary>
private void StopAgentEventProcessor()
{
_agentEventChannel.Writer.TryComplete();
// 프로세서 완료를 동기 대기하지 않음 — 데드락 방지
// GC가 나머지를 정리합니다.
}
/// <summary>
/// 에이전트 이벤트를 백그라운드 큐에 추가합니다.
/// 대화 변이(AppendExecutionEvent, AppendAgentRun)와 디스크 저장을 백그라운드에서 처리합니다.
/// </summary>
private void EnqueueAgentEventWork(AgentEvent evt, string eventTab, bool shouldRender)
{
_agentEventChannel.Writer.TryWrite(new AgentEventWorkItem(evt, eventTab, _activeTab, shouldRender));
}
/// <summary>백그라운드 전용: 채널에서 이벤트를 배치로 읽어 대화 변이 + 저장을 수행합니다.</summary>
private async Task ProcessAgentEventsAsync()
{
var reader = _agentEventChannel.Reader;
var persistStopwatch = System.Diagnostics.Stopwatch.StartNew();
ChatConversation? pendingPersist = null;
var batch = new List<AgentEventWorkItem>(16);
try
{
while (await reader.WaitToReadAsync().ConfigureAwait(false))
{
batch.Clear();
while (reader.TryRead(out var item))
batch.Add(item);
if (batch.Count == 0)
continue;
bool anyNeedsRender = false;
bool hasTerminalEvent = false;
foreach (var work in batch)
{
var evt = work.Event;
var eventTab = work.EventTab;
var activeTab = work.ActiveTab;
// ── 대화 변이: execution event 추가 ──
try
{
lock (_convLock)
{
var session = _appState.ChatSession;
if (session != null)
{
var result = _chatEngine.AppendExecutionEvent(
session, _storage, _currentConversation, activeTab, eventTab, evt);
_currentConversation = result.CurrentConversation;
pendingPersist = result.UpdatedConversation;
}
}
}
catch (Exception ex)
{
LogService.Debug($"백그라운드 이벤트 처리 오류 (execution): {ex.Message}");
}
// ── 대화 변이: agent run 추가 (Complete/Error) ──
if (evt.Type == AgentEventType.Complete)
{
try
{
lock (_convLock)
{
var session = _appState.ChatSession;
if (session != null)
{
var summary = string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary;
var result = _chatEngine.AppendAgentRun(
session, _storage, _currentConversation, activeTab, eventTab, evt, "completed", summary);
_currentConversation = result.CurrentConversation;
pendingPersist = result.UpdatedConversation;
}
}
}
catch (Exception ex)
{
LogService.Debug($"백그라운드 이벤트 처리 오류 (agent run complete): {ex.Message}");
}
hasTerminalEvent = true;
}
else if (evt.Type == AgentEventType.Error && string.IsNullOrWhiteSpace(evt.ToolName))
{
try
{
lock (_convLock)
{
var session = _appState.ChatSession;
if (session != null)
{
var summary = string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 실행 실패" : evt.Summary;
var result = _chatEngine.AppendAgentRun(
session, _storage, _currentConversation, activeTab, eventTab, evt, "failed", summary);
_currentConversation = result.CurrentConversation;
pendingPersist = result.UpdatedConversation;
}
}
}
catch (Exception ex)
{
LogService.Debug($"백그라운드 이벤트 처리 오류 (agent run error): {ex.Message}");
}
hasTerminalEvent = true;
}
if (work.ShouldRender)
anyNeedsRender = true;
}
// ── 디바운스 저장: 2초마다 또는 종료 이벤트 시 즉시 ──
if (pendingPersist != null && (hasTerminalEvent || persistStopwatch.ElapsedMilliseconds > 2000))
{
try
{
_storage.Save(pendingPersist);
var rememberTab = pendingPersist.Tab ?? "Cowork";
_appState.ChatSession?.RememberConversation(rememberTab, pendingPersist.Id);
}
catch (Exception ex)
{
LogService.Debug($"백그라운드 대화 저장 실패: {ex.Message}");
}
pendingPersist = null;
persistStopwatch.Restart();
}
// ── UI 새로고침 신호: 배치 전체에 대해 단 1회 ──
if (anyNeedsRender)
{
try
{
Application.Current?.Dispatcher?.BeginInvoke(
() => ScheduleExecutionHistoryRender(autoScroll: true),
DispatcherPriority.Background);
}
catch { /* 앱 종료 중 무시 */ }
}
}
}
catch (OperationCanceledException) { }
catch (ChannelClosedException) { }
catch (Exception ex)
{
LogService.Debug($"에이전트 이벤트 프로세서 종료: {ex.Message}");
}
// ── 종료 시 미저장 대화 플러시 ──
if (pendingPersist != null)
{
try { _storage.Save(pendingPersist); } catch { }
}
}
}

View File

@@ -38,6 +38,7 @@ public partial class ChatWindow
bool liveWaitingStyle = false)
{
var liveAccentColor = ResolveLiveProgressAccentColor(accentBrush);
var pillMaxWidth = GetMessageMaxWidth();
return new Border
{
Background = liveWaitingStyle
@@ -50,7 +51,8 @@ public partial class ChatWindow
CornerRadius = new CornerRadius(10),
Padding = new Thickness(10, 8, 10, 8),
Margin = new Thickness(12, 6, 12, 2),
HorizontalAlignment = HorizontalAlignment.Stretch,
HorizontalAlignment = HorizontalAlignment.Center,
MaxWidth = pillMaxWidth,
Child = new Grid
{
ColumnDefinitions =
@@ -176,8 +178,11 @@ public partial class ChatWindow
if (string.IsNullOrWhiteSpace(summary))
summary = transcriptBadgeLabel;
var msgMaxWidth = GetMessageMaxWidth();
var stack = new StackPanel
{
HorizontalAlignment = HorizontalAlignment.Center,
MaxWidth = msgMaxWidth,
Margin = new Thickness(0),
};
@@ -206,14 +211,7 @@ public partial class ChatWindow
stack.Children.Add(bodyBlock);
}
var memoryEvidence = BuildMemoryContextEvidenceText();
if (!string.IsNullOrWhiteSpace(memoryEvidence))
{
var memoryBlock = CreateProcessFeedBody(memoryEvidence, secondaryText);
memoryBlock.Margin = new Thickness(28, 2, 12, 8);
memoryBlock.Opacity = 0.92;
stack.Children.Add(memoryBlock);
}
// 메모리 증거 텍스트 — 프로세스 피드에서 표시하지 않음 (불필요한 중복 정보)
if (!string.IsNullOrWhiteSpace(evt.FilePath))
{
@@ -1196,6 +1194,7 @@ public partial class ChatWindow
var borderColor = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0");
var accentBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(fgHex));
var bannerMaxWidth = GetMessageMaxWidth();
var banner = new Border
{
Background = hintBg,
@@ -1204,7 +1203,8 @@ public partial class ChatWindow
CornerRadius = new CornerRadius(10),
Padding = new Thickness(9, 7, 9, 7),
Margin = new Thickness(12, 3, 12, 3),
HorizontalAlignment = HorizontalAlignment.Stretch,
HorizontalAlignment = HorizontalAlignment.Center,
MaxWidth = bannerMaxWidth,
};
if (!string.IsNullOrWhiteSpace(evt.RunId))
_runBannerAnchors[evt.RunId] = banner;

View File

@@ -23,10 +23,27 @@ public partial class ChatWindow
_sortConversationsByRecent);
}
private string? _lastTaskSummaryRuntimeLabel;
private string? _lastTaskSummaryStripText;
private bool _lastTaskSummaryShowBadge;
private bool _lastTaskSummaryShowStrip;
private void UpdateTaskSummaryIndicators()
{
var status = BuildOperationalStatusPresentation();
// 값이 변경되지 않았으면 UI property setter 호출 스킵 (measure/arrange 방지)
if (status.RuntimeLabel == _lastTaskSummaryRuntimeLabel
&& status.StripText == _lastTaskSummaryStripText
&& status.ShowRuntimeBadge == _lastTaskSummaryShowBadge
&& status.ShowCompactStrip == _lastTaskSummaryShowStrip)
return;
_lastTaskSummaryRuntimeLabel = status.RuntimeLabel;
_lastTaskSummaryStripText = status.StripText;
_lastTaskSummaryShowBadge = status.ShowRuntimeBadge;
_lastTaskSummaryShowStrip = status.ShowCompactStrip;
if (RuntimeActivityBadge != null)
RuntimeActivityBadge.Visibility = status.ShowRuntimeBadge
? Visibility.Visible

View File

@@ -11,9 +11,28 @@ namespace AxCopilot.Views;
public partial class ChatWindow
{
// 스트리밍 중 LINQ 재실행 방지용 캐시
private List<ChatMessage>? _cachedVisibleMessages;
private int _cachedVisibleMessagesSourceCount = -1;
private List<ChatExecutionEvent>? _cachedVisibleEvents;
private int _cachedVisibleEventsSourceCount = -1;
private bool _cachedVisibleEventsShowHistory;
private void InvalidateTimelineCache()
{
_cachedVisibleMessages = null;
_cachedVisibleMessagesSourceCount = -1;
_cachedVisibleEvents = null;
_cachedVisibleEventsSourceCount = -1;
}
private List<ChatMessage> GetVisibleTimelineMessages(ChatConversation? conversation)
{
return conversation?.Messages?.Where(msg =>
var sourceCount = conversation?.Messages?.Count ?? 0;
if (_cachedVisibleMessages != null && sourceCount == _cachedVisibleMessagesSourceCount)
return _cachedVisibleMessages;
var result = conversation?.Messages?.Where(msg =>
{
if (string.Equals(msg.Role, "system", StringComparison.OrdinalIgnoreCase))
return false;
@@ -24,15 +43,36 @@ public partial class ChatWindow
return true;
}).ToList() ?? new List<ChatMessage>();
_cachedVisibleMessages = result;
_cachedVisibleMessagesSourceCount = sourceCount;
return result;
}
private List<ChatExecutionEvent> GetVisibleTimelineEvents(ChatConversation? conversation)
{
var events = conversation?.ExecutionEvents?.ToList() ?? new List<ChatExecutionEvent>();
if (conversation?.ShowExecutionHistory ?? true)
return events;
var sourceCount = conversation?.ExecutionEvents?.Count ?? 0;
var showHistory = conversation?.ShowExecutionHistory ?? true;
if (_cachedVisibleEvents != null
&& sourceCount == _cachedVisibleEventsSourceCount
&& showHistory == _cachedVisibleEventsShowHistory)
return _cachedVisibleEvents;
return events.Where(ShouldShowCollapsedProgressEvent).ToList();
List<ChatExecutionEvent> result;
if (showHistory)
{
result = conversation?.ExecutionEvents?.ToList() ?? new List<ChatExecutionEvent>();
}
else
{
result = (conversation?.ExecutionEvents ?? Enumerable.Empty<ChatExecutionEvent>())
.Where(ShouldShowCollapsedProgressEvent).ToList();
}
_cachedVisibleEvents = result;
_cachedVisibleEventsSourceCount = sourceCount;
_cachedVisibleEventsShowHistory = showHistory;
return result;
}
private static bool ShouldShowCollapsedProgressEvent(ChatExecutionEvent executionEvent)
@@ -64,17 +104,17 @@ public partial class ChatWindow
or "csv_create" or "markdown_create" or "md_create" or "script_create"
or "pptx_create";
private List<(DateTime Timestamp, int Order, Action Render)> BuildTimelineRenderActions(
private List<(string Key, DateTime Timestamp, int Order, Action Render)> BuildTimelineRenderActions(
IReadOnlyCollection<ChatMessage> visibleMessages,
IReadOnlyCollection<ChatExecutionEvent> visibleEvents)
{
var timeline = new List<(DateTime Timestamp, int Order, Action Render)>(visibleMessages.Count + visibleEvents.Count);
var timeline = new List<(string Key, DateTime Timestamp, int Order, Action Render)>(visibleMessages.Count + visibleEvents.Count);
foreach (var msg in visibleMessages)
{
var capturedMsg = msg;
var cacheKey = $"m_{msg.MsgId}";
timeline.Add((msg.Timestamp, 0, () =>
timeline.Add((cacheKey, msg.Timestamp, 0, () =>
{
// 캐시된 버블이 있으면 재생성 없이 재사용 (O(1) Add vs 전체 재생성)
if (_elementCache.TryGetValue(cacheKey, out var cached))
@@ -88,6 +128,9 @@ public partial class ChatWindow
var showFullHistory = _currentConversation?.ShowExecutionHistory ?? true;
var activeRunId = _isStreaming ? (_appState.AgentRun.RunId ?? "") : "";
var eventIndex = 0;
string? prevToolCallName = null;
int consecutiveToolCallCount = 0;
foreach (var executionEvent in visibleEvents)
{
// 스트리밍 중이고 히스토리 접힘 상태일 때, 현재 run의 process feed 이벤트는 통합 카드에서 표시
@@ -101,7 +144,28 @@ public partial class ChatWindow
}
var restoredEvent = ToAgentEvent(executionEvent);
timeline.Add((executionEvent.Timestamp, 1, () => AddAgentEventBanner(restoredEvent)));
// 접힌 모드: 연속 동일 ToolCall 병합 (예: document_read 3회 → 1개 pill)
if (!showFullHistory && restoredEvent.Type == AgentEventType.ToolCall
&& !string.IsNullOrWhiteSpace(restoredEvent.ToolName))
{
if (string.Equals(prevToolCallName, restoredEvent.ToolName, StringComparison.OrdinalIgnoreCase))
{
consecutiveToolCallCount++;
continue; // 연속 중복 스킵
}
// 이전 연속 카운트가 있었으면 이전 pill에 반영됨
prevToolCallName = restoredEvent.ToolName;
consecutiveToolCallCount = 1;
}
else
{
prevToolCallName = null;
consecutiveToolCallCount = 0;
}
var eventKey = $"e_{executionEvent.Timestamp.Ticks}_{eventIndex++}";
timeline.Add((eventKey, executionEvent.Timestamp, 1, () => AddAgentEventBanner(restoredEvent)));
}
// 스트리밍 중 + 히스토리 접힘: 통합 진행 카드 삽입 (개별 pill 대체)
@@ -109,14 +173,32 @@ public partial class ChatWindow
{
var capturedSteps = _currentRunProgressSteps.ToList();
var cardTimestamp = capturedSteps[^1].Timestamp;
timeline.Add((cardTimestamp, 1, () => AddLiveRunProgressCard(capturedSteps)));
// 통합 진행 카드는 매번 내용이 바뀌므로 volatile 키 사용
timeline.Add(("_live_progress", cardTimestamp, 1, () => AddLiveRunProgressCard(capturedSteps)));
}
var liveProgressHint = GetLiveAgentProgressHint();
if (liveProgressHint != null)
timeline.Add((liveProgressHint.Timestamp, 2, () => AddAgentEventBanner(liveProgressHint)));
timeline.Add(("_live_hint", liveProgressHint.Timestamp, 2, () => AddAgentEventBanner(liveProgressHint)));
return timeline.OrderBy(x => x.Timestamp).ThenBy(x => x.Order).ToList();
// 대부분 이미 시간순이므로 정렬 필요 여부를 먼저 확인 — O(n) 스캔으로 O(n log n) 정렬 회피
var needsSort = false;
for (int i = 1; i < timeline.Count; i++)
{
var cmp = timeline[i].Timestamp.CompareTo(timeline[i - 1].Timestamp);
if (cmp < 0 || (cmp == 0 && timeline[i].Order < timeline[i - 1].Order))
{
needsSort = true;
break;
}
}
if (needsSort)
timeline.Sort((a, b) =>
{
var cmp = a.Timestamp.CompareTo(b.Timestamp);
return cmp != 0 ? cmp : a.Order.CompareTo(b.Order);
});
return timeline;
}
private Border CreateTimelineLoadMoreCard(int hiddenCount)

View File

@@ -2035,7 +2035,8 @@
<Grid>
<!-- 무지개 글로우 외부 테두리 (메시지 전송 시 애니메이션) -->
<Border x:Name="InputGlowBorder" CornerRadius="18" Opacity="0"
Margin="-2" IsHitTestVisible="False">
Visibility="Collapsed"
Margin="-1" IsHitTestVisible="False">
<Border.BorderBrush>
<LinearGradientBrush x:Name="RainbowBrush" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="#FF6B6B" Offset="0.0"/>
@@ -2048,10 +2049,10 @@
</LinearGradientBrush>
</Border.BorderBrush>
<Border.BorderThickness>
<Thickness>1.15</Thickness>
<Thickness>1</Thickness>
</Border.BorderThickness>
<Border.Effect>
<BlurEffect Radius="6"/>
<BlurEffect Radius="4"/>
</Border.Effect>
</Border>
<!-- 실제 입력 영역 -->
@@ -4481,6 +4482,50 @@
Foreground="{DynamicResource AccentColor}"/>
</Border>
</Grid>
<Border x:Name="OverlayToggleDetailedLog" Style="{StaticResource OverlayAdvancedToggleRowStyle}" Margin="0,8,0,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Margin="0,0,16,0">
<TextBlock Text="상세 워크플로우 로그" FontSize="12.5" FontWeight="SemiBold" Foreground="{DynamicResource PrimaryText}"/>
<TextBlock Text="에이전트 워크플로우 상세 이력(LLM 요청/응답, 도구 호출/결과, 판단 등)을 기록합니다. 워크플로우 분석기와 함께 사용하면 디버깅에 유용합니다."
Margin="0,4,0,0"
FontSize="11.5"
TextWrapping="Wrap"
Foreground="{DynamicResource SecondaryText}"/>
</StackPanel>
<CheckBox x:Name="ChkOverlayEnableDetailedLog"
Grid.Column="1"
VerticalAlignment="Center"
Style="{StaticResource ToggleSwitch}"
Checked="ChkOverlayEnableDetailedLog_Changed"
Unchecked="ChkOverlayEnableDetailedLog_Changed"/>
</Grid>
</Border>
<Border x:Name="OverlayToggleRawLlmLog" Style="{StaticResource OverlayAdvancedToggleRowStyle}" Margin="0,8,0,0">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<StackPanel Margin="0,0,16,0">
<TextBlock Text="LLM 통신 원문 로깅" FontSize="12.5" FontWeight="SemiBold" Foreground="{DynamicResource PrimaryText}"/>
<TextBlock Text="LLM에 보낸 요청 JSON과 돌아온 응답 원문을 기록합니다. 도구 미호출 디버깅용이며 파일이 커질 수 있습니다."
Margin="0,4,0,0"
FontSize="11.5"
TextWrapping="Wrap"
Foreground="{DynamicResource SecondaryText}"/>
</StackPanel>
<CheckBox x:Name="ChkOverlayEnableRawLlmLog"
Grid.Column="1"
VerticalAlignment="Center"
Style="{StaticResource ToggleSwitch}"
Checked="ChkOverlayEnableRawLlmLog_Changed"
Unchecked="ChkOverlayEnableRawLlmLog_Changed"/>
</Grid>
</Border>
</StackPanel>
<StackPanel x:Name="OverlayAdvancedTogglePanel" Margin="0,14,0,0">
<CheckBox x:Name="ChkOverlayVllmAllowInsecureTls" Visibility="Collapsed" Checked="ChkOverlayVllmAllowInsecureTls_Changed" Unchecked="ChkOverlayVllmAllowInsecureTls_Changed"/>
@@ -5794,4 +5839,3 @@
</Border>
</Window>

View File

@@ -72,6 +72,8 @@ public partial class ChatWindow : Window
private bool _cursorVisible = true;
private TextBlock? _activeStreamText;
private string _cachedStreamContent = ""; // sb.ToString() 캐시 — 중복 호출 방지
private readonly char[] _streamDisplayBuffer = new char[256 * 1024]; // 256KB 재사용 버퍼 (타이핑 표시용)
private int _streamDisplayBufferLen; // 버퍼에 기록된 실제 길이
private TextBlock? _activeAiIcon; // 로딩 펄스 중인 AI 아이콘
private bool _aiIconPulseStopped; // 펄스 1회만 중지
private WorkflowAnalyzerWindow? _analyzerWindow; // 워크플로우 분석기
@@ -85,6 +87,13 @@ public partial class ChatWindow : Window
private long _lastScrollTick; // 스크롤 스로틀링용 (Environment.TickCount64)
// 메시지 버블 캐시: messageId → 렌더링된 UIElement (재생성 방지)
private readonly Dictionary<string, UIElement> _elementCache = new(StringComparer.Ordinal);
// 증분 렌더링: 이전 렌더링의 타임라인 키 목록 (변경 감지용)
private List<string> _lastRenderedTimelineKeys = new();
private int _lastRenderedHiddenCount;
// 스트리밍 중 불필요한 재렌더링 방지용 카운터
private int _lastRenderedMessageCount;
private int _lastRenderedEventCount;
private bool _lastRenderedShowHistory;
private readonly HashSet<string> _sessionPermissionRules = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, bool> _sessionMcpEnabledOverrides = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, string> _sessionMcpAuthTokens = new(StringComparer.OrdinalIgnoreCase);
@@ -206,6 +215,8 @@ public partial class ChatWindow : Window
foreach (var tab in new[] { "Chat", "Cowork", "Code" })
_agentLoops[tab] = CreateAgentLoopForTab(tab, settings);
SubAgentTool.StatusChanged += OnSubAgentStatusChanged;
// 에이전트 이벤트 백그라운드 프로세서 시작 (대화 변이·저장을 UI 스레드에서 분리)
StartAgentEventProcessor();
// 설정에서 초기값 로드 (Loaded 전에도 null 방지)
_selectedMood = settings.Settings.Llm.DefaultMood ?? "modern";
@@ -217,7 +228,7 @@ public partial class ChatWindow : Window
_elapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
_elapsedTimer.Tick += ElapsedTimer_Tick;
_typingTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(20) };
_typingTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(80) };
_typingTimer.Tick += TypingTimer_Tick;
_gitRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(450) };
_gitRefreshTimer.Tick += async (_, _) =>
@@ -242,6 +253,10 @@ public partial class ChatWindow : Window
_executionHistoryRenderTimer.Tick += (_, _) =>
{
_executionHistoryRenderTimer.Stop();
// 스트리밍 중에는 전체 재렌더링 빈도를 줄여 UI 부하 감소
_executionHistoryRenderTimer.Interval = _isStreaming
? TimeSpan.FromMilliseconds(1500)
: TimeSpan.FromMilliseconds(350);
RenderMessages(preserveViewport: true);
if (_pendingExecutionHistoryAutoScroll)
AutoScrollIfNeeded();
@@ -251,6 +266,9 @@ public partial class ChatWindow : Window
_taskSummaryRefreshTimer.Tick += (_, _) =>
{
_taskSummaryRefreshTimer.Stop();
_taskSummaryRefreshTimer.Interval = _isStreaming
? TimeSpan.FromMilliseconds(800)
: TimeSpan.FromMilliseconds(120);
UpdateTaskSummaryIndicators();
};
_conversationPersistTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(220) };
@@ -263,6 +281,9 @@ public partial class ChatWindow : Window
_agentUiEventTimer.Tick += (_, _) =>
{
_agentUiEventTimer.Stop();
_agentUiEventTimer.Interval = _isStreaming
? TimeSpan.FromMilliseconds(300)
: TimeSpan.FromMilliseconds(140);
FlushPendingAgentUiEvent();
};
_agentProgressHintTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
@@ -277,6 +298,12 @@ public partial class ChatWindow : Window
_responsiveLayoutTimer.Tick += (_, _) =>
{
_responsiveLayoutTimer.Stop();
// 스트리밍 중 전체 메시지 재렌더링은 UI 부하가 크므로 연기
if (_isStreaming)
{
_pendingResponsiveLayoutRefresh = true;
return;
}
UpdateTopicPresetScrollMode();
if (UpdateResponsiveChatLayout())
RenderMessages(preserveViewport: true);
@@ -541,6 +568,9 @@ public partial class ChatWindow : Window
/// <summary>앱 종료 시 창을 실제로 닫습니다.</summary>
public void ForceClose()
{
// 백그라운드 이벤트 프로세서 종료 (미저장 대화 플러시됨)
StopAgentEventProcessor();
// 현재 대화 저장 + 탭별 마지막 대화 ID를 설정에 영속 저장
lock (_convLock)
{
@@ -616,8 +646,8 @@ public partial class ChatWindow : Window
var currentOffset = MessageScroll.VerticalOffset;
var diff = targetOffset - currentOffset;
// 차이가 작으면 즉시 이동 (깜빡임 방지)
if (diff <= 60)
// 스트리밍 중이거나 차이가 작으면 즉시 이동 (UI 부하 최소화)
if (diff <= 60 || _isStreaming)
{
MessageScroll.ScrollToEnd();
return;
@@ -1122,6 +1152,16 @@ public partial class ChatWindow : Window
return IntPtr.Zero;
}
// 드래그/리사이즈 중 일시 정지할 타이머 목록
private DispatcherTimer[] GetSuspendableTimers() => new[]
{
_cursorTimer, _elapsedTimer, _typingTimer, _gitRefreshTimer,
_conversationSearchTimer, _inputUiRefreshTimer, _executionHistoryRenderTimer,
_taskSummaryRefreshTimer, _conversationPersistTimer, _agentUiEventTimer,
_agentProgressHintTimer, _tokenUsagePopupCloseTimer, _responsiveLayoutTimer,
};
private readonly List<DispatcherTimer> _timersRunningBeforeMove = new();
private void BeginWindowMoveSizeLoop()
{
if (_isInWindowMoveSizeLoop)
@@ -1130,6 +1170,21 @@ public partial class ChatWindow : Window
_isInWindowMoveSizeLoop = true;
_pendingResponsiveLayoutRefresh = false;
// 비필수 타이머 일시 정지 → 드래그 중 UI 부하 최소화
_timersRunningBeforeMove.Clear();
foreach (var t in GetSuspendableTimers())
{
if (t.IsEnabled)
{
_timersRunningBeforeMove.Add(t);
t.Stop();
}
}
// Storyboard 일시 정지
_pulseDotStoryboard?.Pause();
_statusDiamondStoryboard?.Pause();
if (Content is UIElement rootElement)
{
_cachedRootCacheModeBeforeMove = rootElement.CacheMode;
@@ -1149,6 +1204,15 @@ public partial class ChatWindow : Window
_cachedRootCacheModeBeforeMove = null;
// 타이머 복원
foreach (var t in _timersRunningBeforeMove)
t.Start();
_timersRunningBeforeMove.Clear();
// Storyboard 복원
_pulseDotStoryboard?.Resume();
_statusDiamondStoryboard?.Resume();
if (_pendingResponsiveLayoutRefresh)
{
_pendingResponsiveLayoutRefresh = false;
@@ -1201,6 +1265,7 @@ public partial class ChatWindow : Window
_cachedStreamContent = "";
_streamingTabs.Clear();
_streamRunTab = null;
_streamStartTime = default;
BtnSend.IsEnabled = true;
BtnStop.Visibility = Visibility.Collapsed;
BtnPause.Visibility = Visibility.Collapsed;
@@ -2198,9 +2263,6 @@ public partial class ChatWindow : Window
var previousScrollableHeight = MessageScroll?.ScrollableHeight ?? 0;
var previousVerticalOffset = MessageScroll?.VerticalOffset ?? 0;
MessagePanel.Children.Clear();
_runBannerAnchors.Clear();
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
_appState.RestoreAgentRunHistory(conv?.AgentRunHistory);
@@ -2208,8 +2270,21 @@ public partial class ChatWindow : Window
var visibleMessages = GetVisibleTimelineMessages(conv);
var visibleEvents = GetVisibleTimelineEvents(conv);
// 스트리밍 중 메시지·이벤트 수가 동일하면 불필요한 재렌더링 스킵
if (_isStreaming && preserveViewport
&& visibleMessages.Count == _lastRenderedMessageCount
&& visibleEvents.Count == _lastRenderedEventCount
&& (conv?.ShowExecutionHistory ?? true) == _lastRenderedShowHistory
&& string.Equals(_lastRenderedConversationId, conv?.Id, StringComparison.OrdinalIgnoreCase))
return;
if (conv == null || (visibleMessages.Count == 0 && visibleEvents.Count == 0))
{
MessagePanel.Children.Clear();
_runBannerAnchors.Clear();
_lastRenderedTimelineKeys.Clear();
_lastRenderedMessageCount = 0;
_lastRenderedEventCount = 0;
EmptyState.Visibility = Visibility.Visible;
return;
}
@@ -2218,19 +2293,113 @@ public partial class ChatWindow : Window
{
_lastRenderedConversationId = conv.Id;
_timelineRenderLimit = TimelineRenderPageSize;
_elementCache.Clear(); // 대화 전환 시 버블 캐시 초기화
_elementCache.Clear();
_lastRenderedTimelineKeys.Clear();
_lastRenderedMessageCount = 0;
_lastRenderedEventCount = 0;
InvalidateTimelineCache();
}
var showHistory = conv.ShowExecutionHistory;
EmptyState.Visibility = Visibility.Collapsed;
var orderedTimeline = BuildTimelineRenderActions(visibleMessages, visibleEvents);
var hiddenCount = Math.Max(0, orderedTimeline.Count - _timelineRenderLimit);
if (hiddenCount > 0)
MessagePanel.Children.Add(CreateTimelineLoadMoreCard(hiddenCount));
var visibleTimeline = hiddenCount > 0
? orderedTimeline.GetRange(hiddenCount, orderedTimeline.Count - hiddenCount)
: orderedTimeline;
var newKeys = new List<string>(visibleTimeline.Count);
foreach (var t in visibleTimeline) newKeys.Add(t.Key);
foreach (var item in orderedTimeline.Skip(hiddenCount))
item.Render();
var incremented = false;
// ── 증분 렌더링: 이전 키 목록과 비교하여 변경 최소화 ──
// agent live card가 존재하면 Children과 키 불일치 가능 → 전체 재빌드
var hasExternalChildren = _agentLiveContainer != null && MessagePanel.Children.Contains(_agentLiveContainer);
var expectedChildCount = _lastRenderedTimelineKeys.Count + (_lastRenderedHiddenCount > 0 ? 1 : 0);
var canIncremental = !hasExternalChildren
&& _lastRenderedTimelineKeys.Count > 0
&& newKeys.Count >= _lastRenderedTimelineKeys.Count
&& _lastRenderedHiddenCount == hiddenCount
&& MessagePanel.Children.Count == expectedChildCount;
if (canIncremental)
{
// _live_ 키 개수를 한 번만 계산 (이전 키 목록에서)
var prevLiveCount = 0;
for (int i = _lastRenderedTimelineKeys.Count - 1; i >= 0; i--)
{
if (_lastRenderedTimelineKeys[i].StartsWith("_live_", StringComparison.Ordinal))
prevLiveCount++;
else
break; // live 키는 항상 끝에 연속으로 위치
}
var prevStableCount = _lastRenderedTimelineKeys.Count - prevLiveCount;
// 안정 키(non-live) 접두사가 일치하는지 확인
var prefixMatch = true;
for (int i = 0; i < prevStableCount; i++)
{
if (i >= newKeys.Count || !string.Equals(_lastRenderedTimelineKeys[i], newKeys[i], StringComparison.Ordinal))
{
prefixMatch = false;
break;
}
}
if (prefixMatch)
{
try
{
// 이전 live 요소를 Children 끝에서 제거
for (int r = 0; r < prevLiveCount && MessagePanel.Children.Count > 0; r++)
MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1);
// 안정 접두사 이후의 새 아이템 렌더링 (새 stable + 새 live 포함)
for (int i = prevStableCount; i < visibleTimeline.Count; i++)
visibleTimeline[i].Render();
_lastRenderedTimelineKeys = newKeys;
_lastRenderedHiddenCount = hiddenCount;
_lastRenderedMessageCount = visibleMessages.Count;
_lastRenderedEventCount = visibleEvents.Count;
_lastRenderedShowHistory = showHistory;
incremented = true;
}
catch (Exception ex)
{
LogService.Warn($"증분 렌더링 실패, 전체 재빌드로 전환: {ex.Message}");
_lastRenderedTimelineKeys.Clear();
incremented = false;
}
}
}
if (!incremented)
{
// ── 전체 재빌드 (대화 전환, 접기/펴기, 중간 변경 등) ──
MessagePanel.Children.Clear();
_runBannerAnchors.Clear();
if (hiddenCount > 0)
MessagePanel.Children.Add(CreateTimelineLoadMoreCard(hiddenCount));
foreach (var item in visibleTimeline)
item.Render();
// 전체 재빌드로 Children이 초기화되었으므로 agent live card 복원
if (_agentLiveContainer != null && !MessagePanel.Children.Contains(_agentLiveContainer))
MessagePanel.Children.Add(_agentLiveContainer);
_lastRenderedTimelineKeys = newKeys;
_lastRenderedHiddenCount = hiddenCount;
_lastRenderedMessageCount = visibleMessages.Count;
_lastRenderedEventCount = visibleEvents.Count;
_lastRenderedShowHistory = showHistory;
}
// ── 스크롤 처리 ──
if (!preserveViewport)
{
_ = Dispatcher.InvokeAsync(() =>
@@ -2323,20 +2492,20 @@ public partial class ChatWindow : Window
private void CursorTimer_Tick(object? sender, EventArgs e)
{
_cursorVisible = !_cursorVisible;
// 커서 상태만 토글 — 실제 텍스트 갱신은 _typingTimer가 담당
if (_activeStreamText != null && _displayedLength > 0)
// 커서 상태만 토글 — 버퍼에 이미 기록된 텍스트의 마지막 커서 문자만 교체
if (_activeStreamText != null && _displayedLength > 0 && _streamDisplayBufferLen > 0)
{
var displayed = _cachedStreamContent.Length > 0
? _cachedStreamContent[..Math.Min(_displayedLength, _cachedStreamContent.Length)]
: "";
_activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
var cursorChar = _cursorVisible ? '\u258c' : ' ';
_streamDisplayBuffer[_streamDisplayBufferLen - 1] = cursorChar;
_activeStreamText.Text = new string(_streamDisplayBuffer, 0, _streamDisplayBufferLen);
}
}
private void ElapsedTimer_Tick(object? sender, EventArgs e)
{
var elapsed = DateTime.UtcNow - _streamStartTime;
var sec = (int)elapsed.TotalSeconds;
var sec = TryGetStreamingElapsed(out var elapsed)
? Math.Max(0, (int)elapsed.TotalSeconds)
: 0;
if (_elapsedLabel != null)
_elapsedLabel.Text = $"{sec}s";
@@ -2356,25 +2525,38 @@ public partial class ChatWindow : Window
if (_displayedLength >= targetLen) return;
// 버퍼에 쌓인 미표시 글자 수에 따라 속도 적응
// IBM/DeepSeek은 대용량 청크를 한번에 보내므로 빠르게 따라잡을 수 있도록 스텝 증가
var pending = targetLen - _displayedLength;
int step;
if (pending > 1000) step = pending / 8; // 대량 버퍼: 빠르게 따라잡기
else if (pending > 300) step = Math.Min(Math.Max(15, pending / 8), 60); // 중-대량: 가속
else if (pending > 120) step = Math.Min(Math.Max(8, pending / 10), 20); // 중간 버퍼
else if (pending > 24) step = Math.Min(6, pending); // 소량
else step = Math.Min(2, pending); // 마무리
if (pending > 1000) step = pending / 4;
else if (pending > 300) step = Math.Min(Math.Max(30, pending / 4), 120);
else if (pending > 120) step = Math.Min(Math.Max(15, pending / 6), 40);
else if (pending > 24) step = Math.Min(12, pending);
else step = Math.Min(4, pending);
_displayedLength += step;
var displayed = _cachedStreamContent[.._displayedLength];
_activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
// 재사용 버퍼에 표시할 텍스트 + 커서를 직접 기록 (string.Concat 할당 제거)
var displayLen = _displayedLength;
var cursorChar = _cursorVisible ? '\u258c' : ' ';
var needed = displayLen + 1;
if (needed <= _streamDisplayBuffer.Length)
{
_cachedStreamContent.CopyTo(0, _streamDisplayBuffer, 0, displayLen);
_streamDisplayBuffer[displayLen] = cursorChar;
_streamDisplayBufferLen = needed;
_activeStreamText.Text = new string(_streamDisplayBuffer, 0, needed);
}
else
{
// 버퍼 초과 시 fallback (256KB 이상 응답)
_activeStreamText.Text = string.Concat(_cachedStreamContent.AsSpan(0, displayLen), cursorChar.ToString());
}
// 스크롤은 80ms마다 한 번만 (매 20ms 레이아웃 재계산 방지)
// 스크롤은 150ms마다 한 번만 (레이아웃 재계산 빈도 감소)
if (!_userScrolled)
{
var now = Environment.TickCount64;
if (now - _lastScrollTick >= 80)
if (now - _lastScrollTick >= 150)
{
_lastScrollTick = now;
MessageScroll.ScrollToVerticalOffset(MessageScroll.ScrollableHeight);
@@ -5568,6 +5750,7 @@ public partial class ChatWindow : Window
_activeStreamText = null;
_elapsedLabel = null;
_cachedStreamContent = "";
_streamStartTime = default;
SetStatusIdle();
}
@@ -5691,26 +5874,27 @@ public partial class ChatWindow : Window
_typingTimer.Start();
ShowStreamingStatusBar("생각하는 중...");
var streamSb = new System.Text.StringBuilder();
var streamSb = new System.Text.StringBuilder(4096);
var lastSyncTick = Environment.TickCount64;
await foreach (var chunk in _llm.StreamAsync(preparedExecution.Messages.ToList(), streamToken))
{
if (string.IsNullOrEmpty(chunk))
continue;
streamSb.Append(chunk);
// 타이핑 타이머가 현재 버퍼를 다 소화했을 때만 ToString() 호출 — GC 압박 최소화
if (_displayedLength >= _cachedStreamContent.Length)
// ToString() 호출 조건: 타이머가 소화 완료 + 최소 30ms 경과
var now = Environment.TickCount64;
if (_displayedLength >= _cachedStreamContent.Length && now - lastSyncTick >= 30)
{
_cachedStreamContent = streamSb.ToString();
// Dispatcher 타이머 틱이 실행될 기회를 보장
// (IBM처럼 응답이 버퍼로 한 번에 오면 타이머가 굶을 수 있음)
lastSyncTick = now;
await Task.Delay(1, streamToken).ConfigureAwait(true);
}
if (_activeStreamText != null && _displayedLength == 0)
_activeStreamText.Text = _cursorVisible ? "\u258c" : " ";
}
assistantContent = streamSb.ToString();
_cachedStreamContent = assistantContent; // 최종 동기화
_cachedStreamContent = assistantContent;
}
else
{
@@ -5722,7 +5906,7 @@ public partial class ChatWindow : Window
assistantContent = response ?? string.Empty;
}
responseElapsedMs = Math.Max(0, (long)(DateTime.UtcNow - _streamStartTime).TotalMilliseconds);
responseElapsedMs = GetStreamingElapsedMsOrZero();
assistantMetaRunId = _appState.AgentRun.RunId;
var usage = _llm.LastTokenUsage;
if (usage != null)
@@ -6001,12 +6185,25 @@ public partial class ChatWindow : Window
if (_pendingConversationPersists.Count == 0)
return;
foreach (var conversation in _pendingConversationPersists.Values.ToList())
{
PersistConversationSnapshot(conversation.Tab ?? _activeTab, conversation, "대화 지연 저장 실패");
}
// 대화 저장(디스크 I/O)을 백그라운드로 이동하여 UI 스레드 블로킹 방지
var snapshot = _pendingConversationPersists.Values.ToList();
_pendingConversationPersists.Clear();
Task.Run(() =>
{
foreach (var conversation in snapshot)
{
try
{
_storage.Save(conversation);
_appState.ChatSession?.RememberConversation(conversation.Tab ?? "Chat", conversation.Id);
}
catch (Exception ex)
{
Services.LogService.Debug($"대화 지연 저장 실패: {ex.Message}");
}
}
});
}
// ─── 코워크 에이전트 지원 ────────────────────────────────────────────
@@ -6536,10 +6733,9 @@ public partial class ChatWindow : Window
private void OnAgentEvent(AgentEvent evt, string runTab)
{
TouchLiveAgentProgressHints();
// runTab은 클로저로 캡처된 실행 탭 — 다중 탭 동시 실행 시에도 올바른 탭에 귀속
var eventTab = runTab;
// Claude 스타일 펄스 닷 실시간 단계 업데이트
// ── 1단계: 경량 UI 피드백만 (UI 스레드) ──────────────────────────────
if (string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase))
{
switch (evt.Type)
@@ -6547,14 +6743,12 @@ public partial class ChatWindow : Window
case AgentEventType.ToolCall when !string.IsNullOrWhiteSpace(evt.ToolName):
{
var (msg, icon, category) = GetStatusInfoForTool(evt.ToolName);
// 카테고리 변경 시 주 텍스트만 업데이트하고 서브 아이템 초기화
bool categoryChanged = category != _currentSubItemCategory;
if (PulseDotStatusText != null && PulseDotBar?.Visibility == Visibility.Visible)
PulseDotStatusText.Text = msg + "...";
if (categoryChanged) ClearStatusSubItems();
_currentSubItemCategory = category;
// 파일명 서브 아이템 추가
string? subItemText = null;
if (!string.IsNullOrEmpty(evt.FilePath))
{
@@ -6568,13 +6762,11 @@ public partial class ChatWindow : Window
subItemText = evt.Summary;
AddStatusSubItem(subItemText, category);
}
// 라이브 카드 업데이트
UpdateAgentLiveCard(msg + "...", subItemText, category, categoryChanged);
break;
}
case AgentEventType.ToolResult when !string.IsNullOrWhiteSpace(evt.ToolName):
{
// 결과 수신 시 기존 서브 아이템을 유지하며 주 텍스트만 변경
var resultMsg = GetToolResultMessage(evt.ToolName) + "...";
if (PulseDotStatusText != null && PulseDotBar?.Visibility == Visibility.Visible)
PulseDotStatusText.Text = resultMsg;
@@ -6623,19 +6815,16 @@ public partial class ChatWindow : Window
_currentRunProgressSteps.RemoveAt(0);
}
// 실행 로그는 직접 배너를 먼저 꽂지 않고, 대화 모델에 누적한 뒤 재렌더합니다.
// 그래야 중간 배너 잔상과 최종 재렌더 중복이 줄어듭니다.
// ── 2단계: 무거운 작업을 백그라운드 큐로 위임 ──────────────────────────
// AppendConversationExecutionEvent, AppendConversationAgentRun, 디스크 저장은
// 백그라운드 스레드에서 배치 처리됩니다. UI 렌더 갱신도 배치 완료 후 1회만 호출됩니다.
var shouldShowExecutionHistory = _currentConversation?.ShowExecutionHistory ?? false;
AppendConversationExecutionEvent(evt, eventTab);
if ((shouldShowExecutionHistory || ShouldRenderProgressEventWhenHistoryCollapsed(evt))
&& string.Equals(eventTab, _activeTab, StringComparison.OrdinalIgnoreCase))
ScheduleExecutionHistoryRender(autoScroll: true);
var shouldRender = (shouldShowExecutionHistory || ShouldRenderProgressEventWhenHistoryCollapsed(evt))
&& string.Equals(eventTab, _activeTab, StringComparison.OrdinalIgnoreCase);
EnqueueAgentEventWork(evt, eventTab, shouldRender);
// ── 3단계: 경량 상태 추적 (UI 스레드) ───────────────────────────────
_appState.ApplyAgentEvent(evt);
if (evt.Type == AgentEventType.Complete)
AppendConversationAgentRun(evt, "completed", string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary, eventTab);
else if (evt.Type == AgentEventType.Error && string.IsNullOrWhiteSpace(evt.ToolName))
AppendConversationAgentRun(evt, "failed", string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 실행 실패" : evt.Summary, eventTab);
// 탭별 토큰 누적 — 활성 탭 것만 하단 바에 표시
if (evt.InputTokens > 0 || evt.OutputTokens > 0)
@@ -6647,7 +6836,6 @@ public partial class ChatWindow : Window
}
ScheduleAgentUiEvent(evt);
ScheduleTaskSummaryRefresh();
}
@@ -7171,7 +7359,7 @@ public partial class ChatWindow : Window
}
var idle = DateTime.UtcNow - _lastAgentProgressEventAt;
var elapsed = DateTime.UtcNow - _streamStartTime.ToUniversalTime();
TryGetStreamingElapsed(out var elapsed);
string? summary = null;
var toolName = "agent_wait";
@@ -7214,10 +7402,7 @@ public partial class ChatWindow : Window
var normalizedSummary = string.IsNullOrWhiteSpace(summary) ? null : summary.Trim();
var currentSummary = _liveAgentProgressHint?.Summary;
var currentToolName = _liveAgentProgressHint?.ToolName ?? "";
var hasValidStreamStart = _streamStartTime.Year >= 2000 && _streamStartTime <= DateTime.UtcNow.AddSeconds(1);
var elapsedMs = _isStreaming && hasValidStreamStart
? Math.Max(0L, (long)(DateTime.UtcNow - _streamStartTime.ToUniversalTime()).TotalMilliseconds)
: 0L;
var elapsedMs = _isStreaming ? GetStreamingElapsedMsOrZero() : 0L;
var inputTokens = (long)Math.Max(0, _tabCumulativeInputTokens.GetValueOrDefault(_activeTab));
var outputTokens = (long)Math.Max(0, _tabCumulativeOutputTokens.GetValueOrDefault(_activeTab));
var currentElapsedBucket = (_liveAgentProgressHint?.ElapsedMs ?? 0) / 3000;
@@ -7298,10 +7483,17 @@ public partial class ChatWindow : Window
private void OnSubAgentStatusChanged(SubAgentStatusEvent evt)
{
Dispatcher.Invoke(() =>
Dispatcher.BeginInvoke(() =>
{
_appState.ApplySubAgentStatus(evt);
ScheduleTaskSummaryRefresh();
try
{
_appState.ApplySubAgentStatus(evt);
ScheduleTaskSummaryRefresh();
}
catch (Exception ex)
{
LogService.Warn($"OnSubAgentStatusChanged 처리 실패: {ex.Message}");
}
});
}
@@ -8756,10 +8948,11 @@ public partial class ChatWindow : Window
container.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(actionBar, mdCard);
// 경과 시간 + 토큰 사용량 (우측 하단, 별도 줄)
var elapsed = DateTime.UtcNow - _streamStartTime;
var elapsedText = elapsed.TotalSeconds < 60
var elapsedText = TryGetStreamingElapsed(out var elapsed)
? (elapsed.TotalSeconds < 60
? $"{elapsed.TotalSeconds:0.#}s"
: $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s";
: $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s")
: "0s";
var usage = _llm.LastTokenUsage;
// 에이전트 루프(Cowork/Code)에서는 누적 토큰 사용, 일반 대화에서는 마지막 호출 토큰 사용
@@ -9593,6 +9786,31 @@ public partial class ChatWindow : Window
private DispatcherTimer? _rainbowTimer;
private DateTime _rainbowStartTime;
private bool TryGetStreamingElapsed(out TimeSpan elapsed)
{
elapsed = TimeSpan.Zero;
if (_streamStartTime.Year < 2000)
return false;
var now = DateTime.UtcNow;
if (_streamStartTime > now.AddSeconds(1))
return false;
elapsed = now - _streamStartTime;
if (elapsed < TimeSpan.Zero || elapsed > TimeSpan.FromHours(6))
{
elapsed = TimeSpan.Zero;
return false;
}
return true;
}
private long GetStreamingElapsedMsOrZero()
=> TryGetStreamingElapsed(out var elapsed)
? Math.Max(0L, (long)elapsed.TotalMilliseconds)
: 0L;
/// <summary>입력창 테두리에 무지개 그라데이션 회전 애니메이션을 재생합니다 (3초).</summary>
private void PlayRainbowGlow()
{
@@ -9600,12 +9818,12 @@ public partial class ChatWindow : Window
if (_rainbowTimer != null) return; // 이미 실행 중이면 opacity 리셋 없이 그냥 유지
_rainbowStartTime = DateTime.UtcNow;
InputGlowBorder.Effect = new System.Windows.Media.Effects.BlurEffect { Radius = 6 };
InputGlowBorder.Visibility = Visibility.Visible;
InputGlowBorder.Effect = new System.Windows.Media.Effects.BlurEffect { Radius = 4 };
InputGlowBorder.BeginAnimation(UIElement.OpacityProperty,
new System.Windows.Media.Animation.DoubleAnimation(0, 0.62, TimeSpan.FromMilliseconds(180)));
new System.Windows.Media.Animation.DoubleAnimation(0, 0.92, TimeSpan.FromMilliseconds(180)));
_rainbowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(150) };
_rainbowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_rainbowTimer.Tick += (_, _) =>
{
var elapsed = (DateTime.UtcNow - _rainbowStartTime).TotalMilliseconds;
@@ -9625,13 +9843,21 @@ public partial class ChatWindow : Window
{
_rainbowTimer?.Stop();
_rainbowTimer = null;
if (InputGlowBorder.Opacity > 0)
if (InputGlowBorder.Opacity > 0 || InputGlowBorder.Visibility == Visibility.Visible)
{
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(
InputGlowBorder.Opacity, 0, TimeSpan.FromMilliseconds(600));
fadeOut.Completed += (_, _) => InputGlowBorder.Opacity = 0;
fadeOut.Completed += (_, _) =>
{
InputGlowBorder.Opacity = 0;
InputGlowBorder.Visibility = Visibility.Collapsed;
};
InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut);
}
else
{
InputGlowBorder.Visibility = Visibility.Collapsed;
}
}
// ─── 토스트 알림 ──────────────────────────────────────────────────────
@@ -11346,6 +11572,10 @@ public partial class ChatWindow : Window
ChkOverlayShowTotalCallStats.IsChecked = llm.ShowTotalCallStats;
if (ChkOverlayEnableAuditLog != null)
ChkOverlayEnableAuditLog.IsChecked = llm.EnableAuditLog;
if (ChkOverlayEnableDetailedLog != null)
ChkOverlayEnableDetailedLog.IsChecked = llm.EnableDetailedLog;
if (ChkOverlayEnableRawLlmLog != null)
ChkOverlayEnableRawLlmLog.IsChecked = llm.EnableRawLlmLog;
if (ChkOverlayEnableChatRainbowGlow != null)
ChkOverlayEnableChatRainbowGlow.IsChecked = llm.EnableChatRainbowGlow;
}
@@ -12005,6 +12235,28 @@ public partial class ChatWindow : Window
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void ChkOverlayEnableDetailedLog_Changed(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing || ChkOverlayEnableDetailedLog == null)
return;
var enabled = ChkOverlayEnableDetailedLog.IsChecked == true;
_settings.Settings.Llm.EnableDetailedLog = enabled;
WorkflowLogService.IsEnabled = enabled;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void ChkOverlayEnableRawLlmLog_Changed(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing || ChkOverlayEnableRawLlmLog == null)
return;
var enabled = ChkOverlayEnableRawLlmLog.IsChecked == true;
_settings.Settings.Llm.EnableRawLlmLog = enabled;
WorkflowLogService.IsRawLogEnabled = enabled;
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
}
private void ChkOverlayFeatureToggle_Changed(object sender, RoutedEventArgs e)
{
if (_isOverlaySettingsSyncing)
@@ -12158,8 +12410,12 @@ public partial class ChatWindow : Window
private void RefreshOverlaySettingsPanel()
{
// 기본 컨트롤 상태만 동기적으로 설정 (빠름)
RefreshOverlayVisualState(loadDeferredInputs: true);
RefreshOverlayEtcPanels();
// 무거운 패널 빌드(스킬/MCP/도구 목록 등)는 UI 프레임 렌더링 후 비동기 지연 실행
// → 스트리밍 중 설정 열기 시 UI 프리즈 방지
Dispatcher.BeginInvoke(RefreshOverlayEtcPanels, System.Windows.Threading.DispatcherPriority.Background);
}
private void RefreshOverlayRetentionButtons()
@@ -16202,9 +16458,16 @@ public partial class ChatWindow : Window
AddTaskSummaryBackgroundSection(panel);
}
private static readonly Dictionary<string, System.Windows.Media.SolidColorBrush> _brushCache = new(StringComparer.OrdinalIgnoreCase);
private static System.Windows.Media.SolidColorBrush BrushFromHex(string hex)
{
if (_brushCache.TryGetValue(hex, out var cached))
return cached;
var c = (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(hex)!;
return new System.Windows.Media.SolidColorBrush(c);
var brush = new System.Windows.Media.SolidColorBrush(c);
brush.Freeze(); // Frozen brush는 스레드간 공유 가능 + 렌더링 최적화
_brushCache[hex] = brush;
return brush;
}
}

View File

@@ -95,10 +95,11 @@ public partial class DockBarWindow : Window
if (_glowTimer != null) return; // 이미 실행 중 — Tick 핸들러 중복 추가 방지
RainbowGlowBorder.Visibility = Visibility.Visible;
var startAngle = 0.0;
_glowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(150) };
_glowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
_glowTimer.Tick += (_, _) =>
{
startAngle += 2;
if (!IsVisible) { _glowTimer?.Stop(); return; }
startAngle += 4;
if (startAngle >= 360) startAngle -= 360;
var rad = startAngle * Math.PI / 180.0;
RainbowBrush.StartPoint = new Point(0.5 + 0.5 * Math.Cos(rad), 0.5 + 0.5 * Math.Sin(rad));

View File

@@ -30,7 +30,7 @@ public partial class LauncherWindow
{
_widgetTimer = new DispatcherTimer(DispatcherPriority.Background)
{
Interval = TimeSpan.FromSeconds(1)
Interval = TimeSpan.FromSeconds(3)
};
_widgetTimer.Tick += (_, _) =>
{
@@ -44,9 +44,9 @@ public partial class LauncherWindow
SyncWidgetPollingState();
RefreshVisibleWidgets(forceWeatherRefresh: false);
if (ShouldShowBatteryWidget() && _widgetBatteryTick++ % 30 == 0)
if (ShouldShowBatteryWidget() && _widgetBatteryTick++ % 10 == 0)
UpdateBatteryWidget();
if (ShouldShowWeatherWidget() && _widgetWeatherTick++ % 120 == 0)
if (ShouldShowWeatherWidget() && _widgetWeatherTick++ % 40 == 0)
_ = RefreshWeatherAsync();
};
}

View File

@@ -676,7 +676,7 @@ public partial class LauncherWindow : Window
RainbowGlowBorder.Opacity = 1; // StopRainbowGlow에서 0으로 설정된 불투명도 복원
_rainbowTimer = new System.Windows.Threading.DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(150)
Interval = TimeSpan.FromMilliseconds(300)
};
var startTime = DateTime.UtcNow;
_rainbowTimer.Tick += (_, _) =>

View File

@@ -5621,6 +5621,31 @@
</StackPanel>
</Grid>
</Border>
<Border Style="{StaticResource SettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
<StackPanel Orientation="Horizontal">
<TextBlock Style="{StaticResource RowLabel}" Text="LLM 통신 원문 로깅"/>
<Border Width="16" Height="16" CornerRadius="8" Background="{DynamicResource ItemHoverBackground}" Margin="6,0,0,0" Cursor="Help" VerticalAlignment="Center">
<TextBlock Text="?" FontSize="10" FontWeight="Bold" Foreground="{DynamicResource AccentColor}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Border.ToolTip>
<ToolTip Style="{StaticResource HelpTooltipStyle}">
<TextBlock TextWrapping="Wrap" Foreground="White" FontSize="12" LineHeight="18">
LLM에 보낸 전체 요청 JSON과 돌아온 응답 원문을 기록합니다.
<LineBreak/>도구 미호출 등 문제 분석 시 유용합니다.
<LineBreak/>파일 크기가 커질 수 있으므로 디버깅 시에만 사용하세요.
<LineBreak/>상세 로그 보관 기간에 따라 자동 삭제됩니다.
</TextBlock>
</ToolTip>
</Border.ToolTip>
</Border>
</StackPanel>
<TextBlock Style="{StaticResource RowHint}" Text="LLM 요청/응답 전문을 기록합니다. 도구 호출 미작동 디버깅용이며 파일이 클 수 있습니다."/>
</StackPanel>
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
IsChecked="{Binding EnableRawLlmLog, Mode=TwoWay}"/>
</Grid>
</Border>
<Border Style="{StaticResource SettingsRow}">
<Grid>
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">