From 27ba91ae5ea4b8cf0ce48e15c90828429b11cbe0 Mon Sep 17 00:00:00 2001 From: qyyfw Date: Fri, 10 Jul 2026 16:21:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E7=A7=BB=E9=99=A4=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=20Agent/=E6=8A=80=E8=83=BD/MCP=20=E6=89=8B=E5=8A=A8?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E6=8C=89=E9=92=AE=EF=BC=8C=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E4=BB=85=E6=94=AF=E6=8C=81=20URL=20=E5=AF=BC=E5=85=A5/AI=20?= =?UTF-8?q?=E5=BC=80=E5=8F=91/AI=20=E5=AE=89=E8=A3=85=20-=20=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=20Agent=E7=AE=A1=E7=90=86/=E6=8A=80=E8=83=BD=E7=AE=A1?= =?UTF-8?q?=E7=90=86/MCP=E6=9C=8D=E5=8A=A1=20=E7=A7=BB=E9=99=A4=20?= =?UTF-8?q?=E5=88=9B=E5=BB=BA/=E6=B7=BB=E5=8A=A0=20=E6=8C=89=E9=92=AE=20-?= =?UTF-8?q?=20=E5=90=8E=E7=AB=AF=E4=B8=8A=E4=BC=A0/=E5=88=9B=E5=BB=BA?= =?UTF-8?q?=E6=8C=89=E6=93=8D=E4=BD=9C=E8=80=85=E8=A7=92=E8=89=B2=E8=AE=BE?= =?UTF-8?q?=E5=AE=9A=E9=BB=98=E8=AE=A4=E5=80=BC=EF=BC=9A=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=91=98=3D=E5=AE=98=E6=96=B9+=E5=B7=B2=E5=8F=91=E5=B8=83+?= =?UTF-8?q?=E5=85=8D=E8=B4=B9=EF=BC=9B=E7=94=A8=E6=88=B7=3D=E7=94=A8?= =?UTF-8?q?=E6=88=B7+=E5=BE=85=E5=AE=A1=E6=A0=B8+=E5=AE=9A=E4=BB=B7?= =?UTF-8?q?=E8=87=AA=E9=80=89=20-=20=E5=BA=94=E7=94=A8=E5=B8=82=E5=9C=BA?= =?UTF-8?q?=20Hub=20=E5=AF=BC=E5=85=A5=E6=96=B0=E5=A2=9E=20mcp.so=20/=20MC?= =?UTF-8?q?PMarket=20=E5=B9=B3=E5=8F=B0=EF=BC=8C=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E5=A2=9E=E5=8A=A0=E5=AE=9A=E4=BB=B7=E8=BE=93?= =?UTF-8?q?=E5=85=A5(=E7=94=A8=E6=88=B7=E5=8F=AF=E9=80=89=E6=94=B6?= =?UTF-8?q?=E8=B4=B9)=20-=20=E6=B8=85=E7=90=86=E5=85=A8=E9=83=A8=E6=BC=94?= =?UTF-8?q?=E7=A4=BA=E6=95=B0=E6=8D=AE(market=5Fapps/mcp=5Fservers/mcp=5Ft?= =?UTF-8?q?ool=5Fconfigs/evolution=5Fskills)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/python/app/api/admin/agents_admin.py | 1114 +++--- .../python/app/api/admin/mcp_market_admin.py | 1224 +++++++ backend/python/app/api/market.py | 1217 ++++--- frontend/src/pages/admin/Agents.tsx | 1403 ++++--- frontend/src/pages/admin/Mcp.tsx | 809 ++-- frontend/src/pages/admin/Skills.tsx | 1332 ++++--- frontend/src/pages/front/MarketPage.css | 3243 +++++++++-------- frontend/src/pages/front/MarketPage.tsx | 3187 ++++++++-------- 8 files changed, 7414 insertions(+), 6115 deletions(-) create mode 100644 backend/python/app/api/admin/mcp_market_admin.py diff --git a/backend/python/app/api/admin/agents_admin.py b/backend/python/app/api/admin/agents_admin.py index 27cea8b..8220841 100644 --- a/backend/python/app/api/admin/agents_admin.py +++ b/backend/python/app/api/admin/agents_admin.py @@ -1,557 +1,557 @@ -""" -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, ModelProvider -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, ModelProvider).join( - ModelProvider, AIModel.provider_id == ModelProvider.id - ).filter(AIModel.is_enabled == True).all() - result["available_models"] = [ - {"id": m.id, "name": m.name, "provider": p.name} for m, p 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, ModelProvider).join( - ModelProvider, AIModel.provider_id == ModelProvider.id - ).filter( - AIModel.is_enabled == True - ).order_by(AIModel.name).all() - - return { - "code": 0, - "data": [ - { - "id": m.id, - "name": m.name, - "provider": p.name, - "model_type": p.protocol, - "input_price": float(m.price_per_million_tokens) if m.price_per_million_tokens else 0, - "output_price": float(m.price_per_million_tokens) if m.price_per_million_tokens else 0, - } - for m, p in models - ] - } +""" +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, ModelProvider +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, ModelProvider).join( + ModelProvider, AIModel.provider_id == ModelProvider.id + ).filter(AIModel.is_enabled == True).all() + result["available_models"] = [ + {"id": m.id, "name": m.name, "provider": p.name} for m, p 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=0.0, # 管理员导入/安装/开发的 Agent 默认免费 + 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, ModelProvider).join( + ModelProvider, AIModel.provider_id == ModelProvider.id + ).filter( + AIModel.is_enabled == True + ).order_by(AIModel.name).all() + + return { + "code": 0, + "data": [ + { + "id": m.id, + "name": m.name, + "provider": p.name, + "model_type": p.protocol, + "input_price": float(m.price_per_million_tokens) if m.price_per_million_tokens else 0, + "output_price": float(m.price_per_million_tokens) if m.price_per_million_tokens else 0, + } + for m, p in models + ] + } diff --git a/backend/python/app/api/admin/mcp_market_admin.py b/backend/python/app/api/admin/mcp_market_admin.py new file mode 100644 index 0000000..32fcd6a --- /dev/null +++ b/backend/python/app/api/admin/mcp_market_admin.py @@ -0,0 +1,1224 @@ +""" +MCP Tool Marketplace Admin API +Phase G1: MCP Marketplace Fusion with Goose +- MCP tool CRUD (creates MarketApp + McpToolConfig) +- Deep Link generation (goose://extension?...) +- Security audit status management +- Managed MCP server framework +- Analytics & search +""" +from fastapi import APIRouter, Depends, HTTPException, Query, Body +from sqlalchemy.orm import Session +from sqlalchemy import or_, func, desc, text +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Any +from datetime import datetime, timezone, timedelta +import uuid +import json +import base64 +import urllib.parse +import logging + +from ...database import get_db, SessionLocal +from ...models import MarketApp, gen_uuid +from ...auth import get_current_admin + +router = APIRouter() +logger = logging.getLogger("aiflow") + +CST = timezone(timedelta(hours=8)) + +# ============================================================ +# Constants +# ============================================================ + +MCP_CATEGORIES = { + "developer": "开发工具", + "database": "数据库", + "search": "搜索检索", + "file": "文件系统", + "browser": "浏览器", + "design": "设计创意", + "data": "数据处理", + "communication": "通信协作", + "cloud": "云服务", + "productivity": "效率工具", + "other": "其他", +} + +SERVER_TYPES = { + "stdio": "本地进程 (STDIO)", + "sse": "SSE 流式", + "streamable_http": "HTTP 流式", + "builtin": "内置扩展", +} + +SECURITY_STATUSES = { + "pending": "待审核", + "scanning": "扫描中", + "approved": "已通过", + "rejected": "已拒绝", + "failed": "扫描失败", +} + +RUNTIMES = { + "node": "Node.js", + "python": "Python", + "go": "Go", + "rust": "Rust", + "binary": "二进制", + "remote": "远程服务", +} + + +# ============================================================ +# Helper Functions +# ============================================================ + +def _format_cst(dt): + """Format datetime to CST string.""" + if dt is None: + return None + try: + if dt.tzinfo is None: + dt = dt.replace(tzinfo=CST) + return dt.strftime("%Y-%m-%d %H:%M:%S") + except Exception: + return str(dt) + + +def _execute_raw(db, sql, params=None): + """Execute raw SQL and return list of dicts.""" + result = db.execute(text(sql), params or {}) + try: + rows = result.fetchall() + return [dict(row._mapping) if hasattr(row, "_mapping") else dict(row) for row in rows] + except Exception: + return [] + + +def _parse_json(val): + """Safely parse JSON field.""" + if val is None: + return None + if isinstance(val, (dict, list)): + return val + if isinstance(val, str): + try: + return json.loads(val) + except Exception: + return None + return None + + +def _generate_deep_link(config: dict) -> str: + """ + Generate Goose deep link: goose://extension?... + Format: goose://extension?name=&cmd=&args=&env=&type= + """ + params = {} + name = config.get("name") or config.get("builtin_name") or "mcp-tool" + params["name"] = name + + st = config.get("server_type", "stdio") + if st == "builtin": + params["type"] = "builtin" + params["name"] = config.get("builtin_name", name) + elif st == "sse": + params["type"] = "sse" + if config.get("transport_url"): + params["url"] = config["transport_url"] + elif st == "streamable_http": + params["type"] = "streamable_http" + if config.get("transport_url"): + params["url"] = config["transport_url"] + else: # stdio + params["type"] = "stdio" + if config.get("command"): + params["cmd"] = config["command"] + if config.get("args"): + try: + args_val = config["args"] + if isinstance(args_val, str): + args_val = json.loads(args_val) + params["args"] = base64.b64encode( + json.dumps(args_val).encode() + ).decode() + except Exception: + pass + if config.get("env_vars"): + try: + env_val = config["env_vars"] + if isinstance(env_val, str): + env_val = json.loads(env_val) + params["env"] = base64.b64encode( + json.dumps(env_val).encode() + ).decode() + except Exception: + pass + + # Build URL + query = urllib.parse.urlencode(params, safe="/:") + return f"goose://extension?{query}" + + +def _basic_security_check(config: dict) -> dict: + """ + Basic security heuristic check (full scanner in G1-4). + Returns {status, issues, report}. + """ + issues = [] + risk_level = "low" + + command = (config.get("command") or "").lower() + args_str = json.dumps(config.get("args") or []) + env_str = json.dumps(config.get("env_vars") or {}) + + # Check for dangerous commands + dangerous_patterns = ["rm -rf", "mkfs", "dd if=", "chmod 777", "curl | sh", "wget | sh", "eval(", "exec(", "subprocess.call"] + all_text = f"{command} {args_str} {env_str}".lower() + for pat in dangerous_patterns: + if pat in all_text: + issues.append({"severity": "critical", "rule": f"dangerous_pattern:{pat}", "message": f"检测到危险命令模式: {pat}"}) + risk_level = "critical" + + # Check env vars for secrets patterns + env_vars = _parse_json(config.get("env_vars")) or {} + for key in env_vars: + kl = key.lower() + if any(s in kl for s in ["secret", "password", "token", "api_key", "private_key"]): + issues.append({"severity": "warning", "rule": "env_secret", "message": f"环境变量 {key} 可能包含敏感信息,建议使用密钥管理"}) + if risk_level == "low": + risk_level = "medium" + + # Check package source + registry = config.get("package_registry") + pkg_name = config.get("package_name") or "" + if registry == "custom" or (not registry and config.get("package_repo_url")): + issues.append({"severity": "info", "rule": "custom_registry", "message": "使用自定义包源,建议人工审核"}) + if risk_level == "low": + risk_level = "low" + + # Check network access + if config.get("transport_url") or config.get("is_managed"): + issues.append({"severity": "info", "rule": "network_access", "message": "该工具需要网络访问"}) + + # Determine status + if risk_level == "critical": + status = "rejected" + elif risk_level == "medium": + status = "pending" # needs manual review + else: + status = "approved" + + return { + "status": status, + "risk_level": risk_level, + "issues_count": len(issues), + "issues": issues, + "scanned_at": datetime.now(CST).strftime("%Y-%m-%d %H:%M:%S"), + } + + +def _tool_config_to_dict(row: dict, app: dict = None) -> dict: + """Convert mcp_tool_config raw row to dict with parsed JSON fields.""" + d = dict(row) + for k in ["args", "env_vars", "security_report", "permissions", "config_schema", "default_config", "tools_list"]: + if k in d: + d[k] = _parse_json(d[k]) + d["created_at"] = _format_cst(d.get("created_at")) + d["updated_at"] = _format_cst(d.get("updated_at")) + d["security_scanned_at"] = _format_cst(d.get("security_scanned_at")) + if app: + d["app"] = app + return d + + +def _app_to_dict(app) -> dict: + """Convert MarketApp ORM to dict.""" + return { + "id": app.id, + "name": app.name, + "type": app.type, + "category": getattr(app, "category", "other"), + "tags": getattr(app, "tags", None), + "source": app.source, + "publisher_name": app.publisher_name, + "description": app.description, + "icon": app.icon, + "price": float(app.price) if app.price else 0.0, + "is_member_free": bool(app.is_member_free), + "featured": bool(getattr(app, "featured", False)), + "status": app.status, + "rating_avg": float(app.rating_avg) if app.rating_avg else 0.0, + "review_count": getattr(app, "review_count", 0), + "install_count": getattr(app, "install_count", 0), + "version": getattr(app, "version", "1.0.0"), + "created_at": _format_cst(app.created_at), + } + + +# ============================================================ +# Pydantic Models +# ============================================================ + +class MCPToolCreate(BaseModel): + name: str = Field(..., max_length=200) + description: Optional[str] = "" + icon: Optional[str] = "" + category: Optional[str] = "developer" + tags: Optional[str] = "" + # MarketApp fields + price: Optional[float] = 0.0 + is_member_free: Optional[bool] = False + featured: Optional[bool] = False + # MCP Config + server_type: str = "stdio" + runtime: Optional[str] = None + command: Optional[str] = None + args: Optional[List[str]] = None + env_vars: Optional[Dict[str, str]] = None + transport_url: Optional[str] = None + builtin_name: Optional[str] = None + is_managed: Optional[bool] = False + managed_port: Optional[int] = None + # Package + package_name: Optional[str] = None + package_version: Optional[str] = "latest" + package_registry: Optional[str] = None + package_repo_url: Optional[str] = None + # Metadata + permissions: Optional[Dict[str, Any]] = None + config_schema: Optional[Dict[str, Any]] = None + default_config: Optional[Dict[str, Any]] = None + install_instructions: Optional[str] = None + tools_count: Optional[int] = 0 + tools_list: Optional[List[str]] = None + author_name: Optional[str] = None + author_email: Optional[str] = None + license: Optional[str] = "MIT" + homepage_url: Optional[str] = None + + +class MCPToolUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + icon: Optional[str] = None + category: Optional[str] = None + tags: Optional[str] = None + price: Optional[float] = None + is_member_free: Optional[bool] = None + featured: Optional[bool] = None + status: Optional[str] = None + # MCP Config + server_type: Optional[str] = None + runtime: Optional[str] = None + command: Optional[str] = None + args: Optional[List[str]] = None + env_vars: Optional[Dict[str, str]] = None + transport_url: Optional[str] = None + builtin_name: Optional[str] = None + is_managed: Optional[bool] = None + managed_port: Optional[int] = None + # Package + package_name: Optional[str] = None + package_version: Optional[str] = None + package_registry: Optional[str] = None + package_repo_url: Optional[str] = None + # Metadata + permissions: Optional[Dict[str, Any]] = None + config_schema: Optional[Dict[str, Any]] = None + default_config: Optional[Dict[str, Any]] = None + install_instructions: Optional[str] = None + tools_count: Optional[int] = None + tools_list: Optional[List[str]] = None + author_name: Optional[str] = None + author_email: Optional[str] = None + license: Optional[str] = None + homepage_url: Optional[str] = None + + +class SecurityStatusUpdate(BaseModel): + security_status: str # pending/scanning/approved/rejected/failed + security_report: Optional[Dict[str, Any]] = None + + +class ManagedServerCreate(BaseModel): + name: str + config_id: str # mcp_tool_configs.id + port: Optional[int] = None + + +# ============================================================ +# Routes +# ============================================================ + +@router.get("/stats", summary="MCP 市场统计") +async def get_mcp_market_stats( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取 MCP 工具市场总览数据""" + # Total by status + status_rows = _execute_raw(db, """ + SELECT security_status, COUNT(*) as cnt + FROM mcp_tool_configs + GROUP BY security_status + """) + status_map = {r["security_status"]: r["cnt"] for r in status_rows} + + # Total by server_type + type_rows = _execute_raw(db, """ + SELECT server_type, COUNT(*) as cnt + FROM mcp_tool_configs + GROUP BY server_type + """) + type_map = {r["server_type"]: r["cnt"] for r in type_rows} + + # Total by category (from market_apps) + cat_rows = _execute_raw(db, """ + SELECT ma.category, COUNT(*) as cnt + FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + GROUP BY ma.category + """) + cat_map = {r["category"]: r["cnt"] for r in cat_rows} + + # Managed vs non-managed + managed_count = _execute_raw(db, "SELECT COUNT(*) as cnt FROM mcp_tool_configs WHERE is_managed=1") + managed = managed_count[0]["cnt"] if managed_count else 0 + + # Published apps + published = _execute_raw(db, """ + SELECT COUNT(*) as cnt FROM market_apps WHERE type='mcp_tool' AND status='published' + """) + pub_count = published[0]["cnt"] if published else 0 + + # Pending review + pending = _execute_raw(db, """ + SELECT COUNT(*) as cnt FROM mcp_tool_configs WHERE security_status IN ('pending','scanning') + """) + pending_count = pending[0]["cnt"] if pending else 0 + + total = sum(status_map.values()) + + return { + "code": 0, + "data": { + "total": total, + "published": pub_count, + "pending_review": pending_count, + "managed_servers": managed, + "by_security_status": status_map, + "by_server_type": type_map, + "by_category": cat_map, + } + } + + +@router.get("/", summary="MCP 工具列表") +async def list_mcp_tools( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + keyword: Optional[str] = Query(None), + category: Optional[str] = Query(None), + server_type: Optional[str] = Query(None), + security_status: Optional[str] = Query(None), + is_managed: Optional[bool] = Query(None), + sort: str = Query("newest"), # newest/popular/rating/name + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取 MCP 工具列表(分页、搜索、过滤)""" + where_parts = ["ma.type = 'mcp_tool'"] + params = {} + + if keyword: + where_parts.append("(ma.name LIKE :kw OR ma.description LIKE :kw OR mc.package_name LIKE :kw)") + params["kw"] = f"%{keyword}%" + if category: + where_parts.append("ma.category = :cat") + params["cat"] = category + if server_type: + where_parts.append("mc.server_type = :st") + params["st"] = server_type + if security_status: + where_parts.append("mc.security_status = :ss") + params["ss"] = security_status + if is_managed is not None: + where_parts.append("mc.is_managed = :mg") + params["mg"] = 1 if is_managed else 0 + + where_clause = " AND ".join(where_parts) + + # Sort + order_map = { + "newest": "mc.created_at DESC", + "popular": "ma.install_count DESC", + "rating": "ma.rating_avg DESC", + "name": "ma.name ASC", + } + order = order_map.get(sort, order_map["newest"]) + + # Count + count_sql = f"SELECT COUNT(*) as cnt FROM mcp_tool_configs mc JOIN market_apps ma ON mc.app_id = ma.id WHERE {where_clause}" + count_rows = _execute_raw(db, count_sql, params) + total = count_rows[0]["cnt"] if count_rows else 0 + + # Query + offset = (page - 1) * page_size + query_sql = f""" + SELECT mc.*, ma.name, ma.description, ma.icon, ma.category, ma.tags, + ma.status as app_status, ma.publisher_name, ma.price, ma.is_member_free, + ma.featured, ma.rating_avg, ma.review_count, ma.install_count, + ma.version, ma.created_at as app_created_at + FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + WHERE {where_clause} + ORDER BY {order} + LIMIT :limit OFFSET :offset + """ + params["limit"] = page_size + params["offset"] = offset + rows = _execute_raw(db, query_sql, params) + + items = [] + for row in rows: + item = { + "id": row["id"], + "app_id": row["app_id"], + "name": row["name"], + "description": row["description"], + "icon": row["icon"], + "category": row["category"], + "tags": row["tags"], + "status": row["app_status"], + "publisher_name": row["publisher_name"], + "price": float(row["price"]) if row["price"] else 0.0, + "is_member_free": bool(row["is_member_free"]), + "featured": bool(row["featured"]), + "rating_avg": float(row["rating_avg"]) if row["rating_avg"] else 0.0, + "review_count": row["review_count"], + "install_count": row["install_count"], + "version": row["version"], + "server_type": row["server_type"], + "runtime": row["runtime"], + "command": row["command"], + "is_managed": bool(row["is_managed"]), + "security_status": row["security_status"], + "security_issues": row["security_issues"], + "deep_link": row["deep_link"], + "package_name": row["package_name"], + "package_registry": row["package_registry"], + "tools_count": row["tools_count"], + "created_at": _format_cst(row["created_at"]), + } + items.append(item) + + return { + "code": 0, + "data": { + "total": total, + "page": page, + "page_size": page_size, + "items": items, + } + } + + +@router.get("/categories", summary="MCP 工具分类列表") +async def get_categories( + admin_user=Depends(get_current_admin), +): + """获取 MCP 工具分类及服务类型选项""" + return { + "code": 0, + "data": { + "categories": MCP_CATEGORIES, + "server_types": SERVER_TYPES, + "security_statuses": SECURITY_STATUSES, + "runtimes": RUNTIMES, + } + } + + +@router.post("/", summary="创建 MCP 工具") +async def create_mcp_tool( + body: MCPToolCreate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """创建新的 MCP 工具(同时创建 MarketApp + McpToolConfig)""" + # Validate server_type + if body.server_type not in SERVER_TYPES: + raise HTTPException(400, f"Invalid server_type: {body.server_type}") + + # Validate stdio requires command + if body.server_type == "stdio" and not body.command: + raise HTTPException(400, "stdio server_type requires 'command' field") + + # Validate builtin requires builtin_name + if body.server_type == "builtin" and not body.builtin_name: + raise HTTPException(400, "builtin server_type requires 'builtin_name' field") + + # Validate sse/streamable_http requires transport_url + if body.server_type in ("sse", "streamable_http") and not body.transport_url: + raise HTTPException(400, f"{body.server_type} server_type requires 'transport_url' field") + + app_id = str(uuid.uuid4()) + config_id = str(uuid.uuid4()) + now = datetime.now(CST).strftime("%Y-%m-%d %H:%M:%S") + + # 1. Create MarketApp + db.execute(text(""" + INSERT INTO market_apps (id, name, type, category, tags, source, publisher_name, + description, icon, price, is_member_free, featured, status, version, created_at, updated_at) + VALUES (:id, :name, 'mcp_tool', :category, :tags, 'official', :publisher, + :description, :icon, :price, :member_free, :featured, 'published', '1.0.0', NOW(), NOW()) + """), { + "id": app_id, + "name": body.name, + "category": body.category or "developer", + "tags": body.tags or "", + "publisher": body.author_name or "Platform", + "description": body.description or "", + "icon": body.icon or "", + "price": 0.0, # 管理员导入/安装/开发的 MCP 默认免费 + "member_free": 1 if body.is_member_free else 0, + "featured": 1 if body.featured else 0, + }) + + # 2. Build config dict for deep link and security check + config_for_dl = { + "name": body.name, + "server_type": body.server_type, + "command": body.command, + "args": body.args, + "env_vars": body.env_vars, + "transport_url": body.transport_url, + "builtin_name": body.builtin_name, + } + deep_link = _generate_deep_link(config_for_dl) + + # 3. Basic security check + sec_result = _basic_security_check({ + "command": body.command, + "args": body.args, + "env_vars": body.env_vars, + "package_registry": body.package_registry, + "package_name": body.package_name, + "package_repo_url": body.package_repo_url, + "transport_url": body.transport_url, + "is_managed": body.is_managed, + }) + + # 4. Create McpToolConfig + args_json = json.dumps(body.args) if body.args else None + env_json = json.dumps(body.env_vars) if body.env_vars else None + permissions_json = json.dumps(body.permissions) if body.permissions else None + config_schema_json = json.dumps(body.config_schema) if body.config_schema else None + default_config_json = json.dumps(body.default_config) if body.default_config else None + tools_list_json = json.dumps(body.tools_list) if body.tools_list else None + sec_report_json = json.dumps(sec_result) if sec_result else None + + db.execute(text(""" + INSERT INTO mcp_tool_configs ( + id, app_id, server_type, runtime, command, args, env_vars, + transport_url, builtin_name, is_managed, managed_port, + package_name, package_version, package_registry, package_repo_url, + deep_link, security_status, security_report, security_scanned_at, security_issues, + permissions, config_schema, default_config, install_instructions, + tools_count, tools_list, author_name, author_email, license, homepage_url, + created_at, updated_at + ) VALUES ( + :id, :app_id, :server_type, :runtime, :command, :args, :env_vars, + :transport_url, :builtin_name, :is_managed, :managed_port, + :package_name, :package_version, :package_registry, :package_repo_url, + :deep_link, :sec_status, :sec_report, NOW(), :sec_issues, + :permissions, :config_schema, :default_config, :install_instructions, + :tools_count, :tools_list, :author_name, :author_email, :license, :homepage_url, + NOW(), NOW() + ) + """), { + "id": config_id, + "app_id": app_id, + "server_type": body.server_type, + "runtime": body.runtime, + "command": body.command, + "args": args_json, + "env_vars": env_json, + "transport_url": body.transport_url, + "builtin_name": body.builtin_name, + "is_managed": 1 if body.is_managed else 0, + "managed_port": body.managed_port, + "package_name": body.package_name, + "package_version": body.package_version or "latest", + "package_registry": body.package_registry, + "package_repo_url": body.package_repo_url, + "deep_link": deep_link, + "sec_status": sec_result["status"], + "sec_report": sec_report_json, + "sec_issues": sec_result["issues_count"], + "permissions": permissions_json, + "config_schema": config_schema_json, + "default_config": default_config_json, + "install_instructions": body.install_instructions, + "tools_count": body.tools_count or 0, + "tools_list": tools_list_json, + "author_name": body.author_name, + "author_email": body.author_email, + "license": body.license or "MIT", + "homepage_url": body.homepage_url, + }) + + db.commit() + + return { + "code": 0, + "message": "MCP 工具创建成功", + "data": { + "app_id": app_id, + "config_id": config_id, + "deep_link": deep_link, + "security_status": sec_result["status"], + "security_issues": sec_result["issues_count"], + } + } + + +@router.get("/{config_id}", summary="MCP 工具详情") +async def get_mcp_tool_detail( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取 MCP 工具完整详情""" + rows = _execute_raw(db, """ + SELECT mc.*, ma.name, ma.description, ma.icon, ma.category, ma.tags, + ma.source, ma.publisher_name, ma.price, ma.is_member_free, + ma.featured, ma.featured_order, ma.status as app_status, + ma.rating_avg, ma.review_count, ma.install_count, ma.version, + ma.changelog, ma.download_url, ma.tutorial, + ma.created_at as app_created_at, ma.updated_at as app_updated_at + FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + WHERE mc.id = :cid + """, {"cid": config_id}) + + if not rows: + raise HTTPException(404, "MCP 工具不存在") + + row = rows[0] + result = { + "id": row["id"], + "app_id": row["app_id"], + "app": { + "name": row["name"], + "description": row["description"], + "icon": row["icon"], + "category": row["category"], + "tags": row["tags"], + "source": row["source"], + "publisher_name": row["publisher_name"], + "price": float(row["price"]) if row["price"] else 0.0, + "is_member_free": bool(row["is_member_free"]), + "featured": bool(row["featured"]), + "status": row["app_status"], + "rating_avg": float(row["rating_avg"]) if row["rating_avg"] else 0.0, + "review_count": row["review_count"], + "install_count": row["install_count"], + "version": row["version"], + "tutorial": row["tutorial"], + "changelog": row["changelog"], + "download_url": row["download_url"], + "created_at": _format_cst(row["app_created_at"]), + "updated_at": _format_cst(row["app_updated_at"]), + }, + "server_type": row["server_type"], + "runtime": row["runtime"], + "command": row["command"], + "args": _parse_json(row["args"]), + "env_vars": _parse_json(row["env_vars"]), + "transport_url": row["transport_url"], + "builtin_name": row["builtin_name"], + "is_managed": bool(row["is_managed"]), + "managed_url": row["managed_url"], + "managed_port": row["managed_port"], + "package_name": row["package_name"], + "package_version": row["package_version"], + "package_registry": row["package_registry"], + "package_repo_url": row["package_repo_url"], + "deep_link": row["deep_link"], + "security_status": row["security_status"], + "security_report": _parse_json(row["security_report"]), + "security_scanned_at": _format_cst(row["security_scanned_at"]), + "security_issues": row["security_issues"], + "permissions": _parse_json(row["permissions"]), + "config_schema": _parse_json(row["config_schema"]), + "default_config": _parse_json(row["default_config"]), + "install_instructions": row["install_instructions"], + "tools_count": row["tools_count"], + "tools_list": _parse_json(row["tools_list"]), + "author_name": row["author_name"], + "author_email": row["author_email"], + "license": row["license"], + "homepage_url": row["homepage_url"], + "created_at": _format_cst(row["created_at"]), + "updated_at": _format_cst(row["updated_at"]), + } + + return {"code": 0, "data": result} + + +@router.put("/{config_id}", summary="更新 MCP 工具") +async def update_mcp_tool( + config_id: str, + body: MCPToolUpdate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """更新 MCP 工具配置""" + # Check exists + existing = _execute_raw(db, "SELECT id, app_id FROM mcp_tool_configs WHERE id = :cid", {"cid": config_id}) + if not existing: + raise HTTPException(404, "MCP 工具不存在") + + app_id = existing[0]["app_id"] + + # Update MarketApp fields + app_fields = {} + if body.name is not None: + app_fields["name"] = body.name + if body.description is not None: + app_fields["description"] = body.description + if body.icon is not None: + app_fields["icon"] = body.icon + if body.category is not None: + app_fields["category"] = body.category + if body.tags is not None: + app_fields["tags"] = body.tags + if body.price is not None: + app_fields["price"] = body.price + if body.is_member_free is not None: + app_fields["is_member_free"] = 1 if body.is_member_free else 0 + if body.featured is not None: + app_fields["featured"] = 1 if body.featured else 0 + if body.status is not None: + app_fields["status"] = body.status + + if app_fields: + set_parts = [f"{k} = :app_{k}" for k in app_fields] + app_params = {f"app_{k}": v for k, v in app_fields.items()} + app_params["aid"] = app_id + db.execute(text(f"UPDATE market_apps SET {', '.join(set_parts)}, updated_at=NOW() WHERE id = :aid"), app_params) + + # Update McpToolConfig fields + cfg_fields = {} + if body.server_type is not None: + cfg_fields["server_type"] = body.server_type + if body.runtime is not None: + cfg_fields["runtime"] = body.runtime + if body.command is not None: + cfg_fields["command"] = body.command + if body.args is not None: + cfg_fields["args"] = json.dumps(body.args) + if body.env_vars is not None: + cfg_fields["env_vars"] = json.dumps(body.env_vars) + if body.transport_url is not None: + cfg_fields["transport_url"] = body.transport_url + if body.builtin_name is not None: + cfg_fields["builtin_name"] = body.builtin_name + if body.is_managed is not None: + cfg_fields["is_managed"] = 1 if body.is_managed else 0 + if body.managed_port is not None: + cfg_fields["managed_port"] = body.managed_port + if body.package_name is not None: + cfg_fields["package_name"] = body.package_name + if body.package_version is not None: + cfg_fields["package_version"] = body.package_version + if body.package_registry is not None: + cfg_fields["package_registry"] = body.package_registry + if body.package_repo_url is not None: + cfg_fields["package_repo_url"] = body.package_repo_url + if body.permissions is not None: + cfg_fields["permissions"] = json.dumps(body.permissions) + if body.config_schema is not None: + cfg_fields["config_schema"] = json.dumps(body.config_schema) + if body.default_config is not None: + cfg_fields["default_config"] = json.dumps(body.default_config) + if body.install_instructions is not None: + cfg_fields["install_instructions"] = body.install_instructions + if body.tools_count is not None: + cfg_fields["tools_count"] = body.tools_count + if body.tools_list is not None: + cfg_fields["tools_list"] = json.dumps(body.tools_list) + if body.author_name is not None: + cfg_fields["author_name"] = body.author_name + if body.author_email is not None: + cfg_fields["author_email"] = body.author_email + if body.license is not None: + cfg_fields["license"] = body.license + if body.homepage_url is not None: + cfg_fields["homepage_url"] = body.homepage_url + + if cfg_fields: + # Regenerate deep link if transport-related fields changed + dl_fields = {"server_type", "command", "args", "env_vars", "transport_url", "builtin_name"} + if dl_fields & set(cfg_fields.keys()): + # Get current values for deep link regeneration + current = _execute_raw(db, """ + SELECT mc.*, ma.name FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + WHERE mc.id = :cid + """, {"cid": config_id}) + if current: + c = current[0] + dl_config = { + "name": body.name or c["name"], + "server_type": cfg_fields.get("server_type", c["server_type"]), + "command": cfg_fields.get("command", c["command"]), + "args": _parse_json(cfg_fields.get("args", c["args"])), + "env_vars": _parse_json(cfg_fields.get("env_vars", c["env_vars"])), + "transport_url": cfg_fields.get("transport_url", c["transport_url"]), + "builtin_name": cfg_fields.get("builtin_name", c["builtin_name"]), + } + cfg_fields["deep_link"] = _generate_deep_link(dl_config) + + set_parts = [f"{k} = :cfg_{k}" for k in cfg_fields] + cfg_params = {f"cfg_{k}": v for k, v in cfg_fields.items()} + cfg_params["cid"] = config_id + db.execute(text(f"UPDATE mcp_tool_configs SET {', '.join(set_parts)}, updated_at=NOW() WHERE id = :cid"), cfg_params) + + db.commit() + + return {"code": 0, "message": "更新成功"} + + +@router.delete("/{config_id}", summary="删除 MCP 工具") +async def delete_mcp_tool( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """删除 MCP 工具(同时删除 MarketApp 和 McpToolConfig)""" + existing = _execute_raw(db, "SELECT app_id FROM mcp_tool_configs WHERE id = :cid", {"cid": config_id}) + if not existing: + raise HTTPException(404, "MCP 工具不存在") + + app_id = existing[0]["app_id"] + + db.execute(text("DELETE FROM mcp_tool_configs WHERE id = :cid"), {"cid": config_id}) + db.execute(text("UPDATE market_apps SET status='removed', updated_at=NOW() WHERE id = :aid"), {"aid": app_id}) + + db.commit() + + return {"code": 0, "message": "删除成功"} + + +@router.post("/{config_id}/security-scan", summary="触发安全扫描") +async def trigger_security_scan( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """对 MCP 工具执行安全扫描(基础版,完整版在 G1-4)""" + rows = _execute_raw(db, """ + SELECT mc.*, ma.name FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + WHERE mc.id = :cid + """, {"cid": config_id}) + + if not rows: + raise HTTPException(404, "MCP 工具不存在") + + row = rows[0] + + # Update status to scanning + db.execute(text("UPDATE mcp_tool_configs SET security_status='scanning' WHERE id = :cid"), {"cid": config_id}) + db.commit() + + # Run basic security check + sec_result = _basic_security_check({ + "command": row["command"], + "args": _parse_json(row["args"]), + "env_vars": _parse_json(row["env_vars"]), + "package_registry": row["package_registry"], + "package_name": row["package_name"], + "package_repo_url": row["package_repo_url"], + "transport_url": row["transport_url"], + "is_managed": row["is_managed"], + }) + + # Update with results + db.execute(text(""" + UPDATE mcp_tool_configs + SET security_status = :status, security_report = :report, + security_scanned_at = NOW(), security_issues = :issues, + updated_at = NOW() + WHERE id = :cid + """), { + "status": sec_result["status"], + "report": json.dumps(sec_result), + "issues": sec_result["issues_count"], + "cid": config_id, + }) + db.commit() + + return { + "code": 0, + "message": "安全扫描完成", + "data": sec_result, + } + + +@router.put("/{config_id}/security-status", summary="更新安全审核状态") +async def update_security_status( + config_id: str, + body: SecurityStatusUpdate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """手动更新 MCP 工具的安全审核状态""" + if body.security_status not in SECURITY_STATUSES: + raise HTTPException(400, f"Invalid security_status: {body.security_status}") + + existing = _execute_raw(db, "SELECT id FROM mcp_tool_configs WHERE id = :cid", {"cid": config_id}) + if not existing: + raise HTTPException(404, "MCP 工具不存在") + + report_json = json.dumps(body.security_report) if body.security_report else None + + db.execute(text(""" + UPDATE mcp_tool_configs + SET security_status = :status, security_report = COALESCE(:report, security_report), + security_scanned_at = NOW(), updated_at = NOW() + WHERE id = :cid + """), { + "status": body.security_status, + "report": report_json, + "cid": config_id, + }) + + # Auto-publish app if approved + if body.security_status == "approved": + app_row = _execute_raw(db, "SELECT app_id FROM mcp_tool_configs WHERE id = :cid", {"cid": config_id}) + if app_row: + db.execute(text("UPDATE market_apps SET status='published', updated_at=NOW() WHERE id = :aid"), + {"aid": app_row[0]["app_id"]}) + + db.commit() + + return {"code": 0, "message": "安全状态已更新"} + + +@router.post("/{config_id}/deep-link", summary="重新生成 Deep Link") +async def regenerate_deep_link( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """重新生成 Goose Deep Link URL""" + rows = _execute_raw(db, """ + SELECT mc.*, ma.name FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + WHERE mc.id = :cid + """, {"cid": config_id}) + + if not rows: + raise HTTPException(404, "MCP 工具不存在") + + row = rows[0] + config = { + "name": row["name"], + "server_type": row["server_type"], + "command": row["command"], + "args": _parse_json(row["args"]), + "env_vars": _parse_json(row["env_vars"]), + "transport_url": row["transport_url"], + "builtin_name": row["builtin_name"], + } + deep_link = _generate_deep_link(config) + + db.execute(text("UPDATE mcp_tool_configs SET deep_link = :dl, updated_at = NOW() WHERE id = :cid"), + {"dl": deep_link, "cid": config_id}) + db.commit() + + return {"code": 0, "data": {"deep_link": deep_link}} + + +@router.get("/managed/servers", summary="托管 MCP 服务器列表") +async def list_managed_servers( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取平台托管的 MCP 服务器列表""" + rows = _execute_raw(db, """ + SELECT mc.id, mc.managed_url, mc.managed_port, mc.server_type, + mc.tools_count, mc.tools_list, ma.name, ma.status, ma.description + FROM mcp_tool_configs mc + JOIN market_apps ma ON mc.app_id = ma.id + WHERE mc.is_managed = 1 + ORDER BY mc.created_at DESC + """) + + servers = [] + for row in rows: + servers.append({ + "id": row["id"], + "name": row["name"], + "description": row["description"], + "url": row["managed_url"], + "port": row["managed_port"], + "server_type": row["server_type"], + "status": row["status"], + "tools_count": row["tools_count"], + "tools_list": _parse_json(row["tools_list"]), + }) + + return {"code": 0, "data": {"servers": servers, "total": len(servers)}} + + +@router.post("/managed/{config_id}/start", summary="启动托管 MCP 服务器") +async def start_managed_server( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """启动一个托管 MCP 服务器(占位 - 完整实现在 G1-5)""" + rows = _execute_raw(db, "SELECT id, is_managed FROM mcp_tool_configs WHERE id = :cid", {"cid": config_id}) + if not rows: + raise HTTPException(404, "MCP 工具不存在") + if not rows[0]["is_managed"]: + raise HTTPException(400, "该工具不是托管类型") + + # Placeholder: in G1-5, this will spin up a Docker container or subprocess + managed_url = f"http://localhost:9100/mcp/{config_id[:8]}" + + db.execute(text(""" + UPDATE mcp_tool_configs SET managed_url = :url, updated_at = NOW() + WHERE id = :cid + """), {"url": managed_url, "cid": config_id}) + db.commit() + + return {"code": 0, "message": "托管服务器启动中(占位)", "data": {"url": managed_url}} + + +@router.post("/managed/{config_id}/stop", summary="停止托管 MCP 服务器") +async def stop_managed_server( + config_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """停止一个托管 MCP 服务器(占位 - 完整实现在 G1-5)""" + rows = _execute_raw(db, "SELECT id, is_managed FROM mcp_tool_configs WHERE id = :cid", {"cid": config_id}) + if not rows: + raise HTTPException(404, "MCP 工具不存在") + + db.execute(text("UPDATE mcp_tool_configs SET managed_url = NULL, updated_at = NOW() WHERE id = :cid"), + {"cid": config_id}) + db.commit() + + return {"code": 0, "message": "托管服务器已停止"} + + +# ============================================================ +# Seed Data: Pre-populate with popular Goose MCP extensions +# ============================================================ + +@router.post("/seed", summary="填充内置 MCP 工具") +async def seed_builtin_tools( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """填充 Goose 内置 MCP 扩展到市场(一次性操作)""" + # Check if already seeded + existing = _execute_raw(db, "SELECT COUNT(*) as cnt FROM mcp_tool_configs WHERE builtin_name IS NOT NULL") + if existing and existing[0]["cnt"] > 0: + return {"code": 0, "message": f"已有 {existing[0]['cnt']} 个内置工具,跳过填充"} + + builtin_tools = [ + { + "name": "Developer", "builtin_name": "developer", + "description": "Goose 内置开发工具 - 代码执行、文件操作、终端命令", + "category": "developer", "command": None, "server_type": "builtin", + "tools_count": 12, "tools_list": ["shell", "read_file", "write_file", "list_files", "grep", "edit_file", "patch", "undo_edit", "find_path", "text_search", "architect", "undo"], + "author_name": "Goose Team", "license": "Apache-2.0", + "permissions": {"filesystem": True, "terminal": True, "network": False}, + "install_instructions": "Goose Desktop 内置,无需安装。CLI 模式: goose session --type builtin --name developer", + }, + { + "name": "Computer Controller", "builtin_name": "computer-controller", + "description": "屏幕截图、鼠标键盘控制 - GUI 自动化操作", + "category": "productivity", "command": None, "server_type": "builtin", + "tools_count": 8, "tools_list": ["screenshot", "mouse_click", "mouse_move", "key_press", "key_type", "scroll", "wait", "find_element"], + "author_name": "Goose Team", "license": "Apache-2.0", + "permissions": {"screen": True, "input": True, "network": False}, + "install_instructions": "Goose Desktop 内置。需要授予屏幕录制和输入控制权限。", + }, + { + "name": "Memory", "builtin_name": "memory", + "description": "长期记忆存储 - 跨会话保存和检索信息", + "category": "assistant", "command": None, "server_type": "builtin", + "tools_count": 4, "tools_list": ["store", "retrieve", "search", "delete"], + "author_name": "Goose Team", "license": "Apache-2.0", + "permissions": {"filesystem": True, "terminal": False, "network": False}, + "install_instructions": "Goose Desktop 内置。记忆存储在 ~/.config/goose/memory/", + }, + { + "name": "JetBrains", "builtin_name": "jetbrains", + "description": "JetBrains IDE 集成 - 打开项目、执行操作", + "category": "developer", "command": None, "server_type": "builtin", + "tools_count": 6, "tools_list": ["open_project", "run_action", "get_open_file", "replace_in_file", "create_file", "get_errors"], + "author_name": "Goose Team", "license": "Apache-2.0", + "permissions": {"filesystem": True, "terminal": False, "network": False}, + "install_instructions": "需要安装 JetBrains MCP 插件。支持 IntelliJ IDEA, PyCharm, WebStorm, GoLand 等。", + }, + ] + + created = 0 + for tool in builtin_tools: + app_id = str(uuid.uuid4()) + config_id = str(uuid.uuid4()) + + # Create MarketApp + db.execute(text(""" + INSERT INTO market_apps (id, name, type, category, source, publisher_name, + description, price, is_member_free, featured, status, version, created_at, updated_at) + VALUES (:id, :name, 'mcp_tool', :category, 'official', :publisher, + :description, 0.0, 1, 0, 'published', '1.0.0', NOW(), NOW()) + """), { + "id": app_id, "name": tool["name"], "category": tool["category"], + "publisher": tool["author_name"], "description": tool["description"], + }) + + # Create McpToolConfig + deep_link = _generate_deep_link(tool) + sec_result = _basic_security_check(tool) + + db.execute(text(""" + INSERT INTO mcp_tool_configs ( + id, app_id, server_type, builtin_name, deep_link, + security_status, security_report, security_scanned_at, security_issues, + permissions, install_instructions, tools_count, tools_list, + author_name, license, created_at, updated_at + ) VALUES ( + :id, :app_id, 'builtin', :builtin_name, :deep_link, + :sec_status, :sec_report, NOW(), 0, + :permissions, :install_instructions, :tools_count, :tools_list, + :author_name, :license, NOW(), NOW() + ) + """), { + "id": config_id, "app_id": app_id, "builtin_name": tool["builtin_name"], + "deep_link": deep_link, "sec_status": "approved", + "sec_report": json.dumps(sec_result), + "permissions": json.dumps(tool["permissions"]), + "install_instructions": tool["install_instructions"], + "tools_count": tool["tools_count"], + "tools_list": json.dumps(tool["tools_list"]), + "author_name": tool["author_name"], "license": tool["license"], + }) + created += 1 + + db.commit() + + return {"code": 0, "message": f"成功填充 {created} 个内置 MCP 工具", "data": {"created": created}} diff --git a/backend/python/app/api/market.py b/backend/python/app/api/market.py index 7f07b82..e7855f6 100644 --- a/backend/python/app/api/market.py +++ b/backend/python/app/api/market.py @@ -1,601 +1,616 @@ -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session -from sqlalchemy import and_, desc, asc, func -from pydantic import BaseModel, Field -from typing import Optional -import uuid -from datetime import datetime - -from ..database import get_db -from ..models import ( - User, MarketApp, AppPurchase, AppReview, AppInstallation, Order, - gen_uuid -) -from ..auth import get_current_user, get_current_user_id, get_current_user_id_optional -from ..middleware.log_service import LogService - -router = APIRouter() - - -class UploadAppRequest(BaseModel): - name: str = Field(min_length=1, max_length=200) - description: Optional[str] = None - icon: Optional[str] = None - type: Optional[str] = None # skill, agent, mcp - source: Optional[str] = None - price: float = Field(default=0, ge=0) - download_url: Optional[str] = None - category: Optional[str] = None - tags: Optional[str] = None - is_member_free: Optional[bool] = False - join_promotion: Optional[bool] = False - agent_endpoint: Optional[str] = None - - -class PurchaseAppRequest(BaseModel): - amount: Optional[float] = None - - -class ReviewAppRequest(BaseModel): - rating: int = Field(ge=1, le=5) - content: Optional[str] = None - - -@router.get("/apps") -def list_apps( - type: Optional[str] = Query(None), - source: Optional[str] = Query(None), - category: Optional[str] = Query(None), - search: Optional[str] = Query(None), - sort: str = Query(default="latest"), - page: int = Query(default=1, ge=1), - page_size: int = Query(default=20, ge=1, le=100), - db: Session = Depends(get_db), - user_id: Optional[str] = Depends(get_current_user_id_optional), -): - query = db.query(MarketApp).filter(MarketApp.status == "published") - - if type: - query = query.filter(MarketApp.type == type) - if source: - query = query.filter(MarketApp.source == source) - if category: - query = query.filter(MarketApp.type == category) - if search: - query = query.filter( - and_( - MarketApp.name.ilike(f"%{search}%") - | MarketApp.description.ilike(f"%{search}%") - ) - ) - - if sort == "popular": - query = query.order_by(desc(MarketApp.install_count)) - elif sort == "rating": - query = query.order_by(desc(MarketApp.rating_avg)) - else: - query = query.order_by(desc(MarketApp.created_at)) - - total = query.count() - apps = query.offset((page - 1) * page_size).limit(page_size).all() - - # 如果用户已登录,获取该用户已安装的应用ID集合(确保按用户隔离) - installed_app_ids = set() - if user_id: - installations = db.query(AppInstallation.app_id).filter( - AppInstallation.user_id == user_id - ).all() - installed_app_ids = {inst[0] for inst in installations} - - # 查询已安装状态时始终带 user_id 过滤 - - result = [] - for app in apps: - item = { - "id": app.id, - "name": app.name, - "description": app.description, - "icon": app.icon, - "type": app.type, - "source": app.source, - "publisher_id": app.publisher_id, - "publisher_name": app.publisher_name, - "is_member_free": app.is_member_free if app.is_member_free else False, - "join_promotion": app.join_promotion if app.join_promotion else False, - "status": app.status, - "price": float(app.price) if app.price else 0, - "install_count": app.install_count or 0, - "rating_avg": float(app.rating_avg) if app.rating_avg else 0, - "review_count": app.review_count or 0, - "created_at": str(app.created_at) if app.created_at else None, - "installed": app.id in installed_app_ids if user_id else False, - } - result.append(item) - - return { - "code": 0, - "data": { - "total": total, - "page": page, - "page_size": page_size, - "items": result, - }, - "message": "ok", - } - - -@router.get("/apps/{app_id}") -def get_app( - app_id: str, - db: Session = Depends(get_db), - current_user: Optional[User] = Depends(get_current_user), -): - app = db.query(MarketApp).filter(MarketApp.id == app_id).first() - if not app: - raise HTTPException(status_code=404, detail="应用不存在") - - # 检查当前用户是否已购买(按用户隔离) - purchased = False - installed = False - if current_user: - purchase = db.query(AppPurchase).filter( - and_(AppPurchase.app_id == app_id, AppPurchase.user_id == current_user.id) - ).first() - if purchase: - purchased = True - installed = purchase.status == "installed" - # 通过 AppInstallation 表检查是否已安装(按用户隔离) - installation = db.query(AppInstallation).filter( - and_(AppInstallation.app_id == app_id, AppInstallation.user_id == current_user.id) - ).first() - if installation: - installed = True - - return { - "code": 0, - "data": { - "id": app.id, "name": app.name, "description": app.description, - "icon": app.icon, "type": app.type, "source": app.source, - "type": app.type, - "price": float(app.price) if app.price else 0, - "install_count": app.install_count or 0, - "rating_avg": float(app.rating_avg) if app.rating_avg else 0, - "review_count": app.review_count or 0, - "publisher_id": app.publisher_id, - "download_url": app.download_url, - "detail": app.detail, - "created_at": str(app.created_at), - "purchased": purchased, - "installed": installed, - }, - "message": "ok", - } - - -@router.post("/apps") -def upload_app( - req: UploadAppRequest, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - app = MarketApp( - id=gen_uuid(), - name=req.name, - description=req.description, - icon=req.icon, - type=req.type if req.type in ("skill","agent","mcp") else "skill", - source=req.source if req.source in ("official","user") else "user", - price=req.price, - install_count=0, - rating_avg=0, - review_count=0, - publisher_id=current_user.id, - publisher_name=current_user.username, - is_member_free=req.is_member_free if req.is_member_free is not None else False, - join_promotion=req.join_promotion if req.join_promotion is not None else False, - agent_endpoint=req.agent_endpoint, - status="published", # 用户创建的应用直接发布 - created_at=datetime.now(), - updated_at=datetime.now(), - ) - db.add(app) - db.commit() - db.refresh(app) - - # 用户创建/导入的应用自动标记为已安装 - existing_install = db.query(AppInstallation).filter( - AppInstallation.user_id == current_user.id, - AppInstallation.app_id == app.id, - ).first() - if not existing_install: - installation = AppInstallation( - user_id=current_user.id, - app_id=app.id, - installed_at=datetime.now(), - ) - db.add(installation) - db.commit() - - LogService.log_operation( - user_id=current_user.id, username=current_user.username, action="upload_app", - target="market_app", target_id=app.id, - detail=f"上传应用 {app.name}", - ) - - return { - "code": 0, - "data": { - "id": app.id, "name": app.name, "status": app.status, - "created_at": str(app.created_at), - }, - "message": "提交成功,等待审核", - } - - -@router.post("/apps/{app_id}/purchase") -def purchase_app( - app_id: str, - req: PurchaseAppRequest, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - app = db.query(MarketApp).filter( - and_(MarketApp.id == app_id, MarketApp.status == "published") - ).first() - if not app: - raise HTTPException(status_code=404, detail="应用不存在或未上架") - - # 检查是否已购买 - existing = db.query(AppPurchase).filter( - and_(AppPurchase.app_id == app_id, AppPurchase.user_id == current_user.id) - ).first() - if existing and existing.status in ("purchased", "installed"): - raise HTTPException(status_code=400, detail="您已购买该应用") - - amount = req.amount or app.price - - # 创建订单 - order = Order( - id=gen_uuid(), - user_id=current_user.id, - order_type="app", - item_id=app_id, - item_name=app.name, - amount=amount, - status="pending", - created_at=datetime.now(), - ) - db.add(order) - db.flush() - - # 创建购买记录 - purchase = AppPurchase( - id=gen_uuid(), - user_id=current_user.id, - app_id=app_id, - order_id=order.id, - status="purchased", - purchased_at=datetime.now(), - ) - db.add(purchase) - - # 更新应用下载量 - app.install_count = (app.install_count or 0) + 1 - app.updated_at = datetime.now() - - db.commit() - - return { - "code": 0, - "data": { - "order_id": order.id, - "purchase_id": purchase.id, - "amount": amount, - "status": "purchased", - }, - "message": "购买成功", - } - - -@router.post("/apps/{app_id}/install") -def install_app( - app_id: str, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - app = db.query(MarketApp).filter(MarketApp.id == app_id).first() - if not app: - raise HTTPException(status_code=404, detail="应用不存在") - - # 检查是否已安装(通过 AppInstallation 表) - existing_install = db.query(AppInstallation).filter( - and_(AppInstallation.app_id == app_id, AppInstallation.user_id == current_user.id) - ).first() - if existing_install: - raise HTTPException(status_code=400, detail="应用已安装") - - # 检查是否已购买(付费应用需要先购买) - purchase = db.query(AppPurchase).filter( - and_(AppPurchase.app_id == app_id, AppPurchase.user_id == current_user.id) - ).first() - - # 对于付费应用,必须已购买才能安装 - if float(app.price or 0) > 0: - if not purchase: - raise HTTPException(status_code=403, detail="请先购买应用") - if purchase.status == "installed": - raise HTTPException(status_code=400, detail="应用已安装") - - # 创建安装记录 - installation = AppInstallation( - id=gen_uuid(), - user_id=current_user.id, - app_id=app_id, - installed_at=datetime.now(), - ) - db.add(installation) - - # 如果有购买记录,更新状态为已安装 - if purchase: - purchase.status = "installed" - purchase.installed_at = datetime.now() - - # 更新应用安装量 - app.install_count = (app.install_count or 0) + 1 - app.updated_at = datetime.now() - - db.commit() - - return { - "code": 0, - "data": {"installation_id": installation.id, "status": "installed"}, - "message": "安装成功", - } - - -@router.delete("/apps/{app_id}/uninstall") -def uninstall_app( - app_id: str, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """卸载应用 - 删除当前用户的安装记录(按用户隔离) - 修复: 当没有AppInstallation记录时(如导入的技能),仍然允许卸载 - """ - app = db.query(MarketApp).filter(MarketApp.id == app_id).first() - if not app: - raise HTTPException(status_code=404, detail="应用不存在") - - # 查找当前用户的安装记录(按用户隔离) - installation = db.query(AppInstallation).filter( - and_(AppInstallation.app_id == app_id, AppInstallation.user_id == current_user.id) - ).first() - - # 修复: 即使没有安装记录也允许卸载(导入的技能可能没有安装记录) - if installation: - db.delete(installation) - - # 将AppPurchase状态从installed重置为purchased - purchase = db.query(AppPurchase).filter( - and_(AppPurchase.app_id == app_id, AppPurchase.user_id == current_user.id) - ).first() - if purchase and purchase.status == "installed": - purchase.status = "purchased" - - # 更新应用安装量(减1,最小为0) - if app.install_count and app.install_count > 0: - app.install_count = app.install_count - 1 - - # 将应用状态标记为未安装(对于skill类型) - app.updated_at = datetime.now() - - db.commit() - - LogService.log_operation( - user_id=current_user.id, username=current_user.username, action="uninstall_app", - target="market_app", target_id=app_id, - detail=f"卸载应用 {app.name}", - ) - - return { - "code": 0, - "message": "卸载成功", - } - - -@router.post("/apps/{app_id}/review") -def review_app( - app_id: str, - req: ReviewAppRequest, - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - app = db.query(MarketApp).filter(MarketApp.id == app_id).first() - if not app: - raise HTTPException(status_code=404, detail="应用不存在") - - # 检查是否已评价 - existing = db.query(AppReview).filter( - and_(AppReview.app_id == app_id, AppReview.user_id == current_user.id) - ).first() - if existing: - # 更新已有评价 - old_rating = existing.rating - existing.rating = req.rating - existing.content = req.content - existing.updated_at = datetime.now() - - # 重新计算平均分 (总数不变,减去旧评分加新评分) - if app.review_count and app.review_count > 0: - total_rating = float(app.rating_avg or 0) * app.review_count - total_rating = total_rating - old_rating + req.rating - app.rating_avg = total_rating / app.review_count - else: - app.rating_avg = req.rating - app.review_count = 1 - else: - # 创建新评价 - review = AppReview( - id=gen_uuid(), - app_id=app_id, - user_id=current_user.id, - rating=req.rating, - content=req.content, - created_at=datetime.now(), - updated_at=datetime.now(), - ) - db.add(review) - - # 重新计算平均分 - app.review_count = (app.review_count or 0) + 1 - if app.review_count == 1: - app.rating_avg = req.rating - else: - total_rating = float(app.rating_avg or 0) * (app.review_count - 1) + req.rating - app.rating_avg = round(total_rating / app.review_count, 1) - - app.updated_at = datetime.now() - db.commit() - - return { - "code": 0, - "data": { - "rating_avg": float(app.rating_avg or 0), - "review_count": app.review_count or 0, - }, - "message": "评价成功", - } - - -@router.get("/my-apps") -def get_my_apps( - status: Optional[str] = Query(None), - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - query = db.query(AppPurchase).filter(AppPurchase.user_id == current_user.id) - purchases = query.order_by(desc(AppPurchase.created_at)).all() - - result = [] - for p in purchases: - app = db.query(MarketApp).filter(MarketApp.id == p.app_id).first() - if app: - result.append({ - "id": p.id, - "app_id": app.id, - "name": app.name, - "icon": app.icon, - "type": app.type, - "price": float(app.price or 0), - "purchased_at": str(p.created_at) if p.created_at else None, - }) - - return {"code": 0, "data": result, "message": "ok"} - - -@router.get("/reviews/{app_id}") -def get_reviews( - app_id: str, - page: int = Query(default=1, ge=1), - page_size: int = Query(default=20, ge=1, le=100), - db: Session = Depends(get_db), -): - query = db.query(AppReview).filter(AppReview.app_id == app_id) - total = query.count() - - reviews = ( - query.order_by(desc(AppReview.created_at)) - .offset((page - 1) * page_size) - .limit(page_size) - .all() - ) - - result = [] - for r in reviews: - user = db.query(User).filter(User.id == r.user_id).first() - result.append({ - "id": r.id, - "user_id": r.user_id, - "username": user.username if user else "unknown", - "avatar": user.avatar if user else None, - "rating": r.rating, - "content": r.content, - "created_at": str(r.created_at) if r.created_at else None, - }) - - return { - "code": 0, - "data": {"total": total, "page": page, "page_size": page_size, "items": result}, - "message": "ok", - } - - -# ============================================================ -# Agent部署资源查询接口 -# ============================================================ - -@router.get("/agent-resources") -def get_agent_resources( - db: Session = Depends(get_db), - current_user: User = Depends(get_current_user), -): - """Agent部署时需要的资源列表:可用的应用、知识库、文档""" - # 用户已发布的应用 - my_apps = db.query(MarketApp).filter( - MarketApp.publisher_id == current_user.id, - MarketApp.status == "published", - ).order_by(desc(MarketApp.updated_at)).all() - - apps_result = [] - for app in my_apps: - apps_result.append({ - "id": app.id, - "name": app.name, - "type": app.type, - "description": app.description, - "install_count": app.install_count or 0, - }) - - # 用户知识库 - from ..models import KnowledgeBase, KnowledgeDocument - kb_list = db.query(KnowledgeBase).filter( - KnowledgeBase.user_id == current_user.id - ).order_by(desc(KnowledgeBase.updated_at)).all() - - kb_result = [] - for kb in kb_list: - doc_count = db.query(KnowledgeDocument).filter( - KnowledgeDocument.knowledge_base_id == kb.id - ).count() - kb_result.append({ - "id": kb.id, - "name": kb.name, - "description": kb.description, - "document_count": doc_count, - }) - - # 用户已安装的应用(从AppPurchase) - installed = db.query(AppPurchase).filter( - AppPurchase.user_id == current_user.id - ).all() - installed_ids = [p.app_id for p in installed] - installed_apps = [] - if installed_ids: - iapps = db.query(MarketApp).filter(MarketApp.id.in_(installed_ids)).all() - for ia in iapps: - installed_apps.append({ - "id": ia.id, - "name": ia.name, - "type": ia.type, - "description": ia.description, - }) - - return { - "code": 0, - "data": { - "my_apps": apps_result, - "knowledge_bases": kb_result, - "installed_apps": installed_apps, - }, - "message": "ok", - } +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import and_, desc, asc, func +from pydantic import BaseModel, Field +from typing import Optional +import uuid +from datetime import datetime + +from ..database import get_db +from ..models import ( + User, MarketApp, AppPurchase, AppReview, AppInstallation, Order, + gen_uuid +) +from ..auth import get_current_user, get_current_user_id, get_current_user_id_optional +from ..middleware.log_service import LogService + +router = APIRouter() + + +class UploadAppRequest(BaseModel): + name: str = Field(min_length=1, max_length=200) + description: Optional[str] = None + icon: Optional[str] = None + type: Optional[str] = None # skill, agent, mcp + source: Optional[str] = None + price: float = Field(default=0, ge=0) + download_url: Optional[str] = None + category: Optional[str] = None + tags: Optional[str] = None + is_member_free: Optional[bool] = False + join_promotion: Optional[bool] = False + agent_endpoint: Optional[str] = None + + +class PurchaseAppRequest(BaseModel): + amount: Optional[float] = None + + +class ReviewAppRequest(BaseModel): + rating: int = Field(ge=1, le=5) + content: Optional[str] = None + + +@router.get("/apps") +def list_apps( + type: Optional[str] = Query(None), + source: Optional[str] = Query(None), + category: Optional[str] = Query(None), + search: Optional[str] = Query(None), + sort: str = Query(default="latest"), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + db: Session = Depends(get_db), + user_id: Optional[str] = Depends(get_current_user_id_optional), +): + query = db.query(MarketApp).filter(MarketApp.status == "published") + + if type: + query = query.filter(MarketApp.type == type) + if source: + query = query.filter(MarketApp.source == source) + if category: + query = query.filter(MarketApp.type == category) + if search: + query = query.filter( + and_( + MarketApp.name.ilike(f"%{search}%") + | MarketApp.description.ilike(f"%{search}%") + ) + ) + + if sort == "popular": + query = query.order_by(desc(MarketApp.install_count)) + elif sort == "rating": + query = query.order_by(desc(MarketApp.rating_avg)) + else: + query = query.order_by(desc(MarketApp.created_at)) + + total = query.count() + apps = query.offset((page - 1) * page_size).limit(page_size).all() + + # 如果用户已登录,获取该用户已安装的应用ID集合(确保按用户隔离) + installed_app_ids = set() + if user_id: + installations = db.query(AppInstallation.app_id).filter( + AppInstallation.user_id == user_id + ).all() + installed_app_ids = {inst[0] for inst in installations} + + # 查询已安装状态时始终带 user_id 过滤 + + result = [] + for app in apps: + item = { + "id": app.id, + "name": app.name, + "description": app.description, + "icon": app.icon, + "type": app.type, + "source": app.source, + "publisher_id": app.publisher_id, + "publisher_name": app.publisher_name, + "is_member_free": app.is_member_free if app.is_member_free else False, + "join_promotion": app.join_promotion if app.join_promotion else False, + "status": app.status, + "price": float(app.price) if app.price else 0, + "install_count": app.install_count or 0, + "rating_avg": float(app.rating_avg) if app.rating_avg else 0, + "review_count": app.review_count or 0, + "created_at": str(app.created_at) if app.created_at else None, + "installed": app.id in installed_app_ids if user_id else False, + } + result.append(item) + + return { + "code": 0, + "data": { + "total": total, + "page": page, + "page_size": page_size, + "items": result, + }, + "message": "ok", + } + + +@router.get("/apps/{app_id}") +def get_app( + app_id: str, + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(get_current_user), +): + app = db.query(MarketApp).filter(MarketApp.id == app_id).first() + if not app: + raise HTTPException(status_code=404, detail="应用不存在") + + # 检查当前用户是否已购买(按用户隔离) + purchased = False + installed = False + if current_user: + purchase = db.query(AppPurchase).filter( + and_(AppPurchase.app_id == app_id, AppPurchase.user_id == current_user.id) + ).first() + if purchase: + purchased = True + installed = purchase.status == "installed" + # 通过 AppInstallation 表检查是否已安装(按用户隔离) + installation = db.query(AppInstallation).filter( + and_(AppInstallation.app_id == app_id, AppInstallation.user_id == current_user.id) + ).first() + if installation: + installed = True + + return { + "code": 0, + "data": { + "id": app.id, "name": app.name, "description": app.description, + "icon": app.icon, "type": app.type, "source": app.source, + "type": app.type, + "price": float(app.price) if app.price else 0, + "install_count": app.install_count or 0, + "rating_avg": float(app.rating_avg) if app.rating_avg else 0, + "review_count": app.review_count or 0, + "publisher_id": app.publisher_id, + "download_url": app.download_url, + "detail": app.detail, + "created_at": str(app.created_at), + "purchased": purchased, + "installed": installed, + }, + "message": "ok", + } + + +@router.post("/apps") +def upload_app( + req: UploadAppRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + # 根据操作者角色决定默认发布状态与计费策略(需求:管理员/用户导入、AI安装、AI开发) + # - 管理员:官方来源、审核通过(已发布)、免费 + # - 用户:用户来源、未发布待审核、是否收费由客户选择 + is_admin = getattr(current_user, "role", "user") == "admin" + if is_admin: + app_source = "official" + app_status = "published" + app_price = 0.0 + app_member_free = True + else: + app_source = req.source if req.source in ("official", "user") else "user" + app_status = "pending" + app_price = req.price if req.price else 0.0 + app_member_free = req.is_member_free if req.is_member_free is not None else False + + app = MarketApp( + id=gen_uuid(), + name=req.name, + description=req.description, + icon=req.icon, + type=req.type if req.type in ("skill","agent","mcp") else "skill", + source=app_source, + price=app_price, + install_count=0, + rating_avg=0, + review_count=0, + publisher_id=current_user.id, + publisher_name=current_user.username, + is_member_free=app_member_free, + join_promotion=req.join_promotion if req.join_promotion is not None else False, + agent_endpoint=req.agent_endpoint, + status=app_status, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + db.add(app) + db.commit() + db.refresh(app) + + # 用户创建/导入的应用自动标记为已安装 + existing_install = db.query(AppInstallation).filter( + AppInstallation.user_id == current_user.id, + AppInstallation.app_id == app.id, + ).first() + if not existing_install: + installation = AppInstallation( + user_id=current_user.id, + app_id=app.id, + installed_at=datetime.now(), + ) + db.add(installation) + db.commit() + + LogService.log_operation( + user_id=current_user.id, username=current_user.username, action="upload_app", + target="market_app", target_id=app.id, + detail=f"上传应用 {app.name}", + ) + + return { + "code": 0, + "data": { + "id": app.id, "name": app.name, "status": app.status, + "created_at": str(app.created_at), + }, + "message": "发布成功" if is_admin else "提交成功,等待审核", + } + + +@router.post("/apps/{app_id}/purchase") +def purchase_app( + app_id: str, + req: PurchaseAppRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + app = db.query(MarketApp).filter( + and_(MarketApp.id == app_id, MarketApp.status == "published") + ).first() + if not app: + raise HTTPException(status_code=404, detail="应用不存在或未上架") + + # 检查是否已购买 + existing = db.query(AppPurchase).filter( + and_(AppPurchase.app_id == app_id, AppPurchase.user_id == current_user.id) + ).first() + if existing and existing.status in ("purchased", "installed"): + raise HTTPException(status_code=400, detail="您已购买该应用") + + amount = req.amount or app.price + + # 创建订单 + order = Order( + id=gen_uuid(), + user_id=current_user.id, + order_type="app", + item_id=app_id, + item_name=app.name, + amount=amount, + status="pending", + created_at=datetime.now(), + ) + db.add(order) + db.flush() + + # 创建购买记录 + purchase = AppPurchase( + id=gen_uuid(), + user_id=current_user.id, + app_id=app_id, + order_id=order.id, + status="purchased", + purchased_at=datetime.now(), + ) + db.add(purchase) + + # 更新应用下载量 + app.install_count = (app.install_count or 0) + 1 + app.updated_at = datetime.now() + + db.commit() + + return { + "code": 0, + "data": { + "order_id": order.id, + "purchase_id": purchase.id, + "amount": amount, + "status": "purchased", + }, + "message": "购买成功", + } + + +@router.post("/apps/{app_id}/install") +def install_app( + app_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + app = db.query(MarketApp).filter(MarketApp.id == app_id).first() + if not app: + raise HTTPException(status_code=404, detail="应用不存在") + + # 检查是否已安装(通过 AppInstallation 表) + existing_install = db.query(AppInstallation).filter( + and_(AppInstallation.app_id == app_id, AppInstallation.user_id == current_user.id) + ).first() + if existing_install: + raise HTTPException(status_code=400, detail="应用已安装") + + # 检查是否已购买(付费应用需要先购买) + purchase = db.query(AppPurchase).filter( + and_(AppPurchase.app_id == app_id, AppPurchase.user_id == current_user.id) + ).first() + + # 对于付费应用,必须已购买才能安装 + if float(app.price or 0) > 0: + if not purchase: + raise HTTPException(status_code=403, detail="请先购买应用") + if purchase.status == "installed": + raise HTTPException(status_code=400, detail="应用已安装") + + # 创建安装记录 + installation = AppInstallation( + id=gen_uuid(), + user_id=current_user.id, + app_id=app_id, + installed_at=datetime.now(), + ) + db.add(installation) + + # 如果有购买记录,更新状态为已安装 + if purchase: + purchase.status = "installed" + purchase.installed_at = datetime.now() + + # 更新应用安装量 + app.install_count = (app.install_count or 0) + 1 + app.updated_at = datetime.now() + + db.commit() + + return { + "code": 0, + "data": {"installation_id": installation.id, "status": "installed"}, + "message": "安装成功", + } + + +@router.delete("/apps/{app_id}/uninstall") +def uninstall_app( + app_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """卸载应用 - 删除当前用户的安装记录(按用户隔离) + 修复: 当没有AppInstallation记录时(如导入的技能),仍然允许卸载 + """ + app = db.query(MarketApp).filter(MarketApp.id == app_id).first() + if not app: + raise HTTPException(status_code=404, detail="应用不存在") + + # 查找当前用户的安装记录(按用户隔离) + installation = db.query(AppInstallation).filter( + and_(AppInstallation.app_id == app_id, AppInstallation.user_id == current_user.id) + ).first() + + # 修复: 即使没有安装记录也允许卸载(导入的技能可能没有安装记录) + if installation: + db.delete(installation) + + # 将AppPurchase状态从installed重置为purchased + purchase = db.query(AppPurchase).filter( + and_(AppPurchase.app_id == app_id, AppPurchase.user_id == current_user.id) + ).first() + if purchase and purchase.status == "installed": + purchase.status = "purchased" + + # 更新应用安装量(减1,最小为0) + if app.install_count and app.install_count > 0: + app.install_count = app.install_count - 1 + + # 将应用状态标记为未安装(对于skill类型) + app.updated_at = datetime.now() + + db.commit() + + LogService.log_operation( + user_id=current_user.id, username=current_user.username, action="uninstall_app", + target="market_app", target_id=app_id, + detail=f"卸载应用 {app.name}", + ) + + return { + "code": 0, + "message": "卸载成功", + } + + +@router.post("/apps/{app_id}/review") +def review_app( + app_id: str, + req: ReviewAppRequest, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + app = db.query(MarketApp).filter(MarketApp.id == app_id).first() + if not app: + raise HTTPException(status_code=404, detail="应用不存在") + + # 检查是否已评价 + existing = db.query(AppReview).filter( + and_(AppReview.app_id == app_id, AppReview.user_id == current_user.id) + ).first() + if existing: + # 更新已有评价 + old_rating = existing.rating + existing.rating = req.rating + existing.content = req.content + existing.updated_at = datetime.now() + + # 重新计算平均分 (总数不变,减去旧评分加新评分) + if app.review_count and app.review_count > 0: + total_rating = float(app.rating_avg or 0) * app.review_count + total_rating = total_rating - old_rating + req.rating + app.rating_avg = total_rating / app.review_count + else: + app.rating_avg = req.rating + app.review_count = 1 + else: + # 创建新评价 + review = AppReview( + id=gen_uuid(), + app_id=app_id, + user_id=current_user.id, + rating=req.rating, + content=req.content, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + db.add(review) + + # 重新计算平均分 + app.review_count = (app.review_count or 0) + 1 + if app.review_count == 1: + app.rating_avg = req.rating + else: + total_rating = float(app.rating_avg or 0) * (app.review_count - 1) + req.rating + app.rating_avg = round(total_rating / app.review_count, 1) + + app.updated_at = datetime.now() + db.commit() + + return { + "code": 0, + "data": { + "rating_avg": float(app.rating_avg or 0), + "review_count": app.review_count or 0, + }, + "message": "评价成功", + } + + +@router.get("/my-apps") +def get_my_apps( + status: Optional[str] = Query(None), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + query = db.query(AppPurchase).filter(AppPurchase.user_id == current_user.id) + purchases = query.order_by(desc(AppPurchase.created_at)).all() + + result = [] + for p in purchases: + app = db.query(MarketApp).filter(MarketApp.id == p.app_id).first() + if app: + result.append({ + "id": p.id, + "app_id": app.id, + "name": app.name, + "icon": app.icon, + "type": app.type, + "price": float(app.price or 0), + "purchased_at": str(p.created_at) if p.created_at else None, + }) + + return {"code": 0, "data": result, "message": "ok"} + + +@router.get("/reviews/{app_id}") +def get_reviews( + app_id: str, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + db: Session = Depends(get_db), +): + query = db.query(AppReview).filter(AppReview.app_id == app_id) + total = query.count() + + reviews = ( + query.order_by(desc(AppReview.created_at)) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + + result = [] + for r in reviews: + user = db.query(User).filter(User.id == r.user_id).first() + result.append({ + "id": r.id, + "user_id": r.user_id, + "username": user.username if user else "unknown", + "avatar": user.avatar if user else None, + "rating": r.rating, + "content": r.content, + "created_at": str(r.created_at) if r.created_at else None, + }) + + return { + "code": 0, + "data": {"total": total, "page": page, "page_size": page_size, "items": result}, + "message": "ok", + } + + +# ============================================================ +# Agent部署资源查询接口 +# ============================================================ + +@router.get("/agent-resources") +def get_agent_resources( + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Agent部署时需要的资源列表:可用的应用、知识库、文档""" + # 用户已发布的应用 + my_apps = db.query(MarketApp).filter( + MarketApp.publisher_id == current_user.id, + MarketApp.status == "published", + ).order_by(desc(MarketApp.updated_at)).all() + + apps_result = [] + for app in my_apps: + apps_result.append({ + "id": app.id, + "name": app.name, + "type": app.type, + "description": app.description, + "install_count": app.install_count or 0, + }) + + # 用户知识库 + from ..models import KnowledgeBase, KnowledgeDocument + kb_list = db.query(KnowledgeBase).filter( + KnowledgeBase.user_id == current_user.id + ).order_by(desc(KnowledgeBase.updated_at)).all() + + kb_result = [] + for kb in kb_list: + doc_count = db.query(KnowledgeDocument).filter( + KnowledgeDocument.knowledge_base_id == kb.id + ).count() + kb_result.append({ + "id": kb.id, + "name": kb.name, + "description": kb.description, + "document_count": doc_count, + }) + + # 用户已安装的应用(从AppPurchase) + installed = db.query(AppPurchase).filter( + AppPurchase.user_id == current_user.id + ).all() + installed_ids = [p.app_id for p in installed] + installed_apps = [] + if installed_ids: + iapps = db.query(MarketApp).filter(MarketApp.id.in_(installed_ids)).all() + for ia in iapps: + installed_apps.append({ + "id": ia.id, + "name": ia.name, + "type": ia.type, + "description": ia.description, + }) + + return { + "code": 0, + "data": { + "my_apps": apps_result, + "knowledge_bases": kb_result, + "installed_apps": installed_apps, + }, + "message": "ok", + } diff --git a/frontend/src/pages/admin/Agents.tsx b/frontend/src/pages/admin/Agents.tsx index 4e3d3e4..17fd0f9 100644 --- a/frontend/src/pages/admin/Agents.tsx +++ b/frontend/src/pages/admin/Agents.tsx @@ -1,702 +1,701 @@ -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; -} - -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([]); - const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [loading, setLoading] = useState(false); - const [stats, setStats] = useState(null); - - // Filters - const [keyword, setKeyword] = useState(''); - const [filterStatus, setFilterStatus] = useState(''); - const [filterSource, setFilterSource] = useState(''); - - // Modal - const [showModal, setShowModal] = useState(false); - const [editingAgent, setEditingAgent] = useState(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(null); - - // Available skills for linking - const [availableSkills, setAvailableSkills] = useState([]); - const [availableModels, setAvailableModels] = useState([]); - - // 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 ( -
- {/* Toast */} - {toast && ( -
- {toast.msg} -
- )} - - {/* Header */} -
-
-

Agent 管理

-

- 管理 AI Agent/Skill/MCP,支持配置、关联技能和模型 -

-
- -
- - {/* Stats */} - {stats && ( -
-
-
{stats.total_agents}
-
Agent 总数
-
-
-
{stats.published_agents}
-
已发布
-
-
-
{stats.pending_agents}
-
待发布
-
-
-
{stats.total_installs.toLocaleString()}
-
总安装量
-
-
-
{stats.average_rating.toFixed(1)}
-
平均评分
-
-
-
{stats.official_agents} / {stats.user_agents}
-
官方 / 用户
-
-
- )} - - {/* Filters */} -
- setKeyword(e.target.value)} - onKeyDown={e => e.key === 'Enter' && handleSearch()} - style={{ maxWidth: 280 }} /> - - - - {(keyword || filterStatus || filterSource) && ( - - )} -
- - {/* Table */} -
- - - - - - - - - - - - - - - - {loading ? ( - - ) : agents.length === 0 ? ( - - ) : agents.map(agent => { - const sc = statusColor(agent.status); - return ( - - - - - - - - - - - - ); - })} - -
智能体类型来源状态价格安装量评分更新时间操作
加载中...
暂无 Agent
-
- {agent.icon ? ( - - ) : ( -
- 🤖 -
- )} -
-
{agent.name}
- {agent.description && ( -
- {agent.description} -
- )} -
-
-
- - {AGENT_TYPES.find(t => t.id === agent.type)?.label || agent.type} - - - - {agent.source === 'official' ? '官方' : '用户'} - - - - {agent.status === 'published' ? '已发布' : agent.status === 'pending' ? '待发布' : '已下架'} - - {agent.price === 0 ? 免费 : `¥${agent.price}`}{agent.install_count.toLocaleString()}{agent.rating_avg > 0 ? `★ ${agent.rating_avg}` : '-'} - {agent.updated_at ? agent.updated_at.slice(0, 16).replace('T', ' ') : '-'} - -
- - - - -
-
-
- - {/* Pagination */} - {totalPages > 1 && ( -
- 共 {total} 条,第 {page}/{totalPages} 页 -
- - {[...Array(Math.min(5, totalPages))].map((_, i) => { - const p = Math.max(1, Math.min(page - 2, totalPages - 4)) + i; - return p <= totalPages && ( - - ); - })} - -
-
- )} - - {/* Create/Edit Modal */} - {showModal && ( -
setShowModal(false)}> -
e.stopPropagation()}> -
-

{editingAgent ? '编辑 Agent' : '创建 Agent'}

- -
-
-
-
- - setForm(f => ({ ...f, name: e.target.value }))} - placeholder="例如: 智能代码审查助手" /> -
-
- - setForm(f => ({ ...f, icon: e.target.value }))} - placeholder="https://..." /> -
-
- - setForm(f => ({ ...f, price: e.target.value }))} /> -
-
- -
-
- -