변경 목적: - 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
420 lines
18 KiB
C#
420 lines
18 KiB
C#
using System;
|
|
using System.Linq;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using System.Windows.Input;
|
|
using System.Windows.Media;
|
|
using System.Windows.Media.Animation;
|
|
using AxCopilot.Models;
|
|
using AxCopilot.Services;
|
|
|
|
namespace AxCopilot.Views;
|
|
|
|
public partial class ChatWindow
|
|
{
|
|
private UIElement CreateMessageBubbleElement(string role, string content, bool animate = true, ChatMessage? message = null)
|
|
{
|
|
var beforeCount = GetTranscriptElementCount();
|
|
AddMessageBubble(role, content, animate, message);
|
|
var element = GetTranscriptElementAt(beforeCount)
|
|
?? throw new InvalidOperationException("메시지 버블을 생성하지 못했습니다.");
|
|
RemoveTranscriptElementAt(beforeCount);
|
|
return element;
|
|
}
|
|
|
|
private void AddMessageBubble(string role, string content, bool animate = true, ChatMessage? message = null)
|
|
{
|
|
var isUser = role == "user";
|
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
|
var itemBg = TryFindResource("ItemBackground") as Brush
|
|
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
|
|
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
|
var hintBg = TryFindResource("HintBackground") as Brush
|
|
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
|
|
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
|
var userBubbleBg = hintBg;
|
|
var assistantBubbleBg = itemBg;
|
|
|
|
if (isUser)
|
|
{
|
|
var msgMaxWidth = GetMessageMaxWidth();
|
|
var wrapper = new StackPanel
|
|
{
|
|
HorizontalAlignment = HorizontalAlignment.Right,
|
|
MaxWidth = msgMaxWidth * 0.85,
|
|
Margin = new Thickness(60, 8, 12, 10), // 좌측 여백 넓게 → 우측에 붙음
|
|
};
|
|
|
|
var bubble = new Border
|
|
{
|
|
BorderBrush = borderBrush,
|
|
BorderThickness = new Thickness(0),
|
|
CornerRadius = new CornerRadius(18),
|
|
Padding = new Thickness(16, 12, 16, 12),
|
|
HorizontalAlignment = HorizontalAlignment.Right,
|
|
};
|
|
// DynamicResource 방식으로 바인딩 — 테마 전환 시 기존 버블도 자동 업데이트
|
|
bubble.SetResourceReference(Border.BackgroundProperty, "HintBackground");
|
|
|
|
if (string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var userCodeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray;
|
|
MarkdownRenderer.EnableFilePathHighlight =
|
|
(System.Windows.Application.Current as App)?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true;
|
|
MarkdownRenderer.EnableCodeSymbolHighlight = true;
|
|
bubble.Child = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, userCodeBgBrush);
|
|
}
|
|
else
|
|
{
|
|
MarkdownRenderer.EnableCodeSymbolHighlight = false;
|
|
bubble.Child = new TextBlock
|
|
{
|
|
Text = content,
|
|
TextAlignment = TextAlignment.Left,
|
|
FontSize = 14,
|
|
Foreground = primaryText,
|
|
TextWrapping = TextWrapping.Wrap,
|
|
LineHeight = 22,
|
|
};
|
|
}
|
|
|
|
wrapper.Children.Add(bubble);
|
|
|
|
var userActionBar = new StackPanel
|
|
{
|
|
Orientation = Orientation.Horizontal,
|
|
HorizontalAlignment = HorizontalAlignment.Right,
|
|
Opacity = 0,
|
|
Margin = new Thickness(0, 2, 0, 0),
|
|
};
|
|
var capturedUserContent = content;
|
|
var userBtnColor = secondaryText;
|
|
userActionBar.Children.Add(CreateActionButton("\uE8C8", "복사", userBtnColor, () =>
|
|
{
|
|
try { Clipboard.SetText(capturedUserContent); } catch { }
|
|
}));
|
|
userActionBar.Children.Add(CreateActionButton("\uE70F", "편집", userBtnColor,
|
|
() => EnterEditMode(wrapper, capturedUserContent)));
|
|
|
|
var userBottomBar = new Grid { Margin = new Thickness(0, 1, 0, 0) };
|
|
userBottomBar.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
|
userBottomBar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
|
userBottomBar.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
|
var timestamp = message?.Timestamp ?? DateTime.Now;
|
|
Grid.SetColumn(userActionBar, 1);
|
|
userBottomBar.Children.Add(userActionBar);
|
|
|
|
var timestampText = new TextBlock
|
|
{
|
|
Text = timestamp.ToString("HH:mm"),
|
|
FontSize = 10.5,
|
|
Opacity = 0,
|
|
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
|
HorizontalAlignment = HorizontalAlignment.Right,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
Margin = new Thickness(8, 0, 2, 1),
|
|
};
|
|
Grid.SetColumn(timestampText, 2);
|
|
userBottomBar.Children.Add(timestampText);
|
|
wrapper.Children.Add(userBottomBar);
|
|
wrapper.MouseEnter += (_, _) =>
|
|
{
|
|
userActionBar.BeginAnimation(OpacityProperty,
|
|
new DoubleAnimation(0.7, TimeSpan.FromMilliseconds(150)));
|
|
timestampText.BeginAnimation(OpacityProperty,
|
|
new DoubleAnimation(0.5, TimeSpan.FromMilliseconds(150)));
|
|
};
|
|
wrapper.MouseLeave += (_, _) =>
|
|
{
|
|
var targetOpacity = ReferenceEquals(_selectedMessageActionBar, userActionBar) ? 1.0 : 0.0;
|
|
userActionBar.BeginAnimation(OpacityProperty,
|
|
new DoubleAnimation(targetOpacity, TimeSpan.FromMilliseconds(200)));
|
|
timestampText.BeginAnimation(OpacityProperty,
|
|
new DoubleAnimation(0, TimeSpan.FromMilliseconds(200)));
|
|
};
|
|
wrapper.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(userActionBar, bubble);
|
|
|
|
var userContent = content;
|
|
wrapper.MouseRightButtonUp += (_, re) =>
|
|
{
|
|
re.Handled = true;
|
|
ShowMessageContextMenu(userContent, "user");
|
|
};
|
|
|
|
if (animate)
|
|
ApplyMessageEntryAnimation(wrapper);
|
|
if (message?.MsgId != null) _elementCache[$"m_{message.MsgId}"] = wrapper;
|
|
AddTranscriptElement(wrapper);
|
|
return;
|
|
}
|
|
|
|
if (message != null && IsCompactionMetaMessage(message))
|
|
{
|
|
var compactCard = CreateCompactionMetaCard(message, primaryText, secondaryText, hintBg, borderBrush, accentBrush);
|
|
if (animate)
|
|
ApplyMessageEntryAnimation(compactCard);
|
|
if (message.MsgId != null) _elementCache[$"m_{message.MsgId}"] = compactCard;
|
|
AddTranscriptElement(compactCard);
|
|
return;
|
|
}
|
|
|
|
var assistantMaxWidth = GetMessageMaxWidth();
|
|
var container = new StackPanel
|
|
{
|
|
HorizontalAlignment = HorizontalAlignment.Left,
|
|
MaxWidth = assistantMaxWidth,
|
|
Margin = new Thickness(12, 10, 60, 10), // 우측 여백 넓게 → 좌측에 붙음
|
|
};
|
|
if (animate)
|
|
ApplyMessageEntryAnimation(container);
|
|
|
|
var (agentName, _, _) = GetAgentIdentity();
|
|
var (iconHost, iconPixels, iconGlows, iconRotate, iconScale) = CreateMiniLauncherIconEx(4.0, "none");
|
|
var header = new StackPanel { Orientation = Orientation.Horizontal, Margin = new Thickness(2, 4, 0, 0) };
|
|
header.Children.Add(iconHost);
|
|
header.Children.Add(new TextBlock
|
|
{
|
|
Text = agentName,
|
|
FontSize = 12,
|
|
FontWeight = FontWeights.Medium,
|
|
Foreground = secondaryText,
|
|
Margin = new Thickness(6, 0, 0, 0),
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
});
|
|
// 아이콘 애니메이션 적용
|
|
var canvas = iconHost.Children.OfType<Canvas>().FirstOrDefault();
|
|
if (canvas != null)
|
|
{
|
|
var animState = new ChatIconAnimState
|
|
{
|
|
Host = iconHost, Canvas = canvas, Pixels = iconPixels,
|
|
Glows = iconGlows, Rotate = iconRotate, Scale = iconScale,
|
|
IsRandomMode = _settings.Settings.Launcher.EnableChatIconRandomAnimation,
|
|
};
|
|
StartChatIconAnimation(animState);
|
|
}
|
|
|
|
var contentCard = new Border
|
|
{
|
|
Background = Brushes.Transparent,
|
|
BorderThickness = new Thickness(0),
|
|
CornerRadius = new CornerRadius(0),
|
|
Padding = new Thickness(2, 4, 2, 4),
|
|
};
|
|
var contentStack = new StackPanel();
|
|
|
|
var app = System.Windows.Application.Current as App;
|
|
MarkdownRenderer.EnableFilePathHighlight =
|
|
app?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true;
|
|
MarkdownRenderer.EnableCodeSymbolHighlight =
|
|
string.Equals(_activeTab, "Cowork", StringComparison.OrdinalIgnoreCase) ||
|
|
string.Equals(_activeTab, "Code", StringComparison.OrdinalIgnoreCase);
|
|
var codeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray;
|
|
if (IsBranchContextMessage(content))
|
|
{
|
|
var branchRun = GetAgentRunStateById(message?.MetaRunId) ?? GetLatestBranchContextRun();
|
|
var branchFiles = GetBranchContextFilePaths(message?.MetaRunId ?? branchRun?.RunId, 3);
|
|
var branchCard = new Border
|
|
{
|
|
Background = hintBg,
|
|
BorderBrush = borderBrush,
|
|
BorderThickness = new Thickness(1),
|
|
CornerRadius = new CornerRadius(14),
|
|
Padding = new Thickness(12, 10, 12, 10),
|
|
Margin = new Thickness(0, 0, 0, 6),
|
|
};
|
|
var branchStack = new StackPanel();
|
|
branchStack.Children.Add(new TextBlock
|
|
{
|
|
Text = "분기 컨텍스트",
|
|
FontSize = 11,
|
|
FontWeight = FontWeights.SemiBold,
|
|
Foreground = primaryText,
|
|
Margin = new Thickness(0, 0, 0, 6),
|
|
});
|
|
branchStack.Children.Add(MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush));
|
|
|
|
if (branchFiles.Count > 0)
|
|
{
|
|
var filesWrap = new WrapPanel { Margin = new Thickness(0, 8, 0, 0) };
|
|
foreach (var path in branchFiles)
|
|
{
|
|
var fileButton = new Button
|
|
{
|
|
Background = itemBg,
|
|
BorderBrush = borderBrush,
|
|
BorderThickness = new Thickness(1),
|
|
Padding = new Thickness(8, 3, 8, 3),
|
|
Margin = new Thickness(0, 0, 6, 6),
|
|
Cursor = Cursors.Hand,
|
|
ToolTip = path,
|
|
Content = new TextBlock
|
|
{
|
|
Text = System.IO.Path.GetFileName(path),
|
|
FontSize = 10,
|
|
FontWeight = FontWeights.SemiBold,
|
|
Foreground = primaryText,
|
|
},
|
|
};
|
|
var capturedPath = path;
|
|
fileButton.Click += (_, _) => OpenRunFilePath(capturedPath);
|
|
filesWrap.Children.Add(fileButton);
|
|
}
|
|
branchStack.Children.Add(filesWrap);
|
|
}
|
|
|
|
if (branchRun != null)
|
|
{
|
|
var actionsWrap = new WrapPanel { Margin = new Thickness(0, 8, 0, 0) };
|
|
|
|
var followUpButton = new Button
|
|
{
|
|
Background = itemBg,
|
|
BorderBrush = borderBrush,
|
|
BorderThickness = new Thickness(1),
|
|
Padding = new Thickness(8, 3, 8, 3),
|
|
Margin = new Thickness(0, 0, 6, 6),
|
|
Cursor = Cursors.Hand,
|
|
Content = new TextBlock
|
|
{
|
|
Text = "후속 작업 큐에 넣기",
|
|
FontSize = 10,
|
|
FontWeight = FontWeights.SemiBold,
|
|
Foreground = primaryText,
|
|
},
|
|
};
|
|
var capturedBranchRun = branchRun;
|
|
followUpButton.Click += (_, _) => EnqueueFollowUpFromRun(capturedBranchRun);
|
|
actionsWrap.Children.Add(followUpButton);
|
|
|
|
var timelineButton = new Button
|
|
{
|
|
Background = itemBg,
|
|
BorderBrush = borderBrush,
|
|
BorderThickness = new Thickness(1),
|
|
Padding = new Thickness(8, 3, 8, 3),
|
|
Margin = new Thickness(0, 0, 6, 6),
|
|
Cursor = Cursors.Hand,
|
|
Content = new TextBlock
|
|
{
|
|
Text = "관련 로그로 이동",
|
|
FontSize = 10,
|
|
FontWeight = FontWeights.SemiBold,
|
|
Foreground = primaryText,
|
|
},
|
|
};
|
|
timelineButton.Click += (_, _) => ScrollToRunInTimeline(capturedBranchRun.RunId);
|
|
actionsWrap.Children.Add(timelineButton);
|
|
|
|
branchStack.Children.Add(actionsWrap);
|
|
}
|
|
|
|
branchCard.Child = branchStack;
|
|
contentStack.Children.Add(branchCard);
|
|
}
|
|
else
|
|
{
|
|
contentStack.Children.Add(MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush));
|
|
}
|
|
|
|
contentCard.Child = contentStack;
|
|
container.Children.Add(contentCard);
|
|
|
|
// 에이전트 이름 푸터 (메시지 본문 아래)
|
|
container.Children.Add(header);
|
|
|
|
// 어시스턴트 메시지에 파일 경로가 포함되어 있으면 프리뷰/열기 퀵 액션 추가
|
|
var outputFilePath = ExtractOutputFilePathFromContent(content);
|
|
if (!string.IsNullOrEmpty(outputFilePath) && System.IO.File.Exists(outputFilePath))
|
|
{
|
|
var quickActions = BuildFileQuickActions(outputFilePath);
|
|
quickActions.Margin = new Thickness(2, 4, 0, 2);
|
|
container.Children.Add(quickActions);
|
|
}
|
|
|
|
var actionBar = new StackPanel
|
|
{
|
|
Orientation = Orientation.Horizontal,
|
|
HorizontalAlignment = HorizontalAlignment.Left,
|
|
Margin = new Thickness(2, 2, 0, 0),
|
|
Opacity = 0,
|
|
};
|
|
var btnColor = secondaryText;
|
|
var capturedContent = content;
|
|
|
|
actionBar.Children.Add(CreateActionButton("\uE8C8", "복사", btnColor, () =>
|
|
{
|
|
try { Clipboard.SetText(capturedContent); } catch { }
|
|
}));
|
|
actionBar.Children.Add(CreateActionButton("\uE72C", "다시 생성", btnColor, () => _ = RegenerateLastAsync()));
|
|
actionBar.Children.Add(CreateActionButton("\uE70F", "수정 후 재시도", btnColor, () => ShowRetryWithFeedbackInput()));
|
|
AddLinkedFeedbackButtons(actionBar, btnColor, message);
|
|
|
|
var aiTimestamp = message?.Timestamp ?? DateTime.Now;
|
|
actionBar.Children.Add(new TextBlock
|
|
{
|
|
Text = aiTimestamp.ToString("HH:mm"),
|
|
FontSize = 10.5,
|
|
Opacity = 0.52,
|
|
Foreground = btnColor,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
Margin = new Thickness(4, 0, 0, 1),
|
|
});
|
|
|
|
container.Children.Add(actionBar);
|
|
var assistantMeta = CreateAssistantMessageMetaText(message);
|
|
if (assistantMeta != null)
|
|
container.Children.Add(assistantMeta);
|
|
|
|
container.MouseEnter += (_, _) =>
|
|
{
|
|
actionBar.BeginAnimation(OpacityProperty,
|
|
new DoubleAnimation(1, TimeSpan.FromMilliseconds(150)));
|
|
};
|
|
container.MouseLeave += (_, _) =>
|
|
{
|
|
var targetOpacity = ReferenceEquals(_selectedMessageActionBar, actionBar) ? 1.0 : 0.0;
|
|
actionBar.BeginAnimation(OpacityProperty,
|
|
new DoubleAnimation(targetOpacity, TimeSpan.FromMilliseconds(200)));
|
|
};
|
|
container.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(actionBar, contentCard);
|
|
|
|
var aiContent = content;
|
|
container.MouseRightButtonUp += (_, re) =>
|
|
{
|
|
re.Handled = true;
|
|
ShowMessageContextMenu(aiContent, "assistant");
|
|
};
|
|
|
|
if (message?.MsgId != null) _elementCache[$"m_{message.MsgId}"] = container;
|
|
AddTranscriptElement(container);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 어시스턴트 메시지 텍스트에서 출력 파일 경로(절대 경로)를 추출합니다.
|
|
/// "완료: C:\...\file.ext" 패턴을 우선 찾고, 없으면 일반 절대 경로를 검색합니다.
|
|
/// </summary>
|
|
private static string? ExtractOutputFilePathFromContent(string content)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(content)) return null;
|
|
|
|
// 패턴 1: "완료: C:\path\file.ext" 또는 "완료: E:\path\file.ext"
|
|
var completionMatch = System.Text.RegularExpressions.Regex.Match(
|
|
content,
|
|
@"완료:\s*([A-Za-z]:\\[^\s\n""',;)]+\.(?:docx|xlsx|pptx|html|htm|pdf|csv|md|txt))");
|
|
if (completionMatch.Success)
|
|
return completionMatch.Groups[1].Value.TrimEnd('.');
|
|
|
|
// 패턴 2: 임의의 절대 경로 (알려진 문서 확장자)
|
|
var absMatch = System.Text.RegularExpressions.Regex.Match(
|
|
content,
|
|
@"([A-Za-z]:\\[^\s\n""',;)]+\.(?:docx|xlsx|pptx|html|htm|pdf|csv|md|txt))");
|
|
if (absMatch.Success)
|
|
return absMatch.Groups[1].Value.TrimEnd('.');
|
|
|
|
return null;
|
|
}
|
|
}
|