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

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

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

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

View File

@@ -1492,3 +1492,8 @@ MIT License
- `LlmService`에 tool-use 전용 스트리밍 이벤트 API를 추가했습니다. 이제 OpenAI/vLLM/IBM 경로는 텍스트 델타와 완성된 도구 호출을 각각 이벤트로 내보낼 수 있습니다. - `LlmService`에 tool-use 전용 스트리밍 이벤트 API를 추가했습니다. 이제 OpenAI/vLLM/IBM 경로는 텍스트 델타와 완성된 도구 호출을 각각 이벤트로 내보낼 수 있습니다.
- `Cowork/Code` 루프도 이 스트리밍 이벤트를 직접 소비하도록 바꿔, 도구 호출이 완성되는 즉시 transcript에 `스트리밍 도구 감지` 진행 표시가 보이고 read-only 도구 조기 실행도 실제 실행 루프와 연결되도록 정리했습니다. - `Cowork/Code` 루프도 이 스트리밍 이벤트를 직접 소비하도록 바꿔, 도구 호출이 완성되는 즉시 transcript에 `스트리밍 도구 감지` 진행 표시가 보이고 read-only 도구 조기 실행도 실제 실행 루프와 연결되도록 정리했습니다.
- 기존의 `응답 전체 수신 -> tool_calls 파싱 -> 도구 실행` 구조에서 한 단계 더 나아가, `스트리밍 수신 -> partial tool_call 조립 -> 조기 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 내부 설정은 채팅 입력창 글로우만 담당하도록 역할을 분리했습니다.

View File

@@ -5415,3 +5415,15 @@ ow + toggle ?쒓컖 ?몄뼱濡??ㅼ떆 ?뺣젹?덈떎.
- Cowork/Code 메인 루프가 tool-use streaming event를 직접 소비하게 바꿨다. - Cowork/Code 메인 루프가 tool-use streaming event를 직접 소비하게 바꿨다.
- 텍스트 델타가 쌓이면 450ms 주기로 `Thinking` 이벤트에 축약 preview를 갱신하고, 도구 호출이 완성되면 `스트리밍 도구 감지` 진행 메시지를 즉시 띄우도록 연결했다. - 텍스트 델타가 쌓이면 450ms 주기로 `Thinking` 이벤트에 축약 preview를 갱신하고, 도구 호출이 완성되면 `스트리밍 도구 감지` 진행 메시지를 즉시 띄우도록 연결했다.
- read-only 조기 실행과 최종 실행 재사용 흐름이 기존 loop와 실제로 이어지도록 정리했다. - 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 내부 설정은 채팅 입력창 글로우만 조정하도록 역할을 분리했다.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -453,7 +453,9 @@ public partial class LlmService : IDisposable
{ {
temperature = ResolveTemperature(), temperature = ResolveTemperature(),
max_new_tokens = ResolveOpenAiCompatibleMaxTokens() 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) 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; var message = choices[0].TryGetProperty("message", out var choiceMessage) ? choiceMessage : default;
if (message.ValueKind == JsonValueKind.Object && if (message.ValueKind == JsonValueKind.Object)
message.TryGetProperty("content", out var content)) {
return content.GetString() ?? ""; 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) 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) if (doc.RootElement.TryGetProperty("choices", out var ch) && ch.GetArrayLength() > 0)
{ {
var first = ch[0]; var first = ch[0];
if (first.TryGetProperty("delta", out var delta) if (first.TryGetProperty("delta", out var delta))
&& delta.TryGetProperty("content", out var cnt))
{ {
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; } if (!string.IsNullOrEmpty(txt)) { sb.Append(txt); collectingChunks = true; }
} }
else if (first.TryGetProperty("message", out _)) else if (first.TryGetProperty("message", out _))
@@ -965,6 +984,8 @@ public partial class LlmService : IDisposable
var delta = ibmChoices[0].GetProperty("delta"); var delta = ibmChoices[0].GetProperty("delta");
if (delta.TryGetProperty("content", out var c)) if (delta.TryGetProperty("content", out var c))
text = c.GetString(); text = c.GetString();
if (string.IsNullOrEmpty(text) && delta.TryGetProperty("reasoning_content", out var rc))
text = rc.GetString();
} }
} }
else else
@@ -975,6 +996,8 @@ public partial class LlmService : IDisposable
var delta = choices[0].GetProperty("delta"); var delta = choices[0].GetProperty("delta");
if (delta.TryGetProperty("content", out var c)) if (delta.TryGetProperty("content", out var c))
text = c.GetString(); text = c.GetString();
if (string.IsNullOrEmpty(text) && delta.TryGetProperty("reasoning_content", out var rc2))
text = rc2.GetString();
} }
} }
} }

View File

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

View File

@@ -33,7 +33,7 @@ internal sealed class ServerStatusService
if (_timer != null) if (_timer != null)
return; 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() public void Stop()

View File

@@ -162,13 +162,44 @@ public class SettingsService
return string.Compare(current, target, StringComparison.Ordinal) < 0; return string.Compare(current, target, StringComparison.Ordinal) < 0;
} }
private static readonly object _saveLock = new();
public void Save() public void Save()
{ {
EnsureDirectories(); EnsureDirectories();
NormalizeRuntimeSettings(); NormalizeRuntimeSettings();
var json = JsonSerializer.Serialize(_settings, JsonOptions); var json = JsonSerializer.Serialize(_settings, JsonOptions);
var encrypted = CryptoService.PortableEncrypt(json); 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); SettingsChanged?.Invoke(this, EventArgs.Empty);
} }

View File

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

View File

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

View File

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

View File

@@ -38,6 +38,7 @@ public partial class ChatWindow
bool liveWaitingStyle = false) bool liveWaitingStyle = false)
{ {
var liveAccentColor = ResolveLiveProgressAccentColor(accentBrush); var liveAccentColor = ResolveLiveProgressAccentColor(accentBrush);
var pillMaxWidth = GetMessageMaxWidth();
return new Border return new Border
{ {
Background = liveWaitingStyle Background = liveWaitingStyle
@@ -50,7 +51,8 @@ public partial class ChatWindow
CornerRadius = new CornerRadius(10), CornerRadius = new CornerRadius(10),
Padding = new Thickness(10, 8, 10, 8), Padding = new Thickness(10, 8, 10, 8),
Margin = new Thickness(12, 6, 12, 2), Margin = new Thickness(12, 6, 12, 2),
HorizontalAlignment = HorizontalAlignment.Stretch, HorizontalAlignment = HorizontalAlignment.Center,
MaxWidth = pillMaxWidth,
Child = new Grid Child = new Grid
{ {
ColumnDefinitions = ColumnDefinitions =
@@ -176,8 +178,11 @@ public partial class ChatWindow
if (string.IsNullOrWhiteSpace(summary)) if (string.IsNullOrWhiteSpace(summary))
summary = transcriptBadgeLabel; summary = transcriptBadgeLabel;
var msgMaxWidth = GetMessageMaxWidth();
var stack = new StackPanel var stack = new StackPanel
{ {
HorizontalAlignment = HorizontalAlignment.Center,
MaxWidth = msgMaxWidth,
Margin = new Thickness(0), Margin = new Thickness(0),
}; };
@@ -206,14 +211,7 @@ public partial class ChatWindow
stack.Children.Add(bodyBlock); 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)) if (!string.IsNullOrWhiteSpace(evt.FilePath))
{ {
@@ -1196,6 +1194,7 @@ public partial class ChatWindow
var borderColor = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0"); var borderColor = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0");
var accentBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(fgHex)); var accentBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(fgHex));
var bannerMaxWidth = GetMessageMaxWidth();
var banner = new Border var banner = new Border
{ {
Background = hintBg, Background = hintBg,
@@ -1204,7 +1203,8 @@ public partial class ChatWindow
CornerRadius = new CornerRadius(10), CornerRadius = new CornerRadius(10),
Padding = new Thickness(9, 7, 9, 7), Padding = new Thickness(9, 7, 9, 7),
Margin = new Thickness(12, 3, 12, 3), Margin = new Thickness(12, 3, 12, 3),
HorizontalAlignment = HorizontalAlignment.Stretch, HorizontalAlignment = HorizontalAlignment.Center,
MaxWidth = bannerMaxWidth,
}; };
if (!string.IsNullOrWhiteSpace(evt.RunId)) if (!string.IsNullOrWhiteSpace(evt.RunId))
_runBannerAnchors[evt.RunId] = banner; _runBannerAnchors[evt.RunId] = banner;

View File

@@ -23,10 +23,27 @@ public partial class ChatWindow
_sortConversationsByRecent); _sortConversationsByRecent);
} }
private string? _lastTaskSummaryRuntimeLabel;
private string? _lastTaskSummaryStripText;
private bool _lastTaskSummaryShowBadge;
private bool _lastTaskSummaryShowStrip;
private void UpdateTaskSummaryIndicators() private void UpdateTaskSummaryIndicators()
{ {
var status = BuildOperationalStatusPresentation(); 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) if (RuntimeActivityBadge != null)
RuntimeActivityBadge.Visibility = status.ShowRuntimeBadge RuntimeActivityBadge.Visibility = status.ShowRuntimeBadge
? Visibility.Visible ? Visibility.Visible

View File

@@ -11,9 +11,28 @@ namespace AxCopilot.Views;
public partial class ChatWindow public partial class ChatWindow
{ {
// 스트리밍 중 LINQ 재실행 방지용 캐시
private List<ChatMessage>? _cachedVisibleMessages;
private int _cachedVisibleMessagesSourceCount = -1;
private List<ChatExecutionEvent>? _cachedVisibleEvents;
private int _cachedVisibleEventsSourceCount = -1;
private bool _cachedVisibleEventsShowHistory;
private void InvalidateTimelineCache()
{
_cachedVisibleMessages = null;
_cachedVisibleMessagesSourceCount = -1;
_cachedVisibleEvents = null;
_cachedVisibleEventsSourceCount = -1;
}
private List<ChatMessage> GetVisibleTimelineMessages(ChatConversation? conversation) private List<ChatMessage> GetVisibleTimelineMessages(ChatConversation? conversation)
{ {
return conversation?.Messages?.Where(msg => var sourceCount = conversation?.Messages?.Count ?? 0;
if (_cachedVisibleMessages != null && sourceCount == _cachedVisibleMessagesSourceCount)
return _cachedVisibleMessages;
var result = conversation?.Messages?.Where(msg =>
{ {
if (string.Equals(msg.Role, "system", StringComparison.OrdinalIgnoreCase)) if (string.Equals(msg.Role, "system", StringComparison.OrdinalIgnoreCase))
return false; return false;
@@ -24,15 +43,36 @@ public partial class ChatWindow
return true; return true;
}).ToList() ?? new List<ChatMessage>(); }).ToList() ?? new List<ChatMessage>();
_cachedVisibleMessages = result;
_cachedVisibleMessagesSourceCount = sourceCount;
return result;
} }
private List<ChatExecutionEvent> GetVisibleTimelineEvents(ChatConversation? conversation) private List<ChatExecutionEvent> GetVisibleTimelineEvents(ChatConversation? conversation)
{ {
var events = conversation?.ExecutionEvents?.ToList() ?? new List<ChatExecutionEvent>(); var sourceCount = conversation?.ExecutionEvents?.Count ?? 0;
if (conversation?.ShowExecutionHistory ?? true) var showHistory = conversation?.ShowExecutionHistory ?? true;
return events; if (_cachedVisibleEvents != null
&& sourceCount == _cachedVisibleEventsSourceCount
&& showHistory == _cachedVisibleEventsShowHistory)
return _cachedVisibleEvents;
return events.Where(ShouldShowCollapsedProgressEvent).ToList(); List<ChatExecutionEvent> result;
if (showHistory)
{
result = conversation?.ExecutionEvents?.ToList() ?? new List<ChatExecutionEvent>();
}
else
{
result = (conversation?.ExecutionEvents ?? Enumerable.Empty<ChatExecutionEvent>())
.Where(ShouldShowCollapsedProgressEvent).ToList();
}
_cachedVisibleEvents = result;
_cachedVisibleEventsSourceCount = sourceCount;
_cachedVisibleEventsShowHistory = showHistory;
return result;
} }
private static bool ShouldShowCollapsedProgressEvent(ChatExecutionEvent executionEvent) 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 "csv_create" or "markdown_create" or "md_create" or "script_create"
or "pptx_create"; or "pptx_create";
private List<(DateTime Timestamp, int Order, Action Render)> BuildTimelineRenderActions( private List<(string Key, DateTime Timestamp, int Order, Action Render)> BuildTimelineRenderActions(
IReadOnlyCollection<ChatMessage> visibleMessages, IReadOnlyCollection<ChatMessage> visibleMessages,
IReadOnlyCollection<ChatExecutionEvent> visibleEvents) IReadOnlyCollection<ChatExecutionEvent> visibleEvents)
{ {
var timeline = new List<(DateTime Timestamp, int Order, Action Render)>(visibleMessages.Count + visibleEvents.Count); var timeline = new List<(string Key, DateTime Timestamp, int Order, Action Render)>(visibleMessages.Count + visibleEvents.Count);
foreach (var msg in visibleMessages) foreach (var msg in visibleMessages)
{ {
var capturedMsg = msg; var capturedMsg = msg;
var cacheKey = $"m_{msg.MsgId}"; var cacheKey = $"m_{msg.MsgId}";
timeline.Add((msg.Timestamp, 0, () => timeline.Add((cacheKey, msg.Timestamp, 0, () =>
{ {
// 캐시된 버블이 있으면 재생성 없이 재사용 (O(1) Add vs 전체 재생성) // 캐시된 버블이 있으면 재생성 없이 재사용 (O(1) Add vs 전체 재생성)
if (_elementCache.TryGetValue(cacheKey, out var cached)) if (_elementCache.TryGetValue(cacheKey, out var cached))
@@ -88,6 +128,9 @@ public partial class ChatWindow
var showFullHistory = _currentConversation?.ShowExecutionHistory ?? true; var showFullHistory = _currentConversation?.ShowExecutionHistory ?? true;
var activeRunId = _isStreaming ? (_appState.AgentRun.RunId ?? "") : ""; var activeRunId = _isStreaming ? (_appState.AgentRun.RunId ?? "") : "";
var eventIndex = 0;
string? prevToolCallName = null;
int consecutiveToolCallCount = 0;
foreach (var executionEvent in visibleEvents) foreach (var executionEvent in visibleEvents)
{ {
// 스트리밍 중이고 히스토리 접힘 상태일 때, 현재 run의 process feed 이벤트는 통합 카드에서 표시 // 스트리밍 중이고 히스토리 접힘 상태일 때, 현재 run의 process feed 이벤트는 통합 카드에서 표시
@@ -101,7 +144,28 @@ public partial class ChatWindow
} }
var restoredEvent = ToAgentEvent(executionEvent); 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 대체) // 스트리밍 중 + 히스토리 접힘: 통합 진행 카드 삽입 (개별 pill 대체)
@@ -109,14 +173,32 @@ public partial class ChatWindow
{ {
var capturedSteps = _currentRunProgressSteps.ToList(); var capturedSteps = _currentRunProgressSteps.ToList();
var cardTimestamp = capturedSteps[^1].Timestamp; var cardTimestamp = capturedSteps[^1].Timestamp;
timeline.Add((cardTimestamp, 1, () => AddLiveRunProgressCard(capturedSteps))); // 통합 진행 카드는 매번 내용이 바뀌므로 volatile 키 사용
timeline.Add(("_live_progress", cardTimestamp, 1, () => AddLiveRunProgressCard(capturedSteps)));
} }
var liveProgressHint = GetLiveAgentProgressHint(); var liveProgressHint = GetLiveAgentProgressHint();
if (liveProgressHint != null) 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) private Border CreateTimelineLoadMoreCard(int hiddenCount)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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