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 = `
${escapeHtml(argsStr)}`;
+ det.className = "tool-call running";
+ det.innerHTML = `${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");