feat: 增强问题工具功能,支持多问题处理
- 新增问题和选项规范化工具函数 (question_utils.py) - 优化 resume_agent_chat 接口的 answer 参数处理,支持列表类型 - 前端 HumanApprovalModal 支持多问题标签页切换 - 改进问题工具组件交互体验 - 添加批量问题测试用例 - 更新文档
This commit is contained in:
parent
1af5b33ccc
commit
f2096b4004
@ -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 数据库。
|
||||
|
||||
这些工具都通过上述注册机制自动加载,开发者无需手动引入。
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
- 检索测试中,添加问答
|
||||
- 探索 subagents 的体系
|
||||
- 集成 Memory,基于 deepagents 的文件后端实现
|
||||
- 探索智能体切换逻辑
|
||||
- 将 后端代码 和 agents 解耦,agents 作为单独的 package 使用
|
||||
|
||||
### Bugs
|
||||
- 部分异常状态下,智能体的模型名称出现重叠[#279](https://github.com/xerrors/Yuxi-Know/issues/279)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
82
src/utils/question_utils.py
Normal file
82
src/utils/question_utils.py
Normal 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)),
|
||||
}
|
||||
64
test/api/test_chat_resume_batch_questions.py
Normal file
64
test/api/test_chat_resume_batch_questions.py
Normal 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
|
||||
@ -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:
|
||||
|
||||
@ -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} - 恢复响应流
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
96
web/src/utils/questionUtils.js
Normal file
96
web/src/utils/questionUtils.js
Normal 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 }
|
||||
Loading…
Reference in New Issue
Block a user