diff --git a/channel/feishu/feishu_channel.py b/channel/feishu/feishu_channel.py index d2d09641..c003aa3c 100644 --- a/channel/feishu/feishu_channel.py +++ b/channel/feishu/feishu_channel.py @@ -533,10 +533,12 @@ class FeiShuChanel(ChatChannel): import time as _time # 共享状态(受 lock 保护) - committed_text = [""] # 已结束轮次的累积内容(含分隔符) - current_text = [""] # 当前轮 LLM 输出的累积内容 - card_id = [None] # 创建出来的卡片实体 ID - message_id = [None] # 卡片发送后的消息 ID(仅日志用) + # 多轮 agent 模式下,每个"中间过场消息"会作为一张独立卡片发送。 + # current_text 只承载当前正在流式渲染的那张卡片的内容;message_end / agent_end + # 时会把它定型并 reset。 + current_text = [""] # 当前卡片正在累加的 LLM 输出 + card_id = [None] # 当前流式卡片的实体 ID(每段独立) + message_id = [None] # 当前卡片发送后的消息 ID(仅日志用) last_update_time = [0.0] # 占位发送是同步进行的,但用一个 in-flight 标记防止并发的多条 message_update # 事件各自触发一次创建+发送,导致发出多张卡片。 @@ -549,9 +551,13 @@ class FeiShuChanel(ChatChannel): is_group = context.get("isgroup", False) receiver = context.get("receiver") receive_id_type = context.get("receive_id_type", "open_id") - # 后端推流间隔与客户端打字机渲染参数:飞书原生 streaming_config 默认值经验证 - # 已能在大部分场景下取得平滑的打字机效果,无需暴露给用户配置。 - interval_s = 0.3 + # 后端推流节流:首个 chunk 立即推(最低首字延迟),之后每 200ms 一波。 + # 客户端按 70ms/字 渲染(约 14 字/秒)是真正的速度瓶颈,再频繁推送也只会 + # 在飞书云端排队,不会让用户感知更快,但会增加一倍以上的 PUT 请求。 + # 飞书 streaming_mode 豁免 10qps 限制,但带宽和 CPU 成本仍是真实开销。 + interval_s = 0.2 + # 客户端打字机渲染参数:飞书默认 step=1(约 14 字/秒)实测偏慢, + # 调成 step=2(约 28 字/秒)更接近 ChatGPT 等同类产品的节奏。 print_freq_ms = 70 print_step = 2 print_strategy = "fast" @@ -753,7 +759,7 @@ class FeiShuChanel(ChatChannel): if disabled[0]: return - # 第二段:累加当前轮文本,按节流推送(锁内只读写状态) + # 第二段:累加当前卡片文本,按节流推送(锁内只读写状态) should_push = False snapshot = "" with lock: @@ -761,31 +767,69 @@ class FeiShuChanel(ChatChannel): now = _time.time() if card_id[0] and (now - last_update_time[0] >= interval_s): last_update_time[0] = now - snapshot = committed_text[0] + current_text[0] + snapshot = current_text[0] should_push = True if should_push: _stream_update_text(snapshot) elif event_type == "message_end": - # 一轮 LLM 输出结束。如果本轮触发了工具调用,把当前轮内容定型到 committed - # 并加分隔符;否则当前轮就是最终内容(agent_end 会处理)。 + # 一轮 LLM 输出结束。如果本轮触发了工具调用,说明当前轮的文本是 + # "中间过场消息"(如"来看看!"),应该作为独立卡片定型,然后为下一轮 + # 重新创建一张新卡片。这样最终用户看到的是: + # [卡片1: 中间过场1] + # [卡片2: 中间过场2] + # ... + # [卡片N: 最终回复] + # 与 wecom_bot 的多消息流式体验对齐。 tool_calls = data.get("tool_calls", []) or [] - if tool_calls: - with lock: - if current_text[0].strip(): - committed_text[0] += current_text[0].rstrip() + "\n\n---\n\n" - current_text[0] = "" + if not tool_calls: + # 没有工具调用:本轮即最终回复,留给 agent_end 统一处理。 + return + + with lock: + text_to_finalize = current_text[0].rstrip() + current_text[0] = "" + + if not text_to_finalize: + return + + # 用最终文本覆盖当前卡片并关闭流式模式(凝固成普通卡片) + _stream_update_text(text_to_finalize) + _close_streaming_mode() + + # 重置卡片状态,下一段 message_update 会触发新卡片的创建 + with lock: + card_id[0] = None + message_id[0] = None + sequence[0] = 0 + last_update_time[0] = 0.0 elif event_type == "agent_end": - # 用 final_response 强制覆盖整张卡片:丢弃中间累积,避免拼接错误。 + # 最终回复:用 final_response 覆盖当前流式卡片,然后关闭流式模式。 final_response = data.get("final_response", "") - if final_response: - final_text = str(final_response) - # 标记 streamed 让 chat_channel 跳过 send() - context["feishu_streamed"] = True - _stream_update_text(final_text) - _close_streaming_mode() + if not final_response: + return + final_text = str(final_response) + # 标记 streamed 让 chat_channel 跳过 send() + context["feishu_streamed"] = True + + with lock: + has_card = card_id[0] is not None + init_busy = init_in_flight[0] + + # 罕见情况:agent_end 触发时还没创建过卡片(极快返回 / 没有 + # message_update),主动创建一张承载 final_text。 + 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 + + _stream_update_text(final_text) + _close_streaming_mode() return on_event diff --git a/plugins/dungeon/dungeon.py b/plugins/dungeon/dungeon.py index 81b686f0..9f5d74a3 100644 --- a/plugins/dungeon/dungeon.py +++ b/plugins/dungeon/dungeon.py @@ -44,6 +44,7 @@ class StoryTeller: @plugins.register( name="Dungeon", desire_priority=0, + enabled=False, namecn="文字冒险", desc="A plugin to play dungeon game", version="1.0", diff --git a/plugins/hello/hello.py b/plugins/hello/hello.py index 5e4162b9..a71aa78e 100644 --- a/plugins/hello/hello.py +++ b/plugins/hello/hello.py @@ -13,6 +13,7 @@ from config import conf name="Hello", desire_priority=-1, hidden=True, + enabled=False, desc="A simple plugin that says hello", version="0.1", author="lanvent", diff --git a/plugins/plugin_manager.py b/plugins/plugin_manager.py index 22e73015..f652c701 100644 --- a/plugins/plugin_manager.py +++ b/plugins/plugin_manager.py @@ -34,7 +34,9 @@ class PluginManager: plugincls.version = kwargs.get("version") if kwargs.get("version") != None else "1.0" plugincls.namecn = kwargs.get("namecn") if kwargs.get("namecn") != None else name plugincls.hidden = kwargs.get("hidden") if kwargs.get("hidden") != None else False - plugincls.enabled = True + # enabled 默认 True;示例性插件可在装饰器中显式传 enabled=False, + # 首次启动写入 plugins.json 时即为关闭状态,避免拦截用户消息。 + plugincls.enabled = kwargs.get("enabled", True) if self.current_plugin_path == None: raise Exception("Plugin path not set") self.plugins[name.upper()] = plugincls