Benutzer-Werkzeuge

Webseiten-Werkzeuge


test

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; }
    }
}

Voice Transcription (nur OpenAI)

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; }
    }
}
test.1765747276.txt.gz · Zuletzt geändert: 2025/12/14 22:21 von jango