AX Agent ?? ?? ?? ??? ??? ?? ??? ????
- Code ??? ?? ???? ?? ??? ??? ? ?? ?? ???? ???? no-progress ??? ??? - ??? ?? ??? 1~2? ????? ????? ToolCall/ToolResult ?? ??? ?? ????? ????? ??? - ??? Thinking/LLM ?? ??? ??? ???? ??? ?? ?? ??? ??? ??? ????? ???? - Cowork/Code ??? ??? ?? ??? ???? ??? ??? ??? ?? ???? ? - README.md, docs/DEVELOPMENT.md ??? 2026-04-15 18:30 (KST) ???? ??? ?? - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_agent_ui_logs\\ -p:IntermediateOutputPath=obj\\verify_agent_ui_logs\\ - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopCodeQualityTests|AgentStatusNarrativeCatalogTests|AgentProgressSummarySanitizerTests" -p:OutputPath=bin\\verify_agent_ui_logs_tests\\ -p:IntermediateOutputPath=obj\\verify_agent_ui_logs_tests\\
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
# AX Commander
|
||||
|
||||
- 업데이트: 2026-04-15 18:30 (KST)
|
||||
- Code 탭에서 동일 도구 호출이 같은 시그니처로 반복될 때 빠져나오지 못하던 루프를 보강했습니다. `src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs`에 일반 반복 시그니처 가드를 추가하고, `src/AxCopilot/Services/Agent/AgentLoopService.cs`가 읽기 전용 도구뿐 아니라 `build_run` 같은 실행 도구 반복도 감지해 다른 접근으로 전환하도록 정리했습니다.
|
||||
- `src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs`, `src/AxCopilot/Views/ChatWindow.V2Rendering.cs`, `src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs`는 상단 라이브 진행 영역을 1~2줄 요약 카드로 축소하고, 실제 ToolCall/ToolResult 이력은 채팅 본문 타임라인에 계속 누적되도록 바꿨습니다. 내부 대기성 Thinking/LLM 대기 문구는 본문에서 더 공격적으로 숨겨 실행 이력이 덜 지저분하게 보이도록 조정했습니다.
|
||||
- `src/AxCopilot/Services/Agent/AgentStatusNarrativeCatalog.cs`는 진행 문구를 다시 정리해 “무엇을 확인 중인지, 왜 이 단계를 거치는지”가 짧고 친절하게 보이도록 다듬었고, `src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs`와 `src/AxCopilot/Views/ChatWindow.V2Rendering.cs`의 반복 대기 로그는 `Info` 대신 `Debug` 중심으로 낮춰 로그 소음을 줄였습니다.
|
||||
- `src/AxCopilot/Services/MarkdownRenderer.cs`와 채팅 메시지 렌더 경로(`src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs`, `src/AxCopilot/Views/ChatWindow.V2MessagePresentation.cs`, `src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs`)는 선택 가능한 마크다운 뷰를 도입해, Cowork/Code 본문 텍스트를 드래그 선택하고 복사할 수 있게 했습니다.
|
||||
- 테스트는 `src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs`, `src/AxCopilot.Tests/Services/AgentStatusNarrativeCatalogTests.cs`, `src/AxCopilot.Tests/Services/AgentProgressSummarySanitizerTests.cs`를 보강했고, `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_agent_ui_logs\\ -p:IntermediateOutputPath=obj\\verify_agent_ui_logs\\` 경고 0 / 오류 0, `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopCodeQualityTests|AgentStatusNarrativeCatalogTests|AgentProgressSummarySanitizerTests" -p:OutputPath=bin\\verify_agent_ui_logs_tests\\ -p:IntermediateOutputPath=obj\\verify_agent_ui_logs_tests\\` 131개 통과를 확인했습니다.
|
||||
|
||||
- 업데이트: 2026-04-15 17:41 (KST)
|
||||
- `~` 워크스페이스 저장/복원에 파일 탐색기와 메모장 상태 복원을 추가했습니다. `src/AxCopilot/Core/AppWorkspaceStateHelper.cs`가 파일 탐색기 현재 폴더 경로와 메모장 열린 파일 경로를 best-effort로 수집하고, `src/AxCopilot/Core/ProcessCommandLineHelper.cs`가 프로세스 명령줄 파싱을 공용화합니다.
|
||||
- `src/AxCopilot/Core/ContextManager.cs`는 저장된 탐색기/메모장 상태가 현재 창 제목과 다를 때 새 창을 띄워 원래 경로와 파일을 다시 열고, 새 창 연속 실행 시에는 CPU·메모리 부하와 이미 실행한 창 수를 반영한 적응형 지연을 넣어 복원 속도를 자동 조절합니다.
|
||||
|
||||
@@ -1471,3 +1471,10 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫
|
||||
- 寃利? - `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_internal_llm_scope\\ -p:IntermediateOutputPath=obj\\verify_internal_llm_scope\\` 寃쎄퀬 0 / ?ㅻ쪟 0
|
||||
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "LlmOperationModeTests" -p:OutputPath=bin\\verify_internal_llm_scope_tests\\ -p:IntermediateOutputPath=obj\\verify_internal_llm_scope_tests\\` ?듦낵 5
|
||||
|
||||
업데이트: 2026-04-15 18:30 (KST)
|
||||
- AX Agent 실행 루프의 반복 호출 방어를 보강했습니다. `src/AxCopilot/Services/Agent/AgentLoopTransitions.Execution.cs`는 동일 시그니처 도구 호출이 계속 반복될 때 read-only 전용 가드 외에 일반 실행 도구용 가드도 적용하고, `src/AxCopilot/Services/Agent/AgentLoopService.cs`는 이 전환을 메인 루프에 연결해 `build_run`, `process`, `wait_agents` 류 호출이 무의미하게 되풀이될 때 다른 접근을 강제하도록 정리했습니다.
|
||||
- 채팅 상단 라이브 진행 표현을 요약형으로 재구성했습니다. `src/AxCopilot/Views/ChatWindow.V2LiveProgressPresentation.cs`는 상단 카드를 1~2줄 서술형 상태 카드로 단순화했고, `src/AxCopilot/Views/ChatWindow.V2Rendering.cs`는 스트리밍 중 발생한 ToolCall/ToolResult 이벤트를 더 이상 라이브 카드 안에만 가두지 않고 본문 타임라인에 누적되게 바꿨습니다.
|
||||
- 실행 이력의 노이즈도 함께 줄였습니다. `src/AxCopilot/Services/Agent/AgentProgressSummarySanitizer.cs`, `src/AxCopilot/Views/ChatWindow.TimelinePresentation.cs`, `src/AxCopilot/Views/ChatWindow.AgentEventRendering.cs`는 모델 응답 대기, 내부 재시도, 저신호 Thinking 요약을 기본 로그/본문에서 더 적극적으로 숨기고, `src/AxCopilot/Services/Agent/StreamingToolExecutionCoordinator.cs`와 `src/AxCopilot/Views/ChatWindow.V2Rendering.cs`는 반복적인 대기 로그를 `Debug` 수준으로 낮췄습니다.
|
||||
- 사용자에게 보이는 작업 설명도 `src/AxCopilot/Services/Agent/AgentStatusNarrativeCatalog.cs`에서 다시 정리했습니다. 코드 탐색, 수정, 실행, 문서화, 권한 대기 같은 단계가 더 짧고 친절한 한국어 문구로 노출되며, 대상 파일/명령/쿼리 같은 힌트는 detail 줄로 별도 노출됩니다.
|
||||
- 채팅 본문 드래그 복사도 지원합니다. `src/AxCopilot/Services/MarkdownRenderer.cs`에 선택 가능한 RichTextBox 기반 마크다운 렌더를 추가했고, `src/AxCopilot/Views/ChatWindow.MessageBubblePresentation.cs`, `src/AxCopilot/Views/ChatWindow.V2MessagePresentation.cs`, `src/AxCopilot/Views/ChatWindow.ResponsePresentation.cs`가 Cowork/Code 본문에 이를 사용하도록 연결했습니다.
|
||||
- 테스트는 `src/AxCopilot.Tests/Services/AgentLoopCodeQualityTests.cs`, `src/AxCopilot.Tests/Services/AgentStatusNarrativeCatalogTests.cs`, `src/AxCopilot.Tests/Services/AgentProgressSummarySanitizerTests.cs`를 갱신했고, `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_agent_ui_logs\\ -p:IntermediateOutputPath=obj\\verify_agent_ui_logs\\` 경고 0 / 오류 0, `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopCodeQualityTests|AgentStatusNarrativeCatalogTests|AgentProgressSummarySanitizerTests" -p:OutputPath=bin\\verify_agent_ui_logs_tests\\ -p:IntermediateOutputPath=obj\\verify_agent_ui_logs_tests\\` 131개 통과를 확인했습니다.
|
||||
|
||||
@@ -781,6 +781,38 @@ public class AgentLoopCodeQualityTests
|
||||
blocked.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldBlockRepeatedNoProgressToolLoop_BlocksRepeatedExecutionTools()
|
||||
{
|
||||
var blocked = InvokePrivateStatic<bool>(
|
||||
"ShouldBlockRepeatedNoProgressToolLoop",
|
||||
"build_run",
|
||||
6);
|
||||
var notYet = InvokePrivateStatic<bool>(
|
||||
"ShouldBlockRepeatedNoProgressToolLoop",
|
||||
"build_run",
|
||||
5);
|
||||
|
||||
blocked.Should().BeTrue();
|
||||
notYet.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldBlockRepeatedNoProgressToolLoop_GivesPollingToolsMoreRoom()
|
||||
{
|
||||
var notYet = InvokePrivateStatic<bool>(
|
||||
"ShouldBlockRepeatedNoProgressToolLoop",
|
||||
"wait_agents",
|
||||
6);
|
||||
var blocked = InvokePrivateStatic<bool>(
|
||||
"ShouldBlockRepeatedNoProgressToolLoop",
|
||||
"wait_agents",
|
||||
7);
|
||||
|
||||
notYet.Should().BeFalse();
|
||||
blocked.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldRequestDocumentArtifact_RequiresDocsTaskAndMissingArtifact()
|
||||
{
|
||||
|
||||
@@ -6,42 +6,23 @@ namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class AgentProgressSummarySanitizerTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("1")]
|
||||
[InlineData("1.")]
|
||||
[InlineData("[")]
|
||||
[InlineData("[O/")]
|
||||
[InlineData("file_read]")]
|
||||
public void NormalizeThinkingSummary_ShouldDropLowSignalFragments(string fragment)
|
||||
[Fact]
|
||||
public void IsLowSignalStatusSummary_ReturnsTrueForModelWaitMessages()
|
||||
{
|
||||
AgentProgressSummarySanitizer.NormalizeThinkingSummary(fragment).Should().BeEmpty();
|
||||
AgentProgressSummarySanitizer.IsLowSignalStatusSummary(
|
||||
"수정 단계: 모델 첫 응답을 기다리는 중입니다... (8초)",
|
||||
"")
|
||||
.Should()
|
||||
.BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeThinkingSummary_ShouldStripPreviousToolCallTranscriptHints()
|
||||
public void IsLowSignalStatusSummary_ReturnsFalseForConcreteWorkMessages()
|
||||
{
|
||||
var summary = """
|
||||
[이전 도구 호출: file_read]
|
||||
|
||||
1. time-clock.html 파일을 새로 생성했습니다.
|
||||
""";
|
||||
|
||||
var normalized = AgentProgressSummarySanitizer.NormalizeThinkingSummary(summary);
|
||||
|
||||
normalized.Should().Be("time-clock.html 파일을 새로 생성했습니다.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeThinkingSummary_ShouldTrimAndClampMeaningfulText()
|
||||
{
|
||||
var summary = """
|
||||
1. 현재 디렉터리 확인 중
|
||||
2. 필요한 HTML 구조를 정리하고 있습니다.
|
||||
""";
|
||||
|
||||
var normalized = AgentProgressSummarySanitizer.NormalizeThinkingSummary(summary, maxLength: 20);
|
||||
|
||||
normalized.Should().StartWith("현재 디렉터리 확인 중 필요한");
|
||||
normalized.Should().EndWith("…");
|
||||
AgentProgressSummarySanitizer.IsLowSignalStatusSummary(
|
||||
"ChatWindow.xaml.cs와 AgentLoopService.cs를 비교해 수정 범위를 정리하고 있습니다.",
|
||||
"file_read")
|
||||
.Should()
|
||||
.BeFalse();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ public class AgentStatusNarrativeCatalogTests
|
||||
{
|
||||
Type = AgentEventType.PermissionRequest,
|
||||
ToolName = "file_edit",
|
||||
Summary = "권한 확인 필요(Deny) · 대상: src/AxCopilot/Views/ChatWindow.xaml.cs"
|
||||
Summary = "권한 확인 필요: src/AxCopilot/Views/ChatWindow.xaml.cs"
|
||||
};
|
||||
|
||||
var narrative = AgentStatusNarrativeCatalog.BuildFromEvent(evt, "Code");
|
||||
|
||||
@@ -1274,7 +1274,21 @@ public partial class AgentLoopService
|
||||
: 1;
|
||||
|
||||
if (TryHandleNoProgressReadOnlyLoopTransition(
|
||||
call,
|
||||
effectiveCall,
|
||||
toolCallSignature,
|
||||
previewRepeatedToolCount,
|
||||
messages,
|
||||
lastModifiedCodeFilePath,
|
||||
requireHighImpactCodeVerification,
|
||||
taskPolicy))
|
||||
{
|
||||
lastAnyToolSignature = toolCallSignature;
|
||||
repeatedAnyToolSignatureCount = previewRepeatedToolCount;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryHandleRepeatedNoProgressToolLoopTransition(
|
||||
effectiveCall,
|
||||
toolCallSignature,
|
||||
previewRepeatedToolCount,
|
||||
messages,
|
||||
|
||||
@@ -497,6 +497,41 @@ public partial class AgentLoopService
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryHandleRepeatedNoProgressToolLoopTransition(
|
||||
ContentBlock call,
|
||||
string toolCallSignature,
|
||||
int repeatedSameSignatureCount,
|
||||
List<ChatMessage> messages,
|
||||
string? lastModifiedCodeFilePath,
|
||||
bool requireHighImpactCodeVerification,
|
||||
TaskTypePolicy taskPolicy)
|
||||
{
|
||||
if (!ShouldBlockRepeatedNoProgressToolLoop(
|
||||
call.ToolName,
|
||||
repeatedSameSignatureCount))
|
||||
return false;
|
||||
|
||||
messages.Add(LlmService.CreateToolResultMessage(
|
||||
call.ToolId,
|
||||
call.ToolName,
|
||||
$"[NO_PROGRESS_SIGNATURE_GUARD] 동일한 도구 호출이 {repeatedSameSignatureCount}회 반복되었습니다. {toolCallSignature}\n" +
|
||||
"Stop repeating the same tool call and switch to a concrete next action that creates new evidence or state change."));
|
||||
messages.Add(new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = BuildNoProgressLoopRecoveryPrompt(
|
||||
call.ToolName,
|
||||
lastModifiedCodeFilePath,
|
||||
requireHighImpactCodeVerification,
|
||||
taskPolicy)
|
||||
});
|
||||
EmitEvent(
|
||||
AgentEventType.Thinking,
|
||||
call.ToolName,
|
||||
$"동일한 도구 호출이 반복되어 다른 방식으로 전환합니다({repeatedSameSignatureCount}회)");
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryHandleReadOnlyStagnationTransition(
|
||||
int consecutiveReadOnlySuccessTools,
|
||||
List<ChatMessage> messages,
|
||||
@@ -684,6 +719,26 @@ public partial class AgentLoopService
|
||||
return AgentToolCatalog.IsReadOnly(toolName);
|
||||
}
|
||||
|
||||
private static bool ShouldBlockRepeatedNoProgressToolLoop(
|
||||
string toolName,
|
||||
int repeatedSameSignatureCount)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(toolName) || AgentToolCatalog.IsReadOnly(toolName))
|
||||
return false;
|
||||
|
||||
var baseThreshold = Math.Max(GetReadOnlySignatureLoopThreshold() + 1, 5);
|
||||
if (IsPollingOrCoordinationTool(toolName))
|
||||
return repeatedSameSignatureCount >= baseThreshold + 2;
|
||||
|
||||
if (IsMutatingOrExecutionProgressTool(toolName))
|
||||
return repeatedSameSignatureCount >= baseThreshold + 1;
|
||||
|
||||
return repeatedSameSignatureCount >= baseThreshold;
|
||||
}
|
||||
|
||||
private static bool IsPollingOrCoordinationTool(string toolName)
|
||||
=> toolName is "wait_agents" or "task_output" or "task_get" or "task_list";
|
||||
|
||||
private static int GetReadOnlySignatureLoopThreshold()
|
||||
{
|
||||
return ResolveConfiguredOrEnvThresholdValue(
|
||||
|
||||
@@ -57,6 +57,24 @@ internal static class AgentProgressSummarySanitizer
|
||||
return normalized;
|
||||
}
|
||||
|
||||
public static bool IsLowSignalStatusSummary(string? summary, string? toolName = null)
|
||||
{
|
||||
var normalized = NormalizeThinkingSummary(summary, toolName, maxLength: 160);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
return true;
|
||||
|
||||
var lower = normalized.ToLowerInvariant();
|
||||
return lower.Contains("모델에 요청하는 중")
|
||||
|| lower.Contains("모델 첫 응답")
|
||||
|| lower.Contains("계속 기다리는 중")
|
||||
|| lower.Contains("응답을 기다리는 중")
|
||||
|| lower.Contains("스트리밍 중간 응답")
|
||||
|| lower.Contains("일시적 llm 오류")
|
||||
|| lower.Contains("gemini 무료 티어 대기")
|
||||
|| lower.Contains("도구명 정규화 적용")
|
||||
|| lower.Contains("[agentloopwait]");
|
||||
}
|
||||
|
||||
private static string CleanThinkingLine(string line)
|
||||
{
|
||||
var cleaned = s_listPrefixRegex.Replace(line.Trim(), string.Empty).Trim();
|
||||
|
||||
@@ -16,12 +16,12 @@ internal static class AgentStatusNarrativeCatalog
|
||||
public static AgentStatusNarrative BuildInitial(string? runTab)
|
||||
=> IsCodeTab(runTab)
|
||||
? new AgentStatusNarrative(
|
||||
"관련 코드와 수정 범위를 파악하고 있습니다...",
|
||||
"필요한 파일과 테스트 범위를 좁혀서 읽기 시작합니다.",
|
||||
"작업 범위와 관련 파일을 먼저 파악하고 있습니다...",
|
||||
"필요한 코드, 로그, 테스트 범위를 정리한 뒤 바로 수정 단계로 이어갑니다.",
|
||||
"initialize")
|
||||
: new AgentStatusNarrative(
|
||||
"요청 목적과 필요한 자료를 정리하고 있습니다...",
|
||||
"답변 구조와 필요한 문서·근거를 먼저 가볍게 확인합니다.",
|
||||
"관련 문서와 작업 범위를 확인한 뒤 바로 다음 단계로 이어갑니다.",
|
||||
"initialize");
|
||||
|
||||
public static AgentStatusNarrative BuildFromEvent(AgentEvent evt, string? runTab)
|
||||
@@ -53,7 +53,7 @@ internal static class AgentStatusNarrativeCatalog
|
||||
{
|
||||
return new AgentStatusNarrative(
|
||||
"긴 대화를 이어가기 위해 컨텍스트를 정리하고 있습니다...",
|
||||
"최근 대화와 작업 결과를 압축한 뒤 이어서 진행합니다.",
|
||||
"최근 작업 결과를 압축해 다음 단계에 필요한 정보만 남기고 있습니다.",
|
||||
"compact");
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ internal static class AgentStatusNarrativeCatalog
|
||||
if (idle >= TimeSpan.FromSeconds(90))
|
||||
{
|
||||
return new AgentStatusNarrative(
|
||||
"작업 범위가 커서 현재까지 수집한 내용을 종합하고 있습니다...",
|
||||
"현재까지 확인한 내용과 다음 단계를 차분히 정리하고 있습니다...",
|
||||
BuildIdleDetail(category),
|
||||
category);
|
||||
}
|
||||
@@ -120,12 +120,12 @@ internal static class AgentStatusNarrativeCatalog
|
||||
{
|
||||
return category switch
|
||||
{
|
||||
"read" => "관련 범위를 읽고 확인하는 중",
|
||||
"edit" => "변경 내용을 적용하는 중",
|
||||
"read" => "관련 코드와 파일을 확인하는 중",
|
||||
"edit" => "변경을 적용하는 중",
|
||||
"execute" => "실행 결과를 확인하는 중",
|
||||
"document" => "문서 산출물을 구성하는 중",
|
||||
"document" => "문서 내용을 구성하는 중",
|
||||
"git" => "변경 범위를 확인하는 중",
|
||||
"web" => "외부 정보를 정리하는 중",
|
||||
"web" => "필요한 정보를 정리하는 중",
|
||||
_ => row.Title,
|
||||
};
|
||||
}
|
||||
@@ -134,7 +134,7 @@ internal static class AgentStatusNarrativeCatalog
|
||||
{
|
||||
return category switch
|
||||
{
|
||||
"read" => "읽은 내용을 정리하는 중",
|
||||
"read" => "확인한 내용을 정리하는 중",
|
||||
"edit" => "적용한 변경을 정리하는 중",
|
||||
"execute" => "실행 결과를 분석하는 중",
|
||||
"document" => "생성 결과를 검토하는 중",
|
||||
@@ -157,24 +157,24 @@ internal static class AgentStatusNarrativeCatalog
|
||||
AgentTranscriptDisplayCatalog.GetEventBadgeLabel(evt)));
|
||||
|
||||
if (category == "compact")
|
||||
return "컨텍스트 압축 중...";
|
||||
return "컨텍스트를 정리하는 중...";
|
||||
|
||||
if (evt.Type == AgentEventType.Thinking && string.Equals(evt.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase))
|
||||
return "응답을 정리하는 중...";
|
||||
return "다음 응답을 준비하는 중...";
|
||||
|
||||
return evt.Type switch
|
||||
{
|
||||
AgentEventType.Planning => "작업 순서를 정리하는 중...",
|
||||
AgentEventType.StepStart when evt.StepTotal > 0 => $"{evt.StepCurrent}/{evt.StepTotal} 단계 진행 중...",
|
||||
AgentEventType.StepDone when evt.StepTotal > 0 => $"{evt.StepCurrent}/{evt.StepTotal} 단계 정리 중...",
|
||||
AgentEventType.PermissionRequest => "권한 확인 대기 중...",
|
||||
AgentEventType.PermissionRequest => "권한 확인을 기다리는 중...",
|
||||
AgentEventType.PermissionDenied => "대체 경로를 검토하는 중...",
|
||||
AgentEventType.ToolCall => category switch
|
||||
{
|
||||
"read" => "관련 파일을 확인하는 중...",
|
||||
"edit" => "변경 내용을 적용하는 중...",
|
||||
"edit" => "변경을 적용하는 중...",
|
||||
"execute" => "실행 결과를 확인하는 중...",
|
||||
"document" => "산출물을 구성하는 중...",
|
||||
"document" => "문서를 구성하는 중...",
|
||||
"git" => "변경 범위를 정리하는 중...",
|
||||
"web" => "필요한 정보를 찾는 중...",
|
||||
_ => null,
|
||||
@@ -182,7 +182,7 @@ internal static class AgentStatusNarrativeCatalog
|
||||
AgentEventType.ToolResult => category switch
|
||||
{
|
||||
"read" => "읽은 내용을 정리하는 중...",
|
||||
"edit" => "적용한 변경을 점검하는 중...",
|
||||
"edit" => "수정 내용을 정리하는 중...",
|
||||
"execute" => "실행 결과를 분석하는 중...",
|
||||
"document" => "생성 결과를 검토하는 중...",
|
||||
_ => null,
|
||||
@@ -207,16 +207,16 @@ internal static class AgentStatusNarrativeCatalog
|
||||
AgentEventType.Planning => "계획",
|
||||
AgentEventType.StepStart or AgentEventType.StepDone => "단계",
|
||||
AgentEventType.PermissionRequest => "권한",
|
||||
AgentEventType.PermissionGranted => "권한 승인",
|
||||
AgentEventType.PermissionGranted => "권한 확인",
|
||||
AgentEventType.PermissionDenied => "권한 거부",
|
||||
AgentEventType.ToolCall => category switch
|
||||
{
|
||||
"read" => "탐색",
|
||||
"edit" => "수정",
|
||||
"execute" => "실행",
|
||||
"document" => "산출물",
|
||||
"document" => "문서",
|
||||
"git" => "Git",
|
||||
"web" => "웹",
|
||||
"web" => "자료",
|
||||
_ => "도구",
|
||||
},
|
||||
AgentEventType.ToolResult => category switch
|
||||
@@ -242,15 +242,15 @@ internal static class AgentStatusNarrativeCatalog
|
||||
{
|
||||
AgentEventType.Planning => IsCodeTab(runTab)
|
||||
? "수정 순서와 검증 단계를 정리하고 있습니다..."
|
||||
: "작업 순서와 산출물 구조를 정리하고 있습니다...",
|
||||
: "작업 순서와 결과물 구성을 정리하고 있습니다...",
|
||||
AgentEventType.StepStart when evt.StepTotal > 0
|
||||
=> $"{evt.StepCurrent}/{evt.StepTotal} 단계 작업을 진행하고 있습니다...",
|
||||
AgentEventType.StepDone when evt.StepTotal > 0
|
||||
=> $"{evt.StepCurrent}/{evt.StepTotal} 단계를 마무리하고 다음 단계로 넘어가고 있습니다...",
|
||||
=> $"{evt.StepCurrent}/{evt.StepTotal} 단계를 마무리하고 다음 단계로 이어가고 있습니다...",
|
||||
AgentEventType.PermissionRequest => "실행 전에 필요한 권한 확인을 기다리고 있습니다...",
|
||||
AgentEventType.PermissionGranted => "권한이 확인되어 작업을 이어가고 있습니다...",
|
||||
AgentEventType.PermissionDenied => "권한이 거부되어 다른 진행 경로를 검토하고 있습니다...",
|
||||
AgentEventType.SkillCall => "전용 스킬 절차를 적용하고 있습니다...",
|
||||
AgentEventType.SkillCall => "적절한 스킬이나 작업 흐름을 적용하고 있습니다...",
|
||||
AgentEventType.ToolCall => category switch
|
||||
{
|
||||
"read" => IsCodeTab(runTab)
|
||||
@@ -260,21 +260,21 @@ internal static class AgentStatusNarrativeCatalog
|
||||
? "코드 변경을 적용하고 있습니다..."
|
||||
: "초안 내용을 다듬고 있습니다...",
|
||||
"execute" => "실행 결과와 로그를 확인하고 있습니다...",
|
||||
"document" => "문서 산출물을 구성하고 있습니다...",
|
||||
"document" => "문서 결과물을 구성하고 있습니다...",
|
||||
"git" => "변경 범위와 저장소 상태를 확인하고 있습니다...",
|
||||
"web" => "필요한 외부 정보를 찾아 정리하고 있습니다...",
|
||||
"web" => "필요한 외부 정보를 정리하고 있습니다...",
|
||||
_ => string.IsNullOrWhiteSpace(itemDisplayName)
|
||||
? "필요한 작업을 실행하고 있습니다..."
|
||||
? "필요한 작업을 진행하고 있습니다..."
|
||||
: $"{itemDisplayName} 작업을 진행하고 있습니다...",
|
||||
},
|
||||
AgentEventType.ToolResult => category switch
|
||||
{
|
||||
"read" => "확인한 내용을 정리하고 다음 단계를 준비하고 있습니다...",
|
||||
"edit" => "적용한 변경을 정리하고 다음 검증으로 넘어가고 있습니다...",
|
||||
"read" => "확인한 내용을 정리하고 다음 단계로 이어가고 있습니다...",
|
||||
"edit" => "적용한 변경을 정리하고 다음 검증으로 이어가고 있습니다...",
|
||||
"execute" => "실행 결과를 분석하고 후속 조치를 판단하고 있습니다...",
|
||||
"document" => "생성한 결과를 검토하고 다듬고 있습니다...",
|
||||
"git" => "변경 내용을 정리하고 다음 작업을 준비하고 있습니다...",
|
||||
"web" => "수집한 정보를 정리하고 답변에 연결하고 있습니다...",
|
||||
"web" => "수집한 정보를 정리하고 답변에 반영하고 있습니다...",
|
||||
_ => string.IsNullOrWhiteSpace(row.Title)
|
||||
? "결과를 정리하고 있습니다..."
|
||||
: EnsureSentence(row.Title),
|
||||
@@ -284,13 +284,13 @@ internal static class AgentStatusNarrativeCatalog
|
||||
AgentEventType.Thinking when string.Equals(evt.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase)
|
||||
=> "현재까지 진행한 내용을 정리하고 있습니다...",
|
||||
AgentEventType.Thinking when category == "document"
|
||||
=> "문서 흐름과 누락된 내용을 정리하고 있습니다...",
|
||||
=> "문서 흐름과 필요한 내용을 정리하고 있습니다...",
|
||||
AgentEventType.Thinking when (evt.Summary ?? string.Empty).Contains("검증", StringComparison.OrdinalIgnoreCase)
|
||||
=> "결과를 검토하고 누락된 부분이 없는지 확인하고 있습니다...",
|
||||
=> "결과를 검토하고 빠진 부분이 없는지 확인하고 있습니다...",
|
||||
AgentEventType.Thinking when (evt.Summary ?? string.Empty).Contains("재시도", StringComparison.OrdinalIgnoreCase)
|
||||
=> "다시 시도할 경로를 정리하고 있습니다...",
|
||||
AgentEventType.Complete => "작업이 완료되었습니다.",
|
||||
AgentEventType.Error => "오류를 정리하고 복구 경로를 판단하고 있습니다...",
|
||||
AgentEventType.Error => "오류를 정리하고 복구 경로를 검토하고 있습니다...",
|
||||
AgentEventType.Decision => "다음 진행을 위해 사용자 확인을 기다리고 있습니다...",
|
||||
_ => string.IsNullOrWhiteSpace(row.Title)
|
||||
? "작업을 진행하고 있습니다..."
|
||||
@@ -313,20 +313,14 @@ internal static class AgentStatusNarrativeCatalog
|
||||
}
|
||||
|
||||
if (evt.Type == AgentEventType.Planning && evt.Steps is { Count: > 0 })
|
||||
{
|
||||
parts.Add($"총 {evt.Steps.Count}단계 계획을 기준으로 진행합니다.");
|
||||
}
|
||||
|
||||
var targetHint = ExtractTargetHint(evt);
|
||||
if (!string.IsNullOrWhiteSpace(targetHint))
|
||||
{
|
||||
parts.Add(evt.Type == AgentEventType.ToolResult
|
||||
? $"결과 대상: {targetHint}"
|
||||
: $"대상: {targetHint}");
|
||||
}
|
||||
parts.Add($"대상: {targetHint}");
|
||||
|
||||
if (category == "compact" && evt.ElapsedMs > 0)
|
||||
parts.Add("이전 대화와 최근 작업 결과를 함께 압축하고 있습니다.");
|
||||
parts.Add("이전 대화와 최근 작업 결과를 이어서 사용할 수 있게 정리하고 있습니다.");
|
||||
|
||||
return parts.Count == 0 ? null : string.Join(" · ", parts);
|
||||
}
|
||||
@@ -336,7 +330,7 @@ internal static class AgentStatusNarrativeCatalog
|
||||
{
|
||||
"read" => IsCodeTab(runTab)
|
||||
? longWait
|
||||
? "읽은 코드 맥락을 종합하고 다음 수정 지점을 정리하고 있습니다..."
|
||||
? "읽은 코드와 검색 결과를 종합해 다음 수정 지점을 정리하고 있습니다..."
|
||||
: "읽은 코드와 검색 결과를 정리하고 있습니다..."
|
||||
: longWait
|
||||
? "확인한 자료를 종합해 답변 구조를 정리하고 있습니다..."
|
||||
@@ -348,8 +342,8 @@ internal static class AgentStatusNarrativeCatalog
|
||||
? "실행 로그를 종합해 원인과 다음 조치를 정리하고 있습니다..."
|
||||
: "실행 결과와 로그를 정리하고 있습니다...",
|
||||
"document" => longWait
|
||||
? "초안 구조와 누락된 근거를 다시 맞추고 있습니다..."
|
||||
: "초안과 산출물 구성을 다듬고 있습니다...",
|
||||
? "초안 구조와 빠진 근거를 다시 맞추고 있습니다..."
|
||||
: "초안과 결과물 구성을 다듬고 있습니다...",
|
||||
"git" => "변경 범위와 저장소 상태를 다시 정리하고 있습니다...",
|
||||
"web" => "수집한 정보를 정리해 답변에 연결하고 있습니다...",
|
||||
"permission" => "권한 확인 결과를 기다리며 다음 단계를 준비하고 있습니다...",
|
||||
@@ -361,14 +355,14 @@ internal static class AgentStatusNarrativeCatalog
|
||||
private static string BuildIdleDetail(string category)
|
||||
=> category switch
|
||||
{
|
||||
"read" => "읽은 파일과 검색 결과를 묶어 다음 단계로 연결합니다.",
|
||||
"edit" => "수정 범위와 영향 파일을 다시 확인합니다.",
|
||||
"execute" => "빌드·테스트 로그에서 실패 원인과 후속 조치를 추립니다.",
|
||||
"document" => "초안 구조와 빠진 근거, 결론 흐름을 다시 맞춥니다.",
|
||||
"git" => "변경 범위와 저장소 상태를 함께 확인합니다.",
|
||||
"web" => "수집한 정보를 질문 흐름에 맞게 추려냅니다.",
|
||||
"permission" => "권한 결과가 정리되면 같은 작업 흐름으로 이어집니다.",
|
||||
_ => "현재까지의 진행 내용을 정리해 다음 작업으로 연결합니다.",
|
||||
"read" => "읽은 파일과 검색 결과를 묶어 다음 단계로 연결하고 있습니다.",
|
||||
"edit" => "수정 범위와 영향 파일을 다시 확인하고 있습니다.",
|
||||
"execute" => "빌드와 테스트 로그에서 원인과 후속 조치를 추리고 있습니다.",
|
||||
"document" => "초안 구조, 빠진 근거, 연결 흐름을 다시 맞추고 있습니다.",
|
||||
"git" => "변경 범위와 저장소 상태를 한 번 더 확인하고 있습니다.",
|
||||
"web" => "수집한 정보를 질문 흐름에 맞게 추리고 있습니다.",
|
||||
"permission" => "권한 결과가 정리되면 같은 작업 흐름으로 바로 이어집니다.",
|
||||
_ => "현재까지의 진행 내용을 정리해 다음 작업으로 연결하고 있습니다.",
|
||||
};
|
||||
|
||||
private static string ResolveCategory(AgentEvent evt, AgentTranscriptRowPresentation row)
|
||||
@@ -448,9 +442,9 @@ internal static class AgentStatusNarrativeCatalog
|
||||
return Path.GetFileName(text);
|
||||
|
||||
if (key == "command")
|
||||
return text.Length > 60 ? text[..60] + "…" : text;
|
||||
return text.Length > 60 ? text[..60] + "..." : text;
|
||||
|
||||
return text.Length > 70 ? text[..70] + "…" : text;
|
||||
return text.Length > 70 ? text[..70] + "..." : text;
|
||||
}
|
||||
|
||||
if (value.ValueKind == JsonValueKind.Array)
|
||||
@@ -506,7 +500,7 @@ internal static class AgentStatusNarrativeCatalog
|
||||
normalized = normalized.Replace(" ", " ", StringComparison.Ordinal);
|
||||
|
||||
if (normalized.Length > 160)
|
||||
normalized = normalized[..160].TrimEnd() + "…";
|
||||
normalized = normalized[..160].TrimEnd() + "...";
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
|
||||
if (!firstEventReceived)
|
||||
{
|
||||
firstEventReceived = true;
|
||||
LogService.Info(
|
||||
LogService.Debug(
|
||||
$"[AgentLoopWait] {phaseLabel}: 첫 응답 수신 ({waitStopwatch.ElapsedMilliseconds}ms, kind={evt.Kind})");
|
||||
if (waitStopwatch.Elapsed >= _firstResponseHeartbeatDelay)
|
||||
_emitEvent(AgentEventType.Thinking, "", $"{phaseLabel}: 모델 첫 응답을 받아 계속 진행합니다.");
|
||||
@@ -226,7 +226,7 @@ internal sealed class StreamingToolExecutionCoordinator : IToolExecutionCoordina
|
||||
var summary = firstEventReceived
|
||||
? $"{phaseLabel}: 모델 응답이 길어져 계속 기다리는 중입니다... ({seconds}초)"
|
||||
: $"{phaseLabel}: 모델 첫 응답을 기다리는 중입니다... ({seconds}초)";
|
||||
LogService.Info($"[AgentLoopWait] {summary}");
|
||||
LogService.Debug($"[AgentLoopWait] {summary}");
|
||||
_emitEvent(AgentEventType.Thinking, "", summary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,8 +200,220 @@ public static class MarkdownRenderer
|
||||
return panel;
|
||||
}
|
||||
|
||||
public static FrameworkElement RenderSelectable(string markdown, Brush textColor, Brush secondaryColor, Brush accentColor, Brush codeBg)
|
||||
{
|
||||
var document = BuildSelectableDocument(markdown, textColor, secondaryColor, accentColor, codeBg);
|
||||
var viewer = new RichTextBox
|
||||
{
|
||||
Document = document,
|
||||
IsReadOnly = true,
|
||||
IsDocumentEnabled = true,
|
||||
Background = Brushes.Transparent,
|
||||
BorderThickness = new Thickness(0),
|
||||
Padding = new Thickness(0),
|
||||
Margin = new Thickness(0),
|
||||
VerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
|
||||
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
|
||||
AcceptsReturn = true,
|
||||
};
|
||||
|
||||
void UpdatePageWidth()
|
||||
{
|
||||
var width = System.Math.Max(0, viewer.ActualWidth - 8);
|
||||
viewer.Document.PageWidth = width <= 0 ? 640 : width;
|
||||
}
|
||||
|
||||
viewer.Loaded += (_, _) => UpdatePageWidth();
|
||||
viewer.SizeChanged += (_, _) => UpdatePageWidth();
|
||||
return viewer;
|
||||
}
|
||||
|
||||
private static FlowDocument BuildSelectableDocument(string markdown, Brush textColor, Brush secondaryColor, Brush accentColor, Brush codeBg)
|
||||
{
|
||||
var document = new FlowDocument
|
||||
{
|
||||
PagePadding = new Thickness(0),
|
||||
Background = Brushes.Transparent,
|
||||
};
|
||||
|
||||
if (string.IsNullOrEmpty(markdown))
|
||||
return document;
|
||||
|
||||
var lines = markdown.Replace("\r\n", "\n").Split('\n');
|
||||
var i = 0;
|
||||
|
||||
while (i < lines.Length)
|
||||
{
|
||||
var line = lines[i];
|
||||
|
||||
if (line.TrimStart().StartsWith("```"))
|
||||
{
|
||||
var lang = line.TrimStart().Length > 3 ? line.TrimStart()[3..].Trim() : "";
|
||||
var codeLines = new System.Text.StringBuilder();
|
||||
i++;
|
||||
while (i < lines.Length && !lines[i].TrimStart().StartsWith("```"))
|
||||
{
|
||||
codeLines.AppendLine(lines[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
if (i < lines.Length)
|
||||
i++;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(lang))
|
||||
{
|
||||
document.Blocks.Add(new Paragraph(new Run(lang))
|
||||
{
|
||||
FontSize = 10.5,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = accentColor,
|
||||
Margin = new Thickness(0, 6, 0, 2),
|
||||
});
|
||||
}
|
||||
|
||||
var codeParagraph = new Paragraph
|
||||
{
|
||||
Margin = new Thickness(0, 0, 0, 8),
|
||||
Padding = new Thickness(12, 10, 12, 10),
|
||||
Background = codeBg,
|
||||
BorderBrush = new SolidColorBrush(Color.FromArgb(0x30, 0xFF, 0xFF, 0xFF)),
|
||||
BorderThickness = new Thickness(1),
|
||||
FontFamily = new FontFamily("Cascadia Code, Consolas, monospace"),
|
||||
FontSize = 12.5,
|
||||
};
|
||||
AppendSyntaxHighlightedInlines(codeParagraph.Inlines, codeLines.ToString().TrimEnd(), lang, textColor);
|
||||
document.Blocks.Add(codeParagraph);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
{
|
||||
document.Blocks.Add(new Paragraph(new Run(" "))
|
||||
{
|
||||
Margin = new Thickness(0, 2, 0, 2),
|
||||
FontSize = 4,
|
||||
});
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Regex.IsMatch(line.Trim(), @"^-{3,}$|^\*{3,}$"))
|
||||
{
|
||||
document.Blocks.Add(new BlockUIContainer(new Border
|
||||
{
|
||||
Height = 1,
|
||||
Background = secondaryColor,
|
||||
Opacity = 0.3,
|
||||
Margin = new Thickness(0, 8, 0, 8),
|
||||
}));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith('#'))
|
||||
{
|
||||
var level = 0;
|
||||
while (level < line.Length && line[level] == '#') level++;
|
||||
var headerText = line[level..].Trim();
|
||||
var fontSize = level switch { 1 => 20.0, 2 => 17.0, 3 => 15.0, _ => 14.0 };
|
||||
var paragraph = new Paragraph
|
||||
{
|
||||
FontSize = fontSize,
|
||||
FontWeight = FontWeights.Bold,
|
||||
Foreground = textColor,
|
||||
Margin = new Thickness(0, level == 1 ? 12 : 8, 0, 4),
|
||||
};
|
||||
AddInlines(paragraph.Inlines, headerText, textColor, accentColor, codeBg, selectable: true);
|
||||
document.Blocks.Add(paragraph);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Regex.IsMatch(line, @"^\s*[-*]\s") || Regex.IsMatch(line, @"^\s*\d+\.\s"))
|
||||
{
|
||||
var match = Regex.Match(line, @"^(\s*)([-*]|\d+\.)\s(.*)");
|
||||
if (match.Success)
|
||||
{
|
||||
var indent = match.Groups[1].Value.Length / 2;
|
||||
var bullet = match.Groups[2].Value;
|
||||
var content = match.Groups[3].Value;
|
||||
var paragraph = new Paragraph
|
||||
{
|
||||
Margin = new Thickness(12 + indent * 16, 2, 0, 2),
|
||||
Foreground = textColor,
|
||||
FontSize = 13.5,
|
||||
};
|
||||
paragraph.Inlines.Add(new Run(bullet is "-" or "*" ? "• " : $"{bullet} ")
|
||||
{
|
||||
Foreground = accentColor,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
});
|
||||
AddInlines(paragraph.Inlines, content, textColor, accentColor, codeBg, selectable: true);
|
||||
document.Blocks.Add(paragraph);
|
||||
}
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.TrimStart().StartsWith('>'))
|
||||
{
|
||||
var quoteLines = new List<string>();
|
||||
while (i < lines.Length && lines[i].TrimStart().StartsWith('>'))
|
||||
{
|
||||
var ql = lines[i].TrimStart();
|
||||
quoteLines.Add(ql.Length > 1 ? ql[1..].TrimStart() : "");
|
||||
i++;
|
||||
}
|
||||
|
||||
var quoteParagraph = new Paragraph
|
||||
{
|
||||
Margin = new Thickness(4, 4, 0, 4),
|
||||
Padding = new Thickness(12, 6, 8, 6),
|
||||
BorderBrush = accentColor,
|
||||
BorderThickness = new Thickness(3, 0, 0, 0),
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x10, 0xFF, 0xFF, 0xFF)),
|
||||
FontStyle = FontStyles.Italic,
|
||||
FontSize = 13,
|
||||
Foreground = secondaryColor,
|
||||
LineHeight = 20,
|
||||
};
|
||||
AddInlines(quoteParagraph.Inlines, string.Join("\n", quoteLines), textColor, accentColor, codeBg, selectable: true);
|
||||
document.Blocks.Add(quoteParagraph);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.Contains('|') && line.Trim().StartsWith('|'))
|
||||
{
|
||||
var tableRows = new List<string>();
|
||||
while (i < lines.Length && lines[i].Contains('|'))
|
||||
{
|
||||
tableRows.Add(lines[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
var table = CreateMarkdownTable(tableRows, textColor, accentColor, codeBg);
|
||||
if (table != null)
|
||||
document.Blocks.Add(new BlockUIContainer(table));
|
||||
continue;
|
||||
}
|
||||
|
||||
var paragraphBlock = new Paragraph
|
||||
{
|
||||
FontSize = 13.5,
|
||||
Foreground = textColor,
|
||||
LineHeight = 22,
|
||||
Margin = new Thickness(0, 2, 0, 2),
|
||||
};
|
||||
AddInlines(paragraphBlock.Inlines, line, textColor, accentColor, codeBg, selectable: true);
|
||||
document.Blocks.Add(paragraphBlock);
|
||||
i++;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
/// <summary>인라인 마크다운 처리: **볼드**, *이탤릭*, `코드`, ~~취소선~~, [링크](url)</summary>
|
||||
private static void AddInlines(InlineCollection inlines, string text, Brush textColor, Brush accentColor, Brush codeBg)
|
||||
private static void AddInlines(InlineCollection inlines, string text, Brush textColor, Brush accentColor, Brush codeBg, bool selectable = false)
|
||||
{
|
||||
// 패턴: [link](url) | ~~strikethrough~~ | **bold** | *italic* | `code` | 일반텍스트
|
||||
var pattern = @"(\[([^\]]+)\]\(([^)]+)\)|~~(.+?)~~|\*\*(.+?)\*\*|\*(.+?)\*|`(.+?)`|([^*`~\[]+))";
|
||||
@@ -242,21 +454,34 @@ public static class MarkdownRenderer
|
||||
}
|
||||
else if (m.Groups[7].Success) // `code`
|
||||
{
|
||||
var codeBorder = new Border
|
||||
if (selectable)
|
||||
{
|
||||
Background = codeBg,
|
||||
CornerRadius = new CornerRadius(4),
|
||||
Padding = new Thickness(5, 1, 5, 1),
|
||||
Margin = new Thickness(1, 0, 1, 0),
|
||||
Child = new TextBlock
|
||||
inlines.Add(new Run(m.Groups[7].Value)
|
||||
{
|
||||
Text = m.Groups[7].Value,
|
||||
FontFamily = new FontFamily("Cascadia Code, Consolas, monospace"),
|
||||
FontSize = 12.5,
|
||||
Foreground = accentColor
|
||||
}
|
||||
};
|
||||
inlines.Add(new InlineUIContainer(codeBorder) { BaselineAlignment = BaselineAlignment.Center });
|
||||
Foreground = accentColor,
|
||||
Background = codeBg,
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
var codeBorder = new Border
|
||||
{
|
||||
Background = codeBg,
|
||||
CornerRadius = new CornerRadius(4),
|
||||
Padding = new Thickness(5, 1, 5, 1),
|
||||
Margin = new Thickness(1, 0, 1, 0),
|
||||
Child = new TextBlock
|
||||
{
|
||||
Text = m.Groups[7].Value,
|
||||
FontFamily = new FontFamily("Cascadia Code, Consolas, monospace"),
|
||||
FontSize = 12.5,
|
||||
Foreground = accentColor
|
||||
}
|
||||
};
|
||||
inlines.Add(new InlineUIContainer(codeBorder) { BaselineAlignment = BaselineAlignment.Center });
|
||||
}
|
||||
}
|
||||
else if (m.Groups[8].Success) // 일반 텍스트
|
||||
{
|
||||
@@ -797,105 +1022,59 @@ public static class MarkdownRenderer
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static void ApplySyntaxHighlighting(TextBlock tb, string code, string lang, Brush defaultColor)
|
||||
{
|
||||
tb.Inlines.Clear();
|
||||
AppendSyntaxHighlightedInlines(tb.Inlines, code, lang, defaultColor);
|
||||
}
|
||||
|
||||
private static void AppendSyntaxHighlightedInlines(InlineCollection inlines, string code, string lang, Brush defaultColor)
|
||||
{
|
||||
if (string.IsNullOrEmpty(lang) || string.IsNullOrEmpty(code))
|
||||
{
|
||||
tb.Text = code;
|
||||
inlines.Add(new Run(code) { Foreground = defaultColor });
|
||||
return;
|
||||
}
|
||||
|
||||
var keywords = GetKeywordsForLang(lang);
|
||||
var isCommentHash = lang.ToLowerInvariant() is "python" or "py" or "bash" or "sh" or "shell"
|
||||
or "ruby" or "rb" or "yaml" or "yml" or "toml" or "powershell" or "ps1";
|
||||
|
||||
foreach (Match m in SyntaxPattern.Matches(code))
|
||||
{
|
||||
if (m.Groups[1].Success) // comment
|
||||
{
|
||||
var commentText = m.Groups[1].Value;
|
||||
// # 주석은 해당 언어에서만 적용
|
||||
if (commentText.StartsWith('#') && !isCommentHash)
|
||||
{
|
||||
// # 이 주석이 아닌 언어: 일반 텍스트로 처리
|
||||
tb.Inlines.Add(new Run(commentText) { Foreground = defaultColor });
|
||||
}
|
||||
else
|
||||
{
|
||||
tb.Inlines.Add(new Run(commentText) { Foreground = CommentBrush, FontStyle = FontStyles.Italic });
|
||||
}
|
||||
}
|
||||
else if (m.Groups[2].Success) // string
|
||||
{
|
||||
tb.Inlines.Add(new Run(m.Groups[2].Value) { Foreground = StringBrush });
|
||||
}
|
||||
else if (m.Groups[3].Success) // number
|
||||
{
|
||||
tb.Inlines.Add(new Run(m.Groups[3].Value) { Foreground = NumberBrush });
|
||||
}
|
||||
else if (m.Groups[4].Success) // type
|
||||
{
|
||||
var word = m.Groups[4].Value;
|
||||
tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : TypeBrush });
|
||||
}
|
||||
else if (m.Groups[5].Success) // method
|
||||
{
|
||||
var word = m.Groups[5].Value;
|
||||
if (keywords.Contains(word))
|
||||
tb.Inlines.Add(new Run(word) { Foreground = KeywordBrush });
|
||||
else
|
||||
tb.Inlines.Add(new Run(word) { Foreground = MethodBrush });
|
||||
}
|
||||
else if (m.Groups[6].Success) // identifier
|
||||
{
|
||||
var word = m.Groups[6].Value;
|
||||
if (keywords.Contains(word))
|
||||
tb.Inlines.Add(new Run(word) { Foreground = KeywordBrush });
|
||||
else
|
||||
tb.Inlines.Add(new Run(word) { Foreground = defaultColor });
|
||||
}
|
||||
}
|
||||
|
||||
// Regex가 매치하지 못한 나머지 문자(공백, 기호 등)를 채움
|
||||
// Inlines로 빌드했으므로 원본과 비교하여 누락된 부분 보완
|
||||
// 대신 각 매치 사이의 간격을 처리하기 위해 인덱스 기반 접근
|
||||
tb.Inlines.Clear();
|
||||
int lastEnd = 0;
|
||||
foreach (Match m in SyntaxPattern.Matches(code))
|
||||
{
|
||||
if (m.Index > lastEnd)
|
||||
tb.Inlines.Add(new Run(code[lastEnd..m.Index]) { Foreground = defaultColor });
|
||||
inlines.Add(new Run(code[lastEnd..m.Index]) { Foreground = defaultColor });
|
||||
|
||||
if (m.Groups[1].Success)
|
||||
{
|
||||
var commentText = m.Groups[1].Value;
|
||||
if (commentText.StartsWith('#') && !isCommentHash)
|
||||
tb.Inlines.Add(new Run(commentText) { Foreground = defaultColor });
|
||||
inlines.Add(new Run(commentText) { Foreground = defaultColor });
|
||||
else
|
||||
tb.Inlines.Add(new Run(commentText) { Foreground = CommentBrush, FontStyle = FontStyles.Italic });
|
||||
inlines.Add(new Run(commentText) { Foreground = CommentBrush, FontStyle = FontStyles.Italic });
|
||||
}
|
||||
else if (m.Groups[2].Success)
|
||||
tb.Inlines.Add(new Run(m.Groups[2].Value) { Foreground = StringBrush });
|
||||
inlines.Add(new Run(m.Groups[2].Value) { Foreground = StringBrush });
|
||||
else if (m.Groups[3].Success)
|
||||
tb.Inlines.Add(new Run(m.Groups[3].Value) { Foreground = NumberBrush });
|
||||
inlines.Add(new Run(m.Groups[3].Value) { Foreground = NumberBrush });
|
||||
else if (m.Groups[4].Success)
|
||||
{
|
||||
var word = m.Groups[4].Value;
|
||||
tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : TypeBrush });
|
||||
inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : TypeBrush });
|
||||
}
|
||||
else if (m.Groups[5].Success)
|
||||
{
|
||||
var word = m.Groups[5].Value;
|
||||
tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : MethodBrush });
|
||||
inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : MethodBrush });
|
||||
}
|
||||
else if (m.Groups[6].Success)
|
||||
{
|
||||
var word = m.Groups[6].Value;
|
||||
tb.Inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : defaultColor });
|
||||
inlines.Add(new Run(word) { Foreground = keywords.Contains(word) ? KeywordBrush : defaultColor });
|
||||
}
|
||||
|
||||
lastEnd = m.Index + m.Length;
|
||||
}
|
||||
if (lastEnd < code.Length)
|
||||
tb.Inlines.Add(new Run(code[lastEnd..]) { Foreground = defaultColor });
|
||||
inlines.Add(new Run(code[lastEnd..]) { Foreground = defaultColor });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1540,6 +1540,13 @@ public partial class ChatWindow
|
||||
&& evt.Type is AgentEventType.Paused or AgentEventType.Resumed)
|
||||
return;
|
||||
|
||||
if (!string.Equals(logLevel, "debug", StringComparison.OrdinalIgnoreCase)
|
||||
&& evt.Type == AgentEventType.Thinking
|
||||
&& !string.Equals(evt.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(evt.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase)
|
||||
&& AgentProgressSummarySanitizer.IsLowSignalStatusSummary(evt.Summary, evt.ToolName))
|
||||
return;
|
||||
|
||||
var isTotalStats = evt.Type == AgentEventType.StepDone && evt.ToolName == "total_stats";
|
||||
var transcriptBadgeLabel = GetTranscriptBadgeLabel(evt);
|
||||
var permissionPresentation = evt.Type switch
|
||||
|
||||
@@ -64,7 +64,7 @@ public partial class ChatWindow
|
||||
MarkdownRenderer.EnableFilePathHighlight =
|
||||
(System.Windows.Application.Current as App)?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true;
|
||||
MarkdownRenderer.EnableCodeSymbolHighlight = true;
|
||||
bubble.Child = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, userCodeBgBrush);
|
||||
bubble.Child = MarkdownRenderer.RenderSelectable(content, primaryText, secondaryText, accentBrush, userCodeBgBrush);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -234,7 +234,7 @@ public partial class ChatWindow
|
||||
Foreground = primaryText,
|
||||
Margin = new Thickness(0, 0, 0, 6),
|
||||
});
|
||||
branchStack.Children.Add(MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush));
|
||||
branchStack.Children.Add(MarkdownRenderer.RenderSelectable(content, primaryText, secondaryText, accentBrush, codeBgBrush));
|
||||
|
||||
if (branchFiles.Count > 0)
|
||||
{
|
||||
@@ -316,7 +316,7 @@ public partial class ChatWindow
|
||||
}
|
||||
else
|
||||
{
|
||||
contentStack.Children.Add(MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush));
|
||||
contentStack.Children.Add(MarkdownRenderer.RenderSelectable(content, primaryText, secondaryText, accentBrush, codeBgBrush));
|
||||
}
|
||||
|
||||
contentCard.Child = contentStack;
|
||||
|
||||
@@ -393,7 +393,7 @@ public partial class ChatWindow
|
||||
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);
|
||||
var mdPanel = MarkdownRenderer.RenderSelectable(finalContent, primaryText, secondaryText, accentBrush, codeBgBrush);
|
||||
mdPanel.Margin = new Thickness(0, 0, 0, 4);
|
||||
mdPanel.Opacity = 0;
|
||||
var mdCard = new Border
|
||||
|
||||
@@ -102,7 +102,10 @@ public partial class ChatWindow
|
||||
|| string.Equals(restoredEvent.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(restoredEvent.Summary))
|
||||
if (!string.IsNullOrWhiteSpace(restoredEvent.Summary)
|
||||
&& !AgentProgressSummarySanitizer.IsLowSignalStatusSummary(
|
||||
restoredEvent.Summary,
|
||||
restoredEvent.ToolName))
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
@@ -16,7 +15,7 @@ public partial class ChatWindow
|
||||
private DateTime _v2LiveStartTime;
|
||||
private TextBlock? _v2LiveElapsedText;
|
||||
|
||||
/// <summary>V2: 스트리밍 시작 시 라이브 진행 컨테이너 생성</summary>
|
||||
/// <summary>V2: 스트리밍 시작 시 상단 라이브 진행 요약 카드를 생성합니다.</summary>
|
||||
private void ShowAgentLiveCardV2(string runTab)
|
||||
{
|
||||
if (MessageList == null) return;
|
||||
@@ -25,11 +24,13 @@ public partial class ChatWindow
|
||||
RemoveAgentLiveCardV2(animated: false);
|
||||
|
||||
_v2LiveStartTime = DateTime.UtcNow;
|
||||
_v2LiveToolCards.Clear();
|
||||
_v2LastLiveToolCallId = null;
|
||||
|
||||
var msgMaxWidth = GetMessageMaxWidth();
|
||||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||
var hintBg = TryFindResource("HintBackground") as Brush
|
||||
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
|
||||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||||
|
||||
_v2LiveContainer = new StackPanel
|
||||
{
|
||||
@@ -39,7 +40,6 @@ public partial class ChatWindow
|
||||
Margin = new Thickness(0, 4, 0, 6),
|
||||
};
|
||||
|
||||
// 에이전트 헤더 (아이콘 + 이름 + 경과시간)
|
||||
var headerGrid = new Grid { Margin = new Thickness(2, 0, 0, 4) };
|
||||
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
headerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
@@ -52,8 +52,12 @@ public partial class ChatWindow
|
||||
{
|
||||
var animState = new ChatIconAnimState
|
||||
{
|
||||
Host = liveIconHost, Canvas = canvas, Pixels = livePixels,
|
||||
Glows = liveGlows, Rotate = liveRotate, Scale = liveScale,
|
||||
Host = liveIconHost,
|
||||
Canvas = canvas,
|
||||
Pixels = livePixels,
|
||||
Glows = liveGlows,
|
||||
Rotate = liveRotate,
|
||||
Scale = liveScale,
|
||||
IsRandomMode = _settings.Settings.Launcher.EnableChatIconRandomAnimation,
|
||||
};
|
||||
StartChatIconAnimation(animState);
|
||||
@@ -79,7 +83,7 @@ public partial class ChatWindow
|
||||
Text = "",
|
||||
FontSize = 10,
|
||||
Foreground = secondaryText,
|
||||
Opacity = 0.70,
|
||||
Opacity = 0.7,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
};
|
||||
@@ -88,10 +92,42 @@ public partial class ChatWindow
|
||||
|
||||
_v2LiveContainer.Children.Add(headerGrid);
|
||||
|
||||
var summaryCard = new Border
|
||||
{
|
||||
Background = hintBg,
|
||||
BorderBrush = borderBrush,
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(12),
|
||||
Padding = new Thickness(12, 10, 12, 10),
|
||||
};
|
||||
|
||||
var summaryStack = new StackPanel();
|
||||
_v2LiveStatusText = new TextBlock
|
||||
{
|
||||
FontSize = 12.5,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = primaryText,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
};
|
||||
summaryStack.Children.Add(_v2LiveStatusText);
|
||||
|
||||
_v2LiveDetailText = new TextBlock
|
||||
{
|
||||
FontSize = 10.5,
|
||||
Foreground = secondaryText,
|
||||
Opacity = 0.86,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Margin = new Thickness(0, 4, 0, 0),
|
||||
};
|
||||
summaryStack.Children.Add(_v2LiveDetailText);
|
||||
summaryCard.Child = summaryStack;
|
||||
_v2LiveContainer.Children.Add(summaryCard);
|
||||
|
||||
ApplyV2LiveNarrative(AgentStatusNarrativeCatalog.BuildInitial(runTab));
|
||||
|
||||
AddTranscriptElement(_v2LiveContainer);
|
||||
ForceScrollToEnd();
|
||||
|
||||
// 경과 시간 타이머
|
||||
_v2LiveElapsedTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
|
||||
_v2LiveElapsedTimer.Tick += (_, _) =>
|
||||
{
|
||||
@@ -102,242 +138,41 @@ public partial class ChatWindow
|
||||
_v2LiveElapsedTimer.Start();
|
||||
}
|
||||
|
||||
/// <summary>V2: 에이전트 이벤트 수신 시 라이브 카드 업데이트</summary>
|
||||
/// <summary>V2: 라이브 카드는 1~2줄 요약만 유지하고, 상세 실행 이력은 본문 타임라인에 누적합니다.</summary>
|
||||
private void UpdateAgentLiveCardV2(AgentEvent agentEvent)
|
||||
{
|
||||
if (_v2LiveContainer == null) return;
|
||||
if (_v2LiveContainer == null)
|
||||
return;
|
||||
|
||||
var secondaryText = TryFindResource("SecondaryText") as Brush ?? Brushes.Gray;
|
||||
var primaryText = TryFindResource("PrimaryText") as Brush ?? Brushes.White;
|
||||
var hintBg = TryFindResource("HintBackground") as Brush
|
||||
?? new SolidColorBrush(Color.FromArgb(0x18, 0xFF, 0xFF, 0xFF));
|
||||
var borderBrush = TryFindResource("BorderColor") as Brush ?? Brushes.Gray;
|
||||
var msgMaxWidth = GetMessageMaxWidth();
|
||||
|
||||
switch (agentEvent.Type)
|
||||
if (agentEvent.Type == AgentEventType.Thinking
|
||||
&& !string.Equals(agentEvent.ToolName, "agent_wait", StringComparison.OrdinalIgnoreCase)
|
||||
&& !string.Equals(agentEvent.ToolName, "context_compaction", StringComparison.OrdinalIgnoreCase)
|
||||
&& AgentProgressSummarySanitizer.IsLowSignalStatusSummary(agentEvent.Summary, agentEvent.ToolName))
|
||||
{
|
||||
case AgentEventType.ToolCall:
|
||||
{
|
||||
var (icon, iconColor) = GetV2ToolIcon(agentEvent.ToolName);
|
||||
var toolId = $"{agentEvent.ToolName}_{agentEvent.Timestamp.Ticks}";
|
||||
_v2LastLiveToolCallId = toolId;
|
||||
|
||||
var outerGrid = new Grid { Margin = new Thickness(0, 2, 0, 2) };
|
||||
outerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto });
|
||||
outerGrid.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) });
|
||||
|
||||
// 왼쪽 세로선 (진행중 = 맥박)
|
||||
var accentColor = ResolveLiveProgressAccentColor(
|
||||
TryFindResource("AccentColor") as Brush ?? Brushes.CornflowerBlue);
|
||||
var pulseLine = new Border
|
||||
{
|
||||
Width = 2,
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x80, accentColor.R, accentColor.G, accentColor.B)),
|
||||
CornerRadius = new CornerRadius(1),
|
||||
Margin = new Thickness(12, 0, 8, 0),
|
||||
};
|
||||
// 맥박 애니메이션
|
||||
var pulseAnim = new DoubleAnimation(0.4, 1.0, TimeSpan.FromMilliseconds(800))
|
||||
{
|
||||
AutoReverse = true,
|
||||
RepeatBehavior = RepeatBehavior.Forever,
|
||||
EasingFunction = new SineEase(),
|
||||
};
|
||||
pulseLine.BeginAnimation(UIElement.OpacityProperty, pulseAnim);
|
||||
Grid.SetColumn(pulseLine, 0);
|
||||
outerGrid.Children.Add(pulseLine);
|
||||
|
||||
var card = new Border
|
||||
{
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x1C, accentColor.R, accentColor.G, accentColor.B)),
|
||||
BorderBrush = new SolidColorBrush(Color.FromArgb(0x40, accentColor.R, accentColor.G, accentColor.B)),
|
||||
BorderThickness = new Thickness(1),
|
||||
CornerRadius = new CornerRadius(8),
|
||||
Padding = new Thickness(10, 6, 10, 6),
|
||||
Tag = "pending",
|
||||
};
|
||||
Grid.SetColumn(card, 1);
|
||||
|
||||
var sp = new StackPanel { Orientation = Orientation.Horizontal };
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = icon,
|
||||
FontSize = 12,
|
||||
Foreground = iconColor,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 6, 0),
|
||||
});
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = GetV2ToolDisplayName(agentEvent.ToolName),
|
||||
FontSize = 11,
|
||||
FontWeight = FontWeights.SemiBold,
|
||||
Foreground = primaryText,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
});
|
||||
if (!string.IsNullOrWhiteSpace(agentEvent.FilePath))
|
||||
{
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = TruncateFilePath(agentEvent.FilePath, 50),
|
||||
FontSize = 10,
|
||||
Foreground = secondaryText,
|
||||
Opacity = 0.7,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(8, 0, 0, 0),
|
||||
});
|
||||
}
|
||||
// 스피너 대신 "..." 텍스트
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "...",
|
||||
FontSize = 11,
|
||||
Foreground = secondaryText,
|
||||
Opacity = 0.5,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(6, 0, 0, 0),
|
||||
});
|
||||
card.Child = sp;
|
||||
outerGrid.Children.Add(card);
|
||||
|
||||
_v2LiveToolCards[toolId] = card;
|
||||
_v2LiveContainer.Children.Add(outerGrid);
|
||||
// 사용자가 수동 스크롤 중이면 강제 스크롤하지 않음
|
||||
AutoScrollIfNeeded();
|
||||
break;
|
||||
}
|
||||
|
||||
case AgentEventType.ToolResult:
|
||||
{
|
||||
// 마지막 pending 카드를 완료 상태로 변환
|
||||
if (_v2LastLiveToolCallId != null && _v2LiveToolCards.TryGetValue(_v2LastLiveToolCallId, out var pendingCard))
|
||||
{
|
||||
var isSuccess = agentEvent.Success;
|
||||
var statusIcon = isSuccess ? "\uE73E" : "\uE711";
|
||||
var statusColor = isSuccess
|
||||
? new SolidColorBrush(Color.FromRgb(0x66, 0xBB, 0x6A))
|
||||
: new SolidColorBrush(Color.FromRgb(0xEF, 0x53, 0x50));
|
||||
|
||||
// 카드 배경/테두리를 성공/실패 색으로 변경
|
||||
pendingCard.Background = isSuccess
|
||||
? new SolidColorBrush(Color.FromArgb(0x0A, 0x66, 0xBB, 0x6A))
|
||||
: new SolidColorBrush(Color.FromArgb(0x0A, 0xEF, 0x53, 0x50));
|
||||
pendingCard.BorderBrush = isSuccess
|
||||
? new SolidColorBrush(Color.FromArgb(0x30, 0x66, 0xBB, 0x6A))
|
||||
: new SolidColorBrush(Color.FromArgb(0x30, 0xEF, 0x53, 0x50));
|
||||
pendingCard.Tag = "complete";
|
||||
|
||||
// "..." 텍스트를 상태 아이콘 + 소요시간으로 교체
|
||||
if (pendingCard.Child is StackPanel sp)
|
||||
{
|
||||
// 마지막 "..." 제거
|
||||
if (sp.Children.Count > 0 && sp.Children[^1] is TextBlock lastTb && lastTb.Text == "...")
|
||||
sp.Children.RemoveAt(sp.Children.Count - 1);
|
||||
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = statusIcon,
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 11,
|
||||
Foreground = statusColor,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(6, 0, 0, 0),
|
||||
});
|
||||
|
||||
var elapsed = NormalizeProgressElapsedMs(agentEvent.ElapsedMs);
|
||||
if (elapsed > 0)
|
||||
{
|
||||
sp.Children.Add(new TextBlock
|
||||
{
|
||||
Text = $"{elapsed / 1000.0:F1}s",
|
||||
FontSize = 10,
|
||||
Foreground = secondaryText,
|
||||
Opacity = 0.6,
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(4, 0, 0, 0),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 세로선 맥박 애니메이션 중지
|
||||
var parent = pendingCard.Parent as Grid;
|
||||
if (parent?.Children[0] is Border pulseLine)
|
||||
{
|
||||
pulseLine.BeginAnimation(UIElement.OpacityProperty, null);
|
||||
pulseLine.Opacity = 1;
|
||||
pulseLine.Background = isSuccess
|
||||
? new SolidColorBrush(Color.FromArgb(0x60, 0x66, 0xBB, 0x6A))
|
||||
: new SolidColorBrush(Color.FromArgb(0x60, 0xEF, 0x53, 0x50));
|
||||
}
|
||||
|
||||
// ★ 섹션 종료 후 자동 접기 — 완료된 카드는 1.2초 뒤 컴팩트 형태로 축소
|
||||
// 얇은 줄이 빠르게 누적되어 공간 낭비되는 문제 해결
|
||||
var cardToCollapse = pendingCard;
|
||||
var outerGridToCollapse = parent;
|
||||
var collapseTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(1200) };
|
||||
collapseTimer.Tick += (_, _) =>
|
||||
{
|
||||
collapseTimer.Stop();
|
||||
if (cardToCollapse == null) return;
|
||||
// Padding 축소 + Opacity 감소로 "접힌" 느낌 연출
|
||||
var padAnim = new ThicknessAnimation(
|
||||
cardToCollapse.Padding,
|
||||
new Thickness(8, 2, 8, 2),
|
||||
TimeSpan.FromMilliseconds(200))
|
||||
{ EasingFunction = new QuadraticEase() };
|
||||
var opAnim = new DoubleAnimation(1.0, 0.55, TimeSpan.FromMilliseconds(200))
|
||||
{ EasingFunction = new QuadraticEase() };
|
||||
cardToCollapse.BeginAnimation(Border.PaddingProperty, padAnim);
|
||||
cardToCollapse.BeginAnimation(UIElement.OpacityProperty, opAnim);
|
||||
if (outerGridToCollapse != null)
|
||||
outerGridToCollapse.Margin = new Thickness(0, 1, 0, 1);
|
||||
};
|
||||
collapseTimer.Start();
|
||||
|
||||
_v2LastLiveToolCallId = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case AgentEventType.Thinking:
|
||||
{
|
||||
var thinkText = AgentProgressSummarySanitizer.NormalizeThinkingSummary(
|
||||
agentEvent.Summary,
|
||||
agentEvent.ToolName,
|
||||
maxLength: 100);
|
||||
if (string.IsNullOrWhiteSpace(thinkText)) break;
|
||||
|
||||
// 사고 과정을 간략히 표시
|
||||
var thinkRow = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Margin = new Thickness(22, 2, 0, 2),
|
||||
};
|
||||
thinkRow.Children.Add(new TextBlock
|
||||
{
|
||||
Text = "\uE915",
|
||||
FontFamily = s_segoeIconFont,
|
||||
FontSize = 10,
|
||||
Foreground = new SolidColorBrush(Color.FromRgb(0x59, 0xA5, 0xF5)),
|
||||
VerticalAlignment = VerticalAlignment.Center,
|
||||
Margin = new Thickness(0, 0, 4, 0),
|
||||
});
|
||||
thinkRow.Children.Add(new TextBlock
|
||||
{
|
||||
Text = thinkText,
|
||||
FontSize = 10.5,
|
||||
FontStyle = FontStyles.Italic,
|
||||
Foreground = secondaryText,
|
||||
Opacity = 0.82,
|
||||
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||
MaxWidth = msgMaxWidth - 60,
|
||||
});
|
||||
_v2LiveContainer.Children.Add(thinkRow);
|
||||
// 사용자가 수동 스크롤 중이면 강제 스크롤하지 않음
|
||||
AutoScrollIfNeeded();
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyV2LiveNarrative(AgentStatusNarrativeCatalog.BuildFromEvent(agentEvent, _activeTab));
|
||||
AutoScrollIfNeeded();
|
||||
}
|
||||
|
||||
private void ApplyV2LiveNarrative(AgentStatusNarrative narrative)
|
||||
{
|
||||
if (_v2LiveStatusText != null)
|
||||
_v2LiveStatusText.Text = narrative.Message;
|
||||
|
||||
if (_v2LiveDetailText == null)
|
||||
return;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(narrative.Detail))
|
||||
{
|
||||
_v2LiveDetailText.Text = string.Empty;
|
||||
_v2LiveDetailText.Visibility = Visibility.Collapsed;
|
||||
return;
|
||||
}
|
||||
|
||||
_v2LiveDetailText.Text = narrative.Detail;
|
||||
_v2LiveDetailText.Visibility = Visibility.Visible;
|
||||
}
|
||||
|
||||
/// <summary>V2: 스트리밍 종료 시 라이브 카드 제거</summary>
|
||||
@@ -346,16 +181,15 @@ public partial class ChatWindow
|
||||
_v2LiveElapsedTimer?.Stop();
|
||||
_v2LiveElapsedTimer = null;
|
||||
_v2LiveElapsedText = null;
|
||||
_v2LiveStatusText = null;
|
||||
_v2LiveDetailText = null;
|
||||
|
||||
if (_v2LiveContainer == null) return;
|
||||
|
||||
// 아이콘 애니메이션 상태 정리
|
||||
_chatIconAnimStates.RemoveAll(s => s.Host != null && !s.Host.IsVisible);
|
||||
|
||||
var toRemove = _v2LiveContainer;
|
||||
_v2LiveContainer = null;
|
||||
_v2LiveToolCards.Clear();
|
||||
_v2LastLiveToolCallId = null;
|
||||
|
||||
if (animated && ContainsTranscriptElement(toRemove))
|
||||
{
|
||||
|
||||
@@ -88,7 +88,7 @@ public partial class ChatWindow
|
||||
MarkdownRenderer.EnableFilePathHighlight =
|
||||
(System.Windows.Application.Current as App)?.SettingsService?.Settings.Llm.EnableFilePathHighlight ?? true;
|
||||
MarkdownRenderer.EnableCodeSymbolHighlight = true;
|
||||
bubble.Child = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, hintBg);
|
||||
bubble.Child = MarkdownRenderer.RenderSelectable(content, primaryText, secondaryText, accentBrush, hintBg);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -192,11 +192,11 @@ public partial class ChatWindow
|
||||
|
||||
if (IsBranchContextMessage(content))
|
||||
{
|
||||
contentPanel.Child = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush);
|
||||
contentPanel.Child = MarkdownRenderer.RenderSelectable(content, primaryText, secondaryText, accentBrush, codeBgBrush);
|
||||
}
|
||||
else
|
||||
{
|
||||
contentPanel.Child = MarkdownRenderer.Render(content, primaryText, secondaryText, accentBrush, codeBgBrush);
|
||||
contentPanel.Child = MarkdownRenderer.RenderSelectable(content, primaryText, secondaryText, accentBrush, codeBgBrush);
|
||||
}
|
||||
|
||||
container.Children.Add(contentPanel);
|
||||
|
||||
@@ -22,8 +22,8 @@ public partial class ChatWindow
|
||||
|
||||
// V2 라이브 프로그레스 상태
|
||||
private StackPanel? _v2LiveContainer;
|
||||
private readonly Dictionary<string, Border> _v2LiveToolCards = new();
|
||||
private string? _v2LastLiveToolCallId;
|
||||
private TextBlock? _v2LiveStatusText;
|
||||
private TextBlock? _v2LiveDetailText;
|
||||
|
||||
private void RenderMessagesV2(
|
||||
ChatConversation conv,
|
||||
@@ -61,7 +61,7 @@ public partial class ChatWindow
|
||||
// 통합 타임라인 빌드 (메시지 + 이벤트를 시간순 병합)
|
||||
var timeline = BuildV2Timeline(visibleMessages, visibleEvents);
|
||||
|
||||
LogService.Info($"[V2Render] timeline={timeline.Count}, msgs={visibleMessages.Count}, evts={visibleEvents.Count}, convId={conv.Id[..Math.Min(8, conv.Id.Length)]}, caller={caller}");
|
||||
LogService.Debug($"[V2Render] timeline={timeline.Count}, msgs={visibleMessages.Count}, evts={visibleEvents.Count}, convId={conv.Id[..Math.Min(8, conv.Id.Length)]}, caller={caller}");
|
||||
|
||||
// 새 키 목록 생성
|
||||
var newKeys = new List<string>(timeline.Count);
|
||||
@@ -78,7 +78,7 @@ public partial class ChatWindow
|
||||
&& newKeys.Count >= _v2LastRenderedKeys.Count
|
||||
&& KeysArePrefixMatch(_v2LastRenderedKeys, newKeys);
|
||||
|
||||
LogService.Info($"[V2Render] canIncremental={canIncremental}, prevKeys={_v2LastRenderedKeys.Count}, newKeys={newKeys.Count}, preElementCount={GetTranscriptElementCount()}");
|
||||
LogService.Debug($"[V2Render] canIncremental={canIncremental}, prevKeys={_v2LastRenderedKeys.Count}, newKeys={newKeys.Count}, preElementCount={GetTranscriptElementCount()}");
|
||||
|
||||
if (canIncremental)
|
||||
{
|
||||
@@ -131,7 +131,7 @@ public partial class ChatWindow
|
||||
_v2LastRenderedMessageCount = visibleMessages.Count;
|
||||
_v2LastRenderedEventCount = visibleEvents.Count;
|
||||
|
||||
LogService.Info($"[V2Render] DONE postElementCount={GetTranscriptElementCount()}, savedKeys={_v2LastRenderedKeys.Count}");
|
||||
LogService.Debug($"[V2Render] DONE postElementCount={GetTranscriptElementCount()}, savedKeys={_v2LastRenderedKeys.Count}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -198,18 +198,12 @@ public partial class ChatWindow
|
||||
// 스트리밍 중이면 라이브 카드가 이미 표시하는 현재 실행 이벤트는 타임라인에서 제외
|
||||
var eventIndex = 0;
|
||||
var events = visibleEvents.ToList();
|
||||
var liveCardCutoff = (_isStreaming && _v2LiveContainer != null)
|
||||
? _v2LiveStartTime
|
||||
: DateTime.MaxValue;
|
||||
|
||||
for (int i = 0; i < events.Count; i++)
|
||||
{
|
||||
var executionEvent = events[i];
|
||||
|
||||
// 스트리밍 중: 라이브 카드 시작 이후 이벤트는 라이브 카드에서 표시하므로 스킵
|
||||
if (executionEvent.Timestamp.ToUniversalTime() >= liveCardCutoff)
|
||||
continue;
|
||||
|
||||
var agentEvent = ToAgentEvent(executionEvent);
|
||||
|
||||
// SessionStart / UserPromptSubmit 숨김
|
||||
|
||||
Reference in New Issue
Block a user