feat: 增强问题工具功能,支持多问题处理

- 新增问题和选项规范化工具函数 (question_utils.py)
- 优化 resume_agent_chat 接口的 answer 参数处理,支持列表类型
- 前端 HumanApprovalModal 支持多问题标签页切换
- 改进问题工具组件交互体验
- 添加批量问题测试用例
- 更新文档
This commit is contained in:
Wenjie Zhang 2026-03-17 03:39:48 +08:00
parent 1af5b33ccc
commit f2096b4004
15 changed files with 908 additions and 271 deletions

View File

@ -45,7 +45,7 @@ def calculator(a: float, b: float, operation: str) -> float:
## 内置工具
系统内置了几类常用工具。计算类包括 calculator可进行加减乘除运算。搜索类包括 tavily_search需要在环境变量中配置 `TAVILY_API_KEY` 才能启用。知识图谱类包括 query_knowledge_graph用于查询通过三元组导入的全局知识图谱。交互类包括 ask_user_question用于在智能体执行过程中向用户发起交互式提问。数据库类包括 mysql_list_tables、mysql_describe_table 和 mysql_query用于连接和查询 MySQL 数据库。
系统内置了几类常用工具。计算类包括 calculator可进行加减乘除运算。搜索类包括 tavily_search需要在环境变量中配置 `TAVILY_API_KEY` 才能启用。知识图谱类包括 query_knowledge_graph用于查询通过三元组导入的全局知识图谱。交互类包括 ask_user_question用于在智能体执行过程中向用户发起交互式提问,当前协议支持一次提交 `questions` 数组并返回 `{question_id: answer}` 的批量答案映射。数据库类包括 mysql_list_tables、mysql_describe_table 和 mysql_query用于连接和查询 MySQL 数据库。
这些工具都通过上述注册机制自动加载,开发者无需手动引入。

View File

@ -13,7 +13,7 @@
- 检索测试中,添加问答
- 探索 subagents 的体系
- 集成 Memory基于 deepagents 的文件后端实现
- 探索智能体切换逻辑
- 将 后端代码 和 agents 解耦agents 作为单独的 package 使用
### Bugs
- 部分异常状态下,智能体的模型名称出现重叠[#279](https://github.com/xerrors/Yuxi-Know/issues/279)

View File

@ -504,7 +504,7 @@ async def resume_agent_chat(
agent_id: str,
thread_id: str = Body(...),
approved: bool | None = Body(None),
answer: dict | list | str | None = Body(None),
answer: dict | None = Body(None),
config: dict = Body({}),
current_user: User = Depends(get_required_user),
db: AsyncSession = Depends(get_db),
@ -512,26 +512,47 @@ async def resume_agent_chat(
"""恢复被人工审批中断的对话(需要登录)"""
def normalize_resume_input(raw_answer: Any, raw_approved: bool | None) -> Any:
if raw_answer is not None:
if isinstance(raw_answer, str):
normalized = raw_answer.strip()
def normalize_single_answer(value: Any) -> Any:
if isinstance(value, str):
normalized = value.strip()
if not normalized:
raise HTTPException(status_code=422, detail="answer 不能为空")
return normalized
if isinstance(raw_answer, list):
if len(raw_answer) == 0:
if isinstance(value, list):
if len(value) == 0:
raise HTTPException(status_code=422, detail="answer 不能为空")
return raw_answer
if isinstance(raw_answer, dict):
if raw_answer.get("type") == "other":
text = raw_answer.get("text")
normalized_list: list[str] = []
for item in value:
if not isinstance(item, str) or not item.strip():
raise HTTPException(status_code=422, detail="answer 列表必须是非空字符串")
normalized_list.append(item.strip())
return normalized_list
if isinstance(value, dict):
if value.get("type") == "other":
text = value.get("text")
if not isinstance(text, str) or not text.strip():
raise HTTPException(status_code=422, detail="other 文本不能为空")
return raw_answer
return value
raise HTTPException(status_code=422, detail="answer 类型不支持")
raise HTTPException(status_code=422, detail="answer 值类型不支持")
if raw_answer is not None:
if isinstance(raw_answer, dict):
if len(raw_answer) == 0:
raise HTTPException(status_code=422, detail="answer 不能为空")
normalized_answers: dict[str, Any] = {}
for question_id, value in raw_answer.items():
normalized_question_id = str(question_id).strip()
if not normalized_question_id:
raise HTTPException(status_code=422, detail="question_id 不能为空")
normalized_answers[normalized_question_id] = normalize_single_answer(value)
return normalized_answers
raise HTTPException(status_code=422, detail="answer 必须是对象映射 {question_id: answer}")
if raw_approved is not None:
return "approve" if raw_approved else "reject"

View File

@ -10,6 +10,7 @@ from src import config, graph_base
from src.agents.common.toolkits.registry import ToolExtraMetadata, _all_tool_instances, _extra_registry, tool
from src.storage.minio import aupload_file_to_minio
from src.utils import logger
from src.utils.question_utils import normalize_questions, normalize_options
# Lazy initialization for TavilySearch (only when API key is available)
_tavily_search_instance = None
@ -79,10 +80,10 @@ ASK_USER_QUESTION_DESCRIPTION = """
4. 在有明显权衡时让用户做取舍
使用规范
1. 问题应当简短具体可回答避免开放式长问句
2. options 提供 2-5 个有区分度的选项每项包含 label value
1. questions 提供 1-5 个问题每项包含questionoptionsmulti_selectallow_other
2. 每个问题的 options 提供 2-5 个有区分度的选项每项包含 label value
3. 若有推荐选项把推荐项放在第一位并在 label 末尾加 "(Recommended)"
4. 若需要多选 multi_select 设为 true
4. 若需要多选该问题的 multi_select 设为 true
5. allow_other 通常保持 true用户可通过 Other 输入自定义答案
注意事项
@ -91,7 +92,8 @@ ASK_USER_QUESTION_DESCRIPTION = """
3. 先基于现有上下文自行决策只有关键不确定性时才提问
返回结果
answer 可能是 string单选list多选 objectOther 文本
answer object格式为 {question_id: answer}
其中 answer 可能是 string单选list多选 objectOther 文本
"""
@ -102,37 +104,43 @@ answer 可能是 string单选、list多选或 objectOther 文本
description=ASK_USER_QUESTION_DESCRIPTION,
)
def ask_user_question(
question: Annotated[str, "向用户展示的问题"],
options: Annotated[list[dict], "候选项列表,格式 [{label, value}],推荐项请在 label 后追加 (Recommended)"],
multi_select: Annotated[bool, "是否允许多选"] = False,
allow_other: Annotated[bool, "是否允许用户输入 Other 自定义答案"] = True,
questions: Annotated[
list[dict] | None,
"问题列表,每项格式 {question, options, multi_select, allow_other, question_id(optional)}",
] = None,
question: Annotated[str, "兼容字段:单个问题文本(建议优先使用 questions"] = "",
options: Annotated[list[dict] | None, "兼容字段:单个问题候选项(建议优先使用 questions"] = None,
multi_select: Annotated[bool, "兼容字段:单个问题是否允许多选"] = False,
allow_other: Annotated[bool, "兼容字段:单个问题是否允许 Other 自定义答案"] = True,
) -> dict:
"""向用户发起问题并等待回答。"""
normalized_options: list[dict[str, str]] = []
for item in options or []:
if not isinstance(item, dict):
continue
label = str(item.get("label") or item.get("value") or "").strip()
value = str(item.get("value") or item.get("label") or "").strip()
if label and value:
normalized_options.append({"label": label, "value": value})
input_questions = questions
if not input_questions:
legacy_question = str(question or "").strip()
if legacy_question:
input_questions = [
{
"question": legacy_question,
"options": options or [],
"multi_select": multi_select,
"allow_other": allow_other,
}
]
normalized_questions = normalize_questions(input_questions or [])
if not normalized_questions:
raise ValueError("questions 至少需要包含一个有效问题")
interrupt_payload = {
"question": question,
"question_id": str(uuid.uuid4()),
"options": normalized_options,
"multi_select": multi_select,
"allow_other": allow_other,
"questions": normalized_questions,
"source": "ask_user_question",
}
answer = interrupt(interrupt_payload)
return {
"question": question,
"question_id": interrupt_payload["question_id"],
"questions": normalized_questions,
"answer": answer,
"multi_select": multi_select,
"allow_other": allow_other,
}

View File

@ -16,6 +16,11 @@ from src.repositories.agent_config_repository import AgentConfigRepository
from src.repositories.conversation_repository import ConversationRepository
from src.storage.postgres.manager import pg_manager
from src.utils.logging_config import logger
from src.utils.question_utils import (
normalize_questions as _normalize_interrupt_questions,
normalize_options as _normalize_interrupt_options,
normalize_legacy_question,
)
def _build_state_files(attachments: list[dict]) -> dict:
@ -217,54 +222,70 @@ def _coerce_interrupt_payload(info: Any) -> dict:
if isinstance(payload, dict):
return payload
questions = getattr(info, "questions", None)
question = getattr(info, "question", None)
question_id = getattr(info, "question_id", None)
options = getattr(info, "options", None)
multi_select = getattr(info, "multi_select", None)
allow_other = getattr(info, "allow_other", None)
operation = getattr(info, "operation", None)
source = getattr(info, "source", None)
result: dict[str, Any] = {}
if isinstance(questions, list):
result["questions"] = questions
if isinstance(question, str) and question.strip():
result["question"] = question
if isinstance(question_id, str) and question_id.strip():
result["question_id"] = question_id
if isinstance(options, list):
result["options"] = options
if isinstance(multi_select, bool):
result["multi_select"] = multi_select
if isinstance(allow_other, bool):
result["allow_other"] = allow_other
if isinstance(operation, str) and operation.strip():
result["operation"] = operation
if isinstance(source, str) and source.strip():
result["source"] = source
return result
def _normalize_interrupt_options(raw_options: Any) -> list[dict[str, str]]:
if not isinstance(raw_options, list):
return []
options: list[dict[str, str]] = []
for item in raw_options:
if isinstance(item, dict):
label = str(item.get("label") or item.get("value") or "").strip()
value = str(item.get("value") or item.get("label") or "").strip()
else:
label = str(item).strip()
value = label
if label and value:
options.append({"label": label, "value": value})
return options
def _build_ask_user_question_payload(info: Any, thread_id: str) -> dict[str, Any]:
"""将 interrupt 信息标准化为 ask_user_question_required 载荷。"""
payload = _coerce_interrupt_payload(info)
question = str(payload.get("question") or "请选择一个选项").strip()
question_id = str(payload.get("question_id") or uuid.uuid4())
source = str(payload.get("source") or payload.get("tool_name") or "interrupt")
multi_select = bool(payload.get("multi_select", False))
allow_other = bool(payload.get("allow_other", True))
operation = payload.get("operation")
questions = _normalize_interrupt_questions(payload.get("questions"))
if not questions:
legacy_question = str(payload.get("question") or "").strip()
if legacy_question:
legacy_item: dict[str, Any] = {
"question_id": str(payload.get("question_id") or uuid.uuid4()),
"question": legacy_question,
"options": _normalize_interrupt_options(payload.get("options")),
"multi_select": bool(payload.get("multi_select", False)),
"allow_other": bool(payload.get("allow_other", True)),
}
legacy_operation = payload.get("operation")
if isinstance(legacy_operation, str) and legacy_operation.strip():
legacy_item["operation"] = legacy_operation.strip()
questions = [legacy_item]
options = _normalize_interrupt_options(payload.get("options"))
if not questions:
questions = [
{
"question_id": str(uuid.uuid4()),
"question": "请选择一个选项",
"options": [],
"multi_select": False,
"allow_other": True,
}
]
source = str(payload.get("source") or payload.get("tool_name") or "interrupt")
return {
"question_id": question_id,
"question": question,
"options": options,
"multi_select": multi_select,
"allow_other": allow_other,
"questions": questions,
"source": source,
"operation": operation if isinstance(operation, str) else "",
"thread_id": thread_id,
}

View File

@ -276,11 +276,18 @@ async def process_agent_run(ctx, run_id: str):
)
terminal_set = True
elif status == "ask_user_question_required":
questions = chunk.get("questions") if isinstance(chunk, dict) else None
first_question = ""
if isinstance(questions, list) and questions:
first = questions[0]
if isinstance(first, dict):
first_question = str(first.get("question") or "").strip()
await mark_run_terminal(
run_id,
"interrupted",
error_type="ask_user_question_required",
error_message=chunk.get("question") or "需要用户回答问题",
error_message=first_question or "需要用户回答问题",
)
terminal_set = True

View File

@ -0,0 +1,82 @@
"""问题和选项规范化工具"""
import uuid
from typing import Any
def normalize_options(raw_options: Any) -> list[dict[str, str]]:
"""规范化选项列表"""
if not isinstance(raw_options, list):
return []
options: list[dict[str, str]] = []
for item in raw_options:
if isinstance(item, dict):
label = str(item.get("label") or item.get("value") or "").strip()
value = str(item.get("value") or item.get("label") or "").strip()
else:
label = str(item).strip()
value = label
if label and value:
options.append({"label": label, "value": value})
return options
def normalize_questions(
raw_questions: Any, default_question_id_prefix: str = "q"
) -> list[dict[str, Any]]:
"""规范化问题列表"""
if not isinstance(raw_questions, list):
return []
questions: list[dict[str, Any]] = []
for idx, item in enumerate(raw_questions):
if not isinstance(item, dict):
continue
question = str(item.get("question") or "").strip()
if not question:
continue
question_id = str(
item.get("question_id") or f"{default_question_id_prefix}-{idx + 1}"
).strip()
if not question_id:
question_id = str(uuid.uuid4())
normalized_question: dict[str, Any] = {
"question_id": question_id,
"question": question,
"options": normalize_options(item.get("options")),
"multi_select": bool(item.get("multi_select", False)),
"allow_other": bool(item.get("allow_other", True)),
}
operation = item.get("operation")
if isinstance(operation, str) and operation.strip():
normalized_question["operation"] = operation.strip()
questions.append(normalized_question)
return questions
def normalize_legacy_question(raw_question: Any) -> dict[str, Any] | None:
"""规范化单个问题(兼容旧格式)"""
if not raw_question:
return None
question = str(raw_question.get("question") or "").strip()
if not question:
return None
question_id = str(raw_question.get("question_id") or "").strip()
if not question_id:
question_id = str(uuid.uuid4())
return {
"question_id": question_id,
"question": question,
"options": normalize_options(raw_question.get("options")),
"multi_select": bool(raw_question.get("multi_select", False)),
"allow_other": bool(raw_question.get("allow_other", True)),
}

View File

@ -0,0 +1,64 @@
"""
Integration tests for batch question resume payload validation.
"""
from __future__ import annotations
import pytest
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
async def test_resume_rejects_non_dict_answer(test_client, admin_headers):
response = await test_client.post(
"/api/chat/agent/dummy-agent/resume",
json={"thread_id": "thread-test", "answer": "approve"},
headers=admin_headers,
)
assert response.status_code == 422
assert "answer 必须是对象映射" in response.text
async def test_resume_rejects_empty_answer_map(test_client, admin_headers):
response = await test_client.post(
"/api/chat/agent/dummy-agent/resume",
json={"thread_id": "thread-test", "answer": {}},
headers=admin_headers,
)
assert response.status_code == 422
assert "answer 不能为空" in response.text
async def test_resume_rejects_empty_question_id(test_client, admin_headers):
response = await test_client.post(
"/api/chat/agent/dummy-agent/resume",
json={"thread_id": "thread-test", "answer": {"": "选项A"}},
headers=admin_headers,
)
assert response.status_code == 422
assert "question_id 不能为空" in response.text
async def test_resume_rejects_empty_answer_text(test_client, admin_headers):
response = await test_client.post(
"/api/chat/agent/dummy-agent/resume",
json={"thread_id": "thread-test", "answer": {"q1": " "}},
headers=admin_headers,
)
assert response.status_code == 422
assert "answer 不能为空" in response.text
async def test_resume_accepts_batch_answer_map(test_client, admin_headers):
response = await test_client.post(
"/api/chat/agent/dummy-agent/resume",
json={"thread_id": "thread-test", "answer": {"q1": "选项A", "q2": ["选项B", "选项C"]}},
headers=admin_headers,
)
# 通过参数校验后会进入流式逻辑dummy-agent 不存在时也会以 200 返回 error chunk。
assert response.status_code == 200

View File

@ -8,6 +8,7 @@ sys.path.insert(0, os.getcwd())
from src.services.chat_stream_service import (
_normalize_interrupt_options,
_normalize_interrupt_questions,
_build_ask_user_question_payload,
_coerce_interrupt_payload,
)
@ -59,85 +60,140 @@ class TestNormalizeInterruptOptions:
class TestBuildAskUserQuestionPayload:
"""测试 _build_ask_user_question_payload 函数"""
def test_basic_question(self):
def test_basic_questions(self):
info = {
"question": "请确认是否继续?",
"options": [
{"label": "确认", "value": "yes"},
{"label": "取消", "value": "no"},
"questions": [
{
"question": "请确认是否继续?",
"options": [
{"label": "确认", "value": "yes"},
{"label": "取消", "value": "no"},
],
}
],
}
result = _build_ask_user_question_payload(info, "thread-123")
assert result["question"] == "请确认是否继续?"
assert len(result["options"]) == 2
assert result["options"][0] == {"label": "确认", "value": "yes"}
assert result["options"][1] == {"label": "取消", "value": "no"}
assert len(result["questions"]) == 1
assert result["questions"][0]["question"] == "请确认是否继续?"
assert len(result["questions"][0]["options"]) == 2
assert result["questions"][0]["options"][0] == {"label": "确认", "value": "yes"}
assert result["questions"][0]["options"][1] == {"label": "取消", "value": "no"}
assert result["source"] == "interrupt"
assert result["thread_id"] == "thread-123"
assert result["multi_select"] is False
assert result["allow_other"] is True
def test_question_with_source(self):
def test_questions_with_source(self):
info = {
"question": "选择一个选项",
"options": ["A", "B", "C"],
"questions": [{"question": "选择一个选项", "options": ["A", "B", "C"]}],
"source": "ask_user_question",
}
result = _build_ask_user_question_payload(info, "thread-456")
assert result["source"] == "ask_user_question"
assert len(result["options"]) == 3
assert len(result["questions"][0]["options"]) == 3
def test_multi_select(self):
info = {
"question": "选择多个",
"options": ["A", "B", "C"],
"multi_select": True,
"questions": [
{
"question": "选择多个",
"options": ["A", "B", "C"],
"multi_select": True,
}
],
}
result = _build_ask_user_question_payload(info, "thread-789")
assert result["multi_select"] is True
assert result["questions"][0]["multi_select"] is True
def test_disable_allow_other(self):
info = {
"question": "只能选择",
"options": ["A", "B"],
"allow_other": False,
"questions": [{"question": "只能选择", "options": ["A", "B"], "allow_other": False}],
}
result = _build_ask_user_question_payload(info, "thread-000")
assert result["allow_other"] is False
assert result["questions"][0]["allow_other"] is False
def test_with_operation(self):
info = {
"question": "是否执行操作?",
"operation": "删除文件",
"options": [{"label": "批准", "value": "approve"}, {"label": "拒绝", "value": "reject"}],
"questions": [
{
"question": "是否执行操作?",
"operation": "删除文件",
"options": [
{"label": "批准", "value": "approve"},
{"label": "拒绝", "value": "reject"},
],
}
],
}
result = _build_ask_user_question_payload(info, "thread-op")
assert result["operation"] == "删除文件"
assert result["questions"][0]["operation"] == "删除文件"
def test_no_options(self):
"""测试没有 options 的情况 - 不再自动填充 legacy 选项"""
info = {
"question": "请确认?",
}
def test_default_question_when_questions_missing(self):
info = {}
result = _build_ask_user_question_payload(info, "thread-no-opt")
# 不再有默认的 approve/reject 选项
assert result["options"] == []
assert len(result["questions"]) == 1
assert result["questions"][0]["question"] == "请选择一个选项"
assert result["questions"][0]["options"] == []
assert result["source"] == "interrupt"
def test_legacy_single_question_payload(self):
info = {
"question": "旧协议问题",
"question_id": "legacy-qid",
"options": ["A", "B"],
"multi_select": True,
"allow_other": False,
"operation": "旧操作",
}
result = _build_ask_user_question_payload(info, "thread-legacy")
assert len(result["questions"]) == 1
assert result["questions"][0]["question"] == "旧协议问题"
assert result["questions"][0]["question_id"] == "legacy-qid"
assert result["questions"][0]["options"] == [
{"label": "A", "value": "A"},
{"label": "B", "value": "B"},
]
assert result["questions"][0]["multi_select"] is True
assert result["questions"][0]["allow_other"] is False
assert result["questions"][0]["operation"] == "旧操作"
def test_question_id_generation(self):
"""测试 question_id 自动生成"""
info = {"question": "测试?"}
info = {"questions": [{"question": "测试?"}]}
result = _build_ask_user_question_payload(info, "thread-id")
# 应该生成了 UUID
assert result["question_id"] != ""
assert len(result["question_id"]) > 0
assert result["questions"][0]["question_id"] != ""
assert len(result["questions"][0]["question_id"]) > 0
class TestNormalizeInterruptQuestions:
"""测试 _normalize_interrupt_questions 函数"""
def test_empty_input(self):
assert _normalize_interrupt_questions(None) == []
assert _normalize_interrupt_questions([]) == []
def test_normalize_basic_question(self):
raw = [{"question": "Q1", "options": ["A", "B"]}]
result = _normalize_interrupt_questions(raw)
assert len(result) == 1
assert result[0]["question"] == "Q1"
assert result[0]["options"][0] == {"label": "A", "value": "A"}
assert result[0]["multi_select"] is False
assert result[0]["allow_other"] is True
def test_invalid_question_filtered(self):
raw = [{"question": " "}, "Q2", {"question": "有效问题"}]
result = _normalize_interrupt_questions(raw)
assert len(result) == 1
assert result[0]["question"] == "有效问题"
class TestCoerceInterruptPayload:

View File

@ -174,7 +174,7 @@ export const agentApi = {
/**
* 恢复被人工审批中断的对话流式响应
* @param {string} agentId - 智能体ID
* @param {Object} data - 恢复数据 { thread_id, approved }
* @param {Object} data - 恢复数据 { thread_id, answer: { question_id: answer }, approved }
* @param {Object} options - 可选参数signal, headers等
* @returns {Promise} - 恢复响应流
*/

View File

@ -114,11 +114,7 @@
<!-- 人工审批弹窗 - 放在输入框上方 -->
<HumanApprovalModal
:visible="approvalState.showModal"
:question="approvalState.question"
:operation="approvalState.operation"
:options="approvalState.options"
:multi-select="approvalState.multiSelect"
:allow-other="approvalState.allowOther"
:questions="approvalState.questions"
@submit="handleQuestionSubmit"
@cancel="handleQuestionCancel"
/>
@ -1167,6 +1163,15 @@ const startRunStream = async (threadId, runId, afterSeq = '0') => {
enqueueLoadingChunkForTyping(threadId, payload.chunk)
}
const approvalStatuses = ['ask_user_question_required', 'human_approval_required']
const isApprovalEvent = approvalStatuses.includes(event) ||
approvalStatuses.includes(payload?.chunk?.status)
if (isApprovalEvent) {
const approvalChunk = payload?.chunk || { status: event, thread_id: threadId }
processApprovalInStream(approvalChunk, threadId, currentAgentId.value)
}
if (event === 'close') {
flushTypingQueueForThread(threadId)
ts.isStreaming = false
@ -1188,11 +1193,13 @@ const startRunStream = async (threadId, runId, afterSeq = '0') => {
}
}
const chunkStatus = payload?.chunk?.status
if (
event === 'finished' ||
event === 'error' ||
event === 'interrupted' ||
event === 'ask_user_question_required'
approvalStatuses.includes(event) ||
approvalStatuses.includes(chunkStatus)
) {
flushTypingQueueForThread(threadId)
ts.isStreaming = false

View File

@ -2,60 +2,83 @@
<transition name="slide-up">
<div v-if="visible" class="approval-modal">
<div class="approval-content">
<div class="approval-header">
<h4>{{ question }}</h4>
</div>
<div v-if="operation" class="approval-operation">
<span class="label">操作</span>
<span class="operation-text">{{ operation }}</span>
</div>
<div class="question-options">
<label
v-for="(item, index) in options"
:key="`${item.value}-${index}`"
class="option-item"
>
<input
v-if="multiSelect"
type="checkbox"
:value="item.value"
:checked="selectedValues.includes(item.value)"
:disabled="isProcessing"
@change="toggleSelect(item.value)"
/>
<input
v-else
type="radio"
name="approval-option"
:value="item.value"
:checked="selectedValues[0] === item.value"
:disabled="isProcessing"
@change="setSingle(item.value)"
/>
<span
:class="{ recommended: index === 0 && String(item.label).includes('(Recommended)') }"
>
{{ item.label }}
</span>
</label>
</div>
<div v-if="allowOther" class="other-input">
<input
v-model.trim="otherText"
type="text"
<div v-if="normalizedQuestions.length > 1" class="question-tabs">
<button
v-for="(questionItem, questionIndex) in normalizedQuestions"
:key="questionItem.questionId"
class="tab-item"
:class="{
active: questionIndex === activeQuestionIndex,
completed: isQuestionAnswered(questionItem)
}"
:disabled="isProcessing"
placeholder="Other: 输入自定义答案"
/>
@click="setActiveQuestion(questionIndex)"
>
<span class="tab-index">{{ questionIndex + 1 }}</span>
</button>
</div>
<div v-if="activeQuestion" class="question-block">
<div class="approval-header">
<h4>
{{ activeQuestionIndex + 1 }}. {{ activeQuestion.question }}
</h4>
</div>
<div v-if="activeQuestion.operation" class="approval-operation">
<span class="label">操作</span>
<span class="operation-text">{{ activeQuestion.operation }}</span>
</div>
<div class="question-options">
<label
v-for="(optionItem, optionIndex) in activeQuestion.options"
:key="`${activeQuestion.questionId}-${optionItem.value}-${optionIndex}`"
class="option-item"
>
<input
v-if="activeQuestion.multiSelect"
type="checkbox"
:value="optionItem.value"
:checked="getSelected(activeQuestion.questionId).includes(optionItem.value)"
:disabled="isProcessing"
@change="toggleSelect(activeQuestion.questionId, optionItem.value)"
/>
<input
v-else
type="radio"
:name="`approval-option-${activeQuestion.questionId}`"
:value="optionItem.value"
:checked="getSelected(activeQuestion.questionId)[0] === optionItem.value"
:disabled="isProcessing"
@change="setSingle(activeQuestion.questionId, optionItem.value)"
/>
<span
:class="{
recommended:
optionIndex === 0 && String(optionItem.label).includes('(Recommended)')
}"
>
{{ optionItem.label }}
</span>
</label>
<div v-if="shouldShowOtherInput(activeQuestion)" class="other-input">
<input
v-model.trim="otherTexts[activeQuestion.questionId]"
type="text"
:disabled="isProcessing"
placeholder="其他:请输入自定义内容"
/>
</div>
</div>
</div>
</div>
<div class="approval-actions">
<button class="btn btn-reject" @click="handleCancel" :disabled="isProcessing">取消</button>
<button class="btn btn-approve" @click="handleSubmit" :disabled="isSubmitDisabled">
提交
<button class="btn btn-approve" @click="handlePrimaryAction" :disabled="isPrimaryButtonDisabled">
{{ primaryButtonText }}
</button>
</div>
@ -69,31 +92,111 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { isOtherOption, normalizeQuestions, DEFAULT_OTHER_OPTION_VALUE } from '@/utils/questionUtils'
const props = defineProps({
visible: { type: Boolean, default: false },
question: { type: String, default: '请选择一个选项' },
operation: { type: String, default: '' },
options: { type: Array, default: () => [] },
multiSelect: { type: Boolean, default: false },
allowOther: { type: Boolean, default: true }
questions: { type: Array, default: () => [] }
})
const emit = defineEmits(['submit', 'cancel'])
const isProcessing = ref(false)
const selectedValues = ref([])
const otherText = ref('')
const activeQuestionIndex = ref(0)
const selectedValues = ref({})
const otherTexts = ref({})
const normalizedQuestions = computed(() => {
const questions = normalizeQuestions(props.questions)
// otherOptionValue
return questions.map((q) => {
const otherOption = q.options.find((opt) => isOtherOption(opt))
return {
...q,
otherOptionValue: otherOption?.value || DEFAULT_OTHER_OPTION_VALUE
}
})
})
const activeQuestion = computed(() => {
if (normalizedQuestions.value.length === 0) return null
const index = Math.min(activeQuestionIndex.value, normalizedQuestions.value.length - 1)
return normalizedQuestions.value[index]
})
const resetForm = () => {
isProcessing.value = false
selectedValues.value = []
otherText.value = ''
activeQuestionIndex.value = 0
selectedValues.value = {}
otherTexts.value = {}
}
const setActiveQuestion = (index) => {
if (isProcessing.value) return
if (index < 0 || index >= normalizedQuestions.value.length) return
activeQuestionIndex.value = index
}
const syncAnswersWithQuestions = () => {
const nextSelectedValues = {}
const nextOtherTexts = {}
normalizedQuestions.value.forEach((questionItem) => {
const questionId = questionItem.questionId
const previousSelected = Array.isArray(selectedValues.value[questionId])
? selectedValues.value[questionId]
: []
const validSelected = previousSelected.filter((value) =>
questionItem.options.some((option) => option?.value === value)
)
if (questionItem.multiSelect) {
nextSelectedValues[questionId] = validSelected
} else {
const current = validSelected[0]
if (current) {
nextSelectedValues[questionId] = [current]
} else if (questionItem.options.length > 0) {
nextSelectedValues[questionId] = [questionItem.options[0].value]
} else {
nextSelectedValues[questionId] = []
}
}
const text = String(otherTexts.value[questionId] || '').trim()
if (text) {
nextOtherTexts[questionId] = text
}
})
selectedValues.value = nextSelectedValues
otherTexts.value = nextOtherTexts
}
const getSelected = (questionId) => {
const selected = selectedValues.value[questionId]
return Array.isArray(selected) ? selected : []
}
const isQuestionOtherSelected = (questionItem) => {
const selected = getSelected(questionItem.questionId)
return selected.includes(questionItem.otherOptionValue)
}
const shouldShowOtherInput = (questionItem) => {
if (!questionItem || !questionItem.allowOther) return false
return isQuestionOtherSelected(questionItem)
}
watch(
() => props.visible,
(newVal) => {
if (newVal) {
activeQuestionIndex.value = 0
return
}
if (!newVal) {
resetForm()
}
@ -101,78 +204,119 @@ watch(
)
watch(
() => props.options,
(next) => {
if (!Array.isArray(next) || next.length === 0) {
selectedValues.value = []
return
normalizedQuestions,
() => {
syncAnswersWithQuestions()
if (activeQuestionIndex.value >= normalizedQuestions.value.length) {
activeQuestionIndex.value = Math.max(0, normalizedQuestions.value.length - 1)
}
if (props.multiSelect) {
selectedValues.value = selectedValues.value.filter((value) =>
next.some((item) => item?.value === value)
)
return
}
const current = selectedValues.value[0]
if (current && next.some((item) => item?.value === current)) return
selectedValues.value = [next[0].value]
},
{ immediate: true, deep: true }
)
watch(
() => props.multiSelect,
(isMulti) => {
if (isMulti) return
if (selectedValues.value.length > 1) {
selectedValues.value = selectedValues.value.slice(0, 1)
}
}
)
const toggleSelect = (value) => {
const toggleSelect = (questionId, value) => {
if (isProcessing.value) return
if (selectedValues.value.includes(value)) {
selectedValues.value = selectedValues.value.filter((item) => item !== value)
const current = getSelected(questionId)
if (current.includes(value)) {
selectedValues.value[questionId] = current.filter((item) => item !== value)
} else {
selectedValues.value = [...selectedValues.value, value]
selectedValues.value[questionId] = [...current, value]
}
}
const setSingle = (value) => {
const setSingle = (questionId, value) => {
if (isProcessing.value) return
selectedValues.value = [value]
selectedValues.value[questionId] = [value]
}
const isQuestionAnswered = (questionItem) => {
const selected = getSelected(questionItem.questionId)
if (selected.length === 0) return false
const other = String(otherTexts.value[questionItem.questionId] || '').trim()
if (questionItem.allowOther && isQuestionOtherSelected(questionItem)) {
return Boolean(other)
}
return true
}
const isSubmitDisabled = computed(() => {
if (isProcessing.value) return true
const hasSelected = selectedValues.value.length > 0
const hasOther = props.allowOther && Boolean(otherText.value)
return !hasSelected && !hasOther
if (normalizedQuestions.value.length === 0) return true
return normalizedQuestions.value.some((questionItem) => !isQuestionAnswered(questionItem))
})
const buildAnswer = () => {
const selected = selectedValues.value
const other = otherText.value
if (props.allowOther && other) {
const isLastQuestion = computed(() => {
if (normalizedQuestions.value.length === 0) return true
return activeQuestionIndex.value >= normalizedQuestions.value.length - 1
})
const isCurrentQuestionAnswered = computed(() => {
if (!activeQuestion.value) return false
return isQuestionAnswered(activeQuestion.value)
})
const primaryButtonText = computed(() => (isLastQuestion.value ? '提交' : '下一项'))
const isPrimaryButtonDisabled = computed(() => {
if (isProcessing.value) return true
if (!activeQuestion.value) return true
if (isLastQuestion.value) {
return isSubmitDisabled.value
}
return !isCurrentQuestionAnswered.value
})
const buildQuestionAnswer = (questionItem) => {
const selected = getSelected(questionItem.questionId)
const other = String(otherTexts.value[questionItem.questionId] || '').trim()
if (questionItem.allowOther && isQuestionOtherSelected(questionItem)) {
const selectedWithoutOther = selected.filter((value) => value !== questionItem.otherOptionValue)
return {
type: 'other',
text: other,
selected: selected
selected: selectedWithoutOther
}
}
if (props.multiSelect) {
if (questionItem.multiSelect) {
return selected
}
return selected[0]
}
const buildAnswer = () => {
const answer = {}
normalizedQuestions.value.forEach((questionItem) => {
answer[questionItem.questionId] = buildQuestionAnswer(questionItem)
})
return answer
}
const handleSubmit = () => {
if (isSubmitDisabled.value) return
isProcessing.value = true
emit('submit', buildAnswer())
}
const handlePrimaryAction = () => {
if (isPrimaryButtonDisabled.value) return
if (isLastQuestion.value) {
handleSubmit()
return
}
setActiveQuestion(activeQuestionIndex.value + 1)
}
const handleCancel = () => {
if (isProcessing.value) return
emit('cancel')
@ -186,7 +330,8 @@ const handleCancel = () => {
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.12);
margin: 0 auto 8px;
max-width: 800px;
width: 100%;
min-width: 360px;
width: fit-content;
border: 1px solid var(--gray-200);
}
@ -194,9 +339,67 @@ const handleCancel = () => {
padding: 16px 20px;
}
.question-tabs {
display: flex;
flex-wrap: nowrap;
justify-content: center;
gap: 8px;
margin-bottom: 14px;
width: 100%;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 2px;
box-sizing: border-box;
overscroll-behavior-x: contain;
}
.tab-item {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
min-width: 36px;
width: 36px;
height: 30px;
border: 1px solid var(--gray-200);
background: var(--gray-25);
color: var(--gray-700);
border-radius: 8px;
padding: 0;
font-size: 12px;
cursor: pointer;
}
.tab-item:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.tab-item.active {
border-color: var(--main-color);
background: var(--main-50);
color: var(--main-700);
}
.tab-item.completed .tab-index {
color: var(--green-700);
border-color: var(--green-200);
background: var(--green-50);
}
.tab-index {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: var(--gray-600);
}
.approval-header {
display: flex;
justify-content: center;
justify-content: flex-start;
align-items: center;
margin-bottom: 12px;
}
@ -206,7 +409,7 @@ const handleCancel = () => {
font-size: 15px;
font-weight: 500;
color: var(--gray-800);
text-align: center;
text-align: left;
}
.approval-operation {
@ -350,10 +553,27 @@ const handleCancel = () => {
}
@media (max-width: 520px) {
.approval-modal {
width: calc(100vw - 12px);
min-width: 0;
}
.approval-content {
padding: 12px 16px;
}
.tab-item {
min-width: 30px;
width: 30px;
height: 26px;
padding: 0;
font-size: 11px;
}
.tab-index {
font-size: 10px;
}
.approval-header h4 {
font-size: 14px;
}

View File

@ -4,7 +4,7 @@
<div class="sep-header">
<span class="note">提问</span>
<span class="separator">|</span>
<span class="description">{{ shortQuestion }}</span>
<span class="description">{{ shortQuestionSummary }}</span>
<span v-if="userAnswer" class="tag tag-answered"> 已回答: {{ displayAnswer }} </span>
</div>
</template>
@ -46,10 +46,30 @@ const parsedResult = computed(() => {
}
})
const question = computed(() => parsedArgs.value.question || '')
const shortQuestion = computed(() => {
const q = question.value
return q.length > 50 ? q.slice(0, 50) + '...' : q
const questions = computed(() => {
const rawQuestions = parsedArgs.value.questions
if (!Array.isArray(rawQuestions)) return []
return rawQuestions
.map((item) => {
if (!item || typeof item !== 'object') return null
const question = String(item.question || '').trim()
if (!question) return null
const questionId = String(item.question_id || item.questionId || '').trim()
return { questionId, question }
})
.filter(Boolean)
})
const shortQuestionSummary = computed(() => {
if (!questions.value.length) return '无问题'
const firstQuestion = questions.value[0].question
const shortFirstQuestion =
firstQuestion.length > 36 ? firstQuestion.slice(0, 36) + '...' : firstQuestion
if (questions.value.length === 1) return shortFirstQuestion
return `${shortFirstQuestion}${questions.value.length}`
})
//
@ -59,13 +79,46 @@ const userAnswer = computed(() => {
return result.user_answer || result.answer || null
})
const formatSingleAnswer = (answer) => {
if (Array.isArray(answer)) {
return answer.join(', ')
}
if (answer && typeof answer === 'object') {
if (answer.type === 'other') {
return `Other: ${String(answer.text || '').trim()}`
}
return JSON.stringify(answer)
}
return String(answer)
}
//
const displayAnswer = computed(() => {
const answer = userAnswer.value
if (!answer) return ''
if (Array.isArray(answer)) {
return answer.join(', ')
}
if (answer && typeof answer === 'object') {
if (answer.type === 'other') {
return `Other: ${String(answer.text || '').trim()}`
}
const entries = Object.entries(answer)
if (!entries.length) return ''
const summary = entries
.map(([questionId, value]) => {
const title = String(questionId || '').trim()
return `${title}: ${formatSingleAnswer(value)}`
})
.join(' | ')
return summary.length > 120 ? summary.slice(0, 120) + '...' : summary
}
return String(answer)
})
</script>

View File

@ -2,55 +2,56 @@ import { reactive } from 'vue'
import { message } from 'ant-design-vue'
import { handleChatError } from '@/utils/errorHandler'
import { agentApi } from '@/apis'
const normalizeOptions = (rawOptions) => {
if (!Array.isArray(rawOptions)) return []
return rawOptions
.map((item) => {
if (item && typeof item === 'object') {
const label = String(item.label || item.value || '').trim()
const value = String(item.value || item.label || '').trim()
return label && value ? { label, value } : null
}
const text = String(item || '').trim()
return text ? { label: text, value: text } : null
})
.filter(Boolean)
}
import { normalizeQuestions, buildLegacyQuestion } from '@/utils/questionUtils'
const extractQuestionPayload = (chunk) => {
const interruptInfo = chunk?.interrupt_info || {}
const rawOptions = chunk?.options || interruptInfo?.options || []
const options = normalizeOptions(rawOptions)
const operation = chunk?.operation || interruptInfo?.operation || ''
const rawQuestions = chunk?.questions || interruptInfo?.questions || []
const source = chunk?.source || interruptInfo?.source || 'interrupt'
const multiSelect = Boolean(chunk?.multi_select ?? interruptInfo?.multi_select ?? false)
const allowOther = Boolean(chunk?.allow_other ?? interruptInfo?.allow_other ?? true)
const questionId = chunk?.question_id || interruptInfo?.question_id || ''
const question = chunk?.question || interruptInfo?.question || '请选择一个选项'
let questions = normalizeQuestions(rawQuestions)
if (!questions.length) {
const legacyQuestion = buildLegacyQuestion(chunk, interruptInfo)
if (legacyQuestion) {
questions = [legacyQuestion]
}
}
return {
questionId,
question,
options,
multiSelect,
allowOther,
source,
operation
questions,
source
}
}
const parseApprovedDecision = (answer) => {
const parseFromValue = (value) => {
if (typeof value === 'boolean') return value
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase()
if (normalized === 'approve' || normalized === 'approved' || normalized === 'true') return true
if (normalized === 'reject' || normalized === 'rejected' || normalized === 'false') return false
}
return null
}
const direct = parseFromValue(answer)
if (direct !== null) return direct
if (answer && typeof answer === 'object' && !Array.isArray(answer)) {
const values = Object.values(answer)
if (values.length === 1) {
return parseFromValue(values[0])
}
}
return null
}
export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessages }) {
const approvalState = reactive({
showModal: false,
questionId: '',
question: '',
operation: '',
options: [],
multiSelect: false,
allowOther: true,
source: '',
questions: [],
status: '',
threadId: null
})
@ -82,10 +83,20 @@ export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessa
const requestBody = {
thread_id: threadId,
answer,
config: agentConfigId ? { agent_config_id: agentConfigId } : {}
}
if (approvalState.status === 'human_approval_required') {
const approved = parseApprovedDecision(answer)
if (approved !== null) {
requestBody.approved = approved
} else {
requestBody.answer = answer
}
} else {
requestBody.answer = answer
}
try {
const response = await agentApi.resumeAgentChat(currentAgentId, requestBody, {
signal: threadState.streamAbortController?.signal
@ -120,17 +131,13 @@ export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessa
if (!threadState) return false
const payload = extractQuestionPayload(chunk)
if (!payload.questions.length) return false
threadState.isStreaming = false
approvalState.showModal = true
approvalState.questionId = payload.questionId
approvalState.question = payload.question
approvalState.operation = payload.operation
approvalState.options = payload.options
approvalState.multiSelect = payload.multiSelect
approvalState.allowOther = payload.allowOther
approvalState.source = payload.source
approvalState.questions = payload.questions
approvalState.status = chunk.status || ''
approvalState.threadId = chunk.thread_id || threadId
fetchThreadMessages({ agentId: currentAgentId, threadId })
@ -140,13 +147,8 @@ export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessa
const resetApprovalState = () => {
approvalState.showModal = false
approvalState.questionId = ''
approvalState.question = ''
approvalState.operation = ''
approvalState.options = []
approvalState.multiSelect = false
approvalState.allowOther = true
approvalState.source = ''
approvalState.questions = []
approvalState.status = ''
approvalState.threadId = null
}

View File

@ -0,0 +1,96 @@
/**
* 问题和选项规范化工具
*/
const DEFAULT_OTHER_OPTION_VALUE = '__other__'
/**
* 判断选项是否为"其他"选项
*/
export const isOtherOption = (option) => {
if (!option || typeof option !== 'object') return false
const label = String(option.label || '').trim().toLowerCase()
const value = String(option.value || '').trim().toLowerCase()
return (
value === DEFAULT_OTHER_OPTION_VALUE ||
value === 'other' ||
label.includes('其他') ||
label.includes('other')
)
}
/**
* 规范化选项列表
*/
export const normalizeOptions = (rawOptions) => {
if (!Array.isArray(rawOptions)) return []
return rawOptions
.map((item) => {
if (item && typeof item === 'object') {
const label = String(item.label || item.value || '').trim()
const value = String(item.value || item.label || '').trim()
return label && value ? { label, value } : null
}
const text = String(item || '').trim()
return text ? { label: text, value: text } : null
})
.filter(Boolean)
}
/**
* 规范化问题列表
*/
export const normalizeQuestions = (rawQuestions) => {
if (!Array.isArray(rawQuestions)) return []
return rawQuestions
.map((item, index) => {
if (!item || typeof item !== 'object') return null
const question = String(item.question || '').trim()
if (!question) return null
const questionId = String(item.questionId || item.question_id || '').trim() || `q-${index + 1}`
const operation = String(item.operation || '').trim()
const allowOther = Boolean(item.allowOther ?? item.allow_other ?? true)
const baseOptions = normalizeOptions(item.options || [])
const hasOtherOption = baseOptions.some((option) => isOtherOption(option))
const options = allowOther && !hasOtherOption
? [...baseOptions, { label: '其他', value: DEFAULT_OTHER_OPTION_VALUE }]
: baseOptions
return {
questionId,
question,
options,
multiSelect: Boolean(item.multiSelect ?? item.multi_select ?? false),
allowOther,
operation
}
})
.filter(Boolean)
}
/**
* 从旧格式构建单个问题向后兼容
*/
export const buildLegacyQuestion = (chunk, interruptInfo) => {
const question = String(chunk?.question || interruptInfo?.question || '').trim()
if (!question) return null
const operation = String(chunk?.operation || interruptInfo?.operation || '').trim()
return {
questionId: String(chunk?.question_id || interruptInfo?.question_id || '').trim() || 'q-1',
question,
options: normalizeOptions(chunk?.options || interruptInfo?.options || []),
multiSelect: Boolean(chunk?.multi_select ?? interruptInfo?.multi_select ?? false),
allowOther: Boolean(chunk?.allow_other ?? interruptInfo?.allow_other ?? true),
operation
}
}
export { DEFAULT_OTHER_OPTION_VALUE }