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:
qyyfw committed 2026-07-02 20:18:10 +08:00
1 parent 70dd2b69af
commit a682dc687e
11 files changed
+4243 -1615

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
]
}
+152
View File
@@ -600,3 +600,155 @@ async def get_model_stats(
"total_calls": int(total_calls), "total_calls": int(total_calls),
"total_tokens": int(total_tokens), "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,
}
}
+215 -214
View File
@@ -1,214 +1,215 @@
""" """␍
Qihu-AIFlow FastAPI 主应用 Qihu-AIFlow FastAPI 主应用␍
完整后端API,监听18080端口,所有接口以 /app 为前缀 完整后端API,监听18080端口,所有接口以 /app 为前缀␍
""" """␍
import sys import sys␍
import os import os␍
import time import time␍
import uuid import uuid␍
import logging import logging␍
from contextlib import asynccontextmanager from contextlib import asynccontextmanager␍
from fastapi import FastAPI, Request from fastapi import FastAPI, Request␍
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware␍
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware␍
from datetime import datetime from datetime import datetime␍
␍
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 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 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.config import setup_file_logging, SLOW_REQUEST_THRESHOLD␍
from app.database import SessionLocal from app.database import SessionLocal␍
from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel␍
␍
# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1) # 导入平台适配器触发注册(AstrBot借鉴优化方案 P1)␍
import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册 import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册␍
␍
# 初始化文件日志(按天轮转) # 初始化文件日志(按天轮转)␍
access_logger = setup_file_logging() access_logger = setup_file_logging()␍
logger = logging.getLogger("aiflow") logger = logging.getLogger("aiflow")␍
␍
␍
class APITrackingMiddleware(BaseHTTPMiddleware): class APITrackingMiddleware(BaseHTTPMiddleware):␍
"""API调用统计中间件 - 记录请求耗时,标记慢请求""" """API调用统计中间件 - 记录请求耗时,标记慢请求"""␍
␍
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):␍
request_id = str(uuid.uuid4()) request_id = str(uuid.uuid4())␍
start_time = time.time() start_time = time.time()␍
path = request.url.path path = request.url.path␍
method = request.method method = request.method␍
␍
# 跳过健康检查和静态资源 # 跳过健康检查和静态资源␍
skip_paths = ["/app/api/health", "/favicon.ico"] skip_paths = ["/app/api/health", "/favicon.ico"]␍
should_skip = any(path.startswith(p) for p in skip_paths) should_skip = any(path.startswith(p) for p in skip_paths)␍
␍
try: try:␍
response = await call_next(request) response = await call_next(request)␍
elapsed = time.time() - start_time elapsed = time.time() - start_time␍
status_code = response.status_code status_code = response.status_code␍
␍
if not should_skip: if not should_skip:␍
access_logger.info( access_logger.info(␍
f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)" f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)"␍
) )␍
␍
# 异步记录API调用统计 # 异步记录API调用统计␍
try: try:␍
db = SessionLocal() db = SessionLocal()␍
try: try:␍
_record_api_call(db, method, path, status_code, elapsed) _record_api_call(db, method, path, status_code, elapsed)␍
finally: finally:␍
db.close() db.close()␍
except Exception as e: except Exception as e:␍
logger.warning(f"API统计记录失败: {e}") logger.warning(f"API统计记录失败: {e}")␍
␍
return response return response␍
except Exception as e: except Exception as e:␍
elapsed = time.time() - start_time elapsed = time.time() - start_time␍
if not should_skip: if not should_skip:␍
access_logger.info( access_logger.info(␍
f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}" f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}"␍
) )␍
raise raise␍
␍
async def __call__(self, scope, receive, send): async def __call__(self, scope, receive, send):␍
if scope["type"] != "http": if scope["type"] != "http":␍
await self.app(scope, receive, send) await self.app(scope, receive, send)␍
return return␍
await super().__call__(scope, receive, send) await super().__call__(scope, receive, send)␍
␍
␍
def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float): def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float):␍
"""记录API调用到数据库统计表""" """记录API调用到数据库统计表"""␍
# 简化路径以减少基数(去掉动态ID) # 简化路径以减少基数(去掉动态ID)␍
parts = path.split("/") parts = path.split("/")␍
simplified_parts = [] simplified_parts = []␍
for part in parts: for part in parts:␍
# 将UUID样式的路径段替换为{id} # 将UUID样式的路径段替换为{id}␍
if len(part) == 36 and part.count("-") == 4: if len(part) == 36 and part.count("-") == 4:␍
simplified_parts.append("{id}") simplified_parts.append("{id}")␍
# 将纯数字路径段替换为{id} # 将纯数字路径段替换为{id}␍
elif part.isdigit(): elif part.isdigit():␍
simplified_parts.append("{id}") simplified_parts.append("{id}")␍
else: else:␍
simplified_parts.append(part) simplified_parts.append(part)␍
simplified_path = "/".join(simplified_parts) simplified_path = "/".join(simplified_parts)␍
␍
# 统计记录通过数据库实现,简化版记录到日志 # 统计记录通过数据库实现,简化版记录到日志␍
is_error = status_code >= 400 is_error = status_code >= 400␍
is_slow = elapsed >= SLOW_REQUEST_THRESHOLD is_slow = elapsed >= SLOW_REQUEST_THRESHOLD␍
␍
if is_slow: if is_slow:␍
logger.warning( logger.warning(␍
f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)" f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)"␍
) )␍
␍
␍
@asynccontextmanager @asynccontextmanager␍
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):␍
logger.info("Qihu-AIFlow v2.0.0 启动中...") logger.info("Qihu-AIFlow v2.0.0 启动中...")␍
␍
# 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行) # 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行)␍
try: try:␍
from app.services.evolution_service import EvolutionWatcherManager from app.services.evolution_service import EvolutionWatcherManager␍
from app.models import UserEvolutionConfig from app.models import UserEvolutionConfig␍
db = SessionLocal() db = SessionLocal()␍
try: try:␍
configs = db.query(UserEvolutionConfig).filter( configs = db.query(UserEvolutionConfig).filter(␍
UserEvolutionConfig.watcher_status == "running" UserEvolutionConfig.watcher_status == "running"␍
).all() ).all()␍
if configs: if configs:␍
watcher = EvolutionWatcherManager() watcher = EvolutionWatcherManager()␍
for cfg in configs: for cfg in configs:␍
try: try:␍
watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal()) watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal())␍
logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline") logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline")␍
except Exception as e: except Exception as e:␍
logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}") logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}")␍
logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline") logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline")␍
else: else:␍
logger.info("[Evolution] 无待恢复的进化配置") logger.info("[Evolution] 无待恢复的进化配置")␍
finally: finally:␍
db.close() db.close()␍
except Exception as e: except Exception as e:␍
logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}") logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}")␍
␍
# 启动Redis事件总线 # 启动Redis事件总线␍
try: try:␍
from app.event_bus import get_event_bus from app.event_bus import get_event_bus␍
event_bus = get_event_bus() event_bus = get_event_bus()␍
event_bus.start(num_consumers=2) event_bus.start(num_consumers=2)␍
logger.info("[EventBus] Redis事件总线已启动") logger.info("[EventBus] Redis事件总线已启动")␍
except Exception as e: except Exception as e:␍
logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}") logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}")␍
␍
logger.info("Qihu-AIFlow v2.0.0 启动完成") logger.info("Qihu-AIFlow v2.0.0 启动完成")␍
␍
yield yield␍
␍
# 停止Redis事件总线 # 停止Redis事件总线␍
try: try:␍
from app.event_bus import get_event_bus from app.event_bus import get_event_bus␍
get_event_bus().stop() get_event_bus().stop()␍
logger.info("[EventBus] Redis事件总线已停止") logger.info("[EventBus] Redis事件总线已停止")␍
except Exception: except Exception:␍
pass pass␍
␍
# 优雅关闭:停止所有 Evolution Pipeline # 优雅关闭:停止所有 Evolution Pipeline␍
try: try:␍
from app.services.evolution_service import EvolutionWatcherManager from app.services.evolution_service import EvolutionWatcherManager␍
EvolutionWatcherManager().shutdown_all() EvolutionWatcherManager().shutdown_all()␍
logger.info("[Evolution] 所有Pipeline已停止") logger.info("[Evolution] 所有Pipeline已停止")␍
except Exception: except Exception:␍
pass pass␍
␍
logger.info("Qihu-AIFlow 正在关闭...") logger.info("Qihu-AIFlow 正在关闭...")␍
␍
␍
app = FastAPI( app = FastAPI(␍
title="Qihu-AIFlow - 智能自动化PaaS平台", title="Qihu-AIFlow - 智能自动化PaaS平台",␍
version="1.0.0", version="1.0.0",␍
lifespan=lifespan, lifespan=lifespan,␍
) )␍
␍
# API调用追踪中间件 # API调用追踪中间件␍
app.add_middleware(APITrackingMiddleware) app.add_middleware(APITrackingMiddleware)␍
␍
# CORS配置 # CORS配置␍
app.add_middleware( app.add_middleware(␍
CORSMiddleware, CORSMiddleware,␍
allow_origins=["*"], allow_origins=["*"],␍
allow_credentials=True, allow_credentials=True,␍
allow_methods=["*"], allow_methods=["*"],␍
allow_headers=["*"], allow_headers=["*"],␍
) )␍
␍
# 前台 API # 前台 API␍
app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"]) app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"])␍
app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"]) app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"])␍
app.include_router(channels.router, prefix="/app/api/channels", tags=["频道"]) app.include_router(channels.router, prefix="/app/api/channels", tags=["频道"])␍
app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"]) app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"])␍
app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"]) app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"])␍
app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"]) app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"])␍
app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"]) app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"])␍
app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"]) app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"])␍
app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"]) app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"])␍
app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"]) app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"])␍
app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"]) app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"])␍
␍
# 后台 API # 后台 API␍
app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"]) app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"])␍
app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"]) app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"])␍
app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"]) app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"])␍
app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"]) app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"])␍
app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"]) app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"])␍
app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"]) app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"])␍
app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"]) app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"])␍
app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"]) 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(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"])␍
app.include_router(logs.router, prefix="/app/api/admin/logs", 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") ␍
async def health_check(): @app.get("/app/api/health")␍
return {"status": "ok", "service": "Qihu-AIFlow"} async def health_check():␍
return {"status": "ok", "service": "Qihu-AIFlow"}␍
+123 -121
View File
@@ -1,121 +1,123 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import type { User } from '@/types'; import type { User } from '@/types';
import { AuthContext, AuthContextType } from '@/contexts/AuthContext'; import { AuthContext, AuthContextType } from '@/contexts/AuthContext';
import { authAPI } from '@/api'; import { authAPI } from '@/api';
// Layouts // Layouts
import FrontLayout from '@/components/layout/FrontLayout'; import FrontLayout from '@/components/layout/FrontLayout';
import AdminLayout from '@/components/layout/AdminLayout'; import AdminLayout from '@/components/layout/AdminLayout';
// Front Pages // Front Pages
import LandingPage from '@/pages/front/LandingPage'; import LandingPage from '@/pages/front/LandingPage';
import LoginPage from '@/pages/front/LoginPage'; import LoginPage from '@/pages/front/LoginPage';
import ChatPage from '@/pages/front/ChatPage'; import ChatPage from '@/pages/front/ChatPage';
import ChannelPage from '@/pages/front/ChannelPage'; import ChannelPage from '@/pages/front/ChannelPage';
import KnowledgePage from '@/pages/front/KnowledgePage'; import KnowledgePage from '@/pages/front/KnowledgePage';
import MarketPage from '@/pages/front/MarketPage'; import MarketPage from '@/pages/front/MarketPage';
import TokenDashboardPage from '@/pages/front/TokenDashboardPage'; import TokenDashboardPage from '@/pages/front/TokenDashboardPage';
import PricingPage from '@/pages/front/PricingPage'; import PricingPage from '@/pages/front/PricingPage';
import ProfilePage from '@/pages/front/ProfilePage'; import ProfilePage from '@/pages/front/ProfilePage';
import NotificationPage from '@/pages/front/NotificationPage'; import NotificationPage from '@/pages/front/NotificationPage';
import AssetsPage from '@/pages/front/AssetsPage'; import AssetsPage from '@/pages/front/AssetsPage';
import NovelPreviewPage from '@/pages/front/NovelPreviewPage'; import NovelPreviewPage from '@/pages/front/NovelPreviewPage';
import EvolutionDashboard from '@/pages/front/EvolutionDashboard'; import EvolutionDashboard from '@/pages/front/EvolutionDashboard';
// Admin Pages // Admin Pages
import AdminDashboard from '@/pages/admin/Dashboard'; import AdminDashboard from '@/pages/admin/Dashboard';
import AdminUsers from '@/pages/admin/Users'; import AdminUsers from '@/pages/admin/Users';
import AdminModels from '@/pages/admin/Models'; import AdminModels from '@/pages/admin/Models';
import AdminSiteSettings from '@/pages/admin/SiteSettings'; import AdminSiteSettings from '@/pages/admin/SiteSettings';
import AdminPayment from '@/pages/admin/Payment'; import AdminPayment from '@/pages/admin/Payment';
import AdminServices from '@/pages/admin/Services'; import AdminServices from '@/pages/admin/Services';
import AdminMembership from '@/pages/admin/Membership'; import AdminMembership from '@/pages/admin/Membership';
import AdminAds from '@/pages/admin/Ads'; import AdminAds from '@/pages/admin/Ads';
import AdminMarket from '@/pages/admin/Market'; import AdminMarket from '@/pages/admin/Market';
import AdminLogs from '@/pages/admin/Logs'; import AdminLogs from '@/pages/admin/Logs';
import AdminSkills from '@/pages/admin/Skills';
const App: React.FC = () => {
const [user, setUser] = useState<User | null>(null); const App: React.FC = () => {
const [loading, setLoading] = useState(true); const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
// 初始化:从localStorage恢复登录状态
useEffect(() => { // 初始化:从localStorage恢复登录状态
const token = localStorage.getItem('aiflow_token'); useEffect(() => {
const cachedUser = localStorage.getItem('aiflow_user'); const token = localStorage.getItem('aiflow_token');
if (token && cachedUser) { const cachedUser = localStorage.getItem('aiflow_user');
try { if (token && cachedUser) {
setUser(JSON.parse(cachedUser)); try {
} catch { setUser(JSON.parse(cachedUser));
localStorage.removeItem('aiflow_token'); } catch {
localStorage.removeItem('aiflow_user'); localStorage.removeItem('aiflow_token');
} localStorage.removeItem('aiflow_user');
} }
setLoading(false); }
}, []); setLoading(false);
}, []);
const login = (u: User) => {
setUser(u); const login = (u: User) => {
localStorage.setItem('aiflow_user', JSON.stringify(u)); setUser(u);
}; localStorage.setItem('aiflow_user', JSON.stringify(u));
const logout = () => { };
setUser(null); const logout = () => {
localStorage.removeItem('aiflow_token'); setUser(null);
localStorage.removeItem('aiflow_user'); localStorage.removeItem('aiflow_token');
}; localStorage.removeItem('aiflow_user');
const isAdmin = user?.role === 'admin'; };
const isAdmin = user?.role === 'admin';
if (loading) {
return <div className="loading-screen">加载中...</div>; if (loading) {
} return <div className="loading-screen">加载中...</div>;
}
return (
<AuthContext.Provider value={{ user, login, logout, isAdmin }}> return (
<Routes> <AuthContext.Provider value={{ user, login, logout, isAdmin }}>
{/* Login */} <Routes>
<Route path="/login" element={ {/* Login */}
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LoginPage /> <Route path="/login" element={
} /> user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LoginPage />
} />
{/* Root - Landing for guests, redirect for authenticated users */}
<Route path="/" element={ {/* Root - Landing for guests, redirect for authenticated users */}
user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LandingPage /> <Route path="/" element={
} /> user ? (isAdmin ? <Navigate to="/admin" /> : <Navigate to="/chat" />) : <LandingPage />
} />
{/* Front Routes (authenticated only) */}
<Route path="/" element={<FrontLayout />}> {/* Front Routes (authenticated only) */}
<Route path="chat" element={<ChatPage />} /> <Route path="/" element={<FrontLayout />}>
<Route path="chat/:id" element={<ChatPage />} /> <Route path="chat" element={<ChatPage />} />
<Route path="channels" element={<ChannelPage />} /> <Route path="chat/:id" element={<ChatPage />} />
<Route path="knowledge" element={<KnowledgePage />} /> <Route path="channels" element={<ChannelPage />} />
<Route path="market" element={<MarketPage />} /> <Route path="knowledge" element={<KnowledgePage />} />
<Route path="token" element={<TokenDashboardPage />} /> <Route path="market" element={<MarketPage />} />
<Route path="pricing" element={<PricingPage />} /> <Route path="token" element={<TokenDashboardPage />} />
<Route path="profile" element={<ProfilePage />} /> <Route path="pricing" element={<PricingPage />} />
<Route path="notifications" element={<NotificationPage />} /> <Route path="profile" element={<ProfilePage />} />
<Route path="assets" element={<AssetsPage />} /> <Route path="notifications" element={<NotificationPage />} />
<Route path="assets/novel/:id" element={<NovelPreviewPage />} /> <Route path="assets" element={<AssetsPage />} />
<Route path="evolution" element={<EvolutionDashboard />} /> <Route path="assets/novel/:id" element={<NovelPreviewPage />} />
</Route> <Route path="evolution" element={<EvolutionDashboard />} />
</Route>
{/* Admin Routes */}
<Route path="/admin" element={<AdminLayout />}> {/* Admin Routes */}
<Route index element={<AdminDashboard />} /> <Route path="/admin" element={<AdminLayout />}>
<Route path="users" element={<AdminUsers />} /> <Route index element={<AdminDashboard />} />
<Route path="models" element={<AdminModels />} /> <Route path="users" element={<AdminUsers />} />
<Route path="settings" element={<AdminSiteSettings />} /> <Route path="models" element={<AdminModels />} />
<Route path="payment" element={<AdminPayment />} /> <Route path="settings" element={<AdminSiteSettings />} />
<Route path="services" element={<AdminServices />} /> <Route path="payment" element={<AdminPayment />} />
<Route path="membership" element={<AdminMembership />} /> <Route path="services" element={<AdminServices />} />
<Route path="ads" element={<AdminAds />} /> <Route path="membership" element={<AdminMembership />} />
<Route path="market" element={<AdminMarket />} /> <Route path="ads" element={<AdminAds />} />
<Route path="logs" element={<AdminLogs />} /> <Route path="market" element={<AdminMarket />} />
</Route> <Route path="logs" element={<AdminLogs />} />
<Route path="*" element={<Navigate to="/" />} /> <Route path="skills" element={<AdminSkills />} /> </Route>
</Routes>
</AuthContext.Provider> <Route path="*" element={<Navigate to="/" />} />
); </Routes>
}; </AuthContext.Provider>
);
export default App; };
export default App;
+528 -507
View File
File diff suppressed because it is too large. Load diff
+126 -124
View File
@@ -1,124 +1,126 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom'; import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import './AdminLayout.css'; import './AdminLayout.css';
const adminNavItems = [ const adminNavItems = [
{ path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true }, { path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true },
{ path: '/admin/users', icon: 'users', label: '用户管理' }, { path: '/admin/users', icon: 'users', label: '用户管理' },
{ path: '/admin/models', icon: 'models', label: '大模型库管理' }, { path: '/admin/models', icon: 'models', label: '大模型库管理' },
{ path: '/admin/settings', icon: 'settings', label: '网站基础设置' }, { path: '/admin/settings', icon: 'settings', label: '网站基础设置' },
{ path: '/admin/payment', icon: 'payment', label: '支付渠道配置' }, { path: '/admin/payment', icon: 'payment', label: '支付渠道配置' },
{ path: '/admin/services', icon: 'services', label: '增值服务管理' }, { path: '/admin/services', icon: 'services', label: '增值服务管理' },
{ path: '/admin/membership', icon: 'membership', label: '会员订阅体系' }, { path: '/admin/membership', icon: 'membership', label: '会员订阅体系' },
{ path: '/admin/ads', icon: 'ads', label: '广告位管理' }, { path: '/admin/ads', icon: 'ads', label: '广告位管理' },
{ path: '/admin/market', icon: 'market', label: '应用市场审核' }, { path: '/admin/market', icon: 'market', label: '应用市场审核' },
{ path: '/admin/logs', icon: 'logs', 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>, const adminIconPaths: Record<string, JSX.Element> = {
users: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>, 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>,
models: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>, users: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>,
settings: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>, models: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>,
payment: <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="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>, settings: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>,
services: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>, payment: <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="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/></svg>,
membership: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>, services: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>,
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>, membership: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></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>, 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>,
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>, 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>,
const AdminLayout: React.FC = () => { 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 { user, logout } = useAuth();
const navigate = useNavigate(); const AdminLayout: React.FC = () => {
const [collapsed, setCollapsed] = useState(false); const { user, logout } = useAuth();
const navigate = useNavigate();
if (!user || user.role !== 'admin') { const [collapsed, setCollapsed] = useState(false);
navigate('/login');
return null; if (!user || user.role !== 'admin') {
} navigate('/login');
return null;
return ( }
<div className="admin-layout">
{/* Admin Sidebar */} return (
<aside className={`admin-sidebar ${collapsed ? 'collapsed' : ''}`}> <div className="admin-layout">
<div className="admin-sidebar-header"> {/* Admin Sidebar */}
<div className="sidebar-logo"> <aside className={`admin-sidebar ${collapsed ? 'collapsed' : ''}`}>
<svg width="28" height="28" viewBox="0 0 32 32" fill="none"> <div className="admin-sidebar-header">
<rect width="32" height="32" rx="8" fill="#1A1A2E"/> <div className="sidebar-logo">
<path d="M8 20V12l4 4 4-4v8" stroke="#6C63FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/> <svg width="28" height="28" viewBox="0 0 32 32" fill="none">
<path d="M6 22h20" stroke="#6C63FF" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/> <rect width="32" height="32" rx="8" fill="#1A1A2E"/>
</svg> <path d="M8 20V12l4 4 4-4v8" stroke="#6C63FF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" fill="none"/>
{!collapsed && <span className="sidebar-brand admin-brand">Qihu-AIFlow 管理后台</span>} <path d="M6 22h20" stroke="#6C63FF" strokeWidth="1.5" strokeLinecap="round" opacity="0.5"/>
</div> </svg>
<button className="collapse-btn" onClick={() => setCollapsed(!collapsed)}> {!collapsed && <span className="sidebar-brand admin-brand">Qihu-AIFlow 管理后台</span>}
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> </div>
<line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/> <button className="collapse-btn" onClick={() => setCollapsed(!collapsed)}>
</svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
</button> <line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/>
</div> </svg>
</button>
<nav className="admin-nav"> </div>
{adminNavItems.map(item => (
<NavLink <nav className="admin-nav">
key={item.path} {adminNavItems.map(item => (
to={item.path} <NavLink
end={item.exact} key={item.path}
className={({ isActive }) => `admin-nav-item ${isActive ? 'active' : ''}`} to={item.path}
> end={item.exact}
<span className="nav-icon">{adminIconPaths[item.icon]}</span> className={({ isActive }) => `admin-nav-item ${isActive ? 'active' : ''}`}
{!collapsed && <span className="nav-label">{item.label}</span>} >
</NavLink> <span className="nav-icon">{adminIconPaths[item.icon]}</span>
))} {!collapsed && <span className="nav-label">{item.label}</span>}
</nav> </NavLink>
))}
<div className="admin-sidebar-footer"> </nav>
<NavLink to="/chat" className="admin-nav-item">
<span className="nav-icon"> <div className="admin-sidebar-footer">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> <NavLink to="/chat" className="admin-nav-item">
</span> <span className="nav-icon">
{!collapsed && <span className="nav-label">返回前台</span>} <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
</NavLink> </span>
<button className="admin-nav-item logout-btn" onClick={() => { logout(); navigate('/login'); }}> {!collapsed && <span className="nav-label">返回前台</span>}
<span className="nav-icon"> </NavLink>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg> <button className="admin-nav-item logout-btn" onClick={() => { logout(); navigate('/login'); }}>
</span> <span className="nav-icon">
{!collapsed && <span className="nav-label">退出登录</span>} <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</button> </span>
</div> {!collapsed && <span className="nav-label">退出登录</span>}
{!collapsed && ( </button>
<div className="admin-version-info"> </div>
Qihu-AIFlow v2.0.0 {!collapsed && (
</div> <div className="admin-version-info">
)} Qihu-AIFlow v2.0.0
</aside> </div>
)}
{/* Admin Main Content */} </aside>
<main className="admin-main">
<header className="admin-topbar"> {/* Admin Main Content */}
<div className="admin-topbar-left"> <main className="admin-main">
<span className="admin-welcome">欢迎,{user.username}</span> <header className="admin-topbar">
</div> <div className="admin-topbar-left">
<div className="admin-topbar-right"> <span className="admin-welcome">欢迎,{user.username}</span>
<a href="#/chat" className="admin-topbar-action"> </div>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg> <div className="admin-topbar-right">
<span>返回前台</span> <a href="#/chat" className="admin-topbar-action">
</a> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
<div className="admin-user-tag"> <span>返回前台</span>
<div className="user-avatar-sm admin-avatar"> </a>
{user.username.charAt(0).toUpperCase()} <div className="admin-user-tag">
</div> <div className="user-avatar-sm admin-avatar">
<span>{user.username}</span> {user.username.charAt(0).toUpperCase()}
</div> </div>
</div> <span>{user.username}</span>
</header> </div>
<div className="admin-content"> </div>
<Outlet /> </header>
</div> <div className="admin-content">
</main> <Outlet />
</div> </div>
); </main>
}; </div>
);
export default AdminLayout; };
export default AdminLayout;
+702
View File
@@ -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;
File diff suppressed because it is too large. Load diff
+667
View File
@@ -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;
+19
View File
@@ -222,12 +222,17 @@ export interface ModelProvider {
protocol: string; protocol: string;
apiKey: string; apiKey: string;
apiBaseUrl: string; apiBaseUrl: string;
// Support both snake_case and camelCase
api_key?: string;
api_base_url?: string;
createdAt: string; createdAt: string;
updatedAt?: string; updatedAt?: string;
} }
// AIModel: supports both camelCase (TypeScript) and snake_case (from backend API)
export interface AIModel { export interface AIModel {
id: string; id: string;
// camelCase (TypeScript convention)
providerId: string; providerId: string;
providerName?: string; providerName?: string;
name: string; name: string;
@@ -238,8 +243,22 @@ export interface AIModel {
status: 'free' | 'paid'; status: 'free' | 'paid';
pricePerMillionTokens: number; pricePerMillionTokens: number;
isEnabled: boolean; isEnabled: boolean;
isDefault?: boolean;
totalCalls?: number; totalCalls?: number;
totalTokens?: 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 { export interface ProtocolDef {