Files
gott/Services/ToolService.cs
T
Meleki 5f3e308975 feat: ask_user_choice Tool – interaktive Auswahlmöglichkeiten im Chat
Neues Tool 'ask_user_choice' analog zum AskUserQuestion aus dem Claude Agent SDK.
Claude kann dem User Multiple-Choice-Fragen stellen, wenn es mehrere sinnvolle
Wege gibt. Der User wählt per Button, Claude arbeitet mit der Antwort weiter.

Backend:
- ToolService: ask_user_choice Tool-Definition (question + choices[2-5])
- AnthropicService/ProcessStream: Tool wird NICHT ausgeführt, sondern als
  SSE-Event 'user_choice' an den Client gesendet. stopReason wird auf
  'awaiting_user_choice' gesetzt (mit Guard gegen Überschreibung durch
  message_delta)
- ChatEndpoint: Erkennung von Choice-Responses (choiceToolCallId + choiceResult),
  tool_result wird in die korrekte Chain gespeichert (wo der tool_use liegt),
  toolChoice wird auf 'auto' gesetzt damit Claude frei antworten kann
- ChatService: FindChainWithToolCall() zum Auffinden der richtigen Chain,
  BuildHistoryFromDb() erweitert um Choice-Chain-Erkennung (wenn letzte Chain
  leer/nur tool_result → vorletzte Chain vollständig rekonstruieren)
- ChatModels: ChatRequest um ChoiceToolCallId + ChoiceResult erweitert

Frontend:
- api.js: apiChatChoice() für Choice-Responses
- chat.js: handleStreamEvent() als shared Handler extrahiert (sendMessage +
  handleChoiceClick nutzen denselben Code), handleChoiceClick() startet neuen
  Stream nach Button-Klick
- ui.js: addChoiceBubble() (live/interaktiv) + addChoiceBubbleStatic() (History)
- history.js: Choice-Bubbles in _renderMessagesIntoFragment + renderMessages,
  Voraus-Suche nach zugehörigem tool_result, unbeantwortet = klickbare Buttons
- chat.css: Styling für Choice-Bubbles (Primary-Color-Outline-Buttons, Hover,
  Selected/Unselected States)

Robustheit:
- Backend-Fallback wenn Claude choices als String statt Array sendet
- Frontend-Guard: Array.isArray(choices) an allen 4 Render-Stellen
- Page-Refresh mit offener Choice → aktive Buttons werden korrekt gerendert
2026-03-07 11:30:55 +01:00

501 lines
24 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.FileSystemGlobbing;
using Microsoft.Extensions.FileSystemGlobbing.Abstractions;
namespace Gott.Services;
public class ToolService
{
public const string AskUserChoiceToolName = "ask_user_choice";
public object[] GetToolDefinitions(bool includeFileTools = false, bool includeMemory = false)
{
var allTools = new List<object>();
// ask_user_choice ist immer verfügbar wenn Tools aktiv sind
allTools.Add(GetAskUserChoiceTool());
if (includeFileTools)
allTools.AddRange(GetFileTools());
if (includeMemory)
allTools.AddRange(GetMemoryToolDefinitions());
return allTools.ToArray();
}
private object GetAskUserChoiceTool()
{
return new
{
name = AskUserChoiceToolName,
description = "Stellt dem User eine Frage mit Auswahlmöglichkeiten, wenn du unsicher bist oder es mehrere sinnvolle Wege gibt. Der User wählt eine Option und du arbeitest mit seiner Antwort weiter. Nutze dies sparsam – nur wenn die Entscheidung wirklich vom User getroffen werden sollte.",
input_schema = new
{
type = "object",
properties = new Dictionary<string, object>
{
["question"] = new { type = "string", description = "Die Frage an den User" },
["choices"] = new
{
type = "array",
description = "Liste der Auswahlmöglichkeiten (2-5 Optionen). MUSS ein JSON-Array mit Strings sein, z.B. [\"Option A\", \"Option B\", \"Option C\"]. Niemals einen einzelnen String verwenden.",
items = new { type = "string", description = "Eine einzelne Auswahlmöglichkeit als kurzer, prägnanter Text" },
minItems = 2,
maxItems = 5
}
},
required = new[] { "question", "choices" }
}
};
}
private object[] GetFileTools()
{
return new object[]
{
new
{
name = "read_file",
description = "Liest den Inhalt einer Datei. Gibt den Textinhalt zurück.",
input_schema = new
{
type = "object",
properties = new Dictionary<string, object>
{
["path"] = new { type = "string", description = "Relativer Dateipfad" }
},
required = new[] { "path" }
}
},
new
{
name = "write_file",
description = "Erstellt oder überschreibt eine Datei mit dem angegebenen Inhalt.",
input_schema = new
{
type = "object",
properties = new Dictionary<string, object>
{
["path"] = new { type = "string", description = "Relativer Dateipfad" },
["content"] = new { type = "string", description = "Dateiinhalt" }
},
required = new[] { "path", "content" }
}
},
new
{
name = "edit_file",
description = "Ersetzt einen exakten Textabschnitt in einer Datei. oldText muss exakt matchen.",
input_schema = new
{
type = "object",
properties = new Dictionary<string, object>
{
["path"] = new { type = "string", description = "Relativer Dateipfad" },
["old_text"] = new { type = "string", description = "Exakter Text der ersetzt werden soll" },
["new_text"] = new { type = "string", description = "Neuer Text" }
},
required = new[] { "path", "old_text", "new_text" }
}
},
new
{
name = "list_files",
description = "Listet Dateien und Ordner in einem Verzeichnis auf.",
input_schema = new
{
type = "object",
properties = new Dictionary<string, object>
{
["path"] = new { type = "string", description = "Relativer Verzeichnispfad (leer = Root)" }
},
required = Array.Empty<string>()
}
},
new
{
name = "delete_file",
description = "Löscht eine Datei oder ein leeres Verzeichnis.",
input_schema = new
{
type = "object",
properties = new Dictionary<string, object>
{
["path"] = new { type = "string", description = "Relativer Pfad zur Datei oder zum Verzeichnis" }
},
required = new[] { "path" }
}
},
new
{
name = "execute",
description = "Führt einen Shell-Befehl im Workspace-Verzeichnis aus. Gibt stdout und stderr zurück. Timeout: 180 Sekunden.",
input_schema = new
{
type = "object",
properties = new Dictionary<string, object>
{
["command"] = new { type = "string", description = "Shell-Befehl zum Ausführen" }
},
required = new[] { "command" }
}
},
new
{
name = "glob",
description = "Findet Dateien im Workspace anhand eines Glob-Patterns (z.B. '**/*.cs', 'src/*.js'). Gibt eine Liste passender Dateipfade zurück.",
input_schema = new
{
type = "object",
properties = new Dictionary<string, object>
{
["pattern"] = new { type = "string", description = "Glob-Pattern, z.B. '**/*.cs' oder 'src/**/*.js'" },
["path"] = new { type = "string", description = "Startverzeichnis (relativ zum Workspace, leer = Root)" }
},
required = new[] { "pattern" }
}
},
new
{
name = "grep",
description = "Durchsucht Dateien im Workspace nach einem Regex- oder Text-Pattern. Gibt Treffer mit Dateiname und Zeilennummer zurück.",
input_schema = new
{
type = "object",
properties = new Dictionary<string, object>
{
["pattern"] = new { type = "string", description = "Suchbegriff oder Regex-Pattern" },
["path"] = new { type = "string", description = "Zu durchsuchendes Verzeichnis oder Datei (relativ, leer = Root)" },
["glob"] = new { type = "string", description = "Dateifilter als Glob-Pattern, z.B. '*.cs' (leer = alle Dateien)" },
["case_sensitive"] = new { type = "boolean", description = "Groß-/Kleinschreibung beachten (Default: false)" }
},
required = new[] { "pattern" }
}
},
new
{
name = "todo_write",
description = "Schreibt die komplette Todo-Liste. Ersetzt immer die gesamte Liste. Nutze dies um Aufgaben zu planen und den Fortschritt zu verfolgen.",
input_schema = new
{
type = "object",
properties = new Dictionary<string, object>
{
["todos"] = new
{
type = "array",
description = "Liste aller Todo-Einträge",
items = new
{
type = "object",
properties = new Dictionary<string, object>
{
["id"] = new { type = "string", description = "Eindeutige ID (z.B. '1', '2', 'task-1')" },
["content"] = new { type = "string", description = "Beschreibung der Aufgabe" },
["status"] = new { type = "string", @enum = new[] { "pending", "in_progress", "completed" }, description = "Status der Aufgabe" },
["priority"] = new { type = "string", @enum = new[] { "high", "medium", "low" }, description = "Priorität (optional)" }
},
required = new[] { "id", "content", "status" }
}
}
},
required = new[] { "todos" }
}
}
};
}
private object[] GetMemoryToolDefinitions()
{
return new object[]
{
new
{
name = "memory_read",
description = "Liest eine Memory-Datei des Agents. Nutze dies um gespeicherte Erinnerungen, Notizen oder Kontextinformationen abzurufen.",
input_schema = new
{
type = "object",
properties = new Dictionary<string, object>
{
["path"] = new { type = "string", description = "Relativer Pfad zur Memory-Datei (z.B. 'MEMORY.md' oder 'memory/YYYY-MM-DD.md')" }
},
required = new[] { "path" }
}
},
new
{
name = "memory_write",
description = "Schreibt oder überschreibt eine Memory-Datei. Nutze dies um wichtige Informationen, Entscheidungen oder Präferenzen dauerhaft zu speichern.",
input_schema = new
{
type = "object",
properties = new Dictionary<string, object>
{
["path"] = new { type = "string", description = "Relativer Pfad zur Memory-Datei (z.B. 'MEMORY.md' oder 'memory/YYYY-MM-DD.md')" },
["content"] = new { type = "string", description = "Inhalt der Datei" },
["append"] = new { type = "boolean", description = "Wenn true, wird der Inhalt angehängt statt überschrieben (Default: false)" }
},
required = new[] { "path", "content" }
}
},
new
{
name = "memory_list",
description = "Listet alle Memory-Dateien des Agents auf.",
input_schema = new
{
type = "object",
properties = new Dictionary<string, object>
{
["path"] = new { type = "string", description = "Optionaler Unterpfad zum Auflisten (leer = Root)" }
},
required = Array.Empty<string>()
}
}
};
}
public async Task<string> ExecuteTool(string name, JsonElement input, string workspace, MemoryService memoryService = null, string agentId = null, CancellationToken cancellationToken = default)
{
switch (name)
{
case "read_file":
{
if (string.IsNullOrEmpty(workspace))
return "Fehler: Kein Workspace konfiguriert";
var path = ResolvePath(input.GetProperty("path").GetString() ?? "", workspace);
if (!File.Exists(path))
return $"Fehler: Datei nicht gefunden: {Path.GetRelativePath(workspace, path)}";
return File.ReadAllText(path);
}
case "write_file":
{
if (string.IsNullOrEmpty(workspace))
return "Fehler: Kein Workspace konfiguriert";
var path = ResolvePath(input.GetProperty("path").GetString() ?? "", workspace);
var dir = Path.GetDirectoryName(path);
if (dir != null) Directory.CreateDirectory(dir);
var content = input.GetProperty("content").GetString() ?? "";
File.WriteAllText(path, content);
return $"Datei geschrieben: {Path.GetRelativePath(workspace, path)} ({content.Length} Zeichen)";
}
case "edit_file":
{
if (string.IsNullOrEmpty(workspace))
return "Fehler: Kein Workspace konfiguriert";
var path = ResolvePath(input.GetProperty("path").GetString() ?? "", workspace);
if (!File.Exists(path))
return $"Fehler: Datei nicht gefunden: {Path.GetRelativePath(workspace, path)}";
var fileContent = File.ReadAllText(path);
var oldText = input.GetProperty("old_text").GetString() ?? "";
var newText = input.GetProperty("new_text").GetString() ?? "";
if (!fileContent.Contains(oldText))
return "Fehler: old_text nicht in der Datei gefunden. Exakter Match nötig.";
fileContent = fileContent.Replace(oldText, newText);
File.WriteAllText(path, fileContent);
return $"Datei bearbeitet: {Path.GetRelativePath(workspace, path)}";
}
case "list_files":
{
if (string.IsNullOrEmpty(workspace))
return "Fehler: Kein Workspace konfiguriert";
var path = ResolvePath(input.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "", workspace);
if (!Directory.Exists(path))
return $"Fehler: Verzeichnis nicht gefunden: {Path.GetRelativePath(workspace, path)}";
var entries = new List<string>();
foreach (var dir in Directory.GetDirectories(path))
entries.Add(Path.GetRelativePath(workspace, dir) + "/");
foreach (var file in Directory.GetFiles(path))
entries.Add(Path.GetRelativePath(workspace, file));
return entries.Count == 0 ? "(leer)" : string.Join("\n", entries);
}
case "delete_file":
{
if (string.IsNullOrEmpty(workspace))
return "Fehler: Kein Workspace konfiguriert";
var path = ResolvePath(input.GetProperty("path").GetString() ?? "", workspace);
if (File.Exists(path))
{
File.Delete(path);
return $"Datei gelöscht: {Path.GetRelativePath(workspace, path)}";
}
if (Directory.Exists(path))
{
Directory.Delete(path, recursive: false);
return $"Verzeichnis gelöscht: {Path.GetRelativePath(workspace, path)}";
}
return $"Fehler: Nicht gefunden: {Path.GetRelativePath(workspace, path)}";
}
case "execute":
{
if (string.IsNullOrEmpty(workspace))
return "Fehler: Kein Workspace konfiguriert";
var command = input.GetProperty("command").GetString() ?? "";
var psi = new ProcessStartInfo
{
FileName = "/bin/bash",
WorkingDirectory = workspace,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
};
using var proc = Process.Start(psi);
if (proc == null) return "Fehler: Prozess konnte nicht gestartet werden";
await proc.StandardInput.WriteAsync(command);
proc.StandardInput.Close();
// Timeout (180s) UND externen CancellationToken verknüpfen
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromSeconds(180));
try
{
await proc.WaitForExitAsync(cts.Token);
}
catch (OperationCanceledException)
{
proc.Kill(entireProcessTree: true);
// Wenn der externe Token abgebrochen wurde, weiterwerfen
cancellationToken.ThrowIfCancellationRequested();
return "Fehler: Timeout nach 180 Sekunden - Prozess abgebrochen";
}
var stdout = await proc.StandardOutput.ReadToEndAsync();
var stderr = await proc.StandardError.ReadToEndAsync();
var result = new StringBuilder();
if (!string.IsNullOrEmpty(stdout)) result.Append(stdout);
if (!string.IsNullOrEmpty(stderr))
{
if (result.Length > 0) result.Append("\n--- stderr ---\n");
result.Append(stderr);
}
if (result.Length == 0) result.Append($"(exit code {proc.ExitCode})");
else if (proc.ExitCode != 0) result.Append($"\n(exit code {proc.ExitCode})");
return result.ToString();
}
case "glob":
{
if (string.IsNullOrEmpty(workspace))
return "Fehler: Kein Workspace konfiguriert";
var pattern = input.GetProperty("pattern").GetString() ?? "*";
var basePath = input.TryGetProperty("path", out var gp) ? gp.GetString() ?? "" : "";
var searchRoot = string.IsNullOrEmpty(basePath)
? workspace
: ResolvePath(basePath, workspace);
if (!Directory.Exists(searchRoot))
return $"Fehler: Verzeichnis nicht gefunden: {basePath}";
var matcher = new Matcher();
matcher.AddInclude(pattern);
var dirInfo = new DirectoryInfoWrapper(new DirectoryInfo(searchRoot));
var result = matcher.Execute(dirInfo);
var matches = result.Files
.Select(f => Path.GetRelativePath(workspace, Path.Combine(searchRoot, f.Path)))
.OrderBy(f => f)
.ToList();
return matches.Count == 0 ? "(keine Treffer)" : string.Join("\n", matches);
}
case "grep":
{
if (string.IsNullOrEmpty(workspace))
return "Fehler: Kein Workspace konfiguriert";
var pattern = input.GetProperty("pattern").GetString() ?? "";
var searchPath = input.TryGetProperty("path", out var sp) ? sp.GetString() ?? "" : "";
var fileGlob = input.TryGetProperty("glob", out var fg) ? fg.GetString() ?? "*" : "*";
var caseSensitive = input.TryGetProperty("case_sensitive", out var cs) && cs.GetBoolean();
var regexOptions = caseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase;
Regex regex;
try { regex = new Regex(pattern, regexOptions); }
catch { regex = new Regex(Regex.Escape(pattern), regexOptions); }
var searchRoot = string.IsNullOrEmpty(searchPath)
? workspace
: ResolvePath(searchPath, workspace);
IEnumerable<string> files;
if (File.Exists(searchRoot))
files = new[] { searchRoot };
else if (Directory.Exists(searchRoot))
files = Directory.GetFiles(searchRoot, fileGlob, SearchOption.AllDirectories);
else
return $"Fehler: Pfad nicht gefunden: {searchPath}";
var results = new List<string>();
foreach (var file in files.OrderBy(f => f))
{
string[] lines;
try { lines = File.ReadAllLines(file); }
catch { continue; }
for (var i = 0; i < lines.Length; i++)
{
if (regex.IsMatch(lines[i]))
results.Add($"{Path.GetRelativePath(workspace, file)}:{i + 1}: {lines[i].Trim()}");
}
}
if (results.Count == 0) return "(keine Treffer)";
if (results.Count > 500) results = results.Take(500).Append("... (auf 500 Treffer begrenzt)").ToList();
return string.Join("\n", results);
}
case "todo_write":
{
var todos = input.GetProperty("todos");
var sb = new StringBuilder();
foreach (var todo in todos.EnumerateArray())
{
var id = todo.GetProperty("id").GetString();
var content = todo.GetProperty("content").GetString();
var status = todo.GetProperty("status").GetString();
var priority = todo.TryGetProperty("priority", out var prio) ? prio.GetString() : null;
var icon = status switch { "completed" => "✅", "in_progress" => "🔄", _ => "⬜" };
var prioTag = priority != null ? $" [{priority}]" : "";
sb.AppendLine($"{icon} [{id}]{prioTag} {content}");
}
return sb.Length == 0 ? "(leere Todo-Liste)" : sb.ToString().TrimEnd();
}
case "memory_read":
{
if (memoryService == null || string.IsNullOrEmpty(agentId))
return "Fehler: Memory-Service nicht verfügbar";
var path = input.GetProperty("path").GetString() ?? "";
var content = memoryService.ReadMemoryFile(agentId, path);
if (content == null)
return $"Datei nicht gefunden: {path}";
return content;
}
case "memory_write":
{
if (memoryService == null || string.IsNullOrEmpty(agentId))
return "Fehler: Memory-Service nicht verfügbar";
var path = input.GetProperty("path").GetString() ?? "";
var content = input.GetProperty("content").GetString() ?? "";
var append = input.TryGetProperty("append", out var a) && a.GetBoolean();
if (append)
memoryService.AppendMemoryFile(agentId, path, content);
else
memoryService.WriteMemoryFile(agentId, path, content);
return $"Memory-Datei {(append ? "erweitert" : "geschrieben")}: {path} ({content.Length} Zeichen)";
}
case "memory_list":
{
if (memoryService == null || string.IsNullOrEmpty(agentId))
return "Fehler: Memory-Service nicht verfügbar";
var subPath = input.TryGetProperty("path", out var p) ? p.GetString() ?? "" : "";
var files = memoryService.ListMemoryFiles(agentId, subPath);
return files.Count == 0 ? "(keine Memory-Dateien vorhanden)" : string.Join("\n", files);
}
default:
return $"Fehler: Unbekanntes Tool: {name}";
}
}
private string ResolvePath(string relativePath, string workspace)
{
var full = Path.GetFullPath(Path.Combine(workspace, relativePath ?? ""));
if (!full.StartsWith(workspace))
throw new Exception("Zugriff außerhalb des Workspace nicht erlaubt");
return full;
}
}