"""State and Card 2.0 rendering for a Feishu agent run.""" from __future__ import annotations import time from typing import Any, Dict, List, Optional from common import i18n _MAX_PANEL_STEPS = 10 _MAX_STEP_CHARS = 800 class FeishuProgressState: """Reduce CowAgent stream events into one renderable Feishu card state.""" def __init__(self, started_at: Optional[float] = None): self.started_at = time.monotonic() if started_at is None else started_at self.status = "running" self.turns = 0 self.current_text = "" self._reasoning_buffer = "" self.reasoning_steps: List[str] = [] self.tool_steps: List[Dict[str, Any]] = [] self._tool_index: Dict[str, Dict[str, Any]] = {} self.cancelled = False def consume(self, event: Dict[str, Any]) -> None: """Consume one event emitted by ``AgentStreamHandler``.""" event_type = event.get("type") data = event.get("data") or {} if event_type == "turn_start": self._mark_running_tools_done() turn = data.get("turn") if isinstance(turn, int): self.turns = max(self.turns, turn) else: self.turns += 1 if self.turns > 1: self.current_text = "" return if event_type == "reasoning_update": self._reasoning_buffer += str(data.get("delta") or "") return if event_type == "message_update": self.current_text += str(data.get("delta") or "") return if event_type == "message_end": self._commit_reasoning() return if event_type == "tool_execution_start": tool_id = data.get("tool_call_id") step = { "summary": str(data.get("tool_name") or "tool"), "status": "running", "started_at": time.monotonic(), "elapsed": None, } self.tool_steps.append(step) if tool_id: self._tool_index[tool_id] = step return if event_type == "tool_execution_end": tool_id = data.get("tool_call_id") step = self._tool_index.get(tool_id) if tool_id else None if step is None: # Fall back to the most recent running step when no id match. step = next((s for s in reversed(self.tool_steps) if s["status"] == "running"), None) if step is not None: step["status"] = "error" if data.get("status") not in (None, "success") else "done" elapsed = data.get("execution_time") if elapsed is None and step.get("started_at") is not None: elapsed = time.monotonic() - step["started_at"] step["elapsed"] = elapsed return if event_type == "agent_cancelled": self.cancelled = True self.status = "stopped" return if event_type == "agent_end": self._commit_reasoning() self._mark_running_tools_done() cancelled = self.cancelled or bool(data.get("cancelled")) if cancelled: self.status = "stopped" self.current_text = self.current_text.rstrip() or "_(stopped)_" else: self.status = "done" final_response = data.get("final_response") if final_response: self.current_text = str(final_response) def build_card(self, streaming: bool, now: Optional[float] = None) -> Dict[str, Any]: """Render the current state as a Feishu Card 2.0 object.""" # Localized status header text; en/zh/zh-Hant via i18n.t. title, template = { "running": (i18n.t("处理中", "Working"), "blue"), "done": (i18n.t("完成", "Done"), "green"), "stopped": (i18n.t("已停止", "Stopped"), "grey"), "error": (i18n.t("出错", "Error"), "red"), }.get(self.status, (i18n.t("处理中", "Working"), "blue")) main_text = self.current_text or "..." elements: List[Dict[str, Any]] = [] # Only render the Reasoning panel when there is real reasoning content. # Upstream emits reasoning_update only when deep thinking is enabled, so # an empty reasoning_steps means we should show no panel at all. if self.reasoning_steps: elements.append( _panel( "🤔 {}".format(i18n.t("思考", "Thinking")), [_text_row(step, muted=True) for step in self.reasoning_steps[-_MAX_PANEL_STEPS:]], expanded=streaming, ) ) if self.tool_steps: elements.append( _panel( "🔧 {} ({})".format(i18n.t("工具", "Tools"), len(self.tool_steps)), [ _text_row(_format_tool_step(step)) for step in self.tool_steps[-_MAX_PANEL_STEPS:] ], expanded=streaming, ) ) elements.append( { "tag": "markdown", "element_id": "stream_md", "content": main_text, } ) elapsed = max(0.0, (time.monotonic() if now is None else now) - self.started_at) turn_label = i18n.t("轮", "turn" if self.turns == 1 else "turns") elements.extend( [ {"tag": "hr"}, { "tag": "markdown", "content": "{:.1f}s · {} {}".format(elapsed, self.turns, turn_label), "text_size": "notation", }, ] ) config: Dict[str, Any] = { "streaming_mode": streaming, "update_multi": True, "enable_forward_interaction": True, "summary": {"content": _summary(main_text, title)}, } if streaming: config["streaming_config"] = { "print_frequency_ms": {"default": 40}, "print_step": {"default": 4}, "print_strategy": "fast", } card: Dict[str, Any] = { "schema": "2.0", "config": config, "body": {"elements": elements}, } # Hide the status header once the run has finished successfully; a plain # answer needs no "Done" banner. Keep the header for running/stopped/error # so users still get progress and failure signals. if self.status != "done": card["header"] = { "template": template, "title": {"tag": "plain_text", "content": title}, } return card def _commit_reasoning(self) -> None: reasoning = self._reasoning_buffer.strip() if reasoning: self.reasoning_steps.append(reasoning[-_MAX_STEP_CHARS:]) self._reasoning_buffer = "" def _mark_running_tools_done(self) -> None: for step in self.tool_steps: if step["status"] == "running": step["status"] = "done" if step.get("elapsed") is None and step.get("started_at") is not None: step["elapsed"] = time.monotonic() - step["started_at"] def _tool_status_label(status: str) -> str: if status == "running": return i18n.t("执行中", "running") if status == "error": return i18n.t("失败", "error") return i18n.t("完成", "done") def _format_tool_step(step: Dict[str, Any]) -> str: # Tool name plus its own status and elapsed time, e.g. "search · done · 1.2s". parts = [str(step.get("summary") or "tool"), _tool_status_label(step["status"])] elapsed = step.get("elapsed") if isinstance(elapsed, (int, float)): parts.append("{:.1f}s".format(max(0.0, float(elapsed)))) return " · ".join(parts) def _panel(title: str, elements: List[Dict[str, Any]], expanded: bool) -> Dict[str, Any]: return { "tag": "collapsible_panel", "expanded": expanded, "background_color": "grey", # Panel title uses markdown so we can shrink the font via text_size # (plain_text titles ignore text_size and break card rendering). "header": {"title": {"tag": "markdown", "content": title, "text_size": "notation"}}, "border": {"color": "grey"}, "vertical_spacing": "8px", "padding": "4px 8px", "elements": elements, } def _text_row(content: str, muted: bool = False) -> Dict[str, Any]: text = { "tag": "plain_text", "content": content, "text_size": "notation", } if muted: text["text_color"] = "grey" return {"tag": "div", "text": text} def _summary(text: str, fallback: str) -> str: preview = " ".join(text.strip().split()) return preview[:60] or fallback