AX Agent 진행 시간·글로우 경로 정리 및 최근 로컬 변경 일괄 반영
- AX Agent 스트리밍 경과 시간을 공용 helper로 통일해 비정상적인 수천만 시간 표시를 방지함 - 채팅 입력창 글로우를 런처와 같은 표시/숨김 중심의 얇은 외곽 글로우로 정리하고 런처 글로우 설정은 일반 설정에 유지함 - README와 DEVELOPMENT 문서를 2026-04-08 12:02 (KST) 기준으로 갱신하고 Release 빌드 경고 0 / 오류 0을 확인함
This commit is contained in:
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