mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-18 20:17:09 +08:00
205 lines
6.6 KiB
Python
205 lines
6.6 KiB
Python
"""State and Card 2.0 rendering for a Feishu agent run."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
_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, str]] = []
|
|
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()
|
|
for tool_call in data.get("tool_calls") or []:
|
|
self.tool_steps.append(
|
|
{
|
|
"summary": _format_tool_call(tool_call),
|
|
"status": "running",
|
|
}
|
|
)
|
|
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."""
|
|
title, template = {
|
|
"running": ("Working", "blue"),
|
|
"done": ("Done", "green"),
|
|
"stopped": ("Stopped", "grey"),
|
|
"error": ("Error", "red"),
|
|
}.get(self.status, ("Working", "blue"))
|
|
|
|
main_text = self.current_text or "..."
|
|
elements: List[Dict[str, Any]] = []
|
|
|
|
if self.reasoning_steps:
|
|
elements.append(
|
|
_panel(
|
|
"Reasoning ({})".format(len(self.reasoning_steps)),
|
|
[_text_row(step, muted=True) for step in self.reasoning_steps[-_MAX_PANEL_STEPS:]],
|
|
expanded=streaming,
|
|
)
|
|
)
|
|
elif streaming:
|
|
elements.append(
|
|
_panel("Reasoning", [_text_row("Thinking...", muted=True)], expanded=True)
|
|
)
|
|
|
|
if self.tool_steps:
|
|
elements.append(
|
|
_panel(
|
|
"Tools ({})".format(len(self.tool_steps)),
|
|
[
|
|
_text_row("{} · {}".format(step["summary"], step["status"]))
|
|
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 = "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",
|
|
}
|
|
|
|
return {
|
|
"schema": "2.0",
|
|
"config": config,
|
|
"header": {
|
|
"template": template,
|
|
"title": {"tag": "plain_text", "content": title},
|
|
},
|
|
"body": {"elements": elements},
|
|
}
|
|
|
|
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"
|
|
|
|
|
|
def _format_tool_call(tool_call: Dict[str, Any]) -> str:
|
|
# Tool arguments can contain user data or credentials. The progress card
|
|
# only needs an activity label, so never echo raw arguments into chat.
|
|
return str(tool_call.get("name") or "tool")
|
|
|
|
|
|
def _panel(title: str, elements: List[Dict[str, Any]], expanded: bool) -> Dict[str, Any]:
|
|
return {
|
|
"tag": "collapsible_panel",
|
|
"expanded": expanded,
|
|
"background_color": "grey",
|
|
"header": {"title": {"tag": "plain_text", "content": title}},
|
|
"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
|