From 463e282afdbf60c8ac2da09006ca0d4d7154ffd6 Mon Sep 17 00:00:00 2001 From: caoqianming Date: Tue, 7 Jul 2026 09:02:31 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20run=20=E9=95=BF=E8=80=97=E6=97=B6?= =?UTF-8?q?=E5=8F=AF=E8=A7=86=E5=8C=96=E2=80=94=E2=80=94reasoning=20?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=B5=81=20+=20=E9=98=B6=E6=AE=B5=E8=B7=B3?= =?UTF-8?q?=E7=A7=92=E6=8C=87=E7=A4=BA(bump=200.43.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修"前端一直静止显示思考中被当成卡死": - 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 --- DESIGN.md | 2 +- PROGRESS.md | 3 +- core/__init__.py | 2 +- core/loop.py | 35 +++++++++++++- web/app.py | 6 +-- web/static/dev.html | 20 ++++++++ web/static/js/chat.js | 106 ++++++++++++++++++++++++++++++++++++++++-- 7 files changed, 164 insertions(+), 10 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 28412c0..45bed74 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 认证 diff --git a/PROGRESS.md b/PROGRESS.md index b1f1f06..024c47d 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 踩出,进故障兜底。 diff --git a/core/__init__.py b/core/__init__.py index 76a7303..01ce4d7 100644 --- a/core/__init__.py +++ b/core/__init__.py @@ -1,3 +1,3 @@ # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # 改版本只动这一行。 -__version__ = "0.42.5" +__version__ = "0.43.0" diff --git a/core/loop.py b/core/loop.py index 6c6f5eb..1222671 100644 --- a/core/loop.py +++ b/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: diff --git a/web/app.py b/web/app.py index d4d47f9..54ad542 100644 --- a/web/app.py +++ b/web/app.py @@ -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) diff --git a/web/static/dev.html b/web/static/dev.html index 5189c43..5c1112d 100644 --- a/web/static/dev.html +++ b/web/static/dev.html @@ -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; diff --git a/web/static/js/chat.js b/web/static/js/chat.js index a9a0076..2277376 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -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 = `思考过程
`; + 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 = `
${roleLabel}
`; + // 思考过程(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 += `
思考过程
${escapeHtml(rc)}
`; + } + } if (typeof p.content === "string" && p.content) { html += `
${renderMd(p.content)}
`; // assistant 正文里 echo 的 /... 路径**永远**挂 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 = `${escapeHtml(label)}
${escapeHtml(argsStr)}
`; + det.className = "tool-call running"; + det.innerHTML = `${escapeHtml(label)}
${escapeHtml(argsStr)}
`; 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");