AX Agent 진행 시간·글로우 경로 정리 및 최근 로컬 변경 일괄 반영
- AX Agent 스트리밍 경과 시간을 공용 helper로 통일해 비정상적인 수천만 시간 표시를 방지함 - 채팅 입력창 글로우를 런처와 같은 표시/숨김 중심의 얇은 외곽 글로우로 정리하고 런처 글로우 설정은 일반 설정에 유지함 - README와 DEVELOPMENT 문서를 2026-04-08 12:02 (KST) 기준으로 갱신하고 Release 빌드 경고 0 / 오류 0을 확인함
This commit is contained in:
@@ -79,6 +79,7 @@ public partial class App : System.Windows.Application
|
||||
WorkflowLogService.IsEnabled = settings.Settings.Llm.EnableDetailedLog;
|
||||
WorkflowLogService.RetentionDays = settings.Settings.Llm.DetailedLogRetentionDays > 0
|
||||
? settings.Settings.Llm.DetailedLogRetentionDays : 3;
|
||||
WorkflowLogService.IsRawLogEnabled = settings.Settings.Llm.EnableRawLlmLog;
|
||||
|
||||
// ─── 대화 보관/디스크 정리 (제품화 하드닝) ───────────────────────────
|
||||
try
|
||||
|
||||
@@ -1003,6 +1003,10 @@ public class LlmSettings
|
||||
[JsonPropertyName("detailedLogRetentionDays")]
|
||||
public int DetailedLogRetentionDays { get; set; } = 3;
|
||||
|
||||
/// <summary>LLM에 보낸 요청 JSON과 돌아온 응답 원문을 모두 기록합니다. 디버깅용이며 파일 크기가 클 수 있습니다.</summary>
|
||||
[JsonPropertyName("enableRawLlmLog")]
|
||||
public bool EnableRawLlmLog { get; set; } = false;
|
||||
|
||||
/// <summary>에이전트 메모리 (지속적 학습) 활성화. 기본 true.</summary>
|
||||
[JsonPropertyName("enableAgentMemory")]
|
||||
public bool EnableAgentMemory { get; set; } = true;
|
||||
|
||||
@@ -539,14 +539,15 @@ public partial class AgentLoopService
|
||||
sendMessages = [.. messages, new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = "[TOOL_REQUIRED] 지금 즉시 도구를 1개 이상 호출하세요. 텍스트만 반환하면 거부됩니다. " +
|
||||
"Call at least one tool RIGHT NOW. Text-only response is rejected."
|
||||
Content = "[TOOL_REQUIRED] 지금 즉시 <tool_call> 형식으로 도구를 호출하세요. 텍스트만 반환하면 거부됩니다.\n" +
|
||||
"Output format:\n<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>"
|
||||
}];
|
||||
}
|
||||
|
||||
// 워크플로우 상세 로그: LLM 요청
|
||||
llmCallSw.Restart();
|
||||
var (_, currentModel) = _llm.GetCurrentModelInfo();
|
||||
WorkflowLogService.SetCallContext(_conversationId, _currentRunId, iteration);
|
||||
WorkflowLogService.LogLlmRequest(_conversationId, _currentRunId, iteration,
|
||||
currentModel, sendMessages.Count, activeTools.Count, forceFirst);
|
||||
var streamedTextPreview = new StringBuilder();
|
||||
@@ -807,17 +808,14 @@ public partial class AgentLoopService
|
||||
"[System:ToolCallRequired] " +
|
||||
"⚠ 경고: 이전 응답에서 도구를 호출하지 않았습니다. " +
|
||||
"텍스트 설명만 반환하는 것은 허용되지 않습니다. " +
|
||||
"지금 즉시 도구를 1개 이상 호출하세요. " +
|
||||
"할 말이 있다면 도구 호출 이후에 하세요 — 도구 호출 전 설명 금지. " +
|
||||
"한 응답에서 여러 도구를 동시에 호출할 수 있고, 그렇게 해야 합니다. " +
|
||||
$"지금 사용 가능한 도구: {activeToolPreview}",
|
||||
"지금 즉시 아래 형식으로 도구를 호출하세요:\n" +
|
||||
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
|
||||
$"사용 가능한 도구: {activeToolPreview}",
|
||||
_ =>
|
||||
"[System:ToolCallRequired] " +
|
||||
"🚨 최종 경고: 도구를 계속 호출하지 않고 있습니다. 이것이 마지막 기회입니다. " +
|
||||
"지금 응답은 반드시 도구 호출만 포함해야 합니다. 텍스트는 한 글자도 쓰지 마세요. " +
|
||||
"작업을 완료하려면 도구를 호출하는 것 외에 다른 방법이 없습니다. " +
|
||||
"도구 이름을 모른다면 아래 목록에서 골라 즉시 호출하세요. " +
|
||||
"여러 도구를 한꺼번에 호출할 수 있습니다 — 지금 그렇게 하세요. " +
|
||||
"텍스트는 한 글자도 쓰지 마세요. 반드시 아래 형식으로 도구를 호출하세요:\n" +
|
||||
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
|
||||
$"반드시 사용해야 할 도구 목록: {activeToolPreview}"
|
||||
};
|
||||
messages.Add(new ChatMessage { Role = "user", Content = recoveryContent });
|
||||
@@ -850,15 +848,13 @@ public partial class AgentLoopService
|
||||
{
|
||||
1 =>
|
||||
"[System:ToolCallRequired] 계획을 세웠지만 도구를 호출하지 않았습니다. " +
|
||||
"계획은 이미 수립되었으므로 지금 당장 실행 단계로 넘어가세요. " +
|
||||
"텍스트 설명 없이 계획의 첫 번째 단계를 도구(tool call)로 즉시 실행하세요. " +
|
||||
"한 응답에서 여러 도구를 동시에 호출할 수 있습니다. " +
|
||||
"지금 당장 실행하세요. 아래 형식으로 도구를 호출하세요:\n" +
|
||||
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
|
||||
$"사용 가능한 도구: {planToolList}",
|
||||
_ =>
|
||||
"[System:ToolCallRequired] 🚨 도구 호출 없이 계획만 반복하고 있습니다. " +
|
||||
"이제 계획 설명은 완전히 금지됩니다. 오직 도구 호출만 하세요. " +
|
||||
"지금 이 응답에 텍스트를 포함하지 마세요. 도구만 호출하세요. " +
|
||||
"독립적인 작업은 한 번에 여러 도구를 병렬 호출하세요. " +
|
||||
"텍스트를 한 글자도 쓰지 마세요. 오직 아래 형식의 도구 호출만 출력하세요:\n" +
|
||||
"<tool_call>\n{\"name\": \"TOOL_NAME\", \"arguments\": {\"param\": \"value\"}}\n</tool_call>\n" +
|
||||
$"사용 가능한 도구: {planToolList}"
|
||||
};
|
||||
messages.Add(new ChatMessage { Role = "user", Content = planRecoveryContent });
|
||||
@@ -884,7 +880,8 @@ public partial class AgentLoopService
|
||||
messages.Add(new ChatMessage { Role = "user",
|
||||
Content = "html_create 도구를 호출하지 않았습니다. " +
|
||||
"document_plan 결과의 body 골격을 바탕으로 각 섹션에 충분한 내용을 채워서 " +
|
||||
"html_create 도구를 지금 즉시 호출하세요. 설명 없이 도구 호출만 하세요." });
|
||||
"지금 즉시 아래 형식으로 호출하세요:\n" +
|
||||
"<tool_call>\n{\"name\": \"html_create\", \"arguments\": {\"file_name\": \"...\", \"html_body\": \"...\"}}\n</tool_call>" });
|
||||
EmitEvent(AgentEventType.Thinking, "", $"html_create 미호출 재시도 {postDocumentPlanRetry}/{documentPlanRetryMax}...");
|
||||
continue; // 루프 재시작
|
||||
}
|
||||
@@ -1475,6 +1472,26 @@ public partial class AgentLoopService
|
||||
{
|
||||
failedToolHistogram.TryGetValue(effectiveCall.ToolName, out var failedCount);
|
||||
failedToolHistogram[effectiveCall.ToolName] = failedCount + 1;
|
||||
|
||||
// 같은 도구가 5회 이상 실패하면 해당 도구를 포기하고 LLM에 알림
|
||||
if (failedCount + 1 >= 5)
|
||||
{
|
||||
var abortMsg = $"도구 '{effectiveCall.ToolName}'이(가) {failedCount + 1}회 실패했습니다. 이 도구를 더 이상 호출하지 마세요. 다른 방법을 시도하거나 사용자에게 결과를 보고하세요.";
|
||||
EmitEvent(AgentEventType.Error, effectiveCall.ToolName, abortMsg);
|
||||
messages.Add(LlmService.CreateToolResultMessage(
|
||||
effectiveCall.ToolId, effectiveCall.ToolName, abortMsg));
|
||||
messages.Add(new ChatMessage { Role = "user", Content = abortMsg });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 전체 실패 횟수가 총 도구 호출의 60% 이상이면 조기 중단
|
||||
var totalFails = failedToolHistogram.Values.Sum();
|
||||
if (totalToolCalls > 6 && totalFails > totalToolCalls * 0.6)
|
||||
{
|
||||
EmitEvent(AgentEventType.Error, "",
|
||||
$"전체 도구 호출 중 실패율이 높아 작업을 중단합니다 (실패 {totalFails}/{totalToolCalls})");
|
||||
return $"도구 실행 실패율이 높아 작업을 중단했습니다. {totalFails}개 실패 / {totalToolCalls}개 호출. 요청을 다시 시도하거나 작업 방식을 변경해 주세요.";
|
||||
}
|
||||
}
|
||||
|
||||
// UI 스레드가 이벤트를 렌더링할 시간 확보
|
||||
|
||||
@@ -36,17 +36,16 @@ public class ClipboardTool : IAgentTool
|
||||
Required = ["action"],
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var action = args.GetProperty("action").GetString() ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
// 클립보드는 STA 스레드에서만 접근 가능
|
||||
ToolResult? result = null;
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
// 클립보드는 STA 스레드에서만 접근 가능 — InvokeAsync로 UI 스레드 블로킹 방지
|
||||
var result = await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
result = action switch
|
||||
return action switch
|
||||
{
|
||||
"read" => ReadClipboard(),
|
||||
"write" => WriteClipboard(args),
|
||||
@@ -55,11 +54,11 @@ public class ClipboardTool : IAgentTool
|
||||
_ => ToolResult.Fail($"Unknown action: {action}"),
|
||||
};
|
||||
});
|
||||
return Task.FromResult(result ?? ToolResult.Fail("클립보드 접근 실패"));
|
||||
return result ?? ToolResult.Fail("클립보드 접근 실패");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"클립보드 오류: {ex.Message}"));
|
||||
return ToolResult.Fail($"클립보드 오류: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ public class NotifyTool : IAgentTool
|
||||
Required = ["title", "message"],
|
||||
};
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
public async Task<ToolResult> ExecuteAsync(JsonElement args, AgentContext context, CancellationToken ct = default)
|
||||
{
|
||||
var title = args.GetProperty("title").GetString() ?? "알림";
|
||||
var message = args.GetProperty("message").GetString() ?? "";
|
||||
@@ -51,15 +51,16 @@ public class NotifyTool : IAgentTool
|
||||
|
||||
try
|
||||
{
|
||||
Application.Current.Dispatcher.Invoke(() =>
|
||||
// InvokeAsync로 변경 — Dispatcher.Invoke는 UI 스레드가 _convLock 대기 중일 때 데드락 발생
|
||||
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||
{
|
||||
ShowToast(title, message, level);
|
||||
});
|
||||
return Task.FromResult(ToolResult.Ok($"✓ Notification sent: [{level}] {title}"));
|
||||
return ToolResult.Ok($"✓ Notification sent: [{level}] {title}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Task.FromResult(ToolResult.Fail($"알림 전송 실패: {ex.Message}"));
|
||||
return ToolResult.Fail($"알림 전송 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -527,6 +527,9 @@ public partial class LlmService
|
||||
url = endpoint.TrimEnd('/') + "/v1/chat/completions";
|
||||
var json = JsonSerializer.Serialize(body);
|
||||
|
||||
// Raw 요청 로깅 (상세 로그 활성 시)
|
||||
WorkflowLogService.LogLlmRawRequestFromContext(url, json);
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
@@ -546,6 +549,7 @@ public partial class LlmService
|
||||
LogService.Warn("[ToolUse] IBM 배포형 경로에서 tool_choice가 거부되어 대체 강제 전략으로 재시도합니다.");
|
||||
var fallbackBody = BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false);
|
||||
var fallbackJson = JsonSerializer.Serialize(fallbackBody);
|
||||
WorkflowLogService.LogLlmRawRequestFromContext(url, fallbackJson);
|
||||
using var retryReq = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = new StringContent(fallbackJson, Encoding.UTF8, "application/json")
|
||||
@@ -574,29 +578,35 @@ public partial class LlmService
|
||||
/// 2. Qwen3 <tool_call>\n{"name":"...", "arguments":{...}}\n</tool_call>
|
||||
/// 3. 여러 건의 연속 tool_call 태그
|
||||
/// </summary>
|
||||
// ── 텍스트 폴백 파싱용 정규식 (static 캐싱 — 매 호출 재생성 방지) ──
|
||||
private static readonly System.Text.RegularExpressions.Regex ToolCallTagRegex = new(
|
||||
@"<\s*tool_call\s*>\s*([\s\S]*?)\s*<\s*/\s*tool_call\s*>",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);
|
||||
|
||||
private static readonly System.Text.RegularExpressions.Regex ToolCallFunctionRegex = new(
|
||||
@"✿FUNCTION✿\s*(\w+)\s*\n\s*(\{[\s\S]*?\})\s*(?:✿|$)",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.Compiled);
|
||||
|
||||
private static readonly System.Text.RegularExpressions.Regex ToolCallJsonRegex = new(
|
||||
@"\{\s*""name""\s*:\s*""(\w+)""\s*,\s*""arguments""\s*:\s*(\{[\s\S]*?\})\s*\}",
|
||||
System.Text.RegularExpressions.RegexOptions.Compiled);
|
||||
|
||||
private static List<ContentBlock> TryExtractToolCallsFromText(string text)
|
||||
{
|
||||
var results = new List<ContentBlock>();
|
||||
if (string.IsNullOrWhiteSpace(text)) return results;
|
||||
|
||||
// 패턴 1: <tool_call>...</tool_call> 태그 (Qwen 계열 기본 출력)
|
||||
var tagPattern = new System.Text.RegularExpressions.Regex(
|
||||
@"<\s*tool_call\s*>\s*(\{[\s\S]*?\})\s*<\s*/\s*tool_call\s*>",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
foreach (System.Text.RegularExpressions.Match m in tagPattern.Matches(text))
|
||||
foreach (System.Text.RegularExpressions.Match m in ToolCallTagRegex.Matches(text))
|
||||
{
|
||||
var block = TryParseToolCallJson(m.Groups[1].Value);
|
||||
if (block != null) results.Add(block);
|
||||
}
|
||||
|
||||
// 패턴 2: ✿FUNCTION✿ 또는 <|tool_call|> (일부 Qwen 변형)
|
||||
// 패턴 2: ✿FUNCTION✿ (일부 Qwen 변형)
|
||||
if (results.Count == 0)
|
||||
{
|
||||
var fnPattern = new System.Text.RegularExpressions.Regex(
|
||||
@"✿FUNCTION✿\s*(\w+)\s*\n\s*(\{[\s\S]*?\})\s*(?:✿|$)",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
foreach (System.Text.RegularExpressions.Match m in fnPattern.Matches(text))
|
||||
foreach (System.Text.RegularExpressions.Match m in ToolCallFunctionRegex.Matches(text))
|
||||
{
|
||||
var block = TryParseToolCallJsonWithName(m.Groups[1].Value, m.Groups[2].Value);
|
||||
if (block != null) results.Add(block);
|
||||
@@ -606,9 +616,7 @@ public partial class LlmService
|
||||
// 패턴 3: JSON 객체가 직접 출력된 경우 ({"name":"tool_name","arguments":{...}})
|
||||
if (results.Count == 0)
|
||||
{
|
||||
var jsonPattern = new System.Text.RegularExpressions.Regex(
|
||||
@"\{\s*""name""\s*:\s*""(\w+)""\s*,\s*""arguments""\s*:\s*(\{[\s\S]*?\})\s*\}");
|
||||
foreach (System.Text.RegularExpressions.Match m in jsonPattern.Matches(text))
|
||||
foreach (System.Text.RegularExpressions.Match m in ToolCallJsonRegex.Matches(text))
|
||||
{
|
||||
var block = TryParseToolCallJsonWithName(m.Groups[1].Value, m.Groups[2].Value);
|
||||
if (block != null) results.Add(block);
|
||||
@@ -623,6 +631,12 @@ public partial class LlmService
|
||||
{
|
||||
try
|
||||
{
|
||||
json = json.Trim();
|
||||
// <tool_call> 태그 내용에서 JSON 객체 부분만 추출 (앞뒤 비-JSON 텍스트 제거)
|
||||
var braceStart = json.IndexOf('{');
|
||||
var braceEnd = json.LastIndexOf('}');
|
||||
if (braceStart >= 0 && braceEnd > braceStart)
|
||||
json = json[braceStart..(braceEnd + 1)];
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
var name = root.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||
@@ -642,7 +656,11 @@ public partial class LlmService
|
||||
ToolInput = args,
|
||||
};
|
||||
}
|
||||
catch { return null; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Services.LogService.Debug($"[ToolUse] tool_call JSON 파싱 실패: {ex.Message} | 원본: {(json.Length > 200 ? json[..200] + "…" : json)}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>이름과 arguments JSON이 별도로 주어진 경우.</summary>
|
||||
@@ -660,7 +678,11 @@ public partial class LlmService
|
||||
ToolInput = doc.RootElement.Clone(),
|
||||
};
|
||||
}
|
||||
catch { return null; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Services.LogService.Debug($"[ToolUse] tool_call JSON 파싱 실패 (name={name}): {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private object BuildOpenAiToolBody(List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, bool forceToolCall = false)
|
||||
@@ -818,10 +840,28 @@ public partial class LlmService
|
||||
string.Equals(executionPolicy.Key, "tool_call_strict", StringComparison.OrdinalIgnoreCase);
|
||||
var msgs = new List<object>();
|
||||
|
||||
// 시스템 프롬프트
|
||||
// 시스템 프롬프트 + IBM/vLLM 도구 호출 가이드 주입
|
||||
var systemPrompt = messages.FirstOrDefault(m => m.Role == "system")?.Content ?? _systemPrompt;
|
||||
if (!string.IsNullOrWhiteSpace(systemPrompt))
|
||||
msgs.Add(new { role = "system", content = systemPrompt });
|
||||
|
||||
// tools 이름 목록을 시스템 프롬프트에 직접 삽입 → Qwen이 도구 이름을 확실히 인식
|
||||
var toolNameList = string.Join(", ", tools.Select(t => t.Name));
|
||||
var toolCallGuidance =
|
||||
"\n\n[Tool Calling Instructions]\n" +
|
||||
"You have access to the following tools: " + toolNameList + "\n" +
|
||||
"When the user's request requires action, you MUST call a tool. NEVER describe what you would do — call the tool directly.\n\n" +
|
||||
"To call a tool, output EXACTLY this format (one per tool):\n" +
|
||||
"<tool_call>\n" +
|
||||
"{\"name\": \"TOOL_NAME\", \"arguments\": {\"param1\": \"value1\"}}\n" +
|
||||
"</tool_call>\n\n" +
|
||||
"Rules:\n" +
|
||||
"- You MUST call at least one tool for every user request that requires action.\n" +
|
||||
"- Do NOT explain what you plan to do. Do NOT say \"I will\" or \"Let me\". Just output <tool_call> immediately.\n" +
|
||||
"- If multiple tools are needed, output multiple <tool_call> blocks.\n" +
|
||||
"- After receiving tool results, use them to answer the user.\n";
|
||||
var fullSystemPrompt = string.IsNullOrWhiteSpace(systemPrompt)
|
||||
? toolCallGuidance.TrimStart()
|
||||
: systemPrompt + toolCallGuidance;
|
||||
msgs.Add(new { role = "system", content = fullSystemPrompt });
|
||||
|
||||
foreach (var m in messages)
|
||||
{
|
||||
@@ -893,7 +933,7 @@ public partial class LlmService
|
||||
msgs.Add(new
|
||||
{
|
||||
role = "user",
|
||||
content = "[TOOL_ONLY] 설명하지 말고 지금 즉시 tools 중 하나 이상을 호출하세요. 평문 응답 금지. 도구 호출만 하세요."
|
||||
content = "[TOOL_ONLY] 설명하지 말고 지금 즉시 <tool_call> 형식으로 도구를 호출하세요. 평문 응답은 거부됩니다.\nExample: <tool_call>\n{\"name\": \"tool_name\", \"arguments\": {\"key\": \"value\"}}\n</tool_call>"
|
||||
});
|
||||
}
|
||||
|
||||
@@ -923,8 +963,9 @@ public partial class LlmService
|
||||
}).ToArray();
|
||||
|
||||
// IBM watsonx: parameters 래퍼 사용, model 필드 없음
|
||||
// tool_choice: "required" 지원 여부는 배포 버전마다 다를 수 있으므로
|
||||
// forceToolCall=true일 때 추가하되, 오류 시 상위에서 ToolCallNotSupportedException으로 폴백됨
|
||||
// tool_choice / tool_choice_option: IBM 버전에 따라 필드명이 다를 수 있으므로 양쪽 모두 전송
|
||||
// 오류 시 상위에서 ToolCallNotSupportedException으로 폴백됨
|
||||
// Qwen3.5 thinking 모드 비활성화: 활성화되면 content=null, reasoning_content에만 출력됨
|
||||
if (forceToolCall && useToolChoice)
|
||||
{
|
||||
return new
|
||||
@@ -932,11 +973,13 @@ public partial class LlmService
|
||||
messages = msgs,
|
||||
tools = toolDefs,
|
||||
tool_choice = "required",
|
||||
tool_choice_option = "required",
|
||||
parameters = new
|
||||
{
|
||||
temperature = ResolveToolTemperature(),
|
||||
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
|
||||
}
|
||||
},
|
||||
chat_template_kwargs = new { enable_thinking = false },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -948,7 +991,8 @@ public partial class LlmService
|
||||
{
|
||||
temperature = ResolveToolTemperature(),
|
||||
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
|
||||
}
|
||||
},
|
||||
chat_template_kwargs = new { enable_thinking = false },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -969,19 +1013,28 @@ public partial class LlmService
|
||||
{
|
||||
var blocks = new List<ContentBlock>();
|
||||
var textBuilder = new StringBuilder();
|
||||
var rawSseBuilder = WorkflowLogService.IsRawLogEnabled ? new StringBuilder() : null;
|
||||
var rawSw = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
await foreach (var evt in StreamOpenAiToolEventsAsync(resp, usesIbmDeploymentApi, prefetchToolCallAsync, ct).WithCancellation(ct))
|
||||
{
|
||||
if (evt.Kind == ToolStreamEventKind.TextDelta && !string.IsNullOrWhiteSpace(evt.Text))
|
||||
{
|
||||
textBuilder.Append(evt.Text);
|
||||
rawSseBuilder?.Append("[text] ").AppendLine(evt.Text);
|
||||
}
|
||||
else if (evt.Kind == ToolStreamEventKind.ToolCallReady && evt.ToolCall != null)
|
||||
{
|
||||
blocks.Add(evt.ToolCall);
|
||||
rawSseBuilder?.Append("[tool_call] ").Append(evt.ToolCall.ToolName)
|
||||
.Append(' ').AppendLine(evt.ToolCall.ToolInput?.GetRawText() ?? "{}");
|
||||
}
|
||||
}
|
||||
|
||||
// Raw 응답 로깅
|
||||
if (rawSseBuilder != null && rawSseBuilder.Length > 0)
|
||||
WorkflowLogService.LogLlmRawResponseFromContext(rawSseBuilder.ToString(), rawSw.ElapsedMilliseconds);
|
||||
|
||||
var text = textBuilder.ToString().Trim();
|
||||
var result = new List<ContentBlock>();
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
@@ -1037,6 +1090,7 @@ public partial class LlmService
|
||||
else
|
||||
url = endpoint.TrimEnd('/') + "/v1/chat/completions";
|
||||
var json = JsonSerializer.Serialize(body);
|
||||
WorkflowLogService.LogLlmRawRequestFromContext(url, json);
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
@@ -1052,6 +1106,7 @@ public partial class LlmService
|
||||
{
|
||||
var fallbackBody = BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false);
|
||||
var fallbackJson = JsonSerializer.Serialize(fallbackBody);
|
||||
WorkflowLogService.LogLlmRawRequestFromContext(url, fallbackJson);
|
||||
using var retryReq = new HttpRequestMessage(HttpMethod.Post, url)
|
||||
{
|
||||
Content = new StringContent(fallbackJson, Encoding.UTF8, "application/json")
|
||||
@@ -1082,6 +1137,35 @@ public partial class LlmService
|
||||
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync,
|
||||
[EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
// Ollama stream:false 등 비-SSE 응답 감지: Content-Type에 text/event-stream이 없으면 전체 JSON으로 처리
|
||||
var contentType = resp.Content.Headers.ContentType?.MediaType ?? "";
|
||||
if (!contentType.Contains("event-stream", StringComparison.OrdinalIgnoreCase)
|
||||
&& !contentType.Contains("octet-stream", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// 비-SSE 전체 JSON 응답 (Ollama stream:false 등)
|
||||
var rawJson = await resp.Content.ReadAsStringAsync(ct);
|
||||
var respJson = ExtractJsonFromSseIfNeeded(rawJson);
|
||||
var trimmed = respJson.TrimStart();
|
||||
if (trimmed.StartsWith('{') || trimmed.StartsWith('['))
|
||||
{
|
||||
using var doc = JsonDocument.Parse(respJson);
|
||||
TryParseOpenAiUsage(doc.RootElement);
|
||||
if (TryExtractMessageToolBlocks(doc.RootElement, out var msgText, out var directBlocks))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(msgText))
|
||||
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, msgText);
|
||||
foreach (var block in directBlocks)
|
||||
{
|
||||
if (prefetchToolCallAsync != null)
|
||||
block.PrefetchedExecutionTask = prefetchToolCallAsync(block);
|
||||
yield return new ToolStreamEvent(ToolStreamEventKind.ToolCallReady, ToolCall: block);
|
||||
}
|
||||
}
|
||||
yield return new ToolStreamEvent(ToolStreamEventKind.Completed);
|
||||
yield break;
|
||||
}
|
||||
}
|
||||
|
||||
using var stream = await resp.Content.ReadAsStreamAsync(ct);
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
@@ -1174,12 +1258,26 @@ public partial class LlmService
|
||||
var firstChoice = choicesEl[0];
|
||||
if (firstChoice.TryGetProperty("delta", out var deltaEl))
|
||||
{
|
||||
var emittedContent = false;
|
||||
if (deltaEl.TryGetProperty("content", out var contentEl) &&
|
||||
contentEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var chunk = contentEl.GetString();
|
||||
if (!string.IsNullOrEmpty(chunk))
|
||||
{
|
||||
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, chunk);
|
||||
emittedContent = true;
|
||||
}
|
||||
}
|
||||
// Qwen3.5 thinking 모드 폴백: content가 비어있거나 없으면 reasoning_content 사용
|
||||
// else if가 아닌 독립 if — content 키가 존재하되 빈 문자열("")인 경우도 커버
|
||||
if (!emittedContent &&
|
||||
deltaEl.TryGetProperty("reasoning_content", out var reasoningEl) &&
|
||||
reasoningEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var reasoningChunk = reasoningEl.GetString();
|
||||
if (!string.IsNullOrEmpty(reasoningChunk))
|
||||
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, reasoningChunk);
|
||||
}
|
||||
|
||||
if (deltaEl.TryGetProperty("tool_calls", out var toolCallsEl) &&
|
||||
@@ -1274,6 +1372,18 @@ public partial class LlmService
|
||||
consumed = true;
|
||||
}
|
||||
}
|
||||
// Qwen3.5 thinking 모드 폴백: content가 비어있으면 reasoning_content 사용
|
||||
if (!consumed &&
|
||||
message.TryGetProperty("reasoning_content", out var reasoningContentEl) &&
|
||||
reasoningContentEl.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var reasoningText = reasoningContentEl.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(reasoningText))
|
||||
{
|
||||
text = reasoningText;
|
||||
consumed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (message.TryGetProperty("tool_calls", out var toolCallsEl) &&
|
||||
toolCallsEl.ValueKind == JsonValueKind.Array)
|
||||
@@ -1325,6 +1435,22 @@ public partial class LlmService
|
||||
if (!(json.StartsWith('{') || json.StartsWith('[')))
|
||||
return false;
|
||||
|
||||
// 빠른 사전 검사: 중괄호/대괄호 균형이 맞지 않으면 파싱 시도 불필요
|
||||
int depth = 0;
|
||||
bool inString = false;
|
||||
bool escape = false;
|
||||
for (int i = 0; i < json.Length; i++)
|
||||
{
|
||||
var ch = json[i];
|
||||
if (escape) { escape = false; continue; }
|
||||
if (ch == '\\' && inString) { escape = true; continue; }
|
||||
if (ch == '"') { inString = !inString; continue; }
|
||||
if (inString) continue;
|
||||
if (ch is '{' or '[') depth++;
|
||||
else if (ch is '}' or ']') depth--;
|
||||
}
|
||||
if (depth != 0) return false;
|
||||
|
||||
try
|
||||
{
|
||||
using var _ = JsonDocument.Parse(json);
|
||||
@@ -1362,6 +1488,12 @@ public partial class LlmService
|
||||
return null;
|
||||
}
|
||||
}
|
||||
else if (!forceEmit)
|
||||
{
|
||||
// 스트리밍 중 이름만 도착하고 arguments가 아직 비어 있는 경우
|
||||
// → 후속 청크에서 arguments가 올 수 있으므로 조기 방출하지 않음
|
||||
return null;
|
||||
}
|
||||
|
||||
var block = new ContentBlock
|
||||
{
|
||||
|
||||
@@ -453,7 +453,9 @@ public partial class LlmService : IDisposable
|
||||
{
|
||||
temperature = ResolveTemperature(),
|
||||
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
|
||||
}
|
||||
},
|
||||
// Qwen3.5 thinking 모드 비활성화: 활성화되면 content=null, reasoning_content에만 출력됨
|
||||
chat_template_kwargs = new { enable_thinking = false },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -462,9 +464,22 @@ public partial class LlmService : IDisposable
|
||||
if (root.TryGetProperty("choices", out var choices) && choices.ValueKind == JsonValueKind.Array && choices.GetArrayLength() > 0)
|
||||
{
|
||||
var message = choices[0].TryGetProperty("message", out var choiceMessage) ? choiceMessage : default;
|
||||
if (message.ValueKind == JsonValueKind.Object &&
|
||||
message.TryGetProperty("content", out var content))
|
||||
return content.GetString() ?? "";
|
||||
if (message.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
if (message.TryGetProperty("content", out var content))
|
||||
{
|
||||
var text = content.GetString();
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
return text;
|
||||
}
|
||||
// Qwen3.5 thinking 모드 폴백
|
||||
if (message.TryGetProperty("reasoning_content", out var reasoning))
|
||||
{
|
||||
var text = reasoning.GetString();
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array && results.GetArrayLength() > 0)
|
||||
@@ -855,10 +870,14 @@ public partial class LlmService : IDisposable
|
||||
if (doc.RootElement.TryGetProperty("choices", out var ch) && ch.GetArrayLength() > 0)
|
||||
{
|
||||
var first = ch[0];
|
||||
if (first.TryGetProperty("delta", out var delta)
|
||||
&& delta.TryGetProperty("content", out var cnt))
|
||||
if (first.TryGetProperty("delta", out var delta))
|
||||
{
|
||||
var txt = cnt.GetString();
|
||||
string? txt = null;
|
||||
if (delta.TryGetProperty("content", out var cnt))
|
||||
txt = cnt.GetString();
|
||||
// Qwen3.5 thinking 모드 폴백: content가 비어있으면 reasoning_content 사용
|
||||
if (string.IsNullOrEmpty(txt) && delta.TryGetProperty("reasoning_content", out var rc))
|
||||
txt = rc.GetString();
|
||||
if (!string.IsNullOrEmpty(txt)) { sb.Append(txt); collectingChunks = true; }
|
||||
}
|
||||
else if (first.TryGetProperty("message", out _))
|
||||
@@ -965,6 +984,8 @@ public partial class LlmService : IDisposable
|
||||
var delta = ibmChoices[0].GetProperty("delta");
|
||||
if (delta.TryGetProperty("content", out var c))
|
||||
text = c.GetString();
|
||||
if (string.IsNullOrEmpty(text) && delta.TryGetProperty("reasoning_content", out var rc))
|
||||
text = rc.GetString();
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -975,6 +996,8 @@ public partial class LlmService : IDisposable
|
||||
var delta = choices[0].GetProperty("delta");
|
||||
if (delta.TryGetProperty("content", out var c))
|
||||
text = c.GetString();
|
||||
if (string.IsNullOrEmpty(text) && delta.TryGetProperty("reasoning_content", out var rc2))
|
||||
text = rc2.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ internal sealed class PerformanceMonitorService
|
||||
if (_timer != null)
|
||||
return;
|
||||
|
||||
_timer = new System.Threading.Timer(_ => Sample(), null, 0, 2000);
|
||||
_timer = new System.Threading.Timer(_ => Sample(), null, 0, 5000);
|
||||
}
|
||||
|
||||
public void StopPolling()
|
||||
|
||||
@@ -33,7 +33,7 @@ internal sealed class ServerStatusService
|
||||
if (_timer != null)
|
||||
return;
|
||||
|
||||
_timer = new System.Threading.Timer(async _ => await CheckAllAsync(), null, 0, 15000);
|
||||
_timer = new System.Threading.Timer(async _ => await CheckAllAsync(), null, 0, 60000);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
|
||||
@@ -162,13 +162,44 @@ public class SettingsService
|
||||
return string.Compare(current, target, StringComparison.Ordinal) < 0;
|
||||
}
|
||||
|
||||
private static readonly object _saveLock = new();
|
||||
|
||||
public void Save()
|
||||
{
|
||||
EnsureDirectories();
|
||||
NormalizeRuntimeSettings();
|
||||
var json = JsonSerializer.Serialize(_settings, JsonOptions);
|
||||
var encrypted = CryptoService.PortableEncrypt(json);
|
||||
File.WriteAllText(SettingsPath, encrypted);
|
||||
|
||||
// 임시 파일에 쓰고 교체 (atomic write) — 동시 읽기 충돌 방지
|
||||
lock (_saveLock)
|
||||
{
|
||||
var tmpPath = SettingsPath + ".tmp";
|
||||
for (int attempt = 0; attempt < 3; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(tmpPath, encrypted);
|
||||
// File.Move with overwrite (atomic on NTFS)
|
||||
File.Move(tmpPath, SettingsPath, overwrite: true);
|
||||
break;
|
||||
}
|
||||
catch (IOException) when (attempt < 2)
|
||||
{
|
||||
Thread.Sleep(50 * (attempt + 1));
|
||||
}
|
||||
catch (Exception ex) when (attempt < 2)
|
||||
{
|
||||
LogService.Warn($"settings.dat 저장 재시도 {attempt + 1}/3: {ex.Message}");
|
||||
Thread.Sleep(50 * (attempt + 1));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 임시 파일 잔여 방지
|
||||
try { if (File.Exists(tmpPath)) File.Delete(tmpPath); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
SettingsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,10 +39,42 @@ public static class WorkflowLogService
|
||||
/// <summary>보관 기간 (일). 이 기간이 지난 로그는 자동 삭제됩니다.</summary>
|
||||
public static int RetentionDays { get; set; } = 3;
|
||||
|
||||
/// <summary>상세 워크플로우 이벤트를 기록합니다.</summary>
|
||||
public static void Log(WorkflowLogEntry entry)
|
||||
// ─── LlmService 등 하위 계층에서 사용할 현재 컨텍스트 ───
|
||||
// AgentLoopService가 LLM 호출 직전에 설정하고, 완료 후 리셋합니다.
|
||||
// AsyncLocal: async/await 전후로 올바르게 전파됨 (ThreadStatic은 continuation 스레드에서 유실)
|
||||
private static readonly AsyncLocal<string?> _ctxConversationId = new();
|
||||
private static readonly AsyncLocal<string?> _ctxRunId = new();
|
||||
private static readonly AsyncLocal<int> _ctxIteration = new();
|
||||
|
||||
/// <summary>현재 LLM 호출 컨텍스트를 설정합니다 (AgentLoopService에서 호출).</summary>
|
||||
public static void SetCallContext(string conversationId, string runId, int iteration)
|
||||
{
|
||||
if (!IsEnabled) return;
|
||||
_ctxConversationId.Value = conversationId;
|
||||
_ctxRunId.Value = runId;
|
||||
_ctxIteration.Value = iteration;
|
||||
}
|
||||
|
||||
/// <summary>Raw LLM 통신 로깅 활성화 여부 (요청 JSON + 응답 원문).</summary>
|
||||
public static bool IsRawLogEnabled { get; set; }
|
||||
|
||||
/// <summary>현재 컨텍스트를 사용하여 raw 요청을 기록합니다 (LlmService에서 호출).</summary>
|
||||
public static void LogLlmRawRequestFromContext(string url, string requestBody)
|
||||
{
|
||||
if (!IsRawLogEnabled || string.IsNullOrEmpty(_ctxConversationId.Value)) return;
|
||||
LogLlmRawRequest(_ctxConversationId.Value!, _ctxRunId.Value ?? "", _ctxIteration.Value, url, requestBody);
|
||||
}
|
||||
|
||||
/// <summary>현재 컨텍스트를 사용하여 raw 응답을 기록합니다 (LlmService에서 호출).</summary>
|
||||
public static void LogLlmRawResponseFromContext(string rawResponse, long elapsedMs)
|
||||
{
|
||||
if (!IsRawLogEnabled || string.IsNullOrEmpty(_ctxConversationId.Value)) return;
|
||||
LogLlmRawResponse(_ctxConversationId.Value!, _ctxRunId.Value ?? "", _ctxIteration.Value, rawResponse, elapsedMs);
|
||||
}
|
||||
|
||||
/// <summary>상세 워크플로우 이벤트를 기록합니다.</summary>
|
||||
public static void Log(WorkflowLogEntry entry, bool bypassEnabledCheck = false)
|
||||
{
|
||||
if (!bypassEnabledCheck && !IsEnabled) return;
|
||||
try
|
||||
{
|
||||
var dayDir = Path.Combine(WorkflowDir, DateTime.Now.ToString("yyyy-MM-dd"));
|
||||
@@ -82,6 +114,42 @@ public static class WorkflowLogService
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>LLM에 보낸 실제 HTTP 요청 body (raw JSON)를 기록합니다.</summary>
|
||||
public static void LogLlmRawRequest(string conversationId, string runId, int iteration,
|
||||
string url, string requestBody)
|
||||
{
|
||||
Log(new WorkflowLogEntry
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
RunId = runId,
|
||||
EventType = "llm_raw_request",
|
||||
Iteration = iteration,
|
||||
Details = new Dictionary<string, object?>
|
||||
{
|
||||
["url"] = url,
|
||||
["body"] = requestBody,
|
||||
}
|
||||
}, bypassEnabledCheck: true);
|
||||
}
|
||||
|
||||
/// <summary>LLM이 돌려준 raw 응답 텍스트를 기록합니다 (SSE 전체 or 단일 JSON).</summary>
|
||||
public static void LogLlmRawResponse(string conversationId, string runId, int iteration,
|
||||
string rawResponse, long elapsedMs)
|
||||
{
|
||||
Log(new WorkflowLogEntry
|
||||
{
|
||||
ConversationId = conversationId,
|
||||
RunId = runId,
|
||||
EventType = "llm_raw_response",
|
||||
Iteration = iteration,
|
||||
ElapsedMs = elapsedMs,
|
||||
Details = new Dictionary<string, object?>
|
||||
{
|
||||
["raw"] = Truncate(rawResponse, 20000),
|
||||
}
|
||||
}, bypassEnabledCheck: true);
|
||||
}
|
||||
|
||||
/// <summary>LLM 응답을 기록합니다.</summary>
|
||||
public static void LogLlmResponse(string conversationId, string runId, int iteration,
|
||||
string? textResponse, int toolCallCount, int inputTokens, int outputTokens, long elapsedMs)
|
||||
|
||||
@@ -421,6 +421,13 @@ public class SettingsViewModel : INotifyPropertyChanged
|
||||
set { _detailedLogRetentionDays = Math.Clamp(value, 1, 30); OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
private bool _enableRawLlmLog;
|
||||
public bool EnableRawLlmLog
|
||||
{
|
||||
get => _enableRawLlmLog;
|
||||
set { _enableRawLlmLog = value; OnPropertyChanged(); }
|
||||
}
|
||||
|
||||
private bool _enableAgentMemory;
|
||||
public bool EnableAgentMemory
|
||||
{
|
||||
@@ -1172,6 +1179,7 @@ public class SettingsViewModel : INotifyPropertyChanged
|
||||
_enableAuditLog = llm.EnableAuditLog;
|
||||
_enableDetailedLog = llm.EnableDetailedLog;
|
||||
_detailedLogRetentionDays = llm.DetailedLogRetentionDays > 0 ? llm.DetailedLogRetentionDays : 3;
|
||||
_enableRawLlmLog = llm.EnableRawLlmLog;
|
||||
_enableAgentMemory = llm.EnableAgentMemory;
|
||||
_enableProjectRules = llm.EnableProjectRules;
|
||||
_maxMemoryEntries = llm.MaxMemoryEntries;
|
||||
@@ -1618,6 +1626,7 @@ public class SettingsViewModel : INotifyPropertyChanged
|
||||
s.Llm.EnableAuditLog = _enableAuditLog;
|
||||
s.Llm.EnableDetailedLog = _enableDetailedLog;
|
||||
s.Llm.DetailedLogRetentionDays = _detailedLogRetentionDays;
|
||||
s.Llm.EnableRawLlmLog = _enableRawLlmLog;
|
||||
s.Llm.EnableAgentMemory = _enableAgentMemory;
|
||||
s.Llm.EnableProjectRules = _enableProjectRules;
|
||||
s.Llm.MaxMemoryEntries = _maxMemoryEntries;
|
||||
@@ -1808,6 +1817,7 @@ public class SettingsViewModel : INotifyPropertyChanged
|
||||
// 워크플로우 상세 로그 설정 즉시 반영
|
||||
WorkflowLogService.IsEnabled = _enableDetailedLog;
|
||||
WorkflowLogService.RetentionDays = _detailedLogRetentionDays > 0 ? _detailedLogRetentionDays : 3;
|
||||
WorkflowLogService.IsRawLogEnabled = _enableRawLlmLog;
|
||||
|
||||
SaveCompleted?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
195
src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs
Normal file
195
src/AxCopilot/Views/ChatWindow.AgentEventProcessor.cs
Normal 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 { }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ public partial class ChatWindow
|
||||
bool liveWaitingStyle = false)
|
||||
{
|
||||
var liveAccentColor = ResolveLiveProgressAccentColor(accentBrush);
|
||||
var pillMaxWidth = GetMessageMaxWidth();
|
||||
return new Border
|
||||
{
|
||||
Background = liveWaitingStyle
|
||||
@@ -50,7 +51,8 @@ public partial class ChatWindow
|
||||
CornerRadius = new CornerRadius(10),
|
||||
Padding = new Thickness(10, 8, 10, 8),
|
||||
Margin = new Thickness(12, 6, 12, 2),
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
MaxWidth = pillMaxWidth,
|
||||
Child = new Grid
|
||||
{
|
||||
ColumnDefinitions =
|
||||
@@ -176,8 +178,11 @@ public partial class ChatWindow
|
||||
if (string.IsNullOrWhiteSpace(summary))
|
||||
summary = transcriptBadgeLabel;
|
||||
|
||||
var msgMaxWidth = GetMessageMaxWidth();
|
||||
var stack = new StackPanel
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
MaxWidth = msgMaxWidth,
|
||||
Margin = new Thickness(0),
|
||||
};
|
||||
|
||||
@@ -206,14 +211,7 @@ public partial class ChatWindow
|
||||
stack.Children.Add(bodyBlock);
|
||||
}
|
||||
|
||||
var memoryEvidence = BuildMemoryContextEvidenceText();
|
||||
if (!string.IsNullOrWhiteSpace(memoryEvidence))
|
||||
{
|
||||
var memoryBlock = CreateProcessFeedBody(memoryEvidence, secondaryText);
|
||||
memoryBlock.Margin = new Thickness(28, 2, 12, 8);
|
||||
memoryBlock.Opacity = 0.92;
|
||||
stack.Children.Add(memoryBlock);
|
||||
}
|
||||
// 메모리 증거 텍스트 — 프로세스 피드에서 표시하지 않음 (불필요한 중복 정보)
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(evt.FilePath))
|
||||
{
|
||||
@@ -1196,6 +1194,7 @@ public partial class ChatWindow
|
||||
var borderColor = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0");
|
||||
var accentBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(fgHex));
|
||||
|
||||
var bannerMaxWidth = GetMessageMaxWidth();
|
||||
var banner = new Border
|
||||
{
|
||||
Background = hintBg,
|
||||
@@ -1204,7 +1203,8 @@ public partial class ChatWindow
|
||||
CornerRadius = new CornerRadius(10),
|
||||
Padding = new Thickness(9, 7, 9, 7),
|
||||
Margin = new Thickness(12, 3, 12, 3),
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
HorizontalAlignment = HorizontalAlignment.Center,
|
||||
MaxWidth = bannerMaxWidth,
|
||||
};
|
||||
if (!string.IsNullOrWhiteSpace(evt.RunId))
|
||||
_runBannerAnchors[evt.RunId] = banner;
|
||||
|
||||
@@ -23,10 +23,27 @@ public partial class ChatWindow
|
||||
_sortConversationsByRecent);
|
||||
}
|
||||
|
||||
private string? _lastTaskSummaryRuntimeLabel;
|
||||
private string? _lastTaskSummaryStripText;
|
||||
private bool _lastTaskSummaryShowBadge;
|
||||
private bool _lastTaskSummaryShowStrip;
|
||||
|
||||
private void UpdateTaskSummaryIndicators()
|
||||
{
|
||||
var status = BuildOperationalStatusPresentation();
|
||||
|
||||
// 값이 변경되지 않았으면 UI property setter 호출 스킵 (measure/arrange 방지)
|
||||
if (status.RuntimeLabel == _lastTaskSummaryRuntimeLabel
|
||||
&& status.StripText == _lastTaskSummaryStripText
|
||||
&& status.ShowRuntimeBadge == _lastTaskSummaryShowBadge
|
||||
&& status.ShowCompactStrip == _lastTaskSummaryShowStrip)
|
||||
return;
|
||||
|
||||
_lastTaskSummaryRuntimeLabel = status.RuntimeLabel;
|
||||
_lastTaskSummaryStripText = status.StripText;
|
||||
_lastTaskSummaryShowBadge = status.ShowRuntimeBadge;
|
||||
_lastTaskSummaryShowStrip = status.ShowCompactStrip;
|
||||
|
||||
if (RuntimeActivityBadge != null)
|
||||
RuntimeActivityBadge.Visibility = status.ShowRuntimeBadge
|
||||
? Visibility.Visible
|
||||
|
||||
@@ -11,9 +11,28 @@ namespace AxCopilot.Views;
|
||||
|
||||
public partial class ChatWindow
|
||||
{
|
||||
// 스트리밍 중 LINQ 재실행 방지용 캐시
|
||||
private List<ChatMessage>? _cachedVisibleMessages;
|
||||
private int _cachedVisibleMessagesSourceCount = -1;
|
||||
private List<ChatExecutionEvent>? _cachedVisibleEvents;
|
||||
private int _cachedVisibleEventsSourceCount = -1;
|
||||
private bool _cachedVisibleEventsShowHistory;
|
||||
|
||||
private void InvalidateTimelineCache()
|
||||
{
|
||||
_cachedVisibleMessages = null;
|
||||
_cachedVisibleMessagesSourceCount = -1;
|
||||
_cachedVisibleEvents = null;
|
||||
_cachedVisibleEventsSourceCount = -1;
|
||||
}
|
||||
|
||||
private List<ChatMessage> GetVisibleTimelineMessages(ChatConversation? conversation)
|
||||
{
|
||||
return conversation?.Messages?.Where(msg =>
|
||||
var sourceCount = conversation?.Messages?.Count ?? 0;
|
||||
if (_cachedVisibleMessages != null && sourceCount == _cachedVisibleMessagesSourceCount)
|
||||
return _cachedVisibleMessages;
|
||||
|
||||
var result = conversation?.Messages?.Where(msg =>
|
||||
{
|
||||
if (string.Equals(msg.Role, "system", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
@@ -24,15 +43,36 @@ public partial class ChatWindow
|
||||
|
||||
return true;
|
||||
}).ToList() ?? new List<ChatMessage>();
|
||||
|
||||
_cachedVisibleMessages = result;
|
||||
_cachedVisibleMessagesSourceCount = sourceCount;
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<ChatExecutionEvent> GetVisibleTimelineEvents(ChatConversation? conversation)
|
||||
{
|
||||
var events = conversation?.ExecutionEvents?.ToList() ?? new List<ChatExecutionEvent>();
|
||||
if (conversation?.ShowExecutionHistory ?? true)
|
||||
return events;
|
||||
var sourceCount = conversation?.ExecutionEvents?.Count ?? 0;
|
||||
var showHistory = conversation?.ShowExecutionHistory ?? true;
|
||||
if (_cachedVisibleEvents != null
|
||||
&& sourceCount == _cachedVisibleEventsSourceCount
|
||||
&& showHistory == _cachedVisibleEventsShowHistory)
|
||||
return _cachedVisibleEvents;
|
||||
|
||||
return events.Where(ShouldShowCollapsedProgressEvent).ToList();
|
||||
List<ChatExecutionEvent> result;
|
||||
if (showHistory)
|
||||
{
|
||||
result = conversation?.ExecutionEvents?.ToList() ?? new List<ChatExecutionEvent>();
|
||||
}
|
||||
else
|
||||
{
|
||||
result = (conversation?.ExecutionEvents ?? Enumerable.Empty<ChatExecutionEvent>())
|
||||
.Where(ShouldShowCollapsedProgressEvent).ToList();
|
||||
}
|
||||
|
||||
_cachedVisibleEvents = result;
|
||||
_cachedVisibleEventsSourceCount = sourceCount;
|
||||
_cachedVisibleEventsShowHistory = showHistory;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool ShouldShowCollapsedProgressEvent(ChatExecutionEvent executionEvent)
|
||||
@@ -64,17 +104,17 @@ public partial class ChatWindow
|
||||
or "csv_create" or "markdown_create" or "md_create" or "script_create"
|
||||
or "pptx_create";
|
||||
|
||||
private List<(DateTime Timestamp, int Order, Action Render)> BuildTimelineRenderActions(
|
||||
private List<(string Key, DateTime Timestamp, int Order, Action Render)> BuildTimelineRenderActions(
|
||||
IReadOnlyCollection<ChatMessage> visibleMessages,
|
||||
IReadOnlyCollection<ChatExecutionEvent> visibleEvents)
|
||||
{
|
||||
var timeline = new List<(DateTime Timestamp, int Order, Action Render)>(visibleMessages.Count + visibleEvents.Count);
|
||||
var timeline = new List<(string Key, DateTime Timestamp, int Order, Action Render)>(visibleMessages.Count + visibleEvents.Count);
|
||||
|
||||
foreach (var msg in visibleMessages)
|
||||
{
|
||||
var capturedMsg = msg;
|
||||
var cacheKey = $"m_{msg.MsgId}";
|
||||
timeline.Add((msg.Timestamp, 0, () =>
|
||||
timeline.Add((cacheKey, msg.Timestamp, 0, () =>
|
||||
{
|
||||
// 캐시된 버블이 있으면 재생성 없이 재사용 (O(1) Add vs 전체 재생성)
|
||||
if (_elementCache.TryGetValue(cacheKey, out var cached))
|
||||
@@ -88,6 +128,9 @@ public partial class ChatWindow
|
||||
var showFullHistory = _currentConversation?.ShowExecutionHistory ?? true;
|
||||
var activeRunId = _isStreaming ? (_appState.AgentRun.RunId ?? "") : "";
|
||||
|
||||
var eventIndex = 0;
|
||||
string? prevToolCallName = null;
|
||||
int consecutiveToolCallCount = 0;
|
||||
foreach (var executionEvent in visibleEvents)
|
||||
{
|
||||
// 스트리밍 중이고 히스토리 접힘 상태일 때, 현재 run의 process feed 이벤트는 통합 카드에서 표시
|
||||
@@ -101,7 +144,28 @@ public partial class ChatWindow
|
||||
}
|
||||
|
||||
var restoredEvent = ToAgentEvent(executionEvent);
|
||||
timeline.Add((executionEvent.Timestamp, 1, () => AddAgentEventBanner(restoredEvent)));
|
||||
|
||||
// 접힌 모드: 연속 동일 ToolCall 병합 (예: document_read 3회 → 1개 pill)
|
||||
if (!showFullHistory && restoredEvent.Type == AgentEventType.ToolCall
|
||||
&& !string.IsNullOrWhiteSpace(restoredEvent.ToolName))
|
||||
{
|
||||
if (string.Equals(prevToolCallName, restoredEvent.ToolName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
consecutiveToolCallCount++;
|
||||
continue; // 연속 중복 스킵
|
||||
}
|
||||
// 이전 연속 카운트가 있었으면 이전 pill에 반영됨
|
||||
prevToolCallName = restoredEvent.ToolName;
|
||||
consecutiveToolCallCount = 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
prevToolCallName = null;
|
||||
consecutiveToolCallCount = 0;
|
||||
}
|
||||
|
||||
var eventKey = $"e_{executionEvent.Timestamp.Ticks}_{eventIndex++}";
|
||||
timeline.Add((eventKey, executionEvent.Timestamp, 1, () => AddAgentEventBanner(restoredEvent)));
|
||||
}
|
||||
|
||||
// 스트리밍 중 + 히스토리 접힘: 통합 진행 카드 삽입 (개별 pill 대체)
|
||||
@@ -109,14 +173,32 @@ public partial class ChatWindow
|
||||
{
|
||||
var capturedSteps = _currentRunProgressSteps.ToList();
|
||||
var cardTimestamp = capturedSteps[^1].Timestamp;
|
||||
timeline.Add((cardTimestamp, 1, () => AddLiveRunProgressCard(capturedSteps)));
|
||||
// 통합 진행 카드는 매번 내용이 바뀌므로 volatile 키 사용
|
||||
timeline.Add(("_live_progress", cardTimestamp, 1, () => AddLiveRunProgressCard(capturedSteps)));
|
||||
}
|
||||
|
||||
var liveProgressHint = GetLiveAgentProgressHint();
|
||||
if (liveProgressHint != null)
|
||||
timeline.Add((liveProgressHint.Timestamp, 2, () => AddAgentEventBanner(liveProgressHint)));
|
||||
timeline.Add(("_live_hint", liveProgressHint.Timestamp, 2, () => AddAgentEventBanner(liveProgressHint)));
|
||||
|
||||
return timeline.OrderBy(x => x.Timestamp).ThenBy(x => x.Order).ToList();
|
||||
// 대부분 이미 시간순이므로 정렬 필요 여부를 먼저 확인 — O(n) 스캔으로 O(n log n) 정렬 회피
|
||||
var needsSort = false;
|
||||
for (int i = 1; i < timeline.Count; i++)
|
||||
{
|
||||
var cmp = timeline[i].Timestamp.CompareTo(timeline[i - 1].Timestamp);
|
||||
if (cmp < 0 || (cmp == 0 && timeline[i].Order < timeline[i - 1].Order))
|
||||
{
|
||||
needsSort = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (needsSort)
|
||||
timeline.Sort((a, b) =>
|
||||
{
|
||||
var cmp = a.Timestamp.CompareTo(b.Timestamp);
|
||||
return cmp != 0 ? cmp : a.Order.CompareTo(b.Order);
|
||||
});
|
||||
return timeline;
|
||||
}
|
||||
|
||||
private Border CreateTimelineLoadMoreCard(int hiddenCount)
|
||||
|
||||
@@ -2035,7 +2035,8 @@
|
||||
<Grid>
|
||||
<!-- 무지개 글로우 외부 테두리 (메시지 전송 시 애니메이션) -->
|
||||
<Border x:Name="InputGlowBorder" CornerRadius="18" Opacity="0"
|
||||
Margin="-2" IsHitTestVisible="False">
|
||||
Visibility="Collapsed"
|
||||
Margin="-1" IsHitTestVisible="False">
|
||||
<Border.BorderBrush>
|
||||
<LinearGradientBrush x:Name="RainbowBrush" StartPoint="0,0" EndPoint="1,1">
|
||||
<GradientStop Color="#FF6B6B" Offset="0.0"/>
|
||||
@@ -2048,10 +2049,10 @@
|
||||
</LinearGradientBrush>
|
||||
</Border.BorderBrush>
|
||||
<Border.BorderThickness>
|
||||
<Thickness>1.15</Thickness>
|
||||
<Thickness>1</Thickness>
|
||||
</Border.BorderThickness>
|
||||
<Border.Effect>
|
||||
<BlurEffect Radius="6"/>
|
||||
<BlurEffect Radius="4"/>
|
||||
</Border.Effect>
|
||||
</Border>
|
||||
<!-- 실제 입력 영역 -->
|
||||
@@ -4481,6 +4482,50 @@
|
||||
Foreground="{DynamicResource AccentColor}"/>
|
||||
</Border>
|
||||
</Grid>
|
||||
<Border x:Name="OverlayToggleDetailedLog" Style="{StaticResource OverlayAdvancedToggleRowStyle}" Margin="0,8,0,0">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Margin="0,0,16,0">
|
||||
<TextBlock Text="상세 워크플로우 로그" FontSize="12.5" FontWeight="SemiBold" Foreground="{DynamicResource PrimaryText}"/>
|
||||
<TextBlock Text="에이전트 워크플로우 상세 이력(LLM 요청/응답, 도구 호출/결과, 판단 등)을 기록합니다. 워크플로우 분석기와 함께 사용하면 디버깅에 유용합니다."
|
||||
Margin="0,4,0,0"
|
||||
FontSize="11.5"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource SecondaryText}"/>
|
||||
</StackPanel>
|
||||
<CheckBox x:Name="ChkOverlayEnableDetailedLog"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource ToggleSwitch}"
|
||||
Checked="ChkOverlayEnableDetailedLog_Changed"
|
||||
Unchecked="ChkOverlayEnableDetailedLog_Changed"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border x:Name="OverlayToggleRawLlmLog" Style="{StaticResource OverlayAdvancedToggleRowStyle}" Margin="0,8,0,0">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
<StackPanel Margin="0,0,16,0">
|
||||
<TextBlock Text="LLM 통신 원문 로깅" FontSize="12.5" FontWeight="SemiBold" Foreground="{DynamicResource PrimaryText}"/>
|
||||
<TextBlock Text="LLM에 보낸 요청 JSON과 돌아온 응답 원문을 기록합니다. 도구 미호출 디버깅용이며 파일이 커질 수 있습니다."
|
||||
Margin="0,4,0,0"
|
||||
FontSize="11.5"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource SecondaryText}"/>
|
||||
</StackPanel>
|
||||
<CheckBox x:Name="ChkOverlayEnableRawLlmLog"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Style="{StaticResource ToggleSwitch}"
|
||||
Checked="ChkOverlayEnableRawLlmLog_Changed"
|
||||
Unchecked="ChkOverlayEnableRawLlmLog_Changed"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<StackPanel x:Name="OverlayAdvancedTogglePanel" Margin="0,14,0,0">
|
||||
<CheckBox x:Name="ChkOverlayVllmAllowInsecureTls" Visibility="Collapsed" Checked="ChkOverlayVllmAllowInsecureTls_Changed" Unchecked="ChkOverlayVllmAllowInsecureTls_Changed"/>
|
||||
@@ -5794,4 +5839,3 @@
|
||||
</Border>
|
||||
</Window>
|
||||
|
||||
|
||||
|
||||
@@ -72,6 +72,8 @@ public partial class ChatWindow : Window
|
||||
private bool _cursorVisible = true;
|
||||
private TextBlock? _activeStreamText;
|
||||
private string _cachedStreamContent = ""; // sb.ToString() 캐시 — 중복 호출 방지
|
||||
private readonly char[] _streamDisplayBuffer = new char[256 * 1024]; // 256KB 재사용 버퍼 (타이핑 표시용)
|
||||
private int _streamDisplayBufferLen; // 버퍼에 기록된 실제 길이
|
||||
private TextBlock? _activeAiIcon; // 로딩 펄스 중인 AI 아이콘
|
||||
private bool _aiIconPulseStopped; // 펄스 1회만 중지
|
||||
private WorkflowAnalyzerWindow? _analyzerWindow; // 워크플로우 분석기
|
||||
@@ -85,6 +87,13 @@ public partial class ChatWindow : Window
|
||||
private long _lastScrollTick; // 스크롤 스로틀링용 (Environment.TickCount64)
|
||||
// 메시지 버블 캐시: messageId → 렌더링된 UIElement (재생성 방지)
|
||||
private readonly Dictionary<string, UIElement> _elementCache = new(StringComparer.Ordinal);
|
||||
// 증분 렌더링: 이전 렌더링의 타임라인 키 목록 (변경 감지용)
|
||||
private List<string> _lastRenderedTimelineKeys = new();
|
||||
private int _lastRenderedHiddenCount;
|
||||
// 스트리밍 중 불필요한 재렌더링 방지용 카운터
|
||||
private int _lastRenderedMessageCount;
|
||||
private int _lastRenderedEventCount;
|
||||
private bool _lastRenderedShowHistory;
|
||||
private readonly HashSet<string> _sessionPermissionRules = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, bool> _sessionMcpEnabledOverrides = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string> _sessionMcpAuthTokens = new(StringComparer.OrdinalIgnoreCase);
|
||||
@@ -206,6 +215,8 @@ public partial class ChatWindow : Window
|
||||
foreach (var tab in new[] { "Chat", "Cowork", "Code" })
|
||||
_agentLoops[tab] = CreateAgentLoopForTab(tab, settings);
|
||||
SubAgentTool.StatusChanged += OnSubAgentStatusChanged;
|
||||
// 에이전트 이벤트 백그라운드 프로세서 시작 (대화 변이·저장을 UI 스레드에서 분리)
|
||||
StartAgentEventProcessor();
|
||||
|
||||
// 설정에서 초기값 로드 (Loaded 전에도 null 방지)
|
||||
_selectedMood = settings.Settings.Llm.DefaultMood ?? "modern";
|
||||
@@ -217,7 +228,7 @@ public partial class ChatWindow : Window
|
||||
_elapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
||||
_elapsedTimer.Tick += ElapsedTimer_Tick;
|
||||
|
||||
_typingTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(20) };
|
||||
_typingTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(80) };
|
||||
_typingTimer.Tick += TypingTimer_Tick;
|
||||
_gitRefreshTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(450) };
|
||||
_gitRefreshTimer.Tick += async (_, _) =>
|
||||
@@ -242,6 +253,10 @@ public partial class ChatWindow : Window
|
||||
_executionHistoryRenderTimer.Tick += (_, _) =>
|
||||
{
|
||||
_executionHistoryRenderTimer.Stop();
|
||||
// 스트리밍 중에는 전체 재렌더링 빈도를 줄여 UI 부하 감소
|
||||
_executionHistoryRenderTimer.Interval = _isStreaming
|
||||
? TimeSpan.FromMilliseconds(1500)
|
||||
: TimeSpan.FromMilliseconds(350);
|
||||
RenderMessages(preserveViewport: true);
|
||||
if (_pendingExecutionHistoryAutoScroll)
|
||||
AutoScrollIfNeeded();
|
||||
@@ -251,6 +266,9 @@ public partial class ChatWindow : Window
|
||||
_taskSummaryRefreshTimer.Tick += (_, _) =>
|
||||
{
|
||||
_taskSummaryRefreshTimer.Stop();
|
||||
_taskSummaryRefreshTimer.Interval = _isStreaming
|
||||
? TimeSpan.FromMilliseconds(800)
|
||||
: TimeSpan.FromMilliseconds(120);
|
||||
UpdateTaskSummaryIndicators();
|
||||
};
|
||||
_conversationPersistTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(220) };
|
||||
@@ -263,6 +281,9 @@ public partial class ChatWindow : Window
|
||||
_agentUiEventTimer.Tick += (_, _) =>
|
||||
{
|
||||
_agentUiEventTimer.Stop();
|
||||
_agentUiEventTimer.Interval = _isStreaming
|
||||
? TimeSpan.FromMilliseconds(300)
|
||||
: TimeSpan.FromMilliseconds(140);
|
||||
FlushPendingAgentUiEvent();
|
||||
};
|
||||
_agentProgressHintTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
||||
@@ -277,6 +298,12 @@ public partial class ChatWindow : Window
|
||||
_responsiveLayoutTimer.Tick += (_, _) =>
|
||||
{
|
||||
_responsiveLayoutTimer.Stop();
|
||||
// 스트리밍 중 전체 메시지 재렌더링은 UI 부하가 크므로 연기
|
||||
if (_isStreaming)
|
||||
{
|
||||
_pendingResponsiveLayoutRefresh = true;
|
||||
return;
|
||||
}
|
||||
UpdateTopicPresetScrollMode();
|
||||
if (UpdateResponsiveChatLayout())
|
||||
RenderMessages(preserveViewport: true);
|
||||
@@ -541,6 +568,9 @@ public partial class ChatWindow : Window
|
||||
/// <summary>앱 종료 시 창을 실제로 닫습니다.</summary>
|
||||
public void ForceClose()
|
||||
{
|
||||
// 백그라운드 이벤트 프로세서 종료 (미저장 대화 플러시됨)
|
||||
StopAgentEventProcessor();
|
||||
|
||||
// 현재 대화 저장 + 탭별 마지막 대화 ID를 설정에 영속 저장
|
||||
lock (_convLock)
|
||||
{
|
||||
@@ -616,8 +646,8 @@ public partial class ChatWindow : Window
|
||||
var currentOffset = MessageScroll.VerticalOffset;
|
||||
var diff = targetOffset - currentOffset;
|
||||
|
||||
// 차이가 작으면 즉시 이동 (깜빡임 방지)
|
||||
if (diff <= 60)
|
||||
// 스트리밍 중이거나 차이가 작으면 즉시 이동 (UI 부하 최소화)
|
||||
if (diff <= 60 || _isStreaming)
|
||||
{
|
||||
MessageScroll.ScrollToEnd();
|
||||
return;
|
||||
@@ -1122,6 +1152,16 @@ public partial class ChatWindow : Window
|
||||
return IntPtr.Zero;
|
||||
}
|
||||
|
||||
// 드래그/리사이즈 중 일시 정지할 타이머 목록
|
||||
private DispatcherTimer[] GetSuspendableTimers() => new[]
|
||||
{
|
||||
_cursorTimer, _elapsedTimer, _typingTimer, _gitRefreshTimer,
|
||||
_conversationSearchTimer, _inputUiRefreshTimer, _executionHistoryRenderTimer,
|
||||
_taskSummaryRefreshTimer, _conversationPersistTimer, _agentUiEventTimer,
|
||||
_agentProgressHintTimer, _tokenUsagePopupCloseTimer, _responsiveLayoutTimer,
|
||||
};
|
||||
private readonly List<DispatcherTimer> _timersRunningBeforeMove = new();
|
||||
|
||||
private void BeginWindowMoveSizeLoop()
|
||||
{
|
||||
if (_isInWindowMoveSizeLoop)
|
||||
@@ -1130,6 +1170,21 @@ public partial class ChatWindow : Window
|
||||
_isInWindowMoveSizeLoop = true;
|
||||
_pendingResponsiveLayoutRefresh = false;
|
||||
|
||||
// 비필수 타이머 일시 정지 → 드래그 중 UI 부하 최소화
|
||||
_timersRunningBeforeMove.Clear();
|
||||
foreach (var t in GetSuspendableTimers())
|
||||
{
|
||||
if (t.IsEnabled)
|
||||
{
|
||||
_timersRunningBeforeMove.Add(t);
|
||||
t.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
// Storyboard 일시 정지
|
||||
_pulseDotStoryboard?.Pause();
|
||||
_statusDiamondStoryboard?.Pause();
|
||||
|
||||
if (Content is UIElement rootElement)
|
||||
{
|
||||
_cachedRootCacheModeBeforeMove = rootElement.CacheMode;
|
||||
@@ -1149,6 +1204,15 @@ public partial class ChatWindow : Window
|
||||
|
||||
_cachedRootCacheModeBeforeMove = null;
|
||||
|
||||
// 타이머 복원
|
||||
foreach (var t in _timersRunningBeforeMove)
|
||||
t.Start();
|
||||
_timersRunningBeforeMove.Clear();
|
||||
|
||||
// Storyboard 복원
|
||||
_pulseDotStoryboard?.Resume();
|
||||
_statusDiamondStoryboard?.Resume();
|
||||
|
||||
if (_pendingResponsiveLayoutRefresh)
|
||||
{
|
||||
_pendingResponsiveLayoutRefresh = false;
|
||||
@@ -1201,6 +1265,7 @@ public partial class ChatWindow : Window
|
||||
_cachedStreamContent = "";
|
||||
_streamingTabs.Clear();
|
||||
_streamRunTab = null;
|
||||
_streamStartTime = default;
|
||||
BtnSend.IsEnabled = true;
|
||||
BtnStop.Visibility = Visibility.Collapsed;
|
||||
BtnPause.Visibility = Visibility.Collapsed;
|
||||
@@ -2198,9 +2263,6 @@ public partial class ChatWindow : Window
|
||||
var previousScrollableHeight = MessageScroll?.ScrollableHeight ?? 0;
|
||||
var previousVerticalOffset = MessageScroll?.VerticalOffset ?? 0;
|
||||
|
||||
MessagePanel.Children.Clear();
|
||||
_runBannerAnchors.Clear();
|
||||
|
||||
ChatConversation? conv;
|
||||
lock (_convLock) conv = _currentConversation;
|
||||
_appState.RestoreAgentRunHistory(conv?.AgentRunHistory);
|
||||
@@ -2208,8 +2270,21 @@ public partial class ChatWindow : Window
|
||||
var visibleMessages = GetVisibleTimelineMessages(conv);
|
||||
var visibleEvents = GetVisibleTimelineEvents(conv);
|
||||
|
||||
// 스트리밍 중 메시지·이벤트 수가 동일하면 불필요한 재렌더링 스킵
|
||||
if (_isStreaming && preserveViewport
|
||||
&& visibleMessages.Count == _lastRenderedMessageCount
|
||||
&& visibleEvents.Count == _lastRenderedEventCount
|
||||
&& (conv?.ShowExecutionHistory ?? true) == _lastRenderedShowHistory
|
||||
&& string.Equals(_lastRenderedConversationId, conv?.Id, StringComparison.OrdinalIgnoreCase))
|
||||
return;
|
||||
|
||||
if (conv == null || (visibleMessages.Count == 0 && visibleEvents.Count == 0))
|
||||
{
|
||||
MessagePanel.Children.Clear();
|
||||
_runBannerAnchors.Clear();
|
||||
_lastRenderedTimelineKeys.Clear();
|
||||
_lastRenderedMessageCount = 0;
|
||||
_lastRenderedEventCount = 0;
|
||||
EmptyState.Visibility = Visibility.Visible;
|
||||
return;
|
||||
}
|
||||
@@ -2218,19 +2293,113 @@ public partial class ChatWindow : Window
|
||||
{
|
||||
_lastRenderedConversationId = conv.Id;
|
||||
_timelineRenderLimit = TimelineRenderPageSize;
|
||||
_elementCache.Clear(); // 대화 전환 시 버블 캐시 초기화
|
||||
_elementCache.Clear();
|
||||
_lastRenderedTimelineKeys.Clear();
|
||||
_lastRenderedMessageCount = 0;
|
||||
_lastRenderedEventCount = 0;
|
||||
InvalidateTimelineCache();
|
||||
}
|
||||
|
||||
var showHistory = conv.ShowExecutionHistory;
|
||||
|
||||
EmptyState.Visibility = Visibility.Collapsed;
|
||||
|
||||
var orderedTimeline = BuildTimelineRenderActions(visibleMessages, visibleEvents);
|
||||
var hiddenCount = Math.Max(0, orderedTimeline.Count - _timelineRenderLimit);
|
||||
if (hiddenCount > 0)
|
||||
MessagePanel.Children.Add(CreateTimelineLoadMoreCard(hiddenCount));
|
||||
var visibleTimeline = hiddenCount > 0
|
||||
? orderedTimeline.GetRange(hiddenCount, orderedTimeline.Count - hiddenCount)
|
||||
: orderedTimeline;
|
||||
var newKeys = new List<string>(visibleTimeline.Count);
|
||||
foreach (var t in visibleTimeline) newKeys.Add(t.Key);
|
||||
|
||||
foreach (var item in orderedTimeline.Skip(hiddenCount))
|
||||
item.Render();
|
||||
var incremented = false;
|
||||
|
||||
// ── 증분 렌더링: 이전 키 목록과 비교하여 변경 최소화 ──
|
||||
// agent live card가 존재하면 Children과 키 불일치 가능 → 전체 재빌드
|
||||
var hasExternalChildren = _agentLiveContainer != null && MessagePanel.Children.Contains(_agentLiveContainer);
|
||||
var expectedChildCount = _lastRenderedTimelineKeys.Count + (_lastRenderedHiddenCount > 0 ? 1 : 0);
|
||||
var canIncremental = !hasExternalChildren
|
||||
&& _lastRenderedTimelineKeys.Count > 0
|
||||
&& newKeys.Count >= _lastRenderedTimelineKeys.Count
|
||||
&& _lastRenderedHiddenCount == hiddenCount
|
||||
&& MessagePanel.Children.Count == expectedChildCount;
|
||||
|
||||
if (canIncremental)
|
||||
{
|
||||
// _live_ 키 개수를 한 번만 계산 (이전 키 목록에서)
|
||||
var prevLiveCount = 0;
|
||||
for (int i = _lastRenderedTimelineKeys.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (_lastRenderedTimelineKeys[i].StartsWith("_live_", StringComparison.Ordinal))
|
||||
prevLiveCount++;
|
||||
else
|
||||
break; // live 키는 항상 끝에 연속으로 위치
|
||||
}
|
||||
var prevStableCount = _lastRenderedTimelineKeys.Count - prevLiveCount;
|
||||
|
||||
// 안정 키(non-live) 접두사가 일치하는지 확인
|
||||
var prefixMatch = true;
|
||||
for (int i = 0; i < prevStableCount; i++)
|
||||
{
|
||||
if (i >= newKeys.Count || !string.Equals(_lastRenderedTimelineKeys[i], newKeys[i], StringComparison.Ordinal))
|
||||
{
|
||||
prefixMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (prefixMatch)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 이전 live 요소를 Children 끝에서 제거
|
||||
for (int r = 0; r < prevLiveCount && MessagePanel.Children.Count > 0; r++)
|
||||
MessagePanel.Children.RemoveAt(MessagePanel.Children.Count - 1);
|
||||
|
||||
// 안정 접두사 이후의 새 아이템 렌더링 (새 stable + 새 live 포함)
|
||||
for (int i = prevStableCount; i < visibleTimeline.Count; i++)
|
||||
visibleTimeline[i].Render();
|
||||
|
||||
_lastRenderedTimelineKeys = newKeys;
|
||||
_lastRenderedHiddenCount = hiddenCount;
|
||||
_lastRenderedMessageCount = visibleMessages.Count;
|
||||
_lastRenderedEventCount = visibleEvents.Count;
|
||||
_lastRenderedShowHistory = showHistory;
|
||||
incremented = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"증분 렌더링 실패, 전체 재빌드로 전환: {ex.Message}");
|
||||
_lastRenderedTimelineKeys.Clear();
|
||||
incremented = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!incremented)
|
||||
{
|
||||
// ── 전체 재빌드 (대화 전환, 접기/펴기, 중간 변경 등) ──
|
||||
MessagePanel.Children.Clear();
|
||||
_runBannerAnchors.Clear();
|
||||
|
||||
if (hiddenCount > 0)
|
||||
MessagePanel.Children.Add(CreateTimelineLoadMoreCard(hiddenCount));
|
||||
|
||||
foreach (var item in visibleTimeline)
|
||||
item.Render();
|
||||
|
||||
// 전체 재빌드로 Children이 초기화되었으므로 agent live card 복원
|
||||
if (_agentLiveContainer != null && !MessagePanel.Children.Contains(_agentLiveContainer))
|
||||
MessagePanel.Children.Add(_agentLiveContainer);
|
||||
|
||||
_lastRenderedTimelineKeys = newKeys;
|
||||
_lastRenderedHiddenCount = hiddenCount;
|
||||
_lastRenderedMessageCount = visibleMessages.Count;
|
||||
_lastRenderedEventCount = visibleEvents.Count;
|
||||
_lastRenderedShowHistory = showHistory;
|
||||
}
|
||||
|
||||
// ── 스크롤 처리 ──
|
||||
if (!preserveViewport)
|
||||
{
|
||||
_ = Dispatcher.InvokeAsync(() =>
|
||||
@@ -2323,20 +2492,20 @@ public partial class ChatWindow : Window
|
||||
private void CursorTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
_cursorVisible = !_cursorVisible;
|
||||
// 커서 상태만 토글 — 실제 텍스트 갱신은 _typingTimer가 담당
|
||||
if (_activeStreamText != null && _displayedLength > 0)
|
||||
// 커서 상태만 토글 — 버퍼에 이미 기록된 텍스트의 마지막 커서 문자만 교체
|
||||
if (_activeStreamText != null && _displayedLength > 0 && _streamDisplayBufferLen > 0)
|
||||
{
|
||||
var displayed = _cachedStreamContent.Length > 0
|
||||
? _cachedStreamContent[..Math.Min(_displayedLength, _cachedStreamContent.Length)]
|
||||
: "";
|
||||
_activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
|
||||
var cursorChar = _cursorVisible ? '\u258c' : ' ';
|
||||
_streamDisplayBuffer[_streamDisplayBufferLen - 1] = cursorChar;
|
||||
_activeStreamText.Text = new string(_streamDisplayBuffer, 0, _streamDisplayBufferLen);
|
||||
}
|
||||
}
|
||||
|
||||
private void ElapsedTimer_Tick(object? sender, EventArgs e)
|
||||
{
|
||||
var elapsed = DateTime.UtcNow - _streamStartTime;
|
||||
var sec = (int)elapsed.TotalSeconds;
|
||||
var sec = TryGetStreamingElapsed(out var elapsed)
|
||||
? Math.Max(0, (int)elapsed.TotalSeconds)
|
||||
: 0;
|
||||
if (_elapsedLabel != null)
|
||||
_elapsedLabel.Text = $"{sec}s";
|
||||
|
||||
@@ -2356,25 +2525,38 @@ public partial class ChatWindow : Window
|
||||
if (_displayedLength >= targetLen) return;
|
||||
|
||||
// 버퍼에 쌓인 미표시 글자 수에 따라 속도 적응
|
||||
// IBM/DeepSeek은 대용량 청크를 한번에 보내므로 빠르게 따라잡을 수 있도록 스텝 증가
|
||||
var pending = targetLen - _displayedLength;
|
||||
int step;
|
||||
if (pending > 1000) step = pending / 8; // 대량 버퍼: 빠르게 따라잡기
|
||||
else if (pending > 300) step = Math.Min(Math.Max(15, pending / 8), 60); // 중-대량: 가속
|
||||
else if (pending > 120) step = Math.Min(Math.Max(8, pending / 10), 20); // 중간 버퍼
|
||||
else if (pending > 24) step = Math.Min(6, pending); // 소량
|
||||
else step = Math.Min(2, pending); // 마무리
|
||||
if (pending > 1000) step = pending / 4;
|
||||
else if (pending > 300) step = Math.Min(Math.Max(30, pending / 4), 120);
|
||||
else if (pending > 120) step = Math.Min(Math.Max(15, pending / 6), 40);
|
||||
else if (pending > 24) step = Math.Min(12, pending);
|
||||
else step = Math.Min(4, pending);
|
||||
|
||||
_displayedLength += step;
|
||||
|
||||
var displayed = _cachedStreamContent[.._displayedLength];
|
||||
_activeStreamText.Text = displayed + (_cursorVisible ? "\u258c" : " ");
|
||||
// 재사용 버퍼에 표시할 텍스트 + 커서를 직접 기록 (string.Concat 할당 제거)
|
||||
var displayLen = _displayedLength;
|
||||
var cursorChar = _cursorVisible ? '\u258c' : ' ';
|
||||
var needed = displayLen + 1;
|
||||
if (needed <= _streamDisplayBuffer.Length)
|
||||
{
|
||||
_cachedStreamContent.CopyTo(0, _streamDisplayBuffer, 0, displayLen);
|
||||
_streamDisplayBuffer[displayLen] = cursorChar;
|
||||
_streamDisplayBufferLen = needed;
|
||||
_activeStreamText.Text = new string(_streamDisplayBuffer, 0, needed);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 버퍼 초과 시 fallback (256KB 이상 응답)
|
||||
_activeStreamText.Text = string.Concat(_cachedStreamContent.AsSpan(0, displayLen), cursorChar.ToString());
|
||||
}
|
||||
|
||||
// 스크롤은 80ms마다 한 번만 (매 20ms 레이아웃 재계산 방지)
|
||||
// 스크롤은 150ms마다 한 번만 (레이아웃 재계산 빈도 감소)
|
||||
if (!_userScrolled)
|
||||
{
|
||||
var now = Environment.TickCount64;
|
||||
if (now - _lastScrollTick >= 80)
|
||||
if (now - _lastScrollTick >= 150)
|
||||
{
|
||||
_lastScrollTick = now;
|
||||
MessageScroll.ScrollToVerticalOffset(MessageScroll.ScrollableHeight);
|
||||
@@ -5568,6 +5750,7 @@ public partial class ChatWindow : Window
|
||||
_activeStreamText = null;
|
||||
_elapsedLabel = null;
|
||||
_cachedStreamContent = "";
|
||||
_streamStartTime = default;
|
||||
SetStatusIdle();
|
||||
}
|
||||
|
||||
@@ -5691,26 +5874,27 @@ public partial class ChatWindow : Window
|
||||
_typingTimer.Start();
|
||||
ShowStreamingStatusBar("생각하는 중...");
|
||||
|
||||
var streamSb = new System.Text.StringBuilder();
|
||||
var streamSb = new System.Text.StringBuilder(4096);
|
||||
var lastSyncTick = Environment.TickCount64;
|
||||
await foreach (var chunk in _llm.StreamAsync(preparedExecution.Messages.ToList(), streamToken))
|
||||
{
|
||||
if (string.IsNullOrEmpty(chunk))
|
||||
continue;
|
||||
|
||||
streamSb.Append(chunk);
|
||||
// 타이핑 타이머가 현재 버퍼를 다 소화했을 때만 ToString() 호출 — GC 압박 최소화
|
||||
if (_displayedLength >= _cachedStreamContent.Length)
|
||||
// ToString() 호출 조건: 타이머가 소화 완료 + 최소 30ms 경과
|
||||
var now = Environment.TickCount64;
|
||||
if (_displayedLength >= _cachedStreamContent.Length && now - lastSyncTick >= 30)
|
||||
{
|
||||
_cachedStreamContent = streamSb.ToString();
|
||||
// Dispatcher 타이머 틱이 실행될 기회를 보장
|
||||
// (IBM처럼 응답이 버퍼로 한 번에 오면 타이머가 굶을 수 있음)
|
||||
lastSyncTick = now;
|
||||
await Task.Delay(1, streamToken).ConfigureAwait(true);
|
||||
}
|
||||
if (_activeStreamText != null && _displayedLength == 0)
|
||||
_activeStreamText.Text = _cursorVisible ? "\u258c" : " ";
|
||||
}
|
||||
assistantContent = streamSb.ToString();
|
||||
_cachedStreamContent = assistantContent; // 최종 동기화
|
||||
_cachedStreamContent = assistantContent;
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -5722,7 +5906,7 @@ public partial class ChatWindow : Window
|
||||
assistantContent = response ?? string.Empty;
|
||||
}
|
||||
|
||||
responseElapsedMs = Math.Max(0, (long)(DateTime.UtcNow - _streamStartTime).TotalMilliseconds);
|
||||
responseElapsedMs = GetStreamingElapsedMsOrZero();
|
||||
assistantMetaRunId = _appState.AgentRun.RunId;
|
||||
var usage = _llm.LastTokenUsage;
|
||||
if (usage != null)
|
||||
@@ -6001,12 +6185,25 @@ public partial class ChatWindow : Window
|
||||
if (_pendingConversationPersists.Count == 0)
|
||||
return;
|
||||
|
||||
foreach (var conversation in _pendingConversationPersists.Values.ToList())
|
||||
{
|
||||
PersistConversationSnapshot(conversation.Tab ?? _activeTab, conversation, "대화 지연 저장 실패");
|
||||
}
|
||||
|
||||
// 대화 저장(디스크 I/O)을 백그라운드로 이동하여 UI 스레드 블로킹 방지
|
||||
var snapshot = _pendingConversationPersists.Values.ToList();
|
||||
_pendingConversationPersists.Clear();
|
||||
|
||||
Task.Run(() =>
|
||||
{
|
||||
foreach (var conversation in snapshot)
|
||||
{
|
||||
try
|
||||
{
|
||||
_storage.Save(conversation);
|
||||
_appState.ChatSession?.RememberConversation(conversation.Tab ?? "Chat", conversation.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Services.LogService.Debug($"대화 지연 저장 실패: {ex.Message}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 코워크 에이전트 지원 ────────────────────────────────────────────
|
||||
@@ -6536,10 +6733,9 @@ public partial class ChatWindow : Window
|
||||
private void OnAgentEvent(AgentEvent evt, string runTab)
|
||||
{
|
||||
TouchLiveAgentProgressHints();
|
||||
// runTab은 클로저로 캡처된 실행 탭 — 다중 탭 동시 실행 시에도 올바른 탭에 귀속
|
||||
var eventTab = runTab;
|
||||
|
||||
// Claude 스타일 펄스 닷 실시간 단계 업데이트
|
||||
// ── 1단계: 경량 UI 피드백만 (UI 스레드) ──────────────────────────────
|
||||
if (string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
switch (evt.Type)
|
||||
@@ -6547,14 +6743,12 @@ public partial class ChatWindow : Window
|
||||
case AgentEventType.ToolCall when !string.IsNullOrWhiteSpace(evt.ToolName):
|
||||
{
|
||||
var (msg, icon, category) = GetStatusInfoForTool(evt.ToolName);
|
||||
// 카테고리 변경 시 주 텍스트만 업데이트하고 서브 아이템 초기화
|
||||
bool categoryChanged = category != _currentSubItemCategory;
|
||||
if (PulseDotStatusText != null && PulseDotBar?.Visibility == Visibility.Visible)
|
||||
PulseDotStatusText.Text = msg + "...";
|
||||
if (categoryChanged) ClearStatusSubItems();
|
||||
_currentSubItemCategory = category;
|
||||
|
||||
// 파일명 서브 아이템 추가
|
||||
string? subItemText = null;
|
||||
if (!string.IsNullOrEmpty(evt.FilePath))
|
||||
{
|
||||
@@ -6568,13 +6762,11 @@ public partial class ChatWindow : Window
|
||||
subItemText = evt.Summary;
|
||||
AddStatusSubItem(subItemText, category);
|
||||
}
|
||||
// 라이브 카드 업데이트
|
||||
UpdateAgentLiveCard(msg + "...", subItemText, category, categoryChanged);
|
||||
break;
|
||||
}
|
||||
case AgentEventType.ToolResult when !string.IsNullOrWhiteSpace(evt.ToolName):
|
||||
{
|
||||
// 결과 수신 시 기존 서브 아이템을 유지하며 주 텍스트만 변경
|
||||
var resultMsg = GetToolResultMessage(evt.ToolName) + "...";
|
||||
if (PulseDotStatusText != null && PulseDotBar?.Visibility == Visibility.Visible)
|
||||
PulseDotStatusText.Text = resultMsg;
|
||||
@@ -6623,19 +6815,16 @@ public partial class ChatWindow : Window
|
||||
_currentRunProgressSteps.RemoveAt(0);
|
||||
}
|
||||
|
||||
// 실행 로그는 직접 배너를 먼저 꽂지 않고, 대화 모델에 누적한 뒤 재렌더합니다.
|
||||
// 그래야 중간 배너 잔상과 최종 재렌더 중복이 줄어듭니다.
|
||||
// ── 2단계: 무거운 작업을 백그라운드 큐로 위임 ──────────────────────────
|
||||
// AppendConversationExecutionEvent, AppendConversationAgentRun, 디스크 저장은
|
||||
// 백그라운드 스레드에서 배치 처리됩니다. UI 렌더 갱신도 배치 완료 후 1회만 호출됩니다.
|
||||
var shouldShowExecutionHistory = _currentConversation?.ShowExecutionHistory ?? false;
|
||||
AppendConversationExecutionEvent(evt, eventTab);
|
||||
if ((shouldShowExecutionHistory || ShouldRenderProgressEventWhenHistoryCollapsed(evt))
|
||||
&& string.Equals(eventTab, _activeTab, StringComparison.OrdinalIgnoreCase))
|
||||
ScheduleExecutionHistoryRender(autoScroll: true);
|
||||
var shouldRender = (shouldShowExecutionHistory || ShouldRenderProgressEventWhenHistoryCollapsed(evt))
|
||||
&& string.Equals(eventTab, _activeTab, StringComparison.OrdinalIgnoreCase);
|
||||
EnqueueAgentEventWork(evt, eventTab, shouldRender);
|
||||
|
||||
// ── 3단계: 경량 상태 추적 (UI 스레드) ───────────────────────────────
|
||||
_appState.ApplyAgentEvent(evt);
|
||||
if (evt.Type == AgentEventType.Complete)
|
||||
AppendConversationAgentRun(evt, "completed", string.IsNullOrWhiteSpace(evt.Summary) ? "작업 완료" : evt.Summary, eventTab);
|
||||
else if (evt.Type == AgentEventType.Error && string.IsNullOrWhiteSpace(evt.ToolName))
|
||||
AppendConversationAgentRun(evt, "failed", string.IsNullOrWhiteSpace(evt.Summary) ? "에이전트 실행 실패" : evt.Summary, eventTab);
|
||||
|
||||
// 탭별 토큰 누적 — 활성 탭 것만 하단 바에 표시
|
||||
if (evt.InputTokens > 0 || evt.OutputTokens > 0)
|
||||
@@ -6647,7 +6836,6 @@ public partial class ChatWindow : Window
|
||||
}
|
||||
|
||||
ScheduleAgentUiEvent(evt);
|
||||
|
||||
ScheduleTaskSummaryRefresh();
|
||||
}
|
||||
|
||||
@@ -7171,7 +7359,7 @@ public partial class ChatWindow : Window
|
||||
}
|
||||
|
||||
var idle = DateTime.UtcNow - _lastAgentProgressEventAt;
|
||||
var elapsed = DateTime.UtcNow - _streamStartTime.ToUniversalTime();
|
||||
TryGetStreamingElapsed(out var elapsed);
|
||||
string? summary = null;
|
||||
var toolName = "agent_wait";
|
||||
|
||||
@@ -7214,10 +7402,7 @@ public partial class ChatWindow : Window
|
||||
var normalizedSummary = string.IsNullOrWhiteSpace(summary) ? null : summary.Trim();
|
||||
var currentSummary = _liveAgentProgressHint?.Summary;
|
||||
var currentToolName = _liveAgentProgressHint?.ToolName ?? "";
|
||||
var hasValidStreamStart = _streamStartTime.Year >= 2000 && _streamStartTime <= DateTime.UtcNow.AddSeconds(1);
|
||||
var elapsedMs = _isStreaming && hasValidStreamStart
|
||||
? Math.Max(0L, (long)(DateTime.UtcNow - _streamStartTime.ToUniversalTime()).TotalMilliseconds)
|
||||
: 0L;
|
||||
var elapsedMs = _isStreaming ? GetStreamingElapsedMsOrZero() : 0L;
|
||||
var inputTokens = (long)Math.Max(0, _tabCumulativeInputTokens.GetValueOrDefault(_activeTab));
|
||||
var outputTokens = (long)Math.Max(0, _tabCumulativeOutputTokens.GetValueOrDefault(_activeTab));
|
||||
var currentElapsedBucket = (_liveAgentProgressHint?.ElapsedMs ?? 0) / 3000;
|
||||
@@ -7298,10 +7483,17 @@ public partial class ChatWindow : Window
|
||||
|
||||
private void OnSubAgentStatusChanged(SubAgentStatusEvent evt)
|
||||
{
|
||||
Dispatcher.Invoke(() =>
|
||||
Dispatcher.BeginInvoke(() =>
|
||||
{
|
||||
_appState.ApplySubAgentStatus(evt);
|
||||
ScheduleTaskSummaryRefresh();
|
||||
try
|
||||
{
|
||||
_appState.ApplySubAgentStatus(evt);
|
||||
ScheduleTaskSummaryRefresh();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogService.Warn($"OnSubAgentStatusChanged 처리 실패: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8756,10 +8948,11 @@ public partial class ChatWindow : Window
|
||||
container.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(actionBar, mdCard);
|
||||
|
||||
// 경과 시간 + 토큰 사용량 (우측 하단, 별도 줄)
|
||||
var elapsed = DateTime.UtcNow - _streamStartTime;
|
||||
var elapsedText = elapsed.TotalSeconds < 60
|
||||
var elapsedText = TryGetStreamingElapsed(out var elapsed)
|
||||
? (elapsed.TotalSeconds < 60
|
||||
? $"{elapsed.TotalSeconds:0.#}s"
|
||||
: $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s";
|
||||
: $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s")
|
||||
: "0s";
|
||||
|
||||
var usage = _llm.LastTokenUsage;
|
||||
// 에이전트 루프(Cowork/Code)에서는 누적 토큰 사용, 일반 대화에서는 마지막 호출 토큰 사용
|
||||
@@ -9593,6 +9786,31 @@ public partial class ChatWindow : Window
|
||||
private DispatcherTimer? _rainbowTimer;
|
||||
private DateTime _rainbowStartTime;
|
||||
|
||||
private bool TryGetStreamingElapsed(out TimeSpan elapsed)
|
||||
{
|
||||
elapsed = TimeSpan.Zero;
|
||||
if (_streamStartTime.Year < 2000)
|
||||
return false;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (_streamStartTime > now.AddSeconds(1))
|
||||
return false;
|
||||
|
||||
elapsed = now - _streamStartTime;
|
||||
if (elapsed < TimeSpan.Zero || elapsed > TimeSpan.FromHours(6))
|
||||
{
|
||||
elapsed = TimeSpan.Zero;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private long GetStreamingElapsedMsOrZero()
|
||||
=> TryGetStreamingElapsed(out var elapsed)
|
||||
? Math.Max(0L, (long)elapsed.TotalMilliseconds)
|
||||
: 0L;
|
||||
|
||||
/// <summary>입력창 테두리에 무지개 그라데이션 회전 애니메이션을 재생합니다 (3초).</summary>
|
||||
private void PlayRainbowGlow()
|
||||
{
|
||||
@@ -9600,12 +9818,12 @@ public partial class ChatWindow : Window
|
||||
if (_rainbowTimer != null) return; // 이미 실행 중이면 opacity 리셋 없이 그냥 유지
|
||||
|
||||
_rainbowStartTime = DateTime.UtcNow;
|
||||
|
||||
InputGlowBorder.Effect = new System.Windows.Media.Effects.BlurEffect { Radius = 6 };
|
||||
InputGlowBorder.Visibility = Visibility.Visible;
|
||||
InputGlowBorder.Effect = new System.Windows.Media.Effects.BlurEffect { Radius = 4 };
|
||||
InputGlowBorder.BeginAnimation(UIElement.OpacityProperty,
|
||||
new System.Windows.Media.Animation.DoubleAnimation(0, 0.62, TimeSpan.FromMilliseconds(180)));
|
||||
new System.Windows.Media.Animation.DoubleAnimation(0, 0.92, TimeSpan.FromMilliseconds(180)));
|
||||
|
||||
_rainbowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(150) };
|
||||
_rainbowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
|
||||
_rainbowTimer.Tick += (_, _) =>
|
||||
{
|
||||
var elapsed = (DateTime.UtcNow - _rainbowStartTime).TotalMilliseconds;
|
||||
@@ -9625,13 +9843,21 @@ public partial class ChatWindow : Window
|
||||
{
|
||||
_rainbowTimer?.Stop();
|
||||
_rainbowTimer = null;
|
||||
if (InputGlowBorder.Opacity > 0)
|
||||
if (InputGlowBorder.Opacity > 0 || InputGlowBorder.Visibility == Visibility.Visible)
|
||||
{
|
||||
var fadeOut = new System.Windows.Media.Animation.DoubleAnimation(
|
||||
InputGlowBorder.Opacity, 0, TimeSpan.FromMilliseconds(600));
|
||||
fadeOut.Completed += (_, _) => InputGlowBorder.Opacity = 0;
|
||||
fadeOut.Completed += (_, _) =>
|
||||
{
|
||||
InputGlowBorder.Opacity = 0;
|
||||
InputGlowBorder.Visibility = Visibility.Collapsed;
|
||||
};
|
||||
InputGlowBorder.BeginAnimation(UIElement.OpacityProperty, fadeOut);
|
||||
}
|
||||
else
|
||||
{
|
||||
InputGlowBorder.Visibility = Visibility.Collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 토스트 알림 ──────────────────────────────────────────────────────
|
||||
@@ -11346,6 +11572,10 @@ public partial class ChatWindow : Window
|
||||
ChkOverlayShowTotalCallStats.IsChecked = llm.ShowTotalCallStats;
|
||||
if (ChkOverlayEnableAuditLog != null)
|
||||
ChkOverlayEnableAuditLog.IsChecked = llm.EnableAuditLog;
|
||||
if (ChkOverlayEnableDetailedLog != null)
|
||||
ChkOverlayEnableDetailedLog.IsChecked = llm.EnableDetailedLog;
|
||||
if (ChkOverlayEnableRawLlmLog != null)
|
||||
ChkOverlayEnableRawLlmLog.IsChecked = llm.EnableRawLlmLog;
|
||||
if (ChkOverlayEnableChatRainbowGlow != null)
|
||||
ChkOverlayEnableChatRainbowGlow.IsChecked = llm.EnableChatRainbowGlow;
|
||||
}
|
||||
@@ -12005,6 +12235,28 @@ public partial class ChatWindow : Window
|
||||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||||
}
|
||||
|
||||
private void ChkOverlayEnableDetailedLog_Changed(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isOverlaySettingsSyncing || ChkOverlayEnableDetailedLog == null)
|
||||
return;
|
||||
|
||||
var enabled = ChkOverlayEnableDetailedLog.IsChecked == true;
|
||||
_settings.Settings.Llm.EnableDetailedLog = enabled;
|
||||
WorkflowLogService.IsEnabled = enabled;
|
||||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||||
}
|
||||
|
||||
private void ChkOverlayEnableRawLlmLog_Changed(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isOverlaySettingsSyncing || ChkOverlayEnableRawLlmLog == null)
|
||||
return;
|
||||
|
||||
var enabled = ChkOverlayEnableRawLlmLog.IsChecked == true;
|
||||
_settings.Settings.Llm.EnableRawLlmLog = enabled;
|
||||
WorkflowLogService.IsRawLogEnabled = enabled;
|
||||
PersistOverlaySettingsState(refreshOverlayDeferredInputs: false);
|
||||
}
|
||||
|
||||
private void ChkOverlayFeatureToggle_Changed(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_isOverlaySettingsSyncing)
|
||||
@@ -12158,8 +12410,12 @@ public partial class ChatWindow : Window
|
||||
|
||||
private void RefreshOverlaySettingsPanel()
|
||||
{
|
||||
// 기본 컨트롤 상태만 동기적으로 설정 (빠름)
|
||||
RefreshOverlayVisualState(loadDeferredInputs: true);
|
||||
RefreshOverlayEtcPanels();
|
||||
|
||||
// 무거운 패널 빌드(스킬/MCP/도구 목록 등)는 UI 프레임 렌더링 후 비동기 지연 실행
|
||||
// → 스트리밍 중 설정 열기 시 UI 프리즈 방지
|
||||
Dispatcher.BeginInvoke(RefreshOverlayEtcPanels, System.Windows.Threading.DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private void RefreshOverlayRetentionButtons()
|
||||
@@ -16202,9 +16458,16 @@ public partial class ChatWindow : Window
|
||||
AddTaskSummaryBackgroundSection(panel);
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, System.Windows.Media.SolidColorBrush> _brushCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static System.Windows.Media.SolidColorBrush BrushFromHex(string hex)
|
||||
{
|
||||
if (_brushCache.TryGetValue(hex, out var cached))
|
||||
return cached;
|
||||
var c = (System.Windows.Media.Color)System.Windows.Media.ColorConverter.ConvertFromString(hex)!;
|
||||
return new System.Windows.Media.SolidColorBrush(c);
|
||||
var brush = new System.Windows.Media.SolidColorBrush(c);
|
||||
brush.Freeze(); // Frozen brush는 스레드간 공유 가능 + 렌더링 최적화
|
||||
_brushCache[hex] = brush;
|
||||
return brush;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,10 +95,11 @@ public partial class DockBarWindow : Window
|
||||
if (_glowTimer != null) return; // 이미 실행 중 — Tick 핸들러 중복 추가 방지
|
||||
RainbowGlowBorder.Visibility = Visibility.Visible;
|
||||
var startAngle = 0.0;
|
||||
_glowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(150) };
|
||||
_glowTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(300) };
|
||||
_glowTimer.Tick += (_, _) =>
|
||||
{
|
||||
startAngle += 2;
|
||||
if (!IsVisible) { _glowTimer?.Stop(); return; }
|
||||
startAngle += 4;
|
||||
if (startAngle >= 360) startAngle -= 360;
|
||||
var rad = startAngle * Math.PI / 180.0;
|
||||
RainbowBrush.StartPoint = new Point(0.5 + 0.5 * Math.Cos(rad), 0.5 + 0.5 * Math.Sin(rad));
|
||||
|
||||
@@ -30,7 +30,7 @@ public partial class LauncherWindow
|
||||
{
|
||||
_widgetTimer = new DispatcherTimer(DispatcherPriority.Background)
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(1)
|
||||
Interval = TimeSpan.FromSeconds(3)
|
||||
};
|
||||
_widgetTimer.Tick += (_, _) =>
|
||||
{
|
||||
@@ -44,9 +44,9 @@ public partial class LauncherWindow
|
||||
|
||||
SyncWidgetPollingState();
|
||||
RefreshVisibleWidgets(forceWeatherRefresh: false);
|
||||
if (ShouldShowBatteryWidget() && _widgetBatteryTick++ % 30 == 0)
|
||||
if (ShouldShowBatteryWidget() && _widgetBatteryTick++ % 10 == 0)
|
||||
UpdateBatteryWidget();
|
||||
if (ShouldShowWeatherWidget() && _widgetWeatherTick++ % 120 == 0)
|
||||
if (ShouldShowWeatherWidget() && _widgetWeatherTick++ % 40 == 0)
|
||||
_ = RefreshWeatherAsync();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -676,7 +676,7 @@ public partial class LauncherWindow : Window
|
||||
RainbowGlowBorder.Opacity = 1; // StopRainbowGlow에서 0으로 설정된 불투명도 복원
|
||||
_rainbowTimer = new System.Windows.Threading.DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(150)
|
||||
Interval = TimeSpan.FromMilliseconds(300)
|
||||
};
|
||||
var startTime = DateTime.UtcNow;
|
||||
_rainbowTimer.Tick += (_, _) =>
|
||||
|
||||
@@ -5621,6 +5621,31 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border Style="{StaticResource SettingsRow}">
|
||||
<Grid>
|
||||
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Style="{StaticResource RowLabel}" Text="LLM 통신 원문 로깅"/>
|
||||
<Border Width="16" Height="16" CornerRadius="8" Background="{DynamicResource ItemHoverBackground}" Margin="6,0,0,0" Cursor="Help" VerticalAlignment="Center">
|
||||
<TextBlock Text="?" FontSize="10" FontWeight="Bold" Foreground="{DynamicResource AccentColor}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<Border.ToolTip>
|
||||
<ToolTip Style="{StaticResource HelpTooltipStyle}">
|
||||
<TextBlock TextWrapping="Wrap" Foreground="White" FontSize="12" LineHeight="18">
|
||||
LLM에 보낸 전체 요청 JSON과 돌아온 응답 원문을 기록합니다.
|
||||
<LineBreak/>도구 미호출 등 문제 분석 시 유용합니다.
|
||||
<LineBreak/>파일 크기가 커질 수 있으므로 디버깅 시에만 사용하세요.
|
||||
<LineBreak/>상세 로그 보관 기간에 따라 자동 삭제됩니다.
|
||||
</TextBlock>
|
||||
</ToolTip>
|
||||
</Border.ToolTip>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<TextBlock Style="{StaticResource RowHint}" Text="LLM 요청/응답 전문을 기록합니다. 도구 호출 미작동 디버깅용이며 파일이 클 수 있습니다."/>
|
||||
</StackPanel>
|
||||
<CheckBox Style="{StaticResource ToggleSwitch}" HorizontalAlignment="Right" VerticalAlignment="Center"
|
||||
IsChecked="{Binding EnableRawLlmLog, Mode=TwoWay}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
<Border Style="{StaticResource SettingsRow}">
|
||||
<Grid>
|
||||
<StackPanel HorizontalAlignment="Left" Margin="0,0,60,0">
|
||||
|
||||
Reference in New Issue
Block a user