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); +} +