fix: Agent bricht bei Page-Refresh nicht mehr ab + SSE-Writes crash-sicher
Ursache: WriteSseEvent in ProcessStream warf OperationCanceledException bei Client-Disconnect (Page-Refresh) → sseCt war gelinkt mit dem ASP.NET cancellationToken → Exception wurde im catch-Block als 'stopped' behandelt → Agent brach ab obwohl nur der Browser refreshed wurde. Fixes: - ProcessStream: Alle WriteSseEvent durch TryWriteSse ersetzt SSE-Writes bei Client-Disconnect werden jetzt zu stillen No-Ops statt den gesamten Agent-Flow per Exception zu killen - catch-Blöcke: OperationCanceledException nur bei workCt/hardCt als 'stopped' behandeln, neuer catch für Client-Disconnect als 'client_disconnected' (Fallback, sollte nicht mehr auftreten) - done-Event nach Stop: sseCt → CancellationToken.None damit das done-Event trotz gecancelltem sseCt noch gesendet wird - Frontend: done-Handler rendert Chain-Badge direkt im apiGetChain- Callback statt Race-Condition mit finally-Block - Frontend: 'stopped' Badge wieder angezeigt (war nur fälschlich bei Page-Refresh, jetzt korrekt nur bei echtem Stop)
This commit is contained in:
1 parent
b1d449b997
commit
6499864f13
2 files changed
+55
-13
No files matched your search
@@ -272,10 +272,25 @@ public class AnthropicService
|
|||||||
await TryWriteSse(response, "error", new { error = "Max iterations erreicht" }, sseCt);
|
await TryWriteSse(response, "error", new { error = "Max iterations erreicht" }, sseCt);
|
||||||
await TryWriteSse(response, "done", new { chainId = chain.ChainId }, sseCt);
|
await TryWriteSse(response, "done", new { chainId = chain.ChainId }, sseCt);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException) when (workCt.IsCancellationRequested || hardCt.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
// Stop-Button gedrückt (workCt oder hardCt wurde gecancelled)
|
// Stop-Button gedrückt (workCt oder hardCt wurde gecancelled)
|
||||||
await FinalizeChain(chain, usage, iterations, "stopped", sw);
|
await FinalizeChain(chain, usage, iterations, "stopped", sw);
|
||||||
|
// sseCt ist hier bereits cancelled (gelinkt mit workCt), daher CancellationToken.None
|
||||||
|
// Der SSE-Stream zum Client ist noch offen – nur der Stop-Button hat ausgelöst
|
||||||
|
await TryWriteSse(response, "done", new
|
||||||
|
{
|
||||||
|
model = modelId,
|
||||||
|
inputTokens = usage.InputTokens,
|
||||||
|
outputTokens = usage.OutputTokens,
|
||||||
|
cacheCreationTokens = usage.CacheCreationTokens,
|
||||||
|
cacheReadTokens = usage.CacheReadTokens,
|
||||||
|
iterations,
|
||||||
|
chainId,
|
||||||
|
durationMs = sw.ElapsedMilliseconds,
|
||||||
|
stopReason = "stopped",
|
||||||
|
planMode
|
||||||
|
}, CancellationToken.None);
|
||||||
}
|
}
|
||||||
catch (Exception) when (workCt.IsCancellationRequested || hardCt.IsCancellationRequested)
|
catch (Exception) when (workCt.IsCancellationRequested || hardCt.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -283,6 +298,27 @@ public class AnthropicService
|
|||||||
// ObjectDisposedException/IOException aus statt OperationCanceledException.
|
// ObjectDisposedException/IOException aus statt OperationCanceledException.
|
||||||
// Wenn workCt/hardCt gecancelled ist, ist das ein regulärer Stop.
|
// Wenn workCt/hardCt gecancelled ist, ist das ein regulärer Stop.
|
||||||
await FinalizeChain(chain, usage, iterations, "stopped", sw);
|
await FinalizeChain(chain, usage, iterations, "stopped", sw);
|
||||||
|
// sseCt ist hier bereits cancelled (gelinkt mit workCt), daher CancellationToken.None
|
||||||
|
await TryWriteSse(response, "done", new
|
||||||
|
{
|
||||||
|
model = modelId,
|
||||||
|
inputTokens = usage.InputTokens,
|
||||||
|
outputTokens = usage.OutputTokens,
|
||||||
|
cacheCreationTokens = usage.CacheCreationTokens,
|
||||||
|
cacheReadTokens = usage.CacheReadTokens,
|
||||||
|
iterations,
|
||||||
|
chainId,
|
||||||
|
durationMs = sw.ElapsedMilliseconds,
|
||||||
|
stopReason = "stopped",
|
||||||
|
planMode
|
||||||
|
}, CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Client-Disconnect (cancellationToken gecancelled, aber NICHT workCt/hardCt)
|
||||||
|
// Agent-Arbeit ist leider schon abgebrochen – Chain als "client_disconnected" finalisieren
|
||||||
|
// (sollte durch TryWriteSse eigentlich nicht mehr passieren)
|
||||||
|
await FinalizeChain(chain, usage, iterations, "client_disconnected", sw);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1136,7 +1172,7 @@ public class AnthropicService
|
|||||||
if (cbType == "text")
|
if (cbType == "text")
|
||||||
{
|
{
|
||||||
currentTextBlock.Clear();
|
currentTextBlock.Clear();
|
||||||
await WriteSseEvent(response, "text_start", new { index = idx }, sseCt);
|
await TryWriteSse(response, "text_start", new { index = idx }, sseCt);
|
||||||
}
|
}
|
||||||
else if (cbType == "tool_use")
|
else if (cbType == "tool_use")
|
||||||
{
|
{
|
||||||
@@ -1148,7 +1184,7 @@ public class AnthropicService
|
|||||||
currentToolId = cb.GetProperty("id").GetString() ?? "";
|
currentToolId = cb.GetProperty("id").GetString() ?? "";
|
||||||
currentToolName = cb.GetProperty("name").GetString() ?? "";
|
currentToolName = cb.GetProperty("name").GetString() ?? "";
|
||||||
currentToolInputJson.Clear();
|
currentToolInputJson.Clear();
|
||||||
await WriteSseEvent(response, "tool_start", new { name = currentToolName, id = currentToolId }, sseCt);
|
await TryWriteSse(response, "tool_start", new { name = currentToolName, id = currentToolId }, sseCt);
|
||||||
}
|
}
|
||||||
else if (cbType == "server_tool_use")
|
else if (cbType == "server_tool_use")
|
||||||
{
|
{
|
||||||
@@ -1160,7 +1196,7 @@ public class AnthropicService
|
|||||||
currentToolId = cb.GetProperty("id").GetString() ?? "";
|
currentToolId = cb.GetProperty("id").GetString() ?? "";
|
||||||
currentToolName = cb.GetProperty("name").GetString() ?? "";
|
currentToolName = cb.GetProperty("name").GetString() ?? "";
|
||||||
currentToolInputJson.Clear();
|
currentToolInputJson.Clear();
|
||||||
await WriteSseEvent(response, "web_search_start", new { name = currentToolName, id = currentToolId }, sseCt);
|
await TryWriteSse(response, "web_search_start", new { name = currentToolName, id = currentToolId }, sseCt);
|
||||||
}
|
}
|
||||||
else if (cbType == "web_search_tool_result")
|
else if (cbType == "web_search_tool_result")
|
||||||
{
|
{
|
||||||
@@ -1185,7 +1221,7 @@ public class AnthropicService
|
|||||||
{
|
{
|
||||||
var text = delta.GetProperty("text").GetString() ?? "";
|
var text = delta.GetProperty("text").GetString() ?? "";
|
||||||
currentTextBlock.Append(text);
|
currentTextBlock.Append(text);
|
||||||
await WriteSseEvent(response, "text_delta", new { text }, sseCt);
|
await TryWriteSse(response, "text_delta", new { text }, sseCt);
|
||||||
}
|
}
|
||||||
else if (deltaType == "input_json_delta")
|
else if (deltaType == "input_json_delta")
|
||||||
{
|
{
|
||||||
@@ -1206,7 +1242,7 @@ public class AnthropicService
|
|||||||
if (text.Length > 0)
|
if (text.Length > 0)
|
||||||
{
|
{
|
||||||
assistantContent.Add(new { type = "text", text });
|
assistantContent.Add(new { type = "text", text });
|
||||||
await WriteSseEvent(response, "text_end", new { }, sseCt);
|
await TryWriteSse(response, "text_end", new { }, sseCt);
|
||||||
}
|
}
|
||||||
currentTextBlock.Clear();
|
currentTextBlock.Clear();
|
||||||
}
|
}
|
||||||
@@ -1243,7 +1279,7 @@ public class AnthropicService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await WriteSseEvent(response, "user_choice", new
|
await TryWriteSse(response, "user_choice", new
|
||||||
{
|
{
|
||||||
toolCallId = currentToolId,
|
toolCallId = currentToolId,
|
||||||
question,
|
question,
|
||||||
@@ -1268,7 +1304,7 @@ public class AnthropicService
|
|||||||
workCt.ThrowIfCancellationRequested();
|
workCt.ThrowIfCancellationRequested();
|
||||||
executedTools.Add(new ToolResult(currentToolId, toolResult, isError));
|
executedTools.Add(new ToolResult(currentToolId, toolResult, isError));
|
||||||
|
|
||||||
await WriteSseEvent(response, "tool_end", new
|
await TryWriteSse(response, "tool_end", new
|
||||||
{
|
{
|
||||||
name = currentToolName,
|
name = currentToolName,
|
||||||
input = currentToolInputJson.ToString(),
|
input = currentToolInputJson.ToString(),
|
||||||
@@ -1296,7 +1332,7 @@ public class AnthropicService
|
|||||||
// Web-Search-Ergebnis von Anthropic – als Raw-JSON in assistantContent aufnehmen
|
// Web-Search-Ergebnis von Anthropic – als Raw-JSON in assistantContent aufnehmen
|
||||||
assistantContent.Add(webSearchResultBlocks[idx]);
|
assistantContent.Add(webSearchResultBlocks[idx]);
|
||||||
var sources = webSearchSources.TryGetValue(idx, out var s) ? s : new List<object>();
|
var sources = webSearchSources.TryGetValue(idx, out var s) ? s : new List<object>();
|
||||||
await WriteSseEvent(response, "web_search_end", new { id = currentToolId, sources }, sseCt);
|
await TryWriteSse(response, "web_search_end", new { id = currentToolId, sources }, sseCt);
|
||||||
}
|
}
|
||||||
else if (bt.EndsWith("_tool_result") && serverToolResultBlocks.TryGetValue(idx, out var srvResultBlock))
|
else if (bt.EndsWith("_tool_result") && serverToolResultBlocks.TryGetValue(idx, out var srvResultBlock))
|
||||||
{
|
{
|
||||||
|
|||||||
+10
-4
@@ -121,11 +121,13 @@ function handleStreamEvent(eventType, data) {
|
|||||||
if (data.model) {
|
if (data.model) {
|
||||||
_lastDoneChainData = data;
|
_lastDoneChainData = data;
|
||||||
} else if (data.chainId) {
|
} else if (data.chainId) {
|
||||||
|
// Fallback: Chain-Daten vom Server nachladen und Badge direkt rendern
|
||||||
|
// (async – _lastDoneChainData wäre im finally schon gelesen)
|
||||||
apiGetChain(data.chainId).then(chain => {
|
apiGetChain(data.chainId).then(chain => {
|
||||||
if (chain) _lastDoneChainData = chain;
|
if (chain) addChainMetaBubble(chain, false);
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
setMeta('Bereit');
|
setMeta(data.stopReason === 'stopped' ? 'Gestoppt' : 'Bereit');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -238,8 +240,7 @@ async function _runStream(fetchPromise) {
|
|||||||
if (_chatAbandoned) return;
|
if (_chatAbandoned) return;
|
||||||
endStreamBubble();
|
endStreamBubble();
|
||||||
if (e.name === 'AbortError') {
|
if (e.name === 'AbortError') {
|
||||||
setMeta('Abgebrochen');
|
setMeta('Gestoppt');
|
||||||
await new Promise(r => setTimeout(r, 500));
|
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const status = await apiIsStreaming(sid);
|
const status = await apiIsStreaming(sid);
|
||||||
@@ -261,6 +262,11 @@ async function _runStream(fetchPromise) {
|
|||||||
stopBtn.title = 'Antwort abbrechen';
|
stopBtn.title = 'Antwort abbrechen';
|
||||||
isStreaming = false;
|
isStreaming = false;
|
||||||
_abortController = null;
|
_abortController = null;
|
||||||
|
// Meta zurücksetzen falls kein done-Event kam (z.B. nach Stop)
|
||||||
|
var metaEl = $('#meta');
|
||||||
|
if (metaEl && metaEl.textContent.includes('Streaming')) {
|
||||||
|
setMeta(_stopClickCount > 0 ? 'Gestoppt' : 'Bereit');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
endStreamBubble();
|
endStreamBubble();
|
||||||
removeStreamedElements();
|
removeStreamedElements();
|
||||||
|
|||||||
Reference in new issue
Block a user