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
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.venv
__pycache__
*.pyc
.env
.DS_Store
+8
View File
@@ -0,0 +1,8 @@
# ai-agent-admin
轻量化 AI Agent 管理后台,保留基础 Admin 能力,并提供 Provider、Model、Agent、Workflow、Knowledge Base 和 Agent Team 协作模块。
默认管理员:
- 用户名:admin
- 密码:admin123456
+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}"
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=AI Agent Admin FastAPI backend
After=network.target postgresql@17-main.service redis-server.service
[Service]
Type=simple
User=ubuntu
WorkingDirectory=/opt/apps/ai-agent-admin/backend
EnvironmentFile=/opt/apps/ai-agent-admin/backend/.env
ExecStart=/opt/apps/ai-agent-admin/backend/.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 18083
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
set -euo pipefail
APP_DIR=/opt/apps/ai-agent-admin
WEB_DIR=/var/www/ai-agent-admin
DB_NAME=ai_agent_admin
DB_USER=ai_agent_admin
DB_PASSWORD=${DB_PASSWORD:-ai_agent_admin}
cd "$APP_DIR"
sudo -n -u postgres psql -tc "select 1 from pg_roles where rolname='${DB_USER}'" | grep -q 1 || \
sudo -n -u postgres psql -c "create user ${DB_USER} with password '${DB_PASSWORD}'"
sudo -n -u postgres psql -tc "select 1 from pg_database where datname='${DB_NAME}'" | grep -q 1 || \
sudo -n -u postgres createdb -O "${DB_USER}" "${DB_NAME}"
cd "$APP_DIR/backend"
python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install -r requirements.txt
if [ ! -f .env ]; then
cp .env.example .env
fi
python3 - <<'PY'
from pathlib import Path
p = Path('.env')
text = p.read_text()
text = text.replace('change-me@127.0.0.1:5432/ai_agent_admin', 'ai_agent_admin@127.0.0.1:5432/ai_agent_admin')
text = text.replace('JWT_SECRET_KEY=change-me-in-production', 'JWT_SECRET_KEY=ai-agent-admin-prod-secret-change-later')
p.write_text(text)
PY
sudo -n cp "$APP_DIR/deploy/ai-agent-admin-backend.service" /etc/systemd/system/ai-agent-admin-backend.service
sudo -n systemctl daemon-reload
sudo -n systemctl enable ai-agent-admin-backend.service
sudo -n systemctl restart ai-agent-admin-backend.service
mkdir -p "$WEB_DIR"
rm -rf "$WEB_DIR"/*
cp -a "$APP_DIR/frontend/dist/." "$WEB_DIR/"
NGINX_SITE=/etc/nginx/sites-enabled/default
if ! sudo -n grep -q "AI Agent Admin begin" "$NGINX_SITE"; then
tmp=$(mktemp)
sudo -n awk 'BEGIN{done=0} /location \\// && done==0 {while ((getline line < "/opt/apps/ai-agent-admin/deploy/nginx-ai-agent-admin.conf") > 0) print line; done=1} {print}' "$NGINX_SITE" > "$tmp"
sudo -n cp "$tmp" "$NGINX_SITE"
rm -f "$tmp"
fi
sudo -n nginx -t
sudo -n systemctl reload nginx
+20
View File
@@ -0,0 +1,20 @@
# AI Agent Admin begin
location = /ai-agent-admin {
return 301 /ai-agent-admin/;
}
location ^~ /ai-agent-admin/basic-api/ {
proxy_pass http://127.0.0.1:18083/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 300s;
client_max_body_size 100m;
}
location ^~ /ai-agent-admin/ {
try_files $uri $uri/ /ai-agent-admin/index.html;
}
# AI Agent Admin end
+8
View File
@@ -0,0 +1,8 @@
services:
backend:
build: ./backend
restart: unless-stopped
env_file:
- ./backend/.env
ports:
- "127.0.0.1:18083:18083"
+2
View File
@@ -0,0 +1,2 @@
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
+2104
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "ai-agent-admin-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --port 5178",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview --host 0.0.0.0 --port 4178"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"element-plus": "^2.9.3",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.2",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
}
}
+80
View File
@@ -0,0 +1,80 @@
<template>
<router-view v-if="isLoginPage" />
<el-container v-else class="app-shell">
<el-aside width="248px" class="sidebar">
<div class="brand">
<span class="brand-mark">AI</span>
<div>
<strong>Agent Admin</strong>
<small>轻量智能体后台</small>
</div>
</div>
<el-menu router :default-active="$route.path" class="menu">
<el-menu-item index="/">
<el-icon><Monitor /></el-icon>
<span>工作台</span>
</el-menu-item>
<el-sub-menu index="system">
<template #title>
<el-icon><Setting /></el-icon>
<span>系统管理</span>
</template>
<el-menu-item index="/system/users">用户管理</el-menu-item>
<el-menu-item index="/system/roles">角色管理</el-menu-item>
<el-menu-item index="/system/menus">菜单管理</el-menu-item>
<el-menu-item index="/system/permissions">权限管理</el-menu-item>
<el-menu-item index="/system/announcements">公告管理</el-menu-item>
</el-sub-menu>
<el-sub-menu index="ai">
<template #title>
<el-icon><Connection /></el-icon>
<span>AI 管理</span>
</template>
<el-menu-item index="/ai/providers">Provider</el-menu-item>
<el-menu-item index="/ai/models">Model</el-menu-item>
<el-menu-item index="/ai/agents">Agent</el-menu-item>
<el-menu-item index="/ai/chat">Agent Chat</el-menu-item>
<el-menu-item index="/ai/workflows">Workflow</el-menu-item>
<el-menu-item index="/ai/workflow-runs">Workflow Runs</el-menu-item>
<el-menu-item index="/ai/knowledge">Knowledge Base</el-menu-item>
<el-menu-item index="/ai/teams">Agent Team</el-menu-item>
<el-menu-item index="/ai/collaboration-runs">Collaboration Runs</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>
<el-container>
<el-header class="topbar">
<div>
<strong>{{ pageTitle }}</strong>
</div>
<div class="user">
<span>{{ auth.user?.nickname || auth.user?.username }}</span>
<el-button text @click="logout">退出</el-button>
</div>
</el-header>
<el-main class="main">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { Connection, Monitor, Setting } from '@element-plus/icons-vue';
import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useAuthStore } from './stores/auth';
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const isLoginPage = computed(() => route.path === '/login');
const pageTitle = computed(() => route.meta.title || 'AI Agent Admin');
function logout() {
auth.logout();
router.push('/login');
}
</script>
+25
View File
@@ -0,0 +1,25 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/ai-agent-admin/basic-api/api',
timeout: 60000,
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('ai-agent-admin-token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
api.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('ai-agent-admin-token');
location.href = '/ai-agent-admin/login';
}
return Promise.reject(error);
},
);
export { api };
+163
View File
@@ -0,0 +1,163 @@
import { api } from './http';
export interface ResourceConfig {
title: string;
path: string;
fields: FieldConfig[];
}
export interface FieldConfig {
key: string;
label: string;
type?: 'text' | 'textarea' | 'number' | 'boolean' | 'json';
required?: boolean;
}
export const resources: Record<string, ResourceConfig> = {
users: {
title: '用户管理',
path: '/core/users',
fields: [
{ key: 'email', label: '邮箱', required: true },
{ key: 'username', label: '用户名', required: true },
{ key: 'nickname', label: '昵称' },
{ key: 'password', label: '密码' },
{ key: 'status', label: '状态' },
{ key: 'is_superuser', label: '超级管理员', type: 'boolean' },
],
},
roles: {
title: '角色管理',
path: '/core/roles',
fields: [
{ key: 'name', label: '名称', required: true },
{ key: 'code', label: '编码', required: true },
{ key: 'description', label: '描述', type: 'textarea' },
{ key: 'status', label: '状态' },
],
},
permissions: {
title: '权限管理',
path: '/core/permissions',
fields: [
{ key: 'name', label: '名称', required: true },
{ key: 'code', label: '编码', required: true },
{ key: 'resource', label: '资源' },
{ key: 'action', label: '动作' },
],
},
menus: {
title: '菜单管理',
path: '/core/menus',
fields: [
{ key: 'parent_id', label: '父级 ID' },
{ key: 'title', label: '标题', required: true },
{ key: 'path', label: '路径' },
{ key: 'icon', label: '图标' },
{ key: 'permission_code', label: '权限编码' },
{ key: 'visible', label: '显示', type: 'boolean' },
],
},
announcements: {
title: '公告管理',
path: '/core/announcements',
fields: [
{ key: 'title', label: '标题', required: true },
{ key: 'content', label: '内容', type: 'textarea' },
{ key: 'status', label: '状态' },
],
},
providers: {
title: 'LLM Provider',
path: '/ai/providers',
fields: [
{ key: 'name', label: '名称', required: true },
{ key: 'code', label: '编码', required: true },
{ key: 'provider_type', label: '类型' },
{ key: 'base_url', label: 'Base URL' },
{ key: 'api_key', label: 'API Key' },
{ key: 'status', label: '状态' },
],
},
models: {
title: 'LLM Model',
path: '/ai/models',
fields: [
{ key: 'provider_id', label: 'Provider ID', required: true },
{ key: 'name', label: '模型名', required: true },
{ key: 'display_name', label: '显示名' },
{ key: 'context_length', label: '上下文长度', type: 'number' },
{ key: 'supports_streaming', label: '流式', type: 'boolean' },
{ key: 'supports_function_call', label: 'Function Call', type: 'boolean' },
{ key: 'default_temperature', label: '默认温度', type: 'number' },
{ key: 'default_max_tokens', label: '最大 Token', type: 'number' },
{ key: 'status', label: '状态' },
],
},
agents: {
title: 'Agent 管理',
path: '/ai/agents',
fields: [
{ key: 'name', label: '名称', required: true },
{ key: 'code', label: '编码', required: true },
{ key: 'description', label: '描述', type: 'textarea' },
{ key: 'avatar', label: '头像' },
{ key: 'status', label: '状态' },
{ key: 'system_prompt', label: '系统提示词', type: 'textarea' },
{ key: 'model_id', label: 'Model ID' },
{ key: 'temperature', label: '温度', type: 'number' },
{ key: 'max_tokens', label: '最大 Token', type: 'number' },
{ key: 'tools', label: '工具 JSON', type: 'json' },
{ key: 'knowledge_base_ids', label: '知识库 ID JSON', type: 'json' },
{ key: 'enable_memory', label: '记忆', type: 'boolean' },
{ key: 'memory_window', label: '记忆窗口', type: 'number' },
],
},
workflows: {
title: 'Workflow',
path: '/ai/workflows',
fields: [
{ key: 'name', label: '名称', required: true },
{ key: 'code', label: '编码', required: true },
{ key: 'description', label: '描述', type: 'textarea' },
{ key: 'status', label: '状态' },
{ key: 'definition', label: '定义 JSON', type: 'json' },
{ key: 'input_variables', label: '输入变量 JSON', type: 'json' },
{ key: 'output_variables', label: '输出变量 JSON', type: 'json' },
],
},
knowledge: {
title: 'Knowledge Base',
path: '/ai/knowledge-bases',
fields: [
{ key: 'name', label: '名称', required: true },
{ key: 'code', label: '编码', required: true },
{ key: 'description', label: '描述', type: 'textarea' },
{ key: 'status', label: '状态' },
],
},
teams: {
title: 'Agent Team',
path: '/ai/teams',
fields: [
{ key: 'name', label: '名称', required: true },
{ key: 'code', label: '编码', required: true },
{ key: 'description', label: '描述', type: 'textarea' },
{ key: 'mode', label: '模式' },
{ key: 'members', label: '成员 JSON', type: 'json' },
{ key: 'status', label: '状态' },
],
},
};
export function listResource(key: string, params?: Record<string, unknown>) {
return api.get(resources[key].path, { params });
}
export function saveResource(key: string, payload: Record<string, unknown>, id?: string) {
return id ? api.put(`${resources[key].path}/${id}`, payload) : api.post(resources[key].path, payload);
}
export function deleteResource(key: string, id: string) {
return api.delete(`${resources[key].path}/${id}`);
}
+5
View File
@@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}
+11
View File
@@ -0,0 +1,11 @@
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import { createPinia } from 'pinia';
import { createApp } from 'vue';
import App from './App.vue';
import { router } from './router';
import './styles.css';
createApp(App).use(createPinia()).use(router).use(ElementPlus).mount('#app');
+24
View File
@@ -0,0 +1,24 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from './stores/auth';
export const router = createRouter({
history: createWebHistory('/ai-agent-admin/'),
routes: [
{ path: '/login', component: () => import('./views/LoginView.vue') },
{ path: '/', component: () => import('./views/DashboardView.vue') },
{ path: '/system/:resource', component: () => import('./views/ResourceView.vue') },
{ path: '/ai/chat', component: () => import('./views/AgentChatView.vue') },
{ path: '/ai/workflow-runs', component: () => import('./views/RunsView.vue'), props: { type: 'workflow' } },
{ path: '/ai/collaboration-runs', component: () => import('./views/RunsView.vue'), props: { type: 'collaboration' } },
{ path: '/ai/:resource', component: () => import('./views/ResourceView.vue') },
],
});
router.beforeEach(async (to) => {
const auth = useAuthStore();
if (to.path === '/login') return true;
if (!auth.token) return '/login';
if (!auth.user) await auth.fetchMe();
return true;
});
+27
View File
@@ -0,0 +1,27 @@
import { defineStore } from 'pinia';
import { api } from '@/api/http';
export const useAuthStore = defineStore('auth', {
state: () => ({
token: localStorage.getItem('ai-agent-admin-token') || '',
user: null as any,
}),
actions: {
async login(username: string, password: string) {
const data: any = await api.post('/auth/login', { username, password });
this.token = data.access_token;
localStorage.setItem('ai-agent-admin-token', this.token);
await this.fetchMe();
},
async fetchMe() {
if (!this.token) return;
this.user = await api.get('/auth/me');
},
logout() {
this.token = '';
this.user = null;
localStorage.removeItem('ai-agent-admin-token');
},
},
});
+155
View File
@@ -0,0 +1,155 @@
:root {
color: #1f2937;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f5f7fb;
}
body {
margin: 0;
}
.app-shell {
min-height: 100vh;
}
.sidebar {
background: #101827;
color: #fff;
}
.brand {
align-items: center;
display: flex;
gap: 12px;
height: 64px;
padding: 0 18px;
}
.brand-mark {
align-items: center;
background: #2dd4bf;
border-radius: 6px;
color: #083344;
display: inline-flex;
font-weight: 800;
height: 34px;
justify-content: center;
width: 34px;
}
.brand small {
color: #9ca3af;
display: block;
font-size: 12px;
margin-top: 2px;
}
.menu {
border-right: 0;
}
.sidebar .el-menu {
background: transparent;
}
.sidebar .el-menu-item,
.sidebar .el-sub-menu__title {
color: #d1d5db;
}
.sidebar .el-menu-item.is-active {
background: #1f3a4a;
color: #67e8f9;
}
.topbar {
align-items: center;
background: #fff;
border-bottom: 1px solid #e5e7eb;
display: flex;
justify-content: space-between;
}
.main {
padding: 20px;
}
.panel {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 18px;
}
.toolbar {
align-items: center;
display: flex;
gap: 12px;
justify-content: space-between;
margin-bottom: 14px;
}
.grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.metric {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 18px;
}
.metric strong {
display: block;
font-size: 28px;
margin-top: 8px;
}
.login-page {
align-items: center;
background: linear-gradient(135deg, #0f172a, #164e63);
display: flex;
min-height: 100vh;
justify-content: center;
}
.login-box {
background: #fff;
border-radius: 8px;
padding: 28px;
width: 360px;
}
.chat {
display: grid;
gap: 16px;
grid-template-columns: 320px 1fr;
}
.chat-log {
background: #f8fafc;
border: 1px solid #e5e7eb;
border-radius: 8px;
height: 460px;
overflow: auto;
padding: 12px;
}
.message {
border-radius: 8px;
margin-bottom: 10px;
padding: 10px;
white-space: pre-wrap;
}
.message.user {
background: #dbeafe;
}
.message.assistant {
background: #ecfdf5;
}
+94
View File
@@ -0,0 +1,94 @@
<template>
<div class="chat">
<div class="panel">
<div class="toolbar">
<h3>智能体</h3>
<el-button @click="loadAgents">刷新</el-button>
</div>
<el-table :data="agents" highlight-current-row @current-change="selectAgent">
<el-table-column prop="name" label="名称" />
<el-table-column prop="status" label="状态" width="100" />
</el-table>
</div>
<div class="panel">
<div class="toolbar">
<h3>{{ currentAgent?.name || 'Agent Chat' }}</h3>
</div>
<div class="chat-log">
<div v-for="(message, index) in messages" :key="index" class="message" :class="message.role">
<strong>{{ message.role === 'user' ? '我' : 'Agent' }}</strong>
<div>{{ message.content }}</div>
</div>
</div>
<el-input v-model="input" :rows="3" type="textarea" placeholder="输入任务或问题" style="margin-top: 12px" />
<el-button type="primary" :loading="sending" style="margin-top: 12px" @click="send">发送</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus';
import { onMounted, ref } from 'vue';
import { api } from '@/api/http';
const agents = ref<any[]>([]);
const currentAgent = ref<any>(null);
const input = ref('请给出这个系统下一步优化建议');
const messages = ref<{ role: string; content: string }[]>([]);
const sending = ref(false);
onMounted(loadAgents);
async function loadAgents() {
const data: any = await api.get('/ai/agents');
agents.value = data.items || [];
currentAgent.value ||= agents.value[0];
}
function selectAgent(row: any) {
currentAgent.value = row;
messages.value = [];
}
async function send() {
if (!currentAgent.value) return ElMessage.warning('请先选择智能体');
if (!input.value.trim()) return;
const userText = input.value;
input.value = '';
messages.value.push({ role: 'user', content: userText });
const assistant = { role: 'assistant', content: '' };
messages.value.push(assistant);
sending.value = true;
try {
const token = localStorage.getItem('ai-agent-admin-token');
const response = await fetch(`/ai-agent-admin/basic-api/api/ai/agents/${currentAgent.value.id}/chat`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: userText }),
});
if (!response.body) throw new Error('浏览器不支持流式读取');
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value);
for (const line of text.split('\n')) {
if (!line.startsWith('data: ')) continue;
const raw = line.slice(6);
if (raw === '[DONE]') continue;
const event = JSON.parse(raw);
if (event.type === 'chunk') assistant.content += event.content;
}
}
} catch (error: any) {
ElMessage.error(error.message || '发送失败');
} finally {
sending.value = false;
}
}
</script>
+34
View File
@@ -0,0 +1,34 @@
<template>
<div>
<div class="grid">
<div v-for="item in metrics" :key="item.label" class="metric">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<div class="panel" style="margin-top: 16px">
<h3>当前交付边界</h3>
<el-table :data="modules" border>
<el-table-column prop="name" label="模块" />
<el-table-column prop="scope" label="范围" />
<el-table-column prop="status" label="状态" width="120" />
</el-table>
</div>
</div>
</template>
<script setup lang="ts">
const metrics = [
{ label: '基础 Admin', value: '5' },
{ label: 'AI 模块', value: '8' },
{ label: '前端架构', value: 'Vite' },
{ label: '构建模式', value: '单应用' },
];
const modules = [
{ name: '认证/RBAC', scope: '登录、用户、角色、菜单、权限、公告', status: '可用' },
{ name: 'AI Agent', scope: 'Provider、Model、Agent、Chat SSE', status: '可用' },
{ name: 'Workflow', scope: 'JSON 定义、发布、运行记录', status: '可用' },
{ name: 'Agent Team', scope: '顺序多 Agent 协作和运行记录', status: '可用' },
];
</script>
+42
View File
@@ -0,0 +1,42 @@
<template>
<div class="login-page">
<el-form class="login-box" @submit.prevent="submit">
<h2>AI Agent Admin</h2>
<el-form-item>
<el-input v-model="username" placeholder="用户名或邮箱" size="large" />
</el-form-item>
<el-form-item>
<el-input v-model="password" placeholder="密码" show-password size="large" />
</el-form-item>
<el-button type="primary" native-type="submit" size="large" :loading="loading" style="width: 100%">
登录
</el-button>
</el-form>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from 'element-plus';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
const username = ref('admin');
const password = ref('admin123456');
const loading = ref(false);
const auth = useAuthStore();
const router = useRouter();
async function submit() {
loading.value = true;
try {
await auth.login(username.value, password.value);
router.push('/');
} catch (error: any) {
ElMessage.error(error.response?.data?.detail || '登录失败');
} finally {
loading.value = false;
}
}
</script>
+132
View File
@@ -0,0 +1,132 @@
<template>
<div class="panel">
<div class="toolbar">
<h3>{{ config.title }}</h3>
<div>
<el-button v-if="resource === 'workflows'" @click="runSelected">运行</el-button>
<el-button v-if="resource === 'teams'" @click="runTeam">协作运行</el-button>
<el-button type="primary" @click="openCreate">新增</el-button>
</div>
</div>
<el-table :data="items" border highlight-current-row @current-change="current = $event">
<el-table-column prop="id" label="ID" width="220" />
<el-table-column v-for="field in visibleFields" :key="field.key" :prop="field.key" :label="field.label" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="120" />
<el-table-column label="操作" width="150">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="editing?.id ? '编辑' : '新增'" width="680px">
<el-form label-width="120px">
<el-form-item v-for="field in config.fields" :key="field.key" :label="field.label">
<el-switch v-if="field.type === 'boolean'" v-model="form[field.key]" />
<el-input-number v-else-if="field.type === 'number'" v-model="form[field.key]" style="width: 100%" />
<el-input v-else-if="field.type === 'textarea' || field.type === 'json'" v-model="form[field.key]" :rows="field.type === 'json' ? 8 : 3" type="textarea" />
<el-input v-else v-model="form[field.key]" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="save">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '@/api/http';
import { deleteResource, listResource, resources, saveResource } from '@/api/resources';
const route = useRoute();
const resource = computed(() => String(route.params.resource || 'users'));
const config = computed(() => resources[resource.value]);
const items = ref<any[]>([]);
const current = ref<any>(null);
const dialogVisible = ref(false);
const editing = ref<any>(null);
const form = reactive<Record<string, any>>({});
const visibleFields = computed(() => config.value.fields.filter((field) => !['api_key', 'password', 'tools', 'knowledge_base_ids', 'definition', 'input_variables', 'output_variables', 'members'].includes(field.key)).slice(0, 5));
watch(resource, load);
onMounted(load);
async function load() {
if (!config.value) return;
const data: any = await listResource(resource.value);
items.value = data.items || [];
}
function resetForm(row?: any) {
Object.keys(form).forEach((key) => delete form[key]);
for (const field of config.value.fields) {
const value = row?.[field.key];
if (field.type === 'json') {
form[field.key] = value === undefined ? '[]' : JSON.stringify(value, null, 2);
} else if (field.type === 'boolean') {
form[field.key] = value ?? false;
} else if (field.type === 'number') {
form[field.key] = value ?? 0;
} else {
form[field.key] = value ?? '';
}
}
}
function openCreate() {
editing.value = null;
resetForm();
dialogVisible.value = true;
}
function openEdit(row: any) {
editing.value = row;
resetForm(row);
dialogVisible.value = true;
}
async function save() {
const payload: Record<string, any> = {};
for (const field of config.value.fields) {
if (field.key === 'password' && !form[field.key]) continue;
if (field.type === 'json') {
payload[field.key] = form[field.key] ? JSON.parse(form[field.key]) : [];
} else {
payload[field.key] = form[field.key];
}
}
await saveResource(resource.value, payload, editing.value?.id);
dialogVisible.value = false;
ElMessage.success('已保存');
await load();
}
async function remove(row: any) {
await ElMessageBox.confirm(`确认删除 ${row.name || row.title || row.username || row.id}`);
await deleteResource(resource.value, row.id);
ElMessage.success('已删除');
await load();
}
async function runSelected() {
const row = current.value || items.value[0];
if (!row) return ElMessage.warning('请先选择工作流');
await api.post(`/ai/workflows/${row.id}/run`, { inputs: { task: '验证最小工作流运行' } });
ElMessage.success('工作流已运行');
}
async function runTeam() {
const row = current.value || items.value[0];
if (!row) return ElMessage.warning('请先选择团队');
await api.post(`/ai/teams/${row.id}/run`, { task: '请协作完成一个轻量后台建设验收建议' });
ElMessage.success('协作任务已运行');
}
</script>
+38
View File
@@ -0,0 +1,38 @@
<template>
<div class="panel">
<div class="toolbar">
<h3>{{ type === 'workflow' ? 'Workflow Runs' : 'Collaboration Runs' }}</h3>
<el-button @click="load">刷新</el-button>
</div>
<el-table :data="items" border>
<el-table-column prop="id" label="ID" width="220" />
<el-table-column v-if="type === 'workflow'" prop="workflow_id" label="Workflow ID" width="220" />
<el-table-column v-else prop="team_id" label="Team ID" width="220" />
<el-table-column prop="status" label="状态" width="120" />
<el-table-column prop="elapsed_time" label="耗时 ms" width="120" />
<el-table-column label="详情">
<template #default="{ row }">
<pre>{{ JSON.stringify(row.outputs || row.messages || row.final_answer, null, 2) }}</pre>
</template>
</el-table-column>
</el-table>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
import { api } from '@/api/http';
const props = defineProps<{ type: 'workflow' | 'collaboration' }>();
const items = ref<any[]>([]);
watch(() => props.type, load);
onMounted(load);
async function load() {
const path = props.type === 'workflow' ? '/ai/workflow-runs' : '/ai/collaboration-runs';
const data: any = await api.get(path);
items.value = data.items || [];
}
</script>
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+26
View File
@@ -0,0 +1,26 @@
import vue from '@vitejs/plugin-vue';
import { defineConfig } from 'vite';
import { fileURLToPath, URL } from 'node:url';
export default defineConfig({
base: '/ai-agent-admin/',
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
proxy: {
'/ai-agent-admin/basic-api': {
target: 'http://127.0.0.1:18083',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/ai-agent-admin\/basic-api/, ''),
},
},
},
build: {
sourcemap: false,
chunkSizeWarningLimit: 900,
},
});