From 76341dc42b20346bac4d65d8ce21a646dea6e549 Mon Sep 17 00:00:00 2001 From: Meleki Date: Sat, 28 Feb 2026 15:54:47 +0100 Subject: [PATCH] Fix: Chat scrollt nach Font-Loading nochmal nach unten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scrollDown() wartet jetzt auf document.fonts.ready und scrollt erneut. chat-page.js wartet nach loadHistory() zusätzlich per requestAnimationFrame auf den Repaint nach Font-Swap. --- Endpoints/ChatEndpoint.cs | 13 +- Endpoints/HistoryEndpoint.cs | 20 +++ Endpoints/SessionEndpoint.cs | 15 ++ Program.cs | 1 + Services/AnthropicService.cs | 248 ++++++++++++++++++++-------------- Services/StreamingTracker.cs | 16 +++ TODO.md | 6 +- wwwroot/css/chat.css | 11 +- wwwroot/js/api.js | 11 ++ wwwroot/js/chat.js | 16 ++- wwwroot/js/file-explorer.js | 28 +++- wwwroot/js/history.js | 4 +- wwwroot/js/pages/chat-page.js | 67 +++++++++ wwwroot/js/ui.js | 14 +- 14 files changed, 349 insertions(+), 121 deletions(-) create mode 100644 Services/StreamingTracker.cs diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index d08afef..be30a99 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using System.Net.Http; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Gott.Models; using Gott.Services; @@ -22,7 +23,7 @@ public static class ChatEndpoint app.MapPost("/api/chat", HandleChat); } - private static async Task HandleChat(HttpRequest req, HttpResponse res, IHttpClientFactory httpFactory, IConfiguration config, ToolService toolService, DatabaseService db) + private static async Task HandleChat(HttpRequest req, HttpResponse res, IHttpClientFactory httpFactory, IConfiguration config, ToolService toolService, DatabaseService db, StreamingTracker streamingTracker, CancellationToken cancellationToken) { var body = await JsonSerializer.DeserializeAsync(req.Body); if (body == null || body.Messages == null || body.Messages.Count == 0) @@ -180,6 +181,14 @@ Wenn der User auf eine bestimmte Zeile in einer Datei verweist (z.B. `[Datei: fo Directory.CreateDirectory(workspace); var anthropic = new AnthropicService(httpFactory, token, toolService, db); - await anthropic.StreamChat(res, modelId, systemPrompt, messages, chainId, workspace); + streamingTracker.Start(sessionId, chainId); + try + { + await anthropic.StreamChat(res, modelId, systemPrompt, messages, chainId, workspace, sessionId, cancellationToken: cancellationToken); + } + finally + { + streamingTracker.Stop(sessionId); + } } } diff --git a/Endpoints/HistoryEndpoint.cs b/Endpoints/HistoryEndpoint.cs index d9ecf4e..b842e8a 100644 --- a/Endpoints/HistoryEndpoint.cs +++ b/Endpoints/HistoryEndpoint.cs @@ -19,6 +19,7 @@ public static class HistoryEndpoint app.MapGet("/api/archives", HandleListArchives); app.MapGet("/api/archive/{archiveId}", HandleGetArchive); app.MapGet("/api/attachments/{messageId}", HandleGetAttachments); + app.MapGet("/api/chains/{chainId}", HandleGetChain); } private static async Task HandleHistory(HttpContext ctx, DatabaseService db) @@ -118,6 +119,25 @@ public static class HistoryEndpoint await ctx.Response.WriteAsJsonAsync(result); } + private static async Task HandleGetChain(HttpContext ctx, DatabaseService db) + { + var chainId = ctx.Request.RouteValues["chainId"]?.ToString(); + if (string.IsNullOrEmpty(chainId)) + { + ctx.Response.StatusCode = 400; + return; + } + + var chain = await db.GetChain(chainId); + if (chain == null) + { + ctx.Response.StatusCode = 404; + return; + } + + await ctx.Response.WriteAsJsonAsync(chain); + } + private static async Task HandleGetArchive(HttpContext ctx, DatabaseService db) { var archiveId = ctx.Request.RouteValues["archiveId"]?.ToString(); diff --git a/Endpoints/SessionEndpoint.cs b/Endpoints/SessionEndpoint.cs index cef75a0..d9755dd 100644 --- a/Endpoints/SessionEndpoint.cs +++ b/Endpoints/SessionEndpoint.cs @@ -21,6 +21,7 @@ public static class SessionEndpoint app.MapPut("/api/sessions/{sessionId}", HandleUpdate); app.MapDelete("/api/sessions/{sessionId}", HandleDelete); app.MapGet("/api/sessions/{sessionId}/system-prompt", HandleSystemPrompt); + app.MapGet("/api/sessions/{sessionId}/streaming", HandleStreaming); app.MapPost("/api/sessions/{sessionId}/exec", HandleExec); } @@ -137,6 +138,20 @@ public static class SessionEndpoint await ctx.Response.WriteAsync(systemPrompt); } + private static async Task HandleStreaming(HttpContext ctx, StreamingTracker streamingTracker) + { + var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString(); + if (string.IsNullOrEmpty(sessionId)) + { + ctx.Response.StatusCode = 400; + return; + } + + var isStreaming = streamingTracker.IsStreaming(sessionId); + var chainId = streamingTracker.GetChainId(sessionId); + await ctx.Response.WriteAsJsonAsync(new { streaming = isStreaming, chainId }); + } + private static async Task HandleExec(HttpContext ctx, DatabaseService db) { var sessionId = ctx.Request.RouteValues["sessionId"]?.ToString(); diff --git a/Program.cs b/Program.cs index 14464fe..b36bb24 100644 --- a/Program.cs +++ b/Program.cs @@ -12,6 +12,7 @@ var dataDir = Path.Combine(AppContext.BaseDirectory, "data"); builder.Services.AddSingleton(new ToolService()); builder.Services.AddSingleton(new ImageService()); +builder.Services.AddSingleton(new StreamingTracker()); 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 d6b3a6a..c3f508a 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -5,6 +5,7 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Text; using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Gott.Models; using Microsoft.AspNetCore.Http; @@ -34,7 +35,8 @@ public class AnthropicService string chainId, string workspace, string sessionId = null, - int maxIterations = 25) + int maxIterations = 25, + CancellationToken cancellationToken = default) { var sw = System.Diagnostics.Stopwatch.StartNew(); var usage = new TokenUsage(); @@ -48,128 +50,152 @@ public class AnthropicService Model = modelId }; - while (iterations < maxIterations) + try { - iterations++; - - var payload = new Dictionary + while (iterations < maxIterations) { - ["model"] = modelId, - ["max_tokens"] = 4096, - ["system"] = systemPrompt, - ["messages"] = messages, - ["stream"] = true - }; + iterations++; - if (!string.IsNullOrEmpty(workspace)) - payload["tools"] = _toolService.GetToolDefinitions(); - - var client = CreateClient(); - var json = JsonSerializer.Serialize(payload); - var httpContent = new StringContent(json, Encoding.UTF8, "application/json"); - - var httpResp = await client.SendAsync( - new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent }, - HttpCompletionOption.ResponseHeadersRead - ); - - if (!httpResp.IsSuccessStatusCode) - { - var errBody = await httpResp.Content.ReadAsStringAsync(); - var errMsg = $"API {(int)httpResp.StatusCode}: {errBody}"; - await _db.SaveMessage(new MessageRecord + var payload = new Dictionary { - ChainId = chainId, Iteration = iterations, - Role = "system", Type = "error", - Content = errMsg, IsError = true - }); - await WriteSseEvent(response, "error", new { error = errMsg }); - await FinalizeChain(chain, usage, iterations, "error", sw); - await WriteSseEvent(response, "done", new { }); - return; - } + ["model"] = modelId, + ["max_tokens"] = 16384, + ["system"] = systemPrompt, + ["messages"] = messages, + ["stream"] = true + }; - var (stopReason, assistantContent, executedTools) = await ProcessStream(response, httpResp, usage, workspace); - lastStopReason = stopReason; + if (!string.IsNullOrEmpty(workspace)) + payload["tools"] = _toolService.GetToolDefinitions(); - messages.Add(new { role = "assistant", content = assistantContent }); + var client = CreateClient(); + var json = JsonSerializer.Serialize(payload); + var httpContent = new StringContent(json, Encoding.UTF8, "application/json"); - // Assistant-Blöcke persistieren - foreach (var block in assistantContent) - { - var blockJson = JsonSerializer.Serialize(block); - var el = JsonSerializer.Deserialize(blockJson); - var blockType = el.GetProperty("type").GetString() ?? ""; + var httpResp = await client.SendAsync( + new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") { Content = httpContent }, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken + ); - if (blockType == "text") + if (!httpResp.IsSuccessStatusCode) + { + var errBody = await httpResp.Content.ReadAsStringAsync(cancellationToken); + var errMsg = $"API {(int)httpResp.StatusCode}: {errBody}"; + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = iterations, + Role = "system", Type = "error", + Content = errMsg, IsError = true + }); + await TryWriteSse(response, "error", new { error = errMsg }, cancellationToken); + await FinalizeChain(chain, usage, iterations, "error", sw); + await TryWriteSse(response, "done", new { }, cancellationToken); + return; + } + + var (stopReason, assistantContent, executedTools) = await ProcessStream(response, httpResp, usage, workspace, cancellationToken); + lastStopReason = stopReason; + + messages.Add(new { role = "assistant", content = assistantContent }); + + // Assistant-Blöcke persistieren + foreach (var block in assistantContent) + { + var blockJson = JsonSerializer.Serialize(block); + var el = JsonSerializer.Deserialize(blockJson); + var blockType = el.GetProperty("type").GetString() ?? ""; + + if (blockType == "text") + { + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = iterations, + Role = "assistant", Type = "text", + Content = el.GetProperty("text").GetString() + }); + } + else if (blockType == "tool_use") + { + await _db.SaveMessage(new MessageRecord + { + ChainId = chainId, Iteration = iterations, + Role = "assistant", Type = "tool_use", + ToolName = el.GetProperty("name").GetString(), + ToolCallId = el.GetProperty("id").GetString(), + Content = el.GetProperty("input").ToString() + }); + } + } + + // Tool Results persistieren + foreach (var tr in executedTools) { await _db.SaveMessage(new MessageRecord { ChainId = chainId, Iteration = iterations, - Role = "assistant", Type = "text", - Content = el.GetProperty("text").GetString() + Role = "user", Type = "tool_result", + ToolCallId = tr.Id, + Content = tr.Result, + IsError = tr.IsError }); } - else if (blockType == "tool_use") + + if (stopReason != "tool_use") { - await _db.SaveMessage(new MessageRecord + var finalStopReason = string.IsNullOrEmpty(stopReason) ? "unexpected_end" : stopReason; + await FinalizeChain(chain, usage, iterations, finalStopReason, sw); + await TryWriteSse(response, "done", new { - ChainId = chainId, Iteration = iterations, - Role = "assistant", Type = "tool_use", - ToolName = el.GetProperty("name").GetString(), - ToolCallId = el.GetProperty("id").GetString(), - Content = el.GetProperty("input").ToString() + model = modelId, + inputTokens = usage.InputTokens, + outputTokens = usage.OutputTokens, + iterations, + chainId, + durationMs = sw.ElapsedMilliseconds, + stopReason = finalStopReason + }, cancellationToken); + return; + } + + var toolResults = new List(); + foreach (var tr in executedTools) + { + toolResults.Add(new + { + type = "tool_result", + tool_use_id = tr.Id, + content = tr.Result, + is_error = tr.IsError }); } + + messages.Add(new { role = "user", content = toolResults }); } - // Tool Results persistieren - foreach (var tr in executedTools) + await FinalizeChain(chain, usage, iterations, "max_iterations", sw); + await TryWriteSse(response, "error", new { error = "Max iterations erreicht" }, cancellationToken); + await TryWriteSse(response, "done", new { }, cancellationToken); + } + catch (OperationCanceledException) + { + // Client hat die Verbindung getrennt (z.B. Browser-Refresh) + await FinalizeChain(chain, usage, iterations, "client_disconnected", sw); + } + catch (Exception ex) + { + // Unerwarteter Fehler – in DB speichern und versuchen an Client zu senden + var errMsg = $"Stream-Fehler: {ex.Message}"; + await _db.SaveMessage(new MessageRecord { - await _db.SaveMessage(new MessageRecord - { - ChainId = chainId, Iteration = iterations, - Role = "user", Type = "tool_result", - ToolCallId = tr.Id, - Content = tr.Result, - IsError = tr.IsError - }); - } - - if (stopReason != "tool_use") - { - await FinalizeChain(chain, usage, iterations, stopReason, sw); - await WriteSseEvent(response, "done", new - { - model = modelId, - inputTokens = usage.InputTokens, - outputTokens = usage.OutputTokens, - iterations, - chainId, - durationMs = sw.ElapsedMilliseconds, - stopReason - }); - return; - } - - var toolResults = new List(); - foreach (var tr in executedTools) - { - toolResults.Add(new - { - type = "tool_result", - tool_use_id = tr.Id, - content = tr.Result, - is_error = tr.IsError - }); - } - - messages.Add(new { role = "user", content = toolResults }); + ChainId = chainId, Iteration = iterations, + Role = "system", Type = "error", + Content = errMsg, IsError = true + }); + await FinalizeChain(chain, usage, iterations, "error", sw); + await TryWriteSse(response, "error", new { error = errMsg }, cancellationToken); + await TryWriteSse(response, "done", new { }, cancellationToken); } - - await FinalizeChain(chain, usage, iterations, "max_iterations", sw); - await WriteSseEvent(response, "error", new { error = "Max iterations erreicht" }); - await WriteSseEvent(response, "done", new { }); } private async Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw) @@ -186,9 +212,10 @@ public class AnthropicService HttpResponse response, HttpResponseMessage httpResp, TokenUsage usage, - string workspace) + string workspace, + CancellationToken cancellationToken = default) { - var stream = await httpResp.Content.ReadAsStreamAsync(); + var stream = await httpResp.Content.ReadAsStreamAsync(cancellationToken); using var reader = new StreamReader(stream); var stopReason = ""; @@ -202,7 +229,7 @@ public class AnthropicService var executedTools = new List(); string line; - while ((line = await reader.ReadLineAsync()) != null) + while ((line = await reader.ReadLineAsync(cancellationToken)) != null) { if (!line.StartsWith("data: ")) continue; var data = line["data: ".Length..]; @@ -357,6 +384,19 @@ public class AnthropicService await response.WriteAsync($"event: {eventType}\ndata: {json}\n\n"); await response.Body.FlushAsync(); } + + private static async Task TryWriteSse(HttpResponse response, string eventType, object data, CancellationToken ct) + { + try + { + if (!ct.IsCancellationRequested) + await WriteSseEvent(response, eventType, data); + } + catch + { + // Client bereits disconnected – ignorieren + } + } } public record ToolResult(string Id, string Result, bool IsError); diff --git a/Services/StreamingTracker.cs b/Services/StreamingTracker.cs new file mode 100644 index 0000000..420d278 --- /dev/null +++ b/Services/StreamingTracker.cs @@ -0,0 +1,16 @@ +using System.Collections.Concurrent; + +namespace Gott.Services; + +public class StreamingTracker +{ + private readonly ConcurrentDictionary _streaming = new ConcurrentDictionary(); + + public void Start(string sessionId, string chainId) => _streaming[sessionId] = chainId; + + public void Stop(string sessionId) => _streaming.TryRemove(sessionId, out _); + + public bool IsStreaming(string sessionId) => _streaming.ContainsKey(sessionId); + + public string GetChainId(string sessionId) => _streaming.TryGetValue(sessionId, out var chainId) ? chainId : null; +} diff --git a/TODO.md b/TODO.md index c22d7da..95e4870 100644 --- a/TODO.md +++ b/TODO.md @@ -8,9 +8,9 @@ - [ ] Größere Tasks brechen irgendwann ab, könnte an der Limitierung von Antrophic/Claude liegen. Jedoch sollte wird es scheinbar nicht behandelt: Der Status bleibt auf "Streaming..." aber es passiert nichts mehr ## Backend -- [ ] System-Prompt anpassen. Explizit auf Workspace-Pfad hinweisen, weil Model halluziniert oft und will in den nicht vorhandenen Pfad /workspace wechseln +- [x] System-Prompt anpassen. Explizit auf Workspace-Pfad hinweisen, weil Model halluziniert oft und will in den nicht vorhandenen Pfad /workspace wechseln ## Frontend -- [ ] Chat: Es scrollt beim Laden nicht ganz nach unten, ich denke weil im DOM noch etwas nachgeladen wird (ich denke es ist die Schriftart), und die Höhe sich beim Laden nochmal verändert +- [x] Chat: Es scrollt beim Laden nicht ganz nach unten, ich denke weil im DOM noch etwas nachgeladen wird (ich denke es ist die Schriftart), und die Höhe sich beim Laden nochmal verändert - [x] Chat: User-Nachrichten mit dem Tag [Datei: pfat/zur/datei.xyz:1-10] wird nicht als HTML geparsed -- [ ] Chat: Wird zwar erkannt und ist klickbar, aber es öffnet sich einfach nur der File Explorer, ohne dass sich die Datei öffnet und die Darstellung in Monospace ist auch nicht schön +- [x] Chat: Wird zwar erkannt und ist klickbar, aber es öffnet sich einfach nur der File Explorer, ohne dass sich die Datei öffnet und die Darstellung in Monospace ist auch nicht schön diff --git a/wwwroot/css/chat.css b/wwwroot/css/chat.css index 685d916..1c53160 100644 --- a/wwwroot/css/chat.css +++ b/wwwroot/css/chat.css @@ -331,14 +331,17 @@ /* File Ref Chips in User-Bubbles */ .file-ref-chip { - display: inline-block; - padding: var(--badge-padding); + display: inline-flex; + align-items: center; + gap: .3em; + padding: .15em .5em; background: var(--f2-bg); border: var(--border-width) solid var(--f2-border); border-radius: var(--border-radius); - font-family: var(--mono); - font-size: .85em; + font-size: .9em; white-space: nowrap; + vertical-align: baseline; + transition: background .15s, border-color .15s; } .file-ref-chip:hover { diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index a684f6c..f69cbe5 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -111,3 +111,14 @@ async function apiExecCommand(sessionId, command) { body: JSON.stringify({ command }) }); } + +async function apiIsStreaming(sessionId) { + const resp = await fetch(`/api/sessions/${sessionId}/streaming`); + return resp.json(); +} + +async function apiGetChain(chainId) { + const resp = await fetch(`/api/chains/${chainId}`); + if (!resp.ok) return null; + return resp.json(); +} diff --git a/wwwroot/js/chat.js b/wwwroot/js/chat.js index b16611f..1d5e81d 100644 --- a/wwwroot/js/chat.js +++ b/wwwroot/js/chat.js @@ -149,10 +149,22 @@ async function sendMessage() { } catch (e) { endStreamBubble(); + // Prüfen ob Backend noch streamt (z.B. bei Netzwerkfehler) + try { + const status = await apiIsStreaming(getCurrentSessionId()); + if (status.streaming) { + // Backend streamt noch weiter – Recovery-Poll starten + setMetaHtml('⏳ Streaming... (Verbindung wiederhergestellt)'); + startStreamingPoll(getCurrentSessionId(), btn, status.chainId); + return; + } + } catch (_) {} addErrorBubble('Netzwerk-Fehler: ' + e.message); } finally { - btn.disabled = false; - isStreaming = false; + if (!isStreaming || !_streamingPollInterval) { + btn.disabled = false; + isStreaming = false; + } endStreamBubble(); // Erst neue aus DB laden, dann gestreamte Elemente ersetzen diff --git a/wwwroot/js/file-explorer.js b/wwwroot/js/file-explorer.js index d2d2498..51fb767 100644 --- a/wwwroot/js/file-explorer.js +++ b/wwwroot/js/file-explorer.js @@ -12,7 +12,8 @@ function openFileExplorer(opts = {}) { onSelect: opts.onSelect || null, currentPath: '', history: [], - selectedFile: null + selectedFile: null, + initialFile: opts.initialFile || null // z.B. "src/foo.cs" – Datei direkt öffnen }; const overlay = document.createElement('div'); @@ -55,7 +56,14 @@ function openFileExplorer(opts = {}) { _feState._keyHandler = (e) => { if (e.key === 'Escape') closeFileExplorer(); }; document.addEventListener('keydown', _feState._keyHandler); - feLoadDir(''); + // Zum Verzeichnis der initialFile navigieren, oder Root + if (_feState.initialFile) { + const filePath = _feState.initialFile; + const dir = filePath.includes('/') ? filePath.substring(0, filePath.lastIndexOf('/')) : ''; + feLoadDir(dir); + } else { + feLoadDir(''); + } } function closeFileExplorer() { @@ -140,6 +148,22 @@ async function feLoadDir(path) { } backBtn.disabled = path === ''; + + // Wenn initialFile gesetzt, Datei automatisch selektieren + if (_feState && _feState.initialFile) { + const targetPath = _feState.initialFile; + _feState.initialFile = null; // Nur einmal triggern + // data-path Selektor sicher aufbauen + const allFiles = sidebar.querySelectorAll('.fe-file'); + let targetItem = null; + allFiles.forEach(el => { if (el.dataset.path === targetPath) targetItem = el; }); + if (targetItem) { + const entry = entries.find(e => e.path === targetPath); + if (entry) { + feSelectFile(entry, targetItem); + } + } + } } catch (e) { sidebar.innerHTML = '
Netzwerk-Fehler
'; } diff --git a/wwwroot/js/history.js b/wwwroot/js/history.js index 7e40085..1fe3d08 100644 --- a/wwwroot/js/history.js +++ b/wwwroot/js/history.js @@ -185,8 +185,8 @@ function stopHistoryPolling() { } } -async function pollHistory() { - if (isStreaming) return; +async function pollHistory(force = false) { + if (isStreaming && !force) return; try { const newMessages = await apiHistoryPoll(getCurrentSessionId(), lastKnownMessageId); diff --git a/wwwroot/js/pages/chat-page.js b/wwwroot/js/pages/chat-page.js index b826bd5..4de5855 100644 --- a/wwwroot/js/pages/chat-page.js +++ b/wwwroot/js/pages/chat-page.js @@ -1,5 +1,50 @@ // ── Chat Page ── +let _streamingPollInterval = null; +let _recoveryChainId = null; + +function startStreamingPoll(sessionId, sendBtn, chainId) { + _recoveryChainId = chainId; + if (_streamingPollInterval) clearInterval(_streamingPollInterval); + _streamingPollInterval = setInterval(async () => { + try { + // Neue Nachrichten laden (inkl. der noch streamenden) + await pollHistory(true); + + const status = await apiIsStreaming(sessionId); + if (!status.streaming) { + clearInterval(_streamingPollInterval); + _streamingPollInterval = null; + isStreaming = false; + if (sendBtn) sendBtn.disabled = false; + + // Finale Nachrichten nochmal laden + await pollHistory(true); + + // Chain-Daten laden für Meta-Info + const cid = _recoveryChainId || status.chainId; + if (cid) { + const chain = await apiGetChain(cid); + if (chain) { + let meta = `Model: ${chain.model || '?'} | In: ${chain.inputTokens || 0} | Out: ${chain.outputTokens || 0}`; + if (chain.iterations > 1) meta += ` | Iterations: ${chain.iterations}`; + if (chain.durationMs) meta += ` | ${(chain.durationMs / 1000).toFixed(1)}s`; + if (chain.stopReason && chain.stopReason !== 'end_turn' && chain.stopReason !== 'tool_use') meta += ` | Stop: ${chain.stopReason}`; + setMeta(meta); + } else { + setMeta('Bereit'); + } + } else { + setMeta('Bereit'); + } + _recoveryChainId = null; + } + } catch (e) { + console.error('Streaming-Poll fehlgeschlagen:', e); + } + }, 1500); +} + registerPage('chat', { async init(params) { const sessionId = params.id; @@ -152,6 +197,24 @@ registerPage('chat', { isStreaming = false; await loadHistory(); + + // Nach Font-Loading nochmal scrollen (Schriftart kann Höhe verändern) + document.fonts.ready.then(() => { + requestAnimationFrame(() => scrollDown()); + }); + + // Prüfen ob Backend gerade noch streamt (z.B. nach Page-Refresh) + try { + const status = await apiIsStreaming(sessionId); + if (status.streaming) { + isStreaming = true; + if (sendBtn) sendBtn.disabled = true; + setMetaHtml('⏳ Streaming...'); + startStreamingPoll(sessionId, sendBtn, status.chainId); + } + } catch (e) { + console.error('Streaming-Status prüfen fehlgeschlagen:', e); + } }, destroy() { @@ -159,6 +222,10 @@ registerPage('chat', { document.removeEventListener('click', this._closeMenu); this._closeMenu = null; } + if (_streamingPollInterval) { + clearInterval(_streamingPollInterval); + _streamingPollInterval = null; + } stopHistoryPolling(); messages.length = 0; lastKnownMessageId = 0; diff --git a/wwwroot/js/ui.js b/wwwroot/js/ui.js index 7ace95d..30412e5 100644 --- a/wwwroot/js/ui.js +++ b/wwwroot/js/ui.js @@ -15,7 +15,12 @@ function renderMarkdown(text) { function scrollDown() { const chat = chatEl(); + if (!chat) return; chat.scrollTop = chat.scrollHeight; + // Nach Font-Swap kann sich die Höhe ändern → nochmal scrollen + document.fonts.ready.then(() => { + chat.scrollTop = chat.scrollHeight; + }); } function esc(s) { @@ -45,13 +50,18 @@ function renderUserContent(container, text) { } const ref = match[1]; // z.B. "src/foo.cs:10-25" + // Pfad ohne Zeilenangabe extrahieren + const filePath = ref.replace(/:\d+(-\d+)?$/, ''); const chip = document.createElement('span'); chip.className = 'file-ref-chip'; - chip.textContent = match[0]; + // Schöne Darstellung: Icon + Kurzpfad + const fileName = filePath.includes('/') ? filePath.substring(filePath.lastIndexOf('/') + 1) : filePath; + const lineInfo = ref.replace(filePath, '').trim(); // z.B. ":10-25" + chip.textContent = '📄 ' + fileName + lineInfo; chip.title = ref; chip.style.cursor = 'pointer'; chip.addEventListener('click', () => { - openFileExplorer({ sessionId: getCurrentSessionId() }); + openFileExplorer({ sessionId: getCurrentSessionId(), initialFile: filePath }); }); frag.appendChild(chip); last = match.index + match[0].length;