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>
This commit is contained in:
@@ -10,13 +10,14 @@ from __future__ import annotations
|
||||
import logging
|
||||
import json
|
||||
from typing import Any, AsyncGenerator, Dict, List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
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
|
||||
@@ -43,6 +44,7 @@ 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):
|
||||
@@ -51,6 +53,7 @@ def _make_llm_logger(
|
||||
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),
|
||||
@@ -74,6 +77,7 @@ 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):
|
||||
@@ -82,6 +86,7 @@ def _make_message_saver(
|
||||
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"),
|
||||
@@ -90,6 +95,29 @@ def _make_message_saver(
|
||||
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)
|
||||
@@ -155,6 +183,7 @@ class SessionItem(BaseModel):
|
||||
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
|
||||
|
||||
@@ -164,6 +193,23 @@ 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
|
||||
@@ -208,6 +254,7 @@ class OrchestrateResponse(BaseModel):
|
||||
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 三种模式。"""
|
||||
@@ -225,7 +272,7 @@ async def orchestrate_agents(
|
||||
for a in req.agents
|
||||
]
|
||||
|
||||
on_llm_call = _make_llm_logger(db, agent_id=None, user_id=current_user.id)
|
||||
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",
|
||||
@@ -265,10 +312,11 @@ class GraphOrchestrateRequest(BaseModel):
|
||||
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)
|
||||
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",
|
||||
@@ -301,6 +349,7 @@ async def orchestrate_graph(
|
||||
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 配置,使用默认设置直接对话。"""
|
||||
@@ -334,8 +383,8 @@ async def chat_bare(
|
||||
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)
|
||||
on_message = _make_message_saver(db, agent_id=None, user_id=current_user.id)
|
||||
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)
|
||||
@@ -366,6 +415,7 @@ async def chat_bare(
|
||||
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)。"""
|
||||
@@ -399,8 +449,8 @@ async def chat_bare_stream(
|
||||
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)
|
||||
on_message = _make_message_saver(db, agent_id=None, user_id=current_user.id)
|
||||
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(
|
||||
@@ -419,6 +469,7 @@ 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。"""
|
||||
@@ -485,8 +536,8 @@ async def chat_with_agent(
|
||||
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)
|
||||
on_message = _make_message_saver(db, agent_id=agent_id, user_id=current_user.id)
|
||||
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)
|
||||
@@ -519,6 +570,7 @@ 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)。"""
|
||||
@@ -581,8 +633,8 @@ async def chat_with_agent_stream(
|
||||
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)
|
||||
on_message = _make_message_saver(db, agent_id=agent_id, user_id=current_user.id)
|
||||
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(
|
||||
@@ -600,18 +652,20 @@ async def chat_with_agent_stream(
|
||||
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 的会话列表,按最近活跃时间排序。"""
|
||||
"""获取 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 = (
|
||||
rows_query = (
|
||||
db.query(
|
||||
ChatMessage.session_id,
|
||||
sa_func.min(ChatMessage.created_at).label("created_at"),
|
||||
@@ -620,23 +674,65 @@ async def list_agent_sessions(
|
||||
)
|
||||
.filter(ChatMessage.agent_id == agent_id)
|
||||
.group_by(ChatMessage.session_id)
|
||||
.order_by(desc("updated_at"))
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
# 搜索过滤:按会话标题(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:
|
||||
# 取第一条 user 消息作为标题
|
||||
first_user_msg = (
|
||||
db.query(ChatMessage)
|
||||
.filter(
|
||||
ChatMessage.session_id == row.session_id,
|
||||
ChatMessage.role == "user",
|
||||
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()
|
||||
)
|
||||
.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)
|
||||
@@ -646,13 +742,21 @@ async def list_agent_sessions(
|
||||
)
|
||||
sessions.append(SessionItem(
|
||||
session_id=row.session_id,
|
||||
title=first_user_msg.content[:100] if first_user_msg and first_user_msg.content else None,
|
||||
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)
|
||||
|
||||
|
||||
@@ -662,10 +766,11 @@ async def get_session_messages(
|
||||
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 限制
|
||||
@@ -676,6 +781,11 @@ async def get_session_messages(
|
||||
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()
|
||||
@@ -725,6 +835,72 @@ async def get_session_messages(
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user