diff --git a/src/langbot/pkg/platform/human_input.py b/src/langbot/pkg/platform/human_input.py new file mode 100644 index 000000000..268885dc0 --- /dev/null +++ b/src/langbot/pkg/platform/human_input.py @@ -0,0 +1,101 @@ +"""Shared presentation helpers for platform human-input prompts.""" + +from __future__ import annotations + +import re +import typing + + +def _field_name(field: dict[str, typing.Any]) -> str: + return str(field.get('output_variable_name') or '').strip() + + +def _field_type(field: dict[str, typing.Any]) -> str: + return str(field.get('type') or 'text').strip().lower() + + +def _select_options(field: dict[str, typing.Any]) -> list[str]: + source = field.get('option_source') or {} + value = source.get('value') if isinstance(source, dict) else None + if isinstance(value, list): + return [str(item) for item in value] + if isinstance(value, str): + return [part.strip() for part in value.splitlines() if part.strip()] + options = field.get('options') + if not isinstance(options, list): + return [] + normalized = [] + for item in options: + if isinstance(item, dict): + normalized.append(str(item.get('label') or item.get('value') or '')) + else: + normalized.append(str(item)) + return [item for item in normalized if item] + + +def _format_fields(input_defs: list[dict[str, typing.Any]]) -> str: + if not input_defs: + return '' + + lines = ['Fields:'] + for field in input_defs: + name = _field_name(field) + typ = _field_type(field) + if typ == 'select': + options = _select_options(field) + option_text = ', '.join(f'{idx}. {value}' for idx, value in enumerate(options, start=1)) + lines.append(f' - {name} (select): {option_text or "choose one option"}') + elif typ in {'file', 'file-list'}: + limit = field.get('number_limits') if typ == 'file-list' else 1 + allowed_types = ', '.join(field.get('allowed_file_types') or []) + suffix = f', up to {limit}' if typ == 'file-list' and limit else '' + allowed = f' ({allowed_types})' if allowed_types else '' + lines.append(f' - {name} ({typ}{allowed}{suffix}): upload file(s) or reply "{name}: "') + else: + lines.append(f' - {name} ({typ}): reply "{name}: "') + + lines.append('You can reply with one or more lines like "field_name: value".') + return '\n'.join(lines) + + +def _strip_field_placeholders(form_content: str, input_defs: list[dict[str, typing.Any]]) -> str: + if not form_content: + return '' + field_names = {_field_name(field) for field in input_defs if _field_name(field)} + kept_lines = [] + for line in form_content.splitlines(): + placeholder = re.fullmatch(r'\s*\{\{#\$output\.([^#{}]+)#\}\}\s*', line) + if placeholder and placeholder.group(1) in field_names: + continue + kept_lines.append(line) + return re.sub(r'\n{3,}', '\n\n', '\n'.join(kept_lines).strip()) + + +def format_human_input_text( + node_title: str, + form_content: str, + actions: list[dict[str, typing.Any]], + input_defs: list[dict[str, typing.Any]] | None = None, +) -> str: + """Render a platform-neutral plain-text prompt for a paused workflow.""" + input_defs = input_defs or [] + form_content = _strip_field_placeholders(form_content, input_defs) + lines = [f'[Human Input Required] {node_title or ""}'.rstrip()] + if form_content: + lines.extend(['', form_content]) + field_help = _format_fields(input_defs) + if field_help: + lines.extend(['', field_help]) + if actions: + lines.append('') + if input_defs: + lines.extend([ + 'Reply with action plus field values to continue:', + ' action: ', + ]) + else: + lines.append('Reply with the number or title to continue:') + for idx, action in enumerate(actions, start=1): + title = action.get('title') or action.get('id') or '' + lines.append(f' {idx}. {title}') + return '\n'.join(lines) diff --git a/src/langbot/pkg/platform/logger.py b/src/langbot/pkg/platform/logger.py index 5c86d8cc9..6431751fd 100644 --- a/src/langbot/pkg/platform/logger.py +++ b/src/langbot/pkg/platform/logger.py @@ -8,10 +8,12 @@ import pydantic import traceback import uuid -from ..core import app import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_event_logger +if typing.TYPE_CHECKING: + from ..core import app + class EventLogLevel(enum.Enum): """日志级别""" diff --git a/src/langbot/pkg/platform/sources/dingtalk.py b/src/langbot/pkg/platform/sources/dingtalk.py index 996187f08..b2c11a69d 100644 --- a/src/langbot/pkg/platform/sources/dingtalk.py +++ b/src/langbot/pkg/platform/sources/dingtalk.py @@ -15,7 +15,7 @@ import langbot_plugin.api.entities.builtin.provider.session as provider_session from langbot.libs.dingtalk_api.api import DingTalkClient import datetime from langbot.pkg.platform.logger import EventLogger -from langbot.pkg.provider.runners.difysvapi import _format_human_input_text +from langbot.pkg.platform.human_input import format_human_input_text class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverter): @@ -1171,7 +1171,7 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): parts.append(form_content) display_text = '\n\n'.join(parts) else: - display_text = _format_human_input_text( + display_text = format_human_input_text( form_data.get('node_title', ''), form_data.get('form_content', ''), form_data.get('actions', []) or [], diff --git a/src/langbot/pkg/platform/sources/wecombot.py b/src/langbot/pkg/platform/sources/wecombot.py index d0febad9e..105c38158 100644 --- a/src/langbot/pkg/platform/sources/wecombot.py +++ b/src/langbot/pkg/platform/sources/wecombot.py @@ -444,9 +444,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter): 'WeComBot: cannot register form pause (no active stream session); falling back to plain text' ) try: - from langbot.pkg.provider.runners.difysvapi import _format_human_input_text + from langbot.pkg.platform.human_input import format_human_input_text - fallback = _format_human_input_text( + fallback = format_human_input_text( form_data.get('node_title', ''), form_data.get('form_content', ''), form_data.get('actions', []) or [], diff --git a/tests/unit_tests/platform/test_human_input.py b/tests/unit_tests/platform/test_human_input.py new file mode 100644 index 000000000..e5eefcea8 --- /dev/null +++ b/tests/unit_tests/platform/test_human_input.py @@ -0,0 +1,32 @@ +from langbot.pkg.platform.human_input import format_human_input_text + + +def test_format_human_input_text_renders_fields_actions_and_strips_placeholders(): + result = format_human_input_text( + 'Approval', + 'Review this request\n{{#$output.choice#}}', + [{'id': 'approve', 'title': 'Approve'}], + [ + { + 'output_variable_name': 'choice', + 'type': 'select', + 'option_source': {'value': ['Yes', 'No']}, + } + ], + ) + + assert result.startswith('[Human Input Required] Approval') + assert '{{#$output.choice#}}' not in result + assert 'choice (select): 1. Yes, 2. No' in result + assert 'action: ' in result + assert '1. Approve' in result + + +def test_format_human_input_text_supports_plain_action_prompt(): + result = format_human_input_text('', '', [{'id': 'continue'}]) + + assert result == ( + '[Human Input Required]\n\n' + 'Reply with the number or title to continue:\n' + ' 1. continue' + ) diff --git a/tests/unit_tests/platform/test_websocket_adapter_attachments.py b/tests/unit_tests/platform/test_websocket_adapter_attachments.py index 71bce9024..025cc6427 100644 --- a/tests/unit_tests/platform/test_websocket_adapter_attachments.py +++ b/tests/unit_tests/platform/test_websocket_adapter_attachments.py @@ -109,6 +109,7 @@ async def test_handle_websocket_message_marks_event_with_pipeline_uuid(): connection = SimpleNamespace( pipeline_uuid='pipeline-123', session_type='person', + session_id=None, connection_id='conn-1', )