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
+10
View File
@@ -0,0 +1,10 @@
APP_NAME=AI Agent Admin
ENV=prod
DEBUG=false
APP_HOST=127.0.0.1
APP_PORT=18083
DATABASE_URL=postgresql+asyncpg://ai_agent_admin:change-me@127.0.0.1:5432/ai_agent_admin
REDIS_URL=redis://127.0.0.1:6379/3
JWT_SECRET_KEY=change-me-in-production
SEED_ADMIN_EMAIL=admin@ai-agent.local
SEED_ADMIN_PASSWORD=admin123456
+7
View File
@@ -0,0 +1,7 @@
FROM python:3.10-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
CMD ["uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", "18083"]
+1
View File
@@ -0,0 +1 @@
+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
+30
View File
@@ -0,0 +1,30 @@
from functools import lru_cache
from typing import Optional
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "AI Agent Admin"
env: str = "dev"
debug: bool = False
app_host: str = "127.0.0.1"
app_port: int = 18083
database_url: str = "postgresql+asyncpg://ai_agent_admin:ai_agent_admin@127.0.0.1:5432/ai_agent_admin"
redis_url: Optional[str] = "redis://127.0.0.1:6379/3"
jwt_secret_key: str = "change-me-in-production"
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 1440
cors_origins: str = "*"
seed_admin_email: str = "admin@ai-agent.local"
seed_admin_password: str = "admin123456"
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+26
View File
@@ -0,0 +1,26 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
class Base(DeclarativeBase):
pass
engine = create_async_engine(
settings.database_url,
echo=settings.debug,
pool_pre_ping=True,
pool_size=5,
max_overflow=10,
)
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
async with AsyncSessionLocal() as session:
yield session
+30
View File
@@ -0,0 +1,30 @@
from datetime import datetime, timedelta, timezone
from typing import Any
import bcrypt
from jose import JWTError, jwt
from app.core.config import settings
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(password: str, password_hash: str) -> bool:
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
def create_access_token(subject: str, extra: dict[str, Any] | None = None) -> str:
expires = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload: dict[str, Any] = {"sub": subject, "exp": expires}
if extra:
payload.update(extra)
return jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
def decode_access_token(token: str) -> dict[str, Any] | None:
try:
return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
except JWTError:
return None
+47
View File
@@ -0,0 +1,47 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.ai import router as ai_router
from app.api.auth import router as auth_router
from app.api.core import router as core_router
from app.core.config import settings
from app.core.database import AsyncSessionLocal, Base, engine
from app.models import * # noqa: F401,F403
from app.services.seed import seed_database
@asynccontextmanager
async def lifespan(app: FastAPI):
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with AsyncSessionLocal() as db:
await seed_database(db)
yield
app = FastAPI(title=settings.app_name, version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"] if settings.cors_origins == "*" else settings.cors_origins.split(","),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth_router, prefix="/api")
app.include_router(core_router, prefix="/api")
app.include_router(ai_router, prefix="/api")
@app.get("/health")
async def health():
return {"status": "ok", "app": settings.app_name}
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host=settings.app_host, port=settings.app_port, reload=settings.debug)
+31
View File
@@ -0,0 +1,31 @@
from app.models.ai import (
Agent,
AgentConversation,
AgentMessage,
AgentTeam,
CollaborationRun,
KnowledgeBase,
LLMModel,
LLMProvider,
Workflow,
WorkflowRun,
)
from app.models.core import Announcement, Menu, Permission, Role, User
__all__ = [
"Agent",
"AgentConversation",
"AgentMessage",
"AgentTeam",
"Announcement",
"CollaborationRun",
"KnowledgeBase",
"LLMModel",
"LLMProvider",
"Menu",
"Permission",
"Role",
"User",
"Workflow",
"WorkflowRun",
]
+141
View File
@@ -0,0 +1,141 @@
from sqlalchemy import Boolean, DateTime, Float, ForeignKey, Integer, JSON, String, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin
class LLMProvider(TimestampMixin, Base):
__tablename__ = "ai_llm_provider"
name: Mapped[str] = mapped_column(String(100))
code: Mapped[str] = mapped_column(String(80), unique=True, index=True)
provider_type: Mapped[str] = mapped_column(String(40), default="openai_compatible")
base_url: Mapped[str] = mapped_column(String(300), default="")
api_key: Mapped[str] = mapped_column(String(500), default="")
status: Mapped[str] = mapped_column(String(20), default="enabled")
models: Mapped[list["LLMModel"]] = relationship("LLMModel", back_populates="provider")
class LLMModel(TimestampMixin, Base):
__tablename__ = "ai_llm_model"
provider_id: Mapped[str] = mapped_column(String(32), ForeignKey("ai_llm_provider.id"), index=True)
name: Mapped[str] = mapped_column(String(120))
display_name: Mapped[str] = mapped_column(String(120), default="")
context_length: Mapped[int] = mapped_column(Integer, default=8192)
supports_streaming: Mapped[bool] = mapped_column(Boolean, default=True)
supports_function_call: Mapped[bool] = mapped_column(Boolean, default=False)
default_temperature: Mapped[float] = mapped_column(Float, default=0.7)
default_max_tokens: Mapped[int] = mapped_column(Integer, default=2048)
status: Mapped[str] = mapped_column(String(20), default="enabled")
provider: Mapped[LLMProvider] = relationship("LLMProvider", back_populates="models")
class Agent(TimestampMixin, Base):
__tablename__ = "ai_agent"
name: Mapped[str] = mapped_column(String(100))
code: Mapped[str] = mapped_column(String(80), unique=True, index=True)
description: Mapped[str] = mapped_column(Text, default="")
avatar: Mapped[str] = mapped_column(String(300), default="")
status: Mapped[str] = mapped_column(String(20), default="draft")
persona: Mapped[dict] = mapped_column(JSON, default=dict)
system_prompt: Mapped[str] = mapped_column(Text, default="")
model_id: Mapped[str | None] = mapped_column(String(32), ForeignKey("ai_llm_model.id"), nullable=True)
temperature: Mapped[float] = mapped_column(Float, default=0.7)
max_tokens: Mapped[int] = mapped_column(Integer, default=2048)
tools: Mapped[list] = mapped_column(JSON, default=list)
knowledge_base_ids: Mapped[list] = mapped_column(JSON, default=list)
enable_memory: Mapped[bool] = mapped_column(Boolean, default=True)
memory_window: Mapped[int] = mapped_column(Integer, default=10)
model: Mapped[LLMModel | None] = relationship("LLMModel")
class AgentConversation(TimestampMixin, Base):
__tablename__ = "ai_agent_conversation"
agent_id: Mapped[str] = mapped_column(String(32), ForeignKey("ai_agent.id"), index=True)
user_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
title: Mapped[str] = mapped_column(String(160), default="新建对话")
total_tokens: Mapped[int] = mapped_column(Integer, default=0)
agent: Mapped[Agent] = relationship("Agent")
messages: Mapped[list["AgentMessage"]] = relationship("AgentMessage", back_populates="conversation")
class AgentMessage(TimestampMixin, Base):
__tablename__ = "ai_agent_message"
conversation_id: Mapped[str] = mapped_column(String(32), ForeignKey("ai_agent_conversation.id"), index=True)
role: Mapped[str] = mapped_column(String(20))
content: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(String(20), default="completed")
reasoning_steps: Mapped[list] = mapped_column(JSON, default=list)
total_tokens: Mapped[int] = mapped_column(Integer, default=0)
elapsed_time: Mapped[int] = mapped_column(Integer, default=0)
conversation: Mapped[AgentConversation] = relationship("AgentConversation", back_populates="messages")
class Workflow(TimestampMixin, Base):
__tablename__ = "ai_workflow"
name: Mapped[str] = mapped_column(String(100))
code: Mapped[str] = mapped_column(String(80), unique=True, index=True)
description: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(String(20), default="draft")
version: Mapped[int] = mapped_column(Integer, default=1)
published_version: Mapped[int | None] = mapped_column(Integer, nullable=True)
definition: Mapped[dict] = mapped_column(JSON, default=dict)
published_definition: Mapped[dict] = mapped_column(JSON, default=dict)
input_variables: Mapped[list] = mapped_column(JSON, default=list)
output_variables: Mapped[list] = mapped_column(JSON, default=list)
run_count: Mapped[int] = mapped_column(Integer, default=0)
class WorkflowRun(TimestampMixin, Base):
__tablename__ = "ai_workflow_run"
workflow_id: Mapped[str] = mapped_column(String(32), ForeignKey("ai_workflow.id"), index=True)
status: Mapped[str] = mapped_column(String(20), default="running")
trigger_type: Mapped[str] = mapped_column(String(40), default="api")
inputs: Mapped[dict] = mapped_column(JSON, default=dict)
outputs: Mapped[dict] = mapped_column(JSON, default=dict)
execution_log: Mapped[list] = mapped_column(JSON, default=list)
error_message: Mapped[str] = mapped_column(Text, default="")
total_tokens: Mapped[int] = mapped_column(Integer, default=0)
elapsed_time: Mapped[int] = mapped_column(Integer, default=0)
started_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
completed_at: Mapped[object | None] = mapped_column(DateTime, nullable=True)
workflow: Mapped[Workflow] = relationship("Workflow")
class KnowledgeBase(TimestampMixin, Base):
__tablename__ = "ai_knowledge_base"
name: Mapped[str] = mapped_column(String(120))
code: Mapped[str] = mapped_column(String(80), unique=True, index=True)
description: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(String(20), default="enabled")
class AgentTeam(TimestampMixin, Base):
__tablename__ = "ai_agent_team"
name: Mapped[str] = mapped_column(String(120))
code: Mapped[str] = mapped_column(String(80), unique=True, index=True)
description: Mapped[str] = mapped_column(Text, default="")
mode: Mapped[str] = mapped_column(String(20), default="sequential")
members: Mapped[list] = mapped_column(JSON, default=list)
status: Mapped[str] = mapped_column(String(20), default="enabled")
class CollaborationRun(TimestampMixin, Base):
__tablename__ = "ai_collaboration_run"
team_id: Mapped[str] = mapped_column(String(32), ForeignKey("ai_agent_team.id"), index=True)
task: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(String(20), default="running")
messages: Mapped[list] = mapped_column(JSON, default=list)
final_answer: Mapped[str] = mapped_column(Text, default="")
elapsed_time: Mapped[int] = mapped_column(Integer, default=0)
team: Mapped[AgentTeam] = relationship("AgentTeam")
+19
View File
@@ -0,0 +1,19 @@
from datetime import datetime
from uuid import uuid4
from sqlalchemy import Boolean, DateTime, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
def new_id() -> str:
return uuid4().hex
class TimestampMixin:
id: Mapped[str] = mapped_column(String(32), primary_key=True, default=new_id)
sort: Mapped[int] = mapped_column(Integer, default=0)
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+71
View File
@@ -0,0 +1,71 @@
from sqlalchemy import Boolean, Column, ForeignKey, String, Table, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.base import TimestampMixin
role_permission = Table(
"core_role_permission",
Base.metadata,
Column("role_id", String(32), ForeignKey("core_role.id"), primary_key=True),
Column("permission_id", String(32), ForeignKey("core_permission.id"), primary_key=True),
)
user_role = Table(
"core_user_role",
Base.metadata,
Column("user_id", String(32), ForeignKey("core_user.id"), primary_key=True),
Column("role_id", String(32), ForeignKey("core_role.id"), primary_key=True),
)
class User(TimestampMixin, Base):
__tablename__ = "core_user"
email: Mapped[str] = mapped_column(String(120), unique=True, index=True)
username: Mapped[str] = mapped_column(String(80), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(200))
nickname: Mapped[str] = mapped_column(String(80), default="")
status: Mapped[str] = mapped_column(String(20), default="enabled")
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
roles: Mapped[list["Role"]] = relationship("Role", secondary=user_role, back_populates="users")
class Role(TimestampMixin, Base):
__tablename__ = "core_role"
name: Mapped[str] = mapped_column(String(80), unique=True)
code: Mapped[str] = mapped_column(String(80), unique=True, index=True)
description: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(String(20), default="enabled")
users: Mapped[list[User]] = relationship("User", secondary=user_role, back_populates="roles")
permissions: Mapped[list["Permission"]] = relationship("Permission", secondary=role_permission)
class Permission(TimestampMixin, Base):
__tablename__ = "core_permission"
name: Mapped[str] = mapped_column(String(100))
code: Mapped[str] = mapped_column(String(120), unique=True, index=True)
resource: Mapped[str] = mapped_column(String(80), default="")
action: Mapped[str] = mapped_column(String(40), default="")
class Menu(TimestampMixin, Base):
__tablename__ = "core_menu"
parent_id: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
title: Mapped[str] = mapped_column(String(100))
path: Mapped[str] = mapped_column(String(200), default="")
icon: Mapped[str] = mapped_column(String(100), default="")
permission_code: Mapped[str] = mapped_column(String(120), default="")
visible: Mapped[bool] = mapped_column(Boolean, default=True)
class Announcement(TimestampMixin, Base):
__tablename__ = "core_announcement"
title: Mapped[str] = mapped_column(String(160))
content: Mapped[str] = mapped_column(Text, default="")
status: Mapped[str] = mapped_column(String(20), default="draft")
+168
View File
@@ -0,0 +1,168 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict
class ProviderBase(BaseModel):
name: str
code: str
provider_type: str = "openai_compatible"
base_url: str = ""
api_key: str = ""
status: str = "enabled"
class ProviderOut(ProviderBase):
id: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class ModelBase(BaseModel):
provider_id: str
name: str
display_name: str = ""
context_length: int = 8192
supports_streaming: bool = True
supports_function_call: bool = False
default_temperature: float = 0.7
default_max_tokens: int = 2048
status: str = "enabled"
class ModelOut(ModelBase):
id: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class AgentBase(BaseModel):
name: str
code: str
description: str = ""
avatar: str = ""
status: str = "draft"
persona: dict[str, Any] = {}
system_prompt: str = ""
model_id: str | None = None
temperature: float = 0.7
max_tokens: int = 2048
tools: list[Any] = []
knowledge_base_ids: list[str] = []
enable_memory: bool = True
memory_window: int = 10
class AgentOut(AgentBase):
id: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class ChatIn(BaseModel):
message: str
conversation_id: str | None = None
class ConversationOut(BaseModel):
id: str
agent_id: str
title: str
total_tokens: int = 0
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class MessageOut(BaseModel):
id: str
conversation_id: str
role: str
content: str
status: str
total_tokens: int = 0
elapsed_time: int = 0
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class WorkflowBase(BaseModel):
name: str
code: str
description: str = ""
status: str = "draft"
definition: dict[str, Any] = {}
input_variables: list[Any] = []
output_variables: list[Any] = []
class WorkflowOut(WorkflowBase):
id: str
version: int
published_version: int | None = None
published_definition: dict[str, Any] = {}
run_count: int = 0
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class WorkflowRunIn(BaseModel):
inputs: dict[str, Any] = {}
class WorkflowRunOut(BaseModel):
id: str
workflow_id: str
status: str
inputs: dict[str, Any]
outputs: dict[str, Any]
execution_log: list[Any]
error_message: str = ""
total_tokens: int = 0
elapsed_time: int = 0
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class KnowledgeBaseIn(BaseModel):
name: str
code: str
description: str = ""
status: str = "enabled"
class KnowledgeBaseOut(KnowledgeBaseIn):
id: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class TeamBase(BaseModel):
name: str
code: str
description: str = ""
mode: str = "sequential"
members: list[dict[str, Any]] = []
status: str = "enabled"
class TeamOut(TeamBase):
id: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class CollaborationRunIn(BaseModel):
task: str
class CollaborationRunOut(BaseModel):
id: str
team_id: str
task: str
status: str
messages: list[Any]
final_answer: str
elapsed_time: int
created_at: datetime
model_config = ConfigDict(from_attributes=True)
+22
View File
@@ -0,0 +1,22 @@
from typing import Any, Generic, TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class ResponseModel(BaseModel):
success: bool = True
message: str = "ok"
data: Any | None = None
class Page(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
page_size: int
class IdsIn(BaseModel):
ids: list[str]
+93
View File
@@ -0,0 +1,93 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr
class LoginIn(BaseModel):
username: str
password: str
class TokenOut(BaseModel):
access_token: str
token_type: str = "bearer"
class UserBase(BaseModel):
email: EmailStr
username: str
nickname: str = ""
status: str = "enabled"
is_superuser: bool = False
class UserCreate(UserBase):
password: str = "123456"
class UserUpdate(BaseModel):
email: EmailStr | None = None
username: str | None = None
nickname: str | None = None
status: str | None = None
is_superuser: bool | None = None
password: str | None = None
class UserOut(UserBase):
id: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class RoleBase(BaseModel):
name: str
code: str
description: str = ""
status: str = "enabled"
class RoleOut(RoleBase):
id: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class PermissionBase(BaseModel):
name: str
code: str
resource: str = ""
action: str = ""
class PermissionOut(PermissionBase):
id: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class MenuBase(BaseModel):
parent_id: str | None = None
title: str
path: str = ""
icon: str = ""
permission_code: str = ""
visible: bool = True
class MenuOut(MenuBase):
id: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class AnnouncementBase(BaseModel):
title: str
content: str = ""
status: str = "draft"
class AnnouncementOut(AnnouncementBase):
id: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
+39
View File
@@ -0,0 +1,39 @@
import time
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Agent, AgentTeam, CollaborationRun
from app.services.llm import LLMService
class CollaborationService:
def __init__(self, db: AsyncSession):
self.db = db
self.llm = LLMService(db)
async def run(self, team: AgentTeam, task: str) -> CollaborationRun:
started = time.time()
run = CollaborationRun(team_id=team.id, task=task, status="running", messages=[], final_answer="")
self.db.add(run)
await self.db.flush()
messages = []
context = task
for member in team.members or []:
agent_id = member.get("agent_id")
role = member.get("role", "member")
agent = await self.db.get(Agent, agent_id) if agent_id else None
if not agent:
content = f"{role}: 未配置有效智能体,跳过。"
else:
content = await self.llm.complete(agent, f"你的协作角色是 {role}。请基于上下文完成任务:{context}")
messages.append({"role": role, "agent_id": agent_id, "agent_name": agent.name if agent else "", "content": content})
context = f"{context}\n\n[{role}] {content}"
run.messages = messages
run.final_answer = messages[-1]["content"] if messages else "团队暂无成员,无法执行协作任务。"
run.status = "completed"
run.elapsed_time = int((time.time() - started) * 1000)
await self.db.commit()
await self.db.refresh(run)
return run
+69
View File
@@ -0,0 +1,69 @@
import json
from collections.abc import AsyncGenerator
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Agent, LLMModel, LLMProvider
class LLMService:
def __init__(self, db: AsyncSession):
self.db = db
async def complete(self, agent: Agent, user_message: str, history: list[dict] | None = None) -> str:
if not agent.model_id:
return self._fallback_answer(agent, user_message)
model = await self.db.get(LLMModel, agent.model_id)
if not model:
return self._fallback_answer(agent, user_message)
provider = await self.db.get(LLMProvider, model.provider_id)
if not provider or provider.status != "enabled" or not provider.api_key or not provider.base_url:
return self._fallback_answer(agent, user_message)
messages = [{"role": "system", "content": agent.system_prompt or f"你是 {agent.name}"}]
messages.extend(history or [])
messages.append({"role": "user", "content": user_message})
url = provider.base_url.rstrip("/") + "/chat/completions"
payload = {
"model": model.name,
"messages": messages,
"temperature": agent.temperature,
"max_tokens": agent.max_tokens,
"stream": False,
}
async with httpx.AsyncClient(timeout=60) as client:
response = await client.post(
url,
headers={"Authorization": f"Bearer {provider.api_key}", "Content-Type": "application/json"},
json=payload,
)
response.raise_for_status()
data = response.json()
return data.get("choices", [{}])[0].get("message", {}).get("content") or ""
async def stream(self, agent: Agent, user_message: str, history: list[dict] | None = None) -> AsyncGenerator[str, None]:
answer = await self.complete(agent, user_message, history)
for chunk in self._chunk_text(answer):
yield chunk
def _fallback_answer(self, agent: Agent, user_message: str) -> str:
prompt = agent.system_prompt.strip() or "轻量 AI Agent"
return (
f"{agent.name} 已收到任务:{user_message}\n\n"
f"当前使用本地占位响应。配置可用的 LLM Provider 和 Model 后,将自动调用真实模型。\n\n"
f"系统提示词摘要:{prompt[:160]}"
)
def _chunk_text(self, text: str) -> list[str]:
if not text:
return [""]
return [text[i : i + 24] for i in range(0, len(text), 24)]
def sse(data: dict | str) -> str:
if isinstance(data, str):
payload = data
else:
payload = json.dumps(data, ensure_ascii=False, default=str)
return f"data: {payload}\n\n"
+87
View File
@@ -0,0 +1,87 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import hash_password
from app.models import Agent, AgentTeam, Announcement, LLMModel, LLMProvider, Menu, Permission, Role, User, Workflow
async def seed_database(db: AsyncSession) -> None:
exists = await db.scalar(select(User.id).limit(1))
if exists:
return
admin = User(
email=settings.seed_admin_email,
username="admin",
nickname="系统管理员",
password_hash=hash_password(settings.seed_admin_password),
is_superuser=True,
)
role = Role(name="超级管理员", code="admin", description="系统内置管理员")
permissions = [
Permission(name="用户管理", code="core:user", resource="user", action="manage"),
Permission(name="角色管理", code="core:role", resource="role", action="manage"),
Permission(name="AI 管理", code="ai:manage", resource="ai", action="manage"),
]
role.permissions = permissions
admin.roles = [role]
menus = [
Menu(title="工作台", path="/", icon="Monitor"),
Menu(title="用户管理", path="/system/users", icon="User"),
Menu(title="角色管理", path="/system/roles", icon="Lock"),
Menu(title="菜单管理", path="/system/menus", icon="Menu"),
Menu(title="权限管理", path="/system/permissions", icon="Key"),
Menu(title="公告管理", path="/system/announcements", icon="Bell"),
Menu(title="Provider", path="/ai/providers", icon="Connection"),
Menu(title="Model", path="/ai/models", icon="Cpu"),
Menu(title="Agent", path="/ai/agents", icon="Avatar"),
Menu(title="Agent Chat", path="/ai/chat", icon="ChatLineRound"),
Menu(title="Workflow", path="/ai/workflows", icon="Share"),
Menu(title="Workflow Runs", path="/ai/workflow-runs", icon="Tickets"),
Menu(title="Knowledge Base", path="/ai/knowledge", icon="Collection"),
Menu(title="Agent Team", path="/ai/teams", icon="Operation"),
]
provider = LLMProvider(
name="OpenAI Compatible",
code="openai_compatible",
provider_type="openai_compatible",
base_url="",
api_key="",
)
model = LLMModel(provider=provider, name="gpt-compatible", display_name="默认兼容模型")
agent = Agent(
name="Planner",
code="planner",
status="published",
system_prompt="你是任务规划智能体,负责拆解目标并给出执行计划。",
model=model,
)
workflow = Workflow(
name="最小 LLM 工作流",
code="minimal_llm",
status="published",
definition={
"nodes": [
{"id": "start", "type": "start", "data": {}},
{"id": "plan", "type": "agent", "data": {"agent_id": ""}},
{"id": "end", "type": "end", "data": {}},
],
"edges": [],
},
)
team = AgentTeam(
name="默认协作团队",
code="default_team",
description="Planner -> Executor -> Reviewer 的最小协作演示",
members=[{"agent_id": "", "role": "planner"}, {"agent_id": "", "role": "reviewer"}],
)
announcement = Announcement(title="AI Agent Admin 已初始化", content="轻量管理后台已可用。", status="published")
db.add_all([admin, role, *permissions, *menus, provider, model, agent, workflow, team, announcement])
await db.flush()
workflow.definition["nodes"][1]["data"]["agent_id"] = agent.id
team.members = [{"agent_id": agent.id, "role": "planner"}, {"agent_id": agent.id, "role": "reviewer"}]
await db.commit()
+82
View File
@@ -0,0 +1,82 @@
import time
from datetime import datetime
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Agent, Workflow, WorkflowRun
from app.services.llm import LLMService
class WorkflowService:
def __init__(self, db: AsyncSession):
self.db = db
self.llm = LLMService(db)
async def run(self, workflow: Workflow, inputs: dict[str, Any]) -> WorkflowRun:
started = time.time()
definition = workflow.published_definition or workflow.definition or {}
run = WorkflowRun(
workflow_id=workflow.id,
status="running",
inputs=inputs,
outputs={},
execution_log=[],
started_at=datetime.utcnow(),
)
self.db.add(run)
await self.db.flush()
variables: dict[str, Any] = dict(inputs)
log: list[dict[str, Any]] = []
try:
nodes = definition.get("nodes") or []
for node in nodes:
node_type = node.get("type", "unknown")
node_id = node.get("id", node_type)
data = node.get("data") or {}
entry = {"node_id": node_id, "node_type": node_type, "status": "completed"}
if node_type == "start":
entry["output"] = variables
elif node_type == "llm":
agent_id = data.get("agent_id")
prompt = data.get("prompt") or inputs.get("task") or inputs.get("message") or ""
agent = await self.db.get(Agent, agent_id) if agent_id else None
if agent:
result = await self.llm.complete(agent, prompt)
else:
result = f"LLM 节点占位输出:{prompt}"
variables[node_id] = result
entry["output"] = result
elif node_type == "agent":
agent_id = data.get("agent_id")
agent = await self.db.get(Agent, agent_id) if agent_id else None
task = data.get("task") or inputs.get("task") or inputs.get("message") or ""
result = await self.llm.complete(agent, task) if agent else f"Agent 节点占位输出:{task}"
variables[node_id] = result
entry["output"] = result
elif node_type == "condition":
entry["output"] = {"matched": True}
elif node_type in {"parallel", "merge", "tool", "http"}:
entry["output"] = f"{node_type} 节点已执行最小占位逻辑"
elif node_type == "end":
entry["output"] = variables
else:
entry["status"] = "skipped"
entry["output"] = "未知节点类型,已跳过"
log.append(entry)
run.status = "completed"
run.outputs = {"result": variables}
run.execution_log = log
workflow.run_count = (workflow.run_count or 0) + 1
except Exception as exc:
run.status = "failed"
run.error_message = str(exc)
run.execution_log = log
finally:
run.elapsed_time = int((time.time() - started) * 1000)
run.completed_at = datetime.utcnow()
await self.db.commit()
await self.db.refresh(run)
return run
+12
View File
@@ -0,0 +1,12 @@
fastapi==0.121.1
uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
asyncpg==0.29.0
pydantic==2.5.2
pydantic-settings==2.1.0
python-jose[cryptography]==3.3.0
bcrypt==4.2.1
httpx==0.27.0
redis==5.0.1
python-multipart==0.0.19
alembic==1.13.0
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
exec .venv/bin/uvicorn app.main:app --host "${APP_HOST:-127.0.0.1}" --port "${APP_PORT:-18083}"