From f2096b4004899059fe85361b828cce35f76d3a76 Mon Sep 17 00:00:00 2001 From: Wenjie Zhang Date: Tue, 17 Mar 2026 03:39:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E9=97=AE=E9=A2=98?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E5=A4=9A=E9=97=AE=E9=A2=98=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增问题和选项规范化工具函数 (question_utils.py) - 优化 resume_agent_chat 接口的 answer 参数处理,支持列表类型 - 前端 HumanApprovalModal 支持多问题标签页切换 - 改进问题工具组件交互体验 - 添加批量问题测试用例 - 更新文档 --- docs/latest/agents/tools-system.md | 2 +- docs/latest/changelog/roadmap.md | 2 +- server/routers/chat_router.py | 45 +- src/agents/common/toolkits/buildin/tools.py | 58 +-- src/services/chat_stream_service.py | 81 ++-- src/services/run_worker.py | 9 +- src/utils/question_utils.py | 82 ++++ test/api/test_chat_resume_batch_questions.py | 64 +++ test/test_chat_stream_interrupt.py | 132 ++++-- web/src/apis/agent_api.js | 2 +- web/src/components/AgentChatComponent.vue | 19 +- web/src/components/HumanApprovalModal.vue | 418 +++++++++++++----- .../tools/AskUserQuestionTool.vue | 63 ++- web/src/composables/useApproval.js | 106 ++--- web/src/utils/questionUtils.js | 96 ++++ 15 files changed, 908 insertions(+), 271 deletions(-) create mode 100644 src/utils/question_utils.py create mode 100644 test/api/test_chat_resume_batch_questions.py create mode 100644 web/src/utils/questionUtils.js diff --git a/docs/latest/agents/tools-system.md b/docs/latest/agents/tools-system.md index 43f91030..da6a7ad7 100644 --- a/docs/latest/agents/tools-system.md +++ b/docs/latest/agents/tools-system.md @@ -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 数据库。 这些工具都通过上述注册机制自动加载,开发者无需手动引入。 diff --git a/docs/latest/changelog/roadmap.md b/docs/latest/changelog/roadmap.md index 4a7b5276..0b08dd9e 100644 --- a/docs/latest/changelog/roadmap.md +++ b/docs/latest/changelog/roadmap.md @@ -13,7 +13,7 @@ - 检索测试中,添加问答 - 探索 subagents 的体系 - 集成 Memory,基于 deepagents 的文件后端实现 -- 探索智能体切换逻辑 +- 将 后端代码 和 agents 解耦,agents 作为单独的 package 使用 ### Bugs - 部分异常状态下,智能体的模型名称出现重叠[#279](https://github.com/xerrors/Yuxi-Know/issues/279) diff --git a/server/routers/chat_router.py b/server/routers/chat_router.py index 1c750e04..c5231c30 100644 --- a/server/routers/chat_router.py +++ b/server/routers/chat_router.py @@ -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" diff --git a/src/agents/common/toolkits/buildin/tools.py b/src/agents/common/toolkits/buildin/tools.py index cdd4ae3c..95740600 100644 --- a/src/agents/common/toolkits/buildin/tools.py +++ b/src/agents/common/toolkits/buildin/tools.py @@ -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 个问题,每项包含:question、options、multi_select、allow_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(多选)或 object(Other 文本)。 +answer 为 object,格式为 {question_id: answer}。 +其中 answer 可能是 string(单选)、list(多选)或 object(Other 文本)。 """ @@ -102,37 +104,43 @@ answer 可能是 string(单选)、list(多选)或 object(Other 文本 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, } diff --git a/src/services/chat_stream_service.py b/src/services/chat_stream_service.py index 9133ad7a..39b259be 100644 --- a/src/services/chat_stream_service.py +++ b/src/services/chat_stream_service.py @@ -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, } diff --git a/src/services/run_worker.py b/src/services/run_worker.py index ebd6403f..46cd4dd0 100644 --- a/src/services/run_worker.py +++ b/src/services/run_worker.py @@ -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 diff --git a/src/utils/question_utils.py b/src/utils/question_utils.py new file mode 100644 index 00000000..1ec6904f --- /dev/null +++ b/src/utils/question_utils.py @@ -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)), + } diff --git a/test/api/test_chat_resume_batch_questions.py b/test/api/test_chat_resume_batch_questions.py new file mode 100644 index 00000000..bf173686 --- /dev/null +++ b/test/api/test_chat_resume_batch_questions.py @@ -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 diff --git a/test/test_chat_stream_interrupt.py b/test/test_chat_stream_interrupt.py index cc9edd1d..bbdd9890 100644 --- a/test/test_chat_stream_interrupt.py +++ b/test/test_chat_stream_interrupt.py @@ -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: diff --git a/web/src/apis/agent_api.js b/web/src/apis/agent_api.js index f1dba43e..57f1bcb9 100644 --- a/web/src/apis/agent_api.js +++ b/web/src/apis/agent_api.js @@ -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} - 恢复响应流 */ diff --git a/web/src/components/AgentChatComponent.vue b/web/src/components/AgentChatComponent.vue index 89386291..af330825 100644 --- a/web/src/components/AgentChatComponent.vue +++ b/web/src/components/AgentChatComponent.vue @@ -114,11 +114,7 @@ @@ -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 diff --git a/web/src/components/HumanApprovalModal.vue b/web/src/components/HumanApprovalModal.vue index 5e6234fa..a60be43f 100644 --- a/web/src/components/HumanApprovalModal.vue +++ b/web/src/components/HumanApprovalModal.vue @@ -2,60 +2,83 @@
-
-

{{ question }}

-
- -
- 操作: - {{ operation }} -
- -
- -
- -
- + +
+ +
+
+

+ {{ activeQuestionIndex + 1 }}. {{ activeQuestion.question }} +

+
+ +
+ 操作: + {{ activeQuestion.operation }} +
+ +
+ + +
+ +
+
-
@@ -69,31 +92,111 @@ diff --git a/web/src/composables/useApproval.js b/web/src/composables/useApproval.js index ac69b0a8..a0a1a951 100644 --- a/web/src/composables/useApproval.js +++ b/web/src/composables/useApproval.js @@ -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 } diff --git a/web/src/utils/questionUtils.js b/web/src/utils/questionUtils.js new file mode 100644 index 00000000..62ef4b9d --- /dev/null +++ b/web/src/utils/questionUtils.js @@ -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 }