Fix: Chat scrollt nach Font-Loading nochmal nach unten
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.
This commit is contained in:
1 parent
3cb468f4e8
commit
76341dc42b
14 files changed
+349
-121
No files matched your search
@@ -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<ChatRequest>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
+144
-104
@@ -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<string, object>
|
||||
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<string, object>
|
||||
{
|
||||
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<JsonElement>(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<JsonElement>(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<object>();
|
||||
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<object>();
|
||||
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<ToolResult>();
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Gott.Services;
|
||||
|
||||
public class StreamingTracker
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, string> _streaming = new ConcurrentDictionary<string, string>();
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
+14
-2
@@ -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('<span>⏳ Streaming... (Verbindung wiederhergestellt)</span>');
|
||||
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
|
||||
|
||||
@@ -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 = '<div class="fe-error">Netzwerk-Fehler</div>';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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('<span>⏳ Streaming...</span>');
|
||||
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;
|
||||
|
||||
+12
-2
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user