Dies ist eine alte Version des Dokuments!
using HtmlAgilityPack; using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using UglyToad.PdfPig; namespace ZARAT; public static class OpenAI { // ========================= // Config // ========================= //private const string DEFAULT_API_BASE = "http://localhost:8080/v1"; // LM Studio / local server private const string DEFAULT_API_BASE = "https://api.openai.com/v1"; private const string DEFAULT_SYSTEM_PROMPT = "Du bist ein hilfreicher Assistent. Verwende Tools (cmd, powershell) um Informationen zu beschaffen! " + "Antworte präzise und verschwende keine unnötigen token. aber code u.ä spare nicht, den schreib immer komplett"; // History / Tool output caps (chars) private const int MAX_HISTORY_CHARS = 90000; private const int MAX_TOOL_CHARS = 30000; private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, WriteIndented = false }; private static readonly HttpClient Http = new(new HttpClientHandler { AllowAutoRedirect = true }) { Timeout = TimeSpan.FromSeconds(300) }; // Global reference for save_history private static List<ChatMessage>? CHAT_HISTORY_REF; public static async Task Main(string[] args) { // Args: [apiBase] [apiKey?] var apiBase = args.Length >= 1 ? args[0] : DEFAULT_API_BASE; var apiKey = args.Length >= 2 ? args[1] : (Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? "sk-dummy"); // Configure HttpClient headers (OpenAI-style) Http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); // /v1/models var modelIds = await TryGetModels(apiBase); if (modelIds.Count > 0) { Console.WriteLine("Verfügbare Modelle:"); for (int i = 0; i < modelIds.Count; i++) Console.WriteLine($" {i + 1}) {modelIds[i]}"); } else { Console.WriteLine("Keine Modelle gefunden (oder /v1/models nicht verfügbar)."); } // Choose model string model; while (true) { if (modelIds.Count > 0) { Console.Write($"\nWähle Modellnummer (1..{modelIds.Count}), oder tippe einen Namen: "); var choice = (Console.ReadLine() ?? "").Trim(); if (int.TryParse(choice, out int idx) && idx >= 1 && idx <= modelIds.Count) { model = modelIds[idx - 1]; break; } if (!string.IsNullOrWhiteSpace(choice)) { model = choice; break; } Console.WriteLine("Bitte eingeben."); } else { Console.Write("Model (Name): "); var choice = (Console.ReadLine() ?? "").Trim(); if (!string.IsNullOrWhiteSpace(choice)) { model = choice; break; } Console.WriteLine("Bitte eingeben."); } } Console.WriteLine($"Gewähltes Modell: {model}"); var chatHistory = new List<ChatMessage> { new ChatMessage { Role = "system", Content = DEFAULT_SYSTEM_PROMPT } }; CHAT_HISTORY_REF = chatHistory; Console.WriteLine("\nTippe 'exit' oder 'quit' zum Beenden.\n"); while (true) { Console.Write("\n##########\nFrage: "); var frage = (Console.ReadLine() ?? "").Trim(); if (string.Equals(frage, "exit", StringComparison.OrdinalIgnoreCase) || string.Equals(frage, "quit", StringComparison.OrdinalIgnoreCase)) { Console.WriteLine("Chat beendet."); break; } chatHistory.Add(new ChatMessage { Role = "user", Content = frage }); var answer = await ChatWithToolsStreamMulti( apiBase: apiBase, model: model, chatHistory: chatHistory, maxRounds: 10, requireConfirm: false, toolChoice: "auto" ); Console.WriteLine("\n"); chatHistory.Add(new ChatMessage { Role = "assistant", Content = answer }); } } // ========================= // OpenAI REST: /v1/models // ========================= private static async Task<List<string>> TryGetModels(string apiBase) { try { var url = $"{apiBase.TrimEnd('/')}/models"; using var req = new HttpRequestMessage(HttpMethod.Get, url); using var resp = await Http.SendAsync(req); var body = await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) return new List<string>(); using var doc = JsonDocument.Parse(body); if (!doc.RootElement.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array) return new List<string>(); var ids = new List<string>(); foreach (var m in data.EnumerateArray()) { if (m.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String) { var id = idEl.GetString(); if (!string.IsNullOrWhiteSpace(id)) ids.Add(id!); } } return ids; } catch { return new List<string>(); } } // ========================= // Core chat loop (multi-round tools) // ========================= private static async Task<string> ChatWithToolsStreamMulti( string apiBase, string model, List<ChatMessage> chatHistory, int maxRounds, bool requireConfirm, string toolChoice ) { for (int round = 0; round < maxRounds; round++) { // prune + sanitize chatHistory = SanitizeHistoryForRequest(PruneHistoryFifo(chatHistory, MAX_HISTORY_CHARS)); // keep original reference list updated if (!ReferenceEquals(CHAT_HISTORY_REF, null) && ReferenceEquals(CHAT_HISTORY_REF, chatHistory) == false) { CHAT_HISTORY_REF = chatHistory; } var toolCallsAcc = new Dictionary<int, ToolCall>(); var state = new ThinkFilterState(); // streaming chat completion await StreamChatCompletion( apiBase: apiBase, model: model, messages: chatHistory, tools: ToolsSchema(), toolChoice: toolChoice, onDeltaContent: delta => StreamContentFilterThink(delta, state), onDeltaToolCalls: deltaToolCalls => MergeToolCalls(toolCallsAcc, deltaToolCalls) ); var toolCalls = toolCallsAcc.Count > 0 ? toolCallsAcc.OrderBy(kv => kv.Key).Select(kv => kv.Value).ToList() : new List<ToolCall>(); if (toolCalls.Count == 0) return state.Answer.Trim(); // add assistant msg with tool_calls chatHistory.Add(new ChatMessage { Role = "assistant", Content = string.IsNullOrWhiteSpace(state.Answer) ? "" : state.Answer.Trim(), ToolCalls = toolCalls }); foreach (var call in toolCalls) { var fname = call.Function?.Name ?? ""; var argsRaw = call.Function?.Arguments ?? "{}"; Console.WriteLine($"\n[TOOL CALL] {fname} {argsRaw}"); bool allowed = true; if (requireConfirm) { Console.Write("Erlauben? (y/n): "); allowed = string.Equals((Console.ReadLine() ?? "").Trim(), "y", StringComparison.OrdinalIgnoreCase); } if (!allowed) { chatHistory.Add(new ChatMessage { Role = "tool", ToolCallId = call.Id, Name = fname, Content = $"Tool Call '{fname}' wurde vom Nutzer abgelehnt." }); continue; } var result = ExecuteToolCall(fname, argsRaw); result = ClipToolOutput(result, MAX_TOOL_CHARS); chatHistory.Add(new ChatMessage { Role = "tool", ToolCallId = call.Id, Name = fname, Content = result }); } } return "\n[Abbruch: zu viele Tool-Runden]\n"; } // ========================= // Streaming: SSE parser // ========================= private static async Task StreamChatCompletion( string apiBase, string model, List<ChatMessage> messages, object tools, string toolChoice, Action<string> onDeltaContent, Action<List<ToolCallDelta>> onDeltaToolCalls ) { var url = $"{apiBase.TrimEnd('/')}/chat/completions"; var payload = new Dictionary<string, object?> { ["model"] = model, ["messages"] = messages, ["tools"] = tools, ["tool_choice"] = toolChoice, ["stream"] = true }; var json = JsonSerializer.Serialize(payload, JsonOpts); using var req = new HttpRequestMessage(HttpMethod.Post, url); req.Content = new StringContent(json, Encoding.UTF8, "application/json"); using var resp = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead); resp.EnsureSuccessStatusCode(); await using var stream = await resp.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); while (!reader.EndOfStream) { var line = await reader.ReadLineAsync(); if (line == null) break; if (line.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) { var data = line.Substring(5).Trim(); if (data == "[DONE]") break; if (string.IsNullOrWhiteSpace(data)) continue; using var doc = JsonDocument.Parse(data); var root = doc.RootElement; if (!root.TryGetProperty("choices", out var choices) || choices.ValueKind != JsonValueKind.Array) continue; var choice0 = choices[0]; if (!choice0.TryGetProperty("delta", out var delta) || delta.ValueKind != JsonValueKind.Object) continue; // delta.content if (delta.TryGetProperty("content", out var contentEl) && contentEl.ValueKind == JsonValueKind.String) { var txt = contentEl.GetString() ?? ""; if (txt.Length > 0) onDeltaContent(txt); } // delta.tool_calls if (delta.TryGetProperty("tool_calls", out var tcEl) && tcEl.ValueKind == JsonValueKind.Array) { var list = new List<ToolCallDelta>(); foreach (var item in tcEl.EnumerateArray()) { var d = new ToolCallDelta(); if (item.TryGetProperty("index", out var idxEl) && idxEl.ValueKind == JsonValueKind.Number) d.Index = idxEl.GetInt32(); if (item.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String) d.Id = idEl.GetString(); if (item.TryGetProperty("type", out var typeEl) && typeEl.ValueKind == JsonValueKind.String) d.Type = typeEl.GetString(); if (item.TryGetProperty("function", out var fnEl) && fnEl.ValueKind == JsonValueKind.Object) { if (fnEl.TryGetProperty("name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String) d.FunctionName = nameEl.GetString(); if (fnEl.TryGetProperty("arguments", out var argEl) && argEl.ValueKind == JsonValueKind.String) d.FunctionArguments = argEl.GetString(); } list.Add(d); } if (list.Count > 0) onDeltaToolCalls(list); } } } } // ========================= // Tool schema // ========================= private static object ToolsSchema() { // OpenAI tools schema format return new object[] { new { type = "function", function = new { name = "read_pdf_text", description = "Extrahiert Text aus einem PDF (für normale PDFs mit Text-Layer).", parameters = new { type = "object", properties = new Dictionary<string, object> { ["path"] = new { type = "string" }, ["start_page"] = new { type = "integer", @default = 1 }, ["max_pages"] = new { type = "integer", @default = 5 }, ["max_chars"] = new { type = "integer", @default = 12000 }, }, required = new[] { "path" } } } }, new { type = "function", function = new { name = "list_files", description = "Listet alle Dateien und Ordner in einem angegebenen Verzeichnis.", parameters = new { type = "object", properties = new Dictionary<string, object> { ["directory"] = new { type = "string", description = "Pfad zum Verzeichnis" } }, required = new[] { "directory" } } } }, new { type = "function", function = new { name = "read_file", description = "Liest Inhalt einer Datei. Unterstützt zeilen/zeichenbasiertes Teil-Lesen, um Kontext zu sparen.", parameters = new { type = "object", properties = new Dictionary<string, object> { ["path"] = new { type = "string", description = "Pfad zur Datei" }, ["start_line"] = new { type = "integer", description = "Startzeile (1-basiert)", @default = 1 }, ["max_lines"] = new { type = "integer", description = "Max. Anzahl Zeilen", @default = 400 }, ["tail_lines"] = new { type = "integer", description = "Liest die letzten N Zeilen (überschreibt start_line/max_lines)" }, ["start_char"] = new { type = "integer", description = "Startindex Zeichen (0-basiert)" }, ["max_chars"] = new { type = "integer", description = "Max. Anzahl Zeichen" }, }, required = new[] { "path" } } } }, new { type = "function", function = new { name = "execute_cmd", description = "Führt einen Shell Befehl (cmd) aus und liest die Ausgabe von Stdout.", parameters = new { type = "object", properties = new Dictionary<string, object> { ["command"] = new { type = "string", description = "Den auszuführenden Befehl" } }, required = new[] { "command" } } } }, new { type = "function", function = new { name = "execute_powershell", description = "Führt einen Powershell Befehl aus und liest die Ausgabe von Stdout.", parameters = new { type = "object", properties = new Dictionary<string, object> { ["command"] = new { type = "string", description = "Reiner PS-Code (ohne 'powershell -Command ...')" } }, required = new[] { "command" } } } }, new { type = "function", function = new { name = "fetch_html", description = "Gibt den sichtbaren Text einer Seite zurück (aus HTML extrahiert).", parameters = new { type = "object", properties = new Dictionary<string, object> { ["url"] = new { type = "string", description = "http(s)-URL" }, ["max_chars"] = new { type = "integer", description = "Hartes Limit (Zeichen)", @default = 8000 }, }, required = new[] { "url" } } } }, new { type = "function", function = new { name = "save_history", description = "Speichert die aktuelle Chat-History in eine JSON-Datei.", parameters = new { type = "object", properties = new Dictionary<string, object> { ["path"] = new { type = "string", description = "Zielpfad, z.B. './chat_history.json'" }, ["pretty"] = new { type = "boolean", description = "JSON hübsch formatieren", @default = true } }, required = new[] { "path" } } } } }; } // ========================= // Tool call merge (streaming) // ========================= private static void MergeToolCalls(Dictionary<int, ToolCall> acc, List<ToolCallDelta> deltas) { foreach (var tc in deltas) { int idx = tc.Index ?? 0; if (!acc.TryGetValue(idx, out var call)) { call = new ToolCall { Id = tc.Id, Type = tc.Type ?? "function", Function = new ToolFunction { Name = tc.FunctionName, Arguments = "" } }; acc[idx] = call; } if (!string.IsNullOrWhiteSpace(tc.Id)) call.Id = tc.Id; if (!string.IsNullOrWhiteSpace(tc.FunctionName)) call.Function!.Name = tc.FunctionName; if (!string.IsNullOrWhiteSpace(tc.FunctionArguments)) call.Function!.Arguments += tc.FunctionArguments; } } // ========================= // <think> filter (stream) // ========================= private sealed class ThinkFilterState { public bool InThink { get; set; } public string Buf { get; set; } = ""; public string Answer { get; set; } = ""; } private static void StreamContentFilterThink(string deltaText, ThinkFilterState state) { state.Buf += deltaText ?? ""; while (state.Buf.Length > 0) { if (!state.InThink) { int start = state.Buf.IndexOf("<think>", StringComparison.OrdinalIgnoreCase); if (start < 0) { state.Answer += state.Buf; Console.Write(state.Buf); state.Buf = ""; return; } var before = state.Buf.Substring(0, start); state.Answer += before; Console.Write(before); state.Buf = state.Buf.Substring(start + "<think>".Length); state.InThink = true; Console.Write("\n🤔 "); } else { int end = state.Buf.IndexOf("</think>", StringComparison.OrdinalIgnoreCase); if (end < 0) { Console.Write(state.Buf); state.Buf = ""; return; } var chunk = state.Buf.Substring(0, end); Console.Write(chunk); state.Buf = state.Buf.Substring(end + "</think>".Length); state.InThink = false; Console.Write("\n"); } } } // ========================= // Tool dispatcher // ========================= private static string ExecuteToolCall(string fname, string argsRaw) { JsonDocument? doc = null; try { doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argsRaw) ? "{}" : argsRaw); } catch (Exception e) { return $"Fehler: Konnte Tool-Argumente nicht als JSON parsen: {e.Message}\nRAW:\n{argsRaw}"; } var root = doc.RootElement; try { return fname switch { "list_files" => ListFiles(GetString(root, "directory") ?? ""), "read_file" => ReadFile( path: GetString(root, "path") ?? "", startLine: GetInt(root, "start_line") ?? 1, maxLines: GetInt(root, "max_lines") ?? 400, startChar: GetInt(root, "start_char"), maxChars: GetInt(root, "max_chars"), tailLines: GetInt(root, "tail_lines") ), "execute_cmd" => ExecuteCmd(GetString(root, "command") ?? ""), "execute_powershell" => ExecutePowerShell(GetString(root, "command") ?? ""), "fetch_html" => FetchHtml(GetString(root, "url") ?? "", GetInt(root, "max_chars") ?? 8000), "save_history" => SaveHistory(GetString(root, "path") ?? "chat_history.json", GetBool(root, "pretty") ?? true), "read_pdf_text" => ReadPdfText( path: GetString(root, "path") ?? "", startPage: GetInt(root, "start_page") ?? 1, maxPages: GetInt(root, "max_pages") ?? 5, maxChars: GetInt(root, "max_chars") ?? 12000 ), _ => $"Unbekannte Funktion: {fname}" }; } catch (Exception e) { return $"Tool-Fehler ({fname}): {e.Message}"; } finally { doc.Dispose(); } } private static string? GetString(JsonElement root, string prop) => root.TryGetProperty(prop, out var el) && el.ValueKind == JsonValueKind.String ? el.GetString() : null; private static int? GetInt(JsonElement root, string prop) => root.TryGetProperty(prop, out var el) && el.ValueKind == JsonValueKind.Number ? el.GetInt32() : null; private static bool? GetBool(JsonElement root, string prop) => root.TryGetProperty(prop, out var el) && (el.ValueKind == JsonValueKind.True || el.ValueKind == JsonValueKind.False) ? el.GetBoolean() : null; // ========================= // Tool implementations // ========================= private static string ListFiles(string directory) { try { var entries = Directory.EnumerateFileSystemEntries(directory).ToList(); // mimic python: files first then folders (optional) var files = entries.Where(File.Exists).Select(Path.GetFileName).Where(n => n != null).ToList(); var dirs = entries.Where(Directory.Exists).Select(Path.GetFileName).Where(n => n != null).ToList(); return string.Join("\n", files.Concat(dirs)); } catch (Exception e) { return $"Fehler beim Zugriff auf das Verzeichnis: {e.Message}"; } } private static string ReadFile( string path, int startLine = 1, int maxLines = 400, int? startChar = null, int? maxChars = null, int? tailLines = null ) { try { if (startLine < 1) startLine = 1; if (maxLines < 1) maxLines = 1; var data = File.ReadAllText(path, Encoding.UTF8); if (startChar.HasValue || maxChars.HasValue) { int s = Math.Max(0, startChar ?? 0); if (s > data.Length) s = data.Length; string outStr = maxChars.HasValue ? data.Substring(s, Math.Min(maxChars.Value, data.Length - s)) : data.Substring(s); return outStr; } // line-based var lines = File.ReadAllLines(path, Encoding.UTF8); int total = lines.Length; int startIdx; string[] sel; if (tailLines.HasValue && tailLines.Value > 0) { int n = Math.Min(tailLines.Value, total); startIdx = Math.Max(0, total - n); sel = lines.Skip(startIdx).Take(n).ToArray(); } else { startIdx = startLine - 1; if (startIdx < 0) startIdx = 0; sel = lines.Skip(startIdx).Take(maxLines).ToArray(); } var header = $"[read_file] path={path}\n" + $"[read_file] total_lines={total} selected_lines={sel.Length} range={startIdx + 1}-{startIdx + sel.Length}\n\n"; return header + string.Join("\n", sel); } catch (Exception e) { return $"Fehler beim Lesen der Datei: {e.Message}"; } } private static string ExecuteCmd(string command) { try { bool isWindows = OperatingSystem.IsWindows(); var psi = new ProcessStartInfo { FileName = isWindows ? "cmd.exe" : "/bin/bash", Arguments = isWindows ? $"/c {command}" : $"-c \"{command.Replace("\"", "\\\"")}\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; using var p = Process.Start(psi)!; var stdout = p.StandardOutput.ReadToEnd(); var stderr = p.StandardError.ReadToEnd(); p.WaitForExit(); if (p.ExitCode == 0) return stdout; return $"Fehler: {(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr)}"; } catch (Exception e) { return $"Fehler: {e.Message}"; } } private static string ExecutePowerShell(string script) { try { // Prefer pwsh if available; fallback to powershell on Windows string exe = OperatingSystem.IsWindows() ? "powershell" : "pwsh"; var psi = new ProcessStartInfo { FileName = exe, Arguments = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command " + QuoteForProcess(script), RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; using var p = Process.Start(psi)!; var stdout = p.StandardOutput.ReadToEnd(); var stderr = p.StandardError.ReadToEnd(); p.WaitForExit(); if (p.ExitCode == 0) return stdout; return $"Fehler: {(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr)}"; } catch (Exception e) { return $"Fehler: {e.Message}"; } } private static string QuoteForProcess(string s) { // simplest robust quote for -Command // wrap in double quotes and escape internal quotes with backtick for PowerShell var escaped = s.Replace("\"", "`\""); return $"\"{escaped}\""; } private static string FetchHtml(string url, int maxChars = 8000) { try { if (!Regex.IsMatch(url, "^https?://", RegexOptions.IgnoreCase)) return "Fehler: Nur http(s)-URLs sind erlaubt."; using var req = new HttpRequestMessage(HttpMethod.Get, url); req.Headers.UserAgent.ParseAdd("Mozilla/5.0 (compatible; LLM-Helper/1.2)"); using var resp = Http.Send(req); resp.EnsureSuccessStatusCode(); var html = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult(); var doc = new HtmlDocument(); doc.LoadHtml(html); // remove script/style/noscript/template var toRemove = doc.DocumentNode.SelectNodes("//script|//style|//noscript|//template"); if (toRemove != null) { foreach (var n in toRemove) n.Remove(); } var text = HtmlEntity.DeEntitize(doc.DocumentNode.InnerText); text = Regex.Replace(text, @"\n{3,}", "\n\n").Trim(); if (text.Length > maxChars) text = text.Substring(0, maxChars) + "\n…[gekürzt]"; return text; } catch (Exception e) { return $"Fehler beim Abrufen der URL: {e.Message}"; } } private static string ReadPdfText(string path, int startPage = 1, int maxPages = 5, int maxChars = 12000) { try { using var pdf = PdfDocument.Open(path); int n = pdf.NumberOfPages; int sp = Math.Max(1, startPage); int ep = Math.Min(n, sp + maxPages - 1); var sb = new StringBuilder(); for (int p = sp; p <= ep; p++) { var page = pdf.GetPage(p); var txt = page.Text ?? ""; sb.Append($"\n--- Seite {p}/{n} ---\n"); sb.Append(txt); sb.Append('\n'); } var outStr = sb.ToString().Trim(); if (outStr.Length > maxChars) outStr = outStr.Substring(0, maxChars) + "\n…[gekürzt]"; return $"[read_pdf_text] path={path} pages={sp}-{ep}/{n}\n\n{outStr}"; } catch (Exception e) { return $"Fehler beim PDF-Lesen: {e.Message}"; } } private static string SaveHistory(string path, bool pretty = true) { try { if (CHAT_HISTORY_REF == null) return "Fehler: CHAT_HISTORY_REF ist nicht gesetzt."; var dir = Path.GetDirectoryName(path); if (!string.IsNullOrWhiteSpace(dir)) Directory.CreateDirectory(dir); var opts = new JsonSerializerOptions(JsonOpts) { WriteIndented = pretty }; var json = JsonSerializer.Serialize(CHAT_HISTORY_REF, opts); File.WriteAllText(path, json, Encoding.UTF8); return $"OK: History gespeichert nach '{path}' ({CHAT_HISTORY_REF.Count} messages)."; } catch (Exception e) { return $"Fehler beim Speichern der History: {e.Message}"; } } // ========================= // History trimming (FIFO) // ========================= private static int MsgCostChars(ChatMessage m) { int cost = m.Content?.Length ?? 0; if (m.ToolCalls != null && m.ToolCalls.Count > 0) { try { cost += JsonSerializer.Serialize(m.ToolCalls, JsonOpts).Length; } catch { cost += 500; } } return cost; } private static List<ChatMessage> PruneHistoryFifo(List<ChatMessage> messages, int maxChars) { if (messages.Count == 0) return messages; var system = messages.Where(m => m.Role == "system").ToList(); var rest = messages.Where(m => m.Role != "system").ToList(); int total = system.Sum(MsgCostChars); int budget = maxChars; var keptRev = new List<ChatMessage>(); foreach (var m in rest.AsEnumerable().Reverse()) { int c = MsgCostChars(m); if (keptRev.Count > 0 && (total + c) > budget) break; keptRev.Add(m); total += c; } keptRev.Reverse(); var kept = new List<ChatMessage>(); kept.AddRange(system); kept.AddRange(keptRev); return kept; } private static string ClipToolOutput(string text, int maxChars) { if (text == null) return ""; if (text.Length <= maxChars) return text; return text.Substring(0, maxChars) + "\n…[tool output gekürzt]"; } // ========================= // sanitize history sequences // ========================= private static List<ChatMessage> SanitizeHistoryForRequest(List<ChatMessage> messages) { var outList = new List<ChatMessage>(); var pending = new HashSet<string>(); foreach (var m in messages) { if (m.Role == "assistant") { pending.Clear(); if (m.ToolCalls != null) { foreach (var tc in m.ToolCalls) { if (!string.IsNullOrWhiteSpace(tc.Id)) pending.Add(tc.Id!); } } outList.Add(m); continue; } if (m.Role == "tool") { var tcid = m.ToolCallId; if (tcid != null && pending.Contains(tcid)) { outList.Add(m); pending.Remove(tcid); } // else drop invalid tool message continue; } if (m.Role == "user" || m.Role == "system") pending.Clear(); outList.Add(m); } return outList; } // ========================= // DTOs (OpenAI chat format) // ========================= public sealed class ChatMessage { [JsonPropertyName("role")] public string Role { get; set; } = ""; [JsonPropertyName("content")] public string? Content { get; set; } // assistant tool_calls [JsonPropertyName("tool_calls")] public List<ToolCall>? ToolCalls { get; set; } // tool message fields [JsonPropertyName("tool_call_id")] public string? ToolCallId { get; set; } [JsonPropertyName("name")] public string? Name { get; set; } } public sealed class ToolCall { [JsonPropertyName("id")] public string? Id { get; set; } [JsonPropertyName("type")] public string Type { get; set; } = "function"; [JsonPropertyName("function")] public ToolFunction? Function { get; set; } } public sealed class ToolFunction { [JsonPropertyName("name")] public string? Name { get; set; } [JsonPropertyName("arguments")] public string? Arguments { get; set; } } // streaming delta structure (partial) private sealed class ToolCallDelta { public int? Index { get; set; } public string? Id { get; set; } public string? Type { get; set; } public string? FunctionName { get; set; } public string? FunctionArguments { get; set; } } }
using HtmlAgilityPack; using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; using System.Threading.Tasks; using UglyToad.PdfPig; using NAudio.Wave; namespace ZARAT; public static class OpenAI { // ========================= // Config // ========================= //private const string DEFAULT_API_BASE = "http://localhost:8080/v1"; // LM Studio / local server private const string DEFAULT_API_BASE = "https://api.openai.com/v1"; private const string DEFAULT_SYSTEM_PROMPT = "Du bist ein hilfreicher Assistent. Verwende Tools (cmd, powershell) um Informationen zu beschaffen! " + "Antworte präzise und verschwende keine unnötigen token. aber code u.ä spare nicht, den schreib immer komplett"; // STT (Speech-to-Text) private const string DEFAULT_STT_MODEL = "gpt-4o-mini-transcribe"; // alternativ: "whisper-1" // History / Tool output caps (chars) private const int MAX_HISTORY_CHARS = 90000; private const int MAX_TOOL_CHARS = 30000; private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, WriteIndented = false }; private static readonly HttpClient Http = new(new HttpClientHandler { AllowAutoRedirect = true }) { Timeout = TimeSpan.FromSeconds(300) }; // Global reference for save_history private static List<ChatMessage>? CHAT_HISTORY_REF; public static async Task Main(string[] args) { // Args: [apiBase] [apiKey?] var apiBase = args.Length >= 1 ? args[0] : DEFAULT_API_BASE; var apiKey = args.Length >= 2 ? args[1] : (Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? "sk-dummy"); // STT model from env (optional) var sttModel = Environment.GetEnvironmentVariable("OPENAI_STT_MODEL") ?? DEFAULT_STT_MODEL; // Configure HttpClient headers (OpenAI-style) Http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey); // /v1/models var modelIds = await TryGetModels(apiBase); if (modelIds.Count > 0) { Console.WriteLine("Verfügbare Modelle:"); for (int i = 0; i < modelIds.Count; i++) Console.WriteLine($" {i + 1}) {modelIds[i]}"); } else { Console.WriteLine("Keine Modelle gefunden (oder /v1/models nicht verfügbar)."); } // Choose model string model; while (true) { if (modelIds.Count > 0) { Console.Write($"\nWähle Modellnummer (1..{modelIds.Count}), oder tippe einen Namen: "); var choice = (Console.ReadLine() ?? "").Trim(); if (int.TryParse(choice, out int idx) && idx >= 1 && idx <= modelIds.Count) { model = modelIds[idx - 1]; break; } if (!string.IsNullOrWhiteSpace(choice)) { model = choice; break; } Console.WriteLine("Bitte eingeben."); } else { Console.Write("Model (Name): "); var choice = (Console.ReadLine() ?? "").Trim(); if (!string.IsNullOrWhiteSpace(choice)) { model = choice; break; } Console.WriteLine("Bitte eingeben."); } } Console.WriteLine($"Gewähltes Modell: {model}"); var chatHistory = new List<ChatMessage> { new ChatMessage { Role = "system", Content = DEFAULT_SYSTEM_PROMPT } }; CHAT_HISTORY_REF = chatHistory; Console.WriteLine("\nTippe 'exit' oder 'quit' zum Beenden.\n"); Console.WriteLine("Tipp: '/mic' nutzt das Mikrofon (Push-to-talk: ENTER Start, ENTER Stop).\n"); while (true) { Console.Write("\n##########\nFrage (oder /mic): "); var frage = (Console.ReadLine() ?? "").Trim(); if (string.Equals(frage, "exit", StringComparison.OrdinalIgnoreCase) || string.Equals(frage, "quit", StringComparison.OrdinalIgnoreCase)) { Console.WriteLine("Chat beendet."); break; } // Mikrofonmodus if (string.Equals(frage, "/mic", StringComparison.OrdinalIgnoreCase)) { try { frage = await RecordAndTranscribeOnce(apiBase, sttModel); Console.WriteLine($"\n[STT] Du hast gesagt: {frage}"); } catch (Exception ex) { Console.WriteLine($"\n[STT-Fehler] {ex.Message}"); continue; } if (string.IsNullOrWhiteSpace(frage)) { Console.WriteLine("[STT] Kein Text erkannt."); continue; } } chatHistory.Add(new ChatMessage { Role = "user", Content = frage }); var answer = await ChatWithToolsStreamMulti( apiBase: apiBase, model: model, chatHistory: chatHistory, maxRounds: 10, requireConfirm: false, toolChoice: "auto" ); Console.WriteLine("\n"); chatHistory.Add(new ChatMessage { Role = "assistant", Content = answer }); } } // ========================= // OpenAI REST: /v1/models // ========================= private static async Task<List<string>> TryGetModels(string apiBase) { try { var url = $"{apiBase.TrimEnd('/')}/models"; using var req = new HttpRequestMessage(HttpMethod.Get, url); using var resp = await Http.SendAsync(req); var body = await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) return new List<string>(); using var doc = JsonDocument.Parse(body); if (!doc.RootElement.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array) return new List<string>(); var ids = new List<string>(); foreach (var m in data.EnumerateArray()) { if (m.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String) { var id = idEl.GetString(); if (!string.IsNullOrWhiteSpace(id)) ids.Add(id!); } } return ids; } catch { return new List<string>(); } } // ========================= // Core chat loop (multi-round tools) // ========================= private static async Task<string> ChatWithToolsStreamMulti( string apiBase, string model, List<ChatMessage> chatHistory, int maxRounds, bool requireConfirm, string toolChoice ) { for (int round = 0; round < maxRounds; round++) { // prune + sanitize chatHistory = SanitizeHistoryForRequest(PruneHistoryFifo(chatHistory, MAX_HISTORY_CHARS)); // keep original reference list updated if (!ReferenceEquals(CHAT_HISTORY_REF, null) && ReferenceEquals(CHAT_HISTORY_REF, chatHistory) == false) { CHAT_HISTORY_REF = chatHistory; } var toolCallsAcc = new Dictionary<int, ToolCall>(); var state = new ThinkFilterState(); // streaming chat completion await StreamChatCompletion( apiBase: apiBase, model: model, messages: chatHistory, tools: ToolsSchema(), toolChoice: toolChoice, onDeltaContent: delta => StreamContentFilterThink(delta, state), onDeltaToolCalls: deltaToolCalls => MergeToolCalls(toolCallsAcc, deltaToolCalls) ); var toolCalls = toolCallsAcc.Count > 0 ? toolCallsAcc.OrderBy(kv => kv.Key).Select(kv => kv.Value).ToList() : new List<ToolCall>(); if (toolCalls.Count == 0) return state.Answer.Trim(); // add assistant msg with tool_calls chatHistory.Add(new ChatMessage { Role = "assistant", Content = string.IsNullOrWhiteSpace(state.Answer) ? "" : state.Answer.Trim(), ToolCalls = toolCalls }); foreach (var call in toolCalls) { var fname = call.Function?.Name ?? ""; var argsRaw = call.Function?.Arguments ?? "{}"; Console.WriteLine($"\n[TOOL CALL] {fname} {argsRaw}"); bool allowed = true; if (requireConfirm) { Console.Write("Erlauben? (y/n): "); allowed = string.Equals((Console.ReadLine() ?? "").Trim(), "y", StringComparison.OrdinalIgnoreCase); } if (!allowed) { chatHistory.Add(new ChatMessage { Role = "tool", ToolCallId = call.Id, Name = fname, Content = $"Tool Call '{fname}' wurde vom Nutzer abgelehnt." }); continue; } var result = ExecuteToolCall(fname, argsRaw); result = ClipToolOutput(result, MAX_TOOL_CHARS); chatHistory.Add(new ChatMessage { Role = "tool", ToolCallId = call.Id, Name = fname, Content = result }); } } return "\n[Abbruch: zu viele Tool-Runden]\n"; } // ========================= // Streaming: SSE parser // ========================= private static async Task StreamChatCompletion( string apiBase, string model, List<ChatMessage> messages, object tools, string toolChoice, Action<string> onDeltaContent, Action<List<ToolCallDelta>> onDeltaToolCalls ) { var url = $"{apiBase.TrimEnd('/')}/chat/completions"; var payload = new Dictionary<string, object?> { ["model"] = model, ["messages"] = messages, ["tools"] = tools, ["tool_choice"] = toolChoice, ["stream"] = true }; var json = JsonSerializer.Serialize(payload, JsonOpts); using var req = new HttpRequestMessage(HttpMethod.Post, url); req.Content = new StringContent(json, Encoding.UTF8, "application/json"); using var resp = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead); resp.EnsureSuccessStatusCode(); await using var stream = await resp.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); while (!reader.EndOfStream) { var line = await reader.ReadLineAsync(); if (line == null) break; if (line.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) { var data = line.Substring(5).Trim(); if (data == "[DONE]") break; if (string.IsNullOrWhiteSpace(data)) continue; using var doc = JsonDocument.Parse(data); var root = doc.RootElement; if (!root.TryGetProperty("choices", out var choices) || choices.ValueKind != JsonValueKind.Array) continue; var choice0 = choices[0]; if (!choice0.TryGetProperty("delta", out var delta) || delta.ValueKind != JsonValueKind.Object) continue; // delta.content if (delta.TryGetProperty("content", out var contentEl) && contentEl.ValueKind == JsonValueKind.String) { var txt = contentEl.GetString() ?? ""; if (txt.Length > 0) onDeltaContent(txt); } // delta.tool_calls if (delta.TryGetProperty("tool_calls", out var tcEl) && tcEl.ValueKind == JsonValueKind.Array) { var list = new List<ToolCallDelta>(); foreach (var item in tcEl.EnumerateArray()) { var d = new ToolCallDelta(); if (item.TryGetProperty("index", out var idxEl) && idxEl.ValueKind == JsonValueKind.Number) d.Index = idxEl.GetInt32(); if (item.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String) d.Id = idEl.GetString(); if (item.TryGetProperty("type", out var typeEl) && typeEl.ValueKind == JsonValueKind.String) d.Type = typeEl.GetString(); if (item.TryGetProperty("function", out var fnEl) && fnEl.ValueKind == JsonValueKind.Object) { if (fnEl.TryGetProperty("name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String) d.FunctionName = nameEl.GetString(); if (fnEl.TryGetProperty("arguments", out var argEl) && argEl.ValueKind == JsonValueKind.String) d.FunctionArguments = argEl.GetString(); } list.Add(d); } if (list.Count > 0) onDeltaToolCalls(list); } } } } // ========================= // Tool schema // ========================= private static object ToolsSchema() { // OpenAI tools schema format return new object[] { new { type = "function", function = new { name = "read_pdf_text", description = "Extrahiert Text aus einem PDF (für normale PDFs mit Text-Layer).", parameters = new { type = "object", properties = new Dictionary<string, object> { ["path"] = new { type = "string" }, ["start_page"] = new { type = "integer", @default = 1 }, ["max_pages"] = new { type = "integer", @default = 5 }, ["max_chars"] = new { type = "integer", @default = 12000 }, }, required = new[] { "path" } } } }, new { type = "function", function = new { name = "list_files", description = "Listet alle Dateien und Ordner in einem angegebenen Verzeichnis.", parameters = new { type = "object", properties = new Dictionary<string, object> { ["directory"] = new { type = "string", description = "Pfad zum Verzeichnis" } }, required = new[] { "directory" } } } }, new { type = "function", function = new { name = "read_file", description = "Liest Inhalt einer Datei. Unterstützt zeilen/zeichenbasiertes Teil-Lesen, um Kontext zu sparen.", parameters = new { type = "object", properties = new Dictionary<string, object> { ["path"] = new { type = "string", description = "Pfad zur Datei" }, ["start_line"] = new { type = "integer", description = "Startzeile (1-basiert)", @default = 1 }, ["max_lines"] = new { type = "integer", description = "Max. Anzahl Zeilen", @default = 400 }, ["tail_lines"] = new { type = "integer", description = "Liest die letzten N Zeilen (überschreibt start_line/max_lines)" }, ["start_char"] = new { type = "integer", description = "Startindex Zeichen (0-basiert)" }, ["max_chars"] = new { type = "integer", description = "Max. Anzahl Zeichen" }, }, required = new[] { "path" } } } }, new { type = "function", function = new { name = "execute_cmd", description = "Führt einen Shell Befehl (cmd) aus und liest die Ausgabe von Stdout.", parameters = new { type = "object", properties = new Dictionary<string, object> { ["command"] = new { type = "string", description = "Den auszuführenden Befehl" } }, required = new[] { "command" } } } }, new { type = "function", function = new { name = "execute_powershell", description = "Führt einen Powershell Befehl aus und liest die Ausgabe von Stdout.", parameters = new { type = "object", properties = new Dictionary<string, object> { ["command"] = new { type = "string", description = "Reiner PS-Code (ohne 'powershell -Command ...')" } }, required = new[] { "command" } } } }, new { type = "function", function = new { name = "fetch_html", description = "Gibt den sichtbaren Text einer Seite zurück (aus HTML extrahiert).", parameters = new { type = "object", properties = new Dictionary<string, object> { ["url"] = new { type = "string", description = "http(s)-URL" }, ["max_chars"] = new { type = "integer", description = "Hartes Limit (Zeichen)", @default = 8000 }, }, required = new[] { "url" } } } }, new { type = "function", function = new { name = "save_history", description = "Speichert die aktuelle Chat-History in eine JSON-Datei.", parameters = new { type = "object", properties = new Dictionary<string, object> { ["path"] = new { type = "string", description = "Zielpfad, z.B. './chat_history.json'" }, ["pretty"] = new { type = "boolean", description = "JSON hübsch formatieren", @default = true } }, required = new[] { "path" } } } } }; } // ========================= // Tool call merge (streaming) // ========================= private static void MergeToolCalls(Dictionary<int, ToolCall> acc, List<ToolCallDelta> deltas) { foreach (var tc in deltas) { int idx = tc.Index ?? 0; if (!acc.TryGetValue(idx, out var call)) { call = new ToolCall { Id = tc.Id, Type = tc.Type ?? "function", Function = new ToolFunction { Name = tc.FunctionName, Arguments = "" } }; acc[idx] = call; } if (!string.IsNullOrWhiteSpace(tc.Id)) call.Id = tc.Id; if (!string.IsNullOrWhiteSpace(tc.FunctionName)) call.Function!.Name = tc.FunctionName; if (!string.IsNullOrWhiteSpace(tc.FunctionArguments)) call.Function!.Arguments += tc.FunctionArguments; } } // ========================= // <think> filter (stream) // ========================= private sealed class ThinkFilterState { public bool InThink { get; set; } public string Buf { get; set; } = ""; public string Answer { get; set; } = ""; } private static void StreamContentFilterThink(string deltaText, ThinkFilterState state) { state.Buf += deltaText ?? ""; while (state.Buf.Length > 0) { if (!state.InThink) { int start = state.Buf.IndexOf("<think>", StringComparison.OrdinalIgnoreCase); if (start < 0) { state.Answer += state.Buf; Console.Write(state.Buf); state.Buf = ""; return; } var before = state.Buf.Substring(0, start); state.Answer += before; Console.Write(before); state.Buf = state.Buf.Substring(start + "<think>".Length); state.InThink = true; Console.Write("\n🤔 "); } else { int end = state.Buf.IndexOf("</think>", StringComparison.OrdinalIgnoreCase); if (end < 0) { Console.Write(state.Buf); state.Buf = ""; return; } var chunk = state.Buf.Substring(0, end); Console.Write(chunk); state.Buf = state.Buf.Substring(end + "</think>".Length); state.InThink = false; Console.Write("\n"); } } } // ========================= // Tool dispatcher // ========================= private static string ExecuteToolCall(string fname, string argsRaw) { JsonDocument? doc = null; try { doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(argsRaw) ? "{}" : argsRaw); } catch (Exception e) { return $"Fehler: Konnte Tool-Argumente nicht als JSON parsen: {e.Message}\nRAW:\n{argsRaw}"; } var root = doc.RootElement; try { return fname switch { "list_files" => ListFiles(GetString(root, "directory") ?? ""), "read_file" => ReadFile( path: GetString(root, "path") ?? "", startLine: GetInt(root, "start_line") ?? 1, maxLines: GetInt(root, "max_lines") ?? 400, startChar: GetInt(root, "start_char"), maxChars: GetInt(root, "max_chars"), tailLines: GetInt(root, "tail_lines") ), "execute_cmd" => ExecuteCmd(GetString(root, "command") ?? ""), "execute_powershell" => ExecutePowerShell(GetString(root, "command") ?? ""), "fetch_html" => FetchHtml(GetString(root, "url") ?? "", GetInt(root, "max_chars") ?? 8000), "save_history" => SaveHistory(GetString(root, "path") ?? "chat_history.json", GetBool(root, "pretty") ?? true), "read_pdf_text" => ReadPdfText( path: GetString(root, "path") ?? "", startPage: GetInt(root, "start_page") ?? 1, maxPages: GetInt(root, "max_pages") ?? 5, maxChars: GetInt(root, "max_chars") ?? 12000 ), _ => $"Unbekannte Funktion: {fname}" }; } catch (Exception e) { return $"Tool-Fehler ({fname}): {e.Message}"; } finally { doc.Dispose(); } } private static string? GetString(JsonElement root, string prop) => root.TryGetProperty(prop, out var el) && el.ValueKind == JsonValueKind.String ? el.GetString() : null; private static int? GetInt(JsonElement root, string prop) => root.TryGetProperty(prop, out var el) && el.ValueKind == JsonValueKind.Number ? el.GetInt32() : null; private static bool? GetBool(JsonElement root, string prop) => root.TryGetProperty(prop, out var el) && (el.ValueKind == JsonValueKind.True || el.ValueKind == JsonValueKind.False) ? el.GetBoolean() : null; // ========================= // STT (Mic -> WAV -> Transcription) // ========================= private static async Task<string> RecordAndTranscribeOnce(string apiBase, string sttModel) { var tmpWav = Path.Combine(Path.GetTempPath(), $"mic_{DateTime.Now:yyyyMMdd_HHmmss}.wav"); await RecordMicPushToTalkWav(tmpWav); try { var text = await TranscribeAudioFile(apiBase, sttModel, tmpWav, language: "de"); return (text ?? "").Trim(); } finally { try { File.Delete(tmpWav); } catch { } } } private static Task RecordMicPushToTalkWav(string outWavPath) { var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously); // 16kHz mono 16-bit ist eine solide Baseline für STT var waveIn = new WaveInEvent { WaveFormat = new WaveFormat(16000, 16, 1), BufferMilliseconds = 100 }; var writer = new WaveFileWriter(outWavPath, waveIn.WaveFormat); waveIn.DataAvailable += (_, e) => { writer.Write(e.Buffer, 0, e.BytesRecorded); writer.Flush(); }; waveIn.RecordingStopped += (_, e) => { try { writer.Dispose(); waveIn.Dispose(); if (e.Exception != null) tcs.TrySetException(e.Exception); else tcs.TrySetResult(true); } catch (Exception ex) { tcs.TrySetException(ex); } }; Console.WriteLine("🎙️ Aufnahme: ENTER = Start"); Console.ReadLine(); Console.WriteLine("🎙️ Aufnahme läuft… ENTER = Stop"); waveIn.StartRecording(); Console.ReadLine(); waveIn.StopRecording(); return tcs.Task; } private static async Task<string> TranscribeAudioFile(string apiBase, string sttModel, string filePath, string? language = null) { var url = $"{apiBase.TrimEnd('/')}/audio/transcriptions"; using var form = new MultipartFormDataContent(); using var fs = File.OpenRead(filePath); using var fileContent = new StreamContent(fs); fileContent.Headers.ContentType = new MediaTypeHeaderValue("audio/wav"); form.Add(fileContent, "file", Path.GetFileName(filePath)); form.Add(new StringContent(sttModel), "model"); if (!string.IsNullOrWhiteSpace(language)) form.Add(new StringContent(language), "language"); form.Add(new StringContent("json"), "response_format"); using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = form }; using var resp = await Http.SendAsync(req); var body = await resp.Content.ReadAsStringAsync(); if (!resp.IsSuccessStatusCode) throw new Exception($"HTTP {(int)resp.StatusCode} {resp.ReasonPhrase}\n{body}"); using var doc = JsonDocument.Parse(body); if (doc.RootElement.TryGetProperty("text", out var t) && t.ValueKind == JsonValueKind.String) return t.GetString() ?? ""; return body; } // ========================= // Tool implementations // ========================= private static string ListFiles(string directory) { try { var entries = Directory.EnumerateFileSystemEntries(directory).ToList(); // mimic python: files first then folders (optional) var files = entries.Where(File.Exists).Select(Path.GetFileName).Where(n => n != null).ToList(); var dirs = entries.Where(Directory.Exists).Select(Path.GetFileName).Where(n => n != null).ToList(); return string.Join("\n", files.Concat(dirs)); } catch (Exception e) { return $"Fehler beim Zugriff auf das Verzeichnis: {e.Message}"; } } private static string ReadFile( string path, int startLine = 1, int maxLines = 400, int? startChar = null, int? maxChars = null, int? tailLines = null ) { try { if (startLine < 1) startLine = 1; if (maxLines < 1) maxLines = 1; var data = File.ReadAllText(path, Encoding.UTF8); if (startChar.HasValue || maxChars.HasValue) { int s = Math.Max(0, startChar ?? 0); if (s > data.Length) s = data.Length; string outStr = maxChars.HasValue ? data.Substring(s, Math.Min(maxChars.Value, data.Length - s)) : data.Substring(s); return outStr; } // line-based var lines = File.ReadAllLines(path, Encoding.UTF8); int total = lines.Length; int startIdx; string[] sel; if (tailLines.HasValue && tailLines.Value > 0) { int n = Math.Min(tailLines.Value, total); startIdx = Math.Max(0, total - n); sel = lines.Skip(startIdx).Take(n).ToArray(); } else { startIdx = startLine - 1; if (startIdx < 0) startIdx = 0; sel = lines.Skip(startIdx).Take(maxLines).ToArray(); } var header = $"[read_file] path={path}\n" + $"[read_file] total_lines={total} selected_lines={sel.Length} range={startIdx + 1}-{startIdx + sel.Length}\n\n"; return header + string.Join("\n", sel); } catch (Exception e) { return $"Fehler beim Lesen der Datei: {e.Message}"; } } private static string ExecuteCmd(string command) { try { bool isWindows = OperatingSystem.IsWindows(); var psi = new ProcessStartInfo { FileName = isWindows ? "cmd.exe" : "/bin/bash", Arguments = isWindows ? $"/c {command}" : $"-c \"{command.Replace("\"", "\\\"")}\"", RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; using var p = Process.Start(psi)!; var stdout = p.StandardOutput.ReadToEnd(); var stderr = p.StandardError.ReadToEnd(); p.WaitForExit(); if (p.ExitCode == 0) return stdout; return $"Fehler: {(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr)}"; } catch (Exception e) { return $"Fehler: {e.Message}"; } } private static string ExecutePowerShell(string script) { try { // Prefer pwsh if available; fallback to powershell on Windows string exe = OperatingSystem.IsWindows() ? "powershell" : "pwsh"; var psi = new ProcessStartInfo { FileName = exe, Arguments = "-NoProfile -NonInteractive -ExecutionPolicy Bypass -Command " + QuoteForProcess(script), RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true }; using var p = Process.Start(psi)!; var stdout = p.StandardOutput.ReadToEnd(); var stderr = p.StandardError.ReadToEnd(); p.WaitForExit(); if (p.ExitCode == 0) return stdout; return $"Fehler: {(string.IsNullOrWhiteSpace(stderr) ? stdout : stderr)}"; } catch (Exception e) { return $"Fehler: {e.Message}"; } } private static string QuoteForProcess(string s) { // simplest robust quote for -Command // wrap in double quotes and escape internal quotes with backtick for PowerShell var escaped = s.Replace("\"", "`\""); return $"\"{escaped}\""; } private static string FetchHtml(string url, int maxChars = 8000) { try { if (!Regex.IsMatch(url, "^https?://", RegexOptions.IgnoreCase)) return "Fehler: Nur http(s)-URLs sind erlaubt."; using var req = new HttpRequestMessage(HttpMethod.Get, url); req.Headers.UserAgent.ParseAdd("Mozilla/5.0 (compatible; LLM-Helper/1.2)"); using var resp = Http.Send(req); resp.EnsureSuccessStatusCode(); var html = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult(); var doc = new HtmlDocument(); doc.LoadHtml(html); // remove script/style/noscript/template var toRemove = doc.DocumentNode.SelectNodes("//script|//style|//noscript|//template"); if (toRemove != null) { foreach (var n in toRemove) n.Remove(); } var text = HtmlEntity.DeEntitize(doc.DocumentNode.InnerText); text = Regex.Replace(text, @"\n{3,}", "\n\n").Trim(); if (text.Length > maxChars) text = text.Substring(0, maxChars) + "\n…[gekürzt]"; return text; } catch (Exception e) { return $"Fehler beim Abrufen der URL: {e.Message}"; } } private static string ReadPdfText(string path, int startPage = 1, int maxPages = 5, int maxChars = 12000) { try { using var pdf = PdfDocument.Open(path); int n = pdf.NumberOfPages; int sp = Math.Max(1, startPage); int ep = Math.Min(n, sp + maxPages - 1); var sb = new StringBuilder(); for (int p = sp; p <= ep; p++) { var page = pdf.GetPage(p); var txt = page.Text ?? ""; sb.Append($"\n--- Seite {p}/{n} ---\n"); sb.Append(txt); sb.Append('\n'); } var outStr = sb.ToString().Trim(); if (outStr.Length > maxChars) outStr = outStr.Substring(0, maxChars) + "\n…[gekürzt]"; return $"[read_pdf_text] path={path} pages={sp}-{ep}/{n}\n\n{outStr}"; } catch (Exception e) { return $"Fehler beim PDF-Lesen: {e.Message}"; } } private static string SaveHistory(string path, bool pretty = true) { try { if (CHAT_HISTORY_REF == null) return "Fehler: CHAT_HISTORY_REF ist nicht gesetzt."; var dir = Path.GetDirectoryName(path); if (!string.IsNullOrWhiteSpace(dir)) Directory.CreateDirectory(dir); var opts = new JsonSerializerOptions(JsonOpts) { WriteIndented = pretty }; var json = JsonSerializer.Serialize(CHAT_HISTORY_REF, opts); File.WriteAllText(path, json, Encoding.UTF8); return $"OK: History gespeichert nach '{path}' ({CHAT_HISTORY_REF.Count} messages)."; } catch (Exception e) { return $"Fehler beim Speichern der History: {e.Message}"; } } // ========================= // History trimming (FIFO) // ========================= private static int MsgCostChars(ChatMessage m) { int cost = m.Content?.Length ?? 0; if (m.ToolCalls != null && m.ToolCalls.Count > 0) { try { cost += JsonSerializer.Serialize(m.ToolCalls, JsonOpts).Length; } catch { cost += 500; } } return cost; } private static List<ChatMessage> PruneHistoryFifo(List<ChatMessage> messages, int maxChars) { if (messages.Count == 0) return messages; var system = messages.Where(m => m.Role == "system").ToList(); var rest = messages.Where(m => m.Role != "system").ToList(); int total = system.Sum(MsgCostChars); int budget = maxChars; var keptRev = new List<ChatMessage>(); foreach (var m in rest.AsEnumerable().Reverse()) { int c = MsgCostChars(m); if (keptRev.Count > 0 && (total + c) > budget) break; keptRev.Add(m); total += c; } keptRev.Reverse(); var kept = new List<ChatMessage>(); kept.AddRange(system); kept.AddRange(keptRev); return kept; } private static string ClipToolOutput(string text, int maxChars) { if (text == null) return ""; if (text.Length <= maxChars) return text; return text.Substring(0, maxChars) + "\n…[tool output gekürzt]"; } // ========================= // sanitize history sequences // ========================= private static List<ChatMessage> SanitizeHistoryForRequest(List<ChatMessage> messages) { var outList = new List<ChatMessage>(); var pending = new HashSet<string>(); foreach (var m in messages) { if (m.Role == "assistant") { pending.Clear(); if (m.ToolCalls != null) { foreach (var tc in m.ToolCalls) { if (!string.IsNullOrWhiteSpace(tc.Id)) pending.Add(tc.Id!); } } outList.Add(m); continue; } if (m.Role == "tool") { var tcid = m.ToolCallId; if (tcid != null && pending.Contains(tcid)) { outList.Add(m); pending.Remove(tcid); } // else drop invalid tool message continue; } if (m.Role == "user" || m.Role == "system") pending.Clear(); outList.Add(m); } return outList; } // ========================= // DTOs (OpenAI chat format) // ========================= public sealed class ChatMessage { [JsonPropertyName("role")] public string Role { get; set; } = ""; [JsonPropertyName("content")] public string? Content { get; set; } // assistant tool_calls [JsonPropertyName("tool_calls")] public List<ToolCall>? ToolCalls { get; set; } // tool message fields [JsonPropertyName("tool_call_id")] public string? ToolCallId { get; set; } [JsonPropertyName("name")] public string? Name { get; set; } } public sealed class ToolCall { [JsonPropertyName("id")] public string? Id { get; set; } [JsonPropertyName("type")] public string Type { get; set; } = "function"; [JsonPropertyName("function")] public ToolFunction? Function { get; set; } } public sealed class ToolFunction { [JsonPropertyName("name")] public string? Name { get; set; } [JsonPropertyName("arguments")] public string? Arguments { get; set; } } // streaming delta structure (partial) private sealed class ToolCallDelta { public int? Index { get; set; } public string? Id { get; set; } public string? Type { get; set; } public string? FunctionName { get; set; } public string? FunctionArguments { get; set; } } }