Merge branch 'main' of https://github.com/xerrors/Yuxi-Know
This commit is contained in:
commit
4ea9f8b15e
@ -1,5 +1,6 @@
|
||||
import traceback
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, UploadFile, File
|
||||
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(
|
||||
agent_id: str,
|
||||
thread_id: str = Body(...),
|
||||
approved: bool = Body(...),
|
||||
approved: bool | None = Body(None),
|
||||
answer: dict | list | str | None = Body(None),
|
||||
config: dict = Body({}),
|
||||
current_user: User = Depends(get_required_user),
|
||||
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 = {
|
||||
"agent_id": agent_id,
|
||||
"thread_id": thread_id,
|
||||
"user_id": current_user.id,
|
||||
"approved": approved,
|
||||
"answer": answer,
|
||||
"resume_input": resume_input,
|
||||
}
|
||||
if "request_id" not in meta or not meta.get("request_id"):
|
||||
meta["request_id"] = str(uuid.uuid4())
|
||||
@ -522,7 +561,7 @@ async def resume_agent_chat(
|
||||
stream_agent_resume(
|
||||
agent_id=agent_id,
|
||||
thread_id=thread_id,
|
||||
approved=approved,
|
||||
resume_input=resume_input,
|
||||
meta=meta,
|
||||
config=config,
|
||||
current_user=current_user,
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
# 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__ = [
|
||||
"ask_user_question",
|
||||
"calculator",
|
||||
"query_knowledge_graph",
|
||||
"text_to_img_qwen_image",
|
||||
|
||||
@ -4,6 +4,7 @@ import uuid
|
||||
from typing import Annotated, Any
|
||||
|
||||
import requests
|
||||
from langgraph.types import interrupt
|
||||
|
||||
from src import config, graph_base
|
||||
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
|
||||
|
||||
|
||||
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 = """
|
||||
使用这个工具可以查询知识图谱中包含的三元组信息。
|
||||
关键词(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:
|
||||
|
||||
@ -4,6 +4,7 @@ import traceback
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from langchain.messages import AIMessage, AIMessageChunk, HumanMessage
|
||||
from langgraph.types import Command
|
||||
@ -193,7 +194,7 @@ async def save_messages_from_langgraph_state(
|
||||
logger.error(traceback.format_exc())
|
||||
|
||||
|
||||
def _extract_interrupt_info(state) -> dict | None:
|
||||
def _extract_interrupt_info(state) -> Any | None:
|
||||
"""从 LangGraph state 中提取中断信息"""
|
||||
if hasattr(state, "tasks") and state.tasks:
|
||||
for task in state.tasks:
|
||||
@ -207,12 +208,73 @@ def _extract_interrupt_info(state) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def _get_interrupt_fields(info) -> tuple[str, str]:
|
||||
"""从中断信息中提取 question 和 operation"""
|
||||
defaults = ("是否批准以下操作?", "需要人工审批的操作")
|
||||
def _coerce_interrupt_payload(info: Any) -> dict:
|
||||
"""将 LangGraph interrupt 对象转换为 dict 结构。"""
|
||||
if isinstance(info, dict):
|
||||
return info.get("question", defaults[0]), info.get("operation", defaults[1])
|
||||
return getattr(info, "question", defaults[0]), getattr(info, "operation", defaults[1])
|
||||
return info
|
||||
|
||||
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:
|
||||
@ -262,13 +324,9 @@ async def check_and_handle_interrupts(
|
||||
|
||||
interrupt_info = _extract_interrupt_info(state)
|
||||
if interrupt_info:
|
||||
question, operation = _get_interrupt_fields(interrupt_info)
|
||||
meta["interrupt"] = {
|
||||
"question": question,
|
||||
"operation": operation,
|
||||
"thread_id": thread_id,
|
||||
}
|
||||
yield make_chunk(status="interrupted", message=question, meta=meta)
|
||||
question_payload = _build_ask_user_question_payload(interrupt_info, thread_id)
|
||||
meta["interrupt"] = question_payload
|
||||
yield make_chunk(status="ask_user_question_required", meta=meta, **question_payload)
|
||||
|
||||
except Exception as 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": agent_config,
|
||||
}
|
||||
full_msg = None
|
||||
accumulated_content: list[str] = []
|
||||
|
||||
try:
|
||||
conv_repo = ConversationRepository(db)
|
||||
@ -489,7 +549,7 @@ async def stream_agent_resume(
|
||||
*,
|
||||
agent_id: str,
|
||||
thread_id: str,
|
||||
approved: bool,
|
||||
resume_input: Any,
|
||||
meta: dict,
|
||||
config: dict,
|
||||
current_user,
|
||||
@ -515,10 +575,10 @@ async def stream_agent_resume(
|
||||
)
|
||||
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)
|
||||
|
||||
resume_command = Command(resume=approved)
|
||||
resume_command = Command(resume=resume_input)
|
||||
graph = await agent.get_graph()
|
||||
|
||||
user_id = str(current_user.id)
|
||||
|
||||
@ -275,6 +275,14 @@ async def process_agent_run(ctx, run_id: str):
|
||||
error_message=chunk.get("message"),
|
||||
)
|
||||
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():
|
||||
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 mcp_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",
|
||||
"""
|
||||
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_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 ix_conversations_is_pinned ON conversations(is_pinned)",
|
||||
]
|
||||
async with self.async_engine.begin() as conn:
|
||||
for stmt in stmts:
|
||||
|
||||
@ -6,7 +6,7 @@ import sys
|
||||
# Add project root to path
|
||||
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
|
||||
GraphDatabase = UploadGraphService
|
||||
@ -40,64 +40,68 @@ async def test_txt_add_vector_entity_parsing():
|
||||
mock_embed_model = MagicMock()
|
||||
mock_select_model.return_value = mock_embed_model
|
||||
|
||||
# Instantiate GraphDatabase with mocked driver
|
||||
# We also need to patch Neo4jConnectionManager in the init
|
||||
with patch("src.knowledge.services.upload_graph_service.Neo4jConnectionManager") as mock_connection_manager:
|
||||
# Mock the connection manager to return our mocked driver
|
||||
mock_connection_manager.return_value.driver = mock_driver
|
||||
mock_connection_manager.return_value.status = "open"
|
||||
# Create a mock connection object with driver and status as attributes
|
||||
# (not a ConnectionManager class, just a simple mock object)
|
||||
mock_connection = MagicMock()
|
||||
mock_connection.driver = mock_driver
|
||||
mock_connection.status = "open"
|
||||
|
||||
gd = GraphDatabase(mock_connection_manager.return_value)
|
||||
# Manually set properties just in case init didn't work as expected due to other mocks
|
||||
gd.driver = mock_driver
|
||||
gd.status = "open"
|
||||
gd.embed_model_name = "test_model" # avoid config check issues if possible
|
||||
# Instantiate GraphDatabase with mocked connection
|
||||
gd = GraphDatabase(mock_connection)
|
||||
# Set embed_model_name directly (this is a settable attribute)
|
||||
gd.embed_model_name = "test_model"
|
||||
|
||||
# Mock config to match
|
||||
with patch("src.config.embed_model", "test_model"):
|
||||
with patch("src.config.embed_model_names", {"test_model": MagicMock(dimension=1024)}):
|
||||
# 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"},
|
||||
},
|
||||
]
|
||||
# Mock config where it's imported in upload_graph_service.py
|
||||
# The import is `from src import config`, so patch at the usage location
|
||||
mock_config = MagicMock()
|
||||
mock_config.embed_model = "test_model"
|
||||
mock_embed_info = MagicMock()
|
||||
mock_embed_info.dimension = 1024
|
||||
mock_config.embed_model_names = {"test_model": mock_embed_info}
|
||||
|
||||
# Run the method
|
||||
await gd.txt_add_vector_entity(triples)
|
||||
with patch("src.knowledge.graphs.upload_graph_service.config", mock_config):
|
||||
# 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
|
||||
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)
|
||||
# Run the method
|
||||
await gd.txt_add_vector_entity(triples)
|
||||
|
||||
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)
|
||||
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"] == {}
|
||||
assert len(merge_calls) == 2, f"Expected 2 merge calls, got {len(merge_calls)}"
|
||||
|
||||
# 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}
|
||||
# Call 1 (Legacy)
|
||||
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"] == {}
|
||||
|
||||
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
|
||||
|
||||
from src.services import skill_service as svc
|
||||
from src.services import tool_service
|
||||
from src.storage.postgres.models_business import Skill
|
||||
|
||||
|
||||
@ -31,15 +32,26 @@ def test_parse_skill_markdown_requires_frontmatter():
|
||||
svc._parse_skill_markdown("# missing")
|
||||
|
||||
|
||||
def test_validate_skill_slug():
|
||||
assert svc.validate_skill_slug("demo-skill") == "demo-skill"
|
||||
with pytest.raises(ValueError, match="无效 skill slug"):
|
||||
svc.validate_skill_slug("../bad")
|
||||
def test_is_valid_skill_slug():
|
||||
# Test valid slugs
|
||||
assert svc.is_valid_skill_slug("demo-skill") is True
|
||||
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
|
||||
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"])
|
||||
|
||||
class FakeRepo:
|
||||
@ -55,7 +67,7 @@ async def test_get_skill_dependency_options(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(svc, "SkillRepository", FakeRepo)
|
||||
|
||||
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["skills"] == ["alpha", "beta"]
|
||||
|
||||
@ -202,7 +214,12 @@ async def test_update_skill_dependencies(monkeypatch: pytest.MonkeyPatch):
|
||||
mcp_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"])
|
||||
|
||||
async def fake_get_skill_or_raise(_db, slug: str):
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
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")])
|
||||
assert len(upload_result) == 1
|
||||
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"
|
||||
:question="approvalState.question"
|
||||
:operation="approvalState.operation"
|
||||
@approve="handleApprove"
|
||||
@reject="handleReject"
|
||||
:options="approvalState.options"
|
||||
:multi-select="approvalState.multiSelect"
|
||||
:allow-other="approvalState.allowOther"
|
||||
@submit="handleQuestionSubmit"
|
||||
@cancel="handleQuestionCancel"
|
||||
/>
|
||||
|
||||
<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)
|
||||
ts.isStreaming = false
|
||||
ts.activeRunId = null
|
||||
@ -1541,10 +1549,10 @@ const handleSendOrStop = async (payload) => {
|
||||
}
|
||||
|
||||
// ==================== 人工审批处理 ====================
|
||||
const handleApprovalWithStream = async (approved) => {
|
||||
const handleApprovalWithStream = async (answer) => {
|
||||
const threadId = approvalState.threadId
|
||||
if (!threadId) {
|
||||
message.error('无效的审批请求')
|
||||
message.error('无效的提问请求')
|
||||
approvalState.showModal = false
|
||||
return
|
||||
}
|
||||
@ -1558,11 +1566,7 @@ const handleApprovalWithStream = async (approved) => {
|
||||
|
||||
try {
|
||||
// 使用审批 composable 处理审批
|
||||
const response = await handleApproval(
|
||||
approved,
|
||||
currentAgentId.value,
|
||||
selectedAgentConfigId.value
|
||||
)
|
||||
const response = await handleApproval(answer, currentAgentId.value, selectedAgentConfigId.value)
|
||||
|
||||
if (!response) return // 如果 handleApproval 抛出错误,这里不会执行
|
||||
|
||||
@ -1586,12 +1590,12 @@ const handleApprovalWithStream = async (approved) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleApprove = () => {
|
||||
handleApprovalWithStream(true)
|
||||
const handleQuestionSubmit = (answer) => {
|
||||
handleApprovalWithStream(answer)
|
||||
}
|
||||
|
||||
const handleReject = () => {
|
||||
handleApprovalWithStream(false)
|
||||
const handleQuestionCancel = () => {
|
||||
handleApprovalWithStream('reject')
|
||||
}
|
||||
|
||||
// 处理示例问题点击
|
||||
|
||||
@ -6,19 +6,49 @@
|
||||
<h4>{{ question }}</h4>
|
||||
</div>
|
||||
|
||||
<div class="approval-operation">
|
||||
<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"
|
||||
:disabled="isProcessing"
|
||||
placeholder="Other: 输入自定义答案"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="approval-actions">
|
||||
<button class="btn btn-reject" @click="handleReject" :disabled="isProcessing">
|
||||
✕ 拒绝
|
||||
</button>
|
||||
<button class="btn btn-approve" @click="handleApprove" :disabled="isProcessing">
|
||||
✓ 批准
|
||||
</button>
|
||||
<button class="btn btn-reject" @click="handleCancel" :disabled="isProcessing">取消</button>
|
||||
<button class="btn btn-approve" @click="handleSubmit" :disabled="isSubmitDisabled">提交</button>
|
||||
</div>
|
||||
|
||||
<div v-if="isProcessing" class="approval-processing">
|
||||
@ -30,47 +60,114 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
question: {
|
||||
type: String,
|
||||
default: '是否批准此操作?'
|
||||
},
|
||||
operation: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
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 }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['approve', 'reject'])
|
||||
const emit = defineEmits(['submit', 'cancel'])
|
||||
|
||||
const isProcessing = ref(false)
|
||||
const selectedValues = ref([])
|
||||
const otherText = ref('')
|
||||
|
||||
const resetForm = () => {
|
||||
isProcessing.value = false
|
||||
selectedValues.value = []
|
||||
otherText.value = ''
|
||||
}
|
||||
|
||||
// 监听弹窗关闭,重置处理状态
|
||||
watch(
|
||||
() => props.visible,
|
||||
(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
|
||||
isProcessing.value = true
|
||||
emit('approve')
|
||||
if (selectedValues.value.includes(value)) {
|
||||
selectedValues.value = selectedValues.value.filter((item) => item !== value)
|
||||
} else {
|
||||
selectedValues.value = [...selectedValues.value, value]
|
||||
}
|
||||
}
|
||||
|
||||
const handleReject = () => {
|
||||
const setSingle = (value) => {
|
||||
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
|
||||
emit('reject')
|
||||
emit('submit', buildAnswer())
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
if (isProcessing.value) return
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -112,6 +209,7 @@ const handleReject = () => {
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.approval-operation .label {
|
||||
@ -125,6 +223,42 @@ const handleReject = () => {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@ -140,10 +274,6 @@ const handleReject = () => {
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
@ -167,7 +297,6 @@ const handleReject = () => {
|
||||
|
||||
.btn-approve:hover:not(:disabled) {
|
||||
background: var(--main-700);
|
||||
box-shadow: 0 2px 6px rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
.approval-processing {
|
||||
@ -197,7 +326,6 @@ const handleReject = () => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 滑入滑出动画 */
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
|
||||
@ -110,6 +110,7 @@ export function useAgentStreamHandler({
|
||||
}
|
||||
return true
|
||||
|
||||
case 'ask_user_question_required':
|
||||
case 'human_approval_required':
|
||||
console.log(`${debugPrefix}[approval_required]`, {
|
||||
threadId,
|
||||
|
||||
@ -3,21 +3,79 @@ 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)
|
||||
}
|
||||
|
||||
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 }) {
|
||||
// 审批状态
|
||||
const approvalState = reactive({
|
||||
showModal: false,
|
||||
questionId: '',
|
||||
question: '',
|
||||
operation: '',
|
||||
threadId: null,
|
||||
interruptInfo: null
|
||||
options: [],
|
||||
multiSelect: false,
|
||||
allowOther: true,
|
||||
source: '',
|
||||
legacyMode: false,
|
||||
threadId: null
|
||||
})
|
||||
|
||||
// 处理审批逻辑
|
||||
const handleApproval = async (approved, currentAgentId, agentConfigId = null) => {
|
||||
const handleApproval = async (answer, currentAgentId, agentConfigId = null) => {
|
||||
const threadId = approvalState.threadId
|
||||
if (!threadId) {
|
||||
message.error('无效的审批请求')
|
||||
message.error('无效的提问请求')
|
||||
approvalState.showModal = false
|
||||
return
|
||||
}
|
||||
@ -29,94 +87,93 @@ export function useApproval({ getThreadState, resetOnGoingConv, fetchThreadMessa
|
||||
return
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
approvalState.showModal = false
|
||||
|
||||
// 清理旧的流式控制器(如果存在)
|
||||
if (threadState.streamAbortController) {
|
||||
threadState.streamAbortController.abort()
|
||||
threadState.streamAbortController = null
|
||||
}
|
||||
|
||||
// 标记为处理中
|
||||
threadState.isStreaming = true
|
||||
resetOnGoingConv(threadId)
|
||||
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 {
|
||||
// 调用恢复接口
|
||||
const response = await agentApi.resumeAgentChat(
|
||||
currentAgentId,
|
||||
{
|
||||
thread_id: threadId,
|
||||
approved: approved,
|
||||
config: agentConfigId ? { agent_config_id: agentConfigId } : {}
|
||||
},
|
||||
{
|
||||
signal: threadState.streamAbortController?.signal
|
||||
}
|
||||
)
|
||||
|
||||
console.log('🔄 [APPROVAL] Resume API response received')
|
||||
const response = await agentApi.resumeAgentChat(currentAgentId, requestBody, {
|
||||
signal: threadState.streamAbortController?.signal
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('Resume API error:', response.status, 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) {
|
||||
console.error('❌ [APPROVAL] Resume failed:', error)
|
||||
if (error.name !== 'AbortError') {
|
||||
handleChatError(error, 'resume')
|
||||
message.error(`恢复对话失败: ${error.message || '未知错误'}`)
|
||||
}
|
||||
// 重置状态 - 只在错误时重置
|
||||
threadState.isStreaming = false
|
||||
threadState.streamAbortController = null
|
||||
throw error // 重新抛出错误让调用方处理
|
||||
throw error
|
||||
}
|
||||
// 移除 finally 块 - 让组件管理流式状态的生命周期
|
||||
}
|
||||
|
||||
// 在流式处理中处理审批请求
|
||||
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
|
||||
}
|
||||
|
||||
const { interrupt_info } = chunk
|
||||
const threadState = getThreadState(threadId)
|
||||
|
||||
if (!threadState) return false
|
||||
|
||||
// 停止显示"处理中"状态,让用户可以看到并操作审批弹窗
|
||||
const payload = extractQuestionPayload(chunk)
|
||||
if (!payload.options.length) {
|
||||
payload.options = toLegacyApprovalOptions()
|
||||
payload.legacyMode = true
|
||||
payload.allowOther = false
|
||||
}
|
||||
|
||||
threadState.isStreaming = false
|
||||
|
||||
// 显示审批弹窗
|
||||
approvalState.showModal = true
|
||||
approvalState.question = interrupt_info?.question || '是否批准以下操作?'
|
||||
approvalState.operation = interrupt_info?.operation || '未知操作'
|
||||
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.legacyMode = payload.legacyMode
|
||||
approvalState.threadId = chunk.thread_id || threadId
|
||||
approvalState.interruptInfo = interrupt_info
|
||||
|
||||
// 刷新消息历史显示已执行的部分
|
||||
fetchThreadMessages({ agentId: currentAgentId, threadId: threadId })
|
||||
fetchThreadMessages({ agentId: currentAgentId, threadId })
|
||||
|
||||
return true // 表示已处理审批请求,应停止流式处理
|
||||
return true
|
||||
}
|
||||
|
||||
// 重置审批状态
|
||||
const resetApprovalState = () => {
|
||||
approvalState.showModal = false
|
||||
approvalState.questionId = ''
|
||||
approvalState.question = ''
|
||||
approvalState.operation = ''
|
||||
approvalState.options = []
|
||||
approvalState.multiSelect = false
|
||||
approvalState.allowOther = true
|
||||
approvalState.source = ''
|
||||
approvalState.legacyMode = false
|
||||
approvalState.threadId = null
|
||||
approvalState.interruptInfo = null
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user