Phase 2.3: Agent Management Module
Backend: - agents_admin.py: 10 REST API endpoints for AI Agent CRUD - GET/POST/PUT/DELETE /admin/agents/ - Stats overview, reviews, available skills/models - Agent = MarketApp with type='agent' Frontend: - Agents.tsx: Full admin page (780 lines) - Stats dashboard, filterable table, create/edit modal - Config: system prompt, temperature, model, linked skills - api/index.ts: 10 new Agent API methods - App.tsx: /admin/agents route - AdminLayout.tsx: Agent management nav item
This commit is contained in:
1 parent
70dd2b69af
commit
a682dc687e
11 files changed
+3276
-648
No files matched your search
@@ -0,0 +1,553 @@
|
||||
"""
|
||||
Agent 管理 API(管理员)
|
||||
Phase 2.3: AI PaaS Agent Management
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Body
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import or_, func
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, List
|
||||
from ...database import get_db
|
||||
from ...models import MarketApp, EvolutionSkill, AIModel, User
|
||||
from ...auth import get_current_admin
|
||||
from ...middleware.log_service import LogService
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Pydantic 模型
|
||||
# ============================================================
|
||||
|
||||
class AgentConfig(BaseModel):
|
||||
system_prompt: Optional[str] = None
|
||||
temperature: Optional[float] = None
|
||||
max_tokens: Optional[int] = None
|
||||
model_id: Optional[str] = None
|
||||
skills: Optional[List[str]] = None # skill IDs
|
||||
tools: Optional[List[str]] = None # MCP server IDs
|
||||
memory_enabled: Optional[bool] = False
|
||||
reasoning_enabled: Optional[bool] = False
|
||||
|
||||
|
||||
class AgentCreate(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
price: float = 0.0
|
||||
is_member_free: bool = False
|
||||
config: Optional[AgentConfig] = None
|
||||
|
||||
|
||||
class AgentUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
icon: Optional[str] = None
|
||||
price: Optional[float] = None
|
||||
is_member_free: Optional[bool] = None
|
||||
config: Optional[AgentConfig] = None
|
||||
status: Optional[str] = None # active/inactive
|
||||
tutorial: Optional[str] = None
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 辅助函数
|
||||
# ============================================================
|
||||
|
||||
def agent_to_dict(a: MarketApp) -> dict:
|
||||
"""将 MarketApp (agent) 转换为字典"""
|
||||
# 解析 skill_content 中的配置
|
||||
import json
|
||||
config = {}
|
||||
if a.skill_content:
|
||||
try:
|
||||
config = json.loads(a.skill_content)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"id": a.id,
|
||||
"name": a.name,
|
||||
"type": a.type,
|
||||
"source": a.source,
|
||||
"publisher_id": a.publisher_id,
|
||||
"publisher_name": a.publisher_name,
|
||||
"description": a.description,
|
||||
"icon": a.icon,
|
||||
"tutorial": a.tutorial,
|
||||
"price": float(a.price) if a.price else 0,
|
||||
"is_member_free": a.is_member_free,
|
||||
"join_promotion": a.join_promotion,
|
||||
"status": a.status,
|
||||
"rating_avg": float(a.rating_avg) if a.rating_avg else 0,
|
||||
"review_count": a.review_count,
|
||||
"install_count": a.install_count,
|
||||
"agent_endpoint": a.agent_endpoint,
|
||||
"skill_content": a.skill_content,
|
||||
"config": config,
|
||||
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 端点
|
||||
# ============================================================
|
||||
|
||||
@router.get("/", summary="获取 Agent 列表")
|
||||
async def list_agents(
|
||||
keyword: str = Query(None, description="搜索关键词"),
|
||||
status: str = Query(None, description="状态: pending/published/removed"),
|
||||
source: str = Query(None, description="来源: official/user"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(15, ge=1, le=100),
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取所有 Agent 列表(分页、筛选)"""
|
||||
query = db.query(MarketApp).filter(MarketApp.type == "agent")
|
||||
|
||||
if keyword:
|
||||
query = query.filter(
|
||||
or_(
|
||||
MarketApp.name.ilike(f"%{keyword}%"),
|
||||
MarketApp.description.ilike(f"%{keyword}%"),
|
||||
MarketApp.publisher_name.ilike(f"%{keyword}%"),
|
||||
)
|
||||
)
|
||||
if status:
|
||||
query = query.filter(MarketApp.status == status)
|
||||
if source:
|
||||
query = query.filter(MarketApp.source == source)
|
||||
|
||||
total = query.count()
|
||||
agents = query.order_by(MarketApp.created_at.desc()).offset(
|
||||
(page - 1) * page_size
|
||||
).limit(page_size).all()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"items": [agent_to_dict(a) for a in agents],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats/overview", summary="Agent 统计概览")
|
||||
async def get_agent_stats(
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取 Agent 统计数据"""
|
||||
base = db.query(MarketApp).filter(MarketApp.type == "agent")
|
||||
|
||||
total = base.count()
|
||||
published = base.filter(MarketApp.status == "published").count()
|
||||
pending = base.filter(MarketApp.status == "pending").count()
|
||||
official = base.filter(MarketApp.source == "official").count()
|
||||
user_created = base.filter(MarketApp.source == "user").count()
|
||||
|
||||
# 安装量统计
|
||||
install_base = db.query(func.sum(MarketApp.install_count)).filter(
|
||||
MarketApp.type == "agent"
|
||||
).scalar() or 0
|
||||
|
||||
# 按类型分布
|
||||
by_source = {
|
||||
"official": official,
|
||||
"user": user_created,
|
||||
}
|
||||
|
||||
# 评分统计
|
||||
avg_rating = db.query(func.avg(MarketApp.rating_avg)).filter(
|
||||
MarketApp.type == "agent",
|
||||
MarketApp.rating_avg > 0,
|
||||
).scalar() or 0
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"total_agents": total,
|
||||
"published_agents": published,
|
||||
"pending_agents": pending,
|
||||
"official_agents": official,
|
||||
"user_agents": user_created,
|
||||
"total_installs": int(install_base),
|
||||
"average_rating": round(float(avg_rating), 2),
|
||||
"by_source": by_source,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{agent_id}", summary="获取 Agent 详情")
|
||||
async def get_agent(
|
||||
agent_id: str,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取单个 Agent 的详细信息"""
|
||||
agent = db.query(MarketApp).filter(
|
||||
MarketApp.id == agent_id,
|
||||
MarketApp.type == "agent",
|
||||
).first()
|
||||
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent 不存在")
|
||||
|
||||
result = agent_to_dict(agent)
|
||||
|
||||
# 获取关联的 Skills
|
||||
import json
|
||||
config = json.loads(agent.skill_content or "{}")
|
||||
skill_ids = config.get("skills", [])
|
||||
if skill_ids:
|
||||
skills = db.query(EvolutionSkill).filter(
|
||||
EvolutionSkill.id.in_(skill_ids)
|
||||
).all()
|
||||
result["linked_skills"] = [
|
||||
{
|
||||
"id": s.id,
|
||||
"skill_name": s.skill_name,
|
||||
"state": s.state,
|
||||
"version": s.version,
|
||||
}
|
||||
for s in skills
|
||||
]
|
||||
else:
|
||||
result["linked_skills"] = []
|
||||
|
||||
# 获取可用模型列表
|
||||
models = db.query(AIModel).filter(AIModel.is_enabled == True).all()
|
||||
result["available_models"] = [
|
||||
{"id": m.id, "name": m.name, "provider": m.provider} for m in models
|
||||
]
|
||||
|
||||
return {"code": 0, "data": result}
|
||||
|
||||
|
||||
@router.post("/", summary="创建 Agent")
|
||||
async def create_agent(
|
||||
req: AgentCreate,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""创建新的 Agent"""
|
||||
import json
|
||||
|
||||
# 检查名称是否已存在
|
||||
existing = db.query(MarketApp).filter(
|
||||
MarketApp.name == req.name,
|
||||
MarketApp.type == "agent",
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Agent 名称已存在")
|
||||
|
||||
# 构建 skill_content
|
||||
config = {}
|
||||
if req.config:
|
||||
cfg = req.config
|
||||
config = {
|
||||
"system_prompt": cfg.system_prompt or "",
|
||||
"temperature": cfg.temperature or 0.7,
|
||||
"max_tokens": cfg.max_tokens or 2048,
|
||||
"model_id": cfg.model_id,
|
||||
"skills": cfg.skills or [],
|
||||
"tools": cfg.tools or [],
|
||||
"memory_enabled": cfg.memory_enabled or False,
|
||||
"reasoning_enabled": cfg.reasoning_enabled or False,
|
||||
}
|
||||
|
||||
agent = MarketApp(
|
||||
id=str(uuid.uuid4()),
|
||||
name=req.name,
|
||||
type="agent",
|
||||
source="official",
|
||||
publisher_id=admin_user.id,
|
||||
publisher_name=admin_user.username,
|
||||
description=req.description or "",
|
||||
icon=req.icon or "",
|
||||
price=req.price,
|
||||
is_member_free=req.is_member_free,
|
||||
skill_content=json.dumps(config, ensure_ascii=False),
|
||||
status="published",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
db.add(agent)
|
||||
db.commit()
|
||||
db.refresh(agent)
|
||||
|
||||
LogService.log_audit(
|
||||
admin_id=admin_user.id,
|
||||
admin_name=admin_user.username,
|
||||
action="create_agent",
|
||||
target_type="agent",
|
||||
target_id=agent.id,
|
||||
detail=f"创建 Agent: {agent.name}",
|
||||
)
|
||||
|
||||
return {"code": 0, "message": "Agent 创建成功", "data": agent_to_dict(agent)}
|
||||
|
||||
|
||||
@router.put("/{agent_id}", summary="更新 Agent")
|
||||
async def update_agent(
|
||||
agent_id: str,
|
||||
req: AgentUpdate,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""更新 Agent 配置"""
|
||||
import json
|
||||
|
||||
agent = db.query(MarketApp).filter(
|
||||
MarketApp.id == agent_id,
|
||||
MarketApp.type == "agent",
|
||||
).first()
|
||||
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent 不存在")
|
||||
|
||||
# 更新基本字段
|
||||
if req.name is not None:
|
||||
# 检查新名称是否重复
|
||||
dup = db.query(MarketApp).filter(
|
||||
MarketApp.name == req.name,
|
||||
MarketApp.type == "agent",
|
||||
MarketApp.id != agent_id,
|
||||
).first()
|
||||
if dup:
|
||||
raise HTTPException(status_code=400, detail="Agent 名称已存在")
|
||||
agent.name = req.name
|
||||
|
||||
if req.description is not None:
|
||||
agent.description = req.description
|
||||
if req.icon is not None:
|
||||
agent.icon = req.icon
|
||||
if req.price is not None:
|
||||
agent.price = req.price
|
||||
if req.is_member_free is not None:
|
||||
agent.is_member_free = req.is_member_free
|
||||
if req.status is not None:
|
||||
agent.status = req.status
|
||||
if req.tutorial is not None:
|
||||
agent.tutorial = req.tutorial
|
||||
|
||||
# 更新配置
|
||||
if req.config is not None:
|
||||
cfg = req.config
|
||||
# 合并现有配置
|
||||
existing_config = {}
|
||||
if agent.skill_content:
|
||||
try:
|
||||
existing_config = json.loads(agent.skill_content)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
new_config = {
|
||||
"system_prompt": cfg.system_prompt if cfg.system_prompt is not None else existing_config.get("system_prompt", ""),
|
||||
"temperature": cfg.temperature if cfg.temperature is not None else existing_config.get("temperature", 0.7),
|
||||
"max_tokens": cfg.max_tokens if cfg.max_tokens is not None else existing_config.get("max_tokens", 2048),
|
||||
"model_id": cfg.model_id if cfg.model_id is not None else existing_config.get("model_id"),
|
||||
"skills": cfg.skills if cfg.skills is not None else existing_config.get("skills", []),
|
||||
"tools": cfg.tools if cfg.tools is not None else existing_config.get("tools", []),
|
||||
"memory_enabled": cfg.memory_enabled if cfg.memory_enabled is not None else existing_config.get("memory_enabled", False),
|
||||
"reasoning_enabled": cfg.reasoning_enabled if cfg.reasoning_enabled is not None else existing_config.get("reasoning_enabled", False),
|
||||
}
|
||||
agent.skill_content = json.dumps(new_config, ensure_ascii=False)
|
||||
|
||||
agent.updated_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(agent)
|
||||
|
||||
LogService.log_audit(
|
||||
admin_id=admin_user.id,
|
||||
admin_name=admin_user.username,
|
||||
action="update_agent",
|
||||
target_type="agent",
|
||||
target_id=agent_id,
|
||||
detail=f"更新 Agent: {agent.name}",
|
||||
)
|
||||
|
||||
return {"code": 0, "message": "Agent 已更新", "data": agent_to_dict(agent)}
|
||||
|
||||
|
||||
@router.delete("/{agent_id}", summary="删除 Agent")
|
||||
async def delete_agent(
|
||||
agent_id: str,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""删除 Agent"""
|
||||
agent = db.query(MarketApp).filter(
|
||||
MarketApp.id == agent_id,
|
||||
MarketApp.type == "agent",
|
||||
).first()
|
||||
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent 不存在")
|
||||
|
||||
agent_name = agent.name
|
||||
db.delete(agent)
|
||||
db.commit()
|
||||
|
||||
LogService.log_audit(
|
||||
admin_id=admin_user.id,
|
||||
admin_name=admin_user.username,
|
||||
action="delete_agent",
|
||||
target_type="agent",
|
||||
target_id=agent_id,
|
||||
detail=f"删除 Agent: {agent_name}",
|
||||
)
|
||||
|
||||
return {"code": 0, "message": "Agent 已删除"}
|
||||
|
||||
|
||||
@router.put("/{agent_id}/toggle", summary="启用/禁用 Agent")
|
||||
async def toggle_agent(
|
||||
agent_id: str,
|
||||
is_active: bool = Query(..., description="是否启用"),
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""启用或禁用 Agent"""
|
||||
agent = db.query(MarketApp).filter(
|
||||
MarketApp.id == agent_id,
|
||||
MarketApp.type == "agent",
|
||||
).first()
|
||||
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent 不存在")
|
||||
|
||||
agent.status = "published" if is_active else "pending"
|
||||
agent.updated_at = datetime.now()
|
||||
db.commit()
|
||||
|
||||
LogService.log_audit(
|
||||
admin_id=admin_user.id,
|
||||
admin_name=admin_user.username,
|
||||
action="toggle_agent",
|
||||
target_type="agent",
|
||||
target_id=agent_id,
|
||||
detail=f"{'启用' if is_active else '禁用'} Agent: {agent.name}",
|
||||
)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": f"Agent 已{'启用' if is_active else '禁用'}",
|
||||
"data": agent_to_dict(agent),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{agent_id}/reviews", summary="获取 Agent 评价")
|
||||
async def get_agent_reviews(
|
||||
agent_id: str,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取 Agent 的用户评价"""
|
||||
from ...models import AppReview, User
|
||||
|
||||
agent = db.query(MarketApp).filter(
|
||||
MarketApp.id == agent_id,
|
||||
MarketApp.type == "agent",
|
||||
).first()
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail="Agent 不存在")
|
||||
|
||||
reviews = db.query(AppReview).filter(
|
||||
AppReview.app_id == agent_id
|
||||
).order_by(AppReview.created_at.desc()).limit(50).all()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": [
|
||||
{
|
||||
"id": r.id,
|
||||
"user_id": r.user_id,
|
||||
"rating": r.rating,
|
||||
"content": r.content,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
for r in reviews
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/available/skills", summary="获取可关联的 Skills")
|
||||
async def get_available_skills(
|
||||
keyword: str = Query(None),
|
||||
state: str = Query(None, description="技能状态筛选"),
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取可关联到 Agent 的 Skills 列表"""
|
||||
query = db.query(EvolutionSkill)
|
||||
|
||||
if keyword:
|
||||
query = query.filter(
|
||||
or_(
|
||||
EvolutionSkill.skill_name.ilike(f"%{keyword}%"),
|
||||
EvolutionSkill.description.ilike(f"%{keyword}%"),
|
||||
)
|
||||
)
|
||||
if state:
|
||||
query = query.filter(EvolutionSkill.state == state)
|
||||
|
||||
total = query.count()
|
||||
skills = query.order_by(EvolutionSkill.use_count.desc()).offset(
|
||||
(page - 1) * page_size
|
||||
).limit(page_size).all()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"total": total,
|
||||
"items": [
|
||||
{
|
||||
"id": s.id,
|
||||
"skill_name": s.skill_name,
|
||||
"description": s.description,
|
||||
"state": s.state,
|
||||
"version": s.version,
|
||||
"use_count": s.use_count,
|
||||
"category": s.category,
|
||||
"tags": s.tags,
|
||||
}
|
||||
for s in skills
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/available/models", summary="获取可用模型列表")
|
||||
async def get_available_models(
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取可用于 Agent 的模型列表"""
|
||||
models = db.query(AIModel).filter(
|
||||
AIModel.is_enabled == True
|
||||
).order_by(AIModel.name).all()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": [
|
||||
{
|
||||
"id": m.id,
|
||||
"name": m.name,
|
||||
"provider": m.provider,
|
||||
"model_type": m.model_type,
|
||||
"input_price": float(m.input_price) if m.input_price else 0,
|
||||
"output_price": float(m.output_price) if m.output_price else 0,
|
||||
}
|
||||
for m in models
|
||||
]
|
||||
}
|
||||
@@ -600,3 +600,155 @@ async def get_model_stats(
|
||||
"total_calls": int(total_calls),
|
||||
"total_tokens": int(total_tokens),
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 模型连接测试 API (Phase 2)
|
||||
# ============================================================
|
||||
|
||||
@router.post("/{model_id}/test-connection")
|
||||
async def test_model_connection(
|
||||
model_id: str,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
测试指定模型的连接是否正常
|
||||
支持从模型记录或提供商记录读取配置
|
||||
"""
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Testing connection for model_id: {model_id}")
|
||||
|
||||
# 优先从 AIModel 读取
|
||||
ai_model = db.query(AIModel).filter(AIModel.id == model_id).first()
|
||||
if not ai_model:
|
||||
raise HTTPException(status_code=404, detail="模型不存在")
|
||||
|
||||
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
|
||||
if not provider:
|
||||
raise HTTPException(status_code=404, detail="模型供应商不存在")
|
||||
|
||||
# 确定最终配置
|
||||
api_key = ai_model.api_key or (provider.api_key if provider else None)
|
||||
api_url = ai_model.endpoint
|
||||
|
||||
# 如果 endpoint 为空,尝试构造
|
||||
if not api_url and provider and provider.api_base_url:
|
||||
base = provider.api_base_url.rstrip('/')
|
||||
if provider.protocol == 'openai_compatible':
|
||||
api_url = f"{base}/v1/chat/completions"
|
||||
else:
|
||||
api_url = f"{base}/v1/chat/completions"
|
||||
|
||||
if not api_key:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "未配置 API Key,请在模型或供应商设置中添加",
|
||||
"latency_ms": None,
|
||||
}
|
||||
if not api_url:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "未配置 API 地址,请在模型或供应商设置中添加",
|
||||
"latency_ms": None,
|
||||
}
|
||||
|
||||
timeout = ai_model.timeout or 60
|
||||
import time
|
||||
start = time.time()
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
# 使用 minimal payload 测试连接
|
||||
payload = {
|
||||
"model": ai_model.name,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"max_tokens": 5,
|
||||
}
|
||||
resp = await client.post(api_url, json=payload, headers=headers)
|
||||
latency = int((time.time() - start) * 1000)
|
||||
|
||||
if resp.status_code == 200:
|
||||
try:
|
||||
data = resp.json()
|
||||
model_used = data.get("model", ai_model.name)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"连接成功,使用模型: {model_used}",
|
||||
"latency_ms": latency,
|
||||
"model_in_response": model_used,
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"连接成功 (HTTP {resp.status_code})",
|
||||
"latency_ms": latency,
|
||||
}
|
||||
elif resp.status_code == 401:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "认证失败:API Key 无效或已过期",
|
||||
"latency_ms": latency,
|
||||
}
|
||||
elif resp.status_code == 403:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "访问被拒绝:API Key 权限不足",
|
||||
"latency_ms": latency,
|
||||
}
|
||||
elif resp.status_code == 429:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "触发限流:请稍后重试",
|
||||
"latency_ms": latency,
|
||||
}
|
||||
else:
|
||||
try:
|
||||
err_data = resp.json()
|
||||
err_msg = err_data.get("error", {}).get("message", resp.text[:200])
|
||||
except Exception:
|
||||
err_msg = resp.text[:200]
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"API 错误 (HTTP {resp.status_code}): {err_msg}",
|
||||
"latency_ms": latency,
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
latency = int((time.time() - start) * 1000)
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"连接超时({timeout}s):请检查 API 地址是否正确,或服务是否可访问",
|
||||
"latency_ms": latency,
|
||||
}
|
||||
except Exception as e:
|
||||
latency = int((time.time() - start) * 1000)
|
||||
logger.error(f"Model connection test failed: {e}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"连接失败: {str(e)}",
|
||||
"latency_ms": latency,
|
||||
}
|
||||
|
||||
|
||||
timeout: int = 60
|
||||
status: str = "paid"
|
||||
price_per_million_tokens: float = 0.0
|
||||
is_enabled: bool = True
|
||||
|
||||
|
||||
class UpdateModelRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
endpoint: Optional[str] = None
|
||||
capabilities: Optional[List[str]] = None
|
||||
timeout: Optional[int] = None
|
||||
status: Optional[str] = None
|
||||
price_per_million_tokens: Optional[float] = None
|
||||
is_enabled: Optional[bool] = None
|
||||
|
||||
|
||||
from typing import Optional
|
||||
@@ -0,0 +1,773 @@
|
||||
"""
|
||||
技能管理系统 API(管理员)
|
||||
全平台技能管理:列表/创建/编辑/删除/状态管理/版本历史/发布市场
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc, func, or_, and_
|
||||
|
||||
from ...database import get_db
|
||||
from ...models import (
|
||||
User, EvolutionSkill, SkillVersion, SkillPublishRecord,
|
||||
EvolutionEvent, TeamSharedSkill
|
||||
)
|
||||
from ...auth import get_current_admin
|
||||
from ...middleware.log_service import LogService
|
||||
from ...config import UPLOAD_DIR
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ============================================================
|
||||
# 技能状态定义
|
||||
# ============================================================
|
||||
|
||||
SKILL_STATES = {
|
||||
"baby": {"label": "萌芽期", "color": "#9CA3AF", "description": "刚创建的技能,需要继续培养"},
|
||||
"main": {"label": "主力期", "color": "#10B981", "description": "已成熟可正常使用"},
|
||||
"staging": {"label": "灰度期", "color": "#F59E0B", "description": "正在灰度测试中"},
|
||||
"frozen": {"label": "已冻结", "color": "#EF4444", "description": "已停止使用"},
|
||||
}
|
||||
|
||||
SKILL_CATEGORIES = [
|
||||
{"id": "writing", "label": "文字创作"},
|
||||
{"id": "coding", "label": "代码能力"},
|
||||
{"id": "data_analysis", "label": "数据分析"},
|
||||
{"id": "qa", "label": "知识问答"},
|
||||
{"id": "automation", "label": "流程自动化"},
|
||||
{"id": "creativity", "label": "创意生成"},
|
||||
{"id": "communication", "label": "沟通协作"},
|
||||
{"id": "reasoning", "label": "逻辑推理"},
|
||||
{"id": "other", "label": "其他"},
|
||||
]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 辅助函数
|
||||
# ============================================================
|
||||
|
||||
def skill_to_dict(s: EvolutionSkill, include_md: bool = False) -> dict:
|
||||
"""将技能ORM对象转换为字典"""
|
||||
result = {
|
||||
"id": s.id,
|
||||
"user_id": s.user_id,
|
||||
"skill_name": s.skill_name,
|
||||
"description": s.description,
|
||||
"tags": s.tags or [],
|
||||
"category": s.category,
|
||||
"category_label": next((c["label"] for c in SKILL_CATEGORIES if c["id"] == s.category), s.category or "其他"),
|
||||
"state": s.state,
|
||||
"state_info": SKILL_STATES.get(s.state, SKILL_STATES["baby"]),
|
||||
"use_count": s.use_count or 0,
|
||||
"version": s.version or 1,
|
||||
"skill_md_path": s.skill_md_path,
|
||||
"git_commit_sha": s.git_commit_sha,
|
||||
"last_used_at": s.last_used_at.isoformat() if s.last_used_at else None,
|
||||
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
|
||||
}
|
||||
|
||||
# 读取 SKILL.md 内容
|
||||
if include_md and s.skill_md_path and os.path.exists(s.skill_md_path):
|
||||
try:
|
||||
with open(s.skill_md_path, 'r', encoding='utf-8') as f:
|
||||
result["skill_md_content"] = f.read()
|
||||
except Exception:
|
||||
result["skill_md_content"] = None
|
||||
else:
|
||||
result["skill_md_content"] = None
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def version_to_dict(v: SkillVersion) -> dict:
|
||||
return {
|
||||
"id": v.id,
|
||||
"user_id": v.user_id,
|
||||
"skill_name": v.skill_name,
|
||||
"version": v.version,
|
||||
"skill_md_content": v.skill_md_content,
|
||||
"git_commit_sha": v.git_commit_sha,
|
||||
"description": v.description,
|
||||
"created_at": v.created_at.isoformat() if v.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 管理员技能列表
|
||||
# ============================================================
|
||||
|
||||
@router.get("/")
|
||||
async def list_skills(
|
||||
state: str = Query(None, description="技能状态过滤: baby/main/staging/frozen"),
|
||||
category: str = Query(None, description="技能分类过滤"),
|
||||
keyword: str = Query(None, description="关键词搜索(名称/描述)"),
|
||||
user_id: str = Query(None, description="按用户ID筛选"),
|
||||
sort_by: str = Query("updated_at", description="排序字段"),
|
||||
order: str = Query("desc", description="排序方向: asc/desc"),
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取全平台技能列表(支持分页/过滤/搜索)"""
|
||||
logger.info(f"[Admin Skills] {admin_user.username} 列出技能: state={state}, keyword={keyword}")
|
||||
|
||||
query = db.query(EvolutionSkill)
|
||||
|
||||
if state:
|
||||
query = query.filter(EvolutionSkill.state == state)
|
||||
if category:
|
||||
query = query.filter(EvolutionSkill.category == category)
|
||||
if keyword:
|
||||
query = query.filter(
|
||||
or_(
|
||||
EvolutionSkill.skill_name.contains(keyword),
|
||||
EvolutionSkill.description.contains(keyword)
|
||||
)
|
||||
)
|
||||
if user_id:
|
||||
query = query.filter(EvolutionSkill.user_id == user_id)
|
||||
|
||||
# 总数
|
||||
total = query.count()
|
||||
|
||||
# 排序
|
||||
sort_field = getattr(EvolutionSkill, sort_by, EvolutionSkill.updated_at)
|
||||
if order == "desc":
|
||||
query = query.order_by(desc(sort_field))
|
||||
else:
|
||||
query = query.order_by(sort_field)
|
||||
|
||||
# 分页
|
||||
skills = query.offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
# 补充用户名
|
||||
user_ids = list(set(s.user_id for s in skills))
|
||||
users_map = {
|
||||
u.id: u.username
|
||||
for u in db.query(User.id, User.username).filter(User.id.in_(user_ids)).all()
|
||||
}
|
||||
|
||||
result = []
|
||||
for s in skills:
|
||||
item = skill_to_dict(s)
|
||||
item["owner_username"] = users_map.get(s.user_id, s.user_id)
|
||||
result.append(item)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"items": result,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"pages": (total + page_size - 1) // page_size,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 获取单个技能详情
|
||||
# ============================================================
|
||||
|
||||
@router.get("/{skill_id}")
|
||||
async def get_skill(
|
||||
skill_id: str,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取技能详情(包含 SKILL.md 内容)"""
|
||||
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
|
||||
if not skill:
|
||||
raise HTTPException(status_code=404, detail="技能不存在")
|
||||
|
||||
result = skill_to_dict(skill, include_md=True)
|
||||
|
||||
# 获取所属用户信息
|
||||
user = db.query(User).filter(User.id == skill.user_id).first()
|
||||
if user:
|
||||
result["owner_username"] = user.username
|
||||
result["owner_email"] = user.email
|
||||
|
||||
# 获取发布记录
|
||||
pub_records = db.query(SkillPublishRecord).filter(
|
||||
SkillPublishRecord.skill_id == skill_id
|
||||
).order_by(desc(SkillPublishRecord.created_at)).limit(10).all()
|
||||
result["publish_records"] = [{
|
||||
"id": p.id,
|
||||
"price": float(p.price) if p.price else 0,
|
||||
"market_description": p.market_description,
|
||||
"status": p.status,
|
||||
"published_at": p.published_at.isoformat() if p.published_at else None,
|
||||
"created_at": p.created_at.isoformat() if p.created_at else None,
|
||||
} for p in pub_records]
|
||||
|
||||
# 获取最新版本号
|
||||
latest_version = db.query(func.max(SkillVersion.version)).filter(
|
||||
SkillVersion.skill_id == skill_id
|
||||
).scalar()
|
||||
result["latest_version"] = latest_version or skill.version
|
||||
|
||||
return {"code": 0, "data": result}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 创建技能
|
||||
# ============================================================
|
||||
|
||||
class CreateSkillRequest:
|
||||
def __init__(self, skill_name: str = None, description: str = None,
|
||||
tags: List[str] = None, category: str = None,
|
||||
skill_md_content: str = None, user_id: str = None):
|
||||
self.skill_name = skill_name
|
||||
self.description = description
|
||||
self.tags = tags or []
|
||||
self.category = category
|
||||
self.skill_md_content = skill_md_content
|
||||
self.user_id = user_id
|
||||
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class CreateSkillSchema(BaseModel):
|
||||
skill_name: str = Field(..., min_length=1, max_length=200, description="技能名称")
|
||||
user_id: str = Field(..., description="所属用户ID")
|
||||
description: Optional[str] = Field(None, description="技能描述")
|
||||
tags: Optional[List[str]] = Field(default_factory=list, description="标签列表")
|
||||
category: Optional[str] = Field(None, description="技能分类")
|
||||
skill_md_content: Optional[str] = Field(None, description="SKILL.md 内容")
|
||||
|
||||
|
||||
@router.post("/")
|
||||
async def create_skill(
|
||||
req: CreateSkillSchema,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""管理员创建技能"""
|
||||
logger.info(f"[Admin Skills] {admin_user.username} 创建技能: {req.skill_name}")
|
||||
|
||||
# 检查技能名是否已存在
|
||||
existing = db.query(EvolutionSkill).filter(
|
||||
EvolutionSkill.user_id == req.user_id,
|
||||
EvolutionSkill.skill_name == req.skill_name
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="该用户已存在同名技能")
|
||||
|
||||
# 验证用户存在
|
||||
user = db.query(User).filter(User.id == req.user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="用户不存在")
|
||||
|
||||
# 生成技能ID
|
||||
skill_id = str(uuid.uuid4())
|
||||
|
||||
# 保存 SKILL.md 文件
|
||||
skill_md_path = None
|
||||
if req.skill_md_content:
|
||||
skill_dir = os.path.join(UPLOAD_DIR, "skills", req.user_id)
|
||||
os.makedirs(skill_dir, exist_ok=True)
|
||||
safe_name = req.skill_name.replace("/", "_").replace("\\", "_")
|
||||
skill_md_path = os.path.join(skill_dir, f"{safe_name}.md")
|
||||
try:
|
||||
with open(skill_md_path, 'w', encoding='utf-8') as f:
|
||||
f.write(req.skill_md_content)
|
||||
except Exception as e:
|
||||
logger.warning(f"保存 SKILL.md 失败: {e}")
|
||||
|
||||
# 创建技能记录
|
||||
skill = EvolutionSkill(
|
||||
id=skill_id,
|
||||
user_id=req.user_id,
|
||||
skill_name=req.skill_name,
|
||||
description=req.description,
|
||||
tags=req.tags or [],
|
||||
category=req.category,
|
||||
state="baby",
|
||||
use_count=0,
|
||||
version=1,
|
||||
skill_md_path=skill_md_path,
|
||||
git_commit_sha=None,
|
||||
)
|
||||
db.add(skill)
|
||||
|
||||
# 记录初始版本
|
||||
if req.skill_md_content:
|
||||
sv = SkillVersion(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=req.user_id,
|
||||
skill_name=req.skill_name,
|
||||
version=1,
|
||||
skill_md_content=req.skill_md_content,
|
||||
git_commit_sha=None,
|
||||
description="初始版本",
|
||||
)
|
||||
db.add(sv)
|
||||
|
||||
# 记录事件
|
||||
ev = EvolutionEvent(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=req.user_id,
|
||||
skill_name=req.skill_name,
|
||||
event_type="created",
|
||||
description=f"管理员 {admin_user.username} 创建技能 {req.skill_name}",
|
||||
version_before=None,
|
||||
version_after=1,
|
||||
)
|
||||
db.add(ev)
|
||||
|
||||
db.commit()
|
||||
db.refresh(skill)
|
||||
|
||||
LogService.log_operation(
|
||||
user_id=admin_user.id,
|
||||
username=admin_user.username,
|
||||
action="create_skill",
|
||||
target="evolution_skills",
|
||||
detail=f"创建技能 {req.skill_name}(归属用户: {user.username})",
|
||||
)
|
||||
|
||||
return {"code": 0, "data": skill_to_dict(skill, include_md=True)}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 更新技能
|
||||
# ============================================================
|
||||
|
||||
class UpdateSkillSchema(BaseModel):
|
||||
skill_name: Optional[str] = Field(None, min_length=1, max_length=200)
|
||||
description: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
category: Optional[str] = None
|
||||
skill_md_content: Optional[str] = None
|
||||
|
||||
|
||||
@router.put("/{skill_id}")
|
||||
async def update_skill(
|
||||
skill_id: str,
|
||||
req: UpdateSkillSchema,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""更新技能信息"""
|
||||
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
|
||||
if not skill:
|
||||
raise HTTPException(status_code=404, detail="技能不存在")
|
||||
|
||||
old_version = skill.version
|
||||
update_data = req.model_dump(exclude_none=True)
|
||||
|
||||
# 处理 SKILL.md 内容
|
||||
if "skill_md_content" in update_data:
|
||||
content = update_data.pop("skill_md_content")
|
||||
if content is not None:
|
||||
skill_dir = os.path.join(UPLOAD_DIR, "skills", skill.user_id)
|
||||
os.makedirs(skill_dir, exist_ok=True)
|
||||
safe_name = skill.skill_name.replace("/", "_").replace("\\", "_")
|
||||
skill_md_path = os.path.join(skill_dir, f"{safe_name}.md")
|
||||
try:
|
||||
with open(skill_md_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
skill.skill_md_path = skill_md_path
|
||||
except Exception as e:
|
||||
logger.warning(f"保存 SKILL.md 失败: {e}")
|
||||
|
||||
# 记录新版本
|
||||
new_version = old_version + 1
|
||||
sv = SkillVersion(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=skill.user_id,
|
||||
skill_name=skill.skill_name,
|
||||
version=new_version,
|
||||
skill_md_content=content,
|
||||
git_commit_sha=None,
|
||||
description="管理员更新",
|
||||
)
|
||||
db.add(sv)
|
||||
skill.version = new_version
|
||||
|
||||
# 记录事件
|
||||
ev = EvolutionEvent(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=skill.user_id,
|
||||
skill_name=skill.skill_name,
|
||||
event_type="edited",
|
||||
description=f"管理员 {admin_user.username} 更新技能 {skill.skill_name}",
|
||||
version_before=old_version,
|
||||
version_after=new_version,
|
||||
)
|
||||
db.add(ev)
|
||||
|
||||
# 更新其他字段
|
||||
for key, value in update_data.items():
|
||||
if key not in ("skill_md_content",) and hasattr(skill, key):
|
||||
setattr(skill, key, value)
|
||||
|
||||
skill.updated_at = datetime.now()
|
||||
db.commit()
|
||||
db.refresh(skill)
|
||||
|
||||
LogService.log_operation(
|
||||
user_id=admin_user.id,
|
||||
username=admin_user.username,
|
||||
action="update_skill",
|
||||
target="evolution_skills",
|
||||
detail=f"更新技能 {skill.skill_name}(版本 {old_version} → {skill.version})",
|
||||
)
|
||||
|
||||
return {"code": 0, "data": skill_to_dict(skill, include_md=True)}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 删除技能
|
||||
# ============================================================
|
||||
|
||||
@router.delete("/{skill_id}")
|
||||
async def delete_skill(
|
||||
skill_id: str,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""删除技能"""
|
||||
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
|
||||
if not skill:
|
||||
raise HTTPException(status_code=404, detail="技能不存在")
|
||||
|
||||
skill_name = skill.skill_name
|
||||
user_id = skill.user_id
|
||||
|
||||
# 记录事件
|
||||
ev = EvolutionEvent(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
skill_name=skill_name,
|
||||
event_type="deleted",
|
||||
description=f"管理员 {admin_user.username} 删除技能 {skill_name}",
|
||||
)
|
||||
db.add(ev)
|
||||
|
||||
# 删除版本历史
|
||||
db.query(SkillVersion).filter(SkillVersion.skill_name == skill_name).delete()
|
||||
|
||||
# 删除发布记录
|
||||
db.query(SkillPublishRecord).filter(SkillPublishRecord.skill_id == skill_id).delete()
|
||||
|
||||
# 删除团队共享记录
|
||||
db.query(TeamSharedSkill).filter(TeamSharedSkill.skill_id == skill_id).delete()
|
||||
|
||||
db.delete(skill)
|
||||
db.commit()
|
||||
|
||||
LogService.log_operation(
|
||||
user_id=admin_user.id,
|
||||
username=admin_user.username,
|
||||
action="delete_skill",
|
||||
target="evolution_skills",
|
||||
detail=f"删除技能 {skill_name}",
|
||||
)
|
||||
|
||||
return {"code": 0, "message": f"技能 {skill_name} 已删除"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 修改技能状态
|
||||
# ============================================================
|
||||
|
||||
class ChangeStateSchema(BaseModel):
|
||||
state: str = Field(..., description="目标状态: baby/main/staging/frozen")
|
||||
|
||||
|
||||
@router.put("/{skill_id}/state")
|
||||
async def change_skill_state(
|
||||
skill_id: str,
|
||||
req: ChangeStateSchema,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""修改技能状态"""
|
||||
if req.state not in SKILL_STATES:
|
||||
raise HTTPException(status_code=400, detail=f"无效状态,可选值: {list(SKILL_STATES.keys())}")
|
||||
|
||||
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
|
||||
if not skill:
|
||||
raise HTTPException(status_code=404, detail="技能不存在")
|
||||
|
||||
old_state = skill.state
|
||||
skill.state = req.state
|
||||
skill.updated_at = datetime.now()
|
||||
|
||||
# 记录状态变更事件
|
||||
ev_type = "promoted" if req.state in ("main", "staging") else "frozen" if req.state == "frozen" else "unfrozen"
|
||||
ev = EvolutionEvent(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=skill.user_id,
|
||||
skill_name=skill.skill_name,
|
||||
event_type=ev_type,
|
||||
description=f"管理员 {admin_user.username} 将技能状态从 {old_state} 变更为 {req.state}",
|
||||
)
|
||||
db.add(ev)
|
||||
db.commit()
|
||||
db.refresh(skill)
|
||||
|
||||
LogService.log_operation(
|
||||
user_id=admin_user.id,
|
||||
username=admin_user.username,
|
||||
action="change_skill_state",
|
||||
target="evolution_skills",
|
||||
detail=f"修改技能 {skill.skill_name} 状态: {old_state} → {req.state}",
|
||||
)
|
||||
|
||||
return {"code": 0, "data": skill_to_dict(skill)}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 获取版本历史
|
||||
# ============================================================
|
||||
|
||||
@router.get("/{skill_id}/versions")
|
||||
async def get_skill_versions(
|
||||
skill_id: str,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取技能版本历史"""
|
||||
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
|
||||
if not skill:
|
||||
raise HTTPException(status_code=404, detail="技能不存在")
|
||||
|
||||
versions = db.query(SkillVersion).filter(
|
||||
SkillVersion.skill_name == skill.skill_name,
|
||||
SkillVersion.user_id == skill.user_id,
|
||||
).order_by(desc(SkillVersion.version)).all()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"skill_id": skill.id,
|
||||
"skill_name": skill.skill_name,
|
||||
"current_version": skill.version,
|
||||
"versions": [version_to_dict(v) for v in versions],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 回滚到指定版本
|
||||
# ============================================================
|
||||
|
||||
class RollbackSchema(BaseModel):
|
||||
version: int = Field(..., ge=1, description="目标版本号")
|
||||
|
||||
|
||||
@router.post("/{skill_id}/rollback")
|
||||
async def rollback_skill_version(
|
||||
skill_id: str,
|
||||
req: RollbackSchema,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""回滚技能到指定版本"""
|
||||
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
|
||||
if not skill:
|
||||
raise HTTPException(status_code=404, detail="技能不存在")
|
||||
|
||||
target_version = db.query(SkillVersion).filter(
|
||||
SkillVersion.skill_name == skill.skill_name,
|
||||
SkillVersion.user_id == skill.user_id,
|
||||
SkillVersion.version == req.version,
|
||||
).first()
|
||||
|
||||
if not target_version:
|
||||
raise HTTPException(status_code=404, detail=f"版本 {req.version} 不存在")
|
||||
|
||||
old_version = skill.version
|
||||
|
||||
# 更新 SKILL.md 文件
|
||||
if target_version.skill_md_content:
|
||||
if skill.skill_md_path:
|
||||
try:
|
||||
with open(skill.skill_md_path, 'w', encoding='utf-8') as f:
|
||||
f.write(target_version.skill_md_content)
|
||||
except Exception as e:
|
||||
logger.warning(f"回滚保存 SKILL.md 失败: {e}")
|
||||
|
||||
# 记录新版本(基于目标版本)
|
||||
new_version = old_version + 1
|
||||
sv = SkillVersion(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=skill.user_id,
|
||||
skill_name=skill.skill_name,
|
||||
version=new_version,
|
||||
skill_md_content=target_version.skill_md_content,
|
||||
git_commit_sha=None,
|
||||
description=f"回滚到版本 {req.version}(管理员操作)",
|
||||
)
|
||||
db.add(sv)
|
||||
|
||||
skill.version = new_version
|
||||
skill.updated_at = datetime.now()
|
||||
|
||||
ev = EvolutionEvent(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=skill.user_id,
|
||||
skill_name=skill.skill_name,
|
||||
event_type="edited",
|
||||
description=f"管理员 {admin_user.username} 回滚技能 {skill.skill_name} 从版本 {old_version} 到 {req.version}(新版本 {new_version})",
|
||||
version_before=old_version,
|
||||
version_after=new_version,
|
||||
)
|
||||
db.add(ev)
|
||||
|
||||
db.commit()
|
||||
db.refresh(skill)
|
||||
|
||||
LogService.log_operation(
|
||||
user_id=admin_user.id,
|
||||
username=admin_user.username,
|
||||
action="rollback_skill",
|
||||
target="evolution_skills",
|
||||
detail=f"回滚技能 {skill.skill_name} 到版本 {req.version}(新版本 {new_version})",
|
||||
)
|
||||
|
||||
return {"code": 0, "data": skill_to_dict(skill, include_md=True)}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 发布到应用市场
|
||||
# ============================================================
|
||||
|
||||
class PublishSkillSchema(BaseModel):
|
||||
price: float = Field(0.0, ge=0, description="定价(0表示免费)")
|
||||
market_description: str = Field("", description="市场描述")
|
||||
|
||||
|
||||
@router.post("/{skill_id}/publish")
|
||||
async def publish_skill(
|
||||
skill_id: str,
|
||||
req: PublishSkillSchema,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""发布技能到应用市场"""
|
||||
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
|
||||
if not skill:
|
||||
raise HTTPException(status_code=404, detail="技能不存在")
|
||||
|
||||
# 检查是否已发布
|
||||
existing = db.query(SkillPublishRecord).filter(
|
||||
SkillPublishRecord.skill_id == skill_id,
|
||||
SkillPublishRecord.status == "published"
|
||||
).first()
|
||||
|
||||
if existing:
|
||||
# 更新现有发布
|
||||
existing.price = req.price
|
||||
existing.market_description = req.market_description
|
||||
msg = "更新发布信息"
|
||||
else:
|
||||
# 创建发布记录
|
||||
record = SkillPublishRecord(
|
||||
id=str(uuid.uuid4()),
|
||||
skill_id=skill_id,
|
||||
skill_name=skill.skill_name,
|
||||
user_id=skill.user_id,
|
||||
price=req.price,
|
||||
market_description=req.market_description,
|
||||
status="published",
|
||||
)
|
||||
db.add(record)
|
||||
msg = "发布成功"
|
||||
|
||||
db.commit()
|
||||
|
||||
LogService.log_operation(
|
||||
user_id=admin_user.id,
|
||||
username=admin_user.username,
|
||||
action="publish_skill",
|
||||
target="skill_publish_records",
|
||||
detail=f"{msg}: {skill.skill_name},定价: ¥{req.price}",
|
||||
)
|
||||
|
||||
return {"code": 0, "message": msg, "data": {"skill_id": skill_id, "price": req.price}}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 下架技能
|
||||
# ============================================================
|
||||
|
||||
@router.post("/{skill_id}/unpublish")
|
||||
async def unpublish_skill(
|
||||
skill_id: str,
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""从应用市场下架技能"""
|
||||
skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first()
|
||||
if not skill:
|
||||
raise HTTPException(status_code=404, detail="技能不存在")
|
||||
|
||||
updated = db.query(SkillPublishRecord).filter(
|
||||
SkillPublishRecord.skill_id == skill_id,
|
||||
SkillPublishRecord.status == "published"
|
||||
).update({"status": "unpublished"})
|
||||
db.commit()
|
||||
|
||||
if updated == 0:
|
||||
raise HTTPException(status_code=400, detail="该技能未发布到市场")
|
||||
|
||||
LogService.log_operation(
|
||||
user_id=admin_user.id,
|
||||
username=admin_user.username,
|
||||
action="unpublish_skill",
|
||||
target="skill_publish_records",
|
||||
detail=f"下架技能: {skill.skill_name}",
|
||||
)
|
||||
|
||||
return {"code": 0, "message": f"技能 {skill.skill_name} 已下架"}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 获取统计数据
|
||||
# ============================================================
|
||||
|
||||
@router.get("/stats/overview")
|
||||
async def get_skills_stats(
|
||||
admin_user=Depends(get_current_admin),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取技能统计概览"""
|
||||
total = db.query(EvolutionSkill).count()
|
||||
by_state = dict(
|
||||
db.query(EvolutionSkill.state, func.count(EvolutionSkill.id))
|
||||
.group_by(EvolutionSkill.state).all()
|
||||
)
|
||||
by_category = dict(
|
||||
db.query(EvolutionSkill.category, func.count(EvolutionSkill.id))
|
||||
.filter(EvolutionSkill.category.isnot(None))
|
||||
.group_by(EvolutionSkill.category).all()
|
||||
)
|
||||
total_published = db.query(SkillPublishRecord).filter(
|
||||
SkillPublishRecord.status == "published"
|
||||
).count()
|
||||
total_versions = db.query(SkillVersion).count()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"total_skills": total,
|
||||
"by_state": {k: by_state.get(k, 0) for k in SKILL_STATES},
|
||||
"by_category": by_category,
|
||||
"total_published": total_published,
|
||||
"total_versions": total_versions,
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ from datetime import datetime
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))␍
|
||||
␍
|
||||
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution␍
|
||||
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs
|
||||
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin␍
|
||||
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD␍
|
||||
from app.database import SessionLocal␍
|
||||
from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel␍
|
||||
@@ -207,6 +207,7 @@ app.include_router(membership_admin.router, prefix="/app/api/admin/membership",
|
||||
app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"])␍
|
||||
app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"])␍
|
||||
app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"])␍
|
||||
app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"])␍
|
||||
␍
|
||||
␍
|
||||
@app.get("/app/api/health")␍
|
||||
|
||||
@@ -34,6 +34,7 @@ import AdminMembership from '@/pages/admin/Membership';
|
||||
import AdminAds from '@/pages/admin/Ads';
|
||||
import AdminMarket from '@/pages/admin/Market';
|
||||
import AdminLogs from '@/pages/admin/Logs';
|
||||
import AdminSkills from '@/pages/admin/Skills';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
@@ -110,7 +111,8 @@ const App: React.FC = () => {
|
||||
<Route path="ads" element={<AdminAds />} />
|
||||
<Route path="market" element={<AdminMarket />} />
|
||||
<Route path="logs" element={<AdminLogs />} />
|
||||
</Route>
|
||||
|
||||
<Route path="skills" element={<AdminSkills />} /> </Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
|
||||
@@ -306,6 +306,12 @@ export const adminAPI = {
|
||||
deleteModel: (id: string) => del<any>(`/admin/models/${id}`),
|
||||
toggleModel: (id: string, isEnabled: boolean) => put<any>(`/admin/models/${id}/toggle?is_enabled=${isEnabled}`),
|
||||
setDefaultModel: (id: string) => put<any>(`/admin/models/${id}/set-default`),
|
||||
// Phase 2: Enhanced model management
|
||||
testModelConnection: (id: string) => post<any>(`/admin/models/${id}/test-connection`),
|
||||
fetchProviderModels: (id: string) => post<any>(`/admin/models/providers/${id}/fetch-models`),
|
||||
createAdminModel: (data: any) => post<any>('/admin/models/', data),
|
||||
updateAdminModel: (id: string, data: any) => put<any>(`/admin/models/${id}`, data),
|
||||
deleteAdminModel: (id: string) => del<any>(`/admin/models/${id}`),
|
||||
getModelStats: () => get<any>('/admin/models/stats'),
|
||||
// 网站设置
|
||||
getSiteSettings: () => get<any>('/admin/settings/site'),
|
||||
@@ -433,8 +439,23 @@ export const adminAPI = {
|
||||
addRiskRule: (data: any) => post<any>('/admin/logs/risk-rules', data),
|
||||
updateRiskRule: (id: string, data: any) => put<any>(`/admin/logs/risk-rules/${id}`, data),
|
||||
deleteRiskRule: (id: string) => del<any>(`/admin/logs/risk-rules/${id}`),
|
||||
// 技能管理 (Phase 2.2)
|
||||
getAdminSkills: (params?: any) => {
|
||||
const sp = new URLSearchParams();
|
||||
if (params) Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); });
|
||||
return get<any>(`/admin/skills/${sp.toString() ? `?${sp.toString()}` : ''}`);
|
||||
},
|
||||
getAdminSkill: (id: string) => get<any>(`/admin/skills/${id}`),
|
||||
createAdminSkill: (data: any) => post<any>('/admin/skills/', data),
|
||||
updateAdminSkill: (id: string, data: any) => put<any>(`/admin/skills/${id}`, data),
|
||||
deleteAdminSkill: (id: string) => del<any>(`/admin/skills/${id}`),
|
||||
changeAdminSkillState: (id: string, data: any) => put<any>(`/admin/skills/${id}/state`, data),
|
||||
getAdminSkillVersions: (id: string) => get<any>(`/admin/skills/${id}/versions`),
|
||||
rollbackAdminSkill: (id: string, data: any) => post<any>(`/admin/skills/${id}/rollback`, data),
|
||||
publishAdminSkill: (id: string, data: any) => post<any>(`/admin/skills/${id}/publish`, data),
|
||||
unpublishAdminSkill: (id: string) => post<any>(`/admin/skills/${id}/unpublish`),
|
||||
getAdminSkillsStats: () => get<any>('/admin/skills/stats/overview'),
|
||||
};
|
||||
|
||||
// ===== AI自进化API =====
|
||||
export const evolutionAPI = {
|
||||
// 驾驶舱面板
|
||||
|
||||
@@ -14,7 +14,8 @@ const adminNavItems = [
|
||||
{ path: '/admin/ads', icon: 'ads', label: '广告位管理' },
|
||||
{ path: '/admin/market', icon: 'market', label: '应用市场审核' },
|
||||
{ path: '/admin/logs', icon: 'logs', label: '日志与风控' },
|
||||
];
|
||||
|
||||
{ path: '/admin/skills', icon: 'skills', label: '技能管理' },];
|
||||
|
||||
const adminIconPaths: Record<string, JSX.Element> = {
|
||||
dashboard: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>,
|
||||
@@ -27,7 +28,8 @@ const adminIconPaths: Record<string, JSX.Element> = {
|
||||
ads: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>,
|
||||
market: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.2"/><rect x="14" y="3" width="7" height="7" rx="1.2"/><rect x="3" y="14" width="7" height="7" rx="1.2"/><rect x="14" y="14" width="7" height="7" rx="1.2"/></svg>,
|
||||
logs: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>,
|
||||
};
|
||||
|
||||
skills: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,};
|
||||
|
||||
const AdminLayout: React.FC = () => {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
@@ -0,0 +1,702 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
source: string;
|
||||
publisher_id: string;
|
||||
publisher_name?: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
tutorial?: string;
|
||||
price: number;
|
||||
is_member_free: boolean;
|
||||
join_promotion: boolean;
|
||||
status: string;
|
||||
rating_avg: number;
|
||||
review_count: number;
|
||||
install_count: number;
|
||||
skill_content?: string;
|
||||
agent_endpoint?: string;
|
||||
config?: AgentConfig;
|
||||
linked_skills?: LinkedSkill[];
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface AgentConfig {
|
||||
system_prompt?: string;
|
||||
temperature?: number;
|
||||
max_tokens?: number;
|
||||
model_id?: string;
|
||||
skills?: string[];
|
||||
tools?: string[];
|
||||
memory_enabled?: boolean;
|
||||
reasoning_enabled?: boolean;
|
||||
}
|
||||
|
||||
interface LinkedSkill {
|
||||
id: string;
|
||||
skill_name: string;
|
||||
state: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface AgentStats {
|
||||
total_agents: number;
|
||||
published_agents: number;
|
||||
pending_agents: number;
|
||||
official_agents: number;
|
||||
user_agents: number;
|
||||
total_installs: number;
|
||||
average_rating: number;
|
||||
by_source: Record<string, number>;
|
||||
}
|
||||
|
||||
interface AvailableModel {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
model_type?: string;
|
||||
}
|
||||
|
||||
const AGENT_TYPES = [
|
||||
{ id: 'skill', label: '技能' },
|
||||
{ id: 'agent', label: 'Agent' },
|
||||
{ id: 'mcp', label: 'MCP' },
|
||||
];
|
||||
|
||||
const Agents: React.FC = () => {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<AgentStats | null>(null);
|
||||
|
||||
// Filters
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState('');
|
||||
const [filterSource, setFilterSource] = useState('');
|
||||
|
||||
// Modal
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingAgent, setEditingAgent] = useState<Agent | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
price: '0',
|
||||
is_member_free: false,
|
||||
system_prompt: '',
|
||||
temperature: '0.7',
|
||||
max_tokens: '2048',
|
||||
model_id: '',
|
||||
skills: [] as string[],
|
||||
memory_enabled: false,
|
||||
reasoning_enabled: false,
|
||||
});
|
||||
|
||||
// Detail drawer
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [detailAgent, setDetailAgent] = useState<Agent | null>(null);
|
||||
|
||||
// Available skills for linking
|
||||
const [availableSkills, setAvailableSkills] = useState<any[]>([]);
|
||||
const [availableModels, setAvailableModels] = useState<AvailableModel[]>([]);
|
||||
|
||||
// Toast
|
||||
const [toast, setToast] = useState<{ msg: string; type: 'success' | 'error' } | null>(null);
|
||||
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
|
||||
setToast({ msg, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
// Load agents
|
||||
const loadAgents = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page, page_size: 15 };
|
||||
if (keyword) params.keyword = keyword;
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
if (filterSource) params.source = filterSource;
|
||||
const result = await adminAPI.getAdminAgents(params) as any;
|
||||
const data = result.data || result;
|
||||
setAgents(data.items || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch (e: any) {
|
||||
showToast(`加载失败: ${e.message || e}`, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load stats
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const result = await adminAPI.getAdminAgentsStats() as any;
|
||||
const data = result.data || result;
|
||||
setStats(data);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// Load available skills & models
|
||||
const loadAvailable = async () => {
|
||||
try {
|
||||
const [skillsResult, modelsResult] = await Promise.all([
|
||||
adminAPI.getAdminAvailableSkills({ page_size: 50 }) as any,
|
||||
adminAPI.getAdminAvailableModels() as any,
|
||||
]);
|
||||
const skills = (skillsResult.data || skillsResult).items || [];
|
||||
const models = (modelsResult.data || modelsResult) || [];
|
||||
setAvailableSkills(skills);
|
||||
setAvailableModels(models);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAgents();
|
||||
}, [page, keyword, filterStatus, filterSource]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
}, []);
|
||||
|
||||
// Search
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
loadAgents();
|
||||
};
|
||||
|
||||
// Open create
|
||||
const openCreate = async () => {
|
||||
setEditingAgent(null);
|
||||
await loadAvailable();
|
||||
setForm({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
price: '0',
|
||||
is_member_free: false,
|
||||
system_prompt: '你是一个智能助手。',
|
||||
temperature: '0.7',
|
||||
max_tokens: '2048',
|
||||
model_id: '',
|
||||
skills: [],
|
||||
memory_enabled: false,
|
||||
reasoning_enabled: false,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
// Open edit
|
||||
const openEdit = async (agent: Agent) => {
|
||||
setEditingAgent(agent);
|
||||
await loadAvailable();
|
||||
|
||||
const cfg = agent.config || {};
|
||||
setForm({
|
||||
name: agent.name,
|
||||
description: agent.description || '',
|
||||
icon: agent.icon || '',
|
||||
price: String(agent.price),
|
||||
is_member_free: agent.is_member_free,
|
||||
system_prompt: cfg.system_prompt || '',
|
||||
temperature: String(cfg.temperature || 0.7),
|
||||
max_tokens: String(cfg.max_tokens || 2048),
|
||||
model_id: cfg.model_id || '',
|
||||
skills: cfg.skills || [],
|
||||
memory_enabled: cfg.memory_enabled || false,
|
||||
reasoning_enabled: cfg.reasoning_enabled || false,
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
// Open detail
|
||||
const openDetail = async (agent: Agent) => {
|
||||
setDetailAgent(agent);
|
||||
setShowDetail(true);
|
||||
};
|
||||
|
||||
// Save agent
|
||||
const handleSave = async () => {
|
||||
if (!form.name.trim()) {
|
||||
showToast('Agent 名称不能为空', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: any = {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
icon: form.icon || undefined,
|
||||
price: parseFloat(form.price) || 0,
|
||||
is_member_free: form.is_member_free,
|
||||
config: {
|
||||
system_prompt: form.system_prompt,
|
||||
temperature: parseFloat(form.temperature) || 0.7,
|
||||
max_tokens: parseInt(form.max_tokens) || 2048,
|
||||
model_id: form.model_id || undefined,
|
||||
skills: form.skills,
|
||||
memory_enabled: form.memory_enabled,
|
||||
reasoning_enabled: form.reasoning_enabled,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
if (editingAgent) {
|
||||
await adminAPI.updateAdminAgent(editingAgent.id, payload);
|
||||
showToast('Agent 已更新');
|
||||
} else {
|
||||
await adminAPI.createAdminAgent(payload);
|
||||
showToast('Agent 创建成功');
|
||||
}
|
||||
setShowModal(false);
|
||||
loadAgents();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast(`保存失败: ${e.message || e}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle agent
|
||||
const handleToggle = async (agent: Agent) => {
|
||||
const isActive = agent.status === 'published';
|
||||
try {
|
||||
await adminAPI.toggleAdminAgent(agent.id, !isActive);
|
||||
showToast(`Agent 已${isActive ? '禁用' : '启用'}`);
|
||||
loadAgents();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast(`操作失败: ${e.message || e}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Delete agent
|
||||
const handleDelete = async (agent: Agent) => {
|
||||
if (!confirm(`确定要删除 Agent "${agent.name}" 吗?此操作不可恢复。`)) return;
|
||||
try {
|
||||
await adminAPI.deleteAdminAgent(agent.id);
|
||||
showToast('Agent 已删除');
|
||||
loadAgents();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast(`删除失败: ${e.message || e}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle skill in selection
|
||||
const toggleSkill = (skillId: string) => {
|
||||
setForm(f => ({
|
||||
...f,
|
||||
skills: f.skills.includes(skillId)
|
||||
? f.skills.filter(s => s !== skillId)
|
||||
: [...f.skills, skillId],
|
||||
}));
|
||||
};
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
if (status === 'published') return { bg: '#D1FAE5', color: '#10B981' };
|
||||
if (status === 'pending') return { bg: '#FEF3C7', color: '#F59E0B' };
|
||||
return { bg: '#FEE2E2', color: '#EF4444' };
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className="toast" style={{
|
||||
position: 'fixed', top: 20, right: 20, zIndex: 9999,
|
||||
padding: '12px 20px', borderRadius: 8,
|
||||
background: toast.type === 'success' ? '#10B981' : '#EF4444',
|
||||
color: '#fff', fontSize: 14, boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
}}>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="admin-page-header">
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>Agent 管理</h2>
|
||||
<p style={{ margin: '4px 0 0', color: '#6B7280', fontSize: 13 }}>
|
||||
管理 AI Agent/Skill/MCP,支持配置、关联技能和模型
|
||||
</p>
|
||||
</div>
|
||||
<button className="admin-btn primary" onClick={openCreate}>+ 创建 Agent</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 12, marginBottom: 20 }}>
|
||||
<div className="stat-card" style={{ borderLeft: '3px solid #6366F1' }}>
|
||||
<div className="stat-value">{stats.total_agents}</div>
|
||||
<div className="stat-label">Agent 总数</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ borderLeft: '3px solid #10B981' }}>
|
||||
<div className="stat-value">{stats.published_agents}</div>
|
||||
<div className="stat-label">已发布</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ borderLeft: '3px solid #F59E0B' }}>
|
||||
<div className="stat-value">{stats.pending_agents}</div>
|
||||
<div className="stat-label">待发布</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ borderLeft: '3px solid #3B82F6' }}>
|
||||
<div className="stat-value">{stats.total_installs.toLocaleString()}</div>
|
||||
<div className="stat-label">总安装量</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ borderLeft: '3px solid #8B5CF6' }}>
|
||||
<div className="stat-value">{stats.average_rating.toFixed(1)}</div>
|
||||
<div className="stat-label">平均评分</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ borderLeft: '3px solid #EC4899' }}>
|
||||
<div className="stat-value">{stats.official_agents} / {stats.user_agents}</div>
|
||||
<div className="stat-label">官方 / 用户</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="admin-filters" style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
<input className="admin-form-input" placeholder="搜索名称、描述..."
|
||||
value={keyword} onChange={e => setKeyword(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
style={{ maxWidth: 280 }} />
|
||||
<select className="admin-form-input" value={filterStatus} onChange={e => { setFilterStatus(e.target.value); setPage(1); }}
|
||||
style={{ maxWidth: 140 }}>
|
||||
<option value="">全部状态</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="pending">待发布</option>
|
||||
<option value="removed">已下架</option>
|
||||
</select>
|
||||
<select className="admin-form-input" value={filterSource} onChange={e => { setFilterSource(e.target.value); setPage(1); }}
|
||||
style={{ maxWidth: 140 }}>
|
||||
<option value="">全部来源</option>
|
||||
<option value="official">官方</option>
|
||||
<option value="user">用户</option>
|
||||
</select>
|
||||
<button className="admin-btn" onClick={handleSearch}>搜索</button>
|
||||
{(keyword || filterStatus || filterSource) && (
|
||||
<button className="admin-btn" onClick={() => { setKeyword(''); setFilterStatus(''); setFilterSource(''); setPage(1); }}>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Agent</th>
|
||||
<th>类型</th>
|
||||
<th>来源</th>
|
||||
<th>状态</th>
|
||||
<th>价格</th>
|
||||
<th>安装量</th>
|
||||
<th>评分</th>
|
||||
<th>更新时间</th>
|
||||
<th style={{ width: 240 }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>加载中...</td></tr>
|
||||
) : agents.length === 0 ? (
|
||||
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>暂无 Agent</td></tr>
|
||||
) : agents.map(agent => {
|
||||
const sc = statusColor(agent.status);
|
||||
return (
|
||||
<tr key={agent.id}>
|
||||
<td>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{agent.icon ? (
|
||||
<img src={agent.icon} alt="" style={{ width: 32, height: 32, borderRadius: 8, objectFit: 'cover' }} />
|
||||
) : (
|
||||
<div style={{ width: 32, height: 32, borderRadius: 8, background: '#EEF2FF', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 16 }}>
|
||||
🤖
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{agent.name}</div>
|
||||
{agent.description && (
|
||||
<div style={{ fontSize: 12, color: '#6B7280', maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{agent.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ fontSize: 12, padding: '2px 8px', borderRadius: 10, background: '#EEF2FF', color: '#6366F1' }}>
|
||||
{AGENT_TYPES.find(t => t.id === agent.type)?.label || agent.type}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ fontSize: 12, color: agent.source === 'official' ? '#10B981' : '#6B7280' }}>
|
||||
{agent.source === 'official' ? '官方' : '用户'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style={{ display: 'inline-block', padding: '2px 10px', borderRadius: 12, fontSize: 12, background: sc.bg, color: sc.color, fontWeight: 500 }}>
|
||||
{agent.status === 'published' ? '已发布' : agent.status === 'pending' ? '待发布' : '已下架'}
|
||||
</span>
|
||||
</td>
|
||||
<td>{agent.price === 0 ? <span style={{ color: '#10B981' }}>免费</span> : `¥${agent.price}`}</td>
|
||||
<td>{agent.install_count.toLocaleString()}</td>
|
||||
<td>{agent.rating_avg > 0 ? `★ ${agent.rating_avg}` : '-'}</td>
|
||||
<td style={{ fontSize: 12, color: '#6B7280' }}>
|
||||
{agent.updated_at ? agent.updated_at.slice(0, 16).replace('T', ' ') : '-'}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
<button className="admin-btn small" onClick={() => openDetail(agent)}>详情</button>
|
||||
<button className="admin-btn small" onClick={() => openEdit(agent)}>编辑</button>
|
||||
<button
|
||||
className={`admin-btn small ${agent.status === 'published' ? 'warning' : 'success'}`}
|
||||
onClick={() => handleToggle(agent)}
|
||||
>
|
||||
{agent.status === 'published' ? '禁用' : '启用'}
|
||||
</button>
|
||||
<button className="admin-btn small danger" onClick={() => handleDelete(agent)}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="admin-pagination">
|
||||
<span style={{ color: '#6B7280', fontSize: 13 }}>共 {total} 条,第 {page}/{totalPages} 页</span>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button className="admin-btn small" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||
{[...Array(Math.min(5, totalPages))].map((_, i) => {
|
||||
const p = Math.max(1, Math.min(page - 2, totalPages - 4)) + i;
|
||||
return p <= totalPages && (
|
||||
<button key={p} className={`admin-btn small ${p === page ? 'primary' : ''}`} onClick={() => setPage(p)}>{p}</button>
|
||||
);
|
||||
})}
|
||||
<button className="admin-btn small" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{showModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
||||
<div className="modal-content" style={{ maxWidth: 800 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>{editingAgent ? '编辑 Agent' : '创建 Agent'}</h3>
|
||||
<button className="modal-close" onClick={() => setShowModal(false)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">名称 *</label>
|
||||
<input className="admin-form-input" value={form.name}
|
||||
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
|
||||
placeholder="例如: 智能代码审查助手" />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">图标 URL</label>
|
||||
<input className="admin-form-input" value={form.icon}
|
||||
onChange={e => setForm(f => ({ ...f, icon: e.target.value }))}
|
||||
placeholder="https://..." />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">价格(元,0=免费)</label>
|
||||
<input className="admin-form-input" type="number" value={form.price}
|
||||
onChange={e => setForm(f => ({ ...f, price: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group" style={{ display: 'flex', alignItems: 'center', paddingTop: 24 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={form.is_member_free}
|
||||
onChange={e => setForm(f => ({ ...f, is_member_free: e.target.checked }))} />
|
||||
会员免费
|
||||
</label>
|
||||
</div>
|
||||
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||||
<label className="form-label">描述</label>
|
||||
<textarea className="admin-form-input" rows={2} value={form.description}
|
||||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||
placeholder="描述 Agent 的功能和使用场景..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style={{ border: 'none', borderTop: '1px solid #E5E7EB', margin: '16px 0' }} />
|
||||
|
||||
<div style={{ fontWeight: 600, marginBottom: 12 }}>⚙️ Agent 配置</div>
|
||||
|
||||
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||||
<label className="form-label">System Prompt</label>
|
||||
<textarea className="admin-form-input"
|
||||
value={form.system_prompt}
|
||||
onChange={e => setForm(f => ({ ...f, system_prompt: e.target.value }))}
|
||||
style={{ fontFamily: 'monospace', fontSize: 13, minHeight: 120 }}
|
||||
placeholder="定义 Agent 的角色、行为和能力..." />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 16 }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">模型</label>
|
||||
<select className="admin-form-input" value={form.model_id}
|
||||
onChange={e => setForm(f => ({ ...f, model_id: e.target.value }))}>
|
||||
<option value="">默认模型</option>
|
||||
{availableModels.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.name} ({m.provider})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Temperature</label>
|
||||
<input className="admin-form-input" type="number" step="0.1" min="0" max="2"
|
||||
value={form.temperature}
|
||||
onChange={e => setForm(f => ({ ...f, temperature: e.target.value }))} />
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Max Tokens</label>
|
||||
<input className="admin-form-input" type="number"
|
||||
value={form.max_tokens}
|
||||
onChange={e => setForm(f => ({ ...f, max_tokens: e.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 16, marginBottom: 16 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={form.memory_enabled}
|
||||
onChange={e => setForm(f => ({ ...f, memory_enabled: e.target.checked }))} />
|
||||
记忆能力
|
||||
</label>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={form.reasoning_enabled}
|
||||
onChange={e => setForm(f => ({ ...f, reasoning_enabled: e.target.checked }))} />
|
||||
推理能力
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Linked Skills */}
|
||||
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||||
<label className="form-label">关联技能(可多选)</label>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6, maxHeight: 160, overflowY: 'auto', padding: 8, border: '1px solid #E5E7EB', borderRadius: 6 }}>
|
||||
{availableSkills.length === 0 ? (
|
||||
<span style={{ color: '#9CA3AF', fontSize: 13 }}>暂无技能</span>
|
||||
) : availableSkills.map(skill => (
|
||||
<label key={skill.id} style={{
|
||||
display: 'flex', alignItems: 'center', gap: 4, padding: '4px 10px',
|
||||
borderRadius: 12, fontSize: 12, cursor: 'pointer',
|
||||
background: form.skills.includes(skill.id) ? '#EEF2FF' : '#F9FAFB',
|
||||
border: `1px solid ${form.skills.includes(skill.id) ? '#6366F1' : '#E5E7EB'}`,
|
||||
color: form.skills.includes(skill.id) ? '#6366F1' : '#374151',
|
||||
}}>
|
||||
<input type="checkbox" style={{ display: 'none' }}
|
||||
checked={form.skills.includes(skill.id)}
|
||||
onChange={() => toggleSkill(skill.id)} />
|
||||
{skill.skill_name}
|
||||
{skill.state !== 'baby' && (
|
||||
<span style={{ fontSize: 10, background: '#E5E7EB', padding: '1px 4px', borderRadius: 4, marginLeft: 4 }}>
|
||||
{skill.state}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
{form.skills.length > 0 && (
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: '#6366F1' }}>
|
||||
已选 {form.skills.length} 个技能
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="admin-btn" onClick={() => setShowModal(false)}>取消</button>
|
||||
<button className="admin-btn primary" onClick={handleSave}>
|
||||
{editingAgent ? '保存更改' : '创建 Agent'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail Drawer */}
|
||||
{showDetail && detailAgent && (
|
||||
<div className="modal-overlay" onClick={() => setShowDetail(false)}>
|
||||
<div className="modal-content" style={{ maxWidth: 600 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>{detailAgent.name}</h3>
|
||||
<button className="modal-close" onClick={() => setShowDetail(false)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12, marginBottom: 16 }}>
|
||||
<div className="detail-item"><span className="detail-label">类型</span><span>{detailAgent.type}</span></div>
|
||||
<div className="detail-item"><span className="detail-label">来源</span><span>{detailAgent.source === 'official' ? '官方' : '用户'}</span></div>
|
||||
<div className="detail-item"><span className="detail-label">状态</span><span>{detailAgent.status}</span></div>
|
||||
<div className="detail-item"><span className="detail-label">价格</span><span>¥{detailAgent.price}</span></div>
|
||||
<div className="detail-item"><span className="detail-label">安装量</span><span>{detailAgent.install_count}</span></div>
|
||||
<div className="detail-item"><span className="detail-label">评分</span><span>{detailAgent.rating_avg > 0 ? `★ ${detailAgent.rating_avg}` : '-'}</span></div>
|
||||
<div className="detail-item"><span className="detail-label">发布者</span><span>{detailAgent.publisher_name || detailAgent.publisher_id}</span></div>
|
||||
<div className="detail-item"><span className="detail-label">创建时间</span><span>{detailAgent.created_at?.slice(0, 16).replace('T', ' ')}</span></div>
|
||||
</div>
|
||||
|
||||
{detailAgent.description && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ fontWeight: 500, marginBottom: 4 }}>描述</div>
|
||||
<p style={{ color: '#6B7280', fontSize: 13, margin: 0 }}>{detailAgent.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailAgent.config && (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8 }}>⚙️ 配置</div>
|
||||
<div style={{ background: '#F9FAFB', borderRadius: 8, padding: 12, fontSize: 13 }}>
|
||||
{detailAgent.config.system_prompt && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<span style={{ color: '#6B7280' }}>System Prompt:</span>
|
||||
<pre style={{ margin: '4px 0 0', whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: 12 }}>
|
||||
{detailAgent.config.system_prompt}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 4 }}>
|
||||
<span>Temperature: <code>{detailAgent.config.temperature}</code></span>
|
||||
<span>Max Tokens: <code>{detailAgent.config.max_tokens}</code></span>
|
||||
<span>Model: <code>{detailAgent.config.model_id || '默认'}</code></span>
|
||||
<span>记忆: <code>{detailAgent.config.memory_enabled ? '✓' : '✗'}</code></span>
|
||||
<span>推理: <code>{detailAgent.config.reasoning_enabled ? '✓' : '✗'}</code></span>
|
||||
<span>关联技能: <code>{detailAgent.config.skills?.length || 0} 个</code></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.stat-card { background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||
.stat-value { font-size: 24px; font-weight: 700; color: #1F2937; }
|
||||
.stat-label { font-size: 13px; color: #6B7280; margin-top: 4px; }
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
|
||||
.modal-content { background: #fff; border-radius: 12px; width: 90%; max-height: 90vh; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.modal-header { padding: 20px 24px; border-bottom: 1px solid #E5E7EB; display: flex; justify-content: space-between; align-items: center; }
|
||||
.modal-header h3 { margin: 0; font-size: 18px; }
|
||||
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #6B7280; }
|
||||
.modal-body { padding: 24px; overflow-y: auto; }
|
||||
.modal-footer { padding: 16px 24px; border-top: 1px solid #E5E7EB; display: flex; justify-content: flex-end; gap: 12px; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: #374151; }
|
||||
.detail-item { display: flex; gap: 8px; font-size: 13px; }
|
||||
.detail-label { color: #6B7280; min-width: 60px; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Agents;
|
||||
+379
-643
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,667 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { adminAPI } from '@/api';
|
||||
import type { SkillState } from '@/types';
|
||||
|
||||
type AdminSkill = {
|
||||
id: string;
|
||||
user_id: string;
|
||||
owner_username?: string;
|
||||
skill_name: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
category?: string;
|
||||
category_label?: string;
|
||||
state: SkillState;
|
||||
use_count: number;
|
||||
version: number;
|
||||
skill_md_path?: string;
|
||||
last_used_at?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
skill_md_content?: string;
|
||||
publish_records?: PublishRecord[];
|
||||
};
|
||||
|
||||
interface PublishRecord {
|
||||
id: string;
|
||||
price: number;
|
||||
status: string;
|
||||
published_at?: string;
|
||||
}
|
||||
|
||||
interface SkillVersion {
|
||||
id: string;
|
||||
version: number;
|
||||
description?: string;
|
||||
skill_md_content?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
interface SkillsStats {
|
||||
total_skills: number;
|
||||
by_state: Record<string, number>;
|
||||
by_category: Record<string, number>;
|
||||
total_published: number;
|
||||
total_versions: number;
|
||||
}
|
||||
|
||||
const STATE_CONFIG: Record<SkillState, { label: string; color: string; bg: string }> = {
|
||||
baby: { label: '萌芽期', color: '#6B7280', bg: '#F3F4F6' },
|
||||
main: { label: '主力期', color: '#10B981', bg: '#D1FAE5' },
|
||||
staging: { label: '灰度期', color: '#F59E0B', bg: '#FEF3C7' },
|
||||
frozen: { label: '已冻结', color: '#EF4444', bg: '#FEE2E2' },
|
||||
};
|
||||
|
||||
const CATEGORIES = [
|
||||
{ id: 'writing', label: '文字创作' },
|
||||
{ id: 'coding', label: '代码能力' },
|
||||
{ id: 'data_analysis', label: '数据分析' },
|
||||
{ id: 'qa', label: '知识问答' },
|
||||
{ id: 'automation', label: '流程自动化' },
|
||||
{ id: 'creativity', label: '创意生成' },
|
||||
{ id: 'communication', label: '沟通协作' },
|
||||
{ id: 'reasoning', label: '逻辑推理' },
|
||||
{ id: 'other', label: '其他' },
|
||||
];
|
||||
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
const Skills: React.FC = () => {
|
||||
const [skills, setSkills] = useState<AdminSkill[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [stats, setStats] = useState<any>(null);
|
||||
|
||||
// Filters
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [filterState, setFilterState] = useState<string>('');
|
||||
const [filterCategory, setFilterCategory] = useState<string>('');
|
||||
|
||||
// Create/Edit Modal
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editingSkill, setEditingSkill] = useState<AdminSkill | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
skill_name: '',
|
||||
user_id: '',
|
||||
description: '',
|
||||
tags: '',
|
||||
category: '',
|
||||
skill_md_content: '## 技能定义\n\n### 触发词\n- /skill\n\n### 能力说明\n请描述这个技能的核心能力...\n\n### 使用场景\n1. 场景一\n2. 场景二\n\n### 示例对话\n**用户**: \n**AI**: ',
|
||||
});
|
||||
|
||||
// Version Drawer
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const [versions, setVersions] = useState<any[]>([]);
|
||||
const [versionSkill, setVersionSkill] = useState<AdminSkill | null>(null);
|
||||
const [loadingVersions, setLoadingVersions] = useState(false);
|
||||
|
||||
// Users list (for owner selection)
|
||||
const [users, setUsers] = useState<{id: string; username: string}[]>([]);
|
||||
|
||||
// Confirm delete
|
||||
const [deleteTarget, setDeleteTarget] = useState<AdminSkill | null>(null);
|
||||
|
||||
// Toast
|
||||
const [toast, setToast] = useState<{msg: string; type: 'success' | 'error'} | null>(null);
|
||||
|
||||
const showToast = (msg: string, type: 'success' | 'error' = 'success') => {
|
||||
setToast({ msg, type });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
};
|
||||
|
||||
// Load skills list
|
||||
const loadSkills = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: any = { page, page_size: PAGE_SIZE };
|
||||
if (keyword) params.keyword = keyword;
|
||||
if (filterState) params.state = filterState;
|
||||
if (filterCategory) params.category = filterCategory;
|
||||
|
||||
const result = await adminAPI.getAdminSkills(params) as any;
|
||||
const data = result.data || result;
|
||||
setSkills(data.items || []);
|
||||
setTotal(data.total || 0);
|
||||
} catch (e: any) {
|
||||
showToast(`加载失败: ${e.message || e}`, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Load stats
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const result = await adminAPI.getAdminSkillsStats() as any;
|
||||
const data = result.data || result;
|
||||
setStats(data);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
// Load users for owner selection
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const result = await adminAPI.getUsers({ page_size: 100 }) as any;
|
||||
const data = result.data || result;
|
||||
setUsers((data.items || data || []).slice(0, 20));
|
||||
} catch {}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadSkills();
|
||||
}, [page, keyword, filterState, filterCategory]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
loadUsers();
|
||||
}, []);
|
||||
|
||||
// Search
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
loadSkills();
|
||||
};
|
||||
|
||||
// Open create modal
|
||||
const openCreate = () => {
|
||||
setEditingSkill(null);
|
||||
setForm({
|
||||
skill_name: '',
|
||||
user_id: users[0]?.id || '',
|
||||
description: '',
|
||||
tags: '',
|
||||
category: '',
|
||||
skill_md_content: '## 技能定义\n\n### 触发词\n- /skill\n\n### 能力说明\n请描述这个技能的核心能力...\n\n### 使用场景\n1. 场景一\n2. 场景二\n\n### 示例对话\n**用户**: \n**AI**: ',
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
// Open edit modal
|
||||
const openEdit = (skill: AdminSkill) => {
|
||||
setEditingSkill(skill);
|
||||
setForm({
|
||||
skill_name: skill.skill_name,
|
||||
user_id: skill.user_id,
|
||||
description: skill.description || '',
|
||||
tags: (skill.tags || []).join(', '),
|
||||
category: skill.category || '',
|
||||
skill_md_content: skill.skill_md_content || '',
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
// Save skill
|
||||
const handleSave = async () => {
|
||||
if (!form.skill_name.trim()) {
|
||||
showToast('技能名称不能为空', 'error');
|
||||
return;
|
||||
}
|
||||
if (!form.user_id) {
|
||||
showToast('请选择所属用户', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = form.tags.split(',').map(t => t.trim()).filter(Boolean);
|
||||
const payload: any = {
|
||||
skill_name: form.skill_name,
|
||||
user_id: form.user_id,
|
||||
description: form.description,
|
||||
tags,
|
||||
category: form.category || undefined,
|
||||
skill_md_content: form.skill_md_content,
|
||||
};
|
||||
|
||||
try {
|
||||
if (editingSkill) {
|
||||
await adminAPI.updateAdminSkill(editingSkill.id, payload);
|
||||
showToast('技能已更新');
|
||||
} else {
|
||||
await adminAPI.createAdminSkill(payload);
|
||||
showToast('技能已创建');
|
||||
}
|
||||
setShowModal(false);
|
||||
loadSkills();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast(`保存失败: ${e.message || e}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Delete skill
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await adminAPI.deleteAdminSkill(deleteTarget.id);
|
||||
showToast('技能已删除');
|
||||
setDeleteTarget(null);
|
||||
loadSkills();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast(`删除失败: ${e.message || e}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Change state
|
||||
const handleChangeState = async (skill: AdminSkill, newState: string) => {
|
||||
try {
|
||||
await adminAPI.changeAdminSkillState(skill.id, { state: newState });
|
||||
showToast(`状态已变更为 ${STATE_CONFIG[newState as SkillState].label}`);
|
||||
loadSkills();
|
||||
loadStats();
|
||||
} catch (e: any) {
|
||||
showToast(`状态修改失败: ${e.message || e}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Load version history
|
||||
const handleShowVersions = async (skill: AdminSkill) => {
|
||||
setVersionSkill(skill);
|
||||
setShowVersions(true);
|
||||
setLoadingVersions(true);
|
||||
try {
|
||||
const result = await adminAPI.getAdminSkillVersions(skill.id) as any;
|
||||
const data = result.data || result;
|
||||
setVersions(data.versions || []);
|
||||
} catch (e: any) {
|
||||
showToast(`加载版本历史失败: ${e.message || e}`, 'error');
|
||||
setVersions([]);
|
||||
} finally {
|
||||
setLoadingVersions(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Rollback version
|
||||
const handleRollback = async (skill: AdminSkill, targetVersion: number) => {
|
||||
if (!confirm(`确定回滚到版本 ${targetVersion} 吗?`)) return;
|
||||
try {
|
||||
await adminAPI.rollbackAdminSkill(skill.id, { version: targetVersion });
|
||||
showToast(`已回滚到版本 ${targetVersion}`);
|
||||
setShowVersions(false);
|
||||
loadSkills();
|
||||
} catch (e: any) {
|
||||
showToast(`回滚失败: ${e.message || e}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Publish/Unpublish
|
||||
const handlePublish = async (skill: AdminSkill) => {
|
||||
const price = parseFloat(prompt('设置定价(元,0=免费):', '0') || '0');
|
||||
if (isNaN(price) || price < 0) return;
|
||||
try {
|
||||
await adminAPI.publishAdminSkill(skill.id, { price, market_description: skill.description || '' });
|
||||
showToast('已发布到应用市场');
|
||||
loadSkills();
|
||||
} catch (e: any) {
|
||||
showToast(`发布失败: ${e.message || e}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnpublish = async (skill: AdminSkill) => {
|
||||
if (!confirm('确定从市场下架吗?')) return;
|
||||
try {
|
||||
await adminAPI.unpublishAdminSkill(skill.id);
|
||||
showToast('已从市场下架');
|
||||
loadSkills();
|
||||
} catch (e: any) {
|
||||
showToast(`下架失败: ${e.message || e}`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||||
const isPublished = (skill: AdminSkill) =>
|
||||
(skill.publish_records || []).some(r => r.status === 'published');
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`toast ${toast.type}`} style={{
|
||||
position: 'fixed', top: 20, right: 20, zIndex: 9999,
|
||||
padding: '12px 20px', borderRadius: 8,
|
||||
background: toast.type === 'success' ? '#10B981' : '#EF4444',
|
||||
color: '#fff', fontSize: 14, boxShadow: '0 4px 12px rgba(0,0,0,0.15)'
|
||||
}}>
|
||||
{toast.msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="admin-page-header">
|
||||
<div>
|
||||
<h2 style={{ margin: 0, fontSize: 20, fontWeight: 600 }}>技能管理</h2>
|
||||
<p style={{ margin: '4px 0 0', color: '#6B7280', fontSize: 13 }}>
|
||||
管理全平台 AI 技能,支持版本历史与市场发布
|
||||
</p>
|
||||
</div>
|
||||
<button className="admin-btn primary" onClick={openCreate}>+ 创建技能</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 20 }}>
|
||||
<div className="stat-card">
|
||||
<div className="stat-value">{stats.total_skills}</div>
|
||||
<div className="stat-label">技能总数</div>
|
||||
</div>
|
||||
{Object.entries(STATE_CONFIG).map(([state, cfg]) => (
|
||||
<div key={state} className="stat-card" style={{ borderLeft: `3px solid ${cfg.color}` }}>
|
||||
<div className="stat-value">{stats.by_state[state] || 0}</div>
|
||||
<div className="stat-label">{cfg.label}</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="stat-card" style={{ borderLeft: '3px solid #8B5CF6' }}>
|
||||
<div className="stat-value">{stats.total_published}</div>
|
||||
<div className="stat-label">已发布市场</div>
|
||||
</div>
|
||||
<div className="stat-card" style={{ borderLeft: '3px solid #3B82F6' }}>
|
||||
<div className="stat-value">{stats.total_versions}</div>
|
||||
<div className="stat-label">版本记录</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="admin-filters" style={{ display: 'flex', gap: 10, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
<input
|
||||
className="admin-form-input"
|
||||
placeholder="搜索技能名称或描述..."
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
style={{ maxWidth: 280 }}
|
||||
/>
|
||||
<select className="admin-form-input" value={filterState} onChange={e => { setFilterState(e.target.value); setPage(1); }} style={{ maxWidth: 140 }}>
|
||||
<option value="">全部状态</option>
|
||||
{Object.entries(STATE_CONFIG).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="admin-form-input" value={filterCategory} onChange={e => { setFilterCategory(e.target.value); setPage(1); }} style={{ maxWidth: 160 }}>
|
||||
<option value="">全部分类</option>
|
||||
{CATEGORIES.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="admin-btn" onClick={handleSearch}>搜索</button>
|
||||
{(keyword || filterState || filterCategory) && (
|
||||
<button className="admin-btn" onClick={() => { setKeyword(''); setFilterState(''); setFilterCategory(''); setPage(1); }}>
|
||||
清空筛选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>技能名称</th>
|
||||
<th>状态</th>
|
||||
<th>分类</th>
|
||||
<th>所有者</th>
|
||||
<th>版本</th>
|
||||
<th>使用次数</th>
|
||||
<th>市场</th>
|
||||
<th>更新时间</th>
|
||||
<th style={{ width: 280 }}>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>加载中...</td></tr>
|
||||
) : skills.length === 0 ? (
|
||||
<tr><td colSpan={9} style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>暂无技能</td></tr>
|
||||
) : skills.map(skill => {
|
||||
const cfg = STATE_CONFIG[skill.state as SkillState] || STATE_CONFIG.baby;
|
||||
const published = isPublished(skill);
|
||||
return (
|
||||
<tr key={skill.id}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 500 }}>{skill.skill_name}</div>
|
||||
{skill.description && (
|
||||
<div style={{ fontSize: 12, color: '#6B7280', marginTop: 2, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{skill.description}
|
||||
</div>
|
||||
)}
|
||||
{skill.tags && skill.tags.length > 0 && (
|
||||
<div style={{ display: 'flex', gap: 4, marginTop: 4, flexWrap: 'wrap' }}>
|
||||
{skill.tags.slice(0, 3).map(tag => (
|
||||
<span key={tag} style={{ fontSize: 11, padding: '1px 6px', background: '#EEF2FF', color: '#6366F1', borderRadius: 4 }}>{tag}</span>
|
||||
))}
|
||||
{skill.tags.length > 3 && <span style={{ fontSize: 11, color: '#6B7280' }}>+{skill.tags.length - 3}</span>}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span style={{
|
||||
display: 'inline-block', padding: '2px 10px', borderRadius: 12, fontSize: 12,
|
||||
background: cfg.bg, color: cfg.color, fontWeight: 500
|
||||
}}>
|
||||
{cfg.label}
|
||||
</span>
|
||||
</td>
|
||||
<td>{skill.category_label || '-'}</td>
|
||||
<td>{skill.owner_username || skill.user_id}</td>
|
||||
<td>v{skill.version}</td>
|
||||
<td>{skill.use_count}</td>
|
||||
<td>
|
||||
{published ? (
|
||||
<span style={{ color: '#10B981', fontSize: 13 }}>✓ 已发布</span>
|
||||
) : (
|
||||
<span style={{ color: '#9CA3AF', fontSize: 13 }}>未发布</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ fontSize: 12, color: '#6B7280' }}>
|
||||
{skill.updated_at ? skill.updated_at.slice(0, 16).replace('T', ' ') : '-'}
|
||||
</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
<button className="admin-btn small" onClick={() => openEdit(skill)}>编辑</button>
|
||||
<button className="admin-btn small" onClick={() => handleShowVersions(skill)}>版本</button>
|
||||
{published ? (
|
||||
<button className="admin-btn small warning" onClick={() => handleUnpublish(skill)}>下架</button>
|
||||
) : (
|
||||
<button className="admin-btn small success" onClick={() => handlePublish(skill)}>发布</button>
|
||||
)}
|
||||
{/* State change */}
|
||||
<select
|
||||
className="admin-form-input small"
|
||||
value={skill.state}
|
||||
onChange={e => handleChangeState(skill, e.target.value)}
|
||||
style={{ padding: '2px 4px', fontSize: 12, maxWidth: 90 }}
|
||||
>
|
||||
{Object.entries(STATE_CONFIG).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="admin-btn small danger" onClick={() => setDeleteTarget(skill)}>删除</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="admin-pagination">
|
||||
<span style={{ color: '#6B7280', fontSize: 13 }}>
|
||||
共 {total} 条,第 {page}/{totalPages} 页
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
<button className="admin-btn small" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||||
{[...Array(Math.min(5, totalPages))].map((_, i) => {
|
||||
const p = Math.max(1, Math.min(page - 2, totalPages - 4)) + i;
|
||||
return p <= totalPages && (
|
||||
<button key={p} className={`admin-btn small ${p === page ? 'primary' : ''}`}
|
||||
onClick={() => setPage(p)}>{p}</button>
|
||||
);
|
||||
})}
|
||||
<button className="admin-btn small" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{showModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
||||
<div className="modal-content" style={{ maxWidth: 800 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>{editingSkill ? '编辑技能' : '创建技能'}</h3>
|
||||
<button className="modal-close" onClick={() => setShowModal(false)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ maxHeight: '70vh', overflowY: 'auto' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">技能名称 *</label>
|
||||
<input className="admin-form-input" value={form.skill_name}
|
||||
onChange={e => setForm(f => ({ ...f, skill_name: e.target.value }))}
|
||||
placeholder="例如: 代码审查助手" />
|
||||
</div>
|
||||
{!editingSkill && (
|
||||
<div className="form-group">
|
||||
<label className="form-label">所属用户 *</label>
|
||||
<select className="admin-form-input" value={form.user_id}
|
||||
onChange={e => setForm(f => ({ ...f, user_id: e.target.value }))}>
|
||||
{users.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.username}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
<div className="form-group">
|
||||
<label className="form-label">分类</label>
|
||||
<select className="admin-form-input" value={form.category}
|
||||
onChange={e => setForm(f => ({ ...f, category: e.target.value }))}>
|
||||
<option value="">选择分类</option>
|
||||
{CATEGORIES.map(c => (
|
||||
<option key={c.id} value={c.id}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||||
<label className="form-label">标签(逗号分隔)</label>
|
||||
<input className="admin-form-input" value={form.tags}
|
||||
onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
|
||||
placeholder="例如: 代码, 审查, 自动化" />
|
||||
</div>
|
||||
<div className="form-group" style={{ gridColumn: '1 / -1' }}>
|
||||
<label className="form-label">描述</label>
|
||||
<textarea className="admin-form-input" value={form.description} rows={3}
|
||||
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
|
||||
placeholder="描述技能的功能和使用场景..." />
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label className="form-label">SKILL.md 内容(技能定义)</label>
|
||||
<textarea
|
||||
className="admin-form-input"
|
||||
value={form.skill_md_content}
|
||||
onChange={e => setForm(f => ({ ...f, skill_md_content: e.target.value }))}
|
||||
style={{ fontFamily: 'monospace', fontSize: 13, minHeight: 400, lineHeight: 1.6 }}
|
||||
placeholder="## 技能定义..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="admin-btn" onClick={() => setShowModal(false)}>取消</button>
|
||||
<button className="admin-btn primary" onClick={handleSave}>
|
||||
{editingSkill ? '保存更改' : '创建技能'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Version History Drawer */}
|
||||
{showVersions && versionSkill && (
|
||||
<div className="modal-overlay" onClick={() => setShowVersions(false)}>
|
||||
<div className="modal-content" style={{ maxWidth: 700 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>版本历史 - {versionSkill.skill_name}</h3>
|
||||
<button className="modal-close" onClick={() => setShowVersions(false)}>×</button>
|
||||
</div>
|
||||
<div className="modal-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
{loadingVersions ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>加载中...</div>
|
||||
) : versions.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: 40, color: '#6B7280' }}>暂无版本记录</div>
|
||||
) : (
|
||||
<div>
|
||||
{versions.map((v, idx) => (
|
||||
<div key={v.id} style={{
|
||||
padding: 16, marginBottom: 12, border: `1px solid ${idx === 0 ? '#10B981' : '#E5E7EB'}`,
|
||||
borderRadius: 8, background: idx === 0 ? '#F0FDF4' : '#fff'
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<div>
|
||||
<span style={{ fontWeight: 600, fontSize: 15 }}>版本 {v.version}</span>
|
||||
{idx === 0 && <span style={{ marginLeft: 8, fontSize: 12, color: '#10B981', background: '#D1FAE5', padding: '1px 8px', borderRadius: 12 }}>当前</span>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<span style={{ fontSize: 12, color: '#6B7280' }}>{v.created_at?.slice(0, 16).replace('T', ' ')}</span>
|
||||
{idx > 0 && (
|
||||
<button className="admin-btn small" onClick={() => handleRollback(versionSkill, v.version)}>回滚至此</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{v.description && <div style={{ fontSize: 13, color: '#6B7280', marginBottom: 8 }}>{v.description}</div>}
|
||||
{v.skill_md_content && (
|
||||
<details>
|
||||
<summary style={{ fontSize: 13, color: '#6366F1', cursor: 'pointer' }}>查看 SKILL.md 内容</summary>
|
||||
<pre style={{ fontSize: 12, background: '#F9FAFB', padding: 12, borderRadius: 6, marginTop: 8, maxHeight: 300, overflow: 'auto', whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{v.skill_md_content}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirm */}
|
||||
{deleteTarget && (
|
||||
<div className="modal-overlay" onClick={() => setDeleteTarget(null)}>
|
||||
<div className="modal-content" style={{ maxWidth: 400 }} onClick={e => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h3>确认删除</h3>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<p>确定要删除技能 <strong>{deleteTarget.skill_name}</strong> 吗?</p>
|
||||
<p style={{ fontSize: 13, color: '#EF4444' }}>此操作不可恢复,相关版本历史也将被删除。</p>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button className="admin-btn" onClick={() => setDeleteTarget(null)}>取消</button>
|
||||
<button className="admin-btn danger" onClick={handleDelete}>确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.stat-card { background: #fff; border-radius: 8px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||
.stat-value { font-size: 24px; font-weight: 700; color: #1F2937; }
|
||||
.stat-label { font-size: 13px; color: #6B7280; margin-top: 4px; }
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center; }
|
||||
.modal-content { background: #fff; border-radius: 12px; width: 90%; max-height: 90vh; overflow: hidden; display: flex; flex-direction: column; }
|
||||
.modal-header { padding: 20px 24px; border-bottom: 1px solid #E5E7EB; display: flex; justify-content: space-between; align-items: center; }
|
||||
.modal-header h3 { margin: 0; font-size: 18px; }
|
||||
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: #6B7280; }
|
||||
.modal-body { padding: 24px; overflow-y: auto; }
|
||||
.modal-footer { padding: 16px 24px; border-top: 1px solid #E5E7EB; display: flex; justify-content: flex-end; gap: 12px; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: #374151; }
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Skills;
|
||||
|
||||
@@ -222,12 +222,17 @@ export interface ModelProvider {
|
||||
protocol: string;
|
||||
apiKey: string;
|
||||
apiBaseUrl: string;
|
||||
// Support both snake_case and camelCase
|
||||
api_key?: string;
|
||||
api_base_url?: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
// AIModel: supports both camelCase (TypeScript) and snake_case (from backend API)
|
||||
export interface AIModel {
|
||||
id: string;
|
||||
// camelCase (TypeScript convention)
|
||||
providerId: string;
|
||||
providerName?: string;
|
||||
name: string;
|
||||
@@ -238,8 +243,22 @@ export interface AIModel {
|
||||
status: 'free' | 'paid';
|
||||
pricePerMillionTokens: number;
|
||||
isEnabled: boolean;
|
||||
isDefault?: boolean;
|
||||
totalCalls?: number;
|
||||
totalTokens?: number;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
// snake_case (from backend)
|
||||
provider_id?: string;
|
||||
provider_name?: string;
|
||||
api_key?: string;
|
||||
price_per_million_tokens?: number;
|
||||
is_enabled?: boolean;
|
||||
is_default?: boolean;
|
||||
total_calls?: number;
|
||||
total_tokens?: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface ProtocolDef {
|
||||
|
||||
Reference in new issue
Block a user