Compare commits
2 Commits
1256fdc43f
...
cc1f1c4e6c
| Author | SHA1 | Date | |
|---|---|---|---|
| cc1f1c4e6c | |||
| 52e9e34ade |
@@ -60,3 +60,6 @@
|
||||
3. hook 실패/예외는 non-blocking(권한 흐름 지속).
|
||||
4. `additionalContext`는 가능한 경로에서 메시지 컨텍스트로 반영.
|
||||
|
||||
- 2026-04-04(추가): `/mcp add/remove/reset` 확장, `tool_search` 기반 복구 프롬프트 강화, 슬래시 힌트 밀도(`rich/balanced/simple`) 연동.
|
||||
- 2026-04-04(추가2): /mcp login/logout 세션 인증 토큰 지원, /mcp status·/chrome 진단에 Auth(Session) 반영.
|
||||
- 2026-04-04(추가3): 권한 UX 통합(/permissions·/allowed-tools·/settings permissions), 복구 혼합 테스트 보강, 좌측 패널 실패 필터 노출 정책 rich 전용으로 정렬.
|
||||
|
||||
@@ -2723,3 +2723,70 @@ else:
|
||||
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj` 통과.
|
||||
- 기준 시점 전체 테스트: 403 passed, 0 failed.
|
||||
|
||||
|
||||
## 2026-04-04 추가 진행 기록 (연속 실행 2차)
|
||||
|
||||
### A. /mcp 명령 확장
|
||||
- `/mcp add <name> :: stdio <command> [args...]` 지원.
|
||||
- `/mcp add <name> :: sse <url>` 지원.
|
||||
- `/mcp remove <name|all>` 지원.
|
||||
- `/mcp reset` 지원 (세션 오버라이드 초기화).
|
||||
- 상태/도움말 문구를 확장 명령 기준으로 업데이트.
|
||||
|
||||
### B. Agentic loop 복구 가이드 강화
|
||||
- 미등록/비허용 도구 복구 프롬프트에 `tool_search` 우선 사용 지침 추가.
|
||||
- 반복 실패 중단 응답에도 `tool_search` 기반 재시도 루트를 명시.
|
||||
|
||||
### C. UI/UX 단순화(슬래시 팝업 힌트)
|
||||
- `agentUiExpressionLevel`(`rich|balanced|simple`)에 따라 슬래시 팝업 힌트 밀도 조정.
|
||||
- simple: 최소 정보, rich: 추천 명령 포함, balanced: 기본 정보.
|
||||
|
||||
### D. 설정값-실동작 점검(핵심)
|
||||
- `MaxRetryOnError`, `EnableProactiveContextCompact`, `ContextCompactTriggerPercent`, `MaxContextTokens`, `AllowInsecureTls`, `AgentUiExpressionLevel` 항목의
|
||||
모델/뷰모델/UI/런타임 참조 경로를 점검하여 동작 연결을 확인.
|
||||
|
||||
### E. 검증 결과
|
||||
- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0).
|
||||
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj` 통과 (411 passed, 0 failed).
|
||||
|
||||
## 2026-04-04 추가 진행 기록 (연속 실행 3차)
|
||||
|
||||
### /mcp 인증 명령 추가
|
||||
- `/mcp login <서버명> <토큰>`: 세션 인증 토큰 설정.
|
||||
- `/mcp logout <서버명|all>`: 세션 인증 토큰 제거.
|
||||
- `/mcp reset` 시 세션 MCP 활성/비활성 오버라이드와 인증 토큰을 함께 초기화.
|
||||
|
||||
### 런타임 반영 범위
|
||||
- MCP 상태 점검(`/mcp status`)과 Chrome 진단(`/chrome`)에서 세션 토큰을 `MCP_AUTH_TOKEN` 환경변수로 합성 적용.
|
||||
- 상태 출력에 `Auth(Session)` 표기 추가.
|
||||
|
||||
### 테스트 보강
|
||||
- `ChatWindowSlashPolicyTests`에 `login/logout` 파서 및 로그인 입력 파서 테스트 추가.
|
||||
|
||||
### 검증 결과
|
||||
- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0).
|
||||
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj` 통과 (415 passed, 0 failed).
|
||||
|
||||
## 2026-04-04 추가 진행 기록 (연속 실행 4차: 계획 1~4)
|
||||
|
||||
### 1) 권한/도구 UX 통합
|
||||
- `/permissions`, `/allowed-tools`, `/settings permissions`가 동일한 권한 상태 모델을 사용하도록 정렬.
|
||||
- 공통 처리 추가:
|
||||
- 권한 모드 적용 헬퍼(`ask|auto|deny`)
|
||||
- 권한 상태 요약 텍스트 생성
|
||||
- 권한 팝업 오픈 경로 통일
|
||||
- `/allowed-tools`에서도 `ask|auto|deny|status`를 동일하게 지원.
|
||||
|
||||
### 2) 루프 복구 테스트 확대
|
||||
- `AgentLoopCodeQualityTests`에 unknown/disallowed/no-progress 혼합 관점 테스트 추가.
|
||||
- unknown/disallowed 중단 응답에 `tool_search` 가이드가 유지되는지 회귀 검증.
|
||||
|
||||
### 3) 좌측 패널 단순화 2차
|
||||
- 표현 레벨 기준 정리:
|
||||
- `rich`: 실패 필터 노출
|
||||
- `balanced/simple`: 실패 필터 비노출
|
||||
- Quick strip 표시 조건도 위 정책과 연동해 실패 버튼이 숨겨진 모드에서 과밀 표시를 방지.
|
||||
|
||||
### 4) 품질 게이트
|
||||
- `dotnet build src/AxCopilot/AxCopilot.csproj` 통과 (경고 0, 오류 0).
|
||||
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj` 통과 (421 passed, 0 failed).
|
||||
|
||||
@@ -399,6 +399,24 @@ public class AgentLoopCodeQualityTests
|
||||
guidance.Should().Contain("NRE");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UnknownAndDisallowedRecoveryPrompts_ShouldGuideToolSearch()
|
||||
{
|
||||
var unknownPrompt = InvokePrivateStatic<string>(
|
||||
"BuildUnknownToolRecoveryPrompt",
|
||||
"read_file_typo",
|
||||
new[] { "file_read", "file_edit", "tool_search" });
|
||||
|
||||
var disallowedPrompt = InvokePrivateStatic<string>(
|
||||
"BuildDisallowedToolRecoveryPrompt",
|
||||
"shell_exec",
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "file_read", "tool_search" },
|
||||
new[] { "file_read", "tool_search" });
|
||||
|
||||
unknownPrompt.Should().Contain("tool_search");
|
||||
disallowedPrompt.Should().Contain("tool_search");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsFailurePatternForTaskType_MatchesCaseInsensitiveTaskToken()
|
||||
{
|
||||
@@ -1644,6 +1662,52 @@ public class AgentLoopCodeQualityTests
|
||||
response.Should().Contain("반복 횟수: 3");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildUnknownToolLoopAbortResponse_ShouldGuideToolSearch()
|
||||
{
|
||||
var response = InvokePrivateStatic<string>(
|
||||
"BuildUnknownToolLoopAbortResponse",
|
||||
"unknown_exec",
|
||||
4,
|
||||
new List<string> { "tool_search", "file_read", "process" });
|
||||
|
||||
response.Should().Contain("tool_search");
|
||||
response.Should().Contain("unknown_exec");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildDisallowedToolLoopAbortResponse_ShouldGuideToolSearch()
|
||||
{
|
||||
var response = InvokePrivateStatic<string>(
|
||||
"BuildDisallowedToolLoopAbortResponse",
|
||||
"dangerous_exec",
|
||||
5,
|
||||
new List<string> { "tool_search", "file_read", "file_edit" });
|
||||
|
||||
response.Should().Contain("tool_search");
|
||||
response.Should().Contain("dangerous_exec");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MixedRecoverySignals_ShouldRemainConsistentAcrossUnknownDisallowedAndNoProgress()
|
||||
{
|
||||
var unknownPrompt = InvokePrivateStatic<string>(
|
||||
"BuildUnknownToolRecoveryPrompt",
|
||||
"bad_tool",
|
||||
new List<string> { "tool_search", "file_read", "glob" });
|
||||
|
||||
var disallowedPrompt = InvokePrivateStatic<string>(
|
||||
"BuildDisallowedToolRecoveryPrompt",
|
||||
"unsafe_tool",
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "tool_search", "file_read" },
|
||||
new List<string> { "tool_search", "file_read" });
|
||||
|
||||
unknownPrompt.Should().Contain("tool_search");
|
||||
disallowedPrompt.Should().Contain("tool_search");
|
||||
unknownPrompt.Should().Contain("bad_tool");
|
||||
disallowedPrompt.Should().Contain("unsafe_tool");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveRequestedToolName_MapsCommonAliasWhenTargetIsActive()
|
||||
{
|
||||
@@ -1756,6 +1820,28 @@ public class AgentLoopCodeQualityTests
|
||||
resolved.Should().Be("Write");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveRequestedToolName_MapsRgAliasToGrep()
|
||||
{
|
||||
var resolved = InvokePrivateStatic<string>(
|
||||
"ResolveRequestedToolName",
|
||||
"rg",
|
||||
new List<string> { "file_read", "grep", "glob" });
|
||||
|
||||
resolved.Should().Be("grep");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveRequestedToolName_MapsCodeSearchAliasToSearchCodebase()
|
||||
{
|
||||
var resolved = InvokePrivateStatic<string>(
|
||||
"ResolveRequestedToolName",
|
||||
"code_search",
|
||||
new List<string> { "search_codebase", "file_read" });
|
||||
|
||||
resolved.Should().Be("search_codebase");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShouldRunPostToolVerification_MatchesTabAndToolTypeRules()
|
||||
{
|
||||
|
||||
@@ -21,6 +21,11 @@ public class ChatWindowSlashPolicyTests
|
||||
[InlineData("/mcp", "status", "")]
|
||||
[InlineData("enable all", "enable", "all")]
|
||||
[InlineData("reconnect chrome", "reconnect", "chrome")]
|
||||
[InlineData("add chrome :: stdio node server.js", "add", "chrome :: stdio node server.js")]
|
||||
[InlineData("remove all", "remove", "all")]
|
||||
[InlineData("reset", "reset", "")]
|
||||
[InlineData("login chrome token-123", "login", "chrome token-123")]
|
||||
[InlineData("logout all", "logout", "all")]
|
||||
[InlineData("status", "status", "")]
|
||||
public void ParseMcpAction_ShouldParseExpected(string displayText, string expectedAction, string expectedTarget)
|
||||
{
|
||||
@@ -43,6 +48,18 @@ public class ChatWindowSlashPolicyTests
|
||||
argument.Should().Be(expectedArgument);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("/allowed-tools", "open", "")]
|
||||
[InlineData("status", "status", "")]
|
||||
[InlineData("ask", "ask", "")]
|
||||
public void ParseGenericAction_ForAllowedTools_ShouldParseExpected(string displayText, string expectedAction, string expectedArgument)
|
||||
{
|
||||
var (action, argument) = ChatWindow.ParseGenericAction(displayText, "/allowed-tools");
|
||||
|
||||
action.Should().Be(expectedAction);
|
||||
argument.Should().Be(expectedArgument);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false, "stdio", true, false, null, "Disabled")]
|
||||
[InlineData(true, "stdio", false, false, null, "Enabled")]
|
||||
@@ -74,4 +91,65 @@ public class ChatWindowSlashPolicyTests
|
||||
files.Count.Should().Be(expectedFileCount);
|
||||
message.Should().Be(expectedMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMcpAddTarget_ShouldParseStdioEntry()
|
||||
{
|
||||
var (success, entry, error) = ChatWindow.ParseMcpAddTarget("chrome :: stdio node server.js --port 3000");
|
||||
|
||||
success.Should().BeTrue(error);
|
||||
entry.Should().NotBeNull();
|
||||
entry!.Name.Should().Be("chrome");
|
||||
entry.Transport.Should().Be("stdio");
|
||||
entry.Command.Should().Be("node");
|
||||
entry.Args.Should().ContainInOrder("server.js", "--port", "3000");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMcpAddTarget_ShouldParseSseEntry()
|
||||
{
|
||||
var (success, entry, error) = ChatWindow.ParseMcpAddTarget("internal-sse :: sse https://intra.example.local/mcp/sse");
|
||||
|
||||
success.Should().BeTrue(error);
|
||||
entry.Should().NotBeNull();
|
||||
entry!.Name.Should().Be("internal-sse");
|
||||
entry.Transport.Should().Be("sse");
|
||||
entry.Url.Should().Be("https://intra.example.local/mcp/sse");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMcpAddTarget_ShouldFailWithoutSeparator()
|
||||
{
|
||||
var (success, _, error) = ChatWindow.ParseMcpAddTarget("chrome stdio node server.js");
|
||||
|
||||
success.Should().BeFalse();
|
||||
error.Should().Contain("::");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TokenizeCommand_ShouldKeepQuotedSegments()
|
||||
{
|
||||
var tokens = ChatWindow.TokenizeCommand("stdio \"C:\\\\Program Files\\\\node.exe\" \"server path.js\" --flag");
|
||||
|
||||
tokens.Should().ContainInOrder("stdio", "C:\\\\Program Files\\\\node.exe", "server path.js", "--flag");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMcpLoginTarget_ShouldParseServerAndToken()
|
||||
{
|
||||
var (success, server, token, error) = ChatWindow.ParseMcpLoginTarget("chrome abc.def.123");
|
||||
|
||||
success.Should().BeTrue(error);
|
||||
server.Should().Be("chrome");
|
||||
token.Should().Be("abc.def.123");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseMcpLoginTarget_ShouldFailWhenTokenMissing()
|
||||
{
|
||||
var (success, _, _, error) = ChatWindow.ParseMcpLoginTarget("chrome");
|
||||
|
||||
success.Should().BeFalse();
|
||||
error.Should().Contain("토큰");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Diagnostics;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using AxCopilot.Models;
|
||||
@@ -14,10 +15,17 @@ namespace AxCopilot.Services.Agent;
|
||||
public partial class AgentLoopService
|
||||
{
|
||||
internal const string ApprovedPlanDecisionPrefix = "[PLAN_APPROVED_STEPS]";
|
||||
public sealed record PermissionPromptPreview(
|
||||
string Kind,
|
||||
string Title,
|
||||
string Summary,
|
||||
string Content,
|
||||
string? PreviousContent = null);
|
||||
|
||||
private readonly LlmService _llm;
|
||||
private readonly ToolRegistry _tools;
|
||||
private readonly SettingsService _settings;
|
||||
private readonly ConcurrentDictionary<string, PermissionPromptPreview> _pendingPermissionPreviews = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>에이전트 이벤트 스트림 (UI 바인딩용).</summary>
|
||||
public ObservableCollection<AgentEvent> Events { get; } = new();
|
||||
@@ -66,6 +74,22 @@ public partial class AgentLoopService
|
||||
_settings = settings;
|
||||
}
|
||||
|
||||
public bool TryGetPendingPermissionPreview(string toolName, string target, out PermissionPromptPreview? preview)
|
||||
{
|
||||
preview = null;
|
||||
var key = BuildPermissionPreviewKey(toolName, target);
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
return false;
|
||||
|
||||
if (_pendingPermissionPreviews.TryGetValue(key, out var value))
|
||||
{
|
||||
preview = value;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 에이전트 루프를 일시정지합니다.
|
||||
/// 다음 반복 시작 시점에서 대기 상태가 됩니다.
|
||||
@@ -414,7 +438,13 @@ public partial class AgentLoopService
|
||||
// 첫 반복에서도 실행 (이전 대화 복원으로 이미 긴 경우 대비)
|
||||
{
|
||||
var condensed = await ContextCondenser.CondenseIfNeededAsync(
|
||||
messages, _llm, llm.MaxContextTokens, ct);
|
||||
messages,
|
||||
_llm,
|
||||
llm.MaxContextTokens,
|
||||
llm.EnableProactiveContextCompact,
|
||||
llm.ContextCompactTriggerPercent,
|
||||
false,
|
||||
ct);
|
||||
if (condensed)
|
||||
EmitEvent(AgentEventType.Thinking, "", "컨텍스트 압축 완료 — 입력 토큰을 절감했습니다");
|
||||
}
|
||||
@@ -3474,6 +3504,7 @@ public partial class AgentLoopService
|
||||
$"- 실패 도구: {unknownToolName}\n" +
|
||||
aliasHint +
|
||||
$"- 사용 가능한 도구 예시: {string.Join(", ", suggestions)}\n" +
|
||||
"- 도구가 애매하면 먼저 tool_search를 호출해 정확한 이름을 찾으세요.\n" +
|
||||
"위 목록에서 실제 존재하는 도구 하나를 골라 다시 호출하세요. 같은 미등록 도구를 반복 호출하지 마세요.";
|
||||
}
|
||||
|
||||
@@ -3493,6 +3524,7 @@ public partial class AgentLoopService
|
||||
$"- 요청 도구: {requestedToolName}\n" +
|
||||
policyLine +
|
||||
$"- 지금 사용 가능한 도구 예시: {activePreview}\n" +
|
||||
"- 도구 선택이 모호하면 tool_search로 허용 가능한 대체 도구를 먼저 찾으세요.\n" +
|
||||
"허용 목록에서 대체 도구를 선택해 다시 호출하세요. 동일한 비허용 도구 재호출은 금지합니다.";
|
||||
}
|
||||
|
||||
@@ -3506,7 +3538,7 @@ public partial class AgentLoopService
|
||||
$"- 반복 횟수: {repeatedUnknownToolCount}\n" +
|
||||
$"- 실패 도구: {unknownToolName}\n" +
|
||||
$"- 현재 사용 가능한 도구 예시: {preview}\n" +
|
||||
"- 다음 실행에서는 위 목록의 실제 도구 이름으로 호출하세요.";
|
||||
"- 다음 실행에서는 tool_search로 도구명을 확인한 뒤 위 목록의 실제 도구 이름으로 호출하세요.";
|
||||
}
|
||||
|
||||
private static string BuildDisallowedToolLoopAbortResponse(
|
||||
@@ -3519,7 +3551,7 @@ public partial class AgentLoopService
|
||||
$"- 반복 횟수: {repeatedCount}\n" +
|
||||
$"- 비허용 도구: {toolName}\n" +
|
||||
$"- 현재 사용 가능한 도구 예시: {preview}\n" +
|
||||
"- 다음 실행에서는 허용된 도구만 호출하도록 계획을 수정하세요.";
|
||||
"- 다음 실행에서는 tool_search로 허용 도구를 확인하고 계획을 수정하세요.";
|
||||
}
|
||||
|
||||
private static readonly Dictionary<string, string> ToolAliasMap = new(StringComparer.OrdinalIgnoreCase)
|
||||
@@ -3541,6 +3573,10 @@ public partial class AgentLoopService
|
||||
["listfiles"] = "glob",
|
||||
["list_files"] = "glob",
|
||||
["grep"] = "grep",
|
||||
["greptool"] = "grep",
|
||||
["grep_tool"] = "grep",
|
||||
["rg"] = "grep",
|
||||
["ripgrep"] = "grep",
|
||||
["search"] = "grep",
|
||||
["globfiles"] = "glob",
|
||||
["glob_files"] = "glob",
|
||||
@@ -3552,8 +3588,13 @@ public partial class AgentLoopService
|
||||
["listmcpresourcestool"] = "mcp_list_resources",
|
||||
["readmcpresourcetool"] = "mcp_read_resource",
|
||||
["agent"] = "spawn_agent",
|
||||
["spawnagent"] = "spawn_agent",
|
||||
["task"] = "spawn_agent",
|
||||
["sendmessage"] = "notify_tool",
|
||||
["shellcommand"] = "process",
|
||||
["execute"] = "process",
|
||||
["codesearch"] = "search_codebase",
|
||||
["code_search"] = "search_codebase",
|
||||
["powershell"] = "process",
|
||||
["toolsearch"] = "tool_search",
|
||||
["todowrite"] = "todo_write",
|
||||
@@ -4006,6 +4047,99 @@ public partial class AgentLoopService
|
||||
return primary;
|
||||
}
|
||||
|
||||
private static string BuildPermissionPreviewKey(string toolName, string target)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(toolName) || string.IsNullOrWhiteSpace(target))
|
||||
return "";
|
||||
return $"{toolName.Trim()}|{target.Trim()}";
|
||||
}
|
||||
|
||||
private static PermissionPromptPreview? BuildPermissionPreview(string toolName, JsonElement input, string target)
|
||||
{
|
||||
var normalizedTool = toolName.Trim().ToLowerInvariant();
|
||||
|
||||
if (normalizedTool.Contains("file_write"))
|
||||
{
|
||||
var content = input.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String
|
||||
? c.GetString() ?? ""
|
||||
: "";
|
||||
var truncated = content.Length <= 2000 ? content : content[..2000] + "\n... (truncated)";
|
||||
string? previous = null;
|
||||
try
|
||||
{
|
||||
if (File.Exists(target))
|
||||
{
|
||||
var original = File.ReadAllText(target);
|
||||
previous = original.Length <= 2000 ? original : original[..2000] + "\n... (truncated)";
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
previous = null;
|
||||
}
|
||||
return new PermissionPromptPreview(
|
||||
Kind: "file_write",
|
||||
Title: "Pending file write",
|
||||
Summary: "The tool will replace or create file content.",
|
||||
Content: truncated,
|
||||
PreviousContent: previous);
|
||||
}
|
||||
|
||||
if (normalizedTool.Contains("file_edit"))
|
||||
{
|
||||
if (input.TryGetProperty("edits", out var edits) && edits.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var lines = edits.EnumerateArray()
|
||||
.Take(6)
|
||||
.Select((edit, index) =>
|
||||
{
|
||||
var oldText = edit.TryGetProperty("old_string", out var oldElem) && oldElem.ValueKind == JsonValueKind.String
|
||||
? oldElem.GetString() ?? ""
|
||||
: "";
|
||||
var newText = edit.TryGetProperty("new_string", out var newElem) && newElem.ValueKind == JsonValueKind.String
|
||||
? newElem.GetString() ?? ""
|
||||
: "";
|
||||
|
||||
oldText = oldText.Length <= 180 ? oldText : oldText[..180] + "...";
|
||||
newText = newText.Length <= 180 ? newText : newText[..180] + "...";
|
||||
return $"{index + 1}) - {oldText}\n + {newText}";
|
||||
});
|
||||
|
||||
return new PermissionPromptPreview(
|
||||
Kind: "file_edit",
|
||||
Title: "Pending file edit",
|
||||
Summary: $"The tool requested {edits.GetArrayLength()} edit block(s).",
|
||||
Content: string.Join("\n", lines));
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedTool.Contains("process") || normalizedTool.Contains("bash") || normalizedTool.Contains("powershell"))
|
||||
{
|
||||
var command = input.TryGetProperty("command", out var cmd) && cmd.ValueKind == JsonValueKind.String
|
||||
? cmd.GetString() ?? target
|
||||
: target;
|
||||
return new PermissionPromptPreview(
|
||||
Kind: "command",
|
||||
Title: "Pending command",
|
||||
Summary: "The tool wants to run this command.",
|
||||
Content: command);
|
||||
}
|
||||
|
||||
if (normalizedTool.Contains("web") || normalizedTool.Contains("fetch") || normalizedTool.Contains("http"))
|
||||
{
|
||||
var url = input.TryGetProperty("url", out var u) && u.ValueKind == JsonValueKind.String
|
||||
? u.GetString() ?? target
|
||||
: target;
|
||||
return new PermissionPromptPreview(
|
||||
Kind: "web",
|
||||
Title: "Pending network access",
|
||||
Summary: "The tool wants to access this URL.",
|
||||
Content: url);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryGetHookUpdatedInput(
|
||||
IEnumerable<HookExecutionResult> results,
|
||||
out JsonElement updatedInput)
|
||||
@@ -4161,7 +4295,22 @@ public partial class AgentLoopService
|
||||
if (PermissionModeCatalog.RequiresUserApproval(effectivePerm))
|
||||
EmitEvent(AgentEventType.PermissionRequest, toolName, $"권한 확인 필요({effectivePerm}) · 대상: {target}");
|
||||
|
||||
var allowed = await context.CheckToolPermissionAsync(toolName, target);
|
||||
var previewKey = BuildPermissionPreviewKey(toolName, target);
|
||||
var preview = BuildPermissionPreview(toolName, input, target);
|
||||
if (!string.IsNullOrWhiteSpace(previewKey) && preview != null)
|
||||
_pendingPermissionPreviews[previewKey] = preview;
|
||||
|
||||
bool allowed;
|
||||
try
|
||||
{
|
||||
allowed = await context.CheckToolPermissionAsync(toolName, target);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(previewKey))
|
||||
_pendingPermissionPreviews.TryRemove(previewKey, out _);
|
||||
}
|
||||
|
||||
if (allowed)
|
||||
{
|
||||
if (PermissionModeCatalog.RequiresUserApproval(effectivePerm))
|
||||
|
||||
@@ -65,6 +65,7 @@ public partial class ChatWindow : Window
|
||||
private bool _userScrolled; // 사용자가 위로 스크롤했는지
|
||||
private readonly HashSet<string> _sessionPermissionRules = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, bool> _sessionMcpEnabledOverrides = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Dictionary<string, string> _sessionMcpAuthTokens = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 경과 시간 표시
|
||||
private readonly DispatcherTimer _elapsedTimer;
|
||||
@@ -1955,6 +1956,48 @@ public partial class ChatWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryApplyPermissionModeFromAction(string action, out string appliedMode)
|
||||
{
|
||||
appliedMode = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission);
|
||||
var next = action switch
|
||||
{
|
||||
"ask" => PermissionModeCatalog.Ask,
|
||||
"auto" => PermissionModeCatalog.Auto,
|
||||
"deny" => PermissionModeCatalog.Deny,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(next))
|
||||
return false;
|
||||
|
||||
_settings.Settings.Llm.FilePermission = next!;
|
||||
_settings.Save();
|
||||
_appState.LoadFromSettings(_settings);
|
||||
UpdatePermissionUI();
|
||||
SaveConversationSettings();
|
||||
RefreshInlineSettingsPanel();
|
||||
appliedMode = next!;
|
||||
return true;
|
||||
}
|
||||
|
||||
private string BuildPermissionStatusText()
|
||||
{
|
||||
ChatConversation? currentConversation;
|
||||
lock (_convLock) currentConversation = _currentConversation;
|
||||
var summary = _appState.GetPermissionSummary(currentConversation);
|
||||
var mode = PermissionModeCatalog.NormalizeGlobalMode(summary.EffectiveMode);
|
||||
var overrides = summary.TopOverrides.Count > 0
|
||||
? string.Join(", ", summary.TopOverrides.Select(x => $"{x.Key}:{x.Value}"))
|
||||
: "없음";
|
||||
return $"현재 권한 모드: {mode}\n설명: {summary.Description}\n기본값: {summary.DefaultMode} · override: {summary.OverrideCount}개\n상위 override: {overrides}";
|
||||
}
|
||||
|
||||
private void OpenPermissionPanelFromSlash(string command, string usageText)
|
||||
{
|
||||
BtnPermission_Click(this, new RoutedEventArgs());
|
||||
AppendLocalSlashResult(_activeTab, command, $"권한 설정 팝업을 열었습니다. ({usageText})");
|
||||
}
|
||||
|
||||
// ──── 데이터 활용 수준 메뉴 ────
|
||||
|
||||
private void BtnDataUsage_Click(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
@@ -4901,9 +4944,15 @@ public partial class ChatWindow : Window
|
||||
var end = Math.Min(start + SlashPageSize, total);
|
||||
var totalSkills = _slashAllMatches.Count(x => x.IsSkill);
|
||||
var totalCommands = total - totalSkills;
|
||||
var expressionLevel = (_settings.Settings.Llm.AgentUiExpressionLevel ?? "balanced").Trim().ToLowerInvariant();
|
||||
|
||||
SlashPopupTitle.Text = "명령 팔레트";
|
||||
SlashPopupHint.Text = $"명령 {totalCommands}개 · 스킬 {totalSkills}개 · /compact 지원";
|
||||
SlashPopupHint.Text = expressionLevel switch
|
||||
{
|
||||
"simple" => $"명령 {totalCommands} · 스킬 {totalSkills}",
|
||||
"rich" => $"명령 {totalCommands}개 · 스킬 {totalSkills}개 · 추천: /compact /mcp /chrome",
|
||||
_ => $"명령 {totalCommands}개 · 스킬 {totalSkills}개",
|
||||
};
|
||||
|
||||
// 위 화살표
|
||||
if (start > 0)
|
||||
@@ -5523,7 +5572,7 @@ public partial class ChatWindow : Window
|
||||
("/doctor", "프로젝트/환경 점검 체크를 수행합니다."));
|
||||
|
||||
AddHelpSection(contentPanel, "연결/확장 명령어", "환경 연결, 플러그인, 에이전트 관련", fg, fg2, accent, itemBg, hoverBg,
|
||||
("/mcp", "외부 도구 연결 상태 점검"),
|
||||
("/mcp", "외부 도구 연결 상태 점검 및 add/remove/reset/login/logout 관리"),
|
||||
("/agents", "에이전트 분담 전략 제시"),
|
||||
("/plugin", "플러그인 구성 점검"),
|
||||
("/reload-plugins", "플러그인 재로드 점검"),
|
||||
@@ -5832,7 +5881,7 @@ public partial class ChatWindow : Window
|
||||
continue;
|
||||
}
|
||||
|
||||
using var client = new McpClientService(server);
|
||||
using var client = new McpClientService(BuildEffectiveMcpServer(server));
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(8));
|
||||
|
||||
@@ -5884,7 +5933,7 @@ public partial class ChatWindow : Window
|
||||
if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
using var client = new McpClientService(server);
|
||||
using var client = new McpClientService(BuildEffectiveMcpServer(server));
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(8));
|
||||
var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
@@ -6177,11 +6226,128 @@ public partial class ChatWindow : Window
|
||||
"enable" => ("enable", target),
|
||||
"disable" => ("disable", target),
|
||||
"reconnect" => ("reconnect", target),
|
||||
"add" => ("add", target),
|
||||
"remove" => ("remove", target),
|
||||
"reset" => ("reset", target),
|
||||
"login" => ("login", target),
|
||||
"logout" => ("logout", target),
|
||||
"status" => ("status", target),
|
||||
_ => ("help", text),
|
||||
};
|
||||
}
|
||||
|
||||
internal static (bool success, string serverTarget, string token, string error) ParseMcpLoginTarget(string target)
|
||||
{
|
||||
var raw = (target ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return (false, "", "", "사용법: /mcp login <서버명> <토큰>");
|
||||
|
||||
var firstSpace = raw.IndexOf(' ');
|
||||
if (firstSpace <= 0 || firstSpace >= raw.Length - 1)
|
||||
return (false, "", "", "토큰이 누락되었습니다. 사용법: /mcp login <서버명> <토큰>");
|
||||
|
||||
var serverTarget = raw[..firstSpace].Trim();
|
||||
var token = raw[(firstSpace + 1)..].Trim();
|
||||
if (string.IsNullOrWhiteSpace(serverTarget))
|
||||
return (false, "", "", "서버명이 비어 있습니다.");
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return (false, "", "", "토큰이 비어 있습니다.");
|
||||
|
||||
return (true, serverTarget, token, "");
|
||||
}
|
||||
|
||||
internal static (bool success, McpServerEntry? entry, string error) ParseMcpAddTarget(string target)
|
||||
{
|
||||
var raw = (target ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return (false, null, "사용법: /mcp add <서버명> :: stdio <명령> [인자...] | /mcp add <서버명> :: sse <URL>");
|
||||
|
||||
var sep = raw.IndexOf("::", StringComparison.Ordinal);
|
||||
if (sep < 0)
|
||||
return (false, null, "추가 형식이 올바르지 않습니다. 구분자 `::` 를 사용하세요.\n예: /mcp add chrome :: stdio node server.js");
|
||||
|
||||
var name = raw[..sep].Trim();
|
||||
var spec = raw[(sep + 2)..].Trim();
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
return (false, null, "서버명이 비어 있습니다. /mcp add <서버명> :: ... 형식으로 입력하세요.");
|
||||
if (string.IsNullOrWhiteSpace(spec))
|
||||
return (false, null, "연결 정보가 비어 있습니다. stdio 또는 sse 설정을 입력하세요.");
|
||||
|
||||
var tokens = TokenizeCommand(spec);
|
||||
if (tokens.Count < 2)
|
||||
return (false, null, "연결 정보가 부족합니다. 예: stdio node server.js 또는 sse https://host/sse");
|
||||
|
||||
var transport = tokens[0].Trim().ToLowerInvariant();
|
||||
if (transport == "stdio")
|
||||
{
|
||||
var command = tokens[1].Trim();
|
||||
if (string.IsNullOrWhiteSpace(command))
|
||||
return (false, null, "stdio 방식은 실행 명령(command)이 필요합니다.");
|
||||
|
||||
var args = tokens.Count > 2 ? tokens.Skip(2).ToList() : new List<string>();
|
||||
return (true, new McpServerEntry
|
||||
{
|
||||
Name = name,
|
||||
Transport = "stdio",
|
||||
Command = command,
|
||||
Args = args,
|
||||
Enabled = true,
|
||||
}, "");
|
||||
}
|
||||
|
||||
if (transport == "sse")
|
||||
{
|
||||
var url = string.Join(" ", tokens.Skip(1)).Trim();
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var parsed))
|
||||
return (false, null, $"유효하지 않은 SSE URL 입니다: {url}");
|
||||
if (!string.Equals(parsed.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.Equals(parsed.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
|
||||
return (false, null, "SSE URL은 http 또는 https 스킴만 지원합니다.");
|
||||
|
||||
return (true, new McpServerEntry
|
||||
{
|
||||
Name = name,
|
||||
Transport = "sse",
|
||||
Url = url,
|
||||
Enabled = true,
|
||||
}, "");
|
||||
}
|
||||
|
||||
return (false, null, $"지원하지 않는 transport 입니다: {transport}. 사용 가능 값: stdio, sse");
|
||||
}
|
||||
|
||||
internal static List<string> TokenizeCommand(string input)
|
||||
{
|
||||
var text = input ?? "";
|
||||
var tokens = new List<string>();
|
||||
var sb = new System.Text.StringBuilder();
|
||||
var inQuote = false;
|
||||
|
||||
foreach (var ch in text)
|
||||
{
|
||||
if (ch == '"')
|
||||
{
|
||||
inQuote = !inQuote;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!inQuote && char.IsWhiteSpace(ch))
|
||||
{
|
||||
if (sb.Length == 0) continue;
|
||||
tokens.Add(sb.ToString());
|
||||
sb.Clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
sb.Append(ch);
|
||||
}
|
||||
|
||||
if (sb.Length > 0)
|
||||
tokens.Add(sb.ToString());
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private async Task<string> BuildMcpRuntimeStatusTextAsync(
|
||||
IEnumerable<McpServerEntry>? source = null,
|
||||
bool runtimeCheck = true,
|
||||
@@ -6202,40 +6368,41 @@ public partial class ChatWindow : Window
|
||||
{
|
||||
var name = string.IsNullOrWhiteSpace(server.Name) ? "(이름 없음)" : server.Name;
|
||||
var transport = string.IsNullOrWhiteSpace(server.Transport) ? "stdio" : server.Transport.Trim().ToLowerInvariant();
|
||||
var authSuffix = _sessionMcpAuthTokens.ContainsKey(server.Name ?? "") ? " · Auth(Session)" : "";
|
||||
if (!IsMcpServerEnabled(server))
|
||||
{
|
||||
lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: false, transport, runtimeCheck, connected: false, toolCount: null)}");
|
||||
lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: false, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!runtimeCheck)
|
||||
{
|
||||
lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}");
|
||||
lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.Equals(transport, "stdio", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}");
|
||||
lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}");
|
||||
continue;
|
||||
}
|
||||
|
||||
using var client = new McpClientService(server);
|
||||
using var client = new McpClientService(BuildEffectiveMcpServer(server));
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(8));
|
||||
var connected = await client.ConnectAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
if (!connected)
|
||||
{
|
||||
lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}");
|
||||
lines.Add($"- {name} [{transport}] : {ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: false, toolCount: null)}{authSuffix}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var toolCount = client.Tools.Count;
|
||||
var statusLabel = ResolveMcpDisplayStatus(isEnabled: true, transport, runtimeCheck, connected: true, toolCount);
|
||||
lines.Add($"- {name} [{transport}] : {statusLabel}");
|
||||
lines.Add($"- {name} [{transport}] : {statusLabel}{authSuffix}");
|
||||
}
|
||||
|
||||
lines.Add("명령: /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all> (enable/disable는 세션 한정)");
|
||||
lines.Add("명령: /mcp status | enable|disable <서버명|all> | reconnect <서버명|all> | add <서버명> :: stdio|sse ... | remove <서버명|all> | reset | login <서버명> <토큰> | logout <서버명|all>");
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
|
||||
@@ -6272,20 +6439,126 @@ public partial class ChatWindow : Window
|
||||
return partial?.Name ?? "";
|
||||
}
|
||||
|
||||
private McpServerEntry BuildEffectiveMcpServer(McpServerEntry server)
|
||||
{
|
||||
var clone = new McpServerEntry
|
||||
{
|
||||
Name = server.Name,
|
||||
Command = server.Command,
|
||||
Args = server.Args?.ToList() ?? new List<string>(),
|
||||
Env = new Dictionary<string, string>(server.Env ?? new Dictionary<string, string>(), StringComparer.OrdinalIgnoreCase),
|
||||
Enabled = server.Enabled,
|
||||
Transport = server.Transport,
|
||||
Url = server.Url,
|
||||
};
|
||||
|
||||
var key = clone.Name ?? "";
|
||||
if (_sessionMcpAuthTokens.TryGetValue(key, out var token) && !string.IsNullOrWhiteSpace(token))
|
||||
clone.Env["MCP_AUTH_TOKEN"] = token;
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
private async Task<string> HandleMcpSlashAsync(string displayText, CancellationToken ct = default)
|
||||
{
|
||||
var llm = _settings.Settings.Llm;
|
||||
var servers = llm.McpServers ?? [];
|
||||
llm.McpServers ??= new List<McpServerEntry>();
|
||||
var servers = llm.McpServers;
|
||||
var (action, target) = ParseMcpAction(displayText);
|
||||
if (action == "help")
|
||||
return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>";
|
||||
return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>, /mcp add <서버명> :: stdio <명령> [인자...] | sse <URL>, /mcp remove <서버명|all>, /mcp reset, /mcp login <서버명> <토큰>, /mcp logout <서버명|all>";
|
||||
|
||||
if (action == "status")
|
||||
return await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: true, ct).ConfigureAwait(false);
|
||||
|
||||
if (action == "reset")
|
||||
{
|
||||
var changed = _sessionMcpEnabledOverrides.Count;
|
||||
_sessionMcpEnabledOverrides.Clear();
|
||||
_sessionMcpAuthTokens.Clear();
|
||||
return $"세션 MCP 오버라이드를 초기화했습니다. ({changed}개 해제)\n" +
|
||||
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (action == "login")
|
||||
{
|
||||
var (ok, serverTarget, token, error) = ParseMcpLoginTarget(target);
|
||||
if (!ok)
|
||||
return error;
|
||||
|
||||
var resolved = ResolveMcpServerName(servers, serverTarget);
|
||||
if (string.IsNullOrWhiteSpace(resolved))
|
||||
return $"로그인 대상 서버를 찾지 못했습니다: {serverTarget}";
|
||||
|
||||
_sessionMcpAuthTokens[resolved] = token;
|
||||
return $"MCP 세션 토큰을 설정했습니다: {resolved}\n" +
|
||||
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: true, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (action == "logout")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var removed = _sessionMcpAuthTokens.Count;
|
||||
_sessionMcpAuthTokens.Clear();
|
||||
return $"모든 MCP 세션 토큰을 제거했습니다. ({removed}개)\n" +
|
||||
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var resolved = ResolveMcpServerName(servers, target);
|
||||
if (string.IsNullOrWhiteSpace(resolved))
|
||||
return $"로그아웃 대상 서버를 찾지 못했습니다: {target}";
|
||||
|
||||
var removedOne = _sessionMcpAuthTokens.Remove(resolved);
|
||||
return $"MCP 세션 토큰 제거: {resolved} ({(removedOne ? 1 : 0)}개)\n" +
|
||||
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (action == "add")
|
||||
{
|
||||
var (ok, entry, error) = ParseMcpAddTarget(target);
|
||||
if (!ok || entry == null)
|
||||
return error;
|
||||
|
||||
var duplicate = servers.Any(s => string.Equals(s.Name, entry.Name, StringComparison.OrdinalIgnoreCase));
|
||||
if (duplicate)
|
||||
return $"동일한 이름의 MCP 서버가 이미 존재합니다: {entry.Name}\n기존 항목을 수정하거나 /mcp remove {entry.Name} 후 다시 추가하세요.";
|
||||
|
||||
servers.Add(entry);
|
||||
_settings.Save();
|
||||
_sessionMcpEnabledOverrides.Remove(entry.Name);
|
||||
|
||||
return $"MCP 서버를 추가했습니다: {entry.Name} ({entry.Transport})\n" +
|
||||
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (servers.Count == 0)
|
||||
return "MCP 서버가 없습니다. 설정에서 서버를 추가한 뒤 다시 시도하세요.";
|
||||
|
||||
if (action == "remove")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(target) || string.Equals(target, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var removed = servers.Count;
|
||||
servers.Clear();
|
||||
_sessionMcpEnabledOverrides.Clear();
|
||||
_sessionMcpAuthTokens.Clear();
|
||||
_settings.Save();
|
||||
return $"MCP 서버를 모두 제거했습니다. ({removed}개)";
|
||||
}
|
||||
|
||||
var resolved = ResolveMcpServerName(servers, target);
|
||||
if (string.IsNullOrWhiteSpace(resolved))
|
||||
return $"제거 대상 서버를 찾지 못했습니다: {target}";
|
||||
|
||||
var removedCount = servers.RemoveAll(s => string.Equals(s.Name, resolved, StringComparison.OrdinalIgnoreCase));
|
||||
_sessionMcpEnabledOverrides.Remove(resolved);
|
||||
_sessionMcpAuthTokens.Remove(resolved);
|
||||
_settings.Save();
|
||||
return $"MCP 서버 제거 완료: {resolved} ({removedCount}개)\n" +
|
||||
await BuildMcpRuntimeStatusTextAsync(servers, runtimeCheck: false, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (action is "enable" or "disable")
|
||||
{
|
||||
var newEnabled = action == "enable";
|
||||
@@ -6335,7 +6608,7 @@ public partial class ChatWindow : Window
|
||||
return await BuildMcpRuntimeStatusTextAsync(targetServers, runtimeCheck: true, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>";
|
||||
return "사용법: /mcp, /mcp status, /mcp enable|disable <서버명|all>, /mcp reconnect <서버명|all>, /mcp add <서버명> :: stdio <명령> [인자...] | sse <URL>, /mcp remove <서버명|all>, /mcp reset, /mcp login <서버명> <토큰>, /mcp logout <서버명|all>";
|
||||
}
|
||||
|
||||
private string BuildSlashHeapDumpText()
|
||||
@@ -6526,39 +6799,37 @@ public partial class ChatWindow : Window
|
||||
if (string.Equals(slashSystem, "__PERMISSIONS__", StringComparison.Ordinal))
|
||||
{
|
||||
var (permAction, _) = ParseGenericAction(displayText ?? "", "/permissions");
|
||||
if (permAction is "ask" or "auto" or "deny")
|
||||
if (TryApplyPermissionModeFromAction(permAction, out var appliedMode))
|
||||
{
|
||||
var next = permAction switch
|
||||
{
|
||||
"ask" => PermissionModeCatalog.Ask,
|
||||
"auto" => PermissionModeCatalog.Auto,
|
||||
_ => PermissionModeCatalog.Deny,
|
||||
};
|
||||
_settings.Settings.Llm.FilePermission = next;
|
||||
_settings.Save();
|
||||
_appState.LoadFromSettings(_settings);
|
||||
UpdatePermissionUI();
|
||||
SaveConversationSettings();
|
||||
RefreshInlineSettingsPanel();
|
||||
AppendLocalSlashResult(_activeTab, "/permissions", $"권한 모드를 {next}로 변경했습니다.");
|
||||
AppendLocalSlashResult(_activeTab, "/permissions", $"권한 모드를 {appliedMode}로 변경했습니다.\n{BuildPermissionStatusText()}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (permAction == "status")
|
||||
{
|
||||
var mode = PermissionModeCatalog.NormalizeGlobalMode(_settings.Settings.Llm.FilePermission);
|
||||
AppendLocalSlashResult(_activeTab, "/permissions", $"현재 권한 모드: {mode}\n사용법: /permissions ask|auto|deny|status");
|
||||
AppendLocalSlashResult(_activeTab, "/permissions", $"{BuildPermissionStatusText()}\n사용법: /permissions ask|auto|deny|status");
|
||||
return;
|
||||
}
|
||||
|
||||
BtnPermission_Click(this, new RoutedEventArgs());
|
||||
AppendLocalSlashResult(_activeTab, "/permissions", "권한 설정 팝업을 열었습니다. (사용법: /permissions ask|auto|deny|status)");
|
||||
OpenPermissionPanelFromSlash("/permissions", "사용법: /permissions ask|auto|deny|status");
|
||||
return;
|
||||
}
|
||||
if (string.Equals(slashSystem, "__ALLOWED_TOOLS__", StringComparison.Ordinal))
|
||||
{
|
||||
BtnPermission_Click(this, new RoutedEventArgs());
|
||||
AppendLocalSlashResult(_activeTab, "/allowed-tools", "허용 도구(권한) 설정 팝업을 열었습니다.");
|
||||
var (toolAction, _) = ParseGenericAction(displayText ?? "", "/allowed-tools");
|
||||
if (TryApplyPermissionModeFromAction(toolAction, out var allowedMode))
|
||||
{
|
||||
AppendLocalSlashResult(_activeTab, "/allowed-tools", $"권한 모드를 {allowedMode}로 변경했습니다.\n{BuildPermissionStatusText()}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolAction == "status")
|
||||
{
|
||||
AppendLocalSlashResult(_activeTab, "/allowed-tools", $"{BuildPermissionStatusText()}\n사용법: /allowed-tools ask|auto|deny|status");
|
||||
return;
|
||||
}
|
||||
|
||||
OpenPermissionPanelFromSlash("/allowed-tools", "사용법: /allowed-tools ask|auto|deny|status");
|
||||
return;
|
||||
}
|
||||
if (string.Equals(slashSystem, "__MODEL__", StringComparison.Ordinal))
|
||||
@@ -6579,8 +6850,7 @@ public partial class ChatWindow : Window
|
||||
|
||||
if (settingsAction == "permissions")
|
||||
{
|
||||
BtnPermission_Click(this, new RoutedEventArgs());
|
||||
AppendLocalSlashResult(_activeTab, "/settings", "권한 설정 팝업을 열었습니다.");
|
||||
OpenPermissionPanelFromSlash("/settings", "사용법: /settings permissions");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7829,7 +8099,15 @@ public partial class ChatWindow : Window
|
||||
|| BtnQuickRunningFilter == null || BtnQuickFailedFilter == null || BtnQuickHotSort == null)
|
||||
return;
|
||||
|
||||
ConversationQuickStrip.Visibility = (_runningConversationCount > 0 || _failedConversationCount > 0 || _spotlightConversationCount > 0)
|
||||
var showFailureFilter = GetAgentUiExpressionLevel() == "rich";
|
||||
if (BtnQuickFailedFilter != null)
|
||||
BtnQuickFailedFilter.Visibility = showFailureFilter ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
var hasQuickSignal = _runningConversationCount > 0
|
||||
|| _spotlightConversationCount > 0
|
||||
|| (showFailureFilter && _failedConversationCount > 0);
|
||||
|
||||
ConversationQuickStrip.Visibility = hasQuickSignal
|
||||
? Visibility.Visible
|
||||
: Visibility.Collapsed;
|
||||
|
||||
@@ -7842,9 +8120,12 @@ public partial class ChatWindow : Window
|
||||
BtnQuickRunningFilter.BorderThickness = new Thickness(1);
|
||||
QuickRunningLabel.Foreground = _runningOnlyFilter ? BrushFromHex("#1D4ED8") : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
|
||||
|
||||
BtnQuickFailedFilter.Background = _failedOnlyFilter ? BrushFromHex("#FEF2F2") : BrushFromHex("#F8FAFC");
|
||||
BtnQuickFailedFilter.BorderBrush = _failedOnlyFilter ? BrushFromHex("#FCA5A5") : BrushFromHex("#E5E7EB");
|
||||
BtnQuickFailedFilter.BorderThickness = new Thickness(1);
|
||||
if (BtnQuickFailedFilter != null)
|
||||
{
|
||||
BtnQuickFailedFilter.Background = _failedOnlyFilter ? BrushFromHex("#FEF2F2") : BrushFromHex("#F8FAFC");
|
||||
BtnQuickFailedFilter.BorderBrush = _failedOnlyFilter ? BrushFromHex("#FCA5A5") : BrushFromHex("#E5E7EB");
|
||||
BtnQuickFailedFilter.BorderThickness = new Thickness(1);
|
||||
}
|
||||
QuickFailedLabel.Foreground = _failedOnlyFilter ? BrushFromHex("#991B1B") : (TryFindResource("SecondaryText") as Brush ?? Brushes.Gray);
|
||||
|
||||
BtnQuickHotSort.Background = !_sortConversationsByRecent ? BrushFromHex("#F5F3FF") : BrushFromHex("#F8FAFC");
|
||||
|
||||
Reference in New Issue
Block a user