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},
}
@@ -705,7 +705,9 @@ async def test_lark_streaming_card_uses_strictly_increasing_sequences():
final_request = adapter.api_client.cardkit.v1.card.aupdate.call_args.args[0]
assert final_request.request_body.sequence == 3
final_card = json.loads(final_request.request_body.card.data)
assert final_card['body']['elements'] == [{'tag': 'markdown', 'content': 'answer'}]
assert final_card['body']['elements'][0] == {'tag': 'markdown', 'content': 'answer'}
assert 'header' not in final_card
assert final_card['body']['elements'][-1]['columns'][1]['elements'][0]['content'] == '[LangBot](https://langbot.app?utm_source=feishu&utm_medium=bot_card&utm_campaign=langbot)'
assert not final_card['config'].get('streaming_mode', False)
assert 'response-1' not in adapter.card_id_dict
assert 'stream-card-1' not in adapter.card_sequence_dict
@@ -910,3 +912,41 @@ async def test_lark_webhook_cardkit_submission_uses_async_card_update_only():
response = await adapter.handle_unified_webhook('bot-1', '', request)
assert response == {'toast': {'type': 'success', 'content': 'Submitted / 已提交'}}
def test_lark_stream_card_separates_multiple_reasoning_rounds():
from langbot.pkg.platform.adapters.lark.stream_card import build_card, streaming_text
content = '<think>first thought</think>first answer<think>second thought</think>final answer'
preview = streaming_text(content)
assert '<think>' not in preview and '</think>' not in preview
card = build_card(content)
panel, answer, footer = card['body']['elements']
assert panel['tag'] == 'collapsible_panel'
assert panel['expanded'] is False
assert panel['elements'][0]['content'] == 'first thought\n\nsecond thought'
assert answer['content'] == 'first answer\n\nfinal answer'
assert 'AI' in footer['columns'][0]['elements'][0]['content']
assert footer['columns'][1]['elements'][0]['icon']['tag'] == 'custom_icon'
def test_lark_live_preview_is_bounded_but_final_reasoning_is_complete():
from langbot.pkg.platform.adapters.lark.stream_card import build_card, streaming_text
thought = 'reasoning ' * 200
content = '<think>' + thought + '</thi'
assert len(streaming_text(content)) < 450
assert '</thi' not in streaming_text(content)
card = build_card(content)
assert card['body']['elements'][0]['elements'][0]['content'] == thought.strip()
assert build_card('plain answer')['body']['elements'][0]['content'] == 'plain answer'
@pytest.mark.asyncio
async def test_lark_stream_card_creation_is_async_and_branded():
adapter = make_adapter()
await adapter.create_card_id('response-1')
request = adapter.api_client.cardkit.v1.card.acreate.await_args.args[0]
card = json.loads(request.request_body.data)
assert 'header' not in card
assert card['body']['elements'][-1]['columns'][1]['elements'][0]['content'] == '[LangBot](https://langbot.app?utm_source=feishu&utm_medium=bot_card&utm_campaign=langbot)'
assert card['config']['streaming_mode'] is True
assert card['body']['elements'][0]['element_id'] == 'streaming_txt'