mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-22 01:48:11 +00:00
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:
@ -31,6 +31,7 @@ const state = {
|
||||
messages: [],
|
||||
chatLoading: false,
|
||||
chatStage: "",
|
||||
_turnWatchdog: null,
|
||||
healthResult: null,
|
||||
starmapInit: false,
|
||||
starmapLoading: false,
|
||||
@ -2036,6 +2037,46 @@ function buildChatStarmapGraph() {
|
||||
});
|
||||
}
|
||||
|
||||
// 回合收尾:由 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";
|
||||
rerenderChatIfActive();
|
||||
}
|
||||
|
||||
// 回合看门狗:POST 已超时且 SSE 迟迟无终帧时兕底收尾(连接不稳/事件丢失),
|
||||
// 提示用户回复可能已生成、可刷新查看历史。避免回合永久卡在 loading。
|
||||
function armTurnWatchdog() {
|
||||
if (state._turnWatchdog) clearTimeout(state._turnWatchdog);
|
||||
state._turnWatchdog = setTimeout(() => {
|
||||
state._turnWatchdog = null;
|
||||
if (state.chatLoading) {
|
||||
endChatTurn();
|
||||
toast(
|
||||
__(
|
||||
"长时间未收到回复,连接可能不稳定;回复可能已生成,可刷新连接后查看",
|
||||
"No reply for a long time; the reply may have been generated, reconnect to check",
|
||||
),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}, 120000);
|
||||
}
|
||||
|
||||
async function sendChat() {
|
||||
var inp = document.getElementById("chat-input");
|
||||
var btn = document.getElementById("chat-send-btn");
|
||||
@ -2125,13 +2166,14 @@ async function sendChat() {
|
||||
rerenderChat();
|
||||
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();
|
||||
if (r && r.response) {
|
||||
// 同步兜底已拿到完整回复:回合结束
|
||||
endChatTurn();
|
||||
} else {
|
||||
// 触发式受理(POST 超时/失败):回合仍打开,等 SSE 流式渲染;
|
||||
// 由 agent_output final / reset 帧 / watchdog 收尾
|
||||
armTurnWatchdog();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -4881,9 +4923,23 @@ async function connectFetchSSE(url) {
|
||||
? state.messages[state.messages.length - 1]
|
||||
: null;
|
||||
if (last && last.role === "assistant" && !last._final) {
|
||||
// 聚合最终响应:覆盖 delta 累积的中间内容(以聚合为准,含 stage 插件改写后的文本),置 final 结束本轮流式。
|
||||
last._grow = true;
|
||||
last.content += p.content || "";
|
||||
last.content = p.content || "";
|
||||
last._final = true;
|
||||
rerenderChatIfActive();
|
||||
endChatTurn();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
last &&
|
||||
last.role === "assistant" &&
|
||||
last._final &&
|
||||
!last.source &&
|
||||
last.content === (p.content || "")
|
||||
) {
|
||||
// 去重:同一轮的重复帧(如 SSE 重连回放)内容相同则忽略,仅收尾回合
|
||||
endChatTurn();
|
||||
return;
|
||||
}
|
||||
state.messages.push({
|
||||
@ -4891,8 +4947,10 @@ async function connectFetchSSE(url) {
|
||||
content: p.content || "",
|
||||
_streaming: true,
|
||||
_grow: true,
|
||||
_final: true,
|
||||
});
|
||||
rerenderChatIfActive();
|
||||
endChatTurn();
|
||||
} else if (type === "reasoning") {
|
||||
if (p.content) {
|
||||
state.chatStage = __("AI 思考中...", "AI thinking...");
|
||||
@ -4910,10 +4968,74 @@ async function connectFetchSSE(url) {
|
||||
});
|
||||
last = state.messages[state.messages.length - 1];
|
||||
}
|
||||
last.reasoning_content =
|
||||
(last.reasoning_content || "") + (p.content || "");
|
||||
last.reasoning_content = p.content;
|
||||
rerenderChatIfActive();
|
||||
}
|
||||
} else if (type === "reasoning_delta") {
|
||||
// token 级思考流式增量:逐块追加到当前思考内容;reset 帧表示轮次作废
|
||||
if (p.channel === "_consolidation_") return;
|
||||
if (p.reset) {
|
||||
var lm = state.messages.length
|
||||
? state.messages[state.messages.length - 1]
|
||||
: null;
|
||||
if (lm && lm.role === "assistant" && !lm._final) {
|
||||
lm._final = true;
|
||||
rerenderChatIfActive();
|
||||
}
|
||||
armTurnWatchdog();
|
||||
return;
|
||||
}
|
||||
if (!p.content) return;
|
||||
state.chatStage = __("AI 思考中...", "AI thinking...");
|
||||
var last =
|
||||
state.messages.length > 0
|
||||
? state.messages[state.messages.length - 1]
|
||||
: null;
|
||||
if (!last || last.role !== "assistant" || last._final) {
|
||||
state.messages.push({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning_content: "",
|
||||
tool_calls: [],
|
||||
_streaming: true,
|
||||
});
|
||||
last = state.messages[state.messages.length - 1];
|
||||
}
|
||||
last.reasoning_content =
|
||||
(last.reasoning_content || "") + p.content;
|
||||
rerenderChatIfActive();
|
||||
} else if (type === "content_delta") {
|
||||
// token 级回复流式增量:逐块追加到当前回复内容;reset 帧表示轮次作废(中断)
|
||||
if (p.channel === "_consolidation_") return;
|
||||
if (p.reset) {
|
||||
var lm = state.messages.length
|
||||
? state.messages[state.messages.length - 1]
|
||||
: null;
|
||||
if (lm && lm.role === "assistant" && !lm._final) {
|
||||
lm._final = true;
|
||||
rerenderChatIfActive();
|
||||
}
|
||||
armTurnWatchdog();
|
||||
return;
|
||||
}
|
||||
if (!p.content) return;
|
||||
state.chatStage = __("AI 回复中...", "AI replying...");
|
||||
var last =
|
||||
state.messages.length > 0
|
||||
? state.messages[state.messages.length - 1]
|
||||
: null;
|
||||
if (!last || last.role !== "assistant" || last._final) {
|
||||
state.messages.push({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [],
|
||||
_streaming: true,
|
||||
_grow: true,
|
||||
});
|
||||
last = state.messages[state.messages.length - 1];
|
||||
}
|
||||
last.content += p.content;
|
||||
rerenderChatIfActive();
|
||||
} else if (type === "tool_call") {
|
||||
if (!p.tool) return;
|
||||
var last =
|
||||
|
||||
Reference in New Issue
Block a user