- MCP: replace fixed single Unity field with a generic list of servers
({name, url, token?}); dynamic mcp-config + allowlist glob mcp__*<name>*
per entry; new mcp-servers-ui.js editor (no data migration)
- Models: frontend refresh button wired to existing POST /api/models/refresh,
reloads optgroups and restores selection
- Model: add sonnet-5 alias + dropdown option (bare "sonnet" stays 4.6)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1004 lines
44 KiB
C#
1004 lines
44 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using Gott.Endpoints;
|
||
using Gott.Models;
|
||
using Microsoft.AspNetCore.Http;
|
||
|
||
namespace Gott.Services;
|
||
|
||
public partial class AnthropicService
|
||
{
|
||
private static string _cachedPythonCmd;
|
||
private static bool _pythonCmdChecked;
|
||
|
||
/// <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 ultracode = false,
|
||
bool webSearch = false,
|
||
string sessionId = null,
|
||
string agentId = null,
|
||
RemoteConnectionManager remoteMgr = null,
|
||
string mcpServers = null,
|
||
bool useAutoContext = true,
|
||
string effort = null,
|
||
TokenUsage seedUsage = null,
|
||
CancellationToken workCt = default,
|
||
CancellationToken sseCt = default)
|
||
{
|
||
if (_cliService == null || !_cliService.IsAvailable)
|
||
{
|
||
await SseHelper.TryWriteSse(response, "error", new { error = "Claude CLI nicht verfügbar" }, sseCt);
|
||
await SseHelper.TryWriteSse(response, "done", new { chainId }, sseCt);
|
||
return;
|
||
}
|
||
|
||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||
var usage = new TokenUsage();
|
||
// Panel-Member-Tokens (Fusion/MoA) vorab in die Chain-Usage falten → erscheinen im
|
||
// done-Event (Live-Badge) UND in FinalizeChain (DB, überlebt F5). Null/leer = No-op.
|
||
if (seedUsage != null) usage.Add(seedUsage);
|
||
var fullText = new StringBuilder();
|
||
var tempImagePaths = new List<string>();
|
||
string mcpConfigPath = null;
|
||
bool isUserChoice = false;
|
||
CancellationTokenSource segCts = null; // aktuelles CLI-Segment (Kill-and-Restart-Steering)
|
||
|
||
var chain = new ChainRecord
|
||
{
|
||
ChainId = chainId,
|
||
SessionId = sessionId,
|
||
Model = modelId
|
||
};
|
||
|
||
try
|
||
{
|
||
// MCP-Config generieren (immer – für ask_user_choice, optional Memory-Tools + remote_execute)
|
||
var remoteConn = remoteMgr != null && !string.IsNullOrEmpty(sessionId) ? remoteMgr.GetBySessionId(sessionId) : null;
|
||
var hasRemote = remoteConn != null;
|
||
var remoteOs = remoteConn?.OsVersion ?? "";
|
||
var mcpServerList = ClaudeCliService.ParseMcpServers(mcpServers);
|
||
mcpConfigPath = GenerateMcpConfig(sessionId, agentId, hasRemote, mcpServerList, remoteOs, modelId, webSearch);
|
||
|
||
// Python-Verfügbarkeit prüfen und ggf. warnen
|
||
if (FindPythonCommand() == null)
|
||
await SseHelper.TryWriteSse(response, "warning", new { message = "⚠ Python wurde nicht gefunden (python3/python/py). MCP-Tools (Remote-Execute, Memory, etc.) sind nicht verfügbar." }, sseCt);
|
||
|
||
// 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());
|
||
}
|
||
}
|
||
|
||
|
||
// ── Automatische Kontext-Injektion via Embedding-Suche (wie in StreamChat) ──
|
||
if (useAutoContext && messages.Count > 0)
|
||
{
|
||
try
|
||
{
|
||
var autoCtx = await BuildAutoContextAsync(_vectorSearch, messages, chainId, agentId);
|
||
if (autoCtx != null)
|
||
{
|
||
systemText.AppendLine(autoCtx);
|
||
await SseHelper.TryWriteSse(response, "auto_context", new { context = autoCtx }, sseCt);
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Role = "system",
|
||
Type = "auto_context",
|
||
Content = autoCtx
|
||
});
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Console.WriteLine($"[AutoContext/CLI] Error: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
// Letzte User-Message als Prompt extrahieren
|
||
var prompt = ExtractLastUserText(messages) ?? "";
|
||
|
||
// Bild-Attachments aus letzter User-Message als Temp-Dateien speichern
|
||
// (CLI kann Bilder nicht inline empfangen, aber per Read-Tool lesen)
|
||
var imageRefs = ExtractLastUserImageRefs(messages, tempImagePaths);
|
||
if (!string.IsNullOrEmpty(imageRefs))
|
||
{
|
||
prompt = imageRefs + "\n" + prompt;
|
||
}
|
||
|
||
// 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>";
|
||
}
|
||
|
||
string choiceToolCallId = null;
|
||
|
||
// ── Kill-and-Restart-Steering ──
|
||
// Der Agentic-Loop läuft INNERHALB von `claude -p` – gott sieht nur Events,
|
||
// echtes Mid-Turn-Inject ist über die CLI nicht möglich (Einwürfe würden nur
|
||
// gequeued). Stattdessen: kommt während eines Laufs ein Einwurf rein, killen wir
|
||
// den Child am nächsten Tool-Boundary, hängen den bisherigen Verlauf + den Einwurf
|
||
// an den Prompt und starten die CLI neu (Abbruch-Semantik – mehr geht über die CLI nicht).
|
||
var basePrompt = prompt;
|
||
var currentPrompt = prompt;
|
||
int totalTurns = 0;
|
||
double totalCost = 0;
|
||
int steerRestartCount = 0;
|
||
CliChatResult result = null;
|
||
bool steerRestart;
|
||
bool segSteerTriggered = false;
|
||
|
||
// Event-Handler als lokale Funktion (damit der Restart-Loop flach bleibt)
|
||
async Task HandleCliEvent(CliStreamEvent evt)
|
||
{
|
||
switch (evt.Type)
|
||
{
|
||
case CliEventType.TextStart:
|
||
await SseHelper.TryWriteSse(response, "text_start", new { index = 0 }, sseCt);
|
||
break;
|
||
case CliEventType.TextDelta:
|
||
fullText.Append(evt.Text);
|
||
await SseHelper.TryWriteSse(response, "text_delta", new { text = evt.Text }, sseCt);
|
||
break;
|
||
case CliEventType.TextEnd:
|
||
await SseHelper.TryWriteSse(response, "text_end", new { }, sseCt);
|
||
break;
|
||
case CliEventType.ToolStart:
|
||
{
|
||
// Text-Puffer flushen bevor Tool-Use gespeichert wird (korrekte Reihenfolge in DB)
|
||
if (fullText.Length > 0)
|
||
{
|
||
var textForEmbed = fullText.ToString();
|
||
var msgId = await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = 1,
|
||
Role = "assistant",
|
||
Type = "text",
|
||
Content = textForEmbed
|
||
});
|
||
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", textForEmbed);
|
||
fullText.Clear();
|
||
}
|
||
var mappedName = MapCliToolName(evt.ToolName);
|
||
await SseHelper.TryWriteSse(response, "tool_start", new { name = mappedName, id = evt.ToolId }, sseCt);
|
||
break;
|
||
}
|
||
case CliEventType.ToolEnd:
|
||
{
|
||
var mappedName = MapCliToolName(evt.ToolName);
|
||
var mappedInput = MapCliToolInput(evt.ToolName, evt.ToolInput);
|
||
// 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 = mappedName,
|
||
ToolCallId = evt.ToolId,
|
||
Content = mappedInput
|
||
});
|
||
await SseHelper.TryWriteSse(response, "tool_end", new { name = mappedName, id = evt.ToolId, input = mappedInput }, sseCt);
|
||
|
||
// ask_user_choice via MCP: TCS erstellen + SSE user_choice Event
|
||
if (mappedName == "ask_user_choice")
|
||
{
|
||
try
|
||
{
|
||
var choiceInput = JsonSerializer.Deserialize<JsonElement>(mappedInput);
|
||
var cQuestion = choiceInput.TryGetProperty("question", out var cq) ? cq.GetString() ?? "" : "";
|
||
var cChoices = new List<string>();
|
||
if (choiceInput.TryGetProperty("choices", out var cc) && cc.ValueKind == JsonValueKind.Array)
|
||
foreach (var cOpt in cc.EnumerateArray())
|
||
cChoices.Add(cOpt.GetString() ?? "");
|
||
|
||
// Alte Antwort entfernen (falls vorhanden)
|
||
InternalEndpoint.ChoiceAnswers.TryRemove(sessionId, out _);
|
||
|
||
// SSE Event mit viaCli-Flag → Frontend zeigt Buttons + nutzt choice-respond Endpoint
|
||
choiceToolCallId = evt.ToolId;
|
||
await SseHelper.TryWriteSse(response, "user_choice", new
|
||
{
|
||
toolCallId = evt.ToolId,
|
||
question = cQuestion,
|
||
choices = cChoices,
|
||
viaCli = true
|
||
}, sseCt);
|
||
|
||
isUserChoice = true;
|
||
}
|
||
catch (JsonException) { /* Input nicht parsbar – MCP-Server gibt Fehler */ }
|
||
}
|
||
break;
|
||
}
|
||
case CliEventType.ToolResult:
|
||
{
|
||
// Tool-Result in DB speichern (damit es beim Neuladen sichtbar ist)
|
||
var resultContent = SseHelper.TruncateToolResult(evt.ToolResultContent ?? "");
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = 1,
|
||
Role = "user",
|
||
Type = "tool_result",
|
||
ToolCallId = evt.ToolId,
|
||
Content = resultContent,
|
||
IsError = evt.ToolResultIsError
|
||
});
|
||
// SSE Event an Client senden
|
||
await SseHelper.TryWriteSse(response, "tool_result", new
|
||
{
|
||
id = evt.ToolId,
|
||
result = resultContent,
|
||
isError = evt.ToolResultIsError
|
||
}, sseCt);
|
||
|
||
// ask_user_choice wurde gerade beantwortet (Result des Choice-Tools kam zurück)
|
||
// → Choice-Pause aufheben: die CLI nimmt die Arbeit in DERSELBEN Invocation
|
||
// wieder auf, also muss Steering ab hier wieder greifen. Ohne diesen Reset
|
||
// bliebe isUserChoice für den Rest des Laufs true und ein Einwurf käme erst
|
||
// ganz am Chain-Ende (als Close()-Leftover) an statt am nächsten Boundary.
|
||
if (isUserChoice && choiceToolCallId != null && evt.ToolId == choiceToolCallId)
|
||
{
|
||
isUserChoice = false;
|
||
choiceToolCallId = null;
|
||
}
|
||
|
||
// Steering: Tool-Boundary erreicht – steht ein Einwurf an, Child killen
|
||
// (löst OperationCanceledException aus → Restart unten).
|
||
if (!segSteerTriggered && !isUserChoice
|
||
&& _steeringQueue != null && !string.IsNullOrEmpty(sessionId)
|
||
&& _steeringQueue.HasPending(sessionId))
|
||
{
|
||
segSteerTriggered = true;
|
||
try { segCts?.Cancel(); } catch { }
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
do
|
||
{
|
||
steerRestart = false;
|
||
segSteerTriggered = false;
|
||
segCts?.Dispose();
|
||
segCts = CancellationTokenSource.CreateLinkedTokenSource(workCt);
|
||
|
||
try
|
||
{
|
||
result = await _cliService.ChatStreamingAsync(
|
||
currentPrompt,
|
||
HandleCliEvent,
|
||
modelId: modelId,
|
||
systemPrompt: systemText.Length > 0 ? systemText.ToString() : null,
|
||
workspace: workspace,
|
||
hasWorkspace: !string.IsNullOrEmpty(workspace),
|
||
planMode: planMode,
|
||
ultracode: ultracode,
|
||
webSearch: webSearch,
|
||
mcpConfigPath: mcpConfigPath,
|
||
mcpServerNames: mcpServerList.Select(s => s.Name).ToList(),
|
||
effort: effort,
|
||
sessionId: sessionId,
|
||
chainId: chainId,
|
||
cancellationToken: segCts.Token
|
||
);
|
||
}
|
||
catch (OperationCanceledException) when (segSteerTriggered && !workCt.IsCancellationRequested)
|
||
{
|
||
// Steering-Kill (kein echter Abbruch) → Restart unten vorbereiten
|
||
result = null;
|
||
}
|
||
|
||
// Tokens des abgeschlossenen Segments akkumulieren (bei Kill: result == null)
|
||
if (result != null)
|
||
{
|
||
usage.InputTokens += result.InputTokens;
|
||
usage.OutputTokens += result.OutputTokens;
|
||
usage.CacheReadTokens += result.CacheReadTokens;
|
||
usage.CacheCreationTokens += result.CacheCreationTokens;
|
||
totalCost += result.CostUsd;
|
||
totalTurns += result.NumTurns > 0 ? result.NumTurns : 1;
|
||
}
|
||
|
||
// Restart nötig? (a) Kill am Tool-Boundary, (b) Einwurf kam erst am Turn-Ende rein
|
||
bool steerPending = !isUserChoice && _steeringQueue != null
|
||
&& !string.IsNullOrEmpty(sessionId) && _steeringQueue.HasPending(sessionId);
|
||
if (segSteerTriggered || steerPending)
|
||
{
|
||
steerRestart = true;
|
||
Console.WriteLine($"[CLI-Steering] Restart #{++steerRestartCount} (chain {chainId})");
|
||
|
||
// 1. Bisher gepufferten Assistant-Text dieses Segments persistieren
|
||
if (fullText.Length > 0)
|
||
{
|
||
var partial = fullText.ToString();
|
||
var pid = await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId, Iteration = 1,
|
||
Role = "assistant", Type = "text", Content = partial
|
||
});
|
||
_embeddingBg?.Enqueue(pid, chainId, sessionId, agentId, "assistant", partial);
|
||
fullText.Clear();
|
||
}
|
||
|
||
// 2. Verlauf dieses Durchlaufs aus der DB als Transkript (vor dem Persistieren der neuen Einwürfe)
|
||
var progressLog = await BuildCliProgressLog(chainId);
|
||
|
||
// 3. Einwürfe entnehmen, RAW persistieren (Type=steer), via SSE melden, gewrappt sammeln
|
||
var steers = _steeringQueue.Drain(sessionId);
|
||
var wrapped = new StringBuilder();
|
||
foreach (var steer in steers)
|
||
{
|
||
var sid = await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId, Iteration = 1,
|
||
Role = "user", Type = "steer", Content = steer
|
||
});
|
||
_embeddingBg?.Enqueue(sid, chainId, sessionId, agentId, "user", steer);
|
||
await SseHelper.TryWriteSse(response, "steer_injected", new { text = steer }, sseCt);
|
||
wrapped.AppendLine(WrapSteeringMessage(steer));
|
||
}
|
||
|
||
// 4. Restart-Prompt: Basis-Prompt + bisheriger Verlauf + Einwürfe
|
||
var sb = new StringBuilder(basePrompt);
|
||
if (!string.IsNullOrEmpty(progressLog))
|
||
{
|
||
sb.Append("\n\n<work_in_progress>\nDu arbeitest bereits an obiger Aufgabe. Bisheriger Verlauf in diesem Durchlauf (Text, Tool-Aufrufe, Ergebnisse):\n");
|
||
sb.Append(progressLog);
|
||
sb.Append("\n\nWICHTIG: Setze die Aufgabe JETZT aktiv fort und bringe sie in diesem Durchlauf zu Ende. Bestätige den Einwurf NICHT nur und beende dann den Turn – arbeite die noch offenen Schritte selbst weiter ab. Falls du zuvor Hintergrund-/Async-Operationen gestartet hattest (z.B. Background-Bash-Tasks, lange Sleeps): diese wurden durch den Neustart beendet und existieren NICHT mehr – warte nicht passiv auf sie, sondern starte sie neu, falls für die Fertigstellung nötig.\n</work_in_progress>");
|
||
}
|
||
sb.Append("\n\n");
|
||
sb.Append(wrapped.ToString().Trim());
|
||
currentPrompt = sb.ToString();
|
||
}
|
||
}
|
||
while (steerRestart && !workCt.IsCancellationRequested);
|
||
|
||
// Bei CLI-Fehler → Fehlermeldung an Client senden und in DB speichern
|
||
if (result != null && !result.Success && !isUserChoice)
|
||
{
|
||
var errorMsg = !string.IsNullOrEmpty(result.Error) ? result.Error : "CLI hat keine Antwort geliefert";
|
||
// Error in DB speichern (damit er beim Neuladen sichtbar ist)
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = 1,
|
||
Role = "system",
|
||
Type = "error",
|
||
Content = $"CLI-Fehler: {errorMsg}"
|
||
});
|
||
await SseHelper.TryWriteSse(response, "error", new { error = $"CLI-Fehler: {errorMsg}" }, sseCt);
|
||
}
|
||
|
||
// Antwort in DB speichern (finaler Text des letzten Segments)
|
||
if (fullText.Length > 0)
|
||
{
|
||
var finalText = fullText.ToString();
|
||
var msgId = await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = 1,
|
||
Role = "assistant",
|
||
Type = "text",
|
||
Content = finalText
|
||
});
|
||
_embeddingBg?.Enqueue(msgId, chainId, sessionId, agentId, "assistant", finalText);
|
||
}
|
||
|
||
// Token-Usage wurde segmentweise in usage/totalTurns/totalCost akkumuliert (siehe Restart-Loop)
|
||
var stopReason = isUserChoice ? "awaiting_user_choice" : (result != null && result.Success ? "end_turn" : "error");
|
||
await FinalizeChain(chain, usage, totalTurns > 0 ? totalTurns : 1, stopReason, sw);
|
||
|
||
await SseHelper.TryWriteSse(response, "done", new
|
||
{
|
||
model = modelId,
|
||
inputTokens = usage.InputTokens,
|
||
outputTokens = usage.OutputTokens,
|
||
cacheCreationTokens = usage.CacheCreationTokens,
|
||
cacheReadTokens = usage.CacheReadTokens,
|
||
costUsd = totalCost,
|
||
iterations = totalTurns > 0 ? totalTurns : 1,
|
||
chainId,
|
||
durationMs = sw.ElapsedMilliseconds,
|
||
stopReason,
|
||
effort,
|
||
viaCli = true
|
||
}, sseCt);
|
||
}
|
||
catch (OperationCanceledException) when (workCt.IsCancellationRequested)
|
||
{
|
||
// Stop-Button gedrückt → CLI-Prozess wurde gestoppt
|
||
// Wenn ask_user_choice aktiv war: als awaiting_user_choice markieren,
|
||
// damit die nächste Chain den Kontext (Frage + Optionen) behält.
|
||
var stoppedReason = isUserChoice ? "awaiting_user_choice" : "stopped";
|
||
await FinalizeChain(chain, usage, 1, stoppedReason, sw);
|
||
await SseHelper.TryWriteSse(response, "done", new
|
||
{
|
||
model = modelId,
|
||
chainId,
|
||
durationMs = sw.ElapsedMilliseconds,
|
||
stopReason = stoppedReason,
|
||
effort,
|
||
viaCli = true
|
||
}, CancellationToken.None);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// Client-Disconnect (sseCt gecancelled, aber NICHT workCt)
|
||
// CLI-Prozess läuft weiter im Hintergrund – hier nur Chain finalisieren
|
||
// (sollte nicht mehr passieren, da CLI jetzt workCt nutzt)
|
||
await FinalizeChain(chain, usage, 1, "client_disconnected", sw);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
var errorMsg = $"CLI-Fehler: {ex.Message}";
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = chainId,
|
||
Iteration = 1,
|
||
Role = "system",
|
||
Type = "error",
|
||
Content = errorMsg
|
||
});
|
||
await SseHelper.TryWriteSse(response, "error", new { error = errorMsg }, sseCt);
|
||
await FinalizeChain(chain, usage, 1, "error", sw);
|
||
await SseHelper.TryWriteSse(response, "done", new
|
||
{
|
||
model = modelId,
|
||
chainId,
|
||
durationMs = sw.ElapsedMilliseconds,
|
||
stopReason = "error",
|
||
effort,
|
||
viaCli = true
|
||
}, sseCt);
|
||
}
|
||
finally
|
||
{
|
||
segCts?.Dispose();
|
||
|
||
// Temp-Bilddateien aufräumen
|
||
CleanupTempImages(tempImagePaths);
|
||
|
||
// MCP-Config Temp-Datei aufräumen
|
||
if (mcpConfigPath != null)
|
||
{
|
||
try { File.Delete(mcpConfigPath); } catch { }
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Baut aus den bereits gespeicherten Messages einer Chain ein kompaktes Verlauf-Transkript
|
||
/// für den Kill-and-Restart-Steering-Prompt (was hat das Modell in diesem Durchlauf bereits getan).
|
||
/// </summary>
|
||
private async Task<string> BuildCliProgressLog(string chainId)
|
||
{
|
||
var msgs = await _db.GetChainMessages(chainId);
|
||
var sb = new StringBuilder();
|
||
foreach (var m in msgs)
|
||
{
|
||
switch (m.Type)
|
||
{
|
||
case "text":
|
||
if (m.Role == "assistant" && !string.IsNullOrWhiteSpace(m.Content))
|
||
sb.AppendLine("[Du]: " + m.Content.Trim());
|
||
break;
|
||
case "tool_use":
|
||
sb.AppendLine($"[Du hast aufgerufen: {m.ToolName}] {TruncateForLog(m.Content, 600)}");
|
||
break;
|
||
case "tool_result":
|
||
sb.AppendLine($"[Ergebnis{(m.IsError ? " (Fehler)" : "")}]: {TruncateForLog(m.Content, 600)}");
|
||
break;
|
||
case "steer":
|
||
sb.AppendLine("[Vorheriger Live-Einwurf des Users]: " + (m.Content ?? "").Trim());
|
||
break;
|
||
}
|
||
}
|
||
return sb.ToString().Trim();
|
||
}
|
||
|
||
private static string TruncateForLog(string s, int max)
|
||
{
|
||
if (string.IsNullOrEmpty(s)) return "";
|
||
s = s.Replace("\r", " ").Replace("\n", " ").Trim();
|
||
return s.Length <= max ? s : s.Substring(0, max) + " …[gekürzt]";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Non-Streaming Chat über die Claude CLI für Telegram (und andere Caller, die kein SSE brauchen).
|
||
/// Sammelt den finalen Text und gibt ihn zurück. Persistiert Text, Tool-Use und Tool-Result in der DB,
|
||
/// damit die History im Web-UI korrekt rekonstruiert werden kann.
|
||
/// </summary>
|
||
public async Task<string> ChatNonStreamingViaCli(
|
||
ChatContext ctx,
|
||
List<object> messages,
|
||
Func<int, Task> onIteration = null,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
if (_cliService == null || !_cliService.IsAvailable)
|
||
return "";
|
||
|
||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||
var usage = new TokenUsage();
|
||
var tempImagePaths = new List<string>();
|
||
|
||
var chain = new ChainRecord
|
||
{
|
||
ChainId = ctx.ChainId,
|
||
SessionId = ctx.SessionId,
|
||
Model = ctx.ModelId
|
||
};
|
||
|
||
try
|
||
{
|
||
// System-Prompt als Text
|
||
var systemText = new StringBuilder(ctx.SystemPromptText ?? "");
|
||
|
||
// Auto-Context (analog Streaming-Pfad)
|
||
if ((ctx.Agent?.UseAutoContext ?? true) && messages.Count > 0)
|
||
{
|
||
try
|
||
{
|
||
var autoCtx = await BuildAutoContextAsync(_vectorSearch, messages, ctx.ChainId, ctx.AgentId);
|
||
if (!string.IsNullOrEmpty(autoCtx))
|
||
{
|
||
systemText.AppendLine(autoCtx);
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = ctx.ChainId,
|
||
Role = "system",
|
||
Type = "auto_context",
|
||
Content = autoCtx
|
||
});
|
||
}
|
||
}
|
||
catch { /* silent – Auto-Context optional */ }
|
||
}
|
||
|
||
// Prompt aus letzter User-Message + Bild-/PDF-Refs + History-Kontext
|
||
var prompt = ExtractLastUserText(messages) ?? "";
|
||
var imageRefs = ExtractLastUserImageRefs(messages, tempImagePaths);
|
||
if (!string.IsNullOrEmpty(imageRefs))
|
||
prompt = imageRefs + "\n" + prompt;
|
||
|
||
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>";
|
||
|
||
var fullText = new StringBuilder();
|
||
if (onIteration != null) await onIteration(1);
|
||
|
||
var result = await _cliService.ChatStreamingAsync(
|
||
prompt,
|
||
async evt =>
|
||
{
|
||
switch (evt.Type)
|
||
{
|
||
case CliEventType.TextDelta:
|
||
fullText.Append(evt.Text);
|
||
break;
|
||
case CliEventType.ToolStart:
|
||
// Text-Puffer flushen bevor Tool-Use gespeichert wird (korrekte Reihenfolge in DB)
|
||
if (fullText.Length > 0)
|
||
{
|
||
var textForEmbed = fullText.ToString();
|
||
var preMsgId = await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = ctx.ChainId,
|
||
Iteration = 1,
|
||
Role = "assistant",
|
||
Type = "text",
|
||
Content = textForEmbed
|
||
});
|
||
_embeddingBg?.Enqueue(preMsgId, ctx.ChainId, ctx.SessionId, ctx.AgentId, "assistant", textForEmbed);
|
||
fullText.Clear();
|
||
}
|
||
if (onIteration != null) await onIteration(1);
|
||
break;
|
||
case CliEventType.ToolEnd:
|
||
{
|
||
var mappedName = MapCliToolName(evt.ToolName);
|
||
var mappedInput = MapCliToolInput(evt.ToolName, evt.ToolInput);
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = ctx.ChainId,
|
||
Iteration = 1,
|
||
Role = "assistant",
|
||
Type = "tool_use",
|
||
ToolName = mappedName,
|
||
ToolCallId = evt.ToolId,
|
||
Content = mappedInput
|
||
});
|
||
break;
|
||
}
|
||
case CliEventType.ToolResult:
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = ctx.ChainId,
|
||
Iteration = 1,
|
||
Role = "user",
|
||
Type = "tool_result",
|
||
ToolCallId = evt.ToolId,
|
||
Content = evt.ToolResultContent ?? "",
|
||
IsError = evt.ToolResultIsError
|
||
});
|
||
break;
|
||
}
|
||
},
|
||
modelId: ctx.ModelId,
|
||
systemPrompt: systemText.ToString(),
|
||
workspace: ctx.Workspace,
|
||
hasWorkspace: !string.IsNullOrEmpty(ctx.Workspace),
|
||
effort: ctx.Effort,
|
||
cancellationToken: cancellationToken
|
||
);
|
||
|
||
// Finalen Text persistieren
|
||
var finalText = fullText.ToString();
|
||
if (finalText.Length > 0)
|
||
{
|
||
var msgId = await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = ctx.ChainId,
|
||
Iteration = 1,
|
||
Role = "assistant",
|
||
Type = "text",
|
||
Content = finalText
|
||
});
|
||
_embeddingBg?.Enqueue(msgId, ctx.ChainId, ctx.SessionId, ctx.AgentId, "assistant", finalText);
|
||
}
|
||
|
||
usage.InputTokens = result.InputTokens;
|
||
usage.OutputTokens = result.OutputTokens;
|
||
usage.CacheReadTokens = result.CacheReadTokens;
|
||
usage.CacheCreationTokens = result.CacheCreationTokens;
|
||
|
||
var stopReason = result.Success ? "end_turn" : "error";
|
||
await FinalizeChain(chain, usage, result.NumTurns > 0 ? result.NumTurns : 1, stopReason, sw);
|
||
|
||
return finalText;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
await _db.SaveMessage(new MessageRecord
|
||
{
|
||
ChainId = ctx.ChainId, Iteration = 1,
|
||
Role = "system", Type = "error",
|
||
Content = $"CLI-Fehler: {ex.Message}", IsError = true
|
||
});
|
||
await FinalizeChain(chain, usage, 1, "error", sw);
|
||
return "";
|
||
}
|
||
finally
|
||
{
|
||
CleanupTempImages(tempImagePaths);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Mappt CLI-Tool-Namen (Edit, Write, Bash, etc.) auf gott-Tool-Namen (edit_file, write_file, etc.).
|
||
/// </summary>
|
||
private static string MapCliToolName(string cliName)
|
||
{
|
||
return cliName switch
|
||
{
|
||
"Edit" => "edit_file",
|
||
"Write" => "write_file",
|
||
"Read" => "read_file",
|
||
"Bash" => "execute",
|
||
"Glob" => "list_files",
|
||
"Grep" => "search_files",
|
||
"WebSearch" => "web_search",
|
||
"WebFetch" => "web_fetch",
|
||
"NotebookEdit" => "notebook_edit",
|
||
"TodoWrite" => "todo_write",
|
||
_ => cliName?.StartsWith("mcp__") == true ? cliName.Split("__").LastOrDefault() ?? cliName : cliName
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Mappt CLI-Tool-Input-Felder auf gott-Konventionen (z.B. file_path→path, old_string→old_text).
|
||
/// </summary>
|
||
private static string MapCliToolInput(string cliToolName, string inputJson)
|
||
{
|
||
if (string.IsNullOrEmpty(inputJson)) return inputJson;
|
||
|
||
try
|
||
{
|
||
var input = JsonSerializer.Deserialize<JsonElement>(inputJson);
|
||
if (input.ValueKind != JsonValueKind.Object) return inputJson;
|
||
|
||
switch (cliToolName)
|
||
{
|
||
case "Edit":
|
||
{
|
||
// file_path → path, old_string → old_text, new_string → new_text
|
||
var mapped = new Dictionary<string, object>();
|
||
foreach (var prop in input.EnumerateObject())
|
||
{
|
||
var key = prop.Name switch
|
||
{
|
||
"file_path" => "path",
|
||
"old_string" => "old_text",
|
||
"new_string" => "new_text",
|
||
_ => prop.Name
|
||
};
|
||
mapped[key] = prop.Value;
|
||
}
|
||
return JsonSerializer.Serialize(mapped);
|
||
}
|
||
case "Write":
|
||
{
|
||
// file_path → path
|
||
var mapped = new Dictionary<string, object>();
|
||
foreach (var prop in input.EnumerateObject())
|
||
{
|
||
var key = prop.Name == "file_path" ? "path" : prop.Name;
|
||
mapped[key] = prop.Value;
|
||
}
|
||
return JsonSerializer.Serialize(mapped);
|
||
}
|
||
case "Read":
|
||
{
|
||
// file_path → path
|
||
var mapped = new Dictionary<string, object>();
|
||
foreach (var prop in input.EnumerateObject())
|
||
{
|
||
var key = prop.Name == "file_path" ? "path" : prop.Name;
|
||
mapped[key] = prop.Value;
|
||
}
|
||
return JsonSerializer.Serialize(mapped);
|
||
}
|
||
case "Bash":
|
||
{
|
||
// command bleibt gleich
|
||
return inputJson;
|
||
}
|
||
default:
|
||
return inputJson;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
return inputJson;
|
||
}
|
||
}
|
||
|
||
/// <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();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sucht einen verfügbaren Python-Befehl (python3, python, py).
|
||
/// Gibt null zurück, wenn keiner gefunden wird.
|
||
/// </summary>
|
||
private static string FindPythonCommand()
|
||
{
|
||
if (_pythonCmdChecked) return _cachedPythonCmd;
|
||
|
||
foreach (var cmd in new[] { "python3", "python", "py" })
|
||
{
|
||
try
|
||
{
|
||
var psi = new System.Diagnostics.ProcessStartInfo
|
||
{
|
||
FileName = cmd,
|
||
Arguments = "--version",
|
||
RedirectStandardOutput = true,
|
||
RedirectStandardError = true,
|
||
UseShellExecute = false,
|
||
CreateNoWindow = true
|
||
};
|
||
using var proc = System.Diagnostics.Process.Start(psi);
|
||
if (proc != null)
|
||
{
|
||
proc.WaitForExit(3000);
|
||
if (proc.ExitCode == 0)
|
||
{
|
||
_cachedPythonCmd = cmd;
|
||
_pythonCmdChecked = true;
|
||
Console.WriteLine($"[MCP] Python gefunden: {cmd}");
|
||
return cmd;
|
||
}
|
||
}
|
||
}
|
||
catch { /* Command not found – try next */ }
|
||
}
|
||
_pythonCmdChecked = true;
|
||
_cachedPythonCmd = null;
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Generiert eine temporäre MCP-Config-Datei für die Claude CLI.
|
||
/// Enthält den MCP-Server für remote_execute.
|
||
/// </summary>
|
||
private string GenerateMcpConfig(string sessionId, string agentId = null, bool hasRemote = false, List<McpServerDef> userMcpServers = null, string remoteOs = null, string modelId = null, bool webSearch = false)
|
||
{
|
||
// Port aus Konfiguration oder Default
|
||
var port = 19000;
|
||
var scriptPath = Path.Combine(AppContext.BaseDirectory, "scripts", "mcp-remote-execute.py");
|
||
|
||
// Fallback: Quell-Verzeichnis prüfen (für Dev-Modus)
|
||
if (!File.Exists(scriptPath))
|
||
{
|
||
var projectRoot = Path.GetDirectoryName(AppPaths.WwwRoot) ?? AppContext.BaseDirectory;
|
||
var srcPath = Path.Combine(projectRoot, "scripts", "mcp-remote-execute.py");
|
||
if (File.Exists(srcPath)) scriptPath = srcPath;
|
||
}
|
||
|
||
var env = new Dictionary<string, string>
|
||
{
|
||
["GOTT_API_URL"] = $"http://localhost:{port}",
|
||
["GOTT_SESSION_ID"] = sessionId ?? "",
|
||
["GOTT_AUTH_TOKEN"] = Endpoints.InternalEndpoint.InternalToken,
|
||
["GOTT_AGENT_ID"] = agentId ?? "",
|
||
["GOTT_HAS_REMOTE"] = hasRemote ? "true" : "false",
|
||
["GOTT_REMOTE_OS"] = remoteOs ?? ""
|
||
};
|
||
|
||
var pythonCmd = FindPythonCommand();
|
||
|
||
var mcpServers = new Dictionary<string, object>
|
||
{
|
||
["gott-remote"] = new
|
||
{
|
||
command = pythonCmd ?? "python3",
|
||
args = new[] { scriptPath },
|
||
env
|
||
}
|
||
};
|
||
|
||
if (pythonCmd == null)
|
||
Console.WriteLine("[MCP] ⚠ Python nicht gefunden (python3/python/py) – MCP-Tools werden nicht funktionieren!");
|
||
|
||
// Benutzerdefinierte MCP-Server (pro Session, URL-basiert: http/sse + optionaler Token).
|
||
// Claude CLI erfordert expliziten "type" – "http" für streamableHttp, "sse" für SSE.
|
||
// Namen sind in ParseMcpServers bereits gesäubert + von reservierten Namen befreit.
|
||
if (userMcpServers != null)
|
||
{
|
||
foreach (var srv in userMcpServers)
|
||
{
|
||
if (srv == null || string.IsNullOrWhiteSpace(srv.Url)) continue;
|
||
// URL-basierte Typ-Erkennung: /sse → SSE-Transport, sonst HTTP (streamableHttp)
|
||
var mcpType = srv.Url.TrimEnd('/').EndsWith("/sse", StringComparison.OrdinalIgnoreCase) ? "sse" : "http";
|
||
var srvConfig = new Dictionary<string, object>
|
||
{
|
||
["type"] = mcpType,
|
||
["url"] = srv.Url
|
||
};
|
||
if (!string.IsNullOrEmpty(srv.Token))
|
||
{
|
||
srvConfig["headers"] = new Dictionary<string, string>
|
||
{
|
||
["Authorization"] = $"Bearer {srv.Token}"
|
||
};
|
||
}
|
||
mcpServers[srv.Name] = srvConfig;
|
||
}
|
||
}
|
||
|
||
// z.ai Web-Search-Prime-MCP: GLM-Modelle können Anthropics native WebSearch nicht nutzen.
|
||
// Läuft ein GLM-Modell mit aktivierter Web-Suche und ist der z.ai-Key gesetzt, registrieren
|
||
// wir z.ais HTTP-MCP-Suchserver (Bearer-Auth mit demselben Key). Tool wird in den
|
||
// --allowed-tools der CLI (ClaudeCliService) als "mcp__web-search-prime__*" freigegeben.
|
||
if (webSearch && ClaudeCliService.IsZaiModel(modelId))
|
||
{
|
||
var zaiKey = _settings?.Get("ZaiApiKey");
|
||
if (!string.IsNullOrEmpty(zaiKey))
|
||
{
|
||
mcpServers["web-search-prime"] = new Dictionary<string, object>
|
||
{
|
||
["type"] = "http",
|
||
["url"] = "https://api.z.ai/api/mcp/web_search_prime/mcp",
|
||
["headers"] = new Dictionary<string, string>
|
||
{
|
||
["Authorization"] = $"Bearer {zaiKey}"
|
||
}
|
||
};
|
||
}
|
||
}
|
||
|
||
var config = new { mcpServers };
|
||
|
||
var json = System.Text.Json.JsonSerializer.Serialize(config, new System.Text.Json.JsonSerializerOptions { WriteIndented = true });
|
||
var tempPath = Path.Combine(Path.GetTempPath(), $"gott-mcp-{sessionId}.json");
|
||
File.WriteAllText(tempPath, json);
|
||
return tempPath;
|
||
}
|
||
}
|