From ee0b817f253e2c7a0afd3c48055b6ce5fccaa02e Mon Sep 17 00:00:00 2001 From: Meleki Date: Wed, 18 Mar 2026 07:01:00 +0100 Subject: [PATCH] =?UTF-8?q?fix:=20CLI-Stream=20=C3=BCberlebt=20Page-Reload?= =?UTF-8?q?=20(Token-Trennung=20workCt/sseCt)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit workCt steuert den CLI-Prozess (nur Stop-Button), sseCt die SSE-Writes (Client-Disconnect). Page-Reload cancelt nur sseCt → CLI läuft weiter. Co-Authored-By: Claude Opus 4.6 --- Endpoints/ChatEndpoint.cs | 2 +- Services/AnthropicService.cs | 41 ++++++++++++++++++++++-------------- 2 files changed, 26 insertions(+), 17 deletions(-) diff --git a/Endpoints/ChatEndpoint.cs b/Endpoints/ChatEndpoint.cs index b9e0a6e..383f1f4 100644 --- a/Endpoints/ChatEndpoint.cs +++ b/Endpoints/ChatEndpoint.cs @@ -245,7 +245,7 @@ public static class ChatEndpoint { if (anthropic.IsCliAvailable) { - await anthropic.StreamChatViaCli(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, workspace: ctx.Workspace, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: body.PlanMode, webSearch: !string.IsNullOrEmpty(ctx.WebSearchVersion), sessionId: sessionId, cancellationToken: sseCt); + await anthropic.StreamChatViaCli(res, ctx.ModelId, ctx.SystemPrompt, messages, ctx.ChainId, workspace: ctx.Workspace, allowUserChoice: ctx.Agent?.AllowUserChoice ?? true, planMode: body.PlanMode, webSearch: !string.IsNullOrEmpty(ctx.WebSearchVersion), sessionId: sessionId, workCt: workCt, sseCt: sseCt); } else { diff --git a/Services/AnthropicService.cs b/Services/AnthropicService.cs index 97af1cf..8a27f26 100644 --- a/Services/AnthropicService.cs +++ b/Services/AnthropicService.cs @@ -1886,12 +1886,13 @@ public class AnthropicService bool planMode = false, bool webSearch = false, string sessionId = null, - CancellationToken cancellationToken = default) + CancellationToken workCt = default, + CancellationToken sseCt = default) { if (_cliService == null || !_cliService.IsAvailable) { - await SseHelper.TryWriteSse(response, "error", new { error = "Claude CLI nicht verfügbar" }, cancellationToken); - await SseHelper.TryWriteSse(response, "done", new { chainId }, cancellationToken); + await SseHelper.TryWriteSse(response, "error", new { error = "Claude CLI nicht verfügbar" }, sseCt); + await SseHelper.TryWriteSse(response, "done", new { chainId }, sseCt); return; } @@ -1964,14 +1965,14 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En switch (evt.Type) { case CliEventType.TextStart: - await SseHelper.TryWriteSse(response, "text_start", new { index = 0 }, cancellationToken); + await SseHelper.TryWriteSse(response, "text_start", new { index = 0 }, sseCt); break; case CliEventType.TextDelta: fullText.Append(evt.Text); - await SseHelper.TryWriteSse(response, "text_delta", new { text = evt.Text }, cancellationToken); + await SseHelper.TryWriteSse(response, "text_delta", new { text = evt.Text }, sseCt); break; case CliEventType.TextEnd: - await SseHelper.TryWriteSse(response, "text_end", new { }, cancellationToken); + await SseHelper.TryWriteSse(response, "text_end", new { }, sseCt); break; case CliEventType.ToolStart: { @@ -1989,7 +1990,7 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En fullText.Clear(); } var mappedName = MapCliToolName(evt.ToolName); - await SseHelper.TryWriteSse(response, "tool_start", new { name = mappedName, id = evt.ToolId }, cancellationToken); + await SseHelper.TryWriteSse(response, "tool_start", new { name = mappedName, id = evt.ToolId }, sseCt); break; } case CliEventType.ToolEnd: @@ -2007,7 +2008,7 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En ToolCallId = evt.ToolId, Content = mappedInput }); - await SseHelper.TryWriteSse(response, "tool_end", new { name = mappedName, id = evt.ToolId, input = mappedInput }, cancellationToken); + await SseHelper.TryWriteSse(response, "tool_end", new { name = mappedName, id = evt.ToolId, input = mappedInput }, sseCt); break; } case CliEventType.ToolResult: @@ -2030,7 +2031,7 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En id = evt.ToolId, result = resultContent, isError = evt.ToolResultIsError - }, cancellationToken); + }, sseCt); break; } case CliEventType.UserChoice: @@ -2066,7 +2067,7 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En toolCallId = choiceToolCallId, question = evt.ChoiceQuestion, choices = evt.ChoiceOptions - }, cancellationToken); + }, sseCt); isUserChoice = true; break; @@ -2078,7 +2079,7 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En hasWorkspace: !string.IsNullOrEmpty(workspace), planMode: planMode, webSearch: webSearch, - cancellationToken: cancellationToken + cancellationToken: workCt ); // Bei CLI-Fehler → Fehlermeldung an Client senden und in DB speichern @@ -2094,7 +2095,7 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En Type = "error", Content = $"CLI-Fehler: {errorMsg}" }); - await SseHelper.TryWriteSse(response, "error", new { error = $"CLI-Fehler: {errorMsg}" }, cancellationToken); + await SseHelper.TryWriteSse(response, "error", new { error = $"CLI-Fehler: {errorMsg}" }, sseCt); } // Antwort in DB speichern (nur wenn nicht schon im UserChoice-Handler gespeichert) @@ -2132,10 +2133,11 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En durationMs = sw.ElapsedMilliseconds, stopReason, viaCli = true - }, cancellationToken); + }, sseCt); } - catch (OperationCanceledException) + catch (OperationCanceledException) when (workCt.IsCancellationRequested) { + // Stop-Button gedrückt → CLI-Prozess wurde gestoppt await FinalizeChain(chain, usage, 1, "stopped", sw); await SseHelper.TryWriteSse(response, "done", new { @@ -2146,6 +2148,13 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En viaCli = true }, CancellationToken.None); } + catch (OperationCanceledException) + { + // Client-Disconnect (sseCt gecancelled, aber NICHT workCt) + // CLI-Prozess läuft weiter im Hintergrund – hier nur Chain finalisieren + // (sollte nicht mehr passieren, da CLI jetzt workCt nutzt) + await FinalizeChain(chain, usage, 1, "client_disconnected", sw); + } catch (Exception ex) { var errorMsg = $"CLI-Fehler: {ex.Message}"; @@ -2157,7 +2166,7 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En Type = "error", Content = errorMsg }); - await SseHelper.TryWriteSse(response, "error", new { error = errorMsg }, cancellationToken); + await SseHelper.TryWriteSse(response, "error", new { error = errorMsg }, sseCt); await FinalizeChain(chain, usage, 1, "error", sw); await SseHelper.TryWriteSse(response, "done", new { @@ -2166,7 +2175,7 @@ Danach STOPPE die Textgenerierung sofort. Nutze dies sparsam — nur wenn die En durationMs = sw.ElapsedMilliseconds, stopReason = "error", viaCli = true - }, cancellationToken); + }, sseCt); } finally {