feat: CLI-Streaming – korrekte Tool-Darstellung, Reihenfolge und Error-Handling

- Tool-Namen/Inputs von CLI-Konvention auf gott-Konvention mappen (Edit→edit_file, file_path→path etc.)
- Text vor Tool-Use in DB flushen für korrekte Reihenfolge (statt alles am Ende als Blob)
- lineBuffer in ClaudeCliService vor Tool-Start und bei Text-Block-Ende flushen
- ToolResult-Events parsen und in DB speichern (CLI sendet diese als type:"user" Messages)
- Errors in DB speichern und als SSE-Event senden
- Bild-Attachments als Temp-Dateien für CLI bereitstellen (Read-Tool kann sie lesen)
- Frontend: tool_result Handler, Tool-Name-Normalisierung, appendToolResult()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MelekiandClaude Opus 4.6 committed 2026-03-18 06:48:10 +01:00
1 parent 3e372b5a6e
commit 6d16d00290
4 files changed
+417 -17

No files matched your search

+256 -9
View File
@@ -1656,6 +1656,83 @@ public class AnthropicService
return null;
}
/// <summary>
/// Extrahiert Bild-Blöcke aus der letzten User-Message, speichert sie als Temp-Dateien
/// und gibt Datei-Referenz-Hinweise zurück, die der CLI-Prompt vorangestellt werden.
/// </summary>
private static string ExtractLastUserImageRefs(List<object> messages, List<string> tempPaths)
{
for (int i = messages.Count - 1; i >= 0; i--)
{
var json = JsonSerializer.Serialize(messages[i]);
var el = JsonSerializer.Deserialize<JsonElement>(json);
if (!el.TryGetProperty("role", out var role) || role.GetString() != "user") continue;
if (!el.TryGetProperty("content", out var content) || content.ValueKind != JsonValueKind.Array) break;
var refs = new List<string>();
foreach (var block in content.EnumerateArray())
{
var blockType = block.TryGetProperty("type", out var bt) ? bt.GetString() : "";
if (blockType != "image" && blockType != "document") continue;
if (!block.TryGetProperty("source", out var source)) continue;
var mediaType = source.TryGetProperty("media_type", out var mt) ? mt.GetString() ?? "" : "";
var data = source.TryGetProperty("data", out var d) ? d.GetString() ?? "" : "";
if (string.IsNullOrEmpty(data)) continue;
// Dateiendung aus media_type ableiten
var ext = mediaType switch
{
"image/png" => ".png",
"image/jpeg" => ".jpg",
"image/gif" => ".gif",
"image/webp" => ".webp",
"application/pdf" => ".pdf",
_ => ".bin"
};
// Temp-Verzeichnis sicherstellen
var tempDir = Path.Combine(Path.GetTempPath(), "gott-attachments");
Directory.CreateDirectory(tempDir);
var fileName = $"{Guid.NewGuid():N}{ext}";
var filePath = Path.Combine(tempDir, fileName);
try
{
File.WriteAllBytes(filePath, Convert.FromBase64String(data));
tempPaths.Add(filePath);
var label = blockType == "image" ? "Screenshot" : "Datei";
refs.Add($"[{label}: {filePath}]");
}
catch
{
// Base64-Decode fehlgeschlagen → überspringen
}
}
if (refs.Count > 0)
{
return string.Join(" ", refs) + "\n<hint>Lies die referenzierten Dateien mit dem Read-Tool um sie zu sehen.</hint>";
}
break;
}
return null;
}
/// <summary>
/// Löscht temporäre Bilddateien die für den CLI-Aufruf erstellt wurden.
/// </summary>
private static void CleanupTempImages(List<string> paths)
{
if (paths == null || paths.Count == 0) return;
foreach (var path in paths)
{
try { if (File.Exists(path)) File.Delete(path); } catch { }
}
}
/// <summary>
/// Extrahiert Web-Search-Sources aus einem web_search_tool_result-Block.
/// </summary>
@@ -1821,6 +1898,7 @@ public class AnthropicService
var sw = System.Diagnostics.Stopwatch.StartNew();
var usage = new TokenUsage();
var fullText = new StringBuilder();
var tempImagePaths = new List<string>();
var chain = new ChainRecord
{
@@ -1861,6 +1939,14 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En
// 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))
@@ -1888,9 +1974,28 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En
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);
{
// Text-Puffer flushen bevor Tool-Use gespeichert wird (korrekte Reihenfolge in DB)
if (fullText.Length > 0)
{
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId,
Iteration = 1,
Role = "assistant",
Type = "text",
Content = fullText.ToString()
});
fullText.Clear();
}
var mappedName = MapCliToolName(evt.ToolName);
await SseHelper.TryWriteSse(response, "tool_start", new { name = mappedName, id = evt.ToolId }, cancellationToken);
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
{
@@ -1898,12 +2003,36 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En
Iteration = 1,
Role = "assistant",
Type = "tool_use",
ToolName = evt.ToolName,
ToolName = mappedName,
ToolCallId = evt.ToolId,
Content = evt.ToolInput
Content = mappedInput
});
await SseHelper.TryWriteSse(response, "tool_end", new { name = evt.ToolName, id = evt.ToolId, input = evt.ToolInput }, cancellationToken);
await SseHelper.TryWriteSse(response, "tool_end", new { name = mappedName, id = evt.ToolId, input = mappedInput }, cancellationToken);
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
}, cancellationToken);
break;
}
case CliEventType.UserChoice:
// Text bisher in DB speichern
if (fullText.Length > 0)
@@ -1952,15 +2081,24 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En
cancellationToken: cancellationToken
);
// Bei CLI-Fehler ohne Output → Fehlermeldung an Client senden
if (!result.Success && fullText.Length == 0 && !isUserChoice)
// Bei CLI-Fehler → Fehlermeldung an Client senden und in DB speichern
if (!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}" }, cancellationToken);
}
// Antwort in DB speichern (nur wenn nicht schon im UserChoice-Handler gespeichert)
if (!isUserChoice && result.Success && fullText.Length > 0)
if (!isUserChoice && fullText.Length > 0)
{
await _db.SaveMessage(new MessageRecord
{
@@ -2010,9 +2148,118 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En
}
catch (Exception ex)
{
await SseHelper.TryWriteSse(response, "error", new { error = $"CLI-Fehler: {ex.Message}" }, cancellationToken);
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 }, cancellationToken);
await FinalizeChain(chain, usage, 1, "error", sw);
await SseHelper.TryWriteSse(response, "done", new { chainId }, cancellationToken);
await SseHelper.TryWriteSse(response, "done", new
{
model = modelId,
chainId,
durationMs = sw.ElapsedMilliseconds,
stopReason = "error",
viaCli = true
}, cancellationToken);
}
finally
{
// Temp-Bilddateien aufräumen
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",
_ => 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, content bleibt
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;
}
}
+79 -2
View File
@@ -234,9 +234,20 @@ public class ClaudeCliService
}
else if (cbType == "tool_use")
{
// Text-Block vorher abschließen
// Text-Block vorher abschließen + lineBuffer flushen
if (inTextBlock)
{
// Restlichen Text im lineBuffer emittieren bevor Tool startet
if (lineBuffer.Length > 0)
{
var remaining = lineBuffer.ToString();
lineBuffer.Clear();
if (remaining.Trim().Length > 0)
{
fullOutput.Append(remaining);
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining });
}
}
inTextBlock = false;
await onEvent(new CliStreamEvent { Type = CliEventType.TextEnd });
}
@@ -361,8 +372,18 @@ public class ClaudeCliService
}
else if (currentBlockType == "text")
{
// lineBuffer flushen (Text ohne trailing \n)
if (lineBuffer.Length > 0)
{
var remaining = lineBuffer.ToString();
lineBuffer.Clear();
if (remaining.Trim().Length > 0)
{
fullOutput.Append(remaining);
await onEvent(new CliStreamEvent { Type = CliEventType.TextDelta, Text = remaining });
}
}
currentBlockType = null;
// text_end wird erst beim nächsten tool_use oder am Ende gesendet
}
break;
}
@@ -439,6 +460,48 @@ public class ClaudeCliService
if (isError)
result.Error = json.TryGetProperty("result", out var errMsg) ? errMsg.GetString() : "CLI error";
}
else if (type == "user" && json.TryGetProperty("message", out var userMsg))
{
// CLI sendet tool_result als type:"user" message
if (userMsg.TryGetProperty("content", out var content) && content.ValueKind == JsonValueKind.Array)
{
foreach (var item in content.EnumerateArray())
{
var itemType = item.TryGetProperty("type", out var iType) ? iType.GetString() : null;
if (itemType == "tool_result")
{
var toolUseId = item.TryGetProperty("tool_use_id", out var tuId) ? tuId.GetString() : "";
var resultIsError = item.TryGetProperty("is_error", out var isErr) && isErr.GetBoolean();
// Content kann string oder array sein
var resultContent = "";
if (item.TryGetProperty("content", out var rc))
{
if (rc.ValueKind == JsonValueKind.String)
resultContent = rc.GetString();
else if (rc.ValueKind == JsonValueKind.Array)
{
var sb = new StringBuilder();
foreach (var part in rc.EnumerateArray())
{
if (part.TryGetProperty("text", out var partText))
sb.Append(partText.GetString());
}
resultContent = sb.ToString();
}
}
await onEvent(new CliStreamEvent
{
Type = CliEventType.ToolResult,
ToolId = toolUseId,
ToolResultContent = resultContent,
ToolResultIsError = resultIsError
});
}
}
}
}
}
catch (JsonException ex)
{
@@ -488,6 +551,17 @@ public class ClaudeCliService
}
// Sicherstellen, dass offene Blöcke geschlossen werden
if (currentBlockType == "tool_use" && currentToolName != null)
{
await onEvent(new CliStreamEvent
{
Type = CliEventType.ToolEnd,
ToolName = currentToolName,
ToolId = currentToolId,
ToolInput = currentToolInput.ToString()
});
currentBlockType = null;
}
if (inTextBlock)
await onEvent(new CliStreamEvent { Type = CliEventType.TextEnd });
@@ -613,6 +687,7 @@ public enum CliEventType
TextEnd,
ToolStart,
ToolEnd,
ToolResult,
UserChoice
}
@@ -623,6 +698,8 @@ public class CliStreamEvent
public string ToolName { get; set; }
public string ToolId { get; set; }
public string ToolInput { get; set; }
public string ToolResultContent { get; set; }
public bool ToolResultIsError { get; set; }
public string ChoiceQuestion { get; set; }
public List<string> ChoiceOptions { get; set; }
}