diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index fe57dc0..b9e0a6e 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -237,12 +237,21 @@ public static class ChatEndpoint } else { - // Standard: Anthropic – Key wird zwingend benötigt + // Standard: Anthropic var anthropicToken = userApiKey?.ApiKey; + + // Kein API-Key → Claude CLI als Fallback verwenden if (string.IsNullOrEmpty(anthropicToken)) { - await SseHelper.TryWriteSse(res, "error", new { error = "Kein API-Key für diesen Agent zugewiesen. Bitte weise einen Key zu." }, sseCt); - await SseHelper.TryWriteSse(res, "done", new { chainId = ctx.ChainId }, sseCt); + if (anthropic.IsCliAvailable) + { + await anthropic.StreamChatViaCli(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, workspace: ctx.Workspace, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: body.PlanMode, webSearch: !string.IsNullOrEmpty(ctx.WebSearchVersion), sessionId: sessionId, cancellationToken: sseCt); + } + else + { + await SseHelper.TryWriteSse(res, "error", new { error = "Kein API-Key zugewiesen und Claude CLI nicht verfügbar. Bitte einen Key zuweisen oder 'claude login' ausführen." }, sseCt); + await SseHelper.TryWriteSse(res, "done", new { chainId = ctx.ChainId }, sseCt); + } return; } diff --git a/Models/ApiKeyRecord.cs b/Models/ApiKeyRecord.cs index 7f2b570..93e0a52 100644 --- a/Models/ApiKeyRecord.cs +++ b/Models/ApiKeyRecord.cs @@ -16,4 +16,9 @@ public class ApiKeyRecord public string SourceUrl { get; set; } // GitHub-URL (bei adoptierten Scan-Keys) public string CreatedAt { get; set; } public string UpdatedAt { get; set; } + + // OAuth-spezifische Felder + public string RefreshToken { get; set; } // OAuth Refresh-Token + public string AccessTokenExpiresAt { get; set; } // Wann der Access-Token abläuft (ISO 8601) + public string OAuthProvider { get; set; } // z.B. "anthropic" (für zukünftige weitere OAuth-Provider) } diff --git a/Program.cs b/Program.cs index a0f705c..03a18da 100644 --- a/Program.cs +++ b/Program.cs @@ -115,6 +115,9 @@ builder.Services.AddHostedService(sp => sp.GetRequiredService builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); +// Claude CLI Service (für OAuth-basierte Chats via CLI) +builder.Services.AddSingleton(); + var app = builder.Build(); // No-cache for static files (mobile browsers love caching too much) @@ -134,6 +137,7 @@ app.Use(async (context, next) => toolService.SetHttpClientFactory(app.Services.GetRequiredService()); app.Services.GetRequiredService().SetTelegramService(app.Services.GetRequiredService()); app.Services.GetRequiredService().SetOpenAiService(app.Services.GetRequiredService()); +app.Services.GetRequiredService().SetCliService(app.Services.GetRequiredService()); await app.Services.GetRequiredService().LoadModelsAsync(); app.UseWebSockets(); diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index 9bcf2a4..8ce5b0d 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; +using System.Linq; using System.Net.Http; using System.Net.Http.Headers; using System.Text; @@ -24,6 +25,7 @@ public class AnthropicService private readonly EmbeddingBackgroundService _embeddingBg; private ChatService _chatService; private TelegramService _telegramService; + private ClaudeCliService _cliService; public AnthropicService(IHttpClientFactory httpFactory, ToolService toolService, DatabaseService db, MemoryService memoryService = null, ServerSettingsService settings = null, VectorSearchService vectorSearch = null, EmbeddingBackgroundService embeddingBg = null) { @@ -36,6 +38,16 @@ public class AnthropicService _embeddingBg = embeddingBg; } + public void SetCliService(ClaudeCliService cliService) + { + _cliService = cliService; + } + + /// + /// Prüft ob die Claude CLI verfügbar ist. + /// + public bool IsCliAvailable => _cliService?.IsAvailable ?? false; + /// /// Wird nach der Konstruktion gesetzt um zirkuläre Abhängigkeit zu vermeiden /// (ChatService → AnthropicService → ChatService). @@ -1781,4 +1793,295 @@ public class AnthropicService } // WriteSseEvent, TryWriteSse, TokenUsage, ToolResult → SseHelper.cs + + /// + /// Streaming-Chat über die Claude CLI. + /// Die CLI verwendet das lokale Token aus ~/.claude/.credentials.json. + /// + public async Task StreamChatViaCli( + HttpResponse response, + string modelId, + List systemPrompt, + List messages, + string chainId, + string workspace = null, + bool allowUserChoice = false, + bool planMode = false, + bool webSearch = false, + string sessionId = null, + CancellationToken cancellationToken = default) + { + if (_cliService == null || !_cliService.IsAvailable) + { + await SseHelper.TryWriteSse(response, "error", new { error = "Claude CLI nicht verfügbar" }, cancellationToken); + await SseHelper.TryWriteSse(response, "done", new { chainId }, cancellationToken); + return; + } + + var sw = System.Diagnostics.Stopwatch.StartNew(); + var usage = new TokenUsage(); + var fullText = new StringBuilder(); + + var chain = new ChainRecord + { + ChainId = chainId, + SessionId = sessionId, + Model = modelId + }; + + try + { + // System-Prompt als Text extrahieren + var systemText = new StringBuilder(); + if (systemPrompt != null) + { + foreach (var block in systemPrompt) + { + var json = JsonSerializer.Serialize(block); + var el = JsonSerializer.Deserialize(json); + if (el.TryGetProperty("text", out var t)) + systemText.AppendLine(t.GetString()); + } + } + + // ask_user_choice Instruktion anhängen + if (allowUserChoice) + { + systemText.AppendLine(); + systemText.AppendLine(@"## Rückfragen +Wenn du dem User eine Frage mit Auswahlmöglichkeiten stellen möchtest (2-5 Optionen), gib EXAKT diesen Block aus, auf eigenen Zeilen, ohne weiteren Text drumherum: + +:::ASK_USER_CHOICE::: +{""question"": ""Deine Frage"", ""choices"": [""Option A"", ""Option B""]} +:::END_ASK_USER_CHOICE::: + +Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die Entscheidung wirklich vom User getroffen werden muss."); + } + + // Letzte User-Message als Prompt extrahieren + var prompt = ExtractLastUserText(messages) ?? ""; + + // Konversations-History als Kontext zum Prompt hinzufügen + var historyContext = BuildHistoryContext(messages); + if (!string.IsNullOrEmpty(historyContext)) + { + prompt = $"\n{historyContext}\n\n\n\n{prompt}\n"; + } + + bool isUserChoice = false; + string choiceToolCallId = null; + + var result = await _cliService.ChatStreamingAsync( + prompt, + async evt => + { + switch (evt.Type) + { + case CliEventType.TextStart: + await SseHelper.TryWriteSse(response, "text_start", new { index = 0 }, cancellationToken); + break; + case CliEventType.TextDelta: + fullText.Append(evt.Text); + await SseHelper.TryWriteSse(response, "text_delta", new { text = evt.Text }, cancellationToken); + break; + case CliEventType.TextEnd: + await SseHelper.TryWriteSse(response, "text_end", new { }, cancellationToken); + break; + case CliEventType.ToolStart: + await SseHelper.TryWriteSse(response, "tool_start", new { name = evt.ToolName, id = evt.ToolId }, cancellationToken); + break; + case CliEventType.ToolEnd: + // Tool-Use in DB speichern (damit Poll die Tools anzeigt) + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, + Iteration = 1, + Role = "assistant", + Type = "tool_use", + ToolName = evt.ToolName, + ToolCallId = evt.ToolId, + Content = evt.ToolInput + }); + await SseHelper.TryWriteSse(response, "tool_end", new { name = evt.ToolName, id = evt.ToolId, input = evt.ToolInput }, cancellationToken); + break; + case CliEventType.UserChoice: + // Text bisher in DB speichern + if (fullText.Length > 0) + { + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, + Iteration = 1, + Role = "assistant", + Type = "text", + Content = fullText.ToString() + }); + } + + // Synthetische tool_use speichern (für History-Rebuild) + choiceToolCallId = $"toolu_cli_{Guid.NewGuid():N}"; + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, + Iteration = 1, + Role = "assistant", + Type = "tool_use", + ToolName = "ask_user_choice", + ToolCallId = choiceToolCallId, + Content = JsonSerializer.Serialize(new { question = evt.ChoiceQuestion, choices = evt.ChoiceOptions }) + }); + + // SSE user_choice Event (Frontend zeigt Buttons) + await SseHelper.TryWriteSse(response, "user_choice", new + { + toolCallId = choiceToolCallId, + question = evt.ChoiceQuestion, + choices = evt.ChoiceOptions + }, cancellationToken); + + isUserChoice = true; + break; + } + }, + modelId: modelId, + systemPrompt: systemText.Length > 0 ? systemText.ToString() : null, + workspace: workspace, + hasWorkspace: !string.IsNullOrEmpty(workspace), + planMode: planMode, + webSearch: webSearch, + cancellationToken: cancellationToken + ); + + // Bei CLI-Fehler ohne Output → Fehlermeldung an Client senden + if (!result.Success && fullText.Length == 0 && !isUserChoice) + { + var errorMsg = !string.IsNullOrEmpty(result.Error) ? result.Error : "CLI hat keine Antwort geliefert"; + await SseHelper.TryWriteSse(response, "error", new { error = $"CLI-Fehler: {errorMsg}" }, cancellationToken); + } + + // Antwort in DB speichern (nur wenn nicht schon im UserChoice-Handler gespeichert) + if (!isUserChoice && result.Success && fullText.Length > 0) + { + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, + Iteration = 1, + Role = "assistant", + Type = "text", + Content = fullText.ToString() + }); + } + + // Token-Usage vom CLI-Result übernehmen + usage.InputTokens = result.InputTokens; + usage.OutputTokens = result.OutputTokens; + usage.CacheReadTokens = result.CacheReadTokens; + usage.CacheCreationTokens = result.CacheCreationTokens; + + var stopReason = isUserChoice ? "awaiting_user_choice" : (result.Success ? "end_turn" : "error"); + await FinalizeChain(chain, usage, result.NumTurns > 0 ? result.NumTurns : 1, stopReason, sw); + + await SseHelper.TryWriteSse(response, "done", new + { + model = modelId, + inputTokens = result.InputTokens, + outputTokens = result.OutputTokens, + cacheCreationTokens = result.CacheCreationTokens, + cacheReadTokens = result.CacheReadTokens, + costUsd = result.CostUsd, + iterations = result.NumTurns > 0 ? result.NumTurns : 1, + chainId, + durationMs = sw.ElapsedMilliseconds, + stopReason, + viaCli = true + }, cancellationToken); + } + catch (OperationCanceledException) + { + await FinalizeChain(chain, usage, 1, "stopped", sw); + await SseHelper.TryWriteSse(response, "done", new + { + model = modelId, + chainId, + durationMs = sw.ElapsedMilliseconds, + stopReason = "stopped", + viaCli = true + }, CancellationToken.None); + } + catch (Exception ex) + { + await SseHelper.TryWriteSse(response, "error", new { error = $"CLI-Fehler: {ex.Message}" }, cancellationToken); + await FinalizeChain(chain, usage, 1, "error", sw); + await SseHelper.TryWriteSse(response, "done", new { chainId }, cancellationToken); + } + } + + /// + /// Baut einen Kontext-String aus der Konversations-History. + /// + private static string BuildHistoryContext(List messages) + { + var sb = new StringBuilder(); + // Letzte Message überspringen wenn es ein Text-Prompt ist (wird separat als current_request gesendet). + // Wenn die letzte Message ein tool_result ist (z.B. Choice-Antwort), NICHT überspringen. + int takeCount = messages.Count; + if (messages.Count > 0) + { + var lastJson = JsonSerializer.Serialize(messages[messages.Count - 1]); + var lastEl = JsonSerializer.Deserialize(lastJson); + bool lastIsToolResult = false; + if (lastEl.TryGetProperty("content", out var lc) && lc.ValueKind == JsonValueKind.Array) + { + foreach (var b in lc.EnumerateArray()) + { + var bType = b.TryGetProperty("type", out var bt) ? bt.GetString() ?? "" : ""; + if (bType == "tool_result") { lastIsToolResult = true; break; } + } + } + if (!lastIsToolResult) + takeCount = messages.Count - 1; + } + foreach (var msg in messages.Take(takeCount)) + { + var json = JsonSerializer.Serialize(msg); + var el = JsonSerializer.Deserialize(json); + if (!el.TryGetProperty("role", out var role)) continue; + var roleStr = role.GetString() ?? ""; + + if (el.TryGetProperty("content", out var content)) + { + if (content.ValueKind == JsonValueKind.String) + { + sb.AppendLine($"[{roleStr}]: {content.GetString()}"); + } + else if (content.ValueKind == JsonValueKind.Array) + { + foreach (var block in content.EnumerateArray()) + { + var blockType = block.TryGetProperty("type", out var bt) ? bt.GetString() ?? "" : ""; + if (blockType == "text" && block.TryGetProperty("text", out var txt)) + { + sb.AppendLine($"[{roleStr}]: {txt.GetString()}"); + } + else if (blockType == "tool_use" && block.TryGetProperty("name", out var tn) && tn.GetString() == "ask_user_choice") + { + if (block.TryGetProperty("input", out var inp)) + { + var q = inp.TryGetProperty("question", out var qq) ? qq.GetString() ?? "" : ""; + var opts = new List(); + if (inp.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array) + foreach (var o in cc.EnumerateArray()) opts.Add(o.GetString() ?? ""); + sb.AppendLine($"[assistant]: Ich habe gefragt: \"{q}\" mit Optionen: {string.Join(", ", opts)}"); + } + } + else if (blockType == "tool_result" && block.TryGetProperty("content", out var trContent)) + { + sb.AppendLine($"[user]: Du hast gewählt: \"{trContent.GetString()}\""); + } + } + } + } + } + return sb.ToString().Trim(); + } } diff --git a/Services/ClaudeCliService.cs b/Services/ClaudeCliService.cs new file mode 100644 index 0000000..c683c80 --- /dev/null +++ b/Services/ClaudeCliService.cs @@ -0,0 +1,653 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Gott.Services; + +/// +/// Service für die Kommunikation mit der Claude Code CLI. +/// Verwendet die CLI als Backend für OAuth-basierte Chats (kein API-Key nötig). +/// +public class ClaudeCliService +{ + private readonly ILogger _logger; + private readonly string _cliPath; + private readonly bool _isAvailable; + + public ClaudeCliService(ILogger logger) + { + _logger = logger; + _cliPath = FindCliPath(); + _isAvailable = !string.IsNullOrEmpty(_cliPath) && File.Exists(_cliPath); + + if (_isAvailable) + _logger.LogInformation("Claude CLI gefunden: {Path}", _cliPath); + else + _logger.LogWarning("Claude CLI nicht gefunden. CLI-basierte Chats sind nicht verfügbar."); + } + + public bool IsAvailable => _isAvailable; + public string CliPath => _cliPath; + + private static string FindCliPath() + { + var possiblePaths = new[] + { + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude.exe"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "claude-code", "claude.exe"), + "/usr/local/bin/claude", + "/usr/bin/claude" + }; + + foreach (var path in possiblePaths) + { + if (File.Exists(path)) + return path; + } + + try + { + var pathEnv = Environment.GetEnvironmentVariable("PATH") ?? ""; + foreach (var dir in pathEnv.Split(Path.PathSeparator)) + { + var exePath = Path.Combine(dir, "claude"); + if (File.Exists(exePath)) return exePath; + + exePath = Path.Combine(dir, "claude.exe"); + if (File.Exists(exePath)) return exePath; + } + } + catch { } + + return null; + } + + /// + /// Streaming-Chat über die Claude CLI mit Event-basiertem Callback. + /// Unterstützt Text-Streaming und Tool-Use Events. + /// + public async Task ChatStreamingAsync( + string prompt, + Func onEvent, + string modelId = null, + string systemPrompt = null, + string workspace = null, + bool hasWorkspace = false, + bool planMode = false, + bool webSearch = false, + CancellationToken cancellationToken = default) + { + if (!_isAvailable) + return new CliChatResult { Success = false, Error = "Claude CLI nicht verfügbar" }; + + var args = new List + { + "-p", + "--verbose", + "--output-format", "stream-json", + "--include-partial-messages", + "--no-session-persistence", + "--permission-mode", "acceptEdits", + "--disallowed-tools", "AskUserQuestion" + }; + + // Tools konfigurieren + var tools = new List(); + if (hasWorkspace && planMode) + { + // Plan-Modus: nur read-only Tools + tools.AddRange(new[] { "Read", "Glob", "Grep" }); + } + else if (hasWorkspace) + { + // Normaler Modus mit Workspace: alle File-Tools + tools.AddRange(new[] { "Bash(*)", "Read", "Write", "Edit", "Glob", "Grep" }); + } + if (webSearch) { tools.Add("WebFetch"); tools.Add("WebSearch"); } + args.Add("--allowed-tools"); + args.Add(tools.Count > 0 ? string.Join(" ", tools) : "\"\""); + + if (!string.IsNullOrEmpty(workspace)) + { + args.Add("--add-dir"); + args.Add(workspace); + } + + if (!string.IsNullOrEmpty(modelId)) + { + args.Add("--model"); + args.Add(modelId); + } + + if (!string.IsNullOrEmpty(systemPrompt)) + { + args.Add("--system-prompt"); + args.Add(systemPrompt); + } + + return await RunCliStreamingAsync(args, prompt, onEvent, cancellationToken); + } + + private async Task RunCliStreamingAsync( + List args, + string stdinPrompt, + Func onEvent, + CancellationToken cancellationToken) + { + var result = new CliChatResult(); + var fullOutput = new StringBuilder(); + var error = new StringBuilder(); + + // Tracking für content blocks + string currentBlockType = null; // "text" oder "tool_use" + string currentToolName = null; + string currentToolId = null; + var currentToolInput = new StringBuilder(); + bool inTextBlock = false; + + // Tracking für ask_user_choice Marker-Erkennung + var lineBuffer = new StringBuilder(); + bool inChoiceCapture = false; + var choiceJsonBuffer = new StringBuilder(); + bool userChoiceDetected = false; + + try + { + var startInfo = new ProcessStartInfo + { + FileName = _cliPath, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + }; + + foreach (var arg in args) + startInfo.ArgumentList.Add(arg); + + using var process = new Process { StartInfo = startInfo }; + + process.ErrorDataReceived += (s, e) => + { + if (e.Data != null) + { + error.AppendLine(e.Data); + _logger.LogDebug("CLI stderr: {Line}", e.Data); + } + }; + + process.Start(); + process.BeginErrorReadLine(); + + await process.StandardInput.WriteAsync(stdinPrompt); + await process.StandardInput.FlushAsync(); + process.StandardInput.Close(); + + using var reader = process.StandardOutput; + string line; + while ((line = await reader.ReadLineAsync(cancellationToken)) != null) + { + if (cancellationToken.IsCancellationRequested) + { + try { process.Kill(entireProcessTree: true); } catch { } + throw new OperationCanceledException(); + } + + if (!line.StartsWith("{")) + continue; + + try + { + var json = JsonSerializer.Deserialize(line); + var type = json.TryGetProperty("type", out var t) ? t.GetString() : null; + + if (type == "stream_event" && json.TryGetProperty("event", out var evt)) + { + var evtType = evt.TryGetProperty("type", out var et) ? et.GetString() : null; + + switch (evtType) + { + case "content_block_start": + { + if (evt.TryGetProperty("content_block", out var cb)) + { + var cbType = cb.TryGetProperty("type", out var cbt) ? cbt.GetString() : null; + + if (cbType == "text") + { + currentBlockType = "text"; + if (!inTextBlock) + { + inTextBlock = true; + await onEvent(new CliStreamEvent { Type = CliEventType.TextStart }); + } + } + else if (cbType == "tool_use") + { + // Text-Block vorher abschließen + if (inTextBlock) + { + inTextBlock = false; + await onEvent(new CliStreamEvent { Type = CliEventType.TextEnd }); + } + + currentBlockType = "tool_use"; + currentToolName = cb.TryGetProperty("name", out var n) ? n.GetString() : "unknown"; + currentToolId = cb.TryGetProperty("id", out var id) ? id.GetString() : ""; + currentToolInput.Clear(); + + await onEvent(new CliStreamEvent + { + Type = CliEventType.ToolStart, + ToolName = currentToolName, + ToolId = currentToolId + }); + } + } + break; + } + + case "content_block_delta": + { + if (evt.TryGetProperty("delta", out var delta)) + { + var deltaType = delta.TryGetProperty("type", out var dt) ? dt.GetString() : null; + + if (deltaType == "text_delta" && delta.TryGetProperty("text", out var txt)) + { + var text = txt.GetString(); + if (!string.IsNullOrEmpty(text) && !userChoiceDetected) + { + // Zeilenbasierte Marker-Erkennung für :::ASK_USER_CHOICE::: + for (int ci = 0; ci < text.Length; ci++) + { + var ch = text[ci]; + if (ch == '\n') + { + var completedLine = lineBuffer.ToString().Trim(); + lineBuffer.Clear(); + + if (inChoiceCapture) + { + if (completedLine == ":::END_ASK_USER_CHOICE:::") + { + // JSON parsen und UserChoice-Event emittieren + try + { + var choiceJson = JsonSerializer.Deserialize(choiceJsonBuffer.ToString()); + var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : ""; + var choices = new List(); + if (choiceJson.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array) + foreach (var cOpt in cc.EnumerateArray()) + choices.Add(cOpt.GetString() ?? ""); + + userChoiceDetected = true; + await onEvent(new CliStreamEvent + { + Type = CliEventType.UserChoice, + ChoiceQuestion = question, + ChoiceOptions = choices + }); + } + catch (JsonException) + { + // Fehlerhaftes JSON → als normalen Text weiterleiten + var fallback = $":::ASK_USER_CHOICE:::\n{choiceJsonBuffer}\n:::END_ASK_USER_CHOICE:::\n"; + fullOutput.Append(fallback); + await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = fallback }); + } + inChoiceCapture = false; + choiceJsonBuffer.Clear(); + } + else + { + choiceJsonBuffer.AppendLine(completedLine); + } + } + else if (completedLine == ":::ASK_USER_CHOICE:::") + { + inChoiceCapture = true; + choiceJsonBuffer.Clear(); + } + else if (completedLine.Length > 0 || fullOutput.Length > 0) + { + // Normale Zeile → Text weiterleiten (inkl. Newline) + // Leere Zeilen am Anfang überspringen (vor dem eigentlichen Text) + var lineText = completedLine + "\n"; + fullOutput.Append(lineText); + await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = lineText }); + } + } + else + { + lineBuffer.Append(ch); + } + } + } + } + else if (deltaType == "input_json_delta" && delta.TryGetProperty("partial_json", out var pj)) + { + currentToolInput.Append(pj.GetString()); + } + } + break; + } + + case "content_block_stop": + { + if (currentBlockType == "tool_use") + { + await onEvent(new CliStreamEvent + { + Type = CliEventType.ToolEnd, + ToolName = currentToolName, + ToolId = currentToolId, + ToolInput = currentToolInput.ToString() + }); + currentBlockType = null; + currentToolName = null; + currentToolId = null; + currentToolInput.Clear(); + } + else if (currentBlockType == "text") + { + currentBlockType = null; + // text_end wird erst beim nächsten tool_use oder am Ende gesendet + } + break; + } + } + } + else if (type == "result") + { + // Restlichen lineBuffer flushen (nicht-abgeschlossene Zeile) + if (lineBuffer.Length > 0 && !userChoiceDetected) + { + var remaining = lineBuffer.ToString().Trim(); + lineBuffer.Clear(); + + if (inChoiceCapture && remaining == ":::END_ASK_USER_CHOICE:::") + { + // End-Marker ohne trailing \n → Capture abschließen + try + { + var choiceJson = JsonSerializer.Deserialize(choiceJsonBuffer.ToString()); + var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : ""; + var choices = new List(); + if (choiceJson.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array) + foreach (var cOpt in cc.EnumerateArray()) + choices.Add(cOpt.GetString() ?? ""); + + userChoiceDetected = true; + await onEvent(new CliStreamEvent + { + Type = CliEventType.UserChoice, + ChoiceQuestion = question, + ChoiceOptions = choices + }); + } + catch (JsonException) + { + var fallback = $":::ASK_USER_CHOICE:::\n{choiceJsonBuffer}\n{remaining}\n"; + fullOutput.Append(fallback); + await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = fallback }); + } + inChoiceCapture = false; + choiceJsonBuffer.Clear(); + } + else if (!inChoiceCapture) + { + fullOutput.Append(remaining); + await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining }); + } + } + + // Offenen Text-Block abschließen + if (inTextBlock) + { + inTextBlock = false; + await onEvent(new CliStreamEvent { Type = CliEventType.TextEnd }); + } + + if (json.TryGetProperty("usage", out var usage)) + { + result.InputTokens = usage.TryGetProperty("input_tokens", out var it) ? it.GetInt64() : 0; + result.OutputTokens = usage.TryGetProperty("output_tokens", out var ot) ? ot.GetInt64() : 0; + result.CacheReadTokens = usage.TryGetProperty("cache_read_input_tokens", out var cr) ? cr.GetInt64() : 0; + result.CacheCreationTokens = usage.TryGetProperty("cache_creation_input_tokens", out var cc) ? cc.GetInt64() : 0; + } + if (json.TryGetProperty("total_cost_usd", out var cost)) + result.CostUsd = cost.GetDouble(); + if (json.TryGetProperty("duration_api_ms", out var dur)) + result.DurationApiMs = dur.GetInt64(); + if (json.TryGetProperty("session_id", out var sid)) + result.SessionId = sid.GetString(); + if (json.TryGetProperty("num_turns", out var nt)) + result.NumTurns = nt.GetInt32(); + + var isError = json.TryGetProperty("is_error", out var ie) && ie.GetBoolean(); + if (isError) + result.Error = json.TryGetProperty("result", out var errMsg) ? errMsg.GetString() : "CLI error"; + } + } + catch (JsonException ex) + { + _logger.LogDebug("CLI JSON parse error: {Error} for line: {Line}", ex.Message, line.Length > 200 ? line[..200] : line); + } + } + + // Restlichen lineBuffer flushen + if (lineBuffer.Length > 0 && !userChoiceDetected) + { + var remaining = lineBuffer.ToString().Trim(); + lineBuffer.Clear(); + + if (inChoiceCapture && remaining == ":::END_ASK_USER_CHOICE:::") + { + try + { + var choiceJson = JsonSerializer.Deserialize(choiceJsonBuffer.ToString()); + var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : ""; + var choices = new List(); + if (choiceJson.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array) + foreach (var cOpt in cc.EnumerateArray()) + choices.Add(cOpt.GetString() ?? ""); + + userChoiceDetected = true; + await onEvent(new CliStreamEvent + { + Type = CliEventType.UserChoice, + ChoiceQuestion = question, + ChoiceOptions = choices + }); + } + catch (JsonException) + { + var fallback = $":::ASK_USER_CHOICE:::\n{choiceJsonBuffer}\n{remaining}\n"; + fullOutput.Append(fallback); + await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = fallback }); + } + inChoiceCapture = false; + choiceJsonBuffer.Clear(); + } + else if (!inChoiceCapture) + { + fullOutput.Append(remaining); + await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining }); + } + } + + // Sicherstellen, dass offene Blöcke geschlossen werden + if (inTextBlock) + await onEvent(new CliStreamEvent { Type = CliEventType.TextEnd }); + + await process.WaitForExitAsync(cancellationToken); + + result.ExitCode = process.ExitCode; + result.Output = fullOutput.ToString().Trim(); + result.Success = process.ExitCode == 0 && (fullOutput.Length > 0 || userChoiceDetected); + result.UserChoiceDetected = userChoiceDetected; + + if (!result.Success && string.IsNullOrEmpty(result.Error)) + result.Error = error.Length > 0 ? error.ToString().Trim() : $"CLI exit code: {process.ExitCode}"; + } + catch (OperationCanceledException) + { + result.Success = false; + result.Error = "Abgebrochen"; + throw; + } + catch (Exception ex) + { + result.Success = false; + result.Error = ex.Message; + _logger.LogError(ex, "Fehler beim Streaming der Claude CLI"); + } + + return result; + } + + /// + /// Prüft ob die CLI funktioniert und authentifiziert ist. + /// + public async Task GetStatusAsync() + { + var result = new CliStatusResult { IsAvailable = _isAvailable }; + + if (!_isAvailable) + { + result.Error = "Claude CLI nicht gefunden"; + return result; + } + + try + { + var versionResult = await RunSimpleAsync(["--version"], CancellationToken.None); + if (versionResult.Success) + result.Version = versionResult.Output.Trim(); + + var doctorResult = await RunSimpleAsync(["doctor"], CancellationToken.None); + result.IsAuthenticated = doctorResult.Success; + + if (!doctorResult.Success) + result.Error = doctorResult.Error ?? doctorResult.Output; + } + catch (Exception ex) + { + result.Error = ex.Message; + } + + return result; + } + + private async Task RunSimpleAsync(List args, CancellationToken cancellationToken) + { + var result = new CliChatResult(); + var output = new StringBuilder(); + var error = new StringBuilder(); + + try + { + var startInfo = new ProcessStartInfo + { + FileName = _cliPath, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + StandardOutputEncoding = Encoding.UTF8, + StandardErrorEncoding = Encoding.UTF8 + }; + + foreach (var arg in args) + startInfo.ArgumentList.Add(arg); + + using var process = new Process { StartInfo = startInfo }; + process.OutputDataReceived += (s, e) => { if (e.Data != null) output.AppendLine(e.Data); }; + process.ErrorDataReceived += (s, e) => { if (e.Data != null) error.AppendLine(e.Data); }; + + process.Start(); + process.BeginOutputReadLine(); + process.BeginErrorReadLine(); + + await process.WaitForExitAsync(cancellationToken); + + result.ExitCode = process.ExitCode; + result.Output = output.ToString().Trim(); + result.Success = process.ExitCode == 0; + + if (!result.Success) + result.Error = error.Length > 0 ? error.ToString().Trim() : $"Exit code: {process.ExitCode}"; + } + catch (OperationCanceledException) + { + result.Success = false; + result.Error = "Abgebrochen"; + throw; + } + catch (Exception ex) + { + result.Success = false; + result.Error = ex.Message; + _logger.LogError(ex, "Fehler beim Ausführen der Claude CLI"); + } + + return result; + } +} + +public enum CliEventType +{ + TextStart, + TextDelta, + TextEnd, + ToolStart, + ToolEnd, + UserChoice +} + +public class CliStreamEvent +{ + public CliEventType Type { get; set; } + public string Text { get; set; } + public string ToolName { get; set; } + public string ToolId { get; set; } + public string ToolInput { get; set; } + public string ChoiceQuestion { get; set; } + public List ChoiceOptions { get; set; } +} + +public class CliChatResult +{ + public bool Success { get; set; } + public string Output { get; set; } + public string Error { get; set; } + public int ExitCode { get; set; } + public long InputTokens { get; set; } + public long OutputTokens { get; set; } + public long CacheReadTokens { get; set; } + public long CacheCreationTokens { get; set; } + public double CostUsd { get; set; } + public long DurationApiMs { get; set; } + public string SessionId { get; set; } + public int NumTurns { get; set; } + public bool UserChoiceDetected { get; set; } +} + +public class CliStatusResult +{ + public bool IsAvailable { get; set; } + public bool IsAuthenticated { get; set; } + public string Version { get; set; } + public string Error { get; set; } +} diff --git a/Services/DatabaseService.Integrations.cs b/Services/DatabaseService.Integrations.cs index 67c4a28..3f540e4 100644 --- a/Services/DatabaseService.Integrations.cs +++ b/Services/DatabaseService.Integrations.cs @@ -563,4 +563,61 @@ public partial class DatabaseService $"SELECT COUNT(*) FROM scanner_logs {where}", new { type, scanner }); } + + // ── OAuth Tokens ── + + /// + /// Erstellt einen neuen API-Key-Eintrag für OAuth-Tokens. + /// + public async Task CreateOAuthApiKey(string provider, string name, string accessToken, string refreshToken, DateTime expiresAt, string oauthProvider) + { + using var conn = CreateConnection(); + var keyId = Guid.NewGuid().ToString("N"); + var now = DateTime.UtcNow.ToString("o"); + await conn.ExecuteAsync(@" + INSERT INTO api_keys (key_id, provider, name, api_key, is_public, status, plan, refresh_token, access_token_expires_at, oauth_provider, created_at, updated_at) + VALUES (@keyId, @provider, @name, @accessToken, 0, 'valid', NULL, @refreshToken, @expiresAt, @oauthProvider, @now, @now)", + new { keyId, provider, name, accessToken, refreshToken, expiresAt = expiresAt.ToString("o"), oauthProvider, now }); + return keyId; + } + + /// + /// Aktualisiert Access-Token und Ablaufzeit eines OAuth-Keys. + /// + public async Task UpdateOAuthTokens(string keyId, string newAccessToken, DateTime newExpiresAt) + { + using var conn = CreateConnection(); + var now = DateTime.UtcNow.ToString("o"); + await conn.ExecuteAsync(@" + UPDATE api_keys SET api_key = @newAccessToken, access_token_expires_at = @expiresAt, updated_at = @now + WHERE key_id = @keyId", + new { keyId, newAccessToken, expiresAt = newExpiresAt.ToString("o"), now }); + } + + /// + /// Holt alle OAuth-Keys die bald ablaufen (innerhalb von refreshBeforeMinutes). + /// + public async Task> GetOAuthKeysNeedingRefresh(int refreshBeforeMinutes = 60) + { + using var conn = CreateConnection(); + var threshold = DateTime.UtcNow.AddMinutes(refreshBeforeMinutes).ToString("o"); + return await conn.QueryAsync(@" + SELECT * FROM api_keys + WHERE refresh_token IS NOT NULL AND refresh_token != '' + AND access_token_expires_at IS NOT NULL AND access_token_expires_at != '' + AND access_token_expires_at < @threshold", + new { threshold }); + } + + /// + /// Setzt den Status eines API-Keys auf "expired". + /// + public async Task SetApiKeyExpired(string keyId) + { + using var conn = CreateConnection(); + var now = DateTime.UtcNow.ToString("o"); + await conn.ExecuteAsync( + "UPDATE api_keys SET status = 'expired', updated_at = @now WHERE key_id = @keyId", + new { keyId, now }); + } } diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index 171460e..92892de 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -357,6 +357,11 @@ public partial class DatabaseService TryAddColumn(conn, "api_keys", "source_url", "TEXT"); TryAddColumn(conn, "api_keys", "is_public", "INTEGER NOT NULL DEFAULT 0"); + // OAuth-spezifische Spalten + TryAddColumn(conn, "api_keys", "refresh_token", "TEXT"); + TryAddColumn(conn, "api_keys", "access_token_expires_at", "TEXT"); + TryAddColumn(conn, "api_keys", "oauth_provider", "TEXT"); + // users: ausgewählter API-Key (legacy, wird durch agent_api_keys ersetzt) TryAddColumn(conn, "users", "selected_api_key_id", "TEXT");