feat: refine Feishu streaming cards and add linked footer branding

This commit is contained in:
Hyu
2026-10-10 22:35:35 +08:00
parent 1c70218383
commit 405f1ac2e7
3 changed files with 119 additions and 30 deletions
@@ -58,6 +58,8 @@ import lark_oapi.ws.exception
import pydantic
import quart
from .stream_card import build_card, streaming_text
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
from langbot.pkg.platform.adapters.lark.api_impl import LarkAPIMixin
@@ -490,28 +492,13 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
self.card_sequence_dict.pop(old_card, None)
self.card_last_update_dict.pop(old_card, None)
self.closed_streaming_cards.discard(old_card)
card_data = {
'schema': '2.0',
'config': {
'update_multi': True,
'streaming_mode': True,
'streaming_config': {
'print_step': {'default': 1},
'print_frequency_ms': {'default': 70},
'print_strategy': 'fast',
},
},
'body': {
'direction': 'vertical',
'elements': [{'tag': 'markdown', 'content': '', 'element_id': 'streaming_txt'}],
},
}
card_data = build_card(streaming=True)
request = (
CreateCardRequest.builder()
.request_body(CreateCardRequestBody.builder().type('card_json').data(json.dumps(card_data)).build())
.build()
)
response: CreateCardResponse = self.api_client.cardkit.v1.card.create(request)
response: CreateCardResponse = await self.api_client.cardkit.v1.card.acreate(request)
if not response.success():
raise RuntimeError(f'Lark create_card failed: {response.code} {response.msg}')
card_id = str(response.data.card_id)
@@ -531,16 +518,9 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
def _streaming_mode_closed(response: ContentCardElementResponse) -> bool:
return response.code == 300309 or 'streaming mode is closed' in str(response.msg).lower()
async def _replace_streaming_card(self, card_id: str, content: str) -> None:
async def _replace_streaming_card(self, card_id: str, content: str, *, finished: bool = True) -> None:
sequence = self._next_card_sequence(card_id)
card_data = {
'schema': '2.0',
'config': {'update_multi': True},
'body': {
'direction': 'vertical',
'elements': [{'tag': 'markdown', 'content': content}],
},
}
card_data = build_card(content, finished=finished)
request = (
UpdateCardRequest.builder()
.card_id(card_id)
@@ -583,14 +563,14 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
for paragraph in text_elements
)
if (is_final and not bot_message.tool_calls) or card_id in self.closed_streaming_cards:
await self._replace_streaming_card(card_id, content)
await self._replace_streaming_card(card_id, content, finished=is_final and not bot_message.tool_calls)
else:
sequence = self._next_card_sequence(card_id)
request = (
ContentCardElementRequest.builder()
.card_id(card_id)
.element_id('streaming_txt')
.request_body(ContentCardElementRequestBody.builder().content(content).sequence(sequence).build())
.request_body(ContentCardElementRequestBody.builder().content(streaming_text(content)).sequence(sequence).build())
.build()
)
response: ContentCardElementResponse = await self.api_client.cardkit.v1.card_element.acontent(
@@ -598,7 +578,7 @@ class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapte
)
if not response.success():
if self._streaming_mode_closed(response):
await self._replace_streaming_card(card_id, content)
await self._replace_streaming_card(card_id, content, finished=is_final and not bot_message.tool_calls)
else:
raise RuntimeError(f'Lark card_element update failed: {response.code} {response.msg}')
self.card_last_update_dict[card_id] = now
@@ -0,0 +1,69 @@
"""Compact streaming previews and a final card with collapsible reasoning."""
import re
def split_content(content: str) -> tuple[str, str]:
reasoning, answer = [], []
inside, offset = False, 0
for marker in re.finditer(r'</?think>', content, re.IGNORECASE):
(reasoning if inside else answer).append(content[offset:marker.start()])
inside = marker.group().lower() == '<think>'
offset = marker.end()
tail = content[offset:]
for size in range(min(7, len(tail)), 0, -1):
if any(tag.startswith(tail[-size:].lower()) for tag in ('<think>', '</think>')):
tail = tail[:-size]
break
(reasoning if inside else answer).append(tail)
return tuple('\n\n'.join(part.strip() for part in pieces if part.strip()) for pieces in (reasoning, answer))
def streaming_text(content: str) -> str:
reasoning, answer = split_content(content)
if not reasoning:
return answer
# Keep live cards compact; the final card retains the complete reasoning.
preview = reasoning[-360:]
if len(reasoning) > 360:
preview = '…' + preview
quote = '> ' + preview.replace('\n', '\n> ')
return '**思考过程 / Thinking**\n\n' + quote + ('\n\n---\n\n' + answer if answer else '')
def build_card(content: str = '', *, streaming: bool = False, finished: bool = True) -> dict:
reasoning, answer = split_content(content)
elements = []
if streaming:
elements.append({'tag': 'markdown', 'content': streaming_text(content), 'element_id': 'streaming_txt'})
else:
if reasoning:
elements.append({
'tag': 'collapsible_panel', 'expanded': not finished,
'header': {'title': {'tag': 'plain_text', 'content': '思考过程 / Thinking'}},
'elements': [{'tag': 'markdown', 'content': reasoning}],
})
if answer:
elements.append({'tag': 'markdown', 'content': answer})
elements.append({
'tag': 'column_set', 'horizontal_spacing': '8px',
'columns': [
{'tag': 'column', 'width': 'weighted', 'weight': 1, 'vertical_align': 'center',
'elements': [{'tag': 'markdown', 'content': '内容由 AI 生成 / AI-generated',
'text_size': 'notation'}]},
{'tag': 'column', 'width': 'auto', 'vertical_align': 'center',
'elements': [{'tag': 'markdown', 'content': '[LangBot](https://langbot.app?utm_source=feishu&utm_medium=bot_card&utm_campaign=langbot)', 'text_size': 'notation',
'text_align': 'right',
'icon': {'tag': 'custom_icon',
'img_key': 'img_v3_02p3_05c65d5d-9bad-440a-a2fb-c89571bfd5bg'}}]},
],
})
config = {'update_multi': True}
if streaming:
config.update(streaming_mode=True, streaming_config={
'print_step': {'default': 3}, 'print_frequency_ms': {'default': 40}, 'print_strategy': 'fast',
})
return {
'schema': '2.0', 'config': config,
'body': {'direction': 'vertical', 'padding': '12px 12px 12px 12px', 'elements': elements},
}