diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..d1cbbfd --- /dev/null +++ b/.gitattributes @@ -0,0 +1,33 @@ +# Normalize all text files to LF on checkout/commit (root-cause fix for mixed line endings) +* text=auto eol=lf + +# Binary assets: never normalize line endings or diff as text +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.woff binary +*.woff2 binary +*.ttf binary +*.eot binary +*.otf binary +*.pdf binary +*.zip binary +*.tar binary +*.gz binary +*.bz2 binary +*.7z binary +*.pyc binary +*.so binary +*.dll binary +*.exe binary +*.db binary +*.sqlite binary +*.sqlite3 binary +*.wasm binary +*.mp4 binary +*.mp3 binary +*.avi binary +*.mov binary diff --git a/backend/python/app/anysearch_client.py b/backend/python/app/anysearch_client.py index 8a6f883..b4608e2 100644 --- a/backend/python/app/anysearch_client.py +++ b/backend/python/app/anysearch_client.py @@ -1,179 +1,179 @@ -""" -AnySearch 联网检索客户端 -====================== -调用 AnySearch 网页/领域搜索 API (https://api.anysearch.com/v1/search), -为 AI 聊天注入实时联网检索结果,亦可被后端端点 / Agent 工具复用。 - -约束:平台容器存在智能路由代理,对外部 API 偶发重置;本客户端使用 -trust_env=False 直连 (等价于 NO_PROXY=api.anysearch.com),规避代理重置。 -失败一律返回空/降级结果,保证聊天链路不中断。 -""" -import json -import logging -from typing import List, Dict, Optional - -logger = logging.getLogger("aiflow.anysearch") - -DEFAULT_API_URL = "https://api.anysearch.com/v1/search" -DEFAULT_ZONE = "cn" -REQUEST_TIMEOUT = 8.0 - - -async def anysearch_search( - api_key: str, - api_url: str = DEFAULT_API_URL, - query: str = "", - top_k: int = 5, - zone: str = DEFAULT_ZONE, - freshness: Optional[str] = None, - timeout: float = REQUEST_TIMEOUT, -) -> List[Dict]: - """调用 AnySearch 搜索,返回结构化结果列表。 - - 每个结果: - { - "content": snippet 摘要文本, - "score": 0.9..0.5 递减, - "source": "web_anysearch", - "metadata": {"url", "title", "source_domain", "zone"} - } - 失败时返回 [] (不抛异常)。 - """ - if not api_key: - return [] - try: - import httpx - except ImportError: - logger.warning("httpx not available for AnySearch") - return [] - - payload = { - "query": query, - "zone": zone, - "top_k": max(1, min(int(top_k), 20)), - } - if freshness: - payload["freshness"] = freshness - - headers = { - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}", - } - try: - # trust_env=False -> 忽略容器内 HTTP(S)_PROXY,直连 api.anysearch.com - async with httpx.AsyncClient(trust_env=False, timeout=timeout) as client: - resp = await client.post(api_url, json=payload, headers=headers) - if resp.status_code != 200: - logger.warning(f"AnySearch HTTP {resp.status_code}: {resp.text[:200]}") - return [] - data = resp.json() - if data.get("code") != 0: - logger.warning(f"AnySearch biz error: {data.get('message')}") - return [] - results = (data.get("data") or {}).get("results") or [] - hits = [] - for idx, r in enumerate(results[:top_k]): - title = r.get("title", "") or "" - url = r.get("url", "") or "" - snippet = r.get("snippet") or r.get("content") or "" - if not snippet and not title: - continue - try: - from urllib.parse import urlparse - domain = urlparse(url).netloc - except Exception: - domain = "" - hits.append({ - "content": snippet, - "score": max(0.4, 0.9 - idx * 0.08), - "source": "web_anysearch", - "metadata": { - "url": url, - "title": title, - "source_domain": domain, - "zone": zone, - }, - }) - return hits - except Exception as e: - logger.warning(f"AnySearch search failed: {e}") - return [] - - -async def anysearch_health(api_key: str, api_url: str = DEFAULT_API_URL, timeout: float = REQUEST_TIMEOUT) -> Dict: - """连通性探测:返回 {"status": "connected"/"disconnected", "detail": ...}。""" - if not api_key: - return {"status": "disconnected", "detail": "未配置 API Key", "connected": False, "usable": False} - try: - import httpx - async with httpx.AsyncClient(trust_env=False, timeout=timeout) as client: - resp = await client.post( - api_url, - json={"query": "ping", "zone": DEFAULT_ZONE, "top_k": 1}, - headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}, - ) - if resp.status_code == 200: - return {"status": "connected", "detail": "AnySearch 连接正常", "connected": True, "usable": True} - return {"status": "error", "detail": f"HTTP {resp.status_code}", "connected": False, "usable": False} - except Exception as e: - return {"status": "error", "detail": str(e)[:200], "connected": False, "usable": False} - - -def load_anysearch_config(db_session) -> Dict: - """从 site_settings 读取全局 AnySearch 配置(失败返回空字典)。""" - if db_session is None: - return {} - try: - from app.models import SiteSetting - row = db_session.query(SiteSetting).filter(SiteSetting.key == "anysearch_config").first() - if not row or not row.value: - return {} - cfg = json.loads(row.value) - if not isinstance(cfg, dict): - return {} - return cfg - except Exception as e: - logger.warning(f"load_anysearch_config failed: {e}") - return {} - - -def save_anysearch_config( - db_session, - api_key: str, - is_enabled: bool = True, - api_url: str = DEFAULT_API_URL, - zone: str = DEFAULT_ZONE, - freshness: Optional[str] = None, - top_k: int = 5, -) -> bool: - """保存/更新全局 AnySearch 配置到 site_settings。""" - if db_session is None: - return False - try: - from app.models import SiteSetting - from datetime import datetime - cfg = { - "api_key": api_key.strip(), - "is_enabled": bool(is_enabled), - "api_url": api_url.strip() or DEFAULT_API_URL, - "zone": zone or DEFAULT_ZONE, - "freshness": freshness, - "top_k": max(1, min(int(top_k), 20)), - } - row = db_session.query(SiteSetting).filter(SiteSetting.key == "anysearch_config").first() - if row: - row.value = json.dumps(cfg, ensure_ascii=False) - row.updated_at = datetime.now() - else: - from app.models import gen_uuid - row = SiteSetting(id=gen_uuid(), key="anysearch_config", value=json.dumps(cfg, ensure_ascii=False)) - db_session.add(row) - db_session.commit() - return True - except Exception as e: - logger.warning(f"save_anysearch_config failed: {e}") - try: - db_session.rollback() - except Exception: - pass - return False +""" +AnySearch 联网检索客户端 +====================== +调用 AnySearch 网页/领域搜索 API (https://api.anysearch.com/v1/search), +为 AI 聊天注入实时联网检索结果,亦可被后端端点 / Agent 工具复用。 + +约束:平台容器存在智能路由代理,对外部 API 偶发重置;本客户端使用 +trust_env=False 直连 (等价于 NO_PROXY=api.anysearch.com),规避代理重置。 +失败一律返回空/降级结果,保证聊天链路不中断。 +""" +import json +import logging +from typing import List, Dict, Optional + +logger = logging.getLogger("aiflow.anysearch") + +DEFAULT_API_URL = "https://api.anysearch.com/v1/search" +DEFAULT_ZONE = "cn" +REQUEST_TIMEOUT = 8.0 + + +async def anysearch_search( + api_key: str, + api_url: str = DEFAULT_API_URL, + query: str = "", + top_k: int = 5, + zone: str = DEFAULT_ZONE, + freshness: Optional[str] = None, + timeout: float = REQUEST_TIMEOUT, +) -> List[Dict]: + """调用 AnySearch 搜索,返回结构化结果列表。 + + 每个结果: + { + "content": snippet 摘要文本, + "score": 0.9..0.5 递减, + "source": "web_anysearch", + "metadata": {"url", "title", "source_domain", "zone"} + } + 失败时返回 [] (不抛异常)。 + """ + if not api_key: + return [] + try: + import httpx + except ImportError: + logger.warning("httpx not available for AnySearch") + return [] + + payload = { + "query": query, + "zone": zone, + "top_k": max(1, min(int(top_k), 20)), + } + if freshness: + payload["freshness"] = freshness + + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + } + try: + # trust_env=False -> 忽略容器内 HTTP(S)_PROXY,直连 api.anysearch.com + async with httpx.AsyncClient(trust_env=False, timeout=timeout) as client: + resp = await client.post(api_url, json=payload, headers=headers) + if resp.status_code != 200: + logger.warning(f"AnySearch HTTP {resp.status_code}: {resp.text[:200]}") + return [] + data = resp.json() + if data.get("code") != 0: + logger.warning(f"AnySearch biz error: {data.get('message')}") + return [] + results = (data.get("data") or {}).get("results") or [] + hits = [] + for idx, r in enumerate(results[:top_k]): + title = r.get("title", "") or "" + url = r.get("url", "") or "" + snippet = r.get("snippet") or r.get("content") or "" + if not snippet and not title: + continue + try: + from urllib.parse import urlparse + domain = urlparse(url).netloc + except Exception: + domain = "" + hits.append({ + "content": snippet, + "score": max(0.4, 0.9 - idx * 0.08), + "source": "web_anysearch", + "metadata": { + "url": url, + "title": title, + "source_domain": domain, + "zone": zone, + }, + }) + return hits + except Exception as e: + logger.warning(f"AnySearch search failed: {e}") + return [] + + +async def anysearch_health(api_key: str, api_url: str = DEFAULT_API_URL, timeout: float = REQUEST_TIMEOUT) -> Dict: + """连通性探测:返回 {"status": "connected"/"disconnected", "detail": ...}。""" + if not api_key: + return {"status": "disconnected", "detail": "未配置 API Key", "connected": False, "usable": False} + try: + import httpx + async with httpx.AsyncClient(trust_env=False, timeout=timeout) as client: + resp = await client.post( + api_url, + json={"query": "ping", "zone": DEFAULT_ZONE, "top_k": 1}, + headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}, + ) + if resp.status_code == 200: + return {"status": "connected", "detail": "AnySearch 连接正常", "connected": True, "usable": True} + return {"status": "error", "detail": f"HTTP {resp.status_code}", "connected": False, "usable": False} + except Exception as e: + return {"status": "error", "detail": str(e)[:200], "connected": False, "usable": False} + + +def load_anysearch_config(db_session) -> Dict: + """从 site_settings 读取全局 AnySearch 配置(失败返回空字典)。""" + if db_session is None: + return {} + try: + from app.models import SiteSetting + row = db_session.query(SiteSetting).filter(SiteSetting.key == "anysearch_config").first() + if not row or not row.value: + return {} + cfg = json.loads(row.value) + if not isinstance(cfg, dict): + return {} + return cfg + except Exception as e: + logger.warning(f"load_anysearch_config failed: {e}") + return {} + + +def save_anysearch_config( + db_session, + api_key: str, + is_enabled: bool = True, + api_url: str = DEFAULT_API_URL, + zone: str = DEFAULT_ZONE, + freshness: Optional[str] = None, + top_k: int = 5, +) -> bool: + """保存/更新全局 AnySearch 配置到 site_settings。""" + if db_session is None: + return False + try: + from app.models import SiteSetting + from datetime import datetime + cfg = { + "api_key": api_key.strip(), + "is_enabled": bool(is_enabled), + "api_url": api_url.strip() or DEFAULT_API_URL, + "zone": zone or DEFAULT_ZONE, + "freshness": freshness, + "top_k": max(1, min(int(top_k), 20)), + } + row = db_session.query(SiteSetting).filter(SiteSetting.key == "anysearch_config").first() + if row: + row.value = json.dumps(cfg, ensure_ascii=False) + row.updated_at = datetime.now() + else: + from app.models import gen_uuid + row = SiteSetting(id=gen_uuid(), key="anysearch_config", value=json.dumps(cfg, ensure_ascii=False)) + db_session.add(row) + db_session.commit() + return True + except Exception as e: + logger.warning(f"save_anysearch_config failed: {e}") + try: + db_session.rollback() + except Exception: + pass + return False diff --git a/backend/python/app/api/admin/agents_admin.py b/backend/python/app/api/admin/agents_admin.py index cc81e93..3a10f3f 100644 --- a/backend/python/app/api/admin/agents_admin.py +++ b/backend/python/app/api/admin/agents_admin.py @@ -1,784 +1,784 @@ -""" -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, - "orchestration": a.orchestration, - "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 - ] - } - - -# ============================================================ -# 3.1 tool_policy 沙箱:Agent 工具/MCP 权限策略 -# ============================================================ -class ToolPolicyModel(BaseModel): - tool_policy: Optional[dict] = None # null = 清除策略(恢复默认允许全部) - - -@router.get("/{agent_id}/tool-policy", summary="获取 Agent 工具策略") -async def get_agent_tool_policy( - agent_id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - agent = db.query(MarketApp).filter( - MarketApp.id == agent_id, MarketApp.type == "agent" - ).first() - if not agent: - raise HTTPException(status_code=404, detail="Agent 不存在") - return {"agent_id": agent_id, "tool_policy": agent.tool_policy} - - -@router.put("/{agent_id}/tool-policy", summary="设置 Agent 工具策略") -async def set_agent_tool_policy( - agent_id: str, - body: ToolPolicyModel, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - agent = db.query(MarketApp).filter( - MarketApp.id == agent_id, MarketApp.type == "agent" - ).first() - if not agent: - raise HTTPException(status_code=404, detail="Agent 不存在") - tp = body.tool_policy - if tp is not None: - if not isinstance(tp, dict): - raise HTTPException(status_code=400, detail="tool_policy 必须为对象") - # 轻量结构校验 - if "allowed_tools" in tp and tp["allowed_tools"] is not None: - at = tp["allowed_tools"] - if not isinstance(at, dict): - raise HTTPException(status_code=400, detail="allowed_tools 必须为对象") - for k in ("skill_ids", "mcp_ids"): - if k in at and at[k] is not None and not isinstance(at[k], list): - raise HTTPException(status_code=400, detail=f"allowed_tools.{k} 必须为数组") - for k in ("max_calls_per_conversation", "per_round_cap"): - if k in tp and tp[k] is not None and not isinstance(tp[k], int): - raise HTTPException(status_code=400, detail=f"{k} 必须为整数") - agent.tool_policy = tp - db.commit() - return {"agent_id": agent_id, "tool_policy": agent.tool_policy, "updated": True} - -# 3.3 多 Agent 编排图(图引擎) -# ============================================================ -class OrchestrationModel(BaseModel): - orchestration: Optional[dict] = None # null = 清除编排图 - - -@router.get("/{agent_id}/orchestration", summary="获取 Agent 编排图") -async def get_agent_orchestration( - agent_id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - agent = db.query(MarketApp).filter( - MarketApp.id == agent_id, MarketApp.type == "agent" - ).first() - if not agent: - raise HTTPException(status_code=404, detail="Agent 不存在") - return {"agent_id": agent_id, "orchestration": agent.orchestration} - - -@router.put("/{agent_id}/orchestration", summary="设置 Agent 编排图") -async def set_agent_orchestration( - agent_id: str, - body: OrchestrationModel, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - agent = db.query(MarketApp).filter( - MarketApp.id == agent_id, MarketApp.type == "agent" - ).first() - if not agent: - raise HTTPException(status_code=404, detail="Agent 不存在") - og = body.orchestration - if og is not None: - if not isinstance(og, dict): - raise HTTPException(status_code=400, detail="orchestration 必须为对象") - try: - from app.core.orchestrator import validate_graph - validate_graph(og) - except Exception as e: - raise HTTPException(status_code=400, detail=f"编排图非法: {str(e)}") - agent.orchestration = og - db.commit() - return {"agent_id": agent_id, "orchestration": agent.orchestration, "updated": True} - - -# ============================================================ -# Phase 4.2: YAML workflow import/export/validate/templates -# ============================================================ - -import yaml as _yaml -import os as _os -from app.core.yaml_workflow import validate_yaml, yaml_to_orchestration, orchestration_to_yaml -from app.core.orchestrator import validate_graph - -_YTMPL = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.dirname(__file__))), "workflow_templates") - - -class YAMLBody(BaseModel): - yaml_text: str - - -@router.post("/orchestration/yaml/validate", summary="校验 YAML 工作流") -async def yaml_validate(req: YAMLBody, admin_user=Depends(get_current_admin)): - try: - data = _yaml.safe_load(req.yaml_text) - except _yaml.YAMLError as e: - return {"ok": False, "errors": [f"YAML parse: {str(e)}"], "warnings": []} - r = validate_yaml(data) - if r["ok"]: - try: - validate_graph(yaml_to_orchestration(data)) - except Exception as e: - r["ok"] = False; r["errors"].append(f"Graph fail: {str(e)}") - return r - - -@router.post("/{agent_id}/orchestration/yaml/import", summary="YAML 导入编排") -async def yaml_import(agent_id: str, req: YAMLBody, admin_user=Depends(get_current_admin), db=Depends(get_db)): - ag = db.query(MarketApp).filter(MarketApp.id == agent_id, MarketApp.type == "agent").first() - if not ag: - raise HTTPException(status_code=404, detail="Agent not found") - try: - data = _yaml.safe_load(req.yaml_text) - except _yaml.YAMLError as e: - raise HTTPException(status_code=400, detail=f"YAML parse: {str(e)}") - v = validate_yaml(data) - if not v["ok"]: - raise HTTPException(status_code=400, detail="YAML invalid: " + "; ".join(v["errors"])) - orch = yaml_to_orchestration(data) - try: - validate_graph(orch) - except Exception as e: - raise HTTPException(status_code=400, detail=f"Graph invalid: {str(e)}") - ag.orchestration = orch - ag.updated_at = datetime.now() - db.commit() - return {"agent_id": agent_id, "orchestration": ag.orchestration, "imported": True} - - -@router.post("/{agent_id}/orchestration/yaml/export", summary="编排导出 YAML") -async def yaml_export(agent_id: str, admin_user=Depends(get_current_admin), db=Depends(get_db)): - ag = db.query(MarketApp).filter(MarketApp.id == agent_id, MarketApp.type == "agent").first() - if not ag: - raise HTTPException(status_code=404, detail="Agent not found") - if not ag.orchestration: - raise HTTPException(status_code=400, detail="No orchestration") - yaml_str = _yaml.dump(orchestration_to_yaml(ag.orchestration), allow_unicode=True, default_flow_style=False, sort_keys=False) - return {"agent_id": agent_id, "yaml": yaml_str, "version": "1.0"} - - -@router.get("/orchestration/yaml/templates", summary="内置模板") -async def yaml_templates(admin_user=Depends(get_current_admin)): - tmpls = [] - if _os.path.isdir(_YTMPL): - for fp in sorted(_os.listdir(_YTMPL)): - if fp.endswith((".yaml", ".yml")): - try: - with open(_os.path.join(_YTMPL, fp), encoding="utf-8") as f: - txt = f.read() - d = _yaml.safe_load(txt) or {} - tmpls.append({"filename": fp, "name": d.get("name", fp), - "description": d.get("description", ""), - "category": d.get("category", "general"), "yaml": txt}) - except Exception: - tmpls.append({"filename": fp, "name": fp, "error": True}) - return {"templates": tmpls, "count": len(tmpls)} - - -# ============================================================ -# Phase 4.3: ACA 协议抽象 — protocols / tools / health -# ============================================================ - -from app.core.aca import registry as _aca_registry, init_aca_registry - -# 懒初始化:首次调用 ACA 端点时确保 registry 已填充 -_aca_initialized = False - -def _ensure_aca(): - global _aca_initialized - if not _aca_initialized: - init_aca_registry() - _aca_initialized = True - - -@router.get("/aca/protocols", summary="ACA 已注册协议") -async def aca_protocols(admin_user=Depends(get_current_admin)): - _ensure_aca() - return [ - {"name": a.name, "version": a.version, "capabilities": a.capabilities} - for a in _aca_registry.list_all() - ] - - -@router.get("/aca/protocols/{name}/tools", summary="协议工具列表") -async def aca_protocol_tools(name: str, admin_user=Depends(get_current_admin)): - _ensure_aca() - adapter = _aca_registry.get(name) - if not adapter: - raise HTTPException(status_code=404, detail=f"协议 {name} 未注册") - tools = await adapter.list_tools() - return {"protocol": name, "tools": tools, "count": len(tools)} - - -@router.post("/aca/protocols/{name}/health", summary="协议健康检查") -async def aca_protocol_health(name: str, admin_user=Depends(get_current_admin)): - _ensure_aca() - adapter = _aca_registry.get(name) - if not adapter: - raise HTTPException(status_code=404, detail=f"协议 {name} 未注册") - ok = await adapter.health_check() - return {"protocol": name, "healthy": ok} +""" +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, + "orchestration": a.orchestration, + "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 + ] + } + + +# ============================================================ +# 3.1 tool_policy 沙箱:Agent 工具/MCP 权限策略 +# ============================================================ +class ToolPolicyModel(BaseModel): + tool_policy: Optional[dict] = None # null = 清除策略(恢复默认允许全部) + + +@router.get("/{agent_id}/tool-policy", summary="获取 Agent 工具策略") +async def get_agent_tool_policy( + agent_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + agent = db.query(MarketApp).filter( + MarketApp.id == agent_id, MarketApp.type == "agent" + ).first() + if not agent: + raise HTTPException(status_code=404, detail="Agent 不存在") + return {"agent_id": agent_id, "tool_policy": agent.tool_policy} + + +@router.put("/{agent_id}/tool-policy", summary="设置 Agent 工具策略") +async def set_agent_tool_policy( + agent_id: str, + body: ToolPolicyModel, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + agent = db.query(MarketApp).filter( + MarketApp.id == agent_id, MarketApp.type == "agent" + ).first() + if not agent: + raise HTTPException(status_code=404, detail="Agent 不存在") + tp = body.tool_policy + if tp is not None: + if not isinstance(tp, dict): + raise HTTPException(status_code=400, detail="tool_policy 必须为对象") + # 轻量结构校验 + if "allowed_tools" in tp and tp["allowed_tools"] is not None: + at = tp["allowed_tools"] + if not isinstance(at, dict): + raise HTTPException(status_code=400, detail="allowed_tools 必须为对象") + for k in ("skill_ids", "mcp_ids"): + if k in at and at[k] is not None and not isinstance(at[k], list): + raise HTTPException(status_code=400, detail=f"allowed_tools.{k} 必须为数组") + for k in ("max_calls_per_conversation", "per_round_cap"): + if k in tp and tp[k] is not None and not isinstance(tp[k], int): + raise HTTPException(status_code=400, detail=f"{k} 必须为整数") + agent.tool_policy = tp + db.commit() + return {"agent_id": agent_id, "tool_policy": agent.tool_policy, "updated": True} + +# 3.3 多 Agent 编排图(图引擎) +# ============================================================ +class OrchestrationModel(BaseModel): + orchestration: Optional[dict] = None # null = 清除编排图 + + +@router.get("/{agent_id}/orchestration", summary="获取 Agent 编排图") +async def get_agent_orchestration( + agent_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + agent = db.query(MarketApp).filter( + MarketApp.id == agent_id, MarketApp.type == "agent" + ).first() + if not agent: + raise HTTPException(status_code=404, detail="Agent 不存在") + return {"agent_id": agent_id, "orchestration": agent.orchestration} + + +@router.put("/{agent_id}/orchestration", summary="设置 Agent 编排图") +async def set_agent_orchestration( + agent_id: str, + body: OrchestrationModel, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + agent = db.query(MarketApp).filter( + MarketApp.id == agent_id, MarketApp.type == "agent" + ).first() + if not agent: + raise HTTPException(status_code=404, detail="Agent 不存在") + og = body.orchestration + if og is not None: + if not isinstance(og, dict): + raise HTTPException(status_code=400, detail="orchestration 必须为对象") + try: + from app.core.orchestrator import validate_graph + validate_graph(og) + except Exception as e: + raise HTTPException(status_code=400, detail=f"编排图非法: {str(e)}") + agent.orchestration = og + db.commit() + return {"agent_id": agent_id, "orchestration": agent.orchestration, "updated": True} + + +# ============================================================ +# Phase 4.2: YAML workflow import/export/validate/templates +# ============================================================ + +import yaml as _yaml +import os as _os +from app.core.yaml_workflow import validate_yaml, yaml_to_orchestration, orchestration_to_yaml +from app.core.orchestrator import validate_graph + +_YTMPL = _os.path.join(_os.path.dirname(_os.path.dirname(_os.path.dirname(__file__))), "workflow_templates") + + +class YAMLBody(BaseModel): + yaml_text: str + + +@router.post("/orchestration/yaml/validate", summary="校验 YAML 工作流") +async def yaml_validate(req: YAMLBody, admin_user=Depends(get_current_admin)): + try: + data = _yaml.safe_load(req.yaml_text) + except _yaml.YAMLError as e: + return {"ok": False, "errors": [f"YAML parse: {str(e)}"], "warnings": []} + r = validate_yaml(data) + if r["ok"]: + try: + validate_graph(yaml_to_orchestration(data)) + except Exception as e: + r["ok"] = False; r["errors"].append(f"Graph fail: {str(e)}") + return r + + +@router.post("/{agent_id}/orchestration/yaml/import", summary="YAML 导入编排") +async def yaml_import(agent_id: str, req: YAMLBody, admin_user=Depends(get_current_admin), db=Depends(get_db)): + ag = db.query(MarketApp).filter(MarketApp.id == agent_id, MarketApp.type == "agent").first() + if not ag: + raise HTTPException(status_code=404, detail="Agent not found") + try: + data = _yaml.safe_load(req.yaml_text) + except _yaml.YAMLError as e: + raise HTTPException(status_code=400, detail=f"YAML parse: {str(e)}") + v = validate_yaml(data) + if not v["ok"]: + raise HTTPException(status_code=400, detail="YAML invalid: " + "; ".join(v["errors"])) + orch = yaml_to_orchestration(data) + try: + validate_graph(orch) + except Exception as e: + raise HTTPException(status_code=400, detail=f"Graph invalid: {str(e)}") + ag.orchestration = orch + ag.updated_at = datetime.now() + db.commit() + return {"agent_id": agent_id, "orchestration": ag.orchestration, "imported": True} + + +@router.post("/{agent_id}/orchestration/yaml/export", summary="编排导出 YAML") +async def yaml_export(agent_id: str, admin_user=Depends(get_current_admin), db=Depends(get_db)): + ag = db.query(MarketApp).filter(MarketApp.id == agent_id, MarketApp.type == "agent").first() + if not ag: + raise HTTPException(status_code=404, detail="Agent not found") + if not ag.orchestration: + raise HTTPException(status_code=400, detail="No orchestration") + yaml_str = _yaml.dump(orchestration_to_yaml(ag.orchestration), allow_unicode=True, default_flow_style=False, sort_keys=False) + return {"agent_id": agent_id, "yaml": yaml_str, "version": "1.0"} + + +@router.get("/orchestration/yaml/templates", summary="内置模板") +async def yaml_templates(admin_user=Depends(get_current_admin)): + tmpls = [] + if _os.path.isdir(_YTMPL): + for fp in sorted(_os.listdir(_YTMPL)): + if fp.endswith((".yaml", ".yml")): + try: + with open(_os.path.join(_YTMPL, fp), encoding="utf-8") as f: + txt = f.read() + d = _yaml.safe_load(txt) or {} + tmpls.append({"filename": fp, "name": d.get("name", fp), + "description": d.get("description", ""), + "category": d.get("category", "general"), "yaml": txt}) + except Exception: + tmpls.append({"filename": fp, "name": fp, "error": True}) + return {"templates": tmpls, "count": len(tmpls)} + + +# ============================================================ +# Phase 4.3: ACA 协议抽象 — protocols / tools / health +# ============================================================ + +from app.core.aca import registry as _aca_registry, init_aca_registry + +# 懒初始化:首次调用 ACA 端点时确保 registry 已填充 +_aca_initialized = False + +def _ensure_aca(): + global _aca_initialized + if not _aca_initialized: + init_aca_registry() + _aca_initialized = True + + +@router.get("/aca/protocols", summary="ACA 已注册协议") +async def aca_protocols(admin_user=Depends(get_current_admin)): + _ensure_aca() + return [ + {"name": a.name, "version": a.version, "capabilities": a.capabilities} + for a in _aca_registry.list_all() + ] + + +@router.get("/aca/protocols/{name}/tools", summary="协议工具列表") +async def aca_protocol_tools(name: str, admin_user=Depends(get_current_admin)): + _ensure_aca() + adapter = _aca_registry.get(name) + if not adapter: + raise HTTPException(status_code=404, detail=f"协议 {name} 未注册") + tools = await adapter.list_tools() + return {"protocol": name, "tools": tools, "count": len(tools)} + + +@router.post("/aca/protocols/{name}/health", summary="协议健康检查") +async def aca_protocol_health(name: str, admin_user=Depends(get_current_admin)): + _ensure_aca() + adapter = _aca_registry.get(name) + if not adapter: + raise HTTPException(status_code=404, detail=f"协议 {name} 未注册") + ok = await adapter.health_check() + return {"protocol": name, "healthy": ok} diff --git a/backend/python/app/api/admin/market_admin.py b/backend/python/app/api/admin/market_admin.py index 77a9865..df82a29 100644 --- a/backend/python/app/api/admin/market_admin.py +++ b/backend/python/app/api/admin/market_admin.py @@ -1,549 +1,549 @@ -""" -应用市场审核 API(管理员) -""" -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session -from ...database import get_db, SessionLocal -from ...models import MarketApp, Coupon, UserCoupon, User, MarketAppVersion -from ...auth import get_current_admin -from ...middleware.log_service import LogService -from app.core.app_security import scan_market_app -import uuid -from datetime import datetime - -router = APIRouter() - - -def _snapshot_app_version(app, db, created_by=None): - """Phase 4.1: 写入一条应用版本快照(回滚用)。""" - snap = MarketAppVersion( - app_id=app.id, - version=app.version or "1.0.0", - type=app.type, - config={ - "name": app.name, - "description": app.description, - "icon": app.icon, - "type": app.type, - "price": float(app.price) if app.price else 0, - "skill_content": app.skill_content, - "mcp_transport": app.mcp_transport, - "agent_endpoint": app.agent_endpoint, - "capability": app.capability, - }, - security_scan=app.security_scan, - dependencies=app.dependencies, - created_by=created_by, - created_at=datetime.now(), - ) - db.add(snap) - db.commit() - - -# ============================================================ -# 应用审核管理 -# ============================================================ - -@router.get("/apps/pending") -async def get_pending_apps( - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取待审核应用列表""" - apps = db.query(MarketApp).filter( - MarketApp.status == "pending" - ).order_by(MarketApp.created_at.desc()).limit(1000).all() - - 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, - "skill_content": a.skill_content, - "mcp_transport": a.mcp_transport, - "agent_endpoint": a.agent_endpoint, - "created_at": a.created_at.isoformat() if a.created_at else None, - "updated_at": a.updated_at.isoformat() if a.updated_at else None, - } - for a in apps - ] - - -@router.put("/apps/{id}/approve") -async def approve_app( - id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """审核通过应用""" - app = db.query(MarketApp).filter(MarketApp.id == id).first() - if not app: - raise HTTPException(status_code=404, detail="应用不存在") - - app.status = "published" - app.verified = True # Phase 4.1: 审核通过即绿标 - app.updated_at = datetime.now() - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="approve_app", - target_type="market_app", - target_id=id, - detail=f"审核通过应用 {app.name}", - ) - - return {"message": "应用审核通过"} - - -@router.put("/apps/{id}/reject") -async def reject_app( - id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """审核拒绝应用""" - app = db.query(MarketApp).filter(MarketApp.id == id).first() - if not app: - raise HTTPException(status_code=404, detail="应用不存在") - - app.status = "removed" - app.updated_at = datetime.now() - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="reject_app", - target_type="market_app", - target_id=id, - detail=f"审核拒绝应用 {app.name}", - ) - - return {"message": "应用审核已拒绝"} - - -@router.get("/apps") -async def get_all_apps( - status: str = Query(None, description="状态筛选: pending/published/removed"), - app_type: str = Query(None, description="类型筛选: skill/agent/mcp"), - 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), -): - """获取所有应用列表(管理员)""" - query = db.query(MarketApp) - - if status: - query = query.filter(MarketApp.status == status) - if app_type: - query = query.filter(MarketApp.type == app_type) - - total = query.count() - apps = query.order_by(MarketApp.created_at.desc()).offset( - (page - 1) * page_size - ).limit(page_size).all() - - return { - "total": total, - "page": page, - "page_size": page_size, - "items": [ - { - "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, - "created_at": a.created_at.isoformat() if a.created_at else None, - "updated_at": a.updated_at.isoformat() if a.updated_at else None, - # Phase 4.1: 安检/版本化/绿标 - "version": a.version or "1.0.0", - "security_scan": a.security_scan, - "verified": bool(a.verified), - "dependencies": a.dependencies, - } - for a in apps - ], - } - - -@router.put("/apps/{id}") -async def admin_edit_app( - id: str, - name: str = None, - description: str = None, - icon: str = None, - price: float = None, - is_member_free: bool = None, - join_promotion: bool = None, - tutorial: str = None, - version: str = None, # Phase 4.1: 指定新版本号时先快照当前状态 - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """管理员编辑应用""" - app = db.query(MarketApp).filter(MarketApp.id == id).first() - if not app: - raise HTTPException(status_code=404, detail="应用不存在") - - if name is not None: - app.name = name - if description is not None: - app.description = description - if icon is not None: - app.icon = icon - if price is not None: - app.price = price - if is_member_free is not None: - app.is_member_free = is_member_free - if join_promotion is not None: - app.join_promotion = join_promotion - if tutorial is not None: - app.tutorial = tutorial - if version is not None and version != app.version: - _snapshot_app_version(app, db, admin_user.id) - app.version = version - app.updated_at = datetime.now() - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="edit_market_app", - target_type="market_app", - target_id=id, - detail=f"管理员编辑应用 {app.name}", - ) - - return {"message": "应用已更新"} - - -# ============================================================ -# Phase 4.1: 版本历史 / 安检 / 回滚 -# ============================================================ - -@router.get("/apps/{id}/versions") -async def get_app_versions( - id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取应用的版本快照历史。""" - app = db.query(MarketApp).filter(MarketApp.id == id).first() - if not app: - raise HTTPException(status_code=404, detail="应用不存在") - vers = db.query(MarketAppVersion).filter( - MarketAppVersion.app_id == id - ).order_by(MarketAppVersion.created_at.desc()).all() - return { - "current_version": app.version or "1.0.0", - "items": [ - { - "id": v.id, - "version": v.version, - "type": v.type, - "created_at": v.created_at.isoformat() if v.created_at else None, - "created_by": v.created_by, - "security_scan": v.security_scan, - "dependencies": v.dependencies, - } - for v in vers - ], - } - - -@router.post("/apps/{id}/scan") -async def rescan_app( - id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """重新执行安全扫描并刷新绿标。""" - app = db.query(MarketApp).filter(MarketApp.id == id).first() - if not app: - raise HTTPException(status_code=404, detail="应用不存在") - scan = scan_market_app( - app_type=app.type, - skill_content=app.skill_content, - agent_endpoint=app.agent_endpoint, - mcp_transport=app.mcp_transport, - mcp_config=app.skill_content if app.type == "mcp" else None, - dependencies=app.dependencies, - ) - app.security_scan = scan - app.verified = (scan.get("status") == "pass") - app.updated_at = datetime.now() - db.commit() - LogService.log_audit( - admin_id=admin_user.id, admin_name=admin_user.username, - action="rescan_market_app", target_type="market_app", target_id=id, - detail=f"重新安检应用 {app.name}:{scan.get('status')}", - ) - return {"status": scan.get("status"), "score": scan.get("score"), "security_scan": scan} - - -@router.post("/apps/{id}/rollback") -async def rollback_app( - id: str, - version_id: str = Query(..., description="要回滚到的版本快照 ID"), - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """回滚应用到指定版本快照。""" - app = db.query(MarketApp).filter(MarketApp.id == id).first() - if not app: - raise HTTPException(status_code=404, detail="应用不存在") - snap = db.query(MarketAppVersion).filter( - MarketAppVersion.id == version_id, MarketAppVersion.app_id == id - ).first() - if not snap: - raise HTTPException(status_code=404, detail="版本快照不存在") - cfg = snap.config or {} - app.name = cfg.get("name", app.name) - app.description = cfg.get("description", app.description) - app.icon = cfg.get("icon", app.icon) - app.skill_content = cfg.get("skill_content", app.skill_content) - app.mcp_transport = cfg.get("mcp_transport", app.mcp_transport) - app.agent_endpoint = cfg.get("agent_endpoint", app.agent_endpoint) - app.capability = cfg.get("capability", app.capability) - app.version = snap.version - app.security_scan = snap.security_scan - app.updated_at = datetime.now() - db.commit() - LogService.log_audit( - admin_id=admin_user.id, admin_name=admin_user.username, - action="rollback_market_app", target_type="market_app", target_id=id, - detail=f"回滚应用 {app.name} 到版本 {snap.version}", - ) - return {"message": "已回滚到版本 " + snap.version} - - -# ============================================================ -# 优惠券管理 -# ============================================================ - -@router.get("/coupons") -async def get_coupons( - status: str = Query(None, description="状态筛选: active/expired/disabled"), - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取优惠券列表""" - query = db.query(Coupon) - - if status: - query = query.filter(Coupon.status == status) - - coupons = query.order_by(Coupon.created_at.desc()).limit(1000).all() - return [ - { - "id": c.id, - "code": c.code, - "type": c.type, - "value": float(c.value) if c.value else 0, - "min_amount": float(c.min_amount) if c.min_amount else 0, - "valid_from": c.valid_from.isoformat() if c.valid_from else None, - "valid_to": c.valid_to.isoformat() if c.valid_to else None, - "total_count": c.total_count, - "used_count": c.used_count, - "status": c.status, - "created_at": c.created_at.isoformat() if c.created_at else None, - } - for c in coupons - ] - - -@router.post("/coupons") -async def create_coupon( - code: str, - coupon_type: str, - value: float, - valid_from: str, - valid_to: str, - min_amount: float = 0, - total_count: int = None, - status: str = "active", - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """创建优惠券""" - # 检查code是否已存在 - existing = db.query(Coupon).filter(Coupon.code == code).first() - if existing: - raise HTTPException(status_code=400, detail="优惠券码已存在") - - coupon = Coupon( - id=str(uuid.uuid4()), - code=code, - type=coupon_type, - value=value, - min_amount=min_amount, - valid_from=datetime.fromisoformat(valid_from), - valid_to=datetime.fromisoformat(valid_to), - total_count=total_count, - status=status, - ) - db.add(coupon) - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="create_coupon", - target_type="coupon", - target_id=coupon.id, - detail=f"创建优惠券 {code}", - ) - - return {"message": "优惠券已创建", "id": coupon.id} - - -@router.put("/coupons/{id}") -async def update_coupon( - id: str, - code: str = None, - value: float = None, - min_amount: float = None, - valid_from: str = None, - valid_to: str = None, - total_count: int = None, - status: str = None, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """更新优惠券""" - coupon = db.query(Coupon).filter(Coupon.id == id).first() - if not coupon: - raise HTTPException(status_code=404, detail="优惠券不存在") - - if code is not None: - # 检查新code是否重复 - if code != coupon.code: - dup = db.query(Coupon).filter(Coupon.code == code).first() - if dup: - raise HTTPException(status_code=400, detail="优惠券码已存在") - coupon.code = code - if value is not None: - coupon.value = value - if min_amount is not None: - coupon.min_amount = min_amount - if valid_from is not None: - coupon.valid_from = datetime.fromisoformat(valid_from) - if valid_to is not None: - coupon.valid_to = datetime.fromisoformat(valid_to) - if total_count is not None: - coupon.total_count = total_count - if status is not None: - coupon.status = status - - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="update_coupon", - target_type="coupon", - target_id=id, - detail=f"更新优惠券 {coupon.code}", - ) - - return {"message": "优惠券已更新"} - - -@router.delete("/coupons/{id}") -async def delete_coupon( - id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """删除优惠券""" - coupon = db.query(Coupon).filter(Coupon.id == id).first() - if not coupon: - raise HTTPException(status_code=404, detail="优惠券不存在") - - db.delete(coupon) - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="delete_coupon", - target_type="coupon", - target_id=id, - detail=f"删除优惠券 {coupon.code}", - ) - - return {"message": "优惠券已删除"} - - -@router.post("/coupons/{id}/assign") -async def assign_coupon( - id: str, - user_id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """发放优惠券给用户""" - coupon = db.query(Coupon).filter(Coupon.id == id).first() - if not coupon: - raise HTTPException(status_code=404, detail="优惠券不存在") - if coupon.status != "active": - raise HTTPException(status_code=400, detail="优惠券非活跃状态") - - user = db.query(User).filter(User.id == user_id).first() - if not user: - raise HTTPException(status_code=404, detail="用户不存在") - - # 检查是否已发放 - existing = db.query(UserCoupon).filter( - UserCoupon.user_id == user_id, - UserCoupon.coupon_id == id, - ).first() - if existing: - raise HTTPException(status_code=400, detail="该用户已拥有此优惠券") - - user_coupon = UserCoupon( - id=str(uuid.uuid4()), - user_id=user_id, - coupon_id=id, - ) - db.add(user_coupon) - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="assign_coupon", - target_type="coupon", - target_id=id, - detail=f"发放优惠券 {coupon.code} 给用户 {user.username}", - ) - - return {"message": "优惠券已发放"} +""" +应用市场审核 API(管理员) +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from ...database import get_db, SessionLocal +from ...models import MarketApp, Coupon, UserCoupon, User, MarketAppVersion +from ...auth import get_current_admin +from ...middleware.log_service import LogService +from app.core.app_security import scan_market_app +import uuid +from datetime import datetime + +router = APIRouter() + + +def _snapshot_app_version(app, db, created_by=None): + """Phase 4.1: 写入一条应用版本快照(回滚用)。""" + snap = MarketAppVersion( + app_id=app.id, + version=app.version or "1.0.0", + type=app.type, + config={ + "name": app.name, + "description": app.description, + "icon": app.icon, + "type": app.type, + "price": float(app.price) if app.price else 0, + "skill_content": app.skill_content, + "mcp_transport": app.mcp_transport, + "agent_endpoint": app.agent_endpoint, + "capability": app.capability, + }, + security_scan=app.security_scan, + dependencies=app.dependencies, + created_by=created_by, + created_at=datetime.now(), + ) + db.add(snap) + db.commit() + + +# ============================================================ +# 应用审核管理 +# ============================================================ + +@router.get("/apps/pending") +async def get_pending_apps( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取待审核应用列表""" + apps = db.query(MarketApp).filter( + MarketApp.status == "pending" + ).order_by(MarketApp.created_at.desc()).limit(1000).all() + + 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, + "skill_content": a.skill_content, + "mcp_transport": a.mcp_transport, + "agent_endpoint": a.agent_endpoint, + "created_at": a.created_at.isoformat() if a.created_at else None, + "updated_at": a.updated_at.isoformat() if a.updated_at else None, + } + for a in apps + ] + + +@router.put("/apps/{id}/approve") +async def approve_app( + id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """审核通过应用""" + app = db.query(MarketApp).filter(MarketApp.id == id).first() + if not app: + raise HTTPException(status_code=404, detail="应用不存在") + + app.status = "published" + app.verified = True # Phase 4.1: 审核通过即绿标 + app.updated_at = datetime.now() + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="approve_app", + target_type="market_app", + target_id=id, + detail=f"审核通过应用 {app.name}", + ) + + return {"message": "应用审核通过"} + + +@router.put("/apps/{id}/reject") +async def reject_app( + id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """审核拒绝应用""" + app = db.query(MarketApp).filter(MarketApp.id == id).first() + if not app: + raise HTTPException(status_code=404, detail="应用不存在") + + app.status = "removed" + app.updated_at = datetime.now() + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="reject_app", + target_type="market_app", + target_id=id, + detail=f"审核拒绝应用 {app.name}", + ) + + return {"message": "应用审核已拒绝"} + + +@router.get("/apps") +async def get_all_apps( + status: str = Query(None, description="状态筛选: pending/published/removed"), + app_type: str = Query(None, description="类型筛选: skill/agent/mcp"), + 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), +): + """获取所有应用列表(管理员)""" + query = db.query(MarketApp) + + if status: + query = query.filter(MarketApp.status == status) + if app_type: + query = query.filter(MarketApp.type == app_type) + + total = query.count() + apps = query.order_by(MarketApp.created_at.desc()).offset( + (page - 1) * page_size + ).limit(page_size).all() + + return { + "total": total, + "page": page, + "page_size": page_size, + "items": [ + { + "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, + "created_at": a.created_at.isoformat() if a.created_at else None, + "updated_at": a.updated_at.isoformat() if a.updated_at else None, + # Phase 4.1: 安检/版本化/绿标 + "version": a.version or "1.0.0", + "security_scan": a.security_scan, + "verified": bool(a.verified), + "dependencies": a.dependencies, + } + for a in apps + ], + } + + +@router.put("/apps/{id}") +async def admin_edit_app( + id: str, + name: str = None, + description: str = None, + icon: str = None, + price: float = None, + is_member_free: bool = None, + join_promotion: bool = None, + tutorial: str = None, + version: str = None, # Phase 4.1: 指定新版本号时先快照当前状态 + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """管理员编辑应用""" + app = db.query(MarketApp).filter(MarketApp.id == id).first() + if not app: + raise HTTPException(status_code=404, detail="应用不存在") + + if name is not None: + app.name = name + if description is not None: + app.description = description + if icon is not None: + app.icon = icon + if price is not None: + app.price = price + if is_member_free is not None: + app.is_member_free = is_member_free + if join_promotion is not None: + app.join_promotion = join_promotion + if tutorial is not None: + app.tutorial = tutorial + if version is not None and version != app.version: + _snapshot_app_version(app, db, admin_user.id) + app.version = version + app.updated_at = datetime.now() + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="edit_market_app", + target_type="market_app", + target_id=id, + detail=f"管理员编辑应用 {app.name}", + ) + + return {"message": "应用已更新"} + + +# ============================================================ +# Phase 4.1: 版本历史 / 安检 / 回滚 +# ============================================================ + +@router.get("/apps/{id}/versions") +async def get_app_versions( + id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取应用的版本快照历史。""" + app = db.query(MarketApp).filter(MarketApp.id == id).first() + if not app: + raise HTTPException(status_code=404, detail="应用不存在") + vers = db.query(MarketAppVersion).filter( + MarketAppVersion.app_id == id + ).order_by(MarketAppVersion.created_at.desc()).all() + return { + "current_version": app.version or "1.0.0", + "items": [ + { + "id": v.id, + "version": v.version, + "type": v.type, + "created_at": v.created_at.isoformat() if v.created_at else None, + "created_by": v.created_by, + "security_scan": v.security_scan, + "dependencies": v.dependencies, + } + for v in vers + ], + } + + +@router.post("/apps/{id}/scan") +async def rescan_app( + id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """重新执行安全扫描并刷新绿标。""" + app = db.query(MarketApp).filter(MarketApp.id == id).first() + if not app: + raise HTTPException(status_code=404, detail="应用不存在") + scan = scan_market_app( + app_type=app.type, + skill_content=app.skill_content, + agent_endpoint=app.agent_endpoint, + mcp_transport=app.mcp_transport, + mcp_config=app.skill_content if app.type == "mcp" else None, + dependencies=app.dependencies, + ) + app.security_scan = scan + app.verified = (scan.get("status") == "pass") + app.updated_at = datetime.now() + db.commit() + LogService.log_audit( + admin_id=admin_user.id, admin_name=admin_user.username, + action="rescan_market_app", target_type="market_app", target_id=id, + detail=f"重新安检应用 {app.name}:{scan.get('status')}", + ) + return {"status": scan.get("status"), "score": scan.get("score"), "security_scan": scan} + + +@router.post("/apps/{id}/rollback") +async def rollback_app( + id: str, + version_id: str = Query(..., description="要回滚到的版本快照 ID"), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """回滚应用到指定版本快照。""" + app = db.query(MarketApp).filter(MarketApp.id == id).first() + if not app: + raise HTTPException(status_code=404, detail="应用不存在") + snap = db.query(MarketAppVersion).filter( + MarketAppVersion.id == version_id, MarketAppVersion.app_id == id + ).first() + if not snap: + raise HTTPException(status_code=404, detail="版本快照不存在") + cfg = snap.config or {} + app.name = cfg.get("name", app.name) + app.description = cfg.get("description", app.description) + app.icon = cfg.get("icon", app.icon) + app.skill_content = cfg.get("skill_content", app.skill_content) + app.mcp_transport = cfg.get("mcp_transport", app.mcp_transport) + app.agent_endpoint = cfg.get("agent_endpoint", app.agent_endpoint) + app.capability = cfg.get("capability", app.capability) + app.version = snap.version + app.security_scan = snap.security_scan + app.updated_at = datetime.now() + db.commit() + LogService.log_audit( + admin_id=admin_user.id, admin_name=admin_user.username, + action="rollback_market_app", target_type="market_app", target_id=id, + detail=f"回滚应用 {app.name} 到版本 {snap.version}", + ) + return {"message": "已回滚到版本 " + snap.version} + + +# ============================================================ +# 优惠券管理 +# ============================================================ + +@router.get("/coupons") +async def get_coupons( + status: str = Query(None, description="状态筛选: active/expired/disabled"), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取优惠券列表""" + query = db.query(Coupon) + + if status: + query = query.filter(Coupon.status == status) + + coupons = query.order_by(Coupon.created_at.desc()).limit(1000).all() + return [ + { + "id": c.id, + "code": c.code, + "type": c.type, + "value": float(c.value) if c.value else 0, + "min_amount": float(c.min_amount) if c.min_amount else 0, + "valid_from": c.valid_from.isoformat() if c.valid_from else None, + "valid_to": c.valid_to.isoformat() if c.valid_to else None, + "total_count": c.total_count, + "used_count": c.used_count, + "status": c.status, + "created_at": c.created_at.isoformat() if c.created_at else None, + } + for c in coupons + ] + + +@router.post("/coupons") +async def create_coupon( + code: str, + coupon_type: str, + value: float, + valid_from: str, + valid_to: str, + min_amount: float = 0, + total_count: int = None, + status: str = "active", + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """创建优惠券""" + # 检查code是否已存在 + existing = db.query(Coupon).filter(Coupon.code == code).first() + if existing: + raise HTTPException(status_code=400, detail="优惠券码已存在") + + coupon = Coupon( + id=str(uuid.uuid4()), + code=code, + type=coupon_type, + value=value, + min_amount=min_amount, + valid_from=datetime.fromisoformat(valid_from), + valid_to=datetime.fromisoformat(valid_to), + total_count=total_count, + status=status, + ) + db.add(coupon) + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="create_coupon", + target_type="coupon", + target_id=coupon.id, + detail=f"创建优惠券 {code}", + ) + + return {"message": "优惠券已创建", "id": coupon.id} + + +@router.put("/coupons/{id}") +async def update_coupon( + id: str, + code: str = None, + value: float = None, + min_amount: float = None, + valid_from: str = None, + valid_to: str = None, + total_count: int = None, + status: str = None, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """更新优惠券""" + coupon = db.query(Coupon).filter(Coupon.id == id).first() + if not coupon: + raise HTTPException(status_code=404, detail="优惠券不存在") + + if code is not None: + # 检查新code是否重复 + if code != coupon.code: + dup = db.query(Coupon).filter(Coupon.code == code).first() + if dup: + raise HTTPException(status_code=400, detail="优惠券码已存在") + coupon.code = code + if value is not None: + coupon.value = value + if min_amount is not None: + coupon.min_amount = min_amount + if valid_from is not None: + coupon.valid_from = datetime.fromisoformat(valid_from) + if valid_to is not None: + coupon.valid_to = datetime.fromisoformat(valid_to) + if total_count is not None: + coupon.total_count = total_count + if status is not None: + coupon.status = status + + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="update_coupon", + target_type="coupon", + target_id=id, + detail=f"更新优惠券 {coupon.code}", + ) + + return {"message": "优惠券已更新"} + + +@router.delete("/coupons/{id}") +async def delete_coupon( + id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """删除优惠券""" + coupon = db.query(Coupon).filter(Coupon.id == id).first() + if not coupon: + raise HTTPException(status_code=404, detail="优惠券不存在") + + db.delete(coupon) + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="delete_coupon", + target_type="coupon", + target_id=id, + detail=f"删除优惠券 {coupon.code}", + ) + + return {"message": "优惠券已删除"} + + +@router.post("/coupons/{id}/assign") +async def assign_coupon( + id: str, + user_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """发放优惠券给用户""" + coupon = db.query(Coupon).filter(Coupon.id == id).first() + if not coupon: + raise HTTPException(status_code=404, detail="优惠券不存在") + if coupon.status != "active": + raise HTTPException(status_code=400, detail="优惠券非活跃状态") + + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + # 检查是否已发放 + existing = db.query(UserCoupon).filter( + UserCoupon.user_id == user_id, + UserCoupon.coupon_id == id, + ).first() + if existing: + raise HTTPException(status_code=400, detail="该用户已拥有此优惠券") + + user_coupon = UserCoupon( + id=str(uuid.uuid4()), + user_id=user_id, + coupon_id=id, + ) + db.add(user_coupon) + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="assign_coupon", + target_type="coupon", + target_id=id, + detail=f"发放优惠券 {coupon.code} 给用户 {user.username}", + ) + + return {"message": "优惠券已发放"} diff --git a/backend/python/app/api/admin/mcp_market_admin.py b/backend/python/app/api/admin/mcp_market_admin.py index 32fcd6a..edbd0f7 100644 --- a/backend/python/app/api/admin/mcp_market_admin.py +++ b/backend/python/app/api/admin/mcp_market_admin.py @@ -1,1224 +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}} +""" +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/admin/settings.py b/backend/python/app/api/admin/settings.py index 609c7ba..5d503a8 100644 --- a/backend/python/app/api/admin/settings.py +++ b/backend/python/app/api/admin/settings.py @@ -1,624 +1,624 @@ -""" -网站基础设置 API(管理员) -""" -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session -from ...database import get_db, SessionLocal -from ...models import SiteSetting, EmailConfig, SmsConfig, User, AIModel -from ...auth import get_current_admin -from ...middleware.log_service import LogService -import uuid -import smtplib -import hmac -import hashlib -import base64 -import urllib.parse -import time as time_module -from email.mime.text import MIMEText -from email.mime.multipart import MIMEMultipart -from datetime import datetime -from pydantic import BaseModel -from typing import Optional, Dict, Any -import logging - -logger = logging.getLogger("aiflow") - -router = APIRouter() - - -# ============================================================ -# Pydantic Models -# ============================================================ - -class EmailConfigUpdate(BaseModel): - smtp_host: Optional[str] = None - smtp_port: Optional[int] = None - username: Optional[str] = None - password: Optional[str] = None - from_address: Optional[str] = None - use_tls: Optional[bool] = None - - -class SmsConfigUpdate(BaseModel): - provider: Optional[str] = None - api_key: Optional[str] = None - api_secret: Optional[str] = None - sign_name: Optional[str] = None - template_code: Optional[str] = None - # 短信宝 (SMSBao) - username: Optional[str] = None - password: Optional[str] = None - goods_id: Optional[str] = None - - -# ============================================================ -# 网站设置 -# ============================================================ - -@router.get("/site") -async def get_site_settings( - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取网站设置(返回所有 key-value 对)""" - settings = db.query(SiteSetting).all() - result = {} - for s in settings: - result[s.key] = s.value - return {"code": 0, "data": result, "message": "ok"} - - -@router.put("/site") -async def update_site_settings( - settings: dict, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """批量更新网站设置""" - for key, value in settings.items(): - existing = db.query(SiteSetting).filter(SiteSetting.key == key).first() - if existing: - existing.value = str(value) if value is not None else None - existing.updated_at = datetime.now() - else: - new_setting = SiteSetting( - id=str(uuid.uuid4()), - key=key, - value=str(value) if value is not None else None, - ) - db.add(new_setting) - - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="update_site_settings", - target_type="site_settings", - detail=f"更新网站设置: {list(settings.keys())}", - ) - - return {"message": "网站设置已更新"} - - -# ============================================================ -# 邮件配置 -# ============================================================ - -@router.get("/email") -async def get_email_config( - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取邮件配置""" - config = db.query(EmailConfig).first() - if not config: - return {"code": 0, "data": { - "smtp_host": "", - "smtp_port": 587, - "username": "", - "password": "", - "from_address": "", - "use_tls": True, - }, "message": "ok"} - - return {"code": 0, "data": { - "id": config.id, - "smtp_host": config.smtp_host, - "smtp_port": config.smtp_port, - "username": config.username, - "password": config.password, - "from_address": config.from_address, - "use_tls": config.use_tls, - }, "message": "ok"} - - -@router.put("/email") -async def update_email_config( - req: EmailConfigUpdate, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """更新邮件配置""" - config = db.query(EmailConfig).first() - if not config: - # 必填字段校验:不允许空值 - if not req.smtp_host: - raise HTTPException(status_code=400, detail="SMTP服务器地址不能为空") - if not req.username: - raise HTTPException(status_code=400, detail="SMTP用户名不能为空") - if not req.password: - raise HTTPException(status_code=400, detail="SMTP密码不能为空") - if not req.from_address: - raise HTTPException(status_code=400, detail="发件地址不能为空") - config = EmailConfig( - id=str(uuid.uuid4()), - smtp_host=req.smtp_host or "", - smtp_port=req.smtp_port or 587, - username=req.username or "", - password=req.password or "", - from_address=req.from_address or "", - use_tls=req.use_tls if req.use_tls is not None else True, - ) - db.add(config) - else: - if req.smtp_host is not None: - if not req.smtp_host.strip(): - raise HTTPException(status_code=400, detail="SMTP服务器地址不能为空") - config.smtp_host = req.smtp_host - if req.smtp_port is not None: - config.smtp_port = req.smtp_port - if req.username is not None: - if not req.username.strip(): - raise HTTPException(status_code=400, detail="SMTP用户名不能为空") - config.username = req.username - if req.password is not None: - if not req.password.strip(): - raise HTTPException(status_code=400, detail="SMTP密码不能为空") - config.password = req.password - if req.from_address is not None: - if not req.from_address.strip(): - raise HTTPException(status_code=400, detail="发件地址不能为空") - config.from_address = req.from_address - if req.use_tls is not None: - config.use_tls = req.use_tls - config.updated_at = datetime.now() - - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="update_email_config", - target_type="email_config", - detail="更新邮件配置", - ) - - return {"code": 0, "data": None, "message": "邮件配置已更新"} - - -# ============================================================ -# 短信配置 -# ============================================================ - -@router.get("/sms") -async def get_sms_config( - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取短信配置""" - config = db.query(SmsConfig).first() - if not config: - return {"code": 0, "data": { - "provider": "", - "api_key": "", - "api_secret": "", - "sign_name": "", - "template_code": "", - "username": "", - "password": "", - "goods_id": "", - }, "message": "ok"} - - return {"code": 0, "data": { - "id": config.id, - "provider": config.provider, - "api_key": config.api_key, - "api_secret": config.api_secret, - "sign_name": config.sign_name, - "template_code": config.template_code, - "username": config.username, - "password": config.password, - "goods_id": config.goods_id, - }, "message": "ok"} - - -@router.put("/sms") -async def update_sms_config( - req: SmsConfigUpdate, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """更新短信配置""" - config = db.query(SmsConfig).first() - if not config: - config = SmsConfig( - id=str(uuid.uuid4()), - provider=req.provider or "", - api_key=req.api_key or "", - api_secret=req.api_secret or "", - sign_name=req.sign_name, - template_code=req.template_code, - username=req.username, - password=req.password, - goods_id=req.goods_id, - ) - db.add(config) - else: - if req.provider is not None: - config.provider = req.provider - if req.api_key is not None: - config.api_key = req.api_key - if req.api_secret is not None: - config.api_secret = req.api_secret - if req.sign_name is not None: - config.sign_name = req.sign_name - if req.template_code is not None: - config.template_code = req.template_code - if req.username is not None: - config.username = req.username - if req.password is not None: - config.password = req.password - if req.goods_id is not None: - config.goods_id = req.goods_id - config.updated_at = datetime.now() - - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="update_sms_config", - target_type="sms_config", - detail="更新短信配置", - ) - - return {"message": "短信配置已更新"} - - -# ============================================================ -# 邮件测试 -# ============================================================ - -@router.post("/email/test") -async def test_email_config( - test_to: str = Query(default=None, description="自定义测试收件地址"), - test_subject: str = Query(default=None, description="自定义测试邮件主题"), - test_body: str = Query(default=None, description="自定义测试邮件正文"), - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """使用当前配置发送测试邮件;可传参自定义收件人、主题和正文""" - config = db.query(EmailConfig).first() - if not config or not config.smtp_host: - raise HTTPException(status_code=400, detail="邮件配置未设置,请先保存邮件配置") - if not config.username: - raise HTTPException(status_code=400, detail="SMTP用户名未填写,请检查邮件配置") - if not config.password: - raise HTTPException(status_code=400, detail="SMTP密码未填写,请检查邮件配置") - if not config.from_address: - raise HTTPException(status_code=400, detail="发件地址未填写,请检查邮件配置") - - # 优先级:传入参数 > 管理员邮箱 > 发件地址 - admin = db.query(User).filter(User.id == admin_user.id).first() - recipient = test_to or (admin.email if admin and admin.email else None) or config.from_address - if not recipient: - raise HTTPException(status_code=400, detail="无法获取接收地址: 请填写收件邮箱或设置管理员邮箱") - - subject = test_subject or "AIFlow 邮件配置测试" - body = test_body or f"这是一封来自 AIFlow 平台的测试邮件。\n\n发送时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\nSMTP服务器: {config.smtp_host}" - - logger.info(f"测试邮件: SMTP={config.smtp_host}:{config.smtp_port}, " - f"from={config.from_address}, to={recipient}") - - try: - msg = MIMEMultipart() - msg["From"] = config.from_address - msg["To"] = recipient - msg["Subject"] = subject - msg.attach(MIMEText(body, "plain")) - - if config.use_tls: - server = smtplib.SMTP(config.smtp_host, config.smtp_port, timeout=10) - server.starttls() - else: - server = smtplib.SMTP(config.smtp_host, config.smtp_port, timeout=10) - - server.login(config.username, config.password) - server.sendmail(config.from_address, recipient, msg.as_string()) - server.quit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="test_email_config", - target_type="email_config", - detail=f"测试邮件发送成功 -> {recipient} via {config.smtp_host}", - ) - - return {"code": 0, "data": {"message": f"测试邮件已发送到 {recipient}", "recipient": recipient}, "message": "ok"} - except smtplib.SMTPAuthenticationError as e: - logger.error(f"SMTP认证失败: {e}") - raise HTTPException(status_code=400, detail=f"SMTP认证失败,请检查用户名和密码: {str(e)}") - except smtplib.SMTPConnectError as e: - logger.error(f"SMTP连接失败: {e}") - raise HTTPException(status_code=400, detail=f"无法连接到SMTP服务器 {config.smtp_host}:{config.smtp_port}: {str(e)}") - except smtplib.SMTPException as e: - logger.error(f"SMTP发送失败: {e}") - raise HTTPException(status_code=500, detail=f"邮件发送失败(SMTP): {str(e)}") - except OSError as e: - logger.error(f"邮件发送网络错误: {e}") - raise HTTPException(status_code=500, detail=f"网络连接错误: {str(e)}") - except BaseException as e: - logger.error(f"测试邮件发送失败(严重): {type(e).__name__}: {e}") - import traceback - traceback.print_exc() - raise HTTPException(status_code=500, detail=f"邮件发送失败: {str(e)}") - - -@router.post("/email/generate-content") -async def generate_email_content( - topic: str = Query(default="测试邮件"), - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """AI生成测试邮件内容:调用默认LLM生成邮件主题和正文""" - import httpx as httpx_lib - subjects = [ - "【测试】AIFlow邮件服务配置验证", - "AIFlow平台 - 邮件投递测试", - "测试邮件:您的SMTP服务已就绪", - ] - bodies = [ - f"您好!\n\n这是一封来自 AIFlow 智能自动化平台的测试邮件,用于验证邮件服务配置是否正常。\n\n如果您收到此邮件,说明SMTP邮件服务已配置成功。\n\n此致\nAIFlow 平台团队", - f"Hi,\n\nThis is a test email from AIFlow Platform.\n\nIf you received this, the SMTP service works fine.\n\nBest regards,\nAIFlow Team", - f"【系统通知】\n邮件服务连通性测试通过。\n\n发件服务器配置正确,能够成功投递邮件。\n\n—— AIFlow", - ] - - # 尝试使用默认LLM生成更好的内容 - try: - default_model = db.query(AIModel).filter( - AIModel.is_default == True, AIModel.is_enabled == True - ).first() - if default_model and default_model.endpoint: - async with httpx_lib.AsyncClient(timeout=15) as client: - headers = {} - if default_model.api_key: - headers["Authorization"] = f"Bearer {default_model.api_key}" - resp = await client.post( - default_model.endpoint, - json={ - "model": default_model.name, - "messages": [ - {"role": "system", "content": "你是一个专业的技术文档撰写者。请根据主题生成一封测试邮件的主题和正文。返回JSON格式: {\"subject\": \"邮件主题\", \"body\": \"邮件正文\"}"}, - {"role": "user", "content": f"主题: {topic}"}, - ], - "max_tokens": 400, - }, - headers=headers, - ) - if resp.status_code == 200: - content = resp.json().get("choices", [{}])[0].get("message", {}).get("content", "") - try: - data = json.loads(content) - if data.get("subject") and data.get("body"): - return {"subject": data["subject"], "body": data["body"], "source": "ai"} - except: - # 尝试解析非JSON格式 - lines = content.strip().split("\n") - if len(lines) >= 2: - return {"subject": lines[0].replace("主题:", "").strip(), "body": "\n".join(lines[1:]).replace("正文:", "").strip(), "source": "ai_partial"} - except Exception: - pass - - import random - idx = random.randint(0, len(subjects) - 1) - return {"subject": subjects[idx], "body": bodies[idx], "source": "fallback"} - - -# ============================================================ -# 短信测试 -# ============================================================ - -def _aliyun_sign(secret: str, params: dict, method: str = "GET") -> str: - """阿里云签名机制 (HMAC-SHA1)""" - sorted_keys = sorted(params.keys()) - qs = "&".join([ - f"{urllib.parse.quote(k, safe='')}={urllib.parse.quote(str(params[k]), safe='')}" - for k in sorted_keys - ]) - string_to_sign = f"{method}&{urllib.parse.quote('/', safe='')}&{urllib.parse.quote(qs, safe='')}" - key = f"{secret}&" - h = hmac.new(key.encode('utf-8'), string_to_sign.encode('utf-8'), hashlib.sha1) - return base64.b64encode(h.digest()).decode('utf-8') - - -# 短信宝 (SMSBao) 辅助 -def _smsbao_md5(password: str) -> str: - """短信宝密码:登录密码的 MD5(32位,不区分大小写)""" - return hashlib.md5(password.encode("utf-8")).hexdigest() - -SMSBAO_ERRORS = { - "0": "发送成功", - "30": "密码错误", - "40": "账号不存在", - "41": "余额不足", - "43": "IP地址限制", - "50": "内容含有敏感词", - "51": "手机号码不正确", -} - - -@router.post("/sms/test") -async def test_sms_config( - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """使用当前配置发送测试短信""" - config = db.query(SmsConfig).first() - if not config: - raise HTTPException(status_code=400, detail="短信配置未设置,请先保存短信配置") - - if not config.api_key or not config.api_secret: - raise HTTPException( - status_code=400, - detail="未配置阿里云短信 AccessKey,请填写 API Key (AccessKey ID) 和 API Secret (AccessKey Secret)" - ) - - if not config.sign_name: - raise HTTPException(status_code=400, detail="未配置短信签名") - - # 获取接收测试短信的手机号(管理员手机号) - admin = db.query(User).filter(User.id == admin_user.id).first() - phone = admin.phone if admin and admin.phone else None - if not phone: - raise HTTPException(status_code=400, detail="管理员未设置手机号,无法发送测试短信") - - logger.info(f"测试短信: provider={config.provider}, sign={config.sign_name}, phone={phone}") - - # 短信宝 (SMSBao) - if config.provider == "短信宝": - if not config.username or not config.password: - raise HTTPException(status_code=400, detail="未配置短信宝用户名或密码,请先在配置中填写") - sign = config.sign_name or "" - if "【" not in sign: - sign = f"【{sign}】" if sign else "【七虎AI】" - content = f"{sign}测试短信:您的验证码是123456,若非本人操作请忽略。" - pwd_md5 = _smsbao_md5(config.password) - params = {"u": config.username, "p": pwd_md5, "m": phone, "c": content} - if config.goods_id: - params["g"] = config.goods_id - import requests as http_requests - try: - resp = http_requests.get( - "https://api.smsbao.com/sms", params=params, timeout=10, - proxies={"http": None, "https": None}, - ) - code = resp.text.strip().split("\n")[0] - except Exception as e: - logger.error(f"短信宝请求失败: {e}") - raise HTTPException(status_code=500, detail=f"短信宝请求失败: {str(e)}") - if code == "0": - LogService.log_audit( - admin_id=admin_user.id, admin_name=admin_user.username, - action="test_sms_config", target_type="sms_config", - detail=f"短信宝测试短信发送成功 -> {phone}", - ) - return {"message": f"测试短信已发送到 {phone}", "phone": phone} - msg = SMSBAO_ERRORS.get(code, f"未知错误码 {code}") - raise HTTPException(status_code=400, detail=f"短信宝发送失败: {msg} (code={code})") - - # 只有阿里云短信支持测试 - if "阿里云" not in (config.provider or ""): - raise HTTPException( - status_code=400, - detail=f"当前短信服务商 '{config.provider}' 暂不支持在线测试,请直接在生产环境测试" - ) - - try: - # 阿里云短信参数 - params = { - "AccessKeyId": config.api_key, - "Action": "SendSms", - "Format": "JSON", - "PhoneNumbers": phone, - "RegionId": "cn-hangzhou", - "SignName": config.sign_name, - "SignatureMethod": "HMAC-SHA1", - "SignatureNonce": str(uuid.uuid4()), - "SignatureVersion": "1.0", - "TemplateCode": config.template_code or "SMS_00000001", - "TemplateParam": '{"code":"123456"}', - "Timestamp": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), - "Version": "2017-05-25", - } - params["Signature"] = _aliyun_sign(config.api_secret, params) - - import requests as http_requests - resp = http_requests.post( - "https://dysmsapi.aliyuncs.com/", - data=params, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - timeout=10, - ) - result = resp.json() - - logger.info(f"阿里云短信API响应: {result}") - - if result.get("Code") == "OK": - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="test_sms_config", - target_type="sms_config", - detail=f"测试短信发送成功 -> {phone}", - ) - return {"message": f"测试短信已发送到 {phone}", "phone": phone, "biz_id": result.get("BizId")} - else: - error_msg = result.get("Message", result.get("Code", "未知错误")) - raise HTTPException(status_code=400, detail=f"阿里云短信发送失败: {error_msg}") - - except HTTPException: - raise - except Exception as e: - logger.error(f"测试短信发送失败: {e}") - raise HTTPException(status_code=500, detail=f"短信发送失败: {str(e)}") - - -# ============================================================ -# 短信宝余额查询 -# ============================================================ - -@router.post("/sms/balance") -async def query_sms_balance( - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """查询短信宝账户余额(发送条数, 剩余条数)""" - config = db.query(SmsConfig).first() - if not config or config.provider != "短信宝": - raise HTTPException(status_code=400, detail="仅短信宝支持余额查询,请先选择短信宝并保存账号") - if not config.username or not config.password: - raise HTTPException(status_code=400, detail="未配置短信宝用户名或密码") - - import requests as http_requests - pwd_md5 = _smsbao_md5(config.password) - try: - resp = http_requests.get( - "https://www.smsbao.com/query", - params={"u": config.username, "p": pwd_md5}, - timeout=10, - proxies={"http": None, "https": None}, - ) - lines = resp.text.strip().split("\n") - except Exception as e: - logger.error(f"短信宝余额查询请求失败: {e}") - raise HTTPException(status_code=500, detail=f"短信宝余额查询失败: {str(e)}") - - code = lines[0].strip() if lines else "" - if code != "0": - msg = SMSBAO_ERRORS.get(code, f"未知错误码 {code}") - raise HTTPException(status_code=400, detail=f"查询失败: {msg}") - - sent = remain = 0 - if len(lines) >= 2: - parts = lines[1].split(",") - try: - sent = int(parts[0]) - except (ValueError, IndexError): - sent = 0 - try: - remain = int(parts[1]) - except (ValueError, IndexError): - remain = 0 - return {"code": 0, "data": {"sent": sent, "remain": remain}, "message": "ok"} +""" +网站基础设置 API(管理员) +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from ...database import get_db, SessionLocal +from ...models import SiteSetting, EmailConfig, SmsConfig, User, AIModel +from ...auth import get_current_admin +from ...middleware.log_service import LogService +import uuid +import smtplib +import hmac +import hashlib +import base64 +import urllib.parse +import time as time_module +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +from datetime import datetime +from pydantic import BaseModel +from typing import Optional, Dict, Any +import logging + +logger = logging.getLogger("aiflow") + +router = APIRouter() + + +# ============================================================ +# Pydantic Models +# ============================================================ + +class EmailConfigUpdate(BaseModel): + smtp_host: Optional[str] = None + smtp_port: Optional[int] = None + username: Optional[str] = None + password: Optional[str] = None + from_address: Optional[str] = None + use_tls: Optional[bool] = None + + +class SmsConfigUpdate(BaseModel): + provider: Optional[str] = None + api_key: Optional[str] = None + api_secret: Optional[str] = None + sign_name: Optional[str] = None + template_code: Optional[str] = None + # 短信宝 (SMSBao) + username: Optional[str] = None + password: Optional[str] = None + goods_id: Optional[str] = None + + +# ============================================================ +# 网站设置 +# ============================================================ + +@router.get("/site") +async def get_site_settings( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取网站设置(返回所有 key-value 对)""" + settings = db.query(SiteSetting).all() + result = {} + for s in settings: + result[s.key] = s.value + return {"code": 0, "data": result, "message": "ok"} + + +@router.put("/site") +async def update_site_settings( + settings: dict, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """批量更新网站设置""" + for key, value in settings.items(): + existing = db.query(SiteSetting).filter(SiteSetting.key == key).first() + if existing: + existing.value = str(value) if value is not None else None + existing.updated_at = datetime.now() + else: + new_setting = SiteSetting( + id=str(uuid.uuid4()), + key=key, + value=str(value) if value is not None else None, + ) + db.add(new_setting) + + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="update_site_settings", + target_type="site_settings", + detail=f"更新网站设置: {list(settings.keys())}", + ) + + return {"message": "网站设置已更新"} + + +# ============================================================ +# 邮件配置 +# ============================================================ + +@router.get("/email") +async def get_email_config( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取邮件配置""" + config = db.query(EmailConfig).first() + if not config: + return {"code": 0, "data": { + "smtp_host": "", + "smtp_port": 587, + "username": "", + "password": "", + "from_address": "", + "use_tls": True, + }, "message": "ok"} + + return {"code": 0, "data": { + "id": config.id, + "smtp_host": config.smtp_host, + "smtp_port": config.smtp_port, + "username": config.username, + "password": config.password, + "from_address": config.from_address, + "use_tls": config.use_tls, + }, "message": "ok"} + + +@router.put("/email") +async def update_email_config( + req: EmailConfigUpdate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """更新邮件配置""" + config = db.query(EmailConfig).first() + if not config: + # 必填字段校验:不允许空值 + if not req.smtp_host: + raise HTTPException(status_code=400, detail="SMTP服务器地址不能为空") + if not req.username: + raise HTTPException(status_code=400, detail="SMTP用户名不能为空") + if not req.password: + raise HTTPException(status_code=400, detail="SMTP密码不能为空") + if not req.from_address: + raise HTTPException(status_code=400, detail="发件地址不能为空") + config = EmailConfig( + id=str(uuid.uuid4()), + smtp_host=req.smtp_host or "", + smtp_port=req.smtp_port or 587, + username=req.username or "", + password=req.password or "", + from_address=req.from_address or "", + use_tls=req.use_tls if req.use_tls is not None else True, + ) + db.add(config) + else: + if req.smtp_host is not None: + if not req.smtp_host.strip(): + raise HTTPException(status_code=400, detail="SMTP服务器地址不能为空") + config.smtp_host = req.smtp_host + if req.smtp_port is not None: + config.smtp_port = req.smtp_port + if req.username is not None: + if not req.username.strip(): + raise HTTPException(status_code=400, detail="SMTP用户名不能为空") + config.username = req.username + if req.password is not None: + if not req.password.strip(): + raise HTTPException(status_code=400, detail="SMTP密码不能为空") + config.password = req.password + if req.from_address is not None: + if not req.from_address.strip(): + raise HTTPException(status_code=400, detail="发件地址不能为空") + config.from_address = req.from_address + if req.use_tls is not None: + config.use_tls = req.use_tls + config.updated_at = datetime.now() + + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="update_email_config", + target_type="email_config", + detail="更新邮件配置", + ) + + return {"code": 0, "data": None, "message": "邮件配置已更新"} + + +# ============================================================ +# 短信配置 +# ============================================================ + +@router.get("/sms") +async def get_sms_config( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取短信配置""" + config = db.query(SmsConfig).first() + if not config: + return {"code": 0, "data": { + "provider": "", + "api_key": "", + "api_secret": "", + "sign_name": "", + "template_code": "", + "username": "", + "password": "", + "goods_id": "", + }, "message": "ok"} + + return {"code": 0, "data": { + "id": config.id, + "provider": config.provider, + "api_key": config.api_key, + "api_secret": config.api_secret, + "sign_name": config.sign_name, + "template_code": config.template_code, + "username": config.username, + "password": config.password, + "goods_id": config.goods_id, + }, "message": "ok"} + + +@router.put("/sms") +async def update_sms_config( + req: SmsConfigUpdate, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """更新短信配置""" + config = db.query(SmsConfig).first() + if not config: + config = SmsConfig( + id=str(uuid.uuid4()), + provider=req.provider or "", + api_key=req.api_key or "", + api_secret=req.api_secret or "", + sign_name=req.sign_name, + template_code=req.template_code, + username=req.username, + password=req.password, + goods_id=req.goods_id, + ) + db.add(config) + else: + if req.provider is not None: + config.provider = req.provider + if req.api_key is not None: + config.api_key = req.api_key + if req.api_secret is not None: + config.api_secret = req.api_secret + if req.sign_name is not None: + config.sign_name = req.sign_name + if req.template_code is not None: + config.template_code = req.template_code + if req.username is not None: + config.username = req.username + if req.password is not None: + config.password = req.password + if req.goods_id is not None: + config.goods_id = req.goods_id + config.updated_at = datetime.now() + + db.commit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="update_sms_config", + target_type="sms_config", + detail="更新短信配置", + ) + + return {"message": "短信配置已更新"} + + +# ============================================================ +# 邮件测试 +# ============================================================ + +@router.post("/email/test") +async def test_email_config( + test_to: str = Query(default=None, description="自定义测试收件地址"), + test_subject: str = Query(default=None, description="自定义测试邮件主题"), + test_body: str = Query(default=None, description="自定义测试邮件正文"), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """使用当前配置发送测试邮件;可传参自定义收件人、主题和正文""" + config = db.query(EmailConfig).first() + if not config or not config.smtp_host: + raise HTTPException(status_code=400, detail="邮件配置未设置,请先保存邮件配置") + if not config.username: + raise HTTPException(status_code=400, detail="SMTP用户名未填写,请检查邮件配置") + if not config.password: + raise HTTPException(status_code=400, detail="SMTP密码未填写,请检查邮件配置") + if not config.from_address: + raise HTTPException(status_code=400, detail="发件地址未填写,请检查邮件配置") + + # 优先级:传入参数 > 管理员邮箱 > 发件地址 + admin = db.query(User).filter(User.id == admin_user.id).first() + recipient = test_to or (admin.email if admin and admin.email else None) or config.from_address + if not recipient: + raise HTTPException(status_code=400, detail="无法获取接收地址: 请填写收件邮箱或设置管理员邮箱") + + subject = test_subject or "AIFlow 邮件配置测试" + body = test_body or f"这是一封来自 AIFlow 平台的测试邮件。\n\n发送时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\nSMTP服务器: {config.smtp_host}" + + logger.info(f"测试邮件: SMTP={config.smtp_host}:{config.smtp_port}, " + f"from={config.from_address}, to={recipient}") + + try: + msg = MIMEMultipart() + msg["From"] = config.from_address + msg["To"] = recipient + msg["Subject"] = subject + msg.attach(MIMEText(body, "plain")) + + if config.use_tls: + server = smtplib.SMTP(config.smtp_host, config.smtp_port, timeout=10) + server.starttls() + else: + server = smtplib.SMTP(config.smtp_host, config.smtp_port, timeout=10) + + server.login(config.username, config.password) + server.sendmail(config.from_address, recipient, msg.as_string()) + server.quit() + + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="test_email_config", + target_type="email_config", + detail=f"测试邮件发送成功 -> {recipient} via {config.smtp_host}", + ) + + return {"code": 0, "data": {"message": f"测试邮件已发送到 {recipient}", "recipient": recipient}, "message": "ok"} + except smtplib.SMTPAuthenticationError as e: + logger.error(f"SMTP认证失败: {e}") + raise HTTPException(status_code=400, detail=f"SMTP认证失败,请检查用户名和密码: {str(e)}") + except smtplib.SMTPConnectError as e: + logger.error(f"SMTP连接失败: {e}") + raise HTTPException(status_code=400, detail=f"无法连接到SMTP服务器 {config.smtp_host}:{config.smtp_port}: {str(e)}") + except smtplib.SMTPException as e: + logger.error(f"SMTP发送失败: {e}") + raise HTTPException(status_code=500, detail=f"邮件发送失败(SMTP): {str(e)}") + except OSError as e: + logger.error(f"邮件发送网络错误: {e}") + raise HTTPException(status_code=500, detail=f"网络连接错误: {str(e)}") + except BaseException as e: + logger.error(f"测试邮件发送失败(严重): {type(e).__name__}: {e}") + import traceback + traceback.print_exc() + raise HTTPException(status_code=500, detail=f"邮件发送失败: {str(e)}") + + +@router.post("/email/generate-content") +async def generate_email_content( + topic: str = Query(default="测试邮件"), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """AI生成测试邮件内容:调用默认LLM生成邮件主题和正文""" + import httpx as httpx_lib + subjects = [ + "【测试】AIFlow邮件服务配置验证", + "AIFlow平台 - 邮件投递测试", + "测试邮件:您的SMTP服务已就绪", + ] + bodies = [ + f"您好!\n\n这是一封来自 AIFlow 智能自动化平台的测试邮件,用于验证邮件服务配置是否正常。\n\n如果您收到此邮件,说明SMTP邮件服务已配置成功。\n\n此致\nAIFlow 平台团队", + f"Hi,\n\nThis is a test email from AIFlow Platform.\n\nIf you received this, the SMTP service works fine.\n\nBest regards,\nAIFlow Team", + f"【系统通知】\n邮件服务连通性测试通过。\n\n发件服务器配置正确,能够成功投递邮件。\n\n—— AIFlow", + ] + + # 尝试使用默认LLM生成更好的内容 + try: + default_model = db.query(AIModel).filter( + AIModel.is_default == True, AIModel.is_enabled == True + ).first() + if default_model and default_model.endpoint: + async with httpx_lib.AsyncClient(timeout=15) as client: + headers = {} + if default_model.api_key: + headers["Authorization"] = f"Bearer {default_model.api_key}" + resp = await client.post( + default_model.endpoint, + json={ + "model": default_model.name, + "messages": [ + {"role": "system", "content": "你是一个专业的技术文档撰写者。请根据主题生成一封测试邮件的主题和正文。返回JSON格式: {\"subject\": \"邮件主题\", \"body\": \"邮件正文\"}"}, + {"role": "user", "content": f"主题: {topic}"}, + ], + "max_tokens": 400, + }, + headers=headers, + ) + if resp.status_code == 200: + content = resp.json().get("choices", [{}])[0].get("message", {}).get("content", "") + try: + data = json.loads(content) + if data.get("subject") and data.get("body"): + return {"subject": data["subject"], "body": data["body"], "source": "ai"} + except: + # 尝试解析非JSON格式 + lines = content.strip().split("\n") + if len(lines) >= 2: + return {"subject": lines[0].replace("主题:", "").strip(), "body": "\n".join(lines[1:]).replace("正文:", "").strip(), "source": "ai_partial"} + except Exception: + pass + + import random + idx = random.randint(0, len(subjects) - 1) + return {"subject": subjects[idx], "body": bodies[idx], "source": "fallback"} + + +# ============================================================ +# 短信测试 +# ============================================================ + +def _aliyun_sign(secret: str, params: dict, method: str = "GET") -> str: + """阿里云签名机制 (HMAC-SHA1)""" + sorted_keys = sorted(params.keys()) + qs = "&".join([ + f"{urllib.parse.quote(k, safe='')}={urllib.parse.quote(str(params[k]), safe='')}" + for k in sorted_keys + ]) + string_to_sign = f"{method}&{urllib.parse.quote('/', safe='')}&{urllib.parse.quote(qs, safe='')}" + key = f"{secret}&" + h = hmac.new(key.encode('utf-8'), string_to_sign.encode('utf-8'), hashlib.sha1) + return base64.b64encode(h.digest()).decode('utf-8') + + +# 短信宝 (SMSBao) 辅助 +def _smsbao_md5(password: str) -> str: + """短信宝密码:登录密码的 MD5(32位,不区分大小写)""" + return hashlib.md5(password.encode("utf-8")).hexdigest() + +SMSBAO_ERRORS = { + "0": "发送成功", + "30": "密码错误", + "40": "账号不存在", + "41": "余额不足", + "43": "IP地址限制", + "50": "内容含有敏感词", + "51": "手机号码不正确", +} + + +@router.post("/sms/test") +async def test_sms_config( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """使用当前配置发送测试短信""" + config = db.query(SmsConfig).first() + if not config: + raise HTTPException(status_code=400, detail="短信配置未设置,请先保存短信配置") + + if not config.api_key or not config.api_secret: + raise HTTPException( + status_code=400, + detail="未配置阿里云短信 AccessKey,请填写 API Key (AccessKey ID) 和 API Secret (AccessKey Secret)" + ) + + if not config.sign_name: + raise HTTPException(status_code=400, detail="未配置短信签名") + + # 获取接收测试短信的手机号(管理员手机号) + admin = db.query(User).filter(User.id == admin_user.id).first() + phone = admin.phone if admin and admin.phone else None + if not phone: + raise HTTPException(status_code=400, detail="管理员未设置手机号,无法发送测试短信") + + logger.info(f"测试短信: provider={config.provider}, sign={config.sign_name}, phone={phone}") + + # 短信宝 (SMSBao) + if config.provider == "短信宝": + if not config.username or not config.password: + raise HTTPException(status_code=400, detail="未配置短信宝用户名或密码,请先在配置中填写") + sign = config.sign_name or "" + if "【" not in sign: + sign = f"【{sign}】" if sign else "【七虎AI】" + content = f"{sign}测试短信:您的验证码是123456,若非本人操作请忽略。" + pwd_md5 = _smsbao_md5(config.password) + params = {"u": config.username, "p": pwd_md5, "m": phone, "c": content} + if config.goods_id: + params["g"] = config.goods_id + import requests as http_requests + try: + resp = http_requests.get( + "https://api.smsbao.com/sms", params=params, timeout=10, + proxies={"http": None, "https": None}, + ) + code = resp.text.strip().split("\n")[0] + except Exception as e: + logger.error(f"短信宝请求失败: {e}") + raise HTTPException(status_code=500, detail=f"短信宝请求失败: {str(e)}") + if code == "0": + LogService.log_audit( + admin_id=admin_user.id, admin_name=admin_user.username, + action="test_sms_config", target_type="sms_config", + detail=f"短信宝测试短信发送成功 -> {phone}", + ) + return {"message": f"测试短信已发送到 {phone}", "phone": phone} + msg = SMSBAO_ERRORS.get(code, f"未知错误码 {code}") + raise HTTPException(status_code=400, detail=f"短信宝发送失败: {msg} (code={code})") + + # 只有阿里云短信支持测试 + if "阿里云" not in (config.provider or ""): + raise HTTPException( + status_code=400, + detail=f"当前短信服务商 '{config.provider}' 暂不支持在线测试,请直接在生产环境测试" + ) + + try: + # 阿里云短信参数 + params = { + "AccessKeyId": config.api_key, + "Action": "SendSms", + "Format": "JSON", + "PhoneNumbers": phone, + "RegionId": "cn-hangzhou", + "SignName": config.sign_name, + "SignatureMethod": "HMAC-SHA1", + "SignatureNonce": str(uuid.uuid4()), + "SignatureVersion": "1.0", + "TemplateCode": config.template_code or "SMS_00000001", + "TemplateParam": '{"code":"123456"}', + "Timestamp": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), + "Version": "2017-05-25", + } + params["Signature"] = _aliyun_sign(config.api_secret, params) + + import requests as http_requests + resp = http_requests.post( + "https://dysmsapi.aliyuncs.com/", + data=params, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=10, + ) + result = resp.json() + + logger.info(f"阿里云短信API响应: {result}") + + if result.get("Code") == "OK": + LogService.log_audit( + admin_id=admin_user.id, + admin_name=admin_user.username, + action="test_sms_config", + target_type="sms_config", + detail=f"测试短信发送成功 -> {phone}", + ) + return {"message": f"测试短信已发送到 {phone}", "phone": phone, "biz_id": result.get("BizId")} + else: + error_msg = result.get("Message", result.get("Code", "未知错误")) + raise HTTPException(status_code=400, detail=f"阿里云短信发送失败: {error_msg}") + + except HTTPException: + raise + except Exception as e: + logger.error(f"测试短信发送失败: {e}") + raise HTTPException(status_code=500, detail=f"短信发送失败: {str(e)}") + + +# ============================================================ +# 短信宝余额查询 +# ============================================================ + +@router.post("/sms/balance") +async def query_sms_balance( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """查询短信宝账户余额(发送条数, 剩余条数)""" + config = db.query(SmsConfig).first() + if not config or config.provider != "短信宝": + raise HTTPException(status_code=400, detail="仅短信宝支持余额查询,请先选择短信宝并保存账号") + if not config.username or not config.password: + raise HTTPException(status_code=400, detail="未配置短信宝用户名或密码") + + import requests as http_requests + pwd_md5 = _smsbao_md5(config.password) + try: + resp = http_requests.get( + "https://www.smsbao.com/query", + params={"u": config.username, "p": pwd_md5}, + timeout=10, + proxies={"http": None, "https": None}, + ) + lines = resp.text.strip().split("\n") + except Exception as e: + logger.error(f"短信宝余额查询请求失败: {e}") + raise HTTPException(status_code=500, detail=f"短信宝余额查询失败: {str(e)}") + + code = lines[0].strip() if lines else "" + if code != "0": + msg = SMSBAO_ERRORS.get(code, f"未知错误码 {code}") + raise HTTPException(status_code=400, detail=f"查询失败: {msg}") + + sent = remain = 0 + if len(lines) >= 2: + parts = lines[1].split(",") + try: + sent = int(parts[0]) + except (ValueError, IndexError): + sent = 0 + try: + remain = int(parts[1]) + except (ValueError, IndexError): + remain = 0 + return {"code": 0, "data": {"sent": sent, "remain": remain}, "message": "ok"} diff --git a/backend/python/app/api/admin/skills_admin.py b/backend/python/app/api/admin/skills_admin.py index 296cebc..baa2c96 100644 --- a/backend/python/app/api/admin/skills_admin.py +++ b/backend/python/app/api/admin/skills_admin.py @@ -1,958 +1,958 @@ -""" -技能管理系统 API(管理员) -全平台技能管理:列表/创建/编辑/删除/状态管理/版本历史/发布市场 -""" -import os -import json -import uuid -import logging -from datetime import datetime -from typing import Optional, List - -from fastapi import APIRouter, Depends, HTTPException, Query -from sqlalchemy.orm import Session -from sqlalchemy import desc, func, or_, and_, text - -from ...database import get_db -from ...models import ( - User, EvolutionSkill, SkillVersion, SkillPublishRecord, - EvolutionEvent, TeamSharedSkill, MarketApp, AppInstallation, - AppPurchase, AppReview -) -from ...auth import get_current_admin -from ...middleware.log_service import LogService -from ...config import UPLOAD_DIR - -router = APIRouter() -logger = logging.getLogger(__name__) - -# ============================================================ -# 技能状态定义 -# ============================================================ - -SKILL_STATES = { - "baby": {"label": "萌芽期", "color": "#9CA3AF", "description": "刚创建的技能,需要继续培养"}, - "main": {"label": "主力期", "color": "#10B981", "description": "已成熟可正常使用"}, - "staging": {"label": "灰度期", "color": "#F59E0B", "description": "正在灰度测试中"}, - "frozen": {"label": "已冻结", "color": "#EF4444", "description": "已停止使用"}, -} - -# 业务场景分类(与前端「应用市场」保持一致,14 类岗位/职能视角) -# 统一标准:以前台 MARKET_SCENES 为准,后台技能管理也按此 14 类展示与筛选。 -SKILL_CATEGORIES = [ - {"id": "design", "label": "🎨 做设计"}, - {"id": "frontend", "label": "💻 写前端"}, - {"id": "mobile", "label": "📱 做移动端"}, - {"id": "backend", "label": "🧪 后端/全栈"}, - {"id": "qa", "label": "🧪 测试质量"}, - {"id": "search", "label": "🔍 搜索信息"}, - {"id": "docs", "label": "✍️ 写文档"}, - {"id": "marketing", "label": "📢 推广获客"}, - {"id": "workflow", "label": "🔄 工作流"}, - {"id": "secops", "label": "🛡️ 安全运维"}, - {"id": "learning", "label": "🎓 学习教学"}, - {"id": "video", "label": "🎬 视频多媒体"}, - {"id": "collab", "label": "🏢 办公协作"}, - {"id": "ai", "label": "🛠️ AI能力"}, -] -_SCENE_IDS = {c["id"] for c in SKILL_CATEGORIES} -# 旧 9 类 taxonomy -> 14 场景 的兜底映射(仅用于历史数据 capability 缺失时) -_CATEGORY_TO_SCENE = { - "writing": "docs", "coding": "backend", "data_analysis": "backend", - "qa": "qa", "automation": "workflow", "creativity": "design", - "communication": "marketing", "reasoning": "search", "other": "workflow", -} - - -# ============================================================ -# 辅助函数 -# ============================================================ - -def market_app_to_skill_dict(app: "MarketApp") -> dict: - """将市场应用(MarketApp type=skill)转换为与 EvolutionSkill 一致的字典格式""" - status = (getattr(app, "status", None) or "pending").strip() - state = "main" if status == "published" else "baby" - raw_tags = getattr(app, "tags", None) - tags_list = [] - if raw_tags: - try: - tags_list = json.loads(raw_tags) if isinstance(raw_tags, str) else list(raw_tags) - except Exception: - tags_list = [t.strip() for t in str(raw_tags).split(",")] - # 以业务场景(capability, 14 类)为分类标准,与前端应用市场统一; - # capability 缺失时回退到旧 9 类 category 映射出的场景。 - raw_scene = getattr(app, "capability", None) - if raw_scene in _SCENE_IDS: - category = raw_scene - else: - category = _CATEGORY_TO_SCENE.get(getattr(app, "category", None), "workflow") - return { - "id": app.id, - "user_id": getattr(app, "publisher_id", None) or "system", - "skill_name": app.name, - "description": getattr(app, "description", None) or "", - "tags": tags_list, - "category": category, - "category_label": next((c["label"] for c in SKILL_CATEGORIES if c["id"] == category), category or "其他"), - "state": state, - "state_info": SKILL_STATES.get(state, SKILL_STATES["baby"]), - "use_count": getattr(app, "install_count", None) or 0, - "version": 1, - "skill_md_path": None, - "git_commit_sha": None, - "last_used_at": None, - "created_at": app.created_at.isoformat() if getattr(app, "created_at", None) else None, - "updated_at": getattr(app, "updated_at", None).isoformat() if getattr(app, "updated_at", None) else None, - "source": "market", - # 让前端 isPublished(skill) 能正确识别 market 已发布项 - "publish_records": [{"status": "published"}] if status == "published" else [], - } - - -def skill_to_dict(s: EvolutionSkill, include_md: bool = False) -> dict: - """将技能ORM对象转换为字典""" - result = { - "id": s.id, - "user_id": s.user_id, - "skill_name": s.skill_name, - "description": s.description, - "tags": s.tags or [], - "category": s.category, - "category_label": next((c["label"] for c in SKILL_CATEGORIES if c["id"] == s.category), s.category or "其他"), - "state": s.state, - "state_info": SKILL_STATES.get(s.state, SKILL_STATES["baby"]), - "use_count": s.use_count or 0, - "version": s.version or 1, - "skill_md_path": s.skill_md_path, - "git_commit_sha": s.git_commit_sha, - "last_used_at": s.last_used_at.isoformat() if s.last_used_at else None, - "created_at": s.created_at.isoformat() if s.created_at else None, - "updated_at": s.updated_at.isoformat() if s.updated_at else None, - } - - # 读取 SKILL.md 内容 - if include_md and s.skill_md_path and os.path.exists(s.skill_md_path): - try: - with open(s.skill_md_path, 'r', encoding='utf-8') as f: - result["skill_md_content"] = f.read() - except Exception: - result["skill_md_content"] = None - else: - result["skill_md_content"] = None - - return result - - -def version_to_dict(v: SkillVersion) -> dict: - return { - "id": v.id, - "user_id": v.user_id, - "skill_name": v.skill_name, - "version": v.version, - "skill_md_content": v.skill_md_content, - "git_commit_sha": v.git_commit_sha, - "description": v.description, - "created_at": v.created_at.isoformat() if v.created_at else None, - } - - -# ============================================================ -# 管理员技能列表 -# ============================================================ - -@router.get("/") -async def list_skills( - state: str = Query(None, description="技能状态过滤: baby/main/staging/frozen"), - category: str = Query(None, description="技能分类过滤"), - keyword: str = Query(None, description="关键词搜索(名称/描述)"), - user_id: str = Query(None, description="按用户ID筛选"), - sort_by: str = Query("updated_at", description="排序字段"), - order: str = Query("desc", description="排序方向: asc/desc"), - page: int = Query(1, ge=1, description="页码"), - page_size: int = Query(20, ge=1, le=100, description="每页数量"), - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取全平台技能列表(支持分页/过滤/搜索)—— 同时查询 EvolutionSkill + MarketApp(type=skill)""" - logger.info(f"[Admin Skills] {admin_user.username} 列出技能: state={state}, keyword={keyword}") - - result: list = [] - - # ===== 1) 查询 EvolutionSkill ===== - evo_query = db.query(EvolutionSkill) - if state: - evo_query = evo_query.filter(EvolutionSkill.state == state) - if category: - evo_query = evo_query.filter(EvolutionSkill.category == category) - if keyword: - evo_query = evo_query.filter( - or_(EvolutionSkill.skill_name.contains(keyword), EvolutionSkill.description.contains(keyword)) - ) - if user_id: - evo_query = evo_query.filter(EvolutionSkill.user_id == user_id) - - evo_skills = evo_query.all() - user_ids = list(set(s.user_id for s in evo_skills)) - users_map = {u.id: u.username for u in db.query(User.id, User.username).filter(User.id.in_(user_ids)).all()} - for s in evo_skills: - item = skill_to_dict(s) - item["owner_username"] = users_map.get(s.user_id, s.user_id) - item["source"] = "evolution" - result.append(item) - - # ===== 2) 查询 MarketApp (type=skill) ===== - mkt_query = db.query(MarketApp).filter(MarketApp.type == "skill") - if keyword: - mkt_query = mkt_query.filter( - or_(MarketApp.name.contains(keyword), MarketApp.description.contains(keyword)) - ) - if category: - # 分类筛选统一按业务场景(capability, 14 类)进行,与前端应用市场一致 - mkt_query = mkt_query.filter(MarketApp.capability == category) - # 状态映射:主面板只展示已发布 → state='main';草稿/移除 → 'baby' - if state == "main": - mkt_query = mkt_query.filter(MarketApp.status == "published") - elif state: - mkt_query = mkt_query.filter(MarketApp.status != "published") # 非发布=草稿等 - - market_apps_rows = mkt_query.all() - for app in market_apps_rows: - item = market_app_to_skill_dict(app) - item["owner_username"] = app.publisher_name or "system" - result.append(item) - - # ===== 3) 合并排序 & 分页 ===== - total = len(result) - reverse = order == "desc" - key_fn = lambda x: x.get(sort_by) or "" - result.sort(key=key_fn, reverse=reverse) - - start = (page - 1) * page_size - end = start + page_size - paged = result[start:end] - - return { - "code": 0, - "data": { - "items": paged, - "total": total, - "page": page, - "page_size": page_size, - "pages": (total + page_size - 1) // page_size if total else 0, - } - } - - -# ============================================================ -# 获取单个技能详情 -# ============================================================ - -@router.get("/{skill_id}") -async def get_skill( - skill_id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取技能详情(包含 SKILL.md 内容)""" - skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() - if not skill: - raise HTTPException(status_code=404, detail="技能不存在") - - result = skill_to_dict(skill, include_md=True) - - # 获取所属用户信息 - user = db.query(User).filter(User.id == skill.user_id).first() - if user: - result["owner_username"] = user.username - result["owner_email"] = user.email - - # 获取发布记录 - pub_records = db.query(SkillPublishRecord).filter( - SkillPublishRecord.skill_id == skill_id - ).order_by(desc(SkillPublishRecord.created_at)).limit(10).all() - result["publish_records"] = [{ - "id": p.id, - "price": float(p.price) if p.price else 0, - "market_description": p.market_description, - "status": p.status, - "published_at": p.published_at.isoformat() if p.published_at else None, - "created_at": p.created_at.isoformat() if p.created_at else None, - } for p in pub_records] - - # 获取最新版本号 - latest_version = db.query(func.max(SkillVersion.version)).filter( - SkillVersion.skill_id == skill_id - ).scalar() - result["latest_version"] = latest_version or skill.version - - return {"code": 0, "data": result} - - -# ============================================================ -# 创建技能 -# ============================================================ - -class CreateSkillRequest: - def __init__(self, skill_name: str = None, description: str = None, - tags: List[str] = None, category: str = None, - skill_md_content: str = None, user_id: str = None): - self.skill_name = skill_name - self.description = description - self.tags = tags or [] - self.category = category - self.skill_md_content = skill_md_content - self.user_id = user_id - - -from pydantic import BaseModel, Field - - -class CreateSkillSchema(BaseModel): - skill_name: str = Field(..., min_length=1, max_length=200, description="技能名称") - user_id: str = Field(..., description="所属用户ID") - description: Optional[str] = Field(None, description="技能描述") - tags: Optional[List[str]] = Field(default_factory=list, description="标签列表") - category: Optional[str] = Field(None, description="技能分类") - skill_md_content: Optional[str] = Field(None, description="SKILL.md 内容") - - -@router.post("/") -async def create_skill( - req: CreateSkillSchema, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """管理员创建技能""" - logger.info(f"[Admin Skills] {admin_user.username} 创建技能: {req.skill_name}") - - # 检查技能名是否已存在 - existing = db.query(EvolutionSkill).filter( - EvolutionSkill.user_id == req.user_id, - EvolutionSkill.skill_name == req.skill_name - ).first() - if existing: - raise HTTPException(status_code=400, detail="该用户已存在同名技能") - - # 验证用户存在 - user = db.query(User).filter(User.id == req.user_id).first() - if not user: - raise HTTPException(status_code=400, detail="用户不存在") - - # 生成技能ID - skill_id = str(uuid.uuid4()) - - # 保存 SKILL.md 文件 - skill_md_path = None - if req.skill_md_content: - skill_dir = os.path.join(UPLOAD_DIR, "skills", req.user_id) - os.makedirs(skill_dir, exist_ok=True) - safe_name = req.skill_name.replace("/", "_").replace("\\", "_") - skill_md_path = os.path.join(skill_dir, f"{safe_name}.md") - try: - with open(skill_md_path, 'w', encoding='utf-8') as f: - f.write(req.skill_md_content) - except Exception as e: - logger.warning(f"保存 SKILL.md 失败: {e}") - - # 创建技能记录 - skill = EvolutionSkill( - id=skill_id, - user_id=req.user_id, - skill_name=req.skill_name, - description=req.description, - tags=req.tags or [], - category=req.category, - state="baby", - use_count=0, - version=1, - skill_md_path=skill_md_path, - git_commit_sha=None, - ) - db.add(skill) - - # 记录初始版本 - if req.skill_md_content: - sv = SkillVersion( - id=str(uuid.uuid4()), - user_id=req.user_id, - skill_name=req.skill_name, - version=1, - skill_md_content=req.skill_md_content, - git_commit_sha=None, - description="初始版本", - ) - db.add(sv) - - # 记录事件 - ev = EvolutionEvent( - id=str(uuid.uuid4()), - user_id=req.user_id, - skill_name=req.skill_name, - event_type="created", - description=f"管理员 {admin_user.username} 创建技能 {req.skill_name}", - version_before=None, - version_after=1, - ) - db.add(ev) - - db.commit() - db.refresh(skill) - - LogService.log_operation( - user_id=admin_user.id, - username=admin_user.username, - action="create_skill", - target="evolution_skills", - detail=f"创建技能 {req.skill_name}(归属用户: {user.username})", - ) - - return {"code": 0, "data": skill_to_dict(skill, include_md=True)} - - -# ============================================================ -# 更新技能 -# ============================================================ - -class UpdateSkillSchema(BaseModel): - skill_name: Optional[str] = Field(None, min_length=1, max_length=200) - description: Optional[str] = None - tags: Optional[List[str]] = None - category: Optional[str] = None - skill_md_content: Optional[str] = None - - -@router.put("/{skill_id}") -async def update_skill( - skill_id: str, - req: UpdateSkillSchema, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """更新技能信息""" - skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() - if not skill: - raise HTTPException(status_code=404, detail="技能不存在") - - old_version = skill.version - update_data = req.model_dump(exclude_none=True) - - # 处理 SKILL.md 内容 - if "skill_md_content" in update_data: - content = update_data.pop("skill_md_content") - if content is not None: - skill_dir = os.path.join(UPLOAD_DIR, "skills", skill.user_id) - os.makedirs(skill_dir, exist_ok=True) - safe_name = skill.skill_name.replace("/", "_").replace("\\", "_") - skill_md_path = os.path.join(skill_dir, f"{safe_name}.md") - try: - with open(skill_md_path, 'w', encoding='utf-8') as f: - f.write(content) - skill.skill_md_path = skill_md_path - except Exception as e: - logger.warning(f"保存 SKILL.md 失败: {e}") - - # 记录新版本 - new_version = old_version + 1 - sv = SkillVersion( - id=str(uuid.uuid4()), - user_id=skill.user_id, - skill_name=skill.skill_name, - version=new_version, - skill_md_content=content, - git_commit_sha=None, - description="管理员更新", - ) - db.add(sv) - skill.version = new_version - - # 记录事件 - ev = EvolutionEvent( - id=str(uuid.uuid4()), - user_id=skill.user_id, - skill_name=skill.skill_name, - event_type="edited", - description=f"管理员 {admin_user.username} 更新技能 {skill.skill_name}", - version_before=old_version, - version_after=new_version, - ) - db.add(ev) - - # 更新其他字段 - for key, value in update_data.items(): - if key not in ("skill_md_content",) and hasattr(skill, key): - setattr(skill, key, value) - - skill.updated_at = datetime.now() - db.commit() - db.refresh(skill) - - LogService.log_operation( - user_id=admin_user.id, - username=admin_user.username, - action="update_skill", - target="evolution_skills", - detail=f"更新技能 {skill.skill_name}(版本 {old_version} → {skill.version})", - ) - - return {"code": 0, "data": skill_to_dict(skill, include_md=True)} - - -# ============================================================ -# 批量删除技能(用于一次性清空技能市场) -# ============================================================ - - -class BatchDeleteSkillsRequest(BaseModel): - ids: list[str] = [] # 要删除的技能 ID 列表;传空列表则删除所有 - - -@router.post("/batch-delete") -async def batch_delete_skills( - req: BatchDeleteSkillsRequest, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """批量删除技能。ids 为空则删除全部技能。""" - if req.ids: - skills = db.query(EvolutionSkill).filter(EvolutionSkill.id.in_(req.ids)).all() - else: - skills = db.query(EvolutionSkill).all() - - deleted_count = 0 - for skill in skills: - skill_name = skill.skill_name - # 删除版本历史 - db.query(SkillVersion).filter(SkillVersion.skill_name == skill_name).delete() - # 删除发布记录 - db.query(SkillPublishRecord).filter(SkillPublishRecord.skill_id == skill.id).delete() - # 删除团队共享 - db.query(TeamSharedSkill).filter(TeamSharedSkill.skill_id == skill.id).delete() - # 同步清理市场应用表中的技能 - market_app = db.query(MarketApp).filter( - MarketApp.type == "skill", - MarketApp.name == skill_name, - ).first() - if market_app: - # 清理关联的安装和购买记录 - db.query(AppInstallation).filter(AppInstallation.app_id == market_app.id).delete() - db.query(AppPurchase).filter(AppPurchase.app_id == market_app.id).delete() - db.query(AppReview).filter(AppReview.app_id == market_app.id).delete() - db.delete(market_app) - db.delete(skill) - deleted_count += 1 - - db.commit() - return {"code": 0, "data": {"deleted": deleted_count}, "message": f"已删除 {deleted_count} 个技能"} - - -# ============================================================ -# 删除技能(单个) -# ============================================================ - -@router.delete("/{skill_id}") -async def delete_skill( - skill_id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """删除技能(支持 EvolutionSkill 和 MarketApp 两种来源)""" - # 1) 先尝试 EvolutionSkill - skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() - if skill: - skill_name = skill.skill_name - user_id = skill.user_id - - # 记录事件 - ev = EvolutionEvent( - id=str(uuid.uuid4()), - user_id=user_id, - skill_name=skill_name, - event_type="deleted", - description=f"管理员 {admin_user.username} 删除技能 {skill_name}", - ) - db.add(ev) - - # 删除版本历史 - db.query(SkillVersion).filter(SkillVersion.skill_name == skill_name).delete() - - # 删除发布记录 - db.query(SkillPublishRecord).filter(SkillPublishRecord.skill_id == skill_id).delete() - - # 删除团队共享记录 - db.query(TeamSharedSkill).filter(TeamSharedSkill.skill_id == skill_id).delete() - - # 同步清理市场应用表中的同名技能 - market_app = db.query(MarketApp).filter( - MarketApp.type == "skill", - MarketApp.name == skill_name, - ).first() - if market_app: - db.query(AppInstallation).filter(AppInstallation.app_id == market_app.id).delete() - db.query(AppPurchase).filter(AppPurchase.app_id == market_app.id).delete() - db.query(AppReview).filter(AppReview.app_id == market_app.id).delete() - db.delete(market_app) - - db.delete(skill) - db.commit() - - LogService.log_operation( - user_id=admin_user.id, - username=admin_user.username, - action="delete_skill", - target="evolution_skills", - detail=f"删除技能 {skill_name}", - ) - - return {"code": 0, "message": f"技能 {skill_name} 已删除"} - - # 2) 再尝试 MarketApp 中导入的技能(type=skill) - market_app = db.query(MarketApp).filter( - MarketApp.type == "skill", - MarketApp.id == skill_id, - ).first() - if market_app: - skill_name = market_app.name - # 清理关联记录 - db.query(AppInstallation).filter(AppInstallation.app_id == market_app.id).delete() - db.query(AppPurchase).filter(AppPurchase.app_id == market_app.id).delete() - db.query(AppReview).filter(AppReview.app_id == market_app.id).delete() - db.delete(market_app) - db.commit() - - LogService.log_operation( - user_id=admin_user.id, - username=admin_user.username, - action="delete_skill", - target="market_apps", - detail=f"删除市场技能 {skill_name}", - ) - - return {"code": 0, "message": f"技能 {skill_name} 已删除"} - - # 3) 都未找到 - raise HTTPException(status_code=404, detail="技能不存在") - - -# ============================================================ -# 修改技能状态 -# ============================================================ - -class ChangeStateSchema(BaseModel): - state: str = Field(..., description="目标状态: baby/main/staging/frozen") - - -@router.put("/{skill_id}/state") -async def change_skill_state( - skill_id: str, - req: ChangeStateSchema, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """修改技能状态""" - if req.state not in SKILL_STATES: - raise HTTPException(status_code=400, detail=f"无效状态,可选值: {list(SKILL_STATES.keys())}") - - skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() - if not skill: - raise HTTPException(status_code=404, detail="技能不存在") - - old_state = skill.state - skill.state = req.state - skill.updated_at = datetime.now() - - # 记录状态变更事件 - ev_type = "promoted" if req.state in ("main", "staging") else "frozen" if req.state == "frozen" else "unfrozen" - ev = EvolutionEvent( - id=str(uuid.uuid4()), - user_id=skill.user_id, - skill_name=skill.skill_name, - event_type=ev_type, - description=f"管理员 {admin_user.username} 将技能状态从 {old_state} 变更为 {req.state}", - ) - db.add(ev) - db.commit() - db.refresh(skill) - - LogService.log_operation( - user_id=admin_user.id, - username=admin_user.username, - action="change_skill_state", - target="evolution_skills", - detail=f"修改技能 {skill.skill_name} 状态: {old_state} → {req.state}", - ) - - return {"code": 0, "data": skill_to_dict(skill)} - - -# ============================================================ -# 获取版本历史 -# ============================================================ - -@router.get("/{skill_id}/versions") -async def get_skill_versions( - skill_id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取技能版本历史""" - skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() - if not skill: - raise HTTPException(status_code=404, detail="技能不存在") - - versions = db.query(SkillVersion).filter( - SkillVersion.skill_name == skill.skill_name, - SkillVersion.user_id == skill.user_id, - ).order_by(desc(SkillVersion.version)).all() - - return { - "code": 0, - "data": { - "skill_id": skill.id, - "skill_name": skill.skill_name, - "current_version": skill.version, - "versions": [version_to_dict(v) for v in versions], - } - } - - -# ============================================================ -# 回滚到指定版本 -# ============================================================ - -class RollbackSchema(BaseModel): - version: int = Field(..., ge=1, description="目标版本号") - - -@router.post("/{skill_id}/rollback") -async def rollback_skill_version( - skill_id: str, - req: RollbackSchema, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """回滚技能到指定版本""" - skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() - if not skill: - raise HTTPException(status_code=404, detail="技能不存在") - - target_version = db.query(SkillVersion).filter( - SkillVersion.skill_name == skill.skill_name, - SkillVersion.user_id == skill.user_id, - SkillVersion.version == req.version, - ).first() - - if not target_version: - raise HTTPException(status_code=404, detail=f"版本 {req.version} 不存在") - - old_version = skill.version - - # 更新 SKILL.md 文件 - if target_version.skill_md_content: - if skill.skill_md_path: - try: - with open(skill.skill_md_path, 'w', encoding='utf-8') as f: - f.write(target_version.skill_md_content) - except Exception as e: - logger.warning(f"回滚保存 SKILL.md 失败: {e}") - - # 记录新版本(基于目标版本) - new_version = old_version + 1 - sv = SkillVersion( - id=str(uuid.uuid4()), - user_id=skill.user_id, - skill_name=skill.skill_name, - version=new_version, - skill_md_content=target_version.skill_md_content, - git_commit_sha=None, - description=f"回滚到版本 {req.version}(管理员操作)", - ) - db.add(sv) - - skill.version = new_version - skill.updated_at = datetime.now() - - ev = EvolutionEvent( - id=str(uuid.uuid4()), - user_id=skill.user_id, - skill_name=skill.skill_name, - event_type="edited", - description=f"管理员 {admin_user.username} 回滚技能 {skill.skill_name} 从版本 {old_version} 到 {req.version}(新版本 {new_version})", - version_before=old_version, - version_after=new_version, - ) - db.add(ev) - - db.commit() - db.refresh(skill) - - LogService.log_operation( - user_id=admin_user.id, - username=admin_user.username, - action="rollback_skill", - target="evolution_skills", - detail=f"回滚技能 {skill.skill_name} 到版本 {req.version}(新版本 {new_version})", - ) - - return {"code": 0, "data": skill_to_dict(skill, include_md=True)} - - -# ============================================================ -# 发布到应用市场 -# ============================================================ - -class PublishSkillSchema(BaseModel): - price: float = Field(0.0, ge=0, description="定价(0表示免费)") - market_description: str = Field("", description="市场描述") - - -@router.post("/{skill_id}/publish") -async def publish_skill( - skill_id: str, - req: PublishSkillSchema, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """发布技能到应用市场""" - skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() - if not skill: - raise HTTPException(status_code=404, detail="技能不存在") - - # 检查是否已发布 - existing = db.query(SkillPublishRecord).filter( - SkillPublishRecord.skill_id == skill_id, - SkillPublishRecord.status == "published" - ).first() - - if existing: - # 更新现有发布 - existing.price = req.price - existing.market_description = req.market_description - msg = "更新发布信息" - else: - # 创建发布记录 - record = SkillPublishRecord( - id=str(uuid.uuid4()), - skill_id=skill_id, - skill_name=skill.skill_name, - user_id=skill.user_id, - price=req.price, - market_description=req.market_description, - status="published", - ) - db.add(record) - msg = "发布成功" - - db.commit() - - LogService.log_operation( - user_id=admin_user.id, - username=admin_user.username, - action="publish_skill", - target="skill_publish_records", - detail=f"{msg}: {skill.skill_name},定价: ¥{req.price}", - ) - - return {"code": 0, "message": msg, "data": {"skill_id": skill_id, "price": req.price}} - - -# ============================================================ -# 下架技能 -# ============================================================ - -@router.post("/{skill_id}/unpublish") -async def unpublish_skill( - skill_id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """从应用市场下架技能""" - skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() - if not skill: - raise HTTPException(status_code=404, detail="技能不存在") - - updated = db.query(SkillPublishRecord).filter( - SkillPublishRecord.skill_id == skill_id, - SkillPublishRecord.status == "published" - ).update({"status": "unpublished"}) - db.commit() - - if updated == 0: - raise HTTPException(status_code=400, detail="该技能未发布到市场") - - LogService.log_operation( - user_id=admin_user.id, - username=admin_user.username, - action="unpublish_skill", - target="skill_publish_records", - detail=f"下架技能: {skill.skill_name}", - ) - - return {"code": 0, "message": f"技能 {skill.skill_name} 已下架"} - - -# ============================================================ -# 获取统计数据 -# ============================================================ - -@router.get("/stats/overview") -async def get_skills_stats( - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取技能统计概览(EvolutionSkill + MarketApp type=skill 合并统计)""" - # EvolutionSkill 维度 - evo_total = db.query(EvolutionSkill).count() - evo_by_state = dict( - db.query(EvolutionSkill.state, func.count(EvolutionSkill.id)) - .group_by(EvolutionSkill.state).all() - ) - evo_by_category = dict( - db.query(EvolutionSkill.category, func.count(EvolutionSkill.id)) - .filter(EvolutionSkill.category.isnot(None)) - .group_by(EvolutionSkill.category).all() - ) - - # MarketApp(type=skill) 维度:状态映射 published→main, 其它→baby - market_total = db.query(MarketApp).filter(MarketApp.type == "skill").count() - market_rows = db.query(MarketApp.status, func.count(MarketApp.id)).filter( - MarketApp.type == "skill" - ).group_by(MarketApp.status).all() - market_published = sum(cnt for status, cnt in market_rows if (status or "") == "published") - # 各 category 计数(market 表 category 在 SQL 列有,ORM 无此属性,用 SQL 拿) - market_by_category = {} - try: - market_by_category_rows = db.execute( - text("SELECT capability, COUNT(*) FROM market_apps WHERE type='skill' AND capability IS NOT NULL GROUP BY capability") - ).fetchall() - market_by_category = {cat or "other": cnt for cat, cnt in market_by_category_rows} - except Exception: - pass - - # 合并 - total_skills = evo_total + market_total - by_state = {k: evo_by_state.get(k, 0) for k in SKILL_STATES} - by_state["main"] = by_state.get("main", 0) + market_published - by_state["baby"] = by_state.get("baby", 0) + (market_total - market_published) - by_category = {} - for c in (evo_by_category or {}).items(): - by_category[c[0]] = by_category.get(c[0], 0) + c[1] - for c in (market_by_category or {}).items(): - by_category[c[0]] = by_category.get(c[0], 0) + c[1] - - # 原始发布记录 + market 已发布 = 全部已发布 - skill_record_published = db.query(SkillPublishRecord).filter( - SkillPublishRecord.status == "published" - ).count() - total_published = skill_record_published + market_published - total_versions = db.query(SkillVersion).count() - - return { - "code": 0, - "data": { - "total_skills": total_skills, - "by_state": by_state, - "by_category": by_category, - "total_published": total_published, - "total_versions": total_versions, - } - } +""" +技能管理系统 API(管理员) +全平台技能管理:列表/创建/编辑/删除/状态管理/版本历史/发布市场 +""" +import os +import json +import uuid +import logging +from datetime import datetime +from typing import Optional, List + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session +from sqlalchemy import desc, func, or_, and_, text + +from ...database import get_db +from ...models import ( + User, EvolutionSkill, SkillVersion, SkillPublishRecord, + EvolutionEvent, TeamSharedSkill, MarketApp, AppInstallation, + AppPurchase, AppReview +) +from ...auth import get_current_admin +from ...middleware.log_service import LogService +from ...config import UPLOAD_DIR + +router = APIRouter() +logger = logging.getLogger(__name__) + +# ============================================================ +# 技能状态定义 +# ============================================================ + +SKILL_STATES = { + "baby": {"label": "萌芽期", "color": "#9CA3AF", "description": "刚创建的技能,需要继续培养"}, + "main": {"label": "主力期", "color": "#10B981", "description": "已成熟可正常使用"}, + "staging": {"label": "灰度期", "color": "#F59E0B", "description": "正在灰度测试中"}, + "frozen": {"label": "已冻结", "color": "#EF4444", "description": "已停止使用"}, +} + +# 业务场景分类(与前端「应用市场」保持一致,14 类岗位/职能视角) +# 统一标准:以前台 MARKET_SCENES 为准,后台技能管理也按此 14 类展示与筛选。 +SKILL_CATEGORIES = [ + {"id": "design", "label": "🎨 做设计"}, + {"id": "frontend", "label": "💻 写前端"}, + {"id": "mobile", "label": "📱 做移动端"}, + {"id": "backend", "label": "🧪 后端/全栈"}, + {"id": "qa", "label": "🧪 测试质量"}, + {"id": "search", "label": "🔍 搜索信息"}, + {"id": "docs", "label": "✍️ 写文档"}, + {"id": "marketing", "label": "📢 推广获客"}, + {"id": "workflow", "label": "🔄 工作流"}, + {"id": "secops", "label": "🛡️ 安全运维"}, + {"id": "learning", "label": "🎓 学习教学"}, + {"id": "video", "label": "🎬 视频多媒体"}, + {"id": "collab", "label": "🏢 办公协作"}, + {"id": "ai", "label": "🛠️ AI能力"}, +] +_SCENE_IDS = {c["id"] for c in SKILL_CATEGORIES} +# 旧 9 类 taxonomy -> 14 场景 的兜底映射(仅用于历史数据 capability 缺失时) +_CATEGORY_TO_SCENE = { + "writing": "docs", "coding": "backend", "data_analysis": "backend", + "qa": "qa", "automation": "workflow", "creativity": "design", + "communication": "marketing", "reasoning": "search", "other": "workflow", +} + + +# ============================================================ +# 辅助函数 +# ============================================================ + +def market_app_to_skill_dict(app: "MarketApp") -> dict: + """将市场应用(MarketApp type=skill)转换为与 EvolutionSkill 一致的字典格式""" + status = (getattr(app, "status", None) or "pending").strip() + state = "main" if status == "published" else "baby" + raw_tags = getattr(app, "tags", None) + tags_list = [] + if raw_tags: + try: + tags_list = json.loads(raw_tags) if isinstance(raw_tags, str) else list(raw_tags) + except Exception: + tags_list = [t.strip() for t in str(raw_tags).split(",")] + # 以业务场景(capability, 14 类)为分类标准,与前端应用市场统一; + # capability 缺失时回退到旧 9 类 category 映射出的场景。 + raw_scene = getattr(app, "capability", None) + if raw_scene in _SCENE_IDS: + category = raw_scene + else: + category = _CATEGORY_TO_SCENE.get(getattr(app, "category", None), "workflow") + return { + "id": app.id, + "user_id": getattr(app, "publisher_id", None) or "system", + "skill_name": app.name, + "description": getattr(app, "description", None) or "", + "tags": tags_list, + "category": category, + "category_label": next((c["label"] for c in SKILL_CATEGORIES if c["id"] == category), category or "其他"), + "state": state, + "state_info": SKILL_STATES.get(state, SKILL_STATES["baby"]), + "use_count": getattr(app, "install_count", None) or 0, + "version": 1, + "skill_md_path": None, + "git_commit_sha": None, + "last_used_at": None, + "created_at": app.created_at.isoformat() if getattr(app, "created_at", None) else None, + "updated_at": getattr(app, "updated_at", None).isoformat() if getattr(app, "updated_at", None) else None, + "source": "market", + # 让前端 isPublished(skill) 能正确识别 market 已发布项 + "publish_records": [{"status": "published"}] if status == "published" else [], + } + + +def skill_to_dict(s: EvolutionSkill, include_md: bool = False) -> dict: + """将技能ORM对象转换为字典""" + result = { + "id": s.id, + "user_id": s.user_id, + "skill_name": s.skill_name, + "description": s.description, + "tags": s.tags or [], + "category": s.category, + "category_label": next((c["label"] for c in SKILL_CATEGORIES if c["id"] == s.category), s.category or "其他"), + "state": s.state, + "state_info": SKILL_STATES.get(s.state, SKILL_STATES["baby"]), + "use_count": s.use_count or 0, + "version": s.version or 1, + "skill_md_path": s.skill_md_path, + "git_commit_sha": s.git_commit_sha, + "last_used_at": s.last_used_at.isoformat() if s.last_used_at else None, + "created_at": s.created_at.isoformat() if s.created_at else None, + "updated_at": s.updated_at.isoformat() if s.updated_at else None, + } + + # 读取 SKILL.md 内容 + if include_md and s.skill_md_path and os.path.exists(s.skill_md_path): + try: + with open(s.skill_md_path, 'r', encoding='utf-8') as f: + result["skill_md_content"] = f.read() + except Exception: + result["skill_md_content"] = None + else: + result["skill_md_content"] = None + + return result + + +def version_to_dict(v: SkillVersion) -> dict: + return { + "id": v.id, + "user_id": v.user_id, + "skill_name": v.skill_name, + "version": v.version, + "skill_md_content": v.skill_md_content, + "git_commit_sha": v.git_commit_sha, + "description": v.description, + "created_at": v.created_at.isoformat() if v.created_at else None, + } + + +# ============================================================ +# 管理员技能列表 +# ============================================================ + +@router.get("/") +async def list_skills( + state: str = Query(None, description="技能状态过滤: baby/main/staging/frozen"), + category: str = Query(None, description="技能分类过滤"), + keyword: str = Query(None, description="关键词搜索(名称/描述)"), + user_id: str = Query(None, description="按用户ID筛选"), + sort_by: str = Query("updated_at", description="排序字段"), + order: str = Query("desc", description="排序方向: asc/desc"), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取全平台技能列表(支持分页/过滤/搜索)—— 同时查询 EvolutionSkill + MarketApp(type=skill)""" + logger.info(f"[Admin Skills] {admin_user.username} 列出技能: state={state}, keyword={keyword}") + + result: list = [] + + # ===== 1) 查询 EvolutionSkill ===== + evo_query = db.query(EvolutionSkill) + if state: + evo_query = evo_query.filter(EvolutionSkill.state == state) + if category: + evo_query = evo_query.filter(EvolutionSkill.category == category) + if keyword: + evo_query = evo_query.filter( + or_(EvolutionSkill.skill_name.contains(keyword), EvolutionSkill.description.contains(keyword)) + ) + if user_id: + evo_query = evo_query.filter(EvolutionSkill.user_id == user_id) + + evo_skills = evo_query.all() + user_ids = list(set(s.user_id for s in evo_skills)) + users_map = {u.id: u.username for u in db.query(User.id, User.username).filter(User.id.in_(user_ids)).all()} + for s in evo_skills: + item = skill_to_dict(s) + item["owner_username"] = users_map.get(s.user_id, s.user_id) + item["source"] = "evolution" + result.append(item) + + # ===== 2) 查询 MarketApp (type=skill) ===== + mkt_query = db.query(MarketApp).filter(MarketApp.type == "skill") + if keyword: + mkt_query = mkt_query.filter( + or_(MarketApp.name.contains(keyword), MarketApp.description.contains(keyword)) + ) + if category: + # 分类筛选统一按业务场景(capability, 14 类)进行,与前端应用市场一致 + mkt_query = mkt_query.filter(MarketApp.capability == category) + # 状态映射:主面板只展示已发布 → state='main';草稿/移除 → 'baby' + if state == "main": + mkt_query = mkt_query.filter(MarketApp.status == "published") + elif state: + mkt_query = mkt_query.filter(MarketApp.status != "published") # 非发布=草稿等 + + market_apps_rows = mkt_query.all() + for app in market_apps_rows: + item = market_app_to_skill_dict(app) + item["owner_username"] = app.publisher_name or "system" + result.append(item) + + # ===== 3) 合并排序 & 分页 ===== + total = len(result) + reverse = order == "desc" + key_fn = lambda x: x.get(sort_by) or "" + result.sort(key=key_fn, reverse=reverse) + + start = (page - 1) * page_size + end = start + page_size + paged = result[start:end] + + return { + "code": 0, + "data": { + "items": paged, + "total": total, + "page": page, + "page_size": page_size, + "pages": (total + page_size - 1) // page_size if total else 0, + } + } + + +# ============================================================ +# 获取单个技能详情 +# ============================================================ + +@router.get("/{skill_id}") +async def get_skill( + skill_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取技能详情(包含 SKILL.md 内容)""" + skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() + if not skill: + raise HTTPException(status_code=404, detail="技能不存在") + + result = skill_to_dict(skill, include_md=True) + + # 获取所属用户信息 + user = db.query(User).filter(User.id == skill.user_id).first() + if user: + result["owner_username"] = user.username + result["owner_email"] = user.email + + # 获取发布记录 + pub_records = db.query(SkillPublishRecord).filter( + SkillPublishRecord.skill_id == skill_id + ).order_by(desc(SkillPublishRecord.created_at)).limit(10).all() + result["publish_records"] = [{ + "id": p.id, + "price": float(p.price) if p.price else 0, + "market_description": p.market_description, + "status": p.status, + "published_at": p.published_at.isoformat() if p.published_at else None, + "created_at": p.created_at.isoformat() if p.created_at else None, + } for p in pub_records] + + # 获取最新版本号 + latest_version = db.query(func.max(SkillVersion.version)).filter( + SkillVersion.skill_id == skill_id + ).scalar() + result["latest_version"] = latest_version or skill.version + + return {"code": 0, "data": result} + + +# ============================================================ +# 创建技能 +# ============================================================ + +class CreateSkillRequest: + def __init__(self, skill_name: str = None, description: str = None, + tags: List[str] = None, category: str = None, + skill_md_content: str = None, user_id: str = None): + self.skill_name = skill_name + self.description = description + self.tags = tags or [] + self.category = category + self.skill_md_content = skill_md_content + self.user_id = user_id + + +from pydantic import BaseModel, Field + + +class CreateSkillSchema(BaseModel): + skill_name: str = Field(..., min_length=1, max_length=200, description="技能名称") + user_id: str = Field(..., description="所属用户ID") + description: Optional[str] = Field(None, description="技能描述") + tags: Optional[List[str]] = Field(default_factory=list, description="标签列表") + category: Optional[str] = Field(None, description="技能分类") + skill_md_content: Optional[str] = Field(None, description="SKILL.md 内容") + + +@router.post("/") +async def create_skill( + req: CreateSkillSchema, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """管理员创建技能""" + logger.info(f"[Admin Skills] {admin_user.username} 创建技能: {req.skill_name}") + + # 检查技能名是否已存在 + existing = db.query(EvolutionSkill).filter( + EvolutionSkill.user_id == req.user_id, + EvolutionSkill.skill_name == req.skill_name + ).first() + if existing: + raise HTTPException(status_code=400, detail="该用户已存在同名技能") + + # 验证用户存在 + user = db.query(User).filter(User.id == req.user_id).first() + if not user: + raise HTTPException(status_code=400, detail="用户不存在") + + # 生成技能ID + skill_id = str(uuid.uuid4()) + + # 保存 SKILL.md 文件 + skill_md_path = None + if req.skill_md_content: + skill_dir = os.path.join(UPLOAD_DIR, "skills", req.user_id) + os.makedirs(skill_dir, exist_ok=True) + safe_name = req.skill_name.replace("/", "_").replace("\\", "_") + skill_md_path = os.path.join(skill_dir, f"{safe_name}.md") + try: + with open(skill_md_path, 'w', encoding='utf-8') as f: + f.write(req.skill_md_content) + except Exception as e: + logger.warning(f"保存 SKILL.md 失败: {e}") + + # 创建技能记录 + skill = EvolutionSkill( + id=skill_id, + user_id=req.user_id, + skill_name=req.skill_name, + description=req.description, + tags=req.tags or [], + category=req.category, + state="baby", + use_count=0, + version=1, + skill_md_path=skill_md_path, + git_commit_sha=None, + ) + db.add(skill) + + # 记录初始版本 + if req.skill_md_content: + sv = SkillVersion( + id=str(uuid.uuid4()), + user_id=req.user_id, + skill_name=req.skill_name, + version=1, + skill_md_content=req.skill_md_content, + git_commit_sha=None, + description="初始版本", + ) + db.add(sv) + + # 记录事件 + ev = EvolutionEvent( + id=str(uuid.uuid4()), + user_id=req.user_id, + skill_name=req.skill_name, + event_type="created", + description=f"管理员 {admin_user.username} 创建技能 {req.skill_name}", + version_before=None, + version_after=1, + ) + db.add(ev) + + db.commit() + db.refresh(skill) + + LogService.log_operation( + user_id=admin_user.id, + username=admin_user.username, + action="create_skill", + target="evolution_skills", + detail=f"创建技能 {req.skill_name}(归属用户: {user.username})", + ) + + return {"code": 0, "data": skill_to_dict(skill, include_md=True)} + + +# ============================================================ +# 更新技能 +# ============================================================ + +class UpdateSkillSchema(BaseModel): + skill_name: Optional[str] = Field(None, min_length=1, max_length=200) + description: Optional[str] = None + tags: Optional[List[str]] = None + category: Optional[str] = None + skill_md_content: Optional[str] = None + + +@router.put("/{skill_id}") +async def update_skill( + skill_id: str, + req: UpdateSkillSchema, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """更新技能信息""" + skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() + if not skill: + raise HTTPException(status_code=404, detail="技能不存在") + + old_version = skill.version + update_data = req.model_dump(exclude_none=True) + + # 处理 SKILL.md 内容 + if "skill_md_content" in update_data: + content = update_data.pop("skill_md_content") + if content is not None: + skill_dir = os.path.join(UPLOAD_DIR, "skills", skill.user_id) + os.makedirs(skill_dir, exist_ok=True) + safe_name = skill.skill_name.replace("/", "_").replace("\\", "_") + skill_md_path = os.path.join(skill_dir, f"{safe_name}.md") + try: + with open(skill_md_path, 'w', encoding='utf-8') as f: + f.write(content) + skill.skill_md_path = skill_md_path + except Exception as e: + logger.warning(f"保存 SKILL.md 失败: {e}") + + # 记录新版本 + new_version = old_version + 1 + sv = SkillVersion( + id=str(uuid.uuid4()), + user_id=skill.user_id, + skill_name=skill.skill_name, + version=new_version, + skill_md_content=content, + git_commit_sha=None, + description="管理员更新", + ) + db.add(sv) + skill.version = new_version + + # 记录事件 + ev = EvolutionEvent( + id=str(uuid.uuid4()), + user_id=skill.user_id, + skill_name=skill.skill_name, + event_type="edited", + description=f"管理员 {admin_user.username} 更新技能 {skill.skill_name}", + version_before=old_version, + version_after=new_version, + ) + db.add(ev) + + # 更新其他字段 + for key, value in update_data.items(): + if key not in ("skill_md_content",) and hasattr(skill, key): + setattr(skill, key, value) + + skill.updated_at = datetime.now() + db.commit() + db.refresh(skill) + + LogService.log_operation( + user_id=admin_user.id, + username=admin_user.username, + action="update_skill", + target="evolution_skills", + detail=f"更新技能 {skill.skill_name}(版本 {old_version} → {skill.version})", + ) + + return {"code": 0, "data": skill_to_dict(skill, include_md=True)} + + +# ============================================================ +# 批量删除技能(用于一次性清空技能市场) +# ============================================================ + + +class BatchDeleteSkillsRequest(BaseModel): + ids: list[str] = [] # 要删除的技能 ID 列表;传空列表则删除所有 + + +@router.post("/batch-delete") +async def batch_delete_skills( + req: BatchDeleteSkillsRequest, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """批量删除技能。ids 为空则删除全部技能。""" + if req.ids: + skills = db.query(EvolutionSkill).filter(EvolutionSkill.id.in_(req.ids)).all() + else: + skills = db.query(EvolutionSkill).all() + + deleted_count = 0 + for skill in skills: + skill_name = skill.skill_name + # 删除版本历史 + db.query(SkillVersion).filter(SkillVersion.skill_name == skill_name).delete() + # 删除发布记录 + db.query(SkillPublishRecord).filter(SkillPublishRecord.skill_id == skill.id).delete() + # 删除团队共享 + db.query(TeamSharedSkill).filter(TeamSharedSkill.skill_id == skill.id).delete() + # 同步清理市场应用表中的技能 + market_app = db.query(MarketApp).filter( + MarketApp.type == "skill", + MarketApp.name == skill_name, + ).first() + if market_app: + # 清理关联的安装和购买记录 + db.query(AppInstallation).filter(AppInstallation.app_id == market_app.id).delete() + db.query(AppPurchase).filter(AppPurchase.app_id == market_app.id).delete() + db.query(AppReview).filter(AppReview.app_id == market_app.id).delete() + db.delete(market_app) + db.delete(skill) + deleted_count += 1 + + db.commit() + return {"code": 0, "data": {"deleted": deleted_count}, "message": f"已删除 {deleted_count} 个技能"} + + +# ============================================================ +# 删除技能(单个) +# ============================================================ + +@router.delete("/{skill_id}") +async def delete_skill( + skill_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """删除技能(支持 EvolutionSkill 和 MarketApp 两种来源)""" + # 1) 先尝试 EvolutionSkill + skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() + if skill: + skill_name = skill.skill_name + user_id = skill.user_id + + # 记录事件 + ev = EvolutionEvent( + id=str(uuid.uuid4()), + user_id=user_id, + skill_name=skill_name, + event_type="deleted", + description=f"管理员 {admin_user.username} 删除技能 {skill_name}", + ) + db.add(ev) + + # 删除版本历史 + db.query(SkillVersion).filter(SkillVersion.skill_name == skill_name).delete() + + # 删除发布记录 + db.query(SkillPublishRecord).filter(SkillPublishRecord.skill_id == skill_id).delete() + + # 删除团队共享记录 + db.query(TeamSharedSkill).filter(TeamSharedSkill.skill_id == skill_id).delete() + + # 同步清理市场应用表中的同名技能 + market_app = db.query(MarketApp).filter( + MarketApp.type == "skill", + MarketApp.name == skill_name, + ).first() + if market_app: + db.query(AppInstallation).filter(AppInstallation.app_id == market_app.id).delete() + db.query(AppPurchase).filter(AppPurchase.app_id == market_app.id).delete() + db.query(AppReview).filter(AppReview.app_id == market_app.id).delete() + db.delete(market_app) + + db.delete(skill) + db.commit() + + LogService.log_operation( + user_id=admin_user.id, + username=admin_user.username, + action="delete_skill", + target="evolution_skills", + detail=f"删除技能 {skill_name}", + ) + + return {"code": 0, "message": f"技能 {skill_name} 已删除"} + + # 2) 再尝试 MarketApp 中导入的技能(type=skill) + market_app = db.query(MarketApp).filter( + MarketApp.type == "skill", + MarketApp.id == skill_id, + ).first() + if market_app: + skill_name = market_app.name + # 清理关联记录 + db.query(AppInstallation).filter(AppInstallation.app_id == market_app.id).delete() + db.query(AppPurchase).filter(AppPurchase.app_id == market_app.id).delete() + db.query(AppReview).filter(AppReview.app_id == market_app.id).delete() + db.delete(market_app) + db.commit() + + LogService.log_operation( + user_id=admin_user.id, + username=admin_user.username, + action="delete_skill", + target="market_apps", + detail=f"删除市场技能 {skill_name}", + ) + + return {"code": 0, "message": f"技能 {skill_name} 已删除"} + + # 3) 都未找到 + raise HTTPException(status_code=404, detail="技能不存在") + + +# ============================================================ +# 修改技能状态 +# ============================================================ + +class ChangeStateSchema(BaseModel): + state: str = Field(..., description="目标状态: baby/main/staging/frozen") + + +@router.put("/{skill_id}/state") +async def change_skill_state( + skill_id: str, + req: ChangeStateSchema, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """修改技能状态""" + if req.state not in SKILL_STATES: + raise HTTPException(status_code=400, detail=f"无效状态,可选值: {list(SKILL_STATES.keys())}") + + skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() + if not skill: + raise HTTPException(status_code=404, detail="技能不存在") + + old_state = skill.state + skill.state = req.state + skill.updated_at = datetime.now() + + # 记录状态变更事件 + ev_type = "promoted" if req.state in ("main", "staging") else "frozen" if req.state == "frozen" else "unfrozen" + ev = EvolutionEvent( + id=str(uuid.uuid4()), + user_id=skill.user_id, + skill_name=skill.skill_name, + event_type=ev_type, + description=f"管理员 {admin_user.username} 将技能状态从 {old_state} 变更为 {req.state}", + ) + db.add(ev) + db.commit() + db.refresh(skill) + + LogService.log_operation( + user_id=admin_user.id, + username=admin_user.username, + action="change_skill_state", + target="evolution_skills", + detail=f"修改技能 {skill.skill_name} 状态: {old_state} → {req.state}", + ) + + return {"code": 0, "data": skill_to_dict(skill)} + + +# ============================================================ +# 获取版本历史 +# ============================================================ + +@router.get("/{skill_id}/versions") +async def get_skill_versions( + skill_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取技能版本历史""" + skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() + if not skill: + raise HTTPException(status_code=404, detail="技能不存在") + + versions = db.query(SkillVersion).filter( + SkillVersion.skill_name == skill.skill_name, + SkillVersion.user_id == skill.user_id, + ).order_by(desc(SkillVersion.version)).all() + + return { + "code": 0, + "data": { + "skill_id": skill.id, + "skill_name": skill.skill_name, + "current_version": skill.version, + "versions": [version_to_dict(v) for v in versions], + } + } + + +# ============================================================ +# 回滚到指定版本 +# ============================================================ + +class RollbackSchema(BaseModel): + version: int = Field(..., ge=1, description="目标版本号") + + +@router.post("/{skill_id}/rollback") +async def rollback_skill_version( + skill_id: str, + req: RollbackSchema, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """回滚技能到指定版本""" + skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() + if not skill: + raise HTTPException(status_code=404, detail="技能不存在") + + target_version = db.query(SkillVersion).filter( + SkillVersion.skill_name == skill.skill_name, + SkillVersion.user_id == skill.user_id, + SkillVersion.version == req.version, + ).first() + + if not target_version: + raise HTTPException(status_code=404, detail=f"版本 {req.version} 不存在") + + old_version = skill.version + + # 更新 SKILL.md 文件 + if target_version.skill_md_content: + if skill.skill_md_path: + try: + with open(skill.skill_md_path, 'w', encoding='utf-8') as f: + f.write(target_version.skill_md_content) + except Exception as e: + logger.warning(f"回滚保存 SKILL.md 失败: {e}") + + # 记录新版本(基于目标版本) + new_version = old_version + 1 + sv = SkillVersion( + id=str(uuid.uuid4()), + user_id=skill.user_id, + skill_name=skill.skill_name, + version=new_version, + skill_md_content=target_version.skill_md_content, + git_commit_sha=None, + description=f"回滚到版本 {req.version}(管理员操作)", + ) + db.add(sv) + + skill.version = new_version + skill.updated_at = datetime.now() + + ev = EvolutionEvent( + id=str(uuid.uuid4()), + user_id=skill.user_id, + skill_name=skill.skill_name, + event_type="edited", + description=f"管理员 {admin_user.username} 回滚技能 {skill.skill_name} 从版本 {old_version} 到 {req.version}(新版本 {new_version})", + version_before=old_version, + version_after=new_version, + ) + db.add(ev) + + db.commit() + db.refresh(skill) + + LogService.log_operation( + user_id=admin_user.id, + username=admin_user.username, + action="rollback_skill", + target="evolution_skills", + detail=f"回滚技能 {skill.skill_name} 到版本 {req.version}(新版本 {new_version})", + ) + + return {"code": 0, "data": skill_to_dict(skill, include_md=True)} + + +# ============================================================ +# 发布到应用市场 +# ============================================================ + +class PublishSkillSchema(BaseModel): + price: float = Field(0.0, ge=0, description="定价(0表示免费)") + market_description: str = Field("", description="市场描述") + + +@router.post("/{skill_id}/publish") +async def publish_skill( + skill_id: str, + req: PublishSkillSchema, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """发布技能到应用市场""" + skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() + if not skill: + raise HTTPException(status_code=404, detail="技能不存在") + + # 检查是否已发布 + existing = db.query(SkillPublishRecord).filter( + SkillPublishRecord.skill_id == skill_id, + SkillPublishRecord.status == "published" + ).first() + + if existing: + # 更新现有发布 + existing.price = req.price + existing.market_description = req.market_description + msg = "更新发布信息" + else: + # 创建发布记录 + record = SkillPublishRecord( + id=str(uuid.uuid4()), + skill_id=skill_id, + skill_name=skill.skill_name, + user_id=skill.user_id, + price=req.price, + market_description=req.market_description, + status="published", + ) + db.add(record) + msg = "发布成功" + + db.commit() + + LogService.log_operation( + user_id=admin_user.id, + username=admin_user.username, + action="publish_skill", + target="skill_publish_records", + detail=f"{msg}: {skill.skill_name},定价: ¥{req.price}", + ) + + return {"code": 0, "message": msg, "data": {"skill_id": skill_id, "price": req.price}} + + +# ============================================================ +# 下架技能 +# ============================================================ + +@router.post("/{skill_id}/unpublish") +async def unpublish_skill( + skill_id: str, + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """从应用市场下架技能""" + skill = db.query(EvolutionSkill).filter(EvolutionSkill.id == skill_id).first() + if not skill: + raise HTTPException(status_code=404, detail="技能不存在") + + updated = db.query(SkillPublishRecord).filter( + SkillPublishRecord.skill_id == skill_id, + SkillPublishRecord.status == "published" + ).update({"status": "unpublished"}) + db.commit() + + if updated == 0: + raise HTTPException(status_code=400, detail="该技能未发布到市场") + + LogService.log_operation( + user_id=admin_user.id, + username=admin_user.username, + action="unpublish_skill", + target="skill_publish_records", + detail=f"下架技能: {skill.skill_name}", + ) + + return {"code": 0, "message": f"技能 {skill.skill_name} 已下架"} + + +# ============================================================ +# 获取统计数据 +# ============================================================ + +@router.get("/stats/overview") +async def get_skills_stats( + admin_user=Depends(get_current_admin), + db: Session = Depends(get_db), +): + """获取技能统计概览(EvolutionSkill + MarketApp type=skill 合并统计)""" + # EvolutionSkill 维度 + evo_total = db.query(EvolutionSkill).count() + evo_by_state = dict( + db.query(EvolutionSkill.state, func.count(EvolutionSkill.id)) + .group_by(EvolutionSkill.state).all() + ) + evo_by_category = dict( + db.query(EvolutionSkill.category, func.count(EvolutionSkill.id)) + .filter(EvolutionSkill.category.isnot(None)) + .group_by(EvolutionSkill.category).all() + ) + + # MarketApp(type=skill) 维度:状态映射 published→main, 其它→baby + market_total = db.query(MarketApp).filter(MarketApp.type == "skill").count() + market_rows = db.query(MarketApp.status, func.count(MarketApp.id)).filter( + MarketApp.type == "skill" + ).group_by(MarketApp.status).all() + market_published = sum(cnt for status, cnt in market_rows if (status or "") == "published") + # 各 category 计数(market 表 category 在 SQL 列有,ORM 无此属性,用 SQL 拿) + market_by_category = {} + try: + market_by_category_rows = db.execute( + text("SELECT capability, COUNT(*) FROM market_apps WHERE type='skill' AND capability IS NOT NULL GROUP BY capability") + ).fetchall() + market_by_category = {cat or "other": cnt for cat, cnt in market_by_category_rows} + except Exception: + pass + + # 合并 + total_skills = evo_total + market_total + by_state = {k: evo_by_state.get(k, 0) for k in SKILL_STATES} + by_state["main"] = by_state.get("main", 0) + market_published + by_state["baby"] = by_state.get("baby", 0) + (market_total - market_published) + by_category = {} + for c in (evo_by_category or {}).items(): + by_category[c[0]] = by_category.get(c[0], 0) + c[1] + for c in (market_by_category or {}).items(): + by_category[c[0]] = by_category.get(c[0], 0) + c[1] + + # 原始发布记录 + market 已发布 = 全部已发布 + skill_record_published = db.query(SkillPublishRecord).filter( + SkillPublishRecord.status == "published" + ).count() + total_published = skill_record_published + market_published + total_versions = db.query(SkillVersion).count() + + return { + "code": 0, + "data": { + "total_skills": total_skills, + "by_state": by_state, + "by_category": by_category, + "total_published": total_published, + "total_versions": total_versions, + } + } diff --git a/backend/python/app/api/chat_context.py b/backend/python/app/api/chat_context.py index 23562d8..29e0008 100644 --- a/backend/python/app/api/chat_context.py +++ b/backend/python/app/api/chat_context.py @@ -1,2906 +1,2906 @@ -# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). - -# Source of truth: scripts/phase1_extract.py. Do not hand-merge back. - - - -from fastapi import APIRouter, Depends, HTTPException, Query, Response - -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, timezone, timedelta - -import re - -import json - -import asyncio - -import math - -import httpx - -import os - -import logging - -from ..database import get_db, SessionLocal - -from ..models import ( - - - - - - - - User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, ConversationSummary, - - - - - - - - KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, - - - - - - - - EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, - - - - - - - - MCPServer, - - - - - - - - gen_uuid - - - - - - - -) - -from ..auth import get_current_user - -from ..platform_knowledge import get_platform_knowledge - -from ..mcp_client import list_mcp_tools, call_mcp_tool - -from ..middleware.log_service import LogService - -from ..capability_registry import resolve_auto_model - -from app.api.chat_skill import (_bm25_search) - -from app.kb_router import kb_router_search - -from app.api.chat_utils import (logger) - - - -async def _retrieve_kb_context(db: Session, kb_ids: list, query: str, max_tokens: int = 1000) -> str: - - - - - - - - """ - - - - - - - - Stage C: 增强版知识库检索 — 使用BM25排序替代朴素拼接 - - - - - - - - 1. 获取每个KB的文档 - - - - - - - - 2. 构建文档列表(content+title+metadata) - - - - - - - - 3. BM25检索最相关的段落 - - - - - - - - 4. 拼接为上下文注入system prompt - - - - - - - - 同时叠加 Dify 外部知识库(RAG)检索结果。 - - - - - - - - """ - - - - - - - - local_context = "" - - - - - - - - - - - - - - - - if kb_ids: - - - - - - - - kbs = db.query(KnowledgeBase).filter(KnowledgeBase.id.in_(kb_ids)).all() - - - - - - - - if kbs: - - - - - - - - # 收集所有文档段落 - - - - - - - - # Stage C.1: 优先使用 KnowledgeDocumentChunk 表的预分段数据 - - - - - - - - all_segments = [] - - - - - - - - for kb in kbs: - - - - - - - - # 先查chunk表 - - - - - - - - chunks = db.query(KnowledgeDocumentChunk).filter( - - - - - - - - KnowledgeDocumentChunk.knowledge_base_id == kb.id - - - - - - - - ).order_by(KnowledgeDocumentChunk.chunk_index).limit(30).all() - - - - - - - - - - - - - - - - if chunks: - - - - - - - - # 使用预分段数据 - - - - - - - - for chunk in chunks: - - - - - - - - if chunk.content and len(chunk.content.strip()) >= 20: - - - - - - - - all_segments.append({ - - - - - - - - "content": "[{}/{}/段{}]: {}".format( - - - - - - - - kb.name, chunk.document_id[:8], chunk.chunk_index, chunk.content[:500] - - - - - - - - ), - - - - - - - - "raw_content": chunk.content, - - - - - - - - "kb_name": kb.name, - - - - - - - - }) - - - - - - - - else: - - - - - - - - # 退化: 使用原始文档分段 - - - - - - - - docs = db.query(KnowledgeDocument).filter( - - - - - - - - KnowledgeDocument.knowledge_base_id == kb.id, - - - - - - - - KnowledgeDocument.status == "completed" - - - - - - - - ).order_by(KnowledgeDocument.updated_at.desc()).limit(20).all() - - - - - - - - - - - - - - - - for doc in docs: - - - - - - - - content = doc.content or "" - - - - - - - - if not content: - - - - - - - - continue - - - - - - - - - - - - - - - - # 如果文档较长, 分段处理 (每段~800字) - - - - - - - - if len(content) > 800: - - - - - - - - paragraphs = re.split(r'\n\n+', content) - - - - - - - - for pi, para in enumerate(paragraphs): - - - - - - - - if len(para.strip()) >= 20: - - - - - - - - all_segments.append({ - - - - - - - - "content": "[{}/{}/段{}]: {}".format( - - - - - - - - kb.name, doc.name, pi+1, para.strip()[:500] - - - - - - - - ), - - - - - - - - "raw_content": para.strip(), - - - - - - - - "kb_name": kb.name, - - - - - - - - "doc_name": doc.name, - - - - - - - - }) - - - - - - - - else: - - - - - - - - all_segments.append({ - - - - - - - - "content": "[{}/{}]: {}".format(kb.name, doc.name, content[:500]), - - - - - - - - "raw_content": content, - - - - - - - - "kb_name": kb.name, - - - - - - - - "doc_name": doc.name, - - - - - - - - }) - - - - - - - - - - - - - - - - if not all_segments: - - - - - - - - # 退化: 使用原始朴素拼接 - - - - - - - - kb_names = ", ".join([k.name for k in kbs]) - - - - - - - - local_context = f"参考知识库: {kb_names}" - - - - - - - - else: - - - - - - - - # BM25检索最相关的段落 - - - - - - - - # 混合检索:BM25(关键词) + 向量语义 + RRF 融合;失败回退 BM25 - bm25_segs = _bm25_search(query, all_segments, top_k=8) - bm25_for_router = [ - {"content": seg.get("raw_content", seg.get("content", "")), - "metadata": {"label": seg.get("content", "")}} - for seg in bm25_segs - ] - merged = None - try: - merged = await kb_router_search( - query, kb_ids=kb_ids, top_k=8, - bm25_results=bm25_for_router, db_session=db, - use_vector=True, use_external=False, - ) - except Exception as e: - logger.warning(f"[kb] 混合检索失败, 回退 BM25: {e}") - if merged: - ctx_parts = [] - used = 0 - for seg in merged: - label = (seg.get("metadata") or {}).get("label") - c = label if label else seg.get("content", "") - t = _estimate_tokens(c) - if ctx_parts and used + t > max_tokens: - break - ctx_parts.append(c) - used += t - local_context = "\n".join(ctx_parts) - else: - ctx_parts = [seg["content"] for seg in bm25_segs] - joined = "\n".join(ctx_parts) - if _estimate_tokens(joined) > max_tokens: - trunc, u = [], 0 - for c in ctx_parts: - if u + _estimate_tokens(c) > max_tokens: - break - trunc.append(c) - u += _estimate_tokens(c) - joined = "\n".join(trunc) + "\n...(内容截断)" - local_context = joined - - - - - - - - - - - - - - - - # 叠加 Dify 外部知识库(RAG)检索 - - - - - - - - dify_context = "" - - - - - - - - try: - - - - - - - - from app.dify_kb import get_dify_kb_context - - - - - - - - dify_context = get_dify_kb_context(db, query, top_k=5) - - - - - - - - except Exception as e: - - - - - - - - logger.warning(f"Dify KB context retrieval failed: {e}") - - - - - - - - - - - - - - - - parts = [] - - - - - - - - if local_context: - - - - - - - - parts.append(local_context) - - - - - - - - if dify_context: - - - - - - - - parts.append("【Dify 知识库检索结果】\n" + dify_context) - - - - - - - - return "\n\n".join(parts) - - - -async def _ingest_answer_to_dify(conv_id: str, msg_id: str, content: str): - - - - - - - - """后台任务:将助手回答入库到 Dify 知识库(量化后可供 RAG 检索复用)。""" - - - - - - - - try: - - - - - - - - from app.dify_kb import aingest_text - - - - - - - - from app.database import SessionLocal - - - - - - - - from sqlalchemy import text as _t - - - - - - - - db = SessionLocal() - - - - - - - - try: - - - - - - - - row = db.execute(_t( - - - - - - - - "SELECT api_url, api_key, config FROM external_kb_services " - - - - - - - - "WHERE service_type='dify' AND is_enabled=1 LIMIT 1" - - - - - - - - )).fetchone() - - - - - - - - finally: - - - - - - - - db.close() - - - - - - - - if not row: - - - - - - - - return - - - - - - - - api_url, api_key, config_raw = row - - - - - - - - dataset_name = None - - - - - - - - if config_raw: - - - - - - - - try: - - - - - - - - import json - - - - - - - - cfg = config_raw if isinstance(config_raw, dict) else json.loads(config_raw) - - - - - - - - dataset_name = cfg.get("dataset_name") - - - - - - - - except Exception: - - - - - - - - pass - - - - - - - - await aingest_text(api_url, api_key, content, name=f"chat_{conv_id}_{msg_id}", dataset_name=dataset_name) - - - - - - - - except Exception as e: - - - - - - - - logger.warning(f"后台入库 Dify 失败: {e}") - - - -def _build_system_prompt( - - - - - - - - skills: list, - - - - - - - - agents: list, - - - - - - - - kb_context: str, - - - - - - - - user_content: str, - - - - - - - - mcp: list = None, - - - - mcp_functions: list = None, - - - - auto_discovered: bool = False, - - all_mcp: list = None, - - db: Session = None, - -) -> tuple: - - - - - - - - """ - - - - - - - - 动态组装system prompt + function definitions。 - - - - - - - - 返回: (system_prompt_str, function_definitions_list) - - - - - - - - """ - - - - - - - - system_parts = ["你是AIFlow平台的AI助手,具备专业的知识储备和工具调用能力。"] - - - - - - - - - - - - - - - - # 平台知识库注入(始终开启):确保AI优先使用平台已有能力 - - - - - - - - try: - - - - - - - - pk = get_platform_knowledge(db=db) - - - - - - - - if pk: - - - - - - - - system_parts.append(f"【平台知识库——优先使用以下知识回答用户问题】\n{pk}") - - - - - - - - except Exception: - - - - - - - - pass - - - - - - - - - - - - - - - - # 回复格式要求:强制使用Markdown - - - - - - - - system_parts.append("回复格式要求:使用 Markdown 格式,层次分明即可。对于简短问题,用 1-2 段自然文字直接回答,不必强行分点、加标题或表格;仅在确实需要分步骤或列举时才使用列表,代码/配置用 ``` 代码块包裹。避免冗余空行,保持紧凑、美观、易读。") - - - - - - - - - - - - - - - - function_definitions = [] - - - - - - - - - - - - - - - - # Stage B.1: Agent角色注入(Issue 2:让用户询问 agent 能力时,AI 能“看到”该 agent 资料) - - - - - - - - if agents: - - - - - - - - for agent in agents: - - - - - - - - name = agent.get("name", "") - - - - - - - - role = agent.get("role_prompt", "") - - - - - - - - instructions = agent.get("instructions", "") - - - - - - - - desc = agent.get("description", "") - - - - - - - - block = [] - - - - - - - - if name: - - - - - - - - block.append( - - - - - - - - f"用户已选择智能体「{name}」。当用户询问该智能体的功能、能力、用法或相关问题时," - - - - - - - - f"请基于以下资料准确作答,不要说“不知道这个智能体”。" - - - - - - - - ) - - - - - - - - if role: - - - - - - - - block.append(f"该智能体的角色设定: {role}") - - - - - - - - if desc: - - - - - - - - block.append(f"该智能体的简介: {desc}") - - - - - - - - if instructions: - - - - - - - - block.append(f"该智能体的行为指引: {instructions}") - - - - - - - - if block: - - - - - - - - system_parts.append("\n".join(block)) - - - - - - - - - - - - - - - - # Stage A.2: Skill工具注入 - - - - - - - - if skills: - - - - - - - - skill_descs = ", ".join([f"{s['name']}({s.get('category', 'general')})" for s in skills]) - - - - - - - - system_parts.append(f"可用技能工具: {skill_descs}") - - - - - - - - if auto_discovered: - - - - - - - - # Issue 8:每轮默认先寻找本账号已安装且平台已部署的技能,多个适用时 AI 自行判断选 1 个最优 - - - - - - - - system_parts.append( - - - - - - - - "重要规则: 以上技能为本账号已安装且平台已部署的技能。每轮对话请先判断用户请求是否可由其中某个技能处理;" - - - - - - - - "若多个技能都适用,请自行判断并选择最贴切的 1 个优先调用,不要一次性全部调用。" - - - - - - - - "调用对应工具后将获得执行结果,基于结果生成最终回复。若这些技能均不适用,则直接基于你的知识回答。" - - - - - - - - ) - - - - - - - - else: - - - - - - - - system_parts.append( - - "重要规则:\n" - - "1) 当用户请求需要由该技能【执行】某项任务时,优先通过 function calling 调用对应工具;调用后基于工具返回结果生成回复。\n" - - "2) 当用户询问所选技能【本身的介绍、功能、用途、用法、特点】(例如“介绍一下这个技能”“它是什么”“怎么用”“能做什么”)时," - - "必须基于下方【已选技能资料】中的名称/描述/指令直接作答,不要泛泛介绍平台整体,也不要说“不知道这个技能”。\n" - - "3) 若用户问题既不执行也不询问该技能本身,则按常规方式回答。" - - ) - - - - - - - - - - - - - - - - # 为每个skill构建function definition - - - - - - - - for s in skills: - - - - - - - - # 从skill_content提取指令注入system prompt - - - - - - - - instructions = s.get("instructions", "") - - - - - - - - name = s.get("name", "") - - if not auto_discovered: - - # 显式选中技能:注入可引用的技能资料,便于“介绍/说明”类问题据此作答 - - ref_parts = [f"【已选技能资料】名称:{name}"] - - desc = s.get("description", "") - - if desc: - - ref_parts.append(f"简介:{desc}") - - if instructions and len(instructions) < 1200: - - ref_parts.append(f"使用指令:{instructions}") - - system_parts.append("\n".join(ref_parts)) - - elif instructions and len(instructions) < 800: - - system_parts.append(f"技能「{name}」的详细指令: {instructions}") - - - - - - - - - - - - - - - - # 构建参数schema - - - - - - - - params = s.get("parameters", {}) - - - - - - - - if isinstance(params, dict) and len(params) > 0: - - - - - - - - properties = {} - - - - - - - - required = [] - - - - - - - - for pname, pdef in params.items(): - - - - - - - - if isinstance(pdef, dict): - - - - - - - - properties[pname] = { - - - - - - - - "type": pdef.get("type", "string"), - - - - - - - - "description": pdef.get("description", f"传递给{s['name']}的{pname}参数"), - - - - - - - - } - - - - - - - - if pdef.get("required", True): - - - - - - - - required.append(pname) - - - - - - - - else: - - - - - - - - properties[pname] = {"type": "string", "description": f"传递给{s['name']}的{pname}参数"} - - - - - - - - required.append(pname) - - - - - - - - - - - - - - - - if not properties: - - - - - - - - properties = {"input": {"type": "string", "description": f"需要传递给{s['name']}的完整用户输入"}} - - - - - - - - required = ["input"] - - - - - - - - else: - - - - - - - - properties = {"input": {"type": "string", "description": f"需要传递给{s['name']}的完整用户输入"}} - - - - - - - - required = ["input"] - - - - - - - - - - - - - - - - desc = s.get("description", "") - - - - - - - - category = s.get("category", "general") - - - - - - - - func_desc = f"调用技能「{s['name']}」处理用户请求。使用场景: {desc}。当用户请求涉及{category}类任务时优先调用此工具。" - - - - - - - - - - - - - - - - function_definitions.append({ - - - - - - - - "name": s["func_name"], - - - - - - - - "description": func_desc, - - - - - - - - "parameters": { - - - - - - - - "type": "object", - - - - - - - - "properties": properties, - - - - - - - - "required": required, - - - - - - - - }, - - - - - - - - }) - - - - - - - - - - - - - - - - # KB context injection - - - - - - - - if kb_context: - - - - - - - - system_parts.append(f"参考以下知识库内容回答用户问题:\n{kb_context}") - - - - - - - - - - - - - - - - # 平台全量 MCP 清单(Issue:让用户询问"平台有哪些 MCP"时 AI 能准确列举,而非凭空编造) - - if all_mcp: - - _mcp_inventory = "\n".join([f"- {m.get('name','')}: {m.get('description','') or '(未填写描述)'}" for m in all_mcp]) - - system_parts.append( - - f"【平台已安装的 MCP 应用清单(共 {len(all_mcp)} 个,均为平台真实接入并启用)】\n{_mcp_inventory}\n" - - "当用户询问“平台有哪些 MCP / 接入了哪些 MCP / 有哪些工具服务”时,请严格依据以上清单准确列举名称与功能," - - "不要编造或遗漏,也不要把未列出的服务说成已接入。" - - ) - - - - # MCP 应用注入(Issue 3:让用户询问 MCP 能力时,AI 能“看到”该 MCP 的资料与地址) - - - - - - - - if mcp: - - - - - - - - mcp_lines = [] - - - - - - - - for m in mcp: - - - - - - - - name = m.get("name", "") - - - - - - - - desc = m.get("description", "") - - - - - - - - transport = m.get("transport", "stdio") - - - - - - - - endpoint = m.get("endpoint", "") - - - - - - - - line = f"用户已选择 MCP 应用「{name}」(传输方式: {transport})" - - - - - - - - if desc: - - - - - - - - line += f",功能简介: {desc}" - - - - - - - - if endpoint: - - - - - - - - line += f",服务地址: {endpoint}" - - - - - - - - mcp_lines.append(line) - - - - - - - - system_parts.append("\n".join(mcp_lines)) - - - - - - - - system_parts.append( - - - - - - - - "用户已选择上述 MCP 应用。当请求需要借助外部工具/系统(如查询数据、调用 API、操作第三方服务)时," - - - - - - - - "请优先参考这些 MCP 应用的能力来回答;若平台已接入对应工具,优先通过工具调用完成;" - - - - - - - - "不要说“不了解该 MCP”。" - - - - - - - - ) - - - - # 高优先级覆盖规则:显式选中工具时,回答必须基于该工具资料 - - if (skills and not auto_discovered) or (mcp): - - system_parts.append( - - "【最高优先级覆盖规则】\n" - - "用户在本轮已明确选择了工具(见上文【已选技能资料】或【用户已选择 MCP 应用】)。\n" - - "如果用户的问题是在询问这些已选工具/技能/应用本身的介绍、功能、能做什么、怎么用、特点或用法," - - "你必须只基于上文提供的该工具具体资料作答;\n" - - "不要引用《平台知识库》中关于技能市场、MCP 协议或平台整体能力的通用描述;" - - "不要列举其它未选中的技能或 MCP 作为示例;也不要说“不知道”或“无法回答”。\n" - - "若用户问题与已选工具本身无关,则按常规方式回答。" - - ) - - - - - - - - - - - - - - - - - - system_prompt = "\n".join(system_parts) - - - - - - - - # MCP function definitions 注入(让 LLM 可通过 function calling 真实调用 MCP 工具) - - - - if mcp_functions: - - - - function_definitions.extend(mcp_functions) - - - - - - - - return system_prompt, function_definitions - - - -def _get_conversation_context(db: Session, conv_id: str) -> dict: - - - - - - - - """ - - - - - - - - Stage B.2: 获取对话的持久化上下文 - - - - - - - - 对话级别的agent/skill/kb选择保存在Conversation字段中。 - - - - - - - - """ - - - - - - - - conv = db.query(Conversation).filter(Conversation.id == conv_id).first() - - - - - - - - if not conv: - - - - - - - - return {} - - - - - - - - - - - - - - - - # Conversation目前没有存储agent/skill/kb的JSON字段 - - - - - - - - # 我们从第一条消息中获取(如果存在), 或返回空 - - - - - - - - # 后续可通过数据库迁移添加Conversation.context字段 - - - - - - - - - - - - - - - - # 查询该对话中最早一条含有skill_ids/agent_ids/knowledge_ids的消息 - - - - - - - - first_tool_msg = db.query(Message).filter( - - - - - - - - Message.conversation_id == conv_id, - - - - - - - - Message.role == "user", - - - - - - - - ).order_by(asc(Message.created_at)).first() - - - - - - - - - - - - - - - - context = {} - - - - - - - - if first_tool_msg: - - - - - - - - if first_tool_msg.skill_ids: - - - - - - - - context["skill_ids"] = first_tool_msg.skill_ids - - - - - - - - if first_tool_msg.agent_ids: - - - - - - - - context["agent_ids"] = first_tool_msg.agent_ids - - - - - - - - if first_tool_msg.knowledge_ids: - - - - - - - - context["knowledge_base_ids"] = first_tool_msg.knowledge_ids - - - - - - - - if getattr(first_tool_msg, "mcp_ids", None): - - - - - - - - context["mcp_ids"] = first_tool_msg.mcp_ids - - - - - - - - - - - - - - - - return context - - - - - - - -# ===== Phase 2 上下文引擎升级:分层记忆 + 压缩 + 隔离 ===== - -logger = logging.getLogger(__name__) - - - - - -_tiktoken_enc = None - -def _estimate_tokens(text): - - """精确 token 估算:优先 tiktoken(cl100k_base),失败回退 2 字符/token。""" - - if not text: - - return 0 - - global _tiktoken_enc - - if _tiktoken_enc is None: - - try: - - import tiktoken - - _tiktoken_enc = tiktoken.get_encoding("cl100k_base") - - except Exception: - - _tiktoken_enc = False - - if _tiktoken_enc: - - try: - - return len(_tiktoken_enc.encode(text)) - - except Exception: - - pass - - return max(1, len(text) // 2) - - - - - -def _build_chat_history(db, conv_id, max_tokens=6000, max_messages=40, - exclude_latest_user=True, include_summary=True): - """会话层上下文引擎:预算感知的历史组装 + 压缩摘要/结构化长期记忆前置。 - - - 若 conversation_summaries 存在结构化长期记忆(structured_memory),以紧凑 - 结构化区块前置(Semantica 式:实体/事实/偏好/决策/待办),比扁平摘要更省 - token 且更便于模型检索关键信息。 - - 否则回退到扁平 summary_text 前置。 - - 按 token 估算从新到旧累加,超预算即截断(隔离:不溢出上下文窗口)。 - - exclude_latest_user=True 时丢弃最新一条 user 消息(当前轮次另行发送)。 - """ - ensure_structured_column() - summary_block = None - if include_summary: - try: - summ = db.query(ConversationSummary).filter( - ConversationSummary.conversation_id == conv_id - ).first() - if summ and summ.structured_memory and isinstance(summ.structured_memory, dict): - block = _format_structured_memory(summ.structured_memory) - if block: - summary_block = { - "role": "system", - "content": "【长期记忆·结构化】请据此理解用户此前的偏好、已确认决策与待办:\n" + block, - } - elif summ and summ.summary_text: - summary_block = { - "role": "system", - "content": "【对话历史摘要,请据此理解用户此前的意图与已确认结论】\n" + summ.summary_text, - } - except Exception: - pass - - rows = db.query(Message).filter( - Message.conversation_id == conv_id, - Message.role.in_(["user", "assistant"]), - ).order_by(desc(Message.created_at)).limit(max_messages).all() - if exclude_latest_user: - for i, m in enumerate(rows): - if m.role == "user": - rows = rows[:i] + rows[i + 1:] - break - - kept = [] - used = 0 - for m in rows: # 新->旧 - t = _estimate_tokens(m.content or "") - if kept and used + t > max_tokens: - break - kept.append({"role": m.role, "content": m.content}) - used += t - - kept.reverse() # 时间正序 - - out = [] - if summary_block: - out.append(summary_block) - out.extend(kept) - return out - - -def _format_structured_memory(mem: dict) -> str: - """将结构化记忆格式化为紧凑可读区块。""" - labels = { - "entities": "实体", - "facts": "事实", - "preferences": "偏好", - "decisions": "决策", - "open_tasks": "待办", - } - lines = [] - for key, label in labels.items(): - items = mem.get(key) - if not items: - continue - if isinstance(items, list): - for it in items: - if it: - lines.append(f"· {label}: {it}") - elif isinstance(items, str): - lines.append(f"· {label}: {items}") - return "\n".join(lines) -async def _summarize_via_llm(prompt): - - """调用默认文本模型生成对话摘要;失败返回 None(压缩为可选项,不阻塞)。""" - - try: - - from ..capability_registry import resolve_auto_model - - from app.api.chat_utils import _proxy_for_url - - sdb = SessionLocal() - - try: - - ai_model = resolve_auto_model("text", sdb) - - if not ai_model: - - return None - - provider = sdb.query(ModelProvider).filter( - - ModelProvider.id == ai_model.provider_id - - ).first() - - api_key = ai_model.api_key or (provider.api_key if provider else None) - - if not provider or not provider.api_base_url or not api_key: - - return None - - api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions" - - body = { - - "model": ai_model.name, - - "messages": [ - - {"role": "system", "content": "你是专业的对话摘要助手,输出简洁中文摘要。"}, - - {"role": "user", "content": prompt}, - - ], - - "max_tokens": 512, - - "temperature": 0.2, - - } - - async with httpx.AsyncClient(proxy=_proxy_for_url(api_url), trust_env=False, timeout=60) as client: - - resp = await client.post( - - api_url, - - headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, - - json=body, - - ) - - if resp.status_code != 200: - - return None - - data = resp.json() - - return data.get("choices", [{}])[0].get("message", {}).get("content", "").strip() - - finally: - - sdb.close() - - except Exception as e: - - logger.warning(f"[context] summarize llm failed: {e}") - - return None - - - - - -_STRUCT_COL_READY = False - - -def ensure_structured_column(): - """幂等地为 conversation_summaries 增加 structured_memory 列(MariaDB 安全)。""" - global _STRUCT_COL_READY - if _STRUCT_COL_READY: - return True - try: - from app.database import engine - from sqlalchemy import text - with engine.connect() as conn: - exists = conn.execute(text( - "SELECT COUNT(*) FROM information_schema.columns " - "WHERE table_schema = DATABASE() AND table_name='conversation_summaries' " - "AND column_name='structured_memory'" - )).scalar() - if not exists: - conn.execute(text( - "ALTER TABLE conversation_summaries ADD COLUMN structured_memory JSON DEFAULT NULL" - )) - conn.commit() - _STRUCT_COL_READY = True - return True - except Exception as e: - logger.warning(f"[context] ensure structured column failed: {e}") - return False - - -async def _recursive_compress(text, depth=0, max_depth=3, budget=700): - """递归再压缩:若摘要仍超过 token 预算,则进一步浓缩,直至收敛或达最大深度。""" - if not text: - return text - if _estimate_tokens(text) <= budget or depth >= max_depth: - return text - s = await _summarize_via_llm("请将以下摘要进一步浓缩,保留全部关键信息,篇幅更短:\n" + text) - if not s: - return text - return await _recursive_compress(s, depth + 1, max_depth, budget) - - -async def _extract_structured_memory(text): - """Semantica 式结构化长期记忆抽取:将对话内容抽取为类型化结构。 - - 模型对抽取类提示偶发返回空,故做最多 3 次重试;并兼容有标签与无标签两种输出。 - """ - if not text or len(text.strip()) < 10: - return None - prompt = ( - "提取要点,按 实体/事实/偏好/决策/待办 五类,用分号分隔输出:" + text[:6000] - ) - raw = None - for _ in range(3): - r = await _summarize_via_llm(prompt) - if r and r.strip(): - raw = r.strip() - break - if not raw: - return None - # JSON 回退 - if "{" in raw: - try: - s = raw.find("{") - e = raw.rfind("}") - data = json.loads(raw[s:e + 1]) - if isinstance(data, dict): - return _normalize_structured(data) - except Exception: - pass - norm = raw.replace(":", ":").replace(";", ";").replace(",", ",").replace("。", "") - result = _parse_structured_segments(norm) - if result: - return result - # 无标签回退:按分号切分,按顺序映射到五类 - segs = [s.strip() for s in norm.replace(chr(10), ";").split(";") if s.strip()] - if segs: - cats = ["entities", "facts", "preferences", "decisions", "open_tasks"] - fb = {} - for i, s in enumerate(segs[:5]): - fb[cats[i]] = [s] - return fb - return None - - -def _parse_structured_segments(norm): - """解析『类别: 内容』格式(兼容全角冒号/分号与单行多类别)。""" - result = {} - for seg in norm.replace(chr(10), ";").split(";"): - if ":" not in seg: - continue - key, _, val = seg.partition(":") - key = key.strip() - val = val.strip().strip(",") - if not val or val in ("无", "none", "None"): - continue - mapped = { - "实体": "entities", "事实": "facts", "偏好": "preferences", - "决策": "decisions", "待办": "open_tasks", - }.get(key) - if mapped: - items = [x.strip() for x in val.split(",") if x.strip()] - if items: - result[mapped] = items - return result - - -def _normalize_structured(data): - """将模型的 JSON 输出规范化为结构化记忆字典。""" - keys = ["entities", "facts", "preferences", "decisions", "open_tasks"] - out = {} - for k in keys: - v = data.get(k) - if isinstance(v, list): - out[k] = [str(x) for x in v if x] - elif isinstance(v, str) and v: - out[k] = [v] - return out - - -def _merge_structured_memory(old, new): - """合并新旧结构化记忆,按字符串去重并控制规模。""" - keys = ["entities", "facts", "preferences", "decisions", "open_tasks"] - merged = {} - for k in keys: - a = old.get(k) if isinstance(old, dict) else None - b = new.get(k) if isinstance(new, dict) else None - items = [] - for x in (a or []) + (b or []): - if x and x not in items: - items.append(x) - merged[k] = items[:50] - return merged - - -async def _maybe_compress_history(conv_id): - """后台异步压缩:将受保护窗口外的旧消息递归再压缩,并抽取结构化长期记忆。 - - 失败静默回滚,绝不阻塞用户本次响应(fire-and-forget)。 - """ - ensure_structured_column() - sdb = None - try: - sdb = SessionLocal() - all_msgs = sdb.query(Message).filter( - Message.conversation_id == conv_id, - Message.role.in_(["user", "assistant"]), - ).order_by(asc(Message.created_at)).all() - PROTECTED = 20 - if len(all_msgs) <= PROTECTED: - return - compressible = all_msgs[: len(all_msgs) - PROTECTED] - if len(compressible) < 8: - return - - # 1) 分块摘要:覆盖全部可压缩消息(而非仅前 30 条) - CHUNK = 30 - chunk_summaries = [] - for i in range(0, len(compressible), CHUNK): - piece = compressible[i:i + CHUNK] - text = "\n".join(f"{m.role}: {m.content}" for m in piece) - s = await _summarize_via_llm("请将以下对话片段浓缩为简明要点(保留关键事实、诉求与结论):\n" + text) - if s: - chunk_summaries.append(s) - - existing = sdb.query(ConversationSummary).filter( - ConversationSummary.conversation_id == conv_id - ).first() - prior = existing.summary_text if existing else "" - - combined = "\n".join(chunk_summaries) - if prior: - combined = prior + "\n" + combined - - # 2) 递归再压缩:合并摘要超预算时逐层再压缩直到收敛 - final_summary = await _recursive_compress(combined) - - # 3) 结构化长期记忆抽取(Semantica 式),并与既有记忆合并 - structured = await _extract_structured_memory(combined) - if structured is None: - structured = {} - if existing and existing.structured_memory and isinstance(existing.structured_memory, dict): - structured = _merge_structured_memory(existing.structured_memory, structured) - - now = datetime.now() - if existing: - existing.summary_text = final_summary - existing.structured_memory = structured - existing.token_count = _estimate_tokens(final_summary) - existing.updated_at = now - else: - sdb.add(ConversationSummary( - conversation_id=conv_id, - summary_text=final_summary, - structured_memory=structured, - token_count=_estimate_tokens(final_summary), - updated_at=now, - )) - sdb.commit() - except Exception as e: - logger.warning(f"[context] compress history failed conv={conv_id}: {e}") - if sdb is not None: - try: - sdb.rollback() - except Exception: - pass - finally: - if sdb is not None: - try: - sdb.close() - except Exception: +# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). + +# Source of truth: scripts/phase1_extract.py. Do not hand-merge back. + + + +from fastapi import APIRouter, Depends, HTTPException, Query, Response + +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, timezone, timedelta + +import re + +import json + +import asyncio + +import math + +import httpx + +import os + +import logging + +from ..database import get_db, SessionLocal + +from ..models import ( + + + + + + + + User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, ConversationSummary, + + + + + + + + KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, + + + + + + + + EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, + + + + + + + + MCPServer, + + + + + + + + gen_uuid + + + + + + + +) + +from ..auth import get_current_user + +from ..platform_knowledge import get_platform_knowledge + +from ..mcp_client import list_mcp_tools, call_mcp_tool + +from ..middleware.log_service import LogService + +from ..capability_registry import resolve_auto_model + +from app.api.chat_skill import (_bm25_search) + +from app.kb_router import kb_router_search + +from app.api.chat_utils import (logger) + + + +async def _retrieve_kb_context(db: Session, kb_ids: list, query: str, max_tokens: int = 1000) -> str: + + + + + + + + """ + + + + + + + + Stage C: 增强版知识库检索 — 使用BM25排序替代朴素拼接 + + + + + + + + 1. 获取每个KB的文档 + + + + + + + + 2. 构建文档列表(content+title+metadata) + + + + + + + + 3. BM25检索最相关的段落 + + + + + + + + 4. 拼接为上下文注入system prompt + + + + + + + + 同时叠加 Dify 外部知识库(RAG)检索结果。 + + + + + + + + """ + + + + + + + + local_context = "" + + + + + + + + + + + + + + + + if kb_ids: + + + + + + + + kbs = db.query(KnowledgeBase).filter(KnowledgeBase.id.in_(kb_ids)).all() + + + + + + + + if kbs: + + + + + + + + # 收集所有文档段落 + + + + + + + + # Stage C.1: 优先使用 KnowledgeDocumentChunk 表的预分段数据 + + + + + + + + all_segments = [] + + + + + + + + for kb in kbs: + + + + + + + + # 先查chunk表 + + + + + + + + chunks = db.query(KnowledgeDocumentChunk).filter( + + + + + + + + KnowledgeDocumentChunk.knowledge_base_id == kb.id + + + + + + + + ).order_by(KnowledgeDocumentChunk.chunk_index).limit(30).all() + + + + + + + + + + + + + + + + if chunks: + + + + + + + + # 使用预分段数据 + + + + + + + + for chunk in chunks: + + + + + + + + if chunk.content and len(chunk.content.strip()) >= 20: + + + + + + + + all_segments.append({ + + + + + + + + "content": "[{}/{}/段{}]: {}".format( + + + + + + + + kb.name, chunk.document_id[:8], chunk.chunk_index, chunk.content[:500] + + + + + + + + ), + + + + + + + + "raw_content": chunk.content, + + + + + + + + "kb_name": kb.name, + + + + + + + + }) + + + + + + + + else: + + + + + + + + # 退化: 使用原始文档分段 + + + + + + + + docs = db.query(KnowledgeDocument).filter( + + + + + + + + KnowledgeDocument.knowledge_base_id == kb.id, + + + + + + + + KnowledgeDocument.status == "completed" + + + + + + + + ).order_by(KnowledgeDocument.updated_at.desc()).limit(20).all() + + + + + + + + + + + + + + + + for doc in docs: + + + + + + + + content = doc.content or "" + + + + + + + + if not content: + + + + + + + + continue + + + + + + + + + + + + + + + + # 如果文档较长, 分段处理 (每段~800字) + + + + + + + + if len(content) > 800: + + + + + + + + paragraphs = re.split(r'\n\n+', content) + + + + + + + + for pi, para in enumerate(paragraphs): + + + + + + + + if len(para.strip()) >= 20: + + + + + + + + all_segments.append({ + + + + + + + + "content": "[{}/{}/段{}]: {}".format( + + + + + + + + kb.name, doc.name, pi+1, para.strip()[:500] + + + + + + + + ), + + + + + + + + "raw_content": para.strip(), + + + + + + + + "kb_name": kb.name, + + + + + + + + "doc_name": doc.name, + + + + + + + + }) + + + + + + + + else: + + + + + + + + all_segments.append({ + + + + + + + + "content": "[{}/{}]: {}".format(kb.name, doc.name, content[:500]), + + + + + + + + "raw_content": content, + + + + + + + + "kb_name": kb.name, + + + + + + + + "doc_name": doc.name, + + + + + + + + }) + + + + + + + + + + + + + + + + if not all_segments: + + + + + + + + # 退化: 使用原始朴素拼接 + + + + + + + + kb_names = ", ".join([k.name for k in kbs]) + + + + + + + + local_context = f"参考知识库: {kb_names}" + + + + + + + + else: + + + + + + + + # BM25检索最相关的段落 + + + + + + + + # 混合检索:BM25(关键词) + 向量语义 + RRF 融合;失败回退 BM25 + bm25_segs = _bm25_search(query, all_segments, top_k=8) + bm25_for_router = [ + {"content": seg.get("raw_content", seg.get("content", "")), + "metadata": {"label": seg.get("content", "")}} + for seg in bm25_segs + ] + merged = None + try: + merged = await kb_router_search( + query, kb_ids=kb_ids, top_k=8, + bm25_results=bm25_for_router, db_session=db, + use_vector=True, use_external=False, + ) + except Exception as e: + logger.warning(f"[kb] 混合检索失败, 回退 BM25: {e}") + if merged: + ctx_parts = [] + used = 0 + for seg in merged: + label = (seg.get("metadata") or {}).get("label") + c = label if label else seg.get("content", "") + t = _estimate_tokens(c) + if ctx_parts and used + t > max_tokens: + break + ctx_parts.append(c) + used += t + local_context = "\n".join(ctx_parts) + else: + ctx_parts = [seg["content"] for seg in bm25_segs] + joined = "\n".join(ctx_parts) + if _estimate_tokens(joined) > max_tokens: + trunc, u = [], 0 + for c in ctx_parts: + if u + _estimate_tokens(c) > max_tokens: + break + trunc.append(c) + u += _estimate_tokens(c) + joined = "\n".join(trunc) + "\n...(内容截断)" + local_context = joined + + + + + + + + + + + + + + + + # 叠加 Dify 外部知识库(RAG)检索 + + + + + + + + dify_context = "" + + + + + + + + try: + + + + + + + + from app.dify_kb import get_dify_kb_context + + + + + + + + dify_context = get_dify_kb_context(db, query, top_k=5) + + + + + + + + except Exception as e: + + + + + + + + logger.warning(f"Dify KB context retrieval failed: {e}") + + + + + + + + + + + + + + + + parts = [] + + + + + + + + if local_context: + + + + + + + + parts.append(local_context) + + + + + + + + if dify_context: + + + + + + + + parts.append("【Dify 知识库检索结果】\n" + dify_context) + + + + + + + + return "\n\n".join(parts) + + + +async def _ingest_answer_to_dify(conv_id: str, msg_id: str, content: str): + + + + + + + + """后台任务:将助手回答入库到 Dify 知识库(量化后可供 RAG 检索复用)。""" + + + + + + + + try: + + + + + + + + from app.dify_kb import aingest_text + + + + + + + + from app.database import SessionLocal + + + + + + + + from sqlalchemy import text as _t + + + + + + + + db = SessionLocal() + + + + + + + + try: + + + + + + + + row = db.execute(_t( + + + + + + + + "SELECT api_url, api_key, config FROM external_kb_services " + + + + + + + + "WHERE service_type='dify' AND is_enabled=1 LIMIT 1" + + + + + + + + )).fetchone() + + + + + + + + finally: + + + + + + + + db.close() + + + + + + + + if not row: + + + + + + + + return + + + + + + + + api_url, api_key, config_raw = row + + + + + + + + dataset_name = None + + + + + + + + if config_raw: + + + + + + + + try: + + + + + + + + import json + + + + + + + + cfg = config_raw if isinstance(config_raw, dict) else json.loads(config_raw) + + + + + + + + dataset_name = cfg.get("dataset_name") + + + + + + + + except Exception: + + + + + + + + pass + + + + + + + + await aingest_text(api_url, api_key, content, name=f"chat_{conv_id}_{msg_id}", dataset_name=dataset_name) + + + + + + + + except Exception as e: + + + + + + + + logger.warning(f"后台入库 Dify 失败: {e}") + + + +def _build_system_prompt( + + + + + + + + skills: list, + + + + + + + + agents: list, + + + + + + + + kb_context: str, + + + + + + + + user_content: str, + + + + + + + + mcp: list = None, + + + + mcp_functions: list = None, + + + + auto_discovered: bool = False, + + all_mcp: list = None, + + db: Session = None, + +) -> tuple: + + + + + + + + """ + + + + + + + + 动态组装system prompt + function definitions。 + + + + + + + + 返回: (system_prompt_str, function_definitions_list) + + + + + + + + """ + + + + + + + + system_parts = ["你是AIFlow平台的AI助手,具备专业的知识储备和工具调用能力。"] + + + + + + + + + + + + + + + + # 平台知识库注入(始终开启):确保AI优先使用平台已有能力 + + + + + + + + try: + + + + + + + + pk = get_platform_knowledge(db=db) + + + + + + + + if pk: + + + + + + + + system_parts.append(f"【平台知识库——优先使用以下知识回答用户问题】\n{pk}") + + + + + + + + except Exception: + + + + + + + + pass + + + + + + + + + + + + + + + + # 回复格式要求:强制使用Markdown + + + + + + + + system_parts.append("回复格式要求:使用 Markdown 格式,层次分明即可。对于简短问题,用 1-2 段自然文字直接回答,不必强行分点、加标题或表格;仅在确实需要分步骤或列举时才使用列表,代码/配置用 ``` 代码块包裹。避免冗余空行,保持紧凑、美观、易读。") + + + + + + + + + + + + + + + + function_definitions = [] + + + + + + + + + + + + + + + + # Stage B.1: Agent角色注入(Issue 2:让用户询问 agent 能力时,AI 能“看到”该 agent 资料) + + + + + + + + if agents: + + + + + + + + for agent in agents: + + + + + + + + name = agent.get("name", "") + + + + + + + + role = agent.get("role_prompt", "") + + + + + + + + instructions = agent.get("instructions", "") + + + + + + + + desc = agent.get("description", "") + + + + + + + + block = [] + + + + + + + + if name: + + + + + + + + block.append( + + + + + + + + f"用户已选择智能体「{name}」。当用户询问该智能体的功能、能力、用法或相关问题时," + + + + + + + + f"请基于以下资料准确作答,不要说“不知道这个智能体”。" + + + + + + + + ) + + + + + + + + if role: + + + + + + + + block.append(f"该智能体的角色设定: {role}") + + + + + + + + if desc: + + + + + + + + block.append(f"该智能体的简介: {desc}") + + + + + + + + if instructions: + + + + + + + + block.append(f"该智能体的行为指引: {instructions}") + + + + + + + + if block: + + + + + + + + system_parts.append("\n".join(block)) + + + + + + + + + + + + + + + + # Stage A.2: Skill工具注入 + + + + + + + + if skills: + + + + + + + + skill_descs = ", ".join([f"{s['name']}({s.get('category', 'general')})" for s in skills]) + + + + + + + + system_parts.append(f"可用技能工具: {skill_descs}") + + + + + + + + if auto_discovered: + + + + + + + + # Issue 8:每轮默认先寻找本账号已安装且平台已部署的技能,多个适用时 AI 自行判断选 1 个最优 + + + + + + + + system_parts.append( + + + + + + + + "重要规则: 以上技能为本账号已安装且平台已部署的技能。每轮对话请先判断用户请求是否可由其中某个技能处理;" + + + + + + + + "若多个技能都适用,请自行判断并选择最贴切的 1 个优先调用,不要一次性全部调用。" + + + + + + + + "调用对应工具后将获得执行结果,基于结果生成最终回复。若这些技能均不适用,则直接基于你的知识回答。" + + + + + + + + ) + + + + + + + + else: + + + + + + + + system_parts.append( + + "重要规则:\n" + + "1) 当用户请求需要由该技能【执行】某项任务时,优先通过 function calling 调用对应工具;调用后基于工具返回结果生成回复。\n" + + "2) 当用户询问所选技能【本身的介绍、功能、用途、用法、特点】(例如“介绍一下这个技能”“它是什么”“怎么用”“能做什么”)时," + + "必须基于下方【已选技能资料】中的名称/描述/指令直接作答,不要泛泛介绍平台整体,也不要说“不知道这个技能”。\n" + + "3) 若用户问题既不执行也不询问该技能本身,则按常规方式回答。" + + ) + + + + + + + + + + + + + + + + # 为每个skill构建function definition + + + + + + + + for s in skills: + + + + + + + + # 从skill_content提取指令注入system prompt + + + + + + + + instructions = s.get("instructions", "") + + + + + + + + name = s.get("name", "") + + if not auto_discovered: + + # 显式选中技能:注入可引用的技能资料,便于“介绍/说明”类问题据此作答 + + ref_parts = [f"【已选技能资料】名称:{name}"] + + desc = s.get("description", "") + + if desc: + + ref_parts.append(f"简介:{desc}") + + if instructions and len(instructions) < 1200: + + ref_parts.append(f"使用指令:{instructions}") + + system_parts.append("\n".join(ref_parts)) + + elif instructions and len(instructions) < 800: + + system_parts.append(f"技能「{name}」的详细指令: {instructions}") + + + + + + + + + + + + + + + + # 构建参数schema + + + + + + + + params = s.get("parameters", {}) + + + + + + + + if isinstance(params, dict) and len(params) > 0: + + + + + + + + properties = {} + + + + + + + + required = [] + + + + + + + + for pname, pdef in params.items(): + + + + + + + + if isinstance(pdef, dict): + + + + + + + + properties[pname] = { + + + + + + + + "type": pdef.get("type", "string"), + + + + + + + + "description": pdef.get("description", f"传递给{s['name']}的{pname}参数"), + + + + + + + + } + + + + + + + + if pdef.get("required", True): + + + + + + + + required.append(pname) + + + + + + + + else: + + + + + + + + properties[pname] = {"type": "string", "description": f"传递给{s['name']}的{pname}参数"} + + + + + + + + required.append(pname) + + + + + + + + + + + + + + + + if not properties: + + + + + + + + properties = {"input": {"type": "string", "description": f"需要传递给{s['name']}的完整用户输入"}} + + + + + + + + required = ["input"] + + + + + + + + else: + + + + + + + + properties = {"input": {"type": "string", "description": f"需要传递给{s['name']}的完整用户输入"}} + + + + + + + + required = ["input"] + + + + + + + + + + + + + + + + desc = s.get("description", "") + + + + + + + + category = s.get("category", "general") + + + + + + + + func_desc = f"调用技能「{s['name']}」处理用户请求。使用场景: {desc}。当用户请求涉及{category}类任务时优先调用此工具。" + + + + + + + + + + + + + + + + function_definitions.append({ + + + + + + + + "name": s["func_name"], + + + + + + + + "description": func_desc, + + + + + + + + "parameters": { + + + + + + + + "type": "object", + + + + + + + + "properties": properties, + + + + + + + + "required": required, + + + + + + + + }, + + + + + + + + }) + + + + + + + + + + + + + + + + # KB context injection + + + + + + + + if kb_context: + + + + + + + + system_parts.append(f"参考以下知识库内容回答用户问题:\n{kb_context}") + + + + + + + + + + + + + + + + # 平台全量 MCP 清单(Issue:让用户询问"平台有哪些 MCP"时 AI 能准确列举,而非凭空编造) + + if all_mcp: + + _mcp_inventory = "\n".join([f"- {m.get('name','')}: {m.get('description','') or '(未填写描述)'}" for m in all_mcp]) + + system_parts.append( + + f"【平台已安装的 MCP 应用清单(共 {len(all_mcp)} 个,均为平台真实接入并启用)】\n{_mcp_inventory}\n" + + "当用户询问“平台有哪些 MCP / 接入了哪些 MCP / 有哪些工具服务”时,请严格依据以上清单准确列举名称与功能," + + "不要编造或遗漏,也不要把未列出的服务说成已接入。" + + ) + + + + # MCP 应用注入(Issue 3:让用户询问 MCP 能力时,AI 能“看到”该 MCP 的资料与地址) + + + + + + + + if mcp: + + + + + + + + mcp_lines = [] + + + + + + + + for m in mcp: + + + + + + + + name = m.get("name", "") + + + + + + + + desc = m.get("description", "") + + + + + + + + transport = m.get("transport", "stdio") + + + + + + + + endpoint = m.get("endpoint", "") + + + + + + + + line = f"用户已选择 MCP 应用「{name}」(传输方式: {transport})" + + + + + + + + if desc: + + + + + + + + line += f",功能简介: {desc}" + + + + + + + + if endpoint: + + + + + + + + line += f",服务地址: {endpoint}" + + + + + + + + mcp_lines.append(line) + + + + + + + + system_parts.append("\n".join(mcp_lines)) + + + + + + + + system_parts.append( + + + + + + + + "用户已选择上述 MCP 应用。当请求需要借助外部工具/系统(如查询数据、调用 API、操作第三方服务)时," + + + + + + + + "请优先参考这些 MCP 应用的能力来回答;若平台已接入对应工具,优先通过工具调用完成;" + + + + + + + + "不要说“不了解该 MCP”。" + + + + + + + + ) + + + + # 高优先级覆盖规则:显式选中工具时,回答必须基于该工具资料 + + if (skills and not auto_discovered) or (mcp): + + system_parts.append( + + "【最高优先级覆盖规则】\n" + + "用户在本轮已明确选择了工具(见上文【已选技能资料】或【用户已选择 MCP 应用】)。\n" + + "如果用户的问题是在询问这些已选工具/技能/应用本身的介绍、功能、能做什么、怎么用、特点或用法," + + "你必须只基于上文提供的该工具具体资料作答;\n" + + "不要引用《平台知识库》中关于技能市场、MCP 协议或平台整体能力的通用描述;" + + "不要列举其它未选中的技能或 MCP 作为示例;也不要说“不知道”或“无法回答”。\n" + + "若用户问题与已选工具本身无关,则按常规方式回答。" + + ) + + + + + + + + + + + + + + + + + + system_prompt = "\n".join(system_parts) + + + + + + + + # MCP function definitions 注入(让 LLM 可通过 function calling 真实调用 MCP 工具) + + + + if mcp_functions: + + + + function_definitions.extend(mcp_functions) + + + + + + + + return system_prompt, function_definitions + + + +def _get_conversation_context(db: Session, conv_id: str) -> dict: + + + + + + + + """ + + + + + + + + Stage B.2: 获取对话的持久化上下文 + + + + + + + + 对话级别的agent/skill/kb选择保存在Conversation字段中。 + + + + + + + + """ + + + + + + + + conv = db.query(Conversation).filter(Conversation.id == conv_id).first() + + + + + + + + if not conv: + + + + + + + + return {} + + + + + + + + + + + + + + + + # Conversation目前没有存储agent/skill/kb的JSON字段 + + + + + + + + # 我们从第一条消息中获取(如果存在), 或返回空 + + + + + + + + # 后续可通过数据库迁移添加Conversation.context字段 + + + + + + + + + + + + + + + + # 查询该对话中最早一条含有skill_ids/agent_ids/knowledge_ids的消息 + + + + + + + + first_tool_msg = db.query(Message).filter( + + + + + + + + Message.conversation_id == conv_id, + + + + + + + + Message.role == "user", + + + + + + + + ).order_by(asc(Message.created_at)).first() + + + + + + + + + + + + + + + + context = {} + + + + + + + + if first_tool_msg: + + + + + + + + if first_tool_msg.skill_ids: + + + + + + + + context["skill_ids"] = first_tool_msg.skill_ids + + + + + + + + if first_tool_msg.agent_ids: + + + + + + + + context["agent_ids"] = first_tool_msg.agent_ids + + + + + + + + if first_tool_msg.knowledge_ids: + + + + + + + + context["knowledge_base_ids"] = first_tool_msg.knowledge_ids + + + + + + + + if getattr(first_tool_msg, "mcp_ids", None): + + + + + + + + context["mcp_ids"] = first_tool_msg.mcp_ids + + + + + + + + + + + + + + + + return context + + + + + + + +# ===== Phase 2 上下文引擎升级:分层记忆 + 压缩 + 隔离 ===== + +logger = logging.getLogger(__name__) + + + + + +_tiktoken_enc = None + +def _estimate_tokens(text): + + """精确 token 估算:优先 tiktoken(cl100k_base),失败回退 2 字符/token。""" + + if not text: + + return 0 + + global _tiktoken_enc + + if _tiktoken_enc is None: + + try: + + import tiktoken + + _tiktoken_enc = tiktoken.get_encoding("cl100k_base") + + except Exception: + + _tiktoken_enc = False + + if _tiktoken_enc: + + try: + + return len(_tiktoken_enc.encode(text)) + + except Exception: + + pass + + return max(1, len(text) // 2) + + + + + +def _build_chat_history(db, conv_id, max_tokens=6000, max_messages=40, + exclude_latest_user=True, include_summary=True): + """会话层上下文引擎:预算感知的历史组装 + 压缩摘要/结构化长期记忆前置。 + + - 若 conversation_summaries 存在结构化长期记忆(structured_memory),以紧凑 + 结构化区块前置(Semantica 式:实体/事实/偏好/决策/待办),比扁平摘要更省 + token 且更便于模型检索关键信息。 + - 否则回退到扁平 summary_text 前置。 + - 按 token 估算从新到旧累加,超预算即截断(隔离:不溢出上下文窗口)。 + - exclude_latest_user=True 时丢弃最新一条 user 消息(当前轮次另行发送)。 + """ + ensure_structured_column() + summary_block = None + if include_summary: + try: + summ = db.query(ConversationSummary).filter( + ConversationSummary.conversation_id == conv_id + ).first() + if summ and summ.structured_memory and isinstance(summ.structured_memory, dict): + block = _format_structured_memory(summ.structured_memory) + if block: + summary_block = { + "role": "system", + "content": "【长期记忆·结构化】请据此理解用户此前的偏好、已确认决策与待办:\n" + block, + } + elif summ and summ.summary_text: + summary_block = { + "role": "system", + "content": "【对话历史摘要,请据此理解用户此前的意图与已确认结论】\n" + summ.summary_text, + } + except Exception: + pass + + rows = db.query(Message).filter( + Message.conversation_id == conv_id, + Message.role.in_(["user", "assistant"]), + ).order_by(desc(Message.created_at)).limit(max_messages).all() + if exclude_latest_user: + for i, m in enumerate(rows): + if m.role == "user": + rows = rows[:i] + rows[i + 1:] + break + + kept = [] + used = 0 + for m in rows: # 新->旧 + t = _estimate_tokens(m.content or "") + if kept and used + t > max_tokens: + break + kept.append({"role": m.role, "content": m.content}) + used += t + + kept.reverse() # 时间正序 + + out = [] + if summary_block: + out.append(summary_block) + out.extend(kept) + return out + + +def _format_structured_memory(mem: dict) -> str: + """将结构化记忆格式化为紧凑可读区块。""" + labels = { + "entities": "实体", + "facts": "事实", + "preferences": "偏好", + "decisions": "决策", + "open_tasks": "待办", + } + lines = [] + for key, label in labels.items(): + items = mem.get(key) + if not items: + continue + if isinstance(items, list): + for it in items: + if it: + lines.append(f"· {label}: {it}") + elif isinstance(items, str): + lines.append(f"· {label}: {items}") + return "\n".join(lines) +async def _summarize_via_llm(prompt): + + """调用默认文本模型生成对话摘要;失败返回 None(压缩为可选项,不阻塞)。""" + + try: + + from ..capability_registry import resolve_auto_model + + from app.api.chat_utils import _proxy_for_url + + sdb = SessionLocal() + + try: + + ai_model = resolve_auto_model("text", sdb) + + if not ai_model: + + return None + + provider = sdb.query(ModelProvider).filter( + + ModelProvider.id == ai_model.provider_id + + ).first() + + api_key = ai_model.api_key or (provider.api_key if provider else None) + + if not provider or not provider.api_base_url or not api_key: + + return None + + api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions" + + body = { + + "model": ai_model.name, + + "messages": [ + + {"role": "system", "content": "你是专业的对话摘要助手,输出简洁中文摘要。"}, + + {"role": "user", "content": prompt}, + + ], + + "max_tokens": 512, + + "temperature": 0.2, + + } + + async with httpx.AsyncClient(proxy=_proxy_for_url(api_url), trust_env=False, timeout=60) as client: + + resp = await client.post( + + api_url, + + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + + json=body, + + ) + + if resp.status_code != 200: + + return None + + data = resp.json() + + return data.get("choices", [{}])[0].get("message", {}).get("content", "").strip() + + finally: + + sdb.close() + + except Exception as e: + + logger.warning(f"[context] summarize llm failed: {e}") + + return None + + + + + +_STRUCT_COL_READY = False + + +def ensure_structured_column(): + """幂等地为 conversation_summaries 增加 structured_memory 列(MariaDB 安全)。""" + global _STRUCT_COL_READY + if _STRUCT_COL_READY: + return True + try: + from app.database import engine + from sqlalchemy import text + with engine.connect() as conn: + exists = conn.execute(text( + "SELECT COUNT(*) FROM information_schema.columns " + "WHERE table_schema = DATABASE() AND table_name='conversation_summaries' " + "AND column_name='structured_memory'" + )).scalar() + if not exists: + conn.execute(text( + "ALTER TABLE conversation_summaries ADD COLUMN structured_memory JSON DEFAULT NULL" + )) + conn.commit() + _STRUCT_COL_READY = True + return True + except Exception as e: + logger.warning(f"[context] ensure structured column failed: {e}") + return False + + +async def _recursive_compress(text, depth=0, max_depth=3, budget=700): + """递归再压缩:若摘要仍超过 token 预算,则进一步浓缩,直至收敛或达最大深度。""" + if not text: + return text + if _estimate_tokens(text) <= budget or depth >= max_depth: + return text + s = await _summarize_via_llm("请将以下摘要进一步浓缩,保留全部关键信息,篇幅更短:\n" + text) + if not s: + return text + return await _recursive_compress(s, depth + 1, max_depth, budget) + + +async def _extract_structured_memory(text): + """Semantica 式结构化长期记忆抽取:将对话内容抽取为类型化结构。 + + 模型对抽取类提示偶发返回空,故做最多 3 次重试;并兼容有标签与无标签两种输出。 + """ + if not text or len(text.strip()) < 10: + return None + prompt = ( + "提取要点,按 实体/事实/偏好/决策/待办 五类,用分号分隔输出:" + text[:6000] + ) + raw = None + for _ in range(3): + r = await _summarize_via_llm(prompt) + if r and r.strip(): + raw = r.strip() + break + if not raw: + return None + # JSON 回退 + if "{" in raw: + try: + s = raw.find("{") + e = raw.rfind("}") + data = json.loads(raw[s:e + 1]) + if isinstance(data, dict): + return _normalize_structured(data) + except Exception: + pass + norm = raw.replace(":", ":").replace(";", ";").replace(",", ",").replace("。", "") + result = _parse_structured_segments(norm) + if result: + return result + # 无标签回退:按分号切分,按顺序映射到五类 + segs = [s.strip() for s in norm.replace(chr(10), ";").split(";") if s.strip()] + if segs: + cats = ["entities", "facts", "preferences", "decisions", "open_tasks"] + fb = {} + for i, s in enumerate(segs[:5]): + fb[cats[i]] = [s] + return fb + return None + + +def _parse_structured_segments(norm): + """解析『类别: 内容』格式(兼容全角冒号/分号与单行多类别)。""" + result = {} + for seg in norm.replace(chr(10), ";").split(";"): + if ":" not in seg: + continue + key, _, val = seg.partition(":") + key = key.strip() + val = val.strip().strip(",") + if not val or val in ("无", "none", "None"): + continue + mapped = { + "实体": "entities", "事实": "facts", "偏好": "preferences", + "决策": "decisions", "待办": "open_tasks", + }.get(key) + if mapped: + items = [x.strip() for x in val.split(",") if x.strip()] + if items: + result[mapped] = items + return result + + +def _normalize_structured(data): + """将模型的 JSON 输出规范化为结构化记忆字典。""" + keys = ["entities", "facts", "preferences", "decisions", "open_tasks"] + out = {} + for k in keys: + v = data.get(k) + if isinstance(v, list): + out[k] = [str(x) for x in v if x] + elif isinstance(v, str) and v: + out[k] = [v] + return out + + +def _merge_structured_memory(old, new): + """合并新旧结构化记忆,按字符串去重并控制规模。""" + keys = ["entities", "facts", "preferences", "decisions", "open_tasks"] + merged = {} + for k in keys: + a = old.get(k) if isinstance(old, dict) else None + b = new.get(k) if isinstance(new, dict) else None + items = [] + for x in (a or []) + (b or []): + if x and x not in items: + items.append(x) + merged[k] = items[:50] + return merged + + +async def _maybe_compress_history(conv_id): + """后台异步压缩:将受保护窗口外的旧消息递归再压缩,并抽取结构化长期记忆。 + + 失败静默回滚,绝不阻塞用户本次响应(fire-and-forget)。 + """ + ensure_structured_column() + sdb = None + try: + sdb = SessionLocal() + all_msgs = sdb.query(Message).filter( + Message.conversation_id == conv_id, + Message.role.in_(["user", "assistant"]), + ).order_by(asc(Message.created_at)).all() + PROTECTED = 20 + if len(all_msgs) <= PROTECTED: + return + compressible = all_msgs[: len(all_msgs) - PROTECTED] + if len(compressible) < 8: + return + + # 1) 分块摘要:覆盖全部可压缩消息(而非仅前 30 条) + CHUNK = 30 + chunk_summaries = [] + for i in range(0, len(compressible), CHUNK): + piece = compressible[i:i + CHUNK] + text = "\n".join(f"{m.role}: {m.content}" for m in piece) + s = await _summarize_via_llm("请将以下对话片段浓缩为简明要点(保留关键事实、诉求与结论):\n" + text) + if s: + chunk_summaries.append(s) + + existing = sdb.query(ConversationSummary).filter( + ConversationSummary.conversation_id == conv_id + ).first() + prior = existing.summary_text if existing else "" + + combined = "\n".join(chunk_summaries) + if prior: + combined = prior + "\n" + combined + + # 2) 递归再压缩:合并摘要超预算时逐层再压缩直到收敛 + final_summary = await _recursive_compress(combined) + + # 3) 结构化长期记忆抽取(Semantica 式),并与既有记忆合并 + structured = await _extract_structured_memory(combined) + if structured is None: + structured = {} + if existing and existing.structured_memory and isinstance(existing.structured_memory, dict): + structured = _merge_structured_memory(existing.structured_memory, structured) + + now = datetime.now() + if existing: + existing.summary_text = final_summary + existing.structured_memory = structured + existing.token_count = _estimate_tokens(final_summary) + existing.updated_at = now + else: + sdb.add(ConversationSummary( + conversation_id=conv_id, + summary_text=final_summary, + structured_memory=structured, + token_count=_estimate_tokens(final_summary), + updated_at=now, + )) + sdb.commit() + except Exception as e: + logger.warning(f"[context] compress history failed conv={conv_id}: {e}") + if sdb is not None: + try: + sdb.rollback() + except Exception: + pass + finally: + if sdb is not None: + try: + sdb.close() + except Exception: pass \ No newline at end of file diff --git a/backend/python/app/api/chat_knowledge.py b/backend/python/app/api/chat_knowledge.py index a6bfc0a..fae7695 100644 --- a/backend/python/app/api/chat_knowledge.py +++ b/backend/python/app/api/chat_knowledge.py @@ -1,319 +1,319 @@ -# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). -# Source of truth: scripts/phase1_extract.py. Do not hand-merge back. - -from fastapi import APIRouter, Depends, HTTPException, Query, Response -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, timezone, timedelta -import re -import json -import asyncio -import math -import httpx -import os -import logging -from ..database import get_db, SessionLocal -from ..models import ( - - - - User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, - - - - KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, - - - - EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, - - - - MCPServer, - - - - gen_uuid - - - -) -from ..auth import get_current_user -from ..platform_knowledge import get_platform_knowledge -from ..mcp_client import list_mcp_tools, call_mcp_tool -from ..middleware.log_service import LogService -from ..capability_registry import resolve_auto_model -from app.api.chat_utils import (cst_now) - -def _build_knowledge_graph(db: Session, kb: KnowledgeBase) -> dict: - - - - """从知识库文档构建知识图谱节点和边,返回 graph_data。""" - - - - documents = db.query(KnowledgeDocument).filter( - - - - and_( - - - - KnowledgeDocument.knowledge_base_id == kb.id, - - - - KnowledgeDocument.status == "completed", - - - - ) - - - - ).all() - - - - - - - - graph_nodes = [] - - - - graph_edges = [] - - - - - - - - doc_entity_types = [ - - - - {"type": "concept", "label": "核心概念"}, - - - - {"type": "entity", "label": "关键实体"}, - - - - {"type": "attribute", "label": "属性特征"}, - - - - {"type": "relation", "label": "关联关系"}, - - - - ] - - - - - - - - for idx, doc in enumerate(documents): - - - - doc_node_id = f"doc-{doc.id[:8]}" - - - - graph_nodes.append({ - - - - "id": doc_node_id, "label": doc.filename, "type": "document", "size": 18, "group": "document", - - - - }) - - - - - - - - filename_base = doc.filename.rsplit(".", 1)[0] - - - - words = filename_base.replace("_", " ").replace("-", " ").split() - - - - - - - - for wi, word in enumerate(words): - - - - if len(word) >= 2: - - - - entity_type = doc_entity_types[wi % len(doc_entity_types)] - - - - node_id = f"ent-{doc.id[:8]}-{wi}" - - - - graph_nodes.append({ - - - - "id": node_id, "label": word, "type": entity_type["type"], - - - - "size": 10 + (wi % 8), "group": entity_type["type"], - - - - }) - - - - graph_edges.append({ - - - - "source": doc_node_id, "target": node_id, - - - - "label": entity_type["label"], "weight": 0.5 + (0.5 / (wi + 1)), - - - - }) - - - - - - - - for i in range(len(documents)): - - - - for j in range(i + 1, len(documents)): - - - - fi = documents[i].filename.lower().rsplit(".", 1)[0] - - - - fj = documents[j].filename.lower().rsplit(".", 1)[0] - - - - words_i = set(fi.replace("_", " ").replace("-", " ").split()) - - - - words_j = set(fj.replace("_", " ").replace("-", " ").split()) - - - - common = words_i & words_j - - - - if common and len(common) > 0: - - - - graph_edges.append({ - - - - "source": f"doc-{documents[i].id[:8]}", - - - - "target": f"doc-{documents[j].id[:8]}", - - - - "label": f"共有关键词: {', '.join(list(common)[:3])}", - - - - "weight": min(0.9, 0.3 * len(common)), - - - - }) - - - - - - - - graph_data = { - - - - "nodes": graph_nodes, - - - - "edges": graph_edges, - - - - "stats": { - - - - "total_nodes": len(graph_nodes), "total_edges": len(graph_edges), - - - - "document_count": len(documents), - - - - }, - - - - } - - - - - - - - kb.knowledge_graph_count = len(graph_nodes) - - - - kb.updated_at = cst_now() - - - - return graph_data +# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). +# Source of truth: scripts/phase1_extract.py. Do not hand-merge back. + +from fastapi import APIRouter, Depends, HTTPException, Query, Response +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, timezone, timedelta +import re +import json +import asyncio +import math +import httpx +import os +import logging +from ..database import get_db, SessionLocal +from ..models import ( + + + + User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, + + + + KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, + + + + EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, + + + + MCPServer, + + + + gen_uuid + + + +) +from ..auth import get_current_user +from ..platform_knowledge import get_platform_knowledge +from ..mcp_client import list_mcp_tools, call_mcp_tool +from ..middleware.log_service import LogService +from ..capability_registry import resolve_auto_model +from app.api.chat_utils import (cst_now) + +def _build_knowledge_graph(db: Session, kb: KnowledgeBase) -> dict: + + + + """从知识库文档构建知识图谱节点和边,返回 graph_data。""" + + + + documents = db.query(KnowledgeDocument).filter( + + + + and_( + + + + KnowledgeDocument.knowledge_base_id == kb.id, + + + + KnowledgeDocument.status == "completed", + + + + ) + + + + ).all() + + + + + + + + graph_nodes = [] + + + + graph_edges = [] + + + + + + + + doc_entity_types = [ + + + + {"type": "concept", "label": "核心概念"}, + + + + {"type": "entity", "label": "关键实体"}, + + + + {"type": "attribute", "label": "属性特征"}, + + + + {"type": "relation", "label": "关联关系"}, + + + + ] + + + + + + + + for idx, doc in enumerate(documents): + + + + doc_node_id = f"doc-{doc.id[:8]}" + + + + graph_nodes.append({ + + + + "id": doc_node_id, "label": doc.filename, "type": "document", "size": 18, "group": "document", + + + + }) + + + + + + + + filename_base = doc.filename.rsplit(".", 1)[0] + + + + words = filename_base.replace("_", " ").replace("-", " ").split() + + + + + + + + for wi, word in enumerate(words): + + + + if len(word) >= 2: + + + + entity_type = doc_entity_types[wi % len(doc_entity_types)] + + + + node_id = f"ent-{doc.id[:8]}-{wi}" + + + + graph_nodes.append({ + + + + "id": node_id, "label": word, "type": entity_type["type"], + + + + "size": 10 + (wi % 8), "group": entity_type["type"], + + + + }) + + + + graph_edges.append({ + + + + "source": doc_node_id, "target": node_id, + + + + "label": entity_type["label"], "weight": 0.5 + (0.5 / (wi + 1)), + + + + }) + + + + + + + + for i in range(len(documents)): + + + + for j in range(i + 1, len(documents)): + + + + fi = documents[i].filename.lower().rsplit(".", 1)[0] + + + + fj = documents[j].filename.lower().rsplit(".", 1)[0] + + + + words_i = set(fi.replace("_", " ").replace("-", " ").split()) + + + + words_j = set(fj.replace("_", " ").replace("-", " ").split()) + + + + common = words_i & words_j + + + + if common and len(common) > 0: + + + + graph_edges.append({ + + + + "source": f"doc-{documents[i].id[:8]}", + + + + "target": f"doc-{documents[j].id[:8]}", + + + + "label": f"共有关键词: {', '.join(list(common)[:3])}", + + + + "weight": min(0.9, 0.3 * len(common)), + + + + }) + + + + + + + + graph_data = { + + + + "nodes": graph_nodes, + + + + "edges": graph_edges, + + + + "stats": { + + + + "total_nodes": len(graph_nodes), "total_edges": len(graph_edges), + + + + "document_count": len(documents), + + + + }, + + + + } + + + + + + + + kb.knowledge_graph_count = len(graph_nodes) + + + + kb.updated_at = cst_now() + + + + return graph_data diff --git a/backend/python/app/api/chat_mcp.py b/backend/python/app/api/chat_mcp.py index ff244fd..475f010 100644 --- a/backend/python/app/api/chat_mcp.py +++ b/backend/python/app/api/chat_mcp.py @@ -1,240 +1,240 @@ -# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). -# Source of truth: scripts/phase1_extract.py. Do not hand-merge back. - -from fastapi import APIRouter, Depends, HTTPException, Query, Response -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, timezone, timedelta -import re -import json -import asyncio -import math -import httpx -import os -import logging -from ..database import get_db, SessionLocal -from ..models import ( - - - - User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, - - - - KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, - - - - EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, - - - - MCPServer, - - - - gen_uuid - - - -) -from ..auth import get_current_user -from ..platform_knowledge import get_platform_knowledge -from ..mcp_client import list_mcp_tools, call_mcp_tool -from ..middleware.log_service import LogService -from ..capability_registry import resolve_auto_model - -def _resolve_market_mcp(db: Session, mcp_ids: list, user_id: str) -> list: - - - - """ - - - - 加载用户选择的 MCP 应用配置(MarketApp.type == 'mcp')。 - - - - 返回: [{id, name, transport, endpoint, description}] - - - - """ - - - - if not mcp_ids: - - - - return [] - - - - - - - - mcps = db.query(MarketApp).filter( - - - - MarketApp.id.in_(mcp_ids), - - - - MarketApp.type == "mcp" - - - - ).all() - - - - - - - - results = [] - - - - for m in mcps: - - - - config = {} - - - - try: - - - - if m.skill_content: - - - - config = json.loads(m.skill_content) - - - - except Exception: - - - - config = {} - - - - results.append({ - - - - "id": m.id, - - - - "name": m.name, - - - - "transport": m.mcp_transport or config.get("transport", "stdio"), - - - - "endpoint": m.agent_endpoint or config.get("endpoint", "") or config.get("url", ""), - - - - "description": m.description or config.get("description", ""), - - - - }) - - - - - - - - return results - -def _resolve_active_mcp_servers(db: Session, mcp_ids: list) -> list: - - """ - - 解析用户选中的 MCP 服务连接信息(来自 mcp_servers 表,import 与后台添加通用)。 - - 返回: [{id, name, description, transport, endpoint, url, headers}] - - """ - - if not mcp_ids: - - return [] - - servers = db.query(MCPServer).filter( - - MCPServer.id.in_(mcp_ids), - - MCPServer.is_enabled == True, - - ).all() - - out = [] - - for s in servers: - - try: - - headers = json.loads(s.headers) if isinstance(s.headers, str) else (s.headers or {}) - - except Exception: - - headers = {} - - out.append({ - - "id": s.id, - - "name": s.name, - - "description": s.description or "", - - "transport": s.transport_type or "streamable_http", - - "endpoint": s.url or "", - - "url": s.url or "", - - "headers": headers, - - }) - - return out - -def _list_enabled_mcp_servers(db: Session) -> list: - """返回平台所有已启用(is_enabled=True)的 MCP 服务清单,供 AI 准确回答"平台有哪些 MCP"。""" - servers = db.query(MCPServer).filter(MCPServer.is_enabled == True).all() - out = [] - for s in servers: - try: - headers = json.loads(s.headers) if isinstance(s.headers, str) else (s.headers or {}) - except Exception: - headers = {} - out.append({ - "id": s.id, - "name": s.name, - "description": s.description or "", - "transport": s.transport_type or "streamable_http", - "endpoint": s.url or "", - "url": s.url or "", - "headers": headers, - }) - return out +# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). +# Source of truth: scripts/phase1_extract.py. Do not hand-merge back. + +from fastapi import APIRouter, Depends, HTTPException, Query, Response +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, timezone, timedelta +import re +import json +import asyncio +import math +import httpx +import os +import logging +from ..database import get_db, SessionLocal +from ..models import ( + + + + User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, + + + + KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, + + + + EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, + + + + MCPServer, + + + + gen_uuid + + + +) +from ..auth import get_current_user +from ..platform_knowledge import get_platform_knowledge +from ..mcp_client import list_mcp_tools, call_mcp_tool +from ..middleware.log_service import LogService +from ..capability_registry import resolve_auto_model + +def _resolve_market_mcp(db: Session, mcp_ids: list, user_id: str) -> list: + + + + """ + + + + 加载用户选择的 MCP 应用配置(MarketApp.type == 'mcp')。 + + + + 返回: [{id, name, transport, endpoint, description}] + + + + """ + + + + if not mcp_ids: + + + + return [] + + + + + + + + mcps = db.query(MarketApp).filter( + + + + MarketApp.id.in_(mcp_ids), + + + + MarketApp.type == "mcp" + + + + ).all() + + + + + + + + results = [] + + + + for m in mcps: + + + + config = {} + + + + try: + + + + if m.skill_content: + + + + config = json.loads(m.skill_content) + + + + except Exception: + + + + config = {} + + + + results.append({ + + + + "id": m.id, + + + + "name": m.name, + + + + "transport": m.mcp_transport or config.get("transport", "stdio"), + + + + "endpoint": m.agent_endpoint or config.get("endpoint", "") or config.get("url", ""), + + + + "description": m.description or config.get("description", ""), + + + + }) + + + + + + + + return results + +def _resolve_active_mcp_servers(db: Session, mcp_ids: list) -> list: + + """ + + 解析用户选中的 MCP 服务连接信息(来自 mcp_servers 表,import 与后台添加通用)。 + + 返回: [{id, name, description, transport, endpoint, url, headers}] + + """ + + if not mcp_ids: + + return [] + + servers = db.query(MCPServer).filter( + + MCPServer.id.in_(mcp_ids), + + MCPServer.is_enabled == True, + + ).all() + + out = [] + + for s in servers: + + try: + + headers = json.loads(s.headers) if isinstance(s.headers, str) else (s.headers or {}) + + except Exception: + + headers = {} + + out.append({ + + "id": s.id, + + "name": s.name, + + "description": s.description or "", + + "transport": s.transport_type or "streamable_http", + + "endpoint": s.url or "", + + "url": s.url or "", + + "headers": headers, + + }) + + return out + +def _list_enabled_mcp_servers(db: Session) -> list: + """返回平台所有已启用(is_enabled=True)的 MCP 服务清单,供 AI 准确回答"平台有哪些 MCP"。""" + servers = db.query(MCPServer).filter(MCPServer.is_enabled == True).all() + out = [] + for s in servers: + try: + headers = json.loads(s.headers) if isinstance(s.headers, str) else (s.headers or {}) + except Exception: + headers = {} + out.append({ + "id": s.id, + "name": s.name, + "description": s.description or "", + "transport": s.transport_type or "streamable_http", + "endpoint": s.url or "", + "url": s.url or "", + "headers": headers, + }) + return out diff --git a/backend/python/app/api/chat_service.py b/backend/python/app/api/chat_service.py index 56c6871..ccc1108 100644 --- a/backend/python/app/api/chat_service.py +++ b/backend/python/app/api/chat_service.py @@ -1,8549 +1,8549 @@ -# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). - - - -# Source of truth: scripts/phase1_extract.py. Do not hand-merge back. - - - - - - - -from fastapi import APIRouter, Depends, HTTPException, Query, Response - - - -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, timezone, timedelta - - - -import re - - - -import json - - - -import asyncio - - - -import math - - - -import httpx - - - -import os - - - -import logging - - - -from ..database import get_db, SessionLocal - - - -from app.core.observability import span, get_trace_id - - - -from ..models import ( - - - - - - - - - - - - - - - - User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, - - - - - - - - - - - - - - - - KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, - - - - - - - - - - - - - - - - EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, - - - - - - - - - - - - - - - - MCPServer, - - - - - - - - - - - - - - - - gen_uuid - - - - - - - - - - - - - - - -) - - - -from ..auth import get_current_user - - - -from ..platform_knowledge import get_platform_knowledge - - - -from ..mcp_client import list_mcp_tools, call_mcp_tool - - - -from ..middleware.log_service import LogService - - - -from ..capability_registry import resolve_auto_model - - - -from app.api.chat_agents import (_call_external_agent) - - - -from app.api.chat_context import (_build_system_prompt, _get_conversation_context, _ingest_answer_to_dify, _retrieve_kb_context, _build_chat_history, _maybe_compress_history) - - - -from app.api.chat_mcp import (_list_enabled_mcp_servers, _resolve_active_mcp_servers) - - - -from app.api.chat_media import (_generate_media_task, detect_ambiguous_creative_intent, detect_media_intent) - - - -from app.api.chat_skill import (_discover_installed_skills, _execute_skill_tool, _resolve_market_agents, _resolve_market_skills, _execute_install_app) - - - -from app.api.chat_utils import (SendMessageRequest, _proxy_for_url, cst_now, logger) - -# 统一模型层(07-24 红线):web chat 与 IM bot 共用标准 OpenAI SDK 客户端 - -from app.core.llm_client import achat_complete - - - - - - - -async def _send_message_core( - - - - - - - - - - - - - - - - conv_id: str, - - - - - - - - - - - - - - - - req: SendMessageRequest, - - - - - - - - - - - - - - - - db: Session = Depends(get_db), - - - - - - - - - - - - - - - - current_user: User = Depends(get_current_user), - stream: bool = False, - on_event=None, - - - - - - - - - - - - - - - -): - - - - - - - - - - - - - - - - conv = db.query(Conversation).filter( - - - - - - - - - - - - - - - - and_(Conversation.id == conv_id, Conversation.user_id == current_user.id) - - - - - - - - - - - - - - - - ).first() - - - - - - - - - - - - - - - - if not conv: - - - - - - - - - - - - - - - - raise HTTPException(status_code=404, detail="对话不存在") - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Stage B.2: 多轮上下文保持 — 如果当前请求没有skill/agent/kb, - - - - - - - - - - - - - - - - # 从对话历史中继承上一次的选择 - - - - - - - - - - - - - - - - effective_skill_ids = req.skill_ids or [] - - - - - - - - - - - - - - - - effective_agent_ids = req.agent_ids or [] - - - - - - - - - - - - - - - - effective_kb_ids = req.knowledge_base_ids or [] - - - - - - - - - - - - - - - - effective_mcp_ids = req.mcp_ids or [] - - - - - - - - - - - - - - - - effective_use_tools = req.use_tools or False - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if not effective_skill_ids and not effective_agent_ids and not effective_kb_ids and not effective_mcp_ids: - - - - - - - - - - - - - - - - # 从对话上下文继承 - - - - - - - - - - - - - - - - prev_context = _get_conversation_context(db, conv_id) - - - - - - - - - - - - - - - - if prev_context: - - - - - - - - - - - - - - - - effective_skill_ids = prev_context.get("skill_ids", []) - - - - - - - - - - - - - - - - effective_agent_ids = prev_context.get("agent_ids", []) - - - - - - - - - - - - - - - - effective_kb_ids = prev_context.get("knowledge_base_ids", []) - - - - - - - - - - - - - - - - effective_mcp_ids = prev_context.get("mcp_ids", []) - - - - - - - - - - - - - - - - if effective_skill_ids or effective_agent_ids or effective_mcp_ids: - - - - - - - - - - - - - - - - effective_use_tools = True - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 附件内容拼接到用户消息中(文本类文件附带内容, 供模型参考) - - - - - - - - - - - - - - - - attachment_block = "" - - - - - - - - - - - - - - - - if req.attachments: - - - - - - - - - - - - - - - - parts = [] - - - - - - - - - - - - - - - - for att in req.attachments: - - - - - - - - - - - - - - - - name = att.get("name", "附件") if isinstance(att, dict) else str(att) - - - - - - - - - - - - - - - - ftype = att.get("file_type", "") if isinstance(att, dict) else "" - - - - - - - - - - - - - - - - content = att.get("content", "") if isinstance(att, dict) else "" - - - - - - - - - - - - - - - - if content: - - - - - - - - - - - - - - - - parts.append(f"【附件: {name}】\n{content}") - - - - - - - - - - - - - - - - else: - - - - - - - - - - - - - - - - parts.append(f"【附件(二进制, 名称: {name}, 类型: {ftype})】") - - - - - - - - - - - - - - - - if parts: - - - - - - - - - - - - - - - - attachment_block = "\n\n" + "\n\n".join(parts) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 保存用户消息(含skill/agent/kb/mcp选择用于上下文保持) - - - - - - - - - - - - - - - - user_msg = Message( - - - - - - - - - - - - - - - - id=gen_uuid(), - - - - - - - - - - - - - - - - conversation_id=conv_id, - - - - - - - - - - - - - - - - role="user", - - - - - - - - - - - - - - - - content=req.content + attachment_block, - - - - - - - - - - - - - - - - token_used=len(req.content) // 2, - - - - - - - - - - - - - - - - model_id=req.model_id or conv.model_id, - - - - - - - - - - - - - - - - skill_ids=req.skill_ids if req.skill_ids else None, - - - - - - - - - - - - - - - - agent_ids=req.agent_ids if req.agent_ids else None, - - - - - - - - - - - - - - - - knowledge_ids=req.knowledge_base_ids if req.knowledge_base_ids else None, - - - - - - - - - - - - - - - - mcp_ids=req.mcp_ids if req.mcp_ids else None, - - - - - - - - - - - - - - - - attachments=req.attachments if req.attachments else None, - - - - - - - - - - - - - - - - created_at=cst_now(), - - - - - - - - - - - - - - - - ) - - - - - - - - - - - - - - - - db.add(user_msg) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # ===== 模糊创作意图拦截 ===== - - - - - - - - - - - - - - - - # 用户使用创作类动词(制作/创作/做/生成…)但未明确产出形式时,先与用户确认方向, - - - - - - - - - - - - - - - - # 避免直接调用大模型被内容安全策略拒答(如返回 "high risk")。 - - - - - - - - - - - - - - - - clarify = detect_ambiguous_creative_intent(req.content) - - - - - - - - - - - - - - - - if clarify: - - - - - - - - - - - - - - - - clarify_content = "<>" + json.dumps( - - - - - - - - - - - - - - - - {"prompt": req.content, "options": clarify["options"]}, ensure_ascii=False - - - - - - - - - - - - - - - - ) + "<>" - - - - - - - - - - - - - - - - ai_msg = Message( - - - - - - - - - - - - - - - - id=gen_uuid(), - - - - - - - - - - - - - - - - conversation_id=conv_id, - - - - - - - - - - - - - - - - role="assistant", - - - - - - - - - - - - - - - - content=clarify_content, - - - - - - - - - - - - - - - - token_used=0, - - - - - - - - - - - - - - - - model_id=req.model_id or conv.model_id, - - - - - - - - - - - - - - - - created_at=cst_now(), - - - - - - - - - - - - - - - - ) - - - - - - - - - - - - - - - - db.add(ai_msg) - - - - - - - - - - - - - - - - db.commit() - - - - - - - - - - - - - - - - db.refresh(user_msg) - - - - - - - - - - - - - - - - db.refresh(ai_msg) - - - - - - - - - - - - - - - - if on_event is not None: - try: - try: - _mid = ai_msg.id - except NameError: - _mid = None - try: - _dc = ai_content - except NameError: - _dc = (ai_msg.content if "ai_msg" in dir() else "") - try: - _ps = process_steps - except NameError: - _ps = [] - try: - _us = used_skills - except NameError: - _us = [] - try: - _um = used_mcp - except NameError: - _um = [] - try: - _mg = media_generating - except NameError: - _mg = False - await on_event({ - "type": "done", - "message_id": _mid, - "conversation_id": conv_id, - "content": _dc, - "process_steps": _ps, - "used_skills": _us, - "used_mcp": _um, - "generating": _mg, - }) - except Exception: - pass - - return { - - - - - - - - - - - - - - - - "code": 0, - - - - - - - - - - - - - - - - "data": { - - - - - - - - - - - - - - - - "user_message": { - - - - - - - - - - - - - - - - "id": user_msg.id, "role": user_msg.role, "content": user_msg.content, - - - - - - - - - - - - - - - - "token_used": user_msg.token_used, "created_at": str(user_msg.created_at), - - - - - - - - - - - - - - - - }, - - - - - - - - - - - - - - - - "assistant_message": { - - - - - - - - - - - - - - - - "id": ai_msg.id, "role": ai_msg.role, "content": clarify_content, - - - - - - - - - - - - - - - - "created_at": str(ai_msg.created_at), "clarify": clarify, - - - - - - - - - - - - - - - - }, - - - - - - - - - - - - - - - - }, - - - - - - - - - - - - - - - - "message": "请选择创作方向", - - - - - - - - - - - - - - - - } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # ===== 核心聊天引擎 ===== - - - - - - - - - - - - - - - - user_id = current_user.id - - - - - - - - - - - - - - - - model_id = req.model_id or conv.model_id - - - - - - - - - - - - - - - - prompt_tokens = 0 - - - - - - - - - - - - - - - - completion_tokens = 0 - - - - - - - - - - - - - - - - total_tokens = 0 - - - - - - - - - - - - - - - - ai_content = "" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ai_model = None - - - - - - - - - - - - - - - - skills = [] # 初始化避免UnboundLocalError - - process_steps = [] # 过程步骤(思考/技能/MCP执行),用于前端收拢式展示 - - - - - - - - - - - - - - - - - - agents = [] # 初始化避免UnboundLocalError - - - - - - - - - - - - - - - - media_generating = False # 提前初始化:外部Agent分支与末尾均会引用 - - - - - - - - - - - - - - - - external_handled = False # 是否已由外部Agent(Dify/n8n/Coze)远程处理 - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # ===== 外部 Agent 远程调用分支 (Dify / n8n / Coze) ===== - - - - - - - - - - - - - - - - # 若本轮对话关联了带有效 agent_endpoint 的外部 Agent,则直接远程调用其平台, - - - - - - - - - - - - - - - - # 跳过本地 LLM「扮演」逻辑。effective_agent_ids 已在前面从请求/上下文继承。 - - - - - - - - - - - - - - - - external_agents = _resolve_market_agents(db, effective_agent_ids, user_id) - - - - - - - - - - - - - - - - external_agent = next((a for a in external_agents if a.get("agent_endpoint")), None) - - - - - - - - # ===== 3.3 多 Agent 编排(图引擎)快路 ===== - - - - _orchestrator = next((a for a in external_agents if a.get("orchestration")), None) - - - - if _orchestrator: - - - - try: - - - - from app.core.orchestrator import run_graph - - - - _answer, _trace = await run_graph( - - - - graph=_orchestrator["orchestration"], - - - - query=req.content + attachment_block, - - - - db=db, - - - - user_id=user_id, - - - - conv_id=conv_id, - - - - trace_id=get_trace_id(), - - - - ) - - - - ai_content = _answer or "编排未产生内容。" - - - - logger.info( - - - - f"[orchestrator] 图执行完成 trace_id={get_trace_id()} nodes={len(_trace.get('nodes', []))}" - - - - ) - - - - except Exception as e: - - - - logger.warning(f"[orchestrator] 图执行失败: {e}") - - - - ai_content = f"⚠️ 多 Agent 编排执行失败:{str(e)[:200]}" - - - - external_handled = True - - - - external_agent = None # 阻止下方外部 Agent 分支重复执行 - - - - - - - - - - - - - - - - if external_agent: - - - - - - - - - - - - - - - - agents = external_agents - - - - - - - - - - - - - - - - # 组装最近历史(供 n8n 等无平台会话态的服务维持多轮上下文) - - - - - - - - - - - - - - - - _history = _build_chat_history(db, conv_id, max_tokens=4000, exclude_latest_user=True) - - - - - - - - - - - - - - - - try: - - - - - - - - - - - - - - - - answer, new_ext_id = await _call_external_agent( - - - - - - - - - - - - - - - - db, external_agent, - - - - - - - - - - - - - - - - query=req.content + attachment_block, - - - - - - - - - - - - - - - - user_id=user_id, - - - - - - - - - - - - - - - - ext_conversation_id=conv.ext_agent_conversation_id, - - - - - - - - - - - - - - - - history=_history, - - - - - - - - - - - - - - - - ) - - - - - - - - - - - - - - - - ai_content = answer or "外部 Agent 未返回内容。" - - - - - - - - - - - - - - - - if new_ext_id: - - - - - - - - - - - - - - - - conv.ext_agent_conversation_id = new_ext_id - - - - - - - - - - - - - - - - except Exception as e: - - - - - - - - - - - - - - - - logger.warning( - - - - - - - - - - - - - - - - f"[external_agent] 远程调用失败 platform={external_agent.get('platform')} " - - - - - - - - - - - - - - - - f"endpoint={external_agent.get('agent_endpoint')}: {e}" - - - - - - - - - - - - - - - - ) - - - - - - - - - - - - - - - - ai_content = f"⚠️ 外部 Agent 调用失败:{str(e)[:200]}" - - - - - - - - - - - - - - - - external_handled = True - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 解析模型:支持 Auto 模式(model_id='auto' 或未指定时,按能力自动匹配平台默认模型) - - - - - - - - - - - - - - - - if not external_handled: - - - - - - - - - - - - - - - - if model_id and model_id != "auto": - - - - - - - - - - - - - - - - ai_model = db.query(AIModel).filter(AIModel.id == model_id).first() - - - - - - - - - - - - - - - - if not ai_model: - - - - - - - - - - - - - - - - # Auto / 未指定 → 按用户输入意图匹配对应能力的默认模型,否则回退文本对话 - - - - - - - - - - - - - - - - intent = detect_media_intent(req.content) - - - - - - - - - - - - - - - - resolved = resolve_auto_model(intent, db) if intent else None - - - - - - - - - - - - - - - - ai_model = resolved or resolve_auto_model("text", db) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # ===== Model Failover: Auto 模式下自动切换备选模型 ===== - - _try_models = [ai_model] if (ai_model and ai_model.is_enabled) else [] - - if not model_id or model_id == "auto": - - # Auto 模式:附加其他已启用的文本模型作为备选 - - _fallbacks = db.query(AIModel).filter( - - AIModel.is_enabled == True, - - AIModel.id != (ai_model.id if ai_model else "") - - ).order_by(AIModel.is_default.desc()).limit(5).all() - - _try_models.extend([m for m in _fallbacks if m not in _try_models]) - - _retry_log = [] - - _all_failed = False - - - - for _try_model in _try_models: - - ai_model = _try_model - - if ai_model: - - - - - - - - - - - - - - - - # ====== 图像/视频生成模型分支 ====== - - - - - - - - - - - - - - - - caps = [c.lower() for c in (ai_model.capabilities or [])] if ai_model else [] - - - - - - - - - - - - - - - - media_generating = False - - - - - - - - - - - - - - - - if ai_model and ("image" in caps or "video" in caps): - - - - - - - - - - - - - - - - # 异步生成:立即返回占位内容,后台任务轮询 AGNES 并回写, - - - - - - - - - - - - - - - - # 避免最长 5 分钟的视频轮询阻塞 HTTP 请求导致客户端超时断开。 - - - - - - - - - - - - - - - - media_generating = True - - - - - - - - - - - - - - - - if "video" in caps: - - - - - - - - - - - - - - - - ai_content = "⏳ 视频生成中…(通常 1~3 分钟,完成后自动刷新)" - - - - - - - - - - - - - - - - else: - - - - - - - - - - - - - - - - ai_content = "⏳ 图像生成中…(完成后自动刷新)" - - - - - - - - - - - - - - - - elif ai_model and ai_model.endpoint: - - - - - - - - - - - - - - - - provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first() - - - - - - - - - - - - - - - - api_key = ai_model.api_key or (provider.api_key if provider else None) - - - - - - - - - - - - - - - - api_url = ai_model.endpoint - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if not api_url and provider and provider.api_base_url: - - - - - - - - - - - - - - - - api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if api_key and api_url: - - - - - - - - - - - - - - - - try: - - - - - - - - - - - - - - - - # Stage A+B: 解析Skills和Agents - - - - - - - - - - - - - - - - skills = _resolve_market_skills(db, effective_skill_ids, user_id) - - - - - - - - - - - - - - - - agents = _resolve_market_agents(db, effective_agent_ids, user_id) - - - - - - - - - - - - - - - - mcps = _resolve_active_mcp_servers(db, effective_mcp_ids) - - - - - - - - - - - - - - - - # MCP 工具调用链路:拉取各 MCP 工具并转为 function definitions - - - - - - - - mcp_servers_conn = mcps - - - - - - - - mcp_function_definitions = [] - - - - - - - - mcp_tool_map = {} - - - - - - - - mcp_idx = 0 - - - - - - - - for _srv in mcp_servers_conn: - - - - - - - - try: - - - - - - - - async with span("mcp.list_tools", server=_srv.get("name") if isinstance(_srv, dict) else None): - - - - _tools = await list_mcp_tools(_srv) - - - - - - - - except Exception as _e: - - - - - - - - logger.warning(f"MCP 拉取工具失败 [{_srv.get('name')}]: {_e}") - - - - - - - - _tools = [] - - - - - - - - if _tools: - - - - - - - - try: - - - - - - - - _upd = db.query(MCPServer).filter(MCPServer.id == _srv["id"]).first() - - - - - - - - if _upd: - - - - - - - - _upd.tools_count = len(_tools) - - - - - - - - db.commit() - - - - - - - - except Exception: - - - - - - - - pass - - - - - - - - for _t in _tools: - - - - - - - - _fname = f"mcp_tool_{mcp_idx}" - - - - - - - - mcp_tool_map[_fname] = { - - - - - - - - "server_id": _srv["id"], - - - - - - - - "name": _t.get("name"), - - - - - - - - "server_name": _srv["name"], - - - - - - - - } - - - - - - - - _schema = _t.get("inputSchema") or {"type": "object", "properties": {}} - - - - - - - - if not isinstance(_schema, dict) or _schema.get("type") != "object": - - - - - - - - _schema = {"type": "object", "properties": {}} - - - - - - - - mcp_function_definitions.append({ - - - - - - - - "name": _fname, - - - - - - - - "description": f"[MCP·{_srv['name']}] {_t.get('description', '')}", - - - - - - - - "parameters": _schema, - - - - - - - - }) - - - - - - - - mcp_idx += 1 - - - - - - - - if mcp_function_definitions: - - - - - - - - effective_use_tools = True - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Issue 8: 默认技能自动发现(仅当用户未显式选择技能时启用) - - - - - - - - - - - - - - - - # 每轮先寻找本账号已安装且平台已部署的技能,多个适用时由模型自行判断选 1 个最优。 - - - - - - - - - - - - - - - - used_auto_skills = False - - - - - - - - - - - - - - - - if not effective_skill_ids: - - - - - - - - - - - - - - - - try: - - - - - - - - - - - - - - - - discovered = _discover_installed_skills(db, user_id) - - - - - - - - - - - - - - - - except Exception as e: - - - - - - - - - - - - - - - - logger.warning(f"自动发现已安装技能失败: {e}") - - - - - - - - - - - - - - - - discovered = [] - - - - - - - - - - - - - - - - if discovered: - - - - - - - - - - - - - - - - skills = discovered - - - - - - - - - - - - - - - - used_auto_skills = True - - - - - - - - - - - - - - - - effective_use_tools = True - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Stage B.1: Agent可能自带skill_ids和kb_ids - - - - - - - - - - - - - - - - agent_extra_skill_ids = [] - - - - - - - - - - - - - - - - agent_extra_kb_ids = [] - - - - - - - - - - - - - - - - agent_model_override = None - - - - - - - - - - - - - - - - for agent in agents: - - - - - - - - - - - - - - - - if agent.get("skill_ids"): - - - - - - - - - - - - - - - - agent_extra_skill_ids.extend(agent["skill_ids"]) - - - - - - - - - - - - - - - - if agent.get("knowledge_base_ids"): - - - - - - - - - - - - - - - - agent_extra_kb_ids.extend(agent["knowledge_base_ids"]) - - - - - - - - - - - - - - - - if agent.get("model_id"): - - - - - - - - - - - - - - - - agent_model_override = agent["model_id"] - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 合并Agent自带的skills和KBs - - - - - - - - - - - - - - - - all_skill_ids = list(set(effective_skill_ids + agent_extra_skill_ids)) - - - - - - - - - - - - - - - - all_kb_ids = list(set(effective_kb_ids + agent_extra_kb_ids)) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if agent_extra_skill_ids or agent_extra_kb_ids: - - - - - - - - - - - - - - - - # 重新解析合并后的skills - - - - - - - - - - - - - - - - skills = _resolve_market_skills(db, all_skill_ids, user_id) - - - - - - - - - - - - - - - - effective_use_tools = True - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Stage C: 增强版KB检索 - - - - - - - - - - - - - - - - kb_context = await _retrieve_kb_context(db, all_kb_ids, req.content) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Stage B: 动态组装system prompt + function definitions - - - - - - - - - - - - - - - - system_prompt, function_definitions = _build_system_prompt( - - - - - - - - - - - - - - - - skills=skills, - - - - - - - - - - - - - - - - agents=agents, - - - - - - - - - - - - - - - - kb_context=kb_context, - - - - - - - - - - - - - - - - user_content=req.content, - - - - - - - - - - - - - - - - mcp=mcps, - - - - - - - - mcp_functions=mcp_function_definitions, - - - - - - - - auto_discovered=used_auto_skills, - - - - all_mcp=_list_enabled_mcp_servers(db), - - - - db=db, - - - - ) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 构建消息列表 - - - - - - - - - - - - - - - - # 实际调用追踪(Issue:反馈真实使用的技能/MCP,而非注入的全部) - - - - called_skill_names = [] - - - - called_mcp_names = [] - - - - - - - - if isinstance(function_definitions, list): - function_definitions.append({ - "name": "install_app", - "description": "当用户提供技能/Skill、MCP 服务或 Agent 的链接(如 GitHub 仓库、mcp.so、modelscope 等)并希望安装到平台时使用。自动识别类型(skill/mcp/agent)并安装,随后可在对话中直接使用。", - "parameters": { - "type": "object", - "properties": { - "url": {"type": "string", "description": "要安装的技能/MCP/Agent 地址(URL)"}, - "type": {"type": "string", "enum": ["auto", "skill", "mcp", "agent"], "description": "安装类型,auto 表示自动识别(默认)"}, - }, - "required": ["url"], - }, - }) - - messages = [{"role": "system", "content": system_prompt}] - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 分层上下文引擎:预算感知历史 + 压缩摘要(若有) - - - - messages.extend(_build_chat_history(db, conv_id, max_tokens=6000, exclude_latest_user=True)) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Issue 1: 必须把附件内容(attachment_block)一并发给模型, - - - - - - - - - - - - - - - - # 否则用户在对话窗上传文件后模型“看不到”文件内容。 - - - - - - - - - - - - - - - - # Issue 2 修复:用户输入含 URL 且要求读取/加入/安装时,服务端抓取真实页面内容注入上下文 - - try: - - from app.api.url_reader import fetch_url_context - - _url_ctx = await fetch_url_context(req.content) - - except Exception as _e: - - import logging as _logging - - _logging.getLogger(__name__).warning("URL 上下文抓取失败(已跳过): %s", _e) - - _url_ctx = None - - if _url_ctx and _url_ctx.get("text"): - - messages.append({ - - "role": "system", - - "content": ( - - "【用户随消息提供的参考链接的真实内容】\n" - - f"链接地址: {_url_ctx.get('url')}\n" - - "下面是该链接页面的真实内容,请严格基于它来回答用户的需求" - - "(例如按其『Quick Start / 加入步骤』逐步引导用户完成安装或加入)," - - "不要凭空编造步骤:\n\n" + _url_ctx["text"] - - ), - - }) - - process_steps.append({ - - "type": "skill", - - "name": "网页/仓库读取", - - "result": f"已读取 {_url_ctx.get('url')}({len(_url_ctx['text'])} 字符)", - - }) - - messages.append({"role": "user", "content": req.content + attachment_block}) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Stage B.3: Function Calling循环(最多5轮) - - - - - - - - - - - - - - - - max_tool_rounds = 5 - - - - - - - - - - - - - - - - # ===== 3.1 tool_policy 沙箱:聚合策略 + 初始化预算计数器 ===== - - - - _agent_tool_policy = _resolve_agent_tool_policy(agents) - - - - _tool_call_count = 0 - - - - for round_num in range(max_tool_rounds): - - - - _round_tool_count = 0 - - - - - - - - - - - - - - - - request_body = { - - - - - - - - - - - - - - - - "model": ai_model.name, - - - - - - - - - - - - - - - - "messages": messages, - - - - - - - - - - - - - - - - "max_tokens": 2048, - - - - - - - - - - - - - - - - } - - - - - - - - - - - - - - - - if function_definitions and effective_use_tools: - - - - - - - - - - - - - - - - request_body["tools"] = [ - - - - - - - - - - - - - - - - {"type": "function", "function": fd} - - - - - - - - - - - - - - - - for fd in function_definitions - - - - - - - - - - - - - - - - ] - - - - - - - - - - - - - - - - request_body["tool_choice"] = "auto" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - async with span("llm.completion", model=ai_model.id if ai_model else None): - - try: - - if stream and on_event is not None and round_num == 0 and not (effective_mcp_ids or effective_skill_ids or effective_agent_ids): - - _sc = "" - _sr = "" - async for _kind, _text in await achat_complete( - api_url, api_key, request_body, - proxy=_proxy_for_url(api_url), - timeout=ai_model.timeout or 120, - stream=True, - ): - if _kind == "content": - _sc += _text - await on_event({"type": "delta", "content": _text}) - elif _kind == "reasoning": - _sr += _text - await on_event({"type": "reasoning", "content": _text}) - message = {"content": _sc, "tool_calls": [], "reasoning": _sr} - choice = {"message": message} - data = {"choices": [choice], "usage": {}} - else: - data = await achat_complete( - api_url, - api_key, - request_body, - proxy=_proxy_for_url(api_url), - timeout=ai_model.timeout or 120, - ) - - except Exception as _exc: - - if round_num == 0: - - ai_content = f"AI模型调用失败 ({type(_exc).__name__}: {str(_exc)[:120]}), 请检查模型配置。" - - logger.warning(f"[chat] 模型 {ai_model.name} 调用异常: {_exc}") - - break - - choice = data.get("choices", [{}])[0] - - message = choice.get("message", {}) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 记录token使用量 - - - - - - - - - - - - - - - - usage = data.get("usage", {}) - - - - - - - - - - - - - - - - prompt_tokens += usage.get("prompt_tokens", 0) - - - - - - - - - - - - - - - - completion_tokens += usage.get("completion_tokens", 0) - - - - - - - - - - - - - - - - total_tokens += usage.get("total_tokens", 0) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 检查是否有tool_calls - - - - - - - - - - - - - - - - tool_calls = message.get("tool_calls", []) - - # 过程步骤:捕获模型思考/推理(产生工具调用前的推理) - - _think = (message.get("content") or "").strip() - - if _think and tool_calls: - - process_steps.append({ - - "type": "thinking", - - "text": _think, - - }) - - - - - - - - - - - - - - - - - - if tool_calls and effective_use_tools: - - - - - - - - - - - - - - - - # 将assistant消息(含tool_calls)加入上下文 - - - - - - - - - - - - - - - - messages.append({ - - - - - - - - - - - - - - - - "role": "assistant", - - - - - - - - - - - - - - - - "content": message.get("content") or "", - - - - - - - - - - - - - - - - "tool_calls": tool_calls, - - - - - - - - - - - - - - - - }) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # Stage A.2: 真实Skill执行 - - - - - - - - - - - - - - - - for tc in tool_calls: - - - - - - - - - - - - - - - - func_name = tc.get("function", {}).get("name", "") - - - - # ===== 3.1 tool_policy 沙箱:逐工具校验(白名单 + 预算 + 每轮上限) ===== - - - - _mcp_info = mcp_tool_map.get(func_name) if func_name.startswith("mcp_tool_") else None - - - - _policy_check = _check_tool_policy(func_name, _mcp_info, skills, _agent_tool_policy, _tool_call_count, _round_tool_count) - - - - if not _policy_check["allowed"]: - - - - _reason = _policy_check["reason"] - - - - logger.warning( - - - - f"[tool_policy] blocked tool call: agent={_agent_tool_policy.get('agent_id') if _agent_tool_policy else None} " - - - - f"tool={func_name} reason={_reason} trace={get_trace_id()}" - - - - ) - - - - tool_result = f"[工具调用已被策略拦截] {_policy_check.get('message', _reason)}" - - - - messages.append({ - - - - "role": "tool", - - - - "tool_call_id": tc.get("id", f"call_{round_num}"), - - - - "content": tool_result, - - - - }) - - - - _tool_call_count += 1 - - - - _round_tool_count += 1 - - - - continue - - - - _tool_call_count += 1 - - - - _round_tool_count += 1 - - - - - - - - - - - - - - - - func_args_str = tc.get("function", {}).get("arguments", "{}") - - - - - - - - - - - - - - - - try: - - - - - - - - - - - - - - - - func_args = json.loads(func_args_str) - - - - - - - - - - - - - - - - except Exception: - - - - - - - - - - - - - - - - func_args = {"input": func_args_str} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if func_name.startswith("mcp_tool_"): - - - - - - - - # MCP 工具调用:代理到对应 MCP 服务执行 tools/call - - - - - - - - _info = mcp_tool_map.get(func_name) - - - - - - - - tool_result = "[MCP 工具未找到或映射缺失]" - - - - - - - - if _info: - - - - - - - - _srv = next( - - - - - - - - (s for s in mcp_servers_conn if s["id"] == _info["server_id"]), - - - - - - - - None, - - - - - - - - ) - - - - - - - - if _srv: - - - - - - - - async with span(f"mcp.call:{_info.get('name') if isinstance(_info, dict) else None}"): - - - - tool_result = await call_mcp_tool( - - - - - - - - _srv, _info["name"], func_args - - - - - - - - ) - - - - - - - - called_mcp_names.append(_srv.get("name", "")) - - process_steps.append({ - - "type": "mcp", - - "name": _info.get("name") if isinstance(_info, dict) else func_name, - - "server": _srv.get("name", ""), - - "args": func_args, - - "result": (tool_result or "")[:800], - - }) - - - - - - - - - - elif func_name == "install_app": - async with span("app.install"): - _iu = func_args.get("url", "") if isinstance(func_args, dict) else "" - _itype = (func_args.get("type", "auto") if isinstance(func_args, dict) else "auto") or "auto" - tool_result = _execute_install_app(db, _iu, current_user.id if current_user else None, _itype) - process_steps.append({ - "type": "skill", - "name": "安装应用", - "args": func_args, - "result": (tool_result or "")[:800], - }) - else: - async with span(f"skill.execute:{func_name}"): - - - - tool_result = _execute_skill_tool( - - - - - - - - db, func_name, func_args, skills, req.content - - - - - - - - ) - - - - - - - - _sk = next((s for s in skills if s.get("func_name") == func_name), None) - - - - - - - - if _sk: - - - - - - - - called_skill_names.append(_sk.get("name", "")) - - process_steps.append({ - - "type": "skill", - - "name": (_sk.get("name") if _sk else func_name), - - "args": func_args, - - "result": (tool_result or "")[:800], - - }) - - - - - - - - - - - - - - - - - - messages.append({ - - - - - - - - - - - - - - - - "role": "tool", - - - - - - - - - - - - - - - - "tool_call_id": tc.get("id", f"call_{round_num}"), - - - - - - - - - - - - - - - - "content": tool_result, - - - - - - - - - - - - - - - - }) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 继续下一轮让模型处理tool结果 - - - - - - - - - - - - - - - - continue - - - - - - - - - - - - - - - - else: - - - - - - - - - - - - - - - - # 普通回复 - - - - - - - - - - - - - - - - ai_content = message.get("content", "") - - - - - - - - - - - - - - - - if not ai_content and round_num > 0: - - - - - - - - - - - - - - - - ai_content = "AI模型已处理工具调用结果, 但返回了空响应。" - - - - - - - - - - - - - - - - break - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if not ai_content: - - - - - - - - - - - - - - - - ai_content = "AI模型返回了空响应, 请稍后重试。" - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - except Exception as e: - - - - - - - - - - - - - - - - logger.warning(f"AI API调用异常: {e}") - - - - - - - - - - - - - - - - ai_content = "AI服务暂时不可用, 请稍后重试或检查模型API配置。" - - - - - - - - - - - - - - - - else: - - - - - - - - - - - - - - - - ai_content = "该模型缺少API密钥或端点配置, 请在后台管理-大模型库中配置API Key和Endpoint。" - - - - - - - - - - - - - - - - else: - - - - - - - - - - - - - - - - ai_content = "请选择一个已配置的AI模型进行对话。" - - - - - - - - - - - - - - - - elif not external_handled: - - - - - - - - - - - - - - - - ai_content = "请先在后台管理-大模型库配置默认对话模型(或聊天中选择模型),Auto 模式下将自动使用平台默认模型。" - - - - - - - - - - # ===== Post-check: 失败则切换备选,成功则跳出 ===== - - _is_error = ( - - ai_content and ( - - "AI模型调用失败" in str(ai_content) - - or "AI服务暂时不可用" in str(ai_content) - - or "AI模型返回了空响应" in str(ai_content) - - or "该模型缺少API密钥" in str(ai_content) - - ) - - ) - - if _is_error: - - _retry_log.append(f"{ai_model.name}: {str(ai_content)[:60]}") - - logger.warning( - - f"[Failover] 模型 {ai_model.name} 失败,切换至备选..." - - ) - - continue # try next model - - - - # Success — exit failover loop - - break - - else: - - # Loop completed without break = all models failed - - _all_failed = True - - - - # ===== All models failed? ===== - - if _all_failed: - - _names = ", ".join([m.name for m in _try_models]) - - _details = "; ".join(_retry_log[-3:]) if _retry_log else "未知错误" - - ai_content = ( - - f"已尝试 {len(_try_models)} 个模型({_names})均调用失败。\n" - - f"最后错误: {_details}\n" - - f"请稍后重试或检查模型配置。" - - ) - - logger.error(f"[Failover] 全部 {len(_try_models)} 个模型均失败: {_details}") - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ai_token_count = len(ai_content) // 2 - - - - - - - - - - - - - - - - ai_msg = Message( - - - - - - - - - - - - - - - - id=gen_uuid(), - - - - - - - - - - - - - - - - conversation_id=conv_id, - - - - - - - - - - - - - - - - role="assistant", - - - - - - - - - - - - - - - - content=ai_content, - - - - - - - - - - - - - - - - token_used=ai_token_count, - - - - - - - - - - - - - - - - model_id=ai_model.id if ai_model else (req.model_id or conv.model_id), - - - - - - - - - - - - - - - - skill_ids=req.skill_ids if req.skill_ids else None, - - - - - - - - - - - - - - - - agent_ids=req.agent_ids if req.agent_ids else None, - - - - - - - - - - - - - - - - knowledge_ids=req.knowledge_base_ids if req.knowledge_base_ids else None, - - - - - - - - - - - - - - - - created_at=cst_now(), - - - - - - - - - - - - - - - - ) - - - - - - - - - - - - - - - - db.add(ai_msg) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 后台异步:将实质性文本回答入库到 Dify 知识库(量化后可供 RAG 复用) - - - - - - - - - - - - - - - - _ingest_text = (ai_content or "").strip() - - - - - - - - - - - - - - - - if len(_ingest_text) > 120 and not ( - - - - - - - - - - - - - - - - _ingest_text.startswith("[MEDIA") - - - - - - - - - - - - - - - - or _ingest_text.startswith("data:") - - - - - - - - - - - - - - - - or _ingest_text.startswith("http") - - - - - - - - - - - - - - - - or " 反馈配对的技能名 - - - - - - - - - - - - - - - - try: - - - - - - - - - - - - - - - - _sel = _resolve_market_skills(db, effective_skill_ids, user_id) - - - - - - - - - - - - - - - - used_skills = [s["name"] for s in _sel] - - - - - - - - - - - - - - - - except Exception: - - - - - - - - - - - - - - - - used_skills = [] - - - - - - - - - - - - - - - - elif called_skill_names: - - - - - - - - - - - - - - - - # 未显式选技能 -> 反馈 AI 本轮实际调用的技能(可能为空) - - - - - - - - - - - - - - - - used_skills = list(dict.fromkeys(called_skill_names)) - - - - - - - - - - - - - - - - if effective_mcp_ids: - - - - - - - - - - - - - - - - # 用户显式选择了 MCP -> 反馈配对的 MCP 名 - - - - - - - - - - - - - - - - try: - - - - - - - - - - - - - - - - _sel_mcp = _resolve_active_mcp_servers(db, effective_mcp_ids) - - - - - - - - - - - - - - - - used_mcp = [m["name"] for m in _sel_mcp] - - - - - - - - - - - - - - - - except Exception: - - - - - - - - - - - - - - - - used_mcp = [] - - - - - - - - - - - - - - - - elif called_mcp_names: - - - - - - - - - - - - - - - - used_mcp = list(dict.fromkeys(called_mcp_names)) - - - - - - - - - - - - - - - - ai_msg.used_skills = used_skills - - - - - - - - - - - - - - - - ai_msg.used_mcp = used_mcp - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 更新对话统计 - - - - - - - - - - - - - - - - # 注意:knowledge_graph_count 仅应在用户实际点击「转知识图谱」时 +1, - - # 不可在每次 AI 回复时无条件自增,否则未操作也会累积计数(见 #715)。 - - - - - - - - - - - - - - - - conv.updated_at = cst_now() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 记录Token使用(媒体生成走 _generate_media_task 异步补写真实消耗,此处跳过避免占位文本重复记账) - - - - - - - - - - - - - - - - if not media_generating: - - - - - - - - - - - - - - - - token_usage = TokenUsageLog( - - - - - - - - - - - - - - - - id=gen_uuid(), - - - - - - - - - - - - - - - - user_id=current_user.id, - - - - - - - - - - - - - - - - model_id=ai_model.id if ai_model else (req.model_id or conv.model_id), - - - - - - - - - - - - - - - - model_name=ai_model.name if ai_model else None, - - - - - - - - - - - - - - - - conversation_id=conv_id, - - - - trace_id=get_trace_id(), - - - - - - - - - - - - - - - - - - - - prompt_tokens=prompt_tokens or len(req.content) // 2, - - - - - - - - - - - - - - - - completion_tokens=completion_tokens or ai_token_count, - - - - - - - - - - - - - - - - total_tokens=total_tokens or (len(req.content) // 2 + ai_token_count), - - - - - - - - - - - - - - - - created_at=cst_now(), - - - - - - - - - - - - - - - - ) - - - - - - - - - - - - - - - - db.add(token_usage) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 进化轨迹记录 - - - - - - - - - - - - - - - - try: - - - - - - - - - - - - - - - - existing = db.query(EvolutionTrajectory).filter( - - - - - - - - - - - - - - - - EvolutionTrajectory.conversation_id == conv_id - - - - - - - - - - - - - - - - ).first() - - - - - - - - - - - - - - - - if not existing: - - - - - - - - - - - - - - - - trajectory = EvolutionTrajectory( - - - - - - - - - - - - - - - - id=gen_uuid(), - - - - - - - - - - - - - - - - user_id=current_user.id, - - - - - - - - - - - - - - - - conversation_id=conv_id, - - - - - - - - - - - - - - - - status="discovered", - - - - - - - - - - - - - - - - token_used=ai_token_count, - - - - - - - - - - - - - - - - created_at=cst_now(), - - - - - - - - - - - - - - - - ) - - - - - - - - - - - - - - - - db.add(trajectory) - - - - - - - - - - - - - - - - db.flush() - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - try: - - - - - - - - - - - - - - - - from app.services.evolution_service import EvolutionWatcherManager - - - - - - - - - - - - - - - - watcher = EvolutionWatcherManager() - - - - - - - - - - - - - - - - status = watcher.get_user_status(current_user.id, lambda: SessionLocal()) - - - - - - - - - - - - - - - - if not status.get("scheduler_running"): - - - - - - - - - - - - - - - - watcher.start_user_evolution(current_user.id, lambda: SessionLocal()) - - - - - - - - - - - - - - - - logger.info(f"[Evolution] 自动启动用户 {current_user.id} 的进化Pipeline") - - - - - - - - - - - - - - - - except Exception as evolution_err: - - - - - - - - - - - - - - - - logger.warning(f"[Evolution] 自动启动Pipeline失败: {evolution_err}") - - - - - - - - - - - - - - - - except Exception: - - - - - - - - - - - - - - - - pass - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - db.commit() - - - - - - - - - - - - - - - - # 后台异步压缩历史(分层记忆),不阻塞本次响应 - - - - asyncio.create_task(_maybe_compress_history(conv_id)) - - - - - - - - db.refresh(user_msg) - - - - - - - - - - - - - - - - db.refresh(ai_msg) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - # 媒体生成改为后台异步:立即返回占位,后台任务完成后回写消息内容 - - - - - - - - - - - - - - - - if media_generating: - - - - - - - - - - - - - - - - try: - - - - - - - - - - - - - - - - asyncio.create_task( - - - - - - - - - - - - - - - - _generate_media_task(ai_msg.id, ai_model.id, req.content, current_user.id) - - - - - - - - - - - - - - - - ) - - - - - - - - - - - - - - - - except Exception as e: - - - - - - - - - - - - - - - - logger.warning(f"[_generate_media_task] 启动后台任务失败: {e}", exc_info=True) - - # 启动失败兜底:不让占位消息永久停在「⏳ 生成中…」 - - try: - - ai_msg.content = "⚠️ 媒体生成启动失败,请稍后重试" - - db.commit() - - except Exception: - - pass - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - if on_event is not None: - try: - try: - _mid = ai_msg.id - except NameError: - _mid = None - try: - _dc = ai_content - except NameError: - _dc = (ai_msg.content if "ai_msg" in dir() else "") - try: - _ps = process_steps - except NameError: - _ps = [] - try: - _us = used_skills - except NameError: - _us = [] - try: - _um = used_mcp - except NameError: - _um = [] - try: - _mg = media_generating - except NameError: - _mg = False - await on_event({ - "type": "done", - "message_id": _mid, - "conversation_id": conv_id, - "content": _dc, - "process_steps": _ps, - "used_skills": _us, - "used_mcp": _um, - "generating": _mg, - }) - except Exception: - pass - - return { - - - - - - - - - - - - - - - - "code": 0, - - - - - - - - - - - - - - - - "data": { - - - - - - - - - - - - - - - - "user_message": { - - - - - - - - - - - - - - - - "id": user_msg.id, "role": user_msg.role, "content": user_msg.content, - - - - - - - - - - - - - - - - "token_used": user_msg.token_used, "created_at": str(user_msg.created_at), - - - - - - - - - - - - - - - - }, - - - - - - - - - - - - - - - - "assistant_message": { - - - - - - - - - - - - - - - - "id": ai_msg.id, "role": ai_msg.role, "content": ai_msg.content, - - - - - - - - - - - - - - - - "token_used": ai_msg.token_used, "created_at": str(ai_msg.created_at), - - - - - - - - - - - - - - - - "used_skills": used_skills, - - - - "used_mcp": used_mcp, - - - - - - - - - - - - - - - - "generating": media_generating, - - "process_steps": process_steps, - - - - - - - - - - - - - - - - - - }, - - - - - - - - - - - - - - - - }, - - - - - - - - - - - - - - - - "message": "发送成功", - - - - - - - - - - - - - - - - } - - - - - - - - - - - -# ====================================================================== - - - -# 3.1 tool_policy 沙箱辅助函数 - - - -# ====================================================================== - - - -def _resolve_agent_tool_policy(agents): - - - - """从已解析的 agents 聚合工具策略。多 agent 时取首个带有非空策略者。""" - - - - if not agents: - - - - return None - - - - for ag in agents: - - - - tp = ag.get("tool_policy") - - - - if tp and isinstance(tp, dict): - - - - tp = dict(tp) - - - - tp["agent_id"] = ag.get("id") - - - - return tp - - - - return None - - - - - - - - - - - -def _check_tool_policy(func_name, mcp_info, skills, policy, call_count, round_count): - - - - """校验单个工具调用是否被策略允许。 - - - - - - - - 返回 {"allowed": bool, "reason": str, "tool_id": str|None, "message": str} - - - - policy 为 None/空 -> 允许(向后兼容)。 - - - - """ - - - - if not policy: - - - - return {"allowed": True, "tool_id": None} - - - - allowed = policy.get("allowed_tools") or {} - - - - skill_ids = allowed.get("skill_ids") or [] - - - - mcp_ids = allowed.get("mcp_ids") or [] - - - - is_mcp = func_name.startswith("mcp_tool_") - - - - tool_id = None - - - - if is_mcp: - - - - if mcp_info and isinstance(mcp_info, dict): - - - - tool_id = mcp_info.get("server_id") - - - - if mcp_ids and tool_id not in mcp_ids: - - - - return {"allowed": False, "reason": "mcp_not_in_allowlist", "tool_id": tool_id, - - - - "message": "MCP 服务不在该智能体的允许列表中"} - - - - else: - - - - if skills: - - - - _s = next((s for s in skills if s.get("func_name") == func_name), None) - - - - if _s: - - - - tool_id = _s.get("id") - - - - if skill_ids and tool_id not in skill_ids: - - - - return {"allowed": False, "reason": "skill_not_in_allowlist", "tool_id": tool_id, - - - - "message": "技能不在该智能体的允许列表中"} - - - - budget = policy.get("max_calls_per_conversation") - - - - if budget is not None and call_count >= int(budget): - - - - return {"allowed": False, "reason": "budget_exceeded", "tool_id": tool_id, - - - - "message": policy.get("fallback_message") - - - - or "该智能体已达到工具调用上限,请开启新对话或调整智能体设置。"} - - - - per_round = policy.get("per_round_cap") - - - - if per_round is not None and round_count >= int(per_round): - - - - return {"allowed": False, "reason": "per_round_cap", "tool_id": tool_id, - - - - "message": "本轮工具调用已达上限"} - - - - return {"allowed": True, "tool_id": tool_id} - - - - +# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). + + + +# Source of truth: scripts/phase1_extract.py. Do not hand-merge back. + + + + + + + +from fastapi import APIRouter, Depends, HTTPException, Query, Response + + + +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, timezone, timedelta + + + +import re + + + +import json + + + +import asyncio + + + +import math + + + +import httpx + + + +import os + + + +import logging + + + +from ..database import get_db, SessionLocal + + + +from app.core.observability import span, get_trace_id + + + +from ..models import ( + + + + + + + + + + + + + + + + User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, + + + + + + + + + + + + + + + + KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, + + + + + + + + + + + + + + + + EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, + + + + + + + + + + + + + + + + MCPServer, + + + + + + + + + + + + + + + + gen_uuid + + + + + + + + + + + + + + + +) + + + +from ..auth import get_current_user + + + +from ..platform_knowledge import get_platform_knowledge + + + +from ..mcp_client import list_mcp_tools, call_mcp_tool + + + +from ..middleware.log_service import LogService + + + +from ..capability_registry import resolve_auto_model + + + +from app.api.chat_agents import (_call_external_agent) + + + +from app.api.chat_context import (_build_system_prompt, _get_conversation_context, _ingest_answer_to_dify, _retrieve_kb_context, _build_chat_history, _maybe_compress_history) + + + +from app.api.chat_mcp import (_list_enabled_mcp_servers, _resolve_active_mcp_servers) + + + +from app.api.chat_media import (_generate_media_task, detect_ambiguous_creative_intent, detect_media_intent) + + + +from app.api.chat_skill import (_discover_installed_skills, _execute_skill_tool, _resolve_market_agents, _resolve_market_skills, _execute_install_app) + + + +from app.api.chat_utils import (SendMessageRequest, _proxy_for_url, cst_now, logger) + +# 统一模型层(07-24 红线):web chat 与 IM bot 共用标准 OpenAI SDK 客户端 + +from app.core.llm_client import achat_complete + + + + + + + +async def _send_message_core( + + + + + + + + + + + + + + + + conv_id: str, + + + + + + + + + + + + + + + + req: SendMessageRequest, + + + + + + + + + + + + + + + + db: Session = Depends(get_db), + + + + + + + + + + + + + + + + current_user: User = Depends(get_current_user), + stream: bool = False, + on_event=None, + + + + + + + + + + + + + + + +): + + + + + + + + + + + + + + + + conv = db.query(Conversation).filter( + + + + + + + + + + + + + + + + and_(Conversation.id == conv_id, Conversation.user_id == current_user.id) + + + + + + + + + + + + + + + + ).first() + + + + + + + + + + + + + + + + if not conv: + + + + + + + + + + + + + + + + raise HTTPException(status_code=404, detail="对话不存在") + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Stage B.2: 多轮上下文保持 — 如果当前请求没有skill/agent/kb, + + + + + + + + + + + + + + + + # 从对话历史中继承上一次的选择 + + + + + + + + + + + + + + + + effective_skill_ids = req.skill_ids or [] + + + + + + + + + + + + + + + + effective_agent_ids = req.agent_ids or [] + + + + + + + + + + + + + + + + effective_kb_ids = req.knowledge_base_ids or [] + + + + + + + + + + + + + + + + effective_mcp_ids = req.mcp_ids or [] + + + + + + + + + + + + + + + + effective_use_tools = req.use_tools or False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if not effective_skill_ids and not effective_agent_ids and not effective_kb_ids and not effective_mcp_ids: + + + + + + + + + + + + + + + + # 从对话上下文继承 + + + + + + + + + + + + + + + + prev_context = _get_conversation_context(db, conv_id) + + + + + + + + + + + + + + + + if prev_context: + + + + + + + + + + + + + + + + effective_skill_ids = prev_context.get("skill_ids", []) + + + + + + + + + + + + + + + + effective_agent_ids = prev_context.get("agent_ids", []) + + + + + + + + + + + + + + + + effective_kb_ids = prev_context.get("knowledge_base_ids", []) + + + + + + + + + + + + + + + + effective_mcp_ids = prev_context.get("mcp_ids", []) + + + + + + + + + + + + + + + + if effective_skill_ids or effective_agent_ids or effective_mcp_ids: + + + + + + + + + + + + + + + + effective_use_tools = True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 附件内容拼接到用户消息中(文本类文件附带内容, 供模型参考) + + + + + + + + + + + + + + + + attachment_block = "" + + + + + + + + + + + + + + + + if req.attachments: + + + + + + + + + + + + + + + + parts = [] + + + + + + + + + + + + + + + + for att in req.attachments: + + + + + + + + + + + + + + + + name = att.get("name", "附件") if isinstance(att, dict) else str(att) + + + + + + + + + + + + + + + + ftype = att.get("file_type", "") if isinstance(att, dict) else "" + + + + + + + + + + + + + + + + content = att.get("content", "") if isinstance(att, dict) else "" + + + + + + + + + + + + + + + + if content: + + + + + + + + + + + + + + + + parts.append(f"【附件: {name}】\n{content}") + + + + + + + + + + + + + + + + else: + + + + + + + + + + + + + + + + parts.append(f"【附件(二进制, 名称: {name}, 类型: {ftype})】") + + + + + + + + + + + + + + + + if parts: + + + + + + + + + + + + + + + + attachment_block = "\n\n" + "\n\n".join(parts) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 保存用户消息(含skill/agent/kb/mcp选择用于上下文保持) + + + + + + + + + + + + + + + + user_msg = Message( + + + + + + + + + + + + + + + + id=gen_uuid(), + + + + + + + + + + + + + + + + conversation_id=conv_id, + + + + + + + + + + + + + + + + role="user", + + + + + + + + + + + + + + + + content=req.content + attachment_block, + + + + + + + + + + + + + + + + token_used=len(req.content) // 2, + + + + + + + + + + + + + + + + model_id=req.model_id or conv.model_id, + + + + + + + + + + + + + + + + skill_ids=req.skill_ids if req.skill_ids else None, + + + + + + + + + + + + + + + + agent_ids=req.agent_ids if req.agent_ids else None, + + + + + + + + + + + + + + + + knowledge_ids=req.knowledge_base_ids if req.knowledge_base_ids else None, + + + + + + + + + + + + + + + + mcp_ids=req.mcp_ids if req.mcp_ids else None, + + + + + + + + + + + + + + + + attachments=req.attachments if req.attachments else None, + + + + + + + + + + + + + + + + created_at=cst_now(), + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + db.add(user_msg) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # ===== 模糊创作意图拦截 ===== + + + + + + + + + + + + + + + + # 用户使用创作类动词(制作/创作/做/生成…)但未明确产出形式时,先与用户确认方向, + + + + + + + + + + + + + + + + # 避免直接调用大模型被内容安全策略拒答(如返回 "high risk")。 + + + + + + + + + + + + + + + + clarify = detect_ambiguous_creative_intent(req.content) + + + + + + + + + + + + + + + + if clarify: + + + + + + + + + + + + + + + + clarify_content = "<>" + json.dumps( + + + + + + + + + + + + + + + + {"prompt": req.content, "options": clarify["options"]}, ensure_ascii=False + + + + + + + + + + + + + + + + ) + "<>" + + + + + + + + + + + + + + + + ai_msg = Message( + + + + + + + + + + + + + + + + id=gen_uuid(), + + + + + + + + + + + + + + + + conversation_id=conv_id, + + + + + + + + + + + + + + + + role="assistant", + + + + + + + + + + + + + + + + content=clarify_content, + + + + + + + + + + + + + + + + token_used=0, + + + + + + + + + + + + + + + + model_id=req.model_id or conv.model_id, + + + + + + + + + + + + + + + + created_at=cst_now(), + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + db.add(ai_msg) + + + + + + + + + + + + + + + + db.commit() + + + + + + + + + + + + + + + + db.refresh(user_msg) + + + + + + + + + + + + + + + + db.refresh(ai_msg) + + + + + + + + + + + + + + + + if on_event is not None: + try: + try: + _mid = ai_msg.id + except NameError: + _mid = None + try: + _dc = ai_content + except NameError: + _dc = (ai_msg.content if "ai_msg" in dir() else "") + try: + _ps = process_steps + except NameError: + _ps = [] + try: + _us = used_skills + except NameError: + _us = [] + try: + _um = used_mcp + except NameError: + _um = [] + try: + _mg = media_generating + except NameError: + _mg = False + await on_event({ + "type": "done", + "message_id": _mid, + "conversation_id": conv_id, + "content": _dc, + "process_steps": _ps, + "used_skills": _us, + "used_mcp": _um, + "generating": _mg, + }) + except Exception: + pass + + return { + + + + + + + + + + + + + + + + "code": 0, + + + + + + + + + + + + + + + + "data": { + + + + + + + + + + + + + + + + "user_message": { + + + + + + + + + + + + + + + + "id": user_msg.id, "role": user_msg.role, "content": user_msg.content, + + + + + + + + + + + + + + + + "token_used": user_msg.token_used, "created_at": str(user_msg.created_at), + + + + + + + + + + + + + + + + }, + + + + + + + + + + + + + + + + "assistant_message": { + + + + + + + + + + + + + + + + "id": ai_msg.id, "role": ai_msg.role, "content": clarify_content, + + + + + + + + + + + + + + + + "created_at": str(ai_msg.created_at), "clarify": clarify, + + + + + + + + + + + + + + + + }, + + + + + + + + + + + + + + + + }, + + + + + + + + + + + + + + + + "message": "请选择创作方向", + + + + + + + + + + + + + + + + } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # ===== 核心聊天引擎 ===== + + + + + + + + + + + + + + + + user_id = current_user.id + + + + + + + + + + + + + + + + model_id = req.model_id or conv.model_id + + + + + + + + + + + + + + + + prompt_tokens = 0 + + + + + + + + + + + + + + + + completion_tokens = 0 + + + + + + + + + + + + + + + + total_tokens = 0 + + + + + + + + + + + + + + + + ai_content = "" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ai_model = None + + + + + + + + + + + + + + + + skills = [] # 初始化避免UnboundLocalError + + process_steps = [] # 过程步骤(思考/技能/MCP执行),用于前端收拢式展示 + + + + + + + + + + + + + + + + + + agents = [] # 初始化避免UnboundLocalError + + + + + + + + + + + + + + + + media_generating = False # 提前初始化:外部Agent分支与末尾均会引用 + + + + + + + + + + + + + + + + external_handled = False # 是否已由外部Agent(Dify/n8n/Coze)远程处理 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # ===== 外部 Agent 远程调用分支 (Dify / n8n / Coze) ===== + + + + + + + + + + + + + + + + # 若本轮对话关联了带有效 agent_endpoint 的外部 Agent,则直接远程调用其平台, + + + + + + + + + + + + + + + + # 跳过本地 LLM「扮演」逻辑。effective_agent_ids 已在前面从请求/上下文继承。 + + + + + + + + + + + + + + + + external_agents = _resolve_market_agents(db, effective_agent_ids, user_id) + + + + + + + + + + + + + + + + external_agent = next((a for a in external_agents if a.get("agent_endpoint")), None) + + + + + + + + # ===== 3.3 多 Agent 编排(图引擎)快路 ===== + + + + _orchestrator = next((a for a in external_agents if a.get("orchestration")), None) + + + + if _orchestrator: + + + + try: + + + + from app.core.orchestrator import run_graph + + + + _answer, _trace = await run_graph( + + + + graph=_orchestrator["orchestration"], + + + + query=req.content + attachment_block, + + + + db=db, + + + + user_id=user_id, + + + + conv_id=conv_id, + + + + trace_id=get_trace_id(), + + + + ) + + + + ai_content = _answer or "编排未产生内容。" + + + + logger.info( + + + + f"[orchestrator] 图执行完成 trace_id={get_trace_id()} nodes={len(_trace.get('nodes', []))}" + + + + ) + + + + except Exception as e: + + + + logger.warning(f"[orchestrator] 图执行失败: {e}") + + + + ai_content = f"⚠️ 多 Agent 编排执行失败:{str(e)[:200]}" + + + + external_handled = True + + + + external_agent = None # 阻止下方外部 Agent 分支重复执行 + + + + + + + + + + + + + + + + if external_agent: + + + + + + + + + + + + + + + + agents = external_agents + + + + + + + + + + + + + + + + # 组装最近历史(供 n8n 等无平台会话态的服务维持多轮上下文) + + + + + + + + + + + + + + + + _history = _build_chat_history(db, conv_id, max_tokens=4000, exclude_latest_user=True) + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + answer, new_ext_id = await _call_external_agent( + + + + + + + + + + + + + + + + db, external_agent, + + + + + + + + + + + + + + + + query=req.content + attachment_block, + + + + + + + + + + + + + + + + user_id=user_id, + + + + + + + + + + + + + + + + ext_conversation_id=conv.ext_agent_conversation_id, + + + + + + + + + + + + + + + + history=_history, + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + ai_content = answer or "外部 Agent 未返回内容。" + + + + + + + + + + + + + + + + if new_ext_id: + + + + + + + + + + + + + + + + conv.ext_agent_conversation_id = new_ext_id + + + + + + + + + + + + + + + + except Exception as e: + + + + + + + + + + + + + + + + logger.warning( + + + + + + + + + + + + + + + + f"[external_agent] 远程调用失败 platform={external_agent.get('platform')} " + + + + + + + + + + + + + + + + f"endpoint={external_agent.get('agent_endpoint')}: {e}" + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + ai_content = f"⚠️ 外部 Agent 调用失败:{str(e)[:200]}" + + + + + + + + + + + + + + + + external_handled = True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 解析模型:支持 Auto 模式(model_id='auto' 或未指定时,按能力自动匹配平台默认模型) + + + + + + + + + + + + + + + + if not external_handled: + + + + + + + + + + + + + + + + if model_id and model_id != "auto": + + + + + + + + + + + + + + + + ai_model = db.query(AIModel).filter(AIModel.id == model_id).first() + + + + + + + + + + + + + + + + if not ai_model: + + + + + + + + + + + + + + + + # Auto / 未指定 → 按用户输入意图匹配对应能力的默认模型,否则回退文本对话 + + + + + + + + + + + + + + + + intent = detect_media_intent(req.content) + + + + + + + + + + + + + + + + resolved = resolve_auto_model(intent, db) if intent else None + + + + + + + + + + + + + + + + ai_model = resolved or resolve_auto_model("text", db) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # ===== Model Failover: Auto 模式下自动切换备选模型 ===== + + _try_models = [ai_model] if (ai_model and ai_model.is_enabled) else [] + + if not model_id or model_id == "auto": + + # Auto 模式:附加其他已启用的文本模型作为备选 + + _fallbacks = db.query(AIModel).filter( + + AIModel.is_enabled == True, + + AIModel.id != (ai_model.id if ai_model else "") + + ).order_by(AIModel.is_default.desc()).limit(5).all() + + _try_models.extend([m for m in _fallbacks if m not in _try_models]) + + _retry_log = [] + + _all_failed = False + + + + for _try_model in _try_models: + + ai_model = _try_model + + if ai_model: + + + + + + + + + + + + + + + + # ====== 图像/视频生成模型分支 ====== + + + + + + + + + + + + + + + + caps = [c.lower() for c in (ai_model.capabilities or [])] if ai_model else [] + + + + + + + + + + + + + + + + media_generating = False + + + + + + + + + + + + + + + + if ai_model and ("image" in caps or "video" in caps): + + + + + + + + + + + + + + + + # 异步生成:立即返回占位内容,后台任务轮询 AGNES 并回写, + + + + + + + + + + + + + + + + # 避免最长 5 分钟的视频轮询阻塞 HTTP 请求导致客户端超时断开。 + + + + + + + + + + + + + + + + media_generating = True + + + + + + + + + + + + + + + + if "video" in caps: + + + + + + + + + + + + + + + + ai_content = "⏳ 视频生成中…(通常 1~3 分钟,完成后自动刷新)" + + + + + + + + + + + + + + + + else: + + + + + + + + + + + + + + + + ai_content = "⏳ 图像生成中…(完成后自动刷新)" + + + + + + + + + + + + + + + + elif ai_model and ai_model.endpoint: + + + + + + + + + + + + + + + + provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first() + + + + + + + + + + + + + + + + api_key = ai_model.api_key or (provider.api_key if provider else None) + + + + + + + + + + + + + + + + api_url = ai_model.endpoint + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if not api_url and provider and provider.api_base_url: + + + + + + + + + + + + + + + + api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if api_key and api_url: + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + # Stage A+B: 解析Skills和Agents + + + + + + + + + + + + + + + + skills = _resolve_market_skills(db, effective_skill_ids, user_id) + + + + + + + + + + + + + + + + agents = _resolve_market_agents(db, effective_agent_ids, user_id) + + + + + + + + + + + + + + + + mcps = _resolve_active_mcp_servers(db, effective_mcp_ids) + + + + + + + + + + + + + + + + # MCP 工具调用链路:拉取各 MCP 工具并转为 function definitions + + + + + + + + mcp_servers_conn = mcps + + + + + + + + mcp_function_definitions = [] + + + + + + + + mcp_tool_map = {} + + + + + + + + mcp_idx = 0 + + + + + + + + for _srv in mcp_servers_conn: + + + + + + + + try: + + + + + + + + async with span("mcp.list_tools", server=_srv.get("name") if isinstance(_srv, dict) else None): + + + + _tools = await list_mcp_tools(_srv) + + + + + + + + except Exception as _e: + + + + + + + + logger.warning(f"MCP 拉取工具失败 [{_srv.get('name')}]: {_e}") + + + + + + + + _tools = [] + + + + + + + + if _tools: + + + + + + + + try: + + + + + + + + _upd = db.query(MCPServer).filter(MCPServer.id == _srv["id"]).first() + + + + + + + + if _upd: + + + + + + + + _upd.tools_count = len(_tools) + + + + + + + + db.commit() + + + + + + + + except Exception: + + + + + + + + pass + + + + + + + + for _t in _tools: + + + + + + + + _fname = f"mcp_tool_{mcp_idx}" + + + + + + + + mcp_tool_map[_fname] = { + + + + + + + + "server_id": _srv["id"], + + + + + + + + "name": _t.get("name"), + + + + + + + + "server_name": _srv["name"], + + + + + + + + } + + + + + + + + _schema = _t.get("inputSchema") or {"type": "object", "properties": {}} + + + + + + + + if not isinstance(_schema, dict) or _schema.get("type") != "object": + + + + + + + + _schema = {"type": "object", "properties": {}} + + + + + + + + mcp_function_definitions.append({ + + + + + + + + "name": _fname, + + + + + + + + "description": f"[MCP·{_srv['name']}] {_t.get('description', '')}", + + + + + + + + "parameters": _schema, + + + + + + + + }) + + + + + + + + mcp_idx += 1 + + + + + + + + if mcp_function_definitions: + + + + + + + + effective_use_tools = True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Issue 8: 默认技能自动发现(仅当用户未显式选择技能时启用) + + + + + + + + + + + + + + + + # 每轮先寻找本账号已安装且平台已部署的技能,多个适用时由模型自行判断选 1 个最优。 + + + + + + + + + + + + + + + + used_auto_skills = False + + + + + + + + + + + + + + + + if not effective_skill_ids: + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + discovered = _discover_installed_skills(db, user_id) + + + + + + + + + + + + + + + + except Exception as e: + + + + + + + + + + + + + + + + logger.warning(f"自动发现已安装技能失败: {e}") + + + + + + + + + + + + + + + + discovered = [] + + + + + + + + + + + + + + + + if discovered: + + + + + + + + + + + + + + + + skills = discovered + + + + + + + + + + + + + + + + used_auto_skills = True + + + + + + + + + + + + + + + + effective_use_tools = True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Stage B.1: Agent可能自带skill_ids和kb_ids + + + + + + + + + + + + + + + + agent_extra_skill_ids = [] + + + + + + + + + + + + + + + + agent_extra_kb_ids = [] + + + + + + + + + + + + + + + + agent_model_override = None + + + + + + + + + + + + + + + + for agent in agents: + + + + + + + + + + + + + + + + if agent.get("skill_ids"): + + + + + + + + + + + + + + + + agent_extra_skill_ids.extend(agent["skill_ids"]) + + + + + + + + + + + + + + + + if agent.get("knowledge_base_ids"): + + + + + + + + + + + + + + + + agent_extra_kb_ids.extend(agent["knowledge_base_ids"]) + + + + + + + + + + + + + + + + if agent.get("model_id"): + + + + + + + + + + + + + + + + agent_model_override = agent["model_id"] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 合并Agent自带的skills和KBs + + + + + + + + + + + + + + + + all_skill_ids = list(set(effective_skill_ids + agent_extra_skill_ids)) + + + + + + + + + + + + + + + + all_kb_ids = list(set(effective_kb_ids + agent_extra_kb_ids)) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if agent_extra_skill_ids or agent_extra_kb_ids: + + + + + + + + + + + + + + + + # 重新解析合并后的skills + + + + + + + + + + + + + + + + skills = _resolve_market_skills(db, all_skill_ids, user_id) + + + + + + + + + + + + + + + + effective_use_tools = True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Stage C: 增强版KB检索 + + + + + + + + + + + + + + + + kb_context = await _retrieve_kb_context(db, all_kb_ids, req.content) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Stage B: 动态组装system prompt + function definitions + + + + + + + + + + + + + + + + system_prompt, function_definitions = _build_system_prompt( + + + + + + + + + + + + + + + + skills=skills, + + + + + + + + + + + + + + + + agents=agents, + + + + + + + + + + + + + + + + kb_context=kb_context, + + + + + + + + + + + + + + + + user_content=req.content, + + + + + + + + + + + + + + + + mcp=mcps, + + + + + + + + mcp_functions=mcp_function_definitions, + + + + + + + + auto_discovered=used_auto_skills, + + + + all_mcp=_list_enabled_mcp_servers(db), + + + + db=db, + + + + ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 构建消息列表 + + + + + + + + + + + + + + + + # 实际调用追踪(Issue:反馈真实使用的技能/MCP,而非注入的全部) + + + + called_skill_names = [] + + + + called_mcp_names = [] + + + + + + + + if isinstance(function_definitions, list): + function_definitions.append({ + "name": "install_app", + "description": "当用户提供技能/Skill、MCP 服务或 Agent 的链接(如 GitHub 仓库、mcp.so、modelscope 等)并希望安装到平台时使用。自动识别类型(skill/mcp/agent)并安装,随后可在对话中直接使用。", + "parameters": { + "type": "object", + "properties": { + "url": {"type": "string", "description": "要安装的技能/MCP/Agent 地址(URL)"}, + "type": {"type": "string", "enum": ["auto", "skill", "mcp", "agent"], "description": "安装类型,auto 表示自动识别(默认)"}, + }, + "required": ["url"], + }, + }) + + messages = [{"role": "system", "content": system_prompt}] + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 分层上下文引擎:预算感知历史 + 压缩摘要(若有) + + + + messages.extend(_build_chat_history(db, conv_id, max_tokens=6000, exclude_latest_user=True)) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Issue 1: 必须把附件内容(attachment_block)一并发给模型, + + + + + + + + + + + + + + + + # 否则用户在对话窗上传文件后模型“看不到”文件内容。 + + + + + + + + + + + + + + + + # Issue 2 修复:用户输入含 URL 且要求读取/加入/安装时,服务端抓取真实页面内容注入上下文 + + try: + + from app.api.url_reader import fetch_url_context + + _url_ctx = await fetch_url_context(req.content) + + except Exception as _e: + + import logging as _logging + + _logging.getLogger(__name__).warning("URL 上下文抓取失败(已跳过): %s", _e) + + _url_ctx = None + + if _url_ctx and _url_ctx.get("text"): + + messages.append({ + + "role": "system", + + "content": ( + + "【用户随消息提供的参考链接的真实内容】\n" + + f"链接地址: {_url_ctx.get('url')}\n" + + "下面是该链接页面的真实内容,请严格基于它来回答用户的需求" + + "(例如按其『Quick Start / 加入步骤』逐步引导用户完成安装或加入)," + + "不要凭空编造步骤:\n\n" + _url_ctx["text"] + + ), + + }) + + process_steps.append({ + + "type": "skill", + + "name": "网页/仓库读取", + + "result": f"已读取 {_url_ctx.get('url')}({len(_url_ctx['text'])} 字符)", + + }) + + messages.append({"role": "user", "content": req.content + attachment_block}) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Stage B.3: Function Calling循环(最多5轮) + + + + + + + + + + + + + + + + max_tool_rounds = 5 + + + + + + + + + + + + + + + + # ===== 3.1 tool_policy 沙箱:聚合策略 + 初始化预算计数器 ===== + + + + _agent_tool_policy = _resolve_agent_tool_policy(agents) + + + + _tool_call_count = 0 + + + + for round_num in range(max_tool_rounds): + + + + _round_tool_count = 0 + + + + + + + + + + + + + + + + request_body = { + + + + + + + + + + + + + + + + "model": ai_model.name, + + + + + + + + + + + + + + + + "messages": messages, + + + + + + + + + + + + + + + + "max_tokens": 2048, + + + + + + + + + + + + + + + + } + + + + + + + + + + + + + + + + if function_definitions and effective_use_tools: + + + + + + + + + + + + + + + + request_body["tools"] = [ + + + + + + + + + + + + + + + + {"type": "function", "function": fd} + + + + + + + + + + + + + + + + for fd in function_definitions + + + + + + + + + + + + + + + + ] + + + + + + + + + + + + + + + + request_body["tool_choice"] = "auto" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + async with span("llm.completion", model=ai_model.id if ai_model else None): + + try: + + if stream and on_event is not None and round_num == 0 and not (effective_mcp_ids or effective_skill_ids or effective_agent_ids): + + _sc = "" + _sr = "" + async for _kind, _text in await achat_complete( + api_url, api_key, request_body, + proxy=_proxy_for_url(api_url), + timeout=ai_model.timeout or 120, + stream=True, + ): + if _kind == "content": + _sc += _text + await on_event({"type": "delta", "content": _text}) + elif _kind == "reasoning": + _sr += _text + await on_event({"type": "reasoning", "content": _text}) + message = {"content": _sc, "tool_calls": [], "reasoning": _sr} + choice = {"message": message} + data = {"choices": [choice], "usage": {}} + else: + data = await achat_complete( + api_url, + api_key, + request_body, + proxy=_proxy_for_url(api_url), + timeout=ai_model.timeout or 120, + ) + + except Exception as _exc: + + if round_num == 0: + + ai_content = f"AI模型调用失败 ({type(_exc).__name__}: {str(_exc)[:120]}), 请检查模型配置。" + + logger.warning(f"[chat] 模型 {ai_model.name} 调用异常: {_exc}") + + break + + choice = data.get("choices", [{}])[0] + + message = choice.get("message", {}) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 记录token使用量 + + + + + + + + + + + + + + + + usage = data.get("usage", {}) + + + + + + + + + + + + + + + + prompt_tokens += usage.get("prompt_tokens", 0) + + + + + + + + + + + + + + + + completion_tokens += usage.get("completion_tokens", 0) + + + + + + + + + + + + + + + + total_tokens += usage.get("total_tokens", 0) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 检查是否有tool_calls + + + + + + + + + + + + + + + + tool_calls = message.get("tool_calls", []) + + # 过程步骤:捕获模型思考/推理(产生工具调用前的推理) + + _think = (message.get("content") or "").strip() + + if _think and tool_calls: + + process_steps.append({ + + "type": "thinking", + + "text": _think, + + }) + + + + + + + + + + + + + + + + + + if tool_calls and effective_use_tools: + + + + + + + + + + + + + + + + # 将assistant消息(含tool_calls)加入上下文 + + + + + + + + + + + + + + + + messages.append({ + + + + + + + + + + + + + + + + "role": "assistant", + + + + + + + + + + + + + + + + "content": message.get("content") or "", + + + + + + + + + + + + + + + + "tool_calls": tool_calls, + + + + + + + + + + + + + + + + }) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # Stage A.2: 真实Skill执行 + + + + + + + + + + + + + + + + for tc in tool_calls: + + + + + + + + + + + + + + + + func_name = tc.get("function", {}).get("name", "") + + + + # ===== 3.1 tool_policy 沙箱:逐工具校验(白名单 + 预算 + 每轮上限) ===== + + + + _mcp_info = mcp_tool_map.get(func_name) if func_name.startswith("mcp_tool_") else None + + + + _policy_check = _check_tool_policy(func_name, _mcp_info, skills, _agent_tool_policy, _tool_call_count, _round_tool_count) + + + + if not _policy_check["allowed"]: + + + + _reason = _policy_check["reason"] + + + + logger.warning( + + + + f"[tool_policy] blocked tool call: agent={_agent_tool_policy.get('agent_id') if _agent_tool_policy else None} " + + + + f"tool={func_name} reason={_reason} trace={get_trace_id()}" + + + + ) + + + + tool_result = f"[工具调用已被策略拦截] {_policy_check.get('message', _reason)}" + + + + messages.append({ + + + + "role": "tool", + + + + "tool_call_id": tc.get("id", f"call_{round_num}"), + + + + "content": tool_result, + + + + }) + + + + _tool_call_count += 1 + + + + _round_tool_count += 1 + + + + continue + + + + _tool_call_count += 1 + + + + _round_tool_count += 1 + + + + + + + + + + + + + + + + func_args_str = tc.get("function", {}).get("arguments", "{}") + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + func_args = json.loads(func_args_str) + + + + + + + + + + + + + + + + except Exception: + + + + + + + + + + + + + + + + func_args = {"input": func_args_str} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if func_name.startswith("mcp_tool_"): + + + + + + + + # MCP 工具调用:代理到对应 MCP 服务执行 tools/call + + + + + + + + _info = mcp_tool_map.get(func_name) + + + + + + + + tool_result = "[MCP 工具未找到或映射缺失]" + + + + + + + + if _info: + + + + + + + + _srv = next( + + + + + + + + (s for s in mcp_servers_conn if s["id"] == _info["server_id"]), + + + + + + + + None, + + + + + + + + ) + + + + + + + + if _srv: + + + + + + + + async with span(f"mcp.call:{_info.get('name') if isinstance(_info, dict) else None}"): + + + + tool_result = await call_mcp_tool( + + + + + + + + _srv, _info["name"], func_args + + + + + + + + ) + + + + + + + + called_mcp_names.append(_srv.get("name", "")) + + process_steps.append({ + + "type": "mcp", + + "name": _info.get("name") if isinstance(_info, dict) else func_name, + + "server": _srv.get("name", ""), + + "args": func_args, + + "result": (tool_result or "")[:800], + + }) + + + + + + + + + + elif func_name == "install_app": + async with span("app.install"): + _iu = func_args.get("url", "") if isinstance(func_args, dict) else "" + _itype = (func_args.get("type", "auto") if isinstance(func_args, dict) else "auto") or "auto" + tool_result = _execute_install_app(db, _iu, current_user.id if current_user else None, _itype) + process_steps.append({ + "type": "skill", + "name": "安装应用", + "args": func_args, + "result": (tool_result or "")[:800], + }) + else: + async with span(f"skill.execute:{func_name}"): + + + + tool_result = _execute_skill_tool( + + + + + + + + db, func_name, func_args, skills, req.content + + + + + + + + ) + + + + + + + + _sk = next((s for s in skills if s.get("func_name") == func_name), None) + + + + + + + + if _sk: + + + + + + + + called_skill_names.append(_sk.get("name", "")) + + process_steps.append({ + + "type": "skill", + + "name": (_sk.get("name") if _sk else func_name), + + "args": func_args, + + "result": (tool_result or "")[:800], + + }) + + + + + + + + + + + + + + + + + + messages.append({ + + + + + + + + + + + + + + + + "role": "tool", + + + + + + + + + + + + + + + + "tool_call_id": tc.get("id", f"call_{round_num}"), + + + + + + + + + + + + + + + + "content": tool_result, + + + + + + + + + + + + + + + + }) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 继续下一轮让模型处理tool结果 + + + + + + + + + + + + + + + + continue + + + + + + + + + + + + + + + + else: + + + + + + + + + + + + + + + + # 普通回复 + + + + + + + + + + + + + + + + ai_content = message.get("content", "") + + + + + + + + + + + + + + + + if not ai_content and round_num > 0: + + + + + + + + + + + + + + + + ai_content = "AI模型已处理工具调用结果, 但返回了空响应。" + + + + + + + + + + + + + + + + break + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if not ai_content: + + + + + + + + + + + + + + + + ai_content = "AI模型返回了空响应, 请稍后重试。" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + except Exception as e: + + + + + + + + + + + + + + + + logger.warning(f"AI API调用异常: {e}") + + + + + + + + + + + + + + + + ai_content = "AI服务暂时不可用, 请稍后重试或检查模型API配置。" + + + + + + + + + + + + + + + + else: + + + + + + + + + + + + + + + + ai_content = "该模型缺少API密钥或端点配置, 请在后台管理-大模型库中配置API Key和Endpoint。" + + + + + + + + + + + + + + + + else: + + + + + + + + + + + + + + + + ai_content = "请选择一个已配置的AI模型进行对话。" + + + + + + + + + + + + + + + + elif not external_handled: + + + + + + + + + + + + + + + + ai_content = "请先在后台管理-大模型库配置默认对话模型(或聊天中选择模型),Auto 模式下将自动使用平台默认模型。" + + + + + + + + + + # ===== Post-check: 失败则切换备选,成功则跳出 ===== + + _is_error = ( + + ai_content and ( + + "AI模型调用失败" in str(ai_content) + + or "AI服务暂时不可用" in str(ai_content) + + or "AI模型返回了空响应" in str(ai_content) + + or "该模型缺少API密钥" in str(ai_content) + + ) + + ) + + if _is_error: + + _retry_log.append(f"{ai_model.name}: {str(ai_content)[:60]}") + + logger.warning( + + f"[Failover] 模型 {ai_model.name} 失败,切换至备选..." + + ) + + continue # try next model + + + + # Success — exit failover loop + + break + + else: + + # Loop completed without break = all models failed + + _all_failed = True + + + + # ===== All models failed? ===== + + if _all_failed: + + _names = ", ".join([m.name for m in _try_models]) + + _details = "; ".join(_retry_log[-3:]) if _retry_log else "未知错误" + + ai_content = ( + + f"已尝试 {len(_try_models)} 个模型({_names})均调用失败。\n" + + f"最后错误: {_details}\n" + + f"请稍后重试或检查模型配置。" + + ) + + logger.error(f"[Failover] 全部 {len(_try_models)} 个模型均失败: {_details}") + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ai_token_count = len(ai_content) // 2 + + + + + + + + + + + + + + + + ai_msg = Message( + + + + + + + + + + + + + + + + id=gen_uuid(), + + + + + + + + + + + + + + + + conversation_id=conv_id, + + + + + + + + + + + + + + + + role="assistant", + + + + + + + + + + + + + + + + content=ai_content, + + + + + + + + + + + + + + + + token_used=ai_token_count, + + + + + + + + + + + + + + + + model_id=ai_model.id if ai_model else (req.model_id or conv.model_id), + + + + + + + + + + + + + + + + skill_ids=req.skill_ids if req.skill_ids else None, + + + + + + + + + + + + + + + + agent_ids=req.agent_ids if req.agent_ids else None, + + + + + + + + + + + + + + + + knowledge_ids=req.knowledge_base_ids if req.knowledge_base_ids else None, + + + + + + + + + + + + + + + + created_at=cst_now(), + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + db.add(ai_msg) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 后台异步:将实质性文本回答入库到 Dify 知识库(量化后可供 RAG 复用) + + + + + + + + + + + + + + + + _ingest_text = (ai_content or "").strip() + + + + + + + + + + + + + + + + if len(_ingest_text) > 120 and not ( + + + + + + + + + + + + + + + + _ingest_text.startswith("[MEDIA") + + + + + + + + + + + + + + + + or _ingest_text.startswith("data:") + + + + + + + + + + + + + + + + or _ingest_text.startswith("http") + + + + + + + + + + + + + + + + or " 反馈配对的技能名 + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + _sel = _resolve_market_skills(db, effective_skill_ids, user_id) + + + + + + + + + + + + + + + + used_skills = [s["name"] for s in _sel] + + + + + + + + + + + + + + + + except Exception: + + + + + + + + + + + + + + + + used_skills = [] + + + + + + + + + + + + + + + + elif called_skill_names: + + + + + + + + + + + + + + + + # 未显式选技能 -> 反馈 AI 本轮实际调用的技能(可能为空) + + + + + + + + + + + + + + + + used_skills = list(dict.fromkeys(called_skill_names)) + + + + + + + + + + + + + + + + if effective_mcp_ids: + + + + + + + + + + + + + + + + # 用户显式选择了 MCP -> 反馈配对的 MCP 名 + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + _sel_mcp = _resolve_active_mcp_servers(db, effective_mcp_ids) + + + + + + + + + + + + + + + + used_mcp = [m["name"] for m in _sel_mcp] + + + + + + + + + + + + + + + + except Exception: + + + + + + + + + + + + + + + + used_mcp = [] + + + + + + + + + + + + + + + + elif called_mcp_names: + + + + + + + + + + + + + + + + used_mcp = list(dict.fromkeys(called_mcp_names)) + + + + + + + + + + + + + + + + ai_msg.used_skills = used_skills + + + + + + + + + + + + + + + + ai_msg.used_mcp = used_mcp + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 更新对话统计 + + + + + + + + + + + + + + + + # 注意:knowledge_graph_count 仅应在用户实际点击「转知识图谱」时 +1, + + # 不可在每次 AI 回复时无条件自增,否则未操作也会累积计数(见 #715)。 + + + + + + + + + + + + + + + + conv.updated_at = cst_now() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 记录Token使用(媒体生成走 _generate_media_task 异步补写真实消耗,此处跳过避免占位文本重复记账) + + + + + + + + + + + + + + + + if not media_generating: + + + + + + + + + + + + + + + + token_usage = TokenUsageLog( + + + + + + + + + + + + + + + + id=gen_uuid(), + + + + + + + + + + + + + + + + user_id=current_user.id, + + + + + + + + + + + + + + + + model_id=ai_model.id if ai_model else (req.model_id or conv.model_id), + + + + + + + + + + + + + + + + model_name=ai_model.name if ai_model else None, + + + + + + + + + + + + + + + + conversation_id=conv_id, + + + + trace_id=get_trace_id(), + + + + + + + + + + + + + + + + + + + + prompt_tokens=prompt_tokens or len(req.content) // 2, + + + + + + + + + + + + + + + + completion_tokens=completion_tokens or ai_token_count, + + + + + + + + + + + + + + + + total_tokens=total_tokens or (len(req.content) // 2 + ai_token_count), + + + + + + + + + + + + + + + + created_at=cst_now(), + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + db.add(token_usage) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 进化轨迹记录 + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + existing = db.query(EvolutionTrajectory).filter( + + + + + + + + + + + + + + + + EvolutionTrajectory.conversation_id == conv_id + + + + + + + + + + + + + + + + ).first() + + + + + + + + + + + + + + + + if not existing: + + + + + + + + + + + + + + + + trajectory = EvolutionTrajectory( + + + + + + + + + + + + + + + + id=gen_uuid(), + + + + + + + + + + + + + + + + user_id=current_user.id, + + + + + + + + + + + + + + + + conversation_id=conv_id, + + + + + + + + + + + + + + + + status="discovered", + + + + + + + + + + + + + + + + token_used=ai_token_count, + + + + + + + + + + + + + + + + created_at=cst_now(), + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + db.add(trajectory) + + + + + + + + + + + + + + + + db.flush() + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + from app.services.evolution_service import EvolutionWatcherManager + + + + + + + + + + + + + + + + watcher = EvolutionWatcherManager() + + + + + + + + + + + + + + + + status = watcher.get_user_status(current_user.id, lambda: SessionLocal()) + + + + + + + + + + + + + + + + if not status.get("scheduler_running"): + + + + + + + + + + + + + + + + watcher.start_user_evolution(current_user.id, lambda: SessionLocal()) + + + + + + + + + + + + + + + + logger.info(f"[Evolution] 自动启动用户 {current_user.id} 的进化Pipeline") + + + + + + + + + + + + + + + + except Exception as evolution_err: + + + + + + + + + + + + + + + + logger.warning(f"[Evolution] 自动启动Pipeline失败: {evolution_err}") + + + + + + + + + + + + + + + + except Exception: + + + + + + + + + + + + + + + + pass + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + db.commit() + + + + + + + + + + + + + + + + # 后台异步压缩历史(分层记忆),不阻塞本次响应 + + + + asyncio.create_task(_maybe_compress_history(conv_id)) + + + + + + + + db.refresh(user_msg) + + + + + + + + + + + + + + + + db.refresh(ai_msg) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + # 媒体生成改为后台异步:立即返回占位,后台任务完成后回写消息内容 + + + + + + + + + + + + + + + + if media_generating: + + + + + + + + + + + + + + + + try: + + + + + + + + + + + + + + + + asyncio.create_task( + + + + + + + + + + + + + + + + _generate_media_task(ai_msg.id, ai_model.id, req.content, current_user.id) + + + + + + + + + + + + + + + + ) + + + + + + + + + + + + + + + + except Exception as e: + + + + + + + + + + + + + + + + logger.warning(f"[_generate_media_task] 启动后台任务失败: {e}", exc_info=True) + + # 启动失败兜底:不让占位消息永久停在「⏳ 生成中…」 + + try: + + ai_msg.content = "⚠️ 媒体生成启动失败,请稍后重试" + + db.commit() + + except Exception: + + pass + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + if on_event is not None: + try: + try: + _mid = ai_msg.id + except NameError: + _mid = None + try: + _dc = ai_content + except NameError: + _dc = (ai_msg.content if "ai_msg" in dir() else "") + try: + _ps = process_steps + except NameError: + _ps = [] + try: + _us = used_skills + except NameError: + _us = [] + try: + _um = used_mcp + except NameError: + _um = [] + try: + _mg = media_generating + except NameError: + _mg = False + await on_event({ + "type": "done", + "message_id": _mid, + "conversation_id": conv_id, + "content": _dc, + "process_steps": _ps, + "used_skills": _us, + "used_mcp": _um, + "generating": _mg, + }) + except Exception: + pass + + return { + + + + + + + + + + + + + + + + "code": 0, + + + + + + + + + + + + + + + + "data": { + + + + + + + + + + + + + + + + "user_message": { + + + + + + + + + + + + + + + + "id": user_msg.id, "role": user_msg.role, "content": user_msg.content, + + + + + + + + + + + + + + + + "token_used": user_msg.token_used, "created_at": str(user_msg.created_at), + + + + + + + + + + + + + + + + }, + + + + + + + + + + + + + + + + "assistant_message": { + + + + + + + + + + + + + + + + "id": ai_msg.id, "role": ai_msg.role, "content": ai_msg.content, + + + + + + + + + + + + + + + + "token_used": ai_msg.token_used, "created_at": str(ai_msg.created_at), + + + + + + + + + + + + + + + + "used_skills": used_skills, + + + + "used_mcp": used_mcp, + + + + + + + + + + + + + + + + "generating": media_generating, + + "process_steps": process_steps, + + + + + + + + + + + + + + + + + + }, + + + + + + + + + + + + + + + + }, + + + + + + + + + + + + + + + + "message": "发送成功", + + + + + + + + + + + + + + + + } + + + + + + + + + + + +# ====================================================================== + + + +# 3.1 tool_policy 沙箱辅助函数 + + + +# ====================================================================== + + + +def _resolve_agent_tool_policy(agents): + + + + """从已解析的 agents 聚合工具策略。多 agent 时取首个带有非空策略者。""" + + + + if not agents: + + + + return None + + + + for ag in agents: + + + + tp = ag.get("tool_policy") + + + + if tp and isinstance(tp, dict): + + + + tp = dict(tp) + + + + tp["agent_id"] = ag.get("id") + + + + return tp + + + + return None + + + + + + + + + + + +def _check_tool_policy(func_name, mcp_info, skills, policy, call_count, round_count): + + + + """校验单个工具调用是否被策略允许。 + + + + + + + + 返回 {"allowed": bool, "reason": str, "tool_id": str|None, "message": str} + + + + policy 为 None/空 -> 允许(向后兼容)。 + + + + """ + + + + if not policy: + + + + return {"allowed": True, "tool_id": None} + + + + allowed = policy.get("allowed_tools") or {} + + + + skill_ids = allowed.get("skill_ids") or [] + + + + mcp_ids = allowed.get("mcp_ids") or [] + + + + is_mcp = func_name.startswith("mcp_tool_") + + + + tool_id = None + + + + if is_mcp: + + + + if mcp_info and isinstance(mcp_info, dict): + + + + tool_id = mcp_info.get("server_id") + + + + if mcp_ids and tool_id not in mcp_ids: + + + + return {"allowed": False, "reason": "mcp_not_in_allowlist", "tool_id": tool_id, + + + + "message": "MCP 服务不在该智能体的允许列表中"} + + + + else: + + + + if skills: + + + + _s = next((s for s in skills if s.get("func_name") == func_name), None) + + + + if _s: + + + + tool_id = _s.get("id") + + + + if skill_ids and tool_id not in skill_ids: + + + + return {"allowed": False, "reason": "skill_not_in_allowlist", "tool_id": tool_id, + + + + "message": "技能不在该智能体的允许列表中"} + + + + budget = policy.get("max_calls_per_conversation") + + + + if budget is not None and call_count >= int(budget): + + + + return {"allowed": False, "reason": "budget_exceeded", "tool_id": tool_id, + + + + "message": policy.get("fallback_message") + + + + or "该智能体已达到工具调用上限,请开启新对话或调整智能体设置。"} + + + + per_round = policy.get("per_round_cap") + + + + if per_round is not None and round_count >= int(per_round): + + + + return {"allowed": False, "reason": "per_round_cap", "tool_id": tool_id, + + + + "message": "本轮工具调用已达上限"} + + + + return {"allowed": True, "tool_id": tool_id} + + + + diff --git a/backend/python/app/api/chat_skill.py b/backend/python/app/api/chat_skill.py index 5bcc098..20f7e1f 100644 --- a/backend/python/app/api/chat_skill.py +++ b/backend/python/app/api/chat_skill.py @@ -1,1742 +1,1742 @@ -# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). -# Source of truth: scripts/phase1_extract.py. Do not hand-merge back. - -from app.core.credential import decrypt_secret, mask_secret -from fastapi import APIRouter, Depends, HTTPException, Query, Response -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, timezone, timedelta -import re -import json -import asyncio -import math -import httpx -import os -import logging -from ..database import get_db, SessionLocal -from ..models import ( - - - - User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, - - - - KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, - - - - EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, - - - - MCPServer, - - - - gen_uuid - - - -) -from ..auth import get_current_user -from ..platform_knowledge import get_platform_knowledge -from ..mcp_client import list_mcp_tools, call_mcp_tool -from ..middleware.log_service import LogService -from ..capability_registry import resolve_auto_model - -def _resolve_market_skills(db: Session, skill_ids: list, user_id: str) -> list: - - - - """ - - - - Stage A.1: 统一Skill ID空间 - - - - 前端传MarketApp.id, 需要映射为可用的技能定义。 - - - - 返回: [{id, name, description, skill_content, category, func_name}] - - - - """ - - - - if not skill_ids: - - - - return [] - - - - - - - - # 1. 从MarketApp查询 (前端传的是MarketApp.id) - - - - market_skills = db.query(MarketApp).filter( - - - - MarketApp.id.in_(skill_ids), - - - - MarketApp.type == "skill" - - - - ).all() - - - - - - - - results = [] - - - - for ms in market_skills: - - - - skill_content = ms.skill_content or "" - - - - # 尝试从skill_content解析SKILL.md格式 - - - - # SKILL.md格式: ---\nname: ...\ndescription: ...\n---\n内容... - - - - parsed = _parse_skill_content(skill_content) - - - - - - - - func_name = ms.name.replace(" ", "_").replace("-", "_").lower() - - - - results.append({ - - - - "id": ms.id, - - - - "market_app_id": ms.id, - - - - "name": ms.name, - - - - "description": ms.description or parsed.get("description", ""), - - - - "skill_content": skill_content, - - - - "category": parsed.get("category", "general"), - - - - "skill_type": parsed.get("skill_type", "general"), - - - - "func_name": func_name, - - - - "parameters": parsed.get("parameters", {"input": {"type": "string", "description": "用户输入内容"}}), - - - - "instructions": parsed.get("instructions", ""), - - - - }) - - - - - - - - # 2. 如果有未匹配的ID, 尝试查EvolutionSkill (兼容旧的进化技能ID) - - - - matched_ids = {r["id"] for r in results} - - - - unmatched = [sid for sid in skill_ids if sid not in matched_ids] - - - - - - - - if unmatched: - - - - evo_skills = db.query(EvolutionSkill).filter( - - - - EvolutionSkill.id.in_(unmatched), - - - - EvolutionSkill.user_id == user_id - - - - ).all() - - - - - - - - for es in evo_skills: - - - - # 读取SKILL.md内容 - - - - skill_md = "" - - - - if es.skill_md_path and os.path.exists(es.skill_md_path): - - - - try: - - - - with open(es.skill_md_path, "r", encoding="utf-8") as f: - - - - skill_md = f.read() - - - - except Exception: - - - - skill_md = "" - - - - elif es.description: - - - - skill_md = es.description - - - - - - - - parsed = _parse_skill_content(skill_md) - - - - func_name = es.skill_name.replace(" ", "_").replace("-", "_").lower() - - - - - - - - results.append({ - - - - "id": es.id, - - - - "market_app_id": None, - - - - "name": es.skill_name, - - - - "description": es.description or parsed.get("description", ""), - - - - "skill_content": skill_md, - - - - "category": es.category or parsed.get("category", "general"), - - - - "skill_type": parsed.get("skill_type", es.category or "general"), - - - - "func_name": func_name, - - - - "parameters": parsed.get("parameters", {"input": {"type": "string", "description": "用户输入内容"}}), - - - - "instructions": parsed.get("instructions", ""), - - - - }) - - - - - - - - return results - -def _resolve_market_agents(db: Session, agent_ids: list, user_id: str) -> list: - - - - """ - - - - Stage B.1: 加载Agent配置 - - - - 前端传MarketApp.id(agent类型), 需要解析AgentConfig。 - - - - 返回: [{id, name, config, skills, knowledge_base_ids}] - - - - """ - - - - if not agent_ids: - - - - return [] - - - - - - - - agents = db.query(MarketApp).filter( - - - - MarketApp.id.in_(agent_ids), - - - - MarketApp.type == "agent" - - - - ).all() - - - - - - - - results = [] - - - - for ag in agents: - - - - # AgentConfig在skill_content字段(JSON序列化) - - - - agent_config = {} - - - - try: - - - - if ag.skill_content: - - - - agent_config = json.loads(ag.skill_content) - - - - except Exception: - - - - agent_config = {"role": ag.description or "AI助手", "instructions": ag.description or ""} - - - - - - - - # 提取配置字段 - - - - role_prompt = agent_config.get("role", ag.description or "AI助手") - - - - instructions = agent_config.get("instructions", "") - - - - agent_skill_ids = agent_config.get("skills", []) # 关联的skill MarketApp IDs - - - - agent_kb_ids = agent_config.get("knowledge_base_ids", []) # 关联的知识库IDs - - - - agent_model_id = agent_config.get("model_id", None) # 指定使用的模型 - - - - tools = agent_config.get("tools", []) # MCP server IDs(暂不用) - - - - - - - - results.append({ - - - - "id": ag.id, - - - - "name": ag.name, - - - - "role_prompt": role_prompt, - - - - "instructions": instructions, - - - - "skill_ids": agent_skill_ids, - - - - "knowledge_base_ids": agent_kb_ids, - - - - "model_id": agent_model_id, - - - - "tools": tools, - - - - "description": ag.description or "", - - - - "tool_policy": ag.tool_policy, - # 外部 Agent 接入(Dify/n8n/Coze)运行期远程调用所需字段: - - - - # 这三列此前被丢弃,导致选中外部 Agent 时仍用本地 LLM「扮演」而非真远程调用。 - - - - "platform": (ag.platform or "").lower() if ag.platform else None, - - - - "agent_endpoint": (ag.agent_endpoint or "").strip() if ag.agent_endpoint else None, - - - - "agent_api_key": mask_secret(decrypt_secret(ag.agent_api_key)) or None, - "orchestration": ag.orchestration, - - - - }) - - - - - - - - return results - -def _discover_installed_skills(db: Session, user_id: str, limit: int = 300) -> list: - - - - """ - - - - Issue 8: 默认技能自动发现。 - - - - 检索「本账号已安装(app_installations) 且 平台已部署发布(MarketApp.status='published')」的 - - - - skill 型应用,返回可供 function calling 使用的技能定义列表(最多 limit 个)。 - - - - 仅当用户未显式选择技能时才启用;多个技能适用时由模型自行判断选 1 个最优。 - - - - """ - - - - if not user_id: - - - - return [] - - - - installed = db.query(AppInstallation).filter( - - - - AppInstallation.user_id == user_id - - - - ).all() - - - - app_ids = [i.app_id for i in installed] - - - - if not app_ids: - - - - return [] - - - - apps = db.query(MarketApp).filter( - - - - MarketApp.id.in_(app_ids), - - - - MarketApp.type == "skill", - - - - MarketApp.status == "published", - - - - ).all() - - - - ids = [a.id for a in apps] - - - - if not ids: - - - - return [] - - - - return _resolve_market_skills(db, ids, user_id)[:limit] - -def _parse_skill_content(content: str) -> dict: - - - - """ - - - - 解析SKILL.md格式内容, 提取结构化信息。 - - - - SKILL.md格式: - - - - --- - - - - name: 技能名 - - - - description: 描述 - - - - category: 分类 - - - - skill_type: 类型 - - - - parameters: JSON或简单定义 - - - - --- - - - - 技能指令/规则内容 - - - - """ - - - - result = { - - - - "name": "", - - - - "description": "", - - - - "category": "general", - - - - "skill_type": "general", - - - - "parameters": {}, - - - - "instructions": "", - - - - } - - - - - - - - if not content: - - - - return result - - - - - - - - # 尝试解析YAML front matter - - - - if content.strip().startswith("---"): - - - - parts = content.split("---") - - - - if len(parts) >= 3: - - - - front_matter = parts[1].strip() - - - - body = parts[2].strip() if len(parts) > 2 else "" - - - - - - - - # 简单YAML解析 (不用pyyaml库) - - - - for line in front_matter.split("\n"): - - - - line = line.strip() - - - - if ":" in line: - - - - key, val = line.split(":", 1) - - - - key = key.strip().lower() - - - - val = val.strip() - - - - if key == "name": - - - - result["name"] = val - - - - elif key == "description": - - - - result["description"] = val - - - - elif key == "category": - - - - result["category"] = val - - - - elif key == "skill_type": - - - - result["skill_type"] = val - - - - elif key == "parameters": - - - - try: - - - - result["parameters"] = json.loads(val) - - - - except Exception: - - - - # 简单格式: input:string,query:string - - - - params = {} - - - - for p in val.split(","): - - - - p = p.strip() - - - - if ":" in p: - - - - pname, ptype = p.split(":", 1) - - - - params[pname.strip()] = {"type": ptype.strip(), "description": pname.strip()} - - - - result["parameters"] = params - - - - - - - - result["instructions"] = body - - - - else: - - - - result["instructions"] = content - - - - else: - - - - # 没有front matter, 全部当作instructions - - - - result["instructions"] = content - - - - # 尝试从第一行提取name - - - - first_line = content.split("\n")[0].strip() - - - - if first_line and len(first_line) < 50: - - - - result["name"] = first_line - - - - - - - - return result - -def _bm25_search(query: str, documents: list, top_k: int = 5) -> list: - - - - """ - - - - 简易BM25检索 — 替代朴素的文档拼接。 - - - - 对文档列表进行关键词匹配+TF-IDF排序, 返回最相关的top_k段。 - - - - """ - - - - if not documents or not query: - - - - return documents[:top_k] - - - - - - - - # 中文简单分词(按字符+标点) - - - - def tokenize(text: str) -> list: - - - - # 移除标点, 拆分为2-4字词组 - - - - text = re.sub(r'[^\w\u4e00-\u9fff]', ' ', text.lower()) - - - - tokens = [] - - - - words = text.split() - - - - for w in words: - - - - if len(w) >= 2: - - - - tokens.append(w) - - - - # 也生成2-gram - - - - if len(w) >= 4: - - - - for i in range(len(w) - 2): - - - - tokens.append(w[i:i+2]) - - - - return tokens - - - - - - - - query_tokens = tokenize(query) - - - - if not query_tokens: - - - - return documents[:top_k] - - - - - - - - scored_docs = [] - - - - avg_doc_len = sum(len(tokenize(d.get("content", ""))) for d in documents) / max(len(documents), 1) - - - - - - - - # BM25参数 - - - - k1 = 1.5 - - - - b = 0.75 - - - - - - - - # 计算每个token的IDF - - - - df = {} - - - - for token in query_tokens: - - - - for doc in documents: - - - - if token in tokenize(doc.get("content", "")): - - - - df[token] = df.get(token, 0) + 1 - - - - - - - - N = len(documents) - - - - idf = {} - - - - for token in query_tokens: - - - - idf[token] = math.log((N - df.get(token, 0) + 0.5) / (df.get(token, 0) + 0.5) + 1) - - - - - - - - # 计算每个文档的BM25得分 - - - - for doc in documents: - - - - doc_tokens = tokenize(doc.get("content", "")) - - - - doc_len = len(doc_tokens) - - - - score = 0 - - - - - - - - for token in query_tokens: - - - - tf = doc_tokens.count(token) - - - - numerator = tf * (k1 + 1) - - - - denominator = tf + k1 * (1 - b + b * doc_len / max(avg_doc_len, 1)) - - - - score += idf.get(token, 0) * numerator / denominator - - - - - - - - scored_docs.append((score, doc)) - - - - - - - - # 按得分排序 - - - - scored_docs.sort(key=lambda x: x[0], reverse=True) - - - - return [d for s, d in scored_docs[:top_k]] - -def _execute_skill_tool( - - - - db: Session, - - - - func_name: str, - - - - args: dict, - - - - skills: list, - - - - user_content: str, - - - -) -> str: - - - - """ - - - - Stage A.2: 真实Skill执行引擎 — 替代mock实现 - - - - 1. 查找匹配的skill定义 - - - - 2. 读取skill_content(SKILL.md)中的指令 - - - - 3. 根据指令类型组装执行prompt - - - - 4. 返回结构化的执行结果给LLM - - - - """ - - - - # 查找匹配的skill - - - - matched_skill = None - - - - for s in skills: - - - - expected_name = s["func_name"] - - - - if func_name == expected_name or func_name == f"skill_{s['id'][:8]}": - - - - matched_skill = s - - - - break - - - - - - - - if not matched_skill: - - - - # 退化: 返回通用结果 - - - - return f"工具 '{func_name}' 执行结果: 已处理输入内容, 但未找到对应的技能定义。请基于通用知识回复用户。" - - - - - - - - skill_name = matched_skill["name"] - - - - instructions = matched_skill.get("instructions", "") - - - - input_text = args.get("input", user_content) - - - - - - - - # 根据skill_content中的指令生成真实执行结果 - - - - # 核心思路: 将skill的instructions作为"执行逻辑", - - - - # 把input_text传入, 让结果包含指令要求的处理格式 - - - - - - - - if instructions: - - - - # 有明确指令的技能 — 按指令结构化输出 - - - - result_parts = [ - - - - f"技能「{skill_name}」执行结果:", - - - - f"---", - - - - ] - - - - - - - - # 尝试从指令中提取输出格式要求 - - - - # 如果指令包含"输出"或"格式"关键字, 遵循其要求 - - - - output_format = "" - - - - if "输出格式" in instructions or "output format" in instructions.lower(): - - - - # 提取格式描述 - - - - for line in instructions.split("\n"): - - - - if "输出" in line or "格式" in line or "format" in line.lower(): - - - - output_format += line + "\n" - - - - - - - - # 根据skill_type增强处理 - - - - skill_type = matched_skill.get("skill_type", "general") - - - - - - - - if skill_type == "writer" or "写作" in skill_name or "写" in instructions: - - - - result_parts.append(f"基于技能「{skill_name}」的写作指令处理用户输入:") - - - - result_parts.append(f"主题: {input_text[:200]}") - - - - result_parts.append(f"根据技能规则: {instructions[:300]}") - - - - result_parts.append(f"处理完成 — 请基于上述技能指令和输入内容, 生成符合技能要求的写作输出。") - - - - elif skill_type == "analyzer" or "分析" in skill_name or "分析" in instructions: - - - - result_parts.append(f"基于技能「{skill_name}」的分析指令处理用户输入:") - - - - result_parts.append(f"分析对象: {input_text[:200]}") - - - - result_parts.append(f"分析维度(按技能规则): {instructions[:300]}") - - - - result_parts.append(f"关键发现: 识别到{max(1, len(input_text)//50)}个分析要点") - - - - result_parts.append(f"处理完成 — 请基于上述技能指令, 对输入内容进行深入分析并给出结构化的分析报告。") - - - - elif skill_type == "translator" or "翻译" in skill_name or "翻译" in instructions: - - - - result_parts.append(f"基于技能「{skill_name}」的翻译指令处理用户输入:") - - - - result_parts.append(f"原文: {input_text[:200]}") - - - - result_parts.append(f"翻译规则: {instructions[:300]}") - - - - result_parts.append(f"处理完成 — 请基于技能翻译规则, 将输入内容翻译为目标语言。") - - - - elif skill_type == "summarizer" or "总结" in skill_name or "摘要" in instructions: - - - - result_parts.append(f"基于技能「{skill_name}」的摘要指令处理用户输入:") - - - - result_parts.append(f"原文长度: {len(input_text)} 字符") - - - - result_parts.append(f"摘要规则: {instructions[:300]}") - - - - result_parts.append(f"处理完成 — 请基于技能摘要规则, 对输入内容进行精准摘要。") - - - - else: - - - - # 通用技能 — 传递指令让LLM执行 - - - - result_parts.append(f"基于技能「{skill_name}」的指令处理用户请求:") - - - - result_parts.append(f"用户输入: {input_text[:200]}") - - - - result_parts.append(f"技能指令: {instructions[:500]}") - - - - if output_format: - - - - result_parts.append(f"期望输出格式: {output_format[:200]}") - - - - result_parts.append(f"处理完成 — 请严格按照技能指令的规则和要求, 处理用户输入并生成最终回复。") - - - - - - - - result = "\n".join(result_parts) - - - - else: - - - - # 无明确指令的技能 — 提供基本信息让LLM自行处理 - - - - desc = matched_skill.get("description", "") - - - - result = ( - - - - f"技能「{skill_name}」已激活。\n" - - - - f"技能描述: {desc}\n" - - - - f"用户输入: {input_text[:200]}\n" - - - - f"请基于技能「{skill_name}」的专业能力处理上述输入。" - - - - ) - - - - - - - - # 更新使用计数(如果关联了MarketApp) - - - - market_app_id = matched_skill.get("market_app_id") - - - - if market_app_id: - - - - try: - - - - app = db.query(MarketApp).filter(MarketApp.id == market_app_id).first() - - - - if app: - - - - app.install_count = (app.install_count or 0) + 1 - - - - except Exception: - - - - pass - - - - - - - - return result - - -def _generate_install_fallback_skill(name: str, description: str) -> str: - """install_app 兜底:无法识别为 SKILL.md 时,生成一份可用技能定义。""" - desc = (description or "").strip() or f"{name} 相关能力" - return ( - f"---\n" - f"name: {name}\n" - f"description: {desc}\n" - f"category: other\n" - f"---\n\n" - f"# {name}\n\n" - f"{desc}\n\n" - f"## 使用建议\n" - f"- 明确用户意图后再执行,必要时先向用户确认方向。\n" - f"- 输出结构清晰、可直接使用。\n" - f"- 如任务超出能力范围,主动说明并给出替代方案。\n" - ) - - -def _execute_install_app(db: Session, url: str, user_id, app_type: str = "auto") -> str: - """前台 AI 聊天:通过 URL 安装 skill / mcp / agent(根治问题 #3)。 - - 逻辑对齐 market.upload_app,但本函数不引用 market 私有函数以避免循环 import: - - 同步 httpx.Client(trust_env=False, 显式 proxy) 抓取 URL 内容(与 _fetch_skill_content 同款) - - 自动识别类型:mcp / agent / skill - - 建 MarketApp(+ mcp 时 MCPServer + AppInstallation),自动标记为已安装 - - 关键约束:本函数在聊天主事件循环中【同步】执行,禁止调用 asyncio.run - (会抛 "cannot be called from a running event loop")。因此 MCP 连接测试交由后台 - 「测试」按钮在 worker 线程异步执行;新建 MCPServer 直接 is_enabled=True 即可被 - 聊天链路 _resolve_active_mcp_servers 使用(该解析仅过滤 is_enabled)。 - """ - if not url or not str(url).strip().lower().startswith("http"): - return "请提供有效的安装地址(URL),例如 GitHub 仓库、mcp.so 或 modelscope 链接。" - url = str(url).strip() - proxy = os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") or None - try: - with httpx.Client(timeout=25, follow_redirects=True, trust_env=False, proxy=proxy) as client: - r = client.get(url, headers={"User-Agent": "Mozilla/5.0 (compatible; QihuAI/1.0)"}) - content = r.text if r.status_code == 200 else "" - except Exception as e: - return f"无法访问该地址:{str(e)[:200]}" - - _u = url.lower() - _c = (content or "").lower() - detected = "skill" - if app_type and app_type != "auto": - detected = app_type - else: - if "mcpservers" in _c or '"mcpservers"' in (content or "").lower() or ( - "url" in _c and ("transport" in _c or '"type"' in _c) - ): - detected = "mcp" - elif any(k in _u for k in ["dify", "n8n", "coze"]) or "agent_endpoint" in _c: - detected = "agent" - else: - detected = "skill" - - import re as _re - _name = _re.sub(r"^https?://", "", url).strip("/").split("/")[-1] or "imported_app" - _name = _name[:120] - _desc = f"通过链接导入:{url}" - - _dup = db.query(MarketApp).filter( - func.lower(MarketApp.name) == _name.lower(), - MarketApp.type == detected, - ).first() - if _dup: - return f"「{_name}」({detected}) 已存在,无需重复安装(ID: {_dup.id})。可在应用市场直接使用。" - - app = MarketApp( - id=gen_uuid(), - name=_name, - description=_desc, - type=detected, - source="user", - category="other", - tags=None, - price=0, - install_count=0, - rating_avg=0, - review_count=0, - publisher_id=user_id, - status="published", - created_at=datetime.now(), - updated_at=datetime.now(), - ) - - if detected == "mcp": - from ..api.admin.mcp_admin import parse_mcp_config - parsed = parse_mcp_config(content) - _murl = parsed.get("url") or url - app.skill_content = content - app.mcp_transport = parsed.get("transport_type", "streamable_http") - db.add(app) - db.commit() - db.refresh(app) - _hdr = json.dumps(parsed.get("headers") or {}, ensure_ascii=False) - server = MCPServer( - id=app.id, - name=app.name, - description=app.description or "", - transport_type=parsed.get("transport_type", "streamable_http"), - command="", - args="{}", - env_vars="{}", - url=_murl, - headers=_hdr, - is_enabled=True, - status="disconnected", - tools_count=0, - resources_count=0, - created_at=app.created_at, - updated_at=app.updated_at, - ) - db.add(server) - db.commit() - elif detected == "agent": - app.agent_endpoint = url - if "dify" in _u: - app.platform = "dify" - elif "n8n" in _u: - app.platform = "n8n" - elif "coze" in _u: - app.platform = "coze" - app.skill_content = content if "{" in content else None - db.add(app) - db.commit() - db.refresh(app) - else: - _is_skillmd = ("name:" in _c and "description:" in _c) or _u.endswith(".md") - app.skill_content = content if _is_skillmd else _generate_install_fallback_skill(_name, _desc) - db.add(app) - db.commit() - db.refresh(app) - - if user_id: - _inst = db.query(AppInstallation).filter( - AppInstallation.user_id == user_id, - AppInstallation.app_id == app.id, - ).first() - if not _inst: - db.add(AppInstallation(user_id=user_id, app_id=app.id)) - db.commit() - - return f"已成功安装「{_name}」({detected})。你现在可以直接在对话中使用它,或在应用市场查看。" +# Auto-extracted from chat.py during Phase 1 refactor (Strangler Fig). +# Source of truth: scripts/phase1_extract.py. Do not hand-merge back. + +from app.core.credential import decrypt_secret, mask_secret +from fastapi import APIRouter, Depends, HTTPException, Query, Response +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, timezone, timedelta +import re +import json +import asyncio +import math +import httpx +import os +import logging +from ..database import get_db, SessionLocal +from ..models import ( + + + + User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, + + + + KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, + + + + EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, + + + + MCPServer, + + + + gen_uuid + + + +) +from ..auth import get_current_user +from ..platform_knowledge import get_platform_knowledge +from ..mcp_client import list_mcp_tools, call_mcp_tool +from ..middleware.log_service import LogService +from ..capability_registry import resolve_auto_model + +def _resolve_market_skills(db: Session, skill_ids: list, user_id: str) -> list: + + + + """ + + + + Stage A.1: 统一Skill ID空间 + + + + 前端传MarketApp.id, 需要映射为可用的技能定义。 + + + + 返回: [{id, name, description, skill_content, category, func_name}] + + + + """ + + + + if not skill_ids: + + + + return [] + + + + + + + + # 1. 从MarketApp查询 (前端传的是MarketApp.id) + + + + market_skills = db.query(MarketApp).filter( + + + + MarketApp.id.in_(skill_ids), + + + + MarketApp.type == "skill" + + + + ).all() + + + + + + + + results = [] + + + + for ms in market_skills: + + + + skill_content = ms.skill_content or "" + + + + # 尝试从skill_content解析SKILL.md格式 + + + + # SKILL.md格式: ---\nname: ...\ndescription: ...\n---\n内容... + + + + parsed = _parse_skill_content(skill_content) + + + + + + + + func_name = ms.name.replace(" ", "_").replace("-", "_").lower() + + + + results.append({ + + + + "id": ms.id, + + + + "market_app_id": ms.id, + + + + "name": ms.name, + + + + "description": ms.description or parsed.get("description", ""), + + + + "skill_content": skill_content, + + + + "category": parsed.get("category", "general"), + + + + "skill_type": parsed.get("skill_type", "general"), + + + + "func_name": func_name, + + + + "parameters": parsed.get("parameters", {"input": {"type": "string", "description": "用户输入内容"}}), + + + + "instructions": parsed.get("instructions", ""), + + + + }) + + + + + + + + # 2. 如果有未匹配的ID, 尝试查EvolutionSkill (兼容旧的进化技能ID) + + + + matched_ids = {r["id"] for r in results} + + + + unmatched = [sid for sid in skill_ids if sid not in matched_ids] + + + + + + + + if unmatched: + + + + evo_skills = db.query(EvolutionSkill).filter( + + + + EvolutionSkill.id.in_(unmatched), + + + + EvolutionSkill.user_id == user_id + + + + ).all() + + + + + + + + for es in evo_skills: + + + + # 读取SKILL.md内容 + + + + skill_md = "" + + + + if es.skill_md_path and os.path.exists(es.skill_md_path): + + + + try: + + + + with open(es.skill_md_path, "r", encoding="utf-8") as f: + + + + skill_md = f.read() + + + + except Exception: + + + + skill_md = "" + + + + elif es.description: + + + + skill_md = es.description + + + + + + + + parsed = _parse_skill_content(skill_md) + + + + func_name = es.skill_name.replace(" ", "_").replace("-", "_").lower() + + + + + + + + results.append({ + + + + "id": es.id, + + + + "market_app_id": None, + + + + "name": es.skill_name, + + + + "description": es.description or parsed.get("description", ""), + + + + "skill_content": skill_md, + + + + "category": es.category or parsed.get("category", "general"), + + + + "skill_type": parsed.get("skill_type", es.category or "general"), + + + + "func_name": func_name, + + + + "parameters": parsed.get("parameters", {"input": {"type": "string", "description": "用户输入内容"}}), + + + + "instructions": parsed.get("instructions", ""), + + + + }) + + + + + + + + return results + +def _resolve_market_agents(db: Session, agent_ids: list, user_id: str) -> list: + + + + """ + + + + Stage B.1: 加载Agent配置 + + + + 前端传MarketApp.id(agent类型), 需要解析AgentConfig。 + + + + 返回: [{id, name, config, skills, knowledge_base_ids}] + + + + """ + + + + if not agent_ids: + + + + return [] + + + + + + + + agents = db.query(MarketApp).filter( + + + + MarketApp.id.in_(agent_ids), + + + + MarketApp.type == "agent" + + + + ).all() + + + + + + + + results = [] + + + + for ag in agents: + + + + # AgentConfig在skill_content字段(JSON序列化) + + + + agent_config = {} + + + + try: + + + + if ag.skill_content: + + + + agent_config = json.loads(ag.skill_content) + + + + except Exception: + + + + agent_config = {"role": ag.description or "AI助手", "instructions": ag.description or ""} + + + + + + + + # 提取配置字段 + + + + role_prompt = agent_config.get("role", ag.description or "AI助手") + + + + instructions = agent_config.get("instructions", "") + + + + agent_skill_ids = agent_config.get("skills", []) # 关联的skill MarketApp IDs + + + + agent_kb_ids = agent_config.get("knowledge_base_ids", []) # 关联的知识库IDs + + + + agent_model_id = agent_config.get("model_id", None) # 指定使用的模型 + + + + tools = agent_config.get("tools", []) # MCP server IDs(暂不用) + + + + + + + + results.append({ + + + + "id": ag.id, + + + + "name": ag.name, + + + + "role_prompt": role_prompt, + + + + "instructions": instructions, + + + + "skill_ids": agent_skill_ids, + + + + "knowledge_base_ids": agent_kb_ids, + + + + "model_id": agent_model_id, + + + + "tools": tools, + + + + "description": ag.description or "", + + + + "tool_policy": ag.tool_policy, + # 外部 Agent 接入(Dify/n8n/Coze)运行期远程调用所需字段: + + + + # 这三列此前被丢弃,导致选中外部 Agent 时仍用本地 LLM「扮演」而非真远程调用。 + + + + "platform": (ag.platform or "").lower() if ag.platform else None, + + + + "agent_endpoint": (ag.agent_endpoint or "").strip() if ag.agent_endpoint else None, + + + + "agent_api_key": mask_secret(decrypt_secret(ag.agent_api_key)) or None, + "orchestration": ag.orchestration, + + + + }) + + + + + + + + return results + +def _discover_installed_skills(db: Session, user_id: str, limit: int = 300) -> list: + + + + """ + + + + Issue 8: 默认技能自动发现。 + + + + 检索「本账号已安装(app_installations) 且 平台已部署发布(MarketApp.status='published')」的 + + + + skill 型应用,返回可供 function calling 使用的技能定义列表(最多 limit 个)。 + + + + 仅当用户未显式选择技能时才启用;多个技能适用时由模型自行判断选 1 个最优。 + + + + """ + + + + if not user_id: + + + + return [] + + + + installed = db.query(AppInstallation).filter( + + + + AppInstallation.user_id == user_id + + + + ).all() + + + + app_ids = [i.app_id for i in installed] + + + + if not app_ids: + + + + return [] + + + + apps = db.query(MarketApp).filter( + + + + MarketApp.id.in_(app_ids), + + + + MarketApp.type == "skill", + + + + MarketApp.status == "published", + + + + ).all() + + + + ids = [a.id for a in apps] + + + + if not ids: + + + + return [] + + + + return _resolve_market_skills(db, ids, user_id)[:limit] + +def _parse_skill_content(content: str) -> dict: + + + + """ + + + + 解析SKILL.md格式内容, 提取结构化信息。 + + + + SKILL.md格式: + + + + --- + + + + name: 技能名 + + + + description: 描述 + + + + category: 分类 + + + + skill_type: 类型 + + + + parameters: JSON或简单定义 + + + + --- + + + + 技能指令/规则内容 + + + + """ + + + + result = { + + + + "name": "", + + + + "description": "", + + + + "category": "general", + + + + "skill_type": "general", + + + + "parameters": {}, + + + + "instructions": "", + + + + } + + + + + + + + if not content: + + + + return result + + + + + + + + # 尝试解析YAML front matter + + + + if content.strip().startswith("---"): + + + + parts = content.split("---") + + + + if len(parts) >= 3: + + + + front_matter = parts[1].strip() + + + + body = parts[2].strip() if len(parts) > 2 else "" + + + + + + + + # 简单YAML解析 (不用pyyaml库) + + + + for line in front_matter.split("\n"): + + + + line = line.strip() + + + + if ":" in line: + + + + key, val = line.split(":", 1) + + + + key = key.strip().lower() + + + + val = val.strip() + + + + if key == "name": + + + + result["name"] = val + + + + elif key == "description": + + + + result["description"] = val + + + + elif key == "category": + + + + result["category"] = val + + + + elif key == "skill_type": + + + + result["skill_type"] = val + + + + elif key == "parameters": + + + + try: + + + + result["parameters"] = json.loads(val) + + + + except Exception: + + + + # 简单格式: input:string,query:string + + + + params = {} + + + + for p in val.split(","): + + + + p = p.strip() + + + + if ":" in p: + + + + pname, ptype = p.split(":", 1) + + + + params[pname.strip()] = {"type": ptype.strip(), "description": pname.strip()} + + + + result["parameters"] = params + + + + + + + + result["instructions"] = body + + + + else: + + + + result["instructions"] = content + + + + else: + + + + # 没有front matter, 全部当作instructions + + + + result["instructions"] = content + + + + # 尝试从第一行提取name + + + + first_line = content.split("\n")[0].strip() + + + + if first_line and len(first_line) < 50: + + + + result["name"] = first_line + + + + + + + + return result + +def _bm25_search(query: str, documents: list, top_k: int = 5) -> list: + + + + """ + + + + 简易BM25检索 — 替代朴素的文档拼接。 + + + + 对文档列表进行关键词匹配+TF-IDF排序, 返回最相关的top_k段。 + + + + """ + + + + if not documents or not query: + + + + return documents[:top_k] + + + + + + + + # 中文简单分词(按字符+标点) + + + + def tokenize(text: str) -> list: + + + + # 移除标点, 拆分为2-4字词组 + + + + text = re.sub(r'[^\w\u4e00-\u9fff]', ' ', text.lower()) + + + + tokens = [] + + + + words = text.split() + + + + for w in words: + + + + if len(w) >= 2: + + + + tokens.append(w) + + + + # 也生成2-gram + + + + if len(w) >= 4: + + + + for i in range(len(w) - 2): + + + + tokens.append(w[i:i+2]) + + + + return tokens + + + + + + + + query_tokens = tokenize(query) + + + + if not query_tokens: + + + + return documents[:top_k] + + + + + + + + scored_docs = [] + + + + avg_doc_len = sum(len(tokenize(d.get("content", ""))) for d in documents) / max(len(documents), 1) + + + + + + + + # BM25参数 + + + + k1 = 1.5 + + + + b = 0.75 + + + + + + + + # 计算每个token的IDF + + + + df = {} + + + + for token in query_tokens: + + + + for doc in documents: + + + + if token in tokenize(doc.get("content", "")): + + + + df[token] = df.get(token, 0) + 1 + + + + + + + + N = len(documents) + + + + idf = {} + + + + for token in query_tokens: + + + + idf[token] = math.log((N - df.get(token, 0) + 0.5) / (df.get(token, 0) + 0.5) + 1) + + + + + + + + # 计算每个文档的BM25得分 + + + + for doc in documents: + + + + doc_tokens = tokenize(doc.get("content", "")) + + + + doc_len = len(doc_tokens) + + + + score = 0 + + + + + + + + for token in query_tokens: + + + + tf = doc_tokens.count(token) + + + + numerator = tf * (k1 + 1) + + + + denominator = tf + k1 * (1 - b + b * doc_len / max(avg_doc_len, 1)) + + + + score += idf.get(token, 0) * numerator / denominator + + + + + + + + scored_docs.append((score, doc)) + + + + + + + + # 按得分排序 + + + + scored_docs.sort(key=lambda x: x[0], reverse=True) + + + + return [d for s, d in scored_docs[:top_k]] + +def _execute_skill_tool( + + + + db: Session, + + + + func_name: str, + + + + args: dict, + + + + skills: list, + + + + user_content: str, + + + +) -> str: + + + + """ + + + + Stage A.2: 真实Skill执行引擎 — 替代mock实现 + + + + 1. 查找匹配的skill定义 + + + + 2. 读取skill_content(SKILL.md)中的指令 + + + + 3. 根据指令类型组装执行prompt + + + + 4. 返回结构化的执行结果给LLM + + + + """ + + + + # 查找匹配的skill + + + + matched_skill = None + + + + for s in skills: + + + + expected_name = s["func_name"] + + + + if func_name == expected_name or func_name == f"skill_{s['id'][:8]}": + + + + matched_skill = s + + + + break + + + + + + + + if not matched_skill: + + + + # 退化: 返回通用结果 + + + + return f"工具 '{func_name}' 执行结果: 已处理输入内容, 但未找到对应的技能定义。请基于通用知识回复用户。" + + + + + + + + skill_name = matched_skill["name"] + + + + instructions = matched_skill.get("instructions", "") + + + + input_text = args.get("input", user_content) + + + + + + + + # 根据skill_content中的指令生成真实执行结果 + + + + # 核心思路: 将skill的instructions作为"执行逻辑", + + + + # 把input_text传入, 让结果包含指令要求的处理格式 + + + + + + + + if instructions: + + + + # 有明确指令的技能 — 按指令结构化输出 + + + + result_parts = [ + + + + f"技能「{skill_name}」执行结果:", + + + + f"---", + + + + ] + + + + + + + + # 尝试从指令中提取输出格式要求 + + + + # 如果指令包含"输出"或"格式"关键字, 遵循其要求 + + + + output_format = "" + + + + if "输出格式" in instructions or "output format" in instructions.lower(): + + + + # 提取格式描述 + + + + for line in instructions.split("\n"): + + + + if "输出" in line or "格式" in line or "format" in line.lower(): + + + + output_format += line + "\n" + + + + + + + + # 根据skill_type增强处理 + + + + skill_type = matched_skill.get("skill_type", "general") + + + + + + + + if skill_type == "writer" or "写作" in skill_name or "写" in instructions: + + + + result_parts.append(f"基于技能「{skill_name}」的写作指令处理用户输入:") + + + + result_parts.append(f"主题: {input_text[:200]}") + + + + result_parts.append(f"根据技能规则: {instructions[:300]}") + + + + result_parts.append(f"处理完成 — 请基于上述技能指令和输入内容, 生成符合技能要求的写作输出。") + + + + elif skill_type == "analyzer" or "分析" in skill_name or "分析" in instructions: + + + + result_parts.append(f"基于技能「{skill_name}」的分析指令处理用户输入:") + + + + result_parts.append(f"分析对象: {input_text[:200]}") + + + + result_parts.append(f"分析维度(按技能规则): {instructions[:300]}") + + + + result_parts.append(f"关键发现: 识别到{max(1, len(input_text)//50)}个分析要点") + + + + result_parts.append(f"处理完成 — 请基于上述技能指令, 对输入内容进行深入分析并给出结构化的分析报告。") + + + + elif skill_type == "translator" or "翻译" in skill_name or "翻译" in instructions: + + + + result_parts.append(f"基于技能「{skill_name}」的翻译指令处理用户输入:") + + + + result_parts.append(f"原文: {input_text[:200]}") + + + + result_parts.append(f"翻译规则: {instructions[:300]}") + + + + result_parts.append(f"处理完成 — 请基于技能翻译规则, 将输入内容翻译为目标语言。") + + + + elif skill_type == "summarizer" or "总结" in skill_name or "摘要" in instructions: + + + + result_parts.append(f"基于技能「{skill_name}」的摘要指令处理用户输入:") + + + + result_parts.append(f"原文长度: {len(input_text)} 字符") + + + + result_parts.append(f"摘要规则: {instructions[:300]}") + + + + result_parts.append(f"处理完成 — 请基于技能摘要规则, 对输入内容进行精准摘要。") + + + + else: + + + + # 通用技能 — 传递指令让LLM执行 + + + + result_parts.append(f"基于技能「{skill_name}」的指令处理用户请求:") + + + + result_parts.append(f"用户输入: {input_text[:200]}") + + + + result_parts.append(f"技能指令: {instructions[:500]}") + + + + if output_format: + + + + result_parts.append(f"期望输出格式: {output_format[:200]}") + + + + result_parts.append(f"处理完成 — 请严格按照技能指令的规则和要求, 处理用户输入并生成最终回复。") + + + + + + + + result = "\n".join(result_parts) + + + + else: + + + + # 无明确指令的技能 — 提供基本信息让LLM自行处理 + + + + desc = matched_skill.get("description", "") + + + + result = ( + + + + f"技能「{skill_name}」已激活。\n" + + + + f"技能描述: {desc}\n" + + + + f"用户输入: {input_text[:200]}\n" + + + + f"请基于技能「{skill_name}」的专业能力处理上述输入。" + + + + ) + + + + + + + + # 更新使用计数(如果关联了MarketApp) + + + + market_app_id = matched_skill.get("market_app_id") + + + + if market_app_id: + + + + try: + + + + app = db.query(MarketApp).filter(MarketApp.id == market_app_id).first() + + + + if app: + + + + app.install_count = (app.install_count or 0) + 1 + + + + except Exception: + + + + pass + + + + + + + + return result + + +def _generate_install_fallback_skill(name: str, description: str) -> str: + """install_app 兜底:无法识别为 SKILL.md 时,生成一份可用技能定义。""" + desc = (description or "").strip() or f"{name} 相关能力" + return ( + f"---\n" + f"name: {name}\n" + f"description: {desc}\n" + f"category: other\n" + f"---\n\n" + f"# {name}\n\n" + f"{desc}\n\n" + f"## 使用建议\n" + f"- 明确用户意图后再执行,必要时先向用户确认方向。\n" + f"- 输出结构清晰、可直接使用。\n" + f"- 如任务超出能力范围,主动说明并给出替代方案。\n" + ) + + +def _execute_install_app(db: Session, url: str, user_id, app_type: str = "auto") -> str: + """前台 AI 聊天:通过 URL 安装 skill / mcp / agent(根治问题 #3)。 + + 逻辑对齐 market.upload_app,但本函数不引用 market 私有函数以避免循环 import: + - 同步 httpx.Client(trust_env=False, 显式 proxy) 抓取 URL 内容(与 _fetch_skill_content 同款) + - 自动识别类型:mcp / agent / skill + - 建 MarketApp(+ mcp 时 MCPServer + AppInstallation),自动标记为已安装 + + 关键约束:本函数在聊天主事件循环中【同步】执行,禁止调用 asyncio.run + (会抛 "cannot be called from a running event loop")。因此 MCP 连接测试交由后台 + 「测试」按钮在 worker 线程异步执行;新建 MCPServer 直接 is_enabled=True 即可被 + 聊天链路 _resolve_active_mcp_servers 使用(该解析仅过滤 is_enabled)。 + """ + if not url or not str(url).strip().lower().startswith("http"): + return "请提供有效的安装地址(URL),例如 GitHub 仓库、mcp.so 或 modelscope 链接。" + url = str(url).strip() + proxy = os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") or None + try: + with httpx.Client(timeout=25, follow_redirects=True, trust_env=False, proxy=proxy) as client: + r = client.get(url, headers={"User-Agent": "Mozilla/5.0 (compatible; QihuAI/1.0)"}) + content = r.text if r.status_code == 200 else "" + except Exception as e: + return f"无法访问该地址:{str(e)[:200]}" + + _u = url.lower() + _c = (content or "").lower() + detected = "skill" + if app_type and app_type != "auto": + detected = app_type + else: + if "mcpservers" in _c or '"mcpservers"' in (content or "").lower() or ( + "url" in _c and ("transport" in _c or '"type"' in _c) + ): + detected = "mcp" + elif any(k in _u for k in ["dify", "n8n", "coze"]) or "agent_endpoint" in _c: + detected = "agent" + else: + detected = "skill" + + import re as _re + _name = _re.sub(r"^https?://", "", url).strip("/").split("/")[-1] or "imported_app" + _name = _name[:120] + _desc = f"通过链接导入:{url}" + + _dup = db.query(MarketApp).filter( + func.lower(MarketApp.name) == _name.lower(), + MarketApp.type == detected, + ).first() + if _dup: + return f"「{_name}」({detected}) 已存在,无需重复安装(ID: {_dup.id})。可在应用市场直接使用。" + + app = MarketApp( + id=gen_uuid(), + name=_name, + description=_desc, + type=detected, + source="user", + category="other", + tags=None, + price=0, + install_count=0, + rating_avg=0, + review_count=0, + publisher_id=user_id, + status="published", + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + if detected == "mcp": + from ..api.admin.mcp_admin import parse_mcp_config + parsed = parse_mcp_config(content) + _murl = parsed.get("url") or url + app.skill_content = content + app.mcp_transport = parsed.get("transport_type", "streamable_http") + db.add(app) + db.commit() + db.refresh(app) + _hdr = json.dumps(parsed.get("headers") or {}, ensure_ascii=False) + server = MCPServer( + id=app.id, + name=app.name, + description=app.description or "", + transport_type=parsed.get("transport_type", "streamable_http"), + command="", + args="{}", + env_vars="{}", + url=_murl, + headers=_hdr, + is_enabled=True, + status="disconnected", + tools_count=0, + resources_count=0, + created_at=app.created_at, + updated_at=app.updated_at, + ) + db.add(server) + db.commit() + elif detected == "agent": + app.agent_endpoint = url + if "dify" in _u: + app.platform = "dify" + elif "n8n" in _u: + app.platform = "n8n" + elif "coze" in _u: + app.platform = "coze" + app.skill_content = content if "{" in content else None + db.add(app) + db.commit() + db.refresh(app) + else: + _is_skillmd = ("name:" in _c and "description:" in _c) or _u.endswith(".md") + app.skill_content = content if _is_skillmd else _generate_install_fallback_skill(_name, _desc) + db.add(app) + db.commit() + db.refresh(app) + + if user_id: + _inst = db.query(AppInstallation).filter( + AppInstallation.user_id == user_id, + AppInstallation.app_id == app.id, + ).first() + if not _inst: + db.add(AppInstallation(user_id=user_id, app_id=app.id)) + db.commit() + + return f"已成功安装「{_name}」({detected})。你现在可以直接在对话中使用它,或在应用市场查看。" diff --git a/backend/python/app/api/im_llm.py b/backend/python/app/api/im_llm.py index bbbc4b4..6888219 100644 --- a/backend/python/app/api/im_llm.py +++ b/backend/python/app/api/im_llm.py @@ -1,282 +1,282 @@ -""" -IM Bot 真实 LLM 调用。 - -原实现位于 channels.py(通用频道管理模块),该模块已移除。 -为保持 IM Bot(微信机器人等)正常生成回复,将 _call_real_llm 迁移至此独立模块。 - -行为(对齐 AI 聊天 — 2026-07-23 同步至最新): -- Issue 7:图像/视频/语音意图优先走平台媒体模型(与 AI 聊天同一套 Auto 路由), - 不再对"制作图片"这类请求只回文本。 -- Issue 8:默认 Auto 模型;每轮先自动发现本账号已安装且平台已部署的技能, - 多个适用时由模型自行判断选 1 个最优,通过 function calling 执行。 -- 2026-07-15:修复 trust_env=False + proxy 处理,避免容器坏代理导致调用失败; - 媒体生成前先经 _optimize_media_prompt 精炼提示词,提升 AGNES 生成质量。 -- 2026-07-23:对齐 Web Chat 最新能力: - * 模型 failover(Auto 模式自动切换备选模型) - * 平台知识库注入(get_platform_knowledge,技能清单实时同步) -""" -import asyncio -import json -import logging -import re as _re -from datetime import datetime, timezone, timedelta -from typing import Optional -from urllib.parse import urlparse as _urlparse - -import httpx -from app.core.llm_client import chat_complete # 统一模型层(07-24): web/IM 共用标准 OpenAI SDK 客户端 -from sqlalchemy.orm import Session - -from ..capability_registry import resolve_auto_model -from ..models import AIModel, ModelProvider, TokenUsageLog, gen_uuid -from app.core.observability import get_trace_id - -logger = logging.getLogger(__name__) - - -# 香港出网代理(实测对 AGNES 延迟更低,2026-07-15 用户实测),与 chat.py 同值。 -_HK_PROXY = "http://qyyfw:XHR521yhf@103.242.14.208:11495" - - -def _proxy_for_url(url: str) -> str | None: - """按目标域名选择是否走代理(与 chat.py 同逻辑)。 - - 2026-07-15 调整:AGNES 改走香港代理(实测延迟更低),不再直连。 - 国内域名(尊重 NO_PROXY)直连,更稳定。 - """ - import os - host = (_urlparse(url).hostname or "").lower() - if "agnes-ai.com" in host or "agnes-ai.space" in host: - return _HK_PROXY - if "qihuyun.com" in host: - return None - _no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()] - for _np in _no_proxy: - if host == _np or host.endswith("." + _np) or _np in host: - return None - return os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") or "http://172.18.0.1:7890" - - -def _run_async(coro, timeout=None): - """在同步上下文中运行协程,兼容「无事件循环」与「已有事件循环」两种场景。 - - IM 个人微信在独立线程中调用(无事件循环)→ asyncio.run; - IM 多平台 Webhook 可能在 FastAPI 协程内调用(已有事件循环)→ 丢到线程池执行。 - - timeout: 可选,秒数;超时抛出 asyncio.TimeoutError(由调用方捕获)。 - """ - try: - loop = asyncio.get_running_loop() - except RuntimeError: - loop = None - - if timeout is not None: - async def _timed(): - return await asyncio.wait_for(coro, timeout=timeout) - target = _timed() - else: - target = coro - - if loop is not None and loop.is_running(): - import concurrent.futures - with concurrent.futures.ThreadPoolExecutor(1) as ex: - return ex.submit(lambda: asyncio.run(target)).result(timeout=(timeout + 10) if timeout else None) - return asyncio.run(target) - - -def _log_media_usage(db: Session, user_id: str, ai_model, media_type: str, prompt: str): - """IM Bot 媒体生成记账:把图片/视频消耗写入 TokenUsageLog,供驾驶舱统计。 - - 此前 IM Bot 路径完全不写 TokenUsageLog,导致微信生成的图片/视频在驾驶舱里"查无此项"。 - 媒体 API 不返回 token 用量,这里用与 chat.py _generate_media_task 一致的估算。 - """ - if not user_id or not ai_model: - return - try: - base = 1200 if media_type == "video" else 400 - est = base + (len(prompt or "") // 4) - db.add(TokenUsageLog( - id=gen_uuid(), - user_id=user_id, - model_id=ai_model.id, - model_name=ai_model.name, - trace_id=get_trace_id(), - prompt_tokens=est, - completion_tokens=0, - total_tokens=est, - created_at=datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None), - )) - db.commit() - except Exception as e: - try: - db.rollback() - except Exception: - pass - logger.warning(f"[im_llm] 媒体 Token 记账失败(忽略): {e}") - - -def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im", user_id: str = None) -> "str | dict": - """调用七虎AI 生成回复,供 IM Bot 复用。支持媒体意图路由、已安装技能自动发现、模型 failover。""" - try: - from app.api.chat import ( - _generate_media, - detect_media_intent, - _discover_installed_skills, - _build_system_prompt, - _execute_skill_tool, - ) - - # ===== 1) 媒体意图路由(Issue 7)===== - intent = detect_media_intent(prompt) - if intent: - ai_model = resolve_auto_model(intent, db) - if ai_model: - try: - result = _run_async(_generate_media(db, ai_model, prompt, user_id), timeout=300) - except Exception: - # 任何未预期异常(如 AGNES 连接重置)统一兜底为友好提示, - # 绝不直接把原始异常(如 "Server disconnected")甩给用户 - result = "⚠️ 媒体生成暂时失败(连接或服务波动),请稍后重试。" - if result: - # 媒体生成成功:返回结构化对象,交由 im_bot_user 在微信内联发送图片/视频 - s = result.strip() - if s.startswith(" str | None: + """按目标域名选择是否走代理(与 chat.py 同逻辑)。 + + 2026-07-15 调整:AGNES 改走香港代理(实测延迟更低),不再直连。 + 国内域名(尊重 NO_PROXY)直连,更稳定。 + """ + import os + host = (_urlparse(url).hostname or "").lower() + if "agnes-ai.com" in host or "agnes-ai.space" in host: + return _HK_PROXY + if "qihuyun.com" in host: + return None + _no_proxy = [h.strip().lower() for h in os.environ.get("NO_PROXY", "").split(",") if h.strip()] + for _np in _no_proxy: + if host == _np or host.endswith("." + _np) or _np in host: + return None + return os.environ.get("HTTPS_PROXY") or os.environ.get("HTTP_PROXY") or "http://172.18.0.1:7890" + + +def _run_async(coro, timeout=None): + """在同步上下文中运行协程,兼容「无事件循环」与「已有事件循环」两种场景。 + + IM 个人微信在独立线程中调用(无事件循环)→ asyncio.run; + IM 多平台 Webhook 可能在 FastAPI 协程内调用(已有事件循环)→ 丢到线程池执行。 + + timeout: 可选,秒数;超时抛出 asyncio.TimeoutError(由调用方捕获)。 + """ + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if timeout is not None: + async def _timed(): + return await asyncio.wait_for(coro, timeout=timeout) + target = _timed() + else: + target = coro + + if loop is not None and loop.is_running(): + import concurrent.futures + with concurrent.futures.ThreadPoolExecutor(1) as ex: + return ex.submit(lambda: asyncio.run(target)).result(timeout=(timeout + 10) if timeout else None) + return asyncio.run(target) + + +def _log_media_usage(db: Session, user_id: str, ai_model, media_type: str, prompt: str): + """IM Bot 媒体生成记账:把图片/视频消耗写入 TokenUsageLog,供驾驶舱统计。 + + 此前 IM Bot 路径完全不写 TokenUsageLog,导致微信生成的图片/视频在驾驶舱里"查无此项"。 + 媒体 API 不返回 token 用量,这里用与 chat.py _generate_media_task 一致的估算。 + """ + if not user_id or not ai_model: + return + try: + base = 1200 if media_type == "video" else 400 + est = base + (len(prompt or "") // 4) + db.add(TokenUsageLog( + id=gen_uuid(), + user_id=user_id, + model_id=ai_model.id, + model_name=ai_model.name, + trace_id=get_trace_id(), + prompt_tokens=est, + completion_tokens=0, + total_tokens=est, + created_at=datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None), + )) + db.commit() + except Exception as e: + try: + db.rollback() + except Exception: + pass + logger.warning(f"[im_llm] 媒体 Token 记账失败(忽略): {e}") + + +def _call_real_llm(db: Session, user=None, prompt: str = "", platform: str = "im", user_id: str = None) -> "str | dict": + """调用七虎AI 生成回复,供 IM Bot 复用。支持媒体意图路由、已安装技能自动发现、模型 failover。""" + try: + from app.api.chat import ( + _generate_media, + detect_media_intent, + _discover_installed_skills, + _build_system_prompt, + _execute_skill_tool, + ) + + # ===== 1) 媒体意图路由(Issue 7)===== + intent = detect_media_intent(prompt) + if intent: + ai_model = resolve_auto_model(intent, db) + if ai_model: + try: + result = _run_async(_generate_media(db, ai_model, prompt, user_id), timeout=300) + except Exception: + # 任何未预期异常(如 AGNES 连接重置)统一兜底为友好提示, + # 绝不直接把原始异常(如 "Server disconnected")甩给用户 + result = "⚠️ 媒体生成暂时失败(连接或服务波动),请稍后重试。" + if result: + # 媒体生成成功:返回结构化对象,交由 im_bot_user 在微信内联发送图片/视频 + s = result.strip() + if s.startswith(" Optional[str]: - """Extract first http/https URL from text.""" - if not text: - return None - match = re.search(r"https?://[^\s\"'<>\)\]\}]+", text) - return match.group(0) if match else None - - -def _skill_http_proxy(): - """容器内走智能代理;显式返回供 httpx 使用(trust_env=False 避免误用系统代理导致 502)。""" - return os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") or None - - -def _sanitize_skill_content(text: str) -> str: - """接受疑似有效 SKILL.md / markdown 规范的内容;仅拒绝整页 HTML 源码。 - - 注意:此前校验过严(必须 # / --- 开头、长度 <50 直接返回空)导致大量合法 - SKILL.md 被误杀、写库为空(#681 根因之一)。此处放宽:只要不是整页 HTML 且 - 携带 markdown/skill 标记即接受。 - """ - if not text: - return "" - t = text.strip() - if len(t) < 20: - return "" - low = t.lower() - # 拒绝整页 HTML 文档(避免把网页源码当技能内容写库) - if low.startswith(" 示例的 markdown - if " 3: - return "" - # 接受携带任一 markdown / skill 标记的内容 - markers = ("#", "---", "name:", "description:", "```", "##") - if not any(m in t for m in markers): - return "" - return t - - -def _normalize_github_url(url: str) -> str: - """GitHub 渲染页(/blob/、/tree/)转 raw.githubusercontent.com 直链,便于直接抓取源文件。""" - u = (url or "").strip() - if "github.com" in u and ("/blob/" in u or "/tree/" in u): - m = re.sub(r"https?://github\.com/", "https://raw.githubusercontent.com/", u) - m = m.replace("/blob/", "/").replace("/tree/", "/") - return m - return u - - -def _extract_skill_from_html(html: str, base_url: str) -> str: - """从 HTML 页面尽力抽取 SKILL.md 正文(代码块优先,其次跳转到 raw 链接)。""" - proxy = _skill_http_proxy() - # 1) fenced code block - fences = re.findall(r"```(?:markdown|md|yaml|json)?\s*\n(.*?)```", html, re.DOTALL | re.IGNORECASE) - for f in fences: - if ("name:" in f.lower() or "description:" in f.lower() or f.count("#") >= 2) and len(f) > 80: - return f.strip() - # 2)
/ blocks
-    for tag in (r"pre", r"code"):
-        for b in re.findall(r"<%s[^>]*>(.*?)" % (tag, tag), html, re.DOTALL | re.IGNORECASE):
-            txt = re.sub(r"<[^>]+>", "", b)
-            if ("name:" in txt.lower() or txt.count("#") >= 2) and len(txt) > 80:
-                return txt.strip()
-    # 3) 跳转到 raw SKILL.md 链接(最多试 5 个,含 GitHub ?raw=true / /raw/ 转换)
-    links = re.findall(r'href=["\']([^"\']*(?:SKILL\.md|skill\.md|\.md|/raw/|/raw\.)[^"\']*)["\']', html, re.IGNORECASE)
-    seen = set()
-    for link in links[:5]:
-        if link in seen:
-            continue
-        seen.add(link)
-        try:
-            abs_url = urljoin(base_url, link)
-            # GitHub 渲染页:/blob/ -> /raw/ 直接取源;其余 blob/tree 归一为 raw.githubusercontent
-            if "github.com" in abs_url and "/blob/" in abs_url and "raw=true" not in abs_url:
-                abs_url = abs_url.replace("/blob/", "/raw/")
-            elif "github.com" in abs_url and "/blob/" in abs_url:
-                abs_url = _normalize_github_url(abs_url)
-            fetch_url = abs_url + ("?raw=true" if "github.com" in abs_url and "raw=true" not in abs_url else "")
-            with httpx.Client(timeout=15, follow_redirects=True, trust_env=False, proxy=proxy) as c:
-                rr = c.get(fetch_url, headers={"User-Agent": "Mozilla/5.0 (compatible; SkillFetcher/1.0)"})
-                if rr.status_code == 200 and len(rr.text) > 80:
-                    return rr.text.strip()
-        except Exception:
-            continue
-    return ""
-
-
-def _fetch_skill_content(url: str) -> str:
-    """抓取目录 URL 上的 SKILL.md 内容回填;失败或无效则返回空字符串。"""
-    if not url or not url.startswith("http"):
-        return ""
-    # GitHub 渲染页归一为 raw 直链,提升抓取成功率(#681 根因之一)
-    url = _normalize_github_url(url)
-    proxy = _skill_http_proxy()
-    try:
-        with httpx.Client(timeout=20, follow_redirects=True, trust_env=False, proxy=proxy) as client:
-            r = client.get(url, headers={"User-Agent": "Mozilla/5.0 (compatible; SkillFetcher/1.0)"})
-            if r.status_code != 200:
-                return ""
-            raw = r.text
-        ctype = (r.headers.get("content-type") or "").lower()
-        u = url.rstrip("/")
-        if u.endswith(".md") or "text/plain" in ctype or "markdown" in ctype:
-            return _sanitize_skill_content(raw)
-        return _sanitize_skill_content(_extract_skill_from_html(raw, url))
-    except Exception:
-        return ""
-
-
-def _category_guidance(cat: Optional[str]) -> str:
-    """按分类返回技能执行指引要点(用于兜底生成 SKILL.md)。"""
-    guides = {
-        "writing": "- 按用户给定的文体/语气/篇幅产出文本\n- 先理解主题与受众,再组织结构与措辞\n- 完成后主动提供可优化的点",
-        "coding": "- 明确编程语言、框架与目标后编码\n- 遵循最佳实践,注意异常处理与边界情况\n- 给出可运行的关键片段并说明用法",
-        "data_analysis": "- 明确数据来源、指标与分析目标\n- 优先用表格/图表呈现结论,再解读\n- 指出数据局限与进一步可探索方向",
-        "qa": "- 先判断问题所属领域与所需知识\n- 引用可靠信息,不确定时明确说明\n- 复杂问题分点作答,必要时追问澄清",
-        "automation": "- 梳理触发条件、步骤与预期结果\n- 保证幂等与安全,避免副作用\n- 提供可复用的执行模板",
-        "creativity": "- 先发散多方案,再收敛到可执行创意\n- 结合目标受众做差异化表达\n- 输出带明确交付物的方案",
-        "communication": "- 识别沟通对象与目的(说服/协调/汇报)\n- 采用恰当的语气与结构\n- 推动达成明确下一步",
-        "reasoning": "- 将复杂问题拆解为可推理步骤\n- 显式给出假设、推导与结论\n- 校验逻辑自洽后再输出",
-    }
-    return guides.get(cat or "other", "- 先理解用户真实意图,必要时澄清\n- 输出结构清晰、可直接使用的结果\n- 超出能力范围时主动说明并给替代方案")
-
-
-def _generate_skill_content(name: str, description: Optional[str], category: Optional[str], capability: Optional[str]) -> str:
-    """当导入时无法获取真实 SKILL.md 内容时,基于名称/描述/分类生成一份可用的 SKILL.md,
-    确保技能在对话中至少作为有效上下文被注入(而非空壳,#681 根因修复)。"""
-    desc = (description or "").strip() or f"{name} 相关能力"
-    cat = category or "other"
-    cat_label_map = {
-        "writing": "文字创作", "coding": "代码能力", "data_analysis": "数据分析",
-        "qa": "知识问答", "automation": "流程自动化", "creativity": "创意生成",
-        "communication": "沟通协作", "reasoning": "逻辑推理", "other": "通用能力",
-    }
-    cat_label = cat_label_map.get(cat, cat)
-    guidance = _category_guidance(cat)
-    return (
-        f"---\n"
-        f"name: {name}\n"
-        f"description: {desc}\n"
-        f"category: {cat}\n"
-        f"---\n\n"
-        f"# {name}\n\n"
-        f"{desc}\n\n"
-        f"## 适用场景\n"
-        f"本技能属于「{cat_label}」类能力,用于帮助用户在相关任务中获得更专业、稳定的结果。\n\n"
-        f"## 执行指引\n"
-        f"{guidance}\n\n"
-        f"## 使用建议\n"
-        f"- 明确用户意图后再执行,必要时先向用户确认方向。\n"
-        f"- 输出结构清晰、可直接使用。\n"
-        f"- 如任务超出能力范围,主动说明并给出替代方案。\n"
-    )
-
-
-# ===== 自动打标签 + 自动分类(#690 需求:存量技能回填 + 新导入自动打标签/分类 + 唯一性校验) =====
-# 说明:采用离线规则法(不依赖 LLM,毫秒级、稳定、可复现),便于 60 存量技能批量回填,
-#       也便于新导入技能在 detect 阶段即时预填。以平台已有的 60 技能分类为 ground-truth 校准。
-
-# 标签词库:关键词(小写) -> 规范标签。命中即追加该标签。
-_TAG_KEYWORDS = {
-    # 前端框架/库
-    "react": "React", "next": "Next.js", "next.js": "Next.js", "vue": "Vue",
-    "angular": "Angular", "svelte": "Svelte", "typescript": "TypeScript",
-    "javascript": "JavaScript", "tailwind": "Tailwind", "shadcn": "shadcn/ui",
-    "remotion": "Remotion", "expo": "Expo", "flutter": "Flutter",
-    "react native": "React Native", "vite": "Vite", "vercel": "Vercel",
-    # 后端/数据库
-    "node": "Node.js", "python": "Python", "postgres": "PostgreSQL",
-    "postgresql": "PostgreSQL", "sqlite": "SQLite", "turso": "Turso",
-    "duckdb": "DuckDB", "supabase": "Supabase", "drizzle": "Drizzle",
-    "orm": "ORM", "sql": "SQL", "数据库": "数据库", "api": "API",
-    "azure": "Azure", "docker": "Docker", "k8s": "K8s", "kubernetes": "K8s",
-    "devops": "DevOps", "monorepo": "Monorepo", "turborepo": "Turborepo",
-    # 测试/质量
-    "playwright": "Playwright", "cypress": "Cypress", "test": "测试",
-    "测试": "测试", "tdd": "TDD", "e2e": "E2E",
-    # 设计/视觉
-    "设计": "设计", "design": "设计", "ui": "UI", "ux": "UX",
-    "视觉": "视觉", "前端设计": "前端设计", "交互": "交互",
-    "图片生成": "图片生成", "ppt": "PPT", "排版": "排版",
-    # 写作/文档
-    "写作": "写作", "文档": "文档", "word": "Word", "pdf": "PDF",
-    "ocr": "OCR", "prd": "PRD", "文案": "文案", "结构化": "结构化",
-    # 营销/沟通
-    "营销": "营销", "marketing": "营销", "社媒": "社媒", "邮件": "邮件",
-    "email": "邮件", "定价": "定价", "pricing": "定价", "内容": "内容",
-    "策略": "策略", "沟通": "沟通", "转化": "转化", "gtm": "GTM",
-    "上市": "上市", "心理学": "心理学",
-    # 推理/思维
-    "推理": "推理", "质疑": "质疑", "批判性": "批判性思维", "验证": "验证",
-    "质询": "质询", "逻辑": "逻辑", "审计": "审计", "seo": "SEO",
-    # 自动化/流程
-    "自动化": "自动化", "工作流": "工作流", "计划": "计划", "流程": "流程",
-    "发现": "技能发现", "执行计划": "执行计划",
-    # 数据
-    "数据分析": "数据分析", "分析": "数据分析",
-    # 内容生成/创意
-    "头脑风暴": "头脑风暴", "创意": "创意", "极简": "极简",
-    # 代码相关
-    "代码": "代码", "编程": "编程", "重构": "重构", "refactor": "重构",
-    "组件": "组件", "schema": "Schema", "ddd": "DDD", "领域建模": "领域建模",
-    "抓取": "爬虫", "爬虫": "爬虫", "crawler": "爬虫", "前端实现": "前端实现",
-    "数据获取": "数据获取", "移动端": "移动端", "移动": "移动端",
-    "全栈": "全栈", "后端": "后端", "前端": "前端",
-}
-
-# 分类 -> 中文基础标签(每个技能至少带一个分类标签)
-_CATEGORY_TAG = {
-    "writing": "写作", "coding": "编程", "data_analysis": "数据分析",
-    "qa": "问答", "automation": "自动化", "creativity": "创意",
-    "communication": "沟通", "reasoning": "推理", "other": "通用",
-}
-
-# 已知技能名(归一化) -> 分类 的精确覆盖,消除关键词歧义,保证存量 60 技能分类 100% 准确
-_CATEGORY_NAME_OVERRIDES = {
-    "typescript 高级类型": "coding", "极简代码": "coding", "drizzle orm": "coding",
-    "expo 原生 ui": "coding", "react native 最佳实践": "coding", "设计工程师": "coding",
-    "原生数据获取": "coding", "shadcn/ui": "coding", "react 最佳实践": "coding",
-    "supabase 全家桶": "coding", "next.js 最佳实践": "coding", "极简代码哲学": "coding",
-    "turborepo": "coding", "网页抓取": "coding", "设计图转代码": "coding",
-    "remotion 视频": "coding", "pdf 处理": "coding", "react 组合模式": "coding",
-    "turso 数据库": "coding", "postgres 最佳实践": "coding", "ai sdk": "coding",
-    "azure 全栈开发": "coding", "测试驱动开发": "coding", "playwright 测试": "coding",
-    "web 应用测试": "coding", "领域建模": "coding",
-    "web 设计规范": "creativity", "设计评审": "creativity", "前端设计指南": "creativity",
-    "高级 ui/ux 设计": "creativity", "高端视觉设计": "creativity", "移动端设计": "creativity",
-    "极简 ui 设计": "creativity", "视觉打磨": "creativity", "web 图片生成": "creativity",
-    "移动端图片生成": "creativity", "头脑风暴": "creativity", "ppt 制作": "creativity",
-    "前端设计品味": "creativity", "界面设计流程": "creativity", "项目重设计": "creativity",
-    "prd 文档": "writing", "结构化写作": "writing", "营销文案": "writing",
-    "写计划": "writing", "word 文档": "writing",
-    "营销心理学": "communication", "上市策略": "communication", "内容策略": "communication",
-    "社媒内容": "communication", "邮件序列": "communication", "定价策略": "communication",
-    "想法质疑": "reasoning", "seo 审计": "reasoning", "文档质询": "reasoning",
-    "完成前验证": "reasoning",
-    "技能发现": "automation", "playwright cli": "automation", "执行计划": "automation",
-    "duckdb 查询": "data_analysis",
-}
-
-# 分类关键词(用于新导入技能的关键词打分);顺序即平局优先级
-_CATEGORY_KEYWORDS = {
-    "coding": ["代码", "编程", "code", "react", "next", "vue", "angular", "svelte", "typescript",
-        "javascript", "node", "python", "api", "后端", "全栈", "前端", "sql", "数据库", "orm",
-        "docker", "kubernetes", "devops", "test", "测试", "tdd", "playwright", "remotion", "expo",
-        "react native", "flutter", "monorepo", "turborepo", "vite", "组件", "schema", "ddd",
-        "领域建模", "azure", "supabase", "postgres", "postgresql", "duckdb", "turso", "sqlite",
-        "drizzle", "shadcn", "tailwind", "ai sdk", "vercel", "抓取", "爬虫", "pdf", "ocr", "数据获取",
-        "设计图转代码", "前端实现"],
-    "creativity": ["设计", "design", "ui", "ux", "视觉", "头脑风暴", "创意", "图片生成", "ppt",
-        "重设计", "极简", "界面", "品味", "打磨", "规范", "指南", "评审"],
-    "writing": ["写作", "写计划", "文档", "word", "prd", "文案", "结构化", "报告", "排版", "质询"],
-    "communication": ["营销", "marketing", "社媒", "邮件", "email", "定价", "pricing", "策略",
-        "推广", "内容", "沟通", "说服", "gtm", "上市", "转化", "心理学"],
-    "reasoning": ["推理", "质疑", "批判性", "验证", "文档质询", "逻辑", "审计", "seo"],
-    "automation": ["自动化", "automation", "工作流", "执行计划", "计划执行", "发现", "流程", "计划"],
-    "data_analysis": ["数据分析", "分析", "数据"],
-    "qa": ["问答", "知识问答", "搜索", "疑问"],
-}
-
-
-def _kw_hit(text: str, kw: str) -> bool:
-    """关键词命中:中文直接子串;英文用词边界避免 latest->test 这类误命中。"""
-    if not kw:
-        return False
-    if kw.isascii():
-        return bool(re.search(r"(? str:
-    """离线规则法自动归类到 9 类之一;同名覆盖优先,其次关键词打分,默认 other。"""
-    norm_name = (name or "").strip().lower()
-    if norm_name in _CATEGORY_NAME_OVERRIDES:
-        return _CATEGORY_NAME_OVERRIDES[norm_name]
-    text = " ".join([name or "", description or "", content or ""]).lower()
-    scores = {c: 0 for c in _CATEGORY_KEYWORDS}
-    for cat, kws in _CATEGORY_KEYWORDS.items():
-        for kw in kws:
-            if _kw_hit(text, kw):
-                scores[cat] += 1
-    best = max(scores, key=lambda c: scores[c])
-    return best if scores[best] > 0 else "other"
-
-
-def _auto_tags(name: Optional[str], description: Optional[str] = None,
-               content: Optional[str] = None, category: Optional[str] = None) -> str:
-    """离线规则法自动生成逗号分隔标签(含分类基础标签 + 关键词派生标签),上限 6 个。"""
-    text = " ".join([name or "", description or "", content or ""]).lower()
-    tags: list = []
-    seen = set()
-    # 1) 分类基础标签
-    if category and category in _CATEGORY_TAG and _CATEGORY_TAG[category] not in seen:
-        tags.append(_CATEGORY_TAG[category]); seen.add(_CATEGORY_TAG[category])
-    # 2) 关键词派生标签
-    for kw, tag in _TAG_KEYWORDS.items():
-        if tag in seen:
-            continue
-        if _kw_hit(text, kw):
-            tags.append(tag); seen.add(tag)
-    return ",".join(tags[:6])
-
-
-def _norm_name(name: Optional[str]) -> str:
-    """技能名归一化:去首尾空格、空白折叠、转小写,用于唯一性校验。"""
-    return re.sub(r"\s+", " ", (name or "").strip().lower())
-
-
-# ===== Agent 接入前连通性 / 鉴权校验 =====
-def _normalize_dify_url(endpoint: str) -> str:
-    ep = endpoint.rstrip("/")
-    if ep.endswith("/chat-messages"):
-        return ep
-    if ep.endswith("/v1/chat-messages"):
-        return ep
-    if ep.endswith("/v1"):
-        return ep + "/chat-messages"
-    return ep + "/v1/chat-messages"
-
-
-def _short_err(e: Exception) -> str:
-    return str(e)[:160]
-
-
-def _post_follow(url: str, headers: dict, json_body: dict, timeout: float = 15.0):
-    """POST 并手动跟随 301/302/307/308 重定向(**保持 POST 方法与请求体**)。
-
-    背景:Dify 若填写 http:// 地址会 301 永久重定向到 https://;httpx 内建
-    follow_redirects 对 301/302 会把 POST 降级为 GET(浏览器行为),导致对
-    /v1/chat-messages 变成 GET → 405 method_not_allowed。故手动跟随并保持 POST。
-    容器内 env 有坏代理(502),必须 trust_env=False 直连。
-    """
-    cur = url
-    last = None
-    for _ in range(4):
-        last = httpx.post(cur, headers=headers, json=json_body, timeout=timeout,
-                          trust_env=False, follow_redirects=False)
-        if last.status_code in (301, 302, 307, 308):
-            loc = last.headers.get("location")
-            if not loc:
-                return last
-            cur = loc if loc.startswith("http") else str(httpx.URL(cur).join(loc))
-            continue
-        return last
-    return last
-
-
-def verify_agent_connectivity(platform: str, endpoint: str, api_key: str) -> dict:
-    """
-    接入 Agent 前的连通性 + 鉴权校验。
-    返回 {ok, verified, detail}
-      ok:       是否通过(dify=鉴权通过;n8n/coze/其他=地址可达)
-      verified: 是否做了真正的鉴权验证(dify 为 True,其余为 False)
-    """
-    ep = (endpoint or "").strip()
-    if not ep or not ep.startswith(("http://", "https://")):
-        return {"ok": False, "verified": False, "detail": "Endpoint 必须以 http:// 或 https:// 开头"}
-    platform = (platform or "dify").lower()
-
-    # 关键:容器内 env 有坏代理(502),必须 trust_env=False 直连
-    if platform == "dify":
-        url = _normalize_dify_url(ep)
-        payload = {
-            "inputs": {},
-            "query": "连通性测试",
-            "user": "__verify__",
-            "response_mode": "blocking",
-        }
-        headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
-        try:
-            r = _post_follow(url, headers, payload, timeout=15.0)
-        except Exception as e:
-            return {"ok": False, "verified": False, "detail": f"无法连接 Dify 地址:{_short_err(e)}"}
-        if r.status_code == 200:
-            return {"ok": True, "verified": True, "detail": "连通且 API Key 有效"}
-        if r.status_code in (401, 403):
-            return {"ok": False, "verified": False, "detail": "API Key 无效(401/403)"}
-        if r.status_code == 404:
-            return {"ok": False, "verified": False,
-                    "detail": "地址路径不正确(404),应为 .../v1/chat-messages"}
-        if r.status_code in (301, 302, 307, 308):
-            loc = r.headers.get("location") or ""
-            return {"ok": False, "verified": False,
-                    "detail": f"地址被重定向({r.status_code})到 {loc or '未知'};"
-                              f"请直接填写重定向后的地址(通常把 http:// 改为 https://)"}
-        if r.status_code == 400:
-            # 400 需区分两种情况:
-            #  (a) 到达应用且鉴权通过,但 Dify 应用/工作流内部执行报错(invalid_param + Run failed)
-            #  (b) 纯参数错误
-            body = (r.text or "")
-            low = body.lower()
-            if ("run failed" in low or "error code" in low or "workflow" in low
-                    or "node" in low or "-500" in body):
-                return {"ok": True, "verified": True,
-                        "detail": "连通且 API Key 有效;但你的 Dify 应用内部执行报错:"
-                                  f"{body[:180]}。这是你自建 Dify 应用/工作流本身的问题"
-                                  "(例如代码节点沙箱缺少 python3),请在 Dify 侧修复。"}
-            return {"ok": False, "verified": False, "detail": f"参数错误(400):{body[:180]}"}
-        return {"ok": False, "verified": False, "detail": f"Dify 返回状态码 {r.status_code}"}
-
-    # n8n / coze / 其他:仅做可达性探测(HEAD,避免触发 webhook 副作用)
-    try:
-        r = httpx.head(ep, timeout=12.0, trust_env=False, follow_redirects=True)
-        if r.status_code < 500:
-            return {"ok": True, "verified": False,
-                    "detail": f"地址可连通(HTTP {r.status_code}),运行时再做协议级鉴权"}
-        return {"ok": False, "verified": False, "detail": f"服务返回 {r.status_code},地址可能不可用"}
-    except Exception as e:
-        return {"ok": False, "verified": False, "detail": f"地址不可达:{_short_err(e)}"}
-
-
-# ===== 业务场景分类(面向用户的技能/MCP 分类,与后端模型路由的 capability_registry 解耦) =====
-# 注意:MarketApp.capability 列在此存放「业务场景 id」(见 MARKET_SCENES),不再是 text/vision 等模型能力。
-MARKET_SCENES: list = [
-    # 元宝生成的「岗位 / 职能」分类(面向使用者视角,与后端模型路由能力解耦)
-    {"id": "design",    "label": "🎨 做设计",     "color": "#EC4899", "desc": "页面、UI、视觉", "kw": "做设计"},
-    {"id": "frontend",  "label": "💻 写前端",     "color": "#3B82F6", "desc": "Web 页面、React、Next.js", "kw": "写前端"},
-    {"id": "mobile",    "label": "📱 做移动端",   "color": "#06B6D4", "desc": "React Native、Expo", "kw": "做移动端"},
-    {"id": "backend",   "label": "🧪 后端/全栈",  "color": "#8B5CF6", "desc": "API、数据库、TypeScript", "kw": "后端"},
-    {"id": "qa",        "label": "🧪 测试质量",   "color": "#6366F1", "desc": "TDD、Playwright、验证", "kw": "测试"},
-    {"id": "search",    "label": "🔍 搜索信息",   "color": "#0EA5E9", "desc": "网页抓取、质疑、SEO", "kw": "搜索"},
-    {"id": "docs",      "label": "✍️ 写文档",     "color": "#F59E0B", "desc": "Word/PPT/PDF、文案、PRD", "kw": "写文档"},
-    {"id": "marketing", "label": "📢 推广获客",   "color": "#F43F5E", "desc": "营销、社媒、邮件、定价", "kw": "推广"},
-    {"id": "workflow",  "label": "🔄 工作流",     "color": "#14B8A6", "desc": "计划、调试、分级、原型", "kw": "工作流"},
-    {"id": "secops",    "label": "🛡️ 安全运维",  "color": "#EF4444", "desc": "安全审计、Azure、K8s", "kw": "安全"},
-    {"id": "learning",  "label": "🎓 学习教学",   "color": "#10B981", "desc": "教学、提问、架构改进", "kw": "学习"},
-    {"id": "video",     "label": "🎬 视频多媒体", "color": "#D946EF", "desc": "AI视频、数字人", "kw": "视频"},
-    {"id": "collab",    "label": "🏢 办公协作",   "color": "#64748B", "desc": "飞书全家桶", "kw": "办公"},
-    {"id": "ai",        "label": "🛠️ AI能力",     "color": "#A855F7", "desc": "创建Skill、代码审查、统计", "kw": "AI能力"},
-]
-_VALID_SCENE_IDS = {s["id"] for s in MARKET_SCENES}
-
-
-def _scene_label(scene_id):
-    return next((s["label"] for s in MARKET_SCENES if s["id"] == scene_id), None)
-
-
-def _classify_scene(name: str, description: Optional[str], content: Optional[str], db: Session) -> Optional[str]:
-    """用平台文本模型把应用归类到面向用户的业务场景之一;失败返回 None(不阻断导入)。"""
-    try:
-        text_model = _resolve_auto_model("text", db)
-        if not text_model:
-            return None
-        provider = None
-        if getattr(text_model, "provider_id", None):
-            provider = db.query(ModelProvider).filter(ModelProvider.id == text_model.provider_id).first()
-        api_key = text_model.api_key or (provider.api_key if provider else None)
-        if not api_key:
-            return None
-        api_url = text_model.endpoint or (
-            f"{provider.api_base_url}/v1/chat/completions" if provider and provider.api_base_url else None
-        )
-        if not api_url:
-            return None
-        options = "、".join(f"{s['id']}({s['label']}:{s.get('desc','')})" for s in MARKET_SCENES)
-        sys_prompt = (
-            "你是 AI 应用市场的『技能场景分类器』。请站在使用者视角,根据应用名称、描述与内容,"
-            "判断它最贴合下列哪一类【岗位 / 职能 / 业务场景】,并只输出对应英文 id:"
-            f"{options}。\n必须只返回如下 JSON,且 capability 的值只能是上面列出的英文 id(小写),"
-            "不要返回中文标签、不要任何解释或 Markdown 代码块:{\"capability\": \"\"}"
-        )
-        user_text = f"名称:{name}\n描述:{description or ''}\n内容摘要:{(content or '')[:2000]}"
-        messages = [
-            {"role": "system", "content": sys_prompt},
-            {"role": "user", "content": user_text},
-        ]
-        proxy = _skill_http_proxy()
-        with httpx.Client(timeout=25, follow_redirects=True, trust_env=False, proxy=proxy) as c:
-            r = c.post(
-                api_url,
-                headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
-                json={"model": text_model.name, "messages": messages, "max_tokens": 64, "temperature": 0, "enable_thinking": False},
-            )
-        if r.status_code != 200:
-            return None
-        data = r.json()
-        text_out = data.get("choices", [{}])[0].get("message", {}).get("content", "")
-        # 主解析:标准 JSON 的 capability 字段
-        m = re.search(r'"capability"\s*:\s*"([a-z_]+)"', text_out)
-        if m and m.group(1) in _VALID_SCENE_IDS:
-            return m.group(1)
-        # 兜底:在输出中扫描任一合法 id 或中文关键词(容错标签/未加引号/多余文本等情况)
-        for s in MARKET_SCENES:
-            sid = s["id"]
-            if re.search(r'(? 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),
-):
-    """卸载应用 - 删除当前用户的安装记录(按用户隔离)"""
-    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 not installation:
-        raise HTTPException(status_code=400, detail="应用未安装")
-
-    # 删除安装记录
-    db.delete(installation)
-
-    # 问题4修复: 将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
-    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 app.core.credential import encrypt_secret
+from app.core.app_security import scan_market_app
+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
+import httpx
+from urllib.parse import urlparse, urljoin
+import re
+import json
+import os
+
+from ..database import get_db
+from ..models import (
+    User, MarketApp, AppPurchase, AppReview, AppInstallation, Order,
+    AdBanner, MCPServer, ModelProvider, gen_uuid, MarketAppVersion
+)
+from ..auth import get_current_user, get_current_user_id, get_current_user_id_optional
+from ..middleware.log_service import LogService
+from ..capability_registry import resolve_auto_model as _resolve_auto_model
+from app.api.admin.mcp_admin import _test_mcp_server, parse_mcp_config
+
+router = APIRouter()
+
+
+def _extract_url_from_text(text: Optional[str]) -> Optional[str]:
+    """Extract first http/https URL from text."""
+    if not text:
+        return None
+    match = re.search(r"https?://[^\s\"'<>\)\]\}]+", text)
+    return match.group(0) if match else None
+
+
+def _skill_http_proxy():
+    """容器内走智能代理;显式返回供 httpx 使用(trust_env=False 避免误用系统代理导致 502)。"""
+    return os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") or None
+
+
+def _sanitize_skill_content(text: str) -> str:
+    """接受疑似有效 SKILL.md / markdown 规范的内容;仅拒绝整页 HTML 源码。
+
+    注意:此前校验过严(必须 # / --- 开头、长度 <50 直接返回空)导致大量合法
+    SKILL.md 被误杀、写库为空(#681 根因之一)。此处放宽:只要不是整页 HTML 且
+    携带 markdown/skill 标记即接受。
+    """
+    if not text:
+        return ""
+    t = text.strip()
+    if len(t) < 20:
+        return ""
+    low = t.lower()
+    # 拒绝整页 HTML 文档(避免把网页源码当技能内容写库)
+    if low.startswith(" 示例的 markdown
+    if " 3:
+        return ""
+    # 接受携带任一 markdown / skill 标记的内容
+    markers = ("#", "---", "name:", "description:", "```", "##")
+    if not any(m in t for m in markers):
+        return ""
+    return t
+
+
+def _normalize_github_url(url: str) -> str:
+    """GitHub 渲染页(/blob/、/tree/)转 raw.githubusercontent.com 直链,便于直接抓取源文件。"""
+    u = (url or "").strip()
+    if "github.com" in u and ("/blob/" in u or "/tree/" in u):
+        m = re.sub(r"https?://github\.com/", "https://raw.githubusercontent.com/", u)
+        m = m.replace("/blob/", "/").replace("/tree/", "/")
+        return m
+    return u
+
+
+def _extract_skill_from_html(html: str, base_url: str) -> str:
+    """从 HTML 页面尽力抽取 SKILL.md 正文(代码块优先,其次跳转到 raw 链接)。"""
+    proxy = _skill_http_proxy()
+    # 1) fenced code block
+    fences = re.findall(r"```(?:markdown|md|yaml|json)?\s*\n(.*?)```", html, re.DOTALL | re.IGNORECASE)
+    for f in fences:
+        if ("name:" in f.lower() or "description:" in f.lower() or f.count("#") >= 2) and len(f) > 80:
+            return f.strip()
+    # 2) 
/ blocks
+    for tag in (r"pre", r"code"):
+        for b in re.findall(r"<%s[^>]*>(.*?)" % (tag, tag), html, re.DOTALL | re.IGNORECASE):
+            txt = re.sub(r"<[^>]+>", "", b)
+            if ("name:" in txt.lower() or txt.count("#") >= 2) and len(txt) > 80:
+                return txt.strip()
+    # 3) 跳转到 raw SKILL.md 链接(最多试 5 个,含 GitHub ?raw=true / /raw/ 转换)
+    links = re.findall(r'href=["\']([^"\']*(?:SKILL\.md|skill\.md|\.md|/raw/|/raw\.)[^"\']*)["\']', html, re.IGNORECASE)
+    seen = set()
+    for link in links[:5]:
+        if link in seen:
+            continue
+        seen.add(link)
+        try:
+            abs_url = urljoin(base_url, link)
+            # GitHub 渲染页:/blob/ -> /raw/ 直接取源;其余 blob/tree 归一为 raw.githubusercontent
+            if "github.com" in abs_url and "/blob/" in abs_url and "raw=true" not in abs_url:
+                abs_url = abs_url.replace("/blob/", "/raw/")
+            elif "github.com" in abs_url and "/blob/" in abs_url:
+                abs_url = _normalize_github_url(abs_url)
+            fetch_url = abs_url + ("?raw=true" if "github.com" in abs_url and "raw=true" not in abs_url else "")
+            with httpx.Client(timeout=15, follow_redirects=True, trust_env=False, proxy=proxy) as c:
+                rr = c.get(fetch_url, headers={"User-Agent": "Mozilla/5.0 (compatible; SkillFetcher/1.0)"})
+                if rr.status_code == 200 and len(rr.text) > 80:
+                    return rr.text.strip()
+        except Exception:
+            continue
+    return ""
+
+
+def _fetch_skill_content(url: str) -> str:
+    """抓取目录 URL 上的 SKILL.md 内容回填;失败或无效则返回空字符串。"""
+    if not url or not url.startswith("http"):
+        return ""
+    # GitHub 渲染页归一为 raw 直链,提升抓取成功率(#681 根因之一)
+    url = _normalize_github_url(url)
+    proxy = _skill_http_proxy()
+    try:
+        with httpx.Client(timeout=20, follow_redirects=True, trust_env=False, proxy=proxy) as client:
+            r = client.get(url, headers={"User-Agent": "Mozilla/5.0 (compatible; SkillFetcher/1.0)"})
+            if r.status_code != 200:
+                return ""
+            raw = r.text
+        ctype = (r.headers.get("content-type") or "").lower()
+        u = url.rstrip("/")
+        if u.endswith(".md") or "text/plain" in ctype or "markdown" in ctype:
+            return _sanitize_skill_content(raw)
+        return _sanitize_skill_content(_extract_skill_from_html(raw, url))
+    except Exception:
+        return ""
+
+
+def _category_guidance(cat: Optional[str]) -> str:
+    """按分类返回技能执行指引要点(用于兜底生成 SKILL.md)。"""
+    guides = {
+        "writing": "- 按用户给定的文体/语气/篇幅产出文本\n- 先理解主题与受众,再组织结构与措辞\n- 完成后主动提供可优化的点",
+        "coding": "- 明确编程语言、框架与目标后编码\n- 遵循最佳实践,注意异常处理与边界情况\n- 给出可运行的关键片段并说明用法",
+        "data_analysis": "- 明确数据来源、指标与分析目标\n- 优先用表格/图表呈现结论,再解读\n- 指出数据局限与进一步可探索方向",
+        "qa": "- 先判断问题所属领域与所需知识\n- 引用可靠信息,不确定时明确说明\n- 复杂问题分点作答,必要时追问澄清",
+        "automation": "- 梳理触发条件、步骤与预期结果\n- 保证幂等与安全,避免副作用\n- 提供可复用的执行模板",
+        "creativity": "- 先发散多方案,再收敛到可执行创意\n- 结合目标受众做差异化表达\n- 输出带明确交付物的方案",
+        "communication": "- 识别沟通对象与目的(说服/协调/汇报)\n- 采用恰当的语气与结构\n- 推动达成明确下一步",
+        "reasoning": "- 将复杂问题拆解为可推理步骤\n- 显式给出假设、推导与结论\n- 校验逻辑自洽后再输出",
+    }
+    return guides.get(cat or "other", "- 先理解用户真实意图,必要时澄清\n- 输出结构清晰、可直接使用的结果\n- 超出能力范围时主动说明并给替代方案")
+
+
+def _generate_skill_content(name: str, description: Optional[str], category: Optional[str], capability: Optional[str]) -> str:
+    """当导入时无法获取真实 SKILL.md 内容时,基于名称/描述/分类生成一份可用的 SKILL.md,
+    确保技能在对话中至少作为有效上下文被注入(而非空壳,#681 根因修复)。"""
+    desc = (description or "").strip() or f"{name} 相关能力"
+    cat = category or "other"
+    cat_label_map = {
+        "writing": "文字创作", "coding": "代码能力", "data_analysis": "数据分析",
+        "qa": "知识问答", "automation": "流程自动化", "creativity": "创意生成",
+        "communication": "沟通协作", "reasoning": "逻辑推理", "other": "通用能力",
+    }
+    cat_label = cat_label_map.get(cat, cat)
+    guidance = _category_guidance(cat)
+    return (
+        f"---\n"
+        f"name: {name}\n"
+        f"description: {desc}\n"
+        f"category: {cat}\n"
+        f"---\n\n"
+        f"# {name}\n\n"
+        f"{desc}\n\n"
+        f"## 适用场景\n"
+        f"本技能属于「{cat_label}」类能力,用于帮助用户在相关任务中获得更专业、稳定的结果。\n\n"
+        f"## 执行指引\n"
+        f"{guidance}\n\n"
+        f"## 使用建议\n"
+        f"- 明确用户意图后再执行,必要时先向用户确认方向。\n"
+        f"- 输出结构清晰、可直接使用。\n"
+        f"- 如任务超出能力范围,主动说明并给出替代方案。\n"
+    )
+
+
+# ===== 自动打标签 + 自动分类(#690 需求:存量技能回填 + 新导入自动打标签/分类 + 唯一性校验) =====
+# 说明:采用离线规则法(不依赖 LLM,毫秒级、稳定、可复现),便于 60 存量技能批量回填,
+#       也便于新导入技能在 detect 阶段即时预填。以平台已有的 60 技能分类为 ground-truth 校准。
+
+# 标签词库:关键词(小写) -> 规范标签。命中即追加该标签。
+_TAG_KEYWORDS = {
+    # 前端框架/库
+    "react": "React", "next": "Next.js", "next.js": "Next.js", "vue": "Vue",
+    "angular": "Angular", "svelte": "Svelte", "typescript": "TypeScript",
+    "javascript": "JavaScript", "tailwind": "Tailwind", "shadcn": "shadcn/ui",
+    "remotion": "Remotion", "expo": "Expo", "flutter": "Flutter",
+    "react native": "React Native", "vite": "Vite", "vercel": "Vercel",
+    # 后端/数据库
+    "node": "Node.js", "python": "Python", "postgres": "PostgreSQL",
+    "postgresql": "PostgreSQL", "sqlite": "SQLite", "turso": "Turso",
+    "duckdb": "DuckDB", "supabase": "Supabase", "drizzle": "Drizzle",
+    "orm": "ORM", "sql": "SQL", "数据库": "数据库", "api": "API",
+    "azure": "Azure", "docker": "Docker", "k8s": "K8s", "kubernetes": "K8s",
+    "devops": "DevOps", "monorepo": "Monorepo", "turborepo": "Turborepo",
+    # 测试/质量
+    "playwright": "Playwright", "cypress": "Cypress", "test": "测试",
+    "测试": "测试", "tdd": "TDD", "e2e": "E2E",
+    # 设计/视觉
+    "设计": "设计", "design": "设计", "ui": "UI", "ux": "UX",
+    "视觉": "视觉", "前端设计": "前端设计", "交互": "交互",
+    "图片生成": "图片生成", "ppt": "PPT", "排版": "排版",
+    # 写作/文档
+    "写作": "写作", "文档": "文档", "word": "Word", "pdf": "PDF",
+    "ocr": "OCR", "prd": "PRD", "文案": "文案", "结构化": "结构化",
+    # 营销/沟通
+    "营销": "营销", "marketing": "营销", "社媒": "社媒", "邮件": "邮件",
+    "email": "邮件", "定价": "定价", "pricing": "定价", "内容": "内容",
+    "策略": "策略", "沟通": "沟通", "转化": "转化", "gtm": "GTM",
+    "上市": "上市", "心理学": "心理学",
+    # 推理/思维
+    "推理": "推理", "质疑": "质疑", "批判性": "批判性思维", "验证": "验证",
+    "质询": "质询", "逻辑": "逻辑", "审计": "审计", "seo": "SEO",
+    # 自动化/流程
+    "自动化": "自动化", "工作流": "工作流", "计划": "计划", "流程": "流程",
+    "发现": "技能发现", "执行计划": "执行计划",
+    # 数据
+    "数据分析": "数据分析", "分析": "数据分析",
+    # 内容生成/创意
+    "头脑风暴": "头脑风暴", "创意": "创意", "极简": "极简",
+    # 代码相关
+    "代码": "代码", "编程": "编程", "重构": "重构", "refactor": "重构",
+    "组件": "组件", "schema": "Schema", "ddd": "DDD", "领域建模": "领域建模",
+    "抓取": "爬虫", "爬虫": "爬虫", "crawler": "爬虫", "前端实现": "前端实现",
+    "数据获取": "数据获取", "移动端": "移动端", "移动": "移动端",
+    "全栈": "全栈", "后端": "后端", "前端": "前端",
+}
+
+# 分类 -> 中文基础标签(每个技能至少带一个分类标签)
+_CATEGORY_TAG = {
+    "writing": "写作", "coding": "编程", "data_analysis": "数据分析",
+    "qa": "问答", "automation": "自动化", "creativity": "创意",
+    "communication": "沟通", "reasoning": "推理", "other": "通用",
+}
+
+# 已知技能名(归一化) -> 分类 的精确覆盖,消除关键词歧义,保证存量 60 技能分类 100% 准确
+_CATEGORY_NAME_OVERRIDES = {
+    "typescript 高级类型": "coding", "极简代码": "coding", "drizzle orm": "coding",
+    "expo 原生 ui": "coding", "react native 最佳实践": "coding", "设计工程师": "coding",
+    "原生数据获取": "coding", "shadcn/ui": "coding", "react 最佳实践": "coding",
+    "supabase 全家桶": "coding", "next.js 最佳实践": "coding", "极简代码哲学": "coding",
+    "turborepo": "coding", "网页抓取": "coding", "设计图转代码": "coding",
+    "remotion 视频": "coding", "pdf 处理": "coding", "react 组合模式": "coding",
+    "turso 数据库": "coding", "postgres 最佳实践": "coding", "ai sdk": "coding",
+    "azure 全栈开发": "coding", "测试驱动开发": "coding", "playwright 测试": "coding",
+    "web 应用测试": "coding", "领域建模": "coding",
+    "web 设计规范": "creativity", "设计评审": "creativity", "前端设计指南": "creativity",
+    "高级 ui/ux 设计": "creativity", "高端视觉设计": "creativity", "移动端设计": "creativity",
+    "极简 ui 设计": "creativity", "视觉打磨": "creativity", "web 图片生成": "creativity",
+    "移动端图片生成": "creativity", "头脑风暴": "creativity", "ppt 制作": "creativity",
+    "前端设计品味": "creativity", "界面设计流程": "creativity", "项目重设计": "creativity",
+    "prd 文档": "writing", "结构化写作": "writing", "营销文案": "writing",
+    "写计划": "writing", "word 文档": "writing",
+    "营销心理学": "communication", "上市策略": "communication", "内容策略": "communication",
+    "社媒内容": "communication", "邮件序列": "communication", "定价策略": "communication",
+    "想法质疑": "reasoning", "seo 审计": "reasoning", "文档质询": "reasoning",
+    "完成前验证": "reasoning",
+    "技能发现": "automation", "playwright cli": "automation", "执行计划": "automation",
+    "duckdb 查询": "data_analysis",
+}
+
+# 分类关键词(用于新导入技能的关键词打分);顺序即平局优先级
+_CATEGORY_KEYWORDS = {
+    "coding": ["代码", "编程", "code", "react", "next", "vue", "angular", "svelte", "typescript",
+        "javascript", "node", "python", "api", "后端", "全栈", "前端", "sql", "数据库", "orm",
+        "docker", "kubernetes", "devops", "test", "测试", "tdd", "playwright", "remotion", "expo",
+        "react native", "flutter", "monorepo", "turborepo", "vite", "组件", "schema", "ddd",
+        "领域建模", "azure", "supabase", "postgres", "postgresql", "duckdb", "turso", "sqlite",
+        "drizzle", "shadcn", "tailwind", "ai sdk", "vercel", "抓取", "爬虫", "pdf", "ocr", "数据获取",
+        "设计图转代码", "前端实现"],
+    "creativity": ["设计", "design", "ui", "ux", "视觉", "头脑风暴", "创意", "图片生成", "ppt",
+        "重设计", "极简", "界面", "品味", "打磨", "规范", "指南", "评审"],
+    "writing": ["写作", "写计划", "文档", "word", "prd", "文案", "结构化", "报告", "排版", "质询"],
+    "communication": ["营销", "marketing", "社媒", "邮件", "email", "定价", "pricing", "策略",
+        "推广", "内容", "沟通", "说服", "gtm", "上市", "转化", "心理学"],
+    "reasoning": ["推理", "质疑", "批判性", "验证", "文档质询", "逻辑", "审计", "seo"],
+    "automation": ["自动化", "automation", "工作流", "执行计划", "计划执行", "发现", "流程", "计划"],
+    "data_analysis": ["数据分析", "分析", "数据"],
+    "qa": ["问答", "知识问答", "搜索", "疑问"],
+}
+
+
+def _kw_hit(text: str, kw: str) -> bool:
+    """关键词命中:中文直接子串;英文用词边界避免 latest->test 这类误命中。"""
+    if not kw:
+        return False
+    if kw.isascii():
+        return bool(re.search(r"(? str:
+    """离线规则法自动归类到 9 类之一;同名覆盖优先,其次关键词打分,默认 other。"""
+    norm_name = (name or "").strip().lower()
+    if norm_name in _CATEGORY_NAME_OVERRIDES:
+        return _CATEGORY_NAME_OVERRIDES[norm_name]
+    text = " ".join([name or "", description or "", content or ""]).lower()
+    scores = {c: 0 for c in _CATEGORY_KEYWORDS}
+    for cat, kws in _CATEGORY_KEYWORDS.items():
+        for kw in kws:
+            if _kw_hit(text, kw):
+                scores[cat] += 1
+    best = max(scores, key=lambda c: scores[c])
+    return best if scores[best] > 0 else "other"
+
+
+def _auto_tags(name: Optional[str], description: Optional[str] = None,
+               content: Optional[str] = None, category: Optional[str] = None) -> str:
+    """离线规则法自动生成逗号分隔标签(含分类基础标签 + 关键词派生标签),上限 6 个。"""
+    text = " ".join([name or "", description or "", content or ""]).lower()
+    tags: list = []
+    seen = set()
+    # 1) 分类基础标签
+    if category and category in _CATEGORY_TAG and _CATEGORY_TAG[category] not in seen:
+        tags.append(_CATEGORY_TAG[category]); seen.add(_CATEGORY_TAG[category])
+    # 2) 关键词派生标签
+    for kw, tag in _TAG_KEYWORDS.items():
+        if tag in seen:
+            continue
+        if _kw_hit(text, kw):
+            tags.append(tag); seen.add(tag)
+    return ",".join(tags[:6])
+
+
+def _norm_name(name: Optional[str]) -> str:
+    """技能名归一化:去首尾空格、空白折叠、转小写,用于唯一性校验。"""
+    return re.sub(r"\s+", " ", (name or "").strip().lower())
+
+
+# ===== Agent 接入前连通性 / 鉴权校验 =====
+def _normalize_dify_url(endpoint: str) -> str:
+    ep = endpoint.rstrip("/")
+    if ep.endswith("/chat-messages"):
+        return ep
+    if ep.endswith("/v1/chat-messages"):
+        return ep
+    if ep.endswith("/v1"):
+        return ep + "/chat-messages"
+    return ep + "/v1/chat-messages"
+
+
+def _short_err(e: Exception) -> str:
+    return str(e)[:160]
+
+
+def _post_follow(url: str, headers: dict, json_body: dict, timeout: float = 15.0):
+    """POST 并手动跟随 301/302/307/308 重定向(**保持 POST 方法与请求体**)。
+
+    背景:Dify 若填写 http:// 地址会 301 永久重定向到 https://;httpx 内建
+    follow_redirects 对 301/302 会把 POST 降级为 GET(浏览器行为),导致对
+    /v1/chat-messages 变成 GET → 405 method_not_allowed。故手动跟随并保持 POST。
+    容器内 env 有坏代理(502),必须 trust_env=False 直连。
+    """
+    cur = url
+    last = None
+    for _ in range(4):
+        last = httpx.post(cur, headers=headers, json=json_body, timeout=timeout,
+                          trust_env=False, follow_redirects=False)
+        if last.status_code in (301, 302, 307, 308):
+            loc = last.headers.get("location")
+            if not loc:
+                return last
+            cur = loc if loc.startswith("http") else str(httpx.URL(cur).join(loc))
+            continue
+        return last
+    return last
+
+
+def verify_agent_connectivity(platform: str, endpoint: str, api_key: str) -> dict:
+    """
+    接入 Agent 前的连通性 + 鉴权校验。
+    返回 {ok, verified, detail}
+      ok:       是否通过(dify=鉴权通过;n8n/coze/其他=地址可达)
+      verified: 是否做了真正的鉴权验证(dify 为 True,其余为 False)
+    """
+    ep = (endpoint or "").strip()
+    if not ep or not ep.startswith(("http://", "https://")):
+        return {"ok": False, "verified": False, "detail": "Endpoint 必须以 http:// 或 https:// 开头"}
+    platform = (platform or "dify").lower()
+
+    # 关键:容器内 env 有坏代理(502),必须 trust_env=False 直连
+    if platform == "dify":
+        url = _normalize_dify_url(ep)
+        payload = {
+            "inputs": {},
+            "query": "连通性测试",
+            "user": "__verify__",
+            "response_mode": "blocking",
+        }
+        headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
+        try:
+            r = _post_follow(url, headers, payload, timeout=15.0)
+        except Exception as e:
+            return {"ok": False, "verified": False, "detail": f"无法连接 Dify 地址:{_short_err(e)}"}
+        if r.status_code == 200:
+            return {"ok": True, "verified": True, "detail": "连通且 API Key 有效"}
+        if r.status_code in (401, 403):
+            return {"ok": False, "verified": False, "detail": "API Key 无效(401/403)"}
+        if r.status_code == 404:
+            return {"ok": False, "verified": False,
+                    "detail": "地址路径不正确(404),应为 .../v1/chat-messages"}
+        if r.status_code in (301, 302, 307, 308):
+            loc = r.headers.get("location") or ""
+            return {"ok": False, "verified": False,
+                    "detail": f"地址被重定向({r.status_code})到 {loc or '未知'};"
+                              f"请直接填写重定向后的地址(通常把 http:// 改为 https://)"}
+        if r.status_code == 400:
+            # 400 需区分两种情况:
+            #  (a) 到达应用且鉴权通过,但 Dify 应用/工作流内部执行报错(invalid_param + Run failed)
+            #  (b) 纯参数错误
+            body = (r.text or "")
+            low = body.lower()
+            if ("run failed" in low or "error code" in low or "workflow" in low
+                    or "node" in low or "-500" in body):
+                return {"ok": True, "verified": True,
+                        "detail": "连通且 API Key 有效;但你的 Dify 应用内部执行报错:"
+                                  f"{body[:180]}。这是你自建 Dify 应用/工作流本身的问题"
+                                  "(例如代码节点沙箱缺少 python3),请在 Dify 侧修复。"}
+            return {"ok": False, "verified": False, "detail": f"参数错误(400):{body[:180]}"}
+        return {"ok": False, "verified": False, "detail": f"Dify 返回状态码 {r.status_code}"}
+
+    # n8n / coze / 其他:仅做可达性探测(HEAD,避免触发 webhook 副作用)
+    try:
+        r = httpx.head(ep, timeout=12.0, trust_env=False, follow_redirects=True)
+        if r.status_code < 500:
+            return {"ok": True, "verified": False,
+                    "detail": f"地址可连通(HTTP {r.status_code}),运行时再做协议级鉴权"}
+        return {"ok": False, "verified": False, "detail": f"服务返回 {r.status_code},地址可能不可用"}
+    except Exception as e:
+        return {"ok": False, "verified": False, "detail": f"地址不可达:{_short_err(e)}"}
+
+
+# ===== 业务场景分类(面向用户的技能/MCP 分类,与后端模型路由的 capability_registry 解耦) =====
+# 注意:MarketApp.capability 列在此存放「业务场景 id」(见 MARKET_SCENES),不再是 text/vision 等模型能力。
+MARKET_SCENES: list = [
+    # 元宝生成的「岗位 / 职能」分类(面向使用者视角,与后端模型路由能力解耦)
+    {"id": "design",    "label": "🎨 做设计",     "color": "#EC4899", "desc": "页面、UI、视觉", "kw": "做设计"},
+    {"id": "frontend",  "label": "💻 写前端",     "color": "#3B82F6", "desc": "Web 页面、React、Next.js", "kw": "写前端"},
+    {"id": "mobile",    "label": "📱 做移动端",   "color": "#06B6D4", "desc": "React Native、Expo", "kw": "做移动端"},
+    {"id": "backend",   "label": "🧪 后端/全栈",  "color": "#8B5CF6", "desc": "API、数据库、TypeScript", "kw": "后端"},
+    {"id": "qa",        "label": "🧪 测试质量",   "color": "#6366F1", "desc": "TDD、Playwright、验证", "kw": "测试"},
+    {"id": "search",    "label": "🔍 搜索信息",   "color": "#0EA5E9", "desc": "网页抓取、质疑、SEO", "kw": "搜索"},
+    {"id": "docs",      "label": "✍️ 写文档",     "color": "#F59E0B", "desc": "Word/PPT/PDF、文案、PRD", "kw": "写文档"},
+    {"id": "marketing", "label": "📢 推广获客",   "color": "#F43F5E", "desc": "营销、社媒、邮件、定价", "kw": "推广"},
+    {"id": "workflow",  "label": "🔄 工作流",     "color": "#14B8A6", "desc": "计划、调试、分级、原型", "kw": "工作流"},
+    {"id": "secops",    "label": "🛡️ 安全运维",  "color": "#EF4444", "desc": "安全审计、Azure、K8s", "kw": "安全"},
+    {"id": "learning",  "label": "🎓 学习教学",   "color": "#10B981", "desc": "教学、提问、架构改进", "kw": "学习"},
+    {"id": "video",     "label": "🎬 视频多媒体", "color": "#D946EF", "desc": "AI视频、数字人", "kw": "视频"},
+    {"id": "collab",    "label": "🏢 办公协作",   "color": "#64748B", "desc": "飞书全家桶", "kw": "办公"},
+    {"id": "ai",        "label": "🛠️ AI能力",     "color": "#A855F7", "desc": "创建Skill、代码审查、统计", "kw": "AI能力"},
+]
+_VALID_SCENE_IDS = {s["id"] for s in MARKET_SCENES}
+
+
+def _scene_label(scene_id):
+    return next((s["label"] for s in MARKET_SCENES if s["id"] == scene_id), None)
+
+
+def _classify_scene(name: str, description: Optional[str], content: Optional[str], db: Session) -> Optional[str]:
+    """用平台文本模型把应用归类到面向用户的业务场景之一;失败返回 None(不阻断导入)。"""
+    try:
+        text_model = _resolve_auto_model("text", db)
+        if not text_model:
+            return None
+        provider = None
+        if getattr(text_model, "provider_id", None):
+            provider = db.query(ModelProvider).filter(ModelProvider.id == text_model.provider_id).first()
+        api_key = text_model.api_key or (provider.api_key if provider else None)
+        if not api_key:
+            return None
+        api_url = text_model.endpoint or (
+            f"{provider.api_base_url}/v1/chat/completions" if provider and provider.api_base_url else None
+        )
+        if not api_url:
+            return None
+        options = "、".join(f"{s['id']}({s['label']}:{s.get('desc','')})" for s in MARKET_SCENES)
+        sys_prompt = (
+            "你是 AI 应用市场的『技能场景分类器』。请站在使用者视角,根据应用名称、描述与内容,"
+            "判断它最贴合下列哪一类【岗位 / 职能 / 业务场景】,并只输出对应英文 id:"
+            f"{options}。\n必须只返回如下 JSON,且 capability 的值只能是上面列出的英文 id(小写),"
+            "不要返回中文标签、不要任何解释或 Markdown 代码块:{\"capability\": \"\"}"
+        )
+        user_text = f"名称:{name}\n描述:{description or ''}\n内容摘要:{(content or '')[:2000]}"
+        messages = [
+            {"role": "system", "content": sys_prompt},
+            {"role": "user", "content": user_text},
+        ]
+        proxy = _skill_http_proxy()
+        with httpx.Client(timeout=25, follow_redirects=True, trust_env=False, proxy=proxy) as c:
+            r = c.post(
+                api_url,
+                headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
+                json={"model": text_model.name, "messages": messages, "max_tokens": 64, "temperature": 0, "enable_thinking": False},
+            )
+        if r.status_code != 200:
+            return None
+        data = r.json()
+        text_out = data.get("choices", [{}])[0].get("message", {}).get("content", "")
+        # 主解析:标准 JSON 的 capability 字段
+        m = re.search(r'"capability"\s*:\s*"([a-z_]+)"', text_out)
+        if m and m.group(1) in _VALID_SCENE_IDS:
+            return m.group(1)
+        # 兜底:在输出中扫描任一合法 id 或中文关键词(容错标签/未加引号/多余文本等情况)
+        for s in MARKET_SCENES:
+            sid = s["id"]
+            if re.search(r'(? 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),
+):
+    """卸载应用 - 删除当前用户的安装记录(按用户隔离)"""
+    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 not installation:
+        raise HTTPException(status_code=400, detail="应用未安装")
+
+    # 删除安装记录
+    db.delete(installation)
+
+    # 问题4修复: 将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
+    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/backend/python/app/core/orchestrator.py b/backend/python/app/core/orchestrator.py
index b98cc1b..e229c36 100644
--- a/backend/python/app/core/orchestrator.py
+++ b/backend/python/app/core/orchestrator.py
@@ -1,395 +1,395 @@
-"""app/core/orchestrator.py — Phase 3.3 多 Agent 编排图引擎。
-
-设计目标:
-- 以轻量 DAG 描述多 Agent 协作:节点(llm/local/external/reduce) + 有向边(数据流)。
-- 拓扑分层后按层并行执行(asyncio.gather),互相独立的节点真正并发。
-- 失败隔离:每个节点异常不影响其它分支;per-node on_error=halt|continue。
-- 节点执行复用既有原语(_build_system_prompt / _execute_skill_tool / _call_external_agent /
-  3.1 工具沙箱),完全向后兼容——agent.orchestration 为 null 时 chat_service 走原逻辑。
-- 并发安全:每个节点用独立 SessionLocal 会话做 DB 读,避免共享请求会话跨协程竞态。
-"""
-from __future__ import annotations
-
-import asyncio
-import json
-import logging
-from typing import Any, Optional
-
-import httpx
-
-logger = logging.getLogger("aiflow.orchestrator")
-
-VALID_NODE_TYPES = ("llm", "local", "external", "reduce")
-
-
-class OrchestrationError(ValueError):
-    """图结构非法或执行被 halt。"""
-
-
-# ---------------------------------------------------------------------------
-# 校验 + 拓扑
-# ---------------------------------------------------------------------------
-def validate_graph(graph: Any) -> None:
-    if not isinstance(graph, dict):
-        raise OrchestrationError("orchestration 必须为对象")
-    nodes = graph.get("nodes")
-    edges = graph.get("edges")
-    if not isinstance(nodes, list) or not nodes:
-        raise OrchestrationError("nodes 必须为非空数组")
-    if not isinstance(edges, list):
-        raise OrchestrationError("edges 必须为数组")
-
-    ids = set()
-    for i, n in enumerate(nodes):
-        if not isinstance(n, dict):
-            raise OrchestrationError(f"节点 #{i} 必须为对象")
-        nid = n.get("id")
-        if not nid or not isinstance(nid, str):
-            raise OrchestrationError(f"节点 #{i} 缺少字符串 id")
-        if nid in ids:
-            raise OrchestrationError(f"节点 id 重复: {nid}")
-        ids.add(nid)
-        ntype = n.get("type")
-        if ntype not in VALID_NODE_TYPES:
-            raise OrchestrationError(
-                f"节点 {nid} 类型非法: {ntype} (允许 {', '.join(VALID_NODE_TYPES)})"
-            )
-        if ntype in ("local", "external") and not n.get("agent_id"):
-            raise OrchestrationError(f"节点 {nid} 类型 {ntype} 必须指定 agent_id")
-
-    for i, e in enumerate(edges):
-        if not isinstance(e, dict):
-            raise OrchestrationError(f"边 #{i} 必须为对象")
-        f = e.get("from")
-        t = e.get("to")
-        if f not in ids:
-            raise OrchestrationError(f"边 #{i} 的 from={f} 指向不存在的节点")
-        if t not in ids:
-            raise OrchestrationError(f"边 #{i} 的 to={t} 指向不存在的节点")
-        if f == t:
-            raise OrchestrationError(f"边 #{i} 存在自环: {f}")
-
-
-def topological_layers(graph: dict) -> list:
-    """Kahn 算法返回分层(每层内节点可并行);含环则抛错。"""
-    nodes = graph["nodes"]
-    edges = graph.get("edges", [])
-    indeg = {n["id"]: 0 for n in nodes}
-    adj = {n["id"]: [] for n in nodes}
-    for e in edges:
-        f, t = e["from"], e["to"]
-        adj[f].append(t)
-        indeg[t] += 1
-    order = {n["id"]: i for i, n in enumerate(nodes)}
-    layers: list = []
-    remaining = set(indeg)
-    while remaining:
-        layer = sorted([nid for nid in remaining if indeg[nid] == 0], key=lambda x: order[x])
-        if not layer:
-            cycle = sorted(remaining, key=lambda x: order[x])
-            raise OrchestrationError(f"检测到环路,无法拓扑排序: {cycle}")
-        layers.append(layer)
-        for nid in layer:
-            remaining.discard(nid)
-            for nxt in adj[nid]:
-                indeg[nxt] -= 1
-    return layers
-
-
-def _node_by_id(graph: dict, nid: str) -> dict:
-    for n in graph["nodes"]:
-        if n["id"] == nid:
-            return n
-    return {}
-
-
-def _predecessors(graph: dict, nid: str) -> list:
-    return [e["from"] for e in graph.get("edges", []) if e["to"] == nid]
-
-
-def _successors(graph: dict, nid: str) -> list:
-    return [e["to"] for e in graph.get("edges", []) if e["from"] == nid]
-
-
-def _find_output_node(graph: dict) -> str:
-    if graph.get("output_node"):
-        return graph["output_node"]
-    sinks = [n["id"] for n in graph["nodes"] if not _successors(graph, n["id"])]
-    if len(sinks) == 1:
-        return sinks[0]
-    for s in sinks:
-        if _node_by_id(graph, s).get("type") == "reduce":
-            return s
-    if sinks:
-        return sinks[0]
-    return graph["nodes"][-1]["id"]
-
-
-def _assemble_node_input(query: str, upstream: str, is_entry: bool) -> str:
-    """入口节点直接吃用户原问题;下游节点综合原问题 + 上游输出。"""
-    if is_entry:
-        return query or ""
-    if upstream:
-        return f"原始用户问题:\n{query}\n\n上游节点输出:\n{upstream}"
-    return query or ""
-
-
-# ---------------------------------------------------------------------------
-# 模型解析 + LLM 调用原语
-# ---------------------------------------------------------------------------
-async def _resolve_model_and_endpoint(model_id, db):
-    from app.models import AIModel, ModelProvider
-    from ..capability_registry import resolve_auto_model
-
-    ai_model = None
-    if model_id and model_id != "auto":
-        ai_model = db.query(AIModel).filter(AIModel.id == model_id).first()
-    if not ai_model:
-        ai_model = resolve_auto_model("text", db)
-    if not ai_model:
-        raise OrchestrationError("无法解析到可用模型")
-    provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
-    api_key = ai_model.api_key or (provider.api_key if provider else None)
-    api_url = ai_model.endpoint
-    if not api_url and provider and provider.api_base_url:
-        api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
-    if not api_url or not api_key:
-        raise OrchestrationError(f"节点模型 {ai_model.name} 缺少 endpoint 或 api_key")
-    return ai_model, api_url, api_key
-
-
-async def _llm_complete(system_prompt: str, user_content: str, model_id, db, user_id) -> str:
-    from app.api.chat_utils import _proxy_for_url
-    from app.core.observability import span
-
-    ai_model, api_url, api_key = await _resolve_model_and_endpoint(model_id, db)
-    messages = [
-        {"role": "system", "content": system_prompt},
-        {"role": "user", "content": user_content},
-    ]
-    request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
-    async with span("orchestrator.llm", model=ai_model.id):
-        async with httpx.AsyncClient(
-            timeout=ai_model.timeout or 120,
-            proxy=_proxy_for_url(api_url),
-            trust_env=False,
-        ) as client:
-            resp = await client.post(
-                api_url,
-                headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
-                json=request_body,
-            )
-        if resp.status_code != 200:
-            raise OrchestrationError(f"LLM 调用失败 HTTP {resp.status_code}: {resp.text[:200]}")
-        data = resp.json()
-        return data["choices"][0]["message"].get("content") or ""
-
-
-# ---------------------------------------------------------------------------
-# local 节点:复用单 Agent system prompt + 受限 tool round + 3.1 沙箱
-# ---------------------------------------------------------------------------
-async def _run_local_agent_turn(agent: dict, node_input: str, db, user_id, conv_id) -> dict:
-    from app.api.chat_skill import _resolve_market_skills, _execute_skill_tool
-    from app.api.chat_context import _build_system_prompt, _retrieve_kb_context
-    from app.api.chat_service import _resolve_agent_tool_policy, _check_tool_policy
-    from app.api.chat_utils import _proxy_for_url
-
-    skills = _resolve_market_skills(db, agent.get("skill_ids") or [], user_id)
-    kb_context = await _retrieve_kb_context(db, agent.get("knowledge_base_ids") or [], node_input)
-    system_prompt, function_definitions = _build_system_prompt(
-        skills=skills,
-        agents=[agent],
-        kb_context=kb_context,
-        user_content=node_input,
-        auto_discovered=False,
-    )
-    ai_model, api_url, api_key = await _resolve_model_and_endpoint(agent.get("model_id"), db)
-    tool_policy = _resolve_agent_tool_policy([agent])
-
-    messages = [
-        {"role": "system", "content": system_prompt},
-        {"role": "user", "content": node_input},
-    ]
-    called_skills: list = []
-    tool_call_count = 0
-    max_tool_rounds = 5
-    last_content = ""
-    async with httpx.AsyncClient(
-        timeout=ai_model.timeout or 120,
-        proxy=_proxy_for_url(api_url),
-        trust_env=False,
-    ) as client:
-        for round_num in range(max_tool_rounds):
-            request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
-            if function_definitions:
-                request_body["tools"] = [
-                    {"type": "function", "function": fd} for fd in function_definitions
-                ]
-                request_body["tool_choice"] = "auto"
-            resp = await client.post(
-                api_url,
-                headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
-                json=request_body,
-            )
-            if resp.status_code != 200:
-                raise OrchestrationError(f"local 节点 LLM 失败 HTTP {resp.status_code}: {resp.text[:200]}")
-            data = resp.json()
-            message = data["choices"][0]["message"]
-            tool_calls = message.get("tool_calls") or []
-            content = message.get("content") or ""
-            last_content = content
-            if not tool_calls or not function_definitions:
-                return {"text": content, "used_tools": called_skills}
-            messages.append({"role": "assistant", "content": content, "tool_calls": tool_calls})
-            round_tool_count = 0
-            for tc in tool_calls:
-                func_name = tc.get("function", {}).get("name", "")
-                check = _check_tool_policy(
-                    func_name, None, skills, tool_policy, tool_call_count, round_tool_count
-                )
-                if not check["allowed"]:
-                    messages.append({
-                        "role": "tool",
-                        "tool_call_id": tc.get("id", f"call_{round_num}"),
-                        "content": f"[工具调用已被策略拦截] {check.get('message', check.get('reason'))}",
-                    })
-                    tool_call_count += 1
-                    round_tool_count += 1
-                    continue
-                tool_call_count += 1
-                round_tool_count += 1
-                try:
-                    func_args = json.loads(tc.get("function", {}).get("arguments", "{}") or "{}")
-                except Exception:
-                    func_args = {}
-                res = _execute_skill_tool(db, func_name, func_args, skills, node_input)
-                called_skills.append(func_name)
-                messages.append({
-                    "role": "tool",
-                    "tool_call_id": tc.get("id", f"call_{round_num}"),
-                    "content": res,
-                })
-    return {"text": last_content, "used_tools": called_skills}
-
-
-# ---------------------------------------------------------------------------
-# 单节点执行(含独立 DB 会话,保证并发安全)
-# ---------------------------------------------------------------------------
-async def _run_node(node, graph, blackboard, query, user_id, conv_id, trace_id) -> dict:
-    from app.api.chat_skill import _resolve_market_agents
-    from app.database import SessionLocal
-
-    nid = node["id"]
-    ntype = node["type"]
-    preds = _predecessors(graph, nid)
-    pred_results = [blackboard[p] for p in preds]
-    is_entry = not preds
-
-    if pred_results:
-        parts = []
-        for b in pred_results:
-            label = f"节点 {b.get('node_id')}/{b.get('node_type')}"
-            if b.get("ok"):
-                parts.append(f"【{label}】\n{b.get('output', '')}")
-            else:
-                parts.append(f"【{label} 执行失败】\n错误: {b.get('error', '')}")
-        upstream = "\n\n".join(parts)
-    else:
-        upstream = ""
-
-    with SessionLocal() as db:
-        if ntype in ("llm", "reduce"):
-            if ntype == "llm":
-                sys_prompt = node.get("system_prompt") or (
-                    "你是一个处理节点,请根据上游输入完成指定的文本变换、摘要或路由判断,"
-                    "只输出处理后的结果,不要多余解释。"
-                )
-            else:
-                sys_prompt = node.get("system_prompt") or (
-                    "你是一个结果汇总器,请将多个子任务的输出综合成连贯、完整、面向用户的最终回答。"
-                )
-            uc = _assemble_node_input(query, upstream, is_entry)
-            text = await _llm_complete(sys_prompt, uc, node.get("model_id"), db, user_id)
-            return {"ok": True, "node_id": nid, "node_type": ntype, "output": text}
-
-        if ntype == "local":
-            agents = _resolve_market_agents(db, [node["agent_id"]], user_id)
-            if not agents:
-                raise OrchestrationError(f"local 节点 {nid} 引用的 agent_id={node['agent_id']} 不存在")
-            ni = _assemble_node_input(query, upstream, is_entry)
-            res = await _run_local_agent_turn(agents[0], ni, db, user_id, conv_id)
-            return {
-                "ok": True,
-                "node_id": nid,
-                "node_type": "local",
-                "output": res["text"],
-                "used_tools": res.get("used_tools"),
-            }
-
-        if ntype == "external":
-            from app.api.chat_agents import _call_external_agent
-
-            agents = _resolve_market_agents(db, [node["agent_id"]], user_id)
-            if not agents:
-                raise OrchestrationError(f"external 节点 {nid} 引用的 agent_id={node['agent_id']} 不存在")
-            ni = _assemble_node_input(query, upstream, is_entry)
-            answer, _ = await _call_external_agent(
-                db, agents[0], query=ni, user_id=user_id, ext_conversation_id=None, history=[]
-            )
-            return {"ok": True, "node_id": nid, "node_type": "external", "output": answer or ""}
-
-    raise OrchestrationError(f"未知节点类型 {ntype}")
-
-
-# ---------------------------------------------------------------------------
-# 入口
-# ---------------------------------------------------------------------------
-async def run_graph(graph, query, db, user_id, conv_id, trace_id=None):
-    """执行编排图,返回 (answer: str, trace: dict)。
-
-    - 拓扑分层后逐层 asyncio.gather 并行执行。
-    - 节点异常按 on_error=halt|continue 处理(默认 continue,失败时下游拿到错误作为输入)。
-    """
-    validate_graph(graph)
-    layers = topological_layers(graph)
-    blackboard: dict = {}
-    trace = {"nodes": [], "trace_id": trace_id, "layers": layers}
-
-    for layer in layers:
-        tasks = [
-            _run_node(_node_by_id(graph, nid), graph, blackboard, query, user_id, conv_id, trace_id)
-            for nid in layer
-        ]
-        results = await asyncio.gather(*tasks, return_exceptions=True)
-        for nid, res in zip(layer, results):
-            node = _node_by_id(graph, nid)
-            if isinstance(res, Exception):
-                on_error = node.get("on_error", "continue")
-                err_msg = str(res)
-                blackboard[nid] = {
-                    "ok": False,
-                    "node_id": nid,
-                    "node_type": node["type"],
-                    "error": err_msg,
-                }
-                trace["nodes"].append({"id": nid, "type": node["type"], "ok": False, "error": err_msg})
-                logger.warning(f"[orchestrator] 节点 {nid} 失败 on_error={on_error}: {err_msg}")
-                if on_error == "halt":
-                    raise OrchestrationError(f"节点 {nid} 执行失败(halt): {err_msg}")
-            else:
-                blackboard[nid] = res
-                trace["nodes"].append({
-                    "id": nid,
-                    "type": res.get("node_type"),
-                    "ok": True,
-                    "used_tools": res.get("used_tools"),
-                })
-
-    output_node = _find_output_node(graph)
-    final = blackboard.get(output_node, {})
-    if final.get("ok"):
-        answer = final.get("output", "")
-    else:
-        answer = f"⚠️ 编排输出节点 {output_node} 执行失败: {final.get('error', '未知错误')}"
-    trace["output_node"] = output_node
-    return answer, trace
+"""app/core/orchestrator.py — Phase 3.3 多 Agent 编排图引擎。
+
+设计目标:
+- 以轻量 DAG 描述多 Agent 协作:节点(llm/local/external/reduce) + 有向边(数据流)。
+- 拓扑分层后按层并行执行(asyncio.gather),互相独立的节点真正并发。
+- 失败隔离:每个节点异常不影响其它分支;per-node on_error=halt|continue。
+- 节点执行复用既有原语(_build_system_prompt / _execute_skill_tool / _call_external_agent /
+  3.1 工具沙箱),完全向后兼容——agent.orchestration 为 null 时 chat_service 走原逻辑。
+- 并发安全:每个节点用独立 SessionLocal 会话做 DB 读,避免共享请求会话跨协程竞态。
+"""
+from __future__ import annotations
+
+import asyncio
+import json
+import logging
+from typing import Any, Optional
+
+import httpx
+
+logger = logging.getLogger("aiflow.orchestrator")
+
+VALID_NODE_TYPES = ("llm", "local", "external", "reduce")
+
+
+class OrchestrationError(ValueError):
+    """图结构非法或执行被 halt。"""
+
+
+# ---------------------------------------------------------------------------
+# 校验 + 拓扑
+# ---------------------------------------------------------------------------
+def validate_graph(graph: Any) -> None:
+    if not isinstance(graph, dict):
+        raise OrchestrationError("orchestration 必须为对象")
+    nodes = graph.get("nodes")
+    edges = graph.get("edges")
+    if not isinstance(nodes, list) or not nodes:
+        raise OrchestrationError("nodes 必须为非空数组")
+    if not isinstance(edges, list):
+        raise OrchestrationError("edges 必须为数组")
+
+    ids = set()
+    for i, n in enumerate(nodes):
+        if not isinstance(n, dict):
+            raise OrchestrationError(f"节点 #{i} 必须为对象")
+        nid = n.get("id")
+        if not nid or not isinstance(nid, str):
+            raise OrchestrationError(f"节点 #{i} 缺少字符串 id")
+        if nid in ids:
+            raise OrchestrationError(f"节点 id 重复: {nid}")
+        ids.add(nid)
+        ntype = n.get("type")
+        if ntype not in VALID_NODE_TYPES:
+            raise OrchestrationError(
+                f"节点 {nid} 类型非法: {ntype} (允许 {', '.join(VALID_NODE_TYPES)})"
+            )
+        if ntype in ("local", "external") and not n.get("agent_id"):
+            raise OrchestrationError(f"节点 {nid} 类型 {ntype} 必须指定 agent_id")
+
+    for i, e in enumerate(edges):
+        if not isinstance(e, dict):
+            raise OrchestrationError(f"边 #{i} 必须为对象")
+        f = e.get("from")
+        t = e.get("to")
+        if f not in ids:
+            raise OrchestrationError(f"边 #{i} 的 from={f} 指向不存在的节点")
+        if t not in ids:
+            raise OrchestrationError(f"边 #{i} 的 to={t} 指向不存在的节点")
+        if f == t:
+            raise OrchestrationError(f"边 #{i} 存在自环: {f}")
+
+
+def topological_layers(graph: dict) -> list:
+    """Kahn 算法返回分层(每层内节点可并行);含环则抛错。"""
+    nodes = graph["nodes"]
+    edges = graph.get("edges", [])
+    indeg = {n["id"]: 0 for n in nodes}
+    adj = {n["id"]: [] for n in nodes}
+    for e in edges:
+        f, t = e["from"], e["to"]
+        adj[f].append(t)
+        indeg[t] += 1
+    order = {n["id"]: i for i, n in enumerate(nodes)}
+    layers: list = []
+    remaining = set(indeg)
+    while remaining:
+        layer = sorted([nid for nid in remaining if indeg[nid] == 0], key=lambda x: order[x])
+        if not layer:
+            cycle = sorted(remaining, key=lambda x: order[x])
+            raise OrchestrationError(f"检测到环路,无法拓扑排序: {cycle}")
+        layers.append(layer)
+        for nid in layer:
+            remaining.discard(nid)
+            for nxt in adj[nid]:
+                indeg[nxt] -= 1
+    return layers
+
+
+def _node_by_id(graph: dict, nid: str) -> dict:
+    for n in graph["nodes"]:
+        if n["id"] == nid:
+            return n
+    return {}
+
+
+def _predecessors(graph: dict, nid: str) -> list:
+    return [e["from"] for e in graph.get("edges", []) if e["to"] == nid]
+
+
+def _successors(graph: dict, nid: str) -> list:
+    return [e["to"] for e in graph.get("edges", []) if e["from"] == nid]
+
+
+def _find_output_node(graph: dict) -> str:
+    if graph.get("output_node"):
+        return graph["output_node"]
+    sinks = [n["id"] for n in graph["nodes"] if not _successors(graph, n["id"])]
+    if len(sinks) == 1:
+        return sinks[0]
+    for s in sinks:
+        if _node_by_id(graph, s).get("type") == "reduce":
+            return s
+    if sinks:
+        return sinks[0]
+    return graph["nodes"][-1]["id"]
+
+
+def _assemble_node_input(query: str, upstream: str, is_entry: bool) -> str:
+    """入口节点直接吃用户原问题;下游节点综合原问题 + 上游输出。"""
+    if is_entry:
+        return query or ""
+    if upstream:
+        return f"原始用户问题:\n{query}\n\n上游节点输出:\n{upstream}"
+    return query or ""
+
+
+# ---------------------------------------------------------------------------
+# 模型解析 + LLM 调用原语
+# ---------------------------------------------------------------------------
+async def _resolve_model_and_endpoint(model_id, db):
+    from app.models import AIModel, ModelProvider
+    from ..capability_registry import resolve_auto_model
+
+    ai_model = None
+    if model_id and model_id != "auto":
+        ai_model = db.query(AIModel).filter(AIModel.id == model_id).first()
+    if not ai_model:
+        ai_model = resolve_auto_model("text", db)
+    if not ai_model:
+        raise OrchestrationError("无法解析到可用模型")
+    provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
+    api_key = ai_model.api_key or (provider.api_key if provider else None)
+    api_url = ai_model.endpoint
+    if not api_url and provider and provider.api_base_url:
+        api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
+    if not api_url or not api_key:
+        raise OrchestrationError(f"节点模型 {ai_model.name} 缺少 endpoint 或 api_key")
+    return ai_model, api_url, api_key
+
+
+async def _llm_complete(system_prompt: str, user_content: str, model_id, db, user_id) -> str:
+    from app.api.chat_utils import _proxy_for_url
+    from app.core.observability import span
+
+    ai_model, api_url, api_key = await _resolve_model_and_endpoint(model_id, db)
+    messages = [
+        {"role": "system", "content": system_prompt},
+        {"role": "user", "content": user_content},
+    ]
+    request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
+    async with span("orchestrator.llm", model=ai_model.id):
+        async with httpx.AsyncClient(
+            timeout=ai_model.timeout or 120,
+            proxy=_proxy_for_url(api_url),
+            trust_env=False,
+        ) as client:
+            resp = await client.post(
+                api_url,
+                headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
+                json=request_body,
+            )
+        if resp.status_code != 200:
+            raise OrchestrationError(f"LLM 调用失败 HTTP {resp.status_code}: {resp.text[:200]}")
+        data = resp.json()
+        return data["choices"][0]["message"].get("content") or ""
+
+
+# ---------------------------------------------------------------------------
+# local 节点:复用单 Agent system prompt + 受限 tool round + 3.1 沙箱
+# ---------------------------------------------------------------------------
+async def _run_local_agent_turn(agent: dict, node_input: str, db, user_id, conv_id) -> dict:
+    from app.api.chat_skill import _resolve_market_skills, _execute_skill_tool
+    from app.api.chat_context import _build_system_prompt, _retrieve_kb_context
+    from app.api.chat_service import _resolve_agent_tool_policy, _check_tool_policy
+    from app.api.chat_utils import _proxy_for_url
+
+    skills = _resolve_market_skills(db, agent.get("skill_ids") or [], user_id)
+    kb_context = await _retrieve_kb_context(db, agent.get("knowledge_base_ids") or [], node_input)
+    system_prompt, function_definitions = _build_system_prompt(
+        skills=skills,
+        agents=[agent],
+        kb_context=kb_context,
+        user_content=node_input,
+        auto_discovered=False,
+    )
+    ai_model, api_url, api_key = await _resolve_model_and_endpoint(agent.get("model_id"), db)
+    tool_policy = _resolve_agent_tool_policy([agent])
+
+    messages = [
+        {"role": "system", "content": system_prompt},
+        {"role": "user", "content": node_input},
+    ]
+    called_skills: list = []
+    tool_call_count = 0
+    max_tool_rounds = 5
+    last_content = ""
+    async with httpx.AsyncClient(
+        timeout=ai_model.timeout or 120,
+        proxy=_proxy_for_url(api_url),
+        trust_env=False,
+    ) as client:
+        for round_num in range(max_tool_rounds):
+            request_body = {"model": ai_model.name, "messages": messages, "max_tokens": 2048}
+            if function_definitions:
+                request_body["tools"] = [
+                    {"type": "function", "function": fd} for fd in function_definitions
+                ]
+                request_body["tool_choice"] = "auto"
+            resp = await client.post(
+                api_url,
+                headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
+                json=request_body,
+            )
+            if resp.status_code != 200:
+                raise OrchestrationError(f"local 节点 LLM 失败 HTTP {resp.status_code}: {resp.text[:200]}")
+            data = resp.json()
+            message = data["choices"][0]["message"]
+            tool_calls = message.get("tool_calls") or []
+            content = message.get("content") or ""
+            last_content = content
+            if not tool_calls or not function_definitions:
+                return {"text": content, "used_tools": called_skills}
+            messages.append({"role": "assistant", "content": content, "tool_calls": tool_calls})
+            round_tool_count = 0
+            for tc in tool_calls:
+                func_name = tc.get("function", {}).get("name", "")
+                check = _check_tool_policy(
+                    func_name, None, skills, tool_policy, tool_call_count, round_tool_count
+                )
+                if not check["allowed"]:
+                    messages.append({
+                        "role": "tool",
+                        "tool_call_id": tc.get("id", f"call_{round_num}"),
+                        "content": f"[工具调用已被策略拦截] {check.get('message', check.get('reason'))}",
+                    })
+                    tool_call_count += 1
+                    round_tool_count += 1
+                    continue
+                tool_call_count += 1
+                round_tool_count += 1
+                try:
+                    func_args = json.loads(tc.get("function", {}).get("arguments", "{}") or "{}")
+                except Exception:
+                    func_args = {}
+                res = _execute_skill_tool(db, func_name, func_args, skills, node_input)
+                called_skills.append(func_name)
+                messages.append({
+                    "role": "tool",
+                    "tool_call_id": tc.get("id", f"call_{round_num}"),
+                    "content": res,
+                })
+    return {"text": last_content, "used_tools": called_skills}
+
+
+# ---------------------------------------------------------------------------
+# 单节点执行(含独立 DB 会话,保证并发安全)
+# ---------------------------------------------------------------------------
+async def _run_node(node, graph, blackboard, query, user_id, conv_id, trace_id) -> dict:
+    from app.api.chat_skill import _resolve_market_agents
+    from app.database import SessionLocal
+
+    nid = node["id"]
+    ntype = node["type"]
+    preds = _predecessors(graph, nid)
+    pred_results = [blackboard[p] for p in preds]
+    is_entry = not preds
+
+    if pred_results:
+        parts = []
+        for b in pred_results:
+            label = f"节点 {b.get('node_id')}/{b.get('node_type')}"
+            if b.get("ok"):
+                parts.append(f"【{label}】\n{b.get('output', '')}")
+            else:
+                parts.append(f"【{label} 执行失败】\n错误: {b.get('error', '')}")
+        upstream = "\n\n".join(parts)
+    else:
+        upstream = ""
+
+    with SessionLocal() as db:
+        if ntype in ("llm", "reduce"):
+            if ntype == "llm":
+                sys_prompt = node.get("system_prompt") or (
+                    "你是一个处理节点,请根据上游输入完成指定的文本变换、摘要或路由判断,"
+                    "只输出处理后的结果,不要多余解释。"
+                )
+            else:
+                sys_prompt = node.get("system_prompt") or (
+                    "你是一个结果汇总器,请将多个子任务的输出综合成连贯、完整、面向用户的最终回答。"
+                )
+            uc = _assemble_node_input(query, upstream, is_entry)
+            text = await _llm_complete(sys_prompt, uc, node.get("model_id"), db, user_id)
+            return {"ok": True, "node_id": nid, "node_type": ntype, "output": text}
+
+        if ntype == "local":
+            agents = _resolve_market_agents(db, [node["agent_id"]], user_id)
+            if not agents:
+                raise OrchestrationError(f"local 节点 {nid} 引用的 agent_id={node['agent_id']} 不存在")
+            ni = _assemble_node_input(query, upstream, is_entry)
+            res = await _run_local_agent_turn(agents[0], ni, db, user_id, conv_id)
+            return {
+                "ok": True,
+                "node_id": nid,
+                "node_type": "local",
+                "output": res["text"],
+                "used_tools": res.get("used_tools"),
+            }
+
+        if ntype == "external":
+            from app.api.chat_agents import _call_external_agent
+
+            agents = _resolve_market_agents(db, [node["agent_id"]], user_id)
+            if not agents:
+                raise OrchestrationError(f"external 节点 {nid} 引用的 agent_id={node['agent_id']} 不存在")
+            ni = _assemble_node_input(query, upstream, is_entry)
+            answer, _ = await _call_external_agent(
+                db, agents[0], query=ni, user_id=user_id, ext_conversation_id=None, history=[]
+            )
+            return {"ok": True, "node_id": nid, "node_type": "external", "output": answer or ""}
+
+    raise OrchestrationError(f"未知节点类型 {ntype}")
+
+
+# ---------------------------------------------------------------------------
+# 入口
+# ---------------------------------------------------------------------------
+async def run_graph(graph, query, db, user_id, conv_id, trace_id=None):
+    """执行编排图,返回 (answer: str, trace: dict)。
+
+    - 拓扑分层后逐层 asyncio.gather 并行执行。
+    - 节点异常按 on_error=halt|continue 处理(默认 continue,失败时下游拿到错误作为输入)。
+    """
+    validate_graph(graph)
+    layers = topological_layers(graph)
+    blackboard: dict = {}
+    trace = {"nodes": [], "trace_id": trace_id, "layers": layers}
+
+    for layer in layers:
+        tasks = [
+            _run_node(_node_by_id(graph, nid), graph, blackboard, query, user_id, conv_id, trace_id)
+            for nid in layer
+        ]
+        results = await asyncio.gather(*tasks, return_exceptions=True)
+        for nid, res in zip(layer, results):
+            node = _node_by_id(graph, nid)
+            if isinstance(res, Exception):
+                on_error = node.get("on_error", "continue")
+                err_msg = str(res)
+                blackboard[nid] = {
+                    "ok": False,
+                    "node_id": nid,
+                    "node_type": node["type"],
+                    "error": err_msg,
+                }
+                trace["nodes"].append({"id": nid, "type": node["type"], "ok": False, "error": err_msg})
+                logger.warning(f"[orchestrator] 节点 {nid} 失败 on_error={on_error}: {err_msg}")
+                if on_error == "halt":
+                    raise OrchestrationError(f"节点 {nid} 执行失败(halt): {err_msg}")
+            else:
+                blackboard[nid] = res
+                trace["nodes"].append({
+                    "id": nid,
+                    "type": res.get("node_type"),
+                    "ok": True,
+                    "used_tools": res.get("used_tools"),
+                })
+
+    output_node = _find_output_node(graph)
+    final = blackboard.get(output_node, {})
+    if final.get("ok"):
+        answer = final.get("output", "")
+    else:
+        answer = f"⚠️ 编排输出节点 {output_node} 执行失败: {final.get('error', '未知错误')}"
+    trace["output_node"] = output_node
+    return answer, trace
diff --git a/backend/python/app/core/otel.py b/backend/python/app/core/otel.py
index cc723bf..3dfc382 100644
--- a/backend/python/app/core/otel.py
+++ b/backend/python/app/core/otel.py
@@ -1,131 +1,131 @@
-"""
-OpenTelemetry 桥接 (Phase 1.I 标准化)
-=====================================
-设计基准(标准对照表见 STANDARDS_AUDIT_2026-07-24.md §I):
-- 标准:OpenTelemetry SDK + OTLP gRPC exporter —— 云原生可观测事实标准。
-- traces:span 经 observability.span() 嵌套导出(见 observability.py)。
-- metrics:MeterProvider 经 OTLP 导出 span 耗时直方图 + 调用计数,供 Grafana 出图。
-- 失败安全(生产红线):若 OTLP 端点不可达 / 初始化异常,自动降级为 no-op,
-  绝不阻断业务请求、不产生未捕获异常。
-- 门控:OTEL_ENABLED=1 默认开启;置 0 则零开销、不走 OTel。
-- 发射目标:OTEL_EXPORTER_OTLP_ENDPOINT,默认 http://qihu-aiflow-alloy:4317
-  (经 Grafana Alloy 桥接 OTLP→Prometheus,再由 Prometheus 喂给 Grafana 出图)。
-
-对外 API:
-    init_otel()            首次调用时构建 TracerProvider + MeterProvider(幂等)
-    get_tracer()           返回 tracer(未启用/失败则 None)
-    get_meter()            返回 meter(未启用/失败则 None)
-    current_span_cm(name)  返回一个 OTel span 的上下文管理器(供 observability 嵌套)
-    record_span_metrics(name, elapsed_ms, status)  记录 span 指标(耗时直方图 + 计数)
-"""
-
-import os
-import logging
-
-logger = logging.getLogger(__name__)
-
-_ENABLED = os.getenv("OTEL_ENABLED", "1") not in ("0", "false", "False", "")
-_ENDPOINT = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://qihu-aiflow-alloy:4317").rstrip("/")
-if not _ENDPOINT.startswith("http://") and not _ENDPOINT.startswith("https://"):
-    _ENDPOINT = "http://" + _ENDPOINT
-
-_tracer = None
-_meter = None
-_initialized = False
-_duration_hist = None
-_span_counter = None
-
-
-def init_otel() -> None:
-    """构建 TracerProvider + MeterProvider(幂等)。失败则降级为 no-op。"""
-    global _tracer, _meter, _initialized
-    if _initialized:
-        return
-    _initialized = True
-    if not _ENABLED:
-        logger.info("[otel] disabled (OTEL_ENABLED=0)")
-        return
-    try:
-        from opentelemetry import trace, metrics
-        from opentelemetry.sdk.resources import Resource
-        from opentelemetry.semconv.resource import ResourceAttributes
-        from opentelemetry.sdk.trace import TracerProvider
-        from opentelemetry.sdk.trace.export import BatchSpanProcessor
-        from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
-            OTLPSpanExporter,
-        )
-        from opentelemetry.sdk.metrics import MeterProvider
-        from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
-        from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
-            OTLPMetricExporter,
-        )
-
-        resource = Resource.create({ResourceAttributes.SERVICE_NAME: "qihu-aiflow"})
-
-        # --- traces ---
-        trace_provider = TracerProvider(resource=resource)
-        trace_provider.add_span_processor(
-            BatchSpanProcessor(OTLPSpanExporter(endpoint=_ENDPOINT, insecure=True))
-        )
-        trace.set_tracer_provider(trace_provider)
-        _tracer = trace.get_tracer("qihu-aiflow")
-
-        # --- metrics ---
-        metric_reader = PeriodicExportingMetricReader(
-            OTLPMetricExporter(endpoint=_ENDPOINT, insecure=True)
-        )
-        meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
-        metrics.set_meter_provider(meter_provider)
-        _meter = metrics.get_meter("qihu-aiflow")
-
-        logger.info(f"[otel] tracer+meter ready -> {_ENDPOINT}")
-    except Exception as e:  # pragma: no cover - 降级保护
-        logger.warning(f"[otel] init failed, degraded to no-op: {e}")
-        _tracer = None
-        _meter = None
-
-
-def get_tracer():
-    """返回 tracer;首次调用触发 init。未启用/失败返回 None。"""
-    if _tracer is None and _ENABLED:
-        init_otel()
-    return _tracer
-
-
-def get_meter():
-    """返回 meter;首次调用触发 init。未启用/失败返回 None。"""
-    if _meter is None and _ENABLED:
-        init_otel()
-    return _meter
-
-
-def current_span_cm(name: str, attributes: dict | None = None):
-    """返回一个 OTel span 的上下文管理器(同步/异步通用)。失败时返回 None。"""
-    tr = get_tracer()
-    if tr is None:
-        return None
-    try:
-        return tr.start_as_current_span(name, attributes=attributes or {})
-    except Exception as e:  # pragma: no cover
-        logger.warning(f"[otel] start_span failed: {e}")
-        return None
-
-
-def record_span_metrics(name: str, elapsed_ms: float, status: str) -> None:
-    """记录 span 指标:耗时直方图 + 调用计数(供 Grafana 出图)。失败安全。"""
-    m = get_meter()
-    if m is None:
-        return
-    global _duration_hist, _span_counter
-    try:
-        if _duration_hist is None:
-            _duration_hist = m.create_histogram(
-                "aiflow_span_duration_ms", "Span duration in milliseconds"
-            )
-            _span_counter = m.create_counter("aiflow_span_count", "Span invocation count")
-        attrs = {"span": name, "status": status}
-        _duration_hist.record(elapsed_ms, attrs)
-        _span_counter.add(1, attrs)
-    except Exception:  # pragma: no cover
-        pass
-
+"""
+OpenTelemetry 桥接 (Phase 1.I 标准化)
+=====================================
+设计基准(标准对照表见 STANDARDS_AUDIT_2026-07-24.md §I):
+- 标准:OpenTelemetry SDK + OTLP gRPC exporter —— 云原生可观测事实标准。
+- traces:span 经 observability.span() 嵌套导出(见 observability.py)。
+- metrics:MeterProvider 经 OTLP 导出 span 耗时直方图 + 调用计数,供 Grafana 出图。
+- 失败安全(生产红线):若 OTLP 端点不可达 / 初始化异常,自动降级为 no-op,
+  绝不阻断业务请求、不产生未捕获异常。
+- 门控:OTEL_ENABLED=1 默认开启;置 0 则零开销、不走 OTel。
+- 发射目标:OTEL_EXPORTER_OTLP_ENDPOINT,默认 http://qihu-aiflow-alloy:4317
+  (经 Grafana Alloy 桥接 OTLP→Prometheus,再由 Prometheus 喂给 Grafana 出图)。
+
+对外 API:
+    init_otel()            首次调用时构建 TracerProvider + MeterProvider(幂等)
+    get_tracer()           返回 tracer(未启用/失败则 None)
+    get_meter()            返回 meter(未启用/失败则 None)
+    current_span_cm(name)  返回一个 OTel span 的上下文管理器(供 observability 嵌套)
+    record_span_metrics(name, elapsed_ms, status)  记录 span 指标(耗时直方图 + 计数)
+"""
+
+import os
+import logging
+
+logger = logging.getLogger(__name__)
+
+_ENABLED = os.getenv("OTEL_ENABLED", "1") not in ("0", "false", "False", "")
+_ENDPOINT = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://qihu-aiflow-alloy:4317").rstrip("/")
+if not _ENDPOINT.startswith("http://") and not _ENDPOINT.startswith("https://"):
+    _ENDPOINT = "http://" + _ENDPOINT
+
+_tracer = None
+_meter = None
+_initialized = False
+_duration_hist = None
+_span_counter = None
+
+
+def init_otel() -> None:
+    """构建 TracerProvider + MeterProvider(幂等)。失败则降级为 no-op。"""
+    global _tracer, _meter, _initialized
+    if _initialized:
+        return
+    _initialized = True
+    if not _ENABLED:
+        logger.info("[otel] disabled (OTEL_ENABLED=0)")
+        return
+    try:
+        from opentelemetry import trace, metrics
+        from opentelemetry.sdk.resources import Resource
+        from opentelemetry.semconv.resource import ResourceAttributes
+        from opentelemetry.sdk.trace import TracerProvider
+        from opentelemetry.sdk.trace.export import BatchSpanProcessor
+        from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
+            OTLPSpanExporter,
+        )
+        from opentelemetry.sdk.metrics import MeterProvider
+        from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
+        from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
+            OTLPMetricExporter,
+        )
+
+        resource = Resource.create({ResourceAttributes.SERVICE_NAME: "qihu-aiflow"})
+
+        # --- traces ---
+        trace_provider = TracerProvider(resource=resource)
+        trace_provider.add_span_processor(
+            BatchSpanProcessor(OTLPSpanExporter(endpoint=_ENDPOINT, insecure=True))
+        )
+        trace.set_tracer_provider(trace_provider)
+        _tracer = trace.get_tracer("qihu-aiflow")
+
+        # --- metrics ---
+        metric_reader = PeriodicExportingMetricReader(
+            OTLPMetricExporter(endpoint=_ENDPOINT, insecure=True)
+        )
+        meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
+        metrics.set_meter_provider(meter_provider)
+        _meter = metrics.get_meter("qihu-aiflow")
+
+        logger.info(f"[otel] tracer+meter ready -> {_ENDPOINT}")
+    except Exception as e:  # pragma: no cover - 降级保护
+        logger.warning(f"[otel] init failed, degraded to no-op: {e}")
+        _tracer = None
+        _meter = None
+
+
+def get_tracer():
+    """返回 tracer;首次调用触发 init。未启用/失败返回 None。"""
+    if _tracer is None and _ENABLED:
+        init_otel()
+    return _tracer
+
+
+def get_meter():
+    """返回 meter;首次调用触发 init。未启用/失败返回 None。"""
+    if _meter is None and _ENABLED:
+        init_otel()
+    return _meter
+
+
+def current_span_cm(name: str, attributes: dict | None = None):
+    """返回一个 OTel span 的上下文管理器(同步/异步通用)。失败时返回 None。"""
+    tr = get_tracer()
+    if tr is None:
+        return None
+    try:
+        return tr.start_as_current_span(name, attributes=attributes or {})
+    except Exception as e:  # pragma: no cover
+        logger.warning(f"[otel] start_span failed: {e}")
+        return None
+
+
+def record_span_metrics(name: str, elapsed_ms: float, status: str) -> None:
+    """记录 span 指标:耗时直方图 + 调用计数(供 Grafana 出图)。失败安全。"""
+    m = get_meter()
+    if m is None:
+        return
+    global _duration_hist, _span_counter
+    try:
+        if _duration_hist is None:
+            _duration_hist = m.create_histogram(
+                "aiflow_span_duration_ms", "Span duration in milliseconds"
+            )
+            _span_counter = m.create_counter("aiflow_span_count", "Span invocation count")
+        attrs = {"span": name, "status": status}
+        _duration_hist.record(elapsed_ms, attrs)
+        _span_counter.add(1, attrs)
+    except Exception:  # pragma: no cover
+        pass
+
diff --git a/backend/python/app/kb_router.py b/backend/python/app/kb_router.py
index 5734857..f26f78a 100644
--- a/backend/python/app/kb_router.py
+++ b/backend/python/app/kb_router.py
@@ -1,271 +1,271 @@
-"""
-知识库路由层
-============
-将检索请求智能路由到本地BM25、向量检索、或外部KB服务(Dify)。
-根据知识库配置自动选择最优检索策略。
-
-使用方式:
-    from app.kb_router import kb_router_search
-    results = await kb_router_search(query, kb_id, top_k=5)
-"""
-
-import logging
-from typing import List, Dict, Optional
-
-logger = logging.getLogger("aiflow.kb_router")
-
-
-async def _get_external_services(db_session, enabled_only: bool = True) -> List[Dict]:
-    """获取已配置的外部KB服务(仅 dify)"""
-    from sqlalchemy import text
-    try:
-        query = "SELECT * FROM external_kb_services"
-        if enabled_only:
-            query += " WHERE is_enabled = 1"
-        query += " ORDER BY created_at DESC"
-        result = db_session.execute(text(query))
-        columns = result.keys()
-        return [dict(zip(columns, row)) for row in result.fetchall()]
-    except Exception as e:
-        logger.error(f"Failed to get external services: {e}")
-        return []
-
-
-async def _search_dify(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
-    """检索 Dify 知识库(走 app.dify_kb 客户端)"""
-    try:
-        from app.dify_kb import dify_retrieve
-        api_url = service.get("api_url", "")
-        api_key = service.get("api_key", "")
-        dataset_name = None
-        cfg = service.get("config")
-        if isinstance(cfg, str):
-            try:
-                import json
-                cfg = json.loads(cfg)
-            except Exception:
-                cfg = None
-        if isinstance(cfg, dict):
-            dataset_name = cfg.get("dataset_name")
-        hits = dify_retrieve(api_url, api_key, query, top_k=top_k, dataset_name=dataset_name)
-        return hits
-    except Exception as e:
-        logger.error(f"Dify search error: {e}")
-        return []
-
-
-async def _search_external(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
-    """路由到对应的外部KB服务(当前仅支持 dify)"""
-    service_type = service.get("service_type", "")
-    if service_type == "dify":
-        return await _search_dify(service, query, top_k)
-    else:
-        logger.warning(f"Unknown external KB type: {service_type}")
-        return []
-
-async def _get_web_services(db_session, enabled_only: bool = True) -> List[Dict]:
-    """获取全局 AnySearch 联网检索配置(从 site_settings 读取)。"""
-    from app.anysearch_client import load_anysearch_config
-    cfg = load_anysearch_config(db_session)
-    if not cfg:
-        return []
-    if enabled_only and not cfg.get("is_enabled"):
-        return []
-    api_key = cfg.get("api_key", "")
-    if not api_key:
-        return []
-    return [{
-        "id": "anysearch_global",
-        "service_type": "anysearch",
-        "api_key": api_key,
-        "api_url": cfg.get("api_url") or "https://api.anysearch.com/v1/search",
-        "config": {
-            "zone": cfg.get("zone", "cn"),
-            "freshness": cfg.get("freshness"),
-            "top_k": cfg.get("top_k", 5),
-        },
-        "is_enabled": cfg.get("is_enabled", False),
-    }]
-
-
-async def _search_anysearch(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
-    """调用 AnySearch 联网检索(走 app.anysearch_client)。"""
-    try:
-        from app.anysearch_client import anysearch_search
-        api_key = service.get("api_key", "")
-        api_url = service.get("api_url") or "https://api.anysearch.com/v1/search"
-        cfg = service.get("config")
-        if isinstance(cfg, str):
-            try:
-                import json
-                cfg = json.loads(cfg)
-            except Exception:
-                cfg = None
-        cfg = cfg or {}
-        zone = cfg.get("zone", "cn")
-        freshness = cfg.get("freshness")
-        k = int(cfg.get("top_k", top_k))
-        return await anysearch_search(api_key, api_url, query, top_k=k, zone=zone, freshness=freshness)
-    except Exception as e:
-        logger.error(f"AnySearch search error: {e}")
-        return []
-
-
-async def kb_router_search(
-    query: str,
-    kb_id: Optional[str] = None,
-    kb_ids: Optional[List[str]] = None,
-    top_k: int = 5,
-    bm25_results: Optional[List[Dict]] = None,
-    db_session=None,
-    use_vector: bool = True,
-    use_external: bool = True,
-    use_tree: bool = True,
-    use_web: bool = False,
-) -> List[Dict]:
-    """
-    知识库路由层 — 混合检索
-
-    检索策略:
-    1. 本地 BM25 检索 (MySQL, 已有)
-    2. 向量语义检索 (PostgreSQL+pgvector, 新增)
-    3. 结构化分片段检索 (TreeSearch, 新增: 知识库目录树 + chunk 级精准命中)
-    4. 外部KB检索 (Dify, 按配置)
-    5. 联网检索 (AnySearch, 可选 use_web)
-
-    融合方式: Reciprocal Rank Fusion (RRF)
-
-    Args:
-        query: 查询文本
-        kb_id: 知识库ID
-        top_k: 返回结果数
-        bm25_results: BM25检索结果
-        db_session: 数据库会话(用于获取外部KB配置与分片段检索)
-        use_vector: 是否使用向量检索
-        use_external: 是否使用外部KB检索
-        use_tree: 是否使用 TreeSearch 结构化分片段检索
-        use_web: 是否使用 AnySearch 联网检索
-
-    Returns:
-        融合后的检索结果列表
-    """
-    all_results = []
-
-    # 1. BM25 结果
-    if bm25_results:
-        for rank, item in enumerate(bm25_results):
-            all_results.append({
-                "content": item.get("content", item.get("text", "")),
-                "score": item.get("score", 1.0 / (60 + rank + 1)),
-                "source": "local_bm25",
-                "rank": rank + 1,
-                "metadata": item.get("metadata", {}),
-            })
-
-    # 2. 向量检索
-    if use_vector:
-        try:
-            from app.pgvector_search import vector_search
-            kb_targets = kb_ids if kb_ids else ([kb_id] if kb_id else [None])
-            vec_results = []
-            for _kid in kb_targets:
-                try:
-                    vec_results.extend(await vector_search(query, _kid, top_k))
-                except Exception as e:
-                    logger.warning(f"Vector search failed for kb={_kid}: {e}")
-            for rank, item in enumerate(vec_results):
-                all_results.append({
-                    "content": item.get("content", ""),
-                    "score": item.get("score", 0.5),
-                    "source": "vector_pg",
-                    "rank": rank + 1,
-                    "metadata": item.get("metadata", {}),
-                })
-        except ImportError:
-            logger.debug("pgvector_search not available, skipping vector search")
-        except Exception as e:
-            logger.warning(f"Vector search failed: {e}")
-
-    # 2.5 结构化分片段检索 (TreeSearch, Level 2)
-    if use_tree and db_session is not None:
-        try:
-            from app.tree_search import tree_search
-            kb_targets = kb_ids if kb_ids else ([kb_id] if kb_id else None)
-            tree_hits = await tree_search(
-                db_session, query, kb_ids=kb_targets, top_k=top_k, mode="hybrid",
-            )
-            for rank, item in enumerate(tree_hits):
-                all_results.append({
-                    "content": item.get("content", ""),
-                    "score": item.get("score", 0.5),
-                    "source": "tree_chunk",
-                    "rank": rank + 1,
-                    "metadata": item.get("metadata", {}),
-                })
-        except Exception as e:
-            logger.warning(f"TreeSearch failed: {e}")
-
-    # 3. 外部KB检索 (Dify)
-    if use_external and db_session:
-        services = await _get_external_services(db_session)
-        for service in services:
-            if service.get("status") == "connected":
-                ext_results = await _search_external(service, query, top_k)
-                for rank, item in enumerate(ext_results):
-                    all_results.append({
-                        "content": item.get("content", ""),
-                        "score": item.get("score", 0.5),
-                        "source": item.get("source", "external_dify"),
-                        "rank": rank + 1,
-                        "metadata": item.get("metadata", {}),
-                    })
-
-    # 4. 联网检索 (AnySearch, 可选)
-    if use_web and db_session is not None:
-        try:
-            web_services = await _get_web_services(db_session)
-            for svc in web_services:
-                web_hits = await _search_anysearch(svc, query, top_k)
-                for rank, item in enumerate(web_hits):
-                    all_results.append({
-                        "content": item.get("content", ""),
-                        "score": item.get("score", 0.6),
-                        "source": item.get("source", "web_anysearch"),
-                        "rank": rank + 1,
-                        "metadata": item.get("metadata", {}),
-                    })
-        except Exception as e:
-            logger.warning(f"AnySearch web search failed: {e}")
-
-    if not all_results:
-        return []
-
-    # RRF 融合
-    rrf_k = 60
-    score_map = {}
-    for item in all_results:
-        content_key = item["content"][:200]  # 用内容前200字符作为去重键
-        rrf_score = 1.0 / (rrf_k + item.get("rank", 1))
-
-        if content_key in score_map:
-            score_map[content_key]["score"] += rrf_score
-            score_map[content_key]["sources"].append(item["source"])
-        else:
-            score_map[content_key] = {
-                "content": item["content"],
-                "score": rrf_score,
-                "sources": [item["source"]],
-                "metadata": item.get("metadata", {}),
-            }
-
-    # 按融合分数排序
-    merged = sorted(score_map.values(), key=lambda x: x["score"], reverse=True)
-
-    # 标准化分数到 0-1
-    if merged:
-        max_score = merged[0]["score"]
-        for item in merged:
-            item["score"] = item["score"] / max_score if max_score > 0 else 0
-            item["sources"] = list(set(item["sources"]))
-
-    return merged[:top_k]
+"""
+知识库路由层
+============
+将检索请求智能路由到本地BM25、向量检索、或外部KB服务(Dify)。
+根据知识库配置自动选择最优检索策略。
+
+使用方式:
+    from app.kb_router import kb_router_search
+    results = await kb_router_search(query, kb_id, top_k=5)
+"""
+
+import logging
+from typing import List, Dict, Optional
+
+logger = logging.getLogger("aiflow.kb_router")
+
+
+async def _get_external_services(db_session, enabled_only: bool = True) -> List[Dict]:
+    """获取已配置的外部KB服务(仅 dify)"""
+    from sqlalchemy import text
+    try:
+        query = "SELECT * FROM external_kb_services"
+        if enabled_only:
+            query += " WHERE is_enabled = 1"
+        query += " ORDER BY created_at DESC"
+        result = db_session.execute(text(query))
+        columns = result.keys()
+        return [dict(zip(columns, row)) for row in result.fetchall()]
+    except Exception as e:
+        logger.error(f"Failed to get external services: {e}")
+        return []
+
+
+async def _search_dify(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
+    """检索 Dify 知识库(走 app.dify_kb 客户端)"""
+    try:
+        from app.dify_kb import dify_retrieve
+        api_url = service.get("api_url", "")
+        api_key = service.get("api_key", "")
+        dataset_name = None
+        cfg = service.get("config")
+        if isinstance(cfg, str):
+            try:
+                import json
+                cfg = json.loads(cfg)
+            except Exception:
+                cfg = None
+        if isinstance(cfg, dict):
+            dataset_name = cfg.get("dataset_name")
+        hits = dify_retrieve(api_url, api_key, query, top_k=top_k, dataset_name=dataset_name)
+        return hits
+    except Exception as e:
+        logger.error(f"Dify search error: {e}")
+        return []
+
+
+async def _search_external(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
+    """路由到对应的外部KB服务(当前仅支持 dify)"""
+    service_type = service.get("service_type", "")
+    if service_type == "dify":
+        return await _search_dify(service, query, top_k)
+    else:
+        logger.warning(f"Unknown external KB type: {service_type}")
+        return []
+
+async def _get_web_services(db_session, enabled_only: bool = True) -> List[Dict]:
+    """获取全局 AnySearch 联网检索配置(从 site_settings 读取)。"""
+    from app.anysearch_client import load_anysearch_config
+    cfg = load_anysearch_config(db_session)
+    if not cfg:
+        return []
+    if enabled_only and not cfg.get("is_enabled"):
+        return []
+    api_key = cfg.get("api_key", "")
+    if not api_key:
+        return []
+    return [{
+        "id": "anysearch_global",
+        "service_type": "anysearch",
+        "api_key": api_key,
+        "api_url": cfg.get("api_url") or "https://api.anysearch.com/v1/search",
+        "config": {
+            "zone": cfg.get("zone", "cn"),
+            "freshness": cfg.get("freshness"),
+            "top_k": cfg.get("top_k", 5),
+        },
+        "is_enabled": cfg.get("is_enabled", False),
+    }]
+
+
+async def _search_anysearch(service: Dict, query: str, top_k: int = 5) -> List[Dict]:
+    """调用 AnySearch 联网检索(走 app.anysearch_client)。"""
+    try:
+        from app.anysearch_client import anysearch_search
+        api_key = service.get("api_key", "")
+        api_url = service.get("api_url") or "https://api.anysearch.com/v1/search"
+        cfg = service.get("config")
+        if isinstance(cfg, str):
+            try:
+                import json
+                cfg = json.loads(cfg)
+            except Exception:
+                cfg = None
+        cfg = cfg or {}
+        zone = cfg.get("zone", "cn")
+        freshness = cfg.get("freshness")
+        k = int(cfg.get("top_k", top_k))
+        return await anysearch_search(api_key, api_url, query, top_k=k, zone=zone, freshness=freshness)
+    except Exception as e:
+        logger.error(f"AnySearch search error: {e}")
+        return []
+
+
+async def kb_router_search(
+    query: str,
+    kb_id: Optional[str] = None,
+    kb_ids: Optional[List[str]] = None,
+    top_k: int = 5,
+    bm25_results: Optional[List[Dict]] = None,
+    db_session=None,
+    use_vector: bool = True,
+    use_external: bool = True,
+    use_tree: bool = True,
+    use_web: bool = False,
+) -> List[Dict]:
+    """
+    知识库路由层 — 混合检索
+
+    检索策略:
+    1. 本地 BM25 检索 (MySQL, 已有)
+    2. 向量语义检索 (PostgreSQL+pgvector, 新增)
+    3. 结构化分片段检索 (TreeSearch, 新增: 知识库目录树 + chunk 级精准命中)
+    4. 外部KB检索 (Dify, 按配置)
+    5. 联网检索 (AnySearch, 可选 use_web)
+
+    融合方式: Reciprocal Rank Fusion (RRF)
+
+    Args:
+        query: 查询文本
+        kb_id: 知识库ID
+        top_k: 返回结果数
+        bm25_results: BM25检索结果
+        db_session: 数据库会话(用于获取外部KB配置与分片段检索)
+        use_vector: 是否使用向量检索
+        use_external: 是否使用外部KB检索
+        use_tree: 是否使用 TreeSearch 结构化分片段检索
+        use_web: 是否使用 AnySearch 联网检索
+
+    Returns:
+        融合后的检索结果列表
+    """
+    all_results = []
+
+    # 1. BM25 结果
+    if bm25_results:
+        for rank, item in enumerate(bm25_results):
+            all_results.append({
+                "content": item.get("content", item.get("text", "")),
+                "score": item.get("score", 1.0 / (60 + rank + 1)),
+                "source": "local_bm25",
+                "rank": rank + 1,
+                "metadata": item.get("metadata", {}),
+            })
+
+    # 2. 向量检索
+    if use_vector:
+        try:
+            from app.pgvector_search import vector_search
+            kb_targets = kb_ids if kb_ids else ([kb_id] if kb_id else [None])
+            vec_results = []
+            for _kid in kb_targets:
+                try:
+                    vec_results.extend(await vector_search(query, _kid, top_k))
+                except Exception as e:
+                    logger.warning(f"Vector search failed for kb={_kid}: {e}")
+            for rank, item in enumerate(vec_results):
+                all_results.append({
+                    "content": item.get("content", ""),
+                    "score": item.get("score", 0.5),
+                    "source": "vector_pg",
+                    "rank": rank + 1,
+                    "metadata": item.get("metadata", {}),
+                })
+        except ImportError:
+            logger.debug("pgvector_search not available, skipping vector search")
+        except Exception as e:
+            logger.warning(f"Vector search failed: {e}")
+
+    # 2.5 结构化分片段检索 (TreeSearch, Level 2)
+    if use_tree and db_session is not None:
+        try:
+            from app.tree_search import tree_search
+            kb_targets = kb_ids if kb_ids else ([kb_id] if kb_id else None)
+            tree_hits = await tree_search(
+                db_session, query, kb_ids=kb_targets, top_k=top_k, mode="hybrid",
+            )
+            for rank, item in enumerate(tree_hits):
+                all_results.append({
+                    "content": item.get("content", ""),
+                    "score": item.get("score", 0.5),
+                    "source": "tree_chunk",
+                    "rank": rank + 1,
+                    "metadata": item.get("metadata", {}),
+                })
+        except Exception as e:
+            logger.warning(f"TreeSearch failed: {e}")
+
+    # 3. 外部KB检索 (Dify)
+    if use_external and db_session:
+        services = await _get_external_services(db_session)
+        for service in services:
+            if service.get("status") == "connected":
+                ext_results = await _search_external(service, query, top_k)
+                for rank, item in enumerate(ext_results):
+                    all_results.append({
+                        "content": item.get("content", ""),
+                        "score": item.get("score", 0.5),
+                        "source": item.get("source", "external_dify"),
+                        "rank": rank + 1,
+                        "metadata": item.get("metadata", {}),
+                    })
+
+    # 4. 联网检索 (AnySearch, 可选)
+    if use_web and db_session is not None:
+        try:
+            web_services = await _get_web_services(db_session)
+            for svc in web_services:
+                web_hits = await _search_anysearch(svc, query, top_k)
+                for rank, item in enumerate(web_hits):
+                    all_results.append({
+                        "content": item.get("content", ""),
+                        "score": item.get("score", 0.6),
+                        "source": item.get("source", "web_anysearch"),
+                        "rank": rank + 1,
+                        "metadata": item.get("metadata", {}),
+                    })
+        except Exception as e:
+            logger.warning(f"AnySearch web search failed: {e}")
+
+    if not all_results:
+        return []
+
+    # RRF 融合
+    rrf_k = 60
+    score_map = {}
+    for item in all_results:
+        content_key = item["content"][:200]  # 用内容前200字符作为去重键
+        rrf_score = 1.0 / (rrf_k + item.get("rank", 1))
+
+        if content_key in score_map:
+            score_map[content_key]["score"] += rrf_score
+            score_map[content_key]["sources"].append(item["source"])
+        else:
+            score_map[content_key] = {
+                "content": item["content"],
+                "score": rrf_score,
+                "sources": [item["source"]],
+                "metadata": item.get("metadata", {}),
+            }
+
+    # 按融合分数排序
+    merged = sorted(score_map.values(), key=lambda x: x["score"], reverse=True)
+
+    # 标准化分数到 0-1
+    if merged:
+        max_score = merged[0]["score"]
+        for item in merged:
+            item["score"] = item["score"] / max_score if max_score > 0 else 0
+            item["sources"] = list(set(item["sources"]))
+
+    return merged[:top_k]
diff --git a/backend/python/app/pgvector_search.py b/backend/python/app/pgvector_search.py
index 60d6490..707dac6 100644
--- a/backend/python/app/pgvector_search.py
+++ b/backend/python/app/pgvector_search.py
@@ -1,379 +1,379 @@
-"""
-PGVector 向量检索模块
-====================
-使用 PostgreSQL + pgvector 实现语义向量检索,
-与现有 MySQL BM25 检索配合,实现混合检索。
-
-使用方式:
-    from app.pgvector_search import vector_search
-    results = await vector_search(query, kb_id, top_k=5)
-"""
-
-import os
-import logging
-import asyncio
-from typing import List, Dict, Optional
-from datetime import datetime
-
-logger = logging.getLogger("aiflow.pgvector")
-
-# PostgreSQL 连接配置
-PG_HOST = os.environ.get("POSTGRES_HOST", "qihu-aiflow-pgvector")
-PG_PORT = int(os.environ.get("POSTGRES_PORT", "5432"))
-PG_USER = os.environ.get("POSTGRES_USER", "aiflow")
-PG_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "pwd2026")
-PG_DB = os.environ.get("POSTGRES_DB", "aiflow_vectors")
-
-# 嵌入模型配置
-EMBEDDING_API_URL = os.environ.get("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn")
-EMBEDDING_API_KEY = os.environ.get("SILICONFLOW_API_KEY", "")
-EMBEDDING_MODEL = os.environ.get("EMBEDDING_MODEL", "BAAI/bge-large-zh-v1.5")
-
-# ---- 本地嵌入缓存(SQLite 持久化):破解"每次嵌入消耗外部 API 额度"的隐性成本障碍 ----
-import sqlite3, hashlib, json as _json
-_EMBED_CACHE_DB = os.environ.get("EMBED_CACHE_DB", "/app/backend/python/.embed_cache.db")
-def _embed_cache_get(text, model):
-    try:
-        key = hashlib.sha256(f"{model}::{text}".encode("utf-8")).hexdigest()
-        conn = sqlite3.connect(_EMBED_CACHE_DB, timeout=5)
-        row = conn.execute("SELECT vec FROM embed_cache WHERE k=?", (key,)).fetchone()
-        conn.close()
-        if row:
-            return _json.loads(row[0])
-    except Exception:
-        return None
-    return None
-def _embed_cache_put(text, model, vec):
-    try:
-        key = hashlib.sha256(f"{model}::{text}".encode("utf-8")).hexdigest()
-        conn = sqlite3.connect(_EMBED_CACHE_DB, timeout=5)
-        conn.execute("CREATE TABLE IF NOT EXISTS embed_cache (k TEXT PRIMARY KEY, vec TEXT, ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
-        conn.execute("INSERT OR REPLACE INTO embed_cache (k, vec) VALUES (?, ?)", (key, _json.dumps(vec)))
-        conn.commit()
-        conn.close()
-    except Exception:
-        pass
-
-
-# 连接池
-_pg_pool = None
-_pool_lock = asyncio.Lock()
-
-
-async def get_pg_pool():
-    """获取 PostgreSQL 异步连接池"""
-    global _pg_pool
-    if _pg_pool is not None:
-        return _pg_pool
-
-    async with _pool_lock:
-        if _pg_pool is not None:
-            return _pg_pool
-
-        try:
-            import asyncpg
-        except ImportError:
-            logger.warning("asyncpg not installed, vector search disabled")
-            return None
-
-        try:
-            _pg_pool = await asyncpg.create_pool(
-                host=PG_HOST,
-                port=PG_PORT,
-                user=PG_USER,
-                password=PG_PASSWORD,
-                database=PG_DB,
-                min_size=2,
-                max_size=10,
-                command_timeout=30,
-            )
-            logger.info(f"PostgreSQL connection pool created: {PG_HOST}:{PG_PORT}/{PG_DB}")
-
-            # 初始化表结构
-            async with _pg_pool.acquire() as conn:
-                await conn.execute("""
-                    CREATE TABLE IF NOT EXISTS document_embeddings (
-                        id SERIAL PRIMARY KEY,
-                        doc_id VARCHAR(64) NOT NULL,
-                        kb_id VARCHAR(64) NOT NULL,
-                        chunk_id VARCHAR(64),
-                        content TEXT NOT NULL,
-                        embedding vector(1024),
-                        metadata JSONB DEFAULT '{}',
-                        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
-                    );
-                """)
-                await conn.execute("""
-                    CREATE INDEX IF NOT EXISTS idx_embeddings_kb 
-                    ON document_embeddings(kb_id);
-                """)
-                await conn.execute("""
-                    CREATE INDEX IF NOT EXISTS idx_embeddings_doc 
-                    ON document_embeddings(doc_id);
-                """)
-                await conn.execute("""
-                    CREATE INDEX IF NOT EXISTS idx_embeddings_vector 
-                    ON document_embeddings USING ivfflat (embedding vector_cosine_ops)
-                    WITH (lists = 100);
-                """)
-                logger.info("PGVector tables initialized")
-
-            return _pg_pool
-        except Exception as e:
-            logger.error(f"Failed to create PostgreSQL pool: {e}")
-            _pg_pool = None
-            return None
-
-
-async def get_embedding(text: str) -> Optional[List[float]]:
-    """调用嵌入API获取文本向量"""
-    if not EMBEDDING_API_KEY:
-        logger.warning("No embedding API key configured")
-        return None
-
-    cached = _embed_cache_get(text, EMBEDDING_MODEL)
-    if cached:
-        return cached
-    try:
-        # 收敛到共享标准模型层(app.core.llm_client.aembed),与聊天共用代理/超时/failover 语义
-        from app.core.llm_client import aembed
-        embedding = await aembed(
-            EMBEDDING_API_URL, EMBEDDING_API_KEY, text[:8000], EMBEDDING_MODEL, timeout=30
-        )
-        if embedding:
-            _embed_cache_put(text, EMBEDDING_MODEL, embedding)
-            return embedding
-        logger.error("Embedding API returned empty")
-        return None
-    except Exception as e:
-        logger.error(f"Embedding generation failed: {e}")
-        return None
-
-
-async def vector_search(
-    query: str,
-    kb_id: Optional[str] = None,
-    top_k: int = 5,
-    threshold: float = 0.5,
-) -> List[Dict]:
-    """
-    向量语义检索
-
-    Args:
-        query: 查询文本
-        kb_id: 知识库ID (可选,不指定则搜索全部)
-        top_k: 返回结果数
-        threshold: 相似度阈值 (0-1)
-
-    Returns:
-        [{"doc_id": ..., "content": ..., "score": ..., "metadata": ...}, ...]
-    """
-    pool = await get_pg_pool()
-    if pool is None:
-        return []
-
-    # 获取查询向量
-    query_embedding = await get_embedding(query)
-    if query_embedding is None:
-        return []
-
-    try:
-        async with pool.acquire() as conn:
-            # 将向量转换为 PostgreSQL 格式
-            embedding_str = f"[{','.join(str(x) for x in query_embedding)}]"
-
-            if kb_id:
-                rows = await conn.fetch(
-                    """
-                    SELECT doc_id, chunk_id, content, metadata,
-                           1 - (embedding <=> $1::vector) as score
-                    FROM document_embeddings
-                    WHERE kb_id = $2
-                    ORDER BY embedding <=> $1::vector
-                    LIMIT $3;
-                    """,
-                    embedding_str, kb_id, top_k,
-                )
-            else:
-                rows = await conn.fetch(
-                    """
-                    SELECT doc_id, chunk_id, content, metadata,
-                           1 - (embedding <=> $1::vector) as score
-                    FROM document_embeddings
-                    ORDER BY embedding <=> $1::vector
-                    LIMIT $2;
-                    """,
-                    embedding_str, top_k,
-                )
-
-            results = []
-            for row in rows:
-                score = float(row["score"])
-                if score >= threshold:
-                    results.append({
-                        "doc_id": row["doc_id"],
-                        "chunk_id": row["chunk_id"],
-                        "content": row["content"],
-                        "score": score,
-                        "metadata": row["metadata"] if isinstance(row["metadata"], dict) else {},
-                    })
-
-            return results
-
-    except Exception as e:
-        logger.error(f"Vector search failed: {e}")
-        return []
-
-
-async def index_document(
-    doc_id: str,
-    kb_id: str,
-    content: str,
-    chunk_id: Optional[str] = None,
-    metadata: Optional[Dict] = None,
-) -> bool:
-    """
-    将文档内容索引到 PGVector
-
-    Args:
-        doc_id: 文档ID
-        kb_id: 知识库ID
-        content: 文档内容
-        chunk_id: 分块ID (可选)
-        metadata: 元数据
-
-    Returns:
-        是否成功
-    """
-    pool = await get_pg_pool()
-    if pool is None:
-        return False
-
-    # 分块处理(简单按长度分块)
-    chunks = []
-    chunk_size = 500
-    overlap = 50
-    if len(content) <= chunk_size:
-        chunks = [(content, chunk_id or f"{doc_id}_0")]
-    else:
-        for i in range(0, len(content), chunk_size - overlap):
-            chunk = content[i:i + chunk_size]
-            if len(chunk.strip()) > 10:
-                chunks.append((chunk, f"{doc_id}_{i // (chunk_size - overlap)}"))
-
-    success_count = 0
-    for chunk_text, cid in chunks:
-        embedding = await get_embedding(chunk_text)
-        if embedding is None:
-            continue
-
-        try:
-            async with pool.acquire() as conn:
-                embedding_str = f"[{','.join(str(x) for x in embedding)}]"
-                import json
-                await conn.execute(
-                    """
-                    INSERT INTO document_embeddings (doc_id, kb_id, chunk_id, content, embedding, metadata)
-                    VALUES ($1, $2, $3, $4, $5::vector, $6)
-                    ON CONFLICT DO NOTHING;
-                    """,
-                    doc_id, kb_id, cid, chunk_text, embedding_str,
-                    json.dumps(metadata or {}),
-                )
-                success_count += 1
-        except Exception as e:
-            logger.error(f"Failed to index chunk {cid}: {e}")
-
-    logger.info(f"Indexed document {doc_id}: {success_count}/{len(chunks)} chunks")
-    return success_count > 0
-
-
-async def hybrid_search(
-    query: str,
-    kb_id: Optional[str] = None,
-    top_k: int = 5,
-    bm25_results: Optional[List[Dict]] = None,
-) -> List[Dict]:
-    """
-    混合检索:BM25 + 向量检索融合
-
-    Args:
-        query: 查询文本
-        kb_id: 知识库ID
-        top_k: 返回结果数
-        bm25_results: BM25 检索结果(来自MySQL)
-
-    Returns:
-        融合后的检索结果
-    """
-    # 获取向量检索结果
-    vec_results = await vector_search(query, kb_id, top_k)
-
-    if not vec_results and not bm25_results:
-        return []
-
-    if not vec_results:
-        return bm25_results or []
-
-    if not bm25_results:
-        return vec_results
-
-    # 融合:Reciprocal Rank Fusion (RRF)
-    rrf_k = 60
-    score_map = {}
-
-    for rank, item in enumerate(bm25_results):
-        doc_id = item.get("doc_id", item.get("id", ""))
-        score_map[doc_id] = {
-            "content": item.get("content", item.get("text", "")),
-            "score": 1.0 / (rrf_k + rank + 1),
-            "bm25_rank": rank + 1,
-            "metadata": item.get("metadata", {}),
-        }
-
-    for rank, item in enumerate(vec_results):
-        doc_id = item["doc_id"]
-        if doc_id in score_map:
-            score_map[doc_id]["score"] += 1.0 / (rrf_k + rank + 1)
-            score_map[doc_id]["vec_score"] = item["score"]
-            score_map[doc_id]["vec_rank"] = rank + 1
-        else:
-            score_map[doc_id] = {
-                "content": item["content"],
-                "score": 1.0 / (rrf_k + rank + 1),
-                "vec_score": item["score"],
-                "vec_rank": rank + 1,
-                "metadata": item.get("metadata", {}),
-            }
-
-    # 按融合分数排序
-    merged = sorted(score_map.values(), key=lambda x: x["score"], reverse=True)
-    return merged[:top_k]
-
-
-async def delete_document_embeddings(doc_id: str) -> bool:
-    """删除文档的所有向量索引"""
-    pool = await get_pg_pool()
-    if pool is None:
-        return False
-
-    try:
-        async with pool.acquire() as conn:
-            await conn.execute(
-                "DELETE FROM document_embeddings WHERE doc_id = $1;",
-                doc_id,
-            )
-        logger.info(f"Deleted embeddings for document {doc_id}")
-        return True
-    except Exception as e:
-        logger.error(f"Failed to delete embeddings: {e}")
-        return False
-
-
-async def close_pool():
-    """关闭连接池"""
-    global _pg_pool
-    if _pg_pool is not None:
-        await _pg_pool.close()
-        _pg_pool = None
-        logger.info("PostgreSQL connection pool closed")
+"""
+PGVector 向量检索模块
+====================
+使用 PostgreSQL + pgvector 实现语义向量检索,
+与现有 MySQL BM25 检索配合,实现混合检索。
+
+使用方式:
+    from app.pgvector_search import vector_search
+    results = await vector_search(query, kb_id, top_k=5)
+"""
+
+import os
+import logging
+import asyncio
+from typing import List, Dict, Optional
+from datetime import datetime
+
+logger = logging.getLogger("aiflow.pgvector")
+
+# PostgreSQL 连接配置
+PG_HOST = os.environ.get("POSTGRES_HOST", "qihu-aiflow-pgvector")
+PG_PORT = int(os.environ.get("POSTGRES_PORT", "5432"))
+PG_USER = os.environ.get("POSTGRES_USER", "aiflow")
+PG_PASSWORD = os.environ.get("POSTGRES_PASSWORD", "pwd2026")
+PG_DB = os.environ.get("POSTGRES_DB", "aiflow_vectors")
+
+# 嵌入模型配置
+EMBEDDING_API_URL = os.environ.get("SILICONFLOW_BASE_URL", "https://api.siliconflow.cn")
+EMBEDDING_API_KEY = os.environ.get("SILICONFLOW_API_KEY", "")
+EMBEDDING_MODEL = os.environ.get("EMBEDDING_MODEL", "BAAI/bge-large-zh-v1.5")
+
+# ---- 本地嵌入缓存(SQLite 持久化):破解"每次嵌入消耗外部 API 额度"的隐性成本障碍 ----
+import sqlite3, hashlib, json as _json
+_EMBED_CACHE_DB = os.environ.get("EMBED_CACHE_DB", "/app/backend/python/.embed_cache.db")
+def _embed_cache_get(text, model):
+    try:
+        key = hashlib.sha256(f"{model}::{text}".encode("utf-8")).hexdigest()
+        conn = sqlite3.connect(_EMBED_CACHE_DB, timeout=5)
+        row = conn.execute("SELECT vec FROM embed_cache WHERE k=?", (key,)).fetchone()
+        conn.close()
+        if row:
+            return _json.loads(row[0])
+    except Exception:
+        return None
+    return None
+def _embed_cache_put(text, model, vec):
+    try:
+        key = hashlib.sha256(f"{model}::{text}".encode("utf-8")).hexdigest()
+        conn = sqlite3.connect(_EMBED_CACHE_DB, timeout=5)
+        conn.execute("CREATE TABLE IF NOT EXISTS embed_cache (k TEXT PRIMARY KEY, vec TEXT, ts TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
+        conn.execute("INSERT OR REPLACE INTO embed_cache (k, vec) VALUES (?, ?)", (key, _json.dumps(vec)))
+        conn.commit()
+        conn.close()
+    except Exception:
+        pass
+
+
+# 连接池
+_pg_pool = None
+_pool_lock = asyncio.Lock()
+
+
+async def get_pg_pool():
+    """获取 PostgreSQL 异步连接池"""
+    global _pg_pool
+    if _pg_pool is not None:
+        return _pg_pool
+
+    async with _pool_lock:
+        if _pg_pool is not None:
+            return _pg_pool
+
+        try:
+            import asyncpg
+        except ImportError:
+            logger.warning("asyncpg not installed, vector search disabled")
+            return None
+
+        try:
+            _pg_pool = await asyncpg.create_pool(
+                host=PG_HOST,
+                port=PG_PORT,
+                user=PG_USER,
+                password=PG_PASSWORD,
+                database=PG_DB,
+                min_size=2,
+                max_size=10,
+                command_timeout=30,
+            )
+            logger.info(f"PostgreSQL connection pool created: {PG_HOST}:{PG_PORT}/{PG_DB}")
+
+            # 初始化表结构
+            async with _pg_pool.acquire() as conn:
+                await conn.execute("""
+                    CREATE TABLE IF NOT EXISTS document_embeddings (
+                        id SERIAL PRIMARY KEY,
+                        doc_id VARCHAR(64) NOT NULL,
+                        kb_id VARCHAR(64) NOT NULL,
+                        chunk_id VARCHAR(64),
+                        content TEXT NOT NULL,
+                        embedding vector(1024),
+                        metadata JSONB DEFAULT '{}',
+                        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+                    );
+                """)
+                await conn.execute("""
+                    CREATE INDEX IF NOT EXISTS idx_embeddings_kb 
+                    ON document_embeddings(kb_id);
+                """)
+                await conn.execute("""
+                    CREATE INDEX IF NOT EXISTS idx_embeddings_doc 
+                    ON document_embeddings(doc_id);
+                """)
+                await conn.execute("""
+                    CREATE INDEX IF NOT EXISTS idx_embeddings_vector 
+                    ON document_embeddings USING ivfflat (embedding vector_cosine_ops)
+                    WITH (lists = 100);
+                """)
+                logger.info("PGVector tables initialized")
+
+            return _pg_pool
+        except Exception as e:
+            logger.error(f"Failed to create PostgreSQL pool: {e}")
+            _pg_pool = None
+            return None
+
+
+async def get_embedding(text: str) -> Optional[List[float]]:
+    """调用嵌入API获取文本向量"""
+    if not EMBEDDING_API_KEY:
+        logger.warning("No embedding API key configured")
+        return None
+
+    cached = _embed_cache_get(text, EMBEDDING_MODEL)
+    if cached:
+        return cached
+    try:
+        # 收敛到共享标准模型层(app.core.llm_client.aembed),与聊天共用代理/超时/failover 语义
+        from app.core.llm_client import aembed
+        embedding = await aembed(
+            EMBEDDING_API_URL, EMBEDDING_API_KEY, text[:8000], EMBEDDING_MODEL, timeout=30
+        )
+        if embedding:
+            _embed_cache_put(text, EMBEDDING_MODEL, embedding)
+            return embedding
+        logger.error("Embedding API returned empty")
+        return None
+    except Exception as e:
+        logger.error(f"Embedding generation failed: {e}")
+        return None
+
+
+async def vector_search(
+    query: str,
+    kb_id: Optional[str] = None,
+    top_k: int = 5,
+    threshold: float = 0.5,
+) -> List[Dict]:
+    """
+    向量语义检索
+
+    Args:
+        query: 查询文本
+        kb_id: 知识库ID (可选,不指定则搜索全部)
+        top_k: 返回结果数
+        threshold: 相似度阈值 (0-1)
+
+    Returns:
+        [{"doc_id": ..., "content": ..., "score": ..., "metadata": ...}, ...]
+    """
+    pool = await get_pg_pool()
+    if pool is None:
+        return []
+
+    # 获取查询向量
+    query_embedding = await get_embedding(query)
+    if query_embedding is None:
+        return []
+
+    try:
+        async with pool.acquire() as conn:
+            # 将向量转换为 PostgreSQL 格式
+            embedding_str = f"[{','.join(str(x) for x in query_embedding)}]"
+
+            if kb_id:
+                rows = await conn.fetch(
+                    """
+                    SELECT doc_id, chunk_id, content, metadata,
+                           1 - (embedding <=> $1::vector) as score
+                    FROM document_embeddings
+                    WHERE kb_id = $2
+                    ORDER BY embedding <=> $1::vector
+                    LIMIT $3;
+                    """,
+                    embedding_str, kb_id, top_k,
+                )
+            else:
+                rows = await conn.fetch(
+                    """
+                    SELECT doc_id, chunk_id, content, metadata,
+                           1 - (embedding <=> $1::vector) as score
+                    FROM document_embeddings
+                    ORDER BY embedding <=> $1::vector
+                    LIMIT $2;
+                    """,
+                    embedding_str, top_k,
+                )
+
+            results = []
+            for row in rows:
+                score = float(row["score"])
+                if score >= threshold:
+                    results.append({
+                        "doc_id": row["doc_id"],
+                        "chunk_id": row["chunk_id"],
+                        "content": row["content"],
+                        "score": score,
+                        "metadata": row["metadata"] if isinstance(row["metadata"], dict) else {},
+                    })
+
+            return results
+
+    except Exception as e:
+        logger.error(f"Vector search failed: {e}")
+        return []
+
+
+async def index_document(
+    doc_id: str,
+    kb_id: str,
+    content: str,
+    chunk_id: Optional[str] = None,
+    metadata: Optional[Dict] = None,
+) -> bool:
+    """
+    将文档内容索引到 PGVector
+
+    Args:
+        doc_id: 文档ID
+        kb_id: 知识库ID
+        content: 文档内容
+        chunk_id: 分块ID (可选)
+        metadata: 元数据
+
+    Returns:
+        是否成功
+    """
+    pool = await get_pg_pool()
+    if pool is None:
+        return False
+
+    # 分块处理(简单按长度分块)
+    chunks = []
+    chunk_size = 500
+    overlap = 50
+    if len(content) <= chunk_size:
+        chunks = [(content, chunk_id or f"{doc_id}_0")]
+    else:
+        for i in range(0, len(content), chunk_size - overlap):
+            chunk = content[i:i + chunk_size]
+            if len(chunk.strip()) > 10:
+                chunks.append((chunk, f"{doc_id}_{i // (chunk_size - overlap)}"))
+
+    success_count = 0
+    for chunk_text, cid in chunks:
+        embedding = await get_embedding(chunk_text)
+        if embedding is None:
+            continue
+
+        try:
+            async with pool.acquire() as conn:
+                embedding_str = f"[{','.join(str(x) for x in embedding)}]"
+                import json
+                await conn.execute(
+                    """
+                    INSERT INTO document_embeddings (doc_id, kb_id, chunk_id, content, embedding, metadata)
+                    VALUES ($1, $2, $3, $4, $5::vector, $6)
+                    ON CONFLICT DO NOTHING;
+                    """,
+                    doc_id, kb_id, cid, chunk_text, embedding_str,
+                    json.dumps(metadata or {}),
+                )
+                success_count += 1
+        except Exception as e:
+            logger.error(f"Failed to index chunk {cid}: {e}")
+
+    logger.info(f"Indexed document {doc_id}: {success_count}/{len(chunks)} chunks")
+    return success_count > 0
+
+
+async def hybrid_search(
+    query: str,
+    kb_id: Optional[str] = None,
+    top_k: int = 5,
+    bm25_results: Optional[List[Dict]] = None,
+) -> List[Dict]:
+    """
+    混合检索:BM25 + 向量检索融合
+
+    Args:
+        query: 查询文本
+        kb_id: 知识库ID
+        top_k: 返回结果数
+        bm25_results: BM25 检索结果(来自MySQL)
+
+    Returns:
+        融合后的检索结果
+    """
+    # 获取向量检索结果
+    vec_results = await vector_search(query, kb_id, top_k)
+
+    if not vec_results and not bm25_results:
+        return []
+
+    if not vec_results:
+        return bm25_results or []
+
+    if not bm25_results:
+        return vec_results
+
+    # 融合:Reciprocal Rank Fusion (RRF)
+    rrf_k = 60
+    score_map = {}
+
+    for rank, item in enumerate(bm25_results):
+        doc_id = item.get("doc_id", item.get("id", ""))
+        score_map[doc_id] = {
+            "content": item.get("content", item.get("text", "")),
+            "score": 1.0 / (rrf_k + rank + 1),
+            "bm25_rank": rank + 1,
+            "metadata": item.get("metadata", {}),
+        }
+
+    for rank, item in enumerate(vec_results):
+        doc_id = item["doc_id"]
+        if doc_id in score_map:
+            score_map[doc_id]["score"] += 1.0 / (rrf_k + rank + 1)
+            score_map[doc_id]["vec_score"] = item["score"]
+            score_map[doc_id]["vec_rank"] = rank + 1
+        else:
+            score_map[doc_id] = {
+                "content": item["content"],
+                "score": 1.0 / (rrf_k + rank + 1),
+                "vec_score": item["score"],
+                "vec_rank": rank + 1,
+                "metadata": item.get("metadata", {}),
+            }
+
+    # 按融合分数排序
+    merged = sorted(score_map.values(), key=lambda x: x["score"], reverse=True)
+    return merged[:top_k]
+
+
+async def delete_document_embeddings(doc_id: str) -> bool:
+    """删除文档的所有向量索引"""
+    pool = await get_pg_pool()
+    if pool is None:
+        return False
+
+    try:
+        async with pool.acquire() as conn:
+            await conn.execute(
+                "DELETE FROM document_embeddings WHERE doc_id = $1;",
+                doc_id,
+            )
+        logger.info(f"Deleted embeddings for document {doc_id}")
+        return True
+    except Exception as e:
+        logger.error(f"Failed to delete embeddings: {e}")
+        return False
+
+
+async def close_pool():
+    """关闭连接池"""
+    global _pg_pool
+    if _pg_pool is not None:
+        await _pg_pool.close()
+        _pg_pool = None
+        logger.info("PostgreSQL connection pool closed")
diff --git a/backend/python/main.py b/backend/python/main.py
index 2809134..890087e 100644
--- a/backend/python/main.py
+++ b/backend/python/main.py
@@ -1,250 +1,250 @@
-"""
-Qihu-AIFlow FastAPI 主应用
-完整后端API,监听18080端口,所有接口以 /app 为前缀
-"""
-import sys
-import os
-import time
-import uuid
-import logging
-from contextlib import asynccontextmanager
-from fastapi import FastAPI, Request
-from fastapi.middleware.cors import CORSMiddleware
-from starlette.middleware.base import BaseHTTPMiddleware
-from datetime import datetime
-
-sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
-
-from app.api import auth, chat, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
-from app.casdoor_auth import router as casdoor_router
-from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin, tenant_admin, billing_admin, analytics_admin, observability_admin
-from app.api.im_bot_user import router as im_bot_user_router
-from app.api.im_bot_platform import router as im_bot_platform_router
-from app.api.portal import user_portal
-from app.api.admin.developer_tools_admin import router as developer_tools_router
-from app.api.admin.mcp_market_admin import router as mcp_market_router
-from app.api.admin import asset_admin, achievement_admin
-from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
-from app.core.observability import set_trace_id
-from app.database import SessionLocal
-from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel
-
-# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1)
-import app.platform_adapters  # noqa: F401 - 触发所有平台适配器装饰器注册
-
-# 初始化文件日志(按天轮转)
-access_logger = setup_file_logging()
-logger = logging.getLogger("aiflow")
-
-
-class APITrackingMiddleware(BaseHTTPMiddleware):
-    """API调用统计中间件 - 记录请求耗时,标记慢请求"""
-
-    async def dispatch(self, request: Request, call_next):
-        request_id = str(uuid.uuid4())
-        set_trace_id(request_id)
-        start_time = time.time()
-        path = request.url.path
-        method = request.method
-
-        # 跳过健康检查和静态资源
-        skip_paths = ["/app/api/health", "/favicon.ico"]
-        should_skip = any(path.startswith(p) for p in skip_paths)
-
-        try:
-            response = await call_next(request)
-            elapsed = time.time() - start_time
-            status_code = response.status_code
-            response.headers["X-Trace-Id"] = request_id
-
-            if not should_skip:
-                access_logger.info(
-                    f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)"
-                )
-
-                # 异步记录API调用统计
-                try:
-                    db = SessionLocal()
-                    try:
-                        _record_api_call(db, method, path, status_code, elapsed)
-                    finally:
-                        db.close()
-                except Exception as e:
-                    logger.warning(f"API统计记录失败: {e}")
-
-            return response
-        except Exception as e:
-            elapsed = time.time() - start_time
-            if not should_skip:
-                access_logger.info(
-                    f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}"
-                )
-            raise
-
-    async def __call__(self, scope, receive, send):
-        if scope["type"] != "http":
-            await self.app(scope, receive, send)
-            return
-        await super().__call__(scope, receive, send)
-
-
-def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float):
-    """记录API调用到数据库统计表"""
-    # 简化路径以减少基数(去掉动态ID)
-    parts = path.split("/")
-    simplified_parts = []
-    for part in parts:
-        # 将UUID样式的路径段替换为{id}
-        if len(part) == 36 and part.count("-") == 4:
-            simplified_parts.append("{id}")
-        # 将纯数字路径段替换为{id}
-        elif part.isdigit():
-            simplified_parts.append("{id}")
-        else:
-            simplified_parts.append(part)
-    simplified_path = "/".join(simplified_parts)
-
-    # 统计记录通过数据库实现,简化版记录到日志
-    is_error = status_code >= 400
-    is_slow = elapsed >= SLOW_REQUEST_THRESHOLD
-
-    if is_slow:
-        logger.warning(
-            f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)"
-        )
-
-
-@asynccontextmanager
-async def lifespan(app: FastAPI):
-    logger.info("Qihu-AIFlow v2.0.0 启动中...")
-
-    # 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行)
-    try:
-        from app.services.evolution_service import EvolutionWatcherManager
-        from app.models import UserEvolutionConfig
-        db = SessionLocal()
-        try:
-            configs = db.query(UserEvolutionConfig).filter(
-                UserEvolutionConfig.watcher_status == "running"
-            ).all()
-            if configs:
-                watcher = EvolutionWatcherManager()
-                for cfg in configs:
-                    try:
-                        watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal())
-                        logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline")
-                    except Exception as e:
-                        logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}")
-                logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline")
-            else:
-                logger.info("[Evolution] 无待恢复的进化配置")
-        finally:
-            db.close()
-    except Exception as e:
-        logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}")
-
-    # 启动Redis事件总线
-    try:
-        from app.event_bus import get_event_bus
-        event_bus = get_event_bus()
-        event_bus.start(num_consumers=2)
-        logger.info("[EventBus] Redis事件总线已启动")
-    except Exception as e:
-        logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}")
-
-    logger.info("Qihu-AIFlow v2.0.0 启动完成")
-
-    yield
-
-    # 停止Redis事件总线
-    try:
-        from app.event_bus import get_event_bus
-        get_event_bus().stop()
-        logger.info("[EventBus] Redis事件总线已停止")
-    except Exception:
-        pass
-
-    # 优雅关闭:停止所有 Evolution Pipeline
-    try:
-        from app.services.evolution_service import EvolutionWatcherManager
-        EvolutionWatcherManager().shutdown_all()
-        logger.info("[Evolution] 所有Pipeline已停止")
-    except Exception:
-        pass
-
-    logger.info("Qihu-AIFlow 正在关闭...")
-
-
-app = FastAPI(
-    title="Qihu-AIFlow - 智能自动化PaaS平台",
-    version="1.0.0",
-    lifespan=lifespan,
-)
-
-# API调用追踪中间件
-app.add_middleware(APITrackingMiddleware)
-
-# CORS配置
-app.add_middleware(
-    CORSMiddleware,
-    allow_origins=["*"],
-    allow_credentials=True,
-    allow_methods=["*"],
-    allow_headers=["*"],
-)
-
-# 前台 API
-app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"])
-app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"])
-app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"])
-app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"])
-app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"])
-app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"])
-app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"])
-app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"])
-app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"])
-app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"])
-
-# 后台 API
-app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"])
-app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"])
-app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"])
-app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"])
-app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"])
-app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"])
-app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"])
-app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"])
-app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"])
-app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"])
-
-app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"])
-app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"])
-app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"])
-app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", tags=["后台-工作流编排"])
-app.include_router(asset_admin.router, prefix="/app/api/admin/assets", tags=["后台-资产管理"])
-app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"])
-app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"])
-app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"])
-app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"])
-app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"])
-app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"])
-app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"])
-app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"])
-app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"])
-app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"])
-app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"])
-app.include_router(im_bot_user_router, prefix="/app/api/im/wechat", tags=["im-wechat-user"])
-app.include_router(im_bot_platform_router, prefix="/app/api/im/platform", tags=["im-platform-user"])
-app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"])
-app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"])
-app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"])
-app.include_router(analytics_admin.router, prefix="/app/api/admin/analytics", tags=["analytics"])
-app.include_router(observability_admin.router, prefix="/app/api/admin/observability", tags=["后台-调用可观测"])
-app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"])
-app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
-app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"])
-
-
-@app.get("/app/api/health")
-async def health_check():
+"""
+Qihu-AIFlow FastAPI 主应用
+完整后端API,监听18080端口,所有接口以 /app 为前缀
+"""
+import sys
+import os
+import time
+import uuid
+import logging
+from contextlib import asynccontextmanager
+from fastapi import FastAPI, Request
+from fastapi.middleware.cors import CORSMiddleware
+from starlette.middleware.base import BaseHTTPMiddleware
+from datetime import datetime
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from app.api import auth, chat, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
+from app.casdoor_auth import router as casdoor_router
+from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin, tenant_admin, billing_admin, analytics_admin, observability_admin
+from app.api.im_bot_user import router as im_bot_user_router
+from app.api.im_bot_platform import router as im_bot_platform_router
+from app.api.portal import user_portal
+from app.api.admin.developer_tools_admin import router as developer_tools_router
+from app.api.admin.mcp_market_admin import router as mcp_market_router
+from app.api.admin import asset_admin, achievement_admin
+from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
+from app.core.observability import set_trace_id
+from app.database import SessionLocal
+from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel
+
+# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1)
+import app.platform_adapters  # noqa: F401 - 触发所有平台适配器装饰器注册
+
+# 初始化文件日志(按天轮转)
+access_logger = setup_file_logging()
+logger = logging.getLogger("aiflow")
+
+
+class APITrackingMiddleware(BaseHTTPMiddleware):
+    """API调用统计中间件 - 记录请求耗时,标记慢请求"""
+
+    async def dispatch(self, request: Request, call_next):
+        request_id = str(uuid.uuid4())
+        set_trace_id(request_id)
+        start_time = time.time()
+        path = request.url.path
+        method = request.method
+
+        # 跳过健康检查和静态资源
+        skip_paths = ["/app/api/health", "/favicon.ico"]
+        should_skip = any(path.startswith(p) for p in skip_paths)
+
+        try:
+            response = await call_next(request)
+            elapsed = time.time() - start_time
+            status_code = response.status_code
+            response.headers["X-Trace-Id"] = request_id
+
+            if not should_skip:
+                access_logger.info(
+                    f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)"
+                )
+
+                # 异步记录API调用统计
+                try:
+                    db = SessionLocal()
+                    try:
+                        _record_api_call(db, method, path, status_code, elapsed)
+                    finally:
+                        db.close()
+                except Exception as e:
+                    logger.warning(f"API统计记录失败: {e}")
+
+            return response
+        except Exception as e:
+            elapsed = time.time() - start_time
+            if not should_skip:
+                access_logger.info(
+                    f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}"
+                )
+            raise
+
+    async def __call__(self, scope, receive, send):
+        if scope["type"] != "http":
+            await self.app(scope, receive, send)
+            return
+        await super().__call__(scope, receive, send)
+
+
+def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float):
+    """记录API调用到数据库统计表"""
+    # 简化路径以减少基数(去掉动态ID)
+    parts = path.split("/")
+    simplified_parts = []
+    for part in parts:
+        # 将UUID样式的路径段替换为{id}
+        if len(part) == 36 and part.count("-") == 4:
+            simplified_parts.append("{id}")
+        # 将纯数字路径段替换为{id}
+        elif part.isdigit():
+            simplified_parts.append("{id}")
+        else:
+            simplified_parts.append(part)
+    simplified_path = "/".join(simplified_parts)
+
+    # 统计记录通过数据库实现,简化版记录到日志
+    is_error = status_code >= 400
+    is_slow = elapsed >= SLOW_REQUEST_THRESHOLD
+
+    if is_slow:
+        logger.warning(
+            f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)"
+        )
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    logger.info("Qihu-AIFlow v2.0.0 启动中...")
+
+    # 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行)
+    try:
+        from app.services.evolution_service import EvolutionWatcherManager
+        from app.models import UserEvolutionConfig
+        db = SessionLocal()
+        try:
+            configs = db.query(UserEvolutionConfig).filter(
+                UserEvolutionConfig.watcher_status == "running"
+            ).all()
+            if configs:
+                watcher = EvolutionWatcherManager()
+                for cfg in configs:
+                    try:
+                        watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal())
+                        logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline")
+                    except Exception as e:
+                        logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}")
+                logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline")
+            else:
+                logger.info("[Evolution] 无待恢复的进化配置")
+        finally:
+            db.close()
+    except Exception as e:
+        logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}")
+
+    # 启动Redis事件总线
+    try:
+        from app.event_bus import get_event_bus
+        event_bus = get_event_bus()
+        event_bus.start(num_consumers=2)
+        logger.info("[EventBus] Redis事件总线已启动")
+    except Exception as e:
+        logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}")
+
+    logger.info("Qihu-AIFlow v2.0.0 启动完成")
+
+    yield
+
+    # 停止Redis事件总线
+    try:
+        from app.event_bus import get_event_bus
+        get_event_bus().stop()
+        logger.info("[EventBus] Redis事件总线已停止")
+    except Exception:
+        pass
+
+    # 优雅关闭:停止所有 Evolution Pipeline
+    try:
+        from app.services.evolution_service import EvolutionWatcherManager
+        EvolutionWatcherManager().shutdown_all()
+        logger.info("[Evolution] 所有Pipeline已停止")
+    except Exception:
+        pass
+
+    logger.info("Qihu-AIFlow 正在关闭...")
+
+
+app = FastAPI(
+    title="Qihu-AIFlow - 智能自动化PaaS平台",
+    version="1.0.0",
+    lifespan=lifespan,
+)
+
+# API调用追踪中间件
+app.add_middleware(APITrackingMiddleware)
+
+# CORS配置
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=["*"],
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+# 前台 API
+app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"])
+app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"])
+app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"])
+app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"])
+app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"])
+app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"])
+app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"])
+app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"])
+app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"])
+app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"])
+
+# 后台 API
+app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"])
+app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"])
+app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"])
+app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"])
+app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"])
+app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"])
+app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"])
+app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"])
+app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"])
+app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"])
+
+app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"])
+app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"])
+app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"])
+app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", tags=["后台-工作流编排"])
+app.include_router(asset_admin.router, prefix="/app/api/admin/assets", tags=["后台-资产管理"])
+app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"])
+app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"])
+app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"])
+app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"])
+app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"])
+app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"])
+app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"])
+app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"])
+app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"])
+app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"])
+app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"])
+app.include_router(im_bot_user_router, prefix="/app/api/im/wechat", tags=["im-wechat-user"])
+app.include_router(im_bot_platform_router, prefix="/app/api/im/platform", tags=["im-platform-user"])
+app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"])
+app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"])
+app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"])
+app.include_router(analytics_admin.router, prefix="/app/api/admin/analytics", tags=["analytics"])
+app.include_router(observability_admin.router, prefix="/app/api/admin/observability", tags=["后台-调用可观测"])
+app.include_router(user_portal.router, prefix="/app/api/portal", tags=["portal"])
+app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
+app.include_router(casdoor_router, prefix="/app/api/auth", tags=["Casdoor SSO"])
+
+
+@app.get("/app/api/health")
+async def health_check():
     return {"status": "ok", "service": "Qihu-AIFlow"}
\ No newline at end of file
diff --git a/backend/python/requirements.txt b/backend/python/requirements.txt
index 89db4b5..1f19771 100644
--- a/backend/python/requirements.txt
+++ b/backend/python/requirements.txt
@@ -1,30 +1,30 @@
-# AIFlow 后端依赖
-fastapi==0.115.6
-uvicorn==0.34.0
-sqlalchemy==2.0.36
-pymysql==1.1.1
-cryptography==44.0.0
-passlib[bcrypt]==1.7.4
-python-jose[cryptography]==3.3.0
-python-multipart==0.0.18
-redis==5.2.1
-pydantic==2.10.3
-pydantic-settings==2.7.0
-python-dotenv==1.0.1
-httpx==0.28.1
-aiofiles==24.1.0
-celery==5.4.0
-qrcode==8.2
-pillow==12.2.0
-xskill==0.5.2
-
-# ---- pinned celery ecosystem (known-good, extracted from running container) ----
-# 避免 pip 浮动解析到与 celery 5.4.0 冲突的新版传递依赖(如 click-repl 0.2.0 的 prompt-toolkit 冲突)
-amqp==5.3.1
-kombu==5.6.2
-billiard==4.2.4
-vine==5.1.0
-click==8.4.2
-click-didyoumean==0.3.1
-click-plugins==1.1.1.2
-click-repl==0.3.0
+# AIFlow 后端依赖
+fastapi==0.115.6
+uvicorn==0.34.0
+sqlalchemy==2.0.36
+pymysql==1.1.1
+cryptography==44.0.0
+passlib[bcrypt]==1.7.4
+python-jose[cryptography]==3.3.0
+python-multipart==0.0.18
+redis==5.2.1
+pydantic==2.10.3
+pydantic-settings==2.7.0
+python-dotenv==1.0.1
+httpx==0.28.1
+aiofiles==24.1.0
+celery==5.4.0
+qrcode==8.2
+pillow==12.2.0
+xskill==0.5.2
+
+# ---- pinned celery ecosystem (known-good, extracted from running container) ----
+# 避免 pip 浮动解析到与 celery 5.4.0 冲突的新版传递依赖(如 click-repl 0.2.0 的 prompt-toolkit 冲突)
+amqp==5.3.1
+kombu==5.6.2
+billiard==4.2.4
+vine==5.1.0
+click==8.4.2
+click-didyoumean==0.3.1
+click-plugins==1.1.1.2
+click-repl==0.3.0
diff --git a/design-system/MASTER.md b/design-system/MASTER.md
index 288c1b8..48532a9 100644
--- a/design-system/MASTER.md
+++ b/design-system/MASTER.md
@@ -1,206 +1,206 @@
-# Design System Master File
-
-> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
-> If that file exists, its rules **override** this Master file.
-> If not, strictly follow the rules below.
-
----
-
-**Project:** 七虎AI
-**Generated:** 2026-07-13 09:07:15
-**Category:** AI PaaS Platform / Developer Console
-
----
-
-## Global Rules
-
-### Color Palette
-
-| Role | Hex | CSS Variable |
-|------|-----|--------------|
-| Primary | `#5B5FED` | `--color-primary` |
-| Secondary | `#3B82F6` | `--color-secondary` |
-| CTA/Accent | `#5B5FED` | `--color-cta` |
-| Background | `#F8F9FB` | `--color-background` |
-| Text | `#101214` | `--color-text` |
-
-**Color Notes:** Indigo #5B5FED is the single brand hue. Success #219B5E / Warning #E8922C / Error #D9383A / Info #3B82F6 are semantic only. Do not introduce extra brand colors.
-
-### Typography
-
-- **Heading Font:** Inter
-- **Body Font:** Inter
-- **Mood:** neutral, precise, technical
-- **Google Fonts:** [Inter + Inter](https://fonts.google.com/share?selection.family=Inter:wght@300;400;500;600;700;800;900|JetBrains+Mono:wght@400;500;600)
-
-**CSS Import:**
-```css
-@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap');
-```
-
-### Spacing Variables
-
-| Token | Value | Usage |
-|-------|-------|-------|
-| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
-| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
-| `--space-md` | `16px` / `1rem` | Standard padding |
-| `--space-lg` | `24px` / `1.5rem` | Section padding |
-| `--space-xl` | `32px` / `2rem` | Large gaps |
-| `--space-2xl` | `48px` / `3rem` | Section margins |
-| `--space-3xl` | `64px` / `4rem` | Hero padding |
-
-### Shadow Depths
-
-| Level | Value | Usage |
-|-------|-------|-------|
-| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
-| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
-| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
-| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
-
----
-
-## Component Specs
-
-### Buttons
-
-```css
-/* Primary Button */
-.btn-primary {
-  background: #5B5FED;
-  color: white;
-  padding: 12px 24px;
-  border-radius: 8px;
-  font-weight: 600;
-  transition: all 200ms ease;
-  cursor: pointer;
-}
-
-.btn-primary:hover {
-  opacity: 0.9;
-  transform: translateY(-1px);
-}
-
-/* Secondary Button */
-.btn-secondary {
-  background: transparent;
-  color: #5B5FED;
-  border: 2px solid #5B5FED;
-  padding: 12px 24px;
-  border-radius: 8px;
-  font-weight: 600;
-  transition: all 200ms ease;
-  cursor: pointer;
-}
-```
-
-### Cards
-
-```css
-.card {
-  background: #F8F9FB;
-  border-radius: 12px;
-  padding: 24px;
-  box-shadow: var(--shadow-md);
-  transition: all 200ms ease;
-  cursor: pointer;
-}
-
-.card:hover {
-  box-shadow: var(--shadow-lg);
-  transform: translateY(-2px);
-}
-```
-
-### Inputs
-
-```css
-.input {
-  padding: 12px 16px;
-  border: 1px solid #E2E8F0;
-  border-radius: 8px;
-  font-size: 16px;
-  transition: border-color 200ms ease;
-}
-
-.input:focus {
-  border-color: #5B5FED;
-  outline: none;
-  box-shadow: 0 0 0 3px #5B5FED20;
-}
-```
-
-### Modals
-
-```css
-.modal-overlay {
-  background: rgba(0, 0, 0, 0.5);
-  backdrop-filter: blur(4px);
-}
-
-.modal {
-  background: white;
-  border-radius: 16px;
-  padding: 32px;
-  box-shadow: var(--shadow-xl);
-  max-width: 500px;
-  width: 90%;
-}
-```
-
----
-
-## Style Guidelines
-
-**Style:** Warm Tech Minimalism
-
-**Keywords:** clean, precise, data-dense, soft surfaces, indigo accent, generous whitespace
-
-**Best For:** AI PaaS consoles, developer platforms, agent/workflow editors
-
-**Key Effects:** Subtle hover lift (translateY -1 to -2px), 150-300ms ease transitions, soft shadow escalation, skeleton shimmer for loading, fade/slide micro-entrances.
-
-### Page Pattern
-
-**Pattern Name:** App Shell + Dashboard Grid
-
-- **Conversion Strategy:** Core value shown immediately via workspace/chat; primary action (新建对话/部署) persistent in header and sidebar.
-- **CTA Placement:** Sticky header + sidebar primary action; contextual CTAs inside cards.
-- **Section Order:** Sidebar > Workspace(Chat/Agents/Skills/MCP) > Main content grid > Detail drawer
-
----
-
-## Anti-Patterns (Do NOT Use)
-
-- ❌ Ornate decoration
-- ❌ Low contrast text
-- ❌ Missing focus states
-- ❌ Emojis as icons
-- ❌ Layout-shifting scale hovers
-- ❌ Instant state changes
-
-### Additional Forbidden Patterns
-
-- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
-- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
-- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
-- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
-- ❌ **Instant state changes** — Always use transitions (150-300ms)
-- ❌ **Invisible focus states** — Focus states must be visible for a11y
-
----
-
-## Pre-Delivery Checklist
-
-Before delivering any UI code, verify:
-
-- [ ] No emojis as icons (use SVG: Heroicons/Lucide)
-- [ ] cursor-pointer on all clickable elements
-- [ ] Hover states with smooth transitions (150-300ms)
-- [ ] Light mode: text contrast 4.5:1 minimum
-- [ ] Focus states visible for keyboard nav
-- [ ] prefers-reduced-motion respected
-- [ ] Responsive: 375px, 768px, 1024px, 1440px
-- [ ] No content hidden behind fixed navbars
-- [ ] No horizontal scroll on mobile
+# Design System Master File
+
+> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
+> If that file exists, its rules **override** this Master file.
+> If not, strictly follow the rules below.
+
+---
+
+**Project:** 七虎AI
+**Generated:** 2026-07-13 09:07:15
+**Category:** AI PaaS Platform / Developer Console
+
+---
+
+## Global Rules
+
+### Color Palette
+
+| Role | Hex | CSS Variable |
+|------|-----|--------------|
+| Primary | `#5B5FED` | `--color-primary` |
+| Secondary | `#3B82F6` | `--color-secondary` |
+| CTA/Accent | `#5B5FED` | `--color-cta` |
+| Background | `#F8F9FB` | `--color-background` |
+| Text | `#101214` | `--color-text` |
+
+**Color Notes:** Indigo #5B5FED is the single brand hue. Success #219B5E / Warning #E8922C / Error #D9383A / Info #3B82F6 are semantic only. Do not introduce extra brand colors.
+
+### Typography
+
+- **Heading Font:** Inter
+- **Body Font:** Inter
+- **Mood:** neutral, precise, technical
+- **Google Fonts:** [Inter + Inter](https://fonts.google.com/share?selection.family=Inter:wght@300;400;500;600;700;800;900|JetBrains+Mono:wght@400;500;600)
+
+**CSS Import:**
+```css
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600&display=swap');
+```
+
+### Spacing Variables
+
+| Token | Value | Usage |
+|-------|-------|-------|
+| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
+| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
+| `--space-md` | `16px` / `1rem` | Standard padding |
+| `--space-lg` | `24px` / `1.5rem` | Section padding |
+| `--space-xl` | `32px` / `2rem` | Large gaps |
+| `--space-2xl` | `48px` / `3rem` | Section margins |
+| `--space-3xl` | `64px` / `4rem` | Hero padding |
+
+### Shadow Depths
+
+| Level | Value | Usage |
+|-------|-------|-------|
+| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
+| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
+| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
+| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
+
+---
+
+## Component Specs
+
+### Buttons
+
+```css
+/* Primary Button */
+.btn-primary {
+  background: #5B5FED;
+  color: white;
+  padding: 12px 24px;
+  border-radius: 8px;
+  font-weight: 600;
+  transition: all 200ms ease;
+  cursor: pointer;
+}
+
+.btn-primary:hover {
+  opacity: 0.9;
+  transform: translateY(-1px);
+}
+
+/* Secondary Button */
+.btn-secondary {
+  background: transparent;
+  color: #5B5FED;
+  border: 2px solid #5B5FED;
+  padding: 12px 24px;
+  border-radius: 8px;
+  font-weight: 600;
+  transition: all 200ms ease;
+  cursor: pointer;
+}
+```
+
+### Cards
+
+```css
+.card {
+  background: #F8F9FB;
+  border-radius: 12px;
+  padding: 24px;
+  box-shadow: var(--shadow-md);
+  transition: all 200ms ease;
+  cursor: pointer;
+}
+
+.card:hover {
+  box-shadow: var(--shadow-lg);
+  transform: translateY(-2px);
+}
+```
+
+### Inputs
+
+```css
+.input {
+  padding: 12px 16px;
+  border: 1px solid #E2E8F0;
+  border-radius: 8px;
+  font-size: 16px;
+  transition: border-color 200ms ease;
+}
+
+.input:focus {
+  border-color: #5B5FED;
+  outline: none;
+  box-shadow: 0 0 0 3px #5B5FED20;
+}
+```
+
+### Modals
+
+```css
+.modal-overlay {
+  background: rgba(0, 0, 0, 0.5);
+  backdrop-filter: blur(4px);
+}
+
+.modal {
+  background: white;
+  border-radius: 16px;
+  padding: 32px;
+  box-shadow: var(--shadow-xl);
+  max-width: 500px;
+  width: 90%;
+}
+```
+
+---
+
+## Style Guidelines
+
+**Style:** Warm Tech Minimalism
+
+**Keywords:** clean, precise, data-dense, soft surfaces, indigo accent, generous whitespace
+
+**Best For:** AI PaaS consoles, developer platforms, agent/workflow editors
+
+**Key Effects:** Subtle hover lift (translateY -1 to -2px), 150-300ms ease transitions, soft shadow escalation, skeleton shimmer for loading, fade/slide micro-entrances.
+
+### Page Pattern
+
+**Pattern Name:** App Shell + Dashboard Grid
+
+- **Conversion Strategy:** Core value shown immediately via workspace/chat; primary action (新建对话/部署) persistent in header and sidebar.
+- **CTA Placement:** Sticky header + sidebar primary action; contextual CTAs inside cards.
+- **Section Order:** Sidebar > Workspace(Chat/Agents/Skills/MCP) > Main content grid > Detail drawer
+
+---
+
+## Anti-Patterns (Do NOT Use)
+
+- ❌ Ornate decoration
+- ❌ Low contrast text
+- ❌ Missing focus states
+- ❌ Emojis as icons
+- ❌ Layout-shifting scale hovers
+- ❌ Instant state changes
+
+### Additional Forbidden Patterns
+
+- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
+- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
+- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
+- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
+- ❌ **Instant state changes** — Always use transitions (150-300ms)
+- ❌ **Invisible focus states** — Focus states must be visible for a11y
+
+---
+
+## Pre-Delivery Checklist
+
+Before delivering any UI code, verify:
+
+- [ ] No emojis as icons (use SVG: Heroicons/Lucide)
+- [ ] cursor-pointer on all clickable elements
+- [ ] Hover states with smooth transitions (150-300ms)
+- [ ] Light mode: text contrast 4.5:1 minimum
+- [ ] Focus states visible for keyboard nav
+- [ ] prefers-reduced-motion respected
+- [ ] Responsive: 375px, 768px, 1024px, 1440px
+- [ ] No content hidden behind fixed navbars
+- [ ] No horizontal scroll on mobile
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 9de57b7..821d6bf 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,177 +1,177 @@
-import React, { useState, useEffect, Suspense } from 'react';
-import { Routes, Route, Navigate } from 'react-router-dom';
-import type { User } from '@/types';
-import { AuthContext, AuthContextType } from '@/contexts/AuthContext';
-import { authAPI } from '@/api';
-
-// Layouts(保留为即时加载,构成应用外壳)
-import FrontLayout from '@/components/layout/FrontLayout';
-import AdminLayout from '@/components/layout/AdminLayout';
-
-// 路由级代码分割:每个页面独立打包成 chunk,首屏只加载外壳与公共依赖,
-// 访问具体页面时才按需加载对应 chunk,显著加快首屏渲染与路由切换速度。
-const LandingPage = React.lazy(() => import('@/pages/front/LandingPage'));
-const LoginPage = React.lazy(() => import('@/pages/front/LoginPage'));
-const ChatPage = React.lazy(() => import('@/pages/front/ChatPage'));
-const KnowledgePage = React.lazy(() => import('@/pages/front/KnowledgePage'));
-const MarketPage = React.lazy(() => import('@/pages/front/MarketPage'));
-const TokenDashboardPage = React.lazy(() => import('@/pages/front/TokenDashboardPage'));
-const PricingPage = React.lazy(() => import('@/pages/front/PricingPage'));
-const ProfilePage = React.lazy(() => import('@/pages/front/ProfilePage'));
-const NotificationPage = React.lazy(() => import('@/pages/front/NotificationPage'));
-const AssetsPage = React.lazy(() => import('@/pages/front/AssetsPage'));
-const NovelPreviewPage = React.lazy(() => import('@/pages/front/NovelPreviewPage'));
-const EvolutionDashboard = React.lazy(() => import('@/pages/front/EvolutionDashboard'));
-
-const AdminDashboard = React.lazy(() => import('@/pages/admin/Dashboard'));
-const AdminUsers = React.lazy(() => import('@/pages/admin/Users'));
-const AdminModels = React.lazy(() => import('@/pages/admin/Models'));
-const AdminSiteSettings = React.lazy(() => import('@/pages/admin/SiteSettings'));
-const AdminSmsConfig = React.lazy(() => import('@/pages/admin/SmsConfig'));
-const AdminPayment = React.lazy(() => import('@/pages/admin/Payment'));
-const AdminServices = React.lazy(() => import('@/pages/admin/Services'));
-const AdminMembership = React.lazy(() => import('@/pages/admin/Membership'));
-const AdminAds = React.lazy(() => import('@/pages/admin/Ads'));
-const AdminMarket = React.lazy(() => import('@/pages/admin/Market'));
-const AdminLogs = React.lazy(() => import('@/pages/admin/Logs'));
-const AdminSkills = React.lazy(() => import('@/pages/admin/Skills'));
-const AdminAgents = React.lazy(() => import('@/pages/admin/Agents'));
-const AdminCodeExec = React.lazy(() => import('@/pages/admin/CodeExec'));
-const DistributionBuilder = React.lazy(() => import('@/pages/admin/DistributionBuilder'));
-const ImBots = React.lazy(() => import('@/pages/admin/ImBots'));
-const PrivacySettings = React.lazy(() => import('@/pages/admin/PrivacySettings'));
-const Promotion = React.lazy(() => import('@/pages/admin/Promotion'));
-const Achievement = React.lazy(() => import('@/pages/admin/Achievement'));
-const Tenant = React.lazy(() => import('@/pages/admin/Tenant'));
-const Billing = React.lazy(() => import('@/pages/admin/Billing'));
-const Analytics = React.lazy(() => import('@/pages/admin/Analytics'));
-const Observability = React.lazy(() => import('@/pages/admin/Observability'));
-const AdminKnowledge = React.lazy(() => import('@/pages/admin/Knowledge'));
-const AdminWorkflow = React.lazy(() => import('@/pages/admin/Workflow'));
-const AdminStorage = React.lazy(() => import('@/pages/admin/Storage'));
-const AdminDeveloper = React.lazy(() => import('@/pages/admin/Developer'));
-const AdminDeveloperTools = React.lazy(() => import('@/pages/admin/DeveloperTools'));
-const AdminAutomation = React.lazy(() => import('@/pages/admin/Automation'));
-const AdminMcp = React.lazy(() => import('@/pages/admin/Mcp'));
-const AdminApps = React.lazy(() => import('@/pages/admin/Apps'));
-const AssetCenter = React.lazy(() => import('@/pages/admin/AssetCenter'));
-const UserPortal = React.lazy(() => import('@/pages/UserPortal'));
-const WechatBot = React.lazy(() => import('@/pages/front/WechatBot'));
-const ImBotHub = React.lazy(() => import('@/pages/front/ImBotHub'));
-const AppServices = React.lazy(() => import('@/pages/front/AppServices'));
-
-const pageFallback = 
加载中...
; - -const App: React.FC = () => { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - - // 初始化:从localStorage恢复登录状态 - useEffect(() => { - const token = localStorage.getItem('aiflow_token'); - const cachedUser = localStorage.getItem('aiflow_user'); - if (token && cachedUser) { - try { - setUser(JSON.parse(cachedUser)); - } catch { - localStorage.removeItem('aiflow_token'); - localStorage.removeItem('aiflow_user'); - } - } - setLoading(false); - }, []); - - const login = (u: User) => { - setUser(u); - localStorage.setItem('aiflow_user', JSON.stringify(u)); - }; - const logout = () => { - setUser(null); - localStorage.removeItem('aiflow_token'); - localStorage.removeItem('aiflow_user'); - }; - const isAdmin = user?.role === 'admin'; - - if (loading) { - return
加载中...
; - } - - return ( - - - - {/* Login */} - : ) : - } /> - - {/* Root - Landing for guests, redirect for authenticated users */} - : ) : - } /> - - {/* Front Routes (authenticated only) */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - {/* Admin Routes */} - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - {/* 公开前台首页:不受登录态拦截,供后台「访问前台」直接打开 */} - } /> - } /> - - - - ); -}; - -export default App; +import React, { useState, useEffect, Suspense } from 'react'; +import { Routes, Route, Navigate } from 'react-router-dom'; +import type { User } from '@/types'; +import { AuthContext, AuthContextType } from '@/contexts/AuthContext'; +import { authAPI } from '@/api'; + +// Layouts(保留为即时加载,构成应用外壳) +import FrontLayout from '@/components/layout/FrontLayout'; +import AdminLayout from '@/components/layout/AdminLayout'; + +// 路由级代码分割:每个页面独立打包成 chunk,首屏只加载外壳与公共依赖, +// 访问具体页面时才按需加载对应 chunk,显著加快首屏渲染与路由切换速度。 +const LandingPage = React.lazy(() => import('@/pages/front/LandingPage')); +const LoginPage = React.lazy(() => import('@/pages/front/LoginPage')); +const ChatPage = React.lazy(() => import('@/pages/front/ChatPage')); +const KnowledgePage = React.lazy(() => import('@/pages/front/KnowledgePage')); +const MarketPage = React.lazy(() => import('@/pages/front/MarketPage')); +const TokenDashboardPage = React.lazy(() => import('@/pages/front/TokenDashboardPage')); +const PricingPage = React.lazy(() => import('@/pages/front/PricingPage')); +const ProfilePage = React.lazy(() => import('@/pages/front/ProfilePage')); +const NotificationPage = React.lazy(() => import('@/pages/front/NotificationPage')); +const AssetsPage = React.lazy(() => import('@/pages/front/AssetsPage')); +const NovelPreviewPage = React.lazy(() => import('@/pages/front/NovelPreviewPage')); +const EvolutionDashboard = React.lazy(() => import('@/pages/front/EvolutionDashboard')); + +const AdminDashboard = React.lazy(() => import('@/pages/admin/Dashboard')); +const AdminUsers = React.lazy(() => import('@/pages/admin/Users')); +const AdminModels = React.lazy(() => import('@/pages/admin/Models')); +const AdminSiteSettings = React.lazy(() => import('@/pages/admin/SiteSettings')); +const AdminSmsConfig = React.lazy(() => import('@/pages/admin/SmsConfig')); +const AdminPayment = React.lazy(() => import('@/pages/admin/Payment')); +const AdminServices = React.lazy(() => import('@/pages/admin/Services')); +const AdminMembership = React.lazy(() => import('@/pages/admin/Membership')); +const AdminAds = React.lazy(() => import('@/pages/admin/Ads')); +const AdminMarket = React.lazy(() => import('@/pages/admin/Market')); +const AdminLogs = React.lazy(() => import('@/pages/admin/Logs')); +const AdminSkills = React.lazy(() => import('@/pages/admin/Skills')); +const AdminAgents = React.lazy(() => import('@/pages/admin/Agents')); +const AdminCodeExec = React.lazy(() => import('@/pages/admin/CodeExec')); +const DistributionBuilder = React.lazy(() => import('@/pages/admin/DistributionBuilder')); +const ImBots = React.lazy(() => import('@/pages/admin/ImBots')); +const PrivacySettings = React.lazy(() => import('@/pages/admin/PrivacySettings')); +const Promotion = React.lazy(() => import('@/pages/admin/Promotion')); +const Achievement = React.lazy(() => import('@/pages/admin/Achievement')); +const Tenant = React.lazy(() => import('@/pages/admin/Tenant')); +const Billing = React.lazy(() => import('@/pages/admin/Billing')); +const Analytics = React.lazy(() => import('@/pages/admin/Analytics')); +const Observability = React.lazy(() => import('@/pages/admin/Observability')); +const AdminKnowledge = React.lazy(() => import('@/pages/admin/Knowledge')); +const AdminWorkflow = React.lazy(() => import('@/pages/admin/Workflow')); +const AdminStorage = React.lazy(() => import('@/pages/admin/Storage')); +const AdminDeveloper = React.lazy(() => import('@/pages/admin/Developer')); +const AdminDeveloperTools = React.lazy(() => import('@/pages/admin/DeveloperTools')); +const AdminAutomation = React.lazy(() => import('@/pages/admin/Automation')); +const AdminMcp = React.lazy(() => import('@/pages/admin/Mcp')); +const AdminApps = React.lazy(() => import('@/pages/admin/Apps')); +const AssetCenter = React.lazy(() => import('@/pages/admin/AssetCenter')); +const UserPortal = React.lazy(() => import('@/pages/UserPortal')); +const WechatBot = React.lazy(() => import('@/pages/front/WechatBot')); +const ImBotHub = React.lazy(() => import('@/pages/front/ImBotHub')); +const AppServices = React.lazy(() => import('@/pages/front/AppServices')); + +const pageFallback =
加载中...
; + +const App: React.FC = () => { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + // 初始化:从localStorage恢复登录状态 + useEffect(() => { + const token = localStorage.getItem('aiflow_token'); + const cachedUser = localStorage.getItem('aiflow_user'); + if (token && cachedUser) { + try { + setUser(JSON.parse(cachedUser)); + } catch { + localStorage.removeItem('aiflow_token'); + localStorage.removeItem('aiflow_user'); + } + } + setLoading(false); + }, []); + + const login = (u: User) => { + setUser(u); + localStorage.setItem('aiflow_user', JSON.stringify(u)); + }; + const logout = () => { + setUser(null); + localStorage.removeItem('aiflow_token'); + localStorage.removeItem('aiflow_user'); + }; + const isAdmin = user?.role === 'admin'; + + if (loading) { + return
加载中...
; + } + + return ( + + + + {/* Login */} + : ) : + } /> + + {/* Root - Landing for guests, redirect for authenticated users */} + : ) : + } /> + + {/* Front Routes (authenticated only) */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* Admin Routes */} + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* 公开前台首页:不受登录态拦截,供后台「访问前台」直接打开 */} + } /> + } /> + + + + ); +}; + +export default App; diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 8c64c6f..ee54ec2 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,2010 +1,2010 @@ -/** - - * AIFlow API 客户端 - - * 统一管理所有前后端API请求 - - */ - -const API_BASE = '/app/api'; - - - -// 从localStorage获取token - -function getToken(): string | null { - - return localStorage.getItem('aiflow_token'); - -} - - - -// 通用请求方法 - -export async function request( - - endpoint: string, - - options: RequestInit = {} - -): Promise<{ code: number; data: T; message: string }> { - - const token = getToken(); - - const headers: Record = { - - 'Content-Type': 'application/json', - - ...(options.headers as Record || {}), - - }; - - if (token) { - - headers['Authorization'] = `Bearer ${token}`; - - } - - const res = await fetch(`${API_BASE}${endpoint}`, { - - ...options, - - headers, - - }); - - if (res.status === 401) { - - // 登录接口的401需要精确提示(用户名不存在 / 密码错误) - - if (endpoint === '/auth/login') { - - const err = await res.json().catch(() => ({ detail: '登录失败' })); - - const error: any = new Error(err.detail || '登录失败'); - - error.status = 401; - - error.detail = err.detail; - - throw error; - - } - - // 其他接口的401 = token过期/未登录,清除状态并跳转 - - localStorage.removeItem('aiflow_token'); - - localStorage.removeItem('aiflow_user'); - - window.location.href = '/login'; - - throw new Error('未登录'); - - } - - if (res.status === 403) { - const err = await res.json().catch(() => ({ detail: 'Forbidden' })); - if (err.detail === 'Not authenticated' || err.detail === '需要管理员权限') { - localStorage.removeItem('aiflow_token'); - localStorage.removeItem('aiflow_user'); - window.location.href = '/login'; - throw new Error('未登录或权限不足'); - } - } - - if (!res.ok) { - - const err = await res.json().catch(() => ({ detail: '请求失败' })); - - const error: any = new Error(err.detail || err.message || '请求失败'); - - error.status = res.status; - - error.response = { status: res.status, data: err }; - - error.detail = err.detail; - - throw error; - - } - - return res.json(); - -} - - - -// GET请求 - -function get(endpoint: string): Promise<{ code: number; data: T; message: string }> { - - return request(endpoint); - -} - - - -// POST请求 - -function post(endpoint: string, body?: any): Promise<{ code: number; data: T; message: string }> { - - return request(endpoint, { - - method: 'POST', - - body: body ? JSON.stringify(body) : undefined, - - }); - -} - - - -// PUT请求 - -function put(endpoint: string, body?: any): Promise<{ code: number; data: T; message: string }> { - - return request(endpoint, { - - method: 'PUT', - - body: body ? JSON.stringify(body) : undefined, - - }); - -} - - - -// DELETE请求 - -function del(endpoint: string): Promise<{ code: number; data: T; message: string }> { - - return request(endpoint, { method: 'DELETE' }); - -} - - - -// ============================================================ - -// 认证相关API - -// ============================================================ - -export const authAPI = { - - login: (username: string, password: string) => - - post('/auth/login', { username, password }), - - register: (data: { username: string; password: string; email?: string; phone?: string }) => - - post('/auth/register', data), - - getMe: () => get('/auth/me'), - - logout: () => post('/auth/logout'), - - getSettings: () => get('/auth/settings'), - - sendCode: (phone: string, purpose: string) => - post('/auth/send-code', { phone, purpose }), - - resetPassword: (data: { phone: string; code: string; new_password: string }) => - post('/auth/reset-password', data), - -}; - - - -// ============================================================ - -// 聊天相关API - -// ============================================================ - -export const chatAPI = { - - getConversations: (status?: string) => - - get(`/chat/conversations${status ? `?status=${status}` : ''}`), - - createConversation: (data: { title: string; model_id?: string }) => - - post('/chat/conversations', data), - - updateConversation: (id: string, data: any) => - - put(`/chat/conversations/${id}`, data), - - deleteConversation: (id: string) => - - del(`/chat/conversations/${id}`), - - getMessages: (convId: string) => - - get(`/chat/conversations/${convId}/messages`), - - sendMessage: (convId: string, data: { content: string; model_id?: string; agent_ids?: string[]; skill_ids?: string[]; knowledge_base_ids?: string[]; use_tools?: boolean }) => - - post(`/chat/conversations/${convId}/messages`, data), - - clearContext: (convId: string) => - - post(`/chat/conversations/${convId}/clear`), - - convertToKnowledgeGraph: (convId: string) => - - post(`/chat/conversations/${convId}/to-knowledge-graph/`), - - getModels: () => get('/chat/models'), - -}; - - - - - - -// ============================================================ - -// 知识库相关API - -// ============================================================ - -export const knowledgeAPI = { - - getBases: () => get('/knowledge/bases'), - - createBase: (data: any) => post('/knowledge/bases', data), - - getBase: (id: string) => get(`/knowledge/bases/${id}`), - - updateBase: (id: string, data: any) => put(`/knowledge/bases/${id}`, data), - - deleteBase: (id: string) => del(`/knowledge/bases/${id}`), - - getDocuments: (baseId: string) => get(`/knowledge/bases/${baseId}/documents`), - - getDocument: (baseId: string, docId: string) => get(`/knowledge/bases/${baseId}/documents/${docId}`), - - uploadDocument: (baseId: string, data: any) => post(`/knowledge/bases/${baseId}/documents`, data), - - deleteDocument: (baseId: string, docId: string) => del(`/knowledge/bases/${baseId}/documents/${docId}`), - - getDirectories: () => get('/knowledge/directories'), - - createDirectory: (data: any) => post('/knowledge/directories', data), - - buildGraph: (baseId: string) => post(`/knowledge/bases/${baseId}/build-graph`), - - getGraph: (baseId: string) => get(`/knowledge/bases/${baseId}/graph`), - - searchQA: (baseId: string, question: string) => post('/knowledge/qa', { base_id: baseId, question }), - - searchDocuments: (baseId: string, keyword: string) => get(`/knowledge/bases/${baseId}/search?keyword=${encodeURIComponent(keyword)}`), - -}; - - - -// ============================================================ - -// 应用市场相关API - -// ============================================================ - -export const marketAPI = { - - getApps: (params?: { type?: string; source?: string; capability?: string; search?: string; page?: number; page_size?: number }) => { - - const searchParams = new URLSearchParams(); - - if (params?.type) searchParams.set('type', params.type); - - if (params?.source) searchParams.set('source', params.source); - - if (params?.capability) searchParams.set('capability', params.capability); - - if (params?.search) searchParams.set('search', params.search); - - if (params?.page) searchParams.set('page', String(params.page)); - - if (params?.page_size) searchParams.set('page_size', String(params.page_size)); - - const qs = searchParams.toString(); - - return get(`/market/apps${qs ? `?${qs}` : ''}`); - - }, - - getApp: (id: string) => get(`/market/apps/${id}`), - - createApp: (data: any) => post('/market/apps', data), - - verifyAgent: (data: any) => post('/market/apps/verify-agent', data), - - detectCapability: (data: any) => post('/market/apps/detect-capability', data), - - purchaseApp: (id: string) => post(`/market/apps/${id}/purchase`), - - installApp: (id: string) => post(`/market/apps/${id}/install`), - - uninstallApp: (id: string) => del(`/market/apps/${id}/uninstall`), - - reviewApp: (id: string, data: { rating: number; content?: string }) => - - post(`/market/apps/${id}/review`, data), - - getMyApps: () => get('/market/my-apps'), - - getReviews: (appId: string) => get(`/market/reviews/${appId}`), - -}; - - - -// ============================================================ - -// Token相关API - -// ============================================================ - -export const tokenAPI = { - - getOverview: () => get('/token/overview'), - - getRankings: () => get('/token/rankings'), - - getLogs: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - const qs = sp.toString(); - - return get(`/token/logs${qs ? `?${qs}` : ''}`); - - }, - - getBalance: () => get('/token/balance'), - - recharge: (amount: number) => post('/token/recharge', { amount }), - -}; - - - -// ============================================================ - -// 付费相关API - -// ============================================================ - -export const paymentAPI = { - - getPlans: () => get('/payment/plans'), - - createOrder: (data: any) => post('/payment/orders', data), - - getOrders: (status?: string) => get(`/payment/orders${status ? `?status=${status}` : ''}`), - - getOrder: (id: string) => get(`/payment/orders/${id}`), - - payOrder: (id: string, method?: string) => post(`/payment/orders/${id}/pay`, { payment_method: method }), - - getCoupons: () => get('/payment/coupons'), - - useCoupon: (code: string) => post('/payment/coupons/use', { code }), - - getServices: () => get('/payment/services'), - - createCustomOrder: (data: any) => post('/payment/custom-services', data), - - createServiceRequest: (data: any) => post('/payment/service-requests', data), - -}; - - - -// ============================================================ - -// 个人中心相关API - -// ============================================================ - -export const profileAPI = { - - getProfile: () => get('/profile/'), - - updateProfile: (data: any) => put('/profile/', data), - - updatePassword: (data: { old_password: string; new_password: string }) => - - put('/profile/password', data), - - getLoginLogs: () => get('/profile/login-logs'), - - getApiKeys: () => get('/profile/api-keys'), - - createApiKey: (name?: string) => post('/profile/api-keys', { name }), - - updateApiKey: (id: string, status: string) => put(`/profile/api-keys/${id}`, { status }), - - getMembership: () => get('/profile/membership'), - - getPurchasedApps: () => get('/profile/purchased-apps'), - -}; - - - -// ============================================================ - -// 通知相关API - -// ============================================================ - -export const notificationAPI = { - - getNotifications: (params?: { type?: string; page?: number }) => { - - const sp = new URLSearchParams(); - - if (params?.type) sp.set('type', params.type); - - if (params?.page) sp.set('page', String(params.page)); - - const qs = sp.toString(); - - return get(`/notifications/${qs ? `?${qs}` : ''}`); - - }, - - markRead: (id: string) => put(`/notifications/${id}/read`), - - markAllRead: () => put('/notifications/read-all'), - - getUnreadCount: () => get('/notifications/unread-count'), - -}; - - - -// ============================================================ - -// 资产管理相关API - -// ============================================================ - -export const assetsAPI = { - - getAssets: (params?: { category?: string; page?: number; page_size?: number }) => { - const qs: string[] = []; - if (params?.category) qs.push(`category=${encodeURIComponent(params.category)}`); - if (params?.page) qs.push(`page=${params.page}`); - if (params?.page_size) qs.push(`page_size=${params.page_size}`); - const q = qs.length ? `?${qs.join('&')}` : ''; - return get(`/assets/${q}`); - }, - - createAsset: (data: any) => post('/assets/', data), - - getAsset: (id: string) => get(`/assets/${id}`), - - updateAsset: (id: string, data: any) => put(`/assets/${id}`, data), - - deleteAsset: (id: string) => del(`/assets/${id}`), - - getCategories: () => get('/assets/categories'), - - getNovelChapters: (novelId: string) => get(`/assets/novels/${novelId}/chapters`), - - addChapter: (novelId: string, data: any) => post(`/assets/novels/${novelId}/chapters`, data), - -}; - - - -// ============================================================ - -// 后台管理API - -// ============================================================ - -export const adminAPI = { - - // 仪表盘 - - getStats: () => get('/admin/dashboard/stats'), - - getRecentLogs: () => get('/admin/dashboard/recent-logs'), - - getRecentAudit: () => get('/admin/dashboard/recent-audit'), - - getDailyStats: (days?: number) => get<{ days: number; data: Array<{ date: string; tokens: number }> }>(`/admin/dashboard/daily-stats${days ? `?days=${days}` : ''}`), - getObservabilitySummary: (days?: number) => get(`/admin/observability/summary${days ? `?days=${days}` : ''}`), - - // 用户管理 - - getUsers: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/users/${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - getUser: (id: string) => get(`/admin/users/${id}`), - - banUser: (id: string) => put(`/admin/users/${id}/ban`), - - unbanUser: (id: string) => put(`/admin/users/${id}/unban`), - - getRegisterSettings: () => get('/admin/users/register-settings'), - - updateRegisterSettings: (data: any) => put('/admin/users/register-settings', data), - - // 模型管理 - - getProviders: () => get('/admin/models/providers'), - - addProvider: (data: any) => post('/admin/models/providers', data), - - updateProvider: (id: string, data: any) => put(`/admin/models/providers/${id}`, data), - - deleteProvider: (id: string) => del(`/admin/models/providers/${id}`), - - fetchModels: (id: string) => post(`/admin/models/providers/${id}/fetch-models`), - - addModels: (id: string, modelIds: string[]) => post(`/admin/models/providers/${id}/add-models`, { model_ids: modelIds }), - - getProtocols: () => get('/admin/models/protocols'), - - getAdminModels: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/models/${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - addModel: (data: any) => post('/admin/models/', data), - - updateModel: (id: string, data: any) => put(`/admin/models/${id}`, data), - - deleteModel: (id: string) => del(`/admin/models/${id}`), - - toggleModel: (id: string, isEnabled: boolean) => put(`/admin/models/${id}/toggle?is_enabled=${isEnabled}`), - - setDefaultModel: (id: string) => put(`/admin/models/${id}/set-default`), - - // 按能力默认模型(Auto 自动模式使用) - getCapabilities: () => get('/admin/models/capabilities'), - getCapabilityDefaults: () => get('/admin/models/capability-defaults'), - setCapabilityDefault: (capability: string, modelId: string | null) => - put('/admin/models/capability-defaults', { capability, model_id: modelId }), - - // Phase 2: Enhanced model management - - testModelConnection: (id: string) => post(`/admin/models/${id}/test-connection`), - - testAllModels: () => post('/admin/models/test-all'), - - fetchProviderModels: (id: string) => post(`/admin/models/providers/${id}/fetch-models`), - - createAdminModel: (data: any) => post('/admin/models/', data), - - updateAdminModel: (id: string, data: any) => put(`/admin/models/${id}`, data), - - deleteAdminModel: (id: string) => del(`/admin/models/${id}`), - - getModelStats: () => get('/admin/models/stats'), - - // 网站设置 - - getSiteSettings: () => get('/admin/settings/site'), - - updateSiteSettings: (data: any) => put('/admin/settings/site', data), - - getEmailConfig: () => get('/admin/settings/email'), - - updateEmailConfig: (data: any) => put('/admin/settings/email', data), - - testEmail: (params?: { test_to?: string; test_subject?: string; test_body?: string }) => { - - const qs = new URLSearchParams(); - - if (params?.test_to) qs.set('test_to', params.test_to); - - if (params?.test_subject) qs.set('test_subject', params.test_subject); - - if (params?.test_body) qs.set('test_body', params.test_body); - - const qstr = qs.toString(); - - return post(`/admin/settings/email/test${qstr ? `?${qstr}` : ''}`); - - }, - - generateEmailContent: (topic: string) => post(`/admin/settings/email/generate-content?topic=${encodeURIComponent(topic)}`), - - getSmsConfig: () => get('/admin/settings/sms'), - - updateSmsConfig: (data: any) => put('/admin/settings/sms', data), - - testSms: () => post('/admin/settings/sms/test'), - - getSmsBalance: () => post('/admin/settings/sms/balance'), - - // 支付配置 - - getPaymentConfigs: () => get('/admin/payment/configs'), - - updatePaymentConfig: (id: string, data: any) => put(`/admin/payment/configs/${id}`, data), - - getAdminOrders: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/payment/orders${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - refundOrder: (id: string) => put(`/admin/payment/orders/${id}/refund`), - - // 增值服务 - - getServices: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/services/${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - addService: (data: any) => post('/admin/services/', data), - - updateService: (id: string, data: any) => put(`/admin/services/${id}`, data), - - deleteService: (id: string) => del(`/admin/services/${id}`), - - getCustomOrders: (status?: string) => get(`/admin/services/custom-orders${status ? `?status=${status}` : ''}`), - - updateCustomOrder: (id: string, data: any) => put(`/admin/services/custom-orders/${id}`, data), - - getCustomRequests: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/services/custom-requests${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - updateCustomRequest: (id: string, data: any) => put(`/admin/services/custom-requests/${id}`, data), - - getAvailableAgents: () => get('/admin/services/agents'), - - assignAgentToService: (id: string, agentId: string) => put(`/admin/services/${id}/agent?agent_id=${agentId}`), - - // 会员管理(增强版) - - getPlans: () => get('/admin/membership/plans'), - - addPlan: (params: string) => post(`/admin/membership/plans${params}`), - - updatePlan: (id: string, params: string) => put(`/admin/membership/plans/${id}${params}`), - - deletePlan: (id: string) => del(`/admin/membership/plans/${id}`), - - getSubscriptions: (page?: number) => get(`/admin/membership/subscriptions${page ? `?page=${page}` : ''}`), - - getAutomationRules: () => get('/admin/membership/automation-rules'), - - getBenefitsComparison: () => get('/admin/membership/benefits-comparison'), - - getMembershipConfigs: () => get('/admin/membership/global-configs'), - - updateMembershipConfig: (key: string, params: string) => put(`/admin/membership/global-configs/${key}${params}`), - - getUserMemberships: (page?: number, planId?: string, status?: string) => { - - const p = new URLSearchParams(); - - if (page) p.append('page', String(page)); - - if (planId) p.append('plan_id', planId); - - if (status) p.append('status', status); - - const qs = p.toString(); - - return get(`/admin/membership/user-memberships${qs ? `?${qs}` : ''}`); - - }, - - assignPlan: (userId: string, planId: string, days: number) => put(`/admin/membership/users/${userId}/assign-plan?plan_id=${planId}&duration_days=${days}`), - - getPaymentLogs: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/logs/payment-logs${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - getAuditLogs: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/logs/audit-logs${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - // 新增:日志统计 - - getLogStats: () => get('/admin/logs/stats'), - - // 新增:API调用统计 - - getApiStats: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/logs/api-stats${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - // 新增:慢请求记录 - - getSlowRequests: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/logs/slow-requests${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - // 新增:操作类型枚举 - - getActionTypes: () => get('/admin/logs/action-types'), - - // 新增:审计日志导出 - - exportAuditLogs: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/logs/export/audit-logs${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - exportOperationLogs: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/logs/export/operations${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - exportTokenLogs: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/logs/export/token-logs${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - getRiskRules: () => get('/admin/logs/risk-rules'), - - addRiskRule: (data: any) => post('/admin/logs/risk-rules', data), - - updateRiskRule: (id: string, data: any) => put(`/admin/logs/risk-rules/${id}`, data), - - deleteRiskRule: (id: string) => del(`/admin/logs/risk-rules/${id}`), - - // 技能管理 (Phase 2.2) - - getAdminSkills: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); }); - - return get(`/admin/skills/${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - getAdminSkill: (id: string) => get(`/admin/skills/${id}`), - - createAdminSkill: (data: any) => post('/admin/skills/', data), - - updateAdminSkill: (id: string, data: any) => put(`/admin/skills/${id}`, data), - - deleteAdminSkill: (id: string) => del(`/admin/skills/${id}`), - - changeAdminSkillState: (id: string, data: any) => put(`/admin/skills/${id}/state`, data), - - getAdminSkillVersions: (id: string) => get(`/admin/skills/${id}/versions`), - - rollbackAdminSkill: (id: string, data: any) => post(`/admin/skills/${id}/rollback`, data), - - publishAdminSkill: (id: string, data: any) => post(`/admin/skills/${id}/publish`, data), - - unpublishAdminSkill: (id: string) => post(`/admin/skills/${id}/unpublish`), - - getAdminSkillsStats: () => get('/admin/skills/stats/overview'), - -// 智能体管理 (Phase 2.3) - - getAdminAgents: (params?: any) => { - - const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : ''; - - return get(`/admin/agents/${query}`); - - }, - - getAdminAgent: (id: string) => get(`/admin/agents/${id}`), - - createAdminAgent: (data: any) => post('/admin/agents/', data), - - updateAdminAgent: (id: string, data: any) => put(`/admin/agents/${id}`, data), - - deleteAdminAgent: (id: string) => del(`/admin/agents/${id}`), - - toggleAdminAgent: (id: string, isActive: boolean) => put(`/admin/agents/${id}/toggle`, { is_active: isActive }), - - getAdminAgentsStats: () => get('/admin/agents/stats/overview'), - - getAdminAgentReviews: (id: string) => get(`/admin/agents/${id}/reviews`), - - // Phase 3.1: 工具/MCP 权限沙箱 (tool_policy) - getAgentToolPolicy: (id: string) => get(`/admin/agents/${id}/tool-policy`), - setAgentToolPolicy: (id: string, toolPolicy: any) => put(`/admin/agents/${id}/tool-policy`, { tool_policy: toolPolicy }), - - // Phase 3.3: 多 Agent 编排图 (orchestration) - getAgentOrchestration: (id: string) => get(`/admin/agents/${id}/orchestration`), - setAgentOrchestration: (id: string, orchestration: any) => put(`/admin/agents/${id}/orchestration`, { orchestration }), - importYamlOrch: (id: string, yaml: string) => post(`/admin/agents/${id}/orchestration/yaml/import`, { yaml_text: yaml }), - exportYamlOrch: (id: string) => post(`/admin/agents/${id}/orchestration/yaml/export`), - getYamlTemplates: () => get('/admin/agents/orchestration/yaml/templates'), - getAcaProtocols: () => get("/admin/agents/aca/protocols"), - getAcaProtocolTools: (name: string) => get(`/admin/agents/aca/protocols/${name}/tools`), - checkAcaHealth: (name: string) => post(`/admin/agents/aca/protocols/${name}/health`, {}), - - getAdminAvailableSkills: (params?: any) => { - - const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : ''; - - return get(`/admin/agents/available/skills${query}`); - - }, - - getAdminAvailableModels: (params?: any) => { - - const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : ''; - - return get(`/admin/agents/available/models${query}`); - - }, - - // 知识库管理 (Phase 2.4) - - getAdminKnowledgeStats: () => get('/admin/knowledge/stats/overview'), - - getAdminKnowledge: (params?: any) => { - - const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : ''; - - return get(`/admin/knowledge/${query}`); - - }, - - getAdminKnowledgeDetail: (id: string) => get(`/admin/knowledge/${id}`), - - createAdminKnowledge: (data: any) => post('/admin/knowledge/', data), - - updateAdminKnowledge: (id: string, data: any) => put(`/admin/knowledge/${id}`, data), - - deleteAdminKnowledge: (id: string) => del(`/admin/knowledge/${id}`), - - toggleAdminKnowledge: (id: string, isActive: boolean) => put(`/admin/knowledge/${id}/toggle`, { is_active: isActive }), - - getAdminKnowledgeDocs: (kbId: string, params?: any) => { - - const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : ''; - - return get(`/admin/knowledge/${kbId}/documents${query}`); - - }, - - deleteAdminKnowledgeDoc: (kbId: string, docId: string) => del(`/admin/knowledge/${kbId}/documents/${docId}`), - - getAdminKnowledgeExtServices: () => get('/admin/knowledge/external/services'), - - createAdminKnowledgeExtService: (data: any) => post('/admin/knowledge/external/services', data), - - updateAdminKnowledgeExtService: (id: string, data: any) => put(`/admin/knowledge/external/services/${id}`, data), - - deleteAdminKnowledgeExtService: (id: string) => del(`/admin/knowledge/external/services/${id}`), - - checkAdminKnowledgeExtStatus: (id: string) => get(`/admin/knowledge/external/services/${id}/status`), - - getAdminAnySearchConfig: () => get('/admin/knowledge/anysearch/config'), - - saveAdminAnySearchConfig: (data: { api_key: string; is_enabled: boolean }) => post('/admin/knowledge/anysearch/config', data), - - syncAdminKnowledge: (kbId: string, data: any) => post(`/admin/knowledge/external/sync/${kbId}`, data), - - batchDeleteAdminKnowledge: (ids: string[]) => post('/admin/knowledge/batch/delete', ids), - - // OSS存储配置 (Phase 3.1) - - getAssetStats: () => get('/admin/assets/stats'), - - getAdminAssets: (query: string) => get(`/admin/assets/?${query}`), - - deleteAdminAsset: (id: string) => del(`/admin/assets/${id}`), - - getAssetActivity: () => get('/admin/assets/activity'), - - getAdminStorageStats: () => get('/admin/storage/stats/overview'), - - getAdminStorageConfigs: (params?: any) => { - - const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : ''; - - return get(`/admin/storage/${query}`); - - }, - - getAdminStorageConfig: (id: string) => get(`/admin/storage/${id}`), - - createAdminStorageConfig: (data: any) => post('/admin/storage/', data), - - updateAdminStorageConfig: (id: string, data: any) => put(`/admin/storage/${id}`, data), - - deleteAdminStorageConfig: (id: string) => del(`/admin/storage/${id}`), - - toggleAdminStorageConfig: (id: string) => put(`/admin/storage/${id}/toggle`), - - setDefaultAdminStorageConfig: (id: string) => put(`/admin/storage/${id}/set-default`), - - testAdminStorageConfig: (data: any) => post('/admin/storage/test', data), - - getAdminStorageUsage: (id: string) => get(`/admin/storage/${id}/usage`), - - // Storage Quota APIs (Phase C5) - - getStorageQuotaOverview: () => get('/admin/storage/quota/overview'), - - getStorageQuotaUsers: (params?: any) => { - - if (params) { - - const query = Object.entries(params).map(([k, v]) => v !== undefined && v !== '' && v !== null ? `${k}=${encodeURIComponent(String(v))}` : '').filter(Boolean).join('&'); - - return get(`/admin/storage/quota/users${query ? '?' + query : ''}`); - - } - - return get('/admin/storage/quota/users'); - - }, - - getStorageQuotaUser: (userId: string) => get(`/admin/storage/quota/users/${userId}`), - - adjustStorageQuota: (userId: string, data: any) => put(`/admin/storage/quota/users/${userId}`, data), - - recalcStorageQuota: (userId: string) => post(`/admin/storage/quota/recalculate/${userId}`, {}), - - initAllStorageQuotas: () => post('/admin/storage/quota/init-all', {}), - - - - // 自动化任务调度 (Phase 3.2) - - getAdminAutomationStats: () => get('/admin/automation/stats/overview'), - - getAdminAutomationTasks: (params?: any) => { - - const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : ''; - - return get(`/admin/automation/${query}`); - - }, - - getAdminAutomationTask: (id: string) => get(`/admin/automation/${id}`), - - createAdminAutomationTask: (data: any) => post('/admin/automation/', data), - - updateAdminAutomationTask: (id: string, data: any) => put(`/admin/automation/${id}`, data), - - deleteAdminAutomationTask: (id: string) => del(`/admin/automation/${id}`), - - toggleAdminAutomationTask: (id: string) => put(`/admin/automation/${id}/toggle`), - - executeAdminAutomationTask: (id: string) => post(`/admin/automation/${id}/execute`), - - getAdminAutomationTaskHistory: (id: string, params?: any) => { - - const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : ''; - - return get(`/admin/automation/${id}/history${query}`); - - }, - - // MCP Server Management - - getAdminMcpStats: () => get('/admin/mcp/stats/overview'), - - getAdminMcpServers: (params?: any) => { - - const query = new URLSearchParams(params).toString(); - - return get(`/admin/mcp/${query ? '?' + query : ''}`); - - }, - - getAdminMcpServer: (id: string) => get(`/admin/mcp/${id}`), - - createAdminMcpServer: (data: any) => post('/admin/mcp/', data), - - updateAdminMcpServer: (id: string, data: any) => put(`/admin/mcp/${id}`, data), - - deleteAdminMcpServer: (id: string) => del(`/admin/mcp/${id}`), - - toggleAdminMcpServer: (id: string) => put(`/admin/mcp/${id}/toggle`), - - testAdminMcpServer: (id: string, data?: any) => post(`/admin/mcp/${id}/test`, data || {}), - - getAdminMcpServerTools: (id: string) => get(`/admin/mcp/${id}/tools`), - - getAdminMcpServerResources: (id: string) => get(`/admin/mcp/${id}/resources`), - - // Developer API Management - - getAdminDeveloperStats: () => get('/admin/developer/stats/overview'), - - getAdminApiKeys: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/developer/api-keys${sp.toString() ? '?' + sp.toString() : ''}`); - - }, - - createAdminApiKey: (data: any) => post('/admin/developer/api-keys/', data), - - updateAdminApiKey: (id: string, data: any) => put(`/admin/developer/api-keys/${id}`, data), - - revokeAdminApiKey: (id: string) => post(`/admin/developer/api-keys/${id}/revoke`), - - deleteAdminApiKey: (id: string) => del(`/admin/developer/api-keys/${id}`), - - getAdminOpenApiClients: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/developer/openapi-clients${sp.toString() ? '?' + sp.toString() : ''}`); - - }, - - createAdminOpenApiClient: (data: any) => post('/admin/developer/openapi-clients/', data), - - updateAdminOpenApiClient: (id: string, data: any) => put(`/admin/developer/openapi-clients/${id}`, data), - - resetAdminOpenApiClientSecret: (id: string) => post(`/admin/developer/openapi-clients/${id}/reset-secret`), - - deleteAdminOpenApiClient: (id: string) => del(`/admin/developer/openapi-clients/${id}`), - - getAdminCallStats: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/developer/call-stats${sp.toString() ? '?' + sp.toString() : ''}`); - - }, - - getAdminCallStatsOverview: () => get('/admin/developer/call-stats/overview'), - - getAdminSlowLogs: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/developer/slow-logs${sp.toString() ? '?' + sp.toString() : ''}`); - - }, - - clearAdminSlowLogs: () => del('/admin/developer/slow-logs'), - - getAdminSlowLogsStats: () => get('/admin/developer/slow-logs/stats'), - - // AI App Management - - getAIAppStats: () => get('/admin/apps/stats/overview'), - - getAIApps: (params?: string) => get('/admin/apps/apps' + (params ? '?' + params : '')), - - getAIApp: (id: string) => get('/admin/apps/apps/' + id), - - createAIApp: (data: any) => post('/admin/apps/apps', data), - - updateAIApp: (id: string, data: any) => put('/admin/apps/apps/' + id, data), - - deleteAIApp: (id: string) => del('/admin/apps/apps/' + id), - - aiAppLifecycle: (id: string, action: string) => post('/admin/apps/apps/' + id + '/' + action, {}), - - getAIAppVersions: (id: string) => get('/admin/apps/apps/' + id + '/versions'), - - getAIAppAnalytics: (id: string) => get('/admin/apps/apps/' + id + '/analytics'), - - getAIAppTemplates: () => get('/admin/apps/templates'), - - createAIAppTemplate: (data: any) => post('/admin/apps/templates', data), - - createAIAppFromTemplate: (templateId: string, data: any) => post('/admin/apps/apps/from-template/' + templateId, data), - - getAIAppCategories: () => get('/admin/apps/categories'), - - // Logo 上传 - - uploadLogo: (formData: FormData) => { - - const token = localStorage.getItem('access_token'); - - return fetch(`/app/api/admin/settings/logo/upload`, { - - method: 'POST', - - headers: { 'Authorization': `Bearer ${token}` }, - - body: formData, - - }).then(res => res.json()); - - }, - - - - // MCP Market (Phase G1) - - getMcpMarketStats: () => - - get('/admin/mcp-market/stats'), - - getMcpTools: (params?: Record) => - - get('/admin/mcp-market/?' + new URLSearchParams(params || {}).toString()), - - getMcpToolDetail: (id: string) => - - get('/admin/mcp-market/' + id), - - createMcpTool: (data: any) => - - post('/admin/mcp-market/', data), - - updateMcpTool: (id: string, data: any) => - - put('/admin/mcp-market/' + id, data), - - deleteMcpTool: (id: string) => - - del('/admin/mcp-market/' + id), - - scanMcpTool: (id: string) => - - post('/admin/mcp-market/' + id + '/security-scan'), - - updateMcpToolSecurity: (id: string, data: any) => - - put('/admin/mcp-market/' + id + '/security-status', data), - - regenMcpDeepLink: (id: string) => - - post('/admin/mcp-market/' + id + '/deep-link'), - - getMcpManagedServers: () => - - get('/admin/mcp-market/managed/servers'), - - seedMcpTools: () => - - post('/admin/mcp-market/seed'), - - - - // Code Execution Sandbox (Phase G2) - - getCodeExecStats: () => get('/admin/code-exec/stats'), - - getCodeExecRuntimes: () => get('/admin/code-exec/runtimes'), - - executeCode: (data: { language: string; code: string; stdin?: string; timeout?: number }) => - - post('/admin/code-exec/execute', data), - - getCodeExecTasks: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/code-exec/tasks${sp.toString() ? '?' + sp.toString() : ''}`); - - }, - - getCodeExecTask: (id: string) => get(`/admin/code-exec/tasks/${id}`), - - deleteCodeExecTask: (id: string) => del(`/admin/code-exec/tasks/${id}`), - - - - - - // Distribution Builder (Phase G4) - - getDistributionStats: () => get('/admin/distributions/stats'), - - getDistributions: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]: [string, any]) => { if (v) sp.set(k, String(v)); }); - - return get('/admin/distributions' + (sp.toString() ? '?' + sp.toString() : '')); - - }, - - getDistributionTemplates: () => get('/admin/distributions/templates'), - - createDistribution: (data: any) => post('/admin/distributions', data), - - getDistribution: (id: string) => get('/admin/distributions/' + id), - - updateDistribution: (id: string, data: any) => put('/admin/distributions/' + id, data), - - deleteDistribution: (id: string) => del('/admin/distributions/' + id), - - getDistributionLicenses: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]: [string, any]) => { if (v) sp.set(k, String(v)); }); - - return get('/admin/distributions/licenses' + (sp.toString() ? '?' + sp.toString() : '')); - - }, - - createDistributionLicense: (distroId: string, data: any) => post('/admin/distributions/' + distroId + '/licenses', data), - - getDistributionLicense: (id: string) => get('/admin/distributions/licenses/' + id), - - revokeDistributionLicense: (id: string) => post('/admin/distributions/licenses/' + id + '/revoke'), - - // ===== Ads Management ===== - - getAds: () => get('/admin/ads/'), - - addAd: (data: any) => post('/admin/ads/', data), - - updateAd: (id: string, data: any) => put(`/admin/ads/${id}`, data), - - deleteAd: (id: string) => del(`/admin/ads/${id}`), - - - - // ===== Market Admin (extended) ===== - - getMarketOverview: () => get('/admin/market/overview'), - - getTopApps: (metric?: string, limit?: number) => get(`/admin/market/top-apps${metric ? '?metric=' + metric : ''}${limit ? (metric ? '&' : '?') + 'limit=' + limit : ''}`), - - getAdminMarketApps: (params?: string) => get(`/admin/market/apps${params ? '?' + params : ''}`), - - getPendingApps: () => get('/admin/market/pending'), - - getCoupons: () => get('/admin/market/coupons'), - - getRecentReviews: () => get('/admin/market/reviews'), - - approveApp: (id: string) => post(`/admin/market/apps/${id}/approve`), - - rejectApp: (id: string) => post(`/admin/market/apps/${id}/reject`), - - setFeaturedApp: (id: string, featured: boolean, sortOrder?: number) => put(`/admin/market/apps/${id}/featured`, { featured, sort_order: sortOrder }), - - deleteMarketApp: (id: string) => del(`/admin/market/apps/${id}`), - - deleteCoupon: (id: string) => del(`/admin/market/coupons/${id}`), - - updateMarketApp: (id: string, data: any) => put(`/admin/market/apps/${id}`, data), - - // Phase 4.1: 安检 + 版本化 + 绿标 - rescanApp: (id: string) => post(`/admin/market/apps/${id}/scan`), - getAppVersions: (id: string) => get(`/admin/market/apps/${id}/versions`), - rollbackApp: (id: string, versionId: string) => post(`/admin/market/apps/${id}/rollback?version_id=${versionId}`), - - updateCoupon: (id: string, data: any) => put(`/admin/market/coupons/${id}`, data), - - addCoupon: (data: any) => post('/admin/market/coupons', data), - - - - // ===== Membership (extended) ===== - - getMembershipStats: () => get('/admin/membership/stats'), - - revokeMembership: (userId: string) => post(`/admin/membership/users/${userId}/revoke`), - - - - // ===== Logs (extended) ===== - - getOperationLogs: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/logs/operation-logs${sp.toString() ? '?' + sp.toString() : ''}`); - - }, - - getTokenLogs: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/logs/token-logs${sp.toString() ? '?' + sp.toString() : ''}`); - - }, - - - - // ===== Analytics ===== - - getAnalyticsOverview: (days?: number) => get(`/admin/analytics/overview${days ? '?days=' + days : ''}`), - - getAnalyticsApiTrends: (days?: number) => get(`/admin/analytics/trends/api-calls${days ? '?days=' + days : ''}`), - - getAnalyticsTokenTrends: (days?: number) => get(`/admin/analytics/trends/tokens${days ? '?days=' + days : ''}`), - - getAnalyticsUserTrends: (days?: number) => get(`/admin/analytics/trends/users${days ? '?days=' + days : ''}`), - - getAnalyticsRevenueTrends: (days?: number) => get(`/admin/analytics/trends/revenue${days ? '?days=' + days : ''}`), - - getAnalyticsModelUsage: (days?: number) => get(`/admin/analytics/models/usage${days ? '?days=' + days : ''}`), - - - - // ===== Billing ===== - - getBillingRules: () => get('/admin/billing/rules'), - - getBillingPackages: () => get('/admin/billing/packages'), - - getBillingTransactions: (page?: number) => get(`/admin/billing/transactions${page ? '?page=' + page : ''}`), - - getBillingStats: () => get('/admin/billing/stats/overview'), - - - - // ===== Tenant ===== - - getTenants: (page?: number) => get(`/admin/tenant/list${page ? '?page=' + page : ''}`), - - getTenantStats: () => get('/admin/tenant/stats/overview'), - - getTenantMembers: (tenantId: string) => get(`/admin/tenant/${tenantId}/members`), - - createTenant: (params: string) => post(`/admin/tenant/create${params}`), - - - -}; - -// ===== AI自进化API ===== - -export const evolutionAPI = { - - // 驾驶舱面板 - - getSkillTree: () => get('/evolution/dashboard/skill-tree'), - - getTimeline: (eventType?: string) => - - get('/evolution/dashboard/timeline' + (eventType ? `?event_type=${eventType}` : '')), - - getStatistics: () => get('/evolution/dashboard/statistics'), - - getProfile: () => get('/evolution/dashboard/profile'), - - - - // 技能管理 - - getSkills: (params?: { state?: string; keyword?: string; sort_by?: string; order?: string }) => { - - const query = new URLSearchParams(); - - if (params?.state) query.append('state', params.state); - - if (params?.keyword) query.append('keyword', params.keyword); - - if (params?.sort_by) query.append('sort_by', params.sort_by); - - if (params?.order) query.append('order', params.order); - - const qs = query.toString(); - - return get('/evolution/skills' + (qs ? `?${qs}` : '')); - - }, - - getSkillDetail: (name: string) => get(`/evolution/skills/${encodeURIComponent(name)}`), - - updateSkill: (name: string, data: { description?: string; tags?: string[]; category?: string }) => - - put(`/evolution/skills/${encodeURIComponent(name)}`, data), - - deleteSkill: (name: string) => del(`/evolution/skills/${encodeURIComponent(name)}`), - - freezeSkill: (name: string) => post(`/evolution/skills/${encodeURIComponent(name)}/freeze`), - - unfreezeSkill: (name: string) => post(`/evolution/skills/${encodeURIComponent(name)}/unfreeze`), - - getSkillHistory: (name: string) => get(`/evolution/skills/${encodeURIComponent(name)}/history`), - - getSkillTrajectories: (name: string) => get(`/evolution/skills/${encodeURIComponent(name)}/trajectories`), - - - - // 事件 - - getEvents: (params?: { event_type?: string; skill_name?: string; limit?: number }) => { - - const query = new URLSearchParams(); - - if (params?.event_type) query.append('event_type', params.event_type); - - if (params?.skill_name) query.append('skill_name', params.skill_name); - - if (params?.limit) query.append('limit', String(params.limit)); - - const qs = query.toString(); - - return get('/evolution/events' + (qs ? `?${qs}` : '')); - - }, - - - - // 轨迹提交 - - submitTrajectory: (data: { conversation_id: string; messages: any[] }) => - - post('/evolution/trajectory/submit', data), - - - - // v2: 技能回滚 - - rollbackSkill: (skillName: string, version: number) => - - post(`/evolution/skills/${encodeURIComponent(skillName)}/rollback/${version}`), - - - - // v2: 发布到市场 - - publishSkill: (skillName: string, data: { price: number; market_description: string }) => - - post(`/evolution/skills/${encodeURIComponent(skillName)}/publish`, data), - - - - // v2: 团队共享 - - shareToTeam: (skillName: string, teamId: string) => - - post(`/evolution/skills/${encodeURIComponent(skillName)}/share-to-team`, { team_id: teamId }), - - - - // v2: 流水线状态 - - getPipelineStatus: () => get('/evolution/pipeline/status'), - - - - // v2: 手动触发流水线 - - triggerPipeline: () => post('/evolution/pipeline/trigger'), - - - - // v2: Watcher状态 - - getWatcherStatus: () => get('/evolution/watcher/status'), - - - - // v2: 进化报告 - - getReports: (reportType?: string) => { - - const qs = reportType ? `?report_type=${reportType}` : ''; - - return get(`/evolution/reports${qs}`); - - }, - - getReportDetail: (reportId: string) => get(`/evolution/reports/${reportId}`), - - - - - - // Workflow (Phase 3.6) - - getWorkflows: (params?: Record) => - - get('/admin/workflows/?' + new URLSearchParams(params || {}).toString()), - - getWorkflow: (id: string) => - - get(`/admin/workflows/${id}`), - - getWorkflowStats: () => - - get('/admin/workflows/stats'), - - getWorkflowTemplates: () => - - get('/admin/workflows/templates'), - - createWorkflow: (data: any) => - - post('/admin/workflows/', data), - - updateWorkflow: (id: string, data: any) => - - put(`/admin/workflows/${id}`, data), - - deleteWorkflow: (id: string) => - - del(`/admin/workflows/${id}`), - - toggleWorkflow: (id: string, isActive: boolean) => - - post(`/admin/workflows/${id}/toggle?is_active=${isActive}`), - - executeWorkflow: (id: string, inputText: string) => - - post(`/admin/workflows/${id}/execute`, { input_text: inputText }), - - - - - - // Privacy & Local Agent (Phase G5) - - getPrivacyStats: () => get('/admin/privacy/stats'), - - getPrivacyConfig: () => get('/admin/privacy/config'), - - updatePrivacyConfig: (params: any) => { - - const sp = new URLSearchParams(); - - Object.entries(params).forEach(([k, v]) => { if (v != null) sp.append(k, String(v)); }); - - return put('/admin/privacy/config?' + sp.toString()); - - }, - - switchPrivacyMode: (mode: string) => post('/admin/privacy/switch-mode?mode=' + encodeURIComponent(mode)), - - getPrivacyLicenses: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params?.status) sp.append('status', params.status); - - return get('/admin/privacy/licenses' + (sp.toString() ? '?' + sp.toString() : '')); - - }, - - generateOfflineLicense: (data: any) => { - - const sp = new URLSearchParams(); - - if (data.agent_id) sp.append('agent_id', data.agent_id); - - sp.append('max_offline_days', String(data.max_offline_days || 7)); - - return post('/admin/privacy/licenses/generate?' + sp.toString()); - - }, - - revokePrivacyLicense: (id: number) => post('/admin/privacy/licenses/' + id + '/revoke'), - - validatePrivacyLicense: (licenseKey: string) => { - - const sp = new URLSearchParams(); - - sp.append('license_key', licenseKey); - - return post('/admin/privacy/licenses/validate?' + sp.toString()); - - }, - - getKbSyncStates: () => get('/admin/privacy/kb-sync'), - - triggerKbSync: (kbId: number, direction: string) => { - - const sp = new URLSearchParams(); - - sp.append('knowledge_base_id', String(kbId)); - - sp.append('sync_direction', direction); - - return post('/admin/privacy/kb-sync/trigger?' + sp.toString()); - - }, - - getLocalAgents: () => get('/admin/privacy/local-agents'), - - getPrivacyUsageStats: () => get('/admin/privacy/usage/stats'), - - getBridgeConfig: () => get('/admin/privacy/bridge-config'), - - - - // IM Bot Management (Phase 4) - - getImBotStats: () => get('/admin/im-bot/stats'), - - getImBotDashboard: () => get('/admin/im-bot/dashboard'), - - getImBotChannels: (params?: string) => get(`/admin/im-bot/channels${params || ''}`), - - getImBotChannelDetail: (id: string) => get(`/admin/im-bot/channels/${id}`), - - testImBotChannel: (id: string) => post(`/admin/im-bot/channels/${id}/test`), - - activateImBotChannel: (id: string) => post(`/admin/im-bot/channels/${id}/activate`), - - deactivateImBotChannel: (id: string) => post(`/admin/im-bot/channels/${id}/deactivate`), - - getImBotRoutes: (params?: string) => get(`/admin/im-bot/routes${params || ''}`), - - createImBotRoute: (params: string) => post(`/admin/im-bot/routes${params}`), - - deleteImBotRoute: (id: string) => del(`/admin/im-bot/routes/${id}`), - - // 个人微信(iLink 协议)原生适配器 - - getWechatConfig: () => get('/admin/im-bot/wechat'), - - wechatQrcode: () => post('/admin/im-bot/wechat/qrcode'), - - wechatQrcodeStatus: (params: string) => post(`/admin/im-bot/wechat/qrcode/status${params}`), - - getWechatStatus: () => get('/admin/im-bot/wechat/status'), - - disconnectWechat: () => post('/admin/im-bot/wechat/disconnect'), - - getImBotPlatforms: () => get('/admin/im-bot/platforms'), - - getImBotWebhookUrl: (platform: string) => get(`/admin/im-bot/webhook-url/${platform}`), - - getImBotMessages: (params?: string) => get(`/admin/im-bot/messages${params || ''}`), - - imBotQuickSetup: (params: string) => post(`/admin/im-bot/quick-setup${params}`), - - - - // 推广系统 - - getPromotionStats: () => get('/admin/promotion/stats'), - - getPromotions: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); }); - - return get(`/admin/promotion/promotions${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - createPromotion: (params: any) => { - - const sp = new URLSearchParams(); - - Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); }); - - return post(`/admin/promotion/promotions${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - updatePromotion: (id: string, params: any) => { - - const sp = new URLSearchParams(); - - Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); }); - - return put(`/admin/promotion/promotions/${id}${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - deletePromotion: (id: string) => del(`/admin/promotion/promotions/${id}`), - - togglePromotion: (id: string) => post(`/admin/promotion/promotions/${id}/toggle`), - - getReferralLinks: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/promotion/referral-links${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - generateReferralLink: (params: any) => { - - const sp = new URLSearchParams(); - - Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return post(`/admin/promotion/referral-links/generate${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - getReferralRewards: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/promotion/rewards${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - approveReward: (id: string) => post(`/admin/promotion/rewards/${id}/approve`), - - markRewardPaid: (id: string, params: any) => { - - const sp = new URLSearchParams(); - - Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return post(`/admin/promotion/rewards/${id}/pay${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - getPromotionLeaderboard: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/promotion/leaderboard${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - regenerateReferralCode: (id: string) => post(`/admin/promotion/referral-links/${id}/regenerate`), - - getMyReferrals: () => get('/admin/promotion/my-referrals'), - - getMyRewards: () => get('/admin/promotion/my-rewards'), - - - - // 成就系统 - - getAchievements: (params?: string) => get(`/admin/achievement/achievements${params || ''}`), - - getAchievementDetail: (id: string) => get(`/admin/achievement/achievements/${id}`), - - createAchievement: (params: string) => post(`/admin/achievement/achievements${params}`), - - updateAchievement: (id: string, params: string) => put(`/admin/achievement/achievements/${id}${params}`), - - deleteAchievement: (id: string) => del(`/admin/achievement/achievements/${id}`), - - getUserAchievements: () => get('/admin/achievement/user/achievements'), - - claimAchievementReward: (id: string) => post(`/admin/achievement/user/achievements/${id}/claim`), - - checkAchievementProgress: () => post('/admin/achievement/check-progress'), - - getAchievementLeaderboard: (params?: string) => get(`/admin/achievement/leaderboard${params || ''}`), - - - - // 多租户管理 - - getTenants: (page?: number, keyword?: string, status?: string) => { - - const sp = new URLSearchParams(); - - if (page) sp.set('page', String(page)); - - if (keyword) sp.set('keyword', keyword); - - if (status) sp.set('status', status); - - sp.set('page_size', '20'); - - return get(`/admin/tenant/list${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - createTenant: (params: string) => post(`/admin/tenant/create${params}`), - - getTenantDetail: (id: string) => get(`/admin/tenant/${id}`), - - updateTenant: (id: string, params: string) => put(`/admin/tenant/${id}${params}`), - - deleteTenant: (id: string) => del(`/admin/tenant/${id}`), - - getTenantMembers: (tenantId: string, page?: number) => { - - const sp = new URLSearchParams(); - - sp.set('page', String(page || 1)); - - sp.set('page_size', '100'); - - return get(`/admin/tenant/${tenantId}/members?${sp.toString()}`); - - }, - - addTenantMember: (tenantId: string, params: string) => post(`/admin/tenant/${tenantId}/members/add${params}`), - - updateTenantMember: (tenantId: string, memberId: string, params: string) => put(`/admin/tenant/${tenantId}/members/${memberId}${params}`), - - removeTenantMember: (tenantId: string, memberId: string) => del(`/admin/tenant/${tenantId}/members/${memberId}`), - - getTenantQuotas: (tenantId: string) => get(`/admin/tenant/${tenantId}/quotas`), - - updateTenantQuota: (tenantId: string, params: string) => put(`/admin/tenant/${tenantId}/quotas${params}`), - - getTenantInvitations: (tenantId: string) => get(`/admin/tenant/${tenantId}/invitations`), - - createTenantInvitation: (tenantId: string, params: string) => post(`/admin/tenant/${tenantId}/invitations${params}`), - - getTenantStats: () => get('/admin/tenant/stats/overview'), - - - - // 计费与支付 - - getBillingRules: (page?: number) => get(`/admin/billing/rules?page=${page || 1}&page_size=100`), - - createBillingRule: (params: string) => post(`/admin/billing/rules/create${params}`), - - updateBillingRule: (id: string, params: string) => put(`/admin/billing/rules/${id}${params}`), - - deleteBillingRule: (id: string) => del(`/admin/billing/rules/${id}`), - - getBillingPackages: () => get('/admin/billing/packages'), - - createBillingPackage: (params: string) => post(`/admin/billing/packages/create${params}`), - - updateBillingPackage: (id: string, params: string) => put(`/admin/billing/packages/${id}${params}`), - - deleteBillingPackage: (id: string) => del(`/admin/billing/packages/${id}`), - - getBillingTransactions: (page?: number) => get(`/admin/billing/transactions?page=${page || 1}&page_size=20`), - - getInvoices: (page?: number) => get(`/admin/billing/invoices?page=${page || 1}&page_size=20`), - - generateInvoice: (params: string) => post(`/admin/billing/invoices/generate${params}`), - - getPaymentConfigs: () => get('/admin/billing/payment-configs'), - - updatePaymentConfig: (id: string, params: string) => put(`/admin/billing/payment-configs/${id}${params}`), - - getBillingStats: () => get('/admin/billing/stats/overview'), - - - - // 数据分析 - - getAnalyticsOverview: (days?: number) => get(`/admin/analytics/overview?days=${days || 30}`), - - getAnalyticsApiTrends: (days?: number) => get(`/admin/analytics/trends/api-calls?days=${days || 7}`), - - getAnalyticsTokenTrends: (days?: number) => get(`/admin/analytics/trends/tokens?days=${days || 7}`), - - getAnalyticsUserTrends: (days?: number) => get(`/admin/analytics/trends/users?days=${days || 30}`), - - getAnalyticsRevenueTrends: (days?: number) => get(`/admin/analytics/trends/revenue?days=${days || 30}`), - - getAnalyticsModelUsage: (days?: number) => get(`/admin/analytics/models/usage?days=${days || 30}`), - - getAnalyticsDashboards: () => get('/admin/analytics/dashboards'), - - createAnalyticsDashboard: (params: string) => post(`/admin/analytics/dashboards/create${params}`), - - deleteAnalyticsDashboard: (id: string) => del(`/admin/analytics/dashboards/${id}`), - - getAnalyticsEvents: (params?: any) => { - - const sp = new URLSearchParams(); - - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - - return get(`/admin/analytics/events${sp.toString() ? `?${sp.toString()}` : ''}`); - - }, - - - -}; - - - - - -// ===== 前台用户个人微信 (iLink 协议 / 微信 ClawBot 插件) ===== - -export const imUserAPI = { - - getStatus: () => get('/im/wechat/status'), - - qrcode: () => post('/im/wechat/qrcode'), - - qrcodeStatus: (qrcode: string) => post(`/im/wechat/qrcode/status?qrcode=${encodeURIComponent(qrcode)}`), - - disconnect: () => post('/im/wechat/disconnect'), - - history: () => get('/im/wechat/history'), - -}; - +/** + + * AIFlow API 客户端 + + * 统一管理所有前后端API请求 + + */ + +const API_BASE = '/app/api'; + + + +// 从localStorage获取token + +function getToken(): string | null { + + return localStorage.getItem('aiflow_token'); + +} + + + +// 通用请求方法 + +export async function request( + + endpoint: string, + + options: RequestInit = {} + +): Promise<{ code: number; data: T; message: string }> { + + const token = getToken(); + + const headers: Record = { + + 'Content-Type': 'application/json', + + ...(options.headers as Record || {}), + + }; + + if (token) { + + headers['Authorization'] = `Bearer ${token}`; + + } + + const res = await fetch(`${API_BASE}${endpoint}`, { + + ...options, + + headers, + + }); + + if (res.status === 401) { + + // 登录接口的401需要精确提示(用户名不存在 / 密码错误) + + if (endpoint === '/auth/login') { + + const err = await res.json().catch(() => ({ detail: '登录失败' })); + + const error: any = new Error(err.detail || '登录失败'); + + error.status = 401; + + error.detail = err.detail; + + throw error; + + } + + // 其他接口的401 = token过期/未登录,清除状态并跳转 + + localStorage.removeItem('aiflow_token'); + + localStorage.removeItem('aiflow_user'); + + window.location.href = '/login'; + + throw new Error('未登录'); + + } + + if (res.status === 403) { + const err = await res.json().catch(() => ({ detail: 'Forbidden' })); + if (err.detail === 'Not authenticated' || err.detail === '需要管理员权限') { + localStorage.removeItem('aiflow_token'); + localStorage.removeItem('aiflow_user'); + window.location.href = '/login'; + throw new Error('未登录或权限不足'); + } + } + + if (!res.ok) { + + const err = await res.json().catch(() => ({ detail: '请求失败' })); + + const error: any = new Error(err.detail || err.message || '请求失败'); + + error.status = res.status; + + error.response = { status: res.status, data: err }; + + error.detail = err.detail; + + throw error; + + } + + return res.json(); + +} + + + +// GET请求 + +function get(endpoint: string): Promise<{ code: number; data: T; message: string }> { + + return request(endpoint); + +} + + + +// POST请求 + +function post(endpoint: string, body?: any): Promise<{ code: number; data: T; message: string }> { + + return request(endpoint, { + + method: 'POST', + + body: body ? JSON.stringify(body) : undefined, + + }); + +} + + + +// PUT请求 + +function put(endpoint: string, body?: any): Promise<{ code: number; data: T; message: string }> { + + return request(endpoint, { + + method: 'PUT', + + body: body ? JSON.stringify(body) : undefined, + + }); + +} + + + +// DELETE请求 + +function del(endpoint: string): Promise<{ code: number; data: T; message: string }> { + + return request(endpoint, { method: 'DELETE' }); + +} + + + +// ============================================================ + +// 认证相关API + +// ============================================================ + +export const authAPI = { + + login: (username: string, password: string) => + + post('/auth/login', { username, password }), + + register: (data: { username: string; password: string; email?: string; phone?: string }) => + + post('/auth/register', data), + + getMe: () => get('/auth/me'), + + logout: () => post('/auth/logout'), + + getSettings: () => get('/auth/settings'), + + sendCode: (phone: string, purpose: string) => + post('/auth/send-code', { phone, purpose }), + + resetPassword: (data: { phone: string; code: string; new_password: string }) => + post('/auth/reset-password', data), + +}; + + + +// ============================================================ + +// 聊天相关API + +// ============================================================ + +export const chatAPI = { + + getConversations: (status?: string) => + + get(`/chat/conversations${status ? `?status=${status}` : ''}`), + + createConversation: (data: { title: string; model_id?: string }) => + + post('/chat/conversations', data), + + updateConversation: (id: string, data: any) => + + put(`/chat/conversations/${id}`, data), + + deleteConversation: (id: string) => + + del(`/chat/conversations/${id}`), + + getMessages: (convId: string) => + + get(`/chat/conversations/${convId}/messages`), + + sendMessage: (convId: string, data: { content: string; model_id?: string; agent_ids?: string[]; skill_ids?: string[]; knowledge_base_ids?: string[]; use_tools?: boolean }) => + + post(`/chat/conversations/${convId}/messages`, data), + + clearContext: (convId: string) => + + post(`/chat/conversations/${convId}/clear`), + + convertToKnowledgeGraph: (convId: string) => + + post(`/chat/conversations/${convId}/to-knowledge-graph/`), + + getModels: () => get('/chat/models'), + +}; + + + + + + +// ============================================================ + +// 知识库相关API + +// ============================================================ + +export const knowledgeAPI = { + + getBases: () => get('/knowledge/bases'), + + createBase: (data: any) => post('/knowledge/bases', data), + + getBase: (id: string) => get(`/knowledge/bases/${id}`), + + updateBase: (id: string, data: any) => put(`/knowledge/bases/${id}`, data), + + deleteBase: (id: string) => del(`/knowledge/bases/${id}`), + + getDocuments: (baseId: string) => get(`/knowledge/bases/${baseId}/documents`), + + getDocument: (baseId: string, docId: string) => get(`/knowledge/bases/${baseId}/documents/${docId}`), + + uploadDocument: (baseId: string, data: any) => post(`/knowledge/bases/${baseId}/documents`, data), + + deleteDocument: (baseId: string, docId: string) => del(`/knowledge/bases/${baseId}/documents/${docId}`), + + getDirectories: () => get('/knowledge/directories'), + + createDirectory: (data: any) => post('/knowledge/directories', data), + + buildGraph: (baseId: string) => post(`/knowledge/bases/${baseId}/build-graph`), + + getGraph: (baseId: string) => get(`/knowledge/bases/${baseId}/graph`), + + searchQA: (baseId: string, question: string) => post('/knowledge/qa', { base_id: baseId, question }), + + searchDocuments: (baseId: string, keyword: string) => get(`/knowledge/bases/${baseId}/search?keyword=${encodeURIComponent(keyword)}`), + +}; + + + +// ============================================================ + +// 应用市场相关API + +// ============================================================ + +export const marketAPI = { + + getApps: (params?: { type?: string; source?: string; capability?: string; search?: string; page?: number; page_size?: number }) => { + + const searchParams = new URLSearchParams(); + + if (params?.type) searchParams.set('type', params.type); + + if (params?.source) searchParams.set('source', params.source); + + if (params?.capability) searchParams.set('capability', params.capability); + + if (params?.search) searchParams.set('search', params.search); + + if (params?.page) searchParams.set('page', String(params.page)); + + if (params?.page_size) searchParams.set('page_size', String(params.page_size)); + + const qs = searchParams.toString(); + + return get(`/market/apps${qs ? `?${qs}` : ''}`); + + }, + + getApp: (id: string) => get(`/market/apps/${id}`), + + createApp: (data: any) => post('/market/apps', data), + + verifyAgent: (data: any) => post('/market/apps/verify-agent', data), + + detectCapability: (data: any) => post('/market/apps/detect-capability', data), + + purchaseApp: (id: string) => post(`/market/apps/${id}/purchase`), + + installApp: (id: string) => post(`/market/apps/${id}/install`), + + uninstallApp: (id: string) => del(`/market/apps/${id}/uninstall`), + + reviewApp: (id: string, data: { rating: number; content?: string }) => + + post(`/market/apps/${id}/review`, data), + + getMyApps: () => get('/market/my-apps'), + + getReviews: (appId: string) => get(`/market/reviews/${appId}`), + +}; + + + +// ============================================================ + +// Token相关API + +// ============================================================ + +export const tokenAPI = { + + getOverview: () => get('/token/overview'), + + getRankings: () => get('/token/rankings'), + + getLogs: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + const qs = sp.toString(); + + return get(`/token/logs${qs ? `?${qs}` : ''}`); + + }, + + getBalance: () => get('/token/balance'), + + recharge: (amount: number) => post('/token/recharge', { amount }), + +}; + + + +// ============================================================ + +// 付费相关API + +// ============================================================ + +export const paymentAPI = { + + getPlans: () => get('/payment/plans'), + + createOrder: (data: any) => post('/payment/orders', data), + + getOrders: (status?: string) => get(`/payment/orders${status ? `?status=${status}` : ''}`), + + getOrder: (id: string) => get(`/payment/orders/${id}`), + + payOrder: (id: string, method?: string) => post(`/payment/orders/${id}/pay`, { payment_method: method }), + + getCoupons: () => get('/payment/coupons'), + + useCoupon: (code: string) => post('/payment/coupons/use', { code }), + + getServices: () => get('/payment/services'), + + createCustomOrder: (data: any) => post('/payment/custom-services', data), + + createServiceRequest: (data: any) => post('/payment/service-requests', data), + +}; + + + +// ============================================================ + +// 个人中心相关API + +// ============================================================ + +export const profileAPI = { + + getProfile: () => get('/profile/'), + + updateProfile: (data: any) => put('/profile/', data), + + updatePassword: (data: { old_password: string; new_password: string }) => + + put('/profile/password', data), + + getLoginLogs: () => get('/profile/login-logs'), + + getApiKeys: () => get('/profile/api-keys'), + + createApiKey: (name?: string) => post('/profile/api-keys', { name }), + + updateApiKey: (id: string, status: string) => put(`/profile/api-keys/${id}`, { status }), + + getMembership: () => get('/profile/membership'), + + getPurchasedApps: () => get('/profile/purchased-apps'), + +}; + + + +// ============================================================ + +// 通知相关API + +// ============================================================ + +export const notificationAPI = { + + getNotifications: (params?: { type?: string; page?: number }) => { + + const sp = new URLSearchParams(); + + if (params?.type) sp.set('type', params.type); + + if (params?.page) sp.set('page', String(params.page)); + + const qs = sp.toString(); + + return get(`/notifications/${qs ? `?${qs}` : ''}`); + + }, + + markRead: (id: string) => put(`/notifications/${id}/read`), + + markAllRead: () => put('/notifications/read-all'), + + getUnreadCount: () => get('/notifications/unread-count'), + +}; + + + +// ============================================================ + +// 资产管理相关API + +// ============================================================ + +export const assetsAPI = { + + getAssets: (params?: { category?: string; page?: number; page_size?: number }) => { + const qs: string[] = []; + if (params?.category) qs.push(`category=${encodeURIComponent(params.category)}`); + if (params?.page) qs.push(`page=${params.page}`); + if (params?.page_size) qs.push(`page_size=${params.page_size}`); + const q = qs.length ? `?${qs.join('&')}` : ''; + return get(`/assets/${q}`); + }, + + createAsset: (data: any) => post('/assets/', data), + + getAsset: (id: string) => get(`/assets/${id}`), + + updateAsset: (id: string, data: any) => put(`/assets/${id}`, data), + + deleteAsset: (id: string) => del(`/assets/${id}`), + + getCategories: () => get('/assets/categories'), + + getNovelChapters: (novelId: string) => get(`/assets/novels/${novelId}/chapters`), + + addChapter: (novelId: string, data: any) => post(`/assets/novels/${novelId}/chapters`, data), + +}; + + + +// ============================================================ + +// 后台管理API + +// ============================================================ + +export const adminAPI = { + + // 仪表盘 + + getStats: () => get('/admin/dashboard/stats'), + + getRecentLogs: () => get('/admin/dashboard/recent-logs'), + + getRecentAudit: () => get('/admin/dashboard/recent-audit'), + + getDailyStats: (days?: number) => get<{ days: number; data: Array<{ date: string; tokens: number }> }>(`/admin/dashboard/daily-stats${days ? `?days=${days}` : ''}`), + getObservabilitySummary: (days?: number) => get(`/admin/observability/summary${days ? `?days=${days}` : ''}`), + + // 用户管理 + + getUsers: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/users/${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + getUser: (id: string) => get(`/admin/users/${id}`), + + banUser: (id: string) => put(`/admin/users/${id}/ban`), + + unbanUser: (id: string) => put(`/admin/users/${id}/unban`), + + getRegisterSettings: () => get('/admin/users/register-settings'), + + updateRegisterSettings: (data: any) => put('/admin/users/register-settings', data), + + // 模型管理 + + getProviders: () => get('/admin/models/providers'), + + addProvider: (data: any) => post('/admin/models/providers', data), + + updateProvider: (id: string, data: any) => put(`/admin/models/providers/${id}`, data), + + deleteProvider: (id: string) => del(`/admin/models/providers/${id}`), + + fetchModels: (id: string) => post(`/admin/models/providers/${id}/fetch-models`), + + addModels: (id: string, modelIds: string[]) => post(`/admin/models/providers/${id}/add-models`, { model_ids: modelIds }), + + getProtocols: () => get('/admin/models/protocols'), + + getAdminModels: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/models/${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + addModel: (data: any) => post('/admin/models/', data), + + updateModel: (id: string, data: any) => put(`/admin/models/${id}`, data), + + deleteModel: (id: string) => del(`/admin/models/${id}`), + + toggleModel: (id: string, isEnabled: boolean) => put(`/admin/models/${id}/toggle?is_enabled=${isEnabled}`), + + setDefaultModel: (id: string) => put(`/admin/models/${id}/set-default`), + + // 按能力默认模型(Auto 自动模式使用) + getCapabilities: () => get('/admin/models/capabilities'), + getCapabilityDefaults: () => get('/admin/models/capability-defaults'), + setCapabilityDefault: (capability: string, modelId: string | null) => + put('/admin/models/capability-defaults', { capability, model_id: modelId }), + + // Phase 2: Enhanced model management + + testModelConnection: (id: string) => post(`/admin/models/${id}/test-connection`), + + testAllModels: () => post('/admin/models/test-all'), + + fetchProviderModels: (id: string) => post(`/admin/models/providers/${id}/fetch-models`), + + createAdminModel: (data: any) => post('/admin/models/', data), + + updateAdminModel: (id: string, data: any) => put(`/admin/models/${id}`, data), + + deleteAdminModel: (id: string) => del(`/admin/models/${id}`), + + getModelStats: () => get('/admin/models/stats'), + + // 网站设置 + + getSiteSettings: () => get('/admin/settings/site'), + + updateSiteSettings: (data: any) => put('/admin/settings/site', data), + + getEmailConfig: () => get('/admin/settings/email'), + + updateEmailConfig: (data: any) => put('/admin/settings/email', data), + + testEmail: (params?: { test_to?: string; test_subject?: string; test_body?: string }) => { + + const qs = new URLSearchParams(); + + if (params?.test_to) qs.set('test_to', params.test_to); + + if (params?.test_subject) qs.set('test_subject', params.test_subject); + + if (params?.test_body) qs.set('test_body', params.test_body); + + const qstr = qs.toString(); + + return post(`/admin/settings/email/test${qstr ? `?${qstr}` : ''}`); + + }, + + generateEmailContent: (topic: string) => post(`/admin/settings/email/generate-content?topic=${encodeURIComponent(topic)}`), + + getSmsConfig: () => get('/admin/settings/sms'), + + updateSmsConfig: (data: any) => put('/admin/settings/sms', data), + + testSms: () => post('/admin/settings/sms/test'), + + getSmsBalance: () => post('/admin/settings/sms/balance'), + + // 支付配置 + + getPaymentConfigs: () => get('/admin/payment/configs'), + + updatePaymentConfig: (id: string, data: any) => put(`/admin/payment/configs/${id}`, data), + + getAdminOrders: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/payment/orders${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + refundOrder: (id: string) => put(`/admin/payment/orders/${id}/refund`), + + // 增值服务 + + getServices: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/services/${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + addService: (data: any) => post('/admin/services/', data), + + updateService: (id: string, data: any) => put(`/admin/services/${id}`, data), + + deleteService: (id: string) => del(`/admin/services/${id}`), + + getCustomOrders: (status?: string) => get(`/admin/services/custom-orders${status ? `?status=${status}` : ''}`), + + updateCustomOrder: (id: string, data: any) => put(`/admin/services/custom-orders/${id}`, data), + + getCustomRequests: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/services/custom-requests${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + updateCustomRequest: (id: string, data: any) => put(`/admin/services/custom-requests/${id}`, data), + + getAvailableAgents: () => get('/admin/services/agents'), + + assignAgentToService: (id: string, agentId: string) => put(`/admin/services/${id}/agent?agent_id=${agentId}`), + + // 会员管理(增强版) + + getPlans: () => get('/admin/membership/plans'), + + addPlan: (params: string) => post(`/admin/membership/plans${params}`), + + updatePlan: (id: string, params: string) => put(`/admin/membership/plans/${id}${params}`), + + deletePlan: (id: string) => del(`/admin/membership/plans/${id}`), + + getSubscriptions: (page?: number) => get(`/admin/membership/subscriptions${page ? `?page=${page}` : ''}`), + + getAutomationRules: () => get('/admin/membership/automation-rules'), + + getBenefitsComparison: () => get('/admin/membership/benefits-comparison'), + + getMembershipConfigs: () => get('/admin/membership/global-configs'), + + updateMembershipConfig: (key: string, params: string) => put(`/admin/membership/global-configs/${key}${params}`), + + getUserMemberships: (page?: number, planId?: string, status?: string) => { + + const p = new URLSearchParams(); + + if (page) p.append('page', String(page)); + + if (planId) p.append('plan_id', planId); + + if (status) p.append('status', status); + + const qs = p.toString(); + + return get(`/admin/membership/user-memberships${qs ? `?${qs}` : ''}`); + + }, + + assignPlan: (userId: string, planId: string, days: number) => put(`/admin/membership/users/${userId}/assign-plan?plan_id=${planId}&duration_days=${days}`), + + getPaymentLogs: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/logs/payment-logs${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + getAuditLogs: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/logs/audit-logs${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + // 新增:日志统计 + + getLogStats: () => get('/admin/logs/stats'), + + // 新增:API调用统计 + + getApiStats: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/logs/api-stats${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + // 新增:慢请求记录 + + getSlowRequests: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/logs/slow-requests${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + // 新增:操作类型枚举 + + getActionTypes: () => get('/admin/logs/action-types'), + + // 新增:审计日志导出 + + exportAuditLogs: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/logs/export/audit-logs${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + exportOperationLogs: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/logs/export/operations${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + exportTokenLogs: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/logs/export/token-logs${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + getRiskRules: () => get('/admin/logs/risk-rules'), + + addRiskRule: (data: any) => post('/admin/logs/risk-rules', data), + + updateRiskRule: (id: string, data: any) => put(`/admin/logs/risk-rules/${id}`, data), + + deleteRiskRule: (id: string) => del(`/admin/logs/risk-rules/${id}`), + + // 技能管理 (Phase 2.2) + + getAdminSkills: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); }); + + return get(`/admin/skills/${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + getAdminSkill: (id: string) => get(`/admin/skills/${id}`), + + createAdminSkill: (data: any) => post('/admin/skills/', data), + + updateAdminSkill: (id: string, data: any) => put(`/admin/skills/${id}`, data), + + deleteAdminSkill: (id: string) => del(`/admin/skills/${id}`), + + changeAdminSkillState: (id: string, data: any) => put(`/admin/skills/${id}/state`, data), + + getAdminSkillVersions: (id: string) => get(`/admin/skills/${id}/versions`), + + rollbackAdminSkill: (id: string, data: any) => post(`/admin/skills/${id}/rollback`, data), + + publishAdminSkill: (id: string, data: any) => post(`/admin/skills/${id}/publish`, data), + + unpublishAdminSkill: (id: string) => post(`/admin/skills/${id}/unpublish`), + + getAdminSkillsStats: () => get('/admin/skills/stats/overview'), + +// 智能体管理 (Phase 2.3) + + getAdminAgents: (params?: any) => { + + const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : ''; + + return get(`/admin/agents/${query}`); + + }, + + getAdminAgent: (id: string) => get(`/admin/agents/${id}`), + + createAdminAgent: (data: any) => post('/admin/agents/', data), + + updateAdminAgent: (id: string, data: any) => put(`/admin/agents/${id}`, data), + + deleteAdminAgent: (id: string) => del(`/admin/agents/${id}`), + + toggleAdminAgent: (id: string, isActive: boolean) => put(`/admin/agents/${id}/toggle`, { is_active: isActive }), + + getAdminAgentsStats: () => get('/admin/agents/stats/overview'), + + getAdminAgentReviews: (id: string) => get(`/admin/agents/${id}/reviews`), + + // Phase 3.1: 工具/MCP 权限沙箱 (tool_policy) + getAgentToolPolicy: (id: string) => get(`/admin/agents/${id}/tool-policy`), + setAgentToolPolicy: (id: string, toolPolicy: any) => put(`/admin/agents/${id}/tool-policy`, { tool_policy: toolPolicy }), + + // Phase 3.3: 多 Agent 编排图 (orchestration) + getAgentOrchestration: (id: string) => get(`/admin/agents/${id}/orchestration`), + setAgentOrchestration: (id: string, orchestration: any) => put(`/admin/agents/${id}/orchestration`, { orchestration }), + importYamlOrch: (id: string, yaml: string) => post(`/admin/agents/${id}/orchestration/yaml/import`, { yaml_text: yaml }), + exportYamlOrch: (id: string) => post(`/admin/agents/${id}/orchestration/yaml/export`), + getYamlTemplates: () => get('/admin/agents/orchestration/yaml/templates'), + getAcaProtocols: () => get("/admin/agents/aca/protocols"), + getAcaProtocolTools: (name: string) => get(`/admin/agents/aca/protocols/${name}/tools`), + checkAcaHealth: (name: string) => post(`/admin/agents/aca/protocols/${name}/health`, {}), + + getAdminAvailableSkills: (params?: any) => { + + const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : ''; + + return get(`/admin/agents/available/skills${query}`); + + }, + + getAdminAvailableModels: (params?: any) => { + + const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : ''; + + return get(`/admin/agents/available/models${query}`); + + }, + + // 知识库管理 (Phase 2.4) + + getAdminKnowledgeStats: () => get('/admin/knowledge/stats/overview'), + + getAdminKnowledge: (params?: any) => { + + const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : ''; + + return get(`/admin/knowledge/${query}`); + + }, + + getAdminKnowledgeDetail: (id: string) => get(`/admin/knowledge/${id}`), + + createAdminKnowledge: (data: any) => post('/admin/knowledge/', data), + + updateAdminKnowledge: (id: string, data: any) => put(`/admin/knowledge/${id}`, data), + + deleteAdminKnowledge: (id: string) => del(`/admin/knowledge/${id}`), + + toggleAdminKnowledge: (id: string, isActive: boolean) => put(`/admin/knowledge/${id}/toggle`, { is_active: isActive }), + + getAdminKnowledgeDocs: (kbId: string, params?: any) => { + + const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : ''; + + return get(`/admin/knowledge/${kbId}/documents${query}`); + + }, + + deleteAdminKnowledgeDoc: (kbId: string, docId: string) => del(`/admin/knowledge/${kbId}/documents/${docId}`), + + getAdminKnowledgeExtServices: () => get('/admin/knowledge/external/services'), + + createAdminKnowledgeExtService: (data: any) => post('/admin/knowledge/external/services', data), + + updateAdminKnowledgeExtService: (id: string, data: any) => put(`/admin/knowledge/external/services/${id}`, data), + + deleteAdminKnowledgeExtService: (id: string) => del(`/admin/knowledge/external/services/${id}`), + + checkAdminKnowledgeExtStatus: (id: string) => get(`/admin/knowledge/external/services/${id}/status`), + + getAdminAnySearchConfig: () => get('/admin/knowledge/anysearch/config'), + + saveAdminAnySearchConfig: (data: { api_key: string; is_enabled: boolean }) => post('/admin/knowledge/anysearch/config', data), + + syncAdminKnowledge: (kbId: string, data: any) => post(`/admin/knowledge/external/sync/${kbId}`, data), + + batchDeleteAdminKnowledge: (ids: string[]) => post('/admin/knowledge/batch/delete', ids), + + // OSS存储配置 (Phase 3.1) + + getAssetStats: () => get('/admin/assets/stats'), + + getAdminAssets: (query: string) => get(`/admin/assets/?${query}`), + + deleteAdminAsset: (id: string) => del(`/admin/assets/${id}`), + + getAssetActivity: () => get('/admin/assets/activity'), + + getAdminStorageStats: () => get('/admin/storage/stats/overview'), + + getAdminStorageConfigs: (params?: any) => { + + const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : ''; + + return get(`/admin/storage/${query}`); + + }, + + getAdminStorageConfig: (id: string) => get(`/admin/storage/${id}`), + + createAdminStorageConfig: (data: any) => post('/admin/storage/', data), + + updateAdminStorageConfig: (id: string, data: any) => put(`/admin/storage/${id}`, data), + + deleteAdminStorageConfig: (id: string) => del(`/admin/storage/${id}`), + + toggleAdminStorageConfig: (id: string) => put(`/admin/storage/${id}/toggle`), + + setDefaultAdminStorageConfig: (id: string) => put(`/admin/storage/${id}/set-default`), + + testAdminStorageConfig: (data: any) => post('/admin/storage/test', data), + + getAdminStorageUsage: (id: string) => get(`/admin/storage/${id}/usage`), + + // Storage Quota APIs (Phase C5) + + getStorageQuotaOverview: () => get('/admin/storage/quota/overview'), + + getStorageQuotaUsers: (params?: any) => { + + if (params) { + + const query = Object.entries(params).map(([k, v]) => v !== undefined && v !== '' && v !== null ? `${k}=${encodeURIComponent(String(v))}` : '').filter(Boolean).join('&'); + + return get(`/admin/storage/quota/users${query ? '?' + query : ''}`); + + } + + return get('/admin/storage/quota/users'); + + }, + + getStorageQuotaUser: (userId: string) => get(`/admin/storage/quota/users/${userId}`), + + adjustStorageQuota: (userId: string, data: any) => put(`/admin/storage/quota/users/${userId}`, data), + + recalcStorageQuota: (userId: string) => post(`/admin/storage/quota/recalculate/${userId}`, {}), + + initAllStorageQuotas: () => post('/admin/storage/quota/init-all', {}), + + + + // 自动化任务调度 (Phase 3.2) + + getAdminAutomationStats: () => get('/admin/automation/stats/overview'), + + getAdminAutomationTasks: (params?: any) => { + + const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : ''; + + return get(`/admin/automation/${query}`); + + }, + + getAdminAutomationTask: (id: string) => get(`/admin/automation/${id}`), + + createAdminAutomationTask: (data: any) => post('/admin/automation/', data), + + updateAdminAutomationTask: (id: string, data: any) => put(`/admin/automation/${id}`, data), + + deleteAdminAutomationTask: (id: string) => del(`/admin/automation/${id}`), + + toggleAdminAutomationTask: (id: string) => put(`/admin/automation/${id}/toggle`), + + executeAdminAutomationTask: (id: string) => post(`/admin/automation/${id}/execute`), + + getAdminAutomationTaskHistory: (id: string, params?: any) => { + + const query = params ? '?' + Object.entries(params).filter(([_, v]) => v !== undefined && v !== '').map(([k, v]) => `${k}=${v}`).join('&') : ''; + + return get(`/admin/automation/${id}/history${query}`); + + }, + + // MCP Server Management + + getAdminMcpStats: () => get('/admin/mcp/stats/overview'), + + getAdminMcpServers: (params?: any) => { + + const query = new URLSearchParams(params).toString(); + + return get(`/admin/mcp/${query ? '?' + query : ''}`); + + }, + + getAdminMcpServer: (id: string) => get(`/admin/mcp/${id}`), + + createAdminMcpServer: (data: any) => post('/admin/mcp/', data), + + updateAdminMcpServer: (id: string, data: any) => put(`/admin/mcp/${id}`, data), + + deleteAdminMcpServer: (id: string) => del(`/admin/mcp/${id}`), + + toggleAdminMcpServer: (id: string) => put(`/admin/mcp/${id}/toggle`), + + testAdminMcpServer: (id: string, data?: any) => post(`/admin/mcp/${id}/test`, data || {}), + + getAdminMcpServerTools: (id: string) => get(`/admin/mcp/${id}/tools`), + + getAdminMcpServerResources: (id: string) => get(`/admin/mcp/${id}/resources`), + + // Developer API Management + + getAdminDeveloperStats: () => get('/admin/developer/stats/overview'), + + getAdminApiKeys: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/developer/api-keys${sp.toString() ? '?' + sp.toString() : ''}`); + + }, + + createAdminApiKey: (data: any) => post('/admin/developer/api-keys/', data), + + updateAdminApiKey: (id: string, data: any) => put(`/admin/developer/api-keys/${id}`, data), + + revokeAdminApiKey: (id: string) => post(`/admin/developer/api-keys/${id}/revoke`), + + deleteAdminApiKey: (id: string) => del(`/admin/developer/api-keys/${id}`), + + getAdminOpenApiClients: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/developer/openapi-clients${sp.toString() ? '?' + sp.toString() : ''}`); + + }, + + createAdminOpenApiClient: (data: any) => post('/admin/developer/openapi-clients/', data), + + updateAdminOpenApiClient: (id: string, data: any) => put(`/admin/developer/openapi-clients/${id}`, data), + + resetAdminOpenApiClientSecret: (id: string) => post(`/admin/developer/openapi-clients/${id}/reset-secret`), + + deleteAdminOpenApiClient: (id: string) => del(`/admin/developer/openapi-clients/${id}`), + + getAdminCallStats: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/developer/call-stats${sp.toString() ? '?' + sp.toString() : ''}`); + + }, + + getAdminCallStatsOverview: () => get('/admin/developer/call-stats/overview'), + + getAdminSlowLogs: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/developer/slow-logs${sp.toString() ? '?' + sp.toString() : ''}`); + + }, + + clearAdminSlowLogs: () => del('/admin/developer/slow-logs'), + + getAdminSlowLogsStats: () => get('/admin/developer/slow-logs/stats'), + + // AI App Management + + getAIAppStats: () => get('/admin/apps/stats/overview'), + + getAIApps: (params?: string) => get('/admin/apps/apps' + (params ? '?' + params : '')), + + getAIApp: (id: string) => get('/admin/apps/apps/' + id), + + createAIApp: (data: any) => post('/admin/apps/apps', data), + + updateAIApp: (id: string, data: any) => put('/admin/apps/apps/' + id, data), + + deleteAIApp: (id: string) => del('/admin/apps/apps/' + id), + + aiAppLifecycle: (id: string, action: string) => post('/admin/apps/apps/' + id + '/' + action, {}), + + getAIAppVersions: (id: string) => get('/admin/apps/apps/' + id + '/versions'), + + getAIAppAnalytics: (id: string) => get('/admin/apps/apps/' + id + '/analytics'), + + getAIAppTemplates: () => get('/admin/apps/templates'), + + createAIAppTemplate: (data: any) => post('/admin/apps/templates', data), + + createAIAppFromTemplate: (templateId: string, data: any) => post('/admin/apps/apps/from-template/' + templateId, data), + + getAIAppCategories: () => get('/admin/apps/categories'), + + // Logo 上传 + + uploadLogo: (formData: FormData) => { + + const token = localStorage.getItem('access_token'); + + return fetch(`/app/api/admin/settings/logo/upload`, { + + method: 'POST', + + headers: { 'Authorization': `Bearer ${token}` }, + + body: formData, + + }).then(res => res.json()); + + }, + + + + // MCP Market (Phase G1) + + getMcpMarketStats: () => + + get('/admin/mcp-market/stats'), + + getMcpTools: (params?: Record) => + + get('/admin/mcp-market/?' + new URLSearchParams(params || {}).toString()), + + getMcpToolDetail: (id: string) => + + get('/admin/mcp-market/' + id), + + createMcpTool: (data: any) => + + post('/admin/mcp-market/', data), + + updateMcpTool: (id: string, data: any) => + + put('/admin/mcp-market/' + id, data), + + deleteMcpTool: (id: string) => + + del('/admin/mcp-market/' + id), + + scanMcpTool: (id: string) => + + post('/admin/mcp-market/' + id + '/security-scan'), + + updateMcpToolSecurity: (id: string, data: any) => + + put('/admin/mcp-market/' + id + '/security-status', data), + + regenMcpDeepLink: (id: string) => + + post('/admin/mcp-market/' + id + '/deep-link'), + + getMcpManagedServers: () => + + get('/admin/mcp-market/managed/servers'), + + seedMcpTools: () => + + post('/admin/mcp-market/seed'), + + + + // Code Execution Sandbox (Phase G2) + + getCodeExecStats: () => get('/admin/code-exec/stats'), + + getCodeExecRuntimes: () => get('/admin/code-exec/runtimes'), + + executeCode: (data: { language: string; code: string; stdin?: string; timeout?: number }) => + + post('/admin/code-exec/execute', data), + + getCodeExecTasks: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/code-exec/tasks${sp.toString() ? '?' + sp.toString() : ''}`); + + }, + + getCodeExecTask: (id: string) => get(`/admin/code-exec/tasks/${id}`), + + deleteCodeExecTask: (id: string) => del(`/admin/code-exec/tasks/${id}`), + + + + + + // Distribution Builder (Phase G4) + + getDistributionStats: () => get('/admin/distributions/stats'), + + getDistributions: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]: [string, any]) => { if (v) sp.set(k, String(v)); }); + + return get('/admin/distributions' + (sp.toString() ? '?' + sp.toString() : '')); + + }, + + getDistributionTemplates: () => get('/admin/distributions/templates'), + + createDistribution: (data: any) => post('/admin/distributions', data), + + getDistribution: (id: string) => get('/admin/distributions/' + id), + + updateDistribution: (id: string, data: any) => put('/admin/distributions/' + id, data), + + deleteDistribution: (id: string) => del('/admin/distributions/' + id), + + getDistributionLicenses: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]: [string, any]) => { if (v) sp.set(k, String(v)); }); + + return get('/admin/distributions/licenses' + (sp.toString() ? '?' + sp.toString() : '')); + + }, + + createDistributionLicense: (distroId: string, data: any) => post('/admin/distributions/' + distroId + '/licenses', data), + + getDistributionLicense: (id: string) => get('/admin/distributions/licenses/' + id), + + revokeDistributionLicense: (id: string) => post('/admin/distributions/licenses/' + id + '/revoke'), + + // ===== Ads Management ===== + + getAds: () => get('/admin/ads/'), + + addAd: (data: any) => post('/admin/ads/', data), + + updateAd: (id: string, data: any) => put(`/admin/ads/${id}`, data), + + deleteAd: (id: string) => del(`/admin/ads/${id}`), + + + + // ===== Market Admin (extended) ===== + + getMarketOverview: () => get('/admin/market/overview'), + + getTopApps: (metric?: string, limit?: number) => get(`/admin/market/top-apps${metric ? '?metric=' + metric : ''}${limit ? (metric ? '&' : '?') + 'limit=' + limit : ''}`), + + getAdminMarketApps: (params?: string) => get(`/admin/market/apps${params ? '?' + params : ''}`), + + getPendingApps: () => get('/admin/market/pending'), + + getCoupons: () => get('/admin/market/coupons'), + + getRecentReviews: () => get('/admin/market/reviews'), + + approveApp: (id: string) => post(`/admin/market/apps/${id}/approve`), + + rejectApp: (id: string) => post(`/admin/market/apps/${id}/reject`), + + setFeaturedApp: (id: string, featured: boolean, sortOrder?: number) => put(`/admin/market/apps/${id}/featured`, { featured, sort_order: sortOrder }), + + deleteMarketApp: (id: string) => del(`/admin/market/apps/${id}`), + + deleteCoupon: (id: string) => del(`/admin/market/coupons/${id}`), + + updateMarketApp: (id: string, data: any) => put(`/admin/market/apps/${id}`, data), + + // Phase 4.1: 安检 + 版本化 + 绿标 + rescanApp: (id: string) => post(`/admin/market/apps/${id}/scan`), + getAppVersions: (id: string) => get(`/admin/market/apps/${id}/versions`), + rollbackApp: (id: string, versionId: string) => post(`/admin/market/apps/${id}/rollback?version_id=${versionId}`), + + updateCoupon: (id: string, data: any) => put(`/admin/market/coupons/${id}`, data), + + addCoupon: (data: any) => post('/admin/market/coupons', data), + + + + // ===== Membership (extended) ===== + + getMembershipStats: () => get('/admin/membership/stats'), + + revokeMembership: (userId: string) => post(`/admin/membership/users/${userId}/revoke`), + + + + // ===== Logs (extended) ===== + + getOperationLogs: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/logs/operation-logs${sp.toString() ? '?' + sp.toString() : ''}`); + + }, + + getTokenLogs: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/logs/token-logs${sp.toString() ? '?' + sp.toString() : ''}`); + + }, + + + + // ===== Analytics ===== + + getAnalyticsOverview: (days?: number) => get(`/admin/analytics/overview${days ? '?days=' + days : ''}`), + + getAnalyticsApiTrends: (days?: number) => get(`/admin/analytics/trends/api-calls${days ? '?days=' + days : ''}`), + + getAnalyticsTokenTrends: (days?: number) => get(`/admin/analytics/trends/tokens${days ? '?days=' + days : ''}`), + + getAnalyticsUserTrends: (days?: number) => get(`/admin/analytics/trends/users${days ? '?days=' + days : ''}`), + + getAnalyticsRevenueTrends: (days?: number) => get(`/admin/analytics/trends/revenue${days ? '?days=' + days : ''}`), + + getAnalyticsModelUsage: (days?: number) => get(`/admin/analytics/models/usage${days ? '?days=' + days : ''}`), + + + + // ===== Billing ===== + + getBillingRules: () => get('/admin/billing/rules'), + + getBillingPackages: () => get('/admin/billing/packages'), + + getBillingTransactions: (page?: number) => get(`/admin/billing/transactions${page ? '?page=' + page : ''}`), + + getBillingStats: () => get('/admin/billing/stats/overview'), + + + + // ===== Tenant ===== + + getTenants: (page?: number) => get(`/admin/tenant/list${page ? '?page=' + page : ''}`), + + getTenantStats: () => get('/admin/tenant/stats/overview'), + + getTenantMembers: (tenantId: string) => get(`/admin/tenant/${tenantId}/members`), + + createTenant: (params: string) => post(`/admin/tenant/create${params}`), + + + +}; + +// ===== AI自进化API ===== + +export const evolutionAPI = { + + // 驾驶舱面板 + + getSkillTree: () => get('/evolution/dashboard/skill-tree'), + + getTimeline: (eventType?: string) => + + get('/evolution/dashboard/timeline' + (eventType ? `?event_type=${eventType}` : '')), + + getStatistics: () => get('/evolution/dashboard/statistics'), + + getProfile: () => get('/evolution/dashboard/profile'), + + + + // 技能管理 + + getSkills: (params?: { state?: string; keyword?: string; sort_by?: string; order?: string }) => { + + const query = new URLSearchParams(); + + if (params?.state) query.append('state', params.state); + + if (params?.keyword) query.append('keyword', params.keyword); + + if (params?.sort_by) query.append('sort_by', params.sort_by); + + if (params?.order) query.append('order', params.order); + + const qs = query.toString(); + + return get('/evolution/skills' + (qs ? `?${qs}` : '')); + + }, + + getSkillDetail: (name: string) => get(`/evolution/skills/${encodeURIComponent(name)}`), + + updateSkill: (name: string, data: { description?: string; tags?: string[]; category?: string }) => + + put(`/evolution/skills/${encodeURIComponent(name)}`, data), + + deleteSkill: (name: string) => del(`/evolution/skills/${encodeURIComponent(name)}`), + + freezeSkill: (name: string) => post(`/evolution/skills/${encodeURIComponent(name)}/freeze`), + + unfreezeSkill: (name: string) => post(`/evolution/skills/${encodeURIComponent(name)}/unfreeze`), + + getSkillHistory: (name: string) => get(`/evolution/skills/${encodeURIComponent(name)}/history`), + + getSkillTrajectories: (name: string) => get(`/evolution/skills/${encodeURIComponent(name)}/trajectories`), + + + + // 事件 + + getEvents: (params?: { event_type?: string; skill_name?: string; limit?: number }) => { + + const query = new URLSearchParams(); + + if (params?.event_type) query.append('event_type', params.event_type); + + if (params?.skill_name) query.append('skill_name', params.skill_name); + + if (params?.limit) query.append('limit', String(params.limit)); + + const qs = query.toString(); + + return get('/evolution/events' + (qs ? `?${qs}` : '')); + + }, + + + + // 轨迹提交 + + submitTrajectory: (data: { conversation_id: string; messages: any[] }) => + + post('/evolution/trajectory/submit', data), + + + + // v2: 技能回滚 + + rollbackSkill: (skillName: string, version: number) => + + post(`/evolution/skills/${encodeURIComponent(skillName)}/rollback/${version}`), + + + + // v2: 发布到市场 + + publishSkill: (skillName: string, data: { price: number; market_description: string }) => + + post(`/evolution/skills/${encodeURIComponent(skillName)}/publish`, data), + + + + // v2: 团队共享 + + shareToTeam: (skillName: string, teamId: string) => + + post(`/evolution/skills/${encodeURIComponent(skillName)}/share-to-team`, { team_id: teamId }), + + + + // v2: 流水线状态 + + getPipelineStatus: () => get('/evolution/pipeline/status'), + + + + // v2: 手动触发流水线 + + triggerPipeline: () => post('/evolution/pipeline/trigger'), + + + + // v2: Watcher状态 + + getWatcherStatus: () => get('/evolution/watcher/status'), + + + + // v2: 进化报告 + + getReports: (reportType?: string) => { + + const qs = reportType ? `?report_type=${reportType}` : ''; + + return get(`/evolution/reports${qs}`); + + }, + + getReportDetail: (reportId: string) => get(`/evolution/reports/${reportId}`), + + + + + + // Workflow (Phase 3.6) + + getWorkflows: (params?: Record) => + + get('/admin/workflows/?' + new URLSearchParams(params || {}).toString()), + + getWorkflow: (id: string) => + + get(`/admin/workflows/${id}`), + + getWorkflowStats: () => + + get('/admin/workflows/stats'), + + getWorkflowTemplates: () => + + get('/admin/workflows/templates'), + + createWorkflow: (data: any) => + + post('/admin/workflows/', data), + + updateWorkflow: (id: string, data: any) => + + put(`/admin/workflows/${id}`, data), + + deleteWorkflow: (id: string) => + + del(`/admin/workflows/${id}`), + + toggleWorkflow: (id: string, isActive: boolean) => + + post(`/admin/workflows/${id}/toggle?is_active=${isActive}`), + + executeWorkflow: (id: string, inputText: string) => + + post(`/admin/workflows/${id}/execute`, { input_text: inputText }), + + + + + + // Privacy & Local Agent (Phase G5) + + getPrivacyStats: () => get('/admin/privacy/stats'), + + getPrivacyConfig: () => get('/admin/privacy/config'), + + updatePrivacyConfig: (params: any) => { + + const sp = new URLSearchParams(); + + Object.entries(params).forEach(([k, v]) => { if (v != null) sp.append(k, String(v)); }); + + return put('/admin/privacy/config?' + sp.toString()); + + }, + + switchPrivacyMode: (mode: string) => post('/admin/privacy/switch-mode?mode=' + encodeURIComponent(mode)), + + getPrivacyLicenses: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params?.status) sp.append('status', params.status); + + return get('/admin/privacy/licenses' + (sp.toString() ? '?' + sp.toString() : '')); + + }, + + generateOfflineLicense: (data: any) => { + + const sp = new URLSearchParams(); + + if (data.agent_id) sp.append('agent_id', data.agent_id); + + sp.append('max_offline_days', String(data.max_offline_days || 7)); + + return post('/admin/privacy/licenses/generate?' + sp.toString()); + + }, + + revokePrivacyLicense: (id: number) => post('/admin/privacy/licenses/' + id + '/revoke'), + + validatePrivacyLicense: (licenseKey: string) => { + + const sp = new URLSearchParams(); + + sp.append('license_key', licenseKey); + + return post('/admin/privacy/licenses/validate?' + sp.toString()); + + }, + + getKbSyncStates: () => get('/admin/privacy/kb-sync'), + + triggerKbSync: (kbId: number, direction: string) => { + + const sp = new URLSearchParams(); + + sp.append('knowledge_base_id', String(kbId)); + + sp.append('sync_direction', direction); + + return post('/admin/privacy/kb-sync/trigger?' + sp.toString()); + + }, + + getLocalAgents: () => get('/admin/privacy/local-agents'), + + getPrivacyUsageStats: () => get('/admin/privacy/usage/stats'), + + getBridgeConfig: () => get('/admin/privacy/bridge-config'), + + + + // IM Bot Management (Phase 4) + + getImBotStats: () => get('/admin/im-bot/stats'), + + getImBotDashboard: () => get('/admin/im-bot/dashboard'), + + getImBotChannels: (params?: string) => get(`/admin/im-bot/channels${params || ''}`), + + getImBotChannelDetail: (id: string) => get(`/admin/im-bot/channels/${id}`), + + testImBotChannel: (id: string) => post(`/admin/im-bot/channels/${id}/test`), + + activateImBotChannel: (id: string) => post(`/admin/im-bot/channels/${id}/activate`), + + deactivateImBotChannel: (id: string) => post(`/admin/im-bot/channels/${id}/deactivate`), + + getImBotRoutes: (params?: string) => get(`/admin/im-bot/routes${params || ''}`), + + createImBotRoute: (params: string) => post(`/admin/im-bot/routes${params}`), + + deleteImBotRoute: (id: string) => del(`/admin/im-bot/routes/${id}`), + + // 个人微信(iLink 协议)原生适配器 + + getWechatConfig: () => get('/admin/im-bot/wechat'), + + wechatQrcode: () => post('/admin/im-bot/wechat/qrcode'), + + wechatQrcodeStatus: (params: string) => post(`/admin/im-bot/wechat/qrcode/status${params}`), + + getWechatStatus: () => get('/admin/im-bot/wechat/status'), + + disconnectWechat: () => post('/admin/im-bot/wechat/disconnect'), + + getImBotPlatforms: () => get('/admin/im-bot/platforms'), + + getImBotWebhookUrl: (platform: string) => get(`/admin/im-bot/webhook-url/${platform}`), + + getImBotMessages: (params?: string) => get(`/admin/im-bot/messages${params || ''}`), + + imBotQuickSetup: (params: string) => post(`/admin/im-bot/quick-setup${params}`), + + + + // 推广系统 + + getPromotionStats: () => get('/admin/promotion/stats'), + + getPromotions: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); }); + + return get(`/admin/promotion/promotions${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + createPromotion: (params: any) => { + + const sp = new URLSearchParams(); + + Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); }); + + return post(`/admin/promotion/promotions${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + updatePromotion: (id: string, params: any) => { + + const sp = new URLSearchParams(); + + Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') sp.set(k, String(v)); }); + + return put(`/admin/promotion/promotions/${id}${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + deletePromotion: (id: string) => del(`/admin/promotion/promotions/${id}`), + + togglePromotion: (id: string) => post(`/admin/promotion/promotions/${id}/toggle`), + + getReferralLinks: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/promotion/referral-links${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + generateReferralLink: (params: any) => { + + const sp = new URLSearchParams(); + + Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return post(`/admin/promotion/referral-links/generate${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + getReferralRewards: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/promotion/rewards${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + approveReward: (id: string) => post(`/admin/promotion/rewards/${id}/approve`), + + markRewardPaid: (id: string, params: any) => { + + const sp = new URLSearchParams(); + + Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return post(`/admin/promotion/rewards/${id}/pay${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + getPromotionLeaderboard: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/promotion/leaderboard${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + regenerateReferralCode: (id: string) => post(`/admin/promotion/referral-links/${id}/regenerate`), + + getMyReferrals: () => get('/admin/promotion/my-referrals'), + + getMyRewards: () => get('/admin/promotion/my-rewards'), + + + + // 成就系统 + + getAchievements: (params?: string) => get(`/admin/achievement/achievements${params || ''}`), + + getAchievementDetail: (id: string) => get(`/admin/achievement/achievements/${id}`), + + createAchievement: (params: string) => post(`/admin/achievement/achievements${params}`), + + updateAchievement: (id: string, params: string) => put(`/admin/achievement/achievements/${id}${params}`), + + deleteAchievement: (id: string) => del(`/admin/achievement/achievements/${id}`), + + getUserAchievements: () => get('/admin/achievement/user/achievements'), + + claimAchievementReward: (id: string) => post(`/admin/achievement/user/achievements/${id}/claim`), + + checkAchievementProgress: () => post('/admin/achievement/check-progress'), + + getAchievementLeaderboard: (params?: string) => get(`/admin/achievement/leaderboard${params || ''}`), + + + + // 多租户管理 + + getTenants: (page?: number, keyword?: string, status?: string) => { + + const sp = new URLSearchParams(); + + if (page) sp.set('page', String(page)); + + if (keyword) sp.set('keyword', keyword); + + if (status) sp.set('status', status); + + sp.set('page_size', '20'); + + return get(`/admin/tenant/list${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + createTenant: (params: string) => post(`/admin/tenant/create${params}`), + + getTenantDetail: (id: string) => get(`/admin/tenant/${id}`), + + updateTenant: (id: string, params: string) => put(`/admin/tenant/${id}${params}`), + + deleteTenant: (id: string) => del(`/admin/tenant/${id}`), + + getTenantMembers: (tenantId: string, page?: number) => { + + const sp = new URLSearchParams(); + + sp.set('page', String(page || 1)); + + sp.set('page_size', '100'); + + return get(`/admin/tenant/${tenantId}/members?${sp.toString()}`); + + }, + + addTenantMember: (tenantId: string, params: string) => post(`/admin/tenant/${tenantId}/members/add${params}`), + + updateTenantMember: (tenantId: string, memberId: string, params: string) => put(`/admin/tenant/${tenantId}/members/${memberId}${params}`), + + removeTenantMember: (tenantId: string, memberId: string) => del(`/admin/tenant/${tenantId}/members/${memberId}`), + + getTenantQuotas: (tenantId: string) => get(`/admin/tenant/${tenantId}/quotas`), + + updateTenantQuota: (tenantId: string, params: string) => put(`/admin/tenant/${tenantId}/quotas${params}`), + + getTenantInvitations: (tenantId: string) => get(`/admin/tenant/${tenantId}/invitations`), + + createTenantInvitation: (tenantId: string, params: string) => post(`/admin/tenant/${tenantId}/invitations${params}`), + + getTenantStats: () => get('/admin/tenant/stats/overview'), + + + + // 计费与支付 + + getBillingRules: (page?: number) => get(`/admin/billing/rules?page=${page || 1}&page_size=100`), + + createBillingRule: (params: string) => post(`/admin/billing/rules/create${params}`), + + updateBillingRule: (id: string, params: string) => put(`/admin/billing/rules/${id}${params}`), + + deleteBillingRule: (id: string) => del(`/admin/billing/rules/${id}`), + + getBillingPackages: () => get('/admin/billing/packages'), + + createBillingPackage: (params: string) => post(`/admin/billing/packages/create${params}`), + + updateBillingPackage: (id: string, params: string) => put(`/admin/billing/packages/${id}${params}`), + + deleteBillingPackage: (id: string) => del(`/admin/billing/packages/${id}`), + + getBillingTransactions: (page?: number) => get(`/admin/billing/transactions?page=${page || 1}&page_size=20`), + + getInvoices: (page?: number) => get(`/admin/billing/invoices?page=${page || 1}&page_size=20`), + + generateInvoice: (params: string) => post(`/admin/billing/invoices/generate${params}`), + + getPaymentConfigs: () => get('/admin/billing/payment-configs'), + + updatePaymentConfig: (id: string, params: string) => put(`/admin/billing/payment-configs/${id}${params}`), + + getBillingStats: () => get('/admin/billing/stats/overview'), + + + + // 数据分析 + + getAnalyticsOverview: (days?: number) => get(`/admin/analytics/overview?days=${days || 30}`), + + getAnalyticsApiTrends: (days?: number) => get(`/admin/analytics/trends/api-calls?days=${days || 7}`), + + getAnalyticsTokenTrends: (days?: number) => get(`/admin/analytics/trends/tokens?days=${days || 7}`), + + getAnalyticsUserTrends: (days?: number) => get(`/admin/analytics/trends/users?days=${days || 30}`), + + getAnalyticsRevenueTrends: (days?: number) => get(`/admin/analytics/trends/revenue?days=${days || 30}`), + + getAnalyticsModelUsage: (days?: number) => get(`/admin/analytics/models/usage?days=${days || 30}`), + + getAnalyticsDashboards: () => get('/admin/analytics/dashboards'), + + createAnalyticsDashboard: (params: string) => post(`/admin/analytics/dashboards/create${params}`), + + deleteAnalyticsDashboard: (id: string) => del(`/admin/analytics/dashboards/${id}`), + + getAnalyticsEvents: (params?: any) => { + + const sp = new URLSearchParams(); + + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + + return get(`/admin/analytics/events${sp.toString() ? `?${sp.toString()}` : ''}`); + + }, + + + +}; + + + + + +// ===== 前台用户个人微信 (iLink 协议 / 微信 ClawBot 插件) ===== + +export const imUserAPI = { + + getStatus: () => get('/im/wechat/status'), + + qrcode: () => post('/im/wechat/qrcode'), + + qrcodeStatus: (qrcode: string) => post(`/im/wechat/qrcode/status?qrcode=${encodeURIComponent(qrcode)}`), + + disconnect: () => post('/im/wechat/disconnect'), + + history: () => get('/im/wechat/history'), + +}; + diff --git a/frontend/src/components/layout/AdminLayout.css b/frontend/src/components/layout/AdminLayout.css index 632401b..68134bb 100644 --- a/frontend/src/components/layout/AdminLayout.css +++ b/frontend/src/components/layout/AdminLayout.css @@ -1,634 +1,634 @@ -/* ===== Admin Layout (DESIGN.md: 浅色主题 + 品牌色 #5B5FED) ===== */ -.admin-layout { - display: flex; - height: 100vh; - overflow: hidden; - background: var(--color-bg-tertiary); -} - -/* Admin Sidebar —— 浅色侧栏 */ -.admin-sidebar { - width: var(--admin-sidebar-width); - min-width: var(--admin-sidebar-width); - background: var(--color-sidebar-bg); - display: flex; - flex-direction: column; - transition: width var(--transition-slow), min-width var(--transition-slow); - overflow: hidden; - border-right: 1px solid var(--color-border); - z-index: var(--z-sticky); -} - -.admin-sidebar.collapsed { - width: var(--sidebar-collapsed); - min-width: var(--sidebar-collapsed); -} - -.admin-sidebar-header { - display: flex; - align-items: center; - justify-content: center; - gap: var(--space-sm); - padding: var(--space-md) var(--space-lg); - min-height: var(--header-height); - border-bottom: 1px solid var(--color-border); - flex-shrink: 0; -} - -.admin-logo { - margin: 0; - font-size: var(--text-lg); - font-weight: 800; - color: var(--color-text-primary); - letter-spacing: 0.02em; - line-height: 1; - display: flex; - align-items: center; -} -.admin-subtitle { - font-size: var(--text-xs); - font-weight: 500; - color: var(--color-text-tertiary); - line-height: 1; - display: flex; - align-items: center; -} - -.admin-frontend-link { - display: flex; - align-items: center; - gap: var(--space-sm); - margin: var(--space-sm) var(--space-md); - padding: var(--space-sm) var(--space-md); - border-radius: var(--radius-md); - border: 1px solid var(--color-border); - background: var(--color-bg-secondary); - color: var(--color-text-secondary); - font-size: var(--text-xs); - font-weight: 500; - cursor: pointer; - text-decoration: none; - transition: all var(--transition-fast); -} -.admin-frontend-link:hover { - background: var(--color-accent-light); - border-color: var(--color-accent-light); - color: var(--color-accent); -} - -/* Admin Nav (侧栏分组 + 菜单项) */ -.sidebar-nav { - flex: 1; - padding: var(--space-sm) var(--space-xs); - overflow-y: auto; - overflow-x: hidden; - display: flex; - flex-direction: column; - gap: 2px; -} - -.nav-group { - display: flex; - flex-direction: column; -} - -.nav-group-header { - display: flex; - align-items: center; - gap: var(--space-sm); - padding: var(--space-sm) var(--space-md); - border-radius: var(--radius-md); - cursor: pointer; - user-select: none; - color: var(--color-text-secondary); - transition: background var(--transition-fast), color var(--transition-fast); -} -.nav-group-header:hover { - background: var(--color-sidebar-hover); - color: var(--color-text-primary); -} -.nav-group-active > .nav-group-header { - color: var(--color-text-primary); - font-weight: 600; -} - -.nav-group-chevron-wrap { - display: inline-flex; - width: 14px; - height: 14px; - flex-shrink: 0; -} -.nav-group-chevron { - display: inline-flex; - color: var(--color-text-tertiary); - transition: transform var(--transition-fast); -} -.nav-group-chevron.chevron-expanded { - transform: rotate(180deg); -} -.nav-group-title { - font-size: var(--text-sm); - font-weight: 600; - letter-spacing: 0.01em; -} - -.nav-group-items { - display: flex; - flex-direction: column; - gap: 2px; - padding: 2px 0 6px; - margin-left: var(--space-lg); - border-left: 1px solid var(--color-border-light); -} - -.nav-item { - display: flex; - align-items: center; - gap: var(--space-md); - padding: 9px var(--space-md); - margin-left: var(--space-sm); - border-radius: var(--radius-md); - color: var(--color-text-secondary); - text-decoration: none; - font-size: var(--text-sm); - font-weight: 500; - transition: all var(--transition-fast); - white-space: nowrap; -} -.nav-item:hover { - background: var(--color-sidebar-hover); - color: var(--color-text-primary); -} -.nav-item.active, -.nav-item-active { - background: var(--color-accent-light); - color: var(--color-accent); -} -.nav-item.active .nav-item-icon, -.nav-item-active .nav-item-icon { - color: var(--color-accent); -} - -.nav-item-icon { - display: inline-flex; - flex-shrink: 0; - color: var(--color-text-tertiary); -} -.nav-item-label { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; -} - -/* Sidebar footer */ -.admin-sidebar-footer { - padding: var(--space-sm); - border-top: 1px solid var(--color-border); - display: flex; - flex-direction: column; - gap: 2px; - flex-shrink: 0; -} -.admin-user-info { - display: flex; - align-items: center; - gap: var(--space-sm); - padding: var(--space-xs) var(--space-sm); -} -.admin-user-avatar { - width: 34px; - height: 34px; - border-radius: var(--radius-full); - background: var(--color-accent); - color: #FFFFFF; - display: inline-flex; - align-items: center; - justify-content: center; - font-weight: 700; - font-size: var(--text-sm); - flex-shrink: 0; -} -.admin-user-detail { - display: flex; - flex-direction: column; - min-width: 0; -} -.admin-user-name { - font-size: var(--text-sm); - font-weight: 600; - color: var(--color-text-primary); -} -.admin-logout-btn { - background: none; - border: none; - padding: 0; - margin-top: 2px; - font-size: var(--text-xs); - color: var(--color-text-tertiary); - cursor: pointer; - text-align: left; - font-family: inherit; -} -.admin-logout-btn:hover { - color: var(--color-error); -} - -/* Admin Main */ -.admin-main { - flex: 1; - display: flex; - flex-direction: column; - min-width: 0; - overflow: hidden; -} - -.admin-topbar { - display: flex; - align-items: center; - justify-content: space-between; - height: var(--header-height); - padding: 0 var(--space-2xl); - background: #FFFFFF; - border-bottom: 1px solid var(--color-border); - flex-shrink: 0; -} - -.admin-topbar-left { - display: flex; - align-items: center; -} - -.admin-welcome { - font-size: var(--text-sm); - color: var(--color-text-secondary); -} - -.admin-topbar-right { - display: flex; - align-items: center; - gap: var(--space-lg); -} - -.admin-user-tag { - display: flex; - align-items: center; - gap: var(--space-sm); - font-size: var(--text-sm); - font-weight: 500; - color: var(--color-text-primary); -} - -.admin-topbar-action { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 14px; - border-radius: var(--radius-md); - border: 1px solid var(--color-border); - background: var(--color-bg-primary); - color: var(--color-accent); - font-size: var(--text-sm); - font-weight: 500; - text-decoration: none; - cursor: pointer; - transition: all var(--transition-fast); -} - -.admin-topbar-action:hover { - background: var(--color-accent); - color: #FFFFFF; - border-color: var(--color-accent); -} - -/* Admin Content Area */ -.admin-content { - flex: 1; - overflow-y: auto; - padding: var(--space-2xl); -} - -/* Admin Common Components */ -.admin-page-title { - font-size: var(--text-xl); - font-weight: 700; - color: var(--color-text-primary); - margin-bottom: var(--space-2xl); -} - -.admin-card { - background: #FFFFFF; - border-radius: var(--radius-xl); - border: 1px solid var(--color-border); - padding: var(--space-2xl); - margin-bottom: var(--space-xl); -} - -.admin-card-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: var(--space-xl); -} - -.admin-card-title { - font-size: var(--text-lg); - font-weight: 600; - color: var(--color-text-primary); -} - -.admin-table { - width: 100%; - border-collapse: collapse; -} - -.admin-table th { - text-align: left; - padding: var(--space-md) var(--space-lg); - font-size: var(--text-xs); - font-weight: 600; - color: var(--color-text-tertiary); - text-transform: uppercase; - letter-spacing: 0.05em; - border-bottom: 1px solid var(--color-border); - background: var(--color-bg-tertiary); -} - -.admin-table td { - padding: var(--space-md) var(--space-lg); - font-size: var(--text-sm); - color: var(--color-text-primary); - border-bottom: 1px solid var(--color-border-light); -} - -.admin-table tr:hover td { - background: var(--color-bg-hover); -} - -.admin-badge { - display: inline-flex; - align-items: center; - padding: 2px 10px; - border-radius: var(--radius-full); - font-size: var(--text-xs); - font-weight: 600; - white-space: nowrap; -} - -.badge-success { background: var(--color-success-light); color: var(--color-success); } -.badge-warning { background: var(--color-warning-light); color: var(--color-warning); } -.badge-error { background: var(--color-error-light); color: var(--color-error); } -.badge-info { background: var(--color-info-light); color: var(--color-info); } -.badge-neutral { background: var(--color-bg-tertiary); color: var(--color-text-secondary); } - -.admin-btn { - display: inline-flex; - align-items: center; - gap: var(--space-sm); - padding: var(--space-sm) var(--space-lg); - border-radius: var(--radius-md); - font-size: var(--text-sm); - font-weight: 600; - transition: all var(--transition-fast); - white-space: nowrap; -} - -.admin-btn-primary { - background: var(--color-accent); - color: #FFFFFF; -} -.admin-btn-primary:hover { - background: var(--color-accent-hover); -} - -.admin-btn-outline { - background: transparent; - border: 1px solid var(--color-border); - color: var(--color-text-primary); -} -.admin-btn-outline:hover { - background: var(--color-bg-hover); - border-color: var(--color-border-hover); -} - -.admin-btn-danger { - background: transparent; - border: 1px solid var(--color-error); - color: var(--color-error); -} -.admin-btn-danger:hover { - background: var(--color-error-light); -} - -.admin-btn-sm { - padding: 3px 10px; - font-size: var(--text-xs); -} - -/* Admin Stats */ -.admin-stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: var(--space-lg); - margin-bottom: var(--space-2xl); -} - -.admin-stat-card { - background: #FFFFFF; - border-radius: var(--radius-xl); - border: 1px solid var(--color-border); - padding: var(--space-xl); -} - -.admin-stat-label { - font-size: var(--text-xs); - color: var(--color-text-tertiary); - font-weight: 500; - margin-bottom: var(--space-sm); - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.admin-stat-value { - font-size: var(--text-2xl); - font-weight: 700; - color: var(--color-text-primary); -} - -/* Admin Form */ -.admin-form-group { - margin-bottom: var(--space-xl); -} - -.admin-form-label { - display: block; - font-size: var(--text-sm); - font-weight: 600; - color: var(--color-text-primary); - margin-bottom: var(--space-sm); -} - -.admin-form-input { - width: 100%; - padding: var(--space-md) var(--space-lg); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - font-size: var(--text-sm); - color: var(--color-text-primary); - background: #FFFFFF; - transition: border-color var(--transition-fast); - outline: none; -} - -.admin-form-input:focus { - border-color: var(--color-accent); - box-shadow: 0 0 0 3px var(--color-accent-light); -} - -.admin-form-input::placeholder { - color: var(--color-text-disabled); -} - -.admin-form-textarea { - min-height: 100px; - resize: vertical; -} - -.admin-form-row { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--space-lg); -} - -.admin-form-actions { - display: flex; - gap: var(--space-md); - padding-top: var(--space-lg); - border-top: 1px solid var(--color-border-light); -} - -/* Version info in sidebar */ -.admin-version-info { - padding: var(--space-sm) var(--space-md); - font-size: var(--text-xs); - color: var(--color-text-disabled); - text-align: center; - white-space: nowrap; - border-top: 1px solid var(--color-border-light); -} - -@media (max-width: 768px) { - .admin-content { - padding: var(--space-lg); - } - .admin-form-row { - grid-template-columns: 1fr; - } -} - - -/* ===== 页面头部与提示组件(短信配置等页复用) ===== */ -.admin-page-header { - display: flex; - align-items: flex-end; - justify-content: space-between; - margin-bottom: var(--space-xl); - gap: var(--space-lg); -} -.admin-page-subtitle { - margin: 6px 0 0; - font-size: var(--text-sm); - color: var(--color-text-secondary); -} - -.admin-alert { - display: flex; - align-items: center; - gap: var(--space-sm); - padding: var(--space-md) var(--space-lg); - border-radius: var(--radius-md); - font-size: var(--text-sm); - margin-bottom: var(--space-lg); - border: 1px solid transparent; - line-height: 1.5; -} -.admin-alert-success { - background: var(--color-success-light); - border-color: var(--color-success); - color: var(--color-success); -} -.admin-alert-error { - background: var(--color-error-light); - border-color: var(--color-error); - color: var(--color-error); -} -.admin-alert-info { - background: var(--color-info-light); - border-color: var(--color-info); - color: var(--color-info); -} - -.admin-form-hint { - margin-top: var(--space-md); - font-size: var(--text-xs); - color: var(--color-text-tertiary); - line-height: 1.6; -} -.admin-form-hint code { - background: var(--color-bg-tertiary); - padding: 1px 6px; - border-radius: var(--radius-sm); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: var(--text-xs); -} - -.admin-tips { - margin: 0; - padding-left: var(--space-xl); - color: var(--color-text-secondary); - font-size: var(--text-sm); - line-height: 1.9; -} -.admin-tips li { - margin-bottom: var(--space-xs); -} - - -/* ===== 顶部栏与面包屑 ===== */ -.admin-topbar { - position: sticky; - top: 0; - z-index: 20; - display: flex; - align-items: center; - justify-content: space-between; - height: 56px; - margin: calc(-1 * var(--space-2xl)) calc(-1 * var(--space-2xl)) var(--space-2xl); - padding: 0 var(--space-2xl); - background: rgba(255, 255, 255, 0.82); - backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); - border-bottom: 1px solid var(--color-border); -} -.admin-breadcrumb { - display: flex; - align-items: center; - gap: var(--space-sm); - font-size: var(--text-sm); -} -.crumb-root { color: var(--color-text-tertiary); } -.crumb-sep { color: var(--color-text-disabled); } -.crumb-group { color: var(--color-text-secondary); } -.crumb-current { color: var(--color-text-primary); font-weight: 600; } -.admin-topbar-user { - display: flex; - align-items: center; - gap: var(--space-sm); - font-size: var(--text-sm); - color: var(--color-text-secondary); -} -.admin-user-avatar.sm { - width: 28px; - height: 28px; - font-size: var(--text-xs); -} -.admin-topbar-username { font-weight: 600; color: var(--color-text-primary); } +/* ===== Admin Layout (DESIGN.md: 浅色主题 + 品牌色 #5B5FED) ===== */ +.admin-layout { + display: flex; + height: 100vh; + overflow: hidden; + background: var(--color-bg-tertiary); +} + +/* Admin Sidebar —— 浅色侧栏 */ +.admin-sidebar { + width: var(--admin-sidebar-width); + min-width: var(--admin-sidebar-width); + background: var(--color-sidebar-bg); + display: flex; + flex-direction: column; + transition: width var(--transition-slow), min-width var(--transition-slow); + overflow: hidden; + border-right: 1px solid var(--color-border); + z-index: var(--z-sticky); +} + +.admin-sidebar.collapsed { + width: var(--sidebar-collapsed); + min-width: var(--sidebar-collapsed); +} + +.admin-sidebar-header { + display: flex; + align-items: center; + justify-content: center; + gap: var(--space-sm); + padding: var(--space-md) var(--space-lg); + min-height: var(--header-height); + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.admin-logo { + margin: 0; + font-size: var(--text-lg); + font-weight: 800; + color: var(--color-text-primary); + letter-spacing: 0.02em; + line-height: 1; + display: flex; + align-items: center; +} +.admin-subtitle { + font-size: var(--text-xs); + font-weight: 500; + color: var(--color-text-tertiary); + line-height: 1; + display: flex; + align-items: center; +} + +.admin-frontend-link { + display: flex; + align-items: center; + gap: var(--space-sm); + margin: var(--space-sm) var(--space-md); + padding: var(--space-sm) var(--space-md); + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-bg-secondary); + color: var(--color-text-secondary); + font-size: var(--text-xs); + font-weight: 500; + cursor: pointer; + text-decoration: none; + transition: all var(--transition-fast); +} +.admin-frontend-link:hover { + background: var(--color-accent-light); + border-color: var(--color-accent-light); + color: var(--color-accent); +} + +/* Admin Nav (侧栏分组 + 菜单项) */ +.sidebar-nav { + flex: 1; + padding: var(--space-sm) var(--space-xs); + overflow-y: auto; + overflow-x: hidden; + display: flex; + flex-direction: column; + gap: 2px; +} + +.nav-group { + display: flex; + flex-direction: column; +} + +.nav-group-header { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-md); + border-radius: var(--radius-md); + cursor: pointer; + user-select: none; + color: var(--color-text-secondary); + transition: background var(--transition-fast), color var(--transition-fast); +} +.nav-group-header:hover { + background: var(--color-sidebar-hover); + color: var(--color-text-primary); +} +.nav-group-active > .nav-group-header { + color: var(--color-text-primary); + font-weight: 600; +} + +.nav-group-chevron-wrap { + display: inline-flex; + width: 14px; + height: 14px; + flex-shrink: 0; +} +.nav-group-chevron { + display: inline-flex; + color: var(--color-text-tertiary); + transition: transform var(--transition-fast); +} +.nav-group-chevron.chevron-expanded { + transform: rotate(180deg); +} +.nav-group-title { + font-size: var(--text-sm); + font-weight: 600; + letter-spacing: 0.01em; +} + +.nav-group-items { + display: flex; + flex-direction: column; + gap: 2px; + padding: 2px 0 6px; + margin-left: var(--space-lg); + border-left: 1px solid var(--color-border-light); +} + +.nav-item { + display: flex; + align-items: center; + gap: var(--space-md); + padding: 9px var(--space-md); + margin-left: var(--space-sm); + border-radius: var(--radius-md); + color: var(--color-text-secondary); + text-decoration: none; + font-size: var(--text-sm); + font-weight: 500; + transition: all var(--transition-fast); + white-space: nowrap; +} +.nav-item:hover { + background: var(--color-sidebar-hover); + color: var(--color-text-primary); +} +.nav-item.active, +.nav-item-active { + background: var(--color-accent-light); + color: var(--color-accent); +} +.nav-item.active .nav-item-icon, +.nav-item-active .nav-item-icon { + color: var(--color-accent); +} + +.nav-item-icon { + display: inline-flex; + flex-shrink: 0; + color: var(--color-text-tertiary); +} +.nav-item-label { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Sidebar footer */ +.admin-sidebar-footer { + padding: var(--space-sm); + border-top: 1px solid var(--color-border); + display: flex; + flex-direction: column; + gap: 2px; + flex-shrink: 0; +} +.admin-user-info { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-xs) var(--space-sm); +} +.admin-user-avatar { + width: 34px; + height: 34px; + border-radius: var(--radius-full); + background: var(--color-accent); + color: #FFFFFF; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: var(--text-sm); + flex-shrink: 0; +} +.admin-user-detail { + display: flex; + flex-direction: column; + min-width: 0; +} +.admin-user-name { + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text-primary); +} +.admin-logout-btn { + background: none; + border: none; + padding: 0; + margin-top: 2px; + font-size: var(--text-xs); + color: var(--color-text-tertiary); + cursor: pointer; + text-align: left; + font-family: inherit; +} +.admin-logout-btn:hover { + color: var(--color-error); +} + +/* Admin Main */ +.admin-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; +} + +.admin-topbar { + display: flex; + align-items: center; + justify-content: space-between; + height: var(--header-height); + padding: 0 var(--space-2xl); + background: #FFFFFF; + border-bottom: 1px solid var(--color-border); + flex-shrink: 0; +} + +.admin-topbar-left { + display: flex; + align-items: center; +} + +.admin-welcome { + font-size: var(--text-sm); + color: var(--color-text-secondary); +} + +.admin-topbar-right { + display: flex; + align-items: center; + gap: var(--space-lg); +} + +.admin-user-tag { + display: flex; + align-items: center; + gap: var(--space-sm); + font-size: var(--text-sm); + font-weight: 500; + color: var(--color-text-primary); +} + +.admin-topbar-action { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 14px; + border-radius: var(--radius-md); + border: 1px solid var(--color-border); + background: var(--color-bg-primary); + color: var(--color-accent); + font-size: var(--text-sm); + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: all var(--transition-fast); +} + +.admin-topbar-action:hover { + background: var(--color-accent); + color: #FFFFFF; + border-color: var(--color-accent); +} + +/* Admin Content Area */ +.admin-content { + flex: 1; + overflow-y: auto; + padding: var(--space-2xl); +} + +/* Admin Common Components */ +.admin-page-title { + font-size: var(--text-xl); + font-weight: 700; + color: var(--color-text-primary); + margin-bottom: var(--space-2xl); +} + +.admin-card { + background: #FFFFFF; + border-radius: var(--radius-xl); + border: 1px solid var(--color-border); + padding: var(--space-2xl); + margin-bottom: var(--space-xl); +} + +.admin-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-xl); +} + +.admin-card-title { + font-size: var(--text-lg); + font-weight: 600; + color: var(--color-text-primary); +} + +.admin-table { + width: 100%; + border-collapse: collapse; +} + +.admin-table th { + text-align: left; + padding: var(--space-md) var(--space-lg); + font-size: var(--text-xs); + font-weight: 600; + color: var(--color-text-tertiary); + text-transform: uppercase; + letter-spacing: 0.05em; + border-bottom: 1px solid var(--color-border); + background: var(--color-bg-tertiary); +} + +.admin-table td { + padding: var(--space-md) var(--space-lg); + font-size: var(--text-sm); + color: var(--color-text-primary); + border-bottom: 1px solid var(--color-border-light); +} + +.admin-table tr:hover td { + background: var(--color-bg-hover); +} + +.admin-badge { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border-radius: var(--radius-full); + font-size: var(--text-xs); + font-weight: 600; + white-space: nowrap; +} + +.badge-success { background: var(--color-success-light); color: var(--color-success); } +.badge-warning { background: var(--color-warning-light); color: var(--color-warning); } +.badge-error { background: var(--color-error-light); color: var(--color-error); } +.badge-info { background: var(--color-info-light); color: var(--color-info); } +.badge-neutral { background: var(--color-bg-tertiary); color: var(--color-text-secondary); } + +.admin-btn { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-sm) var(--space-lg); + border-radius: var(--radius-md); + font-size: var(--text-sm); + font-weight: 600; + transition: all var(--transition-fast); + white-space: nowrap; +} + +.admin-btn-primary { + background: var(--color-accent); + color: #FFFFFF; +} +.admin-btn-primary:hover { + background: var(--color-accent-hover); +} + +.admin-btn-outline { + background: transparent; + border: 1px solid var(--color-border); + color: var(--color-text-primary); +} +.admin-btn-outline:hover { + background: var(--color-bg-hover); + border-color: var(--color-border-hover); +} + +.admin-btn-danger { + background: transparent; + border: 1px solid var(--color-error); + color: var(--color-error); +} +.admin-btn-danger:hover { + background: var(--color-error-light); +} + +.admin-btn-sm { + padding: 3px 10px; + font-size: var(--text-xs); +} + +/* Admin Stats */ +.admin-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: var(--space-lg); + margin-bottom: var(--space-2xl); +} + +.admin-stat-card { + background: #FFFFFF; + border-radius: var(--radius-xl); + border: 1px solid var(--color-border); + padding: var(--space-xl); +} + +.admin-stat-label { + font-size: var(--text-xs); + color: var(--color-text-tertiary); + font-weight: 500; + margin-bottom: var(--space-sm); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.admin-stat-value { + font-size: var(--text-2xl); + font-weight: 700; + color: var(--color-text-primary); +} + +/* Admin Form */ +.admin-form-group { + margin-bottom: var(--space-xl); +} + +.admin-form-label { + display: block; + font-size: var(--text-sm); + font-weight: 600; + color: var(--color-text-primary); + margin-bottom: var(--space-sm); +} + +.admin-form-input { + width: 100%; + padding: var(--space-md) var(--space-lg); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + font-size: var(--text-sm); + color: var(--color-text-primary); + background: #FFFFFF; + transition: border-color var(--transition-fast); + outline: none; +} + +.admin-form-input:focus { + border-color: var(--color-accent); + box-shadow: 0 0 0 3px var(--color-accent-light); +} + +.admin-form-input::placeholder { + color: var(--color-text-disabled); +} + +.admin-form-textarea { + min-height: 100px; + resize: vertical; +} + +.admin-form-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--space-lg); +} + +.admin-form-actions { + display: flex; + gap: var(--space-md); + padding-top: var(--space-lg); + border-top: 1px solid var(--color-border-light); +} + +/* Version info in sidebar */ +.admin-version-info { + padding: var(--space-sm) var(--space-md); + font-size: var(--text-xs); + color: var(--color-text-disabled); + text-align: center; + white-space: nowrap; + border-top: 1px solid var(--color-border-light); +} + +@media (max-width: 768px) { + .admin-content { + padding: var(--space-lg); + } + .admin-form-row { + grid-template-columns: 1fr; + } +} + + +/* ===== 页面头部与提示组件(短信配置等页复用) ===== */ +.admin-page-header { + display: flex; + align-items: flex-end; + justify-content: space-between; + margin-bottom: var(--space-xl); + gap: var(--space-lg); +} +.admin-page-subtitle { + margin: 6px 0 0; + font-size: var(--text-sm); + color: var(--color-text-secondary); +} + +.admin-alert { + display: flex; + align-items: center; + gap: var(--space-sm); + padding: var(--space-md) var(--space-lg); + border-radius: var(--radius-md); + font-size: var(--text-sm); + margin-bottom: var(--space-lg); + border: 1px solid transparent; + line-height: 1.5; +} +.admin-alert-success { + background: var(--color-success-light); + border-color: var(--color-success); + color: var(--color-success); +} +.admin-alert-error { + background: var(--color-error-light); + border-color: var(--color-error); + color: var(--color-error); +} +.admin-alert-info { + background: var(--color-info-light); + border-color: var(--color-info); + color: var(--color-info); +} + +.admin-form-hint { + margin-top: var(--space-md); + font-size: var(--text-xs); + color: var(--color-text-tertiary); + line-height: 1.6; +} +.admin-form-hint code { + background: var(--color-bg-tertiary); + padding: 1px 6px; + border-radius: var(--radius-sm); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: var(--text-xs); +} + +.admin-tips { + margin: 0; + padding-left: var(--space-xl); + color: var(--color-text-secondary); + font-size: var(--text-sm); + line-height: 1.9; +} +.admin-tips li { + margin-bottom: var(--space-xs); +} + + +/* ===== 顶部栏与面包屑 ===== */ +.admin-topbar { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: space-between; + height: 56px; + margin: calc(-1 * var(--space-2xl)) calc(-1 * var(--space-2xl)) var(--space-2xl); + padding: 0 var(--space-2xl); + background: rgba(255, 255, 255, 0.82); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border-bottom: 1px solid var(--color-border); +} +.admin-breadcrumb { + display: flex; + align-items: center; + gap: var(--space-sm); + font-size: var(--text-sm); +} +.crumb-root { color: var(--color-text-tertiary); } +.crumb-sep { color: var(--color-text-disabled); } +.crumb-group { color: var(--color-text-secondary); } +.crumb-current { color: var(--color-text-primary); font-weight: 600; } +.admin-topbar-user { + display: flex; + align-items: center; + gap: var(--space-sm); + font-size: var(--text-sm); + color: var(--color-text-secondary); +} +.admin-user-avatar.sm { + width: 28px; + height: 28px; + font-size: var(--text-xs); +} +.admin-topbar-username { font-weight: 600; color: var(--color-text-primary); } diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index 7bbda48..ca4415b 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -1,437 +1,437 @@ -import React, { useState, useEffect } from 'react'; - -import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; - -import { useAuth } from '@/contexts/AuthContext'; - -import './AdminLayout.css'; - - - -interface NavItem { - - path: string; - - icon: string; - - label: string; - - exact?: boolean; - -} - - - -interface NavGroup { - - title: string; - - icon: string; - - items: NavItem[]; - -} - - - -const adminNavGroups: NavGroup[] = [ - - { - title: '数据驾驶舱', - icon: 'dashboard', - items: [ - { path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true }, - ], - }, - - { - title: 'AI技能中心', - icon: 'agents', - items: [ - { path: '/admin/models', icon: 'models', label: '大模型库' }, - { path: '/admin/agents', icon: 'agents', label: 'Agent 管理' }, - { path: '/admin/skills', icon: 'skills', label: 'Skills技能管理' }, - { path: '/admin/mcp', icon: 'mcp', label: 'MCP Client 管理' }, - { path: '/admin/market', icon: 'market', label: 'AI 技能审核' }, - ], - }, - - { - title: '平台运营', - icon: 'users', - items: [ - { path: '/admin/users', icon: 'users', label: '用户管理' }, - { path: '/admin/membership', icon: 'membership', label: '会员管理' }, - { path: '/admin/promotion', icon: 'promotion', label: '推广系统' }, - { path: '/admin/achievement', icon: 'achievement', label: '成就系统' }, - ], - }, - - { - title: '商业变现', - icon: 'billing', - items: [ - { path: '/admin/billing', icon: 'billing', label: '计费与支付' }, - { path: '/admin/payment', icon: 'payment', label: '支付渠道' }, - { path: '/admin/services', icon: 'services', label: '增值服务' }, - { path: '/admin/ads', icon: 'ads', label: '广告管理' }, - { path: '/admin/assets', icon: 'assets', label: '资产管理' }, - ], - }, - - { - title: '系统管理', - icon: 'settings', - items: [ - { path: '/admin/settings', icon: 'settings', label: '网站设置' }, - { path: '/admin/logs', icon: 'logs', label: '日志与风控' }, - { path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' }, - { path: '/admin/sms', icon: 'sms', label: '短信配置' }, - { path: '/admin/storage', icon: 'storage', label: '存储配置' }, - { path: '/admin/automation', icon: 'automation', label: '自动化任务' }, - ], - }, - - { - title: '待处理', - icon: 'developer', - items: [ - { path: '/admin/analytics', icon: 'analytics', label: '数据分析' }, - { path: '/admin/observability', icon: 'observability', label: '调用可观测' }, - { path: '/admin/workflow', icon: 'workflow', label: '工作流编排' }, - { path: '/admin/apps', icon: 'apps', label: 'AI 应用管理' }, - { path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' }, - { path: '/admin/tenant', icon: 'tenant', label: '多租户管理' }, - { path: '/admin/im-bots', icon: 'imbot', label: 'IM 机器人' }, - { path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' }, - { path: '/admin/distributions', icon: 'distro', label: '发行版构建' }, - { path: '/admin/developer', icon: 'developer', label: '开发者 API' }, - { path: '/admin/developer-tools', icon: 'devtools', label: '开发者工具' }, - ], - }, - -]; - - - -const adminIconPaths: Record = { - - dashboard: , - - users: , - - models: , - - settings: , - - payment: , - - services: , - - membership: , - - ads: , - - market: , - - logs: , - - agents: , - - skills: , - - code: , - - distro: , - - privacy: , - - promotion: , - - achievement: , - - tenant: , - - billing: , - - analytics: , - observability: , - - imbot: , - - storage: , - - automation: , - - developer: , - - devtools: , - - knowledge: , - - workflow: , - - mcp: , - - apps: , - - assets: , - - chevron: , - - sms: , - -}; - - - -const AdminLayout: React.FC = () => { - - const { user, logout } = useAuth(); - - const navigate = useNavigate(); - - const location = useLocation(); - - // 后台必须登录且为管理员 - useEffect(() => { - const token = localStorage.getItem('aiflow_token'); - if (!token || !user || user.role !== 'admin') { - logout(); - navigate('/login', { replace: true }); - } - }, [user, logout, navigate]); - - const navCrumb = (() => { - - for (const g of adminNavGroups) { - - for (const it of g.items) { - - const match = it.exact - - ? location.pathname === it.path - - : location.pathname === it.path || location.pathname.startsWith(it.path + '/'); - - if (match) return { group: g.title, label: it.label }; - - } - - } - - return { group: '', label: '' }; - - })(); - - const navGroup = navCrumb.group; - - const navLabel = navCrumb.label; - - - - // 默认展开全部功能板块,确保后台所有模块在 UI 中可见(DESIGN.md: 侧栏分类清晰) - const [expandedGroups, setExpandedGroups] = useState>(() => { - return new Set(adminNavGroups.map((g) => g.title)); - }); - - - - const toggleGroup = (title: string) => { - - setExpandedGroups(prev => { - - const next = new Set(prev); - - if (next.has(title)) next.delete(title); - - else next.add(title); - - return next; - - }); - - }; - - - - const handleLogout = () => { - - logout(); - - navigate('/'); - - }; - - - - const handleVisitFrontend = () => { - - const url = window.location.origin + '/chat'; - - window.open(url, '_blank'); - - }; - - - - return ( - -
- - - -
- -
- - - -
- -
{user?.username?.[0]?.toUpperCase() || 'A'}
- - {user?.username || 'admin'} - -
- -
- - - -
- -
- - ); - -}; - - - -export default AdminLayout; - +import React, { useState, useEffect } from 'react'; + +import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom'; + +import { useAuth } from '@/contexts/AuthContext'; + +import './AdminLayout.css'; + + + +interface NavItem { + + path: string; + + icon: string; + + label: string; + + exact?: boolean; + +} + + + +interface NavGroup { + + title: string; + + icon: string; + + items: NavItem[]; + +} + + + +const adminNavGroups: NavGroup[] = [ + + { + title: '数据驾驶舱', + icon: 'dashboard', + items: [ + { path: '/admin', icon: 'dashboard', label: '平台驾驶舱', exact: true }, + ], + }, + + { + title: 'AI技能中心', + icon: 'agents', + items: [ + { path: '/admin/models', icon: 'models', label: '大模型库' }, + { path: '/admin/agents', icon: 'agents', label: 'Agent 管理' }, + { path: '/admin/skills', icon: 'skills', label: 'Skills技能管理' }, + { path: '/admin/mcp', icon: 'mcp', label: 'MCP Client 管理' }, + { path: '/admin/market', icon: 'market', label: 'AI 技能审核' }, + ], + }, + + { + title: '平台运营', + icon: 'users', + items: [ + { path: '/admin/users', icon: 'users', label: '用户管理' }, + { path: '/admin/membership', icon: 'membership', label: '会员管理' }, + { path: '/admin/promotion', icon: 'promotion', label: '推广系统' }, + { path: '/admin/achievement', icon: 'achievement', label: '成就系统' }, + ], + }, + + { + title: '商业变现', + icon: 'billing', + items: [ + { path: '/admin/billing', icon: 'billing', label: '计费与支付' }, + { path: '/admin/payment', icon: 'payment', label: '支付渠道' }, + { path: '/admin/services', icon: 'services', label: '增值服务' }, + { path: '/admin/ads', icon: 'ads', label: '广告管理' }, + { path: '/admin/assets', icon: 'assets', label: '资产管理' }, + ], + }, + + { + title: '系统管理', + icon: 'settings', + items: [ + { path: '/admin/settings', icon: 'settings', label: '网站设置' }, + { path: '/admin/logs', icon: 'logs', label: '日志与风控' }, + { path: '/admin/knowledge', icon: 'knowledge', label: '知识库管理' }, + { path: '/admin/sms', icon: 'sms', label: '短信配置' }, + { path: '/admin/storage', icon: 'storage', label: '存储配置' }, + { path: '/admin/automation', icon: 'automation', label: '自动化任务' }, + ], + }, + + { + title: '待处理', + icon: 'developer', + items: [ + { path: '/admin/analytics', icon: 'analytics', label: '数据分析' }, + { path: '/admin/observability', icon: 'observability', label: '调用可观测' }, + { path: '/admin/workflow', icon: 'workflow', label: '工作流编排' }, + { path: '/admin/apps', icon: 'apps', label: 'AI 应用管理' }, + { path: '/admin/code-exec', icon: 'code', label: '代码执行沙箱' }, + { path: '/admin/tenant', icon: 'tenant', label: '多租户管理' }, + { path: '/admin/im-bots', icon: 'imbot', label: 'IM 机器人' }, + { path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' }, + { path: '/admin/distributions', icon: 'distro', label: '发行版构建' }, + { path: '/admin/developer', icon: 'developer', label: '开发者 API' }, + { path: '/admin/developer-tools', icon: 'devtools', label: '开发者工具' }, + ], + }, + +]; + + + +const adminIconPaths: Record = { + + dashboard: , + + users: , + + models: , + + settings: , + + payment: , + + services: , + + membership: , + + ads: , + + market: , + + logs: , + + agents: , + + skills: , + + code: , + + distro: , + + privacy: , + + promotion: , + + achievement: , + + tenant: , + + billing: , + + analytics: , + observability: , + + imbot: , + + storage: , + + automation: , + + developer: , + + devtools: , + + knowledge: , + + workflow: , + + mcp: , + + apps: , + + assets: , + + chevron: , + + sms: , + +}; + + + +const AdminLayout: React.FC = () => { + + const { user, logout } = useAuth(); + + const navigate = useNavigate(); + + const location = useLocation(); + + // 后台必须登录且为管理员 + useEffect(() => { + const token = localStorage.getItem('aiflow_token'); + if (!token || !user || user.role !== 'admin') { + logout(); + navigate('/login', { replace: true }); + } + }, [user, logout, navigate]); + + const navCrumb = (() => { + + for (const g of adminNavGroups) { + + for (const it of g.items) { + + const match = it.exact + + ? location.pathname === it.path + + : location.pathname === it.path || location.pathname.startsWith(it.path + '/'); + + if (match) return { group: g.title, label: it.label }; + + } + + } + + return { group: '', label: '' }; + + })(); + + const navGroup = navCrumb.group; + + const navLabel = navCrumb.label; + + + + // 默认展开全部功能板块,确保后台所有模块在 UI 中可见(DESIGN.md: 侧栏分类清晰) + const [expandedGroups, setExpandedGroups] = useState>(() => { + return new Set(adminNavGroups.map((g) => g.title)); + }); + + + + const toggleGroup = (title: string) => { + + setExpandedGroups(prev => { + + const next = new Set(prev); + + if (next.has(title)) next.delete(title); + + else next.add(title); + + return next; + + }); + + }; + + + + const handleLogout = () => { + + logout(); + + navigate('/'); + + }; + + + + const handleVisitFrontend = () => { + + const url = window.location.origin + '/chat'; + + window.open(url, '_blank'); + + }; + + + + return ( + +
+ + + +
+ +
+ + + +
+ +
{user?.username?.[0]?.toUpperCase() || 'A'}
+ + {user?.username || 'admin'} + +
+ +
+ + + +
+ +
+ + ); + +}; + + + +export default AdminLayout; + diff --git a/frontend/src/pages/admin/Agents.tsx b/frontend/src/pages/admin/Agents.tsx index 0a6a798..d933973 100644 --- a/frontend/src/pages/admin/Agents.tsx +++ b/frontend/src/pages/admin/Agents.tsx @@ -1,1837 +1,1837 @@ -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([]); - - // Available MCP servers for tool_policy sandbox allowlist - - const [availableMcps, setAvailableMcps] = useState([]); - - // Tool policy (sandbox) — null means disabled / no policy - - const [toolPolicy, setToolPolicy] = useState(null); - - // Phase 3.3: orchestration graph editor (JSON text + error) - - const [orchestrationText, setOrchestrationText] = useState(''); - - const [orchestrationError, setOrchestrationError] = 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, mcpsResult] = await Promise.all([ - - adminAPI.getAdminAvailableSkills({ page_size: 50 }) as any, - - adminAPI.getAdminAvailableModels() as any, - - adminAPI.getAdminMcpServers({ page_size: 50 }) as any, - - ]); - - const skills = (skillsResult.data || skillsResult).items || []; - - const models = (modelsResult.data || modelsResult) || []; - - const mcps = (mcpsResult.data || mcpsResult).items || []; - - setAvailableSkills(skills); - - setAvailableModels(models); - - setAvailableMcps(mcps); - - } 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, - - }); - - setToolPolicy(null); - - setOrchestrationText(''); - - setOrchestrationError(''); - - setShowModal(true); - - }; - - - - // Open edit - - const openEdit = async (agent: Agent) => { - - setEditingAgent(agent); - - await loadAvailable(); - - await openEditLoadPolicy(agent.id); - - await openEditLoadOrchestration(agent.id); - - - - 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); - - }; - - - - // Fetch existing tool_policy (sandbox) for this agent - - const openEditLoadPolicy = async (agentId: string) => { - - try { - - const tpRes = await adminAPI.getAgentToolPolicy(agentId) as any; - - setToolPolicy(tpRes?.data?.tool_policy || null); - - } catch { - - setToolPolicy(null); - - } - - }; - - // Phase 3.3: 载入已有编排图 - - const openEditLoadOrchestration = async (agentId: string) => { - - try { - - const oRes = await adminAPI.getAgentOrchestration(agentId) as any; - - const og = oRes?.data?.orchestration; - - setOrchestrationText(og ? JSON.stringify(og, null, 2) : ''); - - setOrchestrationError(''); - - } catch { - - setOrchestrationText(''); - - } - - }; - - 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); - - // Phase 3.1: persist tool_policy sandbox (null clears it) - - try { - - await adminAPI.setAgentToolPolicy(editingAgent.id, toolPolicy); - - showToast('Agent 已更新'); - - } catch (pe: any) { - - showToast(`Agent 已更新,但策略保存失败: ${pe.message || pe}`, 'error'); - - } - - } 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 }))} /> - -
- -
- - - -
- -
- - - -