From e662548d796af6ca9ededb6377ac6f167d3ed0be Mon Sep 17 00:00:00 2001 From: Meleki Date: Fri, 27 Feb 2026 16:46:07 +0100 Subject: [PATCH] =?UTF-8?q?Avatar:=20Ctrl+V=20paste=20+=20serverseitige=20?= =?UTF-8?q?Skalierung=20auf=20max=20256px;=20Tool-Boxes:=20Diff-View=20f?= =?UTF-8?q?=C3=BCr=20edit/write=5Ffile,=20Foldout=20f=C3=BCr=20alle=20Tool?= =?UTF-8?q?s,=20stop=5Freason=20im=20Chat;=20ImageSharp=203.1.12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Endpoints/AgentEndpoint.cs | 10 +- Endpoints/SessionEndpoint.cs | 11 +- Program.cs | 1 + Services/AnthropicService.cs | 3 +- Services/DatabaseService.cs | 10 +- Services/ImageService.cs | 47 +++++++ gott.csproj | 1 + wwwroot/css/chat.css | 95 +++++++++++++-- wwwroot/js/chat.js | 4 + wwwroot/js/history.js | 23 ++-- wwwroot/js/pages/agent-page.js | 25 ++++ wwwroot/js/pages/session-page.js | 25 ++++ wwwroot/js/ui.js | 203 +++++++++++++++++++++++++++++-- 13 files changed, 416 insertions(+), 42 deletions(-) create mode 100644 Services/ImageService.cs diff --git a/Endpoints/AgentEndpoint.cs b/Endpoints/AgentEndpoint.cs index 024b073..27ad5d0 100644 --- a/Endpoints/AgentEndpoint.cs +++ b/Endpoints/AgentEndpoint.cs @@ -36,14 +36,16 @@ public static class AgentEndpoint await ctx.Response.WriteAsJsonAsync(agent); } - private static async Task HandleCreate(HttpContext ctx, DatabaseService db) + private static async Task HandleCreate(HttpContext ctx, DatabaseService db, ImageService imageService) { var body = await JsonSerializer.DeserializeAsync(ctx.Request.Body); + var avatarRaw = body.TryGetProperty("avatar", out var av) ? av.GetString() : null; + var agent = new AgentRecord { Name = body.TryGetProperty("name", out var n) ? n.GetString() ?? "Neuer Agent" : "Neuer Agent", - Avatar = body.TryGetProperty("avatar", out var av) ? av.GetString() : null, + Avatar = imageService.ResizeDataUrl(avatarRaw), Description = body.TryGetProperty("description", out var d) ? d.GetString() : null, Model = body.TryGetProperty("model", out var m) ? m.GetString() ?? "sonnet" : "sonnet", Persona = body.TryGetProperty("persona", out var p) ? p.GetString() : null, @@ -54,7 +56,7 @@ public static class AgentEndpoint await ctx.Response.WriteAsJsonAsync(agent); } - private static async Task HandleUpdate(HttpContext ctx, DatabaseService db) + private static async Task HandleUpdate(HttpContext ctx, DatabaseService db, ImageService imageService) { var agentId = ctx.Request.RouteValues["agentId"]?.ToString(); if (string.IsNullOrEmpty(agentId)) { ctx.Response.StatusCode = 400; return; } @@ -65,7 +67,7 @@ public static class AgentEndpoint var body = await JsonSerializer.DeserializeAsync(ctx.Request.Body); if (body.TryGetProperty("name", out var n)) agent.Name = n.GetString(); - if (body.TryGetProperty("avatar", out var av)) agent.Avatar = av.GetString(); + if (body.TryGetProperty("avatar", out var av)) agent.Avatar = imageService.ResizeDataUrl(av.GetString()); if (body.TryGetProperty("description", out var d)) agent.Description = d.GetString(); if (body.TryGetProperty("model", out var m)) agent.Model = m.GetString(); if (body.TryGetProperty("persona", out var p)) agent.Persona = p.GetString(); diff --git a/Endpoints/SessionEndpoint.cs b/Endpoints/SessionEndpoint.cs index 0607047..c861399 100644 --- a/Endpoints/SessionEndpoint.cs +++ b/Endpoints/SessionEndpoint.cs @@ -47,13 +47,14 @@ public static class SessionEndpoint await ctx.Response.WriteAsJsonAsync(session); } - private static async Task HandleCreate(HttpContext ctx, DatabaseService db) + private static async Task HandleCreate(HttpContext ctx, DatabaseService db, ImageService imageService) { var body = await JsonSerializer.DeserializeAsync(ctx.Request.Body); var name = body.TryGetProperty("name", out var n) ? n.GetString() : "Neue Session"; var description = body.TryGetProperty("description", out var d) ? d.GetString() : null; - var image = body.TryGetProperty("image", out var img) ? img.GetString() : null; + var imageRaw = body.TryGetProperty("image", out var img) ? img.GetString() : null; + var image = imageService.ResizeDataUrl(imageRaw); var agentId = body.TryGetProperty("agentId", out var a) ? a.GetString() : null; var workspace = body.TryGetProperty("workspace", out var w) ? w.GetString() : null; var context = body.TryGetProperty("context", out var ctxEl) ? ctxEl.GetString() : null; @@ -63,7 +64,7 @@ public static class SessionEndpoint await ctx.Response.WriteAsJsonAsync(session); } - private static async Task HandleUpdate(HttpContext ctx, DatabaseService db) + private static async Task HandleUpdate(HttpContext ctx, DatabaseService db, ImageService imageService) { var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString(); if (string.IsNullOrEmpty(sessionId)) @@ -73,7 +74,7 @@ public static class SessionEndpoint } var body = await JsonSerializer.DeserializeAsync(ctx.Request.Body); - await db.UpdateSession(sessionId, body); + await db.UpdateSession(sessionId, body, imageService); var session = await db.GetSession(sessionId); await ctx.Response.WriteAsJsonAsync(session); @@ -183,7 +184,7 @@ public static class SessionEndpoint var process = Process.Start(psi); if (process != null) { - process.WaitForExit(5000); + process.WaitForExit(500); } ctx.Response.StatusCode = 202; diff --git a/Program.cs b/Program.cs index 5c27887..14464fe 100644 --- a/Program.cs +++ b/Program.cs @@ -11,6 +11,7 @@ builder.Services.AddHttpClient(); var dataDir = Path.Combine(AppContext.BaseDirectory, "data"); builder.Services.AddSingleton(new ToolService()); +builder.Services.AddSingleton(new ImageService()); builder.Services.AddSingleton(new DatabaseService(Path.Combine(dataDir, "gott.db"))); var app = builder.Build(); diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index f34c5bc..d6b3a6a 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -146,7 +146,8 @@ public class AnthropicService outputTokens = usage.OutputTokens, iterations, chainId, - durationMs = sw.ElapsedMilliseconds + durationMs = sw.ElapsedMilliseconds, + stopReason }); return; } diff --git a/Services/DatabaseService.cs b/Services/DatabaseService.cs index 5c187ff..afae6db 100644 --- a/Services/DatabaseService.cs +++ b/Services/DatabaseService.cs @@ -229,7 +229,7 @@ public class DatabaseService new { sessionId, now = DateTime.UtcNow.ToString("o") }); } - public async Task UpdateSession(string sessionId, System.Text.Json.JsonElement body) + public async Task UpdateSession(string sessionId, System.Text.Json.JsonElement body, ImageService imageService = null) { using var conn = CreateConnection(); var sets = new List { "updated_at = @now" }; @@ -239,7 +239,13 @@ public class DatabaseService if (body.TryGetProperty("name", out var n)) { sets.Add("name = @name"); p.Add("name", n.GetString()); } if (body.TryGetProperty("description", out var d)) { sets.Add("description = @desc"); p.Add("desc", d.GetString()); } - if (body.TryGetProperty("image", out var img)) { sets.Add("image = @image"); p.Add("image", img.GetString()); } + if (body.TryGetProperty("image", out var img)) + { + var imageData = img.GetString(); + if (imageService != null) imageData = imageService.ResizeDataUrl(imageData); + sets.Add("image = @image"); + p.Add("image", imageData); + } if (body.TryGetProperty("agentId", out var a)) { sets.Add("agent_id = @agentId2"); p.Add("agentId2", a.GetString()); } if (body.TryGetProperty("workspace", out var w)) { sets.Add("workspace = @workspace"); p.Add("workspace", w.GetString()); } if (body.TryGetProperty("context", out var ctx)) { sets.Add("context = @context"); p.Add("context", ctx.GetString()); } diff --git a/Services/ImageService.cs b/Services/ImageService.cs new file mode 100644 index 0000000..6ff7336 --- /dev/null +++ b/Services/ImageService.cs @@ -0,0 +1,47 @@ +using System; +using System.IO; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Processing; + +namespace Gott.Services; + +public class ImageService +{ + private const int MaxSideLength = 512; + + /// + /// Nimmt eine Base64-Data-URL (z.B. "data:image/png;base64,..."), skaliert das Bild + /// auf max 512px Seitenlänge und gibt eine neue Data-URL zurück. + /// Gibt null zurück wenn input null/leer ist. + /// + public string ResizeDataUrl(string dataUrl) + { + if (string.IsNullOrEmpty(dataUrl)) return null; + + // Data-URL parsen: "data:;base64," + var commaIndex = dataUrl.IndexOf(','); + if (commaIndex < 0) return dataUrl; + + var base64 = dataUrl.Substring(commaIndex + 1); + var bytes = Convert.FromBase64String(base64); + + using var image = Image.Load(bytes); + + // Nur skalieren wenn nötig + if (image.Width <= MaxSideLength && image.Height <= MaxSideLength) + return dataUrl; + + image.Mutate(x => x.Resize(new ResizeOptions + { + Size = new Size(MaxSideLength, MaxSideLength), + Mode = ResizeMode.Max + })); + + using var ms = new MemoryStream(); + image.Save(ms, new PngEncoder()); + var resizedBase64 = Convert.ToBase64String(ms.ToArray()); + + return "data:image/png;base64," + resizedBase64; + } +} diff --git a/gott.csproj b/gott.csproj index 150f10f..6504d16 100755 --- a/gott.csproj +++ b/gott.csproj @@ -23,6 +23,7 @@ + diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index f17e638..957fa33 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -12,11 +12,6 @@ display: none; } -.custom-cmd-btn { - padding: var(--s1) var(--s2); - line-height: 1; -} - .custom-cmd-btn:hover { background: var(--f3-bg); border-color: var(--f3-border); @@ -24,7 +19,6 @@ .custom-cmd-btn.running { opacity: var(--semi-transparent); - cursor: wait; } /* Header Menu */ @@ -34,11 +28,6 @@ margin-left: auto; } -.header-menu-trigger { - padding: var(--s1) var(--s2); - line-height: 1; -} - .header-menu-dropdown { position: absolute; top: 100%; @@ -128,12 +117,94 @@ } .tool-box { - padding: var(--s2); + align-self: flex-start; background: var(--f1-bg); border: var(--border-width) solid var(--f1-border); + padding: var(--s3); +} + +.tool-box:has(.tool-box-header) { + padding: 0; +} + +.tool-box:has([hidden]) { opacity: var(--semi-transparent); } +.tool-box-header { + display: flex; + align-items: center; + gap: var(--s2); + padding: var(--s3); + flex-wrap: wrap; +} + +.tool-error-badge { + color: #ff6b6b; + border: var(--border-width) solid #ff6b6b; + padding: 0 var(--s1); +} + +/* Diff View */ + +.tool-diff { + font-family: monospace; + max-height: 16.5em; + overflow: auto; +} + +.diff-line { + display: flex; + align-items: flex-start; + white-space: pre; +} + +.diff-removed { + color: #f33; + background: #f331 +} + +.diff-added { + color: #3f3; + background-color: #3f31; +} + +.diff-marker { + flex-shrink: 0; + width: 1.5em; + text-align: center; + user-select: none; + border-right: var(--border-width) solid var(--f2-border); + margin-right: var(--s2); +} + +.diff-content { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + padding-right: var(--s2); +} + +.diff-more { + padding: var(--s1) var(--s2); + opacity: var(--semi-transparent); + font-family: monospace; +} + +.tool-result { + white-space: pre-wrap; + word-break: break-word; + font-family: monospace; + padding: var(--s4); + max-height: 16em; + overflow: auto; +} + +.tool-write-meta { + opacity: var(--semi-transparent); + margin-left: auto; +} + .input-area { border-top: var(--border-width) solid var(--f1-border); display: flex; diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index 92f3046..ce144d7 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -132,9 +132,13 @@ async function sendMessage() { case 'done': endStreamBubble(); + if (data.stopReason === 'max_tokens') { + addWarningBubble('⚠️ Antwort wurde abgeschnitten (max_tokens erreicht). Bitte frage nach dem Rest.', true); + } let meta = `Model: ${data.model || '?'} | In: ${data.inputTokens || 0} | Out: ${data.outputTokens || 0}`; if (data.iterations > 1) meta += ` | Iterations: ${data.iterations}`; if (data.durationMs) meta += ` | ${(data.durationMs / 1000).toFixed(1)}s`; + if (data.stopReason && data.stopReason !== 'end_turn' && data.stopReason !== 'tool_use') meta += ` | Stop: ${data.stopReason}`; setMeta(meta); break; } diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index efc5526..53a4104 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -47,7 +47,8 @@ function renderMessages(msgs, addToConversation) { break; case 'tool_result': - addToolBoxStatic(null, null, msg.content, msg.isError); + // tool_result wird dem vorherigen tool_use-Box angehängt wenn möglich + appendToolResult(msg.content, msg.isError); break; case 'error': @@ -131,14 +132,20 @@ async function loadLatestArchive() { break; case 'tool_use': div.className = 'tool-box archived'; - div.innerHTML = '' + esc(msg.toolName) + '' - + '' + esc(truncate(msg.content, 120)) + ''; - break; - case 'tool_result': - div.className = 'tool-box archived'; - div.innerHTML = '
' - + esc(truncate(msg.content, 300)) + '
'; + renderToolBoxContent(div, msg.toolName, msg.content, null, false); break; + case 'tool_result': { + // An vorherigen tool-box anhängen + const prevBoxes = fragment.querySelectorAll ? fragment.querySelectorAll('.tool-box') : []; + const lastToolBox = prevBoxes[prevBoxes.length - 1]; + if (lastToolBox && !lastToolBox.querySelector('.tool-result')) { + const rd = document.createElement('div'); + rd.className = 'tool-result' + (msg.isError ? ' tool-error' : ''); + rd.textContent = truncate(msg.content, 300); + lastToolBox.appendChild(rd); + } + continue; // kein separates div + } case 'error': div.className = 'msg system-info archived'; div.style.color = '#ff6b6b'; diff --git a/wwwroot/js/pages/agent-page.js b/wwwroot/js/pages/agent-page.js index 10eed25..224bc9c 100644 --- a/wwwroot/js/pages/agent-page.js +++ b/wwwroot/js/pages/agent-page.js @@ -1,6 +1,7 @@ // ── Agent Page ── let _agentAvatarData = null; +let _agentPasteHandler = null; registerPage('agent', { async init(params) { @@ -63,6 +64,26 @@ registerPage('agent', { avatarInput.value = ''; }); + // Avatar Paste (Ctrl+V) + document.addEventListener('paste', _agentPasteHandler = (e) => { + const items = e.clipboardData?.items; + if (!items) return; + for (const item of items) { + if (item.type.startsWith('image/')) { + e.preventDefault(); + const file = item.getAsFile(); + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + _agentAvatarData = reader.result; + avatarPreview.innerHTML = `Avatar`; + }; + reader.readAsDataURL(file); + return; + } + } + }); + // Speichern saveBtn.addEventListener('click', async () => { const data = { @@ -93,6 +114,10 @@ registerPage('agent', { }); }, destroy() { + if (_agentPasteHandler) { + document.removeEventListener('paste', _agentPasteHandler); + _agentPasteHandler = null; + } _agentAvatarData = null; } }); diff --git a/wwwroot/js/pages/session-page.js b/wwwroot/js/pages/session-page.js index e58b289..517c7bf 100644 --- a/wwwroot/js/pages/session-page.js +++ b/wwwroot/js/pages/session-page.js @@ -1,6 +1,7 @@ // ── Session Edit Page ── let _sessionImageData = null; +let _sessionPasteHandler = null; function _createCommandRow(container, name = '', command = '') { const item = document.createElement('div'); @@ -125,6 +126,26 @@ registerPage('session', { imageInput.value = ''; }); + // Bild Paste (Ctrl+V) + document.addEventListener('paste', _sessionPasteHandler = (e) => { + const items = e.clipboardData?.items; + if (!items) return; + for (const item of items) { + if (item.type.startsWith('image/')) { + e.preventDefault(); + const file = item.getAsFile(); + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + _sessionImageData = reader.result; + imagePreview.innerHTML = ``; + }; + reader.readAsDataURL(file); + return; + } + } + }); + // Workspace Browse const workspaceBrowse = document.getElementById('sessionWorkspaceBrowse'); if (workspaceBrowse) { @@ -175,6 +196,10 @@ registerPage('session', { }); }, destroy() { + if (_sessionPasteHandler) { + document.removeEventListener('paste', _sessionPasteHandler); + _sessionPasteHandler = null; + } _sessionImageData = null; } }); diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js index 96b236a..e7f6485 100644 --- a/wwwroot/js/ui.js +++ b/wwwroot/js/ui.js @@ -58,6 +58,16 @@ function addErrorBubble(text, streamed = false) { scrollDown(); } +function addWarningBubble(text, streamed = false) { + const div = document.createElement('div'); + div.className = 'msg system-info'; + if (streamed) div.classList.add('streamed'); + div.style.color = '#f0a500'; + div.textContent = (text || '').trim(); + chatEl().appendChild(div); + scrollDown(); +} + // ── Streaming Bubble ── let _streamDiv = null; @@ -104,28 +114,201 @@ function addToolBoxLoading(name) { return div; } +function renderToolBoxContent(div, name, inputJson, result, isError) { + let parsedInput = null; + try { parsedInput = JSON.parse(inputJson); } catch {} + + const header = document.createElement('div'); + header.className = 'tool-box-header'; + + const nameSpan = document.createElement('span'); + nameSpan.className = 'tool-name'; + nameSpan.textContent = name; + header.appendChild(nameSpan); + + if (isError) { + const errSpan = document.createElement('span'); + errSpan.className = 'tool-error-badge'; + errSpan.textContent = 'error'; + header.appendChild(errSpan); + } + + div.innerHTML = ''; + div.appendChild(header); + + let foldTarget = null; + + if (name === 'edit_file' && parsedInput && parsedInput.path) { + const pathSpan = document.createElement('span'); + pathSpan.className = 'tool-input'; + pathSpan.textContent = parsedInput.path; + header.appendChild(pathSpan); + + const diff = buildEditDiff(parsedInput.old_text || '', parsedInput.new_text || ''); + diff.hidden = true; + div.appendChild(diff); + foldTarget = diff; + + } else if (name === 'write_file' && parsedInput && parsedInput.path) { + const pathSpan = document.createElement('span'); + pathSpan.className = 'tool-input'; + pathSpan.textContent = parsedInput.path; + header.appendChild(pathSpan); + + const content = parsedInput.content || ''; + const lines = content.split('\n'); + const lineCount = lines.length; + const charCount = content.length; + + const meta = document.createElement('span'); + meta.className = 'tool-write-meta'; + meta.textContent = lineCount + ' Zeilen, ' + charCount + ' Zeichen'; + header.appendChild(meta); + + const preview = buildWritePreview(content); + preview.hidden = true; + div.appendChild(preview); + foldTarget = preview; + + } else { + // Standard: path/command als input anzeigen + let inputLabel = ''; + if (parsedInput) { + inputLabel = parsedInput.path || parsedInput.command || JSON.stringify(parsedInput).substring(0, 120); + } else { + inputLabel = truncate(inputJson, 120); + } + if (inputLabel) { + const inputSpan = document.createElement('span'); + inputSpan.className = 'tool-input'; + inputSpan.textContent = inputLabel; + header.appendChild(inputSpan); + } + + const resultDiv = document.createElement('div'); + resultDiv.className = 'tool-result' + (isError ? ' tool-error' : ''); + resultDiv.textContent = result ? truncate(result, 300) : ''; + resultDiv.hidden = true; + div.appendChild(resultDiv); + foldTarget = resultDiv; + } + + if (foldTarget) { + header.style.cursor = 'pointer'; + header.addEventListener('click', () => { + foldTarget.hidden = !foldTarget.hidden; + }); + } +} + +function buildEditDiff(oldText, newText) { + const oldLines = oldText.split('\n'); + const newLines = newText.split('\n'); + + const container = document.createElement('div'); + container.className = 'tool-diff'; + + // Entfernte Zeilen (-) + for (const line of oldLines) { + const row = document.createElement('div'); + row.className = 'diff-line diff-removed'; + const marker = document.createElement('span'); + marker.className = 'diff-marker'; + marker.textContent = '-'; + const content = document.createElement('span'); + content.className = 'diff-content'; + content.textContent = line; + row.appendChild(marker); + row.appendChild(content); + container.appendChild(row); + } + + // Hinzugefügte Zeilen (+) + for (const line of newLines) { + const row = document.createElement('div'); + row.className = 'diff-line diff-added'; + const marker = document.createElement('span'); + marker.className = 'diff-marker'; + marker.textContent = '+'; + const content = document.createElement('span'); + content.className = 'diff-content'; + content.textContent = line; + row.appendChild(marker); + row.appendChild(content); + container.appendChild(row); + } + + return container; +} + +function buildWritePreview(content) { + const lines = content.split('\n'); + const maxLines = 20; + const shown = lines.slice(0, maxLines); + + const container = document.createElement('div'); + container.className = 'tool-diff'; + + for (const line of shown) { + const row = document.createElement('div'); + row.className = 'diff-line diff-added'; + const marker = document.createElement('span'); + marker.className = 'diff-marker'; + marker.textContent = '+'; + const contentSpan = document.createElement('span'); + contentSpan.className = 'diff-content'; + contentSpan.textContent = line; + row.appendChild(marker); + row.appendChild(contentSpan); + container.appendChild(row); + } + + if (lines.length > maxLines) { + const more = document.createElement('div'); + more.className = 'diff-more'; + more.textContent = '… ' + (lines.length - maxLines) + ' weitere Zeilen'; + container.appendChild(more); + } + + return container; +} + function finishToolBox(div, data) { if (!div) return; - div.innerHTML = '' + esc(data.name) + '' - + '' + esc(truncate(data.input, 120)) + '' - + '
' - + esc(truncate(data.result, 300)) + '
'; + renderToolBoxContent(div, data.name, data.input, data.result, data.isError); scrollDown(); } function addToolBoxStatic(name, input, result, isError) { const div = document.createElement('div'); div.className = 'tool-box'; - div.innerHTML = '' + esc(name) + '' - + '' + esc(truncate(input, 120)) + ''; - if (result) { - div.innerHTML += '
' - + esc(truncate(result, 300)) + '
'; - } + renderToolBoxContent(div, name, input, result, isError); chatEl().appendChild(div); scrollDown(); } +function appendToolResult(result, isError) { + // Letzten tool-box im Chat finden und Result in die bereits vorhandene .tool-result-Div schreiben + const boxes = chatEl().querySelectorAll('.tool-box'); + const lastBox = boxes[boxes.length - 1]; + if (!lastBox) return; + + // Wenn Diff vorhanden (edit_file/write_file) → kein Result anhängen + if (lastBox.querySelector('.tool-diff')) return; + + const existing = lastBox.querySelector('.tool-result'); + if (existing) { + existing.textContent = truncate(result, 300); + if (isError) existing.classList.add('tool-error'); + } else { + const resultDiv = document.createElement('div'); + resultDiv.className = 'tool-result' + (isError ? ' tool-error' : ''); + resultDiv.textContent = truncate(result, 300); + resultDiv.hidden = true; + lastBox.appendChild(resultDiv); + } +} + // ── Streamed Elements ── function removeStreamedElements() {