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
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft ob die Claude CLI verfügbar ist.
|
||||
/// </summary>
|
||||
public bool IsCliAvailable => _cliService?.IsAvailable ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
|
||||
/// <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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user