Files
AX-Copilot-Codex/src/AxCopilot/Views/ChatWindow.AgentStatusPresentation.cs
lacvet 8cb08576d5 AX Agent 도구·스킬 정합성 재구성 및 실행 품질 보강
변경 목적:
- 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
2026-04-14 17:52:46 +09:00

1599 lines
66 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Threading;
using AxCopilot.Models;
using AxCopilot.Services;
using AxCopilot.Services.Agent;
namespace AxCopilot.Views;
public partial class ChatWindow
{
// ─── Claude 스타일 펄스 닷 + 단계 표시 (입력창 위) ──────────────────────────
private System.Windows.Media.Animation.Storyboard? _pulseDotStoryboard;
private System.Windows.Media.Animation.Storyboard? _statusDiamondStoryboard;
private readonly System.Collections.Generic.List<string> _activeSubItems = new();
private string? _currentSubItemCategory; // 현재 세부 항목 카테고리 (변경 시 초기화)
private const int MaxStatusSubItems = 6;
// ShowStreamingStatusBar → 펄스 닷 바로 위임 (플로팅 상태 바 표시 안 함)
private void ShowStreamingStatusBar(string message, string? iconCode = null)
=> ShowPulseDots(message, iconCode);
private void HideStreamingStatusBar()
=> HidePulseDots();
private void UpdateStreamingStatusBar(string message, string? iconCode = null)
=> UpdatePulseDotsText(message, iconCode);
// ─── 입력창 위 펄스 닷 애니메이션 ──────────────────────────────────────────
private void ShowPulseDots(string? message = null, string? iconCode = null, string? detail = null)
{
if (PulseDotBar == null) return;
if (PulseDotStatusText != null)
PulseDotStatusText.Text = message ?? "생각하는 중...";
ClearStatusSubItems();
PulseDotBar.Visibility = Visibility.Visible;
StartStatusDiamondAnimation();
if (_pulseDotStoryboard != null) return; // 이미 실행 중
var sb = new System.Windows.Media.Animation.Storyboard();
var dots = new System.Windows.Shapes.Ellipse[] { PulseDot1, PulseDot2, PulseDot3 };
const double cycleSecs = 1.2;
for (int i = 0; i < dots.Length; i++)
{
var animX = new System.Windows.Media.Animation.DoubleAnimationUsingKeyFrames
{
BeginTime = TimeSpan.FromSeconds(i * 0.2),
RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever,
Duration = new System.Windows.Duration(TimeSpan.FromSeconds(cycleSecs)),
};
animX.KeyFrames.Add(new System.Windows.Media.Animation.EasingDoubleKeyFrame(
0.3, System.Windows.Media.Animation.KeyTime.FromTimeSpan(TimeSpan.Zero)));
animX.KeyFrames.Add(new System.Windows.Media.Animation.EasingDoubleKeyFrame(
1.0, System.Windows.Media.Animation.KeyTime.FromTimeSpan(TimeSpan.FromSeconds(cycleSecs * 0.4)))
{
EasingFunction = new System.Windows.Media.Animation.SineEase
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseInOut }
});
animX.KeyFrames.Add(new System.Windows.Media.Animation.EasingDoubleKeyFrame(
0.3, System.Windows.Media.Animation.KeyTime.FromTimeSpan(TimeSpan.FromSeconds(cycleSecs * 0.8)))
{
EasingFunction = new System.Windows.Media.Animation.SineEase
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseInOut }
});
animX.KeyFrames.Add(new System.Windows.Media.Animation.EasingDoubleKeyFrame(
0.3, System.Windows.Media.Animation.KeyTime.FromTimeSpan(TimeSpan.FromSeconds(cycleSecs))));
System.Windows.Media.Animation.Storyboard.SetTarget(animX, dots[i]);
System.Windows.Media.Animation.Storyboard.SetTargetProperty(animX,
new PropertyPath(UIElement.OpacityProperty));
sb.Children.Add(animX);
}
_pulseDotStoryboard = sb;
_pulseDotStoryboard.Begin();
}
private void HidePulseDots()
{
if (PulseDotBar == null) return;
_pulseDotStoryboard?.Stop();
_pulseDotStoryboard = null;
if (PulseDot1 != null) PulseDot1.Opacity = 0.3;
if (PulseDot2 != null) PulseDot2.Opacity = 0.3;
if (PulseDot3 != null) PulseDot3.Opacity = 0.3;
StopStatusDiamondAnimation();
ClearStatusSubItems();
_currentSubItemCategory = null;
PulseDotBar.Visibility = Visibility.Collapsed;
}
// ─── 채팅창 내 에이전트 라이브 진행 카드 ─────────────────────────────────────
// ─── 미니 다이아몬드 아이콘 애니메이션 ───────────────────────────────────────
private void StartStatusDiamondAnimation()
{
if (_statusDiamondStoryboard != null) return;
if (StatusIconScale == null || StatusPixelBlue == null) return;
var sb = new System.Windows.Media.Animation.Storyboard();
// 심장 박동 스케일 펄스 (부드러운 단일 박동 — 레이아웃 부하 경감)
var scaleAnimX = new System.Windows.Media.Animation.DoubleAnimationUsingKeyFrames
{
RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever,
Duration = new System.Windows.Duration(TimeSpan.FromSeconds(3.0)),
};
var beatTimes = new (double t, double v)[]
{
(0.00, 1.00), (0.18, 1.15), (0.40, 1.00), (3.00, 1.00),
};
foreach (var (t, v) in beatTimes)
{
scaleAnimX.KeyFrames.Add(new System.Windows.Media.Animation.EasingDoubleKeyFrame(v,
System.Windows.Media.Animation.KeyTime.FromTimeSpan(TimeSpan.FromSeconds(t)))
{
EasingFunction = new System.Windows.Media.Animation.QuadraticEase
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseInOut }
});
}
System.Windows.Media.Animation.Storyboard.SetTarget(scaleAnimX, StatusIconScale);
System.Windows.Media.Animation.Storyboard.SetTargetProperty(scaleAnimX, new PropertyPath("ScaleX"));
sb.Children.Add(scaleAnimX);
var scaleAnimY = scaleAnimX.Clone();
System.Windows.Media.Animation.Storyboard.SetTarget(scaleAnimY, StatusIconScale);
System.Windows.Media.Animation.Storyboard.SetTargetProperty(scaleAnimY, new PropertyPath("ScaleY"));
sb.Children.Add(scaleAnimY);
// 픽셀 교차 페이드 (파란→초록→빨간 색상이 교대로 빛남, 부드럽게)
var pixels = new System.Windows.Shapes.Rectangle?[]
{ StatusPixelBlue, StatusPixelGreen1, StatusPixelGreen2, StatusPixelRed };
for (int i = 0; i < pixels.Length; i++)
{
if (pixels[i] == null) continue;
var fade = new System.Windows.Media.Animation.DoubleAnimation(
fromValue: 1.0, toValue: 0.55,
duration: new System.Windows.Duration(TimeSpan.FromSeconds(1.2)))
{
AutoReverse = true,
RepeatBehavior = System.Windows.Media.Animation.RepeatBehavior.Forever,
BeginTime = TimeSpan.FromSeconds(i * 0.30),
EasingFunction = new System.Windows.Media.Animation.SineEase
{ EasingMode = System.Windows.Media.Animation.EasingMode.EaseInOut },
};
System.Windows.Media.Animation.Storyboard.SetTarget(fade, pixels[i]);
System.Windows.Media.Animation.Storyboard.SetTargetProperty(fade,
new PropertyPath(UIElement.OpacityProperty));
sb.Children.Add(fade);
}
_statusDiamondStoryboard = sb;
_statusDiamondStoryboard.Begin();
}
private void StopStatusDiamondAnimation()
{
_statusDiamondStoryboard?.Stop();
_statusDiamondStoryboard = null;
if (StatusIconScale != null) { StatusIconScale.ScaleX = 1; StatusIconScale.ScaleY = 1; }
if (StatusPixelBlue != null) StatusPixelBlue.Opacity = 1;
if (StatusPixelGreen1 != null) StatusPixelGreen1.Opacity = 1;
if (StatusPixelGreen2 != null) StatusPixelGreen2.Opacity = 1;
if (StatusPixelRed != null) StatusPixelRed.Opacity = 1;
}
// ─── 세부 항목 서브텍스트 관리 ──────────────────────────────────────────────
private void AddStatusSubItem(string text, string? category = null)
{
if (PulseDotSubItems == null) return;
if (_activeSubItems.Contains(text)) return; // 중복 방지
// 카테고리 변경 시 기존 항목 초기화
if (category != null && category != _currentSubItemCategory)
{
ClearStatusSubItems();
_currentSubItemCategory = category;
}
_activeSubItems.Add(text);
if (_activeSubItems.Count > MaxStatusSubItems)
{
_activeSubItems.RemoveAt(0);
if (PulseDotSubItems.Children.Count > 0)
PulseDotSubItems.Children.RemoveAt(0);
}
var secondary = TryFindResource("SecondaryText") as System.Windows.Media.Brush
?? System.Windows.Media.Brushes.Gray;
var tb = new TextBlock
{
Text = $" {text}",
FontSize = 12.5,
FontFamily = new System.Windows.Media.FontFamily("Segoe UI, Malgun Gothic"),
Foreground = secondary,
Opacity = 0.60,
LineHeight = 18,
TextTrimming = TextTrimming.CharacterEllipsis,
MaxWidth = 380,
Margin = new Thickness(0, 0, 0, 1),
};
PulseDotSubItems.Children.Add(tb);
}
private void ClearStatusSubItems()
{
_activeSubItems.Clear();
PulseDotSubItems?.Children.Clear();
}
private void UpdatePulseDotsText(string message, string? iconCode = null, string? detail = null,
bool clearSubItems = false, string? subItemCategory = null)
{
if (PulseDotBar?.Visibility != Visibility.Visible) return;
if (PulseDotStatusText != null) PulseDotStatusText.Text = message;
if (clearSubItems) ClearStatusSubItems();
if (!string.IsNullOrEmpty(detail))
AddStatusSubItem(detail, subItemCategory);
}
// 레거시 호환 (단일 세부 텍스트 — 현재는 서브 아이템 목록으로 대체)
private void SetPulseDotDetail(string? detail)
{
if (!string.IsNullOrEmpty(detail))
AddStatusSubItem(detail);
else
ClearStatusSubItems();
}
// 도구 이름 → (상태 메시지, MDL2 아이콘 코드, 카테고리) 변환
private static (string message, string icon, string category) GetStatusInfoForTool(string toolName)
=> toolName.ToLowerInvariant() switch
{
"document_read" => ("문서 읽는 중", "\uE8A5", "read"),
"file_read" => ("파일 읽는 중", "\uE8A5", "read"),
"folder_map" => ("폴더 구조 파악 중", "\uE8B7", "folder"),
"file_write" or "html_create" or "md_create"
or "docx_create" or "xlsx_create"
or "csv_create" or "script_create"
or "pptx_create" => ("파일 작성 중", "\uE8A3", "write"),
"file_edit" => ("파일 수정 중", "\uE70F", "edit"),
"terminal" => ("명령 실행 중", "\uE756", "terminal"),
"web_search" => ("웹 검색 중", "\uE721", "search"),
"document_plan" => ("작업 계획 수립 중", "\uE8F1", "plan"),
"diff_preview" => ("변경 사항 검토 중", "\uE8A9", "diff"),
"code_run" or "run_script" => ("코드 실행 중", "\uE756", "run"),
"git_commit" or "git_push" or "git_pull"
or "git_status" or "git_diff" => ("Git 작업 중", "\uE8A7", "git"),
_ => ("작업 실행 중", "\uE8A7", "misc"),
};
// 도구별 서브 아이템 접미사 ("> filename.txt 읽고 분석 중")
private static string GetToolSubItemSuffix(string toolName)
=> toolName.ToLowerInvariant() switch
{
"document_read" or "file_read" => "읽고 분석 중",
"folder_map" => "구조 파악 중",
"file_write" or "html_create" or "md_create"
or "docx_create" or "xlsx_create"
or "csv_create" or "script_create"
or "pptx_create" => "작성 중",
"file_edit" => "수정 중",
"terminal" => "실행 중",
"web_search" => "검색 중",
"code_run" or "run_script" => "실행 중",
"diff_preview" => "비교 중",
"git_commit" => "커밋 중",
"git_push" => "푸시 중",
"git_pull" => "풀 중",
_ => "처리 중",
};
// 도구 결과 수신 후 표시할 메시지
private static string GetToolResultMessage(string toolName)
=> toolName.ToLowerInvariant() switch
{
"document_read" or "file_read" => "내용 분석 중",
"folder_map" => "구조 파악 중",
"web_search" => "검색 결과 검토 중",
"file_write" or "html_create" or "md_create"
or "docx_create" or "xlsx_create"
or "csv_create" or "script_create"
or "pptx_create" => "작성 완료, 검토 중",
"terminal" or "code_run" or "run_script" => "실행 결과 분석 중",
"git_commit" or "git_push" or "git_pull" => "Git 결과 확인 중",
_ => "결과 처리 중",
};
// 하위 호환 (단순 문자열만 필요한 경우)
private static string GetStatusMessageForTool(string toolName)
=> GetStatusInfoForTool(toolName).message + "...";
private void TouchLiveAgentProgressHints()
{
_lastAgentProgressEventAt = DateTime.UtcNow;
}
private void AgentProgressHintTimer_Tick(object? sender, EventArgs e)
{
if (!_streamingTabs.Contains(_activeTab) || !GetAgentLoop(_activeTab).IsRunning)
{
StopLiveAgentProgressHints();
return;
}
// _streamingTabs.Contains(_activeTab)가 위에서 이미 검증됨 — _streamRunTab은 다른 탭 시작 시 덮어써질 수 있으므로 _activeTab 사용
var runTab = _activeTab;
if (!string.Equals(runTab, "Cowork", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(runTab, "Code", StringComparison.OrdinalIgnoreCase))
{
UpdateLiveAgentProgressHint(null);
return;
}
var idle = DateTime.UtcNow - _lastAgentProgressEventAt;
TryGetStreamingElapsed(out var elapsed);
string? summary = null;
var toolName = "agent_wait";
if (_pendingPostCompaction && idle >= TimeSpan.FromSeconds(2))
{
toolName = "context_compaction";
summary = idle >= TimeSpan.FromSeconds(12)
? "컨텍스트를 압축한 뒤 응답을 정리하는 중입니다..."
: "컨텍스트를 압축하고 있습니다...";
UpdateStreamingStatusBar("컨텍스트 정리 중...", "\uE72C");
}
else if (idle >= TimeSpan.FromSeconds(90))
{
summary = "대용량 컨텍스트 처리 중입니다... (최대 3분 소요될 수 있습니다)";
UpdateStreamingStatusBar("대용량 컨텍스트 처리 중...", "\uE895");
}
else if (idle >= TimeSpan.FromSeconds(30))
{
summary = "모델이 응답을 준비하는 중입니다...";
UpdateStreamingStatusBar("모델이 응답 준비 중...", "\uE895");
}
else if (idle >= TimeSpan.FromSeconds(12))
{
summary = "응답을 정리하는 중입니다...";
}
else if (idle >= TimeSpan.FromSeconds(5))
{
summary = "생각을 정리하는 중입니다...";
}
else if (elapsed >= TimeSpan.FromSeconds(4))
{
summary = "작업을 진행하는 중입니다...";
}
UpdateLiveAgentProgressHint(summary, toolName);
}
private void UpdateLiveAgentProgressHint(string? summary, string toolName = "")
{
var normalizedSummary = string.IsNullOrWhiteSpace(summary) ? null : summary.Trim();
var currentSummary = _liveAgentProgressHint?.Summary;
var currentToolName = _liveAgentProgressHint?.ToolName ?? "";
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;
var nextElapsedBucket = elapsedMs / 3000;
var currentTokenBucket = ((_liveAgentProgressHint?.InputTokens ?? 0) + (_liveAgentProgressHint?.OutputTokens ?? 0)) / 500;
var nextTokenBucket = (inputTokens + outputTokens) / 500;
if (string.Equals(currentSummary, normalizedSummary, StringComparison.Ordinal)
&& string.Equals(currentToolName, toolName, StringComparison.Ordinal)
&& currentElapsedBucket == nextElapsedBucket
&& currentTokenBucket == nextTokenBucket)
return;
_liveAgentProgressHint = normalizedSummary == null
? null
: new AgentEvent
{
Timestamp = DateTime.Now,
RunId = _appState.AgentRun.RunId,
Type = AgentEventType.Thinking,
ToolName = toolName,
Summary = normalizedSummary,
ElapsedMs = elapsedMs,
InputTokens = (int)inputTokens,
OutputTokens = (int)outputTokens,
};
// 스트리밍 중 프로그레스 힌트 변경으로 인한 ScheduleExecutionHistoryRender 호출 차단
// 힌트 업데이트만으로 전체 트랜스크립트를 재렌더하면 UI 스레드 멈춤 발생
// 타이머가 자연스럽게 다음 렌더 사이클에서 힌트를 반영함
}
private AgentEvent? GetLiveAgentProgressHint()
{
if (_liveAgentProgressHint == null)
return null;
var runTab = string.IsNullOrWhiteSpace(_streamRunTab) ? _activeTab : _streamRunTab!;
if (!string.Equals(runTab, _activeTab, StringComparison.OrdinalIgnoreCase))
return null;
return new AgentEvent
{
Timestamp = _liveAgentProgressHint.Timestamp,
RunId = _liveAgentProgressHint.RunId,
Type = _liveAgentProgressHint.Type,
ToolName = _liveAgentProgressHint.ToolName,
Summary = _liveAgentProgressHint.Summary,
FilePath = _liveAgentProgressHint.FilePath,
Success = _liveAgentProgressHint.Success,
StepCurrent = _liveAgentProgressHint.StepCurrent,
StepTotal = _liveAgentProgressHint.StepTotal,
Steps = _liveAgentProgressHint.Steps,
ElapsedMs = _liveAgentProgressHint.ElapsedMs,
InputTokens = _liveAgentProgressHint.InputTokens,
OutputTokens = _liveAgentProgressHint.OutputTokens,
ToolInput = _liveAgentProgressHint.ToolInput,
Iteration = _liveAgentProgressHint.Iteration,
};
}
private static bool ShouldRenderProgressEventWhenHistoryCollapsed(AgentEvent evt)
{
if (evt.Type == AgentEventType.Complete || evt.Type == AgentEventType.Error)
return true;
if (evt.Type == AgentEventType.Thinking)
{
if (string.Equals(evt.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase)
|| string.Equals(evt.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase))
return true;
if (!string.IsNullOrWhiteSpace(evt.Summary))
return true;
}
return IsProcessFeedEvent(evt);
}
private void OnSubAgentStatusChanged(SubAgentStatusEvent evt)
{
Dispatcher.BeginInvoke(() =>
{
try
{
_appState.ApplySubAgentStatus(evt);
ScheduleTaskSummaryRefresh();
}
catch (Exception ex)
{
LogService.Warn($"OnSubAgentStatusChanged 처리 실패: {ex.Message}");
}
});
}
private void AppendConversationAgentRun(AgentEvent evt, string status, string summary, string targetTab)
{
lock (_convLock)
{
var session = _appState.ChatSession;
if (session == null)
return;
var result = _chatEngine.AppendAgentRun(
session,
_storage,
_currentConversation,
_activeTab,
targetTab,
evt,
status,
summary);
_currentConversation = result.CurrentConversation;
ScheduleConversationPersist(result.UpdatedConversation);
}
}
private void AppendConversationExecutionEvent(AgentEvent evt, string targetTab)
{
lock (_convLock)
{
var session = _appState.ChatSession;
if (session == null)
return;
var result = _chatEngine.AppendExecutionEvent(
session,
_storage,
_currentConversation,
_activeTab,
targetTab,
evt);
_currentConversation = result.CurrentConversation;
ScheduleConversationPersist(result.UpdatedConversation);
}
}
private void SyncAppStateWithCurrentConversation()
{
ChatConversation? conv;
lock (_convLock) conv = _currentConversation;
_appState.RestoreAgentRunHistory(conv?.AgentRunHistory);
_appState.RestoreCurrentAgentRun(conv?.ExecutionEvents, conv?.AgentRunHistory);
_appState.RestoreRecentTasks(conv?.ExecutionEvents);
ApplyConversationListPreferences(conv);
UpdateTaskSummaryIndicators();
}
private static string GetRunStatusLabel(string? status)
=> status switch
{
"completed" => "완료",
"failed" => "실패",
"paused" => "일시중지",
_ => "진행 중",
};
private static string GetTaskStatusLabel(string? status)
=> status switch
{
"completed" => "완료",
"failed" => "실패",
"blocked" => "재시도 대기",
"waiting" => "승인 대기",
"cancelled" => "중단",
_ => "진행 중",
};
private string GetAgentItemDisplayName(string? rawName, bool slashPrefix = false)
=> GetTranscriptDisplayName(rawName, slashPrefix);
private static bool IsTranscriptToolLikeEvent(AgentEvent evt)
=> evt.Type is AgentEventType.ToolCall or AgentEventType.ToolResult or AgentEventType.SkillCall
|| (!string.IsNullOrWhiteSpace(evt.ToolName)
&& evt.Type is AgentEventType.PermissionRequest or AgentEventType.PermissionGranted or AgentEventType.PermissionDenied);
private string BuildAgentEventSummaryText(AgentEvent evt, string displayName)
=> GetTranscriptEventSummary(evt, displayName);
private IEnumerable<TaskRunStore.TaskRun> FilterTaskSummaryItems(IEnumerable<TaskRunStore.TaskRun> tasks)
=> _taskSummaryTaskFilter switch
{
"permission" => tasks.Where(t => string.Equals(t.Kind, "permission", StringComparison.OrdinalIgnoreCase)),
"queue" => tasks.Where(t => string.Equals(t.Kind, "queue", StringComparison.OrdinalIgnoreCase)),
"hook" => tasks.Where(t => string.Equals(t.Kind, "hook", StringComparison.OrdinalIgnoreCase)),
"subagent" => tasks.Where(t => string.Equals(t.Kind, "subagent", StringComparison.OrdinalIgnoreCase)),
"tool" => tasks.Where(t => string.Equals(t.Kind, "tool", StringComparison.OrdinalIgnoreCase)),
_ => tasks,
};
private Border CreateTaskSummaryFilterChip(string key, string label)
{
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
var active = string.Equals(_taskSummaryTaskFilter, key, StringComparison.OrdinalIgnoreCase);
var chip = new Border
{
Background = active ? BrushFromHex("#EEF2FF") : BrushFromHex("#F8FAFC"),
BorderBrush = active ? BrushFromHex("#A5B4FC") : BrushFromHex("#E5E7EB"),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(8, 4, 8, 4),
Margin = new Thickness(0, 0, 5, 5),
Cursor = Cursors.Hand,
Child = new TextBlock
{
Text = label,
FontSize = 10.5,
Foreground = active ? BrushFromHex("#4338CA") : secondaryText,
}
};
chip.MouseLeftButtonUp += (_, _) =>
{
_taskSummaryTaskFilter = key;
if (_taskSummaryTarget != null)
ShowTaskSummaryPopup();
};
return chip;
}
private static Brush GetRunStatusBrush(string? status)
=> status switch
{
"completed" => BrushFromHex("#166534"),
"failed" => BrushFromHex("#B91C1C"),
"paused" => BrushFromHex("#B45309"),
_ => BrushFromHex("#1D4ED8"),
};
private static string ShortRunId(string? runId)
{
if (string.IsNullOrWhiteSpace(runId))
return "main";
return runId.Length <= 8 ? runId : runId[..8];
}
// ─── Task Decomposition UI ────────────────────────────────────────────
private Border? _planningCard;
private StackPanel? _planStepsPanel;
private ProgressBar? _planProgressBar;
private TextBlock? _planProgressText;
private TextBlock? _planToggleText;
/// <summary>작업 계획 카드를 생성합니다 (단계 목록 + 진행률 바).</summary>
private void AddPlanningCard(AgentEvent evt)
{
var steps = evt.Steps!;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.Black;
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.DimGray;
var hintBg = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#F8FAFC");
var borderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#E2E8F0");
var accentBrush = TryFindResource("AccentColor") as Brush ?? BrushFromHex("#4B5EFC");
var card = new Border
{
Background = hintBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(8),
Padding = new Thickness(8, 6, 8, 6),
Margin = new Thickness(8, 2, 248, 5),
HorizontalAlignment = HorizontalAlignment.Left,
MaxWidth = GetMessageMaxWidth(),
};
var sp = new StackPanel();
// 헤더
var header = new Grid { Margin = new Thickness(0, 0, 0, 4) };
header.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
header.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
var headerLeft = new StackPanel { Orientation = Orientation.Horizontal };
headerLeft.Children.Add(new TextBlock
{
Text = "\uE9D5", // plan icon
FontFamily = s_segoeIconFont,
FontSize = 8,
Foreground = accentBrush,
VerticalAlignment = VerticalAlignment.Center
});
headerLeft.Children.Add(new TextBlock
{
Text = $"계획 {steps.Count}단계",
FontSize = 9, FontWeight = FontWeights.SemiBold,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(4, 0, 0, 0),
});
header.Children.Add(headerLeft);
var toggleWrap = new Border
{
Background = Brushes.Transparent,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(10),
Padding = new Thickness(6, 2, 6, 2),
Cursor = Cursors.Hand,
VerticalAlignment = VerticalAlignment.Center,
};
_planToggleText = new TextBlock
{
Text = steps.Count > 0 ? "펼치기" : "",
FontSize = 8.25,
FontWeight = FontWeights.SemiBold,
Foreground = secondaryText,
};
toggleWrap.Child = _planToggleText;
Grid.SetColumn(toggleWrap, 1);
header.Children.Add(toggleWrap);
sp.Children.Add(header);
// 진행률 바
var progressGrid = new Grid { Margin = new Thickness(0, 0, 0, 6) };
progressGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
progressGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
_planProgressBar = new ProgressBar
{
Minimum = 0,
Maximum = steps.Count,
Value = 0,
Height = 3,
Foreground = accentBrush,
Background = TryFindResource("HintBackground") as Brush ?? BrushFromHex("#E5E7EB"),
VerticalAlignment = VerticalAlignment.Center,
};
// Remove the default border on ProgressBar
_planProgressBar.BorderThickness = new Thickness(0);
Grid.SetColumn(_planProgressBar, 0);
progressGrid.Children.Add(_planProgressBar);
_planProgressText = new TextBlock
{
Text = "0%",
FontSize = 8, FontWeight = FontWeights.SemiBold,
Foreground = secondaryText,
Margin = new Thickness(5, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
};
Grid.SetColumn(_planProgressText, 1);
progressGrid.Children.Add(_planProgressText);
sp.Children.Add(progressGrid);
// 단계 목록
_planStepsPanel = new StackPanel
{
Visibility = Visibility.Collapsed,
};
for (int i = 0; i < steps.Count; i++)
{
var stepRow = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(0, 1, 0, 0),
Tag = i, // 인덱스 저장
};
stepRow.Children.Add(new TextBlock
{
Text = "○", // 빈 원 (미완료)
FontSize = 8.5,
Foreground = secondaryText,
VerticalAlignment = VerticalAlignment.Center,
Margin = new Thickness(0, 0, 4, 0),
Tag = "status",
});
stepRow.Children.Add(new TextBlock
{
Text = $"{i + 1}. {steps[i]}",
FontSize = 8.75,
Foreground = primaryText,
TextWrapping = TextWrapping.Wrap,
MaxWidth = Math.Max(280, GetMessageMaxWidth() - 68),
VerticalAlignment = VerticalAlignment.Center,
});
_planStepsPanel.Children.Add(stepRow);
}
sp.Children.Add(_planStepsPanel);
toggleWrap.MouseLeftButtonUp += (_, _) =>
{
if (_planStepsPanel == null || _planToggleText == null)
return;
var expanded = _planStepsPanel.Visibility == Visibility.Visible;
_planStepsPanel.Visibility = expanded ? Visibility.Collapsed : Visibility.Visible;
_planToggleText.Text = expanded ? "펼치기" : "접기";
toggleWrap.Background = expanded ? Brushes.Transparent : BrushFromHex("#F8FAFC");
};
card.Child = sp;
_planningCard = card;
// 페이드인
card.Opacity = 0;
card.BeginAnimation(UIElement.OpacityProperty,
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(300)));
AddTranscriptElement(card);
}
/// <summary>계획 카드 아래에 승인/수정/취소 의사결정 버튼을 추가합니다.</summary>
private void AddDecisionButtons(TaskCompletionSource<string?> tcs, List<string> options)
{
var expressionLevel = GetAgentUiExpressionLevel();
var showDetailedCopy = expressionLevel != "simple";
var showRichHint = expressionLevel == "rich";
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
var accentColor = accentBrush is SolidColorBrush solidAccent ? solidAccent.Color : Color.FromRgb(0x4B, 0x5E, 0xFC);
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
var surfaceBrush = TryFindResource("LauncherBackground") as Brush
?? new SolidColorBrush(Color.FromRgb(0x12, 0x14, 0x1F));
var itemBg = TryFindResource("ItemBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x20, accentColor.R, accentColor.G, accentColor.B));
var hoverBg = TryFindResource("ItemHoverBackground") as Brush
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
var borderBrush = TryFindResource("BorderColor") as Brush
?? new SolidColorBrush(Color.FromArgb(0x30, accentColor.R, accentColor.G, accentColor.B));
var accentTintBrush = new SolidColorBrush(Color.FromArgb(0x18, accentColor.R, accentColor.G, accentColor.B));
var successBrush = new SolidColorBrush(Color.FromRgb(0x10, 0xB9, 0x81));
var dangerBrush = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26));
var container = new Border
{
Margin = expressionLevel == "simple"
? new Thickness(40, 2, 120, 6)
: new Thickness(40, 2, 80, 6),
HorizontalAlignment = HorizontalAlignment.Left,
MaxWidth = expressionLevel == "simple" ? 460 : 560,
Background = surfaceBrush,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(14),
Padding = new Thickness(14, 12, 14, 12),
};
var outerStack = new StackPanel();
outerStack.Children.Add(new TextBlock
{
Text = "실행 계획 승인 요청",
FontSize = 12.5,
FontWeight = FontWeights.SemiBold,
Foreground = primaryText,
});
if (showDetailedCopy)
{
outerStack.Children.Add(new TextBlock
{
Text = "승인하면 바로 실행되고, 수정 요청 시 계획이 재작성됩니다.",
FontSize = 11.5,
Foreground = secondaryText,
Margin = new Thickness(0, 2, 0, 8),
TextWrapping = TextWrapping.Wrap,
});
}
if (showDetailedCopy && options.Count > 0)
{
var optionCandidates = new List<string>();
foreach (var option in options)
{
if (string.IsNullOrWhiteSpace(option))
continue;
optionCandidates.Add(option.Trim());
if (optionCandidates.Count >= 3)
break;
}
var optionHint = string.Join(" · ", optionCandidates);
if (!string.IsNullOrWhiteSpace(optionHint))
{
outerStack.Children.Add(new TextBlock
{
Text = $"선택지: {optionHint}",
FontSize = 11,
Foreground = secondaryText,
Margin = new Thickness(0, 0, 0, 8),
TextWrapping = TextWrapping.Wrap,
});
}
}
if (showRichHint)
{
outerStack.Children.Add(new TextBlock
{
Text = "팁: 승인 후에도 실행 중 단계에서 계획 보기 버튼으로 진행 상황을 다시 열 수 있습니다.",
FontSize = 11,
Foreground = secondaryText,
Margin = new Thickness(0, 0, 0, 8),
TextWrapping = TextWrapping.Wrap,
});
}
// 버튼 행
var btnRow = new WrapPanel
{
HorizontalAlignment = HorizontalAlignment.Left,
ItemHeight = 38,
};
// 승인 버튼 (강조)
var approveBtn = new Border
{
Background = accentBrush,
CornerRadius = new CornerRadius(18),
Padding = new Thickness(16, 9, 16, 9),
Margin = new Thickness(0, 0, 8, 8),
Cursor = Cursors.Hand,
};
var approveSp = new StackPanel { Orientation = Orientation.Horizontal };
approveSp.Children.Add(new TextBlock
{
Text = "\uE73E", FontFamily = s_segoeIconFont, FontSize = 11,
Foreground = Brushes.White, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0),
});
approveSp.Children.Add(new TextBlock
{
Text = "승인 후 실행",
FontSize = 12.5,
FontWeight = FontWeights.SemiBold,
Foreground = Brushes.White
});
approveBtn.Child = approveSp;
ApplyHoverScaleAnimation(approveBtn, 1.02);
approveBtn.MouseLeftButtonUp += (_, _) =>
{
CollapseDecisionButtons(outerStack, "✓ 승인됨", accentBrush);
tcs.TrySetResult(null); // null = 승인
};
btnRow.Children.Add(approveBtn);
// 수정 요청 버튼
var editBtn = new Border
{
Background = itemBg,
CornerRadius = new CornerRadius(18),
Padding = new Thickness(14, 9, 14, 9),
Margin = new Thickness(0, 0, 8, 8),
Cursor = Cursors.Hand,
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)),
BorderThickness = new Thickness(1),
};
var editSp = new StackPanel { Orientation = Orientation.Horizontal };
editSp.Children.Add(new TextBlock
{
Text = "\uE70F", FontFamily = s_segoeIconFont, FontSize = 11,
Foreground = accentBrush, VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0),
});
editSp.Children.Add(new TextBlock { Text = expressionLevel == "simple" ? "수정" : "수정 요청", FontSize = 12.5, FontWeight = FontWeights.SemiBold, Foreground = accentBrush });
editBtn.Child = editSp;
ApplyMenuItemHover(editBtn);
// 수정 요청용 텍스트 입력 패널 (초기 숨김)
var editInputPanel = new Border
{
Visibility = Visibility.Collapsed,
Background = itemBg,
BorderBrush = borderBrush,
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(12),
Padding = new Thickness(12, 10, 12, 10),
Margin = new Thickness(0, 4, 0, 0),
};
var editInputStack = new StackPanel();
editInputStack.Children.Add(new TextBlock
{
Text = showDetailedCopy ? "수정 사항을 입력하세요:" : "수정 내용을 입력하세요:",
FontSize = 11.5, Foreground = secondaryText,
Margin = new Thickness(0, 0, 0, 6),
});
var editTextBox = new TextBox
{
MinHeight = 36,
MaxHeight = 100,
AcceptsReturn = true,
TextWrapping = TextWrapping.Wrap,
FontSize = 12.5,
Background = surfaceBrush,
Foreground = primaryText,
CaretBrush = primaryText,
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)),
BorderThickness = new Thickness(1),
Padding = new Thickness(8, 6, 8, 6),
};
editInputStack.Children.Add(editTextBox);
var submitEditBtn = new Border
{
Background = accentBrush,
CornerRadius = new CornerRadius(8),
Padding = new Thickness(12, 5, 12, 5),
Margin = new Thickness(0, 6, 0, 0),
Cursor = Cursors.Hand,
HorizontalAlignment = HorizontalAlignment.Right,
};
submitEditBtn.Child = new TextBlock
{
Text = "피드백 전송",
FontSize = 12.5,
FontWeight = FontWeights.SemiBold,
Foreground = Brushes.White,
LineHeight = 18,
};
ApplyHoverScaleAnimation(submitEditBtn, 1.05);
submitEditBtn.MouseLeftButtonUp += (_, _) =>
{
var feedback = editTextBox.Text.Trim();
if (string.IsNullOrEmpty(feedback)) return;
CollapseDecisionButtons(outerStack, "✎ 수정 요청됨", accentBrush);
tcs.TrySetResult(feedback);
};
editInputStack.Children.Add(submitEditBtn);
editInputPanel.Child = editInputStack;
editBtn.MouseLeftButtonUp += (_, _) =>
{
editInputPanel.Visibility = editInputPanel.Visibility == Visibility.Visible
? Visibility.Collapsed : Visibility.Visible;
if (editInputPanel.Visibility == Visibility.Visible)
editTextBox.Focus();
};
btnRow.Children.Add(editBtn);
// 취소 버튼
var cancelBtn = new Border
{
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(18),
Padding = new Thickness(14, 9, 14, 9),
Margin = new Thickness(0, 0, 0, 8),
Cursor = Cursors.Hand,
BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xDC, 0x26, 0x26)),
BorderThickness = new Thickness(1),
};
var cancelSp = new StackPanel { Orientation = Orientation.Horizontal };
cancelSp.Children.Add(new TextBlock
{
Text = "\uE711", FontFamily = s_segoeIconFont, FontSize = 11,
Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)),
VerticalAlignment = VerticalAlignment.Center, Margin = new Thickness(0, 0, 5, 0),
});
cancelSp.Children.Add(new TextBlock
{
Text = "취소", FontSize = 12.5, FontWeight = FontWeights.SemiBold,
Foreground = new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)),
});
cancelBtn.Child = cancelSp;
ApplyMenuItemHover(cancelBtn);
cancelBtn.MouseLeftButtonUp += (_, _) =>
{
CollapseDecisionButtons(outerStack, "✕ 취소됨",
new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26)));
tcs.TrySetResult("취소");
};
btnRow.Children.Add(cancelBtn);
outerStack.Children.Add(btnRow);
outerStack.Children.Add(editInputPanel);
container.Child = outerStack;
// 슬라이드 + 페이드 등장 애니메이션
ApplyMessageEntryAnimation(container);
AddTranscriptElement(container);
ForceScrollToEnd(); // 의사결정 버튼 표시 시 강제 하단 이동
// PlanViewerWindow 등 외부에서 TCS가 완료되면 인라인 버튼도 자동 접기
var capturedOuterStack = outerStack;
var capturedAccent = accentBrush;
_ = tcs.Task.ContinueWith(t =>
{
if (t.IsFaulted || t.IsCanceled) return;
Dispatcher.BeginInvoke(() =>
{
// 이미 접혀있으면 스킵 (인라인 버튼으로 직접 클릭한 경우)
if (capturedOuterStack.Children.Count <= 1) return;
var label = t.Result == null ? "✓ 승인됨"
: t.Result == "취소" ? "✕ 취소됨"
: "✎ 수정 요청됨";
var fg = t.Result == "취소"
? new SolidColorBrush(Color.FromRgb(0xDC, 0x26, 0x26))
: capturedAccent;
CollapseDecisionButtons(capturedOuterStack, label, fg);
});
}, TaskScheduler.Default);
}
/// <summary>의사결정 버튼을 숨기고 결과 라벨로 교체합니다.</summary>
private void CollapseDecisionButtons(StackPanel outerStack, string resultText, Brush fg)
{
outerStack.Children.Clear();
// 부모 컨테이너(Border)의 테두리·배경 제거 — 승인 후 깔끔하게
if (outerStack.Parent is Border containerBorder)
{
containerBorder.BorderThickness = new Thickness(0);
containerBorder.Background = Brushes.Transparent;
containerBorder.Padding = new Thickness(0, 2, 0, 2);
}
var resultLabel = new TextBlock
{
Text = resultText,
FontSize = 12.5,
FontWeight = FontWeights.SemiBold,
Foreground = fg,
Opacity = 0.8,
LineHeight = 18,
Margin = new Thickness(40, 2, 0, 2),
};
outerStack.Children.Add(resultLabel);
}
// ════════════════════════════════════════════════════════════
// 후속 작업 제안 칩 (suggest_actions)
// ════════════════════════════════════════════════════════════
/// <summary>suggest_actions 도구 결과를 클릭 가능한 칩으로 렌더링합니다.</summary>
private void RenderSuggestActionChips(string jsonSummary)
{
List<(string label, string command)> actions = new();
try
{
if (jsonSummary.Contains("\"label\""))
{
using var doc = System.Text.Json.JsonDocument.Parse(jsonSummary);
if (doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array)
{
foreach (var item in doc.RootElement.EnumerateArray())
{
var label = item.TryGetProperty("label", out var l) ? l.GetString() ?? "" : "";
var cmd = item.TryGetProperty("command", out var c) ? c.GetString() ?? label : label;
if (!string.IsNullOrEmpty(label)) actions.Add((label, cmd));
}
}
}
else
{
// 줄바꿈 형식: "1. label → command"
foreach (var line in jsonSummary.Split('\n'))
{
var trimmed = line.Trim().TrimStart('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', ' ');
if (string.IsNullOrEmpty(trimmed)) continue;
var parts = trimmed.Split('→', ':', '—');
if (parts.Length >= 2)
actions.Add((parts[0].Trim(), parts[1].Trim()));
else if (!string.IsNullOrEmpty(trimmed))
actions.Add((trimmed, trimmed));
}
}
}
catch { return; }
if (actions.Count == 0) return;
var preview = string.Join(", ", actions.Take(3).Select(static action => action.label));
var suffix = actions.Count > 3 ? $" 외 {actions.Count - 3}개" : "";
ShowToast($"다음 작업 제안 준비됨: {preview}{suffix}");
}
// ════════════════════════════════════════════════════════════
// 피드백 학습 반영 (J)
// ════════════════════════════════════════════════════════════
/// <summary>피드백 컨텍스트 캐시 — 1분간 유효.</summary>
private string? _feedbackContextCache;
private DateTime _feedbackContextCacheExpiry;
/// <summary>최근 대화의 피드백(좋아요/싫어요)을 분석하여 선호도 요약을 반환합니다.</summary>
/// <remarks>
/// 성능 최적화: LoadAllMeta() + Load() x20 은 모든 .axchat 파일을 복호화하므로
/// 매 전송마다 호출하면 수 초~10초 이상 UI를 블로킹합니다.
/// 결과를 1분간 캐시하고, 현재 대화의 피드백만 즉시 반영합니다.
/// </remarks>
private string BuildFeedbackContext()
{
// 캐시가 유효하면 즉시 반환 — 디스크 I/O 없음
if (_feedbackContextCache != null && DateTime.UtcNow < _feedbackContextCacheExpiry)
return _feedbackContextCache;
try
{
// 현재 대화에서만 피드백 수집 — 디스크 I/O 없이 메모리 내 처리
var likedPatterns = new List<string>();
var dislikedPatterns = new List<string>();
ChatConversation? currentConv;
lock (_convLock) currentConv = _currentConversation;
if (currentConv != null)
{
foreach (var msg in currentConv.Messages.Where(m => m.Role == "assistant" && m.Feedback != null))
{
var preview = msg.Content?.Length > 80 ? msg.Content[..80] : msg.Content ?? "";
if (msg.Feedback == "like")
likedPatterns.Add(preview);
else if (msg.Feedback == "dislike")
dislikedPatterns.Add(preview);
}
}
// 현재 대화에 피드백이 없으면 백그라운드에서 전체 캐시를 갱신 예약
if (likedPatterns.Count == 0 && dislikedPatterns.Count == 0)
{
// 비동기 캐시 갱신 — UI 블로킹 없음
_ = Task.Run(() => RefreshFeedbackCacheBackground());
// 이전 캐시가 있으면 반환, 없으면 빈 문자열
return _feedbackContextCache ?? "";
}
var result = FormatFeedbackPatterns(likedPatterns, dislikedPatterns);
_feedbackContextCache = result;
_feedbackContextCacheExpiry = DateTime.UtcNow.AddMinutes(1);
return result;
}
catch { return ""; }
}
/// <summary>백그라운드에서 전체 대화 피드백을 수집하여 캐시를 갱신합니다.</summary>
private void RefreshFeedbackCacheBackground()
{
try
{
var metaList = _storage.LoadAllMeta()
.OrderByDescending(m => m.UpdatedAt)
.Take(10)
.ToList();
var liked = new List<string>();
var disliked = new List<string>();
foreach (var meta in metaList)
{
var conv = _storage.Load(meta.Id);
if (conv == null) continue;
foreach (var msg in conv.Messages.Where(m => m.Role == "assistant" && m.Feedback != null))
{
var preview = msg.Content?.Length > 80 ? msg.Content[..80] : msg.Content ?? "";
if (msg.Feedback == "like") liked.Add(preview);
else if (msg.Feedback == "dislike") disliked.Add(preview);
}
// 충분한 피드백을 모았으면 중단
if (liked.Count + disliked.Count >= 10) break;
}
var result = FormatFeedbackPatterns(liked, disliked);
_feedbackContextCache = result;
_feedbackContextCacheExpiry = DateTime.UtcNow.AddMinutes(1);
}
catch { /* 백그라운드 실패 무시 */ }
}
private static string FormatFeedbackPatterns(List<string> likedPatterns, List<string> dislikedPatterns)
{
if (likedPatterns.Count == 0 && dislikedPatterns.Count == 0)
return "";
var sb = new System.Text.StringBuilder();
sb.AppendLine("\n[사용자 선호도 참고]");
if (likedPatterns.Count > 0)
{
sb.AppendLine($"사용자가 좋아한 응답 스타일 ({likedPatterns.Count}건):");
foreach (var p in likedPatterns.Take(5))
sb.AppendLine($" - \"{p}...\"");
}
if (dislikedPatterns.Count > 0)
{
sb.AppendLine($"사용자가 싫어한 응답 스타일 ({dislikedPatterns.Count}건):");
foreach (var p in dislikedPatterns.Take(5))
sb.AppendLine($" - \"{p}...\"");
}
sb.AppendLine("위 선호도를 참고하여 응답 스타일을 조정하세요.");
return sb.ToString();
}
/// <summary>진행률 바와 단계 상태를 업데이트합니다.</summary>
private void UpdateProgressBar(AgentEvent evt)
{
if (_planProgressBar == null || _planStepsPanel == null || _planProgressText == null)
return;
var stepIdx = evt.StepCurrent - 1; // 0-based
var total = evt.StepTotal;
// 진행률 바 업데이트
_planProgressBar.Value = evt.StepCurrent;
var pct = (int)((double)evt.StepCurrent / total * 100);
_planProgressText.Text = $"{pct}%";
// 이전 단계 완료 표시 + 현재 단계 강조
for (int i = 0; i < _planStepsPanel.Children.Count; i++)
{
if (_planStepsPanel.Children[i] is StackPanel row && row.Children.Count >= 2)
{
var statusTb = row.Children[0] as TextBlock;
var textTb = row.Children[1] as TextBlock;
if (statusTb == null || textTb == null) continue;
if (i < stepIdx)
{
// 완료
statusTb.Text = "●";
statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#16A34A"));
textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#6B7280"));
}
else if (i == stepIdx)
{
// 현재 진행 중
statusTb.Text = "◉";
statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5EFC"));
textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#1E293B"));
textTb.FontWeight = FontWeights.SemiBold;
}
else
{
// 대기
statusTb.Text = "○";
statusTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9CA3AF"));
textTb.Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563"));
textTb.FontWeight = FontWeights.Normal;
}
}
}
}
/// <summary>Diff 텍스트를 색상 하이라이팅된 StackPanel로 렌더링합니다.</summary>
private static UIElement BuildDiffView(string text)
{
var panel = new StackPanel
{
Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#FAFAFA")),
MaxWidth = 520,
};
var diffStarted = false;
foreach (var rawLine in text.Split('\n'))
{
var line = rawLine.TrimEnd('\r');
// diff 헤더 전의 일반 텍스트
if (!diffStarted && !line.StartsWith("--- "))
{
panel.Children.Add(new TextBlock
{
Text = line,
FontSize = 11,
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#4B5563")),
FontFamily = new FontFamily("Consolas"),
Margin = new Thickness(0, 0, 0, 1),
});
continue;
}
diffStarted = true;
string bgHex, fgHex;
if (line.StartsWith("---") || line.StartsWith("+++"))
{
bgHex = "#F3F4F6"; fgHex = "#374151";
}
else if (line.StartsWith("@@"))
{
bgHex = "#EFF6FF"; fgHex = "#3B82F6";
}
else if (line.StartsWith("+"))
{
bgHex = "#ECFDF5"; fgHex = "#059669";
}
else if (line.StartsWith("-"))
{
bgHex = "#FEF2F2"; fgHex = "#DC2626";
}
else
{
bgHex = "Transparent"; fgHex = "#6B7280";
}
var tb = new TextBlock
{
Text = line,
FontSize = 10.5,
FontFamily = new FontFamily("Consolas"),
Foreground = new SolidColorBrush((Color)ColorConverter.ConvertFromString(fgHex)),
Padding = new Thickness(4, 1, 4, 1),
};
if (bgHex != "Transparent")
tb.Background = new SolidColorBrush((Color)ColorConverter.ConvertFromString(bgHex));
panel.Children.Add(tb);
}
return panel;
}
private sealed class ReviewSignalSummary
{
public int P0 { get; init; }
public int P1 { get; init; }
public int P2 { get; init; }
public int P3 { get; init; }
public bool HasFixed { get; init; }
public bool HasUnfixed { get; init; }
public bool HasAny => P0 > 0 || P1 > 0 || P2 > 0 || P3 > 0 || HasFixed || HasUnfixed;
}
private static ReviewSignalSummary ExtractReviewSignals(string? text)
{
if (string.IsNullOrWhiteSpace(text))
return new ReviewSignalSummary();
var source = text!;
var hasUnfixed = ContainsAny(source, "unfixed", "not fixed", "open issue", "remaining issue", "pending fix", "미수정", "미해결", "보류", "남은 이슈");
var hasFixed = ContainsWholeWord(source, "fixed")
|| ContainsAny(source, "resolved", "patched", "조치 완료", "수정 완료", "해결 완료");
return new ReviewSignalSummary
{
P0 = CountToken(source, "P0"),
P1 = CountToken(source, "P1"),
P2 = CountToken(source, "P2"),
P3 = CountToken(source, "P3"),
HasUnfixed = hasUnfixed,
HasFixed = hasFixed,
};
}
private static int CountToken(string source, string token)
{
if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(token))
return 0;
var count = 0;
var index = 0;
while (index < source.Length)
{
var hit = source.IndexOf(token, index, StringComparison.OrdinalIgnoreCase);
if (hit < 0)
break;
var before = hit == 0 ? ' ' : source[hit - 1];
var afterIndex = hit + token.Length;
var after = afterIndex >= source.Length ? ' ' : source[afterIndex];
if (!char.IsLetterOrDigit(before) && !char.IsLetterOrDigit(after))
count++;
index = hit + token.Length;
}
return count;
}
private static bool ContainsAny(string source, params string[] needles)
{
foreach (var needle in needles)
{
if (source.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0)
return true;
}
return false;
}
private static bool ContainsWholeWord(string source, string token)
{
if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(token))
return false;
var pattern = $@"\b{System.Text.RegularExpressions.Regex.Escape(token)}\b";
return System.Text.RegularExpressions.Regex.IsMatch(source, pattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
}
private static bool IsReviewContext(string? kind, string? toolName, string? title, string? summary)
{
if (!string.IsNullOrWhiteSpace(kind) && string.Equals(kind, "review", StringComparison.OrdinalIgnoreCase))
return true;
if (!string.IsNullOrWhiteSpace(toolName) &&
(toolName.Contains("review", StringComparison.OrdinalIgnoreCase) ||
toolName.Contains("code_review", StringComparison.OrdinalIgnoreCase)))
return true;
if (!string.IsNullOrWhiteSpace(title) &&
title.Contains("review", StringComparison.OrdinalIgnoreCase))
return true;
if (!string.IsNullOrWhiteSpace(summary) &&
(summary.Contains("P0", StringComparison.OrdinalIgnoreCase) ||
summary.Contains("P1", StringComparison.OrdinalIgnoreCase) ||
summary.Contains("P2", StringComparison.OrdinalIgnoreCase) ||
summary.Contains("P3", StringComparison.OrdinalIgnoreCase)))
return true;
return false;
}
private Border BuildReviewChip(string text, string bgHex, string fgHex, string borderHex)
{
return new Border
{
Background = BrushFromHex(bgHex),
BorderBrush = BrushFromHex(borderHex),
BorderThickness = new Thickness(1),
CornerRadius = new CornerRadius(999),
Margin = new Thickness(0, 0, 6, 0),
Padding = new Thickness(8, 2, 8, 2),
Child = new TextBlock
{
Text = text,
FontSize = 10,
FontWeight = FontWeights.SemiBold,
Foreground = BrushFromHex(fgHex),
}
};
}
private WrapPanel? BuildReviewSignalChipRow(string? kind, string? toolName, string? title, string? summary)
{
if (!IsReviewContext(kind, toolName, title, summary))
return null;
var signals = ExtractReviewSignals(summary);
if (!signals.HasAny)
return null;
var row = new WrapPanel
{
Margin = new Thickness(0, 6, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
};
if (signals.P0 > 0)
row.Children.Add(BuildReviewChip($"P0 {signals.P0}", "#FEF2F2", "#991B1B", "#FCA5A5"));
if (signals.P1 > 0)
row.Children.Add(BuildReviewChip($"P1 {signals.P1}", "#FFF7ED", "#9A3412", "#FDBA74"));
if (signals.P2 > 0)
row.Children.Add(BuildReviewChip($"P2 {signals.P2}", "#FFFBEB", "#854D0E", "#FDE68A"));
if (signals.P3 > 0)
row.Children.Add(BuildReviewChip($"P3 {signals.P3}", "#EFF6FF", "#1E40AF", "#93C5FD"));
if (signals.HasFixed)
row.Children.Add(BuildReviewChip("Fixed", "#ECFDF5", "#166534", "#86EFAC"));
if (signals.HasUnfixed)
row.Children.Add(BuildReviewChip("Unfixed", "#FEF2F2", "#991B1B", "#FCA5A5"));
return row.Children.Count == 0 ? null : row;
}
/// <summary>파일 빠른 작업 버튼 패널을 생성합니다.</summary>
private StackPanel BuildFileQuickActions(string filePath)
{
var panel = new StackPanel
{
Orientation = Orientation.Horizontal,
Margin = new Thickness(8, 0, 0, 0),
VerticalAlignment = VerticalAlignment.Center,
};
var accentColor = (Color)ColorConverter.ConvertFromString("#3B82F6");
var accentBrush = new SolidColorBrush(accentColor);
Border MakeBtn(string mdlIcon, string tooltip, Action action)
{
var icon = new TextBlock
{
Text = mdlIcon,
FontFamily = s_segoeIconFont,
FontSize = 10,
Foreground = accentBrush,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
var btn = new Border
{
Child = icon,
Background = Brushes.Transparent,
CornerRadius = new CornerRadius(4),
Width = 22,
Height = 22,
Margin = new Thickness(0, 0, 2, 0),
Cursor = Cursors.Hand,
ToolTip = tooltip,
};
btn.MouseEnter += (s, _) => { if (s is Border b) b.Background = new SolidColorBrush(Color.FromArgb(0x15, 0x3B, 0x82, 0xF6)); };
btn.MouseLeave += (s, _) => { if (s is Border b) b.Background = Brushes.Transparent; };
btn.MouseLeftButtonUp += (_, _) => action();
return btn;
}
// 프리뷰 (지원 확장자만)
var ext = System.IO.Path.GetExtension(filePath).ToLowerInvariant();
if (_previewableExtensions.Contains(ext))
{
var path1 = filePath;
panel.Children.Add(MakeBtn("\uE8A1", "프리뷰", () => ShowPreviewPanel(path1)));
}
// 외부 열기
var path2 = filePath;
panel.Children.Add(MakeBtn("\uE8A7", "열기", () =>
{
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = path2, UseShellExecute = true }); } catch { }
}));
// 폴더 열기
var path3 = filePath;
panel.Children.Add(MakeBtn("\uED25", "폴더", () =>
{
try { System.Diagnostics.Process.Start("explorer.exe", $"/select,\"{path3}\""); } catch { }
}));
// 경로 복사
var path4 = filePath;
panel.Children.Add(MakeBtn("\uE8C8", "복사", () =>
{
try { Clipboard.SetText(path4); } catch { }
}));
return panel;
}
}