From 1b4a2bfb1c3c91b36b36de3f0c981dea98292111 Mon Sep 17 00:00:00 2001 From: lacvet Date: Wed, 8 Apr 2026 23:20:53 +0900 Subject: [PATCH] =?UTF-8?q?AX=20Agent=20=EC=A7=84=ED=96=89=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=C2=B7=EA=B8=80=EB=A1=9C=EC=9A=B0=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EC=B5=9C=EA=B7=BC=20?= =?UTF-8?q?=EB=A1=9C=EC=BB=AC=20=EB=B3=80=EA=B2=BD=20=EC=9D=BC=EA=B4=84=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AX Agent 스트리밍 경과 시간을 공용 helper로 통일해 비정상적인 수천만 시간 표시를 방지함 - 채팅 입력창 글로우를 런처와 같은 표시/숨김 중심의 얇은 외곽 글로우로 정리하고 런처 글로우 설정은 일반 설정에 유지함 - README와 DEVELOPMENT 문서를 2026-04-08 12:02 (KST) 기준으로 갱신하고 Release 빌드 경고 0 / 오류 0을 확인함 --- README.md | 5 + docs/DEVELOPMENT.md | 12 + src/AxCopilot/App.xaml.cs | 1 + src/AxCopilot/Models/AppSettings.cs | 4 + .../Services/Agent/AgentLoopService.cs | 51 ++- src/AxCopilot/Services/Agent/ClipboardTool.cs | 13 +- src/AxCopilot/Services/Agent/NotifyTool.cs | 9 +- src/AxCopilot/Services/LlmService.ToolUse.cs | 178 +++++++- src/AxCopilot/Services/LlmService.cs | 37 +- .../Services/PerformanceMonitorService.cs | 2 +- src/AxCopilot/Services/ServerStatusService.cs | 2 +- src/AxCopilot/Services/SettingsService.cs | 33 +- src/AxCopilot/Services/WorkflowLogService.cs | 74 +++- src/AxCopilot/ViewModels/SettingsViewModel.cs | 10 + .../Views/ChatWindow.AgentEventProcessor.cs | 195 ++++++++ .../Views/ChatWindow.AgentEventRendering.cs | 20 +- .../Views/ChatWindow.StatusPresentation.cs | 17 + .../Views/ChatWindow.TimelinePresentation.cs | 106 ++++- src/AxCopilot/Views/ChatWindow.xaml | 52 ++- src/AxCopilot/Views/ChatWindow.xaml.cs | 417 ++++++++++++++---- src/AxCopilot/Views/DockBarWindow.xaml.cs | 5 +- src/AxCopilot/Views/LauncherWindow.Widgets.cs | 6 +- src/AxCopilot/Views/LauncherWindow.xaml.cs | 2 +- src/AxCopilot/Views/SettingsWindow.xaml | 25 ++ 24 files changed, 1103 insertions(+), 173 deletions(-) create mode 100644 src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs diff --git a/README.md b/README.md index edb2684..261fa98 100644 --- a/README.md +++ b/README.md @@ -1492,3 +1492,8 @@ MIT License - `LlmService`에 tool-use 전용 스트리밍 이벤트 API를 추가했습니다. 이제 OpenAI/vLLM/IBM 경로는 텍스트 델타와 완성된 도구 호출을 각각 이벤트로 내보낼 수 있습니다. - `Cowork/Code` 루프도 이 스트리밍 이벤트를 직접 소비하도록 바꿔, 도구 호출이 완성되는 즉시 transcript에 `스트리밍 도구 감지` 진행 표시가 보이고 read-only 도구 조기 실행도 실제 실행 루프와 연결되도록 정리했습니다. - 기존의 `응답 전체 수신 -> tool_calls 파싱 -> 도구 실행` 구조에서 한 단계 더 나아가, `스트리밍 수신 -> partial tool_call 조립 -> 조기 read-only 실행 -> 최종 루프 재사용` 흐름으로 리팩터링했습니다. +- 업데이트: 2026-04-08 12:02 (KST) + - AX Agent 진행 카드의 경과 시간 계산을 공용 검증 helper로 통일했습니다. `_streamStartTime`이 초기화되지 않았거나 6시간을 넘는 비정상 상태이면 `0초`로 정리해 `수천만 시간`처럼 표시되던 문제를 막았습니다. + - 스트리밍 종료/취소 시 `_streamStartTime`을 즉시 초기화하도록 정리해, 이전 실행의 시간이 다음 실행 카드나 assistant 메타에 새어 들어가지 않게 했습니다. + - 채팅 입력창 글로우는 런처와 같은 방식으로 `표시/숨김 + 얇은 외곽선 + 부드러운 투명도` 중심으로 다듬었습니다. 과한 블러와 두꺼운 외곽선 때문에 지저분하게 보이던 인상을 줄였습니다. + - 런처 글로우 토글은 일반 설정에 그대로 유지하고, AX Agent 내부 설정은 채팅 입력창 글로우만 담당하도록 역할을 분리했습니다. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index b5cf4d4..36a240d 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -5415,3 +5415,15 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎. - Cowork/Code 메인 루프가 tool-use streaming event를 직접 소비하게 바꿨다. - 텍스트 델타가 쌓이면 450ms 주기로 `Thinking` 이벤트에 축약 preview를 갱신하고, 도구 호출이 완성되면 `스트리밍 도구 감지` 진행 메시지를 즉시 띄우도록 연결했다. - read-only 조기 실행과 최종 실행 재사용 흐름이 기존 loop와 실제로 이어지도록 정리했다. + +## 2026-04-08 12:02 (KST) + +- [ChatWindow.xaml.cs](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml.cs) + - 스트리밍 경과 시간 계산을 `TryGetStreamingElapsed()` / `GetStreamingElapsedMsOrZero()` 공용 helper로 통일했다. + - `_streamStartTime`이 비정상이거나 6시간을 넘는 경우에는 시간을 0으로 처리해 라이브 진행 카드와 assistant 메타에 `수천만 시간`처럼 표시되던 문제를 막았다. + - 스트리밍 종료/취소 시 `_streamStartTime`을 즉시 초기화해 이전 실행의 시간이 다음 실행에 누수되지 않도록 했다. +- [ChatWindow.xaml](/E:/AX%20Copilot%20-%20Codex/src/AxCopilot/Views/ChatWindow.xaml) + - 입력창 글로우 보더를 더 얇은 외곽선과 작은 블러 반경으로 조정하고, 기본 상태에서는 `Collapsed`로 두어 불필요한 존재감을 줄였다. +- 글로우 설정 정책 정리 + - 런처 글로우(`런처 무지개 글로우`, `런처 선택 글로우`)는 일반 설정에 그대로 유지한다. + - AX Agent 내부 설정은 채팅 입력창 글로우만 조정하도록 역할을 분리했다. diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs index dac3c84..c676bb1 100644 --- a/src/AxCopilot/App.xaml.cs +++ b/src/AxCopilot/App.xaml.cs @@ -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 diff --git a/src/AxCopilot/Models/AppSettings.cs b/src/AxCopilot/Models/AppSettings.cs index 7cfb940..1c3a0c5 100644 --- a/src/AxCopilot/Models/AppSettings.cs +++ b/src/AxCopilot/Models/AppSettings.cs @@ -1003,6 +1003,10 @@ public class LlmSettings [JsonPropertyName("detailedLogRetentionDays")] public int DetailedLogRetentionDays { get; set; } = 3; + /// LLM에 보낸 요청 JSON과 돌아온 응답 원문을 모두 기록합니다. 디버깅용이며 파일 크기가 클 수 있습니다. + [JsonPropertyName("enableRawLlmLog")] + public bool EnableRawLlmLog { get; set; } = false; + /// 에이전트 메모리 (지속적 학습) 활성화. 기본 true. [JsonPropertyName("enableAgentMemory")] public bool EnableAgentMemory { get; set; } = true; diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index 9e77895..3d6996e 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -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] 지금 즉시 형식으로 도구를 호출하세요. 텍스트만 반환하면 거부됩니다.\n" + + "Output format:\n\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n" }]; } // 워크플로우 상세 로그: 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" + + "\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n\n" + + $"사용 가능한 도구: {activeToolPreview}", _ => "[System:ToolCallRequired] " + "🚨 최종 경고: 도구를 계속 호출하지 않고 있습니다. 이것이 마지막 기회입니다. " + - "지금 응답은 반드시 도구 호출만 포함해야 합니다. 텍스트는 한 글자도 쓰지 마세요. " + - "작업을 완료하려면 도구를 호출하는 것 외에 다른 방법이 없습니다. " + - "도구 이름을 모른다면 아래 목록에서 골라 즉시 호출하세요. " + - "여러 도구를 한꺼번에 호출할 수 있습니다 — 지금 그렇게 하세요. " + + "텍스트는 한 글자도 쓰지 마세요. 반드시 아래 형식으로 도구를 호출하세요:\n" + + "\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n\n" + $"반드시 사용해야 할 도구 목록: {activeToolPreview}" }; messages.Add(new ChatMessage { Role = "user", Content = recoveryContent }); @@ -850,15 +848,13 @@ public partial class AgentLoopService { 1 => "[System:ToolCallRequired] 계획을 세웠지만 도구를 호출하지 않았습니다. " + - "계획은 이미 수립되었으므로 지금 당장 실행 단계로 넘어가세요. " + - "텍스트 설명 없이 계획의 첫 번째 단계를 도구(tool call)로 즉시 실행하세요. " + - "한 응답에서 여러 도구를 동시에 호출할 수 있습니다. " + + "지금 당장 실행하세요. 아래 형식으로 도구를 호출하세요:\n" + + "\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n\n" + $"사용 가능한 도구: {planToolList}", _ => "[System:ToolCallRequired] 🚨 도구 호출 없이 계획만 반복하고 있습니다. " + - "이제 계획 설명은 완전히 금지됩니다. 오직 도구 호출만 하세요. " + - "지금 이 응답에 텍스트를 포함하지 마세요. 도구만 호출하세요. " + - "독립적인 작업은 한 번에 여러 도구를 병렬 호출하세요. " + + "텍스트를 한 글자도 쓰지 마세요. 오직 아래 형식의 도구 호출만 출력하세요:\n" + + "\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n\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" + + "\n{\"name\": \"html_create\", \"arguments\": {\"file_name\": \"...\", \"html_body\": \"...\"}}\n" }); 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 스레드가 이벤트를 렌더링할 시간 확보 diff --git a/src/AxCopilot/Services/Agent/ClipboardTool.cs b/src/AxCopilot/Services/Agent/ClipboardTool.cs index a319a6e..f9f2e5e 100644 --- a/src/AxCopilot/Services/Agent/ClipboardTool.cs +++ b/src/AxCopilot/Services/Agent/ClipboardTool.cs @@ -36,17 +36,16 @@ public class ClipboardTool : IAgentTool Required = ["action"], }; - public Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default) + public async Task 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}"); } } diff --git a/src/AxCopilot/Services/Agent/NotifyTool.cs b/src/AxCopilot/Services/Agent/NotifyTool.cs index 57e27d6..14510d4 100644 --- a/src/AxCopilot/Services/Agent/NotifyTool.cs +++ b/src/AxCopilot/Services/Agent/NotifyTool.cs @@ -43,7 +43,7 @@ public class NotifyTool : IAgentTool Required = ["title", "message"], }; - public Task ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default) + public async Task 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}"); } } diff --git a/src/AxCopilot/Services/LlmService.ToolUse.cs b/src/AxCopilot/Services/LlmService.ToolUse.cs index c7510ce..519946e 100644 --- a/src/AxCopilot/Services/LlmService.ToolUse.cs +++ b/src/AxCopilot/Services/LlmService.ToolUse.cs @@ -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 <tool_call>\n{"name":"...", "arguments":{...}}\n</tool_call> /// 3. 여러 건의 연속 tool_call 태그 /// + // ── 텍스트 폴백 파싱용 정규식 (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 TryExtractToolCallsFromText(string text) { var results = new List(); if (string.IsNullOrWhiteSpace(text)) return results; // 패턴 1: ... 태그 (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(); + // 태그 내용에서 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; + } } /// 이름과 arguments JSON이 별도로 주어진 경우. @@ -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 messages, IReadOnlyCollection 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(); - // 시스템 프롬프트 + // 시스템 프롬프트 + 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" + + "\n" + + "{\"name\": \"TOOL_NAME\", \"arguments\": {\"param1\": \"value1\"}}\n" + + "\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 immediately.\n" + + "- If multiple tools are needed, output multiple 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] 설명하지 말고 지금 즉시 형식으로 도구를 호출하세요. 평문 응답은 거부됩니다.\nExample: \n{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}\n" }); } @@ -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(); 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(); 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>? 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 { diff --git a/src/AxCopilot/Services/LlmService.cs b/src/AxCopilot/Services/LlmService.cs index 42193a0..9505c5f 100644 --- a/src/AxCopilot/Services/LlmService.cs +++ b/src/AxCopilot/Services/LlmService.cs @@ -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(); } } } diff --git a/src/AxCopilot/Services/PerformanceMonitorService.cs b/src/AxCopilot/Services/PerformanceMonitorService.cs index 7aedcd2..0cd56c1 100644 --- a/src/AxCopilot/Services/PerformanceMonitorService.cs +++ b/src/AxCopilot/Services/PerformanceMonitorService.cs @@ -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() diff --git a/src/AxCopilot/Services/ServerStatusService.cs b/src/AxCopilot/Services/ServerStatusService.cs index 3cbde87..a35347b 100644 --- a/src/AxCopilot/Services/ServerStatusService.cs +++ b/src/AxCopilot/Services/ServerStatusService.cs @@ -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() diff --git a/src/AxCopilot/Services/SettingsService.cs b/src/AxCopilot/Services/SettingsService.cs index 9870f1f..dd525d1 100644 --- a/src/AxCopilot/Services/SettingsService.cs +++ b/src/AxCopilot/Services/SettingsService.cs @@ -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); } diff --git a/src/AxCopilot/Services/WorkflowLogService.cs b/src/AxCopilot/Services/WorkflowLogService.cs index 7c383d4..2962065 100644 --- a/src/AxCopilot/Services/WorkflowLogService.cs +++ b/src/AxCopilot/Services/WorkflowLogService.cs @@ -39,10 +39,42 @@ public static class WorkflowLogService /// 보관 기간 (일). 이 기간이 지난 로그는 자동 삭제됩니다. public static int RetentionDays { get; set; } = 3; - /// 상세 워크플로우 이벤트를 기록합니다. - public static void Log(WorkflowLogEntry entry) + // ─── LlmService 등 하위 계층에서 사용할 현재 컨텍스트 ─── + // AgentLoopService가 LLM 호출 직전에 설정하고, 완료 후 리셋합니다. + // AsyncLocal: async/await 전후로 올바르게 전파됨 (ThreadStatic은 continuation 스레드에서 유실) + private static readonly AsyncLocal _ctxConversationId = new(); + private static readonly AsyncLocal _ctxRunId = new(); + private static readonly AsyncLocal _ctxIteration = new(); + + /// 현재 LLM 호출 컨텍스트를 설정합니다 (AgentLoopService에서 호출). + public static void SetCallContext(string conversationId, string runId, int iteration) { - if (!IsEnabled) return; + _ctxConversationId.Value = conversationId; + _ctxRunId.Value = runId; + _ctxIteration.Value = iteration; + } + + /// Raw LLM 통신 로깅 활성화 여부 (요청 JSON + 응답 원문). + public static bool IsRawLogEnabled { get; set; } + + /// 현재 컨텍스트를 사용하여 raw 요청을 기록합니다 (LlmService에서 호출). + public static void LogLlmRawRequestFromContext(string url, string requestBody) + { + if (!IsRawLogEnabled || string.IsNullOrEmpty(_ctxConversationId.Value)) return; + LogLlmRawRequest(_ctxConversationId.Value!, _ctxRunId.Value ?? "", _ctxIteration.Value, url, requestBody); + } + + /// 현재 컨텍스트를 사용하여 raw 응답을 기록합니다 (LlmService에서 호출). + public static void LogLlmRawResponseFromContext(string rawResponse, long elapsedMs) + { + if (!IsRawLogEnabled || string.IsNullOrEmpty(_ctxConversationId.Value)) return; + LogLlmRawResponse(_ctxConversationId.Value!, _ctxRunId.Value ?? "", _ctxIteration.Value, rawResponse, elapsedMs); + } + + /// 상세 워크플로우 이벤트를 기록합니다. + 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 }); } + /// LLM에 보낸 실제 HTTP 요청 body (raw JSON)를 기록합니다. + 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 + { + ["url"] = url, + ["body"] = requestBody, + } + }, bypassEnabledCheck: true); + } + + /// LLM이 돌려준 raw 응답 텍스트를 기록합니다 (SSE 전체 or 단일 JSON). + 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 + { + ["raw"] = Truncate(rawResponse, 20000), + } + }, bypassEnabledCheck: true); + } + /// LLM 응답을 기록합니다. public static void LogLlmResponse(string conversationId, string runId, int iteration, string? textResponse, int toolCallCount, int inputTokens, int outputTokens, long elapsedMs) diff --git a/src/AxCopilot/ViewModels/SettingsViewModel.cs b/src/AxCopilot/ViewModels/SettingsViewModel.cs index f5da364..c9326ed 100644 --- a/src/AxCopilot/ViewModels/SettingsViewModel.cs +++ b/src/AxCopilot/ViewModels/SettingsViewModel.cs @@ -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); } diff --git a/src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs b/src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs new file mode 100644 index 0000000..9d56b72 --- /dev/null +++ b/src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs @@ -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; + +/// +/// 에이전트 이벤트의 무거운 작업(대화 변이·저장)을 백그라운드 스레드에서 처리합니다. +/// UI 스레드는 시각적 피드백만 담당하여 채팅창 멈춤을 방지합니다. +/// +public partial class ChatWindow +{ + /// 백그라운드 처리 대상 이벤트 큐 아이템. + private readonly record struct AgentEventWorkItem( + AgentEvent Event, + string EventTab, + string ActiveTab, + bool ShouldRender); + + private readonly Channel _agentEventChannel = + Channel.CreateUnbounded( + new UnboundedChannelOptions { SingleReader = true, AllowSynchronousContinuations = false }); + + private Task? _agentEventProcessorTask; + + /// 백그라운드 이벤트 프로세서를 시작합니다. 창 초기화 시 한 번 호출됩니다. + private void StartAgentEventProcessor() + { + _agentEventProcessorTask = Task.Run(ProcessAgentEventsAsync); + } + + /// 백그라운드 이벤트 프로세서를 종료합니다. 창 닫기 시 호출됩니다. + private void StopAgentEventProcessor() + { + _agentEventChannel.Writer.TryComplete(); + // 프로세서 완료를 동기 대기하지 않음 — 데드락 방지 + // GC가 나머지를 정리합니다. + } + + /// + /// 에이전트 이벤트를 백그라운드 큐에 추가합니다. + /// 대화 변이(AppendExecutionEvent, AppendAgentRun)와 디스크 저장을 백그라운드에서 처리합니다. + /// + private void EnqueueAgentEventWork(AgentEvent evt, string eventTab, bool shouldRender) + { + _agentEventChannel.Writer.TryWrite(new AgentEventWorkItem(evt, eventTab, _activeTab, shouldRender)); + } + + /// 백그라운드 전용: 채널에서 이벤트를 배치로 읽어 대화 변이 + 저장을 수행합니다. + private async Task ProcessAgentEventsAsync() + { + var reader = _agentEventChannel.Reader; + var persistStopwatch = System.Diagnostics.Stopwatch.StartNew(); + ChatConversation? pendingPersist = null; + var batch = new List(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 { } + } + } +} diff --git a/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs b/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs index 78417b4..4d115a4 100644 --- a/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs +++ b/src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs @@ -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; diff --git a/src/AxCopilot/Views/ChatWindow.StatusPresentation.cs b/src/AxCopilot/Views/ChatWindow.StatusPresentation.cs index f7370bc..ba5635d 100644 --- a/src/AxCopilot/Views/ChatWindow.StatusPresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.StatusPresentation.cs @@ -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 diff --git a/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs b/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs index 5bea8ff..a1baef3 100644 --- a/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs @@ -11,9 +11,28 @@ namespace AxCopilot.Views; public partial class ChatWindow { + // 스트리밍 중 LINQ 재실행 방지용 캐시 + private List? _cachedVisibleMessages; + private int _cachedVisibleMessagesSourceCount = -1; + private List? _cachedVisibleEvents; + private int _cachedVisibleEventsSourceCount = -1; + private bool _cachedVisibleEventsShowHistory; + + private void InvalidateTimelineCache() + { + _cachedVisibleMessages = null; + _cachedVisibleMessagesSourceCount = -1; + _cachedVisibleEvents = null; + _cachedVisibleEventsSourceCount = -1; + } + private List 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(); + + _cachedVisibleMessages = result; + _cachedVisibleMessagesSourceCount = sourceCount; + return result; } private List GetVisibleTimelineEvents(ChatConversation? conversation) { - var events = conversation?.ExecutionEvents?.ToList() ?? new List(); - 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 result; + if (showHistory) + { + result = conversation?.ExecutionEvents?.ToList() ?? new List(); + } + else + { + result = (conversation?.ExecutionEvents ?? Enumerable.Empty()) + .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 visibleMessages, IReadOnlyCollection 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) diff --git a/src/AxCopilot/Views/ChatWindow.xaml b/src/AxCopilot/Views/ChatWindow.xaml index 6590551..a36018f 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml +++ b/src/AxCopilot/Views/ChatWindow.xaml @@ -2035,7 +2035,8 @@ + Visibility="Collapsed" + Margin="-1" IsHitTestVisible="False"> @@ -2048,10 +2049,10 @@ - 1.15 + 1 - + @@ -4481,6 +4482,50 @@ Foreground="{DynamicResource AccentColor}"/> + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -5794,4 +5839,3 @@ - diff --git a/src/AxCopilot/Views/ChatWindow.xaml.cs b/src/AxCopilot/Views/ChatWindow.xaml.cs index 7282bac..a2cd198 100644 --- a/src/AxCopilot/Views/ChatWindow.xaml.cs +++ b/src/AxCopilot/Views/ChatWindow.xaml.cs @@ -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 _elementCache = new(StringComparer.Ordinal); + // 증분 렌더링: 이전 렌더링의 타임라인 키 목록 (변경 감지용) + private List _lastRenderedTimelineKeys = new(); + private int _lastRenderedHiddenCount; + // 스트리밍 중 불필요한 재렌더링 방지용 카운터 + private int _lastRenderedMessageCount; + private int _lastRenderedEventCount; + private bool _lastRenderedShowHistory; private readonly HashSet _sessionPermissionRules = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _sessionMcpEnabledOverrides = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _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 /// 앱 종료 시 창을 실제로 닫습니다. 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 _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(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; + /// 입력창 테두리에 무지개 그라데이션 회전 애니메이션을 재생합니다 (3초). 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 _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; } } diff --git a/src/AxCopilot/Views/DockBarWindow.xaml.cs b/src/AxCopilot/Views/DockBarWindow.xaml.cs index 5240f9a..85fb8bf 100644 --- a/src/AxCopilot/Views/DockBarWindow.xaml.cs +++ b/src/AxCopilot/Views/DockBarWindow.xaml.cs @@ -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)); diff --git a/src/AxCopilot/Views/LauncherWindow.Widgets.cs b/src/AxCopilot/Views/LauncherWindow.Widgets.cs index 7d9658d..2adcb38 100644 --- a/src/AxCopilot/Views/LauncherWindow.Widgets.cs +++ b/src/AxCopilot/Views/LauncherWindow.Widgets.cs @@ -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(); }; } diff --git a/src/AxCopilot/Views/LauncherWindow.xaml.cs b/src/AxCopilot/Views/LauncherWindow.xaml.cs index 5a6259a..54097b0 100644 --- a/src/AxCopilot/Views/LauncherWindow.xaml.cs +++ b/src/AxCopilot/Views/LauncherWindow.xaml.cs @@ -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 += (_, _) => diff --git a/src/AxCopilot/Views/SettingsWindow.xaml b/src/AxCopilot/Views/SettingsWindow.xaml index f6e2264..2606c6c 100644 --- a/src/AxCopilot/Views/SettingsWindow.xaml +++ b/src/AxCopilot/Views/SettingsWindow.xaml @@ -5621,6 +5621,31 @@ + + + + + + + + + + + LLM에 보낸 전체 요청 JSON과 돌아온 응답 원문을 기록합니다. + 도구 미호출 등 문제 분석 시 유용합니다. + 파일 크기가 커질 수 있으므로 디버깅 시에만 사용하세요. + 상세 로그 보관 기간에 따라 자동 삭제됩니다. + + + + + + + + + +