初始化提交
This commit is contained in:
commit
887c1f449e
963 files changed
+166869
No files matched your search
@@ -0,0 +1 @@
|
||||
"""API 路由包。"""
|
||||
@@ -0,0 +1,60 @@
|
||||
"""API 路由聚合。"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1.endpoints import (
|
||||
admin,
|
||||
audio,
|
||||
auth,
|
||||
avatar,
|
||||
character_import,
|
||||
characters,
|
||||
chat,
|
||||
drive,
|
||||
events,
|
||||
evolution,
|
||||
extraction,
|
||||
goals,
|
||||
health,
|
||||
imports,
|
||||
lorebooks,
|
||||
mcp,
|
||||
memory,
|
||||
personality,
|
||||
runs,
|
||||
safety,
|
||||
sandbox_files,
|
||||
settings,
|
||||
skills,
|
||||
time,
|
||||
tools,
|
||||
world,
|
||||
)
|
||||
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(health.router)
|
||||
api_router.include_router(auth.router)
|
||||
api_router.include_router(characters.router)
|
||||
api_router.include_router(character_import.router)
|
||||
api_router.include_router(chat.router)
|
||||
api_router.include_router(runs.router)
|
||||
api_router.include_router(memory.router)
|
||||
api_router.include_router(lorebooks.router)
|
||||
api_router.include_router(personality.router)
|
||||
api_router.include_router(tools.router)
|
||||
api_router.include_router(skills.router)
|
||||
api_router.include_router(mcp.router)
|
||||
api_router.include_router(safety.router)
|
||||
api_router.include_router(sandbox_files.router)
|
||||
api_router.include_router(settings.router)
|
||||
api_router.include_router(world.router)
|
||||
api_router.include_router(imports.router)
|
||||
api_router.include_router(evolution.router)
|
||||
api_router.include_router(drive.router)
|
||||
api_router.include_router(events.router)
|
||||
api_router.include_router(extraction.router)
|
||||
api_router.include_router(goals.router)
|
||||
api_router.include_router(admin.router)
|
||||
api_router.include_router(audio.router)
|
||||
api_router.include_router(avatar.router)
|
||||
api_router.include_router(time.router)
|
||||
@@ -0,0 +1 @@
|
||||
"""v1 API 路由。"""
|
||||
@@ -0,0 +1,186 @@
|
||||
"""API 层依赖注入。
|
||||
|
||||
从 ``app.state`` 取 lifespan 装配的共享运行时(ToolRuntime / ConfirmationStore / EventStore)。
|
||||
CRUD 类路由直接调用 ``services`` 函数(函数内部自管 ``AsyncSessionLocal``),不在此注入 session。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.agent.confirmation import ConfirmationRegistry
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_session
|
||||
from app.modules.runtime.events import EventStore
|
||||
from app.modules.safety.confirmation import ConfirmationStore
|
||||
from app.schemas.auth import CurrentUser as CurrentUserSchema
|
||||
from app.services.auth import decode_token, get_user_by_id, get_user_permissions
|
||||
|
||||
|
||||
def _unavailable(message: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=503,
|
||||
detail={"error": {"code": "service_unavailable", "message": message}},
|
||||
)
|
||||
|
||||
|
||||
def get_tool_runtime(request: Request) -> ToolRuntime:
|
||||
runtime: ToolRuntime | None = getattr(request.app.state, "tool_runtime", None)
|
||||
if runtime is None:
|
||||
raise _unavailable("工具运行时未就绪(沙箱未配置或未启动)")
|
||||
return runtime
|
||||
|
||||
|
||||
def get_optional_tool_runtime(request: Request) -> ToolRuntime | None:
|
||||
"""宽松版:tool_runtime 未就绪时返回 None(不抛 503),供聚合视图降级呈现。"""
|
||||
return getattr(request.app.state, "tool_runtime", None)
|
||||
|
||||
|
||||
def get_confirmation_store(request: Request) -> ConfirmationStore:
|
||||
store = getattr(request.app.state, "confirmation_store", None)
|
||||
if store is None:
|
||||
raise _unavailable("确认存储未就绪")
|
||||
return store
|
||||
|
||||
|
||||
def get_confirmation_registry(request: Request) -> ConfirmationRegistry:
|
||||
registry: ConfirmationRegistry = getattr(request.app.state, "confirmation_registry", None)
|
||||
if registry is None:
|
||||
raise _unavailable("确认注册表未就绪")
|
||||
return registry
|
||||
|
||||
|
||||
def get_event_store(request: Request) -> EventStore:
|
||||
store = getattr(request.app.state, "event_store", None)
|
||||
if store is None:
|
||||
raise _unavailable("事件存储未就绪")
|
||||
return store
|
||||
|
||||
|
||||
async def require_admin(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""管理端点鉴权:JWT + is_admin 优先,ADMIN_TOKEN fallback。
|
||||
|
||||
JWT 模式下:
|
||||
- 有效 JWT 且 is_admin=True → 放行
|
||||
- 有效 JWT 且 is_admin=False → 403
|
||||
- 无效 JWT → 回退 ADMIN_TOKEN 校验
|
||||
ADMIN_TOKEN 模式下(原 v1 逻辑):
|
||||
- 同现有逻辑,常数时间比较
|
||||
- ADMIN_TOKEN 未配置且无 JWT → 503 fail-closed
|
||||
"""
|
||||
# 0) fail-closed:两种鉴权均未配置时直接拒绝
|
||||
if not settings.jwt_secret and not settings.admin_token:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail={"error": {"code": "admin_not_configured", "message": "管理端点未启用。"}},
|
||||
)
|
||||
|
||||
credential = request.headers.get("Authorization", "")
|
||||
if not credential.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": {"code": "unauthorized", "message": "缺少管理员凭证。"}},
|
||||
)
|
||||
presented = credential[len("Bearer ") :]
|
||||
|
||||
# 1) 尝试 JWT 模式(如果 jwt_secret 已配置)
|
||||
if settings.jwt_secret:
|
||||
try:
|
||||
payload = decode_token(presented)
|
||||
if payload.get("type") == "access":
|
||||
user_id = payload.get("sub")
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={
|
||||
"error": {"code": "unauthorized", "message": "用户不存在或已禁用。"}
|
||||
},
|
||||
)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": {"code": "forbidden", "message": "仅管理员可访问此接口。"}
|
||||
},
|
||||
)
|
||||
return # JWT 模式鉴权通过
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass # JWT 无效,回退 ADMIN_TOKEN 校验
|
||||
|
||||
# 2) Fallback: ADMIN_TOKEN 校验(原 v1 逻辑)
|
||||
token = settings.admin_token
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail={"error": {"code": "admin_not_configured", "message": "管理端点未启用。"}},
|
||||
)
|
||||
if not hmac.compare_digest(presented, token):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": {"code": "unauthorized", "message": "管理员凭证无效。"}},
|
||||
)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> CurrentUserSchema:
|
||||
"""从 Authorization: Bearer <access_token> 解析当前用户。
|
||||
|
||||
JWT 未配置(jwt_secret 为空)时 503 fail-closed。
|
||||
"""
|
||||
if not settings.jwt_secret:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "service_unavailable",
|
||||
"message": "认证服务未配置(JWT_SECRET 为空)。",
|
||||
}
|
||||
},
|
||||
)
|
||||
credential = request.headers.get("Authorization", "")
|
||||
if not credential.startswith("Bearer "):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": {"code": "unauthorized", "message": "缺少认证凭证。"}},
|
||||
)
|
||||
token = credential[len("Bearer ") :]
|
||||
try:
|
||||
payload = decode_token(token)
|
||||
except Exception:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": {"code": "unauthorized", "message": "Token 无效或已过期。"}},
|
||||
) from None
|
||||
if payload.get("type") != "access":
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": {"code": "unauthorized", "message": "Token 类型不匹配。"}},
|
||||
)
|
||||
user_id = payload.get("sub")
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": {"code": "unauthorized", "message": "用户不存在或已禁用。"}},
|
||||
)
|
||||
perms = await get_user_permissions(db, user.id)
|
||||
return CurrentUserSchema(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
owner_scope=user.id,
|
||||
is_admin=user.is_admin,
|
||||
permissions=perms,
|
||||
)
|
||||
@@ -0,0 +1,9 @@
|
||||
"""v1 API 路由端点。
|
||||
|
||||
路由实现统一放在 ``endpoints/`` 子目录,``deps.py`` 留在父级 ``api/v1/`` 提供依赖注入。
|
||||
每个端点模块只做 参数校验 → 调用 service/usecase → 转响应,不直接持有 ``AsyncSession``。
|
||||
"""
|
||||
|
||||
from app.api.v1.endpoints import auth
|
||||
|
||||
__all__ = ["auth"]
|
||||
@@ -0,0 +1,534 @@
|
||||
"""司命台管理 API(api-spec §19)。
|
||||
|
||||
聚合九枢治理视图:九枢健康度 / 朱批待办 / 起居注审计(§19.1–§19.3)
|
||||
+ 五组治理对象 CRUD(§19.4)。治理写操作由 service 层记审计并入起居注。
|
||||
|
||||
路由直接调用 ``services`` 函数(函数内部自管 ``AsyncSessionLocal``),不持有 session;
|
||||
``ToolRuntime`` / ``ConfirmationStore`` 等运行时对象经 ``deps`` 从 ``app.state`` 注入。
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.api.v1.deps import (
|
||||
get_confirmation_store,
|
||||
get_current_user,
|
||||
get_optional_tool_runtime,
|
||||
require_admin,
|
||||
)
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.models.runtime import Event
|
||||
from app.modules.safety.confirmation import ConfirmationStore
|
||||
from app.schemas.admin import (
|
||||
AdminCreateUserRequest,
|
||||
AdminUpdateUserRequest,
|
||||
AdminUserResponse,
|
||||
AgentRosterResponse,
|
||||
AuditEntry,
|
||||
CreateAgentRequest,
|
||||
CreateGoalRequest,
|
||||
CreatePerceptionChannelRequest,
|
||||
CreatePlanRequest,
|
||||
CreateWorldEntityRequest,
|
||||
GoalNode,
|
||||
PendingApproval,
|
||||
PerceptionChannelResponse,
|
||||
PivotHealthReport,
|
||||
PlanSummaryResponse,
|
||||
TimeEventListResponse,
|
||||
TimeEventResponse,
|
||||
TraceDetail,
|
||||
TraceListResponse,
|
||||
UpdateAgentRequest,
|
||||
UpdateGoalRequest,
|
||||
UpdatePerceptionChannelRequest,
|
||||
UpdatePlanRequest,
|
||||
UpdateWorldEntityRequest,
|
||||
WorldEntityResponse,
|
||||
)
|
||||
from app.schemas.admin_character import (
|
||||
AdminCreateTemplateRequest,
|
||||
AdminTemplateSummaryResponse,
|
||||
AdminUpdateTemplateRequest,
|
||||
)
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.common import Page
|
||||
from app.services import governance as governance_service
|
||||
from app.services.admin import (
|
||||
get_audit_timeline,
|
||||
get_pending_approvals,
|
||||
get_pivots_health,
|
||||
get_trace,
|
||||
list_time_events,
|
||||
list_traces,
|
||||
)
|
||||
from app.services.admin_characters import (
|
||||
admin_create_template,
|
||||
admin_delete_template,
|
||||
admin_get_template,
|
||||
admin_list_templates,
|
||||
admin_update_template,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
# —— §19.1 九枢健康度聚合 ——
|
||||
|
||||
|
||||
@router.get("/pivots/health", response_model=PivotHealthReport)
|
||||
async def pivots_health(
|
||||
confirmation_store: ConfirmationStore = Depends(get_confirmation_store),
|
||||
tool_runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PivotHealthReport:
|
||||
return await get_pivots_health(
|
||||
confirmation_store=confirmation_store,
|
||||
tool_runtime=tool_runtime,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
# —— §19.2 朱批待办 ——
|
||||
|
||||
|
||||
@router.get("/safety/pending", response_model=Page[PendingApproval])
|
||||
async def safety_pending(
|
||||
confirmation_store: ConfirmationStore = Depends(get_confirmation_store),
|
||||
tool_runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> Page[PendingApproval]:
|
||||
items = await get_pending_approvals(
|
||||
confirmation_store=confirmation_store, tool_runtime=tool_runtime
|
||||
)
|
||||
return Page(items=items, page=1, page_size=len(items), total=len(items))
|
||||
|
||||
|
||||
# —— §19.3 起居注审计 ——
|
||||
|
||||
|
||||
@router.get("/audit/timeline", response_model=Page[AuditEntry])
|
||||
async def audit_timeline(
|
||||
pivot: str | None = Query(default=None, description="按枢过滤(契约字典 §5.9)。"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
) -> Page[AuditEntry]:
|
||||
return await get_audit_timeline(pivot=pivot, page=page, page_size=page_size)
|
||||
|
||||
|
||||
# —— §19.4 太一枢 · 目标 ——
|
||||
|
||||
|
||||
@router.get("/goals", response_model=list[GoalNode])
|
||||
async def list_goals(
|
||||
character_id: str | None = Query(default=None, description="按人物过滤。"),
|
||||
) -> list[GoalNode]:
|
||||
if character_id:
|
||||
return await governance_service.list_goals_by_character(character_id)
|
||||
return await governance_service.list_goals()
|
||||
|
||||
|
||||
@router.post("/goals", response_model=GoalNode)
|
||||
async def create_goal(payload: CreateGoalRequest) -> GoalNode:
|
||||
return await governance_service.create_goal(payload)
|
||||
|
||||
|
||||
@router.patch("/goals/{goal_id}", response_model=GoalNode)
|
||||
async def update_goal(
|
||||
goal_id: str,
|
||||
payload: UpdateGoalRequest,
|
||||
) -> GoalNode:
|
||||
return await governance_service.update_goal(goal_id, payload)
|
||||
|
||||
|
||||
@router.delete("/goals/{goal_id}", status_code=204)
|
||||
async def delete_goal(goal_id: str) -> None:
|
||||
await governance_service.delete_goal(goal_id)
|
||||
|
||||
|
||||
# —— §19.4 烛照枢 · 感知通道 ——
|
||||
|
||||
|
||||
@router.get("/perception/channels", response_model=list[PerceptionChannelResponse])
|
||||
async def list_perception_channels() -> list[PerceptionChannelResponse]:
|
||||
return await governance_service.list_perception_channels()
|
||||
|
||||
|
||||
@router.post("/perception/channels", response_model=PerceptionChannelResponse)
|
||||
async def create_perception_channel(
|
||||
payload: CreatePerceptionChannelRequest,
|
||||
) -> PerceptionChannelResponse:
|
||||
return await governance_service.create_perception_channel(payload)
|
||||
|
||||
|
||||
@router.patch("/perception/channels/{channel}", response_model=PerceptionChannelResponse)
|
||||
async def update_perception_channel(
|
||||
channel: str,
|
||||
payload: UpdatePerceptionChannelRequest,
|
||||
) -> PerceptionChannelResponse:
|
||||
return await governance_service.update_perception_channel(channel, payload)
|
||||
|
||||
|
||||
@router.delete("/perception/channels/{channel}", status_code=204)
|
||||
async def delete_perception_channel(channel: str) -> None:
|
||||
await governance_service.delete_perception_channel(channel)
|
||||
|
||||
|
||||
# —— §19.4 河图枢 · 世界实体 ——
|
||||
|
||||
|
||||
@router.get("/world/entities", response_model=list[WorldEntityResponse])
|
||||
async def list_world_entities() -> list[WorldEntityResponse]:
|
||||
return await governance_service.list_world_entities()
|
||||
|
||||
|
||||
@router.post("/world/entities", response_model=WorldEntityResponse)
|
||||
async def create_world_entity(
|
||||
payload: CreateWorldEntityRequest,
|
||||
) -> WorldEntityResponse:
|
||||
return await governance_service.create_world_entity(payload)
|
||||
|
||||
|
||||
@router.patch("/world/entities/{entity_id}", response_model=WorldEntityResponse)
|
||||
async def update_world_entity(
|
||||
entity_id: str,
|
||||
payload: UpdateWorldEntityRequest,
|
||||
) -> WorldEntityResponse:
|
||||
return await governance_service.update_world_entity(entity_id, payload)
|
||||
|
||||
|
||||
@router.delete("/world/entities/{entity_id}", status_code=204)
|
||||
async def delete_world_entity(entity_id: str) -> None:
|
||||
await governance_service.delete_world_entity(entity_id)
|
||||
|
||||
|
||||
# —— §19.4 洛书枢 · 计划 ——
|
||||
|
||||
|
||||
@router.get("/plans", response_model=list[PlanSummaryResponse])
|
||||
async def list_plans() -> list[PlanSummaryResponse]:
|
||||
return await governance_service.list_plans()
|
||||
|
||||
|
||||
@router.post("/plans", response_model=PlanSummaryResponse)
|
||||
async def create_plan(payload: CreatePlanRequest) -> PlanSummaryResponse:
|
||||
return await governance_service.create_plan(payload)
|
||||
|
||||
|
||||
@router.patch("/plans/{plan_id}", response_model=PlanSummaryResponse)
|
||||
async def update_plan(
|
||||
plan_id: str,
|
||||
payload: UpdatePlanRequest,
|
||||
) -> PlanSummaryResponse:
|
||||
return await governance_service.update_plan(plan_id, payload)
|
||||
|
||||
|
||||
@router.delete("/plans/{plan_id}", status_code=204)
|
||||
async def delete_plan(plan_id: str) -> None:
|
||||
await governance_service.delete_plan(plan_id)
|
||||
|
||||
|
||||
# —— §19.4 天工阁枢 · 角色在岗 ——
|
||||
|
||||
|
||||
@router.get("/agents/roster", response_model=list[AgentRosterResponse])
|
||||
async def list_agent_roster() -> list[AgentRosterResponse]:
|
||||
return await governance_service.list_agent_roster()
|
||||
|
||||
|
||||
@router.post("/agents/roster", response_model=AgentRosterResponse)
|
||||
async def create_agent(payload: CreateAgentRequest) -> AgentRosterResponse:
|
||||
return await governance_service.create_agent(payload)
|
||||
|
||||
|
||||
@router.patch("/agents/roster/{role}", response_model=AgentRosterResponse)
|
||||
async def update_agent(
|
||||
role: str,
|
||||
payload: UpdateAgentRequest,
|
||||
) -> AgentRosterResponse:
|
||||
return await governance_service.update_agent(role, payload)
|
||||
|
||||
|
||||
@router.delete("/agents/roster/{role}", status_code=204)
|
||||
async def delete_agent(role: str) -> None:
|
||||
await governance_service.delete_agent(role)
|
||||
|
||||
|
||||
# —— §19.5 链路追踪 ——
|
||||
|
||||
|
||||
@router.get("/traces", response_model=TraceListResponse)
|
||||
async def trace_list(
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
) -> TraceListResponse:
|
||||
"""列出最近活动的 trace_id 概览(跨运行事件 / 安全审计 / 请求幂等聚合)。"""
|
||||
return await list_traces(limit=limit)
|
||||
|
||||
|
||||
@router.get("/traces/{trace_id}", response_model=TraceDetail)
|
||||
async def trace_detail(trace_id: str) -> TraceDetail:
|
||||
"""按 trace_id 聚合运行事件 / 安全审计 / 请求幂等三表为升序时间线。"""
|
||||
return await get_trace(trace_id)
|
||||
|
||||
|
||||
# —— §8 司命台 · 起居注时间河(辰正 time.* 事件)——
|
||||
|
||||
|
||||
@router.get("/time-events", response_model=TimeEventListResponse)
|
||||
async def time_event_list(
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> TimeEventListResponse:
|
||||
"""起居注时间河:辰正 time.* 事件(§8 司命台接入,source='time_sense')。"""
|
||||
items, total = await list_time_events(limit=limit, offset=offset)
|
||||
return TimeEventListResponse(
|
||||
items=[_time_event_to_response(item) for item in items],
|
||||
total=total,
|
||||
)
|
||||
|
||||
|
||||
def _time_event_to_response(event: Event) -> TimeEventResponse:
|
||||
"""复用既有 payload 解析模式(json.loads + 异常回退到空 dict,与 _event_to_item 一致)。"""
|
||||
try:
|
||||
payload = json.loads(event.payload_json) if event.payload_json else {}
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
payload = {}
|
||||
return TimeEventResponse(
|
||||
id=event.id,
|
||||
type=event.type,
|
||||
payload=payload,
|
||||
run_id=event.run_id,
|
||||
character_id=event.character_id,
|
||||
sensitivity=event.sensitivity,
|
||||
created_at=event.created_at.isoformat() if event.created_at else "",
|
||||
)
|
||||
|
||||
|
||||
# —— §19.6 用户管理 ——
|
||||
|
||||
|
||||
@router.get("/users", response_model=list[AdminUserResponse])
|
||||
async def admin_list_users() -> list[AdminUserResponse]:
|
||||
"""列出所有用户(管理员用)。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.user import User
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(User).order_by(User.created_at))
|
||||
users = result.scalars().all()
|
||||
return [AdminUserResponse.model_validate(u) for u in users]
|
||||
|
||||
|
||||
@router.post("/users", response_model=AdminUserResponse)
|
||||
async def admin_create_user(payload: AdminCreateUserRequest) -> AdminUserResponse:
|
||||
"""创建用户(管理员用)。"""
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.services.auth import create_user
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
user = await create_user(
|
||||
session,
|
||||
username=payload.username,
|
||||
email=payload.email,
|
||||
password=payload.password,
|
||||
is_admin=payload.is_admin,
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return AdminUserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.patch("/users/{user_id}", response_model=AdminUserResponse)
|
||||
async def admin_update_user(user_id: str, payload: AdminUpdateUserRequest) -> AdminUserResponse:
|
||||
"""更新用户(启用/禁用、管理员角色)。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.user import User
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user is None:
|
||||
from app.core.errors import AppError
|
||||
|
||||
raise AppError("not_found", "用户不存在。")
|
||||
if payload.is_active is not None:
|
||||
user.is_active = payload.is_active
|
||||
if payload.is_admin is not None:
|
||||
user.is_admin = payload.is_admin
|
||||
await session.commit()
|
||||
await session.refresh(user)
|
||||
return AdminUserResponse.model_validate(user)
|
||||
|
||||
|
||||
# —— §19.7 多租户管理视图 ——
|
||||
|
||||
|
||||
class _TenantUserOverview(BaseModel):
|
||||
"""租户概览(管理员跨租户视角)。"""
|
||||
|
||||
user_id: str
|
||||
username: str
|
||||
email: str
|
||||
is_active: bool
|
||||
is_admin: bool
|
||||
character_count: int
|
||||
memory_count: int
|
||||
|
||||
|
||||
@router.get("/tenants/overview", response_model=list[_TenantUserOverview])
|
||||
async def admin_tenants_overview() -> list[_TenantUserOverview]:
|
||||
"""跨租户概览:每用户的人物与记忆计数(管理员用)。"""
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.character import Character
|
||||
from app.models.memory import Memory
|
||||
from app.models.user import User
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(User).order_by(User.created_at))
|
||||
users = result.scalars().all()
|
||||
overview: list[_TenantUserOverview] = []
|
||||
for user in users:
|
||||
char_count = await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(Character)
|
||||
.where(
|
||||
Character.owner_scope == user.id,
|
||||
Character.is_system_template.is_(False),
|
||||
)
|
||||
)
|
||||
mem_count = await session.scalar(
|
||||
select(func.count())
|
||||
.select_from(Memory)
|
||||
.join(Character, Memory.character_id == Character.id)
|
||||
.where(
|
||||
Character.owner_scope == user.id,
|
||||
Character.is_system_template.is_(False),
|
||||
)
|
||||
)
|
||||
overview.append(
|
||||
_TenantUserOverview(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
is_active=user.is_active,
|
||||
is_admin=user.is_admin,
|
||||
character_count=char_count or 0,
|
||||
memory_count=mem_count or 0,
|
||||
)
|
||||
)
|
||||
return overview
|
||||
|
||||
|
||||
@router.get("/tenants/{user_id}/characters")
|
||||
async def admin_tenant_characters(user_id: str) -> list[dict]:
|
||||
"""查看指定用户的所有人物(跨租户管理员用)。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.character import Character
|
||||
from app.services.serializers import character_to_response
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Character)
|
||||
.where(
|
||||
Character.owner_scope == user_id,
|
||||
Character.is_system_template.is_(False),
|
||||
)
|
||||
.order_by(Character.created_at)
|
||||
)
|
||||
return [character_to_response(c).model_dump(mode="json") for c in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/tenants/{user_id}/memories")
|
||||
async def admin_tenant_memories(user_id: str) -> list[dict]:
|
||||
"""查看指定用户的所有记忆(跨租户管理员用)。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.character import Character
|
||||
from app.models.memory import Memory
|
||||
from app.services.serializers import memory_to_response
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(Memory)
|
||||
.join(Character, Memory.character_id == Character.id)
|
||||
.where(
|
||||
Character.owner_scope == user_id,
|
||||
Character.is_system_template.is_(False),
|
||||
)
|
||||
.order_by(Memory.created_at.desc())
|
||||
)
|
||||
return [memory_to_response(m).model_dump(mode="json") for m in result.scalars().all()]
|
||||
|
||||
|
||||
# —— §19.8 人物模板管理(管理员专用 CRUD) ——
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/templates",
|
||||
response_model=list[AdminTemplateSummaryResponse],
|
||||
)
|
||||
async def list_templates() -> list[AdminTemplateSummaryResponse]:
|
||||
"""列出所有系统模板(管理端完整视图,含 profile / model_policy 全文)。"""
|
||||
return await admin_list_templates()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/templates/{key}",
|
||||
response_model=AdminTemplateSummaryResponse,
|
||||
)
|
||||
async def get_template(key: str) -> AdminTemplateSummaryResponse:
|
||||
"""获取单个系统模板详情。"""
|
||||
return await admin_get_template(key)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/characters/templates",
|
||||
response_model=AdminTemplateSummaryResponse,
|
||||
status_code=201,
|
||||
)
|
||||
async def create_template(
|
||||
payload: AdminCreateTemplateRequest,
|
||||
) -> AdminTemplateSummaryResponse:
|
||||
"""创建新的系统模板。
|
||||
|
||||
如 key 与 YAML 中已存在的 key 相同,下次重启时会被 YAML 配置覆盖。
|
||||
"""
|
||||
return await admin_create_template(payload)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/characters/templates/{key}",
|
||||
response_model=AdminTemplateSummaryResponse,
|
||||
)
|
||||
async def update_template(
|
||||
key: str,
|
||||
payload: AdminUpdateTemplateRequest,
|
||||
) -> AdminTemplateSummaryResponse:
|
||||
"""更新系统模板(部分更新)。
|
||||
|
||||
仅提供非 None 的字段会被更新。YAML 定义的模板字段会在下次重启时被 YAML 覆盖。
|
||||
"""
|
||||
return await admin_update_template(key, payload)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/characters/templates/{key}",
|
||||
status_code=204,
|
||||
)
|
||||
async def delete_template(key: str) -> None:
|
||||
"""删除系统模板。
|
||||
|
||||
仅允许删除未被任何用户人物引用的模板。
|
||||
被引用时返回 409 conflict。
|
||||
"""
|
||||
await admin_delete_template(key)
|
||||
@@ -0,0 +1,275 @@
|
||||
"""音频相关 API 端点 — TTS(文本转语音)、ASR(语音转文本)等。
|
||||
|
||||
音频端点使用神识的模型注册表查找可用的音频模型,并调用对应音频适配器合成 /
|
||||
转写。ASR 与 TTS 按 ``tags`` 中的 ``asr`` / ``tts`` 能力区分,ASR 内部再按
|
||||
``provider`` 区分协议:``yuanqi`` 走 multipart ``/v1/audio/transcriptions``,
|
||||
其他走 MiMo 风格的 ``/chat/completions`` + ``input_audio``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, File, Form, HTTPException, Response, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.ai_provider.audio import AudioService, AudioServiceError
|
||||
from app.ai_provider.manager import ProviderManagerFactory
|
||||
from app.core.logging import logger
|
||||
|
||||
router = APIRouter(tags=["audio"])
|
||||
|
||||
_MIME_MAP = {
|
||||
"wav": "audio/wav",
|
||||
"mp3": "audio/mpeg",
|
||||
"opus": "audio/opus",
|
||||
"flac": "audio/flac",
|
||||
}
|
||||
|
||||
|
||||
class _AudioModelFactory:
|
||||
"""Adapter: wraps ProviderManager to provide ModelFactory interface for AudioService.
|
||||
|
||||
``create_audio(name)`` 按 ``tags`` 判断 ``asr`` / ``tts`` 能力:
|
||||
- ``tts`` → ``HunjingTTSOpenAI``(``/chat/completions`` + ``audio``)
|
||||
- ``asr`` → 按 ``provider`` 分派:
|
||||
- ``yuanqi`` → ``YuanqiASRWhisper``(multipart ``/v1/audio/transcriptions``)
|
||||
- 其他 → ``HunjingASROpenAI``(``/chat/completions`` + ``input_audio``)
|
||||
"""
|
||||
|
||||
def __init__(self, manager) -> None:
|
||||
self._manager = manager
|
||||
|
||||
@property
|
||||
def registry(self):
|
||||
return self._manager.model_registry
|
||||
|
||||
def create_audio(self, name: str):
|
||||
model = self._manager.get_model_config(name)
|
||||
is_asr = "asr" in model.tags
|
||||
|
||||
if is_asr:
|
||||
return self._create_asr(model)
|
||||
return self._create_tts(model)
|
||||
|
||||
def _provider_or_none(self, model):
|
||||
"""安全获取 provider 配置(provider 未配置时返回 None,走纯端点解析)。"""
|
||||
try:
|
||||
return self._manager.get_provider_config(model.provider)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# ── ASR ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _create_asr(self, model):
|
||||
"""按 provider 选择 ASR 协议适配器。"""
|
||||
# yuanqi 本地引擎走 multipart /v1/audio/transcriptions
|
||||
if model.provider == "yuanqi":
|
||||
from app.ai_provider.audio.yuanqi_asr import YuanqiASRWhisper
|
||||
|
||||
if model.endpoints:
|
||||
return YuanqiASRWhisper.from_model_config(
|
||||
model, provider=self._provider_or_none(model)
|
||||
)
|
||||
|
||||
provider = self._manager.get_provider_config(model.provider)
|
||||
api_key, base_url = self._resolve_credentials(provider)
|
||||
if not base_url:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_asr_no_base_url",
|
||||
message=f"Provider '{model.provider}' has no base_url configured.",
|
||||
)
|
||||
return YuanqiASRWhisper(
|
||||
base_url=base_url,
|
||||
api_key=api_key or "",
|
||||
model=model.model,
|
||||
request_timeout_seconds=model.timeout_seconds or 300,
|
||||
)
|
||||
|
||||
# MiMo 等走 /chat/completions + input_audio
|
||||
from app.ai_provider.audio.asr import HunjingASROpenAI
|
||||
|
||||
if model.endpoints:
|
||||
return HunjingASROpenAI.from_model_config(
|
||||
model, provider=self._provider_or_none(model)
|
||||
)
|
||||
|
||||
provider = self._manager.get_provider_config(model.provider)
|
||||
api_key, base_url = self._resolve_credentials(provider)
|
||||
if not base_url:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_asr_no_base_url",
|
||||
message=f"Provider '{model.provider}' has no base_url configured.",
|
||||
)
|
||||
return HunjingASROpenAI(
|
||||
base_url=base_url,
|
||||
api_key=api_key or "",
|
||||
model=model.model,
|
||||
request_timeout_seconds=model.timeout_seconds or 300,
|
||||
)
|
||||
|
||||
# ── TTS ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _create_tts(self, model):
|
||||
from app.ai_provider.audio.tts import HunjingTTSOpenAI
|
||||
|
||||
# 有 endpoints 时交给 from_model_config(按端点的 status/model/超时)
|
||||
if model.endpoints:
|
||||
return HunjingTTSOpenAI.from_model_config(
|
||||
model, provider=self._provider_or_none(model)
|
||||
)
|
||||
|
||||
# 无 endpoint 时回退到 provider 级别凭证
|
||||
provider = self._manager.get_provider_config(model.provider)
|
||||
api_key, base_url = self._resolve_credentials(provider)
|
||||
if not base_url:
|
||||
raise AudioServiceError(
|
||||
status_code=500,
|
||||
code="audio_tts_no_base_url",
|
||||
message=f"Provider '{model.provider}' has no base_url configured.",
|
||||
)
|
||||
return HunjingTTSOpenAI(
|
||||
base_url=base_url,
|
||||
api_key=api_key or "",
|
||||
model=model.model,
|
||||
request_timeout_seconds=model.timeout_seconds or 120,
|
||||
)
|
||||
|
||||
def _resolve_credentials(self, provider):
|
||||
"""从 settings 解析 provider 级别的 API key 和 base URL。"""
|
||||
from app.core.config import settings
|
||||
|
||||
api_key = (
|
||||
settings.__getattribute__(provider.api_key_env.lower())
|
||||
if hasattr(settings, provider.api_key_env.lower())
|
||||
else ""
|
||||
)
|
||||
base_url = (
|
||||
settings.__getattribute__(provider.base_url_env.lower())
|
||||
if hasattr(settings, provider.base_url_env.lower())
|
||||
else None
|
||||
)
|
||||
return api_key, base_url
|
||||
|
||||
|
||||
class TTSRequest(BaseModel):
|
||||
"""TTS 合成请求。"""
|
||||
|
||||
text: str = Field(..., min_length=1, max_length=4096, description="要合成语音的文本。")
|
||||
voice: str = Field("default", description="语音角色标识(取决于模型,如 default 或声纹 ID)。")
|
||||
format: str = Field("wav", description="音频格式:wav / mp3 / opus / flac。")
|
||||
|
||||
|
||||
@router.post("/audio/tts")
|
||||
async def synthesize_speech(body: TTSRequest) -> Response:
|
||||
"""合成文本为语音,返回音频二进制流。
|
||||
|
||||
使用神识注册表中第一个活跃的 TTS 模型(capability: tts)。
|
||||
未配置 TTS 模型时返回 503。
|
||||
"""
|
||||
if not body.text.strip():
|
||||
raise HTTPException(status_code=400, detail="text 不能为空。")
|
||||
|
||||
mime = _MIME_MAP.get(body.format, "audio/wav")
|
||||
|
||||
try:
|
||||
manager = ProviderManagerFactory.create()
|
||||
service = AudioService(_AudioModelFactory(manager))
|
||||
async with service:
|
||||
result = await service.synthesize(
|
||||
text=body.text,
|
||||
voice=body.voice,
|
||||
response_format=body.format,
|
||||
)
|
||||
except AudioServiceError as exc:
|
||||
logger.error(
|
||||
"audio.tts.api_error",
|
||||
voice=body.voice,
|
||||
format=body.format,
|
||||
error_code=exc.code,
|
||||
error_message=exc.message,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=exc.status_code or 502,
|
||||
detail=exc.message,
|
||||
) from exc
|
||||
|
||||
logger.info(
|
||||
"audio.tts.success",
|
||||
voice=body.voice,
|
||||
format=body.format,
|
||||
audio_bytes=len(result.audio_data),
|
||||
)
|
||||
|
||||
return Response(
|
||||
content=result.audio_data,
|
||||
media_type=mime,
|
||||
headers={
|
||||
"Content-Disposition": f'inline; filename="speech.{body.format}"',
|
||||
"X-Audio-Format": body.format,
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── ASR(语音转文本)───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TranscriptionResponse(BaseModel):
|
||||
"""OpenAI Whisper 兼容的转写响应。"""
|
||||
|
||||
text: str = Field(description="转写后的文本")
|
||||
|
||||
|
||||
@router.post("/audio/transcriptions", response_model=TranscriptionResponse)
|
||||
async def transcribe_audio(
|
||||
file: UploadFile = File(..., description="音频文件(wav/mp3/m4a/flac/ogg/opus/webm 等)"),
|
||||
language: str | None = Form(default=None, description="语言提示(ISO-639-1:zh/en/ja/ko 等),省略则自动检测"),
|
||||
model: str | None = Form(default=None, description="可选,指定使用的 ASR 模型逻辑名(如 funasr-sensevoice / mimo-v2.5-asr),省略则用注册表中第一个"),
|
||||
) -> TranscriptionResponse:
|
||||
"""转写音频为文本,返回 OpenAI Whisper 兼容的 JSON。
|
||||
|
||||
不指定 ``model`` 时用神识注册表中第一个活跃的 ASR 模型(capability: asr),
|
||||
按其 ``provider`` 自动选择协议适配器:``yuanqi`` 走本地 multipart 端点,
|
||||
其他走 MiMo 风格的 chat completions。未配置 ASR 模型时返回 503。
|
||||
"""
|
||||
audio_bytes = await file.read()
|
||||
if not audio_bytes:
|
||||
raise HTTPException(status_code=400, detail="音频文件为空。")
|
||||
|
||||
fmt = (file.filename or "audio.wav").rsplit(".", 1)[-1].lower() if "." in (file.filename or "") else "wav"
|
||||
|
||||
try:
|
||||
manager = ProviderManagerFactory.create()
|
||||
service = AudioService(_AudioModelFactory(manager))
|
||||
async with service:
|
||||
result = await service.transcribe(
|
||||
audio_data=audio_bytes,
|
||||
format=fmt,
|
||||
language=language,
|
||||
model_name=model,
|
||||
)
|
||||
except AudioServiceError as exc:
|
||||
logger.error(
|
||||
"audio.asr.api_error",
|
||||
filename=file.filename,
|
||||
format=fmt,
|
||||
language=language,
|
||||
error_code=exc.code,
|
||||
error_message=exc.message,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=exc.status_code or 502,
|
||||
detail=exc.message,
|
||||
) from exc
|
||||
|
||||
logger.info(
|
||||
"audio.asr.success",
|
||||
filename=file.filename,
|
||||
format=fmt,
|
||||
language=language,
|
||||
text_length=len(result.text),
|
||||
text=result.text,
|
||||
)
|
||||
|
||||
return TranscriptionResponse(text=result.text)
|
||||
@@ -0,0 +1,207 @@
|
||||
"""认证 API。"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_session
|
||||
from app.models.rbac import Role, UserRole
|
||||
from app.schemas.auth import (
|
||||
ChangePasswordRequest,
|
||||
CurrentUser,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RefreshRequest,
|
||||
RegisterRequest,
|
||||
UpdateProfileRequest,
|
||||
UserResponse,
|
||||
)
|
||||
from app.services import auth as auth_service
|
||||
from app.services.auth import get_user_permissions
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
def _jwt_enabled() -> None:
|
||||
if not settings.jwt_secret:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "service_unavailable",
|
||||
"message": "认证服务未配置(JWT_SECRET 为空)。",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse)
|
||||
async def register(
|
||||
payload: RegisterRequest,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> UserResponse:
|
||||
_jwt_enabled()
|
||||
user = await auth_service.create_user(
|
||||
db,
|
||||
username=payload.username,
|
||||
email=payload.email,
|
||||
password=payload.password,
|
||||
)
|
||||
# 为新用户创建默认角色和默认人物
|
||||
from app.services.characters import ensure_default_character_for_owner
|
||||
|
||||
await ensure_default_character_for_owner(db, user.id)
|
||||
# 分配普通用户角色
|
||||
role_result = await db.execute(select(Role).where(Role.name == "user"))
|
||||
user_role = role_result.scalar_one_or_none()
|
||||
if user_role:
|
||||
db.add(UserRole(user_id=user.id, role_id=user_role.id))
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
return UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
is_admin=user.is_admin,
|
||||
created_at=user.created_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
payload: LoginRequest,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> LoginResponse:
|
||||
_jwt_enabled()
|
||||
user = await auth_service.authenticate_user(db, payload.username, payload.password)
|
||||
access_token = auth_service.create_access_token(
|
||||
subject=user.id,
|
||||
extra={"owner_scope": user.id},
|
||||
)
|
||||
refresh_token = auth_service.create_refresh_token(subject=user.id)
|
||||
perms = await get_user_permissions(db, user.id)
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.access_token_ttl,
|
||||
user=UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
is_admin=user.is_admin,
|
||||
permissions=perms,
|
||||
created_at=user.created_at.isoformat(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=LoginResponse)
|
||||
async def refresh(
|
||||
payload: RefreshRequest,
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> LoginResponse:
|
||||
_jwt_enabled()
|
||||
data = auth_service.decode_token(payload.refresh_token)
|
||||
if data.get("type") != "refresh":
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": {"code": "unauthorized", "message": "Token 类型不匹配。"}},
|
||||
)
|
||||
user = await auth_service.get_user_by_id(db, data["sub"])
|
||||
if user is None or not user.is_active:
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": {"code": "unauthorized", "message": "用户不存在或已禁用。"}},
|
||||
)
|
||||
new_access = auth_service.create_access_token(
|
||||
subject=user.id,
|
||||
extra={"owner_scope": user.id},
|
||||
)
|
||||
new_refresh = auth_service.create_refresh_token(subject=user.id)
|
||||
perms = await get_user_permissions(db, user.id)
|
||||
return LoginResponse(
|
||||
access_token=new_access,
|
||||
refresh_token=new_refresh,
|
||||
token_type="bearer",
|
||||
expires_in=settings.access_token_ttl,
|
||||
user=UserResponse(
|
||||
id=user.id,
|
||||
username=user.username,
|
||||
email=user.email,
|
||||
is_admin=user.is_admin,
|
||||
permissions=perms,
|
||||
created_at=user.created_at.isoformat(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> UserResponse:
|
||||
perms = await get_user_permissions(db, current_user.user_id)
|
||||
return UserResponse(
|
||||
id=current_user.user_id,
|
||||
username=current_user.username,
|
||||
email=current_user.email,
|
||||
is_admin=current_user.is_admin,
|
||||
permissions=perms,
|
||||
created_at="",
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserResponse)
|
||||
async def update_profile(
|
||||
payload: UpdateProfileRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> UserResponse:
|
||||
"""更新个人资料(当前仅支持邮箱)。"""
|
||||
if payload.email is not None:
|
||||
from sqlalchemy import select as _select
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
result = await db.execute(_select(User).where(User.email == payload.email))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing is not None and existing.id != current_user.user_id:
|
||||
from app.core.errors import AppError
|
||||
|
||||
raise AppError("conflict", "该邮箱已被其他用户使用。")
|
||||
user = await db.get(User, current_user.user_id)
|
||||
if user is not None:
|
||||
user.email = payload.email
|
||||
await db.commit()
|
||||
return await get_me(current_user, db)
|
||||
|
||||
|
||||
@router.post("/me/change-password", status_code=204)
|
||||
async def change_password(
|
||||
payload: ChangePasswordRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
"""修改当前用户密码。"""
|
||||
from app.models.user import User
|
||||
|
||||
user = await db.get(User, current_user.user_id)
|
||||
if user is None:
|
||||
from app.core.errors import AppError
|
||||
|
||||
raise AppError("not_found", "用户不存在。")
|
||||
if not auth_service.verify_password(payload.old_password, user.password_hash):
|
||||
from app.core.errors import AppError
|
||||
|
||||
raise AppError("unauthorized", "原密码错误。")
|
||||
user.password_hash = auth_service.hash_password(payload.new_password)
|
||||
await db.commit()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""角色头像 API。
|
||||
|
||||
上传与获取角色头像图片文件。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, File, UploadFile
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.services.avatar import (
|
||||
get_avatar_response,
|
||||
upload_avatar,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/characters", tags=["characters"])
|
||||
|
||||
|
||||
@router.post("/{character_id}/avatar")
|
||||
async def upload_character_avatar(
|
||||
character_id: str,
|
||||
file: UploadFile = File(..., description="头像图片,支持 JPG/PNG/WebP,最大 5MB。"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> dict:
|
||||
"""上传角色头像。
|
||||
|
||||
支持的格式:JPEG、PNG、WebP。最大 5MB。
|
||||
新头像会替换旧头像(同角色只保留一张)。
|
||||
"""
|
||||
content = await file.read()
|
||||
avatar_url = await upload_avatar(
|
||||
character_id=character_id,
|
||||
content=content,
|
||||
content_type=file.content_type or "image/jpeg",
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
return {"avatar_url": avatar_url}
|
||||
|
||||
|
||||
@router.get("/{character_id}/avatar")
|
||||
async def get_character_avatar(
|
||||
character_id: str,
|
||||
) -> object:
|
||||
"""获取角色头像图片。
|
||||
|
||||
返回原始图片文件。无头像时返回 404。
|
||||
"""
|
||||
return await get_avatar_response(character_id)
|
||||
@@ -0,0 +1,40 @@
|
||||
"""角色卡导入 / 导出 API。"""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Response, UploadFile
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.character import ImportCardResponse
|
||||
from app.services.card_import import export_character_card, import_character_card_response
|
||||
|
||||
router = APIRouter(prefix="/characters", tags=["characters"])
|
||||
|
||||
|
||||
@router.post("/import-card", response_model=ImportCardResponse)
|
||||
async def import_character_card_api(
|
||||
file: UploadFile = File(..., description="角色卡文件,支持 .png / .json / .charx。"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ImportCardResponse:
|
||||
"""导入酒馆角色卡(V1/V2/V3),建立人物 + 世谱(character_book)。
|
||||
|
||||
``personality`` 写入道体 ``personality_summary`` 作初始值;
|
||||
``character_book`` 转为该角色的世谱书 + 条目。
|
||||
"""
|
||||
raw = await file.read()
|
||||
return await import_character_card_response(raw, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/{character_id}/export-card")
|
||||
async def export_character_card_api(
|
||||
character_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Response:
|
||||
"""导出人物为 ST V3 卡静态快照(.json,丢弃演化维度)。"""
|
||||
card = await export_character_card(character_id, owner_scope=current_user.owner_scope)
|
||||
return Response(
|
||||
content=json.dumps(card, ensure_ascii=False, indent=2),
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": 'attachment; filename="character_card.json"'},
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
"""人物 API。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Query, Request
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.core.errors import AppError
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.character import (
|
||||
ActivateCharacterResponse,
|
||||
CharacterResponse,
|
||||
CharacterSummaryResponse,
|
||||
CharacterTemplateSummaryResponse,
|
||||
CreateCharacterRequest,
|
||||
UpdateModelPolicyRequest,
|
||||
UpdateProfileRequest,
|
||||
)
|
||||
from app.schemas.common import Page
|
||||
from app.services.characters import (
|
||||
activate_character,
|
||||
archive_character,
|
||||
create_character,
|
||||
get_active_character,
|
||||
get_character_by_id,
|
||||
list_characters_page,
|
||||
list_system_templates_page,
|
||||
pause_character,
|
||||
resume_character,
|
||||
update_character_model_policy,
|
||||
update_character_profile,
|
||||
)
|
||||
from app.services.idempotency import run_idempotent
|
||||
|
||||
router = APIRouter(prefix="/characters", tags=["characters"])
|
||||
|
||||
|
||||
@router.get("/active", response_model=CharacterResponse)
|
||||
async def active_character(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CharacterResponse:
|
||||
return await get_active_character(owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/templates", response_model=list[CharacterTemplateSummaryResponse])
|
||||
async def list_templates(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[CharacterTemplateSummaryResponse]:
|
||||
"""列出所有系统模板,仅管理员可见(管理端完整 CRUD 见 ``/admin/characters/templates``)。"""
|
||||
if not current_user.is_admin:
|
||||
raise AppError("permission_denied", "仅管理员可查看系统模板。")
|
||||
return await list_system_templates_page()
|
||||
|
||||
|
||||
@router.get("", response_model=Page[CharacterSummaryResponse])
|
||||
async def list_characters(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
status: str | None = Query(default=None, description="按人物状态过滤。"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Page[CharacterSummaryResponse]:
|
||||
return await list_characters_page(
|
||||
owner_scope=current_user.owner_scope, status=status, page=page, page_size=page_size
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=CharacterResponse)
|
||||
async def create_character_api(
|
||||
payload: CreateCharacterRequest,
|
||||
request: Request,
|
||||
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CharacterResponse:
|
||||
"""创建人物。
|
||||
|
||||
- 传 ``template_key`` 时从系统模板复制(仅管理员可用)。
|
||||
- 不传 ``template_key`` 时创建空白人物(任何用户可用,status=wuji)。
|
||||
"""
|
||||
if payload.template_key and not current_user.is_admin:
|
||||
raise AppError("permission_denied", "仅管理员可从系统模板创建人物。")
|
||||
return await run_idempotent(
|
||||
key=idempotency_key,
|
||||
method="POST",
|
||||
path=str(request.url.path),
|
||||
payload=payload,
|
||||
response_type=CharacterResponse,
|
||||
resource_type="character",
|
||||
handler=lambda session: create_character(
|
||||
session, payload, owner_scope=current_user.owner_scope
|
||||
),
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{character_id}", response_model=CharacterResponse)
|
||||
async def get_character(
|
||||
character_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CharacterResponse:
|
||||
return await get_character_by_id(character_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.patch("/{character_id}/profile", response_model=CharacterResponse)
|
||||
async def update_profile(
|
||||
character_id: str,
|
||||
payload: UpdateProfileRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CharacterResponse:
|
||||
return await update_character_profile(
|
||||
character_id, payload, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{character_id}/model-policy", response_model=CharacterResponse)
|
||||
async def update_model_policy(
|
||||
character_id: str,
|
||||
payload: UpdateModelPolicyRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CharacterResponse:
|
||||
return await update_character_model_policy(
|
||||
character_id, payload, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{character_id}/activate", response_model=ActivateCharacterResponse)
|
||||
async def activate(
|
||||
character_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ActivateCharacterResponse:
|
||||
return await activate_character(character_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.post("/{character_id}/pause", response_model=CharacterResponse)
|
||||
async def pause(
|
||||
character_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CharacterResponse:
|
||||
return await pause_character(character_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.post("/{character_id}/resume", response_model=CharacterResponse)
|
||||
async def resume(
|
||||
character_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CharacterResponse:
|
||||
return await resume_character(character_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.post("/{character_id}/archive", response_model=CharacterResponse)
|
||||
async def archive(
|
||||
character_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> CharacterResponse:
|
||||
return await archive_character(character_id, owner_scope=current_user.owner_scope)
|
||||
@@ -0,0 +1,137 @@
|
||||
"""聊天 API。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Query, Request, Response, status
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.chat import (
|
||||
ChatMessageListResponse,
|
||||
ChatMessageResponse,
|
||||
ChatSessionListResponse,
|
||||
ChatSessionResponse,
|
||||
CreateSessionRequest,
|
||||
SelectVersionRequest,
|
||||
SendMessageRequest,
|
||||
SendMessageResponse,
|
||||
UpdateSessionRequest,
|
||||
)
|
||||
from app.services.chat import (
|
||||
create_session,
|
||||
delete_session,
|
||||
get_session_response,
|
||||
list_message_responses,
|
||||
list_session_responses,
|
||||
select_message_version,
|
||||
send_message,
|
||||
update_session_response,
|
||||
)
|
||||
from app.services.idempotency import run_idempotent
|
||||
|
||||
router = APIRouter(prefix="/chat", tags=["chat"])
|
||||
|
||||
|
||||
@router.post("/sessions", response_model=ChatSessionResponse)
|
||||
async def create_chat_session(
|
||||
payload: CreateSessionRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ChatSessionResponse:
|
||||
return await create_session(payload, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=ChatSessionListResponse)
|
||||
async def list_chat_sessions(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
status: str | None = Query(default=None, description="按状态过滤:active / archived"),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> ChatSessionListResponse:
|
||||
return await list_session_responses(
|
||||
owner_scope=current_user.owner_scope, status=status, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}", response_model=ChatSessionResponse)
|
||||
async def get_chat_session(
|
||||
session_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ChatSessionResponse:
|
||||
return await get_session_response(session_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.patch("/sessions/{session_id}", response_model=ChatSessionResponse)
|
||||
async def update_chat_session(
|
||||
session_id: str,
|
||||
payload: UpdateSessionRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ChatSessionResponse:
|
||||
return await update_session_response(session_id, payload, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.delete("/sessions/{session_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_chat_session(
|
||||
session_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Response:
|
||||
await delete_session(session_id, owner_scope=current_user.owner_scope)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/messages", response_model=SendMessageResponse)
|
||||
async def send_chat_message(
|
||||
session_id: str,
|
||||
payload: SendMessageRequest,
|
||||
request: Request,
|
||||
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> SendMessageResponse:
|
||||
return await run_idempotent(
|
||||
key=idempotency_key,
|
||||
method="POST",
|
||||
path=str(request.url.path),
|
||||
payload=payload,
|
||||
response_type=SendMessageResponse,
|
||||
resource_type="run",
|
||||
handler=lambda session: send_message(
|
||||
session,
|
||||
session_id,
|
||||
payload,
|
||||
owner_scope=current_user.owner_scope,
|
||||
tool_runtime=getattr(request.app.state, "tool_runtime", None),
|
||||
confirmation_registry=getattr(request.app.state, "confirmation_registry", None),
|
||||
confirmation_store=getattr(request.app.state, "confirmation_store", None),
|
||||
event_store=getattr(request.app.state, "event_store", None),
|
||||
agent_run_registry=getattr(request.app.state, "agent_run_registry", None),
|
||||
python_skill_catalog=getattr(request.app.state, "python_skill_catalog", None),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/messages", response_model=ChatMessageListResponse)
|
||||
async def list_chat_messages(
|
||||
session_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
limit: int = Query(default=100, ge=1, le=200),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> ChatMessageListResponse:
|
||||
return await list_message_responses(
|
||||
session_id, owner_scope=current_user.owner_scope, limit=limit, offset=offset
|
||||
)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/sessions/{session_id}/messages/{message_id}/version",
|
||||
response_model=ChatMessageResponse,
|
||||
)
|
||||
async def select_message_version_endpoint(
|
||||
session_id: str,
|
||||
message_id: str,
|
||||
payload: SelectVersionRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ChatMessageResponse:
|
||||
"""选择/激活消息的某个历史版本。"""
|
||||
return await select_message_version(
|
||||
session_id,
|
||||
message_id,
|
||||
payload,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""内驱力 /drive API。"""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.drive import CuriosityResponse, SubgoalResponse
|
||||
from app.services.drive import (
|
||||
approve_subgoal,
|
||||
defer_subgoal,
|
||||
list_curiosities,
|
||||
list_subgoals,
|
||||
reject_subgoal,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["drive"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/curiosities",
|
||||
response_model=Page[CuriosityResponse],
|
||||
)
|
||||
async def list_curiosities_endpoint(
|
||||
character_id: str,
|
||||
source: str | None = Query(default=None, description="按来源过滤(契约字典内驱力 source)"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[CuriosityResponse]:
|
||||
"""列出某人物的好奇问题(大周天内驱力生成)。"""
|
||||
return await list_curiosities(
|
||||
character_id=character_id,
|
||||
source=source,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/subgoals",
|
||||
response_model=Page[SubgoalResponse],
|
||||
)
|
||||
async def list_subgoals_endpoint(
|
||||
character_id: str,
|
||||
status: str | None = Query(
|
||||
default=None, description="按状态过滤:candidate/approved/deferred/rejected"
|
||||
),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[SubgoalResponse]:
|
||||
"""列出某人物的探索性次生目标。"""
|
||||
return await list_subgoals(
|
||||
character_id=character_id,
|
||||
status=status,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/subgoals/{subgoal_id}/approve", response_model=SubgoalResponse)
|
||||
async def approve_subgoal_endpoint(subgoal_id: str) -> SubgoalResponse:
|
||||
"""通过探索性次生目标(仅标记 approved,本期不自动执行)。"""
|
||||
return await approve_subgoal(subgoal_id)
|
||||
|
||||
|
||||
@router.post("/subgoals/{subgoal_id}/defer", response_model=SubgoalResponse)
|
||||
async def defer_subgoal_endpoint(subgoal_id: str) -> SubgoalResponse:
|
||||
"""延后探索性次生目标(status → deferred)。"""
|
||||
return await defer_subgoal(subgoal_id)
|
||||
|
||||
|
||||
@router.post("/subgoals/{subgoal_id}/reject", response_model=SubgoalResponse)
|
||||
async def reject_subgoal_endpoint(subgoal_id: str) -> SubgoalResponse:
|
||||
"""驳回探索性次生目标(status → rejected)。"""
|
||||
return await reject_subgoal(subgoal_id)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""全局一炁事件 SSE 端点(非 run-scoped)。
|
||||
|
||||
供前端 useGlobalEventStream hook 订阅全局广播事件:
|
||||
- ``proactive.session`` —— 叩心枢新建的主动会话
|
||||
- ``proactive.message`` —— 叩心枢追加到已有会话的消息
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.event_store import DbEventStore
|
||||
|
||||
router = APIRouter(prefix="/events", tags=["events"])
|
||||
|
||||
|
||||
def _get_event_store(request: Request) -> DbEventStore:
|
||||
return request.app.state.event_store
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def global_event_stream(
|
||||
request: Request,
|
||||
) -> EventSourceResponse:
|
||||
"""全局事件 SSE 流:通过 EventStore.subscribe_by_pattern 监听所有
|
||||
proactive.* 事件,推送给前端。"""
|
||||
if not settings.enable_sse:
|
||||
from app.core.errors import AppError
|
||||
|
||||
raise AppError("invalid_request", "SSE 当前未启用。")
|
||||
|
||||
event_store = _get_event_store(request)
|
||||
return EventSourceResponse(_event_generator(event_store))
|
||||
|
||||
|
||||
async def _event_generator(
|
||||
event_store: DbEventStore,
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
queue = event_store.subscribe_by_pattern("proactive.*")
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
event = await asyncio.wait_for(queue.get(), timeout=settings.sse_heartbeat_seconds)
|
||||
yield {
|
||||
"id": event.id,
|
||||
"event": event.type,
|
||||
"data": event.model_dump_json(),
|
||||
}
|
||||
except TimeoutError:
|
||||
yield {
|
||||
"event": "heartbeat",
|
||||
"data": '{"type":"heartbeat"}',
|
||||
}
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
event_store.unsubscribe_by_pattern("proactive.*", queue)
|
||||
@@ -0,0 +1,60 @@
|
||||
"""演化候选审核 API。"""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.evolution import EvolutionCandidateResponse
|
||||
from app.services.evolution import (
|
||||
approve_evolution_candidate,
|
||||
list_evolution_candidates,
|
||||
reject_evolution_candidate,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["evolution"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/evolution-candidates",
|
||||
response_model=Page[EvolutionCandidateResponse],
|
||||
)
|
||||
async def list_evolution_candidates_endpoint(
|
||||
character_id: str,
|
||||
status: str | None = Query(
|
||||
default=None, description="按状态过滤:pending/approved/rejected/applied"
|
||||
),
|
||||
candidate_type: str | None = Query(
|
||||
default=None, alias="type", description="按候选类型过滤(契约字典 §12.1)"
|
||||
),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[EvolutionCandidateResponse]:
|
||||
"""列出某人物的演化候选(大周天生成 + 审核状态)。"""
|
||||
return await list_evolution_candidates(
|
||||
character_id=character_id,
|
||||
status=status,
|
||||
candidate_type=candidate_type,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/evolution-candidates/{candidate_id}/approve",
|
||||
response_model=EvolutionCandidateResponse,
|
||||
)
|
||||
async def approve_candidate(
|
||||
candidate_id: str,
|
||||
) -> EvolutionCandidateResponse:
|
||||
"""审核通过演化候选:派生到下游(人格版本/程序记忆等),status → applied。"""
|
||||
return await approve_evolution_candidate(candidate_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/evolution-candidates/{candidate_id}/reject",
|
||||
response_model=EvolutionCandidateResponse,
|
||||
)
|
||||
async def reject_candidate(
|
||||
candidate_id: str,
|
||||
) -> EvolutionCandidateResponse:
|
||||
"""驳回演化候选:status → rejected,不派生下游。"""
|
||||
return await reject_evolution_candidate(candidate_id)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""大周天抽取候选审核 API(记忆候选 + 世界实体候选)。"""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.schemas.candidates import MemoryCandidateResponse
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.world import WorldEntityResponse
|
||||
from app.services.extraction import (
|
||||
approve_memory_proposal,
|
||||
approve_world_entity,
|
||||
list_memory_proposals,
|
||||
list_world_entities_pending,
|
||||
reject_memory_proposal,
|
||||
reject_world_entity,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["extraction"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/memory-candidates",
|
||||
response_model=Page[MemoryCandidateResponse],
|
||||
)
|
||||
async def list_memory_candidates_endpoint(
|
||||
character_id: str,
|
||||
source: str | None = Query(
|
||||
default=None, description="来源过滤:cycle(大周天)/ import(资料导入)"
|
||||
),
|
||||
status: str | None = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[MemoryCandidateResponse]:
|
||||
"""列出记忆候选(大周天 + 资料导入统一)。"""
|
||||
return await list_memory_proposals(
|
||||
character_id=character_id,
|
||||
source=source,
|
||||
status=status,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/memory-candidates/{candidate_id}/approve",
|
||||
response_model=MemoryCandidateResponse,
|
||||
)
|
||||
async def approve_memory_candidate_endpoint(
|
||||
candidate_id: str,
|
||||
) -> MemoryCandidateResponse:
|
||||
"""通过记忆候选:派生为 memories 记录(source=big_cycle)。"""
|
||||
return await approve_memory_proposal(candidate_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/memory-candidates/{candidate_id}/reject",
|
||||
response_model=MemoryCandidateResponse,
|
||||
)
|
||||
async def reject_memory_candidate_endpoint(
|
||||
candidate_id: str,
|
||||
) -> MemoryCandidateResponse:
|
||||
"""驳回记忆候选。"""
|
||||
return await reject_memory_proposal(candidate_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/world/entity-proposals",
|
||||
response_model=Page[WorldEntityResponse],
|
||||
)
|
||||
async def list_world_entity_proposals_endpoint(
|
||||
character_id: str,
|
||||
status: str | None = Query(default="pending", description="默认 pending(候选)"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[WorldEntityResponse]:
|
||||
"""列出世界实体候选(大周天抽取,status=pending 待审核)。"""
|
||||
return await list_world_entities_pending(
|
||||
character_id=character_id, status=status, page=page, page_size=page_size
|
||||
)
|
||||
|
||||
|
||||
@router.post("/world/entities/{entity_id}/approve", response_model=WorldEntityResponse)
|
||||
async def approve_world_entity_endpoint(entity_id: str) -> WorldEntityResponse:
|
||||
"""通过世界实体候选:status → confirmed 进入世界模型。"""
|
||||
return await approve_world_entity(entity_id)
|
||||
|
||||
|
||||
@router.post("/world/entities/{entity_id}/reject", response_model=WorldEntityResponse)
|
||||
async def reject_world_entity_endpoint(entity_id: str) -> WorldEntityResponse:
|
||||
"""驳回世界实体候选。"""
|
||||
return await reject_world_entity(entity_id)
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Per-Character 目标管理端点(非 admin,供 /drive 等前端使用)。"""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.schemas.admin import CreateGoalRequest, GoalNode
|
||||
from app.services import governance as governance_service
|
||||
|
||||
router = APIRouter(prefix="/goals", tags=["goals"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[GoalNode])
|
||||
async def list_goals(
|
||||
character_id: str = Query(..., description="按人物过滤。"),
|
||||
status: str | None = Query(default=None, description="按状态过滤(§5.12)。"),
|
||||
) -> list[GoalNode]:
|
||||
return await governance_service.list_goals_by_character(character_id, status_filter=status)
|
||||
|
||||
|
||||
@router.post("", response_model=GoalNode, status_code=201)
|
||||
async def create_goal(payload: CreateGoalRequest) -> GoalNode:
|
||||
return await governance_service.create_goal(payload)
|
||||
@@ -0,0 +1,12 @@
|
||||
"""健康检查 API。"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> dict[str, str]:
|
||||
return {"status": "ok", "app_name": settings.app_name, "env": settings.app_env}
|
||||
@@ -0,0 +1,224 @@
|
||||
"""资料导入 API(api-spec §8)。
|
||||
|
||||
JSON 提交与文件上传分两个端点(FastAPI 无法优雅地在单端点同时吃 JSON body
|
||||
与 multipart);两者返回结构一致。路径挂在 ``/characters/{character_id}`` 下。
|
||||
候选抽取为异步后台任务:触发即返回 job 状态,前端轮询 ``GET /imports/{id}``
|
||||
(extracting_* → reviewing)与候选列表获取结果。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.candidates import (
|
||||
BatchReviewRequest,
|
||||
BatchReviewResult,
|
||||
MemoryCandidateResponse,
|
||||
ProfileCandidateResponse,
|
||||
)
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.imports import (
|
||||
CreateImportRequest,
|
||||
ImportItemResponse,
|
||||
ImportJobResponse,
|
||||
)
|
||||
from app.services.imports import (
|
||||
batch_review_memory_candidates,
|
||||
batch_review_profile_candidates,
|
||||
create_import_from_json,
|
||||
create_import_from_upload,
|
||||
get_import,
|
||||
list_import_items,
|
||||
list_jobs_page,
|
||||
list_memory_candidates,
|
||||
list_profile_candidates,
|
||||
trigger_memory_extraction,
|
||||
trigger_personality_extraction,
|
||||
trigger_profile_extraction,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["imports"])
|
||||
|
||||
|
||||
@router.post("/characters/{character_id}/imports", response_model=ImportJobResponse)
|
||||
async def create_import_json(
|
||||
character_id: str,
|
||||
payload: CreateImportRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ImportJobResponse:
|
||||
return await create_import_from_json(
|
||||
character_id, payload, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.post("/characters/{character_id}/imports/upload", response_model=ImportJobResponse)
|
||||
async def create_import_upload(
|
||||
character_id: str,
|
||||
file: UploadFile = File(..., description="上传文件(文本或音频)。"),
|
||||
title: str | None = Form(default=None),
|
||||
source_description: str | None = Form(default=None),
|
||||
speaker_label: str | None = Form(default=None, description="音频说话人标签。"),
|
||||
consent_status: str = Form(default="unknown"),
|
||||
usage_scope: str = Form(default="reference_only"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ImportJobResponse:
|
||||
return await create_import_from_upload(
|
||||
character_id,
|
||||
file,
|
||||
title=title,
|
||||
source_description=source_description,
|
||||
speaker_label=speaker_label,
|
||||
consent_status=consent_status,
|
||||
usage_scope=usage_scope,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/characters/{character_id}/imports", response_model=Page[ImportJobResponse])
|
||||
async def list_imports(
|
||||
character_id: str,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Page[ImportJobResponse]:
|
||||
return await list_jobs_page(
|
||||
character_id, page=page, page_size=page_size, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/imports/{import_id}",
|
||||
response_model=ImportJobResponse,
|
||||
)
|
||||
async def read_import(
|
||||
character_id: str,
|
||||
import_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ImportJobResponse:
|
||||
return await get_import(character_id, import_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/imports/{import_id}/items",
|
||||
response_model=Page[ImportItemResponse],
|
||||
)
|
||||
async def read_import_items(
|
||||
character_id: str,
|
||||
import_id: str,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Page[ImportItemResponse]:
|
||||
return await list_import_items(
|
||||
character_id,
|
||||
import_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
# —— 候选抽取(5b,异步后台任务)——
|
||||
|
||||
|
||||
@router.post(
|
||||
"/characters/{character_id}/imports/{import_id}/profile-candidates",
|
||||
response_model=ImportJobResponse,
|
||||
)
|
||||
async def trigger_profile_candidates(
|
||||
character_id: str,
|
||||
import_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ImportJobResponse:
|
||||
return await trigger_profile_extraction(
|
||||
character_id, import_id, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/imports/{import_id}/profile-candidates",
|
||||
response_model=list[ProfileCandidateResponse],
|
||||
)
|
||||
async def list_profile_candidates_api(
|
||||
character_id: str,
|
||||
import_id: str,
|
||||
status: str | None = Query(default=None, description="按审核状态过滤。"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[ProfileCandidateResponse]:
|
||||
return await list_profile_candidates(
|
||||
character_id, import_id, status=status, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/characters/{character_id}/imports/{import_id}/memory-candidates",
|
||||
response_model=ImportJobResponse,
|
||||
)
|
||||
async def trigger_memory_candidates(
|
||||
character_id: str,
|
||||
import_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ImportJobResponse:
|
||||
return await trigger_memory_extraction(
|
||||
character_id, import_id, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/characters/{character_id}/imports/{import_id}/memory-candidates",
|
||||
response_model=list[MemoryCandidateResponse],
|
||||
)
|
||||
async def list_memory_candidates_api(
|
||||
character_id: str,
|
||||
import_id: str,
|
||||
status: str | None = Query(default=None, description="按审核状态过滤。"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[MemoryCandidateResponse]:
|
||||
return await list_memory_candidates(
|
||||
character_id, import_id, status=status, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/characters/{character_id}/imports/{import_id}/personality-proposals",
|
||||
response_model=ImportJobResponse,
|
||||
)
|
||||
async def trigger_personality_proposals(
|
||||
character_id: str,
|
||||
import_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> ImportJobResponse:
|
||||
return await trigger_personality_extraction(
|
||||
character_id, import_id, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
# —— 候选审核应用(5c)——
|
||||
|
||||
|
||||
@router.post(
|
||||
"/characters/{character_id}/profile-candidates/batch-review",
|
||||
response_model=BatchReviewResult,
|
||||
)
|
||||
async def batch_review_profile(
|
||||
character_id: str,
|
||||
payload: BatchReviewRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> BatchReviewResult:
|
||||
return await batch_review_profile_candidates(
|
||||
character_id, payload, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/characters/{character_id}/memory-candidates/batch-review",
|
||||
response_model=BatchReviewResult,
|
||||
)
|
||||
async def batch_review_memory(
|
||||
character_id: str,
|
||||
payload: BatchReviewRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> BatchReviewResult:
|
||||
return await batch_review_memory_candidates(
|
||||
character_id, payload, owner_scope=current_user.owner_scope
|
||||
)
|
||||
@@ -0,0 +1,127 @@
|
||||
"""世谱 API。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Query, Request
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.lorebook import (
|
||||
LorebookCreateRequest,
|
||||
LorebookEntryCreateRequest,
|
||||
LorebookEntryResponse,
|
||||
LorebookEntryUpdateRequest,
|
||||
LorebookResponse,
|
||||
LorebookUpdateRequest,
|
||||
)
|
||||
from app.services.idempotency import run_idempotent
|
||||
from app.services.lorebook import (
|
||||
create_entry,
|
||||
create_lorebook,
|
||||
delete_entry,
|
||||
delete_lorebook,
|
||||
get_lorebook,
|
||||
list_lorebooks,
|
||||
update_entry,
|
||||
update_lorebook,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/lorebooks", tags=["lorebooks"])
|
||||
|
||||
|
||||
@router.post("", response_model=LorebookResponse)
|
||||
async def create_lorebook_api(
|
||||
payload: LorebookCreateRequest,
|
||||
request: Request,
|
||||
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> LorebookResponse:
|
||||
return await run_idempotent(
|
||||
key=idempotency_key,
|
||||
method="POST",
|
||||
path=str(request.url.path),
|
||||
payload=payload,
|
||||
response_type=LorebookResponse,
|
||||
resource_type="lorebook",
|
||||
handler=lambda session: create_lorebook(
|
||||
session, payload, owner_scope=current_user.owner_scope
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[LorebookResponse])
|
||||
async def list_lorebooks_api(
|
||||
character_id: str | None = Query(default=None),
|
||||
scope: str | None = Query(default=None),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[LorebookResponse]:
|
||||
return await list_lorebooks(
|
||||
character_id=character_id,
|
||||
owner_scope=current_user.owner_scope,
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{lorebook_id}", response_model=LorebookResponse)
|
||||
async def get_lorebook_api(
|
||||
lorebook_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> LorebookResponse:
|
||||
return await get_lorebook(lorebook_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.patch("/{lorebook_id}", response_model=LorebookResponse)
|
||||
async def update_lorebook_api(
|
||||
lorebook_id: str,
|
||||
payload: LorebookUpdateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> LorebookResponse:
|
||||
return await update_lorebook(lorebook_id, payload, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.delete("/{lorebook_id}", response_model=LorebookResponse)
|
||||
async def delete_lorebook_api(
|
||||
lorebook_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> LorebookResponse:
|
||||
return await delete_lorebook(lorebook_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.post("/{lorebook_id}/entries", response_model=LorebookEntryResponse)
|
||||
async def create_entry_api(
|
||||
lorebook_id: str,
|
||||
payload: LorebookEntryCreateRequest,
|
||||
request: Request,
|
||||
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> LorebookEntryResponse:
|
||||
return await run_idempotent(
|
||||
key=idempotency_key,
|
||||
method="POST",
|
||||
path=str(request.url.path),
|
||||
payload=payload,
|
||||
response_type=LorebookEntryResponse,
|
||||
resource_type="lorebook_entry",
|
||||
handler=lambda session: create_entry(
|
||||
session, lorebook_id, payload, owner_scope=current_user.owner_scope
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{lorebook_id}/entries/{entry_id}", response_model=LorebookEntryResponse)
|
||||
async def update_entry_api(
|
||||
lorebook_id: str,
|
||||
entry_id: str,
|
||||
payload: LorebookEntryUpdateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> LorebookEntryResponse:
|
||||
return await update_entry(
|
||||
lorebook_id, entry_id, payload, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{lorebook_id}/entries/{entry_id}", status_code=204)
|
||||
async def delete_entry_api(
|
||||
lorebook_id: str,
|
||||
entry_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> None:
|
||||
await delete_entry(lorebook_id, entry_id, owner_scope=current_user.owner_scope)
|
||||
@@ -0,0 +1,173 @@
|
||||
"""MCP server API(/tools/mcp-servers,skills-design §6)。
|
||||
|
||||
支持两种传输协议:streamable-http / stdio。
|
||||
stdio 模式通过 /start、/stop、/restart 控制子进程生命周期。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import status as http_status
|
||||
|
||||
from app.api.v1.deps import get_optional_tool_runtime
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.integrations.mcp.process_manager import get_process_manager
|
||||
from app.schemas.mcp import (
|
||||
CreateMcpServerRequest,
|
||||
McpServerResponse,
|
||||
McpServerStatusResponse,
|
||||
UpdateMcpServerRequest,
|
||||
)
|
||||
from app.services import mcp as mcp_service
|
||||
|
||||
router = APIRouter(prefix="/tools/mcp-servers", tags=["mcp"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=McpServerResponse,
|
||||
status_code=http_status.HTTP_201_CREATED,
|
||||
)
|
||||
async def register_mcp_server(
|
||||
payload: CreateMcpServerRequest,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> McpServerResponse:
|
||||
"""注册 MCP server。
|
||||
|
||||
streamable-http:同步拉取工具清单并注册到运行时。
|
||||
stdio:只存 DB,需后续调用 /start 启动子进程后拉取工具。
|
||||
"""
|
||||
return await mcp_service.register_mcp_server(
|
||||
name=payload.name,
|
||||
transport=payload.transport,
|
||||
endpoint_url=payload.endpoint_url,
|
||||
auth_token=payload.auth_token,
|
||||
command=payload.command,
|
||||
args_json=payload.args_json,
|
||||
env_json=payload.env_json,
|
||||
cwd=payload.cwd,
|
||||
status=payload.status,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[McpServerResponse])
|
||||
async def list_mcp_servers(
|
||||
status: str | None = Query(default=None, description="按状态过滤:enabled/disabled"),
|
||||
) -> list[McpServerResponse]:
|
||||
"""列举 MCP server(含工具清单摘要)。"""
|
||||
return await mcp_service.list_mcp_servers(status=status)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{server_id}",
|
||||
response_model=McpServerResponse,
|
||||
)
|
||||
async def update_mcp_server(
|
||||
server_id: str,
|
||||
payload: UpdateMcpServerRequest,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> McpServerResponse:
|
||||
"""更新 MCP server 字段(部分更新)。"""
|
||||
return await mcp_service.update_mcp_server(
|
||||
server_id,
|
||||
status=payload.status,
|
||||
endpoint_url=payload.endpoint_url,
|
||||
auth_token=payload.auth_token,
|
||||
command=payload.command,
|
||||
args_json=payload.args_json,
|
||||
env_json=payload.env_json,
|
||||
cwd=payload.cwd,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{server_id}", status_code=http_status.HTTP_204_NO_CONTENT)
|
||||
async def delete_mcp_server(
|
||||
server_id: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> None:
|
||||
"""删除 MCP server(先 stop 子进程再删)。"""
|
||||
await mcp_service.delete_mcp_server(
|
||||
server_id,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{server_id}/start",
|
||||
response_model=McpServerStatusResponse,
|
||||
)
|
||||
async def start_mcp_server(
|
||||
server_id: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> McpServerStatusResponse:
|
||||
"""启动 stdio MCP server 子进程,拉取工具并注册。"""
|
||||
return await mcp_service.start_mcp_server(
|
||||
server_id,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{server_id}/stop",
|
||||
response_model=McpServerStatusResponse,
|
||||
)
|
||||
async def stop_mcp_server(
|
||||
server_id: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> McpServerStatusResponse:
|
||||
"""停止 stdio MCP server 子进程,注销工具。"""
|
||||
return await mcp_service.stop_mcp_server(
|
||||
server_id,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{server_id}/restart",
|
||||
response_model=McpServerStatusResponse,
|
||||
)
|
||||
async def restart_mcp_server(
|
||||
server_id: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> McpServerStatusResponse:
|
||||
"""重启 stdio MCP server。"""
|
||||
return await mcp_service.restart_mcp_server(
|
||||
server_id,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{server_id}/status",
|
||||
response_model=McpServerStatusResponse,
|
||||
)
|
||||
async def get_mcp_server_status(
|
||||
server_id: str,
|
||||
) -> McpServerStatusResponse:
|
||||
"""查询 MCP server 进程状态。"""
|
||||
return await mcp_service.get_mcp_server_status(
|
||||
server_id,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{server_id}/refresh",
|
||||
response_model=McpServerResponse,
|
||||
)
|
||||
async def refresh_mcp_server_tools(
|
||||
server_id: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> McpServerResponse:
|
||||
"""重新拉取 MCP server 工具清单并注册到运行时。"""
|
||||
return await mcp_service.refresh_mcp_server_tools(
|
||||
server_id,
|
||||
runtime=runtime,
|
||||
process_manager=get_process_manager(),
|
||||
)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""记忆 API。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, Query, Request
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.memory import MemoryCreateRequest, MemoryResponse, MemoryUpdateRequest
|
||||
from app.services.idempotency import run_idempotent
|
||||
from app.services.memory import create_memory, delete_memory, list_memory_page, update_memory
|
||||
|
||||
router = APIRouter(prefix="/memory", tags=["memory"])
|
||||
|
||||
|
||||
@router.get("", response_model=Page[MemoryResponse])
|
||||
async def list_memory(
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
memory_type: str | None = Query(
|
||||
default=None,
|
||||
alias="type",
|
||||
description="按类型过滤(如 user/assistant/system,契约字典 memory.type)。",
|
||||
),
|
||||
source: str | None = Query(
|
||||
default=None,
|
||||
description="按来源过滤(如 user_explicit/big_cycle/import,契约字典 memory.source)。",
|
||||
),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Page[MemoryResponse]:
|
||||
return await list_memory_page(
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
memory_type=memory_type,
|
||||
source=source,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=MemoryResponse)
|
||||
async def create_memory_api(
|
||||
payload: MemoryCreateRequest,
|
||||
request: Request,
|
||||
idempotency_key: str | None = Header(default=None, alias="Idempotency-Key"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> MemoryResponse:
|
||||
return await run_idempotent(
|
||||
key=idempotency_key,
|
||||
method="POST",
|
||||
path=str(request.url.path),
|
||||
payload=payload,
|
||||
response_type=MemoryResponse,
|
||||
resource_type="memory",
|
||||
handler=lambda session: create_memory(
|
||||
session, payload, owner_scope=current_user.owner_scope
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{memory_id}", response_model=MemoryResponse)
|
||||
async def update_memory_api(
|
||||
memory_id: str,
|
||||
payload: MemoryUpdateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> MemoryResponse:
|
||||
return await update_memory(memory_id, payload, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.delete("/{memory_id}", response_model=MemoryResponse)
|
||||
async def delete_memory_api(
|
||||
memory_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> MemoryResponse:
|
||||
return await delete_memory(memory_id, owner_scope=current_user.owner_scope)
|
||||
@@ -0,0 +1,122 @@
|
||||
"""人格 API。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.personality import (
|
||||
PersonalityProposalResponse,
|
||||
PersonalityResponse,
|
||||
ProposePersonalityUpdateRequest,
|
||||
)
|
||||
from app.services.personality import (
|
||||
approve_personality_proposal,
|
||||
get_current_personality,
|
||||
list_personality_proposals,
|
||||
list_personality_versions,
|
||||
propose_personality_update,
|
||||
reject_personality_proposal,
|
||||
rollback_personality,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/personality", tags=["personality"])
|
||||
|
||||
|
||||
@router.get("/current", response_model=PersonalityResponse)
|
||||
async def current_personality(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PersonalityResponse:
|
||||
return await get_current_personality(owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/versions", response_model=Page[PersonalityResponse])
|
||||
async def personality_versions(
|
||||
character_id: str | None = Query(default=None, description="人物 ID,省略取当前激活人物。"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Page[PersonalityResponse]:
|
||||
return await list_personality_versions(
|
||||
character_id=character_id,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/proposals", response_model=Page[PersonalityProposalResponse])
|
||||
async def personality_proposals(
|
||||
character_id: str | None = Query(default=None, description="人物 ID,省略取当前激活人物。"),
|
||||
status: str | None = Query(
|
||||
default=None, description="按状态过滤:pending / approved / rejected / applied"
|
||||
),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Page[PersonalityProposalResponse]:
|
||||
"""人格修改建议列表(待审审批墙数据源)。"""
|
||||
return await list_personality_proposals(
|
||||
character_id=character_id,
|
||||
status=status,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
# —— 人格修改建议与版本回滚(api-spec §11) ——
|
||||
|
||||
|
||||
@router.post(
|
||||
"/propose-update",
|
||||
response_model=PersonalityProposalResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def propose_personality_update_endpoint(
|
||||
payload: ProposePersonalityUpdateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PersonalityProposalResponse:
|
||||
"""提交人格修改建议;进入 pending 待审核,不直接写入人格版本。"""
|
||||
return await propose_personality_update(
|
||||
character_id=payload.character_id,
|
||||
fields=payload.fields,
|
||||
reason=payload.reason,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/proposals/{proposal_id}/approve",
|
||||
response_model=PersonalityProposalResponse,
|
||||
)
|
||||
async def approve_proposal(
|
||||
proposal_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PersonalityProposalResponse:
|
||||
"""审核通过人格建议:派生新人格版本并切换人物当前指针。"""
|
||||
return await approve_personality_proposal(proposal_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/proposals/{proposal_id}/reject",
|
||||
response_model=PersonalityProposalResponse,
|
||||
)
|
||||
async def reject_proposal(
|
||||
proposal_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PersonalityProposalResponse:
|
||||
"""驳回人格建议:标记 rejected,不生成新版本。"""
|
||||
return await reject_personality_proposal(proposal_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.post("/versions/{version_id}/rollback", response_model=PersonalityResponse)
|
||||
async def rollback_personality_version(
|
||||
version_id: str,
|
||||
character_id: str | None = Query(default=None, description="人物 ID,省略取当前激活人物。"),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> PersonalityResponse:
|
||||
"""回滚到指定人格版本;被取代的旧版本标记 rolled_back_at。"""
|
||||
return await rollback_personality(
|
||||
character_id=character_id, version_id=version_id, owner_scope=current_user.owner_scope
|
||||
)
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Run 与 SSE API。"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Header, Query, Request
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from app.application.agent.confirmation import ConfirmationRegistry
|
||||
from app.application.agent.run_registry import AgentRunRegistry
|
||||
from app.core.config import settings
|
||||
from app.core.errors import AppError
|
||||
from app.core.time import to_iso, utc_now
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.repositories import runtime as runtime_repo
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.runtime import (
|
||||
AgentControlRequest,
|
||||
AgentControlResponse,
|
||||
ConfirmationResolutionResponse,
|
||||
EventResponse,
|
||||
RunResponse,
|
||||
)
|
||||
from app.services.event_bus import bus
|
||||
from app.services.runtime import (
|
||||
cancel_run,
|
||||
confirmation_exists_in_run,
|
||||
get_run_response,
|
||||
list_run_event_responses,
|
||||
list_runs_response,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/runs", tags=["runs"])
|
||||
|
||||
|
||||
@router.get("", response_model=Page[RunResponse])
|
||||
async def list_runs(
|
||||
character_id: str | None = Query(default=None, description="按人物过滤。"),
|
||||
session_id: str | None = Query(default=None, description="按会话过滤。"),
|
||||
status: str | None = Query(default=None, description="按状态过滤。"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[RunResponse]:
|
||||
"""Run 列表(按创建时间倒序);归档页运行时间河数据源。"""
|
||||
return await list_runs_response(
|
||||
character_id=character_id,
|
||||
session_id=session_id,
|
||||
status=status,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{run_id}", response_model=RunResponse)
|
||||
async def get_run(run_id: str) -> RunResponse:
|
||||
return await get_run_response(run_id)
|
||||
|
||||
|
||||
@router.get("/{run_id}/events", response_model=list[EventResponse])
|
||||
async def list_run_events(
|
||||
run_id: str,
|
||||
limit: int = Query(default=200, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
) -> list[EventResponse]:
|
||||
"""Run 的事件时间线(按 sequence 升序)。"""
|
||||
return await list_run_event_responses(run_id, limit=limit, offset=offset)
|
||||
|
||||
|
||||
def _get_agent_run_registry(request: Request) -> AgentRunRegistry:
|
||||
registry: AgentRunRegistry | None = getattr(request.app.state, "agent_run_registry", None)
|
||||
if registry is None:
|
||||
raise AppError("service_unavailable", "Agent 运行控制未启用。", status_code=503)
|
||||
return registry
|
||||
|
||||
|
||||
@router.post("/{run_id}/cancel", response_model=RunResponse)
|
||||
async def cancel_run_api(run_id: str, request: Request) -> RunResponse:
|
||||
registry = getattr(request.app.state, "agent_run_registry", None)
|
||||
if registry is not None:
|
||||
await registry.abort(run_id)
|
||||
return await cancel_run(run_id)
|
||||
|
||||
|
||||
@router.post("/{run_id}/steer", response_model=AgentControlResponse)
|
||||
async def steer_run(
|
||||
run_id: str,
|
||||
payload: AgentControlRequest,
|
||||
request: Request,
|
||||
) -> AgentControlResponse:
|
||||
if not _get_agent_run_registry(request).steer(run_id, payload.content):
|
||||
raise AppError("conflict", "Run 当前不在本进程执行,无法 steer。", status_code=409)
|
||||
return AgentControlResponse()
|
||||
|
||||
|
||||
@router.post("/{run_id}/follow-up", response_model=AgentControlResponse)
|
||||
async def follow_up_run(
|
||||
run_id: str,
|
||||
payload: AgentControlRequest,
|
||||
request: Request,
|
||||
) -> AgentControlResponse:
|
||||
if not _get_agent_run_registry(request).follow_up(run_id, payload.content):
|
||||
raise AppError("conflict", "Run 当前不在本进程执行,无法追加 follow-up。", status_code=409)
|
||||
return AgentControlResponse()
|
||||
|
||||
|
||||
def _get_confirmation_registry(request: Request) -> ConfirmationRegistry:
|
||||
"""从 app.state 取 ConfirmationRegistry;未挂载时 503。"""
|
||||
registry: ConfirmationRegistry | None = getattr(
|
||||
request.app.state, "confirmation_registry", None
|
||||
)
|
||||
if registry is None:
|
||||
raise AppError(
|
||||
"service_unavailable",
|
||||
"确认机制未启用(进程内 Future 注册表未挂载)",
|
||||
status_code=503,
|
||||
)
|
||||
return registry
|
||||
|
||||
|
||||
async def _resolve_confirmation(
|
||||
request: Request,
|
||||
run_id: str,
|
||||
confirmation_id: str,
|
||||
approved: bool,
|
||||
) -> ConfirmationResolutionResponse:
|
||||
"""approve/deny 共用逻辑:通过 ConfirmationRegistry 更新确认状态。"""
|
||||
registry = _get_confirmation_registry(request)
|
||||
if await registry.resolve(run_id, approved, confirmation_id=confirmation_id):
|
||||
return ConfirmationResolutionResponse(approved=approved)
|
||||
|
||||
# 记录已处理/不存在——查 events 表区分 410 与 404
|
||||
event_store = getattr(request.app.state, "event_store", None)
|
||||
if event_store is not None and await confirmation_exists_in_run(
|
||||
event_store, run_id, confirmation_id
|
||||
):
|
||||
raise AppError(
|
||||
"confirmation_expired",
|
||||
"确认已失效(服务可能已重启或已处理),请重新发起对话",
|
||||
status_code=410,
|
||||
)
|
||||
raise AppError("confirmation_not_found", "确认项不存在", status_code=404)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{run_id}/confirmations/{confirmation_id}/approve",
|
||||
response_model=ConfirmationResolutionResponse,
|
||||
)
|
||||
async def approve_confirmation(
|
||||
run_id: str,
|
||||
confirmation_id: str,
|
||||
request: Request,
|
||||
) -> ConfirmationResolutionResponse:
|
||||
"""批准高风险工具执行:触发 Agent 循环恢复(confirmed=True 重试)。"""
|
||||
return await _resolve_confirmation(request, run_id, confirmation_id, approved=True)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{run_id}/confirmations/{confirmation_id}/deny",
|
||||
response_model=ConfirmationResolutionResponse,
|
||||
)
|
||||
async def deny_confirmation(
|
||||
run_id: str,
|
||||
confirmation_id: str,
|
||||
request: Request,
|
||||
) -> ConfirmationResolutionResponse:
|
||||
"""拒绝高风险工具执行:Agent 循环回填拒绝 ToolMessage。"""
|
||||
return await _resolve_confirmation(request, run_id, confirmation_id, approved=False)
|
||||
|
||||
|
||||
@router.get("/{run_id}/stream")
|
||||
async def stream_run(
|
||||
run_id: str,
|
||||
last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
|
||||
) -> EventSourceResponse:
|
||||
"""Run 事件流(SSE):按 run_id 实时订阅事件增量。
|
||||
|
||||
事件通过进程内 ``bus`` 实时推送(``append_event`` 自动 publish),
|
||||
DB 持久化事件供断线重连(``Last-Event-ID`` 回放)。
|
||||
"""
|
||||
if not settings.enable_sse:
|
||||
raise AppError("invalid_request", "SSE 当前未启用。")
|
||||
async with AsyncSessionLocal() as session:
|
||||
run = await runtime_repo.get_run(session, run_id)
|
||||
if run is None:
|
||||
raise AppError("not_found", "Run 不存在。")
|
||||
return EventSourceResponse(_event_generator(run_id, last_event_id))
|
||||
|
||||
|
||||
async def _event_generator(
|
||||
run_id: str, last_event_id: str | None
|
||||
) -> AsyncGenerator[dict[str, Any], None]:
|
||||
# 断线重连:解析 Last-Event-ID 对应的 sequence 作为回放起点
|
||||
after_sequence = 0
|
||||
if last_event_id:
|
||||
async with AsyncSessionLocal() as session:
|
||||
previous_event = await runtime_repo.get_event(session, last_event_id)
|
||||
if previous_event and previous_event.run_id == run_id:
|
||||
after_sequence = previous_event.sequence
|
||||
|
||||
last_sequence = after_sequence
|
||||
# 先订阅再回放,避免回放与订阅之间的事件落入间隙
|
||||
queue = bus.subscribe(run_id)
|
||||
try:
|
||||
for event in bus.list_after(run_id, after_sequence=last_sequence):
|
||||
last_sequence = event.sequence
|
||||
yield {
|
||||
"id": event.event_id,
|
||||
"event": event.type,
|
||||
"data": event.model_dump_json(),
|
||||
}
|
||||
if bus.is_terminal(event):
|
||||
return
|
||||
|
||||
while True:
|
||||
try:
|
||||
event = await asyncio.wait_for(queue.get(), timeout=settings.sse_heartbeat_seconds)
|
||||
except TimeoutError:
|
||||
yield {
|
||||
"event": "heartbeat",
|
||||
"data": json.dumps(
|
||||
{
|
||||
"event_id": "heartbeat",
|
||||
"run_id": run_id,
|
||||
"type": "heartbeat",
|
||||
"sequence": last_sequence,
|
||||
"visibility": "public",
|
||||
"payload": {},
|
||||
"created_at": to_iso(utc_now()),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
}
|
||||
continue
|
||||
if event.sequence <= last_sequence:
|
||||
continue # 回放阶段已转发,去重
|
||||
last_sequence = event.sequence
|
||||
yield {
|
||||
"id": event.event_id,
|
||||
"event": event.type,
|
||||
"data": event.model_dump_json(),
|
||||
}
|
||||
if bus.is_terminal(event):
|
||||
return
|
||||
finally:
|
||||
bus.unsubscribe(run_id, queue)
|
||||
@@ -0,0 +1,162 @@
|
||||
"""安全 API(api-spec §16)。
|
||||
|
||||
audit-events 按人物/枢过滤查询审计时间线;confirmations 的 approve/deny 负责
|
||||
高风险工具确认:通过 ConfirmationRegistry 更新工具确认状态唤醒 Agent 循环。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
|
||||
from app.api.v1.deps import get_confirmation_registry, get_optional_tool_runtime
|
||||
from app.application.agent.confirmation import ConfirmationRegistry
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.schemas.admin import AuditEntry
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.safety import (
|
||||
CreateToolPolicyRequest,
|
||||
SafetyPolicyItem,
|
||||
ToolPolicyResponse,
|
||||
UpdateToolPolicyRequest,
|
||||
)
|
||||
from app.services import audit as audit_service
|
||||
from app.services import governance as governance_service
|
||||
|
||||
router = APIRouter(prefix="/safety", tags=["safety"])
|
||||
|
||||
|
||||
# —— §16 审计事件查询 ——
|
||||
|
||||
|
||||
@router.get("/audit-events", response_model=Page[AuditEntry])
|
||||
async def list_audit_events(
|
||||
character_id: str | None = Query(default=None, description="按人物过滤。"),
|
||||
pivot: str | None = Query(default=None, description="按枢过滤(契约字典 §5.9)。"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=50, ge=1, le=200),
|
||||
) -> Page[AuditEntry]:
|
||||
return await audit_service.query_audit_events(
|
||||
character_id=character_id, pivot=pivot, page=page, page_size=page_size
|
||||
)
|
||||
|
||||
|
||||
# —— §16 工具策略治理 ——
|
||||
|
||||
|
||||
async def _refresh_governor(runtime: ToolRuntime | None) -> None:
|
||||
"""策略写操作后刷新 ToolRuntime 的司命决策器;tool_runtime 未就绪则跳过(重启后从 db 加载)。"""
|
||||
if runtime is not None:
|
||||
await runtime.reload_governor()
|
||||
|
||||
|
||||
@router.get("/policies", response_model=list[SafetyPolicyItem])
|
||||
async def list_safety_policies() -> list[SafetyPolicyItem]:
|
||||
return await governance_service.list_safety_policies()
|
||||
|
||||
|
||||
@router.post("/policies", response_model=ToolPolicyResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_safety_policy(
|
||||
payload: CreateToolPolicyRequest,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> ToolPolicyResponse:
|
||||
response = await governance_service.create_tool_policy(payload)
|
||||
await _refresh_governor(runtime)
|
||||
return response
|
||||
|
||||
|
||||
@router.patch("/policies/{tool}", response_model=ToolPolicyResponse)
|
||||
async def update_safety_policy(
|
||||
tool: str,
|
||||
payload: UpdateToolPolicyRequest,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> ToolPolicyResponse:
|
||||
response = await governance_service.update_tool_policy(tool, payload)
|
||||
await _refresh_governor(runtime)
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/policies/{tool}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_safety_policy(
|
||||
tool: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> Response:
|
||||
await governance_service.delete_tool_policy(tool)
|
||||
await _refresh_governor(runtime)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# —— §16 高风险工具确认 ——
|
||||
|
||||
|
||||
def _not_found(confirmation_id: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=404,
|
||||
detail={
|
||||
"error": {"code": "not_found", "message": f"确认不存在或已被消费: {confirmation_id}"}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _cancelled(confirmation_id: str) -> dict[str, str]:
|
||||
return {"status": "cancelled", "confirmation_id": confirmation_id}
|
||||
|
||||
|
||||
@router.post("/confirmations/{confirmation_id}/approve")
|
||||
async def approve(
|
||||
confirmation_id: str,
|
||||
registry: ConfirmationRegistry = Depends(get_confirmation_registry),
|
||||
) -> dict[str, bool | str]:
|
||||
"""批准工具执行。通过 ConfirmationRegistry 唤醒等待的 Agent 循环。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.safety import PendingConfirmationRecord
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(PendingConfirmationRecord).where(PendingConfirmationRecord.id == confirmation_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None or record.run_id is None:
|
||||
raise _not_found(confirmation_id)
|
||||
|
||||
# 更新 resolution 并唤醒进程内 Event
|
||||
ok = await registry.resolve(
|
||||
record.run_id,
|
||||
approved=True,
|
||||
confirmation_id=confirmation_id,
|
||||
)
|
||||
if not ok:
|
||||
raise _not_found(confirmation_id)
|
||||
|
||||
return {"status": "resolved", "approved": True, "run_id": record.run_id}
|
||||
|
||||
|
||||
@router.post("/confirmations/{confirmation_id}/deny")
|
||||
async def deny(
|
||||
confirmation_id: str,
|
||||
registry: ConfirmationRegistry = Depends(get_confirmation_registry),
|
||||
) -> dict[str, str]:
|
||||
"""拒绝工具执行。通过 ConfirmationRegistry 通知 Agent 循环。"""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.models.safety import PendingConfirmationRecord
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(PendingConfirmationRecord).where(PendingConfirmationRecord.id == confirmation_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None or record.run_id is None:
|
||||
raise _not_found(confirmation_id)
|
||||
|
||||
ok = await registry.resolve(
|
||||
record.run_id,
|
||||
approved=False,
|
||||
confirmation_id=confirmation_id,
|
||||
)
|
||||
if not ok:
|
||||
raise _not_found(confirmation_id)
|
||||
|
||||
return {"status": "cancelled", "confirmation_id": confirmation_id}
|
||||
@@ -0,0 +1,151 @@
|
||||
"""沙箱文件预览 API。
|
||||
|
||||
通过此端点,前端可以读取沙箱中由工具写入的文件内容,实现源码/渲染双模式预览。
|
||||
|
||||
使用场景:
|
||||
- write_file 写入 HTML 文件 → 前端 iframe 渲染预览
|
||||
- write_file 写入 SVG → 前端 img 展示
|
||||
- shell 生成图片/文档 → 前端按 MIME 类型渲染
|
||||
- 任何工具的 stdout 生成的文件
|
||||
|
||||
安全约束:
|
||||
- 用户必须经过 JWT 鉴权
|
||||
- 路径由沙箱端校验(防逃逸)
|
||||
- 可选 run_id/character_id 绑定授权上下文
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import mimetypes
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.api.v1.deps import get_current_user, get_tool_runtime
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.integrations.sandbox.exceptions import SandboxError, SandboxNotFound
|
||||
from app.schemas.auth import CurrentUser
|
||||
|
||||
router = APIRouter(prefix="/sandbox", tags=["sandbox"])
|
||||
|
||||
|
||||
@router.get("/file")
|
||||
async def read_sandbox_file(
|
||||
path: str = Query(
|
||||
...,
|
||||
description="文件路径。isolated=True 时视为工作区相对路径"
|
||||
"(如 write_file 传入的 ``path`` 参数);"
|
||||
"isolated=False 时作为沙箱 base 下的完整相对路径。",
|
||||
),
|
||||
run_id: str | None = Query(
|
||||
None,
|
||||
description="关联的 run_id(可选,写入授权上下文)。仅用于审计链路,不影响文件可读性。",
|
||||
),
|
||||
character_id: str | None = Query(
|
||||
None,
|
||||
description="人物 ID。提供时自动构造隔离工作区路径"
|
||||
"(``workspace/<character_id>/<path>``),"
|
||||
"与 write_file/shell 等工具的 isolated_workspace 路径一致。",
|
||||
),
|
||||
isolated: bool = Query(
|
||||
True,
|
||||
description="是否自动按 isolated_workspace 规则补全路径前缀。"
|
||||
"write_file 写入的文件在 workspace/<character_id>/ 下,"
|
||||
"传递 write_file 的 path 时保持 True 即可。",
|
||||
),
|
||||
runtime: ToolRuntime = Depends(get_tool_runtime),
|
||||
_current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> Response:
|
||||
"""读取沙箱内文件,以原始字节返回(MIME 类型由路径扩展名推导)。
|
||||
|
||||
前端根据 MIME 类型自行决定展示方式:
|
||||
- ``text/html`` → iframe sandbox 渲染预览
|
||||
- ``image/*`` → img 标签展示
|
||||
- ``text/*`` → 源码高亮
|
||||
- ``application/pdf`` → PDF 内嵌展示
|
||||
- 其他 → 下载
|
||||
|
||||
路径安全:沙箱端会对路径做 canonicalize + starts_with(base) 校验,
|
||||
防止目录穿越到沙箱 base 之外。
|
||||
"""
|
||||
# 1) 推导 MIME 类型
|
||||
mime_type, _ = mimetypes.guess_type(path)
|
||||
if mime_type is None:
|
||||
mime_type = "application/octet-stream"
|
||||
|
||||
# 2) 从沙箱读取
|
||||
try:
|
||||
result = await runtime.read_sandbox_file(
|
||||
path=path,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
isolated=isolated,
|
||||
)
|
||||
except SandboxNotFound:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": {"code": "file_not_found", "message": f"文件不存在: {path}"}},
|
||||
) from None
|
||||
except SandboxError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "sandbox_error",
|
||||
"message": f"沙箱文件读取失败: {exc}",
|
||||
}
|
||||
},
|
||||
) from None
|
||||
|
||||
# 3) base64url-no-pad → raw bytes
|
||||
pad_len = -len(result.content_b64) % 4
|
||||
content = base64.urlsafe_b64decode(result.content_b64 + "=" * pad_len)
|
||||
|
||||
return Response(content=content, media_type=mime_type)
|
||||
|
||||
|
||||
@router.get("/files")
|
||||
async def list_sandbox_files(
|
||||
path: str = Query(
|
||||
".",
|
||||
description="目录路径。默认工作区根。",
|
||||
),
|
||||
run_id: str | None = Query(None, description="关联的 run_id(可选)"),
|
||||
character_id: str | None = Query(
|
||||
None,
|
||||
description="人物 ID。提供时自动应用 isolated_workspace 隔离前缀。",
|
||||
),
|
||||
isolated: bool = Query(True, description="是否自动补全隔离前缀"),
|
||||
runtime: ToolRuntime = Depends(get_tool_runtime),
|
||||
_current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[dict[str, object]]:
|
||||
"""列出沙箱工作区目录内容。
|
||||
|
||||
返回有序列表,每个条目含 ``name`` / ``kind``(``"file"`` | ``"dir"``)/ ``size``。
|
||||
适用于前端文件浏览器导航。
|
||||
"""
|
||||
try:
|
||||
entries = await runtime.list_sandbox_dir(
|
||||
path=path,
|
||||
run_id=run_id,
|
||||
character_id=character_id,
|
||||
isolated=isolated,
|
||||
)
|
||||
except SandboxNotFound:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail={"error": {"code": "dir_not_found", "message": f"目录不存在: {path}"}},
|
||||
) from None
|
||||
except SandboxError as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "sandbox_error",
|
||||
"message": f"沙箱目录读取失败: {exc}",
|
||||
}
|
||||
},
|
||||
) from None
|
||||
|
||||
return [{"name": e.name, "kind": e.kind, "size": e.size} for e in entries]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""设置 API(模型网关状态 + 生成偏好)。
|
||||
|
||||
对应前端 settingsApi。模型网关为只读总览,生成偏好可读可写;
|
||||
真实密钥不进入响应。业务逻辑在 services/preferences(与系统配置 core/config/settings 区分)。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.core.errors import AppError
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.preferences import (
|
||||
GenerationPreferencesResponse,
|
||||
ModelEndpointStatus,
|
||||
ModelGatewayStatus,
|
||||
UpdateEndpointOverrideRequest,
|
||||
UpdateGenerationPreferencesRequest,
|
||||
)
|
||||
from app.services.ai_provider_admin import update_endpoint_override
|
||||
from app.services.preferences import (
|
||||
get_generation_preferences,
|
||||
get_provider_gateway,
|
||||
update_generation_preferences,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
|
||||
@router.get("/models", response_model=ModelGatewayStatus)
|
||||
async def read_provider_gateway() -> ModelGatewayStatus:
|
||||
return get_provider_gateway()
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/models/{model}/endpoints/{endpoint}",
|
||||
response_model=ModelEndpointStatus,
|
||||
)
|
||||
async def update_model_endpoint(
|
||||
model: str,
|
||||
endpoint: str,
|
||||
payload: UpdateEndpointOverrideRequest,
|
||||
) -> ModelEndpointStatus:
|
||||
"""更新 endpoint 覆盖(base_url / api_key / status / weight / priority,DB 优先 env)。
|
||||
|
||||
写后热重载 registry(reset_model_router)。响应脱敏,不回 base_url / api_key 原值。
|
||||
"""
|
||||
await update_endpoint_override(model, endpoint, payload)
|
||||
gateway = get_provider_gateway()
|
||||
for m in gateway.models:
|
||||
if m.name != model:
|
||||
continue
|
||||
for ep in m.endpoints or []:
|
||||
if ep.name == endpoint:
|
||||
return ep
|
||||
raise AppError("not_found", "endpoint 不存在。")
|
||||
|
||||
|
||||
@router.get("/preferences", response_model=GenerationPreferencesResponse)
|
||||
async def read_generation_preferences(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> GenerationPreferencesResponse:
|
||||
return await get_generation_preferences(owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.patch("/preferences", response_model=GenerationPreferencesResponse)
|
||||
async def update_generation_preferences_api(
|
||||
payload: UpdateGenerationPreferencesRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> GenerationPreferencesResponse:
|
||||
return await update_generation_preferences(payload, owner_scope=current_user.owner_scope)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Skill 能力包 API(/tools/skills,skills-design §6)。"""
|
||||
|
||||
from fastapi import APIRouter, Query, status
|
||||
|
||||
from app.schemas.skills import (
|
||||
CreateSkillRequest,
|
||||
SkillResponse,
|
||||
UpdateSkillRequest,
|
||||
)
|
||||
from app.services import skills as skills_service
|
||||
|
||||
router = APIRouter(prefix="/tools/skills", tags=["skills"])
|
||||
|
||||
|
||||
@router.post("", response_model=SkillResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_skill(payload: CreateSkillRequest) -> SkillResponse:
|
||||
"""注册 Skill 能力包(name 唯一)。"""
|
||||
return await skills_service.create_skill(
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
trigger=payload.trigger,
|
||||
prompt_fragment=payload.prompt_fragment,
|
||||
tool_bindings=payload.tool_bindings,
|
||||
risk_level=payload.risk_level,
|
||||
status=payload.status,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[SkillResponse])
|
||||
async def list_skills(
|
||||
status: str | None = Query(default=None, description="按状态过滤:enabled/disabled"),
|
||||
) -> list[SkillResponse]:
|
||||
"""列举 Skill(可按 status 过滤)。"""
|
||||
return await skills_service.list_skills(status=status)
|
||||
|
||||
|
||||
@router.patch("/{skill_id}", response_model=SkillResponse)
|
||||
async def update_skill(skill_id: str, payload: UpdateSkillRequest) -> SkillResponse:
|
||||
"""更新 / 启用 / 禁用 Skill。"""
|
||||
return await skills_service.update_skill(
|
||||
skill_id,
|
||||
description=payload.description,
|
||||
trigger=payload.trigger,
|
||||
prompt_fragment=payload.prompt_fragment,
|
||||
tool_bindings=payload.tool_bindings,
|
||||
risk_level=payload.risk_level,
|
||||
status=payload.status,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{skill_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_skill(skill_id: str) -> None:
|
||||
"""删除 Skill。"""
|
||||
await skills_service.delete_skill(skill_id)
|
||||
@@ -0,0 +1,113 @@
|
||||
"""辰正时态 API(设计 §10)。
|
||||
|
||||
挂载于 /api/v1/time。事件不走 REST(走 EventStore 订阅)。路由薄层:参数校验 → 调
|
||||
service → 转响应;session 由 service 自管(deps.py 惯例)。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from app.api.v1.deps import get_current_user, get_event_store
|
||||
from app.core.config import settings
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.time import (
|
||||
AcquaintanceResponse,
|
||||
AnniversaryCreateRequest,
|
||||
AnniversaryResponse,
|
||||
AnniversaryUpdateRequest,
|
||||
SnapshotResponse,
|
||||
TimeConfigResponse,
|
||||
)
|
||||
from app.services.time_sense import (
|
||||
create_anniversary_for_active,
|
||||
delete_anniversary_or_404,
|
||||
get_acquaintance_for_active,
|
||||
get_anniversary_or_404,
|
||||
get_live_snapshot,
|
||||
list_anniversaries_for_active,
|
||||
update_anniversary_or_404,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/time", tags=["time"])
|
||||
|
||||
|
||||
@router.get("/snapshot", response_model=SnapshotResponse)
|
||||
async def get_snapshot(
|
||||
request: Request, current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> SnapshotResponse:
|
||||
"""当前 TemporalSnapshot + ModulationFactors + context_text。"""
|
||||
result = await get_live_snapshot(get_event_store(request), owner_scope=current_user.owner_scope)
|
||||
return SnapshotResponse(
|
||||
snapshot=result.snapshot,
|
||||
factors=result.factors,
|
||||
context_text=result.context_text,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/anniversaries", response_model=list[AnniversaryResponse])
|
||||
async def list_anniversaries_api(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> list[AnniversaryResponse]:
|
||||
items = await list_anniversaries_for_active(owner_scope=current_user.owner_scope)
|
||||
return [AnniversaryResponse.model_validate(a) for a in items]
|
||||
|
||||
|
||||
@router.post("/anniversaries", response_model=AnniversaryResponse, status_code=201)
|
||||
async def create_anniversary_api(
|
||||
payload: AnniversaryCreateRequest, current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> AnniversaryResponse:
|
||||
created = await create_anniversary_for_active(payload, owner_scope=current_user.owner_scope)
|
||||
return AnniversaryResponse.model_validate(created)
|
||||
|
||||
|
||||
@router.get("/anniversaries/{anniv_id}", response_model=AnniversaryResponse)
|
||||
async def get_anniversary_api(
|
||||
anniv_id: str, current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> AnniversaryResponse:
|
||||
return AnniversaryResponse.model_validate(
|
||||
await get_anniversary_or_404(anniv_id, owner_scope=current_user.owner_scope)
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/anniversaries/{anniv_id}", response_model=AnniversaryResponse)
|
||||
async def update_anniversary_api(
|
||||
anniv_id: str,
|
||||
payload: AnniversaryUpdateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> AnniversaryResponse:
|
||||
return AnniversaryResponse.model_validate(
|
||||
await update_anniversary_or_404(anniv_id, payload, owner_scope=current_user.owner_scope)
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/anniversaries/{anniv_id}", status_code=204)
|
||||
async def delete_anniversary_api(
|
||||
anniv_id: str, current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> None:
|
||||
await delete_anniversary_or_404(anniv_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/acquaintance", response_model=AcquaintanceResponse)
|
||||
async def get_acquaintance_api(
|
||||
request: Request, current_user: CurrentUser = Depends(get_current_user)
|
||||
) -> AcquaintanceResponse:
|
||||
acq = await get_acquaintance_for_active(
|
||||
get_event_store(request), owner_scope=current_user.owner_scope
|
||||
)
|
||||
return AcquaintanceResponse(total_days=acq.total_days, natural_language=acq.natural_language)
|
||||
|
||||
|
||||
@router.get("/config", response_model=TimeConfigResponse)
|
||||
async def get_time_config_api(
|
||||
current_user: CurrentUser = Depends(get_current_user), # noqa: ARG001
|
||||
) -> TimeConfigResponse:
|
||||
return TimeConfigResponse(
|
||||
user_timezone=settings.user_timezone,
|
||||
home_timezone=settings.home_timezone,
|
||||
default_city=settings.default_city,
|
||||
offline_active_minutes=settings.offline_active_minutes,
|
||||
offline_short_absence_minutes=settings.offline_short_absence_minutes,
|
||||
offline_long_absence_minutes=settings.offline_long_absence_minutes,
|
||||
offline_reunion_impact_minutes=settings.offline_reunion_impact_minutes,
|
||||
care_enabled=settings.care_enabled,
|
||||
jetlag_threshold_minutes=settings.jetlag_threshold_minutes,
|
||||
)
|
||||
@@ -0,0 +1,252 @@
|
||||
"""工具调用 API(api-spec §15)。
|
||||
|
||||
- GET /tools:列出已注册工具策略元数据(从 tool_policies 表,含 executable 派生)。
|
||||
- POST /tools:注册自定义工具策略(百工枢器用治理声明,记审计)。
|
||||
- GET /tools/calls/{tool_call_id}:单次工具调用事件详情。
|
||||
- PATCH /tools/{tool_name}:编辑工具策略元数据(spec §15: risk_level 等)。
|
||||
- DELETE /tools/{tool_name}:注销自定义工具(builtin 不可删)。
|
||||
- POST /tools/{tool_name}/invoke:调用工具;高风险返回 requires_confirmation + confirmation_id,
|
||||
交由 /safety/confirmations/{id}/approve 消费。
|
||||
- POST /tools/{tool_name}/invoke/stream:流式调用(SSE),实时推送 tool.stdout/tool.stderr
|
||||
增量与 tool.completed;司命 deny/ask-未确认/沙箱错误分别以 tool.failed / tool.requires_confirmation 结束流。
|
||||
|
||||
策略写操作(POST/PATCH/DELETE)后刷新 ToolRuntime 的司命决策器,使变更即时生效;
|
||||
tool_runtime 未就绪时仅写库(重启后从 db 加载)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Response, status
|
||||
from pydantic import BaseModel
|
||||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from app.api.v1.deps import get_confirmation_store, get_optional_tool_runtime, get_tool_runtime
|
||||
from app.application.tool_runtime.runtime import ToolRuntime
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.integrations.sandbox.exceptions import SandboxError
|
||||
from app.models.safety import PendingConfirmationRecord
|
||||
from app.modules.safety.confirmation import (
|
||||
ConfirmationStore,
|
||||
PendingConfirmation,
|
||||
new_confirmation_id,
|
||||
)
|
||||
from app.modules.safety.exceptions import PermissionDenied, RequiresConfirmation
|
||||
from app.schemas.safety import (
|
||||
CreateToolPolicyRequest,
|
||||
ToolCallDetail,
|
||||
ToolPolicyResponse,
|
||||
UpdateToolPolicyRequest,
|
||||
)
|
||||
from app.schemas.skills import ToolCapability
|
||||
from app.services import governance as governance_service
|
||||
from app.services import skills as skills_service
|
||||
|
||||
router = APIRouter(prefix="/tools", tags=["tools"])
|
||||
|
||||
|
||||
class InvokeRequest(BaseModel):
|
||||
input: dict[str, Any]
|
||||
run_id: str | None = None
|
||||
character_id: str | None = None
|
||||
trace_id: str | None = None
|
||||
confirmed: bool = False
|
||||
|
||||
|
||||
class ToolResultPayload(BaseModel):
|
||||
ok: bool
|
||||
exit_code: int | None = None
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
duration_ms: int = 0
|
||||
timeout: bool = False
|
||||
truncated: bool = False
|
||||
trace_id: str = ""
|
||||
|
||||
|
||||
class CompletedResponse(BaseModel):
|
||||
status: str = "completed"
|
||||
result: ToolResultPayload
|
||||
|
||||
|
||||
class RequiresConfirmationResponse(BaseModel):
|
||||
status: str = "requires_confirmation"
|
||||
confirmation_id: str
|
||||
risk_level: str
|
||||
reason: str
|
||||
|
||||
|
||||
async def _sync_confirmation_record(pending: PendingConfirmation) -> None:
|
||||
"""把 InMemoryConfirmationStore 的待确认记录同步持久化到 DB。"""
|
||||
async with AsyncSessionLocal() as session:
|
||||
record = PendingConfirmationRecord(
|
||||
id=pending.confirmation_id,
|
||||
tool=pending.tool,
|
||||
input_json=json.dumps(pending.input) if pending.input else "{}",
|
||||
risk_level=pending.risk_level,
|
||||
reason=pending.reason or "",
|
||||
run_id=pending.run_id,
|
||||
character_id=pending.character_id,
|
||||
trace_id=pending.trace_id,
|
||||
status="pending",
|
||||
)
|
||||
session.add(record)
|
||||
await session.commit()
|
||||
|
||||
|
||||
# —— §15 工具策略治理 ——
|
||||
|
||||
|
||||
@router.get("", response_model=list[ToolPolicyResponse])
|
||||
async def list_tools() -> list[ToolPolicyResponse]:
|
||||
return await governance_service.list_tool_policies()
|
||||
|
||||
|
||||
@router.get("/capabilities", response_model=list[ToolCapability])
|
||||
async def list_capabilities(
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> list[ToolCapability]:
|
||||
"""统一列举所有能力(builtin / mcp / skill,含来源标记,skills-design §6)。"""
|
||||
return await skills_service.list_all_capabilities(runtime)
|
||||
|
||||
|
||||
@router.post("", response_model=ToolPolicyResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def register_tool(
|
||||
payload: CreateToolPolicyRequest,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> ToolPolicyResponse:
|
||||
response = await governance_service.create_tool_policy(payload)
|
||||
if runtime is not None:
|
||||
await runtime.reload_governor()
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/calls/{tool_call_id}", response_model=ToolCallDetail)
|
||||
async def get_tool_call(tool_call_id: str) -> ToolCallDetail:
|
||||
return await governance_service.get_tool_call(tool_call_id)
|
||||
|
||||
|
||||
@router.patch("/{tool_name}", response_model=ToolPolicyResponse)
|
||||
async def update_tool(
|
||||
tool_name: str,
|
||||
payload: UpdateToolPolicyRequest,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> ToolPolicyResponse:
|
||||
response = await governance_service.update_tool_policy(tool_name, payload)
|
||||
if runtime is not None:
|
||||
await runtime.reload_governor()
|
||||
return response
|
||||
|
||||
|
||||
@router.delete("/{tool_name}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_tool(
|
||||
tool_name: str,
|
||||
runtime: ToolRuntime | None = Depends(get_optional_tool_runtime),
|
||||
) -> Response:
|
||||
await governance_service.delete_tool_policy(tool_name)
|
||||
if runtime is not None:
|
||||
await runtime.reload_governor()
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
# —— §15 工具调用 ——
|
||||
|
||||
|
||||
@router.post("/{tool_name}/invoke", response_model=None)
|
||||
async def invoke_tool(
|
||||
tool_name: str,
|
||||
body: InvokeRequest,
|
||||
runtime: ToolRuntime = Depends(get_tool_runtime),
|
||||
confirmations: ConfirmationStore = Depends(get_confirmation_store),
|
||||
) -> CompletedResponse | RequiresConfirmationResponse:
|
||||
try:
|
||||
result = await runtime.execute(
|
||||
tool_name,
|
||||
body.input,
|
||||
run_id=body.run_id,
|
||||
character_id=body.character_id,
|
||||
trace_id=body.trace_id,
|
||||
confirmed=body.confirmed,
|
||||
)
|
||||
except RequiresConfirmation as exc:
|
||||
pending = await confirmations.create(
|
||||
PendingConfirmation(
|
||||
confirmation_id=new_confirmation_id(),
|
||||
tool=tool_name,
|
||||
input=body.input,
|
||||
risk_level=exc.risk_level or "L2",
|
||||
reason=exc.message,
|
||||
run_id=body.run_id,
|
||||
character_id=body.character_id,
|
||||
trace_id=body.trace_id,
|
||||
)
|
||||
)
|
||||
# 同步持久化 PendingConfirmationRecord(供 safety/confirmations 新端点查找)
|
||||
await _sync_confirmation_record(pending)
|
||||
return RequiresConfirmationResponse(
|
||||
confirmation_id=pending.confirmation_id,
|
||||
risk_level=pending.risk_level,
|
||||
reason=pending.reason,
|
||||
)
|
||||
|
||||
return CompletedResponse(
|
||||
result=ToolResultPayload(
|
||||
ok=result.ok,
|
||||
exit_code=result.exit_code,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
duration_ms=result.duration_ms,
|
||||
timeout=result.timeout,
|
||||
truncated=result.truncated,
|
||||
trace_id=result.trace_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{tool_name}/invoke/stream")
|
||||
async def invoke_tool_stream(
|
||||
tool_name: str,
|
||||
body: InvokeRequest,
|
||||
runtime: ToolRuntime = Depends(get_tool_runtime),
|
||||
) -> EventSourceResponse:
|
||||
"""流式调用工具(SSE)。
|
||||
|
||||
事件:``tool.stdout`` / ``tool.stderr``(data 为 ``{"data":"<base64>"}``,前端解码)、
|
||||
``tool.completed``(data 为结束汇总 JSON)。司命 deny → ``tool.failed``;
|
||||
ask 且未确认 → ``tool.requires_confirmation``(前端应先走非流式 invoke 完成 confirmation,
|
||||
再以 confirmed=True 调用本端点);沙箱错误 → ``tool.failed``。
|
||||
"""
|
||||
|
||||
async def event_gen():
|
||||
try:
|
||||
async for ev in runtime.execute_stream(
|
||||
tool_name,
|
||||
body.input,
|
||||
run_id=body.run_id,
|
||||
character_id=body.character_id,
|
||||
trace_id=body.trace_id,
|
||||
confirmed=body.confirmed,
|
||||
):
|
||||
if ev.kind in ("stdout", "stderr"):
|
||||
yield {"event": f"tool.{ev.kind}", "data": json.dumps({"data": ev.payload})}
|
||||
elif ev.kind == "done":
|
||||
yield {"event": "tool.completed", "data": json.dumps(ev.payload)}
|
||||
except RequiresConfirmation as exc:
|
||||
yield {
|
||||
"event": "tool.requires_confirmation",
|
||||
"data": json.dumps({"risk_level": exc.risk_level, "reason": exc.message}),
|
||||
}
|
||||
except PermissionDenied as exc:
|
||||
yield {
|
||||
"event": "tool.failed",
|
||||
"data": json.dumps({"error_code": "permission_denied", "error_message": str(exc)}),
|
||||
}
|
||||
except SandboxError as exc:
|
||||
yield {
|
||||
"event": "tool.failed",
|
||||
"data": json.dumps({"error_code": exc.code, "error_message": str(exc)}),
|
||||
}
|
||||
|
||||
return EventSourceResponse(event_gen())
|
||||
@@ -0,0 +1,95 @@
|
||||
"""世界模型 API(河图枢运行时,api-spec §12)。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.api.v1.deps import get_current_user
|
||||
from app.schemas.auth import CurrentUser
|
||||
from app.schemas.common import Page
|
||||
from app.schemas.world import (
|
||||
WorldEntityCreateRequest,
|
||||
WorldEntityResponse,
|
||||
WorldGraphResponse,
|
||||
WorldRelationCreateRequest,
|
||||
WorldRelationResponse,
|
||||
)
|
||||
from app.services.world import (
|
||||
create_entity,
|
||||
create_relation,
|
||||
delete_relation,
|
||||
get_entity_by_id,
|
||||
get_graph,
|
||||
list_entities_page,
|
||||
list_relations_page,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/world", tags=["world"])
|
||||
|
||||
|
||||
@router.get("/entities", response_model=Page[WorldEntityResponse])
|
||||
async def list_entities(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
character_id: str | None = Query(default=None, description="所属人物;省略取当前激活。"),
|
||||
type: str | None = Query(default=None, description="按实体类型过滤(契约字典 §11.1)。"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[WorldEntityResponse]:
|
||||
return await list_entities_page(
|
||||
character_id,
|
||||
entity_type=type,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
owner_scope=current_user.owner_scope,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/entities", response_model=WorldEntityResponse)
|
||||
async def create_entity_api(
|
||||
payload: WorldEntityCreateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> WorldEntityResponse:
|
||||
return await create_entity(payload, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/entities/{entity_id}", response_model=WorldEntityResponse)
|
||||
async def get_entity(
|
||||
entity_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> WorldEntityResponse:
|
||||
return await get_entity_by_id(entity_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/relations", response_model=Page[WorldRelationResponse])
|
||||
async def list_relations(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
character_id: str | None = Query(default=None, description="所属人物;省略取当前激活。"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
) -> Page[WorldRelationResponse]:
|
||||
return await list_relations_page(
|
||||
character_id, page=page, page_size=page_size, owner_scope=current_user.owner_scope
|
||||
)
|
||||
|
||||
|
||||
@router.post("/relations", response_model=WorldRelationResponse)
|
||||
async def create_relation_api(
|
||||
payload: WorldRelationCreateRequest,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> WorldRelationResponse:
|
||||
return await create_relation(payload, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.delete("/relations/{relation_id}", status_code=204)
|
||||
async def delete_relation_api(
|
||||
relation_id: str,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
) -> None:
|
||||
await delete_relation(relation_id, owner_scope=current_user.owner_scope)
|
||||
|
||||
|
||||
@router.get("/graph", response_model=WorldGraphResponse)
|
||||
async def world_graph(
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
character_id: str | None = Query(default=None, description="所属人物;省略取当前激活。"),
|
||||
) -> WorldGraphResponse:
|
||||
"""返回人物的完整世界图谱(无分页),供前端 D3/Force 图谱渲染。"""
|
||||
return await get_graph(character_id, owner_scope=current_user.owner_scope)
|
||||
Reference in new issue
Block a user