변경 목적: - AX Agent의 도구 이름, 내부 설정, 스킬 정책, 실행 루프 사이의 불일치를 줄이고 전체 동작 품질을 높인다. - claw-code 수준의 일관된 동작 품질을 참고하되 AX 구조에 맞는 고유한 카탈로그·정규화 레이어로 재구성한다. 핵심 수정사항: - 도구 canonical id, legacy alias, 탭 노출, 설정 카테고리, read-only 분류를 중앙 카탈로그로 통합했다. - ToolRegistry, AgentLoopService, 병렬 실행 분류, 권한 처리, 훅 처리, 스킬 allowed-tools 해석이 같은 이름 체계를 사용하도록 정리했다. - Agent 설정/일반 설정/도움말의 도구 카드와 훅 편집기, 스킬 설명을 현재 런타임 구조에 맞게 갱신했다. - 컨텍스트 압축, intent gate, spawn agents, session learning, model prompt adapter, workspace context 관련 변경과 테스트 추가를 함께 반영했다. - 문서 이력과 비교/로드맵 문서를 최신 상태로 갱신했다. 검증 결과: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify_toolcat\ -p:IntermediateOutputPath=obj\verify_toolcat\ : 경고 0 / 오류 0 - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter AgentToolCatalogTests -p:OutputPath=bin\verify_toolcat_tests\ -p:IntermediateOutputPath=obj\verify_toolcat_tests\ : 통과 8
258 lines
11 KiB
C#
258 lines
11 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Linq;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Media;
|
|
using System.Windows.Threading;
|
|
using AxCopilot.Models;
|
|
using AxCopilot.Services;
|
|
using AxCopilot.Services.Agent;
|
|
|
|
namespace AxCopilot.Views;
|
|
|
|
public partial class ChatWindow
|
|
{
|
|
// ─── V2 렌더 상태 ───────────────────────────────────────────────────
|
|
private string? _v2LastRenderedConversationId;
|
|
private int _v2LastRenderedMessageCount;
|
|
private int _v2LastRenderedEventCount;
|
|
private readonly List<string> _v2LastRenderedKeys = new();
|
|
|
|
// V2 라이브 프로그레스 상태
|
|
private StackPanel? _v2LiveContainer;
|
|
private readonly Dictionary<string, Border> _v2LiveToolCards = new();
|
|
private string? _v2LastLiveToolCallId;
|
|
|
|
private void RenderMessagesV2(
|
|
ChatConversation conv,
|
|
IReadOnlyList<ChatMessage> visibleMessages,
|
|
IReadOnlyList<ChatExecutionEvent> visibleEvents,
|
|
bool preserveViewport,
|
|
double previousScrollableHeight,
|
|
double previousVerticalOffset,
|
|
Stopwatch renderStopwatch,
|
|
string? caller)
|
|
{
|
|
try
|
|
{
|
|
// 대화 전환 감지 → 캐시 초기화
|
|
if (!string.Equals(_v2LastRenderedConversationId, conv.Id, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
_v2LastRenderedConversationId = conv.Id;
|
|
_v2LastRenderedKeys.Clear();
|
|
_v2LastRenderedMessageCount = 0;
|
|
_v2LastRenderedEventCount = 0;
|
|
_elementCache.Clear();
|
|
}
|
|
|
|
// ★ 패널이 비어있는데 V2 캐시가 남아있는 경우 강제 리셋
|
|
// 탭 전환 시 ClearTranscriptElements()로 패널이 비워지지만
|
|
// 빈 대화 탭을 거치면 V2가 호출되지 않아 캐시가 잔류하는 버그 방지
|
|
if (GetTranscriptElementCount() == 0 && _v2LastRenderedKeys.Count > 0)
|
|
{
|
|
LogService.Info($"[V2Render] CACHE STALE RESET: panel=0 but cachedKeys={_v2LastRenderedKeys.Count}, forcing full rebuild");
|
|
_v2LastRenderedKeys.Clear();
|
|
_v2LastRenderedMessageCount = 0;
|
|
_v2LastRenderedEventCount = 0;
|
|
}
|
|
|
|
// 통합 타임라인 빌드 (메시지 + 이벤트를 시간순 병합)
|
|
var timeline = BuildV2Timeline(visibleMessages, visibleEvents);
|
|
|
|
LogService.Info($"[V2Render] timeline={timeline.Count}, msgs={visibleMessages.Count}, evts={visibleEvents.Count}, convId={conv.Id[..Math.Min(8, conv.Id.Length)]}, caller={caller}");
|
|
|
|
// 새 키 목록 생성
|
|
var newKeys = new List<string>(timeline.Count);
|
|
foreach (var item in timeline)
|
|
newKeys.Add(item.Key);
|
|
|
|
// 인크리멘탈 렌더 시도: 기존 키가 새 키의 접두사인 경우 추가분만 렌더
|
|
// ★ 패널에 실제 엘리먼트가 있어야 인크리멘탈 가능 —
|
|
// 탭 전환 시 ClearTranscriptElements()로 패널이 비워지지만
|
|
// V2 캐시(_v2LastRenderedKeys)는 유지되어 "이미 렌더됨"으로 오판하는 버그 방지
|
|
var actualElementCount = GetTranscriptElementCount();
|
|
var canIncremental = _v2LastRenderedKeys.Count > 0
|
|
&& actualElementCount > 0
|
|
&& newKeys.Count >= _v2LastRenderedKeys.Count
|
|
&& KeysArePrefixMatch(_v2LastRenderedKeys, newKeys);
|
|
|
|
LogService.Info($"[V2Render] canIncremental={canIncremental}, prevKeys={_v2LastRenderedKeys.Count}, newKeys={newKeys.Count}, preElementCount={GetTranscriptElementCount()}");
|
|
|
|
if (canIncremental)
|
|
{
|
|
// 라이브 컨테이너가 있으면 임시 제거 (맨 끝에 다시 추가)
|
|
if (_v2LiveContainer != null && ContainsTranscriptElement(_v2LiveContainer))
|
|
RemoveTranscriptElement(_v2LiveContainer);
|
|
|
|
for (int i = _v2LastRenderedKeys.Count; i < timeline.Count; i++)
|
|
{
|
|
var item = timeline[i];
|
|
AddDeferredTranscriptElement(item.Key, () =>
|
|
{
|
|
if (_elementCache.TryGetValue(item.Key, out var cached))
|
|
return cached;
|
|
var element = item.CreateElement();
|
|
_elementCache[item.Key] = element;
|
|
return element;
|
|
});
|
|
}
|
|
|
|
// 라이브 컨테이너 재삽입
|
|
if (_v2LiveContainer != null && _isStreaming)
|
|
AddTranscriptElement(_v2LiveContainer);
|
|
}
|
|
else
|
|
{
|
|
// 전체 재빌드
|
|
ClearTranscriptElements();
|
|
|
|
foreach (var item in timeline)
|
|
{
|
|
var capturedItem = item;
|
|
AddDeferredTranscriptElement(capturedItem.Key, () =>
|
|
{
|
|
if (_elementCache.TryGetValue(capturedItem.Key, out var cached))
|
|
return cached;
|
|
var element = capturedItem.CreateElement();
|
|
_elementCache[capturedItem.Key] = element;
|
|
return element;
|
|
});
|
|
}
|
|
|
|
// 라이브 컨테이너 재삽입
|
|
if (_v2LiveContainer != null && _isStreaming)
|
|
AddTranscriptElement(_v2LiveContainer);
|
|
}
|
|
|
|
_v2LastRenderedKeys.Clear();
|
|
_v2LastRenderedKeys.AddRange(newKeys);
|
|
_v2LastRenderedMessageCount = visibleMessages.Count;
|
|
_v2LastRenderedEventCount = visibleEvents.Count;
|
|
|
|
LogService.Info($"[V2Render] DONE postElementCount={GetTranscriptElementCount()}, savedKeys={_v2LastRenderedKeys.Count}");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
LogService.Error($"[V2Render] 렌더링 예외: {ex.GetType().Name}: {ex.Message}\n{ex.StackTrace}");
|
|
}
|
|
|
|
_lastRenderTicks = Environment.TickCount64;
|
|
_lastRenderedMessageCount = visibleMessages.Count;
|
|
_lastRenderedEventCount = visibleEvents.Count;
|
|
_lastRenderedShowHistory = conv?.ShowExecutionHistory ?? true;
|
|
renderStopwatch.Stop();
|
|
|
|
if (renderStopwatch.ElapsedMilliseconds >= (_isStreaming ? 100 : 24))
|
|
{
|
|
AgentPerformanceLogService.LogMetric(
|
|
"transcript", "render_messages_v2", conv?.Id ?? "", _activeTab ?? "",
|
|
renderStopwatch.ElapsedMilliseconds,
|
|
new { preserveViewport, streaming = _isStreaming, visibleMessages = visibleMessages.Count, visibleEvents = visibleEvents.Count });
|
|
}
|
|
|
|
if (!preserveViewport)
|
|
{
|
|
_ = Dispatcher.InvokeAsync(ScrollTranscriptToEnd, DispatcherPriority.Background);
|
|
return;
|
|
}
|
|
|
|
_ = Dispatcher.InvokeAsync(() =>
|
|
{
|
|
if (_transcriptScrollViewer == null) return;
|
|
var newScrollableHeight = GetTranscriptScrollableHeight();
|
|
var delta = newScrollableHeight - previousScrollableHeight;
|
|
var targetOffset = Math.Max(0, previousVerticalOffset + Math.Max(0, delta));
|
|
ScrollTranscriptToVerticalOffset(targetOffset);
|
|
}, DispatcherPriority.Background);
|
|
}
|
|
|
|
private static bool KeysArePrefixMatch(List<string> oldKeys, List<string> newKeys)
|
|
{
|
|
for (int i = 0; i < oldKeys.Count; i++)
|
|
{
|
|
if (!string.Equals(oldKeys[i], newKeys[i], StringComparison.Ordinal))
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private sealed record V2TimelineItem(string Key, DateTime Timestamp, Func<UIElement> CreateElement);
|
|
|
|
private List<V2TimelineItem> BuildV2Timeline(
|
|
IReadOnlyList<ChatMessage> visibleMessages,
|
|
IReadOnlyList<ChatExecutionEvent> visibleEvents)
|
|
{
|
|
var timeline = new List<V2TimelineItem>(visibleMessages.Count + visibleEvents.Count);
|
|
|
|
// 1. 메시지 추가
|
|
foreach (var msg in visibleMessages)
|
|
{
|
|
var capturedMsg = msg;
|
|
var key = $"v2m_{msg.MsgId}";
|
|
timeline.Add(new V2TimelineItem(key, msg.Timestamp, () => CreateV2MessageElement(capturedMsg)));
|
|
}
|
|
|
|
// 2. 실행 이벤트 추가 — ToolCall+ToolResult 쌍을 병합
|
|
// 스트리밍 중이면 라이브 카드가 이미 표시하는 현재 실행 이벤트는 타임라인에서 제외
|
|
var eventIndex = 0;
|
|
var events = visibleEvents.ToList();
|
|
var liveCardCutoff = (_isStreaming && _v2LiveContainer != null)
|
|
? _v2LiveStartTime
|
|
: DateTime.MaxValue;
|
|
|
|
for (int i = 0; i < events.Count; i++)
|
|
{
|
|
var executionEvent = events[i];
|
|
|
|
// 스트리밍 중: 라이브 카드 시작 이후 이벤트는 라이브 카드에서 표시하므로 스킵
|
|
if (executionEvent.Timestamp.ToUniversalTime() >= liveCardCutoff)
|
|
continue;
|
|
|
|
var agentEvent = ToAgentEvent(executionEvent);
|
|
|
|
// SessionStart / UserPromptSubmit 숨김
|
|
if (agentEvent.Type == AgentEventType.SessionStart || agentEvent.Type == AgentEventType.UserPromptSubmit)
|
|
continue;
|
|
|
|
var key = $"v2e_{executionEvent.Timestamp.Ticks}_{eventIndex++}";
|
|
|
|
// ToolCall → 바로 다음 같은 도구의 ToolResult를 찾아 병합
|
|
if (agentEvent.Type == AgentEventType.ToolCall && i + 1 < events.Count)
|
|
{
|
|
var nextEvent = ToAgentEvent(events[i + 1]);
|
|
if (nextEvent.Type == AgentEventType.ToolResult
|
|
&& string.Equals(nextEvent.ToolName, agentEvent.ToolName, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var capturedCall = agentEvent;
|
|
var capturedResult = nextEvent;
|
|
timeline.Add(new V2TimelineItem(key, executionEvent.Timestamp,
|
|
() => CreateV2ToolExecutionCard(capturedCall, capturedResult)));
|
|
i++; // ToolResult 스킵
|
|
continue;
|
|
}
|
|
}
|
|
|
|
var capturedEvent = agentEvent;
|
|
timeline.Add(new V2TimelineItem(key, executionEvent.Timestamp,
|
|
() => CreateV2AgentEventElement(capturedEvent)));
|
|
}
|
|
|
|
// 시간순 정렬
|
|
var needsSort = false;
|
|
for (int i = 1; i < timeline.Count; i++)
|
|
{
|
|
if (timeline[i].Timestamp < timeline[i - 1].Timestamp)
|
|
{
|
|
needsSort = true;
|
|
break;
|
|
}
|
|
}
|
|
if (needsSort)
|
|
timeline.Sort((a, b) => a.Timestamp.CompareTo(b.Timestamp));
|
|
|
|
return timeline;
|
|
}
|
|
}
|