Files
aiagent/backend/app/api/agent_chat.py
renjianbo 876789fac1 feat: multi-tenant workspace isolation, RBAC, sidebar nav, billing, and Android enhancements
- Backend: workspace_id isolation for 14 model tables + safe migration/backfill
- Backend: RBAC system with 4 roles and 23 permissions, seeded on startup
- Backend: workspace admin endpoints (list/manage all workspaces)
- Backend: admin user management API (CRUD, reset password)
- Backend: billing API with subscription plans, usage tracking, rate limiting
- Backend: fix system_logs.py UNION query and wrong column references
- Backend: WebSocket JWT auth and workspace enforcement
- Frontend: sidebar navigation replacing top dropdown menu
- Frontend: user management page (Users.vue) for admins
- Frontend: enhanced Workspaces.vue with admin table view
- Frontend: workspace RBAC computed properties in user store
- Android: agent marketplace, billing/subscription UI, onboarding wizard
- Android: phone login, analytics tracker, crash handler, network diagnostics
- Android: splash screen, encrypted token storage, app update enhancements
- Docs: multi-tenant RBAC guide with 8 sections and role-permission matrix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-04 01:00:22 +08:00

938 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Agent 独立聊天 API — 不依赖工作流 DAG直接与 Agent Runtime 对话。
POST /api/v1/agent-chat/bare
{"message": "你好,帮我..."}
{"content": "...", "iterations": 3, "tool_calls": 5}
"""
from __future__ import annotations
import logging
import json
from typing import Any, AsyncGenerator, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.core.database import get_db
from sqlalchemy.orm import Session
from app.api.auth import get_current_user
from app.api.deps import get_current_workspace_id, get_workspace_membership, WorkspaceContext
from app.models.user import User
from app.models.agent import Agent
from app.models.agent_llm_log import AgentLLMLog
from app.agent_runtime import (
AgentRuntime,
AgentConfig,
AgentLLMConfig,
AgentToolConfig,
AgentBudgetConfig,
AgentMemoryConfig,
AgentStep,
AgentContext,
AgentOrchestrator,
OrchestratorAgentConfig,
)
from app.models.chat_message import ChatMessage
from app.core.config import settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/agent-chat", tags=["agent-chat"])
def _make_llm_logger(
db: Session,
agent_id: Optional[str] = None,
user_id: Optional[str] = None,
workspace_id: Optional[str] = None,
):
"""创建 LLM 调用日志回调,写入 AgentLLMLog 表。"""
def _log(metrics: dict):
try:
log = AgentLLMLog(
agent_id=agent_id,
session_id=metrics.get("session_id"),
user_id=user_id,
workspace_id=workspace_id,
model=metrics.get("model", ""),
provider=metrics.get("provider"),
prompt_tokens=metrics.get("prompt_tokens", 0),
completion_tokens=metrics.get("completion_tokens", 0),
total_tokens=metrics.get("total_tokens", 0),
latency_ms=metrics.get("latency_ms", 0),
iteration_number=metrics.get("iteration_number", 0),
step_type=metrics.get("step_type"),
tool_name=metrics.get("tool_name"),
status=metrics.get("status", "success"),
error_message=metrics.get("error_message"),
)
db.add(log)
db.commit()
except Exception as e:
logger.warning("写入 AgentLLMLog 失败: %s", e)
return _log
def _make_message_saver(
db: Session,
agent_id: Optional[str] = None,
user_id: Optional[str] = None,
workspace_id: Optional[str] = None,
):
"""创建消息持久化回调,将每条消息写入 chat_messages 表。"""
def _save(msg: dict):
try:
record = ChatMessage(
session_id=msg.get("session_id"),
agent_id=agent_id,
user_id=user_id,
workspace_id=workspace_id,
role=msg.get("role", "user"),
content=msg.get("content"),
tool_name=msg.get("tool_name"),
tool_input=msg.get("tool_input"),
tool_output=msg.get("tool_output"),
iteration=msg.get("iteration", 0),
)
db.add(record)
# 自动创建 / 更新 AgentSession 记录
session_id = msg.get("session_id")
if session_id and agent_id:
from app.models.agent_session import AgentSession
existing = db.query(AgentSession).filter(
AgentSession.id == session_id
).first()
if not existing:
title = None
if msg.get("role") == "user" and msg.get("content"):
title = msg["content"][:100]
session = AgentSession(
id=session_id,
user_id=user_id,
agent_id=agent_id,
workspace_id=workspace_id,
title=title,
)
db.add(session)
elif existing.title is None and msg.get("role") == "user" and msg.get("content"):
existing.title = msg["content"][:100]
db.commit()
except Exception as e:
logger.warning("写入 ChatMessage 失败: %s", e)
return _save
async def _sse_stream(gen: AsyncGenerator[dict, None]) -> AsyncGenerator[str, None]:
"""将 run_stream 生成的 dict 事件格式化为 SSE 文本流。"""
async for event in gen:
event_type = event.get("type", "message")
data = {k: v for k, v in event.items() if k != "type"}
yield f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
class ChatRequest(BaseModel):
message: str
session_id: Optional[str] = None
model: Optional[str] = None
temperature: Optional[float] = None
max_iterations: Optional[int] = None
streamlined: bool = Field(default=False, description="启用工具结果流式美化")
prompt_sections_enabled: bool = Field(default=True, description="启用系统提示词分层装配")
system_prompt_override: Optional[str] = Field(default=None, description="覆盖 Agent 的 System Prompt")
class ChatResponse(BaseModel):
content: str
iterations_used: int
tool_calls_made: int
truncated: bool
session_id: str
agent_id: Optional[str] = None
steps: List[AgentStep] = Field(default_factory=list, description="执行追踪步骤")
streamlined_summary: Optional[str] = Field(default=None, description="流式美化摘要streamlined 模式)")
token_usage: Optional[Dict[str, Any]] = Field(default=None, description="Token 预算摘要")
class MessageItem(BaseModel):
"""消息历史条目"""
id: str
session_id: str
agent_id: Optional[str] = None
user_id: Optional[str] = None
role: str
content: Optional[str] = None
tool_name: Optional[str] = None
tool_input: Optional[str] = None
tool_output: Optional[str] = None
iteration: int = 0
created_at: Optional[str] = None
class MessageHistoryResponse(BaseModel):
"""消息历史分页响应"""
messages: List[MessageItem]
has_more: bool
total: int
class SessionItem(BaseModel):
"""会话列表条目"""
session_id: str
title: Optional[str] = None
last_message: Optional[str] = None
message_count: int = 0
is_pinned: bool = False
created_at: Optional[str] = None
updated_at: Optional[str] = None
class SessionListResponse(BaseModel):
"""会话列表响应"""
sessions: List[SessionItem]
class SearchMessageItem(BaseModel):
"""跨会话搜索消息条目"""
id: str
session_id: str
agent_id: Optional[str] = None
role: str
content: Optional[str] = None
session_title: Optional[str] = None
created_at: Optional[str] = None
class SearchMessagesResponse(BaseModel):
"""消息搜索响应"""
messages: List[SearchMessageItem]
total: int
class OrchestrateAgentItem(BaseModel):
"""编排中单个 Agent 的定义"""
id: str
name: str = "Agent"
system_prompt: str = "你是一个有用的AI助手。"
model: str = "deepseek-v4-flash"
provider: str = "deepseek"
temperature: float = 0.7
max_iterations: int = 10
tools: List[str] = Field(default_factory=list)
description: str = ""
class OrchestrateRequest(BaseModel):
"""多 Agent 编排请求"""
message: str
mode: str = "debate"
agents: List[OrchestrateAgentItem] = Field(..., min_length=1)
model: Optional[str] = None
class OrchestrateStepItem(BaseModel):
"""编排步骤"""
agent_id: str
agent_name: str
input: str = ""
output: str = ""
iterations_used: int = 0
tool_calls_made: int = 0
error: Optional[str] = None
class OrchestrateResponse(BaseModel):
"""多 Agent 编排响应"""
mode: str
final_answer: str
steps: List[OrchestrateStepItem] = Field(default_factory=list)
agent_results: List[Dict[str, Any]] = Field(default_factory=list)
@router.post("/orchestrate", response_model=OrchestrateResponse)
async def orchestrate_agents(
req: OrchestrateRequest,
current_user: User = Depends(get_current_user),
workspace_id: str = Depends(get_current_workspace_id),
db: Session = Depends(get_db),
):
"""多 Agent 编排:支持 route / sequential / debate 三种模式。"""
agents = [
OrchestratorAgentConfig(
id=a.id, name=a.name,
system_prompt=a.system_prompt,
model=req.model or a.model,
provider=a.provider,
temperature=a.temperature,
max_iterations=a.max_iterations,
tools=a.tools,
description=a.description,
)
for a in req.agents
]
on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id)
orchestrator = AgentOrchestrator(
default_llm_config=AgentLLMConfig(
model=req.model or "deepseek-v4-flash",
temperature=0.3,
),
)
result = await orchestrator.run(req.mode, req.message, agents, on_llm_call=on_llm_call)
return OrchestrateResponse(
mode=result.mode,
final_answer=result.final_answer,
steps=[
OrchestrateStepItem(
agent_id=s.agent_id,
agent_name=s.agent_name,
input=s.input,
output=s.output,
iterations_used=s.iterations_used,
tool_calls_made=s.tool_calls_made,
error=s.error,
)
for s in result.steps
],
agent_results=result.agent_results,
)
class GraphOrchestrateRequest(BaseModel):
"""图编排请求 — 以 nodes + edges 描述 DAG"""
message: str
nodes: List[Dict[str, Any]] = Field(..., description="编排节点列表")
edges: List[Dict[str, Any]] = Field(default_factory=list, description="编排连线列表")
model: Optional[str] = None
@router.post("/orchestrate/graph", response_model=OrchestrateResponse)
async def orchestrate_graph(
req: GraphOrchestrateRequest,
current_user: User = Depends(get_current_user),
workspace_id: str = Depends(get_current_workspace_id),
db: Session = Depends(get_db),
):
"""图编排模式:按 DAG 拓扑顺序执行 Agent 和条件节点。"""
on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id)
orchestrator = AgentOrchestrator(
default_llm_config=AgentLLMConfig(
model=req.model or "deepseek-v4-flash",
temperature=0.3,
),
)
result = await orchestrator._graph(
req.message, req.nodes, req.edges, on_llm_call=on_llm_call,
)
return OrchestrateResponse(
mode=result.mode,
final_answer=result.final_answer,
steps=[
OrchestrateStepItem(
agent_id=s.agent_id,
agent_name=s.agent_name,
input=s.input,
output=s.output,
iterations_used=s.iterations_used,
tool_calls_made=s.tool_calls_made,
error=s.error,
)
for s in result.steps
],
agent_results=result.agent_results,
)
@router.post("/bare", response_model=ChatResponse)
async def chat_bare(
req: ChatRequest,
current_user: User = Depends(get_current_user),
workspace_id: str = Depends(get_current_workspace_id),
db: Session = Depends(get_db),
):
"""无需 Agent 配置,使用默认设置直接对话。"""
uid = current_user.id
bare_scope = f"{uid}:__bare__" if uid else "__bare__"
config = AgentConfig(
name="bare_agent",
system_prompt="你是一个有用的AI助手。请使用可用工具来帮助用户完成任务。",
llm=AgentLLMConfig(
model=req.model or (
"gpt-4o-mini" if settings.OPENAI_API_KEY and settings.OPENAI_API_KEY != "your-openai-api-key"
else "deepseek-v4-flash"
),
temperature=req.temperature or 0.7,
max_iterations=req.max_iterations or 10,
),
user_id=uid,
memory_scope_id=bare_scope,
memory=AgentMemoryConfig(
memory_dir_enabled=True,
memory_dir_path="",
persist_to_db=True,
vector_memory_enabled=True,
learning_enabled=True,
),
tools=AgentToolConfig(
permission_level="acceptEdits",
),
)
if not req.prompt_sections_enabled:
config.prompt_sections.enabled = False
if req.system_prompt_override:
config.system_prompt = req.system_prompt_override
on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id)
on_message = _make_message_saver(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id)
context = AgentContext(session_id=req.session_id)
runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
result = await runtime.run(req.message)
# 流式美化:为 steps 生成累计摘要
streamlined_summary = None
if req.streamlined and result.steps:
from app.core.streamlined_output import ToolCounts, categorize_tool, get_tool_summary_text
counts = ToolCounts()
for s in result.steps:
if s.type == "tool_result" and s.tool_name:
counts.add(categorize_tool(s.tool_name))
streamlined_summary = get_tool_summary_text(counts)
return ChatResponse(
content=result.content,
iterations_used=result.iterations_used,
tool_calls_made=result.tool_calls_made,
truncated=result.truncated,
session_id=runtime.context.session_id,
steps=result.steps,
streamlined_summary=streamlined_summary,
token_usage=result.token_usage.model_dump() if result.token_usage else None,
)
@router.post("/bare/stream")
async def chat_bare_stream(
req: ChatRequest,
current_user: User = Depends(get_current_user),
workspace_id: str = Depends(get_current_workspace_id),
db: Session = Depends(get_db),
):
"""无需 Agent 配置,使用默认设置直接对话(流式 SSE"""
uid = current_user.id
bare_scope = f"{uid}:__bare__" if uid else "__bare__"
config = AgentConfig(
name="bare_agent",
system_prompt="你是一个有用的AI助手。请使用可用工具来帮助用户完成任务。",
llm=AgentLLMConfig(
model=req.model or (
"gpt-4o-mini" if settings.OPENAI_API_KEY and settings.OPENAI_API_KEY != "your-openai-api-key"
else "deepseek-v4-flash"
),
temperature=req.temperature or 0.7,
max_iterations=req.max_iterations or 10,
),
user_id=uid,
memory_scope_id=bare_scope,
memory=AgentMemoryConfig(
memory_dir_enabled=True,
memory_dir_path="",
persist_to_db=True,
vector_memory_enabled=True,
learning_enabled=True,
),
tools=AgentToolConfig(
permission_level="acceptEdits",
),
)
if not req.prompt_sections_enabled:
config.prompt_sections.enabled = False
if req.system_prompt_override:
config.system_prompt = req.system_prompt_override
on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id)
on_message = _make_message_saver(db, agent_id=None, user_id=current_user.id, workspace_id=workspace_id)
context = AgentContext(session_id=req.session_id)
runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
return StreamingResponse(
_sse_stream(runtime.run_stream(req.message)),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.post("/{agent_id}", response_model=ChatResponse)
async def chat_with_agent(
agent_id: str,
req: ChatRequest,
current_user: User = Depends(get_current_user),
workspace_id: str = Depends(get_current_workspace_id),
db: Session = Depends(get_db),
):
"""与指定的 Agent 对话。Agent 的工作流配置会用于构建 Runtime。"""
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
if agent.user_id and agent.user_id != current_user.id and current_user.role != "admin" and not agent.is_public:
raise HTTPException(status_code=403, detail="无权访问该 Agent")
# 从 Agent 配置构建 Runtime
wc = agent.workflow_config or {}
nodes = wc.get("nodes", [])
# 查找 agent 节点的配置(或第一个 llm 节点的配置)
agent_node_cfg = _find_agent_node_config(nodes)
# 构建 system prompt并自动注入智能体名称
system_prompt = agent_node_cfg.get("system_prompt") or agent.description or "你是一个有用的AI助手。"
if agent.name:
name_prefix = f"你的名字是{agent.name}"
if name_prefix not in system_prompt:
system_prompt = f"{name_prefix}\n\n{system_prompt}"
# 合并执行预算Agent.budget_config 覆盖默认值
budget = AgentBudgetConfig()
if agent.budget_config and isinstance(agent.budget_config, dict):
bc = agent.budget_config
if "max_llm_invocations" in bc and bc["max_llm_invocations"] is not None:
budget.max_llm_invocations = max(1, int(bc["max_llm_invocations"]))
if "max_tool_calls" in bc and bc["max_tool_calls"] is not None:
budget.max_tool_calls = max(1, int(bc["max_tool_calls"]))
uid = current_user.id
mem_scope = f"{uid}:{agent_id}" if uid else str(agent_id)
memory_cfg = _build_memory_config_from_node(agent_node_cfg)
if getattr(agent, "parent_agent_id", None):
memory_cfg.parent_agent_id = agent.parent_agent_id
config = AgentConfig(
name=agent.name,
system_prompt=system_prompt,
llm=AgentLLMConfig(
provider=agent_node_cfg.get("provider", "openai"),
model=req.model or agent_node_cfg.get("model", "gpt-4o-mini"),
temperature=req.temperature or float(agent_node_cfg.get("temperature", 0.7)),
max_iterations=req.max_iterations or int(agent_node_cfg.get("max_iterations", 10)),
# 计划模式 (P2)
plan_mode_enabled=bool(agent_node_cfg.get("plan_mode_enabled", False)),
plan_approval_required=bool(agent_node_cfg.get("plan_approval_required", True)),
),
tools=AgentToolConfig(
include_tools=agent_node_cfg.get("tools", []),
exclude_tools=agent_node_cfg.get("exclude_tools", []),
# 工具安全分级 (P3)
permission_level=str(agent_node_cfg.get("permission_level", "default")),
deny_tools=agent_node_cfg.get("deny_tools", []),
auto_approve_rules=agent_node_cfg.get("auto_approve_rules", []),
),
memory=memory_cfg,
budget=budget,
user_id=uid,
memory_scope_id=mem_scope,
)
if not req.prompt_sections_enabled:
config.prompt_sections.enabled = False
if req.system_prompt_override:
config.system_prompt = req.system_prompt_override
on_llm_call = _make_llm_logger(db, agent_id=agent_id, user_id=current_user.id, workspace_id=workspace_id)
on_message = _make_message_saver(db, agent_id=agent_id, user_id=current_user.id, workspace_id=workspace_id)
context = AgentContext(session_id=req.session_id)
runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
result = await runtime.run(req.message)
# 流式美化:为 steps 生成累计摘要
streamlined_summary = None
if req.streamlined and result.steps:
from app.core.streamlined_output import ToolCounts, categorize_tool, get_tool_summary_text
counts = ToolCounts()
for s in result.steps:
if s.type == "tool_result" and s.tool_name:
counts.add(categorize_tool(s.tool_name))
streamlined_summary = get_tool_summary_text(counts)
return ChatResponse(
content=result.content,
iterations_used=result.iterations_used,
tool_calls_made=result.tool_calls_made,
truncated=result.truncated,
session_id=runtime.context.session_id,
agent_id=agent_id,
steps=result.steps,
streamlined_summary=streamlined_summary,
token_usage=result.token_usage.model_dump() if result.token_usage else None,
)
@router.post("/{agent_id}/stream")
async def chat_with_agent_stream(
agent_id: str,
req: ChatRequest,
current_user: User = Depends(get_current_user),
workspace_id: str = Depends(get_current_workspace_id),
db: Session = Depends(get_db),
):
"""与指定的 Agent 对话(流式 SSE"""
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
if agent.user_id and agent.user_id != current_user.id and current_user.role != "admin" and not agent.is_public:
raise HTTPException(status_code=403, detail="无权访问该 Agent")
wc = agent.workflow_config or {}
nodes = wc.get("nodes", [])
agent_node_cfg = _find_agent_node_config(nodes)
system_prompt = agent_node_cfg.get("system_prompt") or agent.description or "你是一个有用的AI助手。"
if agent.name:
name_prefix = f"你的名字是{agent.name}"
if name_prefix not in system_prompt:
system_prompt = f"{name_prefix}\n\n{system_prompt}"
budget = AgentBudgetConfig()
if agent.budget_config and isinstance(agent.budget_config, dict):
bc = agent.budget_config
if "max_llm_invocations" in bc and bc["max_llm_invocations"] is not None:
budget.max_llm_invocations = max(1, int(bc["max_llm_invocations"]))
if "max_tool_calls" in bc and bc["max_tool_calls"] is not None:
budget.max_tool_calls = max(1, int(bc["max_tool_calls"]))
uid = current_user.id
mem_scope = f"{uid}:{agent_id}" if uid else str(agent_id)
memory_cfg = _build_memory_config_from_node(agent_node_cfg)
if getattr(agent, "parent_agent_id", None):
memory_cfg.parent_agent_id = agent.parent_agent_id
config = AgentConfig(
name=agent.name,
system_prompt=system_prompt,
llm=AgentLLMConfig(
provider=agent_node_cfg.get("provider", "openai"),
model=req.model or agent_node_cfg.get("model", "gpt-4o-mini"),
temperature=req.temperature or float(agent_node_cfg.get("temperature", 0.7)),
max_iterations=req.max_iterations or int(agent_node_cfg.get("max_iterations", 10)),
# 计划模式 (P2)
plan_mode_enabled=bool(agent_node_cfg.get("plan_mode_enabled", False)),
plan_approval_required=bool(agent_node_cfg.get("plan_approval_required", True)),
),
tools=AgentToolConfig(
include_tools=agent_node_cfg.get("tools", []),
exclude_tools=agent_node_cfg.get("exclude_tools", []),
# 工具安全分级 (P3)
permission_level=str(agent_node_cfg.get("permission_level", "default")),
deny_tools=agent_node_cfg.get("deny_tools", []),
auto_approve_rules=agent_node_cfg.get("auto_approve_rules", []),
),
memory=memory_cfg,
budget=budget,
user_id=uid,
memory_scope_id=mem_scope,
)
if not req.prompt_sections_enabled:
config.prompt_sections.enabled = False
if req.system_prompt_override:
config.system_prompt = req.system_prompt_override
on_llm_call = _make_llm_logger(db, agent_id=agent_id, user_id=current_user.id, workspace_id=workspace_id)
on_message = _make_message_saver(db, agent_id=agent_id, user_id=current_user.id, workspace_id=workspace_id)
context = AgentContext(session_id=req.session_id)
runtime = AgentRuntime(config=config, context=context, on_llm_call=on_llm_call, on_message=on_message, streamlined=req.streamlined)
return StreamingResponse(
_sse_stream(runtime.run_stream(req.message)),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.get("/{agent_id}/sessions", response_model=SessionListResponse)
async def list_agent_sessions(
agent_id: str,
limit: int = 50,
search: Optional[str] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取 Agent 的会话列表,置顶优先,再按最近活跃时间排序。支持 search 参数按标题/内容搜索。"""
from sqlalchemy import func as sa_func, desc, or_
from app.models.agent_session import AgentSession
# 验证 agent 存在或有权限
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
rows_query = (
db.query(
ChatMessage.session_id,
sa_func.min(ChatMessage.created_at).label("created_at"),
sa_func.max(ChatMessage.created_at).label("updated_at"),
sa_func.count(ChatMessage.id).label("message_count"),
)
.filter(ChatMessage.agent_id == agent_id)
.group_by(ChatMessage.session_id)
)
# 搜索过滤按会话标题AgentSession.title或消息内容
if search and search.strip():
pattern = f"%{search.strip()}%"
# 子查询:在标题或消息内容中匹配的 session_id
matching_session_ids = set()
# 匹配 AgentSession.title
title_matches = db.query(AgentSession.id).filter(
AgentSession.title.like(pattern)
).all()
matching_session_ids.update(r[0] for r in title_matches)
# 匹配消息内容
content_matches = db.query(ChatMessage.session_id).filter(
ChatMessage.agent_id == agent_id,
ChatMessage.content.like(pattern),
).distinct().all()
matching_session_ids.update(r[0] for r in content_matches)
if matching_session_ids:
rows_query = rows_query.filter(
ChatMessage.session_id.in_(list(matching_session_ids))
)
else:
# 无匹配,返回空
return SessionListResponse(sessions=[])
rows = rows_query.order_by(desc("updated_at")).limit(limit).all()
# 批量查询 AgentSession 记录
session_ids = [row.session_id for row in rows]
agent_sessions_map = {}
if session_ids:
records = db.query(AgentSession).filter(
AgentSession.id.in_(session_ids)
).all()
agent_sessions_map = {s.id: s for s in records}
sessions = []
for row in rows:
asession = agent_sessions_map.get(row.session_id)
# 标题优先级: AgentSession.title > 第一条 user 消息
title = asession.title if asession and asession.title else None
if not title:
first_user_msg = (
db.query(ChatMessage)
.filter(
ChatMessage.session_id == row.session_id,
ChatMessage.role == "user",
)
.order_by(ChatMessage.created_at.asc())
.first()
)
if first_user_msg and first_user_msg.content:
title = first_user_msg.content[:100]
# 取最后一条消息作为预览
last_msg = (
db.query(ChatMessage)
.filter(ChatMessage.session_id == row.session_id)
.order_by(ChatMessage.created_at.desc())
.first()
)
sessions.append(SessionItem(
session_id=row.session_id,
title=title,
last_message=last_msg.content[:200] if last_msg and last_msg.content else None,
message_count=row.message_count,
is_pinned=asession.is_pinned if asession else False,
created_at=row.created_at.isoformat() if row.created_at else None,
updated_at=row.updated_at.isoformat() if row.updated_at else None,
))
# 排序:置顶优先,再按更新时间降序
pinned = [s for s in sessions if s.is_pinned]
unpinned = [s for s in sessions if not s.is_pinned]
pinned.sort(key=lambda s: s.updated_at or "", reverse=True)
unpinned.sort(key=lambda s: s.updated_at or "", reverse=True)
sessions = pinned + unpinned
return SessionListResponse(sessions=sessions)
@router.get("/{agent_id}/sessions/{session_id}/messages", response_model=MessageHistoryResponse)
async def get_session_messages(
agent_id: str,
session_id: str,
before_id: Optional[str] = None,
limit: int = 50,
search: Optional[str] = None,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取会话的消息历史(分页),从旧到新排序。支持 search 参数按内容搜索。"""
from sqlalchemy import or_
# limit 限制
limit = min(max(limit, 1), 200)
base_q = db.query(ChatMessage).filter(
ChatMessage.agent_id == agent_id,
ChatMessage.session_id == session_id,
)
# 消息内容搜索
if search and search.strip():
pattern = f"%{search.strip()}%"
base_q = base_q.filter(ChatMessage.content.like(pattern))
# 游标分页before_id 之前的老消息(使用复合键避免 created_at 相同导致遗漏)
if before_id:
cursor_msg = db.query(ChatMessage).filter(ChatMessage.id == before_id).first()
if cursor_msg and cursor_msg.created_at:
base_q = base_q.filter(
or_(
ChatMessage.created_at < cursor_msg.created_at,
(ChatMessage.created_at == cursor_msg.created_at) & (ChatMessage.id < cursor_msg.id),
)
)
# 取 N+1 条,判断 has_more按时间降序取最新 N 条,再反转)
# 二级排序 iteration + id 避免同一秒内多条消息顺序不确定
batch = (
base_q
.order_by(ChatMessage.created_at.desc(), ChatMessage.iteration.asc(), ChatMessage.id.asc())
.limit(limit + 1)
.all()
)
has_more = len(batch) > limit
if has_more:
batch = batch[:limit]
# 反转为从旧到新
batch.reverse()
total = base_q.count()
messages = [
MessageItem(
id=m.id,
session_id=m.session_id,
agent_id=m.agent_id,
user_id=m.user_id,
role=m.role,
content=m.content,
tool_name=m.tool_name,
tool_input=m.tool_input,
tool_output=m.tool_output,
iteration=m.iteration or 0,
created_at=m.created_at.isoformat() if m.created_at else None,
)
for m in batch
]
return MessageHistoryResponse(messages=messages, has_more=has_more, total=total)
@router.get("/{agent_id}/search-messages", response_model=SearchMessagesResponse)
async def search_messages(
agent_id: str,
q: str = Query(..., min_length=1, description="搜索关键词"),
limit: int = Query(50, ge=1, le=100),
skip: int = Query(0, ge=0),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""跨会话搜索消息内容,返回匹配的消息及所属会话信息。"""
from app.models.agent_session import AgentSession
# 验证 agent 存在
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent 不存在")
pattern = f"%{q.strip()}%"
# 搜索匹配的消息
total = (
db.query(ChatMessage)
.filter(
ChatMessage.agent_id == agent_id,
ChatMessage.content.like(pattern),
)
.count()
)
messages = (
db.query(ChatMessage)
.filter(
ChatMessage.agent_id == agent_id,
ChatMessage.content.like(pattern),
)
.order_by(ChatMessage.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
# 批量获取会话标题
session_ids = list(set(m.session_id for m in messages))
session_titles = {}
if session_ids:
records = db.query(AgentSession).filter(
AgentSession.id.in_(session_ids)
).all()
session_titles = {s.id: s.title for s in records if s.title}
result = [
SearchMessageItem(
id=m.id,
session_id=m.session_id,
agent_id=m.agent_id,
role=m.role,
content=m.content,
session_title=session_titles.get(m.session_id),
created_at=m.created_at.isoformat() if m.created_at else None,
)
for m in messages
]
return SearchMessagesResponse(messages=result, total=total)
def _find_agent_node_config(nodes: list) -> Dict[str, Any]:
"""从工作流节点列表中查找第一个 agent 类型或 llm 类型的节点配置。"""
if not nodes:
return {}
for node in nodes:
typ = node.get("type", "")
if typ in ("agent", "llm", "template"):
return node.get("data") or {}
return {}
def _build_memory_config_from_node(agent_node_cfg: dict) -> AgentMemoryConfig:
"""从 Agent 工作流节点配置中提取记忆配置。"""
from app.core.compaction_config import CompactionConfig
# 压缩配置
compaction_raw = agent_node_cfg.get("compaction")
if isinstance(compaction_raw, dict):
compaction = CompactionConfig(**compaction_raw)
else:
compaction = CompactionConfig() # 默认配置
return AgentMemoryConfig(
max_history_messages=int(agent_node_cfg.get("memory_max_history", 20)),
vector_memory_top_k=int(agent_node_cfg.get("memory_vector_top_k", 5)),
persist_to_db=bool(agent_node_cfg.get("memory_persist", True)),
vector_memory_enabled=bool(agent_node_cfg.get("memory_vector_enabled", True)),
learning_enabled=bool(agent_node_cfg.get("memory_learning", True)),
# 文件式记忆 (MEMORY.md)
memory_dir_enabled=bool(agent_node_cfg.get("memory_dir_enabled", False)),
memory_dir_path=str(agent_node_cfg.get("memory_dir_path", "")),
# 对话压缩
compaction=compaction,
)