변경 목적: - 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
642 lines
26 KiB
C#
642 lines
26 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
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;
|
|
using AxCopilot.Services.Agent;
|
|
|
|
namespace AxCopilot.Views;
|
|
|
|
public partial class ChatWindow
|
|
{
|
|
// ─── 응답 재생성 ──────────────────────────────────────────────────────
|
|
|
|
private async Task RegenerateLastAsync()
|
|
{
|
|
if (_streamingTabs.Contains(_activeTab)) return;
|
|
ChatConversation conv;
|
|
lock (_convLock)
|
|
{
|
|
if (_currentConversation == null) return;
|
|
conv = _currentConversation;
|
|
}
|
|
|
|
// 마지막 assistant 메시지 제거
|
|
lock (_convLock)
|
|
{
|
|
var session = ChatSession;
|
|
if (session != null)
|
|
{
|
|
session.RemoveLastAssistantMessage(_activeTab, _storage);
|
|
_currentConversation = session.CurrentConversation;
|
|
conv = _currentConversation!;
|
|
}
|
|
else if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant")
|
|
{
|
|
conv.Messages.RemoveAt(conv.Messages.Count - 1);
|
|
}
|
|
}
|
|
|
|
RenderMessages(preserveViewport: true);
|
|
AutoScrollIfNeeded();
|
|
|
|
// 재전송
|
|
await SendRegenerateAsync(conv);
|
|
}
|
|
|
|
/// <summary>"수정 후 재시도" — 피드백 입력 패널을 표시하고, 사용자 지시를 추가하여 재생성합니다.</summary>
|
|
private void ShowRetryWithFeedbackInput()
|
|
{
|
|
if (_streamingTabs.Contains(_activeTab)) return;
|
|
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue;
|
|
var itemBg = TryFindResource("ItemBackground") as Brush
|
|
?? new SolidColorBrush(Color.FromRgb(0x2A, 0x2B, 0x40));
|
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
|
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
|
|
|
var container = new Border
|
|
{
|
|
Margin = new Thickness(40, 4, 40, 8),
|
|
Padding = new Thickness(14, 10, 14, 10),
|
|
CornerRadius = new CornerRadius(12),
|
|
Background = itemBg,
|
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
|
};
|
|
|
|
var stack = new StackPanel();
|
|
stack.Children.Add(new TextBlock
|
|
{
|
|
Text = "어떻게 수정하면 좋을지 알려주세요:",
|
|
FontSize = 12,
|
|
Foreground = secondaryText,
|
|
Margin = new Thickness(0, 0, 0, 6),
|
|
});
|
|
|
|
var textBox = new TextBox
|
|
{
|
|
MinHeight = 38,
|
|
MaxHeight = 80,
|
|
AcceptsReturn = true,
|
|
TextWrapping = TextWrapping.Wrap,
|
|
FontSize = 13,
|
|
Background = TryFindResource("LauncherBackground") as Brush ?? Brushes.Black,
|
|
Foreground = primaryText,
|
|
CaretBrush = primaryText,
|
|
BorderBrush = borderBrush,
|
|
BorderThickness = new Thickness(1),
|
|
Padding = new Thickness(10, 6, 10, 6),
|
|
};
|
|
stack.Children.Add(textBox);
|
|
|
|
var btnRow = new StackPanel
|
|
{
|
|
Orientation = Orientation.Horizontal,
|
|
HorizontalAlignment = HorizontalAlignment.Right,
|
|
Margin = new Thickness(0, 8, 0, 0),
|
|
};
|
|
|
|
var sendBtn = new Border
|
|
{
|
|
Background = accentBrush,
|
|
CornerRadius = new CornerRadius(8),
|
|
Padding = new Thickness(14, 6, 14, 6),
|
|
Cursor = Cursors.Hand,
|
|
Margin = new Thickness(6, 0, 0, 0),
|
|
};
|
|
sendBtn.Child = new TextBlock { Text = "재시도", FontSize = 12, FontWeight = FontWeights.SemiBold, Foreground = Brushes.White };
|
|
sendBtn.MouseEnter += (s, _) => ((Border)s).Opacity = 0.85;
|
|
sendBtn.MouseLeave += (s, _) => ((Border)s).Opacity = 1.0;
|
|
sendBtn.MouseLeftButtonUp += (_, _) =>
|
|
{
|
|
var feedback = textBox.Text.Trim();
|
|
if (string.IsNullOrEmpty(feedback)) return;
|
|
RemoveTranscriptElement(container);
|
|
_ = RetryWithFeedbackAsync(feedback);
|
|
};
|
|
|
|
var cancelBtn = new Border
|
|
{
|
|
Background = Brushes.Transparent,
|
|
CornerRadius = new CornerRadius(8),
|
|
Padding = new Thickness(12, 6, 12, 6),
|
|
Cursor = Cursors.Hand,
|
|
};
|
|
cancelBtn.Child = new TextBlock { Text = "취소", FontSize = 12, Foreground = secondaryText };
|
|
cancelBtn.MouseLeftButtonUp += (_, _) => RemoveTranscriptElement(container);
|
|
|
|
btnRow.Children.Add(cancelBtn);
|
|
btnRow.Children.Add(sendBtn);
|
|
stack.Children.Add(btnRow);
|
|
container.Child = stack;
|
|
|
|
ApplyMessageEntryAnimation(container);
|
|
AddTranscriptElement(container);
|
|
ForceScrollToEnd();
|
|
textBox.Focus();
|
|
}
|
|
|
|
/// <summary>사용자 피드백과 함께 마지막 응답을 재생성합니다.</summary>
|
|
private async Task RetryWithFeedbackAsync(string feedback)
|
|
{
|
|
if (_streamingTabs.Contains(_activeTab)) return;
|
|
ChatConversation conv;
|
|
lock (_convLock)
|
|
{
|
|
if (_currentConversation == null) return;
|
|
conv = _currentConversation;
|
|
}
|
|
|
|
// 마지막 assistant 메시지 제거
|
|
lock (_convLock)
|
|
{
|
|
var session = ChatSession;
|
|
if (session != null)
|
|
{
|
|
session.RemoveLastAssistantMessage(_activeTab, _storage);
|
|
_currentConversation = session.CurrentConversation;
|
|
conv = _currentConversation!;
|
|
}
|
|
else if (conv.Messages.Count > 0 && conv.Messages[^1].Role == "assistant")
|
|
{
|
|
conv.Messages.RemoveAt(conv.Messages.Count - 1);
|
|
}
|
|
}
|
|
|
|
// 피드백을 사용자 메시지로 추가
|
|
var feedbackMsg = new ChatMessage
|
|
{
|
|
Role = "user",
|
|
Content = $"[이전 응답에 대한 수정 요청] {feedback}\n\n위 피드백을 반영하여 다시 작성해주세요."
|
|
};
|
|
lock (_convLock)
|
|
{
|
|
var session = ChatSession;
|
|
if (session != null)
|
|
{
|
|
session.AppendMessage(_activeTab, feedbackMsg, _storage);
|
|
_currentConversation = session.CurrentConversation;
|
|
conv = _currentConversation!;
|
|
}
|
|
else
|
|
{
|
|
conv.Messages.Add(feedbackMsg);
|
|
}
|
|
}
|
|
|
|
RenderMessages(preserveViewport: true);
|
|
AutoScrollIfNeeded();
|
|
|
|
// 재전송
|
|
await SendRegenerateAsync(conv);
|
|
}
|
|
|
|
private async Task SendRegenerateAsync(ChatConversation conv)
|
|
{
|
|
var runTab = NormalizeTabName(conv.Tab);
|
|
AxAgentExecutionEngine.PreparedExecution preparedExecution;
|
|
lock (_convLock)
|
|
preparedExecution = PrepareExecutionForConversation(conv, runTab, null);
|
|
await ExecutePreparedTurnAsync(
|
|
conv,
|
|
runTab,
|
|
conv.Tab ?? _activeTab,
|
|
preparedExecution,
|
|
"에이전트 작업 중...");
|
|
}
|
|
|
|
/// <summary>채팅 본문 폭을 세 탭에서 동일한 기준으로 맞춥니다.</summary>
|
|
private double GetMessageMaxWidth()
|
|
{
|
|
var hostWidth = _lastResponsiveComposerWidth;
|
|
if (hostWidth < 100)
|
|
hostWidth = ComposerShell?.ActualWidth ?? 0;
|
|
if (hostWidth < 100)
|
|
hostWidth = MessageList?.ActualWidth ?? 0;
|
|
if (hostWidth < 100)
|
|
hostWidth = 1120;
|
|
|
|
var maxW = hostWidth - 44;
|
|
return Math.Clamp(maxW, 320, 760);
|
|
}
|
|
|
|
private bool UpdateResponsiveChatLayout()
|
|
{
|
|
var viewportWidth = MessageList?.ActualWidth ?? 0;
|
|
if (viewportWidth < 200)
|
|
viewportWidth = ActualWidth;
|
|
if (viewportWidth < 200)
|
|
return false;
|
|
|
|
// 메시지 축과 입력축이 같은 중심선을 공유하도록
|
|
// 본문 폭 상한을 조금 더 낮추고 창 폭 변화에 더 부드럽게 반응시킵니다.
|
|
var contentWidth = Math.Max(360, viewportWidth - 24);
|
|
// 고정 최대폭 — 큰 창에서 안정적, 작은 창에서만 축소
|
|
var messageWidth = Math.Min(contentWidth - 10, 800);
|
|
var composerWidth = Math.Min(contentWidth - 24, 760);
|
|
|
|
var changed = false;
|
|
if (Math.Abs(_lastResponsiveMessageWidth - messageWidth) > 8)
|
|
{
|
|
_lastResponsiveMessageWidth = messageWidth;
|
|
if (MessageList != null)
|
|
MessageList.MaxWidth = messageWidth + 48;
|
|
if (EmptyState != null)
|
|
EmptyState.MaxWidth = messageWidth;
|
|
changed = true;
|
|
}
|
|
|
|
if (Math.Abs(_lastResponsiveComposerWidth - composerWidth) > 8)
|
|
{
|
|
_lastResponsiveComposerWidth = composerWidth;
|
|
if (ComposerShell != null)
|
|
{
|
|
ComposerShell.Width = composerWidth;
|
|
ComposerShell.MaxWidth = composerWidth;
|
|
}
|
|
changed = true;
|
|
}
|
|
|
|
return changed;
|
|
}
|
|
|
|
private StackPanel CreateStreamingContainer(out TextBlock streamText)
|
|
{
|
|
var msgMaxWidth = GetMessageMaxWidth();
|
|
var container = new StackPanel
|
|
{
|
|
HorizontalAlignment = HorizontalAlignment.Center,
|
|
Width = msgMaxWidth,
|
|
MaxWidth = msgMaxWidth,
|
|
Margin = new Thickness(0, 3, 0, 3),
|
|
Opacity = 0,
|
|
RenderTransform = new TranslateTransform(0, 10)
|
|
};
|
|
|
|
// 컨테이너 페이드인 + 슬라이드 업
|
|
container.BeginAnimation(UIElement.OpacityProperty,
|
|
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(280)));
|
|
((TranslateTransform)container.RenderTransform).BeginAnimation(
|
|
TranslateTransform.YProperty,
|
|
new DoubleAnimation(10, 0, TimeSpan.FromMilliseconds(300))
|
|
{ EasingFunction = new QuadraticEase { EasingMode = EasingMode.EaseOut } });
|
|
|
|
var headerGrid = new Grid { Margin = new Thickness(2, 0, 0, 2) };
|
|
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
|
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
|
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
|
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
|
|
|
var aiIcon = new TextBlock
|
|
{
|
|
Text = "\uE945", FontFamily = s_segoeIconFont, FontSize = 8,
|
|
Foreground = TryFindResource("AccentColor") as Brush ?? Brushes.Blue,
|
|
VerticalAlignment = VerticalAlignment.Center
|
|
};
|
|
// AI 아이콘 펄스 애니메이션 (응답 대기 중)
|
|
aiIcon.BeginAnimation(UIElement.OpacityProperty,
|
|
new DoubleAnimation(1.0, 0.35, TimeSpan.FromMilliseconds(700))
|
|
{ AutoReverse = true, RepeatBehavior = RepeatBehavior.Forever,
|
|
EasingFunction = new SineEase() });
|
|
_activeAiIcon = aiIcon;
|
|
Grid.SetColumn(aiIcon, 0);
|
|
headerGrid.Children.Add(aiIcon);
|
|
|
|
var (streamAgentName, _, _) = GetAgentIdentity();
|
|
var aiNameTb = new TextBlock
|
|
{
|
|
Text = streamAgentName, FontSize = 9, FontWeight = FontWeights.Medium,
|
|
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
|
Margin = new Thickness(4, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center
|
|
};
|
|
Grid.SetColumn(aiNameTb, 1);
|
|
headerGrid.Children.Add(aiNameTb);
|
|
|
|
// 유니코드 스피너 (에이전트 이름 옆)
|
|
var spinChars = new[] { "\u00b7", "\u2722", "\u2733", "\u2736", "\u273b", "\u273d" };
|
|
var spinIndex = 0;
|
|
var spinnerText = new TextBlock
|
|
{
|
|
Text = spinChars[0],
|
|
FontFamily = new FontFamily("Consolas"),
|
|
FontSize = 13,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
Margin = new Thickness(6, 0, 0, 0),
|
|
};
|
|
spinnerText.SetResourceReference(TextBlock.ForegroundProperty, "AccentColor");
|
|
var spinTimer = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromMilliseconds(333) };
|
|
spinTimer.Tick += (_, _) =>
|
|
{
|
|
spinIndex = (spinIndex + 1) % spinChars.Length;
|
|
spinnerText.Text = spinChars[spinIndex];
|
|
};
|
|
spinTimer.Start();
|
|
_activeSpinnerTimer = spinTimer;
|
|
Grid.SetColumn(spinnerText, 2);
|
|
headerGrid.Children.Add(spinnerText);
|
|
|
|
// 실시간 경과 시간 (헤더 우측)
|
|
_elapsedLabel = new TextBlock
|
|
{
|
|
Text = "0s",
|
|
FontSize = 9.5,
|
|
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
|
HorizontalAlignment = HorizontalAlignment.Right,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
Opacity = 0.5,
|
|
};
|
|
Grid.SetColumn(_elapsedLabel, 3);
|
|
headerGrid.Children.Add(_elapsedLabel);
|
|
|
|
container.Children.Add(headerGrid);
|
|
|
|
var streamCard = new Border
|
|
{
|
|
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.White,
|
|
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
|
|
BorderThickness = new Thickness(1),
|
|
CornerRadius = new CornerRadius(14),
|
|
Padding = new Thickness(13, 10, 13, 10)
|
|
};
|
|
streamText = new TextBlock
|
|
{
|
|
Text = "\u258c",
|
|
FontSize = 12.5,
|
|
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
|
TextWrapping = TextWrapping.Wrap,
|
|
LineHeight = 20,
|
|
};
|
|
streamCard.Child = streamText;
|
|
container.Children.Add(streamCard);
|
|
return container;
|
|
}
|
|
|
|
// ─── 스트리밍 완료 후 마크다운 렌더링으로 교체 ───────────────────────
|
|
|
|
private void FinalizeStreamingContainer(StackPanel container, TextBlock streamText, string finalContent, ChatMessage? message = null)
|
|
{
|
|
finalContent = string.IsNullOrWhiteSpace(finalContent) ? "(빈 응답)" : finalContent;
|
|
|
|
// 스트리밍 plaintext 카드 제거
|
|
if (streamText.Parent is Border streamCard)
|
|
container.Children.Remove(streamCard);
|
|
else
|
|
container.Children.Remove(streamText);
|
|
|
|
// 마크다운 렌더링
|
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
|
var accentBrush = TryFindResource("AccentColor") as Brush ?? Brushes.Blue;
|
|
var codeBgBrush = TryFindResource("HintBackground") as Brush ?? Brushes.DarkGray;
|
|
|
|
var mdPanel = MarkdownRenderer.Render(finalContent, primaryText, secondaryText, accentBrush, codeBgBrush);
|
|
mdPanel.Margin = new Thickness(0, 0, 0, 4);
|
|
mdPanel.Opacity = 0;
|
|
var mdCard = new Border
|
|
{
|
|
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.White,
|
|
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
|
|
BorderThickness = new Thickness(1),
|
|
CornerRadius = new CornerRadius(14),
|
|
Padding = new Thickness(13, 10, 13, 10),
|
|
Child = mdPanel,
|
|
};
|
|
container.Children.Add(mdCard);
|
|
mdPanel.BeginAnimation(UIElement.OpacityProperty,
|
|
new DoubleAnimation(0, 1, TimeSpan.FromMilliseconds(180)));
|
|
|
|
// 액션 버튼 바 + 토큰 표시
|
|
var btnColor = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
|
var capturedContent = finalContent;
|
|
var actionBar = new StackPanel
|
|
{
|
|
Orientation = Orientation.Horizontal,
|
|
HorizontalAlignment = HorizontalAlignment.Left,
|
|
Margin = new Thickness(2, 2, 0, 0),
|
|
Opacity = 0
|
|
};
|
|
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);
|
|
|
|
container.Children.Add(actionBar);
|
|
container.MouseEnter += (_, _) => ShowMessageActionBar(actionBar);
|
|
container.MouseLeave += (_, _) => HideMessageActionBarIfNotSelected(actionBar);
|
|
container.MouseLeftButtonUp += (_, _) => SelectMessageActionBar(actionBar, mdCard);
|
|
|
|
// 경과 시간 + 토큰 사용량 (우측 하단, 별도 줄)
|
|
var elapsedText = TryGetStreamingElapsed(out var elapsed)
|
|
? (elapsed.TotalSeconds < 60
|
|
? $"{elapsed.TotalSeconds:0.#}s"
|
|
: $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s")
|
|
: "0s";
|
|
|
|
var usage = _llm.LastTokenUsage;
|
|
// 에이전트 루프(Cowork/Code)에서는 누적 토큰 사용, 일반 대화에서는 마지막 호출 토큰 사용
|
|
var isAgentTab = _activeTab is "Cowork" or "Code";
|
|
var (usageService, usageModel) = _llm.GetCurrentModelInfo();
|
|
var tabCumIn = _tabCumulativeInputTokens.GetValueOrDefault(_activeTab);
|
|
var tabCumOut = _tabCumulativeOutputTokens.GetValueOrDefault(_activeTab);
|
|
var displayInput = isAgentTab && tabCumIn > 0
|
|
? (int)tabCumIn
|
|
: usage?.PromptTokens ?? 0;
|
|
var displayOutput = isAgentTab && tabCumOut > 0
|
|
? (int)tabCumOut
|
|
: usage?.CompletionTokens ?? 0;
|
|
|
|
var wasPostCompactionResponse = _pendingPostCompaction;
|
|
if (displayInput > 0 || displayOutput > 0)
|
|
{
|
|
UpdateStatusTokens(displayInput, displayOutput);
|
|
Services.UsageStatisticsService.RecordTokens(displayInput, displayOutput, usageService, usageModel, wasPostCompactionResponse);
|
|
ConsumePostCompactionUsageIfNeeded(displayInput, displayOutput);
|
|
}
|
|
string tokenText;
|
|
if (displayInput > 0 || displayOutput > 0)
|
|
tokenText = $"{FormatTokenCount(displayInput)} + {FormatTokenCount(displayOutput)} = {FormatTokenCount(displayInput + displayOutput)} tokens";
|
|
else if (usage != null)
|
|
tokenText = $"{FormatTokenCount(usage.PromptTokens)} + {FormatTokenCount(usage.CompletionTokens)} = {FormatTokenCount(usage.TotalTokens)} tokens";
|
|
else
|
|
tokenText = $"~{FormatTokenCount(EstimateTokenCount(finalContent))} tokens";
|
|
|
|
var metaText = new TextBlock
|
|
{
|
|
Text = $"{elapsedText} · {tokenText}",
|
|
FontSize = 9.5,
|
|
Foreground = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray,
|
|
HorizontalAlignment = HorizontalAlignment.Right,
|
|
Margin = new Thickness(0, 4, 0, 0),
|
|
Opacity = 0.55,
|
|
};
|
|
container.Children.Add(metaText);
|
|
|
|
// Suggestion chips — AI가 번호 선택지를 제시한 경우 클릭 가능 버튼 표시
|
|
var chips = ParseSuggestionChips(finalContent);
|
|
if (chips.Count > 0)
|
|
{
|
|
var chipPanel = new WrapPanel
|
|
{
|
|
Margin = new Thickness(0, 8, 0, 4),
|
|
HorizontalAlignment = HorizontalAlignment.Left,
|
|
};
|
|
foreach (var (num, label) in chips)
|
|
{
|
|
var chipBorder = new Border
|
|
{
|
|
Background = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent,
|
|
BorderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray,
|
|
BorderThickness = new Thickness(1),
|
|
CornerRadius = new CornerRadius(16),
|
|
Padding = new Thickness(14, 7, 14, 7),
|
|
Margin = new Thickness(0, 0, 8, 6),
|
|
Cursor = Cursors.Hand,
|
|
RenderTransformOrigin = new Point(0.5, 0.5),
|
|
RenderTransform = new ScaleTransform(1, 1),
|
|
};
|
|
chipBorder.Child = new TextBlock
|
|
{
|
|
Text = $"{num}. {label}",
|
|
FontSize = 12.5,
|
|
Foreground = TryFindResource("PrimaryText") as Brush ?? Brushes.White,
|
|
};
|
|
|
|
var chipHover = TryFindResource("ItemHoverBackground") as Brush ?? Brushes.Transparent;
|
|
var chipNormal = TryFindResource("ItemBackground") as Brush ?? Brushes.Transparent;
|
|
chipBorder.MouseEnter += (s, _) =>
|
|
{
|
|
if (s is Border b && b.RenderTransform is ScaleTransform st)
|
|
{ st.ScaleX = 1.02; st.ScaleY = 1.02; b.Background = chipHover; }
|
|
};
|
|
chipBorder.MouseLeave += (s, _) =>
|
|
{
|
|
if (s is Border b && b.RenderTransform is ScaleTransform st)
|
|
{ st.ScaleX = 1.0; st.ScaleY = 1.0; b.Background = chipNormal; }
|
|
};
|
|
|
|
var capturedLabel = $"{num}. {label}";
|
|
var capturedPanel = chipPanel;
|
|
chipBorder.MouseLeftButtonDown += (_, _) =>
|
|
{
|
|
// 칩 패널 제거 (1회용)
|
|
if (capturedPanel.Parent is Panel parent)
|
|
parent.Children.Remove(capturedPanel);
|
|
// 선택한 옵션을 사용자 메시지로 전송
|
|
InputBox.Text = capturedLabel;
|
|
_ = SendMessageAsync();
|
|
};
|
|
chipPanel.Children.Add(chipBorder);
|
|
}
|
|
container.Children.Add(chipPanel);
|
|
}
|
|
|
|
// 완성된 메시지 컨테이너를 GPU 비트맵으로 캐시 → 스크롤 시 레이아웃 재계산 없이 렌더링
|
|
container.CacheMode = new System.Windows.Media.BitmapCache(1.0)
|
|
{ SnapsToDevicePixels = true };
|
|
}
|
|
|
|
/// <summary>AI 응답에서 번호 선택지를 파싱합니다. (1. xxx / 2. xxx 패턴)</summary>
|
|
private static List<(string Num, string Label)> ParseSuggestionChips(string content)
|
|
{
|
|
var chips = new List<(string, string)>();
|
|
if (string.IsNullOrEmpty(content)) return chips;
|
|
|
|
var lines = content.Split('\n');
|
|
// 마지막 번호 목록 블록을 찾음 (연속된 번호 라인)
|
|
var candidates = new List<(string, string)>();
|
|
var lastBlockStart = -1;
|
|
|
|
for (int i = 0; i < lines.Length; i++)
|
|
{
|
|
var line = lines[i].Trim();
|
|
// "1. xxx", "2) xxx", "① xxx" 등 번호 패턴
|
|
var m = System.Text.RegularExpressions.Regex.Match(line, @"^(\d+)[.\)]\s+(.+)$");
|
|
if (m.Success)
|
|
{
|
|
if (lastBlockStart < 0 || i == lastBlockStart + candidates.Count)
|
|
{
|
|
if (lastBlockStart < 0) { lastBlockStart = i; candidates.Clear(); }
|
|
candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd()));
|
|
}
|
|
else
|
|
{
|
|
// 새로운 블록 시작
|
|
lastBlockStart = i;
|
|
candidates.Clear();
|
|
candidates.Add((m.Groups[1].Value, m.Groups[2].Value.TrimEnd()));
|
|
}
|
|
}
|
|
else if (!string.IsNullOrWhiteSpace(line))
|
|
{
|
|
// 번호 목록이 아닌 줄이 나오면 블록 리셋
|
|
lastBlockStart = -1;
|
|
candidates.Clear();
|
|
}
|
|
// 빈 줄은 블록 유지 (번호 목록 사이 빈 줄 허용)
|
|
}
|
|
|
|
// 2개 이상 선택지, 10개 이하일 때만 chips로 표시
|
|
if (candidates.Count >= 2 && candidates.Count <= 10)
|
|
chips.AddRange(candidates);
|
|
|
|
return chips;
|
|
}
|
|
|
|
/// <summary>토큰 수를 k/m 단위로 포맷</summary>
|
|
private static string FormatTokenCount(int count) => count switch
|
|
{
|
|
>= 1_000_000 => $"{count / 1_000_000.0:0.#}m",
|
|
>= 1_000 => $"{count / 1_000.0:0.#}k",
|
|
_ => count.ToString(),
|
|
};
|
|
|
|
/// <summary>토큰 수 추정 (한국어~3자/토큰, 영어~4자/토큰, 혼합 평균 ~3자/토큰)</summary>
|
|
private static int EstimateTokenCount(string text)
|
|
{
|
|
if (string.IsNullOrEmpty(text)) return 0;
|
|
// 한국어 문자 비율에 따라 가중
|
|
int cjk = 0;
|
|
foreach (var c in text)
|
|
if (c >= 0xAC00 && c <= 0xD7A3 || c >= 0x3000 && c <= 0x9FFF) cjk++;
|
|
double ratio = text.Length > 0 ? (double)cjk / text.Length : 0;
|
|
double charsPerToken = 4.0 - ratio * 2.0; // 영어 4, 한국어 2
|
|
return Math.Max(1, (int)Math.Round(text.Length / charsPerToken));
|
|
}
|
|
|
|
// ─── 생성 중지 ──────────────────────────────────────────────────────
|
|
|
|
private void StopGeneration()
|
|
{
|
|
// 현재 활성 탭이 스트리밍 중이면 그 탭만 취소; 아니면 모두 취소
|
|
if (_tabStreamCts.TryGetValue(_activeTab, out var activeCts))
|
|
activeCts.Cancel();
|
|
else
|
|
foreach (var cts in _tabStreamCts.Values) cts.Cancel();
|
|
|
|
// 대기열 정리: 실행 중인 항목만 취소, 대기 중 항목은 보존
|
|
lock (_convLock)
|
|
{
|
|
_draftQueueProcessor.CancelRunning(ChatSession, _activeTab, _storage);
|
|
// Queue preserved — user can manually clear or items will execute on next send
|
|
// _draftQueueProcessor.ClearQueued(ChatSession, _activeTab, _storage);
|
|
_runningDraftId = null;
|
|
}
|
|
RefreshDraftQueueUi();
|
|
|
|
// 즉시 UI 상태 정리 — 에이전트 루프의 finally가 비동기로 도달할 때까지 대기하지 않음
|
|
StopLiveAgentProgressHints();
|
|
RemoveAgentLiveCard();
|
|
HideStickyProgress();
|
|
StopRainbowGlow();
|
|
}
|
|
|
|
// ─── 대화 내보내기 ──────────────────────────────────────────────────
|
|
}
|