fix: 流式渲染回合生命周期 + LLM 瞬断重试与 SSE body 兜底

问题一(webui 不是真流式):
- sendChat 的 finally 在 POST 结束(15s ackTimer abort)时就复位
  chatLoading,但 agent 生成窗口 15~190s,后续 SSE delta 全部走
  全量重建路径、停止按钮提前消失、用户误发重复消息。
- GUI app.js 完全没有 content_delta/reasoning_delta 监听器,
  只能等聚合帧一次性显示。

修复:三端统一回合生命周期——POST 只是触发,收尾由 SSE 驱动:
- dashboard/GUI 新增 endChatTurn/armTurnWatchdog;拿到同步兜底
  响应立即收尾,否则保持回合打开等 agent_output final / reset 帧 /
  120s watchdog 兜底
- GUI 补齐 delta 监听器;agent_output 聚合分支 += 改覆盖;
  reasoning 聚合帧改覆盖(多轮工具调用时旧逻辑会重复累加)
- agent_output 误杀分支(final 无 source 即 return 丢弃新输出)
  改为内容比较去重,多轮连发时新一轮回复不再被吞
- waiter reasoning_delta reset 从清空全部消息改为 sealLastAgent

问题二(三条只成功一条):
- handleChat 60s ctx 含排队时间,agent 串行处理下第 N 条必超时
  (实测第 3 条 62s 超时 504);放宽到 300s(客户端 abort 时立即取消)
- LLM 单 provider 瞬断无重试:process.go provider 循环内加同源
  重试(2 次、退避 2s),401/403 凭证错误与用户中断不重试
- llmsproxy auto 链在非流式请求下可能返回 SSE body(上游恢复后
  吐已生成的 chunk 流),非流式解析报 invalid character 'd' 丢掉
  整段回复;新增 parseOpenAICompatibleSSEBody 拼接为完整响应
- 顺带修 normalizeStreamToolCalls 分片续传 bug:name 不重发时
  argsRaw 被顶层 Arguments(nil) 覆盖丢失 function.arguments

验证:
- 连发 3 条 + 单条共 4 条全部成功(首条 190s 重试扛住瞬断)
- sse_body_test.go 锁定 SSE body 解析契约(content/usage/tool call 分片)
This commit is contained in:
JianFeeeee
2026-08-25 12:24:11 +08:00
parent 061d2ae320
commit 79b7766ed4
7 changed files with 469 additions and 45 deletions

View File

@ -3633,6 +3633,46 @@ background:
});
}
// 回合收尾:由 SSE 事件agent_output final / reset 帧)或 watchdog 驱动。
// POST 结束 ≠ 回合结束agent 可能还在生成(排队+长生成),提前复位
// chatLoading 会让后续 delta 走全量重建、停止按钮消失、用户误发重复消息。
function endChatTurn() {
if (!state.chatLoading) return;
state.chatLoading = false;
state.chatStage = "";
if (state._turnWatchdog) {
clearTimeout(state._turnWatchdog);
state._turnWatchdog = null;
}
var btn = document.getElementById("chat-send-btn");
if (btn) {
btn.disabled = false;
btn.textContent = __("发送", "Send");
}
var sb = document.getElementById("chat-stop-btn");
if (sb) sb.style.display = "none";
rerenderChat(true);
}
// 回合看门狗POST 已 abort 且 SSE 迟迟无终帧时兕底收尾(连接不稳/事件丢失),
// 提示用户回复可能已生成、可刷新查看历史。避免回合永久卡在 loading。
function armTurnWatchdog() {
if (state._turnWatchdog) clearTimeout(state._turnWatchdog);
state._turnWatchdog = setTimeout(function () {
state._turnWatchdog = null;
if (state.chatLoading) {
endChatTurn();
toast(
__(
"长时间未收到回复,连接可能不稳定;回复可能已生成,可刷新页面查看",
"No reply received for a long time; the reply may have been generated, refresh to check",
),
true,
);
}
}, 120000);
}
async function sendChat() {
var inp = document.getElementById("chat-input");
var btn = document.getElementById("chat-send-btn");
@ -3677,7 +3717,6 @@ background:
} finally {
clearTimeout(ackTimer);
}
state.chatStage = __("AI 回复中...", "AI replying...");
var last = state.messages[state.messages.length - 1];
if (r && r.response) {
if (last && last.role === "assistant" && last._streaming) {
@ -3716,13 +3755,14 @@ background:
toast(__("请求失败: ", "Request failed: ") + e.message, true);
}
} finally {
state.chatLoading = false;
state.chatStage = "";
btn.disabled = false;
btn.textContent = __("发送", "Send");
var sb2 = document.getElementById("chat-stop-btn");
if (sb2) sb2.style.display = "none"; // 回复完成/失败,隐藏停止按钮
rerenderChat(true);
if (r && r.response) {
// 同步兜底已拿到完整回复:回合结束
endChatTurn();
} else {
// 触发式受理POST 已 abort/失败):回合仍打开,等 SSE 流式渲染;
// 由 agent_output final / reset 帧 / watchdog 收尾
armTurnWatchdog();
}
}
}
@ -4146,6 +4186,7 @@ background:
lastM2.content = p.content;
lastM2._final = true;
rerenderChat();
endChatTurn();
return;
}
if (
@ -4154,7 +4195,13 @@ background:
lastM2._final &&
!lastM2.source
) {
return;
// 去重:同一轮的重复帧(如 SSE 重连回放)内容相同则忽略;
// 内容不同视为新一轮输出(上一轮已 final 且无 source开新消息。
// 旧逻辑无条件 return 会丢弃多轮连发时新一轮的最终回复。
if (lastM2.content === p.content) {
endChatTurn();
return;
}
}
state.messages.push({
role: "assistant",
@ -4164,6 +4211,7 @@ background:
_final: true,
});
rerenderChat();
endChatTurn();
} catch (ex) {
console.error("[SSE] agent_output error", ex);
}
@ -4183,6 +4231,10 @@ background:
lm._final = true;
rerenderChat();
}
// 轮次作废(用户中断):定格已显示内容;核心会以 [中断消息]
// 重启轮次,保持回合打开让确认回复继续流式渲染,
// 由其 agent_output final / watchdog 收尾。
armTurnWatchdog();
return;
}
if (!p.content) return;

View File

@ -1342,8 +1342,11 @@ func (h *Handler) handleChat(w http.ResponseWriter, r *http.Request) {
if body.ClientMsgID != "" {
payload["client_msg_id"] = body.ClientMsgID
}
// 带超时的上下文,防止 InjectInputSync 长时间阻塞 HTTP 请求
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
// 带超时的上下文,防止 InjectInputSync 长时间阻塞 HTTP 请求
// 注意ctx 派生自 r.Context(),客户端提前断开(前端 15s ackTimer abort
// 立即取消,不会真等满 300s300s 只约束"连接保持 + agent 排队/长生成"场景
// agent 串行处理后发消息的排队时间也计入60s 曾导致连发第 3 条必超时)。
ctx, cancel := context.WithTimeout(r.Context(), 300*time.Second)
defer cancel()
respCh := make(chan *agentIO.OutputEvent, 1)
@ -1706,8 +1709,8 @@ func (h *Handler) handleOpenAICompletions(w http.ResponseWriter, r *http.Request
return
}
// 带超时的上下文
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
// 带超时的上下文(同 handleChat客户端断开立即取消300s 约束长生成)
ctx, cancel := context.WithTimeout(r.Context(), 300*time.Second)
defer cancel()
respCh := make(chan *agentIO.OutputEvent, 1)
@ -1719,7 +1722,7 @@ func (h *Handler) handleOpenAICompletions(w http.ResponseWriter, r *http.Request
select {
case response = <-respCh:
case <-ctx.Done():
writeJSON(w, http.StatusGatewayTimeout, map[string]string{"error": "agent timeout (60s)"})
writeJSON(w, http.StatusGatewayTimeout, map[string]string{"error": "agent timeout (300s)"})
return
}