mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-14 16:36:07 +00:00
feat: Update the human-computer interaction template cards, and optimize the prompt information and content display.
This commit is contained in:
@@ -797,22 +797,12 @@ def _wecom_input_hint_lines(form_data: dict) -> list[str]:
|
||||
continue
|
||||
if not field_name:
|
||||
continue
|
||||
if field_type == 'select':
|
||||
source = field.get('option_source') or {}
|
||||
options = source.get('value') if isinstance(source, dict) else []
|
||||
if isinstance(options, list) and options:
|
||||
option_text = ', '.join(f'{idx}. {option}' for idx, option in enumerate(options, start=1))
|
||||
lines.append(f'- {field_label}: {option_text}')
|
||||
else:
|
||||
lines.append(f'- {field_label}: choose one option')
|
||||
elif field_type in {'file', 'file-list'}:
|
||||
if field_type in {'file', 'file-list'}:
|
||||
limit = field.get('number_limits') if field_type == 'file-list' else 1
|
||||
allowed_types = ', '.join(field.get('allowed_file_types') or [])
|
||||
suffix = f', up to {limit}' if field_type == 'file-list' and limit else ''
|
||||
allowed = f' ({allowed_types})' if allowed_types else ''
|
||||
lines.append(f'- {field_label}: upload file(s){allowed}{suffix} or reply `{field_name}: <url>`')
|
||||
else:
|
||||
lines.append(f'请直接回复:{field_label}')
|
||||
return lines
|
||||
|
||||
|
||||
@@ -861,18 +851,8 @@ def _wecom_field_title(field: dict, fallback: str) -> str:
|
||||
|
||||
|
||||
def _wecom_form_desc(form_data: dict) -> str:
|
||||
current_field = str(form_data.get('_current_input_field') or '').strip()
|
||||
if current_field:
|
||||
for field in form_data.get('input_defs') or form_data.get('all_input_defs') or []:
|
||||
if str(field.get('output_variable_name') or '').strip() != current_field:
|
||||
continue
|
||||
field_type = str(field.get('type') or 'text').strip().lower()
|
||||
if field_type != 'select':
|
||||
return f'请直接回复:{_wecom_field_display_name(field, current_field)}'
|
||||
form_content = _wecom_clean_form_content(form_data)
|
||||
if form_content:
|
||||
return form_content[:512]
|
||||
return 'Please choose an option to continue.'
|
||||
return form_content[:512] if form_content else ''
|
||||
|
||||
|
||||
def build_human_input_text_prompt(form_data: dict) -> Optional[str]:
|
||||
@@ -887,8 +867,11 @@ def build_human_input_text_prompt(form_data: dict) -> Optional[str]:
|
||||
field_type = str(field.get('type') or 'text').strip().lower()
|
||||
if field_type == 'select':
|
||||
return None
|
||||
label = _wecom_field_display_name(field, current_field)
|
||||
return f'{str(form_data.get("node_title") or "人工介入").strip()}\n\n请直接回复:{label}'
|
||||
form_content = _wecom_clean_form_content(form_data)
|
||||
if not form_content:
|
||||
form_content = _wecom_field_display_name(field, current_field)
|
||||
node_title = str(form_data.get('node_title') or '人工介入').strip()
|
||||
return f'{node_title}\n\n{form_content}' if form_content else node_title
|
||||
return None
|
||||
|
||||
|
||||
@@ -1010,13 +993,11 @@ def build_select_button_interaction_payload(
|
||||
overflow_options = options[6:]
|
||||
|
||||
node_title = (form_data.get('node_title') or '').strip() or 'Human Input'
|
||||
field_title = _wecom_field_title(field, field_name or 'Select') if field else 'Select'
|
||||
form_content = _wecom_clean_form_content(form_data)
|
||||
|
||||
sub_title_parts: list[str] = []
|
||||
if form_content:
|
||||
sub_title_parts.append(form_content)
|
||||
sub_title_parts.append(f'Choose {field_title}.')
|
||||
if overflow_options:
|
||||
extra_lines = [f' - {idx + 7}. {option}' for idx, option in enumerate(overflow_options)]
|
||||
sub_title_parts.append(
|
||||
@@ -1066,21 +1047,57 @@ def build_human_input_template_card_payload(
|
||||
|
||||
|
||||
def _wecom_clean_form_content(form_data: dict) -> str:
|
||||
content = form_data.get('raw_form_content') or form_data.get('form_content') or ''
|
||||
field_names = {
|
||||
str(field.get('output_variable_name') or '').strip()
|
||||
for field in form_data.get('input_defs') or []
|
||||
is_field_step = bool(form_data.get('_current_input_field')) and not form_data.get('_action_select_only')
|
||||
raw_content = str(form_data.get('raw_form_content') or '')
|
||||
content = form_data.get('form_content') or raw_content
|
||||
input_defs = list(form_data.get('all_input_defs') or form_data.get('input_defs') or [])
|
||||
fields = {
|
||||
str(field.get('output_variable_name') or '').strip(): field
|
||||
for field in input_defs
|
||||
if str(field.get('output_variable_name') or '').strip()
|
||||
}
|
||||
|
||||
if form_data.get('_action_select_only') and raw_content:
|
||||
placeholders = [
|
||||
match
|
||||
for match in re.finditer(r'\{\{#\$output\.([^#{}]+)#\}\}', raw_content)
|
||||
if match.group(1).strip() in fields
|
||||
]
|
||||
if placeholders:
|
||||
content = raw_content[placeholders[-1].end() :]
|
||||
|
||||
if is_field_step:
|
||||
inputs = form_data.get('inputs') or {}
|
||||
|
||||
def replace_placeholder(match: re.Match[str]) -> str:
|
||||
field_name = match.group(1).strip()
|
||||
field = fields.get(field_name)
|
||||
value = inputs.get(field_name)
|
||||
if not field or value in (None, '', []):
|
||||
return ''
|
||||
return f'✅ {field_name}:{_wecom_display_input_value(field, value)}'
|
||||
|
||||
content = re.sub(r'\{\{#\$output\.([^#{}]+)#\}\}', replace_placeholder, str(content))
|
||||
|
||||
kept_lines: list[str] = []
|
||||
for line in str(content).splitlines():
|
||||
placeholder = re.fullmatch(r'\s*\{\{#\$output\.([^#{}]+)#\}\}\s*', line)
|
||||
if placeholder and placeholder.group(1) in field_names:
|
||||
if placeholder and placeholder.group(1).strip() in fields:
|
||||
continue
|
||||
kept_lines.append(line)
|
||||
return re.sub(r'\n{3,}', '\n\n', '\n'.join(kept_lines).strip())
|
||||
|
||||
|
||||
def _wecom_display_input_value(field: dict, value: Any) -> str:
|
||||
field_type = str(field.get('type') or 'text').strip().lower()
|
||||
if field_type == 'file':
|
||||
if isinstance(value, dict):
|
||||
return str(value.get('url') or value.get('upload_file_id') or '1 file')
|
||||
elif field_type == 'file-list' and isinstance(value, list):
|
||||
return f'{len(value)} file(s)'
|
||||
return str(value)
|
||||
|
||||
|
||||
def build_button_interaction_payload(
|
||||
form_data: dict,
|
||||
task_id: str,
|
||||
@@ -1150,7 +1167,6 @@ def build_button_interaction_payload(
|
||||
'card_type': 'button_interaction',
|
||||
'main_title': {
|
||||
'title': node_title,
|
||||
'desc': _wecom_form_desc(form_data),
|
||||
},
|
||||
'sub_title_text': sub_title_text,
|
||||
'button_list': button_list,
|
||||
@@ -1434,7 +1450,7 @@ def build_button_interaction_update_card(
|
||||
"""Build the template_card body used to update a clicked form card."""
|
||||
|
||||
node_title = str(form_data.get('node_title') or '').strip() or '人工介入'
|
||||
form_content = str(form_data.get('form_content') or '').strip()
|
||||
form_content = _wecom_clean_form_content(form_data)
|
||||
action_title = resolve_form_action_title(form_data, action_id)
|
||||
clean_action_id = str(action_id or '').strip()
|
||||
|
||||
@@ -1528,15 +1544,19 @@ def build_multiple_interaction_update_card(
|
||||
}
|
||||
)
|
||||
|
||||
display_form_data = dict(form_data)
|
||||
display_form_data['inputs'] = selected_values
|
||||
form_content = _wecom_clean_form_content(display_form_data)
|
||||
|
||||
card: dict[str, Any] = {
|
||||
'card_type': 'multiple_interaction',
|
||||
'main_title': {
|
||||
'title': node_title,
|
||||
'desc': 'Submitted',
|
||||
'desc': form_content,
|
||||
},
|
||||
'select_list': select_list,
|
||||
'submit_button': {
|
||||
'text': 'Submitted',
|
||||
'text': '✅',
|
||||
'key': 'submit_human_input',
|
||||
},
|
||||
'task_id': task_id,
|
||||
|
||||
@@ -169,11 +169,12 @@ def test_build_payload_can_emulate_select_as_buttons_for_wecombot_ws():
|
||||
assert parse_select_button_action(card['button_list'][1]['key'], form_data) == {'choice': 'B'}
|
||||
|
||||
|
||||
def test_text_input_card_shows_direct_reply_prompt():
|
||||
def test_text_input_card_uses_current_stage_content_without_direct_reply_prompt():
|
||||
payload = build_human_input_template_card_payload(
|
||||
{
|
||||
'node_title': '人工介入',
|
||||
'form_content': '请输入你的问题\n\n{{#$output.us_input#}}',
|
||||
'form_content': '11\n请输入你的问题\n\n{{#$output.us_input#}}',
|
||||
'raw_form_content': ('11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'),
|
||||
'input_defs': [
|
||||
{
|
||||
'output_variable_name': 'us_input',
|
||||
@@ -189,8 +190,8 @@ def test_text_input_card_shows_direct_reply_prompt():
|
||||
)
|
||||
|
||||
card = payload['template_card']
|
||||
assert card['main_title']['desc'] == '请直接回复:请输入你的问题'
|
||||
assert '请直接回复:请输入你的问题' in card['sub_title_text']
|
||||
assert 'desc' not in card['main_title']
|
||||
assert card['sub_title_text'] == '11\n请输入你的问题'
|
||||
assert card['button_list'] == []
|
||||
|
||||
|
||||
@@ -198,6 +199,8 @@ def test_build_human_input_text_prompt_for_current_text_field():
|
||||
prompt = build_human_input_text_prompt(
|
||||
{
|
||||
'node_title': '人工介入',
|
||||
'form_content': '11\n请输入你的问题\n{{#$output.us_input#}}',
|
||||
'raw_form_content': ('11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'),
|
||||
'input_defs': [
|
||||
{
|
||||
'output_variable_name': 'us_input',
|
||||
@@ -209,7 +212,7 @@ def test_build_human_input_text_prompt_for_current_text_field():
|
||||
}
|
||||
)
|
||||
|
||||
assert prompt == '人工介入\n\n请直接回复:请输入你的问题'
|
||||
assert prompt == '人工介入\n\n11\n请输入你的问题'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -229,6 +232,8 @@ async def test_ws_push_form_pause_sends_text_prompt_without_empty_card():
|
||||
'msg-1',
|
||||
{
|
||||
'node_title': '人工介入',
|
||||
'form_content': '11\n请输入你的问题\n{{#$output.us_input#}}',
|
||||
'raw_form_content': ('11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'),
|
||||
'input_defs': [
|
||||
{
|
||||
'output_variable_name': 'us_input',
|
||||
@@ -243,7 +248,7 @@ async def test_ws_push_form_pause_sends_text_prompt_without_empty_card():
|
||||
assert ok is True
|
||||
assert stream_id == 'stream-1'
|
||||
assert task_id is None
|
||||
assert sent == [('req-1', '人工介入\n\n请直接回复:请输入你的问题')]
|
||||
assert sent == [('req-1', '人工介入\n\n11\n请输入你的问题')]
|
||||
assert client._pending_forms_by_task == {}
|
||||
assert 'msg-1' not in client._stream_ids
|
||||
|
||||
@@ -363,10 +368,12 @@ def test_extract_template_card_selections_reads_response_data_direct_mapping():
|
||||
assert selections == {'choice': 'B'}
|
||||
|
||||
|
||||
def test_build_multiple_interaction_update_card_disables_submitted_select():
|
||||
def test_build_multiple_interaction_update_card_disables_selected_value_without_submitted_text():
|
||||
card = build_multiple_interaction_update_card(
|
||||
{
|
||||
'node_title': 'Manual Review',
|
||||
'form_content': 'Choose a label\n{{#$output.choice#}}',
|
||||
'raw_form_content': 'Choose a label\n{{#$output.choice#}}',
|
||||
'input_defs': [
|
||||
{
|
||||
'output_variable_name': 'choice',
|
||||
@@ -374,12 +381,73 @@ def test_build_multiple_interaction_update_card_disables_submitted_select():
|
||||
'option_source': {'type': 'constant', 'value': ['A', 'B']},
|
||||
}
|
||||
],
|
||||
'_current_input_field': 'choice',
|
||||
},
|
||||
task_id='task-1',
|
||||
selections={'choice': 'B'},
|
||||
)
|
||||
|
||||
assert card['card_type'] == 'multiple_interaction'
|
||||
assert card['main_title']['desc'] == 'Submitted'
|
||||
assert card['main_title']['desc'] == 'Choose a label\n✅ choice:B'
|
||||
assert card['submit_button']['text'] == '✅'
|
||||
assert card['select_list'][0]['disable'] is True
|
||||
assert card['select_list'][0]['selected_id'] == 'opt_2'
|
||||
|
||||
|
||||
def test_select_stage_only_shows_current_prompt_in_a_separate_message():
|
||||
raw_content = '11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}'
|
||||
payload = build_human_input_template_card_payload(
|
||||
{
|
||||
'node_title': '人工介入',
|
||||
'form_content': '请选择你的答案\n{{#$output.xiala#}}',
|
||||
'raw_form_content': raw_content,
|
||||
'input_defs': [
|
||||
{
|
||||
'output_variable_name': 'xiala',
|
||||
'type': 'select',
|
||||
'option_source': {'type': 'constant', 'value': ['1', '2']},
|
||||
}
|
||||
],
|
||||
'all_input_defs': [
|
||||
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
||||
{
|
||||
'output_variable_name': 'xiala',
|
||||
'type': 'select',
|
||||
'option_source': {'type': 'constant', 'value': ['1', '2']},
|
||||
},
|
||||
],
|
||||
'inputs': {'us_input': '你叫啥'},
|
||||
'_current_input_field': 'xiala',
|
||||
},
|
||||
task_id='task-1',
|
||||
)
|
||||
|
||||
assert payload['template_card']['main_title']['desc'] == '请选择你的答案'
|
||||
|
||||
|
||||
def test_action_stage_only_shows_content_after_fields_without_placeholders():
|
||||
raw_content = '11\n请输入你的问题\n{{#$output.us_input#}}\n请选择你的答案\n{{#$output.xiala#}}\n请选择操作'
|
||||
payload = build_human_input_template_card_payload(
|
||||
{
|
||||
'node_title': '人工介入',
|
||||
'form_content': raw_content,
|
||||
'raw_form_content': raw_content,
|
||||
'input_defs': [],
|
||||
'all_input_defs': [
|
||||
{'output_variable_name': 'us_input', 'type': 'paragraph'},
|
||||
{'output_variable_name': 'xiala', 'type': 'select'},
|
||||
],
|
||||
'inputs': {'us_input': '你叫啥', 'xiala': '2'},
|
||||
'actions': [
|
||||
{'id': 'yes', 'title': 'yes'},
|
||||
{'id': 'no', 'title': 'no'},
|
||||
],
|
||||
'_action_select_only': True,
|
||||
},
|
||||
task_id='task-1',
|
||||
)
|
||||
|
||||
card = payload['template_card']
|
||||
assert card['sub_title_text'] == '请选择操作'
|
||||
assert '{{#$output.' not in card['sub_title_text']
|
||||
assert [button['text'] for button in card['button_list']] == ['yes', 'no']
|
||||
|
||||
Reference in New Issue
Block a user