Initial lightweight AI agent admin
This commit is contained in:
@@ -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
|
||||
@@ -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"]
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user