diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs
index 8ce5b0d..97af1cf 100644
--- a/Services/AnthropicService.cs
+++ b/Services/AnthropicService.cs
@@ -1656,6 +1656,83 @@ public class AnthropicService
return null;
}
+ ///
+ /// 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.
+ ///
+ private static string ExtractLastUserImageRefs(List messages, List tempPaths)
+ {
+ for (int i = messages.Count - 1; i >= 0; i--)
+ {
+ var json = JsonSerializer.Serialize(messages[i]);
+ var el = JsonSerializer.Deserialize(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();
+ 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) + "\nLies die referenzierten Dateien mit dem Read-Tool um sie zu sehen. ";
+ }
+ break;
+ }
+ return null;
+ }
+
+ ///
+ /// Löscht temporäre Bilddateien die für den CLI-Aufruf erstellt wurden.
+ ///
+ private static void CleanupTempImages(List paths)
+ {
+ if (paths == null || paths.Count == 0) return;
+ foreach (var path in paths)
+ {
+ try { if (File.Exists(path)) File.Delete(path); } catch { }
+ }
+ }
+
///
/// Extrahiert Web-Search-Sources aus einem web_search_tool_result-Block.
///
@@ -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();
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);
+ }
+ }
+
+ ///
+ /// Mappt CLI-Tool-Namen (Edit, Write, Bash, etc.) auf gott-Tool-Namen (edit_file, write_file, etc.).
+ ///
+ 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
+ };
+ }
+
+ ///
+ /// Mappt CLI-Tool-Input-Felder auf gott-Konventionen (z.B. file_path→path, old_string→old_text).
+ ///
+ private static string MapCliToolInput(string cliToolName, string inputJson)
+ {
+ if (string.IsNullOrEmpty(inputJson)) return inputJson;
+
+ try
+ {
+ var input = JsonSerializer.Deserialize(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();
+ 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();
+ 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();
+ 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;
}
}
diff --git a/Services/ClaudeCliService.cs b/Services/ClaudeCliService.cs
index c683c80..4e1ad51 100644
--- a/Services/ClaudeCliService.cs
+++ b/Services/ClaudeCliService.cs
@@ -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 ChoiceOptions { get; set; }
}
diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js
index eda4923..a978c82 100644
--- a/wwwroot/js/chat.js
+++ b/wwwroot/js/chat.js
@@ -52,10 +52,18 @@ function handleStreamEvent(eventType, data) {
case 'tool_end':
if (_currentToolBox) {
finishToolBox(_currentToolBox, data);
+ _lastFinishedToolBox = _currentToolBox;
}
_currentToolBox = null;
break;
+ case 'tool_result':
+ if (_lastFinishedToolBox && data.result) {
+ appendToolResult(_lastFinishedToolBox, data.result, data.isError);
+ }
+ _lastFinishedToolBox = null;
+ break;
+
case 'web_search_start':
pauseStreamBubble();
_currentToolBox = addToolBoxLoading('web_search');
@@ -158,6 +166,7 @@ function togglePlanMode() {
let isStreaming = false;
let _lastDoneChainData = null; // Chain-Daten vom letzten done-Event
let _currentToolBox = null; // Aktuell laufende Tool-Box (für Stream-Events)
+let _lastFinishedToolBox = null; // Letzte fertige Tool-Box (für tool_result Zuordnung)
let _chatAbandoned = false; // true wenn User weg-navigiert während Stream läuft
let _abortController = null; // AbortController für laufenden Stream
diff --git a/wwwroot/js/tool-ui.js b/wwwroot/js/tool-ui.js
index 6099a46..70b2d25 100644
--- a/wwwroot/js/tool-ui.js
+++ b/wwwroot/js/tool-ui.js
@@ -1,3 +1,50 @@
+// ── CLI Tool Name / Input Normalization ──
+
+const CLI_TOOL_NAME_MAP = {
+ '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',
+ 'ToolSearch': 'tool_search'
+};
+
+/** Normalize CLI tool name to gott convention (Edit → edit_file, etc.) */
+function normalizeToolName(name) {
+ return CLI_TOOL_NAME_MAP[name] || name;
+}
+
+/** Normalize CLI tool input fields (file_path → path, old_string → old_text, etc.) */
+function normalizeToolInput(name, parsedInput) {
+ if (!parsedInput || typeof parsedInput !== 'object') return parsedInput;
+ var normalized = name === 'edit_file' || name === 'Edit';
+ var isWrite = name === 'write_file' || name === 'Write';
+ var isRead = name === 'read_file' || name === 'Read';
+ var isBash = name === 'execute' || name === 'Bash';
+
+ if (normalized) {
+ return {
+ path: parsedInput.path || parsedInput.file_path,
+ old_text: parsedInput.old_text || parsedInput.old_string,
+ new_text: parsedInput.new_text || parsedInput.new_string
+ };
+ }
+ if (isWrite || isRead) {
+ var result = Object.assign({}, parsedInput);
+ if (parsedInput.file_path && !parsedInput.path) result.path = parsedInput.file_path;
+ return result;
+ }
+ if (isBash && parsedInput.command === undefined && parsedInput.description) {
+ // CLI Bash tool hat command direkt
+ return parsedInput;
+ }
+ return parsedInput;
+}
+
// ── Tool Call Visibility ──
const MODIFICATION_TOOLS = ['write_file', 'edit_file', 'delete_file', 'memory_write', 'remote_execute'];
@@ -9,16 +56,18 @@ const TOOL_DISPLAY_NAMES = {
web_search: 'Web-Suche',
remote_execute: 'Remote-Befehl',
memory_search: 'Erinnerungen durchsuchen',
- send_to_session: 'Nachricht an Session senden'
+ send_to_session: 'Nachricht an Session senden',
+ tool_search: 'Tool-Suche'
};
function isToolVisible(toolName) {
+ const normalized = normalizeToolName(toolName);
const mode = loadSettings().toolCallDisplay;
if (mode === 'all') return true;
if (mode === 'hidden') return false;
// 'modifications' – nur modifizierende Tools + web_search anzeigen
- if (toolName === 'web_search') return true;
- return MODIFICATION_TOOLS.includes(toolName);
+ if (normalized === 'web_search') return true;
+ return MODIFICATION_TOOLS.includes(normalized);
}
// ── Chain Meta Visibility ──
@@ -38,7 +87,8 @@ function isChainMetaVisible(chainData) {
// ── Tool Boxes ──
-function addToolBoxLoading(name) {
+function addToolBoxLoading(rawName) {
+ const name = normalizeToolName(rawName);
const div = document.createElement('div');
div.className = 'tool-box streamed';
div.dataset.toolName = name;
@@ -49,9 +99,10 @@ function addToolBoxLoading(name) {
return div;
}
-function renderToolBoxContent(div, name, inputJson, result, isError, sources) {
+function renderToolBoxContent(div, rawName, inputJson, result, isError, sources) {
+ const name = normalizeToolName(rawName);
let parsedInput = null;
- try { parsedInput = JSON.parse(inputJson); } catch {}
+ try { parsedInput = normalizeToolInput(rawName, JSON.parse(inputJson)); } catch {}
const header = document.createElement('div');
header.className = 'tool-box-header';
@@ -259,4 +310,20 @@ function finishToolBox(div, data) {
scrollDown();
}
+/** Fügt ein Tool-Result nachträglich an eine bestehende Tool-Box an (CLI tool_result Events). */
+function appendToolResult(div, result, isError) {
+ if (!div || !result) return;
+ // Nur anfügen wenn noch kein Result vorhanden
+ if (div.querySelector('.tool-result')) return;
+ // Bei edit_file/write_file: Diff reicht, kein extra Result nötig
+ var toolName = div.dataset.toolName;
+ if (toolName === 'edit_file' || toolName === 'write_file') return;
+
+ var rd = document.createElement('div');
+ rd.className = 'tool-result' + (isError ? ' tool-error' : '');
+ rd.textContent = truncate(result, 300);
+ rd.hidden = true;
+ div.appendChild(rd);
+}
+