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
@@ -245,7 +245,7 @@ public static class ChatEndpoint
|
|||||||
{
|
{
|
||||||
if (anthropic.IsCliAvailable)
|
if (anthropic.IsCliAvailable)
|
||||||
{
|
{
|
||||||
await anthropic.StreamChatViaCli(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, workspace: ctx.Workspace, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: body.PlanMode, webSearch: !string.IsNullOrEmpty(ctx.WebSearchVersion), sessionId: sessionId, remoteMgr: remoteMgr, workCt: workCt, sseCt: sseCt);
|
await anthropic.StreamChatViaCli(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, workspace: ctx.Workspace, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: body.PlanMode, webSearch: !string.IsNullOrEmpty(ctx.WebSearchVersion), sessionId: sessionId, agentId: ctx.AgentId, remoteMgr: remoteMgr, workCt: workCt, sseCt: sseCt);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -19,11 +21,22 @@ public static class InternalEndpoint
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Interner Auth-Token – wird beim Start generiert und an MCP-Server übergeben.
|
/// Interner Auth-Token – wird beim Start generiert und an MCP-Server übergeben.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string InternalToken { get; } = System.Guid.NewGuid().ToString("N");
|
public static string InternalToken { get; } = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pending user choices – Key ist sessionId, Value wird completed wenn der User klickt.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly ConcurrentDictionary<string, TaskCompletionSource<string>> PendingChoices = new();
|
||||||
|
|
||||||
public static void MapInternalEndpoints(this IEndpointRouteBuilder app)
|
public static void MapInternalEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
app.MapPost("/api/internal/remote-execute", HandleRemoteExecute);
|
app.MapPost("/api/internal/remote-execute", HandleRemoteExecute);
|
||||||
|
app.MapPost("/api/internal/ask-user-choice", HandleAskUserChoice);
|
||||||
|
// choice-respond ist NICHT unter /api/internal/ weil es vom Frontend aufgerufen wird (normale Auth)
|
||||||
|
app.MapPost("/api/chat/choice-respond", HandleChoiceRespond);
|
||||||
|
app.MapPost("/api/internal/memory-read", HandleMemoryRead);
|
||||||
|
app.MapPost("/api/internal/memory-write", HandleMemoryWrite);
|
||||||
|
app.MapPost("/api/internal/memory-list", HandleMemoryList);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> HandleRemoteExecute(HttpContext ctx, RemoteConnectionManager mgr)
|
private static async Task<IResult> HandleRemoteExecute(HttpContext ctx, RemoteConnectionManager mgr)
|
||||||
@@ -70,4 +83,194 @@ public static class InternalEndpoint
|
|||||||
return Results.Json(new { error = ex.Message, stackTrace = ex.StackTrace }, statusCode: 500);
|
return Results.Json(new { error = ex.Message, stackTrace = ex.StackTrace }, statusCode: 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MCP-Server ruft diesen Endpoint auf und blockiert bis der User eine Auswahl trifft.
|
||||||
|
/// Der TCS wird vorher von AnthropicService erstellt (beim tool_use Event).
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<IResult> HandleAskUserChoice(HttpContext ctx)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var denied = CheckToken(ctx);
|
||||||
|
if (denied != null) return denied;
|
||||||
|
|
||||||
|
using var reader = new StreamReader(ctx.Request.Body);
|
||||||
|
var body = await reader.ReadToEndAsync();
|
||||||
|
JsonElement json;
|
||||||
|
try { json = JsonSerializer.Deserialize<JsonElement>(body); }
|
||||||
|
catch { return Results.BadRequest(new { error = "Invalid JSON" }); }
|
||||||
|
|
||||||
|
var sessionId = json.TryGetProperty("sessionId", out var sid) ? sid.GetString() ?? "" : "";
|
||||||
|
if (string.IsNullOrEmpty(sessionId))
|
||||||
|
return Results.BadRequest(new { error = "sessionId erforderlich" });
|
||||||
|
|
||||||
|
// Warte auf den TCS der von AnthropicService erstellt wurde
|
||||||
|
if (!PendingChoices.TryGetValue(sessionId, out var tcs))
|
||||||
|
{
|
||||||
|
// TCS noch nicht erstellt (Race Condition) – kurz warten und nochmal probieren
|
||||||
|
for (int i = 0; i < 10; i++)
|
||||||
|
{
|
||||||
|
await Task.Delay(500, ctx.RequestAborted);
|
||||||
|
if (PendingChoices.TryGetValue(sessionId, out tcs)) break;
|
||||||
|
}
|
||||||
|
if (tcs == null)
|
||||||
|
return Results.Json(new { error = "Keine ausstehende Auswahl für diese Session" }, statusCode: 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blockieren bis User antwortet (max 5 Minuten)
|
||||||
|
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ctx.RequestAborted);
|
||||||
|
cts.CancelAfter(TimeSpan.FromMinutes(5));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var choice = await tcs.Task.WaitAsync(cts.Token);
|
||||||
|
PendingChoices.TryRemove(sessionId, out _);
|
||||||
|
return Results.Ok(new { choice });
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
PendingChoices.TryRemove(sessionId, out _);
|
||||||
|
return Results.Json(new { error = "Timeout – keine Auswahl getroffen" }, statusCode: 408);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Results.Json(new { error = ex.Message }, statusCode: 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Frontend ruft diesen Endpoint auf wenn der User einen Choice-Button klickt (CLI-Modus).
|
||||||
|
/// </summary>
|
||||||
|
private static async Task<IResult> HandleChoiceRespond(HttpContext ctx)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Kein Token-Check – wird über normale Auth-Middleware geschützt (Cookie-basiert)
|
||||||
|
using var reader = new StreamReader(ctx.Request.Body);
|
||||||
|
var body = await reader.ReadToEndAsync();
|
||||||
|
JsonElement json;
|
||||||
|
try { json = JsonSerializer.Deserialize<JsonElement>(body); }
|
||||||
|
catch { return Results.BadRequest(new { error = "Invalid JSON" }); }
|
||||||
|
|
||||||
|
var sessionId = json.TryGetProperty("sessionId", out var sid) ? sid.GetString() ?? "" : "";
|
||||||
|
var choice = json.TryGetProperty("choice", out var c) ? c.GetString() ?? "" : "";
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(sessionId))
|
||||||
|
return Results.BadRequest(new { error = "sessionId erforderlich" });
|
||||||
|
if (string.IsNullOrEmpty(choice))
|
||||||
|
return Results.BadRequest(new { error = "choice erforderlich" });
|
||||||
|
|
||||||
|
if (PendingChoices.TryGetValue(sessionId, out var tcs))
|
||||||
|
{
|
||||||
|
tcs.TrySetResult(choice);
|
||||||
|
return Results.Ok(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.NotFound(new { error = "Keine ausstehende Auswahl für diese Session" });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Results.Json(new { error = ex.Message }, statusCode: 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IResult CheckToken(HttpContext ctx)
|
||||||
|
{
|
||||||
|
var token = ctx.Request.Headers["X-Internal-Token"].FirstOrDefault() ?? "";
|
||||||
|
return token != InternalToken ? Results.StatusCode(403) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> HandleMemoryRead(HttpContext ctx, MemoryService memoryService)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var denied = CheckToken(ctx);
|
||||||
|
if (denied != null) return denied;
|
||||||
|
|
||||||
|
using var reader = new StreamReader(ctx.Request.Body);
|
||||||
|
var body = await reader.ReadToEndAsync();
|
||||||
|
JsonElement json;
|
||||||
|
try { json = JsonSerializer.Deserialize<JsonElement>(body); }
|
||||||
|
catch { return Results.BadRequest(new { error = "Invalid JSON" }); }
|
||||||
|
|
||||||
|
var agentId = json.TryGetProperty("agentId", out var a) ? a.GetString() ?? "" : "";
|
||||||
|
var path = json.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(agentId)) return Results.BadRequest(new { error = "agentId erforderlich" });
|
||||||
|
if (string.IsNullOrEmpty(path)) return Results.BadRequest(new { error = "path erforderlich" });
|
||||||
|
|
||||||
|
var content = memoryService.ReadMemoryFile(agentId, path);
|
||||||
|
if (content == null)
|
||||||
|
return Results.NotFound(new { error = $"Datei nicht gefunden: {path}" });
|
||||||
|
|
||||||
|
return Results.Ok(new { content });
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
return Results.Json(new { error = ex.Message }, statusCode: 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> HandleMemoryWrite(HttpContext ctx, MemoryService memoryService)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var denied = CheckToken(ctx);
|
||||||
|
if (denied != null) return denied;
|
||||||
|
|
||||||
|
using var reader = new StreamReader(ctx.Request.Body);
|
||||||
|
var body = await reader.ReadToEndAsync();
|
||||||
|
JsonElement json;
|
||||||
|
try { json = JsonSerializer.Deserialize<JsonElement>(body); }
|
||||||
|
catch { return Results.BadRequest(new { error = "Invalid JSON" }); }
|
||||||
|
|
||||||
|
var agentId = json.TryGetProperty("agentId", out var a) ? a.GetString() ?? "" : "";
|
||||||
|
var path = json.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
|
||||||
|
var content = json.TryGetProperty("content", out var c) ? c.GetString() ?? "" : "";
|
||||||
|
var append = json.TryGetProperty("append", out var ap) && ap.GetBoolean();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(agentId)) return Results.BadRequest(new { error = "agentId erforderlich" });
|
||||||
|
if (string.IsNullOrEmpty(path)) return Results.BadRequest(new { error = "path erforderlich" });
|
||||||
|
|
||||||
|
if (append)
|
||||||
|
memoryService.AppendMemoryFile(agentId, path, content);
|
||||||
|
else
|
||||||
|
memoryService.WriteMemoryFile(agentId, path, content);
|
||||||
|
|
||||||
|
return Results.Ok(new { success = true, message = $"Memory-Datei {(append ? "erweitert" : "geschrieben")}: {path} ({content.Length} Zeichen)" });
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
return Results.Json(new { error = ex.Message }, statusCode: 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> HandleMemoryList(HttpContext ctx, MemoryService memoryService)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var denied = CheckToken(ctx);
|
||||||
|
if (denied != null) return denied;
|
||||||
|
|
||||||
|
using var reader = new StreamReader(ctx.Request.Body);
|
||||||
|
var body = await reader.ReadToEndAsync();
|
||||||
|
JsonElement json;
|
||||||
|
try { json = JsonSerializer.Deserialize<JsonElement>(body); }
|
||||||
|
catch { return Results.BadRequest(new { error = "Invalid JSON" }); }
|
||||||
|
|
||||||
|
var agentId = json.TryGetProperty("agentId", out var a) ? a.GetString() ?? "" : "";
|
||||||
|
var subPath = json.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(agentId)) return Results.BadRequest(new { error = "agentId erforderlich" });
|
||||||
|
|
||||||
|
var files = memoryService.ListMemoryFiles(agentId, subPath);
|
||||||
|
return Results.Ok(new { files });
|
||||||
|
}
|
||||||
|
catch (System.Exception ex)
|
||||||
|
{
|
||||||
|
return Results.Json(new { error = ex.Message }, statusCode: 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -9,6 +9,7 @@ using System.Text;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Gott.Endpoints;
|
||||||
using Gott.Models;
|
using Gott.Models;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
|
|
||||||
@@ -1737,7 +1738,7 @@ public class AnthropicService
|
|||||||
/// Generiert eine temporäre MCP-Config-Datei für die Claude CLI.
|
/// Generiert eine temporäre MCP-Config-Datei für die Claude CLI.
|
||||||
/// Enthält den MCP-Server für remote_execute.
|
/// Enthält den MCP-Server für remote_execute.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private string GenerateMcpConfig(string sessionId)
|
private string GenerateMcpConfig(string sessionId, string agentId = null, bool hasRemote = false)
|
||||||
{
|
{
|
||||||
// Port aus Konfiguration oder Default
|
// Port aus Konfiguration oder Default
|
||||||
var port = 19000;
|
var port = 19000;
|
||||||
@@ -1746,12 +1747,20 @@ public class AnthropicService
|
|||||||
// Fallback: Quell-Verzeichnis prüfen (für Dev-Modus)
|
// Fallback: Quell-Verzeichnis prüfen (für Dev-Modus)
|
||||||
if (!File.Exists(scriptPath))
|
if (!File.Exists(scriptPath))
|
||||||
{
|
{
|
||||||
// wwwroot ist typischerweise /app/gott/wwwroot → eine Ebene hoch = Projekt-Root
|
|
||||||
var projectRoot = Path.GetDirectoryName(AppPaths.WwwRoot) ?? AppContext.BaseDirectory;
|
var projectRoot = Path.GetDirectoryName(AppPaths.WwwRoot) ?? AppContext.BaseDirectory;
|
||||||
var srcPath = Path.Combine(projectRoot, "scripts", "mcp-remote-execute.py");
|
var srcPath = Path.Combine(projectRoot, "scripts", "mcp-remote-execute.py");
|
||||||
if (File.Exists(srcPath)) scriptPath = srcPath;
|
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
|
var config = new
|
||||||
{
|
{
|
||||||
mcpServers = new Dictionary<string, object>
|
mcpServers = new Dictionary<string, object>
|
||||||
@@ -1760,12 +1769,7 @@ public class AnthropicService
|
|||||||
{
|
{
|
||||||
command = "python3",
|
command = "python3",
|
||||||
args = new[] { scriptPath },
|
args = new[] { scriptPath },
|
||||||
env = new Dictionary<string, string>
|
env
|
||||||
{
|
|
||||||
["GOTT_API_URL"] = $"http://localhost:{port}",
|
|
||||||
["GOTT_SESSION_ID"] = sessionId,
|
|
||||||
["GOTT_AUTH_TOKEN"] = Endpoints.InternalEndpoint.InternalToken
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1929,6 +1933,7 @@ public class AnthropicService
|
|||||||
bool planMode = false,
|
bool planMode = false,
|
||||||
bool webSearch = false,
|
bool webSearch = false,
|
||||||
string sessionId = null,
|
string sessionId = null,
|
||||||
|
string agentId = null,
|
||||||
RemoteConnectionManager remoteMgr = null,
|
RemoteConnectionManager remoteMgr = null,
|
||||||
CancellationToken workCt = default,
|
CancellationToken workCt = default,
|
||||||
CancellationToken sseCt = default)
|
CancellationToken sseCt = default)
|
||||||
@@ -1955,11 +1960,9 @@ public class AnthropicService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// MCP-Config für remote_execute generieren (wenn Remote-Verbindung vorhanden)
|
// MCP-Config generieren (immer – für ask_user_choice, optional Memory-Tools + remote_execute)
|
||||||
if (remoteMgr != null && !string.IsNullOrEmpty(sessionId) && remoteMgr.GetBySessionId(sessionId) != null)
|
var hasRemote = remoteMgr != null && !string.IsNullOrEmpty(sessionId) && remoteMgr.GetBySessionId(sessionId) != null;
|
||||||
{
|
mcpConfigPath = GenerateMcpConfig(sessionId, agentId, hasRemote);
|
||||||
mcpConfigPath = GenerateMcpConfig(sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// System-Prompt als Text extrahieren
|
// System-Prompt als Text extrahieren
|
||||||
var systemText = new StringBuilder();
|
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
|
// Letzte User-Message als Prompt extrahieren
|
||||||
var prompt = ExtractLastUserText(messages) ?? "";
|
var prompt = ExtractLastUserText(messages) ?? "";
|
||||||
@@ -2060,6 +2050,36 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En
|
|||||||
Content = mappedInput
|
Content = mappedInput
|
||||||
});
|
});
|
||||||
await SseHelper.TryWriteSse(response, "tool_end", new { name = mappedName, id = evt.ToolId, input = mappedInput }, sseCt);
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case CliEventType.ToolResult:
|
case CliEventType.ToolResult:
|
||||||
@@ -2085,43 +2105,6 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En
|
|||||||
}, sseCt);
|
}, sseCt);
|
||||||
break;
|
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,
|
modelId: modelId,
|
||||||
|
|||||||
+16
-120
@@ -113,7 +113,14 @@ public class ClaudeCliService
|
|||||||
}
|
}
|
||||||
if (webSearch) { tools.Add("WebFetch"); tools.Add("WebSearch"); }
|
if (webSearch) { tools.Add("WebFetch"); tools.Add("WebSearch"); }
|
||||||
// MCP-Tools erlauben (wenn MCP-Config vorhanden)
|
// 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("--allowed-tools");
|
||||||
args.Add(tools.Count > 0 ? string.Join(" ", tools) : "\"\"");
|
args.Add(tools.Count > 0 ? string.Join(" ", tools) : "\"\"");
|
||||||
|
|
||||||
@@ -161,11 +168,7 @@ public class ClaudeCliService
|
|||||||
var currentToolInput = new StringBuilder();
|
var currentToolInput = new StringBuilder();
|
||||||
bool inTextBlock = false;
|
bool inTextBlock = false;
|
||||||
|
|
||||||
// Tracking für ask_user_choice Marker-Erkennung
|
|
||||||
var lineBuffer = new StringBuilder();
|
var lineBuffer = new StringBuilder();
|
||||||
bool inChoiceCapture = false;
|
|
||||||
var choiceJsonBuffer = new StringBuilder();
|
|
||||||
bool userChoiceDetected = false;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -286,9 +289,8 @@ public class ClaudeCliService
|
|||||||
if (deltaType == "text_delta" && delta.TryGetProperty("text", out var txt))
|
if (deltaType == "text_delta" && delta.TryGetProperty("text", out var txt))
|
||||||
{
|
{
|
||||||
var text = txt.GetString();
|
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++)
|
for (int ci = 0; ci < text.Length; ci++)
|
||||||
{
|
{
|
||||||
var ch = text[ci];
|
var ch = text[ci];
|
||||||
@@ -297,52 +299,8 @@ public class ClaudeCliService
|
|||||||
var completedLine = lineBuffer.ToString().Trim();
|
var completedLine = lineBuffer.ToString().Trim();
|
||||||
lineBuffer.Clear();
|
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";
|
var lineText = completedLine + "\n";
|
||||||
fullOutput.Append(lineText);
|
fullOutput.Append(lineText);
|
||||||
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = lineText });
|
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = lineText });
|
||||||
@@ -401,41 +359,12 @@ public class ClaudeCliService
|
|||||||
else if (type == "result")
|
else if (type == "result")
|
||||||
{
|
{
|
||||||
// Restlichen lineBuffer flushen (nicht-abgeschlossene Zeile)
|
// Restlichen lineBuffer flushen (nicht-abgeschlossene Zeile)
|
||||||
if (lineBuffer.Length > 0 && !userChoiceDetected)
|
if (lineBuffer.Length > 0)
|
||||||
{
|
{
|
||||||
var remaining = lineBuffer.ToString().Trim();
|
var remaining = lineBuffer.ToString().Trim();
|
||||||
lineBuffer.Clear();
|
lineBuffer.Clear();
|
||||||
|
|
||||||
if (inChoiceCapture && remaining == ":::END_ASK_USER_CHOICE:::")
|
if (remaining.Length > 0)
|
||||||
{
|
|
||||||
// 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)
|
|
||||||
{
|
{
|
||||||
fullOutput.Append(remaining);
|
fullOutput.Append(remaining);
|
||||||
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining });
|
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining });
|
||||||
@@ -519,40 +448,12 @@ public class ClaudeCliService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Restlichen lineBuffer flushen
|
// Restlichen lineBuffer flushen
|
||||||
if (lineBuffer.Length > 0 && !userChoiceDetected)
|
if (lineBuffer.Length > 0)
|
||||||
{
|
{
|
||||||
var remaining = lineBuffer.ToString().Trim();
|
var remaining = lineBuffer.ToString().Trim();
|
||||||
lineBuffer.Clear();
|
lineBuffer.Clear();
|
||||||
|
|
||||||
if (inChoiceCapture && remaining == ":::END_ASK_USER_CHOICE:::")
|
if (remaining.Length > 0)
|
||||||
{
|
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
fullOutput.Append(remaining);
|
fullOutput.Append(remaining);
|
||||||
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining });
|
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining });
|
||||||
@@ -578,8 +479,7 @@ public class ClaudeCliService
|
|||||||
|
|
||||||
result.ExitCode = process.ExitCode;
|
result.ExitCode = process.ExitCode;
|
||||||
result.Output = fullOutput.ToString().Trim();
|
result.Output = fullOutput.ToString().Trim();
|
||||||
result.Success = process.ExitCode == 0 && (fullOutput.Length > 0 || userChoiceDetected);
|
result.Success = process.ExitCode == 0 && fullOutput.Length > 0;
|
||||||
result.UserChoiceDetected = userChoiceDetected;
|
|
||||||
|
|
||||||
if (!result.Success && string.IsNullOrEmpty(result.Error))
|
if (!result.Success && string.IsNullOrEmpty(result.Error))
|
||||||
result.Error = error.Length > 0 ? error.ToString().Trim() : $"CLI exit code: {process.ExitCode}";
|
result.Error = error.Length > 0 ? error.ToString().Trim() : $"CLI exit code: {process.ExitCode}";
|
||||||
@@ -696,8 +596,7 @@ public enum CliEventType
|
|||||||
TextEnd,
|
TextEnd,
|
||||||
ToolStart,
|
ToolStart,
|
||||||
ToolEnd,
|
ToolEnd,
|
||||||
ToolResult,
|
ToolResult
|
||||||
UserChoice
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CliStreamEvent
|
public class CliStreamEvent
|
||||||
@@ -709,8 +608,6 @@ public class CliStreamEvent
|
|||||||
public string ToolInput { get; set; }
|
public string ToolInput { get; set; }
|
||||||
public string ToolResultContent { get; set; }
|
public string ToolResultContent { get; set; }
|
||||||
public bool ToolResultIsError { get; set; }
|
public bool ToolResultIsError { get; set; }
|
||||||
public string ChoiceQuestion { get; set; }
|
|
||||||
public List<string> ChoiceOptions { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CliChatResult
|
public class CliChatResult
|
||||||
@@ -727,7 +624,6 @@ public class CliChatResult
|
|||||||
public long DurationApiMs { get; set; }
|
public long DurationApiMs { get; set; }
|
||||||
public string SessionId { get; set; }
|
public string SessionId { get; set; }
|
||||||
public int NumTurns { get; set; }
|
public int NumTurns { get; set; }
|
||||||
public bool UserChoiceDetected { get; set; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CliStatusResult
|
public class CliStatusResult
|
||||||
|
|||||||
+205
-93
@@ -19,13 +19,90 @@ import os
|
|||||||
API_URL = os.environ.get("GOTT_API_URL", "http://localhost:19000")
|
API_URL = os.environ.get("GOTT_API_URL", "http://localhost:19000")
|
||||||
SESSION_ID = os.environ.get("GOTT_SESSION_ID", "")
|
SESSION_ID = os.environ.get("GOTT_SESSION_ID", "")
|
||||||
AUTH_TOKEN = os.environ.get("GOTT_AUTH_TOKEN", "")
|
AUTH_TOKEN = os.environ.get("GOTT_AUTH_TOKEN", "")
|
||||||
|
AGENT_ID = os.environ.get("GOTT_AGENT_ID", "")
|
||||||
|
HAS_REMOTE = os.environ.get("GOTT_HAS_REMOTE", "false") == "true"
|
||||||
|
|
||||||
TOOL_NAME = "remote_execute"
|
TOOLS = []
|
||||||
TOOL_DESCRIPTION = (
|
|
||||||
"Führt einen PowerShell-Befehl auf der verbundenen Remote-Windows-Workstation aus. "
|
# Remote-Execute Tool (nur wenn Remote-Verbindung vorhanden)
|
||||||
"Gibt stdout und stderr zurück. Nutze dies für Diagnose und Administration des Remote-PCs "
|
if HAS_REMOTE:
|
||||||
"(z.B. Druckerprobleme, Netzwerk, Services, Registry). Timeout: 180 Sekunden."
|
TOOLS.append({
|
||||||
)
|
"name": "remote_execute",
|
||||||
|
"description": (
|
||||||
|
"Führt einen PowerShell-Befehl auf der verbundenen Remote-Windows-Workstation aus. "
|
||||||
|
"Gibt stdout und stderr zurück. Nutze dies für Diagnose und Administration des Remote-PCs "
|
||||||
|
"(z.B. Druckerprobleme, Netzwerk, Services, Registry). Timeout: 180 Sekunden."
|
||||||
|
),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"command": {"type": "string", "description": "PowerShell-Befehl zum Ausführen auf dem Remote-PC"}
|
||||||
|
},
|
||||||
|
"required": ["command"]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# ask_user_choice Tool (immer verfügbar)
|
||||||
|
TOOLS.append({
|
||||||
|
"name": "ask_user_choice",
|
||||||
|
"description": (
|
||||||
|
"Stellt dem User eine Frage mit Auswahlmöglichkeiten (2-5 Optionen). "
|
||||||
|
"Der User wählt eine Option und du arbeitest mit seiner Antwort weiter. "
|
||||||
|
"Nutze dies wenn du unsicher bist oder es mehrere sinnvolle Wege gibt."
|
||||||
|
),
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"question": {"type": "string", "description": "Die Frage an den User"},
|
||||||
|
"choices": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"description": "Liste der Auswahlmöglichkeiten (2-5 Optionen)",
|
||||||
|
"minItems": 2,
|
||||||
|
"maxItems": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["question", "choices"]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Memory-Tools (immer verfügbar wenn agentId gesetzt)
|
||||||
|
if AGENT_ID:
|
||||||
|
TOOLS.append({
|
||||||
|
"name": "memory_read",
|
||||||
|
"description": "Liest eine Memory-Datei des Agents. Nutze dies um gespeicherte Erinnerungen, Notizen oder Kontextinformationen abzurufen.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string", "description": "Relativer Pfad zur Memory-Datei (z.B. 'MEMORY.md' oder 'memory/YYYY-MM-DD.md')"}
|
||||||
|
},
|
||||||
|
"required": ["path"]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
TOOLS.append({
|
||||||
|
"name": "memory_write",
|
||||||
|
"description": "Schreibt oder überschreibt eine Memory-Datei. Nutze dies um wichtige Informationen, Entscheidungen oder Präferenzen dauerhaft zu speichern.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string", "description": "Relativer Pfad zur Memory-Datei (z.B. 'MEMORY.md' oder 'memory/YYYY-MM-DD.md')"},
|
||||||
|
"content": {"type": "string", "description": "Inhalt der Datei"},
|
||||||
|
"append": {"type": "boolean", "description": "Wenn true, wird der Inhalt angehängt statt überschrieben (Default: false)"}
|
||||||
|
},
|
||||||
|
"required": ["path", "content"]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
TOOLS.append({
|
||||||
|
"name": "memory_list",
|
||||||
|
"description": "Listet alle Memory-Dateien des Agents auf.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {"type": "string", "description": "Optionaler Unterpfad zum Auflisten (leer = Root)"}
|
||||||
|
},
|
||||||
|
"required": []
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
def send_response(response):
|
def send_response(response):
|
||||||
@@ -59,112 +136,147 @@ def handle_tools_list(req_id):
|
|||||||
"jsonrpc": "2.0",
|
"jsonrpc": "2.0",
|
||||||
"id": req_id,
|
"id": req_id,
|
||||||
"result": {
|
"result": {
|
||||||
"tools": [
|
"tools": TOOLS
|
||||||
{
|
|
||||||
"name": TOOL_NAME,
|
|
||||||
"description": TOOL_DESCRIPTION,
|
|
||||||
"inputSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"command": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "PowerShell-Befehl zum Ausführen auf dem Remote-PC"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["command"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def api_call(endpoint, payload):
|
||||||
|
"""Macht einen API-Call an gott und gibt das Ergebnis zurück."""
|
||||||
|
url = f"{API_URL}/api/internal/{endpoint}"
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=data, method="POST")
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
if AUTH_TOKEN:
|
||||||
|
req.add_header("X-Internal-Token", AUTH_TOKEN)
|
||||||
|
with urllib.request.urlopen(req, timeout=200) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def make_result(req_id, text, is_error=False):
|
||||||
|
"""Erstellt eine MCP Tool-Result Response."""
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": req_id,
|
||||||
|
"result": {
|
||||||
|
"content": [{"type": "text", "text": text}],
|
||||||
|
"isError": is_error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def handle_remote_execute(req_id, arguments):
|
||||||
|
"""Führt remote_execute aus."""
|
||||||
|
command = arguments.get("command", "")
|
||||||
|
if not command:
|
||||||
|
return make_result(req_id, "Fehler: Kein Befehl angegeben", True)
|
||||||
|
|
||||||
|
result = api_call("remote-execute", {"command": command, "sessionId": SESSION_ID})
|
||||||
|
|
||||||
|
output_parts = []
|
||||||
|
if result.get("stdout"):
|
||||||
|
output_parts.append(result["stdout"])
|
||||||
|
if result.get("stderr"):
|
||||||
|
output_parts.append(f"[stderr] {result['stderr']}")
|
||||||
|
if result.get("exitCode", 0) != 0:
|
||||||
|
output_parts.append(f"[Exit Code: {result['exitCode']}]")
|
||||||
|
|
||||||
|
text = "\n".join(output_parts) if output_parts else "(keine Ausgabe)"
|
||||||
|
return make_result(req_id, text, result.get("exitCode", 0) != 0)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_memory_read(req_id, arguments):
|
||||||
|
"""Liest eine Memory-Datei."""
|
||||||
|
path = arguments.get("path", "")
|
||||||
|
if not path:
|
||||||
|
return make_result(req_id, "Fehler: path erforderlich", True)
|
||||||
|
|
||||||
|
result = api_call("memory-read", {"agentId": AGENT_ID, "path": path})
|
||||||
|
return make_result(req_id, result.get("content", ""))
|
||||||
|
|
||||||
|
|
||||||
|
def handle_memory_write(req_id, arguments):
|
||||||
|
"""Schreibt eine Memory-Datei."""
|
||||||
|
path = arguments.get("path", "")
|
||||||
|
content = arguments.get("content", "")
|
||||||
|
append = arguments.get("append", False)
|
||||||
|
if not path:
|
||||||
|
return make_result(req_id, "Fehler: path erforderlich", True)
|
||||||
|
|
||||||
|
result = api_call("memory-write", {"agentId": AGENT_ID, "path": path, "content": content, "append": append})
|
||||||
|
return make_result(req_id, result.get("message", "OK"))
|
||||||
|
|
||||||
|
|
||||||
|
def handle_memory_list(req_id, arguments):
|
||||||
|
"""Listet Memory-Dateien auf."""
|
||||||
|
path = arguments.get("path", "")
|
||||||
|
|
||||||
|
result = api_call("memory-list", {"agentId": AGENT_ID, "path": path})
|
||||||
|
files = result.get("files", [])
|
||||||
|
return make_result(req_id, "\n".join(files) if files else "(keine Memory-Dateien vorhanden)")
|
||||||
|
|
||||||
|
|
||||||
|
def handle_ask_user_choice(req_id, arguments):
|
||||||
|
"""Stellt dem User eine Frage und wartet auf Antwort (long-poll)."""
|
||||||
|
question = arguments.get("question", "")
|
||||||
|
choices = arguments.get("choices", [])
|
||||||
|
if not question:
|
||||||
|
return make_result(req_id, "Fehler: question erforderlich", True)
|
||||||
|
if not choices or len(choices) < 2:
|
||||||
|
return make_result(req_id, "Fehler: Mindestens 2 choices erforderlich", True)
|
||||||
|
|
||||||
|
# Long-Poll: Blockiert bis der User antwortet (max 5 Min)
|
||||||
|
# Der Backend-Endpoint /api/internal/ask-user-choice blockiert selbst
|
||||||
|
result = api_call_long("ask-user-choice", {"sessionId": SESSION_ID})
|
||||||
|
choice = result.get("choice", "")
|
||||||
|
return make_result(req_id, f"User hat gewählt: {choice}")
|
||||||
|
|
||||||
|
|
||||||
|
def api_call_long(endpoint, payload):
|
||||||
|
"""API-Call mit langem Timeout (für Long-Poll Endpoints)."""
|
||||||
|
url = f"{API_URL}/api/internal/{endpoint}"
|
||||||
|
data = json.dumps(payload).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=data, method="POST")
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
if AUTH_TOKEN:
|
||||||
|
req.add_header("X-Internal-Token", AUTH_TOKEN)
|
||||||
|
with urllib.request.urlopen(req, timeout=330) as resp:
|
||||||
|
return json.loads(resp.read().decode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
TOOL_HANDLERS = {
|
||||||
|
"remote_execute": handle_remote_execute,
|
||||||
|
"ask_user_choice": handle_ask_user_choice,
|
||||||
|
"memory_read": handle_memory_read,
|
||||||
|
"memory_write": handle_memory_write,
|
||||||
|
"memory_list": handle_memory_list,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def handle_tools_call(req_id, params):
|
def handle_tools_call(req_id, params):
|
||||||
"""Führt ein Tool aus."""
|
"""Führt ein Tool aus."""
|
||||||
tool_name = params.get("name", "")
|
tool_name = params.get("name", "")
|
||||||
arguments = params.get("arguments", {})
|
arguments = params.get("arguments", {})
|
||||||
|
|
||||||
if tool_name != TOOL_NAME:
|
handler = TOOL_HANDLERS.get(tool_name)
|
||||||
return {
|
if not handler:
|
||||||
"jsonrpc": "2.0",
|
return make_result(req_id, f"Unbekanntes Tool: {tool_name}", True)
|
||||||
"id": req_id,
|
|
||||||
"result": {
|
|
||||||
"content": [{"type": "text", "text": f"Unbekanntes Tool: {tool_name}"}],
|
|
||||||
"isError": True
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
command = arguments.get("command", "")
|
# Prüfen ob das Tool auch in TOOLS registriert ist
|
||||||
if not command:
|
if not any(t["name"] == tool_name for t in TOOLS):
|
||||||
return {
|
return make_result(req_id, f"Tool nicht verfügbar: {tool_name}", True)
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": req_id,
|
|
||||||
"result": {
|
|
||||||
"content": [{"type": "text", "text": "Fehler: Kein Befehl angegeben"}],
|
|
||||||
"isError": True
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# API-Call an gott
|
|
||||||
try:
|
try:
|
||||||
url = f"{API_URL}/api/internal/remote-execute"
|
return handler(req_id, arguments)
|
||||||
payload = json.dumps({
|
|
||||||
"command": command,
|
|
||||||
"sessionId": SESSION_ID
|
|
||||||
}).encode("utf-8")
|
|
||||||
|
|
||||||
req = urllib.request.Request(url, data=payload, method="POST")
|
|
||||||
req.add_header("Content-Type", "application/json")
|
|
||||||
if AUTH_TOKEN:
|
|
||||||
req.add_header("X-Internal-Token", AUTH_TOKEN)
|
|
||||||
|
|
||||||
with urllib.request.urlopen(req, timeout=200) as resp:
|
|
||||||
result = json.loads(resp.read().decode("utf-8"))
|
|
||||||
|
|
||||||
# Ergebnis formatieren
|
|
||||||
output_parts = []
|
|
||||||
if result.get("stdout"):
|
|
||||||
output_parts.append(result["stdout"])
|
|
||||||
if result.get("stderr"):
|
|
||||||
output_parts.append(f"[stderr] {result['stderr']}")
|
|
||||||
if result.get("exitCode", 0) != 0:
|
|
||||||
output_parts.append(f"[Exit Code: {result['exitCode']}]")
|
|
||||||
|
|
||||||
text = "\n".join(output_parts) if output_parts else "(keine Ausgabe)"
|
|
||||||
is_error = result.get("exitCode", 0) != 0
|
|
||||||
|
|
||||||
return {
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": req_id,
|
|
||||||
"result": {
|
|
||||||
"content": [{"type": "text", "text": text}],
|
|
||||||
"isError": is_error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
body = ""
|
body = ""
|
||||||
try:
|
try:
|
||||||
body = e.read().decode("utf-8")
|
body = e.read().decode("utf-8")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return {
|
return make_result(req_id, f"API-Fehler {e.code}: {body}", True)
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": req_id,
|
|
||||||
"result": {
|
|
||||||
"content": [{"type": "text", "text": f"API-Fehler {e.code}: {body}"}],
|
|
||||||
"isError": True
|
|
||||||
}
|
|
||||||
}
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {
|
return make_result(req_id, f"Fehler: {str(e)}", True)
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": req_id,
|
|
||||||
"result": {
|
|
||||||
"content": [{"type": "text", "text": f"Fehler: {str(e)}"}],
|
|
||||||
"isError": True
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def handle_notifications_initialized(params):
|
def handle_notifications_initialized(params):
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ function apiChatChoice(sessionId, choiceToolCallId, choiceResult, signal, planMo
|
|||||||
return fetch('/api/chat', { method: 'POST', headers: _jsonHeaders, body: JSON.stringify(payload), signal: signal });
|
return fetch('/api/chat', { method: 'POST', headers: _jsonHeaders, body: JSON.stringify(payload), signal: signal });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function apiChoiceRespond(sessionId, choice) {
|
||||||
|
return _post('/api/chat/choice-respond', { sessionId: sessionId, choice: choice });
|
||||||
|
}
|
||||||
|
|
||||||
function apiStopChat(sessionId) { return _post('/api/chat/stop', { sessionId: sessionId }); }
|
function apiStopChat(sessionId) { return _post('/api/chat/stop', { sessionId: sessionId }); }
|
||||||
|
|
||||||
// ── Agent API ──
|
// ── Agent API ──
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ function addWarningBubble(text, streamed = false) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a choice bubble element (for both live stream and history rendering).
|
* Creates a choice bubble element (for both live stream and history rendering).
|
||||||
* @param {object} opts - { toolCallId, question, choices, selectedChoice, timestamp, messageId, streamed }
|
* @param {object} opts - { toolCallId, question, choices, selectedChoice, timestamp, messageId, streamed, viaCli }
|
||||||
*/
|
*/
|
||||||
function _createChoiceBubble(opts) {
|
function _createChoiceBubble(opts) {
|
||||||
if (!Array.isArray(opts.choices)) opts.choices = [String(opts.choices)];
|
if (!Array.isArray(opts.choices)) opts.choices = [String(opts.choices)];
|
||||||
@@ -189,6 +189,7 @@ function _createChoiceBubble(opts) {
|
|||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'msg choice-bubble' + (answered ? ' choice-answered' : '');
|
div.className = 'msg choice-bubble' + (answered ? ' choice-answered' : '');
|
||||||
if (opts.streamed) div.classList.add('streamed');
|
if (opts.streamed) div.classList.add('streamed');
|
||||||
|
if (opts.viaCli) div.dataset.viaCli = 'true';
|
||||||
div.dataset.toolCallId = opts.toolCallId || '';
|
div.dataset.toolCallId = opts.toolCallId || '';
|
||||||
if (opts.messageId) div.dataset.messageId = opts.messageId;
|
if (opts.messageId) div.dataset.messageId = opts.messageId;
|
||||||
|
|
||||||
@@ -219,8 +220,8 @@ function _createChoiceBubble(opts) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wird live aus dem SSE-Stream aufgerufen
|
// Wird live aus dem SSE-Stream aufgerufen
|
||||||
function addChoiceBubble(toolCallId, question, choices, streamed = true) {
|
function addChoiceBubble(toolCallId, question, choices, streamed = true, viaCli = false) {
|
||||||
const div = _createChoiceBubble({ toolCallId, question, choices, streamed });
|
const div = _createChoiceBubble({ toolCallId, question, choices, streamed, viaCli });
|
||||||
chatEl().appendChild(div);
|
chatEl().appendChild(div);
|
||||||
scrollDown();
|
scrollDown();
|
||||||
return div;
|
return div;
|
||||||
|
|||||||
+12
-1
@@ -78,7 +78,7 @@ function handleStreamEvent(eventType, data) {
|
|||||||
|
|
||||||
case 'user_choice':
|
case 'user_choice':
|
||||||
endStreamBubble();
|
endStreamBubble();
|
||||||
addChoiceBubble(data.toolCallId, data.question, data.choices);
|
addChoiceBubble(data.toolCallId, data.question, data.choices, true, data.viaCli);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// Preparer / Review / Finalizer events (shared pattern)
|
// Preparer / Review / Finalizer events (shared pattern)
|
||||||
@@ -425,6 +425,17 @@ async function handleChoiceClick(toolCallId, choice, bubbleDiv) {
|
|||||||
});
|
});
|
||||||
bubbleDiv.classList.add('choice-answered');
|
bubbleDiv.classList.add('choice-answered');
|
||||||
|
|
||||||
|
// CLI-Modus: Antwort über internen Endpoint an MCP-Server weiterleiten (CLI läuft weiter)
|
||||||
|
if (bubbleDiv.dataset.viaCli === 'true') {
|
||||||
|
try {
|
||||||
|
await apiChoiceRespond(getCurrentSessionId(), choice);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Choice respond failed:', e);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API-Modus: Normaler Chat-Flow (neue Chain)
|
||||||
await _runStream((signal) => apiChatChoice(getCurrentSessionId(), toolCallId, choice, signal, _planModeActive));
|
await _runStream((signal) => apiChatChoice(getCurrentSessionId(), toolCallId, choice, signal, _planModeActive));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,18 +6,6 @@ function renderMarkdown(text) {
|
|||||||
if (!text) return '';
|
if (!text) return '';
|
||||||
var src = text.trim();
|
var src = text.trim();
|
||||||
|
|
||||||
// 0) :::ASK_USER_CHOICE::: Blöcke erkennen und als Platzhalter speichern (vor Markdown-Parsing)
|
|
||||||
var choiceBlocks = [];
|
|
||||||
src = src.replace(/:::ASK_USER_CHOICE:::\s*\n?([\s\S]*?)\n?\s*:::END_ASK_USER_CHOICE:::/g, function(match, jsonStr) {
|
|
||||||
try {
|
|
||||||
var data = JSON.parse(jsonStr.trim());
|
|
||||||
choiceBlocks.push(data);
|
|
||||||
return '%%CHOICE' + (choiceBlocks.length - 1) + '%%';
|
|
||||||
} catch(e) {
|
|
||||||
return match; // JSON-Fehler → als Text belassen
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 1) LaTeX-Ausdrücke VOR dem Markdown-Parsing schützen
|
// 1) LaTeX-Ausdrücke VOR dem Markdown-Parsing schützen
|
||||||
// Reihenfolge: zuerst Block ($$...$$), dann Inline ($...$)
|
// Reihenfolge: zuerst Block ($$...$$), dann Inline ($...$)
|
||||||
var mathBlocks = [];
|
var mathBlocks = [];
|
||||||
@@ -107,22 +95,6 @@ function renderMarkdown(text) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 8) Choice-Platzhalter durch interaktive Buttons ersetzen
|
|
||||||
html = html.replace(/%%CHOICE(\d+)%%/g, function(m, idx) {
|
|
||||||
var data = choiceBlocks[parseInt(idx)];
|
|
||||||
var q = data.question || '';
|
|
||||||
var choices = data.choices || [];
|
|
||||||
var jsonAttr = esc(JSON.stringify(data));
|
|
||||||
var out = '<div class="inline-choice-block" data-choice-json="' + jsonAttr + '">';
|
|
||||||
out += '<div class="choice-question">' + esc(q) + '</div>';
|
|
||||||
out += '<div class="choice-buttons">';
|
|
||||||
for (var i = 0; i < choices.length; i++) {
|
|
||||||
out += '<button class="choice-btn inline-choice-btn" data-choice="' + esc(choices[i]) + '">' + esc(choices[i]) + '</button>';
|
|
||||||
}
|
|
||||||
out += '</div></div>';
|
|
||||||
return out;
|
|
||||||
});
|
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,15 +16,7 @@ function appendToStream(text) {
|
|||||||
if (!_streamDiv) startStreamBubble();
|
if (!_streamDiv) startStreamBubble();
|
||||||
_streamText += text;
|
_streamText += text;
|
||||||
|
|
||||||
// Wenn ein :::ASK_USER_CHOICE::: Block begonnen aber noch nicht abgeschlossen wurde,
|
_streamDiv.innerHTML = renderMarkdown(_streamText);
|
||||||
// nur den Text VOR dem Marker rendern (Marker-Block verstecken bis er komplett ist)
|
|
||||||
var displayText = _streamText;
|
|
||||||
var choiceStart = displayText.indexOf(':::ASK_USER_CHOICE:::');
|
|
||||||
if (choiceStart >= 0 && displayText.indexOf(':::END_ASK_USER_CHOICE:::') < 0) {
|
|
||||||
displayText = displayText.substring(0, choiceStart);
|
|
||||||
}
|
|
||||||
|
|
||||||
_streamDiv.innerHTML = renderMarkdown(displayText);
|
|
||||||
// Cursor ins letzte Inline/Text-Element einfügen, damit er nicht auf eine neue Zeile rutscht
|
// Cursor ins letzte Inline/Text-Element einfügen, damit er nicht auf eine neue Zeile rutscht
|
||||||
const cursor = document.createElement('span');
|
const cursor = document.createElement('span');
|
||||||
cursor.className = 'cursor';
|
cursor.className = 'cursor';
|
||||||
|
|||||||
Reference in new issue
Block a user