- AX Agent 스트리밍 경과 시간을 공용 helper로 통일해 비정상적인 수천만 시간 표시를 방지함 - 채팅 입력창 글로우를 런처와 같은 표시/숨김 중심의 얇은 외곽 글로우로 정리하고 런처 글로우 설정은 일반 설정에 유지함 - README와 DEVELOPMENT 문서를 2026-04-08 12:02 (KST) 기준으로 갱신하고 Release 빌드 경고 0 / 오류 0을 확인함
374 lines
15 KiB
C#
374 lines
15 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Media;
|
|
using AxCopilot.Models;
|
|
using AxCopilot.Services.Agent;
|
|
|
|
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)
|
|
{
|
|
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;
|
|
|
|
if (string.Equals(msg.Role, "assistant", StringComparison.OrdinalIgnoreCase)
|
|
&& string.IsNullOrWhiteSpace(msg.Content))
|
|
return false;
|
|
|
|
return true;
|
|
}).ToList() ?? new List<ChatMessage>();
|
|
|
|
_cachedVisibleMessages = result;
|
|
_cachedVisibleMessagesSourceCount = sourceCount;
|
|
return result;
|
|
}
|
|
|
|
private List<ChatExecutionEvent> GetVisibleTimelineEvents(ChatConversation? conversation)
|
|
{
|
|
var sourceCount = conversation?.ExecutionEvents?.Count ?? 0;
|
|
var showHistory = conversation?.ShowExecutionHistory ?? true;
|
|
if (_cachedVisibleEvents != null
|
|
&& sourceCount == _cachedVisibleEventsSourceCount
|
|
&& showHistory == _cachedVisibleEventsShowHistory)
|
|
return _cachedVisibleEvents;
|
|
|
|
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)
|
|
{
|
|
var restoredEvent = ToAgentEvent(executionEvent);
|
|
if (restoredEvent.Type == AgentEventType.Complete || restoredEvent.Type == AgentEventType.Error)
|
|
return true;
|
|
|
|
if (restoredEvent.Type == AgentEventType.Thinking)
|
|
{
|
|
if (string.Equals(restoredEvent.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase)
|
|
|| string.Equals(restoredEvent.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase))
|
|
return true;
|
|
|
|
if (!string.IsNullOrWhiteSpace(restoredEvent.Summary))
|
|
return true;
|
|
}
|
|
|
|
// 문서 생성 ToolResult 성공 시 항상 표시 (미리보기 카드용)
|
|
if (restoredEvent.Type == AgentEventType.ToolResult && restoredEvent.Success
|
|
&& IsDocumentCreationTool(restoredEvent.ToolName))
|
|
return true;
|
|
|
|
return IsProcessFeedEvent(restoredEvent);
|
|
}
|
|
|
|
private static bool IsDocumentCreationTool(string? toolName) =>
|
|
toolName is "html_create" or "docx_create" or "excel_create" or "xlsx_create"
|
|
or "csv_create" or "markdown_create" or "md_create" or "script_create"
|
|
or "pptx_create";
|
|
|
|
private List<(string Key, DateTime Timestamp, int Order, Action Render)> BuildTimelineRenderActions(
|
|
IReadOnlyCollection<ChatMessage> visibleMessages,
|
|
IReadOnlyCollection<ChatExecutionEvent> visibleEvents)
|
|
{
|
|
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((cacheKey, msg.Timestamp, 0, () =>
|
|
{
|
|
// 캐시된 버블이 있으면 재생성 없이 재사용 (O(1) Add vs 전체 재생성)
|
|
if (_elementCache.TryGetValue(cacheKey, out var cached))
|
|
MessagePanel.Children.Add(cached);
|
|
else
|
|
AddMessageBubble(capturedMsg.Role, capturedMsg.Content, animate: false, message: capturedMsg);
|
|
}));
|
|
}
|
|
|
|
// 현재 실행 중인 run의 process feed 이벤트를 통합 카드로 대체 (히스토리 접힘 모드)
|
|
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 이벤트는 통합 카드에서 표시
|
|
if (!showFullHistory && _isStreaming
|
|
&& !string.IsNullOrEmpty(activeRunId)
|
|
&& string.Equals(executionEvent.RunId, activeRunId, StringComparison.Ordinal))
|
|
{
|
|
var restoredCheck = ToAgentEvent(executionEvent);
|
|
if (IsProcessFeedEvent(restoredCheck))
|
|
continue; // 통합 카드로 대체 — 개별 pill 스킵
|
|
}
|
|
|
|
var restoredEvent = ToAgentEvent(executionEvent);
|
|
|
|
// 접힌 모드: 연속 동일 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 대체)
|
|
if (!showFullHistory && _isStreaming && _currentRunProgressSteps.Count > 0)
|
|
{
|
|
var capturedSteps = _currentRunProgressSteps.ToList();
|
|
var cardTimestamp = capturedSteps[^1].Timestamp;
|
|
// 통합 진행 카드는 매번 내용이 바뀌므로 volatile 키 사용
|
|
timeline.Add(("_live_progress", cardTimestamp, 1, () => AddLiveRunProgressCard(capturedSteps)));
|
|
}
|
|
|
|
var liveProgressHint = GetLiveAgentProgressHint();
|
|
if (liveProgressHint != null)
|
|
timeline.Add(("_live_hint", liveProgressHint.Timestamp, 2, () => AddAgentEventBanner(liveProgressHint)));
|
|
|
|
// 대부분 이미 시간순이므로 정렬 필요 여부를 먼저 확인 — 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)
|
|
{
|
|
var hoverBg = TryFindResource("ItemHoverBackground") as Brush ?? BrushFromHex("#F8FAFC");
|
|
var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0");
|
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#334155");
|
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#64748B");
|
|
|
|
var loadMoreBtn = new Button
|
|
{
|
|
Background = Brushes.Transparent,
|
|
BorderBrush = borderBrush,
|
|
BorderThickness = new Thickness(1),
|
|
Padding = new Thickness(7, 3, 7, 3),
|
|
Cursor = System.Windows.Input.Cursors.Hand,
|
|
Foreground = primaryText,
|
|
HorizontalAlignment = HorizontalAlignment.Center,
|
|
};
|
|
loadMoreBtn.Template = BuildMinimalIconButtonTemplate();
|
|
loadMoreBtn.Content = new StackPanel
|
|
{
|
|
Orientation = Orientation.Horizontal,
|
|
Children =
|
|
{
|
|
new TextBlock
|
|
{
|
|
Text = "\uE70D",
|
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
|
FontSize = 8,
|
|
Foreground = secondaryText,
|
|
Margin = new Thickness(0, 0, 4, 0),
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
},
|
|
new TextBlock
|
|
{
|
|
Text = $"이전 대화 {hiddenCount:N0}개",
|
|
FontSize = 9.25,
|
|
Foreground = secondaryText,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
}
|
|
}
|
|
};
|
|
loadMoreBtn.MouseEnter += (_, _) => loadMoreBtn.Background = hoverBg;
|
|
loadMoreBtn.MouseLeave += (_, _) => loadMoreBtn.Background = Brushes.Transparent;
|
|
loadMoreBtn.Click += (_, _) =>
|
|
{
|
|
_timelineRenderLimit += TimelineRenderPageSize;
|
|
RenderMessages(preserveViewport: true);
|
|
};
|
|
|
|
return new Border
|
|
{
|
|
CornerRadius = new CornerRadius(10),
|
|
Margin = new Thickness(0, 2, 0, 8),
|
|
Padding = new Thickness(0),
|
|
Background = Brushes.Transparent,
|
|
BorderBrush = Brushes.Transparent,
|
|
BorderThickness = new Thickness(0),
|
|
HorizontalAlignment = HorizontalAlignment.Center,
|
|
Child = loadMoreBtn,
|
|
};
|
|
}
|
|
|
|
private static AgentEvent ToAgentEvent(ChatExecutionEvent executionEvent)
|
|
{
|
|
var parsedType = Enum.TryParse<AgentEventType>(executionEvent.Type, out var eventType)
|
|
? eventType
|
|
: AgentEventType.Thinking;
|
|
|
|
return new AgentEvent
|
|
{
|
|
Timestamp = executionEvent.Timestamp,
|
|
RunId = executionEvent.RunId,
|
|
Type = parsedType,
|
|
ToolName = executionEvent.ToolName,
|
|
Summary = executionEvent.Summary,
|
|
FilePath = executionEvent.FilePath,
|
|
Success = executionEvent.Success,
|
|
StepCurrent = executionEvent.StepCurrent,
|
|
StepTotal = executionEvent.StepTotal,
|
|
Steps = executionEvent.Steps,
|
|
ElapsedMs = executionEvent.ElapsedMs,
|
|
InputTokens = executionEvent.InputTokens,
|
|
OutputTokens = executionEvent.OutputTokens,
|
|
};
|
|
}
|
|
|
|
private static bool IsCompactionMetaMessage(ChatMessage? message)
|
|
{
|
|
var kind = message?.MetaKind ?? "";
|
|
return kind.Equals("microcompact_boundary", StringComparison.OrdinalIgnoreCase)
|
|
|| kind.Equals("session_memory_compaction", StringComparison.OrdinalIgnoreCase)
|
|
|| kind.Equals("collapsed_boundary", StringComparison.OrdinalIgnoreCase);
|
|
}
|
|
|
|
private Border CreateCompactionMetaCard(ChatMessage message, Brush primaryText, Brush secondaryText, Brush hintBg, Brush borderBrush, Brush accentBrush)
|
|
{
|
|
var icon = "\uE9CE";
|
|
var title = message.MetaKind switch
|
|
{
|
|
"session_memory_compaction" => "세션 메모리 압축",
|
|
"collapsed_boundary" => "압축 경계 병합",
|
|
_ => "Microcompact 경계",
|
|
};
|
|
|
|
var wrapper = new Border
|
|
{
|
|
Background = hintBg,
|
|
BorderBrush = borderBrush,
|
|
BorderThickness = new Thickness(1),
|
|
CornerRadius = new CornerRadius(12),
|
|
Padding = new Thickness(12, 10, 12, 10),
|
|
Margin = new Thickness(10, 4, 150, 4),
|
|
MaxWidth = GetMessageMaxWidth(),
|
|
HorizontalAlignment = HorizontalAlignment.Left,
|
|
};
|
|
|
|
var stack = new StackPanel();
|
|
var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(0, 0, 0, 6) };
|
|
header.Children.Add(new TextBlock
|
|
{
|
|
Text = icon,
|
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
|
FontSize = 11,
|
|
Foreground = accentBrush,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
});
|
|
header.Children.Add(new TextBlock
|
|
{
|
|
Text = title,
|
|
FontSize = 11,
|
|
FontWeight = FontWeights.SemiBold,
|
|
Foreground = primaryText,
|
|
Margin = new Thickness(6, 0, 0, 0),
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
});
|
|
stack.Children.Add(header);
|
|
|
|
var lines = (message.Content ?? "").Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
|
.Select(line => line.Trim()).Where(line => !string.IsNullOrWhiteSpace(line)).ToList();
|
|
|
|
foreach (var line in lines)
|
|
{
|
|
var isHeaderLine = line.StartsWith("[", StringComparison.Ordinal);
|
|
stack.Children.Add(new TextBlock
|
|
{
|
|
Text = isHeaderLine ? line.Trim('[', ']') : line,
|
|
FontSize = 10.5,
|
|
FontWeight = isHeaderLine ? FontWeights.SemiBold : FontWeights.Normal,
|
|
Foreground = isHeaderLine ? primaryText : secondaryText,
|
|
TextWrapping = TextWrapping.Wrap,
|
|
Margin = isHeaderLine ? new Thickness(0, 0, 0, 3) : new Thickness(0, 0, 0, 2),
|
|
});
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(message.MetaRunId))
|
|
{
|
|
stack.Children.Add(new TextBlock
|
|
{
|
|
Text = $"run {message.MetaRunId}",
|
|
FontSize = 9.5,
|
|
Foreground = secondaryText,
|
|
Opacity = 0.7,
|
|
Margin = new Thickness(0, 6, 0, 0),
|
|
});
|
|
}
|
|
|
|
wrapper.Child = stack;
|
|
return wrapper;
|
|
}
|
|
}
|