diff --git a/src/langbot/pkg/platform/adapters/lark/adapter.py b/src/langbot/pkg/platform/adapters/lark/adapter.py index 7b32c4400..c6b677595 100644 --- a/src/langbot/pkg/platform/adapters/lark/adapter.py +++ b/src/langbot/pkg/platform/adapters/lark/adapter.py @@ -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 diff --git a/src/langbot/pkg/platform/adapters/lark/stream_card.py b/src/langbot/pkg/platform/adapters/lark/stream_card.py new file mode 100644 index 000000000..88b165eef --- /dev/null +++ b/src/langbot/pkg/platform/adapters/lark/stream_card.py @@ -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'', content, re.IGNORECASE): + (reasoning if inside else answer).append(content[offset:marker.start()]) + inside = marker.group().lower() == '' + 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 ('', '')): + 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}, + } diff --git a/tests/unit_tests/platform/test_lark_eba_adapter.py b/tests/unit_tests/platform/test_lark_eba_adapter.py index 9590fbed8..2dd23fd2b 100644 --- a/tests/unit_tests/platform/test_lark_eba_adapter.py +++ b/tests/unit_tests/platform/test_lark_eba_adapter.py @@ -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 = 'first thoughtfirst answersecond thoughtfinal answer' + preview = streaming_text(content) + assert '' not in preview and '' 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 = '' + thought + '