Files
server-core/app/api/routers/agent_gate.py
T

220 lines
9.1 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.
# -*- coding: utf-8 -*-
"""智能体互通 API:内部事件接收 + 临时授权管理。
- ``POST /internal/im/agent-events``im-service 在 agent_task 消息落库后回调,
由编排层完成权限校验 → 在线查询 → MQTT 派发(携带 X-IM-Token 内部令牌)。
- ``POST/GET/DELETE /agent-grants``L3 成员开放调用(临时授权)管理(用户 API)。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, Header, HTTPException
from ..dependencies import get_db, get_current_user
from ... import config
from ...agent_gate import handle_agent_task_event
from ...im import client as im_client
from ...infrastructure.repositories import Database
log = logging.getLogger("agent_gate.api")
router = APIRouter(tags=["agent-gate"])
internal_router = APIRouter(prefix="/internal/im", tags=["agent-gate-internal"])
# ── 内部事件接收(im-service → server-core) ─────────────────────
def _require_im_internal(x_im_token: str = Header(default="", alias="X-IM-Token")) -> None:
"""仅允许携带 IM_INTERNAL_TOKEN 的调用方(im-service)。"""
if not config.IM_INTERNAL_TOKEN or x_im_token != config.IM_INTERNAL_TOKEN:
raise HTTPException(status_code=403, detail="内部接口访问被拒绝")
@internal_router.post("/agent-events", dependencies=[Depends(_require_im_internal)])
async def agent_events(payload: dict, db: Database = Depends(get_db)):
"""接收 im-service 的智能体任务事件(agent_task 消息落库后回调)。
body: {event, conv_id, sender_id, sender_name, content, metadata}
"""
event = payload.get("event", "")
if event != "agent_task":
return {"ok": False, "reason": f"unsupported event: {event}"}
conv_id = str(payload.get("conv_id", ""))
if not conv_id:
return {"ok": False, "reason": "missing conv_id"}
result = await handle_agent_task_event(
db,
im_client,
conv_id=conv_id,
sender_id=str(payload.get("sender_id", "")),
sender_name=str(payload.get("sender_name", "")),
content=str(payload.get("content", "")),
metadata=payload.get("metadata") or {},
)
return result
# ── 用户 API:L3 临时授权管理 ────────────────────────────────────
@router.post("/agent-grants", summary="创建智能体临时授权")
async def create_grant(payload: dict, user: dict = Depends(get_current_user),
db: Database = Depends(get_db)):
"""成员开放调用:授权另一个成员调用自己(的某个)智能体。
body: {grantee_user_id, target_agent_addr, scope?, max_calls?, expires_at?}
"""
grantee = str(payload.get("grantee_user_id", "")).strip()
target_addr = str(payload.get("target_agent_addr", "")).strip()
if not grantee or not target_addr:
raise HTTPException(status_code=422, detail="grantee_user_id 与 target_agent_addr 必填")
from ...agent_gate import parse_agent_addr
parsed = parse_agent_addr(target_addr)
if parsed is None or parsed[0] != user["id"]:
raise HTTPException(status_code=403, detail="只能授权自己名下的智能体")
if grantee == user["id"]:
raise HTTPException(status_code=422, detail="不能授权给自己")
grant = await db.agent_access_grants.create(
grantor_user_id=user["id"],
target_agent_addr=target_addr,
grantee_user_id=grantee,
scope=str(payload.get("scope", "")),
max_calls=int(payload.get("max_calls", 0) or 0),
expires_at=str(payload.get("expires_at", "")),
)
return grant
@router.get("/agent-grants", summary="智能体临时授权列表")
async def list_grants(direction: str = "grantor",
user: dict = Depends(get_current_user),
db: Database = Depends(get_db)):
"""direction=grantor(我授权的)| grantee(我被授权的)。"""
if direction == "grantee":
return await db.agent_access_grants.list_by_grantee(user["id"])
return await db.agent_access_grants.list_by_grantor(user["id"])
@router.delete("/agent-grants/{grant_id}", summary="撤销智能体临时授权")
async def revoke_grant(grant_id: str, user: dict = Depends(get_current_user),
db: Database = Depends(get_db)):
ok = await db.agent_access_grants.revoke(grant_id, user["id"])
if not ok:
raise HTTPException(status_code=404, detail="授权不存在或无权撤销")
return {"ok": True}
# ── 用户 API:企业智能体目录(可调用智能体列表 + 在线状态) ──────────
@router.get("/agents/directory", summary="企业可调用智能体目录")
async def list_agent_directory(user: dict = Depends(get_current_user),
db: Database = Depends(get_db)):
"""返回当前用户可调用的智能体目录(按成员分组),含在线状态。
可调用范围:自己的智能体 + 同企业成员 external_callable=True 的智能体
(企业管理员可看全部;普通成员受 L2/L3 权限约束,但目录仅展示「可被调用」
的智能体,实际调用时再由 authorize_call 做最终校验)。
"""
from ...api.dependencies import get_user_organizations, get_user_primary_org
from ...infrastructure.models import OrganizationMember, Organization, CompanyMember
from sqlalchemy import select
caller_id = user["id"]
is_admin = "enterprise" in (user.get("capabilities") or [])
# 1. 确定当前用户的主企业(enterprise 类型)
primary_org = await get_user_primary_org(db, user, org_type="enterprise")
if not primary_org:
# 无企业归属:仅返回自己的智能体
org_id = ""
else:
org_id = primary_org["org_id"]
# 2. 收集企业成员 user_id 集合(含自己)
member_ids: set[str] = {caller_id}
if org_id:
# 优先查统一组织表
try:
members = (await db.session.scalars(
select(OrganizationMember).where(
OrganizationMember.org_id == org_id,
OrganizationMember.status == "active",
)
)).all()
for m in members:
member_ids.add(m.user_id)
except Exception:
pass
# 回退旧表 company_members(通过 company_id 关联)
if len(member_ids) <= 1:
try:
from ...infrastructure.models import Company
company = await db.session.scalar(
select(Company).where(Company.org_id == org_id).limit(1)
)
if company:
cms = (await db.session.scalars(
select(CompanyMember).where(
CompanyMember.company_id == company.id,
CompanyMember.status == "active",
)
)).all()
for cm in cms:
member_ids.add(cm.user_id)
except Exception:
pass
# 3. 批量查询每个成员的用户资料(名称/头像)
user_profiles: dict[str, dict] = {}
for uid in member_ids:
u = await db.users.get_by_id(uid)
if u:
user_profiles[uid] = u
# 4. 批量查询每个成员的可外部调用智能体(port=opc)
from ...im import client as im_client
result: list[dict] = []
for uid in sorted(member_ids):
profile = user_profiles.get(uid) or {}
agents = await db.agents.get_by_user(uid, port="opc")
# 过滤:自己的全部展示;他人的仅展示 external_callable=True
visible_agents = []
for a in agents:
if uid == caller_id or is_admin or a.get("external_callable"):
visible_agents.append(a)
if not visible_agents:
continue
agent_items = []
for a in visible_agents:
addr = f"{uid}.{a['id']}"
# 在线状态查询(IM 内部接口,失败视为离线)
online = False
try:
online_info = await im_client.internal_agent_online(addr)
online = bool(online_info.get("online"))
except Exception:
pass
agent_items.append({
"agent_id": a["id"],
"name": a["name"],
"avatar": "", # 智能体头像暂由前端默认占位
"description": a.get("description", ""),
"call_mode": a.get("call_mode", "auto"),
"external_callable": bool(a.get("external_callable")),
"online": online,
"is_own": uid == caller_id,
})
result.append({
"user_id": uid,
"name": profile.get("name") or profile.get("nickname") or profile.get("username") or uid,
"avatar": profile.get("avatar", ""),
"is_admin": bool(profile.get("is_admin")) or (uid == caller_id and is_admin),
"agents": agent_items,
})
return result