feat(fsapp): render agent run as one continuously-updating Feishu card (#131)

Rework the Feishu frontend so each user turn renders as a single
collapsible task card that patches itself in place, replacing the
dq-based streaming path that produced many fragmented messages.

- One _TaskCard per turn; hook reacts to summary / exit_reason events
  from the agent loop and patches the same card.
- Each step is a foldable panel: header shows the summary, expanding
  reveals three sections (auto-hidden when empty):
    * Thinking   - from response.thinking (separate field, not content)
    * Tool Calls - tool name + truncated JSON args
    * Output     - response.content, with protocol tags stripped so
                   the header summary is not duplicated inside
- Final reply rendered as a schema 2.0 markdown card for consistency.
- Code-review pass per code_review_principles.md:
    * _TaskCard owns only stateful card lifecycle (start/step/done/fail)
    * Pure formatting extracted to module-level _build_step_detail and
      _fmt_tool_call (no more reaching into card._private from the hook)
    * Hook is a ~10-line dispatcher
    * Flattened a 4-level nested lambda into a named function
This commit is contained in:
weijia
2026-04-22 14:12:51 +08:00
committed by GitHub
parent 132edb3154
commit b34cffe801

View File

@@ -8,6 +8,7 @@ from frontends.chatapp_common import format_restore
from frontends.continue_cmd import handle_frontend_command as handle_continue_frontend, reset_conversation from frontends.continue_cmd import handle_frontend_command as handle_continue_frontend, reset_conversation
from llmcore import mykeys from llmcore import mykeys
import traceback
import lark_oapi as lark import lark_oapi as lark
from lark_oapi.api.im.v1 import * from lark_oapi.api.im.v1 import *
@@ -225,6 +226,7 @@ APP_ID = str(mykeys.get("fs_app_id", "") or "").strip()
APP_SECRET = str(mykeys.get("fs_app_secret", "") or "").strip() APP_SECRET = str(mykeys.get("fs_app_secret", "") or "").strip()
ALLOWED_USERS = _to_allowed_set(mykeys.get("fs_allowed_users", [])) ALLOWED_USERS = _to_allowed_set(mykeys.get("fs_allowed_users", []))
PUBLIC_ACCESS = not ALLOWED_USERS or "*" in ALLOWED_USERS PUBLIC_ACCESS = not ALLOWED_USERS or "*" in ALLOWED_USERS
AGENT_TIMEOUT_SEC = 900
agent = GeneraticAgent() agent = GeneraticAgent()
threading.Thread(target=agent.run, daemon=True).start() threading.Thread(target=agent.run, daemon=True).start()
@@ -235,35 +237,49 @@ def create_client():
return lark.Client.builder().app_id(APP_ID).app_secret(APP_SECRET).log_level(lark.LogLevel.INFO).build() return lark.Client.builder().app_id(APP_ID).app_secret(APP_SECRET).log_level(lark.LogLevel.INFO).build()
def _card_raw(elements):
return json.dumps({
"schema": "2.0",
"config": {"streaming_mode": False, "width_mode": "fill"},
"body": {"elements": elements},
}, ensure_ascii=False)
def _card(text): def _card(text):
return json.dumps({"config": {"wide_screen_mode": True}, "elements": [{"tag": "markdown", "content": text}]}, ensure_ascii=False) return _card_raw([{"tag": "markdown", "content": text}])
def _send_raw(receive_id, payload, msg_type, rtype):
body = CreateMessageRequest.builder().receive_id_type(rtype).request_body(
CreateMessageRequestBody.builder().receive_id(receive_id).msg_type(msg_type).content(payload).build()
).build()
r = client.im.v1.message.create(body)
if r.success():
return r.data.message_id if r.data else None
print(f"发送失败: {r.code}, {r.msg}")
return None
def _patch_card(message_id, card_json):
body = PatchMessageRequest.builder().message_id(message_id).request_body(
PatchMessageRequestBody.builder().content(card_json).build()
).build()
r = client.im.v1.message.patch(body)
if not r.success():
print(f"[ERROR] patch_card 失败: {r.code}, {r.msg}")
return r.success()
def send_message(receive_id, content, msg_type="text", use_card=False, receive_id_type="open_id"): def send_message(receive_id, content, msg_type="text", use_card=False, receive_id_type="open_id"):
if use_card: if use_card:
payload, real_type = _card(content), "interactive" return _send_raw(receive_id, _card(content), "interactive", receive_id_type)
elif msg_type == "text": if msg_type == "text":
payload, real_type = json.dumps({"text": content}, ensure_ascii=False), "text" return _send_raw(receive_id, json.dumps({"text": content}, ensure_ascii=False), "text", receive_id_type)
else: return _send_raw(receive_id, content, msg_type, receive_id_type)
payload, real_type = content, msg_type
body = CreateMessageRequest.builder().receive_id_type(receive_id_type).request_body(
CreateMessageRequestBody.builder().receive_id(receive_id).msg_type(real_type).content(payload).build()
).build()
response = client.im.v1.message.create(body)
if response.success():
return response.data.message_id if response.data else None
print(f"发送失败: {response.code}, {response.msg}")
return None
def update_message(message_id, content): def update_message(message_id, content):
body = PatchMessageRequest.builder().message_id(message_id).request_body( return _patch_card(message_id, _card(content))
PatchMessageRequestBody.builder().content(_card(content)).build()
).build()
response = client.im.v1.message.patch(body)
if not response.success():
print(f"[ERROR] update_message 失败: {response.code}, {response.msg}")
return response.success()
def _upload_image_sync(file_path): def _upload_image_sync(file_path):
@@ -421,6 +437,100 @@ def _build_user_message(message):
return "\n".join([p for p in parts if p]).strip(), image_paths return "\n".join([p for p in parts if p]).strip(), image_paths
def _fmt_tool_call(tc):
name = tc.get('tool_name', '?')
args = {k: v for k, v in (tc.get('args') or {}).items() if not k.startswith('_')}
return f"- `{name}`({json.dumps(args, ensure_ascii=False)[:200]})"
def _build_step_detail(resp, tool_calls):
"""从 LLM response + tool_calls 组装单步展开详情(纯函数)。"""
parts = []
thinking = (getattr(resp, 'thinking', '') or '').strip() if resp else ''
if thinking:
parts.append(f"### 💭 Thinking\n{thinking}")
if tool_calls:
parts.append("### 🛠 Tool Calls\n" + "\n".join(_fmt_tool_call(tc) for tc in tool_calls))
content = _display_text((getattr(resp, 'content', '') or '')).strip() if resp else ''
if content and content != '...':
parts.append(f"### 📝 Output\n{content}")
return "\n\n".join(parts)
class _TaskCard:
"""飞书任务卡片:单卡片持续 patch每步一个独立折叠面板header 显示 summary展开看详情"""
_DETAIL_LIMIT = 8000
def __init__(self, receive_id, rid_type):
self.rid, self.rtype = receive_id, rid_type
self.steps = [] # [(summary, detail), ...]
self.status = "🤔 思考中..."
self.final = None
self.msg_id = None
def _step_panel(self, idx, summary, detail):
detail = detail or "_(无输出)_"
if len(detail) > self._DETAIL_LIMIT:
detail = detail[:self._DETAIL_LIMIT] + f"\n\n…(已截断,共 {len(detail)} 字符)"
return {
"tag": "collapsible_panel", "expanded": False,
"header": {"title": {"tag": "plain_text", "content": f"Turn {idx} · {summary}"}},
"elements": [{"tag": "markdown", "content": detail}],
}
def _build(self):
els = [{"tag": "markdown", "content": f"**{self.status}**"}]
for i, (s, d) in enumerate(self.steps, 1):
els.append(self._step_panel(i, s, d))
if self.final:
els += [{"tag": "hr"}, {"tag": "markdown", "content": self.final}]
return _card_raw(els)
def _push(self):
card = self._build()
if self.msg_id:
_patch_card(self.msg_id, card)
else:
self.msg_id = _send_raw(self.rid, card, "interactive", self.rtype)
# ── 公开接口 ──
def start(self):
self._push()
def step(self, summary, detail=""):
self.steps.append((summary, detail))
self.status = f"⏳ 工作中 · Turn {len(self.steps)}"
self._push()
def done(self, text):
self.status = "✅ 已完成"
self.final = text or "_(无文本输出)_"
self._push()
def fail(self, msg):
self.status = f"{msg}"
self._push()
def _make_task_hook(card, done_event, on_final):
"""飞书任务 hook每轮 patch 卡片状态;结束触发 on_final(raw) 处理附件。"""
def hook(ctx):
try:
if ctx.get('exit_reason'):
resp = ctx.get('response')
raw = resp.content if hasattr(resp, 'content') else str(resp)
card.done(_display_text(raw))
on_final(raw)
done_event.set()
elif ctx.get('summary'):
detail = _build_step_detail(ctx.get('response'), ctx.get('tool_calls') or [])
card.step(ctx['summary'], detail)
except Exception as e:
print(f"[fs hook] error: {e}")
return hook
def handle_message(data): def handle_message(data):
event, message, sender = data.event, data.event.message, data.event.sender event, message, sender = data.event, data.event.message, data.event.sender
open_id = sender.sender_id.open_id open_id = sender.sender_id.open_id
@@ -441,57 +551,32 @@ def handle_message(data):
def run_agent(): def run_agent():
user_tasks[open_id] = {"running": True} user_tasks[open_id] = {"running": True}
receive_id = chat_id or open_id
rid_type = "chat_id" if chat_id else "open_id"
done_event = threading.Event()
hook_key = f"fs_{open_id}"
card = _TaskCard(receive_id, rid_type)
card.start()
on_final = lambda raw: _send_generated_files(receive_id, raw, receive_id_type=rid_type)
if not hasattr(agent, '_turn_end_hooks'): agent._turn_end_hooks = {}
agent._turn_end_hooks[hook_key] = _make_task_hook(card, done_event, on_final)
try: try:
if chat_id: agent.put_task(user_input, source="feishu", images=image_paths)
msg_id, dq, last_text = send_message(chat_id, "思考中...", use_card=True, receive_id_type="chat_id"), agent.put_task(user_input, source="feishu", images=image_paths), "" start = time.time()
else: while not done_event.wait(timeout=3):
msg_id, dq, last_text = send_message(open_id, "思考中...", use_card=True), agent.put_task(user_input, source="feishu", images=image_paths), ""
while user_tasks.get(open_id, {}).get("running", False):
time.sleep(3)
item = None
try:
while True:
item = dq.get_nowait()
except Exception:
pass
if item is None:
continue
raw = item.get("done") or item.get("next", "")
done = "done" in item
show = _display_text(raw)
if len(show) > 3500:
cut = show[-3000:]
if cut.count("```") % 2 == 1:
cut = "```\n" + cut
if chat_id:
msg_id, last_text, show = send_message(chat_id, "(继续...)", use_card=True, receive_id_type="chat_id"), "", cut
else:
msg_id, last_text, show = send_message(open_id, "(继续...)", use_card=True), "", cut
display = show if done else show + ""
if display != last_text and msg_id:
update_message(msg_id, display)
last_text = display
if done:
if chat_id:
_send_generated_files(chat_id, raw, receive_id_type="chat_id")
else:
_send_generated_files(open_id, raw)
break
if not user_tasks.get(open_id, {}).get("running", True): if not user_tasks.get(open_id, {}).get("running", True):
if chat_id: agent.abort()
send_message(chat_id, "已停止", receive_id_type="chat_id") card.fail("已停止")
else: break
send_message(open_id, "已停止") if time.time() - start > AGENT_TIMEOUT_SEC:
agent.abort()
card.fail("任务超时")
break
except Exception as e: except Exception as e:
import traceback
print(f"[ERROR] run_agent 异常: {e}")
traceback.print_exc() traceback.print_exc()
if chat_id: card.fail(f"错误: {e}")
send_message(chat_id, f"错误: {str(e)}", receive_id_type="chat_id")
else:
send_message(open_id, f"错误: {str(e)}")
finally: finally:
agent._turn_end_hooks.pop(hook_key, None)
user_tasks.pop(open_id, None) user_tasks.pop(open_id, None)
threading.Thread(target=run_agent, daemon=True).start() threading.Thread(target=run_agent, daemon=True).start()