refactor(chat-api): 重构聊天接口请求模型:流式与非流式聊天统一使用 query + agent_config_id 请求体
This commit is contained in:
parent
7037d48d51
commit
9f51cfd96b
@ -12,6 +12,7 @@ from fastapi import HTTPException
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from yuxi.agents.buildin import agent_manager
|
||||
from yuxi.repositories.agent_config_repository import AgentConfigRepository
|
||||
from yuxi.repositories.agent_run_repository import TERMINAL_RUN_STATUSES, AgentRunRepository
|
||||
from yuxi.repositories.conversation_repository import ConversationRepository
|
||||
from yuxi.services.run_queue_service import (
|
||||
@ -51,9 +52,10 @@ def _format_sse(data: dict, event: str | None = None) -> str:
|
||||
|
||||
async def create_agent_run_view(
|
||||
*,
|
||||
agent_id: str,
|
||||
query: str,
|
||||
config: dict,
|
||||
agent_config_id: int,
|
||||
thread_id: str,
|
||||
meta: dict,
|
||||
image_content: str | None,
|
||||
current_user_id: str,
|
||||
db: AsyncSession,
|
||||
@ -61,19 +63,28 @@ async def create_agent_run_view(
|
||||
if not query:
|
||||
raise HTTPException(status_code=422, detail="query 不能为空")
|
||||
|
||||
if not thread_id:
|
||||
raise HTTPException(status_code=422, detail="thread_id 不能为空")
|
||||
|
||||
config_repo = AgentConfigRepository(db)
|
||||
config_item = await config_repo.get_by_id(config_id=int(agent_config_id))
|
||||
if config_item is None:
|
||||
raise HTTPException(status_code=404, detail="配置不存在")
|
||||
|
||||
agent_id = config_item.agent_id
|
||||
if not agent_manager.get_agent(agent_id):
|
||||
raise HTTPException(status_code=404, detail=f"智能体 {agent_id} 不存在")
|
||||
|
||||
thread_id = (config or {}).get("thread_id")
|
||||
if not thread_id:
|
||||
raise HTTPException(status_code=422, detail="config.thread_id 不能为空")
|
||||
|
||||
conv_repo = ConversationRepository(db)
|
||||
conversation = await conv_repo.get_conversation_by_thread_id(thread_id)
|
||||
if not conversation or conversation.user_id != str(current_user_id) or conversation.status == "deleted":
|
||||
raise HTTPException(status_code=404, detail="对话线程不存在")
|
||||
|
||||
request_id = str((config or {}).get("request_id") or uuid.uuid4())
|
||||
request_id = str((meta or {}).get("request_id") or uuid.uuid4())
|
||||
config = {
|
||||
"thread_id": thread_id,
|
||||
"agent_config_id": int(agent_config_id),
|
||||
}
|
||||
run_repo = AgentRunRepository(db)
|
||||
existing = await run_repo.get_run_by_request_id(request_id)
|
||||
if existing and existing.user_id == str(current_user_id):
|
||||
|
||||
@ -298,6 +298,35 @@ def _ensure_full_msg(full_msg: AIMessage | None, accumulated_content: list[str])
|
||||
return full_msg
|
||||
|
||||
|
||||
def _extract_ai_message(messages: list[Any] | None) -> AIMessage | None:
|
||||
"""从消息列表中提取最后一条 AIMessage。"""
|
||||
if not isinstance(messages, list):
|
||||
return None
|
||||
|
||||
for msg in reversed(messages):
|
||||
if isinstance(msg, AIMessage):
|
||||
return msg
|
||||
|
||||
msg_dict = msg.model_dump() if hasattr(msg, "model_dump") else {}
|
||||
if msg_dict.get("type") == "ai":
|
||||
content = msg_dict.get("content", "")
|
||||
return msg if hasattr(msg, "content") else AIMessage(content=content)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_agent_config_by_id(db, user: User, agent_config_id: int):
|
||||
"""按配置 ID 解析 AgentConfig 记录。"""
|
||||
department_id = user.department_id
|
||||
|
||||
agent_config_repo = AgentConfigRepository(db)
|
||||
config_item = await agent_config_repo.get_by_id(config_id=int(agent_config_id))
|
||||
if config_item is None or config_item.department_id != department_id:
|
||||
raise ValueError("配置不存在")
|
||||
|
||||
return config_item
|
||||
|
||||
|
||||
async def _resolve_agent_config(db, agent_id: str, user: User, agent_config_id):
|
||||
"""解析 agent_config,返回 agent_config"""
|
||||
department_id = user.department_id
|
||||
@ -305,8 +334,8 @@ async def _resolve_agent_config(db, agent_id: str, user: User, agent_config_id):
|
||||
agent_config_repo = AgentConfigRepository(db)
|
||||
config_item = None
|
||||
if agent_config_id is not None:
|
||||
config_item = await agent_config_repo.get_by_id(config_id=int(agent_config_id))
|
||||
if config_item is not None and (config_item.department_id != department_id or config_item.agent_id != agent_id):
|
||||
config_item = await get_agent_config_by_id(db, user, int(agent_config_id))
|
||||
if config_item.agent_id != agent_id:
|
||||
config_item = None
|
||||
|
||||
if config_item is None:
|
||||
@ -344,9 +373,9 @@ async def check_and_handle_interrupts(
|
||||
|
||||
async def agent_chat(
|
||||
*,
|
||||
agent_id: str,
|
||||
query: str,
|
||||
config: dict,
|
||||
agent_config_id: int,
|
||||
thread_id: str | None,
|
||||
meta: dict,
|
||||
image_content: str | None,
|
||||
current_user,
|
||||
@ -367,13 +396,6 @@ async def agent_chat(
|
||||
human_message = HumanMessage(content=query)
|
||||
message_type = "text"
|
||||
|
||||
init_msg = {"role": "user", "content": query, "type": "human"}
|
||||
if image_content:
|
||||
init_msg["message_type"] = "multimodal_image"
|
||||
init_msg["image_content"] = image_content
|
||||
else:
|
||||
init_msg["message_type"] = "text"
|
||||
|
||||
if conf.enable_content_guard and await content_guard.check(query):
|
||||
return {
|
||||
"status": "error",
|
||||
@ -382,6 +404,42 @@ async def agent_chat(
|
||||
"request_id": meta.get("request_id"),
|
||||
}
|
||||
|
||||
if not current_user.department_id:
|
||||
return {
|
||||
"status": "error",
|
||||
"error_type": "invalid_config",
|
||||
"error_message": "当前用户未绑定部门",
|
||||
"request_id": meta.get("request_id"),
|
||||
}
|
||||
|
||||
user_id = str(current_user.id)
|
||||
meta = dict(meta or {})
|
||||
if "request_id" not in meta or not meta.get("request_id"):
|
||||
logger.warning("请求缺少 request_id,已自动生成一个新的 request_id")
|
||||
meta["request_id"] = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
config_item = await get_agent_config_by_id(db, current_user, agent_config_id)
|
||||
except ValueError as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"error_type": "invalid_config",
|
||||
"error_message": str(e),
|
||||
"request_id": meta.get("request_id"),
|
||||
}
|
||||
|
||||
agent_id = config_item.agent_id
|
||||
meta.update(
|
||||
{
|
||||
"query": query,
|
||||
"agent_id": agent_id,
|
||||
"server_model_name": agent_id,
|
||||
"thread_id": thread_id,
|
||||
"user_id": current_user.id,
|
||||
"has_image": bool(image_content),
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
agent = agent_manager.get_agent(agent_id)
|
||||
except Exception as e:
|
||||
@ -394,20 +452,9 @@ async def agent_chat(
|
||||
}
|
||||
|
||||
messages = [human_message]
|
||||
agent_config = (config_item.config_json or {}).get("context", {})
|
||||
|
||||
user_id = str(current_user.id)
|
||||
agent_config_id = config.get("agent_config_id")
|
||||
try:
|
||||
agent_config = await _resolve_agent_config(db, agent_id, current_user, agent_config_id)
|
||||
except ValueError as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"error_type": "invalid_config",
|
||||
"error_message": str(e),
|
||||
"request_id": meta.get("request_id"),
|
||||
}
|
||||
|
||||
if not (thread_id := config.get("thread_id")):
|
||||
if not thread_id:
|
||||
thread_id = str(uuid.uuid4())
|
||||
logger.warning(f"No thread_id provided, generated new thread_id: {thread_id}")
|
||||
|
||||
@ -429,20 +476,18 @@ async def agent_chat(
|
||||
logger.error(f"Error saving user message: {e}")
|
||||
|
||||
langgraph_config = {"configurable": {"thread_id": thread_id, "user_id": user_id}}
|
||||
invoke_result = await agent.invoke_messages(messages, input_context=input_context)
|
||||
full_msg = _extract_ai_message(invoke_result.get("messages") if isinstance(invoke_result, dict) else None)
|
||||
|
||||
full_msg = None
|
||||
accumulated_content: list[str] = []
|
||||
if full_msg is None:
|
||||
try:
|
||||
graph = await agent.get_graph()
|
||||
state = await graph.aget_state(langgraph_config)
|
||||
full_msg = _extract_ai_message(getattr(state, "values", {}).get("messages", [])) if state else None
|
||||
except Exception:
|
||||
full_msg = None
|
||||
|
||||
async for msg, metadata in agent.stream_messages(messages, input_context=input_context):
|
||||
if isinstance(msg, AIMessageChunk):
|
||||
accumulated_content.append(msg.content)
|
||||
else:
|
||||
msg_dict = msg.model_dump() if hasattr(msg, "model_dump") else {}
|
||||
if msg_dict.get("type") == "ai":
|
||||
full_msg = msg
|
||||
|
||||
full_msg = _ensure_full_msg(full_msg, accumulated_content)
|
||||
full_content = "".join(accumulated_content) if accumulated_content else (full_msg.content if full_msg else "")
|
||||
full_content = full_msg.content if full_msg else ""
|
||||
|
||||
if conf.enable_content_guard and await content_guard.check(full_content):
|
||||
await save_partial_message(conv_repo, thread_id, full_msg, "content_guard_blocked")
|
||||
@ -488,9 +533,9 @@ async def agent_chat(
|
||||
|
||||
async def stream_agent_chat(
|
||||
*,
|
||||
agent_id: str,
|
||||
query: str,
|
||||
config: dict,
|
||||
agent_config_id: int,
|
||||
thread_id: str | None,
|
||||
meta: dict,
|
||||
image_content: str | None,
|
||||
current_user,
|
||||
@ -533,6 +578,34 @@ async def stream_agent_chat(
|
||||
)
|
||||
return
|
||||
|
||||
if not current_user.department_id:
|
||||
yield make_chunk(status="error", error_type="invalid_config", error_message="当前用户未绑定部门", meta=meta)
|
||||
return
|
||||
|
||||
meta = dict(meta or {})
|
||||
if "request_id" not in meta or not meta.get("request_id"):
|
||||
logger.warning("请求缺少 request_id,已自动生成一个新的 request_id")
|
||||
meta["request_id"] = str(uuid.uuid4())
|
||||
|
||||
user_id = str(current_user.id)
|
||||
try:
|
||||
config_item = await get_agent_config_by_id(db, current_user, agent_config_id)
|
||||
except ValueError as e:
|
||||
yield make_chunk(status="error", error_type="invalid_config", error_message=str(e), meta=meta)
|
||||
return
|
||||
|
||||
agent_id = config_item.agent_id
|
||||
meta.update(
|
||||
{
|
||||
"query": query,
|
||||
"agent_id": agent_id,
|
||||
"server_model_name": agent_id,
|
||||
"thread_id": thread_id,
|
||||
"user_id": current_user.id,
|
||||
"has_image": bool(image_content),
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
agent = agent_manager.get_agent(agent_id)
|
||||
except Exception as e:
|
||||
@ -546,16 +619,9 @@ async def stream_agent_chat(
|
||||
return
|
||||
|
||||
messages = [human_message]
|
||||
agent_config = (config_item.config_json or {}).get("context", {})
|
||||
|
||||
user_id = str(current_user.id)
|
||||
agent_config_id = config.get("agent_config_id")
|
||||
try:
|
||||
agent_config = await _resolve_agent_config(db, agent_id, current_user, agent_config_id)
|
||||
except ValueError as e:
|
||||
yield make_chunk(status="error", error_type="invalid_config", error_message=str(e), meta=meta)
|
||||
return
|
||||
|
||||
if not (thread_id := config.get("thread_id")):
|
||||
if not thread_id:
|
||||
thread_id = str(uuid.uuid4())
|
||||
logger.warning(f"No thread_id provided, generated new thread_id: {thread_id}")
|
||||
|
||||
|
||||
@ -235,9 +235,9 @@ async def process_agent_run(ctx, run_id: str):
|
||||
try:
|
||||
async with pg_manager.get_async_session_context() as db:
|
||||
stream = stream_agent_chat(
|
||||
agent_id=agent_id,
|
||||
query=query,
|
||||
config=config,
|
||||
agent_config_id=config.get("agent_config_id"),
|
||||
thread_id=config.get("thread_id"),
|
||||
meta=meta,
|
||||
image_content=image_content,
|
||||
current_user=user,
|
||||
|
||||
@ -14,7 +14,7 @@ from server.utils.auth_middleware import get_db, get_required_user
|
||||
from yuxi import config as conf
|
||||
from yuxi.agents.buildin import agent_manager
|
||||
from yuxi.models import select_model
|
||||
from yuxi.services.chat_service import get_agent_state_view, stream_agent_chat, stream_agent_resume, agent_chat
|
||||
from yuxi.services.chat_service import agent_chat, get_agent_state_view, stream_agent_chat, stream_agent_resume
|
||||
from yuxi.services.agent_run_service import (
|
||||
cancel_agent_run_view,
|
||||
create_agent_run_view,
|
||||
@ -43,6 +43,8 @@ from yuxi.utils.logging_config import logger
|
||||
from yuxi.utils.image_processor import process_uploaded_image
|
||||
|
||||
|
||||
# TODO:当前文件的功能过于庞杂,路由标签混乱
|
||||
|
||||
# 图片上传响应模型
|
||||
class ImageUploadResponse(BaseModel):
|
||||
success: bool
|
||||
@ -76,9 +78,19 @@ class AgentConfigUpdate(BaseModel):
|
||||
|
||||
|
||||
class AgentRunCreate(BaseModel):
|
||||
query: str
|
||||
config: dict = Field(default_factory=dict)
|
||||
image_content: str | None = None
|
||||
query: str = Field(..., description="用户输入的问题")
|
||||
agent_config_id: int = Field(..., description="智能体配置 ID,后端将据此解析 agent_id 和运行时 context")
|
||||
thread_id: str = Field(..., description="会话线程 ID")
|
||||
meta: dict = Field(default_factory=dict, description="可选,请求追踪信息,例如 request_id")
|
||||
image_content: str | None = Field(None, description="可选,base64 图片内容")
|
||||
|
||||
|
||||
class AgentChatRequest(BaseModel):
|
||||
query: str = Field(..., description="用户输入的问题")
|
||||
agent_config_id: int = Field(..., description="智能体配置 ID,后端将据此解析 agent_id 和运行时 context")
|
||||
thread_id: str | None = Field(None, description="可选,会话线程 ID;不传则自动创建")
|
||||
meta: dict = Field(default_factory=dict, description="可选,请求追踪信息,例如 request_id")
|
||||
image_content: str | None = Field(None, description="可选,base64 图片内容")
|
||||
|
||||
|
||||
chat = APIRouter(prefix="/chat", tags=["chat"])
|
||||
@ -218,7 +230,6 @@ async def list_agent_configs(
|
||||
if not items:
|
||||
await repo.get_or_create_default(
|
||||
department_id=current_user.department_id,
|
||||
agent_id=agent_id,
|
||||
created_by=str(current_user.id),
|
||||
)
|
||||
items = await repo.list_by_department_agent(department_id=current_user.department_id, agent_id=agent_id)
|
||||
@ -275,7 +286,6 @@ async def create_agent_config_profile(
|
||||
repo = AgentConfigRepository(db)
|
||||
item = await repo.create(
|
||||
department_id=current_user.department_id,
|
||||
agent_id=agent_id,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
icon=payload.icon,
|
||||
@ -367,44 +377,28 @@ async def delete_agent_config_profile(
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@chat.post("/agent/{agent_id}")
|
||||
@chat.post("/agent")
|
||||
async def chat_agent(
|
||||
agent_id: str,
|
||||
query: str = Body(...),
|
||||
config: dict = Body({}),
|
||||
meta: dict = Body({}),
|
||||
image_content: str | None = Body(None),
|
||||
payload: AgentChatRequest,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""使用特定智能体进行对话(需要登录)"""
|
||||
logger.info(f"agent_id: {agent_id}, query: {query}, config: {config}, meta: {meta}")
|
||||
logger.info(f"image_content present: {image_content is not None}")
|
||||
if image_content:
|
||||
logger.info(f"image_content length: {len(image_content)}")
|
||||
logger.info(f"image_content preview: {image_content[:50]}...")
|
||||
logger.info(f"query: {payload.query}, agent_config_id: {payload.agent_config_id}, meta: {payload.meta}")
|
||||
|
||||
# 确保 request_id 存在
|
||||
if "request_id" not in meta or not meta.get("request_id"):
|
||||
meta["request_id"] = str(uuid.uuid4())
|
||||
# 查看图片内容
|
||||
logger.info(f"image_content present: {payload.image_content is not None}")
|
||||
if payload.image_content:
|
||||
logger.info(f"image_content length: {len(payload.image_content)}")
|
||||
logger.info(f"image_content preview: {payload.image_content[:50]}...")
|
||||
|
||||
meta.update(
|
||||
{
|
||||
"query": query,
|
||||
"agent_id": agent_id,
|
||||
"server_model_name": config.get("model", agent_id),
|
||||
"thread_id": config.get("thread_id"),
|
||||
"user_id": current_user.id,
|
||||
"has_image": bool(image_content),
|
||||
}
|
||||
)
|
||||
return StreamingResponse(
|
||||
stream_agent_chat(
|
||||
agent_id=agent_id,
|
||||
query=query,
|
||||
config=config,
|
||||
meta=meta,
|
||||
image_content=image_content,
|
||||
query=payload.query,
|
||||
agent_config_id=payload.agent_config_id,
|
||||
thread_id=payload.thread_id,
|
||||
meta=dict(payload.meta or {}),
|
||||
image_content=payload.image_content,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
),
|
||||
@ -412,60 +406,41 @@ async def chat_agent(
|
||||
)
|
||||
|
||||
|
||||
@chat.post("/agent/{agent_id}/sync")
|
||||
@chat.post("/agent/sync")
|
||||
async def chat_agent_sync(
|
||||
agent_id: str,
|
||||
query: str = Body(...),
|
||||
config: dict = Body({}),
|
||||
meta: dict = Body({}),
|
||||
image_content: str | None = Body(None),
|
||||
payload: AgentChatRequest,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""使用特定智能体进行非流式对话(需要登录)"""
|
||||
logger.info(f"[sync] agent_id: {agent_id}, query: {query}, config: {config}, meta: {meta}")
|
||||
logger.info(f"[sync] image_content present: {image_content is not None}")
|
||||
if image_content:
|
||||
logger.info(f"[sync] image_content length: {len(image_content)}")
|
||||
|
||||
# 确保 request_id 存在
|
||||
if "request_id" not in meta or not meta.get("request_id"):
|
||||
meta["request_id"] = str(uuid.uuid4())
|
||||
|
||||
meta.update(
|
||||
{
|
||||
"query": query,
|
||||
"agent_id": agent_id,
|
||||
"server_model_name": config.get("model", agent_id),
|
||||
"thread_id": config.get("thread_id"),
|
||||
"user_id": current_user.id,
|
||||
"has_image": bool(image_content),
|
||||
}
|
||||
)
|
||||
logger.info(f"[sync] query: {payload.query}, agent_config_id: {payload.agent_config_id}, meta: {payload.meta}")
|
||||
logger.info(f"[sync] image_content present: {payload.image_content is not None}")
|
||||
if payload.image_content:
|
||||
logger.info(f"[sync] image_content length: {len(payload.image_content)}")
|
||||
|
||||
return await agent_chat(
|
||||
agent_id=agent_id,
|
||||
query=query,
|
||||
config=config,
|
||||
meta=meta,
|
||||
image_content=image_content,
|
||||
query=payload.query,
|
||||
agent_config_id=payload.agent_config_id,
|
||||
thread_id=payload.thread_id,
|
||||
meta=dict(payload.meta or {}),
|
||||
image_content=payload.image_content,
|
||||
current_user=current_user,
|
||||
db=db,
|
||||
)
|
||||
|
||||
|
||||
@chat.post("/agent/{agent_id}/runs")
|
||||
@chat.post("/runs")
|
||||
async def create_agent_run(
|
||||
agent_id: str,
|
||||
payload: AgentRunCreate,
|
||||
current_user: User = Depends(get_required_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建异步 run 任务并入队(需要登录)"""
|
||||
return await create_agent_run_view(
|
||||
agent_id=agent_id,
|
||||
query=payload.query,
|
||||
config=payload.config or {},
|
||||
agent_config_id=payload.agent_config_id,
|
||||
thread_id=payload.thread_id,
|
||||
meta=dict(payload.meta or {}),
|
||||
image_content=payload.image_content,
|
||||
current_user_id=str(current_user.id),
|
||||
db=db,
|
||||
@ -627,7 +602,6 @@ async def resume_agent_chat(
|
||||
meta["request_id"] = str(uuid.uuid4())
|
||||
return StreamingResponse(
|
||||
stream_agent_resume(
|
||||
agent_id=agent_id,
|
||||
thread_id=thread_id,
|
||||
resume_input=resume_input,
|
||||
meta=meta,
|
||||
|
||||
@ -49,6 +49,7 @@ class AgentBubbleSortE2ETester:
|
||||
self.client = httpx.AsyncClient(base_url=API_BASE_URL, timeout=TIMEOUT, follow_redirects=True)
|
||||
self.headers: dict[str, str] | None = None
|
||||
self.agent_id: str | None = None
|
||||
self.agent_config_id: int | None = None
|
||||
self.thread_id: str | None = None
|
||||
|
||||
async def close(self):
|
||||
@ -98,14 +99,29 @@ class AgentBubbleSortE2ETester:
|
||||
if not self.thread_id:
|
||||
raise RuntimeError(f"thread id missing: {payload}")
|
||||
|
||||
async def pick_agent_config(self) -> None:
|
||||
assert self.headers and self.agent_id
|
||||
response = await self.client.get(f"/api/chat/agent/{self.agent_id}/configs", headers=self.headers)
|
||||
if response.status_code != 200:
|
||||
raise RuntimeError(f"list configs failed: {response.status_code} {response.text}")
|
||||
configs = response.json().get("configs") or []
|
||||
if not configs:
|
||||
raise RuntimeError("no available agent configs")
|
||||
config_id = configs[0].get("id")
|
||||
if not config_id:
|
||||
raise RuntimeError(f"config id missing: {response.text}")
|
||||
self.agent_config_id = int(config_id)
|
||||
|
||||
async def create_run(self, query: str) -> str:
|
||||
assert self.headers and self.agent_id and self.thread_id
|
||||
assert self.headers and self.agent_config_id and self.thread_id
|
||||
request_id = f"agent-bubble-sort-{uuid.uuid4()}"
|
||||
response = await self.client.post(
|
||||
f"/api/chat/agent/{self.agent_id}/runs",
|
||||
"/api/chat/runs",
|
||||
json={
|
||||
"query": query,
|
||||
"config": {"thread_id": self.thread_id, "request_id": request_id},
|
||||
"agent_config_id": self.agent_config_id,
|
||||
"thread_id": self.thread_id,
|
||||
"meta": {"request_id": request_id},
|
||||
},
|
||||
headers=self.headers,
|
||||
)
|
||||
@ -166,6 +182,7 @@ class AgentBubbleSortE2ETester:
|
||||
async def run_case(self) -> None:
|
||||
await self.login()
|
||||
await self.pick_agent()
|
||||
await self.pick_agent_config()
|
||||
await self.create_thread()
|
||||
|
||||
query = (
|
||||
|
||||
@ -9,6 +9,26 @@ import pytest
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||||
|
||||
|
||||
async def _get_default_agent_config_id(test_client, headers):
|
||||
agent_response = await test_client.get("/api/chat/default_agent", headers=headers)
|
||||
assert agent_response.status_code == 200
|
||||
agent_id = agent_response.json().get("default_agent_id")
|
||||
if not agent_id:
|
||||
pytest.skip("No default agent configured")
|
||||
|
||||
configs_response = await test_client.get(f"/api/chat/agent/{agent_id}/configs", headers=headers)
|
||||
assert configs_response.status_code == 200, configs_response.text
|
||||
configs = configs_response.json().get("configs", [])
|
||||
if not configs:
|
||||
pytest.skip("No configs found for default agent")
|
||||
|
||||
config_id = configs[0].get("id")
|
||||
if not config_id:
|
||||
pytest.skip("Agent config payload missing id field.")
|
||||
|
||||
return agent_id, config_id
|
||||
|
||||
|
||||
async def test_list_api_keys_requires_auth(test_client):
|
||||
"""List API keys should require authentication."""
|
||||
response = await test_client.get("/api/apikey/")
|
||||
@ -46,9 +66,7 @@ async def test_create_api_key(test_client, admin_headers):
|
||||
async def test_get_api_key(test_client, admin_headers):
|
||||
"""Admin should be able to get a single API key."""
|
||||
# First create a key
|
||||
create_response = await test_client.post(
|
||||
"/api/apikey/", json={"name": "Get Test"}, headers=admin_headers
|
||||
)
|
||||
create_response = await test_client.post("/api/apikey/", json={"name": "Get Test"}, headers=admin_headers)
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()["api_key"]
|
||||
|
||||
@ -63,9 +81,7 @@ async def test_get_api_key(test_client, admin_headers):
|
||||
async def test_update_api_key(test_client, admin_headers):
|
||||
"""Admin should be able to update an API key."""
|
||||
# Create a key
|
||||
create_response = await test_client.post(
|
||||
"/api/apikey/", json={"name": "Update Test"}, headers=admin_headers
|
||||
)
|
||||
create_response = await test_client.post("/api/apikey/", json={"name": "Update Test"}, headers=admin_headers)
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()["api_key"]
|
||||
|
||||
@ -84,9 +100,7 @@ async def test_update_api_key(test_client, admin_headers):
|
||||
async def test_delete_api_key(test_client, admin_headers):
|
||||
"""Admin should be able to delete an API key."""
|
||||
# Create a key
|
||||
create_response = await test_client.post(
|
||||
"/api/apikey/", json={"name": "Delete Test"}, headers=admin_headers
|
||||
)
|
||||
create_response = await test_client.post("/api/apikey/", json={"name": "Delete Test"}, headers=admin_headers)
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()["api_key"]
|
||||
|
||||
@ -103,9 +117,7 @@ async def test_delete_api_key(test_client, admin_headers):
|
||||
async def test_regenerate_api_key(test_client, admin_headers):
|
||||
"""Admin should be able to regenerate an API key."""
|
||||
# Create a key
|
||||
create_response = await test_client.post(
|
||||
"/api/apikey/", json={"name": "Regenerate Test"}, headers=admin_headers
|
||||
)
|
||||
create_response = await test_client.post("/api/apikey/", json={"name": "Regenerate Test"}, headers=admin_headers)
|
||||
assert create_response.status_code == 200
|
||||
original_secret = create_response.json()["secret"]
|
||||
created = create_response.json()["api_key"]
|
||||
@ -122,26 +134,19 @@ async def test_regenerate_api_key(test_client, admin_headers):
|
||||
async def test_api_key_auth_chat_endpoint(test_client, admin_headers):
|
||||
"""Test that API Key can be used to authenticate to chat endpoint via Bearer token."""
|
||||
# Create an API key
|
||||
create_response = await test_client.post(
|
||||
"/api/apikey/", json={"name": "Chat Auth Test"}, headers=admin_headers
|
||||
)
|
||||
create_response = await test_client.post("/api/apikey/", json={"name": "Chat Auth Test"}, headers=admin_headers)
|
||||
assert create_response.status_code == 200
|
||||
api_key_secret = create_response.json()["secret"]
|
||||
created = create_response.json()["api_key"]
|
||||
|
||||
try:
|
||||
# Get default agent
|
||||
agent_response = await test_client.get("/api/chat/default_agent", headers=admin_headers)
|
||||
assert agent_response.status_code == 200
|
||||
agent_id = agent_response.json().get("default_agent_id")
|
||||
if not agent_id:
|
||||
pytest.skip("No default agent configured")
|
||||
_, agent_config_id = await _get_default_agent_config_id(test_client, admin_headers)
|
||||
|
||||
# Call chat endpoint with API Key using Bearer format (streaming response)
|
||||
async with test_client.stream(
|
||||
"POST",
|
||||
f"/api/chat/agent/{agent_id}",
|
||||
json={"query": "Hello"},
|
||||
"/api/chat/agent",
|
||||
json={"query": "Hello", "agent_config_id": agent_config_id},
|
||||
headers={"Authorization": f"Bearer {api_key_secret}"},
|
||||
) as response:
|
||||
assert response.status_code == 200, response.text
|
||||
@ -155,8 +160,8 @@ async def test_api_key_auth_requires_valid_key(test_client):
|
||||
"""Test that invalid API Key is rejected."""
|
||||
# Call chat endpoint with invalid API Key
|
||||
response = await test_client.post(
|
||||
"/api/chat/agent/ChatbotAgent",
|
||||
json={"query": "Hello"},
|
||||
"/api/chat/agent",
|
||||
json={"query": "Hello", "agent_config_id": 1},
|
||||
headers={"Authorization": "Bearer yxkey_invalid_key_that_does_not_exist"},
|
||||
)
|
||||
assert response.status_code == 401, response.text
|
||||
@ -165,9 +170,7 @@ async def test_api_key_auth_requires_valid_key(test_client):
|
||||
async def test_api_key_auth_requires_bearer_prefix(test_client, admin_headers):
|
||||
"""Test that API Key must be prefixed with 'Bearer '."""
|
||||
# Create an API key
|
||||
admin_response = await test_client.post(
|
||||
"/api/apikey/", json={"name": "Prefix Test"}, headers=admin_headers
|
||||
)
|
||||
admin_response = await test_client.post("/api/apikey/", json={"name": "Prefix Test"}, headers=admin_headers)
|
||||
assert admin_response.status_code == 200
|
||||
api_key_secret = admin_response.json()["secret"]
|
||||
created = admin_response.json()["api_key"]
|
||||
@ -175,8 +178,8 @@ async def test_api_key_auth_requires_bearer_prefix(test_client, admin_headers):
|
||||
try:
|
||||
# Call without Bearer prefix should fail
|
||||
response = await test_client.post(
|
||||
"/api/chat/agent/ChatbotAgent",
|
||||
json={"query": "Hello"},
|
||||
"/api/chat/agent",
|
||||
json={"query": "Hello", "agent_config_id": 1},
|
||||
headers={"Authorization": api_key_secret}, # Missing "Bearer " prefix
|
||||
)
|
||||
assert response.status_code == 401, response.text
|
||||
@ -187,18 +190,13 @@ async def test_api_key_auth_requires_bearer_prefix(test_client, admin_headers):
|
||||
|
||||
async def test_jwt_still_works_after_apikey_auth(test_client, admin_headers):
|
||||
"""Test that JWT Bearer tokens still work after API Key changes."""
|
||||
# Get default agent
|
||||
agent_response = await test_client.get("/api/chat/default_agent", headers=admin_headers)
|
||||
assert agent_response.status_code == 200
|
||||
agent_id = agent_response.json().get("default_agent_id")
|
||||
if not agent_id:
|
||||
pytest.skip("No default agent configured")
|
||||
_, agent_config_id = await _get_default_agent_config_id(test_client, admin_headers)
|
||||
|
||||
# Call chat with JWT Bearer token (admin_headers) - streaming response
|
||||
async with test_client.stream(
|
||||
"POST",
|
||||
f"/api/chat/agent/{agent_id}",
|
||||
json={"query": "Hello"},
|
||||
"/api/chat/agent",
|
||||
json={"query": "Hello", "agent_config_id": agent_config_id},
|
||||
headers=admin_headers,
|
||||
) as response:
|
||||
assert response.status_code == 200, response.text
|
||||
@ -208,9 +206,7 @@ async def test_jwt_still_works_after_apikey_auth(test_client, admin_headers):
|
||||
async def test_api_key_auto_binds_to_current_user(test_client, admin_headers):
|
||||
"""Test that API Key created without user_id is auto-bound to creator."""
|
||||
# Create API key as admin
|
||||
create_response = await test_client.post(
|
||||
"/api/apikey/", json={"name": "Auto Bind Test"}, headers=admin_headers
|
||||
)
|
||||
create_response = await test_client.post("/api/apikey/", json={"name": "Auto Bind Test"}, headers=admin_headers)
|
||||
assert create_response.status_code == 200
|
||||
created = create_response.json()["api_key"]
|
||||
|
||||
@ -220,15 +216,14 @@ async def test_api_key_auto_binds_to_current_user(test_client, admin_headers):
|
||||
|
||||
# Verify the key can be used for auth
|
||||
api_key_secret = create_response.json()["secret"]
|
||||
_, agent_config_id = await _get_default_agent_config_id(test_client, admin_headers)
|
||||
async with test_client.stream(
|
||||
"POST",
|
||||
"/api/chat/agent/ChatbotAgent",
|
||||
json={"query": "Hello"},
|
||||
"/api/chat/agent",
|
||||
json={"query": "Hello", "agent_config_id": agent_config_id},
|
||||
headers={"Authorization": f"Bearer {api_key_secret}"},
|
||||
) as response:
|
||||
assert response.status_code == 200, response.text
|
||||
finally:
|
||||
# Cleanup: delete the test API key
|
||||
await test_client.delete(f"/api/apikey/{created['id']}", headers=admin_headers)
|
||||
|
||||
|
||||
|
||||
@ -183,6 +183,25 @@ class APITester:
|
||||
print(f" ✗ 获取失败: {response.status_code} - {response.text}")
|
||||
return None
|
||||
|
||||
async def get_agent_config_id(self, agent_id: str) -> int | None:
|
||||
"""获取指定 Agent 的一个配置 ID"""
|
||||
async with self._client() as client:
|
||||
response = await client.get(
|
||||
f"{self.base_url}/api/chat/agent/{agent_id}/configs",
|
||||
headers=self.headers,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
print(f" ✗ 获取 Agent 配置失败: {response.status_code} - {response.text}")
|
||||
return None
|
||||
|
||||
configs = response.json().get("configs", [])
|
||||
if not configs:
|
||||
print(" ✗ 当前 Agent 没有可用配置")
|
||||
return None
|
||||
|
||||
return configs[0].get("id")
|
||||
|
||||
async def send_chat_message(self, agent_id: str, thread_id: str, query: str) -> bool:
|
||||
"""发送聊天消息(流式)"""
|
||||
print(f"\n{'=' * 60}")
|
||||
@ -191,11 +210,19 @@ class APITester:
|
||||
print(f" Query: {query}")
|
||||
print(f" Thread ID: {thread_id}")
|
||||
|
||||
agent_config_id = await self.get_agent_config_id(agent_id)
|
||||
if not agent_config_id:
|
||||
return False
|
||||
|
||||
async with self._client(timeout=120.0) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
f"{self.base_url}/api/chat/agent/{agent_id}",
|
||||
json={"query": query, "config": {"thread_id": thread_id}},
|
||||
f"{self.base_url}/api/chat/agent",
|
||||
json={
|
||||
"query": query,
|
||||
"agent_config_id": agent_config_id,
|
||||
"thread_id": thread_id,
|
||||
},
|
||||
headers=self.headers,
|
||||
) as response:
|
||||
print(f"\n 响应状态: {response.status_code}")
|
||||
|
||||
@ -9,16 +9,8 @@ import pytest
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||||
|
||||
|
||||
async def test_chat_agent_sync_requires_authentication(test_client):
|
||||
"""非流式端点需要认证"""
|
||||
response = await test_client.post("/api/chat/agent/test_agent/sync", json={"query": "hello"})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_chat_agent_sync_basic_conversation(test_client, admin_headers):
|
||||
"""测试非流式对话基本功能"""
|
||||
# 先获取可用智能体
|
||||
agents_response = await test_client.get("/api/chat/agent", headers=admin_headers)
|
||||
async def _get_agent_and_config_id(test_client, headers):
|
||||
agents_response = await test_client.get("/api/chat/agent", headers=headers)
|
||||
assert agents_response.status_code == 200, agents_response.text
|
||||
agents = agents_response.json().get("agents", [])
|
||||
|
||||
@ -29,10 +21,33 @@ async def test_chat_agent_sync_basic_conversation(test_client, admin_headers):
|
||||
if not agent_id:
|
||||
pytest.skip("Agent payload missing id field.")
|
||||
|
||||
configs_response = await test_client.get(f"/api/chat/agent/{agent_id}/configs", headers=headers)
|
||||
assert configs_response.status_code == 200, configs_response.text
|
||||
configs = configs_response.json().get("configs", [])
|
||||
if not configs:
|
||||
pytest.skip("No agent configs are available in the system.")
|
||||
|
||||
config_id = configs[0].get("id")
|
||||
if not config_id:
|
||||
pytest.skip("Agent config payload missing id field.")
|
||||
|
||||
return agent_id, config_id
|
||||
|
||||
|
||||
async def test_chat_agent_sync_requires_authentication(test_client):
|
||||
"""非流式端点需要认证"""
|
||||
response = await test_client.post("/api/chat/agent/sync", json={"query": "hello", "agent_config_id": 1})
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
async def test_chat_agent_sync_basic_conversation(test_client, admin_headers):
|
||||
"""测试非流式对话基本功能"""
|
||||
_, agent_config_id = await _get_agent_and_config_id(test_client, admin_headers)
|
||||
|
||||
# 调用非流式端点
|
||||
response = await test_client.post(
|
||||
f"/api/chat/agent/{agent_id}/sync",
|
||||
json={"query": "Hello, say 'Hi' back to me"},
|
||||
"/api/chat/agent/sync",
|
||||
json={"query": "Hello, say 'Hi' back to me", "agent_config_id": agent_config_id},
|
||||
headers=admin_headers,
|
||||
)
|
||||
|
||||
@ -58,26 +73,18 @@ async def test_chat_agent_sync_basic_conversation(test_client, admin_headers):
|
||||
|
||||
async def test_chat_agent_sync_with_thread_id(test_client, admin_headers):
|
||||
"""测试非流式对话指定 thread_id"""
|
||||
# 获取可用智能体
|
||||
agents_response = await test_client.get("/api/chat/agent", headers=admin_headers)
|
||||
assert agents_response.status_code == 200, agents_response.text
|
||||
agents = agents_response.json().get("agents", [])
|
||||
|
||||
if not agents:
|
||||
pytest.skip("No agents are registered in the system.")
|
||||
|
||||
agent_id = agents[0].get("id")
|
||||
if not agent_id:
|
||||
pytest.skip("Agent payload missing id field.")
|
||||
_, agent_config_id = await _get_agent_and_config_id(test_client, admin_headers)
|
||||
|
||||
import uuid
|
||||
|
||||
thread_id = str(uuid.uuid4())
|
||||
|
||||
response = await test_client.post(
|
||||
f"/api/chat/agent/{agent_id}/sync",
|
||||
"/api/chat/agent/sync",
|
||||
json={
|
||||
"query": "Hello",
|
||||
"config": {"thread_id": thread_id},
|
||||
"agent_config_id": agent_config_id,
|
||||
"thread_id": thread_id,
|
||||
},
|
||||
headers=admin_headers,
|
||||
)
|
||||
@ -87,29 +94,24 @@ async def test_chat_agent_sync_with_thread_id(test_client, admin_headers):
|
||||
|
||||
# 验证 thread_id 是否保持一致
|
||||
if payload["status"] == "finished":
|
||||
assert payload.get("thread_id") == thread_id, f"thread_id mismatch: expected {thread_id}, got {payload.get('thread_id')}"
|
||||
assert payload.get("thread_id") == thread_id, (
|
||||
f"thread_id mismatch: expected {thread_id}, got {payload.get('thread_id')}"
|
||||
)
|
||||
|
||||
|
||||
async def test_chat_agent_sync_with_meta(test_client, admin_headers):
|
||||
"""测试非流式对话传递 meta 参数"""
|
||||
agents_response = await test_client.get("/api/chat/agent", headers=admin_headers)
|
||||
assert agents_response.status_code == 200, agents_response.text
|
||||
agents = agents_response.json().get("agents", [])
|
||||
|
||||
if not agents:
|
||||
pytest.skip("No agents are registered in the system.")
|
||||
|
||||
agent_id = agents[0].get("id")
|
||||
if not agent_id:
|
||||
pytest.skip("Agent payload missing id field.")
|
||||
_, agent_config_id = await _get_agent_and_config_id(test_client, admin_headers)
|
||||
|
||||
import uuid
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
response = await test_client.post(
|
||||
f"/api/chat/agent/{agent_id}/sync",
|
||||
"/api/chat/agent/sync",
|
||||
json={
|
||||
"query": "Hello",
|
||||
"agent_config_id": agent_config_id,
|
||||
"meta": {"request_id": request_id},
|
||||
},
|
||||
headers=admin_headers,
|
||||
@ -119,35 +121,29 @@ async def test_chat_agent_sync_with_meta(test_client, admin_headers):
|
||||
payload = response.json()
|
||||
|
||||
# 验证 request_id 是否保持一致
|
||||
assert payload.get("request_id") == request_id, f"request_id mismatch: expected {request_id}, got {payload.get('request_id')}"
|
||||
assert payload.get("request_id") == request_id, (
|
||||
f"request_id mismatch: expected {request_id}, got {payload.get('request_id')}"
|
||||
)
|
||||
|
||||
|
||||
async def test_chat_agent_sync_vs_streaming_consistency(test_client, admin_headers):
|
||||
"""对比测试:非流式与流式端点行为一致性"""
|
||||
# 获取可用智能体
|
||||
agents_response = await test_client.get("/api/chat/agent", headers=admin_headers)
|
||||
assert agents_response.status_code == 200, agents_response.text
|
||||
agents = agents_response.json().get("agents", [])
|
||||
|
||||
if not agents:
|
||||
pytest.skip("No agents are registered in the system.")
|
||||
|
||||
agent_id = agents[0].get("id")
|
||||
if not agent_id:
|
||||
pytest.skip("Agent payload missing id field.")
|
||||
_, agent_config_id = await _get_agent_and_config_id(test_client, admin_headers)
|
||||
|
||||
query = "What is 1+1?"
|
||||
|
||||
# 调用流式端点
|
||||
import uuid
|
||||
|
||||
thread_id = str(uuid.uuid4())
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
streaming_response = await test_client.post(
|
||||
f"/api/chat/agent/{agent_id}",
|
||||
"/api/chat/agent",
|
||||
json={
|
||||
"query": query,
|
||||
"config": {"thread_id": thread_id},
|
||||
"agent_config_id": agent_config_id,
|
||||
"thread_id": thread_id,
|
||||
"meta": {"request_id": request_id},
|
||||
},
|
||||
headers=admin_headers,
|
||||
@ -160,11 +156,12 @@ async def test_chat_agent_sync_vs_streaming_consistency(test_client, admin_heade
|
||||
async for line in streaming_response.aiter_lines():
|
||||
if line:
|
||||
import json as json_lib
|
||||
|
||||
try:
|
||||
data = json_lib.loads(line)
|
||||
if data.get("response"):
|
||||
streaming_content.append(data["response"])
|
||||
except:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 调用非流式端点
|
||||
@ -172,10 +169,11 @@ async def test_chat_agent_sync_vs_streaming_consistency(test_client, admin_heade
|
||||
request_id2 = str(uuid.uuid4())
|
||||
|
||||
sync_response = await test_client.post(
|
||||
f"/api/chat/agent/{agent_id}/sync",
|
||||
"/api/chat/agent/sync",
|
||||
json={
|
||||
"query": query,
|
||||
"config": {"thread_id": thread_id2},
|
||||
"agent_config_id": agent_config_id,
|
||||
"thread_id": thread_id2,
|
||||
"meta": {"request_id": request_id2},
|
||||
},
|
||||
headers=admin_headers,
|
||||
|
||||
@ -9,6 +9,14 @@ from sqlalchemy.exc import IntegrityError
|
||||
import yuxi.services.agent_run_service as agent_run_service
|
||||
|
||||
|
||||
class FakeConfigRepo:
|
||||
def __init__(self, db_session):
|
||||
self.db = db_session
|
||||
|
||||
async def get_by_id(self, config_id: int):
|
||||
return SimpleNamespace(id=config_id, agent_id="ChatbotAgent", department_id=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_agent_run_events_emits_error_and_close_on_db_error(monkeypatch: pytest.MonkeyPatch):
|
||||
@asynccontextmanager
|
||||
@ -134,7 +142,7 @@ async def test_create_agent_run_commits_before_enqueue(monkeypatch: pytest.Monke
|
||||
|
||||
async def get_conversation_by_thread_id(self, thread_id: str):
|
||||
del thread_id
|
||||
return SimpleNamespace(user_id="1", status="active")
|
||||
return SimpleNamespace(user_id="1", status="active", department_id=1)
|
||||
|
||||
class Queue:
|
||||
async def enqueue_job(self, job_name: str, run_id: str, _job_id: str):
|
||||
@ -148,14 +156,16 @@ async def test_create_agent_run_commits_before_enqueue(monkeypatch: pytest.Monke
|
||||
return Queue()
|
||||
|
||||
monkeypatch.setattr(agent_run_service.agent_manager, "get_agent", lambda agent_id: object())
|
||||
monkeypatch.setattr(agent_run_service, "AgentConfigRepository", FakeConfigRepo)
|
||||
monkeypatch.setattr(agent_run_service, "ConversationRepository", ConvRepo)
|
||||
monkeypatch.setattr(agent_run_service, "AgentRunRepository", Repo)
|
||||
monkeypatch.setattr(agent_run_service, "get_arq_pool", fake_get_arq_pool)
|
||||
|
||||
result = await agent_run_service.create_agent_run_view(
|
||||
agent_id="ChatbotAgent",
|
||||
query="hello",
|
||||
config={"thread_id": "thread-1", "request_id": "req-1"},
|
||||
agent_config_id=1,
|
||||
thread_id="thread-1",
|
||||
meta={"request_id": "req-1"},
|
||||
image_content=None,
|
||||
current_user_id="1",
|
||||
db=db,
|
||||
@ -217,20 +227,22 @@ async def test_create_agent_run_handles_integrity_error_with_same_user_existing(
|
||||
|
||||
async def get_conversation_by_thread_id(self, thread_id: str):
|
||||
del thread_id
|
||||
return SimpleNamespace(user_id="1", status="active")
|
||||
return SimpleNamespace(user_id="1", status="active", department_id=1)
|
||||
|
||||
async def fake_get_arq_pool():
|
||||
raise AssertionError("should not enqueue on integrity fallback")
|
||||
|
||||
monkeypatch.setattr(agent_run_service.agent_manager, "get_agent", lambda agent_id: object())
|
||||
monkeypatch.setattr(agent_run_service, "AgentConfigRepository", FakeConfigRepo)
|
||||
monkeypatch.setattr(agent_run_service, "ConversationRepository", ConvRepo)
|
||||
monkeypatch.setattr(agent_run_service, "AgentRunRepository", Repo)
|
||||
monkeypatch.setattr(agent_run_service, "get_arq_pool", fake_get_arq_pool)
|
||||
|
||||
result = await agent_run_service.create_agent_run_view(
|
||||
agent_id="ChatbotAgent",
|
||||
query="hello",
|
||||
config={"thread_id": "thread-1", "request_id": "req-1"},
|
||||
agent_config_id=1,
|
||||
thread_id="thread-1",
|
||||
meta={"request_id": "req-1"},
|
||||
image_content=None,
|
||||
current_user_id="1",
|
||||
db=db,
|
||||
@ -288,17 +300,19 @@ async def test_create_agent_run_integrity_error_returns_409_for_other_user(monke
|
||||
|
||||
async def get_conversation_by_thread_id(self, thread_id: str):
|
||||
del thread_id
|
||||
return SimpleNamespace(user_id="1", status="active")
|
||||
return SimpleNamespace(user_id="1", status="active", department_id=1)
|
||||
|
||||
monkeypatch.setattr(agent_run_service.agent_manager, "get_agent", lambda agent_id: object())
|
||||
monkeypatch.setattr(agent_run_service, "AgentConfigRepository", FakeConfigRepo)
|
||||
monkeypatch.setattr(agent_run_service, "ConversationRepository", ConvRepo)
|
||||
monkeypatch.setattr(agent_run_service, "AgentRunRepository", Repo)
|
||||
|
||||
with pytest.raises(agent_run_service.HTTPException) as exc:
|
||||
await agent_run_service.create_agent_run_view(
|
||||
agent_id="ChatbotAgent",
|
||||
query="hello",
|
||||
config={"thread_id": "thread-1", "request_id": "req-1"},
|
||||
agent_config_id=1,
|
||||
thread_id="thread-1",
|
||||
meta={"request_id": "req-1"},
|
||||
image_content=None,
|
||||
current_user_id="1",
|
||||
db=db,
|
||||
|
||||
155
backend/test/test_chat_service_sync.py
Normal file
155
backend/test/test_chat_service_sync.py
Normal file
@ -0,0 +1,155 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from langchain.messages import AIMessage, HumanMessage
|
||||
|
||||
from yuxi.services import chat_service as svc
|
||||
|
||||
|
||||
class _FakeConvRepo:
|
||||
def __init__(self, _db):
|
||||
self.saved_messages: list[dict] = []
|
||||
|
||||
async def add_message_by_thread_id(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
role: str,
|
||||
content: str,
|
||||
message_type: str = "text",
|
||||
extra_metadata: dict | None = None,
|
||||
image_content: str | None = None,
|
||||
):
|
||||
self.saved_messages.append(
|
||||
{
|
||||
"thread_id": thread_id,
|
||||
"role": role,
|
||||
"content": content,
|
||||
"message_type": message_type,
|
||||
"extra_metadata": extra_metadata,
|
||||
"image_content": image_content,
|
||||
}
|
||||
)
|
||||
return SimpleNamespace(id=1)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_chat_uses_invoke_messages_and_persists_langgraph_state(monkeypatch: pytest.MonkeyPatch):
|
||||
calls: dict[str, object] = {}
|
||||
|
||||
class FakeGraph:
|
||||
async def aget_state(self, config):
|
||||
calls["state_config"] = config
|
||||
return SimpleNamespace(values={"messages": [AIMessage(content="Hi from graph")], "todos": ["todo-1"]})
|
||||
|
||||
class FakeAgent:
|
||||
async def invoke_messages(self, messages, input_context=None, **kwargs):
|
||||
calls["invoke_messages"] = messages
|
||||
calls["invoke_input_context"] = input_context
|
||||
return {"messages": [messages[0], AIMessage(content="Hi from invoke")]}
|
||||
|
||||
async def stream_messages(self, messages, input_context=None, **kwargs):
|
||||
raise AssertionError("stream_messages should not be used by sync chat")
|
||||
|
||||
async def get_graph(self):
|
||||
return FakeGraph()
|
||||
|
||||
async def fake_get_agent_config_by_id(db, user, agent_config_id):
|
||||
assert user.id == "user-1"
|
||||
assert agent_config_id == 123
|
||||
return SimpleNamespace(agent_id="test-agent", config_json={"context": {"temperature": 0.1}})
|
||||
|
||||
async def fake_save_messages_from_langgraph_state(*, agent_instance, thread_id, conv_repo, config_dict):
|
||||
calls["saved_state"] = {
|
||||
"agent_instance": agent_instance,
|
||||
"thread_id": thread_id,
|
||||
"conv_repo": conv_repo,
|
||||
"config_dict": config_dict,
|
||||
}
|
||||
|
||||
async def fake_guard_check(_content):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(svc.agent_manager, "get_agent", lambda agent_id: FakeAgent())
|
||||
monkeypatch.setattr(svc, "get_agent_config_by_id", fake_get_agent_config_by_id)
|
||||
monkeypatch.setattr(svc, "ConversationRepository", _FakeConvRepo)
|
||||
monkeypatch.setattr(svc, "save_messages_from_langgraph_state", fake_save_messages_from_langgraph_state)
|
||||
monkeypatch.setattr(svc.content_guard, "check", fake_guard_check)
|
||||
|
||||
result = await svc.agent_chat(
|
||||
query="hello",
|
||||
agent_config_id=123,
|
||||
thread_id="thread-1",
|
||||
meta={"request_id": "req-1"},
|
||||
image_content=None,
|
||||
current_user=SimpleNamespace(id="user-1", department_id="dept-1"),
|
||||
db=object(),
|
||||
)
|
||||
|
||||
assert result["status"] == "finished"
|
||||
assert result["response"] == "Hi from invoke"
|
||||
assert result["thread_id"] == "thread-1"
|
||||
assert result["request_id"] == "req-1"
|
||||
assert result["agent_state"] == {"todos": ["todo-1"], "files": {}}
|
||||
|
||||
invoke_messages = calls["invoke_messages"]
|
||||
assert isinstance(invoke_messages, list)
|
||||
assert len(invoke_messages) == 1
|
||||
assert isinstance(invoke_messages[0], HumanMessage)
|
||||
assert invoke_messages[0].content == "hello"
|
||||
assert calls["invoke_input_context"] == {"temperature": 0.1, "user_id": "user-1", "thread_id": "thread-1"}
|
||||
assert calls["saved_state"]["thread_id"] == "thread-1"
|
||||
assert calls["saved_state"]["config_dict"] == {"configurable": {"thread_id": "thread-1", "user_id": "user-1"}}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_chat_sync_returns_finished_even_when_state_has_interrupt(monkeypatch: pytest.MonkeyPatch):
|
||||
class FakeGraph:
|
||||
async def aget_state(self, config):
|
||||
return SimpleNamespace(
|
||||
values={
|
||||
"messages": [AIMessage(content="Need input later")],
|
||||
"__interrupt__": [{"questions": [{"question": "继续吗?"}]}],
|
||||
}
|
||||
)
|
||||
|
||||
class FakeAgent:
|
||||
async def invoke_messages(self, messages, input_context=None, **kwargs):
|
||||
return {"messages": [messages[0], AIMessage(content="Need input later")]}
|
||||
|
||||
async def stream_messages(self, messages, input_context=None, **kwargs):
|
||||
raise AssertionError("stream_messages should not be used by sync chat")
|
||||
|
||||
async def get_graph(self):
|
||||
return FakeGraph()
|
||||
|
||||
async def fake_get_agent_config_by_id(db, user, agent_config_id):
|
||||
return SimpleNamespace(agent_id="test-agent", config_json={"context": {}})
|
||||
|
||||
async def fake_save_messages_from_langgraph_state(*, agent_instance, thread_id, conv_repo, config_dict):
|
||||
return None
|
||||
|
||||
async def fake_guard_check(_content):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(svc.agent_manager, "get_agent", lambda agent_id: FakeAgent())
|
||||
monkeypatch.setattr(svc, "get_agent_config_by_id", fake_get_agent_config_by_id)
|
||||
monkeypatch.setattr(svc, "ConversationRepository", _FakeConvRepo)
|
||||
monkeypatch.setattr(svc, "save_messages_from_langgraph_state", fake_save_messages_from_langgraph_state)
|
||||
monkeypatch.setattr(svc.content_guard, "check", fake_guard_check)
|
||||
|
||||
result = await svc.agent_chat(
|
||||
query="hello",
|
||||
agent_config_id=456,
|
||||
thread_id="thread-2",
|
||||
meta={"request_id": "req-2"},
|
||||
image_content=None,
|
||||
current_user=SimpleNamespace(id="user-1", department_id="dept-1"),
|
||||
db=object(),
|
||||
)
|
||||
|
||||
assert result["status"] == "finished"
|
||||
assert result["response"] == "Need input later"
|
||||
assert result["thread_id"] == "thread-2"
|
||||
@ -14,7 +14,7 @@ API Key 是一种用于身份验证的密钥字符串,外部系统可以通过
|
||||
|
||||
## 接口调用方式
|
||||
|
||||
外部系统通过 HTTP 请求调用 Yuxi 的对话接口,需要在请求头中携带 API Key。接口地址为 `POST /api/chat/agent/{agent_id}`,其中 `{agent_id}` 是目标智能体的标识符。请求头需要包含 `Authorization` 字段,值格式为 `Bearer <api_key>`,其中 `<api_key>` 是创建 API Key 时获取的完整密钥。请求体为 JSON 格式,包含 `query` 字段表示用户问题,以及可选的 `config` 和 `meta` 字段用于配置对话参数。
|
||||
外部系统通过 HTTP 请求调用 Yuxi 的对话接口,需要在请求头中携带 API Key。流式接口地址为 `POST /api/chat/agent`,非流式接口地址为 `POST /api/chat/agent/sync`(不支持 HITL)。请求头需要包含 `Authorization` 字段,值格式为 `Bearer <api_key>`,其中 `<api_key>` 是创建 API Key 时获取的完整密钥。请求体为 JSON 格式,必填字段为 `query` 和 `agent_config_id`,可选字段为 `thread_id`、`image_content` 和 `meta`。
|
||||
|
||||
以下是一个典型的 Python 调用示例:
|
||||
|
||||
@ -22,14 +22,14 @@ API Key 是一种用于身份验证的密钥字符串,外部系统可以通过
|
||||
import requests
|
||||
import json
|
||||
|
||||
url = "http://your-yuxi-server/api/chat/agent/agent_id"
|
||||
url = "http://your-yuxi-server/api/chat/agent"
|
||||
headers = {
|
||||
"Authorization": "Bearer yxkey_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"query": "你好,请介绍一下你自己",
|
||||
"config": {},
|
||||
"agent_config_id": 1,
|
||||
"meta": {}
|
||||
}
|
||||
|
||||
@ -47,7 +47,7 @@ for line in response.iter_lines():
|
||||
|
||||
`event: data` 表示数据事件,携带实际的对话内容。`event: error` 表示错误事件,当对话过程中发生错误时会收到此类事件。`event: done` 表示完成事件,标志对话结束。
|
||||
|
||||
每次调用都会在响应中包含 `request_id`,这是本次对话的唯一标识符,可用于日志追踪和问题排查。如果需要在多轮对话中使用同一个会话,可以通过 `config.thread_id` 参数指定线程 ID,系统会将同一线程的消息串联起来形成连贯的对话上下文。
|
||||
每次调用都会在响应中包含 `request_id`,这是本次对话的唯一标识符,可用于日志追踪和问题排查。如果需要在多轮对话中使用同一个会话,可以通过 `thread_id` 参数指定线程 ID,系统会将同一线程的消息串联起来形成连贯的对话上下文。
|
||||
|
||||
## 认证方式
|
||||
|
||||
|
||||
@ -177,7 +177,7 @@ Context 的价值不只在“配置页面”。它贯穿了从配置加载到实
|
||||
当前主流程在 `chat_service.py` 中:
|
||||
|
||||
1. 通过 `agent_config_id` 查找配置
|
||||
2. 若未指定,则获取该部门下该 Agent 的默认配置
|
||||
2. 读取该配置绑定的 `agent_id`
|
||||
3. 取出 `config_json.context`
|
||||
4. 与 `user_id`、`thread_id` 合并成运行时输入
|
||||
|
||||
|
||||
@ -47,6 +47,7 @@
|
||||
|
||||
### 修复
|
||||
|
||||
- 重构聊天接口请求模型:流式与非流式聊天统一使用 `query + agent_config_id` 请求体,并移除路径中的 `agent_id`;同时修复非流式接口实际误走流式执行链路的问题,改为调用 `invoke_messages` 一次性执行,并补充对应测试
|
||||
- 为沙盒与 viewer 文件系统补齐知识库只读映射:新增 `/home/gem/kbs` 命名空间,按“用户可访问知识库 ∩ 当前 Agent 已启用知识库”暴露原始文件与解析后的 Markdown,并补充对应后端与 viewer 路由测试
|
||||
- 修复前端工具图标与渲染匹配不准确的问题:工具管理列表与工具调用结果统一改为基于工具 `id` 的精确映射,避免模糊匹配导致的误渲染,未命中的工具不再显示默认扳手图标
|
||||
- 修复 GitHub Pages 文档部署工作流失败:移除 `actions/setup-node@v4` 对不存在 `docs/package-lock.json` 的缓存依赖,并将 `docs` 目录安装命令从 `npm ci` 调整为 `npm install`,避免因未提交锁文件导致 CI 在依赖缓存和安装阶段直接失败
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
欢迎使用 Yuxi(语析),这是一个智能知识库和知识图谱 Agent 开发平台。
|
||||
本指南将帮助你在几分钟内启动并运行系统,使你能够利用 LangGraph、RAG 技术和知识图谱构建 AI 驱动的知识应用。
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
::: tip 提示
|
||||
|
||||
@ -22,18 +22,17 @@ import { useUserStore } from '@/stores/user'
|
||||
export const agentApi = {
|
||||
/**
|
||||
* 发送聊天消息到指定智能体(流式响应)
|
||||
* @param {string} agentId - 智能体ID
|
||||
* @param {Object} data - 聊天数据
|
||||
* @returns {Promise} - 聊天响应流
|
||||
*/
|
||||
sendAgentMessage: (agentId, data, options = {}) => {
|
||||
sendAgentMessage: (data, options = {}) => {
|
||||
const { signal, headers: extraHeaders, ...restOptions } = options || {}
|
||||
const baseHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
...useUserStore().getAuthHeaders()
|
||||
}
|
||||
|
||||
return fetch(`/api/chat/agent/${agentId}`, {
|
||||
return fetch('/api/chat/agent', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
signal,
|
||||
@ -190,14 +189,15 @@ export const agentApi = {
|
||||
|
||||
/**
|
||||
* 创建异步运行任务(Run)
|
||||
* @param {string} agentId - 智能体ID
|
||||
* @param {Object} data - run 请求体
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
createAgentRun: (agentId, data) =>
|
||||
apiPost(`/api/chat/agent/${agentId}/runs`, {
|
||||
createAgentRun: (data) =>
|
||||
apiPost('/api/chat/runs', {
|
||||
query: data.query,
|
||||
config: data.config || {},
|
||||
agent_config_id: data.agent_config_id,
|
||||
thread_id: data.thread_id,
|
||||
meta: data.meta || {},
|
||||
image_content: data.image_content || null
|
||||
}),
|
||||
|
||||
|
||||
@ -928,10 +928,8 @@ const sendMessage = async ({
|
||||
|
||||
const requestData = {
|
||||
query: text,
|
||||
config: {
|
||||
thread_id: threadId,
|
||||
agent_config_id: selectedAgentConfigId.value
|
||||
}
|
||||
thread_id: threadId,
|
||||
agent_config_id: selectedAgentConfigId.value
|
||||
}
|
||||
|
||||
// 如果有图片,添加到请求中
|
||||
@ -940,7 +938,7 @@ const sendMessage = async ({
|
||||
}
|
||||
|
||||
try {
|
||||
return await agentApi.sendAgentMessage(agentId, requestData, signal ? { signal } : undefined)
|
||||
return await agentApi.sendAgentMessage(requestData, signal ? { signal } : undefined)
|
||||
} catch (error) {
|
||||
handleChatError(error, 'send')
|
||||
throw error
|
||||
@ -1170,12 +1168,10 @@ const handleSendMessage = async ({ image } = {}) => {
|
||||
resetOnGoingConv(threadId)
|
||||
threadState.isStreaming = true
|
||||
try {
|
||||
const runResp = await agentApi.createAgentRun(currentAgentId.value, {
|
||||
const runResp = await agentApi.createAgentRun({
|
||||
query: text,
|
||||
config: {
|
||||
thread_id: threadId,
|
||||
agent_config_id: selectedAgentConfigId.value
|
||||
},
|
||||
agent_config_id: selectedAgentConfigId.value,
|
||||
thread_id: threadId,
|
||||
image_content: image?.imageContent
|
||||
})
|
||||
const runId = runResp?.run_id
|
||||
|
||||
Loading…
Reference in New Issue
Block a user