From da11029284241c0e45db34731e09ffe3ae69facf Mon Sep 17 00:00:00 2001 From: lacvet Date: Sun, 12 Apr 2026 22:32:40 +0900 Subject: [PATCH] =?UTF-8?q?claude-code=20=EA=B8=B0=EC=A4=80=20provider=20?= =?UTF-8?q?=ED=98=B8=ED=99=98=EC=84=B1=EA=B3=BC=20compact=20=ED=9B=84?= =?UTF-8?q?=EC=86=8D=20=ED=9D=90=EB=A6=84=EC=9D=84=20=EB=B3=B4=EA=B0=95?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OpenAI 호환 tool_choice 400 오류에 대한 일반 fallback을 추가하고 Qwen·LLaMA·DeepSeek 계열 vLLM의 도구 호출 프로파일을 더 보수적으로 조정 - compact 이후 branch context와 최근 tool state를 query view에 재주입하고 UI 표현 수준에 맞춰 compact 카드/컨텍스트 사용 팝업/최종 보고 밀도를 세분화 - README와 DEVELOPMENT 문서 이력을 2026-04-12 23:45 KST 기준으로 갱신 - 검증: dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\ 경고 0 / 오류 0 --- README.md | 6 ++ docs/DEVELOPMENT.md | 9 ++ .../Services/Agent/AgentLoopService.cs | 35 +++++++ .../Agent/AgentQueryContextBuilder.cs | 44 ++++++++- src/AxCopilot/Services/LlmService.ToolUse.cs | 95 +++++++++++++++---- .../ChatWindow.ContextUsagePresentation.cs | 22 ++++- .../Views/ChatWindow.TimelinePresentation.cs | 31 +++++- 7 files changed, 215 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 3fd1610..ff05cab 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,12 @@ Windows 전용 시맨틱 런처 & 워크스페이스 매니저 개발 참고: Claw Code 동등성 작업 추적 문서 `docs/claw-code-parity-plan.md` +- 업데이트: 2026-04-12 23:45 (KST) +- `claude-code` 기준으로 남아 있던 provider/compact/UI 후속 차이를 더 줄였습니다. OpenAI 호환 도구 호출은 이제 모델 계열별 호환 프로파일을 적용해 Qwen·LLaMA·DeepSeek 같은 취약한 vLLM 계열에서 최근 structured tool history 범위를 더 좁히고, 병렬 tool call과 reasoning_effort도 더 보수적으로 전송합니다. +- tool forcing fallback도 보강했습니다. IBM 배포형뿐 아니라 일반 OpenAI 호환 경로에서도 `tool_choice`가 400으로 거부되면, plain text 지시를 덧붙인 대체 요청으로 한 번 더 재시도해 `채팅은 되지만 Cowork/Code만 막히는` 조합을 줄였습니다. +- compact 이후 query view에는 복원된 branch context와 최근 tool state까지 함께 다시 주입합니다. UI는 표현 수준(`rich/balanced/simple`)에 맞춰 compact 카드와 컨텍스트 사용 팝업 밀도를 다르게 보여주고, 최종 보고 프롬프트도 같은 수준에 맞춰 더 짧거나 더 구조적으로 정리되도록 맞췄습니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0 + - 업데이트: 2026-04-10 14:30 (KST) - **UI 프리징 근본 수정**: 스트리밍 중 렌더링 쓰로틀(1.5초 최소 간격), 이중 RenderMessages 제거, 타이머 Stop→Start 무한 루프 차단, 불필요 타이머 4개 일시 정지, 타이머 간격 2-10배 증가(350ms→5s, 500ms→2s 등). 에이전트 이벤트 디스패처 우선순위를 Normal→Background로 하향. - **모델 프로파일 도구 사용 버그 수정**: Ollama 모델에 `tool_choice: "required"` 미전달 버그 수정 — `BuildOpenAiToolBody`에서 Ollama 조기 리턴 전에 `tool_choice` 주입. `FindRegisteredModel`의 대소문자 민감 비교를 `OrdinalIgnoreCase`로 변경하여 프로파일 매칭 실패 방지. diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 3fd4d97..e5803ff 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -734,3 +734,12 @@ owKindCounts를 함께 남겨 %APPDATA%\\AxCopilot\\perf 기준으로 transcript - compact 뒤 첫 LLM 호출이 복원된 맥락의 종류를 더 안정적으로 전달받습니다. - transcript와 usage UI가 `claw-code`처럼 더 얇고 조용한 운영 메타 표현을 유지합니다. +## claude-code식 provider/compact/UI 후속 정렬 + +- 업데이트: 2026-04-12 23:45 (KST) +- `LlmService.ToolUse`에 OpenAI 호환 provider별 도구 호출 호환 프로파일을 추가했습니다. Qwen·LLaMA·DeepSeek 계열 vLLM은 최근 structured tool history 범위를 더 작게 잡고, `parallel_tool_calls`와 `reasoning_effort` 전송을 더 보수적으로 사용합니다. +- OpenAI 호환 도구 호출 재시도도 확장했습니다. IBM 배포형만이 아니라 일반 OpenAI 호환 경로에서도 `tool_choice`가 400으로 거부되면 `tool_choice` 없이 plain-text tool-only 지시를 덧붙인 fallback body로 한 번 더 재시도합니다. +- `AgentQueryContextBuilder`의 post-compact context에는 compact summary, file/image refs 외에 `branch_context`와 최근 tool state를 함께 싣도록 보강했습니다. compact 직후 첫 턴에서 요약/분기/도구 맥락이 더 자연스럽게 이어지도록 맞춘 변경입니다. +- `AgentLoopService`의 최종 보고 프롬프트는 `AgentUiExpressionLevel`을 반영합니다. `simple`은 매우 짧은 종료 보고, `balanced`는 기본 요약, `rich`는 review/high-impact 중심의 구조화 보고로 밀도를 달리합니다. +- `ChatWindow.TimelinePresentation`, `ChatWindow.ContextUsagePresentation`도 표현 수준에 맞춰 compact 메타 노출량을 다르게 조정했습니다. `simple`은 짧은 한 줄 위주, `rich`는 필요한 경우 compact preview를 덧붙여 보여줍니다. +- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify\\ -p:IntermediateOutputPath=obj\\verify\\` 경고 0 / 오류 0 diff --git a/src/AxCopilot/Services/Agent/AgentLoopService.cs b/src/AxCopilot/Services/Agent/AgentLoopService.cs index 334e3c5..8e91b06 100644 --- a/src/AxCopilot/Services/Agent/AgentLoopService.cs +++ b/src/AxCopilot/Services/Agent/AgentLoopService.cs @@ -3517,8 +3517,30 @@ public partial class AgentLoopService "찾을 수 없"); } + private static string GetAgentUiExpressionLevel() + { + var app = System.Windows.Application.Current as App; + var raw = app?.SettingsService?.Settings?.Llm?.AgentUiExpressionLevel; + return (raw ?? "balanced").Trim().ToLowerInvariant() switch + { + "rich" => "rich", + "simple" => "simple", + _ => "balanced", + }; + } + private static string BuildFinalReportQualityPrompt(TaskTypePolicy taskPolicy, bool highImpact) { + var expressionLevel = GetAgentUiExpressionLevel(); + + if (expressionLevel == "simple" && !taskPolicy.IsReviewTask && !highImpact) + { + return "[System:FinalReportQuality] 최종 답변을 매우 짧게 정리하세요.\n" + + "1. 변경 또는 산출물 한 줄\n" + + "2. 확인한 근거 한 줄\n" + + "실제 미해결 문제가 있을 때만 마지막 한 줄을 추가하세요."; + } + if (!taskPolicy.IsReviewTask && !highImpact) { return "[System:FinalReportQuality] 최종 답변을 짧고 명확하게 정리하세요.\n" + @@ -3531,6 +3553,19 @@ public partial class AgentLoopService var taskLine = taskPolicy.FinalReportTaskLine; var riskLine = "남은 리스크나 추가 확인 필요 사항이 실제로 남아 있을 때만 짧게 적으세요.\n"; + if (expressionLevel == "rich") + { + return "[System:FinalReportQuality] 최종 답변을 구조적으로 정리하세요.\n" + + "1. 무엇을 변경했는지\n" + + "2. 어떤 파일/호출부/자료를 확인했는지\n" + + "3. 어떤 build/test/검증 근거가 있는지\n" + + "4. 실제 파일 경로 또는 파일명 1~3개를 명시하세요\n" + + "5. review 작업이면 이슈별 상태를 구분하세요\n" + + taskLine + + riskLine + + "후속 권유는 실제 미해결 위험이 남아 있을 때만 포함하세요."; + } + return "[System:FinalReportQuality] 최종 답변을 더 구조적으로 정리하세요.\n" + "1. 무엇을 변경했는지\n" + "2. 어떤 파일/호출부를 확인했는지\n" + diff --git a/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs b/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs index 4605bb5..964d768 100644 --- a/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs +++ b/src/AxCopilot/Services/Agent/AgentQueryContextBuilder.cs @@ -157,21 +157,39 @@ public static class AgentQueryContextBuilder string.Equals(m.MetaKind, "microcompact_boundary", StringComparison.OrdinalIgnoreCase) || string.Equals(m.MetaKind, "session_memory_compaction", StringComparison.OrdinalIgnoreCase) || string.Equals(m.MetaKind, "collapsed_boundary", StringComparison.OrdinalIgnoreCase)); + var branchContextCount = messages.Count(m => + string.Equals(m.MetaKind, "branch_context", StringComparison.OrdinalIgnoreCase)); var structuredToolHistoryCount = messages.Count(m => { var content = m.Content ?? ""; return content.StartsWith("{\"_tool_use_blocks\"", StringComparison.Ordinal) || content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal); }); + var recentToolNames = messages + .Where(m => m.Role == "user") + .Select(m => TryExtractToolResultToolName(m, out var toolName) ? toolName : "") + .Where(toolName => !string.IsNullOrWhiteSpace(toolName)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(4) + .ToList(); - if (attachedFiles.Count == 0 && imageCount == 0 && compactSummaryCount == 0 && structuredToolHistoryCount == 0) + if (attachedFiles.Count == 0 + && imageCount == 0 + && compactSummaryCount == 0 + && structuredToolHistoryCount == 0 + && branchContextCount == 0 + && recentToolNames.Count == 0) return; var lines = new List { "[post-compact context]" }; if (compactSummaryCount > 0) lines.Add($"restored compact summaries: {compactSummaryCount}"); + if (branchContextCount > 0) + lines.Add($"restored branch context: {branchContextCount}"); if (structuredToolHistoryCount > 0) lines.Add($"restored tool history blocks: {structuredToolHistoryCount}"); + if (recentToolNames.Count > 0) + lines.Add("restored recent tools: " + string.Join(", ", recentToolNames)); if (attachedFiles.Count > 0) lines.Add("restored file refs: " + string.Join(", ", attachedFiles)); if (imageCount > 0) @@ -190,4 +208,28 @@ public static class AgentQueryContextBuilder AttachedFiles = attachedFiles.Count > 0 ? attachedFiles : null, }); } + + private static bool TryExtractToolResultToolName(ChatMessage message, out string toolName) + { + toolName = ""; + if (!string.Equals(message.Role, "user", StringComparison.OrdinalIgnoreCase) + || string.IsNullOrWhiteSpace(message.Content) + || !message.Content.StartsWith("{\"type\":\"tool_result\"", StringComparison.Ordinal)) + return false; + + try + { + using var doc = System.Text.Json.JsonDocument.Parse(message.Content); + if (doc.RootElement.TryGetProperty("tool_name", out var toolNameEl)) + { + toolName = toolNameEl.GetString() ?? ""; + return !string.IsNullOrWhiteSpace(toolName); + } + } + catch + { + } + + return false; + } } diff --git a/src/AxCopilot/Services/LlmService.ToolUse.cs b/src/AxCopilot/Services/LlmService.ToolUse.cs index ed99123..5a272e3 100644 --- a/src/AxCopilot/Services/LlmService.ToolUse.cs +++ b/src/AxCopilot/Services/LlmService.ToolUse.cs @@ -545,10 +545,14 @@ public partial class LlmService var detail = ExtractErrorDetail(errBody); LogService.Warn($"[ToolUse] {activeService} API 오류 ({resp.StatusCode}): {errBody}"); - if (isIbmDeployment && forceToolCall && (int)resp.StatusCode == 400) + if (forceToolCall && (int)resp.StatusCode == 400) { - LogService.Warn("[ToolUse] IBM 배포형 경로에서 tool_choice가 거부되어 대체 강제 전략으로 재시도합니다."); - var fallbackBody = BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false); + LogService.Warn(isIbmDeployment + ? "[ToolUse] IBM 배포형 경로에서 tool_choice가 거부되어 대체 강제 전략으로 재시도합니다." + : "[ToolUse] OpenAI 호환 경로에서 tool_choice가 거부되어 텍스트 지시 기반 강제 전략으로 재시도합니다."); + var fallbackBody = isIbmDeployment + ? BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false) + : BuildOpenAiToolBody(messages, tools, forceToolCall: true, useToolChoice: false); var fallbackJson = JsonSerializer.Serialize(fallbackBody); WorkflowLogService.LogLlmRawRequestFromContext(url, fallbackJson); using var retryReq = new HttpRequestMessage(HttpMethod.Post, url) @@ -558,7 +562,7 @@ public partial class LlmService await ApplyAuthHeaderAsync(retryReq, ct); using var retryResp = await SendWithTlsAsync(retryReq, allowInsecureTls, ct, HttpCompletionOption.ResponseHeadersRead); if (retryResp.IsSuccessStatusCode) - return await ReadOpenAiToolBlocksFromStreamAsync(retryResp, true, prefetchToolCallAsync, ct); + return await ReadOpenAiToolBlocksFromStreamAsync(retryResp, isIbmDeployment, prefetchToolCallAsync, ct); } // 400 BadRequest → 도구 없이 일반 응답으로 폴백 시도 @@ -686,11 +690,59 @@ public partial class LlmService } } - private object BuildOpenAiToolBody(List messages, IReadOnlyCollection tools, bool forceToolCall = false) + private sealed record OpenAiToolCompatibilityProfile( + int StructuredHistoryRecentWindow, + bool AllowParallelToolCalls, + bool IncludeReasoningEffort, + bool AddToolOnlyDirectiveOnFallback); + + private OpenAiToolCompatibilityProfile GetOpenAiToolCompatibilityProfile(string service, string model) + { + var normalizedService = NormalizeServiceName(service); + var normalizedModel = (model ?? "").Trim().ToLowerInvariant(); + var isFragileVllmFamily = + normalizedService == "vllm" && + (normalizedModel.Contains("qwen", StringComparison.OrdinalIgnoreCase) + || normalizedModel.Contains("llama", StringComparison.OrdinalIgnoreCase) + || normalizedModel.Contains("deepseek", StringComparison.OrdinalIgnoreCase) + || normalizedModel.Contains("mistral", StringComparison.OrdinalIgnoreCase)); + + if (isFragileVllmFamily) + { + return new OpenAiToolCompatibilityProfile( + StructuredHistoryRecentWindow: 4, + AllowParallelToolCalls: false, + IncludeReasoningEffort: false, + AddToolOnlyDirectiveOnFallback: true); + } + + return new OpenAiToolCompatibilityProfile( + StructuredHistoryRecentWindow: 8, + AllowParallelToolCalls: true, + IncludeReasoningEffort: true, + AddToolOnlyDirectiveOnFallback: true); + } + + private static string BuildOpenAiToolOnlyDirective(IReadOnlyCollection tools) + { + var toolNames = string.Join(", ", tools.Select(t => t.Name).Take(12)); + return "[TOOL_ONLY] 텍스트로 설명하지 말고 지금 바로 도구를 호출하세요. " + + $"사용 가능한 도구: {toolNames}. " + + "plain text 대신 function/tool call을 사용하세요."; + } + + private object BuildOpenAiToolBody( + List messages, + IReadOnlyCollection tools, + bool forceToolCall = false, + bool useToolChoice = true) { var llm = _settings.Settings.Llm; + var activeService = ResolveService(); + var activeModel = ResolveModel(); + var compatibilityProfile = GetOpenAiToolCompatibilityProfile(activeService, activeModel); var msgs = new List(); - var structuredHistoryStart = GetStructuredToolHistoryStartIndex(messages); + var structuredHistoryStart = GetStructuredToolHistoryStartIndex(messages, compatibilityProfile.StructuredHistoryRecentWindow); for (var messageIndex = 0; messageIndex < messages.Count; messageIndex++) { @@ -790,6 +842,15 @@ public partial class LlmService } } + if (forceToolCall && !useToolChoice && compatibilityProfile.AddToolOnlyDirectiveOnFallback) + { + msgs.Add(new + { + role = "user", + content = BuildOpenAiToolOnlyDirective(tools), + }); + } + // ── tool_calls ↔ tool 메시지 쌍 검증 ── // 컨텍스트 압축 후 tool_calls assistant 메시지는 남아있는데 // 대응하는 tool result 메시지가 누락되면 vLLM이 400 에러를 반환함. @@ -822,8 +883,6 @@ public partial class LlmService }; }).ToArray(); - var activeService = ResolveService(); - var activeModel = ResolveModel(); var executionPolicy = GetActiveExecutionPolicy(); var isOllama = activeService.Equals("ollama", StringComparison.OrdinalIgnoreCase); if (isOllama) @@ -839,7 +898,7 @@ public partial class LlmService ["options"] = new { temperature = ResolveToolTemperature() } }; // Ollama에도 tool_choice 전달 — 프로파일 ForceInitialToolCall 적용 - if (forceToolCall) + if (forceToolCall && useToolChoice) ollamaBody["tool_choice"] = "required"; return ollamaBody; } @@ -852,23 +911,22 @@ public partial class LlmService ["stream"] = true, ["temperature"] = ResolveToolTemperature(), ["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(), - ["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch, + ["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch && compatibilityProfile.AllowParallelToolCalls, }; // 스트리밍 시 마지막 청크에 토큰 사용량 포함 요청 (vLLM/OpenAI 호환) body["stream_options"] = new { include_usage = true }; // tool_choice: "required" — 모델이 반드시 도구를 호출하도록 강제 // 아직 한 번도 도구를 호출하지 않은 첫 번째 요청에서만 사용 (chatty 모델 대응) - if (forceToolCall) + if (forceToolCall && useToolChoice) body["tool_choice"] = "required"; var effort = ResolveReasoningEffort(); - if (!string.IsNullOrWhiteSpace(effort)) + if (compatibilityProfile.IncludeReasoningEffort && !string.IsNullOrWhiteSpace(effort)) body["reasoning_effort"] = effort; return body; } - private static int GetStructuredToolHistoryStartIndex(IReadOnlyList messages) + private static int GetStructuredToolHistoryStartIndex(IReadOnlyList messages, int protectedRecentNonSystemMessages) { - const int protectedRecentNonSystemMessages = 8; var nonSystemMessages = messages .Select((message, index) => new { message, index }) .Where(x => !string.Equals(x.message.Role, "system", StringComparison.OrdinalIgnoreCase)) @@ -1229,9 +1287,14 @@ public partial class LlmService { var errBody = await resp.Content.ReadAsStringAsync(ct); var detail = ExtractErrorDetail(errBody); - if (isIbmDeployment && forceToolCall && (int)resp.StatusCode == 400) + if (forceToolCall && (int)resp.StatusCode == 400) { - var fallbackBody = BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false); + LogService.Warn(isIbmDeployment + ? "[ToolUse] IBM 배포형 스트리밍 경로에서 tool_choice가 거부되어 대체 강제 전략으로 재시도합니다." + : "[ToolUse] OpenAI 호환 스트리밍 경로에서 tool_choice가 거부되어 텍스트 지시 기반 강제 전략으로 재시도합니다."); + var fallbackBody = isIbmDeployment + ? BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false) + : BuildOpenAiToolBody(messages, tools, forceToolCall: true, useToolChoice: false); var fallbackJson = JsonSerializer.Serialize(fallbackBody); WorkflowLogService.LogLlmRawRequestFromContext(url, fallbackJson); using var retryReq = new HttpRequestMessage(HttpMethod.Post, url) diff --git a/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs b/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs index 9f28ee7..b855a7d 100644 --- a/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.ContextUsagePresentation.cs @@ -6,7 +6,7 @@ namespace AxCopilot.Views; public partial class ChatWindow { - // 토큰 추정 캐시: 메시지 수나 대화 ID가 달라질 때만 재계산 + // 메시지 개수와 대화 ID가 바뀔 때만 재계산한다. private int _cachedMessageTokens; private int _cachedMessageCountForTokens = -1; private string? _cachedConvIdForTokens; @@ -28,6 +28,7 @@ public partial class ChatWindow } var llm = _settings.Settings.Llm; + var expressionLevel = GetAgentUiExpressionLevel(); var maxContextTokens = Math.Clamp(llm.MaxContextTokens, 1024, 1_000_000); var triggerPercent = Math.Clamp(llm.ContextCompactTriggerPercent, 10, 95); var triggerRatio = triggerPercent / 100.0; @@ -45,6 +46,7 @@ public partial class ChatWindow _cachedConvIdForTokens = convId; _cachedMessageCountForTokens = msgCount; } + messageTokens = _cachedMessageTokens; } @@ -52,7 +54,7 @@ public partial class ChatWindow var draftTokens = string.IsNullOrWhiteSpace(draftText) ? 0 : Services.TokenEstimator.Estimate(draftText) + 4; var hasAnyMessages = messageTokens > 0 || _isStreaming; - int baseOverhead = 0; + var baseOverhead = 0; if (hasAnyMessages) { var sysPromptLen = _llm?.SystemPrompt?.Length ?? 0; @@ -118,11 +120,21 @@ public partial class ChatWindow if (TokenUsagePopupUsage != null) TokenUsagePopupUsage.Text = $"{Services.TokenEstimator.Format(currentTokens)}/{Services.TokenEstimator.Format(maxContextTokens)}"; if (TokenUsagePopupDetail != null) - TokenUsagePopupDetail.Text = detailText; + TokenUsagePopupDetail.Text = expressionLevel == "simple" + ? detailText + : $"{detailText} · 임계 {triggerPercent}%"; if (TokenUsagePopupCompact != null) + { TokenUsagePopupCompact.Text = _sessionCompactionCount > 0 - ? $"누적 압축 {_sessionCompactionCount}회 · 절감 {FormatTokenCount(_sessionCompactionSavedTokens)}" - : "AX Agent가 컨텍스트를 자동 관리합니다"; + ? expressionLevel switch + { + "simple" => $"압축 {_sessionCompactionCount}회 · {FormatTokenCount(_sessionCompactionSavedTokens)} 절감", + _ => $"누적 압축 {_sessionCompactionCount}회 · 절감 {FormatTokenCount(_sessionCompactionSavedTokens)}" + } + : expressionLevel == "rich" + ? "AX Agent가 컨텍스트를 자동 관리합니다" + : "컨텍스트 자동 관리"; + } TokenUsageCard.ToolTip = null; diff --git a/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs b/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs index 3c31afe..4c66a5e 100644 --- a/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs +++ b/src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs @@ -350,6 +350,7 @@ public partial class ChatWindow private Border CreateCompactionMetaCard(ChatMessage message, Brush primaryText, Brush secondaryText, Brush hintBg, Brush borderBrush, Brush accentBrush) { + var expressionLevel = GetAgentUiExpressionLevel(); var icon = "\uE9CE"; var title = message.MetaKind switch { @@ -399,20 +400,40 @@ public partial class ChatWindow }; var detailBits = new List(); - if (message.AttachedFiles?.Count > 0) + if (message.AttachedFiles?.Count > 0 && expressionLevel != "simple") detailBits.Add($"파일 {message.AttachedFiles.Count}개"); - var compactDetail = detailBits.Count > 0 - ? $"{summary} · {string.Join(", ", detailBits)}" - : summary; + var compactDetail = expressionLevel switch + { + "simple" => summary, + _ when detailBits.Count > 0 => $"{summary} · {string.Join(", ", detailBits)}", + _ => summary + }; stack.Children.Add(new TextBlock { Text = compactDetail, - FontSize = 10.5, + FontSize = expressionLevel == "simple" ? 10 : 10.5, Foreground = secondaryText, TextWrapping = TextWrapping.Wrap, }); + if (expressionLevel == "rich" && !string.IsNullOrWhiteSpace(message.Content)) + { + var preview = message.Content.Trim(); + if (preview.Length > 120) + preview = preview[..120] + "..."; + + stack.Children.Add(new TextBlock + { + Text = preview, + FontSize = 10, + Foreground = secondaryText, + Opacity = 0.88, + Margin = new Thickness(0, 6, 0, 0), + TextWrapping = TextWrapping.Wrap, + }); + } + wrapper.Child = stack; return wrapper; }