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