From 5f2b2e2f183bcb65c54fce6263abba21e37ab35c Mon Sep 17 00:00:00 2001 From: Meleki Date: Tue, 3 Mar 2026 14:10:07 +0100 Subject: [PATCH] feat: add glob, grep and todo_write builtin tools --- Services/ToolService.cs | 141 ++++++++++++++++++++++++++++++++++ TODO.md | 1 + wwwroot/css/file-explorer.css | 2 +- 3 files changed, 143 insertions(+), 1 deletion(-) diff --git a/Services/ToolService.cs b/Services/ToolService.cs index a0cf4f8..3caf488 100644 --- a/Services/ToolService.cs +++ b/Services/ToolService.cs @@ -2,10 +2,14 @@ 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; @@ -111,6 +115,68 @@ public class ToolService }, 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 + { + ["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 + { + ["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 + { + ["todos"] = new + { + type = "array", + description = "Liste aller Todo-Einträge", + items = new + { + type = "object", + properties = new Dictionary + { + ["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" } + } } }; } @@ -279,6 +345,81 @@ public class ToolService 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 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(); + 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)) diff --git a/TODO.md b/TODO.md index a6d27ad..24ce4a0 100644 --- a/TODO.md +++ b/TODO.md @@ -15,6 +15,7 @@ - [x] Weiteres Modell hinzufügen: "Haiku 4.5" - [ ] Weitere Tools: Glob? Search? Todo List? - [ ] Settings in Datenbank speichern (users table) anstelle von localStorage +- [ ] Einzelne Nachicht als Link teilen ## Bugs - [x] Wenn gerade gestreamt wird und man refreshed die Seite, dann verliert das Frontend den Status "Streaming..." und ich kann eine Nachricht schreiben, obwohl das nicht möglich sein sollte diff --git a/wwwroot/css/file-explorer.css b/wwwroot/css/file-explorer.css index 246fcd0..be37655 100644 --- a/wwwroot/css/file-explorer.css +++ b/wwwroot/css/file-explorer.css @@ -262,8 +262,8 @@ /* EasyMDE Toolbar */ .fe-mde-wrap .editor-toolbar { background: var(--f1-bg) !important; + border: none !important; border-bottom: var(--border-width) solid var(--f1-border) !important; - border-top: none !important; padding: 4px !important; opacity: 1 !important; }