初始化提交

This commit is contained in:
chenxin committed 2026-08-21 16:00:13 +08:00
commit 887c1f449e
963 files changed
+166869

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
"""v1 API 路由端点。
路由实现统一放在 ``endpoints/`` 子目录,``deps.py`` 留在父级 ``api/v1/`` 提供依赖注入。
每个端点模块只做 参数校验 → 调用 service/usecase → 转响应,不直接持有 ``AsyncSession``。
"""
from app.api.v1.endpoints import auth
__all__ = ["auth"]
+534
View File
@@ -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)
+275
View File
@@ -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)
+207
View File
@@ -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()
+47
View File
@@ -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"'},
)
+152
View File
@@ -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)
+137
View File
@@ -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,
)
+73
View File
@@ -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)
+63
View File
@@ -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)
+60
View File
@@ -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)
+21
View File
@@ -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)
+12
View File
@@ -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}
+224
View File
@@ -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
)
+127
View File
@@ -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)
+173
View File
@@ -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(),
)
+73
View File
@@ -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)
+122
View File
@@ -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
)
+247
View File
@@ -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)
+162
View File
@@ -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]
+70
View File
@@ -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)
+54
View File
@@ -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)
+113
View File
@@ -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,
)
+252
View File
@@ -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())
+95
View File
@@ -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)