mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-20 05:27:59 +08:00
Merge pull request #2963 from AaronZ345/agent/feishu-rich-progress-card
feat(feishu): add rich single-card progress
This commit is contained in:
@@ -29,6 +29,7 @@ from bridge.reply import Reply, ReplyType
|
|||||||
from channel.chat_channel import ChatChannel, check_prefix
|
from channel.chat_channel import ChatChannel, check_prefix
|
||||||
from channel.feishu.feishu_message import FeishuMessage
|
from channel.feishu.feishu_message import FeishuMessage
|
||||||
from channel.feishu.feishu_static_card import build_text_delivery
|
from channel.feishu.feishu_static_card import build_text_delivery
|
||||||
|
from channel.feishu.feishu_progress_card import FeishuProgressState
|
||||||
from common import utils
|
from common import utils
|
||||||
from common.expired_dict import ExpiredDict
|
from common.expired_dict import ExpiredDict
|
||||||
from common.log import logger
|
from common.log import logger
|
||||||
@@ -762,6 +763,340 @@ class FeiShuChanel(ChatChannel):
|
|||||||
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
|
logger.error(f"[FeiShu] send message failed, code={res.get('code')}, msg={res.get('msg')}")
|
||||||
|
|
||||||
def _make_feishu_stream_callback(self, context, access_token):
|
def _make_feishu_stream_callback(self, context, access_token):
|
||||||
|
"""Route to progress or plain streaming callback based on config.
|
||||||
|
|
||||||
|
feishu_progress_card 默认关闭:普通对话走原有的打字机文本卡片
|
||||||
|
(_make_feishu_stream_callback_plain)。开启后才使用带状态头、
|
||||||
|
Reasoning/Tools 面板与耗时的富进度卡片。
|
||||||
|
"""
|
||||||
|
if conf().get("feishu_progress_card", False):
|
||||||
|
return self._make_feishu_stream_callback_progress(context, access_token)
|
||||||
|
return self._make_feishu_stream_callback_plain(context, access_token)
|
||||||
|
|
||||||
|
def _make_feishu_stream_callback_progress(self, context, access_token):
|
||||||
|
"""
|
||||||
|
基于飞书官方"流式更新卡片"API 实现打字机回复。
|
||||||
|
|
||||||
|
流程:
|
||||||
|
1. agent_start → POST /cardkit/v1/cards 创建带 streaming_mode 的状态卡片,
|
||||||
|
随后用 POST /im/v1/messages(或 reply)以 card_id 把卡片发出去
|
||||||
|
2. 后续 message_update → PUT /cardkit/v1/cards/{id}/elements/{eid}/content
|
||||||
|
传入"当前轮"的全量文本,飞书平台自动计算增量并以打字机效果上屏
|
||||||
|
(流式模式下不受 10 QPS 限制)
|
||||||
|
3. message_end(本轮触发工具调用)→ 原地刷新 Reasoning / Tools 面板,
|
||||||
|
后续 turn 继续复用同一张卡片
|
||||||
|
4. agent_end → 用 final_response、最终状态和耗时整卡更新,关闭 streaming_mode,
|
||||||
|
标记 context["feishu_streamed"]=True 让 chat_channel 跳过普通 send()
|
||||||
|
|
||||||
|
前提条件:
|
||||||
|
- 机器人已开通 cardkit:card:write 权限
|
||||||
|
- 飞书客户端 7.20+
|
||||||
|
|
||||||
|
失败降级:
|
||||||
|
- 创建卡片实体失败(缺权限、网络等)→ 不设置 feishu_streamed 标记,让 chat_channel
|
||||||
|
走普通文本回复路径,用户收到完整回复但无打字机效果,并打 warning 日志
|
||||||
|
"""
|
||||||
|
# 共享状态(受 lock 保护)。一个 agent run 始终复用同一张卡片;
|
||||||
|
# reasoning、tools、最终正文和状态头均由 progress_state 统一渲染。
|
||||||
|
progress_state = FeishuProgressState()
|
||||||
|
card_id = [None]
|
||||||
|
message_id = [None]
|
||||||
|
# 占位发送是同步进行的,但用一个 in-flight 标记防止并发的多条 message_update
|
||||||
|
# 事件各自触发一次创建+发送,导致发出多张卡片。
|
||||||
|
init_in_flight = [False]
|
||||||
|
# 一旦初始化失败就长期标记为 disabled,本次回复不再尝试任何流式调用
|
||||||
|
disabled = [False]
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
# ---- 异步推送队列 ----------------------------------------------------
|
||||||
|
# 同步 requests.put 单次 100~300ms,会阻塞 LLM stream 线程读下一个 chunk。
|
||||||
|
# 把推送丢给独立 worker 线程消费 queue,回调本身只做内存追加,立即返回。
|
||||||
|
# 队列里只放"最新累积文本"的快照;worker 用 deduplication 避免重复推同一个
|
||||||
|
# 内容(高频 chunk 场景下队列会堆积,只推最后一个就够了)。
|
||||||
|
import queue as _queue
|
||||||
|
push_queue: "_queue.Queue[str | None]" = _queue.Queue()
|
||||||
|
|
||||||
|
def _push_worker():
|
||||||
|
while True:
|
||||||
|
snapshot = push_queue.get()
|
||||||
|
if snapshot is None:
|
||||||
|
push_queue.task_done()
|
||||||
|
return
|
||||||
|
# 合并队列中已堆积的快照:只推最后一个,省 PUT 次数同时降低延迟
|
||||||
|
merged_count = 1
|
||||||
|
stop = False
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
nxt = push_queue.get_nowait()
|
||||||
|
except _queue.Empty:
|
||||||
|
break
|
||||||
|
merged_count += 1
|
||||||
|
if nxt is None:
|
||||||
|
stop = True
|
||||||
|
break
|
||||||
|
snapshot = nxt
|
||||||
|
try:
|
||||||
|
_stream_update_text(snapshot)
|
||||||
|
finally:
|
||||||
|
for _ in range(merged_count):
|
||||||
|
push_queue.task_done()
|
||||||
|
if stop:
|
||||||
|
return
|
||||||
|
|
||||||
|
push_thread = threading.Thread(target=_push_worker, daemon=True, name="feishu-stream-push")
|
||||||
|
push_thread.start()
|
||||||
|
|
||||||
|
def _drain_push_queue():
|
||||||
|
"""等当前队列里所有 PUT 都完成。message_end/agent_end 在做最终定型前必须 drain,
|
||||||
|
否则 worker 里堆积的旧快照可能在 final_text PUT 之后到达,把最终内容覆盖掉。"""
|
||||||
|
try:
|
||||||
|
push_queue.join()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
msg = context.get("msg")
|
||||||
|
is_group = context.get("isgroup", False)
|
||||||
|
receiver = context.get("receiver")
|
||||||
|
receive_id_type = context.get("receive_id_type", "open_id")
|
||||||
|
headers = {
|
||||||
|
"Authorization": "Bearer " + access_token,
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
}
|
||||||
|
# 卡片中富文本组件的 element_id,后续所有 PUT 流式更新都打到这个组件
|
||||||
|
ELEMENT_ID = "stream_md"
|
||||||
|
# 操作序号,每次 PUT 必须严格递增(飞书要求)
|
||||||
|
sequence = [0]
|
||||||
|
|
||||||
|
def _next_sequence():
|
||||||
|
with lock:
|
||||||
|
sequence[0] += 1
|
||||||
|
return sequence[0]
|
||||||
|
|
||||||
|
def _build_card_json():
|
||||||
|
"""Build the initial streaming Card 2.0 payload."""
|
||||||
|
with lock:
|
||||||
|
card = progress_state.build_card(streaming=True)
|
||||||
|
return json.dumps(card, ensure_ascii=False)
|
||||||
|
|
||||||
|
def _create_and_send_card():
|
||||||
|
"""同步执行:创建卡片实体 → 发送消息。任意一步失败则 disabled=True 触发降级"""
|
||||||
|
try:
|
||||||
|
# 步骤 1: 创建卡片实体
|
||||||
|
create_url = "https://open.feishu.cn/open-apis/cardkit/v1/cards"
|
||||||
|
create_body = {"type": "card_json", "data": _build_card_json()}
|
||||||
|
res = requests.post(
|
||||||
|
create_url, headers=headers, json=create_body, timeout=(5, 10)
|
||||||
|
)
|
||||||
|
res_json = res.json()
|
||||||
|
if res_json.get("code") != 0:
|
||||||
|
logger.warning(
|
||||||
|
f"[FeiShu] Stream: create card failed "
|
||||||
|
f"(code={res_json.get('code')}, msg={res_json.get('msg')}). "
|
||||||
|
f"本次回复已自动降级为普通文本回复(一次性返回完整内容)。"
|
||||||
|
f"如需开启流式打字机效果与完整 Markdown 渲染,请到飞书开放平台 "
|
||||||
|
f"https://open.feishu.cn/app 给机器人开通 cardkit:card:write 权限"
|
||||||
|
f"(创建与更新卡片)并重新发布版本,同时确保飞书客户端 >= 7.20。"
|
||||||
|
)
|
||||||
|
with lock:
|
||||||
|
disabled[0] = True
|
||||||
|
return
|
||||||
|
cid = res_json["data"]["card_id"]
|
||||||
|
with lock:
|
||||||
|
card_id[0] = cid
|
||||||
|
|
||||||
|
# 步骤 2: 通过 card_id 发送消息(群聊优先用 reply,单聊直接 send)
|
||||||
|
content_payload = json.dumps(
|
||||||
|
{"type": "card", "data": {"card_id": cid}}, ensure_ascii=False
|
||||||
|
)
|
||||||
|
can_reply = is_group and msg and hasattr(msg, "msg_id") and msg.msg_id
|
||||||
|
if can_reply:
|
||||||
|
send_url = (
|
||||||
|
f"https://open.feishu.cn/open-apis/im/v1/messages/"
|
||||||
|
f"{msg.msg_id}/reply"
|
||||||
|
)
|
||||||
|
send_body = {"msg_type": "interactive", "content": content_payload}
|
||||||
|
send_res = requests.post(
|
||||||
|
send_url, headers=headers, json=send_body, timeout=(5, 10)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
send_url = "https://open.feishu.cn/open-apis/im/v1/messages"
|
||||||
|
params = {"receive_id_type": receive_id_type}
|
||||||
|
send_body = {
|
||||||
|
"receive_id": receiver,
|
||||||
|
"msg_type": "interactive",
|
||||||
|
"content": content_payload,
|
||||||
|
}
|
||||||
|
send_res = requests.post(
|
||||||
|
send_url, headers=headers, params=params, json=send_body,
|
||||||
|
timeout=(5, 10),
|
||||||
|
)
|
||||||
|
send_json = send_res.json()
|
||||||
|
if send_json.get("code") != 0:
|
||||||
|
logger.warning(
|
||||||
|
f"[FeiShu] Stream: send card failed: {send_json}. 降级为普通文本。"
|
||||||
|
)
|
||||||
|
with lock:
|
||||||
|
disabled[0] = True
|
||||||
|
return
|
||||||
|
mid = send_json["data"]["message_id"]
|
||||||
|
with lock:
|
||||||
|
message_id[0] = mid
|
||||||
|
logger.info(
|
||||||
|
f"[FeiShu] Stream: card created and sent, "
|
||||||
|
f"card_id={cid}, message_id={mid}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[FeiShu] Stream: create/send card exception: {e}. 降级为普通文本。"
|
||||||
|
)
|
||||||
|
with lock:
|
||||||
|
disabled[0] = True
|
||||||
|
finally:
|
||||||
|
with lock:
|
||||||
|
init_in_flight[0] = False
|
||||||
|
|
||||||
|
def _stream_update_text(full_text):
|
||||||
|
"""PUT 流式更新文本组件。content 必须是当前组件的全量文本。"""
|
||||||
|
with lock:
|
||||||
|
cid = card_id[0]
|
||||||
|
if not cid:
|
||||||
|
return
|
||||||
|
url = (
|
||||||
|
f"https://open.feishu.cn/open-apis/cardkit/v1/cards/"
|
||||||
|
f"{cid}/elements/{ELEMENT_ID}/content"
|
||||||
|
)
|
||||||
|
body = {
|
||||||
|
"content": full_text,
|
||||||
|
"sequence": _next_sequence(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
res = requests.put(url, headers=headers, json=body, timeout=(5, 10))
|
||||||
|
res_json = res.json()
|
||||||
|
if res_json.get("code") != 0:
|
||||||
|
logger.warning(
|
||||||
|
f"[FeiShu] Stream: update text failed: {res_json}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[FeiShu] Stream: update text exception: {e}")
|
||||||
|
|
||||||
|
def _update_full_card(streaming: bool):
|
||||||
|
"""Refresh panels, header, footer and streaming state in one update."""
|
||||||
|
with lock:
|
||||||
|
cid = card_id[0]
|
||||||
|
full_card = progress_state.build_card(streaming=streaming)
|
||||||
|
if not cid:
|
||||||
|
return
|
||||||
|
put_url = f"https://open.feishu.cn/open-apis/cardkit/v1/cards/{cid}"
|
||||||
|
put_body = {
|
||||||
|
"card": {"type": "card_json", "data": json.dumps(full_card, ensure_ascii=False)},
|
||||||
|
"sequence": _next_sequence(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
res = requests.put(put_url, headers=headers, json=put_body, timeout=(5, 10))
|
||||||
|
res_json = res.json()
|
||||||
|
if res_json.get("code") != 0:
|
||||||
|
logger.warning(
|
||||||
|
f"[FeiShu] Stream: full card update failed: {res_json}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[FeiShu] Stream: full card update exception: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_event(event: dict):
|
||||||
|
event_type = event.get("type")
|
||||||
|
data = event.get("data", {})
|
||||||
|
|
||||||
|
# 一旦降级,本次回复不再做任何流式操作
|
||||||
|
with lock:
|
||||||
|
if disabled[0]:
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type == "agent_start":
|
||||||
|
with lock:
|
||||||
|
progress_state.consume(event)
|
||||||
|
if card_id[0] is None and not init_in_flight[0]:
|
||||||
|
init_in_flight[0] = True
|
||||||
|
_create_and_send_card()
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type in ("turn_start", "reasoning_update"):
|
||||||
|
with lock:
|
||||||
|
progress_state.consume(event)
|
||||||
|
return
|
||||||
|
|
||||||
|
if event_type == "message_update":
|
||||||
|
delta = data.get("delta", "")
|
||||||
|
if not delta:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 第一段:判断是否需要初始化(创建卡片 + 发送)
|
||||||
|
need_init = False
|
||||||
|
with lock:
|
||||||
|
if card_id[0] is None and not init_in_flight[0]:
|
||||||
|
init_in_flight[0] = True
|
||||||
|
need_init = True
|
||||||
|
|
||||||
|
if need_init:
|
||||||
|
_create_and_send_card()
|
||||||
|
# 初始化失败已标记 disabled,下次循环直接 return
|
||||||
|
with lock:
|
||||||
|
if disabled[0]:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 第二段:累加文本,把快照丢给 push worker 异步推送。
|
||||||
|
# 这里不能直接 requests.put,否则会阻塞 LLM stream 线程读下一个 chunk
|
||||||
|
# (实测 DeepSeek 高频小 chunk 场景每个 PUT ~150ms,累积起来非常卡)。
|
||||||
|
snapshot = ""
|
||||||
|
should_push = False
|
||||||
|
with lock:
|
||||||
|
progress_state.consume(event)
|
||||||
|
if card_id[0]:
|
||||||
|
snapshot = progress_state.current_text
|
||||||
|
should_push = True
|
||||||
|
|
||||||
|
if should_push:
|
||||||
|
push_queue.put(snapshot)
|
||||||
|
|
||||||
|
elif event_type == "message_end":
|
||||||
|
# 工具轮结束后原地刷新 Reasoning / Tools 面板,保留同一张卡片。
|
||||||
|
tool_calls = data.get("tool_calls", []) or []
|
||||||
|
with lock:
|
||||||
|
progress_state.consume(event)
|
||||||
|
if not tool_calls:
|
||||||
|
return
|
||||||
|
_drain_push_queue()
|
||||||
|
_update_full_card(streaming=True)
|
||||||
|
|
||||||
|
elif event_type == "agent_cancelled":
|
||||||
|
with lock:
|
||||||
|
progress_state.consume(event)
|
||||||
|
|
||||||
|
elif event_type == "agent_end":
|
||||||
|
# Finalize the same card with a status header and elapsed footer.
|
||||||
|
with lock:
|
||||||
|
progress_state.consume(event)
|
||||||
|
final_text = progress_state.current_text
|
||||||
|
has_card = card_id[0] is not None
|
||||||
|
init_busy = init_in_flight[0]
|
||||||
|
context["feishu_streamed"] = True
|
||||||
|
|
||||||
|
if not has_card and not init_busy:
|
||||||
|
with lock:
|
||||||
|
init_in_flight[0] = True
|
||||||
|
_create_and_send_card()
|
||||||
|
with lock:
|
||||||
|
if disabled[0]:
|
||||||
|
return
|
||||||
|
|
||||||
|
_drain_push_queue()
|
||||||
|
_stream_update_text(final_text)
|
||||||
|
_update_full_card(streaming=False)
|
||||||
|
push_queue.put(None)
|
||||||
|
|
||||||
|
return on_event
|
||||||
|
|
||||||
|
def _make_feishu_stream_callback_plain(self, context, access_token):
|
||||||
"""
|
"""
|
||||||
基于飞书官方"流式更新卡片"API 实现打字机回复。
|
基于飞书官方"流式更新卡片"API 实现打字机回复。
|
||||||
|
|
||||||
|
|||||||
204
channel/feishu/feishu_progress_card.py
Normal file
204
channel/feishu/feishu_progress_card.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""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
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"feishu_app_secret": "",
|
"feishu_app_secret": "",
|
||||||
"feishu_stream_reply": true,
|
"feishu_stream_reply": true,
|
||||||
"feishu_markdown_card": true,
|
"feishu_markdown_card": true,
|
||||||
|
"feishu_progress_card": false,
|
||||||
"dingtalk_client_id": "",
|
"dingtalk_client_id": "",
|
||||||
"dingtalk_client_secret": "",
|
"dingtalk_client_secret": "",
|
||||||
"wecom_bot_id": "",
|
"wecom_bot_id": "",
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ available_setting = {
|
|||||||
# Feishu streaming reply (based on the official cardkit streaming-card API; requires the cardkit:card:write permission and Feishu client 7.20+)
|
# Feishu streaming reply (based on the official cardkit streaming-card API; requires the cardkit:card:write permission and Feishu client 7.20+)
|
||||||
"feishu_stream_reply": True, # whether to enable streaming reply (typewriter effect); auto-downgrades to non-streaming or shows an upgrade prompt on failure/old clients
|
"feishu_stream_reply": True, # whether to enable streaming reply (typewriter effect); auto-downgrades to non-streaming or shows an upgrade prompt on failure/old clients
|
||||||
"feishu_markdown_card": True, # render non-streaming Markdown replies (including scheduled messages) as Card 2.0; falls back to text on failure
|
"feishu_markdown_card": True, # render non-streaming Markdown replies (including scheduled messages) as Card 2.0; falls back to text on failure
|
||||||
|
"feishu_progress_card": False, # render normal chat streaming as a rich progress card (status header, reasoning/tool panels, elapsed time); off by default keeps the plain typewriter card
|
||||||
# DingTalk config
|
# DingTalk config
|
||||||
"dingtalk_client_id": "", # DingTalk bot Client ID
|
"dingtalk_client_id": "", # DingTalk bot Client ID
|
||||||
"dingtalk_client_secret": "", # DingTalk bot Client Secret
|
"dingtalk_client_secret": "", # DingTalk bot Client Secret
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| `feishu_app_secret` | Feishu app App Secret | - |
|
| `feishu_app_secret` | Feishu app App Secret | - |
|
||||||
| `feishu_stream_reply` | Enable streaming typewriter reply | `true` |
|
| `feishu_stream_reply` | Enable streaming typewriter reply | `true` |
|
||||||
| `feishu_markdown_card` | Render non-streaming Markdown and scheduled text as Card 2.0 | `true` |
|
| `feishu_markdown_card` | Render non-streaming Markdown and scheduled text as Card 2.0 | `true` |
|
||||||
|
| `feishu_progress_card` | Use a rich progress card (status header, reasoning/tool panels, elapsed time) for normal chat streaming; off keeps the plain typewriter card | `false` |
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -101,6 +102,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| Voice messages | ✅ send/receive |
|
| Voice messages | ✅ send/receive |
|
||||||
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
|
| Streaming reply | ✅ (powered by Feishu cardkit streaming card) |
|
||||||
| Markdown card | ✅ for non-streaming and scheduled replies |
|
| Markdown card | ✅ for non-streaming and scheduled replies |
|
||||||
|
| Rich progress card | ✅ status header, reasoning/tool panels and elapsed time (enable with `feishu_progress_card: true`, off by default) |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
Streaming reply requires the `cardkit:card:write` permission (already enabled by one-click creation) and Feishu client version ≥ 7.20. Older clients see an upgrade prompt; if the permission or version is not satisfied, replies fall back to plain text automatically.
|
Streaming reply requires the `cardkit:card:write` permission (already enabled by one-click creation) and Feishu client version ≥ 7.20. Older clients see an upgrade prompt; if the permission or version is not satisfied, replies fall back to plain text automatically.
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| `feishu_app_secret` | Feishu アプリの App Secret | - |
|
| `feishu_app_secret` | Feishu アプリの App Secret | - |
|
||||||
| `feishu_stream_reply` | ストリーミングタイプライター応答を有効化 | `true` |
|
| `feishu_stream_reply` | ストリーミングタイプライター応答を有効化 | `true` |
|
||||||
| `feishu_markdown_card` | 非ストリーミング Markdown と定期配信を Card 2.0 で表示 | `true` |
|
| `feishu_markdown_card` | 非ストリーミング Markdown と定期配信を Card 2.0 で表示 | `true` |
|
||||||
|
| `feishu_progress_card` | 通常会話のストリーミングをリッチ進捗カード(ステータス、推論/ツールパネル、経過時間)で表示。無効時は通常のタイプライターカード | `false` |
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -98,6 +99,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| 音声メッセージ | ✅ 送受信 |
|
| 音声メッセージ | ✅ 送受信 |
|
||||||
| ストリーミング応答 | ✅(Feishu cardkit ストリーミングカードベース) |
|
| ストリーミング応答 | ✅(Feishu cardkit ストリーミングカードベース) |
|
||||||
| Markdown カード | ✅ 非ストリーミング応答と定期配信 |
|
| Markdown カード | ✅ 非ストリーミング応答と定期配信 |
|
||||||
|
| リッチ進捗カード | ✅ ステータス、推論/ツールパネル、経過時間(`feishu_progress_card: true` で有効化、デフォルト無効) |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
ストリーミング応答には `cardkit:card:write` 権限(ワンクリック作成では自動付与)と Feishu クライアント 7.20 以上が必要です。古いクライアントではアップグレード案内が表示され、権限/バージョン未充足時は通常テキスト応答に自動フォールバックします。
|
ストリーミング応答には `cardkit:card:write` 権限(ワンクリック作成では自動付与)と Feishu クライアント 7.20 以上が必要です。古いクライアントではアップグレード案内が表示され、権限/バージョン未充足時は通常テキスト応答に自動フォールバックします。
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| `feishu_app_secret` | 飞书应用 App Secret | - |
|
| `feishu_app_secret` | 飞书应用 App Secret | - |
|
||||||
| `feishu_stream_reply` | 是否开启流式打字机回复 | `true` |
|
| `feishu_stream_reply` | 是否开启流式打字机回复 | `true` |
|
||||||
| `feishu_markdown_card` | 将非流式 Markdown 与定时推送渲染为 Card 2.0 | `true` |
|
| `feishu_markdown_card` | 将非流式 Markdown 与定时推送渲染为 Card 2.0 | `true` |
|
||||||
|
| `feishu_progress_card` | 普通对话流式回复是否使用富进度卡片(状态头、思考/工具面板、耗时);关闭时用普通打字机卡片 | `false` |
|
||||||
</Tab>
|
</Tab>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ im:message,im:message.group_at_msg,im:message.group_at_msg:readonly,im:message.p
|
|||||||
| 语音消息 | ✅ 收发 |
|
| 语音消息 | ✅ 收发 |
|
||||||
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
|
| 流式回复 | ✅(通过 `feishu_stream_reply` 配置控制,默认开启) |
|
||||||
| Markdown 卡片 | ✅ 非流式回复与定时推送 |
|
| Markdown 卡片 | ✅ 非流式回复与定时推送 |
|
||||||
|
| 富进度卡片 | ✅ 状态头、思考/工具面板与耗时(需配置 `feishu_progress_card: true` 开启,默认关闭) |
|
||||||
|
|
||||||
<Note>
|
<Note>
|
||||||
流式回复需要机器人具备 `cardkit:card:write` 权限(一键创建已默认开通),且接收方飞书客户端版本 ≥ 7.20。低版本客户端会显示升级提示,权限或版本不满足时自动降级为普通文本回复。
|
流式回复需要机器人具备 `cardkit:card:write` 权限(一键创建已默认开通),且接收方飞书客户端版本 ≥ 7.20。低版本客户端会显示升级提示,权限或版本不满足时自动降级为普通文本回复。
|
||||||
|
|||||||
103
tests/test_feishu_progress_card.py
Normal file
103
tests/test_feishu_progress_card.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from channel.feishu.feishu_progress_card import FeishuProgressState
|
||||||
|
|
||||||
|
|
||||||
|
def _panels(card):
|
||||||
|
return [
|
||||||
|
element
|
||||||
|
for element in card["body"]["elements"]
|
||||||
|
if element.get("tag") == "collapsible_panel"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_running_card_has_status_reasoning_lane_and_stream_target():
|
||||||
|
state = FeishuProgressState(started_at=100.0)
|
||||||
|
|
||||||
|
card = state.build_card(streaming=True, now=102.0)
|
||||||
|
|
||||||
|
assert card["header"]["template"] == "blue"
|
||||||
|
assert card["header"]["title"]["content"] == "Working"
|
||||||
|
assert card["config"]["streaming_mode"] is True
|
||||||
|
assert _panels(card)[0]["header"]["title"]["content"] == "Reasoning"
|
||||||
|
stream = next(
|
||||||
|
element
|
||||||
|
for element in card["body"]["elements"]
|
||||||
|
if element.get("element_id") == "stream_md"
|
||||||
|
)
|
||||||
|
assert stream["content"] == "..."
|
||||||
|
|
||||||
|
|
||||||
|
def test_tool_turn_populates_reasoning_and_tools_lanes():
|
||||||
|
state = FeishuProgressState(started_at=100.0)
|
||||||
|
state.consume({"type": "turn_start", "data": {"turn": 1}})
|
||||||
|
state.consume({"type": "reasoning_update", "data": {"delta": "Need the latest files."}})
|
||||||
|
state.consume({"type": "message_update", "data": {"delta": "Checking now."}})
|
||||||
|
state.consume(
|
||||||
|
{
|
||||||
|
"type": "message_end",
|
||||||
|
"data": {
|
||||||
|
"tool_calls": [
|
||||||
|
{"name": "read_file", "arguments": {"path": "README.md"}}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
card = state.build_card(streaming=True, now=103.0)
|
||||||
|
panels = _panels(card)
|
||||||
|
assert [panel["header"]["title"]["content"] for panel in panels] == [
|
||||||
|
"Reasoning (1)",
|
||||||
|
"Tools (1)",
|
||||||
|
]
|
||||||
|
assert panels[0]["elements"][0]["text"]["content"] == "Need the latest files."
|
||||||
|
assert "read_file" in panels[1]["elements"][0]["text"]["content"]
|
||||||
|
assert "running" in panels[1]["elements"][0]["text"]["content"]
|
||||||
|
|
||||||
|
state.consume({"type": "turn_start", "data": {"turn": 2}})
|
||||||
|
done_card = state.build_card(streaming=True, now=104.0)
|
||||||
|
assert "done" in _panels(done_card)[1]["elements"][0]["text"]["content"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_end_finalizes_status_body_and_footer():
|
||||||
|
state = FeishuProgressState(started_at=100.0)
|
||||||
|
state.consume({"type": "turn_start", "data": {"turn": 1}})
|
||||||
|
state.consume(
|
||||||
|
{
|
||||||
|
"type": "agent_end",
|
||||||
|
"data": {"final_response": "**Finished**", "cancelled": False},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
card = state.build_card(streaming=False, now=105.2)
|
||||||
|
|
||||||
|
assert card["header"]["template"] == "green"
|
||||||
|
assert card["header"]["title"]["content"] == "Done"
|
||||||
|
assert card["config"]["streaming_mode"] is False
|
||||||
|
assert next(
|
||||||
|
element["content"]
|
||||||
|
for element in card["body"]["elements"]
|
||||||
|
if element.get("element_id") == "stream_md"
|
||||||
|
) == "**Finished**"
|
||||||
|
footer = card["body"]["elements"][-1]
|
||||||
|
assert footer["text_size"] == "notation"
|
||||||
|
assert footer["content"] == "5.2s · 1 turn"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancelled_agent_uses_partial_output_and_stopped_status():
|
||||||
|
state = FeishuProgressState(started_at=100.0)
|
||||||
|
state.consume({"type": "message_update", "data": {"delta": "partial"}})
|
||||||
|
state.consume({"type": "agent_cancelled", "data": {}})
|
||||||
|
state.consume(
|
||||||
|
{
|
||||||
|
"type": "agent_end",
|
||||||
|
"data": {"final_response": "stale response", "cancelled": True},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
card = state.build_card(streaming=False, now=101.0)
|
||||||
|
|
||||||
|
assert card["header"]["title"]["content"] == "Stopped"
|
||||||
|
assert next(
|
||||||
|
element["content"]
|
||||||
|
for element in card["body"]["elements"]
|
||||||
|
if element.get("element_id") == "stream_md"
|
||||||
|
) == "partial"
|
||||||
Reference in New Issue
Block a user