Initial lightweight AI agent admin

This commit is contained in:
Hermes Agent
2026-06-08 15:05:57 +08:00
commit 0e485492cf
49 changed files with 4520 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
import json
import time
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.crud import crud_router
from app.api.deps import get_current_user
from app.core.database import get_db
from app.models import (
Agent,
AgentConversation,
AgentMessage,
AgentTeam,
CollaborationRun,
KnowledgeBase,
LLMModel,
LLMProvider,
Workflow,
WorkflowRun,
)
from app.schemas.ai import (
AgentBase,
AgentOut,
ChatIn,
CollaborationRunIn,
CollaborationRunOut,
ConversationOut,
KnowledgeBaseIn,
KnowledgeBaseOut,
ModelBase,
ModelOut,
ProviderBase,
ProviderOut,
TeamBase,
TeamOut,
WorkflowBase,
WorkflowOut,
WorkflowRunIn,
WorkflowRunOut,
)
from app.schemas.common import Page, ResponseModel
from app.services.collaboration import CollaborationService
from app.services.llm import LLMService, sse
from app.services.workflow import WorkflowService
router = APIRouter(prefix="/ai", tags=["AI 平台"], dependencies=[Depends(get_current_user)])
router.include_router(
crud_router(prefix="/providers", tags=["Provider"], model=LLMProvider, create_schema=ProviderBase, update_schema=ProviderBase, out_schema=ProviderOut)
)
router.include_router(
crud_router(prefix="/models", tags=["Model"], model=LLMModel, create_schema=ModelBase, update_schema=ModelBase, out_schema=ModelOut)
)
router.include_router(
crud_router(prefix="/agents", tags=["Agent"], model=Agent, create_schema=AgentBase, update_schema=AgentBase, out_schema=AgentOut)
)
router.include_router(
crud_router(prefix="/workflows", tags=["Workflow"], model=Workflow, create_schema=WorkflowBase, update_schema=WorkflowBase, out_schema=WorkflowOut)
)
router.include_router(
crud_router(prefix="/knowledge-bases", tags=["Knowledge"], model=KnowledgeBase, create_schema=KnowledgeBaseIn, update_schema=KnowledgeBaseIn, out_schema=KnowledgeBaseOut)
)
router.include_router(
crud_router(prefix="/teams", tags=["Agent Team"], model=AgentTeam, create_schema=TeamBase, update_schema=TeamBase, out_schema=TeamOut)
)
@router.post("/agents/{agent_id}/chat")
async def chat(agent_id: str, payload: ChatIn, db: AsyncSession = Depends(get_db)):
agent = await db.get(Agent, agent_id)
if not agent or agent.is_deleted:
raise HTTPException(status_code=404, detail="智能体不存在")
conversation = None
if payload.conversation_id:
conversation = await db.get(AgentConversation, payload.conversation_id)
if not conversation:
conversation = AgentConversation(agent_id=agent.id, title=payload.message[:60] or "新建对话")
db.add(conversation)
await db.flush()
user_msg = AgentMessage(conversation_id=conversation.id, role="user", content=payload.message)
assistant_msg = AgentMessage(conversation_id=conversation.id, role="assistant", content="", status="pending")
db.add_all([user_msg, assistant_msg])
await db.flush()
async def generate():
started = time.time()
yield sse({"type": "start", "conversation_id": conversation.id, "message_id": assistant_msg.id})
chunks = []
service = LLMService(db)
async for chunk in service.stream(agent, payload.message):
chunks.append(chunk)
yield sse({"type": "chunk", "content": chunk})
assistant_msg.content = "".join(chunks)
assistant_msg.status = "completed"
assistant_msg.elapsed_time = int((time.time() - started) * 1000)
conversation.total_tokens = conversation.total_tokens + len(assistant_msg.content)
await db.commit()
yield sse({"type": "complete", "conversation_id": conversation.id, "message_id": assistant_msg.id})
yield sse("[DONE]")
return StreamingResponse(generate(), media_type="text/event-stream")
@router.get("/agents/{agent_id}/conversations", response_model=Page[ConversationOut])
async def list_conversations(agent_id: str, page: int = 1, page_size: int = 20, db: AsyncSession = Depends(get_db)):
query = select(AgentConversation).where(AgentConversation.agent_id == agent_id, AgentConversation.is_deleted == False)
result = await db.execute(query.order_by(AgentConversation.created_at.desc()).offset((page - 1) * page_size).limit(page_size))
total = len(result.scalars().all())
result = await db.execute(query.order_by(AgentConversation.created_at.desc()).offset((page - 1) * page_size).limit(page_size))
return Page(items=result.scalars().all(), total=total, page=page, page_size=page_size)
@router.get("/conversations/{conversation_id}/messages")
async def list_messages(conversation_id: str, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(AgentMessage)
.where(AgentMessage.conversation_id == conversation_id, AgentMessage.is_deleted == False)
.order_by(AgentMessage.created_at.asc())
)
return result.scalars().all()
@router.post("/workflows/{workflow_id}/publish", response_model=WorkflowOut)
async def publish_workflow(workflow_id: str, db: AsyncSession = Depends(get_db)):
workflow = await db.get(Workflow, workflow_id)
if not workflow or workflow.is_deleted:
raise HTTPException(status_code=404, detail="工作流不存在")
workflow.status = "published"
workflow.published_version = workflow.version
workflow.published_definition = workflow.definition
await db.commit()
await db.refresh(workflow)
return workflow
@router.post("/workflows/{workflow_id}/run", response_model=WorkflowRunOut)
async def run_workflow(workflow_id: str, payload: WorkflowRunIn, db: AsyncSession = Depends(get_db)):
workflow = await db.get(Workflow, workflow_id)
if not workflow or workflow.is_deleted:
raise HTTPException(status_code=404, detail="工作流不存在")
return await WorkflowService(db).run(workflow, payload.inputs)
@router.get("/workflow-runs", response_model=Page[WorkflowRunOut])
async def list_workflow_runs(page: int = 1, page_size: int = 20, db: AsyncSession = Depends(get_db)):
query = select(WorkflowRun).where(WorkflowRun.is_deleted == False)
result = await db.execute(query.order_by(WorkflowRun.created_at.desc()).offset((page - 1) * page_size).limit(page_size))
items = result.scalars().all()
return Page(items=items, total=len(items), page=page, page_size=page_size)
@router.post("/teams/{team_id}/run", response_model=CollaborationRunOut)
async def run_team(team_id: str, payload: CollaborationRunIn, db: AsyncSession = Depends(get_db)):
team = await db.get(AgentTeam, team_id)
if not team or team.is_deleted:
raise HTTPException(status_code=404, detail="团队不存在")
return await CollaborationService(db).run(team, payload.task)
@router.get("/collaboration-runs", response_model=Page[CollaborationRunOut])
async def list_collaboration_runs(page: int = 1, page_size: int = 20, db: AsyncSession = Depends(get_db)):
query = select(CollaborationRun).where(CollaborationRun.is_deleted == False)
result = await db.execute(query.order_by(CollaborationRun.created_at.desc()).offset((page - 1) * page_size).limit(page_size))
items = result.scalars().all()
return Page(items=items, total=len(items), page=page, page_size=page_size)
+33
View File
@@ -0,0 +1,33 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.database import get_db
from app.core.security import create_access_token, hash_password, verify_password
from app.models import User
from app.schemas.core import LoginIn, TokenOut, UserCreate, UserOut, UserUpdate
from app.schemas.common import Page
router = APIRouter(prefix="/auth", tags=["认证"])
@router.post("/login", response_model=TokenOut)
async def login(payload: LoginIn, db: AsyncSession = Depends(get_db)):
result = await db.execute(
select(User).where(
or_(User.username == payload.username, User.email == payload.username),
User.is_deleted == False,
)
)
user = result.scalar_one_or_none()
if not user or not verify_password(payload.password, user.password_hash):
raise HTTPException(status_code=400, detail="用户名或密码错误")
if user.status != "enabled":
raise HTTPException(status_code=403, detail="用户已禁用")
return TokenOut(access_token=create_access_token(user.id, {"username": user.username}))
@router.get("/me", response_model=UserOut)
async def me(user: User = Depends(get_current_user)):
return user
+107
View File
@@ -0,0 +1,107 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.crud import crud_router
from app.api.deps import get_current_user
from app.core.database import get_db
from app.core.security import hash_password
from app.models import Announcement, Menu, Permission, Role, User
from app.schemas.common import Page, ResponseModel
from app.schemas.core import (
AnnouncementBase,
AnnouncementOut,
MenuBase,
MenuOut,
PermissionBase,
PermissionOut,
RoleBase,
RoleOut,
UserCreate,
UserOut,
UserUpdate,
)
router = APIRouter(prefix="/core", tags=["基础管理"], dependencies=[Depends(get_current_user)])
@router.get("/users", response_model=Page[UserOut])
async def list_users(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
keyword: str | None = None,
db: AsyncSession = Depends(get_db),
):
conditions = [User.is_deleted == False]
if keyword:
conditions.append((User.username.ilike(f"%{keyword}%")) | (User.email.ilike(f"%{keyword}%")))
query = select(User).where(*conditions)
total = await db.scalar(select(func.count()).select_from(query.subquery()))
result = await db.execute(query.order_by(User.created_at.desc()).offset((page - 1) * page_size).limit(page_size))
return Page(items=result.scalars().all(), total=total or 0, page=page, page_size=page_size)
@router.post("/users", response_model=UserOut)
async def create_user(payload: UserCreate, db: AsyncSession = Depends(get_db)):
exists = await db.scalar(select(User.id).where((User.username == payload.username) | (User.email == payload.email)))
if exists:
raise HTTPException(status_code=400, detail="用户已存在")
user = User(**payload.model_dump(exclude={"password"}), password_hash=hash_password(payload.password))
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.put("/users/{user_id}", response_model=UserOut)
async def update_user(user_id: str, payload: UserUpdate, db: AsyncSession = Depends(get_db)):
user = await db.get(User, user_id)
if not user or user.is_deleted:
raise HTTPException(status_code=404, detail="用户不存在")
data = payload.model_dump(exclude_unset=True)
password = data.pop("password", None)
for key, value in data.items():
setattr(user, key, value)
if password:
user.password_hash = hash_password(password)
await db.commit()
await db.refresh(user)
return user
@router.delete("/users/{user_id}", response_model=ResponseModel)
async def delete_user(user_id: str, db: AsyncSession = Depends(get_db)):
user = await db.get(User, user_id)
if not user or user.is_deleted:
raise HTTPException(status_code=404, detail="用户不存在")
user.is_deleted = True
await db.commit()
return ResponseModel(message="deleted")
router.include_router(
crud_router(prefix="/roles", tags=["角色"], model=Role, create_schema=RoleBase, update_schema=RoleBase, out_schema=RoleOut)
)
router.include_router(
crud_router(
prefix="/permissions",
tags=["权限"],
model=Permission,
create_schema=PermissionBase,
update_schema=PermissionBase,
out_schema=PermissionOut,
)
)
router.include_router(
crud_router(prefix="/menus", tags=["菜单"], model=Menu, create_schema=MenuBase, update_schema=MenuBase, out_schema=MenuOut)
)
router.include_router(
crud_router(
prefix="/announcements",
tags=["公告"],
model=Announcement,
create_schema=AnnouncementBase,
update_schema=AnnouncementBase,
out_schema=AnnouncementOut,
)
)
+79
View File
@@ -0,0 +1,79 @@
from typing import Any, Type
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.database import get_db
from app.models import User
from app.schemas.common import Page, ResponseModel
def crud_router(
*,
prefix: str,
tags: list[str],
model: Type,
create_schema: Type[BaseModel],
update_schema: Type[BaseModel],
out_schema: Type[BaseModel],
) -> APIRouter:
router = APIRouter(prefix=prefix, tags=tags, dependencies=[Depends(get_current_user)])
@router.get("", response_model=Page[out_schema]) # type: ignore[valid-type]
async def list_items(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
keyword: str | None = Query(None),
db: AsyncSession = Depends(get_db),
):
conditions = [model.is_deleted == False]
if keyword and hasattr(model, "name"):
conditions.append(model.name.ilike(f"%{keyword}%"))
query = select(model).where(*conditions)
total = await db.scalar(select(func.count()).select_from(query.subquery()))
result = await db.execute(
query.order_by(model.sort.desc(), model.created_at.desc()).offset((page - 1) * page_size).limit(page_size)
)
return Page(items=result.scalars().all(), total=total or 0, page=page, page_size=page_size)
@router.get("/{item_id}", response_model=out_schema) # type: ignore[valid-type]
async def get_item(item_id: str, db: AsyncSession = Depends(get_db)):
item = await db.get(model, item_id)
if not item or item.is_deleted:
raise HTTPException(status_code=404, detail="Not found")
return item
@router.post("", response_model=out_schema) # type: ignore[valid-type]
async def create_item(payload: create_schema, db: AsyncSession = Depends(get_db)): # type: ignore[valid-type]
item = model(**payload.model_dump())
db.add(item)
await db.commit()
await db.refresh(item)
return item
@router.put("/{item_id}", response_model=out_schema) # type: ignore[valid-type]
async def update_item(item_id: str, payload: update_schema, db: AsyncSession = Depends(get_db)): # type: ignore[valid-type]
item = await db.get(model, item_id)
if not item or item.is_deleted:
raise HTTPException(status_code=404, detail="Not found")
data: dict[str, Any] = payload.model_dump(exclude_unset=True)
for key, value in data.items():
if hasattr(item, key):
setattr(item, key, value)
await db.commit()
await db.refresh(item)
return item
@router.delete("/{item_id}", response_model=ResponseModel)
async def delete_item(item_id: str, db: AsyncSession = Depends(get_db)):
item = await db.get(model, item_id)
if not item or item.is_deleted:
raise HTTPException(status_code=404, detail="Not found")
item.is_deleted = True
await db.commit()
return ResponseModel(message="deleted")
return router
+26
View File
@@ -0,0 +1,26 @@
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import decode_access_token
from app.models import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
async def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
db: Annotated[AsyncSession, Depends(get_db)],
) -> User:
payload = decode_access_token(token)
if not payload or not payload.get("sub"):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
result = await db.execute(select(User).where(User.id == payload["sub"], User.is_deleted == False))
user = result.scalar_one_or_none()
if not user or user.status != "enabled":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user")
return user