Initial lightweight AI agent admin
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
.venv
|
||||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
.DS_Store
|
||||
@@ -0,0 +1,8 @@
|
||||
# ai-agent-admin
|
||||
|
||||
轻量化 AI Agent 管理后台,保留基础 Admin 能力,并提供 Provider、Model、Agent、Workflow、Knowledge Base 和 Agent Team 协作模块。
|
||||
|
||||
默认管理员:
|
||||
|
||||
- 用户名:admin
|
||||
- 密码:admin123456
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
services:
|
||||
backend:
|
||||
build: ./backend
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./backend/.env
|
||||
ports:
|
||||
- "127.0.0.1:18083:18083"
|
||||
@@ -0,0 +1,2 @@
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
Generated
+2104
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
@@ -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}`);
|
||||
}
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue';
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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');
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user