feat(web): run 长耗时可视化——reasoning 事件流 + 阶段跳秒指示(bump 0.43.0)
修"前端一直静止显示思考中被当成卡死":
- core/loop.py:新增 SSE reasoning{delta} 事件——thinking 模型分钟级推理
原先 delta.reasoning_content 整段丢弃,前端零反馈;流式/非流式兜底两路都发
- chat.js:reasoning 渲染成灰色可折叠"思考过程"卡(直播态随流展开、正文
开始折叠;历史消息里持久化的 reasoning_content 同款渲染)
- chat.js:活跃状态指示——TTFT/推理期占位段每秒跳"思考中/深度思考中 · Ns"
(data-status + CSS attr),工具卡运行中 spinner + 跳秒、tool_result 定格
为执行耗时;每轮 llm_start 重建占位段,工具轮之后不再空窗
- DESIGN §7.2 SSE 事件清单补 reasoning
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
de53629573
commit
463e282afd
|
|
@ -150,7 +150,7 @@ Admin GET /v1/admin/*(require_admin;overview + usage/models|users + storage/
|
|||
Export GET /v1/tasks/{id}/export(docx)
|
||||
```
|
||||
|
||||
**SSE 事件**:`run_start / llm_start / text{delta} / tool_call / tool_result(预览,完整走 DB)/ llm_end / model_switch / cancelled / error / done`。fan-out:每订阅独立 queue;迟到订阅立收 done。事件不持久化(messages 走 PG)。
|
||||
**SSE 事件**:`run_start / llm_start / text{delta} / reasoning{delta}(thinking 模型推理流,前端灰色折叠卡)/ tool_call / tool_result(预览,完整走 DB)/ llm_end / model_switch / cancelled / error / done`。fan-out:每订阅独立 queue;迟到订阅立收 done。事件不持久化(messages 走 PG)。
|
||||
**版本化**:`/v1` minor 半年兼容,major 6 个月 deprecation。**CORS**:本地 `*`,部署收紧。
|
||||
|
||||
### 7.3 认证
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`。
|
||||
|
||||
最后更新:2026-07-06(497 跳转 301→308:修平台 POST 被降级成 GET 报 405,bump 0.42.5)
|
||||
最后更新:2026-07-07(run 长耗时可视化:reasoning 事件流 + 阶段跳秒指示,bump 0.43.0)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
|
||||
### 2026-07
|
||||
|
||||
- **07-07 / 0.43.0**:run 长耗时可视化——修"前端一直静止显示思考中被当成卡死":① 新增 SSE `reasoning{delta}` 事件(loop 提取 `delta.reasoning_content`,thinking 模型分钟级推理原先整段丢弃、前端零反馈),前端渲染灰色折叠"思考过程"卡,历史消息里持久化的 reasoning_content 同款渲染;② 前端活跃状态指示:TTFT/推理期占位段每秒跳"思考中/深度思考中 · Ns"(CSS attr(data-status)),工具卡运行中加 spinner + 跳秒、结果到达定格为执行耗时;每轮 llm_start 重建占位段,工具轮之后不再空窗。
|
||||
- **07-06 / 0.42.5**:497 跳转示例 301→308——平台走 http 入口 POST login 被客户端按 301 语义降级成 GET 报 405;308 保留方法/body。治本仍是调用方直配 https(明文首跳暴露 platform_key)。
|
||||
- **07-06 / 0.42.4**:497 跳转目标示例改写死域名(http://IP 进来被带回域名无证书警告;https://IP 直连警告 nginx 层无解,入口统一域名),进故障兜底。
|
||||
- **07-06 / 0.42.3**:nginx 示例补 TLS 正确写法(同端口单条 listen + error_page 497 跳 https;`if ($scheme)` 在 ssl-only 端口不触发)+ 企微回调只认 80/443 提醒;实际部署上 SSL 踩出,进故障兜底。
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
|
||||
# 改版本只动这一行。
|
||||
__version__ = "0.42.5"
|
||||
__version__ = "0.43.0"
|
||||
|
|
|
|||
35
core/loop.py
35
core/loop.py
|
|
@ -124,6 +124,27 @@ def _extract_delta_content(chunk: Any) -> Optional[str]:
|
|||
return None
|
||||
|
||||
|
||||
def _extract_delta_reasoning(chunk: Any) -> Optional[str]:
|
||||
"""从 stream chunk 提 delta.reasoning_content(thinking 模型的推理片段)。
|
||||
litellm 对多数 provider 归一到 delta.reasoning_content,个别只放
|
||||
provider_specific_fields —— 两处都查。没有则返 None(非 thinking 模型零开销)。
|
||||
"""
|
||||
try:
|
||||
choices = getattr(chunk, "choices", None)
|
||||
if not choices:
|
||||
return None
|
||||
delta = getattr(choices[0], "delta", None)
|
||||
if delta is None:
|
||||
return None
|
||||
rc = getattr(delta, "reasoning_content", None)
|
||||
if not rc:
|
||||
psf = getattr(delta, "provider_specific_fields", None) or {}
|
||||
rc = psf.get("reasoning_content") if isinstance(psf, dict) else None
|
||||
return rc if rc else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _malformed_tool_calls(response: Any) -> List[str]:
|
||||
"""检出 arguments 损坏(JSON 解析不了)的 tool_call,返回 [name(len=N), ...]。
|
||||
|
||||
|
|
@ -451,6 +472,11 @@ class AgentLoop:
|
|||
delta_text = _extract_delta_content(chunk)
|
||||
if delta_text:
|
||||
self._emit({"type": "text", "delta": delta_text})
|
||||
# thinking 模型的推理 delta 也实时流出(reasoning 事件):深度推理可达
|
||||
# 分钟级,不发的话前端全程静止"思考中",用户以为卡死。
|
||||
delta_reasoning = _extract_delta_reasoning(chunk)
|
||||
if delta_reasoning:
|
||||
self._emit({"type": "reasoning", "delta": delta_reasoning})
|
||||
finally:
|
||||
# generator 提前 break 时 GeneratorExit 触发 chat_stream finally → close 底层连接
|
||||
stream.close()
|
||||
|
|
@ -473,7 +499,14 @@ class AgentLoop:
|
|||
reasoning_effort=self.caps.default_reasoning_effort or None,
|
||||
)
|
||||
try:
|
||||
text = getattr(response.choices[0].message, "content", None)
|
||||
msg = response.choices[0].message
|
||||
rc = getattr(msg, "reasoning_content", None)
|
||||
if not rc:
|
||||
psf = getattr(msg, "provider_specific_fields", None) or {}
|
||||
rc = psf.get("reasoning_content") if isinstance(psf, dict) else None
|
||||
if rc:
|
||||
self._emit({"type": "reasoning", "delta": rc})
|
||||
text = getattr(msg, "content", None)
|
||||
if text:
|
||||
self._emit({"type": "text", "delta": text})
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -2782,9 +2782,9 @@ def create_app() -> FastAPI:
|
|||
user_id: UUID = Depends(require_user),
|
||||
):
|
||||
"""SSE 流。订阅当前 task 的活动 event(单活 run 形态下无歧义)。
|
||||
事件类型:run_start / llm_start / text / tool_call / tool_result /
|
||||
llm_end / cancelled / error / done。data 是 JSON dict(已剔除 `type` 字段,
|
||||
移到 event 名)。
|
||||
事件类型:run_start / llm_start / text / reasoning / tool_call /
|
||||
tool_result / llm_end / cancelled / error / done。data 是 JSON dict
|
||||
(已剔除 `type` 字段,移到 event 名)。
|
||||
"""
|
||||
try:
|
||||
tid = UUID(task_id)
|
||||
|
|
|
|||
|
|
@ -674,6 +674,8 @@
|
|||
.msg.assistant.live-run { border-color: rgba(220, 38, 38, 0.28); box-shadow: 0 0 0 1px rgba(220, 38, 38, 0.08), 0 8px 24px rgba(220, 38, 38, 0.08); }
|
||||
.msg .body.streaming { min-width: 96px; min-height: 22px; }
|
||||
.msg .body.streaming:empty::before { content: "思考中"; color: var(--muted); }
|
||||
/* 活跃状态指示:JS 每秒写入「阶段 · Ns」,秒数在走 = 没卡死;首字到达后 :empty 失效自动消失 */
|
||||
.msg .body.streaming:empty[data-status]::before { content: attr(data-status); }
|
||||
.msg .body.streaming::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
|
|
@ -730,6 +732,24 @@
|
|||
color: #555;
|
||||
}
|
||||
.tool-call summary:hover { background: #ebebeb; }
|
||||
.tool-call summary .tool-elapsed { color: var(--muted); margin-left: 6px; font-size: 11px; }
|
||||
.tool-call.running summary::after {
|
||||
content: ""; display: inline-block; width: 0.85em; height: 0.85em;
|
||||
margin-left: 8px; vertical-align: -0.1em;
|
||||
border: 2px solid rgba(220, 38, 38, 0.18); border-top-color: var(--accent);
|
||||
border-radius: 50%; animation: spin 0.8s linear infinite;
|
||||
}
|
||||
/* reasoning 思考过程卡:灰色斜体、默认随流展开 / 正文开始后折叠,历史态默认折叠 */
|
||||
details.reasoning { margin: 2px 0 6px; font-size: 12px; }
|
||||
details.reasoning summary {
|
||||
cursor: pointer; color: var(--muted); padding: 2px 0; user-select: none;
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
}
|
||||
details.reasoning .reason-body {
|
||||
white-space: pre-wrap; word-wrap: break-word; color: #8a8a8a; font-style: italic;
|
||||
border-left: 2px solid var(--border); padding: 4px 10px; margin: 4px 0 0;
|
||||
max-height: 220px; overflow-y: auto; line-height: 1.5;
|
||||
}
|
||||
.tool-call pre {
|
||||
margin: 4px 0 0; padding: 8px; background: var(--code-bg); border-radius: var(--r-sm);
|
||||
overflow-x: auto; max-height: 300px; white-space: pre-wrap;
|
||||
|
|
|
|||
|
|
@ -865,6 +865,68 @@ function closeTextSeg(run) {
|
|||
run.curSeg = null;
|
||||
}
|
||||
|
||||
// reasoning(思考过程)段:thinking 模型的推理 delta 流进灰色可折叠卡,首个正文
|
||||
// delta / 工具调用到达时定稿折叠。插在当前空占位段之前,保持「思考过程 → 正文」
|
||||
// 时序;与历史渲染同款结构(details.reasoning),run 结束 reload 无跳变。
|
||||
function ensureReasonSeg(run) {
|
||||
if (run.curReason) return run.curReason;
|
||||
const det = document.createElement("details");
|
||||
det.className = "reasoning streaming";
|
||||
det.open = true;
|
||||
det.innerHTML = `<summary>思考过程</summary><div class="reason-body"></div>`;
|
||||
const anchor = run.curSeg && !run.curSeg.acc ? run.curSeg.el : null;
|
||||
if (anchor) run.card.insertBefore(det, anchor);
|
||||
else run.card.appendChild(det);
|
||||
run.curReason = { el: det, body: det.querySelector(".reason-body"), acc: "", pending: false };
|
||||
return run.curReason;
|
||||
}
|
||||
|
||||
function closeReasonSeg(run) {
|
||||
const rs = run.curReason;
|
||||
if (!rs) return;
|
||||
if (!rs.acc) {
|
||||
rs.el.remove();
|
||||
} else {
|
||||
rs.el.classList.remove("streaming");
|
||||
rs.el.open = false; // 正文开始后折叠,点开可回看
|
||||
}
|
||||
run.curReason = null;
|
||||
}
|
||||
|
||||
// ───── run 活跃状态指示(阶段 + 已耗时) ─────
|
||||
// 长耗时阶段(TTFT / 深度推理 / 工具执行)原本页面全静止,和卡死无法区分。
|
||||
// 每秒把「阶段 · Ns」写进空占位段的 data-status(CSS :empty::before 渲染),
|
||||
// 工具卡运行中在 summary 里跳秒 —— 秒数在走本身就是最强的"没卡住"信号。
|
||||
let _statusTicker = null;
|
||||
|
||||
function setRunPhase(run, phase) {
|
||||
run.phase = phase;
|
||||
run.phaseSince = Date.now();
|
||||
tickRunStatus(run);
|
||||
if (!_statusTicker) {
|
||||
_statusTicker = setInterval(() => {
|
||||
if (!state.liveRuns.size) { clearInterval(_statusTicker); _statusTicker = null; return; }
|
||||
state.liveRuns.forEach(tickRunStatus);
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function tickRunStatus(run) {
|
||||
const secs = Math.floor((Date.now() - (run.phaseSince || Date.now())) / 1000);
|
||||
const suffix = secs >= 2 ? ` · ${secs}s` : "";
|
||||
if (run.curSeg && !run.curSeg.acc && run.curSeg.el) {
|
||||
const label = run.phase === "reasoning" ? "深度思考中" : "思考中";
|
||||
run.curSeg.el.dataset.status = label + suffix;
|
||||
}
|
||||
if (run.toolElapsedEl && suffix) run.toolElapsedEl.textContent = suffix;
|
||||
}
|
||||
|
||||
// 工具执行收尾:去 spinner,elapsed 定格(留着当执行耗时看)
|
||||
function finishRunningTool(run) {
|
||||
if (run.runningToolEl) { run.runningToolEl.classList.remove("running"); run.runningToolEl = null; }
|
||||
run.toolElapsedEl = null;
|
||||
}
|
||||
|
||||
function createLiveAssistantCard(run) {
|
||||
const card = document.createElement("div");
|
||||
card.className = "msg assistant live-run";
|
||||
|
|
@ -1042,6 +1104,14 @@ function renderMessages(msgs) {
|
|||
card.dataset.idx = m.idx;
|
||||
const roleLabel = { user: "我", assistant: "助手", error: "错误" }[role] || role;
|
||||
let html = `<div class="role">${roleLabel}</div>`;
|
||||
// 思考过程(thinking 模型持久化的 reasoning_content):默认折叠,与直播态同构
|
||||
if (role === "assistant") {
|
||||
const rc = (typeof p.reasoning_content === "string" && p.reasoning_content)
|
||||
|| ((p.provider_specific_fields || {}).reasoning_content || "");
|
||||
if (rc) {
|
||||
html += `<details class="reasoning"><summary>思考过程</summary><div class="reason-body">${escapeHtml(rc)}</div></details>`;
|
||||
}
|
||||
}
|
||||
if (typeof p.content === "string" && p.content) {
|
||||
html += `<div class="body">${renderMd(p.content)}</div>`;
|
||||
// assistant 正文里 echo 的 <wd>/... 路径**永远**挂 chip(绕开 seenRels —— 上面
|
||||
|
|
@ -1565,13 +1635,19 @@ async function fetchSse(url, run) {
|
|||
}
|
||||
// 最终定稿 + 代码高亮(流式中不 highlight,省 CPU):定稿当前打开的文字段,
|
||||
// 已被工具关闭的历史段在 closeTextSeg 时已定稿 + 高亮。
|
||||
closeReasonSeg(ctx); // 仍开着的思考过程段定稿折叠(如推理中被 cancel)
|
||||
if (ctx.curSeg && ctx.curSeg.el) {
|
||||
if (ctx.curSeg.acc) ctx.curSeg.el.innerHTML = renderMd(ctx.curSeg.acc);
|
||||
else ctx.curSeg.el.remove(); // 收尾时仍是空占位段 → 移除
|
||||
}
|
||||
if (ctx.card) highlightIn(ctx.card);
|
||||
} finally {
|
||||
if (ctx.card) ctx.card.querySelectorAll(".body.streaming").forEach((b) => b.classList.remove("streaming"));
|
||||
finishRunningTool(ctx); // 工具执行中被杀/断流 → 去 spinner
|
||||
if (ctx.card) {
|
||||
ctx.card.querySelectorAll(".body.streaming").forEach((b) => b.classList.remove("streaming"));
|
||||
ctx.card.querySelectorAll(".tool-call.running").forEach((d) => d.classList.remove("running"));
|
||||
ctx.card.querySelectorAll("details.reasoning.streaming").forEach((d) => { d.classList.remove("streaming"); d.open = false; });
|
||||
}
|
||||
state.liveRuns.delete(ctx.taskId);
|
||||
state.streaming = state.liveRuns.size > 0;
|
||||
syncTaskRowRunIndicator(ctx.taskId); // 先按本地态即时清标识,loadTaskList 随后带回服务端真相(如 error)
|
||||
|
|
@ -1679,10 +1755,28 @@ function handleSseEvent(ev, asstCard, ctx) {
|
|||
if (t === "llm_start") {
|
||||
ctx.contextStats = ev.data || {};
|
||||
setRunHint(ctx, formatContextStats(ctx.contextStats));
|
||||
// 每轮 LLM 起点重建空占位段(工具轮之后 curSeg 已被关):TTFT 期间显示
|
||||
// 「思考中 · Ns」,而不是页面静止
|
||||
ensureTextSeg(ctx);
|
||||
setRunPhase(ctx, "llm");
|
||||
} else if (t === "llm_end") {
|
||||
ctx.lastUsageHint = formatUsageStats(ev.data || {}, ctx.contextStats);
|
||||
setRunHint(ctx, ctx.lastUsageHint);
|
||||
} else if (t === "reasoning" && ev.data && ev.data.delta) {
|
||||
if (ctx.phase !== "reasoning") setRunPhase(ctx, "reasoning");
|
||||
const rs = ensureReasonSeg(ctx);
|
||||
rs.acc += ev.data.delta;
|
||||
if (!rs.pending) {
|
||||
rs.pending = true;
|
||||
requestAnimationFrame(() => {
|
||||
rs.body.textContent = rs.acc;
|
||||
rs.body.scrollTop = rs.body.scrollHeight; // 内滚容器跟到最新一行
|
||||
rs.pending = false;
|
||||
if (nearBottom) stream.scrollTop = stream.scrollHeight;
|
||||
});
|
||||
}
|
||||
} else if (t === "text" && ev.data && ev.data.delta) {
|
||||
closeReasonSeg(ctx); // 正文开始 → 思考过程定稿折叠
|
||||
ctx.acc += ev.data.delta;
|
||||
const seg = ensureTextSeg(ctx); // 无打开文字段则在卡片底部另起一段
|
||||
seg.acc += ev.data.delta;
|
||||
|
|
@ -1709,6 +1803,7 @@ function handleSseEvent(ev, asstCard, ctx) {
|
|||
if (nearBottom) stream.scrollTop = stream.scrollHeight;
|
||||
return; // 进度是隐形动作,不落可见卡 → 不打断当前文字段
|
||||
}
|
||||
closeReasonSeg(ctx); // 思考过程随文字段一起定稿
|
||||
closeTextSeg(ctx); // 关闭当前文字段:工具/选项卡追加到其下方,之后新文字另起底部段
|
||||
if (fn === "ask_user") {
|
||||
asstCard.appendChild(buildAskUserCard(args, { interactive: true }));
|
||||
|
|
@ -1717,9 +1812,13 @@ function handleSseEvent(ev, asstCard, ctx) {
|
|||
}
|
||||
const label = toolActivityLabel(fn, args);
|
||||
const det = document.createElement("details");
|
||||
det.className = "tool-call";
|
||||
det.innerHTML = `<summary>${escapeHtml(label)}</summary><pre>${escapeHtml(argsStr)}</pre>`;
|
||||
det.className = "tool-call running";
|
||||
det.innerHTML = `<summary>${escapeHtml(label)}<span class="tool-elapsed"></span></summary><pre>${escapeHtml(argsStr)}</pre>`;
|
||||
asstCard.appendChild(det);
|
||||
// 运行态:spinner + 跳秒,tool_result 到达时定格(区分"在执行"和"卡死")
|
||||
ctx.runningToolEl = det;
|
||||
ctx.toolElapsedEl = det.querySelector(".tool-elapsed");
|
||||
setRunPhase(ctx, "tool");
|
||||
const wd = _workingDirName(ctx.workingDir);
|
||||
const isProducer = ARTIFACT_PRODUCING_TOOLS.has(fn);
|
||||
const fresh = isProducer
|
||||
|
|
@ -1737,6 +1836,7 @@ function handleSseEvent(ev, asstCard, ctx) {
|
|||
const toolName = (ev.data && ev.data.name) || "";
|
||||
if (toolName === "task_progress") return;
|
||||
if (toolName === "ask_user") return; // 选项卡已在 tool_call 阶段渲染,结果是占位不展示
|
||||
finishRunningTool(ctx); // 去 spinner,elapsed 定格
|
||||
closeTextSeg(ctx); // 结果卡追加到当前文字段之下,之后新文字另起底部段
|
||||
const banner = extractMediaBanner(toolName, txtStr);
|
||||
const det = document.createElement("details");
|
||||
|
|
|
|||
Loading…
Reference in New Issue