Some checks failed
Release Gate / gate (push) Has been cancelled
- IBM 배포형 도구 호출 바디에 프로파일 기반 tool temperature를 적용하고 tool_call_strict 프로파일에서 더 직접적인 tool-only 지시를 추가함 - IBM 경로가 tool_choice를 거부할 때 tool_choice만 제거한 대체 강제 재시도 경로를 추가함 - OpenAI/vLLM tool-use 응답을 SSE로 수신하고 delta.tool_calls를 부분 조립해 도구 호출을 더 빨리 감지하도록 변경함 - read-only 도구 조기 실행과 결과 재사용 경로를 도입해 Cowork/Code 도구 착수 속도를 개선함 - README와 DEVELOPMENT 문서를 2026-04-08 11:14(KST) 기준으로 갱신함 검증 - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\ - 경고 0 / 오류 0
534 lines
19 KiB
C#
534 lines
19 KiB
C#
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 AxCopilot.Models;
|
|
using AxCopilot.Services;
|
|
|
|
namespace AxCopilot.Views;
|
|
|
|
public partial class ChatWindow
|
|
{
|
|
// 레이아웃 재계산 억제용 캐시: 동일한 높이면 WPF measure/arrange 생략
|
|
private double _cachedInputBoxHeight = -1;
|
|
private int _cachedInputBoxMaxLines = -1;
|
|
|
|
private void UpdateInputBoxHeight()
|
|
{
|
|
if (InputBox == null)
|
|
return;
|
|
|
|
var text = InputBox.Text ?? string.Empty;
|
|
var explicitLineCount = 1 + text.Count(ch => ch == '\n');
|
|
var displayMode = (_settings.Settings.Llm.AgentUiExpressionLevel ?? "balanced").Trim().ToLowerInvariant();
|
|
if (displayMode is not ("rich" or "balanced" or "simple"))
|
|
displayMode = "balanced";
|
|
|
|
var maxLines = displayMode switch
|
|
{
|
|
"rich" => 6,
|
|
"simple" => 4,
|
|
_ => 5,
|
|
};
|
|
const double baseHeight = 42;
|
|
const double lineStep = 22;
|
|
var visibleLines = Math.Clamp(explicitLineCount, 1, maxLines);
|
|
var targetHeight = baseHeight + ((visibleLines - 1) * lineStep);
|
|
var needsScroll = explicitLineCount > maxLines;
|
|
|
|
// 값이 바뀐 경우에만 WPF 속성 쓰기 (매 키입력마다 레이아웃 통과 방지)
|
|
if (Math.Abs(targetHeight - _cachedInputBoxHeight) < 0.5 && maxLines == _cachedInputBoxMaxLines)
|
|
return;
|
|
|
|
_cachedInputBoxHeight = targetHeight;
|
|
_cachedInputBoxMaxLines = maxLines;
|
|
|
|
InputBox.MinLines = 1;
|
|
InputBox.MaxLines = maxLines;
|
|
InputBox.Height = targetHeight;
|
|
InputBox.VerticalScrollBarVisibility = needsScroll
|
|
? ScrollBarVisibility.Auto
|
|
: ScrollBarVisibility.Disabled;
|
|
}
|
|
|
|
private string BuildComposerDraftText()
|
|
{
|
|
var rawText = InputBox?.Text?.Trim() ?? "";
|
|
return _slashPalette.ActiveCommand != null
|
|
? (_slashPalette.ActiveCommand + " " + rawText).Trim()
|
|
: rawText;
|
|
}
|
|
|
|
private static string InferDraftKind(string text, string? explicitKind = null)
|
|
{
|
|
var trimmed = text?.Trim() ?? "";
|
|
var requestedKind = explicitKind?.Trim().ToLowerInvariant();
|
|
|
|
if (requestedKind is "followup" or "steering")
|
|
return requestedKind;
|
|
|
|
if (trimmed.StartsWith("/", StringComparison.OrdinalIgnoreCase))
|
|
return "command";
|
|
|
|
if (requestedKind is "direct" or "message")
|
|
return requestedKind;
|
|
|
|
if (trimmed.StartsWith("steer:", StringComparison.OrdinalIgnoreCase) ||
|
|
trimmed.StartsWith("@steer ", StringComparison.OrdinalIgnoreCase) ||
|
|
trimmed.StartsWith("조정:", StringComparison.OrdinalIgnoreCase))
|
|
return "steering";
|
|
|
|
return "message";
|
|
}
|
|
|
|
private void QueueComposerDraft(string priority = "next", string? explicitKind = null, bool startImmediatelyWhenIdle = true)
|
|
{
|
|
if (InputBox == null)
|
|
return;
|
|
|
|
var text = BuildComposerDraftText();
|
|
if (string.IsNullOrWhiteSpace(text))
|
|
return;
|
|
|
|
// 현재 탭이 스트리밍 중일 때만 우선순위를 "next"로 낮춤 — 다른 탭 스트리밍은 무관
|
|
if (_streamingTabs.Contains(_activeTab) && string.Equals(priority, "now", StringComparison.OrdinalIgnoreCase))
|
|
priority = "next";
|
|
|
|
HideSlashChip(restoreText: false);
|
|
ClearPromptCardPlaceholder();
|
|
|
|
var queuedItem = EnqueueDraftRequest(text, priority, explicitKind);
|
|
|
|
InputBox.Clear();
|
|
InputBox.Focus();
|
|
UpdateInputBoxHeight();
|
|
RefreshDraftQueueUi();
|
|
|
|
if (queuedItem == null)
|
|
return;
|
|
|
|
if (!_streamingTabs.Contains(_activeTab) && startImmediatelyWhenIdle)
|
|
{
|
|
StartNextQueuedDraftIfAny(queuedItem.Id);
|
|
return;
|
|
}
|
|
|
|
var runningTab = _streamRunTab;
|
|
var runningLabel = runningTab switch
|
|
{
|
|
"Cowork" => "코워크",
|
|
"Code" => "코드",
|
|
"Chat" => "채팅",
|
|
_ => runningTab ?? "다른 탭",
|
|
};
|
|
var suffix = !string.IsNullOrEmpty(runningTab) && !string.Equals(runningTab, _activeTab, StringComparison.OrdinalIgnoreCase)
|
|
? $" ({runningLabel} 실행 완료 후 자동 실행)"
|
|
: " (실행 완료 후 자동 실행)";
|
|
var toast = queuedItem.Kind switch
|
|
{
|
|
"command" => "명령이 대기열에 추가되었습니다." + suffix,
|
|
"direct" => "직접 실행 요청이 대기열에 추가되었습니다." + suffix,
|
|
"steering" => "조정 요청이 대기열에 추가되었습니다." + suffix,
|
|
"followup" => "후속 작업이 대기열에 추가되었습니다." + suffix,
|
|
_ => "메시지가 대기열에 추가되었습니다." + suffix,
|
|
};
|
|
ShowToast(toast);
|
|
}
|
|
|
|
private void RefreshDraftQueueUi()
|
|
{
|
|
if (DraftPreviewCard == null || DraftPreviewText == null || DraftQueuePanel == null || BtnDraftEnqueue == null)
|
|
return;
|
|
|
|
lock (_convLock)
|
|
{
|
|
var session = ChatSession;
|
|
if (session != null)
|
|
_draftQueueProcessor.PromoteReadyBlockedItems(session, _activeTab, _storage);
|
|
}
|
|
|
|
var items = _appState.GetDraftQueueItems(_activeTab);
|
|
|
|
DraftPreviewCard.Visibility = Visibility.Collapsed;
|
|
BtnDraftEnqueue.IsEnabled = false;
|
|
DraftPreviewText.Text = string.Empty;
|
|
|
|
RebuildDraftQueuePanel(items);
|
|
}
|
|
|
|
// --- Queue panel (Codex style) ---
|
|
|
|
private void RebuildDraftQueuePanel(IReadOnlyList<DraftQueueItem> items)
|
|
{
|
|
if (DraftQueuePanel == null)
|
|
return;
|
|
|
|
DraftQueuePanel.Children.Clear();
|
|
|
|
var visibleItems = items
|
|
.Where(x => !string.Equals(x.State, "completed", StringComparison.OrdinalIgnoreCase) // 완료 항목 제거
|
|
&& !string.Equals(x.State, "running", StringComparison.OrdinalIgnoreCase)) // 수행 중인 항목은 채팅창에서 이미 표시됨
|
|
.OrderBy(GetDraftStateRank)
|
|
.ThenBy(GetDraftPriorityRank)
|
|
.ThenBy(x => x.CreatedAt)
|
|
.ToList();
|
|
|
|
if (visibleItems.Count == 0)
|
|
{
|
|
DraftQueuePanel.Visibility = Visibility.Collapsed;
|
|
return;
|
|
}
|
|
|
|
DraftQueuePanel.Visibility = Visibility.Visible;
|
|
|
|
// 단일 통합 컨테이너
|
|
var containerBorder = new Border
|
|
{
|
|
Background = TryFindResource("ItemBackground") as Brush ?? BrushFromHex("#1E1E2A"),
|
|
BorderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#3A3A4A"),
|
|
BorderThickness = new Thickness(1),
|
|
CornerRadius = new CornerRadius(10),
|
|
Margin = new Thickness(0, 0, 0, 4),
|
|
};
|
|
|
|
var innerStack = new StackPanel();
|
|
containerBorder.Child = innerStack;
|
|
|
|
for (int i = 0; i < visibleItems.Count; i++)
|
|
{
|
|
innerStack.Children.Add(CreateDraftQueueRow(visibleItems[i]));
|
|
|
|
// 구분선 (마지막 항목 제외)
|
|
if (i < visibleItems.Count - 1)
|
|
{
|
|
innerStack.Children.Add(new Border
|
|
{
|
|
Height = 1,
|
|
Background = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#2E2E3E"),
|
|
Margin = new Thickness(10, 0, 10, 0),
|
|
});
|
|
}
|
|
}
|
|
|
|
DraftQueuePanel.Children.Add(containerBorder);
|
|
|
|
// 실패 항목 정리 버튼 (실패만 — 완료는 자동 제거됨)
|
|
var summary = _appState.GetDraftQueueSummary(_activeTab);
|
|
if (summary.FailedCount > 0)
|
|
{
|
|
var failBtn = CreateQueueFooterButton($"실패 정리 ({summary.FailedCount})", ClearFailedDrafts);
|
|
DraftQueuePanel.Children.Add(failBtn);
|
|
}
|
|
}
|
|
|
|
// --- Codex-style row ---
|
|
|
|
private Border CreateDraftQueueRow(DraftQueueItem item)
|
|
{
|
|
var isRunning = string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase);
|
|
var isFailed = string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase);
|
|
var primaryText = TryFindResource("PrimaryText") as Brush ?? BrushFromHex("#E5E5EA");
|
|
var secondaryText = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#8E8E9A");
|
|
|
|
var row = new Border
|
|
{
|
|
Padding = new Thickness(10, 7, 8, 7),
|
|
};
|
|
|
|
var grid = new Grid();
|
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // state icon
|
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); // text
|
|
grid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); // actions
|
|
row.Child = grid;
|
|
|
|
// State icon
|
|
var stateIcon = new TextBlock
|
|
{
|
|
Text = GetDraftStateIcon(item),
|
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
|
FontSize = 11,
|
|
Foreground = GetDraftStateIconBrush(item),
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
Margin = new Thickness(0, 0, 8, 0),
|
|
Width = 14,
|
|
};
|
|
Grid.SetColumn(stateIcon, 0);
|
|
grid.Children.Add(stateIcon);
|
|
|
|
// Message text
|
|
var msgText = new TextBlock
|
|
{
|
|
Text = item.Text,
|
|
FontSize = 12,
|
|
Foreground = isFailed ? (TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#8E8E9A")) : primaryText,
|
|
TextTrimming = TextTrimming.CharacterEllipsis,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
Margin = new Thickness(0, 0, 8, 0),
|
|
};
|
|
Grid.SetColumn(msgText, 1);
|
|
grid.Children.Add(msgText);
|
|
|
|
// Right actions
|
|
var actions = new StackPanel
|
|
{
|
|
Orientation = Orientation.Horizontal,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
};
|
|
Grid.SetColumn(actions, 2);
|
|
grid.Children.Add(actions);
|
|
|
|
if (!isRunning)
|
|
{
|
|
// Kind chip (↪ 조정 style)
|
|
actions.Children.Add(CreateKindChip(item, secondaryText));
|
|
}
|
|
|
|
if (!isRunning && !isFailed)
|
|
{
|
|
// Run now button
|
|
actions.Children.Add(CreateRowIconButton("\uE768", "지금 실행", () => QueueDraftForImmediateRun(item.Id)));
|
|
}
|
|
|
|
if (!isRunning)
|
|
{
|
|
// Edit button
|
|
actions.Children.Add(CreateRowIconButton("\uE70F", "편집", () => PopDraftToEditor(item.Id)));
|
|
}
|
|
|
|
// Delete
|
|
actions.Children.Add(CreateRowIconButton("\uE74D", isRunning ? "취소" : "삭제", () => RemoveDraftFromQueue(item.Id)));
|
|
|
|
return row;
|
|
}
|
|
|
|
// Kind chip on the right — "↪ 조정" style
|
|
private Border CreateKindChip(DraftQueueItem item, Brush defaultForeground)
|
|
{
|
|
var (kindIcon, kindLabel) = GetDraftKindChipContent(item);
|
|
var foreground = GetDraftKindChipColor(item);
|
|
|
|
return new Border
|
|
{
|
|
BorderBrush = foreground,
|
|
BorderThickness = new Thickness(1),
|
|
CornerRadius = new CornerRadius(5),
|
|
Padding = new Thickness(5, 2, 6, 2),
|
|
Margin = new Thickness(0, 0, 4, 0),
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
Child = new StackPanel
|
|
{
|
|
Orientation = Orientation.Horizontal,
|
|
Children =
|
|
{
|
|
new TextBlock
|
|
{
|
|
Text = kindIcon,
|
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
|
FontSize = 10,
|
|
Foreground = foreground,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
Margin = new Thickness(0, 0, 3, 0),
|
|
},
|
|
new TextBlock
|
|
{
|
|
Text = kindLabel,
|
|
FontSize = 10.5,
|
|
Foreground = foreground,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
},
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
// Row icon button — flat, no border
|
|
private Button CreateRowIconButton(string icon, string tooltip, Action onClick)
|
|
{
|
|
var btn = new Button
|
|
{
|
|
Content = new TextBlock
|
|
{
|
|
Text = icon,
|
|
FontFamily = new FontFamily("Segoe MDL2 Assets"),
|
|
FontSize = 11,
|
|
VerticalAlignment = VerticalAlignment.Center,
|
|
HorizontalAlignment = HorizontalAlignment.Center,
|
|
},
|
|
Width = 24,
|
|
Height = 24,
|
|
Padding = new Thickness(0),
|
|
Background = Brushes.Transparent,
|
|
BorderThickness = new Thickness(0),
|
|
Foreground = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#8E8E9A"),
|
|
Cursor = Cursors.Hand,
|
|
ToolTip = tooltip,
|
|
};
|
|
btn.Click += (_, _) => onClick();
|
|
return btn;
|
|
}
|
|
|
|
// Footer button (failed clear)
|
|
private Button CreateQueueFooterButton(string label, Action onClick)
|
|
{
|
|
var btn = new Button
|
|
{
|
|
Content = label,
|
|
Margin = new Thickness(0, 2, 0, 0),
|
|
Padding = new Thickness(10, 4, 10, 4),
|
|
FontSize = 11,
|
|
Background = Brushes.Transparent,
|
|
BorderBrush = TryFindResource("BorderColor") as Brush ?? BrushFromHex("#3A3A4A"),
|
|
BorderThickness = new Thickness(1),
|
|
Foreground = TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#8E8E9A"),
|
|
HorizontalAlignment = HorizontalAlignment.Left,
|
|
Cursor = Cursors.Hand,
|
|
};
|
|
btn.Click += (_, _) => onClick();
|
|
return btn;
|
|
}
|
|
|
|
// Pop queued draft back into the InputBox for editing
|
|
private void PopDraftToEditor(string draftId)
|
|
{
|
|
string? text = null;
|
|
lock (_convLock)
|
|
{
|
|
var session = ChatSession;
|
|
if (session != null)
|
|
{
|
|
var item = session.GetDraftQueueItems(_activeTab)
|
|
.FirstOrDefault(x => string.Equals(x.Id, draftId, StringComparison.OrdinalIgnoreCase));
|
|
if (item != null)
|
|
{
|
|
text = item.Text;
|
|
if (session.RemoveDraft(_activeTab, draftId, _storage))
|
|
_currentConversation = session.CurrentConversation ?? _currentConversation;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (InputBox != null && text != null)
|
|
{
|
|
InputBox.Text = text;
|
|
InputBox.CaretIndex = text.Length;
|
|
InputBox.Focus();
|
|
UpdateInputBoxHeight();
|
|
}
|
|
|
|
RefreshDraftQueueUi();
|
|
}
|
|
|
|
// --- Icon-only button (kept for compatibility) ---
|
|
private Button CreateIconButton(string icon, string tooltip, Action onClick)
|
|
=> CreateRowIconButton(icon, tooltip, onClick);
|
|
|
|
// --- Ranking helpers ---
|
|
|
|
private static int GetDraftStateRank(DraftQueueItem item)
|
|
=> string.Equals(item.State, "running", StringComparison.OrdinalIgnoreCase) ? 0
|
|
: IsDraftBlocked(item) ? 1
|
|
: string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase) ? 2
|
|
: string.Equals(item.State, "failed", StringComparison.OrdinalIgnoreCase) ? 3
|
|
: 4;
|
|
|
|
private static int GetDraftPriorityRank(DraftQueueItem item)
|
|
=> item.Priority?.ToLowerInvariant() switch
|
|
{
|
|
"now" => 0,
|
|
"next" => 1,
|
|
_ => 2,
|
|
};
|
|
|
|
// --- State icon ---
|
|
|
|
private static string GetDraftStateIcon(DraftQueueItem item)
|
|
{
|
|
if (IsDraftBlocked(item)) return "\uE9F5"; // 시계
|
|
return item.State?.ToLowerInvariant() switch
|
|
{
|
|
"running" => "\uE895", // 회전 (재생 아이콘)
|
|
"failed" => "\uE783", // 경고
|
|
"completed" => "\uE73E", // 체크
|
|
_ => "\uE76C", // 대기 점
|
|
};
|
|
}
|
|
|
|
private Brush GetDraftStateIconBrush(DraftQueueItem item)
|
|
{
|
|
if (IsDraftBlocked(item)) return BrushFromHex("#C2410C");
|
|
return item.State?.ToLowerInvariant() switch
|
|
{
|
|
"running" => TryFindResource("AccentColor") as Brush ?? BrushFromHex("#5B8AF5"),
|
|
"failed" => BrushFromHex("#DC2626"),
|
|
"completed" => BrushFromHex("#16A34A"),
|
|
_ => TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#8E8E9A"),
|
|
};
|
|
}
|
|
|
|
// --- Kind chip ---
|
|
|
|
private static (string Icon, string Label) GetDraftKindChipContent(DraftQueueItem item)
|
|
=> item.Kind?.ToLowerInvariant() switch
|
|
{
|
|
"followup" => ("\uE8A5", "후속"),
|
|
"steering" => ("\uE7C3", "조정"),
|
|
"command" => ("\uE756", "명령"),
|
|
"direct" => ("\uE8A7", "직접"),
|
|
_ => ("\uE8BD", "메시지"),
|
|
};
|
|
|
|
private Brush GetDraftKindChipColor(DraftQueueItem item)
|
|
=> item.Kind?.ToLowerInvariant() switch
|
|
{
|
|
"followup" => BrushFromHex("#0F766E"),
|
|
"steering" => BrushFromHex("#B45309"),
|
|
"command" => BrushFromHex("#7C3AED"),
|
|
"direct" => BrushFromHex("#2563EB"),
|
|
_ => TryFindResource("SecondaryText") as Brush ?? BrushFromHex("#6B7280"),
|
|
};
|
|
|
|
// --- Legacy helpers (used by other partial classes) ---
|
|
|
|
private static string GetDraftKindLabel(DraftQueueItem item)
|
|
=> item.Kind?.ToLowerInvariant() switch
|
|
{
|
|
"followup" => "후속",
|
|
"steering" => "조정",
|
|
"command" => "명령",
|
|
"direct" => "직접",
|
|
_ => "메시지",
|
|
};
|
|
|
|
private static string GetDraftStateLabel(DraftQueueItem item)
|
|
=> IsDraftBlocked(item) ? "재시도 대기"
|
|
: item.State?.ToLowerInvariant() switch
|
|
{
|
|
"running" => "실행 중",
|
|
"failed" => "실패",
|
|
"completed" => "완료",
|
|
_ => "대기",
|
|
};
|
|
|
|
private (Brush Background, Brush Border, Brush Foreground) GetDraftStateBadgeColors(DraftQueueItem item)
|
|
=> IsDraftBlocked(item)
|
|
? (BrushFromHex("#FFF7ED"), BrushFromHex("#FDBA74"), BrushFromHex("#C2410C"))
|
|
: item.State?.ToLowerInvariant() switch
|
|
{
|
|
"running" => (BrushFromHex("#EFF6FF"), BrushFromHex("#BFDBFE"), BrushFromHex("#1D4ED8")),
|
|
"failed" => (BrushFromHex("#FEF2F2"), BrushFromHex("#FECACA"), BrushFromHex("#991B1B")),
|
|
"completed" => (BrushFromHex("#ECFDF5"), BrushFromHex("#BBF7D0"), BrushFromHex("#166534")),
|
|
_ => (BrushFromHex("#F5F3FF"), BrushFromHex("#DDD6FE"), BrushFromHex("#6D28D9")),
|
|
};
|
|
|
|
private static bool IsDraftBlocked(DraftQueueItem item)
|
|
=> string.Equals(item.State, "queued", StringComparison.OrdinalIgnoreCase)
|
|
&& item.NextRetryAt.HasValue
|
|
&& item.NextRetryAt.Value > DateTime.Now;
|
|
|
|
private Button CreateDraftQueueActionButton(string label, Action onClick, Brush? background = null)
|
|
=> CreateQueueFooterButton(label, onClick);
|
|
}
|