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과 돌아온 응답 원문을 기록합니다.
+ 도구 미호출 등 문제 분석 시 유용합니다.
+ 파일 크기가 커질 수 있으므로 디버깅 시에만 사용하세요.
+ 상세 로그 보관 기간에 따라 자동 삭제됩니다.
+
+
+
+
+
+
+
+
+
+