feat: MCP-Tools ask_user_choice + memory_read/write/list, Text-Marker entfernt
MCP-Server erweitert um ask_user_choice (Long-Poll), memory_read, memory_write und memory_list Tools. MCP-Config wird jetzt immer generiert (nicht nur bei Remote-Verbindung). InternalEndpoint um PendingChoices + choice-respond Endpoint ergänzt. Frontend differenziert CLI-Modus (choice-respond API) vs. API-Modus. Gesamter :::ASK_USER_CHOICE::: Text-Marker-Mechanismus aus ClaudeCliService, AnthropicService, markdown.js und stream-ui.js entfernt. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
3b068a7866
commit
3e0a69e7a4
10 files changed
+493
-319
No files matched your search
@@ -9,6 +9,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Gott.Endpoints;
|
||||
using Gott.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
@@ -1737,7 +1738,7 @@ public class AnthropicService
|
||||
/// Generiert eine temporäre MCP-Config-Datei für die Claude CLI.
|
||||
/// Enthält den MCP-Server für remote_execute.
|
||||
/// </summary>
|
||||
private string GenerateMcpConfig(string sessionId)
|
||||
private string GenerateMcpConfig(string sessionId, string agentId = null, bool hasRemote = false)
|
||||
{
|
||||
// Port aus Konfiguration oder Default
|
||||
var port = 19000;
|
||||
@@ -1746,12 +1747,20 @@ public class AnthropicService
|
||||
// Fallback: Quell-Verzeichnis prüfen (für Dev-Modus)
|
||||
if (!File.Exists(scriptPath))
|
||||
{
|
||||
// wwwroot ist typischerweise /app/gott/wwwroot → eine Ebene hoch = Projekt-Root
|
||||
var projectRoot = Path.GetDirectoryName(AppPaths.WwwRoot) ?? AppContext.BaseDirectory;
|
||||
var srcPath = Path.Combine(projectRoot, "scripts", "mcp-remote-execute.py");
|
||||
if (File.Exists(srcPath)) scriptPath = srcPath;
|
||||
}
|
||||
|
||||
var env = new Dictionary<string, string>
|
||||
{
|
||||
["GOTT_API_URL"] = $"http://localhost:{port}",
|
||||
["GOTT_SESSION_ID"] = sessionId ?? "",
|
||||
["GOTT_AUTH_TOKEN"] = Endpoints.InternalEndpoint.InternalToken,
|
||||
["GOTT_AGENT_ID"] = agentId ?? "",
|
||||
["GOTT_HAS_REMOTE"] = hasRemote ? "true" : "false"
|
||||
};
|
||||
|
||||
var config = new
|
||||
{
|
||||
mcpServers = new Dictionary<string, object>
|
||||
@@ -1760,12 +1769,7 @@ public class AnthropicService
|
||||
{
|
||||
command = "python3",
|
||||
args = new[] { scriptPath },
|
||||
env = new Dictionary<string, string>
|
||||
{
|
||||
["GOTT_API_URL"] = $"http://localhost:{port}",
|
||||
["GOTT_SESSION_ID"] = sessionId,
|
||||
["GOTT_AUTH_TOKEN"] = Endpoints.InternalEndpoint.InternalToken
|
||||
}
|
||||
env
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1929,6 +1933,7 @@ public class AnthropicService
|
||||
bool planMode = false,
|
||||
bool webSearch = false,
|
||||
string sessionId = null,
|
||||
string agentId = null,
|
||||
RemoteConnectionManager remoteMgr = null,
|
||||
CancellationToken workCt = default,
|
||||
CancellationToken sseCt = default)
|
||||
@@ -1955,11 +1960,9 @@ public class AnthropicService
|
||||
|
||||
try
|
||||
{
|
||||
// MCP-Config für remote_execute generieren (wenn Remote-Verbindung vorhanden)
|
||||
if (remoteMgr != null && !string.IsNullOrEmpty(sessionId) && remoteMgr.GetBySessionId(sessionId) != null)
|
||||
{
|
||||
mcpConfigPath = GenerateMcpConfig(sessionId);
|
||||
}
|
||||
// MCP-Config generieren (immer – für ask_user_choice, optional Memory-Tools + remote_execute)
|
||||
var hasRemote = remoteMgr != null && !string.IsNullOrEmpty(sessionId) && remoteMgr.GetBySessionId(sessionId) != null;
|
||||
mcpConfigPath = GenerateMcpConfig(sessionId, agentId, hasRemote);
|
||||
|
||||
// System-Prompt als Text extrahieren
|
||||
var systemText = new StringBuilder();
|
||||
@@ -1974,19 +1977,6 @@ public class AnthropicService
|
||||
}
|
||||
}
|
||||
|
||||
// ask_user_choice Instruktion anhängen
|
||||
if (allowUserChoice)
|
||||
{
|
||||
systemText.AppendLine();
|
||||
systemText.AppendLine(@"## Rückfragen
|
||||
Wenn du dem User eine Frage mit Auswahlmöglichkeiten stellen möchtest (2-5 Optionen), gib EXAKT diesen Block aus, auf eigenen Zeilen, ohne weiteren Text drumherum:
|
||||
|
||||
:::ASK_USER_CHOICE:::
|
||||
{""question"": ""Deine Frage"", ""choices"": [""Option A"", ""Option B""]}
|
||||
:::END_ASK_USER_CHOICE:::
|
||||
|
||||
Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die Entscheidung wirklich vom User getroffen werden muss.");
|
||||
}
|
||||
|
||||
// Letzte User-Message als Prompt extrahieren
|
||||
var prompt = ExtractLastUserText(messages) ?? "";
|
||||
@@ -2060,6 +2050,36 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En
|
||||
Content = mappedInput
|
||||
});
|
||||
await SseHelper.TryWriteSse(response, "tool_end", new { name = mappedName, id = evt.ToolId, input = mappedInput }, sseCt);
|
||||
|
||||
// ask_user_choice via MCP: TCS erstellen + SSE user_choice Event
|
||||
if (mappedName == "ask_user_choice")
|
||||
{
|
||||
try
|
||||
{
|
||||
var choiceInput = JsonSerializer.Deserialize<JsonElement>(mappedInput);
|
||||
var cQuestion = choiceInput.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
|
||||
var cChoices = new List<string>();
|
||||
if (choiceInput.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array)
|
||||
foreach (var cOpt in cc.EnumerateArray())
|
||||
cChoices.Add(cOpt.GetString() ?? "");
|
||||
|
||||
// TCS erstellen bevor der MCP-Server den Endpoint aufruft
|
||||
InternalEndpoint.PendingChoices[sessionId] = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
// SSE Event mit viaCli-Flag → Frontend zeigt Buttons + nutzt choice-respond Endpoint
|
||||
choiceToolCallId = evt.ToolId;
|
||||
await SseHelper.TryWriteSse(response, "user_choice", new
|
||||
{
|
||||
toolCallId = evt.ToolId,
|
||||
question = cQuestion,
|
||||
choices = cChoices,
|
||||
viaCli = true
|
||||
}, sseCt);
|
||||
|
||||
isUserChoice = true;
|
||||
}
|
||||
catch (JsonException) { /* Input nicht parsbar – MCP-Server gibt Fehler */ }
|
||||
}
|
||||
break;
|
||||
}
|
||||
case CliEventType.ToolResult:
|
||||
@@ -2085,43 +2105,6 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En
|
||||
}, sseCt);
|
||||
break;
|
||||
}
|
||||
case CliEventType.UserChoice:
|
||||
// Text bisher in DB speichern
|
||||
if (fullText.Length > 0)
|
||||
{
|
||||
await _db.SaveMessage(new MessageRecord
|
||||
{
|
||||
ChainId = chainId,
|
||||
Iteration = 1,
|
||||
Role = "assistant",
|
||||
Type = "text",
|
||||
Content = fullText.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
// Synthetische tool_use speichern (für History-Rebuild)
|
||||
choiceToolCallId = $"toolu_cli_{Guid.NewGuid():N}";
|
||||
await _db.SaveMessage(new MessageRecord
|
||||
{
|
||||
ChainId = chainId,
|
||||
Iteration = 1,
|
||||
Role = "assistant",
|
||||
Type = "tool_use",
|
||||
ToolName = "ask_user_choice",
|
||||
ToolCallId = choiceToolCallId,
|
||||
Content = JsonSerializer.Serialize(new { question = evt.ChoiceQuestion, choices = evt.ChoiceOptions })
|
||||
});
|
||||
|
||||
// SSE user_choice Event (Frontend zeigt Buttons)
|
||||
await SseHelper.TryWriteSse(response, "user_choice", new
|
||||
{
|
||||
toolCallId = choiceToolCallId,
|
||||
question = evt.ChoiceQuestion,
|
||||
choices = evt.ChoiceOptions
|
||||
}, sseCt);
|
||||
|
||||
isUserChoice = true;
|
||||
break;
|
||||
}
|
||||
},
|
||||
modelId: modelId,
|
||||
|
||||
+16
-120
@@ -113,7 +113,14 @@ public class ClaudeCliService
|
||||
}
|
||||
if (webSearch) { tools.Add("WebFetch"); tools.Add("WebSearch"); }
|
||||
// MCP-Tools erlauben (wenn MCP-Config vorhanden)
|
||||
if (!string.IsNullOrEmpty(mcpConfigPath)) { tools.Add("mcp__gott-remote__remote_execute"); }
|
||||
if (!string.IsNullOrEmpty(mcpConfigPath))
|
||||
{
|
||||
tools.Add("mcp__gott-remote__remote_execute");
|
||||
tools.Add("mcp__gott-remote__ask_user_choice");
|
||||
tools.Add("mcp__gott-remote__memory_read");
|
||||
tools.Add("mcp__gott-remote__memory_write");
|
||||
tools.Add("mcp__gott-remote__memory_list");
|
||||
}
|
||||
args.Add("--allowed-tools");
|
||||
args.Add(tools.Count > 0 ? string.Join(" ", tools) : "\"\"");
|
||||
|
||||
@@ -161,11 +168,7 @@ public class ClaudeCliService
|
||||
var currentToolInput = new StringBuilder();
|
||||
bool inTextBlock = false;
|
||||
|
||||
// Tracking für ask_user_choice Marker-Erkennung
|
||||
var lineBuffer = new StringBuilder();
|
||||
bool inChoiceCapture = false;
|
||||
var choiceJsonBuffer = new StringBuilder();
|
||||
bool userChoiceDetected = false;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -286,9 +289,8 @@ public class ClaudeCliService
|
||||
if (deltaType == "text_delta" && delta.TryGetProperty("text", out var txt))
|
||||
{
|
||||
var text = txt.GetString();
|
||||
if (!string.IsNullOrEmpty(text) && !userChoiceDetected)
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
// Zeilenbasierte Marker-Erkennung für :::ASK_USER_CHOICE:::
|
||||
for (int ci = 0; ci < text.Length; ci++)
|
||||
{
|
||||
var ch = text[ci];
|
||||
@@ -297,52 +299,8 @@ public class ClaudeCliService
|
||||
var completedLine = lineBuffer.ToString().Trim();
|
||||
lineBuffer.Clear();
|
||||
|
||||
if (inChoiceCapture)
|
||||
if (completedLine.Length > 0 || fullOutput.Length > 0)
|
||||
{
|
||||
if (completedLine == ":::END_ASK_USER_CHOICE:::")
|
||||
{
|
||||
// JSON parsen und UserChoice-Event emittieren
|
||||
try
|
||||
{
|
||||
var choiceJson = JsonSerializer.Deserialize<JsonElement>(choiceJsonBuffer.ToString());
|
||||
var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
|
||||
var choices = new List<string>();
|
||||
if (choiceJson.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array)
|
||||
foreach (var cOpt in cc.EnumerateArray())
|
||||
choices.Add(cOpt.GetString() ?? "");
|
||||
|
||||
userChoiceDetected = true;
|
||||
await onEvent(new CliStreamEvent
|
||||
{
|
||||
Type = CliEventType.UserChoice,
|
||||
ChoiceQuestion = question,
|
||||
ChoiceOptions = choices
|
||||
});
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Fehlerhaftes JSON → als normalen Text weiterleiten
|
||||
var fallback = $":::ASK_USER_CHOICE:::\n{choiceJsonBuffer}\n:::END_ASK_USER_CHOICE:::\n";
|
||||
fullOutput.Append(fallback);
|
||||
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = fallback });
|
||||
}
|
||||
inChoiceCapture = false;
|
||||
choiceJsonBuffer.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
choiceJsonBuffer.AppendLine(completedLine);
|
||||
}
|
||||
}
|
||||
else if (completedLine == ":::ASK_USER_CHOICE:::")
|
||||
{
|
||||
inChoiceCapture = true;
|
||||
choiceJsonBuffer.Clear();
|
||||
}
|
||||
else if (completedLine.Length > 0 || fullOutput.Length > 0)
|
||||
{
|
||||
// Normale Zeile → Text weiterleiten (inkl. Newline)
|
||||
// Leere Zeilen am Anfang überspringen (vor dem eigentlichen Text)
|
||||
var lineText = completedLine + "\n";
|
||||
fullOutput.Append(lineText);
|
||||
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = lineText });
|
||||
@@ -401,41 +359,12 @@ public class ClaudeCliService
|
||||
else if (type == "result")
|
||||
{
|
||||
// Restlichen lineBuffer flushen (nicht-abgeschlossene Zeile)
|
||||
if (lineBuffer.Length > 0 && !userChoiceDetected)
|
||||
if (lineBuffer.Length > 0)
|
||||
{
|
||||
var remaining = lineBuffer.ToString().Trim();
|
||||
lineBuffer.Clear();
|
||||
|
||||
if (inChoiceCapture && remaining == ":::END_ASK_USER_CHOICE:::")
|
||||
{
|
||||
// End-Marker ohne trailing \n → Capture abschließen
|
||||
try
|
||||
{
|
||||
var choiceJson = JsonSerializer.Deserialize<JsonElement>(choiceJsonBuffer.ToString());
|
||||
var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
|
||||
var choices = new List<string>();
|
||||
if (choiceJson.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array)
|
||||
foreach (var cOpt in cc.EnumerateArray())
|
||||
choices.Add(cOpt.GetString() ?? "");
|
||||
|
||||
userChoiceDetected = true;
|
||||
await onEvent(new CliStreamEvent
|
||||
{
|
||||
Type = CliEventType.UserChoice,
|
||||
ChoiceQuestion = question,
|
||||
ChoiceOptions = choices
|
||||
});
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
var fallback = $":::ASK_USER_CHOICE:::\n{choiceJsonBuffer}\n{remaining}\n";
|
||||
fullOutput.Append(fallback);
|
||||
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = fallback });
|
||||
}
|
||||
inChoiceCapture = false;
|
||||
choiceJsonBuffer.Clear();
|
||||
}
|
||||
else if (!inChoiceCapture)
|
||||
if (remaining.Length > 0)
|
||||
{
|
||||
fullOutput.Append(remaining);
|
||||
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining });
|
||||
@@ -519,40 +448,12 @@ public class ClaudeCliService
|
||||
}
|
||||
|
||||
// Restlichen lineBuffer flushen
|
||||
if (lineBuffer.Length > 0 && !userChoiceDetected)
|
||||
if (lineBuffer.Length > 0)
|
||||
{
|
||||
var remaining = lineBuffer.ToString().Trim();
|
||||
lineBuffer.Clear();
|
||||
|
||||
if (inChoiceCapture && remaining == ":::END_ASK_USER_CHOICE:::")
|
||||
{
|
||||
try
|
||||
{
|
||||
var choiceJson = JsonSerializer.Deserialize<JsonElement>(choiceJsonBuffer.ToString());
|
||||
var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
|
||||
var choices = new List<string>();
|
||||
if (choiceJson.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array)
|
||||
foreach (var cOpt in cc.EnumerateArray())
|
||||
choices.Add(cOpt.GetString() ?? "");
|
||||
|
||||
userChoiceDetected = true;
|
||||
await onEvent(new CliStreamEvent
|
||||
{
|
||||
Type = CliEventType.UserChoice,
|
||||
ChoiceQuestion = question,
|
||||
ChoiceOptions = choices
|
||||
});
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
var fallback = $":::ASK_USER_CHOICE:::\n{choiceJsonBuffer}\n{remaining}\n";
|
||||
fullOutput.Append(fallback);
|
||||
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = fallback });
|
||||
}
|
||||
inChoiceCapture = false;
|
||||
choiceJsonBuffer.Clear();
|
||||
}
|
||||
else if (!inChoiceCapture)
|
||||
if (remaining.Length > 0)
|
||||
{
|
||||
fullOutput.Append(remaining);
|
||||
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining });
|
||||
@@ -578,8 +479,7 @@ public class ClaudeCliService
|
||||
|
||||
result.ExitCode = process.ExitCode;
|
||||
result.Output = fullOutput.ToString().Trim();
|
||||
result.Success = process.ExitCode == 0 && (fullOutput.Length > 0 || userChoiceDetected);
|
||||
result.UserChoiceDetected = userChoiceDetected;
|
||||
result.Success = process.ExitCode == 0 && fullOutput.Length > 0;
|
||||
|
||||
if (!result.Success && string.IsNullOrEmpty(result.Error))
|
||||
result.Error = error.Length > 0 ? error.ToString().Trim() : $"CLI exit code: {process.ExitCode}";
|
||||
@@ -696,8 +596,7 @@ public enum CliEventType
|
||||
TextEnd,
|
||||
ToolStart,
|
||||
ToolEnd,
|
||||
ToolResult,
|
||||
UserChoice
|
||||
ToolResult
|
||||
}
|
||||
|
||||
public class CliStreamEvent
|
||||
@@ -709,8 +608,6 @@ public class CliStreamEvent
|
||||
public string ToolInput { get; set; }
|
||||
public string ToolResultContent { get; set; }
|
||||
public bool ToolResultIsError { get; set; }
|
||||
public string ChoiceQuestion { get; set; }
|
||||
public List<string> ChoiceOptions { get; set; }
|
||||
}
|
||||
|
||||
public class CliChatResult
|
||||
@@ -727,7 +624,6 @@ public class CliChatResult
|
||||
public long DurationApiMs { get; set; }
|
||||
public string SessionId { get; set; }
|
||||
public int NumTurns { get; set; }
|
||||
public bool UserChoiceDetected { get; set; }
|
||||
}
|
||||
|
||||
public class CliStatusResult
|
||||
|
||||
Reference in new issue
Block a user