Merge branch 'main' of https://github.com/xerrors/Yuxi-Know
This commit is contained in:
commit
4ea9f8b15e
@ -1,5 +1,6 @@
|
|||||||
import traceback
|
import traceback
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, UploadFile, File
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query, UploadFile, File
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
@ -502,19 +503,57 @@ async def update_chat_models(model_provider: str, model_names: list[str], curren
|
|||||||
async def resume_agent_chat(
|
async def resume_agent_chat(
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
thread_id: str = Body(...),
|
thread_id: str = Body(...),
|
||||||
approved: bool = Body(...),
|
approved: bool | None = Body(None),
|
||||||
|
answer: dict | list | str | None = Body(None),
|
||||||
config: dict = Body({}),
|
config: dict = Body({}),
|
||||||
current_user: User = Depends(get_required_user),
|
current_user: User = Depends(get_required_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""恢复被人工审批中断的对话(需要登录)"""
|
"""恢复被人工审批中断的对话(需要登录)"""
|
||||||
logger.info(f"Resuming agent_id: {agent_id}, thread_id: {thread_id}, approved: {approved}")
|
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()
|
||||||
|
if not normalized:
|
||||||
|
raise HTTPException(status_code=422, detail="answer 不能为空")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
if isinstance(raw_answer, list):
|
||||||
|
if len(raw_answer) == 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")
|
||||||
|
if not isinstance(text, str) or not text.strip():
|
||||||
|
raise HTTPException(status_code=422, detail="other 文本不能为空")
|
||||||
|
return raw_answer
|
||||||
|
|
||||||
|
raise HTTPException(status_code=422, detail="answer 类型不支持")
|
||||||
|
|
||||||
|
if raw_approved is not None:
|
||||||
|
return "approve" if raw_approved else "reject"
|
||||||
|
|
||||||
|
raise HTTPException(status_code=422, detail="approved 或 answer 至少提供一个")
|
||||||
|
|
||||||
|
resume_input = normalize_resume_input(answer, approved)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Resuming agent_id: %s, thread_id: %s, approved: %s, answer_type: %s",
|
||||||
|
agent_id,
|
||||||
|
thread_id,
|
||||||
|
approved,
|
||||||
|
type(answer).__name__ if answer is not None else "None",
|
||||||
|
)
|
||||||
|
|
||||||
meta = {
|
meta = {
|
||||||
"agent_id": agent_id,
|
"agent_id": agent_id,
|
||||||
"thread_id": thread_id,
|
"thread_id": thread_id,
|
||||||
"user_id": current_user.id,
|
"user_id": current_user.id,
|
||||||
"approved": approved,
|
"approved": approved,
|
||||||
|
"answer": answer,
|
||||||
|
"resume_input": resume_input,
|
||||||
}
|
}
|
||||||
if "request_id" not in meta or not meta.get("request_id"):
|
if "request_id" not in meta or not meta.get("request_id"):
|
||||||
meta["request_id"] = str(uuid.uuid4())
|
meta["request_id"] = str(uuid.uuid4())
|
||||||
@ -522,7 +561,7 @@ async def resume_agent_chat(
|
|||||||
stream_agent_resume(
|
stream_agent_resume(
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
approved=approved,
|
resume_input=resume_input,
|
||||||
meta=meta,
|
meta=meta,
|
||||||
config=config,
|
config=config,
|
||||||
current_user=current_user,
|
current_user=current_user,
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
# buildin 工具包
|
# buildin 工具包
|
||||||
from .tools import calculator, query_knowledge_graph, text_to_img_qwen_image
|
from .tools import ask_user_question, calculator, query_knowledge_graph, text_to_img_qwen_image
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"ask_user_question",
|
||||||
"calculator",
|
"calculator",
|
||||||
"query_knowledge_graph",
|
"query_knowledge_graph",
|
||||||
"text_to_img_qwen_image",
|
"text_to_img_qwen_image",
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import uuid
|
|||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
from langgraph.types import interrupt
|
||||||
|
|
||||||
from src import config, graph_base
|
from src import config, graph_base
|
||||||
from src.agents.common.toolkits.registry import ToolExtraMetadata, _all_tool_instances, _extra_registry, tool
|
from src.agents.common.toolkits.registry import ToolExtraMetadata, _all_tool_instances, _extra_registry, tool
|
||||||
@ -68,6 +69,73 @@ def calculator(a: float, b: float, operation: str) -> float:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
ASK_USER_QUESTION_DESCRIPTION = """
|
||||||
|
在执行过程中,当你需要用户做决定或补充需求时,使用这个工具向用户提问。
|
||||||
|
|
||||||
|
适用场景:
|
||||||
|
1. 收集用户偏好或需求(例如风格、范围、优先级)
|
||||||
|
2. 澄清模糊指令(存在多种合理解释时)
|
||||||
|
3. 在实现过程中让用户选择方案方向
|
||||||
|
4. 在有明显权衡时让用户做取舍
|
||||||
|
|
||||||
|
使用规范:
|
||||||
|
1. 问题应当简短、具体、可回答,避免开放式长问句
|
||||||
|
2. options 提供 2-5 个有区分度的选项,每项包含 label 和 value
|
||||||
|
3. 若有推荐选项:把推荐项放在第一位,并在 label 末尾加 "(Recommended)"
|
||||||
|
4. 若需要多选:将 multi_select 设为 true
|
||||||
|
5. allow_other 通常保持 true,用户可通过 Other 输入自定义答案
|
||||||
|
|
||||||
|
注意事项:
|
||||||
|
1. 不要用这个工具询问“是否继续执行”“计划是否准备好”这类流程控制问题
|
||||||
|
2. 不要在信息已充分、无需用户决策时滥用该工具
|
||||||
|
3. 先基于现有上下文自行决策,只有关键不确定性时才提问
|
||||||
|
|
||||||
|
返回结果:
|
||||||
|
answer 可能是 string(单选)、list(多选)或 object(Other 文本)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
category="buildin",
|
||||||
|
tags=["交互"],
|
||||||
|
display_name="向用户提问",
|
||||||
|
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,
|
||||||
|
) -> 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})
|
||||||
|
|
||||||
|
interrupt_payload = {
|
||||||
|
"question": question,
|
||||||
|
"question_id": str(uuid.uuid4()),
|
||||||
|
"options": normalized_options,
|
||||||
|
"multi_select": multi_select,
|
||||||
|
"allow_other": allow_other,
|
||||||
|
"source": "ask_user_question",
|
||||||
|
}
|
||||||
|
answer = interrupt(interrupt_payload)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"question": question,
|
||||||
|
"question_id": interrupt_payload["question_id"],
|
||||||
|
"answer": answer,
|
||||||
|
"multi_select": multi_select,
|
||||||
|
"allow_other": allow_other,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
KG_QUERY_DESCRIPTION = """
|
KG_QUERY_DESCRIPTION = """
|
||||||
使用这个工具可以查询知识图谱中包含的三元组信息。
|
使用这个工具可以查询知识图谱中包含的三元组信息。
|
||||||
关键词(query),使用可能帮助回答这个问题的关键词进行查询,不要直接使用用户的原始输入去查询。
|
关键词(query),使用可能帮助回答这个问题的关键词进行查询,不要直接使用用户的原始输入去查询。
|
||||||
|
|||||||
@ -22,7 +22,25 @@ def get_approved_user_goal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 触发人工审批
|
# 触发人工审批
|
||||||
is_approved = interrupt(interrupt_info)
|
interrupt_result = interrupt(interrupt_info)
|
||||||
|
|
||||||
|
if isinstance(interrupt_result, bool):
|
||||||
|
is_approved = interrupt_result
|
||||||
|
elif isinstance(interrupt_result, str):
|
||||||
|
is_approved = interrupt_result.strip().lower() in {"approve", "approved", "true", "yes", "1"}
|
||||||
|
elif isinstance(interrupt_result, list):
|
||||||
|
lowered = {str(item).strip().lower() for item in interrupt_result}
|
||||||
|
is_approved = "approve" in lowered or "approved" in lowered
|
||||||
|
elif isinstance(interrupt_result, dict):
|
||||||
|
selected = interrupt_result.get("selected")
|
||||||
|
if isinstance(selected, list):
|
||||||
|
lowered = {str(item).strip().lower() for item in selected}
|
||||||
|
is_approved = "approve" in lowered or "approved" in lowered
|
||||||
|
else:
|
||||||
|
text = str(interrupt_result.get("text") or "").strip().lower()
|
||||||
|
is_approved = text in {"approve", "approved", "true", "yes", "1"}
|
||||||
|
else:
|
||||||
|
is_approved = bool(interrupt_result)
|
||||||
|
|
||||||
# 返回审批结果
|
# 返回审批结果
|
||||||
if is_approved:
|
if is_approved:
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import traceback
|
|||||||
import uuid
|
import uuid
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from langchain.messages import AIMessage, AIMessageChunk, HumanMessage
|
from langchain.messages import AIMessage, AIMessageChunk, HumanMessage
|
||||||
from langgraph.types import Command
|
from langgraph.types import Command
|
||||||
@ -193,7 +194,7 @@ async def save_messages_from_langgraph_state(
|
|||||||
logger.error(traceback.format_exc())
|
logger.error(traceback.format_exc())
|
||||||
|
|
||||||
|
|
||||||
def _extract_interrupt_info(state) -> dict | None:
|
def _extract_interrupt_info(state) -> Any | None:
|
||||||
"""从 LangGraph state 中提取中断信息"""
|
"""从 LangGraph state 中提取中断信息"""
|
||||||
if hasattr(state, "tasks") and state.tasks:
|
if hasattr(state, "tasks") and state.tasks:
|
||||||
for task in state.tasks:
|
for task in state.tasks:
|
||||||
@ -207,12 +208,73 @@ def _extract_interrupt_info(state) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _get_interrupt_fields(info) -> tuple[str, str]:
|
def _coerce_interrupt_payload(info: Any) -> dict:
|
||||||
"""从中断信息中提取 question 和 operation"""
|
"""将 LangGraph interrupt 对象转换为 dict 结构。"""
|
||||||
defaults = ("是否批准以下操作?", "需要人工审批的操作")
|
|
||||||
if isinstance(info, dict):
|
if isinstance(info, dict):
|
||||||
return info.get("question", defaults[0]), info.get("operation", defaults[1])
|
return info
|
||||||
return getattr(info, "question", defaults[0]), getattr(info, "operation", defaults[1])
|
|
||||||
|
payload = getattr(info, "value", None)
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
return payload
|
||||||
|
|
||||||
|
question = getattr(info, "question", None)
|
||||||
|
operation = getattr(info, "operation", None)
|
||||||
|
result: dict[str, Any] = {}
|
||||||
|
if isinstance(question, str) and question.strip():
|
||||||
|
result["question"] = question
|
||||||
|
if isinstance(operation, str) and operation.strip():
|
||||||
|
result["operation"] = operation
|
||||||
|
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")
|
||||||
|
|
||||||
|
options = _normalize_interrupt_options(payload.get("options"))
|
||||||
|
if not options and isinstance(operation, str) and operation.strip():
|
||||||
|
# 兼容旧版 get_approved_user_goal 的 interrupt 结构
|
||||||
|
options = [
|
||||||
|
{"label": "批准 (Recommended)", "value": "approve"},
|
||||||
|
{"label": "拒绝", "value": "reject"},
|
||||||
|
]
|
||||||
|
source = "get_approved_user_goal"
|
||||||
|
allow_other = False
|
||||||
|
|
||||||
|
return {
|
||||||
|
"question_id": question_id,
|
||||||
|
"question": question,
|
||||||
|
"options": options,
|
||||||
|
"multi_select": multi_select,
|
||||||
|
"allow_other": allow_other,
|
||||||
|
"source": source,
|
||||||
|
"operation": operation if isinstance(operation, str) else "",
|
||||||
|
"thread_id": thread_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _ensure_full_msg(full_msg: AIMessage | None, accumulated_content: list[str]) -> AIMessage | None:
|
def _ensure_full_msg(full_msg: AIMessage | None, accumulated_content: list[str]) -> AIMessage | None:
|
||||||
@ -262,13 +324,9 @@ async def check_and_handle_interrupts(
|
|||||||
|
|
||||||
interrupt_info = _extract_interrupt_info(state)
|
interrupt_info = _extract_interrupt_info(state)
|
||||||
if interrupt_info:
|
if interrupt_info:
|
||||||
question, operation = _get_interrupt_fields(interrupt_info)
|
question_payload = _build_ask_user_question_payload(interrupt_info, thread_id)
|
||||||
meta["interrupt"] = {
|
meta["interrupt"] = question_payload
|
||||||
"question": question,
|
yield make_chunk(status="ask_user_question_required", meta=meta, **question_payload)
|
||||||
"operation": operation,
|
|
||||||
"thread_id": thread_id,
|
|
||||||
}
|
|
||||||
yield make_chunk(status="interrupted", message=question, meta=meta)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking interrupts: {e}")
|
logger.error(f"Error checking interrupts: {e}")
|
||||||
@ -357,6 +415,8 @@ async def stream_agent_chat(
|
|||||||
"agent_config_id": agent_config_id,
|
"agent_config_id": agent_config_id,
|
||||||
"agent_config": agent_config,
|
"agent_config": agent_config,
|
||||||
}
|
}
|
||||||
|
full_msg = None
|
||||||
|
accumulated_content: list[str] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
conv_repo = ConversationRepository(db)
|
conv_repo = ConversationRepository(db)
|
||||||
@ -489,7 +549,7 @@ async def stream_agent_resume(
|
|||||||
*,
|
*,
|
||||||
agent_id: str,
|
agent_id: str,
|
||||||
thread_id: str,
|
thread_id: str,
|
||||||
approved: bool,
|
resume_input: Any,
|
||||||
meta: dict,
|
meta: dict,
|
||||||
config: dict,
|
config: dict,
|
||||||
current_user,
|
current_user,
|
||||||
@ -515,10 +575,10 @@ async def stream_agent_resume(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
init_msg = {"type": "system", "content": f"Resume with approved: {approved}"}
|
init_msg = {"type": "system", "content": f"Resume with input: {resume_input}"}
|
||||||
yield make_resume_chunk(status="init", meta=meta, msg=init_msg)
|
yield make_resume_chunk(status="init", meta=meta, msg=init_msg)
|
||||||
|
|
||||||
resume_command = Command(resume=approved)
|
resume_command = Command(resume=resume_input)
|
||||||
graph = await agent.get_graph()
|
graph = await agent.get_graph()
|
||||||
|
|
||||||
user_id = str(current_user.id)
|
user_id = str(current_user.id)
|
||||||
|
|||||||
@ -275,6 +275,14 @@ async def process_agent_run(ctx, run_id: str):
|
|||||||
error_message=chunk.get("message"),
|
error_message=chunk.get("message"),
|
||||||
)
|
)
|
||||||
terminal_set = True
|
terminal_set = True
|
||||||
|
elif status == "ask_user_question_required":
|
||||||
|
await mark_run_terminal(
|
||||||
|
run_id,
|
||||||
|
"interrupted",
|
||||||
|
error_type="ask_user_question_required",
|
||||||
|
error_message=chunk.get("question") or "需要用户回答问题",
|
||||||
|
)
|
||||||
|
terminal_set = True
|
||||||
|
|
||||||
if await run_ctx.is_cancelled():
|
if await run_ctx.is_cancelled():
|
||||||
raise asyncio.CancelledError(f"run {run_id} cancelled")
|
raise asyncio.CancelledError(f"run {run_id} cancelled")
|
||||||
|
|||||||
@ -171,6 +171,7 @@ class PostgresManager(metaclass=SingletonMeta):
|
|||||||
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS tool_dependencies JSONB DEFAULT '[]'::jsonb",
|
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS tool_dependencies JSONB DEFAULT '[]'::jsonb",
|
||||||
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS mcp_dependencies JSONB DEFAULT '[]'::jsonb",
|
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS mcp_dependencies JSONB DEFAULT '[]'::jsonb",
|
||||||
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS skill_dependencies JSONB DEFAULT '[]'::jsonb",
|
"ALTER TABLE IF EXISTS skills ADD COLUMN IF NOT EXISTS skill_dependencies JSONB DEFAULT '[]'::jsonb",
|
||||||
|
"ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS is_pinned BOOLEAN NOT NULL DEFAULT FALSE",
|
||||||
"ALTER TABLE IF EXISTS mcp_servers ADD COLUMN IF NOT EXISTS env JSONB",
|
"ALTER TABLE IF EXISTS mcp_servers ADD COLUMN IF NOT EXISTS env JSONB",
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS agent_runs (
|
CREATE TABLE IF NOT EXISTS agent_runs (
|
||||||
@ -192,6 +193,7 @@ class PostgresManager(metaclass=SingletonMeta):
|
|||||||
"CREATE INDEX IF NOT EXISTS idx_agent_runs_user_created ON agent_runs(user_id, created_at DESC)",
|
"CREATE INDEX IF NOT EXISTS idx_agent_runs_user_created ON agent_runs(user_id, created_at DESC)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_agent_runs_thread_created ON agent_runs(thread_id, created_at DESC)",
|
"CREATE INDEX IF NOT EXISTS idx_agent_runs_thread_created ON agent_runs(thread_id, created_at DESC)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_agent_runs_status_updated ON agent_runs(status, updated_at)",
|
"CREATE INDEX IF NOT EXISTS idx_agent_runs_status_updated ON agent_runs(status, updated_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_conversations_is_pinned ON conversations(is_pinned)",
|
||||||
]
|
]
|
||||||
async with self.async_engine.begin() as conn:
|
async with self.async_engine.begin() as conn:
|
||||||
for stmt in stmts:
|
for stmt in stmts:
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import sys
|
|||||||
# Add project root to path
|
# Add project root to path
|
||||||
sys.path.append(os.getcwd())
|
sys.path.append(os.getcwd())
|
||||||
|
|
||||||
from src.knowledge.services.upload_graph_service import UploadGraphService
|
from src.knowledge.graphs.upload_graph_service import UploadGraphService
|
||||||
|
|
||||||
# For backward compatibility with the existing test
|
# For backward compatibility with the existing test
|
||||||
GraphDatabase = UploadGraphService
|
GraphDatabase = UploadGraphService
|
||||||
@ -40,64 +40,68 @@ async def test_txt_add_vector_entity_parsing():
|
|||||||
mock_embed_model = MagicMock()
|
mock_embed_model = MagicMock()
|
||||||
mock_select_model.return_value = mock_embed_model
|
mock_select_model.return_value = mock_embed_model
|
||||||
|
|
||||||
# Instantiate GraphDatabase with mocked driver
|
# Create a mock connection object with driver and status as attributes
|
||||||
# We also need to patch Neo4jConnectionManager in the init
|
# (not a ConnectionManager class, just a simple mock object)
|
||||||
with patch("src.knowledge.services.upload_graph_service.Neo4jConnectionManager") as mock_connection_manager:
|
mock_connection = MagicMock()
|
||||||
# Mock the connection manager to return our mocked driver
|
mock_connection.driver = mock_driver
|
||||||
mock_connection_manager.return_value.driver = mock_driver
|
mock_connection.status = "open"
|
||||||
mock_connection_manager.return_value.status = "open"
|
|
||||||
|
|
||||||
gd = GraphDatabase(mock_connection_manager.return_value)
|
# Instantiate GraphDatabase with mocked connection
|
||||||
# Manually set properties just in case init didn't work as expected due to other mocks
|
gd = GraphDatabase(mock_connection)
|
||||||
gd.driver = mock_driver
|
# Set embed_model_name directly (this is a settable attribute)
|
||||||
gd.status = "open"
|
gd.embed_model_name = "test_model"
|
||||||
gd.embed_model_name = "test_model" # avoid config check issues if possible
|
|
||||||
|
|
||||||
# Mock config to match
|
# Mock config where it's imported in upload_graph_service.py
|
||||||
with patch("src.config.embed_model", "test_model"):
|
# The import is `from src import config`, so patch at the usage location
|
||||||
with patch("src.config.embed_model_names", {"test_model": MagicMock(dimension=1024)}):
|
mock_config = MagicMock()
|
||||||
# Test data: Mixed format
|
mock_config.embed_model = "test_model"
|
||||||
triples = [
|
mock_embed_info = MagicMock()
|
||||||
# Legacy format
|
mock_embed_info.dimension = 1024
|
||||||
{"h": "A", "r": "KNOWS", "t": "B"},
|
mock_config.embed_model_names = {"test_model": mock_embed_info}
|
||||||
# Extended format
|
|
||||||
{
|
|
||||||
"h": {"name": "C", "age": 30},
|
|
||||||
"r": {"type": "LIKES", "weight": 0.8},
|
|
||||||
"t": {"name": "D", "role": "User"},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
# Run the method
|
with patch("src.knowledge.graphs.upload_graph_service.config", mock_config):
|
||||||
await gd.txt_add_vector_entity(triples)
|
# Test data: Mixed format
|
||||||
|
triples = [
|
||||||
|
# Legacy format
|
||||||
|
{"h": "A", "r": "KNOWS", "t": "B"},
|
||||||
|
# Extended format
|
||||||
|
{
|
||||||
|
"h": {"name": "C", "age": 30},
|
||||||
|
"r": {"type": "LIKES", "weight": 0.8},
|
||||||
|
"t": {"name": "D", "role": "User"},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
# Verify calls to mock_tx.run
|
# Run the method
|
||||||
merge_calls = []
|
await gd.txt_add_vector_entity(triples)
|
||||||
for call in mock_tx.run.call_args_list:
|
|
||||||
args, kwargs = call
|
|
||||||
query = args[0] if args else kwargs.get("query", "")
|
|
||||||
if "MERGE (h:Entity:Upload" in query:
|
|
||||||
# The args are passed as kwargs to run: h_name=..., etc.
|
|
||||||
merge_calls.append(kwargs)
|
|
||||||
|
|
||||||
assert len(merge_calls) == 2, f"Expected 2 merge calls, got {len(merge_calls)}"
|
# Verify calls to mock_tx.run
|
||||||
|
merge_calls = []
|
||||||
|
for call in mock_tx.run.call_args_list:
|
||||||
|
args, kwargs = call
|
||||||
|
query = args[0] if args else kwargs.get("query", "")
|
||||||
|
if "MERGE (h:Entity:Upload" in query:
|
||||||
|
# The args are passed as kwargs to run: h_name=..., etc.
|
||||||
|
merge_calls.append(kwargs)
|
||||||
|
|
||||||
# Call 1 (Legacy)
|
assert len(merge_calls) == 2, f"Expected 2 merge calls, got {len(merge_calls)}"
|
||||||
call1 = merge_calls[0]
|
|
||||||
assert call1["h_name"] == "A"
|
|
||||||
assert call1["h_props"] == {}
|
|
||||||
assert call1["t_name"] == "B"
|
|
||||||
assert call1["t_props"] == {}
|
|
||||||
assert call1["r_type"] == "KNOWS"
|
|
||||||
assert call1["r_props"] == {}
|
|
||||||
|
|
||||||
# Call 2 (Extended)
|
# Call 1 (Legacy)
|
||||||
call2 = merge_calls[1]
|
call1 = merge_calls[0]
|
||||||
assert call2["h_name"] == "C"
|
assert call1["h_name"] == "A"
|
||||||
assert call2["h_props"] == {"age": 30}
|
assert call1["h_props"] == {}
|
||||||
assert call2["t_name"] == "D"
|
assert call1["t_name"] == "B"
|
||||||
assert call2["t_props"] == {"role": "User"}
|
assert call1["t_props"] == {}
|
||||||
assert call2["r_type"] == "LIKES"
|
assert call1["r_type"] == "KNOWS"
|
||||||
assert call2["r_props"] == {"weight": 0.8}
|
assert call1["r_props"] == {}
|
||||||
|
|
||||||
print("Verification passed!")
|
# Call 2 (Extended)
|
||||||
|
call2 = merge_calls[1]
|
||||||
|
assert call2["h_name"] == "C"
|
||||||
|
assert call2["h_props"] == {"age": 30}
|
||||||
|
assert call2["t_name"] == "D"
|
||||||
|
assert call2["t_props"] == {"role": "User"}
|
||||||
|
assert call2["r_type"] == "LIKES"
|
||||||
|
assert call2["r_props"] == {"weight": 0.8}
|
||||||
|
|
||||||
|
print("Verification passed!")
|
||||||
|
|||||||
@ -1,286 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from langchain_core.messages import SystemMessage, ToolMessage
|
|
||||||
from langgraph.types import Command
|
|
||||||
|
|
||||||
import src.agents.common.middlewares.runtime_config_middleware as runtime_middleware
|
|
||||||
from src.agents.common.middlewares.runtime_config_middleware import RuntimeConfigMiddleware
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _FakeTool:
|
|
||||||
name: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _FakeRequest:
|
|
||||||
runtime: Any
|
|
||||||
tools: list[Any]
|
|
||||||
system_message: SystemMessage
|
|
||||||
state: dict[str, Any]
|
|
||||||
|
|
||||||
def override(self, **kwargs):
|
|
||||||
return _FakeRequest(
|
|
||||||
runtime=kwargs.get("runtime", self.runtime),
|
|
||||||
tools=kwargs.get("tools", self.tools),
|
|
||||||
system_message=kwargs.get("system_message", self.system_message),
|
|
||||||
state=kwargs.get("state", self.state),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class _FakeToolCallRequest:
|
|
||||||
tool_call: dict[str, Any]
|
|
||||||
runtime: Any
|
|
||||||
state: dict[str, Any]
|
|
||||||
|
|
||||||
|
|
||||||
async def _echo_handler(request):
|
|
||||||
return request
|
|
||||||
|
|
||||||
|
|
||||||
def _build_request(
|
|
||||||
*,
|
|
||||||
skills: list[str],
|
|
||||||
tools: list[str],
|
|
||||||
system_prompt: str = "你是助手",
|
|
||||||
state: dict[str, Any] | None = None,
|
|
||||||
skill_snapshot: dict[str, Any] | None = None,
|
|
||||||
) -> _FakeRequest:
|
|
||||||
context = SimpleNamespace(
|
|
||||||
system_prompt=system_prompt,
|
|
||||||
skills=skills,
|
|
||||||
tools=[],
|
|
||||||
knowledges=[],
|
|
||||||
mcps=[],
|
|
||||||
)
|
|
||||||
if skill_snapshot is not None:
|
|
||||||
context.skill_session_snapshot = skill_snapshot
|
|
||||||
runtime = SimpleNamespace(context=context)
|
|
||||||
return _FakeRequest(
|
|
||||||
runtime=runtime,
|
|
||||||
tools=[_FakeTool(name=name) for name in tools],
|
|
||||||
system_message=SystemMessage(content=[{"type": "text", "text": "base"}]),
|
|
||||||
state=state or {},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_tool_request(*, skills: list[str], visible_skills: list[str], file_path: str) -> _FakeToolCallRequest:
|
|
||||||
return _FakeToolCallRequest(
|
|
||||||
tool_call={"name": "read_file", "args": {"file_path": file_path}},
|
|
||||||
runtime=SimpleNamespace(
|
|
||||||
context=SimpleNamespace(
|
|
||||||
skills=skills,
|
|
||||||
skill_session_snapshot=_build_snapshot(selected=skills, visible=visible_skills),
|
|
||||||
)
|
|
||||||
),
|
|
||||||
state={},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_appended_prompt(request: _FakeRequest) -> str:
|
|
||||||
return request.system_message.content_blocks[-1]["text"]
|
|
||||||
|
|
||||||
|
|
||||||
def _build_middleware() -> RuntimeConfigMiddleware:
|
|
||||||
return RuntimeConfigMiddleware(
|
|
||||||
enable_model_override=False,
|
|
||||||
enable_tools_override=False,
|
|
||||||
enable_system_prompt_override=True,
|
|
||||||
enable_skills_prompt_override=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_snapshot(
|
|
||||||
selected: list[str],
|
|
||||||
visible: list[str] | None = None,
|
|
||||||
metadata: dict[str, dict[str, str]] | None = None,
|
|
||||||
dependency_map: dict[str, dict[str, list[str]]] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"selected_skills": selected,
|
|
||||||
"visible_skills": visible if visible is not None else selected,
|
|
||||||
"prompt_metadata": metadata or {},
|
|
||||||
"dependency_map": dependency_map or {},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_abefore_agent_resolves_visible_skills_and_preinjects_prompt(monkeypatch: pytest.MonkeyPatch):
|
|
||||||
async def fake_resolve(selected):
|
|
||||||
assert selected == ["deliver-prd"]
|
|
||||||
return _build_snapshot(
|
|
||||||
selected=["deliver-prd"],
|
|
||||||
visible=["deliver-prd", "brainstorming"],
|
|
||||||
metadata={
|
|
||||||
"deliver-prd": {
|
|
||||||
"name": "deliver-prd",
|
|
||||||
"description": "deliver prd",
|
|
||||||
"path": "/skills/deliver-prd/SKILL.md",
|
|
||||||
},
|
|
||||||
"brainstorming": {
|
|
||||||
"name": "brainstorming",
|
|
||||||
"description": "brainstorming desc",
|
|
||||||
"path": "/skills/brainstorming/SKILL.md",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
dependency_map={
|
|
||||||
"deliver-prd": {"tools": [], "mcps": [], "skills": ["brainstorming"]},
|
|
||||||
"brainstorming": {"tools": [], "mcps": [], "skills": []},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve)
|
|
||||||
middleware = _build_middleware()
|
|
||||||
request = _build_request(skills=["deliver-prd"], tools=["read_file"], system_prompt="你是助手")
|
|
||||||
|
|
||||||
await middleware.abefore_agent(request.state, request.runtime)
|
|
||||||
|
|
||||||
snapshot = request.runtime.context.skill_session_snapshot
|
|
||||||
assert snapshot["selected_skills"] == ["deliver-prd"]
|
|
||||||
assert snapshot["visible_skills"] == ["deliver-prd", "brainstorming"]
|
|
||||||
assert snapshot["dependency_map"]["deliver-prd"]["skills"] == ["brainstorming"]
|
|
||||||
assert request.runtime.context._skills_prompt_injected is True
|
|
||||||
assert "## Skills System" in request.runtime.context.system_prompt
|
|
||||||
assert "- **deliver-prd**: deliver prd" in request.runtime.context.system_prompt
|
|
||||||
assert "- **brainstorming**: brainstorming desc" in request.runtime.context.system_prompt
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_abefore_agent_injection_is_idempotent(monkeypatch: pytest.MonkeyPatch):
|
|
||||||
async def fake_resolve(_selected):
|
|
||||||
return _build_snapshot(
|
|
||||||
selected=["alpha"],
|
|
||||||
metadata={"alpha": {"name": "alpha", "description": "alpha desc", "path": "/skills/alpha/SKILL.md"}},
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve)
|
|
||||||
middleware = _build_middleware()
|
|
||||||
request = _build_request(skills=["alpha"], tools=["read_file"], system_prompt="base prompt")
|
|
||||||
|
|
||||||
await middleware.abefore_agent(request.state, request.runtime)
|
|
||||||
await middleware.abefore_agent(request.state, request.runtime)
|
|
||||||
|
|
||||||
assert request.runtime.context.system_prompt.count("## Skills System") == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_awrap_model_call_keeps_preinjected_skills_prompt(monkeypatch: pytest.MonkeyPatch):
|
|
||||||
middleware = _build_middleware()
|
|
||||||
request = _build_request(
|
|
||||||
skills=["alpha"],
|
|
||||||
tools=["read_file"],
|
|
||||||
system_prompt="base prompt\n\n## Skills System\n- **alpha**: alpha desc",
|
|
||||||
)
|
|
||||||
|
|
||||||
def raise_if_called(_skills_meta):
|
|
||||||
raise AssertionError("_build_skills_section should not be called in awrap_model_call")
|
|
||||||
|
|
||||||
monkeypatch.setattr(middleware, "_build_skills_section", raise_if_called)
|
|
||||||
result = await middleware.awrap_model_call(request, _echo_handler)
|
|
||||||
prompt = _extract_appended_prompt(result)
|
|
||||||
|
|
||||||
assert "当前时间:" in prompt
|
|
||||||
assert "## Skills System" in prompt
|
|
||||||
assert "- **alpha**: alpha desc" in prompt
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_abefore_agent_degrades_when_resolver_fails(monkeypatch: pytest.MonkeyPatch):
|
|
||||||
async def fake_resolve(_selected):
|
|
||||||
raise RuntimeError("boom")
|
|
||||||
|
|
||||||
monkeypatch.setattr(runtime_middleware, "resolve_session_snapshot", fake_resolve)
|
|
||||||
middleware = _build_middleware()
|
|
||||||
request = _build_request(skills=["alpha"], tools=["read_file"], system_prompt="base prompt")
|
|
||||||
|
|
||||||
await middleware.abefore_agent(request.state, request.runtime)
|
|
||||||
|
|
||||||
snapshot = request.runtime.context.skill_session_snapshot
|
|
||||||
assert snapshot["selected_skills"] == ["alpha"]
|
|
||||||
assert snapshot["visible_skills"] == []
|
|
||||||
assert "## Skills System" not in request.runtime.context.system_prompt
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_awrap_tool_call_activates_skill_when_visible_in_context():
|
|
||||||
middleware = _build_middleware()
|
|
||||||
request = _build_tool_request(
|
|
||||||
skills=["deliver-prd"],
|
|
||||||
visible_skills=["deliver-prd", "brainstorming"],
|
|
||||||
file_path="/skills/brainstorming/SKILL.md",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _handler(_request):
|
|
||||||
return ToolMessage(content="ok", tool_call_id="tc-1")
|
|
||||||
|
|
||||||
result = await middleware.awrap_tool_call(request, _handler)
|
|
||||||
assert isinstance(result, Command)
|
|
||||||
assert result.update["activated_skills"] == ["brainstorming"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_awrap_tool_call_denies_invisible_skill():
|
|
||||||
middleware = _build_middleware()
|
|
||||||
request = _build_tool_request(
|
|
||||||
skills=["deliver-prd"],
|
|
||||||
visible_skills=["deliver-prd"],
|
|
||||||
file_path="/skills/brainstorming/SKILL.md",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _handler(_request):
|
|
||||||
return ToolMessage(content="ok", tool_call_id="tc-1")
|
|
||||||
|
|
||||||
result = await middleware.awrap_tool_call(request, _handler)
|
|
||||||
assert isinstance(result, ToolMessage)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_model_call_injects_dependency_tools_and_mcps_after_activation(monkeypatch: pytest.MonkeyPatch):
|
|
||||||
monkeypatch.setattr(runtime_middleware, "get_kb_based_tools", lambda db_names=None: [])
|
|
||||||
|
|
||||||
snapshot = _build_snapshot(
|
|
||||||
selected=["alpha"],
|
|
||||||
visible=["alpha", "beta"],
|
|
||||||
dependency_map={"alpha": {"tools": ["dep-tool"], "mcps": ["mcp-a"], "skills": ["beta"]}},
|
|
||||||
)
|
|
||||||
|
|
||||||
def fake_build_dependency_bundle(received_snapshot, activated):
|
|
||||||
assert received_snapshot == snapshot
|
|
||||||
assert activated == ["alpha"]
|
|
||||||
return {"tools": ["dep-tool"], "mcps": ["mcp-a"], "skills": ["alpha", "beta"]}
|
|
||||||
|
|
||||||
monkeypatch.setattr(runtime_middleware, "build_dependency_bundle", fake_build_dependency_bundle)
|
|
||||||
|
|
||||||
async def fake_get_enabled_mcp_tools(server_name: str):
|
|
||||||
if server_name == "mcp-a":
|
|
||||||
return [_FakeTool(name="mcp_tool")]
|
|
||||||
return []
|
|
||||||
|
|
||||||
monkeypatch.setattr(runtime_middleware, "get_enabled_mcp_tools", fake_get_enabled_mcp_tools)
|
|
||||||
|
|
||||||
middleware = RuntimeConfigMiddleware(
|
|
||||||
extra_tools=[_FakeTool(name="mcp_tool")],
|
|
||||||
enable_model_override=False,
|
|
||||||
enable_tools_override=True,
|
|
||||||
enable_system_prompt_override=False,
|
|
||||||
enable_skills_prompt_override=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
request = _build_request(
|
|
||||||
skills=["alpha"],
|
|
||||||
tools=["calculator", "dep-tool", "mcp_tool", "read_file"],
|
|
||||||
state={"activated_skills": ["alpha"]},
|
|
||||||
skill_snapshot=snapshot,
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await middleware.awrap_model_call(request, _echo_handler)
|
|
||||||
tool_names = [t.name for t in result.tools]
|
|
||||||
assert "dep-tool" in tool_names
|
|
||||||
assert "mcp_tool" in tool_names
|
|
||||||
assert "calculator" not in tool_names
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from src.services import skill_resolver as resolver
|
|
||||||
from src.storage.postgres.models_business import Skill
|
|
||||||
|
|
||||||
|
|
||||||
def test_expand_skill_closure_and_dependency_bundle():
|
|
||||||
dependency_map = {
|
|
||||||
"alpha": {"tools": ["t1"], "mcps": ["m1"], "skills": ["beta"]},
|
|
||||||
"beta": {"tools": ["t2"], "mcps": ["m2"], "skills": ["gamma"]},
|
|
||||||
"gamma": {"tools": ["t3"], "mcps": [], "skills": []},
|
|
||||||
}
|
|
||||||
snapshot: resolver.SkillSessionSnapshot = {
|
|
||||||
"selected_skills": ["alpha"],
|
|
||||||
"visible_skills": ["alpha", "beta", "gamma"],
|
|
||||||
"prompt_metadata": {},
|
|
||||||
"dependency_map": dependency_map,
|
|
||||||
}
|
|
||||||
|
|
||||||
closure = resolver.expand_skill_closure(["alpha"], dependency_map)
|
|
||||||
assert closure == ["alpha", "beta", "gamma"]
|
|
||||||
|
|
||||||
bundle = resolver.build_dependency_bundle(snapshot, ["alpha"])
|
|
||||||
assert bundle["skills"] == ["alpha", "beta", "gamma"]
|
|
||||||
assert bundle["tools"] == ["t1", "t2", "t3"]
|
|
||||||
assert bundle["mcps"] == ["m1", "m2"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_expand_skill_closure_cycle():
|
|
||||||
dependency_map = {
|
|
||||||
"alpha": {"tools": [], "mcps": [], "skills": ["beta"]},
|
|
||||||
"beta": {"tools": [], "mcps": [], "skills": ["alpha"]},
|
|
||||||
}
|
|
||||||
assert resolver.expand_skill_closure(["alpha"], dependency_map) == ["alpha", "beta"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_collect_prompt_metadata_order_and_dedup():
|
|
||||||
snapshot: resolver.SkillSessionSnapshot = {
|
|
||||||
"selected_skills": ["beta", "alpha"],
|
|
||||||
"visible_skills": ["beta", "alpha"],
|
|
||||||
"prompt_metadata": {
|
|
||||||
"beta": {"name": "beta", "description": "beta skill", "path": "/skills/beta/SKILL.md"},
|
|
||||||
"alpha": {"name": "alpha", "description": "alpha skill", "path": "/skills/alpha/SKILL.md"},
|
|
||||||
},
|
|
||||||
"dependency_map": {},
|
|
||||||
}
|
|
||||||
result = resolver.collect_prompt_metadata(snapshot, ["beta", "missing", "alpha", "beta"])
|
|
||||||
assert [item["name"] for item in result] == ["beta", "alpha"]
|
|
||||||
assert [item["path"] for item in result] == ["/skills/beta/SKILL.md", "/skills/alpha/SKILL.md"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_resolve_session_snapshot_and_selected_change(monkeypatch: pytest.MonkeyPatch):
|
|
||||||
async def fake_list_skills(_db=None):
|
|
||||||
return [
|
|
||||||
Skill(
|
|
||||||
slug="alpha",
|
|
||||||
name="alpha",
|
|
||||||
description="a",
|
|
||||||
tool_dependencies=[],
|
|
||||||
mcp_dependencies=[],
|
|
||||||
skill_dependencies=["beta"],
|
|
||||||
dir_path="skills/alpha",
|
|
||||||
),
|
|
||||||
Skill(
|
|
||||||
slug="beta",
|
|
||||||
name="beta",
|
|
||||||
description="b",
|
|
||||||
tool_dependencies=[],
|
|
||||||
mcp_dependencies=[],
|
|
||||||
skill_dependencies=[],
|
|
||||||
dir_path="skills/beta",
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
monkeypatch.setattr(resolver, "_list_skills_from_db", fake_list_skills)
|
|
||||||
|
|
||||||
snapshot = await resolver.resolve_session_snapshot([" alpha ", "alpha"])
|
|
||||||
assert snapshot["selected_skills"] == ["alpha"]
|
|
||||||
assert snapshot["visible_skills"] == ["alpha", "beta"]
|
|
||||||
|
|
||||||
assert resolver.is_snapshot_match_selected_skills(snapshot, ["alpha"]) is True
|
|
||||||
assert resolver.is_snapshot_match_selected_skills(snapshot, ["beta"]) is False
|
|
||||||
@ -7,6 +7,7 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.services import skill_service as svc
|
from src.services import skill_service as svc
|
||||||
|
from src.services import tool_service
|
||||||
from src.storage.postgres.models_business import Skill
|
from src.storage.postgres.models_business import Skill
|
||||||
|
|
||||||
|
|
||||||
@ -31,15 +32,26 @@ def test_parse_skill_markdown_requires_frontmatter():
|
|||||||
svc._parse_skill_markdown("# missing")
|
svc._parse_skill_markdown("# missing")
|
||||||
|
|
||||||
|
|
||||||
def test_validate_skill_slug():
|
def test_is_valid_skill_slug():
|
||||||
assert svc.validate_skill_slug("demo-skill") == "demo-skill"
|
# Test valid slugs
|
||||||
with pytest.raises(ValueError, match="无效 skill slug"):
|
assert svc.is_valid_skill_slug("demo-skill") is True
|
||||||
svc.validate_skill_slug("../bad")
|
assert svc.is_valid_skill_slug("valid-name-123") is True
|
||||||
|
# Test invalid slugs
|
||||||
|
assert svc.is_valid_skill_slug("../bad") is False
|
||||||
|
assert svc.is_valid_skill_slug("Invalid") is False # uppercase not allowed
|
||||||
|
assert svc.is_valid_skill_slug("") is False
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_skill_dependency_options(monkeypatch: pytest.MonkeyPatch):
|
async def test_get_skill_dependency_options(monkeypatch: pytest.MonkeyPatch):
|
||||||
monkeypatch.setattr(svc, "_get_buildin_tool_names", lambda: ["calculator", "search"])
|
# Mock get_tool_metadata to return tool list
|
||||||
|
def fake_get_tool_metadata(category=None):
|
||||||
|
return [
|
||||||
|
{"id": "calculator", "name": "Calculator"},
|
||||||
|
{"id": "search", "name": "Search"},
|
||||||
|
]
|
||||||
|
|
||||||
|
monkeypatch.setattr(tool_service, "get_tool_metadata", fake_get_tool_metadata)
|
||||||
monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a", "mcp-b"])
|
monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a", "mcp-b"])
|
||||||
|
|
||||||
class FakeRepo:
|
class FakeRepo:
|
||||||
@ -55,7 +67,7 @@ async def test_get_skill_dependency_options(monkeypatch: pytest.MonkeyPatch):
|
|||||||
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
|
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
|
||||||
|
|
||||||
result = await svc.get_skill_dependency_options(None)
|
result = await svc.get_skill_dependency_options(None)
|
||||||
assert result["tools"] == ["calculator", "search"]
|
assert result["tools"] == [{"id": "calculator", "name": "Calculator"}, {"id": "search", "name": "Search"}]
|
||||||
assert result["mcps"] == ["mcp-a", "mcp-b"]
|
assert result["mcps"] == ["mcp-a", "mcp-b"]
|
||||||
assert result["skills"] == ["alpha", "beta"]
|
assert result["skills"] == ["alpha", "beta"]
|
||||||
|
|
||||||
@ -202,7 +214,12 @@ async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch):
|
|||||||
mcp_dependencies=[],
|
mcp_dependencies=[],
|
||||||
skill_dependencies=[],
|
skill_dependencies=[],
|
||||||
)
|
)
|
||||||
monkeypatch.setattr(svc, "_get_buildin_tool_names", lambda: ["calculator"])
|
|
||||||
|
# Mock get_tool_metadata to return tool list
|
||||||
|
def fake_get_tool_metadata(category=None):
|
||||||
|
return [{"id": "calculator", "name": "Calculator"}]
|
||||||
|
|
||||||
|
monkeypatch.setattr(tool_service, "get_tool_metadata", fake_get_tool_metadata)
|
||||||
monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a"])
|
monkeypatch.setattr(svc, "get_mcp_server_names", lambda: ["mcp-a"])
|
||||||
|
|
||||||
async def fake_get_skill_or_raise(_db, slug: str):
|
async def fake_get_skill_or_raise(_db, slug: str):
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
from src.agents.common.backends import skills_backend
|
from src.agents.common.backends import skills_backend
|
||||||
|
|
||||||
@ -44,69 +43,3 @@ def test_selected_skills_backend_readonly_and_visible_only_selected(tmp_path, mo
|
|||||||
upload_result = backend.upload_files([("/alpha/a.txt", b"a")])
|
upload_result = backend.upload_files([("/alpha/a.txt", b"a")])
|
||||||
assert len(upload_result) == 1
|
assert len(upload_result) == 1
|
||||||
assert upload_result[0].error == "permission_denied"
|
assert upload_result[0].error == "permission_denied"
|
||||||
|
|
||||||
|
|
||||||
def test_composite_backend_mounts_skills_under_prefix(tmp_path, monkeypatch):
|
|
||||||
_prepare_skills_dir(tmp_path)
|
|
||||||
monkeypatch.setattr(skills_backend, "get_skills_root_dir", lambda: tmp_path)
|
|
||||||
|
|
||||||
runtime = SimpleNamespace(
|
|
||||||
context=SimpleNamespace(
|
|
||||||
skills=["alpha"],
|
|
||||||
skill_session_snapshot={
|
|
||||||
"selected_skills": ["alpha"],
|
|
||||||
"visible_skills": ["alpha", "beta"],
|
|
||||||
"prompt_metadata": {},
|
|
||||||
"dependency_map": {},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
state={},
|
|
||||||
)
|
|
||||||
composite = skills_backend.create_agent_composite_backend(runtime)
|
|
||||||
|
|
||||||
root = composite.ls_info("/")
|
|
||||||
all_paths = [entry.get("path") for entry in root]
|
|
||||||
assert "/skills/" in all_paths
|
|
||||||
|
|
||||||
skills_root = composite.ls_info("/skills/")
|
|
||||||
skill_paths = sorted(entry.get("path") for entry in skills_root)
|
|
||||||
assert skill_paths == ["/skills/alpha/", "/skills/beta/"]
|
|
||||||
|
|
||||||
denied = composite.write("/skills/alpha/new.md", "x")
|
|
||||||
assert denied.error and "read-only" in denied.error
|
|
||||||
|
|
||||||
|
|
||||||
def test_composite_backend_fallbacks_to_context_skills_when_snapshot_missing(tmp_path, monkeypatch):
|
|
||||||
_prepare_skills_dir(tmp_path)
|
|
||||||
monkeypatch.setattr(skills_backend, "get_skills_root_dir", lambda: tmp_path)
|
|
||||||
|
|
||||||
runtime = SimpleNamespace(
|
|
||||||
context=SimpleNamespace(skills=["alpha"]),
|
|
||||||
state={},
|
|
||||||
)
|
|
||||||
composite = skills_backend.create_agent_composite_backend(runtime)
|
|
||||||
skills_root = composite.ls_info("/skills/")
|
|
||||||
skill_paths = sorted(entry.get("path") for entry in skills_root)
|
|
||||||
assert skill_paths == ["/skills/alpha/"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_composite_backend_reads_visible_skills_from_runtime_context(tmp_path, monkeypatch):
|
|
||||||
_prepare_skills_dir(tmp_path)
|
|
||||||
monkeypatch.setattr(skills_backend, "get_skills_root_dir", lambda: tmp_path)
|
|
||||||
|
|
||||||
runtime = SimpleNamespace(
|
|
||||||
context=SimpleNamespace(
|
|
||||||
skills=["alpha"],
|
|
||||||
skill_session_snapshot={
|
|
||||||
"selected_skills": ["alpha"],
|
|
||||||
"visible_skills": ["alpha", "beta"],
|
|
||||||
"prompt_metadata": {},
|
|
||||||
"dependency_map": {},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
state=None,
|
|
||||||
)
|
|
||||||
composite = skills_backend.create_agent_composite_backend(runtime)
|
|
||||||
skills_root = composite.ls_info("/skills/")
|
|
||||||
skill_paths = sorted(entry.get("path") for entry in skills_root)
|
|
||||||
assert skill_paths == ["/skills/alpha/", "/skills/beta/"]
|
|
||||||
|
|||||||
@ -112,8 +112,11 @@
|
|||||||
:visible="approvalState.showModal"
|
:visible="approvalState.showModal"
|
||||||
:question="approvalState.question"
|
:question="approvalState.question"
|
||||||
:operation="approvalState.operation"
|
:operation="approvalState.operation"
|
||||||
@approve="handleApprove"
|
:options="approvalState.options"
|
||||||
@reject="handleReject"
|
:multi-select="approvalState.multiSelect"
|
||||||
|
:allow-other="approvalState.allowOther"
|
||||||
|
@submit="handleQuestionSubmit"
|
||||||
|
@cancel="handleQuestionCancel"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="message-input-wrapper">
|
<div class="message-input-wrapper">
|
||||||
@ -1113,7 +1116,12 @@ const startRunStream = async (threadId, runId, afterSeq = '0') => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event === 'finished' || event === 'error' || event === 'interrupted') {
|
if (
|
||||||
|
event === 'finished' ||
|
||||||
|
event === 'error' ||
|
||||||
|
event === 'interrupted' ||
|
||||||
|
event === 'ask_user_question_required'
|
||||||
|
) {
|
||||||
flushTypingQueueForThread(threadId)
|
flushTypingQueueForThread(threadId)
|
||||||
ts.isStreaming = false
|
ts.isStreaming = false
|
||||||
ts.activeRunId = null
|
ts.activeRunId = null
|
||||||
@ -1541,10 +1549,10 @@ const handleSendOrStop = async (payload) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 人工审批处理 ====================
|
// ==================== 人工审批处理 ====================
|
||||||
const handleApprovalWithStream = async (approved) => {
|
const handleApprovalWithStream = async (answer) => {
|
||||||
const threadId = approvalState.threadId
|
const threadId = approvalState.threadId
|
||||||
if (!threadId) {
|
if (!threadId) {
|
||||||
message.error('无效的审批请求')
|
message.error('无效的提问请求')
|
||||||
approvalState.showModal = false
|
approvalState.showModal = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -1558,11 +1566,7 @@ const handleApprovalWithStream = async (approved) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 使用审批 composable 处理审批
|
// 使用审批 composable 处理审批
|
||||||
const response = await handleApproval(
|
const response = await handleApproval(answer, currentAgentId.value, selectedAgentConfigId.value)
|
||||||
approved,
|
|
||||||
currentAgentId.value,
|
|
||||||
selectedAgentConfigId.value
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!response) return // 如果 handleApproval 抛出错误,这里不会执行
|
if (!response) return // 如果 handleApproval 抛出错误,这里不会执行
|
||||||
|
|
||||||
@ -1586,12 +1590,12 @@ const handleApprovalWithStream = async (approved) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleApprove = () => {
|
const handleQuestionSubmit = (answer) => {
|
||||||
handleApprovalWithStream(true)
|
handleApprovalWithStream(answer)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReject = () => {
|
const handleQuestionCancel = () => {
|
||||||
handleApprovalWithStream(false)
|
handleApprovalWithStream('reject')
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理示例问题点击
|
// 处理示例问题点击
|
||||||
|
|||||||
@ -6,19 +6,49 @@
|
|||||||
<h4>{{ question }}</h4>
|
<h4>{{ question }}</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="approval-operation">
|
<div v-if="operation" class="approval-operation">
|
||||||
<span class="label">操作:</span>
|
<span class="label">操作:</span>
|
||||||
<span class="operation-text">{{ operation }}</span>
|
<span class="operation-text">{{ operation }}</span>
|
||||||
</div>
|
</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"
|
||||||
|
:disabled="isProcessing"
|
||||||
|
placeholder="Other: 输入自定义答案"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="approval-actions">
|
<div class="approval-actions">
|
||||||
<button class="btn btn-reject" @click="handleReject" :disabled="isProcessing">
|
<button class="btn btn-reject" @click="handleCancel" :disabled="isProcessing">取消</button>
|
||||||
✕ 拒绝
|
<button class="btn btn-approve" @click="handleSubmit" :disabled="isSubmitDisabled">提交</button>
|
||||||
</button>
|
|
||||||
<button class="btn btn-approve" @click="handleApprove" :disabled="isProcessing">
|
|
||||||
✓ 批准
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isProcessing" class="approval-processing">
|
<div v-if="isProcessing" class="approval-processing">
|
||||||
@ -30,47 +60,114 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visible: {
|
visible: { type: Boolean, default: false },
|
||||||
type: Boolean,
|
question: { type: String, default: '请选择一个选项' },
|
||||||
default: false
|
operation: { type: String, default: '' },
|
||||||
},
|
options: { type: Array, default: () => [] },
|
||||||
question: {
|
multiSelect: { type: Boolean, default: false },
|
||||||
type: String,
|
allowOther: { type: Boolean, default: true }
|
||||||
default: '是否批准此操作?'
|
|
||||||
},
|
|
||||||
operation: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['approve', 'reject'])
|
const emit = defineEmits(['submit', 'cancel'])
|
||||||
|
|
||||||
const isProcessing = ref(false)
|
const isProcessing = ref(false)
|
||||||
|
const selectedValues = ref([])
|
||||||
|
const otherText = ref('')
|
||||||
|
|
||||||
|
const resetForm = () => {
|
||||||
|
isProcessing.value = false
|
||||||
|
selectedValues.value = []
|
||||||
|
otherText.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
// 监听弹窗关闭,重置处理状态
|
|
||||||
watch(
|
watch(
|
||||||
() => props.visible,
|
() => props.visible,
|
||||||
(newVal) => {
|
(newVal) => {
|
||||||
if (!newVal) {
|
if (!newVal) {
|
||||||
isProcessing.value = false
|
resetForm()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleApprove = () => {
|
watch(
|
||||||
|
() => props.options,
|
||||||
|
(next) => {
|
||||||
|
if (!Array.isArray(next) || next.length === 0) {
|
||||||
|
selectedValues.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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) => {
|
||||||
if (isProcessing.value) return
|
if (isProcessing.value) return
|
||||||
isProcessing.value = true
|
if (selectedValues.value.includes(value)) {
|
||||||
emit('approve')
|
selectedValues.value = selectedValues.value.filter((item) => item !== value)
|
||||||
|
} else {
|
||||||
|
selectedValues.value = [...selectedValues.value, value]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReject = () => {
|
const setSingle = (value) => {
|
||||||
if (isProcessing.value) return
|
if (isProcessing.value) return
|
||||||
|
selectedValues.value = [value]
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSubmitDisabled = computed(() => {
|
||||||
|
if (isProcessing.value) return true
|
||||||
|
const hasSelected = selectedValues.value.length > 0
|
||||||
|
const hasOther = props.allowOther && Boolean(otherText.value)
|
||||||
|
return !hasSelected && !hasOther
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildAnswer = () => {
|
||||||
|
const selected = selectedValues.value
|
||||||
|
const other = otherText.value
|
||||||
|
if (props.allowOther && other) {
|
||||||
|
return {
|
||||||
|
type: 'other',
|
||||||
|
text: other,
|
||||||
|
selected: selected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (props.multiSelect) {
|
||||||
|
return selected
|
||||||
|
}
|
||||||
|
return selected[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (isSubmitDisabled.value) return
|
||||||
isProcessing.value = true
|
isProcessing.value = true
|
||||||
emit('reject')
|
emit('submit', buildAnswer())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (isProcessing.value) return
|
||||||
|
emit('cancel')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -112,6 +209,7 @@ const handleReject = () => {
|
|||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.approval-operation .label {
|
.approval-operation .label {
|
||||||
@ -125,6 +223,42 @@ const handleReject = () => {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.question-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--gray-800);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-item .recommended {
|
||||||
|
color: var(--main-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.other-input {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.other-input input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--gray-300);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.other-input input:focus {
|
||||||
|
border-color: var(--main-color);
|
||||||
|
}
|
||||||
|
|
||||||
.approval-actions {
|
.approval-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@ -140,10 +274,6 @@ const handleReject = () => {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
@ -167,7 +297,6 @@ const handleReject = () => {
|
|||||||
|
|
||||||
.btn-approve:hover:not(:disabled) {
|
.btn-approve:hover:not(:disabled) {
|
||||||
background: var(--main-700);
|
background: var(--main-700);
|
||||||
box-shadow: 0 2px 6px rgba(59, 130, 246, 0.25);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.approval-processing {
|
.approval-processing {
|
||||||
@ -197,7 +326,6 @@ const handleReject = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滑入滑出动画 */
|
|
||||||
.slide-up-enter-active,
|
.slide-up-enter-active,
|
||||||
.slide-up-leave-active {
|
.slide-up-leave-active {
|
||||||
transition: all 0.25s ease;
|
transition: all 0.25s ease;
|
||||||
|
|||||||
@ -110,6 +110,7 @@ export function useAgentStreamHandler({
|
|||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
|
|
||||||
|
case 'ask_user_question_required':
|
||||||
case 'human_approval_required':
|
case 'human_approval_required':
|
||||||
console.log(`${debugPrefix}[approval_required]`, {
|
console.log(`${debugPrefix}[approval_required]`, {
|
||||||
threadId,
|
threadId,
|
||||||
|
|||||||
@ -3,21 +3,79 @@ import { message } from 'ant-design-vue'
|
|||||||
import { handleChatError } from '@/utils/errorHandler'
|
import { handleChatError } from '@/utils/errorHandler'
|
||||||
import { agentApi } from '@/apis'
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toLegacyApprovalOptions = () => [
|
||||||
|
{ label: '批准 (Recommended)', value: 'approve' },
|
||||||
|
{ label: '拒绝', value: 'reject' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const extractQuestionPayload = (chunk) => {
|
||||||
|
const interruptInfo = chunk?.interrupt_info || {}
|
||||||
|
const rawOptions =
|
||||||
|
chunk?.options || interruptInfo?.options || (interruptInfo?.operation ? toLegacyApprovalOptions() : [])
|
||||||
|
const options = normalizeOptions(rawOptions)
|
||||||
|
const operation = chunk?.operation || interruptInfo?.operation || ''
|
||||||
|
|
||||||
|
const source = chunk?.source || interruptInfo?.source || (operation ? 'get_approved_user_goal' : 'interrupt')
|
||||||
|
const multiSelect = Boolean(chunk?.multi_select ?? interruptInfo?.multi_select ?? false)
|
||||||
|
let allowOther = Boolean(chunk?.allow_other ?? interruptInfo?.allow_other ?? true)
|
||||||
|
const questionId = chunk?.question_id || interruptInfo?.question_id || ''
|
||||||
|
const question = chunk?.question || interruptInfo?.question || '请选择一个选项'
|
||||||
|
const legacyMode = source === 'get_approved_user_goal' && options.length === 2
|
||||||
|
if (source === 'get_approved_user_goal') {
|
||||||
|
allowOther = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
questionId,
|
||||||
|
question,
|
||||||
|
options,
|
||||||
|
multiSelect,
|
||||||
|
allowOther,
|
||||||
|
source,
|
||||||
|
operation,
|
||||||
|
legacyMode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inferApprovedFromAnswer = (answer) => {
|
||||||
|
if (answer === 'approve') return true
|
||||||
|
if (answer === 'reject') return false
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessages }) {
|
export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessages }) {
|
||||||
// 审批状态
|
|
||||||
const approvalState = reactive({
|
const approvalState = reactive({
|
||||||
showModal: false,
|
showModal: false,
|
||||||
|
questionId: '',
|
||||||
question: '',
|
question: '',
|
||||||
operation: '',
|
operation: '',
|
||||||
threadId: null,
|
options: [],
|
||||||
interruptInfo: null
|
multiSelect: false,
|
||||||
|
allowOther: true,
|
||||||
|
source: '',
|
||||||
|
legacyMode: false,
|
||||||
|
threadId: null
|
||||||
})
|
})
|
||||||
|
|
||||||
// 处理审批逻辑
|
const handleApproval = async (answer, currentAgentId, agentConfigId = null) => {
|
||||||
const handleApproval = async (approved, currentAgentId, agentConfigId = null) => {
|
|
||||||
const threadId = approvalState.threadId
|
const threadId = approvalState.threadId
|
||||||
if (!threadId) {
|
if (!threadId) {
|
||||||
message.error('无效的审批请求')
|
message.error('无效的提问请求')
|
||||||
approvalState.showModal = false
|
approvalState.showModal = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -29,94 +87,93 @@ export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessa
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭弹窗
|
|
||||||
approvalState.showModal = false
|
approvalState.showModal = false
|
||||||
|
|
||||||
// 清理旧的流式控制器(如果存在)
|
|
||||||
if (threadState.streamAbortController) {
|
if (threadState.streamAbortController) {
|
||||||
threadState.streamAbortController.abort()
|
threadState.streamAbortController.abort()
|
||||||
threadState.streamAbortController = null
|
threadState.streamAbortController = null
|
||||||
}
|
}
|
||||||
|
|
||||||
// 标记为处理中
|
|
||||||
threadState.isStreaming = true
|
threadState.isStreaming = true
|
||||||
resetOnGoingConv(threadId)
|
resetOnGoingConv(threadId)
|
||||||
threadState.streamAbortController = new AbortController()
|
threadState.streamAbortController = new AbortController()
|
||||||
|
|
||||||
console.log('🔄 [APPROVAL] Starting resume process:', { approved, threadId, currentAgentId })
|
const approved = inferApprovedFromAnswer(answer)
|
||||||
|
const requestBody = {
|
||||||
|
thread_id: threadId,
|
||||||
|
answer,
|
||||||
|
config: agentConfigId ? { agent_config_id: agentConfigId } : {}
|
||||||
|
}
|
||||||
|
if (approved !== null) {
|
||||||
|
requestBody.approved = approved
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 调用恢复接口
|
const response = await agentApi.resumeAgentChat(currentAgentId, requestBody, {
|
||||||
const response = await agentApi.resumeAgentChat(
|
signal: threadState.streamAbortController?.signal
|
||||||
currentAgentId,
|
})
|
||||||
{
|
|
||||||
thread_id: threadId,
|
|
||||||
approved: approved,
|
|
||||||
config: agentConfigId ? { agent_config_id: agentConfigId } : {}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
signal: threadState.streamAbortController?.signal
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
console.log('🔄 [APPROVAL] Resume API response received')
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text()
|
const errorText = await response.text()
|
||||||
console.error('Resume API error:', response.status, errorText)
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}, details: ${errorText}`)
|
throw new Error(`HTTP error! status: ${response.status}, details: ${errorText}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔄 [APPROVAL] Resume API successful, returning response for stream processing')
|
return response
|
||||||
return response // 返回响应供调用方处理流式数据
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ [APPROVAL] Resume failed:', error)
|
|
||||||
if (error.name !== 'AbortError') {
|
if (error.name !== 'AbortError') {
|
||||||
handleChatError(error, 'resume')
|
handleChatError(error, 'resume')
|
||||||
message.error(`恢复对话失败: ${error.message || '未知错误'}`)
|
message.error(`恢复对话失败: ${error.message || '未知错误'}`)
|
||||||
}
|
}
|
||||||
// 重置状态 - 只在错误时重置
|
|
||||||
threadState.isStreaming = false
|
threadState.isStreaming = false
|
||||||
threadState.streamAbortController = null
|
threadState.streamAbortController = null
|
||||||
throw error // 重新抛出错误让调用方处理
|
throw error
|
||||||
}
|
}
|
||||||
// 移除 finally 块 - 让组件管理流式状态的生命周期
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 在流式处理中处理审批请求
|
|
||||||
const processApprovalInStream = (chunk, threadId, currentAgentId) => {
|
const processApprovalInStream = (chunk, threadId, currentAgentId) => {
|
||||||
if (chunk.status !== 'human_approval_required') {
|
if (chunk.status !== 'ask_user_question_required' && chunk.status !== 'human_approval_required') {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const { interrupt_info } = chunk
|
|
||||||
const threadState = getThreadState(threadId)
|
const threadState = getThreadState(threadId)
|
||||||
|
|
||||||
if (!threadState) return false
|
if (!threadState) return false
|
||||||
|
|
||||||
// 停止显示"处理中"状态,让用户可以看到并操作审批弹窗
|
const payload = extractQuestionPayload(chunk)
|
||||||
|
if (!payload.options.length) {
|
||||||
|
payload.options = toLegacyApprovalOptions()
|
||||||
|
payload.legacyMode = true
|
||||||
|
payload.allowOther = false
|
||||||
|
}
|
||||||
|
|
||||||
threadState.isStreaming = false
|
threadState.isStreaming = false
|
||||||
|
|
||||||
// 显示审批弹窗
|
|
||||||
approvalState.showModal = true
|
approvalState.showModal = true
|
||||||
approvalState.question = interrupt_info?.question || '是否批准以下操作?'
|
approvalState.questionId = payload.questionId
|
||||||
approvalState.operation = interrupt_info?.operation || '未知操作'
|
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.legacyMode = payload.legacyMode
|
||||||
approvalState.threadId = chunk.thread_id || threadId
|
approvalState.threadId = chunk.thread_id || threadId
|
||||||
approvalState.interruptInfo = interrupt_info
|
|
||||||
|
|
||||||
// 刷新消息历史显示已执行的部分
|
fetchThreadMessages({ agentId: currentAgentId, threadId })
|
||||||
fetchThreadMessages({ agentId: currentAgentId, threadId: threadId })
|
|
||||||
|
|
||||||
return true // 表示已处理审批请求,应停止流式处理
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 重置审批状态
|
|
||||||
const resetApprovalState = () => {
|
const resetApprovalState = () => {
|
||||||
approvalState.showModal = false
|
approvalState.showModal = false
|
||||||
|
approvalState.questionId = ''
|
||||||
approvalState.question = ''
|
approvalState.question = ''
|
||||||
approvalState.operation = ''
|
approvalState.operation = ''
|
||||||
|
approvalState.options = []
|
||||||
|
approvalState.multiSelect = false
|
||||||
|
approvalState.allowOther = true
|
||||||
|
approvalState.source = ''
|
||||||
|
approvalState.legacyMode = false
|
||||||
approvalState.threadId = null
|
approvalState.threadId = null
|
||||||
approvalState.interruptInfo = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user