feat: Claude CLI Pfad mit ask_user_choice, Plan-Modus und Tool-Konfiguration
- ClaudeCliService: Marker-basierte ask_user_choice Erkennung im Stream-Parser - AnthropicService: StreamChatViaCli mit allowUserChoice, planMode, webSearch - BuildHistoryContext erweitert für tool_use/tool_result (Choice-Antworten) - Tool-Use Records werden in DB persistiert (Poll-Kompatibilität) - CLI Tools konfigurierbar: Workspace/Plan-Modus/WebSearch/kein Workspace - OAuth DB-Felder für zukünftige Token-Verwaltung Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
8150b58110
commit
4af9430466
7 files changed
+1039
-3
No files matched your search
@@ -237,12 +237,21 @@ public static class ChatEndpoint
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Standard: Anthropic – Key wird zwingend benötigt
|
// Standard: Anthropic
|
||||||
var anthropicToken = userApiKey?.ApiKey;
|
var anthropicToken = userApiKey?.ApiKey;
|
||||||
|
|
||||||
|
// Kein API-Key → Claude CLI als Fallback verwenden
|
||||||
if (string.IsNullOrEmpty(anthropicToken))
|
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);
|
if (anthropic.IsCliAvailable)
|
||||||
await SseHelper.TryWriteSse(res, "done", new { chainId = ctx.ChainId }, sseCt);
|
{
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,4 +16,9 @@ public class ApiKeyRecord
|
|||||||
public string SourceUrl { get; set; } // GitHub-URL (bei adoptierten Scan-Keys)
|
public string SourceUrl { get; set; } // GitHub-URL (bei adoptierten Scan-Keys)
|
||||||
public string CreatedAt { get; set; }
|
public string CreatedAt { get; set; }
|
||||||
public string UpdatedAt { 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)
|
||||||
}
|
}
|
||||||
@@ -115,6 +115,9 @@ builder.Services.AddHostedService<KeyScannerService>(sp => sp.GetRequiredService
|
|||||||
builder.Services.AddSingleton<GitHubEventScannerService>();
|
builder.Services.AddSingleton<GitHubEventScannerService>();
|
||||||
builder.Services.AddHostedService<GitHubEventScannerService>(sp => sp.GetRequiredService<GitHubEventScannerService>());
|
builder.Services.AddHostedService<GitHubEventScannerService>(sp => sp.GetRequiredService<GitHubEventScannerService>());
|
||||||
|
|
||||||
|
// Claude CLI Service (für OAuth-basierte Chats via CLI)
|
||||||
|
builder.Services.AddSingleton<ClaudeCliService>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// No-cache for static files (mobile browsers love caching too much)
|
// 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<IHttpClientFactory>());
|
toolService.SetHttpClientFactory(app.Services.GetRequiredService<IHttpClientFactory>());
|
||||||
app.Services.GetRequiredService<AnthropicService>().SetTelegramService(app.Services.GetRequiredService<TelegramService>());
|
app.Services.GetRequiredService<AnthropicService>().SetTelegramService(app.Services.GetRequiredService<TelegramService>());
|
||||||
app.Services.GetRequiredService<AnthropicService>().SetOpenAiService(app.Services.GetRequiredService<OpenAiService>());
|
app.Services.GetRequiredService<AnthropicService>().SetOpenAiService(app.Services.GetRequiredService<OpenAiService>());
|
||||||
|
app.Services.GetRequiredService<AnthropicService>().SetCliService(app.Services.GetRequiredService<ClaudeCliService>());
|
||||||
await app.Services.GetRequiredService<OpenAiService>().LoadModelsAsync();
|
await app.Services.GetRequiredService<OpenAiService>().LoadModelsAsync();
|
||||||
app.UseWebSockets();
|
app.UseWebSockets();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
|
using System.Linq;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@@ -24,6 +25,7 @@ public class AnthropicService
|
|||||||
private readonly EmbeddingBackgroundService _embeddingBg;
|
private readonly EmbeddingBackgroundService _embeddingBg;
|
||||||
private ChatService _chatService;
|
private ChatService _chatService;
|
||||||
private TelegramService _telegramService;
|
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)
|
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;
|
_embeddingBg = embeddingBg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void SetCliService(ClaudeCliService cliService)
|
||||||
|
{
|
||||||
|
_cliService = cliService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prüft ob die Claude CLI verfügbar ist.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsCliAvailable => _cliService?.IsAvailable ?? false;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wird nach der Konstruktion gesetzt um zirkuläre Abhängigkeit zu vermeiden
|
/// Wird nach der Konstruktion gesetzt um zirkuläre Abhängigkeit zu vermeiden
|
||||||
/// (ChatService → AnthropicService → ChatService).
|
/// (ChatService → AnthropicService → ChatService).
|
||||||
@@ -1781,4 +1793,295 @@ public class AnthropicService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WriteSseEvent, TryWriteSse, TokenUsage, ToolResult → SseHelper.cs
|
// WriteSseEvent, TryWriteSse, TokenUsage, ToolResult → SseHelper.cs
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streaming-Chat über die Claude CLI.
|
||||||
|
/// Die CLI verwendet das lokale Token aus ~/.claude/.credentials.json.
|
||||||
|
/// </summary>
|
||||||
|
public async Task StreamChatViaCli(
|
||||||
|
HttpResponse response,
|
||||||
|
string modelId,
|
||||||
|
List<object> systemPrompt,
|
||||||
|
List<object> 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<JsonElement>(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 = $"<conversation_history>\n{historyContext}\n</conversation_history>\n\n<current_request>\n{prompt}\n</current_request>";
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Baut einen Kontext-String aus der Konversations-History.
|
||||||
|
/// </summary>
|
||||||
|
private static string BuildHistoryContext(List<object> 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<JsonElement>(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<JsonElement>(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<string>();
|
||||||
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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).
|
||||||
|
/// </summary>
|
||||||
|
public class ClaudeCliService
|
||||||
|
{
|
||||||
|
private readonly ILogger<ClaudeCliService> _logger;
|
||||||
|
private readonly string _cliPath;
|
||||||
|
private readonly bool _isAvailable;
|
||||||
|
|
||||||
|
public ClaudeCliService(ILogger<ClaudeCliService> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streaming-Chat über die Claude CLI mit Event-basiertem Callback.
|
||||||
|
/// Unterstützt Text-Streaming und Tool-Use Events.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<CliChatResult> ChatStreamingAsync(
|
||||||
|
string prompt,
|
||||||
|
Func<CliStreamEvent, Task> 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<string>
|
||||||
|
{
|
||||||
|
"-p",
|
||||||
|
"--verbose",
|
||||||
|
"--output-format", "stream-json",
|
||||||
|
"--include-partial-messages",
|
||||||
|
"--no-session-persistence",
|
||||||
|
"--permission-mode", "acceptEdits",
|
||||||
|
"--disallowed-tools", "AskUserQuestion"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tools konfigurieren
|
||||||
|
var tools = new List<string>();
|
||||||
|
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<CliChatResult> RunCliStreamingAsync(
|
||||||
|
List<string> args,
|
||||||
|
string stdinPrompt,
|
||||||
|
Func<CliStreamEvent, Task> 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<JsonElement>(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<JsonElement>(choiceJsonBuffer.ToString());
|
||||||
|
var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
|
||||||
|
var choices = new List<string>();
|
||||||
|
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<JsonElement>(choiceJsonBuffer.ToString());
|
||||||
|
var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
|
||||||
|
var choices = new List<string>();
|
||||||
|
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<JsonElement>(choiceJsonBuffer.ToString());
|
||||||
|
var question = choiceJson.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
|
||||||
|
var choices = new List<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Prüft ob die CLI funktioniert und authentifiziert ist.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<CliStatusResult> 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<CliChatResult> RunSimpleAsync(List<string> 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<string> 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; }
|
||||||
|
}
|
||||||
@@ -563,4 +563,61 @@ public partial class DatabaseService
|
|||||||
$"SELECT COUNT(*) FROM scanner_logs {where}",
|
$"SELECT COUNT(*) FROM scanner_logs {where}",
|
||||||
new { type, scanner });
|
new { type, scanner });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── OAuth Tokens ──
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Erstellt einen neuen API-Key-Eintrag für OAuth-Tokens.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Aktualisiert Access-Token und Ablaufzeit eines OAuth-Keys.
|
||||||
|
/// </summary>
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Holt alle OAuth-Keys die bald ablaufen (innerhalb von refreshBeforeMinutes).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IEnumerable<ApiKeyRecord>> GetOAuthKeysNeedingRefresh(int refreshBeforeMinutes = 60)
|
||||||
|
{
|
||||||
|
using var conn = CreateConnection();
|
||||||
|
var threshold = DateTime.UtcNow.AddMinutes(refreshBeforeMinutes).ToString("o");
|
||||||
|
return await conn.QueryAsync<ApiKeyRecord>(@"
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Setzt den Status eines API-Keys auf "expired".
|
||||||
|
/// </summary>
|
||||||
|
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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -357,6 +357,11 @@ public partial class DatabaseService
|
|||||||
TryAddColumn(conn, "api_keys", "source_url", "TEXT");
|
TryAddColumn(conn, "api_keys", "source_url", "TEXT");
|
||||||
TryAddColumn(conn, "api_keys", "is_public", "INTEGER NOT NULL DEFAULT 0");
|
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)
|
// users: ausgewählter API-Key (legacy, wird durch agent_api_keys ersetzt)
|
||||||
TryAddColumn(conn, "users", "selected_api_key_id", "TEXT");
|
TryAddColumn(conn, "users", "selected_api_key_id", "TEXT");
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user