From 96b3f0af374072d8520a4e3575baef09ce387528 Mon Sep 17 00:00:00 2001 From: QihuAI Design Date: Sun, 19 Jul 2026 16:41:27 +0800 Subject: [PATCH] =?UTF-8?q?Phase=203.3=20=E5=A4=9A=20Agent=20=E7=BC=96?= =?UTF-8?q?=E6=8E=92=E5=9B=BE=E5=BC=95=E6=93=8E:=20orchestration=20JSON=20?= =?UTF-8?q?=E5=88=97=20+=20=E6=8B=93=E6=89=91=E5=B9=B6=E8=A1=8C=E5=BC=95?= =?UTF-8?q?=E6=93=8E=20+=20local/external/reduce=20=E8=8A=82=E7=82=B9=20+?= =?UTF-8?q?=20=E5=B7=A5=E5=85=B7=E6=B2=99=E7=AE=B1=E7=BB=A7=E6=89=BF=20+?= =?UTF-8?q?=20=E5=90=8E=E5=8F=B0=E7=BC=96=E6=8E=92=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- backend/python/app/api/admin/agents_admin.py | 1266 +++--- backend/python/app/api/chat_service.py | 4203 +++++++++--------- backend/python/app/api/chat_skill.py | 3189 ++++++------- backend/python/app/core/orchestrator.py | 395 ++ backend/python/app/models.py | 2277 +++++----- migrations/024_add_orchestration.sql | 15 + 7 files changed, 5914 insertions(+), 5433 deletions(-) create mode 100644 backend/python/app/core/orchestrator.py create mode 100644 migrations/024_add_orchestration.sql diff --git a/VERSION b/VERSION index 8ea2798..9b7f002 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v2026.07.19.6 +v2026.07.19.8 diff --git a/backend/python/app/api/admin/agents_admin.py b/backend/python/app/api/admin/agents_admin.py index 326b2fc..fa3a16f 100644 --- a/backend/python/app/api/admin/agents_admin.py +++ b/backend/python/app/api/admin/agents_admin.py @@ -1,610 +1,656 @@ -""" -Agent 管理 API(管理员) -Phase 2.3: AI PaaS Agent Management -""" -from fastapi import APIRouter, Depends, HTTPException, Query, Body -from sqlalchemy.orm import Session -from sqlalchemy import or_, func -from pydantic import BaseModel -from typing import Optional, List -from ...database import get_db -from ...models import MarketApp, EvolutionSkill, AIModel, User, ModelProvider -from ...auth import get_current_admin -from ...middleware.log_service import LogService -import uuid -from datetime import datetime - - -router = APIRouter() - - -# ============================================================ -# Pydantic 模型 -# ============================================================ - -class AgentConfig(BaseModel): - system_prompt: Optional[str] = None - temperature: Optional[float] = None - max_tokens: Optional[int] = None - model_id: Optional[str] = None - skills: Optional[List[str]] = None # skill IDs - tools: Optional[List[str]] = None # MCP server IDs - memory_enabled: Optional[bool] = False - reasoning_enabled: Optional[bool] = False - - -class AgentCreate(BaseModel): - name: str - description: Optional[str] = None - icon: Optional[str] = None - price: float = 0.0 - is_member_free: bool = False - config: Optional[AgentConfig] = None - - -class AgentUpdate(BaseModel): - name: Optional[str] = None - description: Optional[str] = None - icon: Optional[str] = None - price: Optional[float] = None - is_member_free: Optional[bool] = None - config: Optional[AgentConfig] = None - status: Optional[str] = None # active/inactive - tutorial: Optional[str] = None - - -# ============================================================ -# 辅助函数 -# ============================================================ - -def agent_to_dict(a: MarketApp) -> dict: - """将 MarketApp (agent) 转换为字典""" - # 解析 skill_content 中的配置 - import json - config = {} - if a.skill_content: - try: - config = json.loads(a.skill_content) - except Exception: - pass - - return { - "id": a.id, - "name": a.name, - "type": a.type, - "source": a.source, - "publisher_id": a.publisher_id, - "publisher_name": a.publisher_name, - "description": a.description, - "icon": a.icon, - "tutorial": a.tutorial, - "price": float(a.price) if a.price else 0, - "is_member_free": a.is_member_free, - "join_promotion": a.join_promotion, - "status": a.status, - "rating_avg": float(a.rating_avg) if a.rating_avg else 0, - "review_count": a.review_count, - "install_count": a.install_count, - "agent_endpoint": a.agent_endpoint, - "skill_content": a.skill_content, - "config": config, - "created_at": a.created_at.isoformat() if a.created_at else None, - "updated_at": a.updated_at.isoformat() if a.updated_at else None, - } - - -# ============================================================ -# 端点 -# ============================================================ - -@router.get("/", summary="获取 Agent 列表") -async def list_agents( - keyword: str = Query(None, description="搜索关键词"), - status: str = Query(None, description="状态: pending/published/removed"), - source: str = Query(None, description="来源: official/user"), - page: int = Query(1, ge=1), - page_size: int = Query(15, ge=1, le=100), - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取所有 Agent 列表(分页、筛选)""" - query = db.query(MarketApp).filter(MarketApp.type == "agent") - - if keyword: - query = query.filter( - or_( - MarketApp.name.ilike(f"%{keyword}%"), - MarketApp.description.ilike(f"%{keyword}%"), - MarketApp.publisher_name.ilike(f"%{keyword}%"), - ) - ) - if status: - query = query.filter(MarketApp.status == status) - if source: - query = query.filter(MarketApp.source == source) - - total = query.count() - agents = query.order_by(MarketApp.created_at.desc()).offset( - (page - 1) * page_size - ).limit(page_size).all() - - return { - "code": 0, - "data": { - "total": total, - "page": page, - "page_size": page_size, - "items": [agent_to_dict(a) for a in agents], - } - } - - -@router.get("/stats/overview", summary="Agent 统计概览") -async def get_agent_stats( - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取 Agent 统计数据""" - base = db.query(MarketApp).filter(MarketApp.type == "agent") - - total = base.count() - published = base.filter(MarketApp.status == "published").count() - pending = base.filter(MarketApp.status == "pending").count() - official = base.filter(MarketApp.source == "official").count() - user_created = base.filter(MarketApp.source == "user").count() - - # 安装量统计 - install_base = db.query(func.sum(MarketApp.install_count)).filter( - MarketApp.type == "agent" - ).scalar() or 0 - - # 按类型分布 - by_source = { - "official": official, - "user": user_created, - } - - # 评分统计 - avg_rating = db.query(func.avg(MarketApp.rating_avg)).filter( - MarketApp.type == "agent", - MarketApp.rating_avg > 0, - ).scalar() or 0 - - return { - "code": 0, - "data": { - "total_agents": total, - "published_agents": published, - "pending_agents": pending, - "official_agents": official, - "user_agents": user_created, - "total_installs": int(install_base), - "average_rating": round(float(avg_rating), 2), - "by_source": by_source, - } - } - - -@router.get("/{agent_id}", summary="获取 Agent 详情") -async def get_agent( - agent_id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取单个 Agent 的详细信息""" - agent = db.query(MarketApp).filter( - MarketApp.id == agent_id, - MarketApp.type == "agent", - ).first() - - if not agent: - raise HTTPException(status_code=404, detail="Agent 不存在") - - result = agent_to_dict(agent) - - # 获取关联的 Skills - import json - config = json.loads(agent.skill_content or "{}") - skill_ids = config.get("skills", []) - if skill_ids: - skills = db.query(EvolutionSkill).filter( - EvolutionSkill.id.in_(skill_ids) - ).all() - result["linked_skills"] = [ - { - "id": s.id, - "skill_name": s.skill_name, - "state": s.state, - "version": s.version, - } - for s in skills - ] - else: - result["linked_skills"] = [] - - # 获取可用模型列表 - models = db.query(AIModel, ModelProvider).join( - ModelProvider, AIModel.provider_id == ModelProvider.id - ).filter(AIModel.is_enabled == True).all() - result["available_models"] = [ - {"id": m.id, "name": m.name, "provider": p.name} for m, p in models - ] - - return {"code": 0, "data": result} - - -@router.post("/", summary="创建 Agent") -async def create_agent( - req: AgentCreate, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """创建新的 Agent""" - import json - - # 检查名称是否已存在 - existing = db.query(MarketApp).filter( - MarketApp.name == req.name, - MarketApp.type == "agent", - ).first() - if existing: - raise HTTPException(status_code=400, detail="Agent 名称已存在") - - # 构建 skill_content - config = {} - if req.config: - cfg = req.config - config = { - "system_prompt": cfg.system_prompt or "", - "temperature": cfg.temperature or 0.7, - "max_tokens": cfg.max_tokens or 2048, - "model_id": cfg.model_id, - "skills": cfg.skills or [], - "tools": cfg.tools or [], - "memory_enabled": cfg.memory_enabled or False, - "reasoning_enabled": cfg.reasoning_enabled or False, - } - - agent = MarketApp( - id=str(uuid.uuid4()), - name=req.name, - type="agent", - source="official", - publisher_id=admin_user.id, - publisher_name=admin_user.username, - description=req.description or "", - icon=req.icon or "", - price=0.0, # 管理员导入/安装/开发的 Agent 默认免费 - is_member_free=req.is_member_free, - skill_content=json.dumps(config, ensure_ascii=False), - status="published", - created_at=datetime.now(), - updated_at=datetime.now(), - ) - db.add(agent) - db.commit() - db.refresh(agent) - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="create_agent", - target_type="agent", - target_id=agent.id, - detail=f"创建 Agent: {agent.name}", - ) - - return {"code": 0, "message": "Agent 创建成功", "data": agent_to_dict(agent)} - - -@router.put("/{agent_id}", summary="更新 Agent") -async def update_agent( - agent_id: str, - req: AgentUpdate, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """更新 Agent 配置""" - import json - - agent = db.query(MarketApp).filter( - MarketApp.id == agent_id, - MarketApp.type == "agent", - ).first() - - if not agent: - raise HTTPException(status_code=404, detail="Agent 不存在") - - # 更新基本字段 - if req.name is not None: - # 检查新名称是否重复 - dup = db.query(MarketApp).filter( - MarketApp.name == req.name, - MarketApp.type == "agent", - MarketApp.id != agent_id, - ).first() - if dup: - raise HTTPException(status_code=400, detail="Agent 名称已存在") - agent.name = req.name - - if req.description is not None: - agent.description = req.description - if req.icon is not None: - agent.icon = req.icon - if req.price is not None: - agent.price = req.price - if req.is_member_free is not None: - agent.is_member_free = req.is_member_free - if req.status is not None: - agent.status = req.status - if req.tutorial is not None: - agent.tutorial = req.tutorial - - # 更新配置 - if req.config is not None: - cfg = req.config - # 合并现有配置 - existing_config = {} - if agent.skill_content: - try: - existing_config = json.loads(agent.skill_content) - except Exception: - pass - - new_config = { - "system_prompt": cfg.system_prompt if cfg.system_prompt is not None else existing_config.get("system_prompt", ""), - "temperature": cfg.temperature if cfg.temperature is not None else existing_config.get("temperature", 0.7), - "max_tokens": cfg.max_tokens if cfg.max_tokens is not None else existing_config.get("max_tokens", 2048), - "model_id": cfg.model_id if cfg.model_id is not None else existing_config.get("model_id"), - "skills": cfg.skills if cfg.skills is not None else existing_config.get("skills", []), - "tools": cfg.tools if cfg.tools is not None else existing_config.get("tools", []), - "memory_enabled": cfg.memory_enabled if cfg.memory_enabled is not None else existing_config.get("memory_enabled", False), - "reasoning_enabled": cfg.reasoning_enabled if cfg.reasoning_enabled is not None else existing_config.get("reasoning_enabled", False), - } - agent.skill_content = json.dumps(new_config, ensure_ascii=False) - - agent.updated_at = datetime.now() - db.commit() - db.refresh(agent) - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="update_agent", - target_type="agent", - target_id=agent_id, - detail=f"更新 Agent: {agent.name}", - ) - - return {"code": 0, "message": "Agent 已更新", "data": agent_to_dict(agent)} - - -@router.delete("/{agent_id}", summary="删除 Agent") -async def delete_agent( - agent_id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """删除 Agent""" - agent = db.query(MarketApp).filter( - MarketApp.id == agent_id, - MarketApp.type == "agent", - ).first() - - if not agent: - raise HTTPException(status_code=404, detail="Agent 不存在") - - agent_name = agent.name - db.delete(agent) - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="delete_agent", - target_type="agent", - target_id=agent_id, - detail=f"删除 Agent: {agent_name}", - ) - - return {"code": 0, "message": "Agent 已删除"} - - -@router.put("/{agent_id}/toggle", summary="启用/禁用 Agent") -async def toggle_agent( - agent_id: str, - is_active: bool = Query(..., description="是否启用"), - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """启用或禁用 Agent""" - agent = db.query(MarketApp).filter( - MarketApp.id == agent_id, - MarketApp.type == "agent", - ).first() - - if not agent: - raise HTTPException(status_code=404, detail="Agent 不存在") - - agent.status = "published" if is_active else "pending" - agent.updated_at = datetime.now() - db.commit() - - LogService.log_audit( - admin_id=admin_user.id, - admin_name=admin_user.username, - action="toggle_agent", - target_type="agent", - target_id=agent_id, - detail=f"{'启用' if is_active else '禁用'} Agent: {agent.name}", - ) - - return { - "code": 0, - "message": f"Agent 已{'启用' if is_active else '禁用'}", - "data": agent_to_dict(agent), - } - - -@router.get("/{agent_id}/reviews", summary="获取 Agent 评价") -async def get_agent_reviews( - agent_id: str, - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取 Agent 的用户评价""" - from ...models import AppReview, User - - agent = db.query(MarketApp).filter( - MarketApp.id == agent_id, - MarketApp.type == "agent", - ).first() - if not agent: - raise HTTPException(status_code=404, detail="Agent 不存在") - - reviews = db.query(AppReview).filter( - AppReview.app_id == agent_id - ).order_by(AppReview.created_at.desc()).limit(50).all() - - return { - "code": 0, - "data": [ - { - "id": r.id, - "user_id": r.user_id, - "rating": r.rating, - "content": r.content, - "created_at": r.created_at.isoformat() if r.created_at else None, - } - for r in reviews - ] - } - - -@router.get("/available/skills", summary="获取可关联的 Skills") -async def get_available_skills( - keyword: str = Query(None), - state: str = Query(None, description="技能状态筛选"), - page: int = Query(1, ge=1), - page_size: int = Query(20, ge=1, le=100), - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取可关联到 Agent 的 Skills 列表""" - query = db.query(EvolutionSkill) - - if keyword: - query = query.filter( - or_( - EvolutionSkill.skill_name.ilike(f"%{keyword}%"), - EvolutionSkill.description.ilike(f"%{keyword}%"), - ) - ) - if state: - query = query.filter(EvolutionSkill.state == state) - - total = query.count() - skills = query.order_by(EvolutionSkill.use_count.desc()).offset( - (page - 1) * page_size - ).limit(page_size).all() - - return { - "code": 0, - "data": { - "total": total, - "items": [ - { - "id": s.id, - "skill_name": s.skill_name, - "description": s.description, - "state": s.state, - "version": s.version, - "use_count": s.use_count, - "category": s.category, - "tags": s.tags, - } - for s in skills - ] - } - } - - -@router.get("/available/models", summary="获取可用模型列表") -async def get_available_models( - admin_user=Depends(get_current_admin), - db: Session = Depends(get_db), -): - """获取可用于 Agent 的模型列表""" - models = db.query(AIModel, ModelProvider).join( - ModelProvider, AIModel.provider_id == ModelProvider.id - ).filter( - AIModel.is_enabled == True - ).order_by(AIModel.name).all() - - return { - "code": 0, - "data": [ - { - "id": m.id, - "name": m.name, - "provider": p.name, - "model_type": p.protocol, - "input_price": float(m.price_per_million_tokens) if m.price_per_million_tokens else 0, - "output_price": float(m.price_per_million_tokens) if m.price_per_million_tokens else 0, - } - for m, p in models - ] - } - - -# ============================================================ -# 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} +""" +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} diff --git a/backend/python/app/api/chat_service.py b/backend/python/app/api/chat_service.py index 8e99790..c06e352 100644 --- a/backend/python/app/api/chat_service.py +++ b/backend/python/app/api/chat_service.py @@ -1,2090 +1,2113 @@ -# 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) -from app.api.chat_utils import (SendMessageRequest, _proxy_for_url, cst_now, logger) - -async def _send_message_core( - - - - conv_id: str, - - - - req: SendMessageRequest, - - - - db: Session = Depends(get_db), - - - - current_user: User = Depends(get_current_user), - - - -): - - - - 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) - - - - 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 - - - - 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) - - - - 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) - - - - - - - - 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 = _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), - - ) - - - - - - - - # 构建消息列表 - - - - # 实际调用追踪(Issue:反馈真实使用的技能/MCP,而非注入的全部) - called_skill_names = [] - called_mcp_names = [] - - 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)一并发给模型, - - - - # 否则用户在对话窗上传文件后模型“看不到”文件内容。 - - - - 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 httpx.AsyncClient( - - - - timeout=ai_model.timeout or 120, - - - - proxy=_proxy_for_url(api_url), - - - - trust_env=False, - - - - ) as client: - - - - async with span("llm.completion", model=ai_model.id if ai_model else None): - resp = await client.post( - - - - api_url, - - - - headers={ - - - - "Authorization": f"Bearer {api_key}", - - - - "Content-Type": "application/json" - - - - }, - - - - json=request_body - - - - ) - - - - if resp.status_code != 200: - - - - if round_num == 0: - - - - ai_content = f"AI模型调用失败 (HTTP {resp.status_code}), 请检查模型配置。" - - - - break - - - - - - - - data = resp.json() - - - - 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", []) - - - - 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("mcp.call", name=_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", "")) - - else: - - async with span("skill.execute", name=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", "")) - - - - 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 模式下将自动使用平台默认模型。" - - - - - - - - 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 - - - - - - - - # 更新对话统计 - - - - conv.knowledge_graph_count = (conv.knowledge_graph_count or 0) + 1 - - - - 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}") - - - - - - - - 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, - - - - }, - - - - }, - - - - "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) +from app.api.chat_utils import (SendMessageRequest, _proxy_for_url, cst_now, logger) + +async def _send_message_core( + + + + conv_id: str, + + + + req: SendMessageRequest, + + + + db: Session = Depends(get_db), + + + + current_user: User = Depends(get_current_user), + + + +): + + + + 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) + + + + 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 + + + + 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) + + + + + + + + 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 = _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), + + ) + + + + + + + + # 构建消息列表 + + + + # 实际调用追踪(Issue:反馈真实使用的技能/MCP,而非注入的全部) + called_skill_names = [] + called_mcp_names = [] + + 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)一并发给模型, + + + + # 否则用户在对话窗上传文件后模型“看不到”文件内容。 + + + + 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 httpx.AsyncClient( + + + + timeout=ai_model.timeout or 120, + + + + proxy=_proxy_for_url(api_url), + + + + trust_env=False, + + + + ) as client: + + + + async with span("llm.completion", model=ai_model.id if ai_model else None): + resp = await client.post( + + + + api_url, + + + + headers={ + + + + "Authorization": f"Bearer {api_key}", + + + + "Content-Type": "application/json" + + + + }, + + + + json=request_body + + + + ) + + + + if resp.status_code != 200: + + + + if round_num == 0: + + + + ai_content = f"AI模型调用失败 (HTTP {resp.status_code}), 请检查模型配置。" + + + + break + + + + + + + + data = resp.json() + + + + 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", []) + + + + 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("mcp.call", name=_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", "")) + + else: + + async with span("skill.execute", name=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", "")) + + + + 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 模式下将自动使用平台默认模型。" + + + + + + + + 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 + + + + + + + + # 更新对话统计 + + + + conv.knowledge_graph_count = (conv.knowledge_graph_count or 0) + 1 + + + + 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}") + + + + + + + + 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, + + + + }, + + + + }, + + + + "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 d422ad0..5466541 100644 --- a/backend/python/app/api/chat_skill.py +++ b/backend/python/app/api/chat_skill.py @@ -1,1594 +1,1595 @@ -# 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, - - - - }) - - - - - - - - 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 +# 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 diff --git a/backend/python/app/core/orchestrator.py b/backend/python/app/core/orchestrator.py new file mode 100644 index 0000000..658c400 --- /dev/null +++ b/backend/python/app/core/orchestrator.py @@ -0,0 +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 = _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/models.py b/backend/python/app/models.py index 1130b45..467c02d 100644 --- a/backend/python/app/models.py +++ b/backend/python/app/models.py @@ -1,1138 +1,1139 @@ -""" -AIFlow 数据库模型定义 - SQLAlchemy ORM -所有用户数据查询必须带 user_id 过滤实现物理隔离 -""" -import uuid -from datetime import datetime, date -from sqlalchemy import ( - Text, - Column, String, Integer, BigInteger, Float, Boolean, - DateTime, Date, Text, Enum, ForeignKey, Index, JSON, - DECIMAL, UniqueConstraint -) -from sqlalchemy.orm import relationship -from .database import Base - - -def gen_uuid() -> str: - return str(uuid.uuid4()) - - -# ============================================================ -# 用户与权限 -# ============================================================ - -class User(Base): - __tablename__ = "users" - - id = Column(String(36), primary_key=True, default=gen_uuid) - username = Column(String(50), unique=True, nullable=False) - email = Column(String(100), default=None) - phone = Column(String(20), default=None) - password_hash = Column(String(255), nullable=False) - avatar = Column(String(500), default=None) - role = Column(String(10), nullable=False, default="user") # user / admin - status = Column(String(10), nullable=False, default="active") # active / banned - real_name_status = Column(String(20), nullable=False, default="unverified") - real_name = Column(String(50), default=None) - id_card = Column(String(20), default=None) - membership_plan_id = Column(String(36), default=None) - membership_level = Column(Integer, default=0) - membership_expire_at = Column(DateTime, default=None) - auto_renew = Column(Boolean, default=False) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class LoginLog(Base): - __tablename__ = "login_logs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - ip = Column(String(45), default=None) - user_agent = Column(String(500), default=None) - login_at = Column(DateTime, default=datetime.now) - - -class ApiKey(Base): - __tablename__ = "api_keys" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - api_key = Column(String(64), unique=True, nullable=False) - secret = Column(String(128), nullable=False) - name = Column(String(100), default=None) - status = Column(String(10), nullable=False, default="active") # active / revoked - last_used_at = Column(DateTime, default=None) - created_at = Column(DateTime, default=datetime.now) - expired_at = Column(DateTime, default=None) - description = Column(String(500), default=None, comment="密钥描述") - rate_limit = Column(Integer, default=60, comment="速率限制(请求/分钟)") - total_calls = Column(BigInteger, default=0, comment="总调用次数") - - -# ============================================================ -# AI聊天与频道 -# ============================================================ - -class Conversation(Base): - __tablename__ = "conversations" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - title = Column(String(200), nullable=False, default="新对话") - model_id = Column(String(36), default=None) - status = Column(String(10), nullable=False, default="active") # active / archived - pinned = Column(Boolean, default=False) - knowledge_graph_count = Column(Integer, default=0) - knowledge_graph_base_id = Column(String(64), default=None, comment="已生成的知识图谱知识库ID") - source = Column(String(32), default=None, comment="对话来源,如 wechat_bot") - ext_agent_conversation_id = Column(String(128), default=None, comment="外部Agent(Dify/Coze)会话ID,用于维持多轮上下文") - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class Message(Base): - __tablename__ = "messages" - - id = Column(String(36), primary_key=True, default=gen_uuid) - conversation_id = Column(String(36), ForeignKey("conversations.id"), nullable=False) - role = Column(String(10), nullable=False) # user / assistant / system - content = Column(Text, nullable=False) - token_used = Column(Integer, default=0) - model_id = Column(String(36), default=None) - knowledge_ids = Column(JSON, default=None) - agent_ids = Column(JSON, default=None) - skill_ids = Column(JSON, default=None) - mcp_ids = Column(JSON, default=None, comment="该轮对话选择的MCP应用ID列表") - attachments = Column(JSON, default=None, comment="该轮对话附带的文件附件列表") - used_skills = Column(JSON, default=None, comment="该轮对话调用的进化技能名列表") - used_mcp = Column(JSON, default=None, comment="该轮对话调用的MCP应用名列表") - created_at = Column(DateTime, default=datetime.now) - - -class Channel(Base): - __tablename__ = "channels" - - id = Column(String(36), primary_key=True, default=gen_uuid) - owner_id = Column(String(36), ForeignKey("users.id"), nullable=False) - name = Column(String(100), nullable=False) - description = Column(Text, default=None) - platform = Column(String(20), nullable=False) - webhook_url = Column(String(500), default=None) - credential = Column(JSON, default=None) - oauth_state = Column(String(500), default=None) - settings = Column(JSON, default=None) - account_name = Column(String(100), default=None) - default_agent_id = Column(String(36), default=None) - default_skill_id = Column(String(36), default=None) - knowledge_base_id = Column(String(36), default=None) - bound_model_id = Column(String(36), default=None) - features = Column(JSON, default=None) - status = Column(String(20), nullable=False, default="pending") - is_active = Column(Boolean, default=True) - last_active = Column(DateTime, default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class ChannelMember(Base): - __tablename__ = "channel_members" - __table_args__ = (UniqueConstraint("channel_id", "user_id"),) - - id = Column(String(36), primary_key=True, default=gen_uuid) - channel_id = Column(String(36), ForeignKey("channels.id"), nullable=False) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - role = Column(String(10), nullable=False, default="member") # owner / member - joined_at = Column(DateTime, default=datetime.now) - - -class ChannelMessage(Base): - __tablename__ = "channel_messages" - - id = Column(String(36), primary_key=True, default=gen_uuid) - channel_id = Column(String(36), ForeignKey("channels.id"), nullable=False) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - role = Column(String(10), nullable=False) - content = Column(Text, nullable=False) - token_used = Column(Integer, default=0) - created_at = Column(DateTime, default=datetime.now) - - -# ============================================================ -# 知识库 -# ============================================================ - -class KnowledgeBase(Base): - __tablename__ = "knowledge_bases" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - name = Column(String(200), nullable=False) - description = Column(Text, default=None) - directory_path = Column(String(500), default="/") - document_count = Column(Integer, default=0) - knowledge_graph_count = Column(Integer, default=0) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - is_official = Column(Boolean, default=False) - status = Column(String(20), default="active") - external_service_id = Column(String(36), default=None) - external_id = Column(String(100), default=None) - external_sync_status = Column(String(20), default="none") - external_config = Column(JSON, default=None) - - -class KnowledgeDirectory(Base): - __tablename__ = "knowledge_directories" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), default=None) - parent_id = Column(String(36), default=None) - name = Column(String(200), nullable=False) - created_at = Column(DateTime, default=datetime.now) - - -class KnowledgeDocument(Base): - __tablename__ = "knowledge_documents" - - id = Column(String(36), primary_key=True, default=gen_uuid) - knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False) - directory_id = Column(String(36), ForeignKey("knowledge_directories.id"), default=None) - filename = Column(String(255), nullable=False) - file_type = Column(String(20), nullable=False) - file_size = Column(BigInteger, default=0) - file_path = Column(String(500), default=None) - status = Column(String(20), nullable=False, default="uploading") - chunk_count = Column(Integer, default=0) - error_message = Column(Text, default=None) - content = Column(Text, default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - - - -class KnowledgeDocumentChunk(Base): - """知识库文档分段表 — Stage C.1""" - __tablename__ = "knowledge_document_chunks" - - id = Column(String(36), primary_key=True, default=gen_uuid) - document_id = Column(String(36), ForeignKey("knowledge_documents.id"), nullable=False) - knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False) - chunk_index = Column(Integer, nullable=False, default=0, comment="分段序号") - content = Column(Text, nullable=False, comment="分段文本内容") - start_char = Column(Integer, default=0, comment="起始字符位置") - end_char = Column(Integer, default=0, comment="结束字符位置") - chunk_metadata = Column(JSON, default=None, comment="分段元数据(标题、关键词等)") - token_count = Column(Integer, default=0, comment="分段token数估算") - created_at = Column(DateTime, default=datetime.now) - - __table_args__ = ( - Index("idx_kdc_doc_id", "document_id"), - Index("idx_kdc_kb_id", "knowledge_base_id"), - ) - -class KnowledgeGraphNode(Base): - __tablename__ = "knowledge_graph_nodes" - - id = Column(String(36), primary_key=True, default=gen_uuid) - knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False) - source_doc_id = Column(String(36), ForeignKey("knowledge_documents.id"), default=None) - label = Column(String(500), nullable=False) - type = Column(String(50), nullable=False, comment="entity/attribute/concept/event") - properties = Column(JSON, default=None) - description = Column(Text, default=None) - created_at = Column(DateTime, default=datetime.now) - - -class KnowledgeGraphEdge(Base): - __tablename__ = "knowledge_graph_edges" - - id = Column(String(36), primary_key=True, default=gen_uuid) - knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False) - source_node_id = Column(String(36), ForeignKey("knowledge_graph_nodes.id"), nullable=False) - target_node_id = Column(String(36), ForeignKey("knowledge_graph_nodes.id"), nullable=False) - relation = Column(String(200), nullable=False) - weight = Column(Float, default=1.0) - description = Column(Text, default=None) - created_at = Column(DateTime, default=datetime.now) - - -class KnowledgeGraphBuildLog(Base): - __tablename__ = "knowledge_graph_build_logs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False) - document_id = Column(String(36), ForeignKey("knowledge_documents.id"), nullable=False) - node_count = Column(Integer, default=0) - edge_count = Column(Integer, default=0) - status = Column(String(20), default="completed") - created_at = Column(DateTime, default=datetime.now) - - -# ============================================================ -# 应用市场 -# ============================================================ - -class MarketApp(Base): - __tablename__ = "market_apps" - - id = Column(String(36), primary_key=True, default=gen_uuid) - name = Column(String(200), nullable=False) - type = Column(String(10), nullable=False) # skill / agent / mcp - source = Column(String(10), nullable=False, default="user") # official / user - publisher_id = Column(String(36), ForeignKey("users.id"), default=None) - publisher_name = Column(String(100), default=None) - description = Column(Text, default=None) - icon = Column(String(500), default=None) - tutorial = Column(Text, default=None) - price = Column(DECIMAL(10, 2), nullable=False, default=0.00) - is_member_free = Column(Boolean, default=False) - join_promotion = Column(Boolean, default=False) - status = Column(String(10), nullable=False, default="pending") # pending / published / removed - rating_avg = Column(DECIMAL(3, 2), default=0.00) - review_count = Column(Integer, default=0) - install_count = Column(Integer, default=0) - skill_content = Column(Text, default=None) - mcp_transport = Column(String(20), default=None) - platform = Column(String(20), default=None, comment="Agent 接入平台:dify/n8n/coze") - capability = Column(String(20), default=None, comment="能力分类:text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video") - agent_endpoint = Column(String(500), default=None) - agent_api_key = Column(Text, default=None, comment="Agent 接入 API Key(3.2 起 Fernet 加密存储)") - tool_policy = Column(JSON, default=None, comment="Agent 工具/MCP 权限沙箱策略(3.1)") - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class AppPurchase(Base): - __tablename__ = "app_purchases" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - app_id = Column(String(36), ForeignKey("market_apps.id"), nullable=False) - price = Column(DECIMAL(10, 2), nullable=False, default=0.00) - order_id = Column(String(36), default=None) - status = Column(String(20), nullable=False, default="purchased") # purchased / installed - installed_at = Column(DateTime, default=None) - purchased_at = Column(DateTime, default=None) - created_at = Column(DateTime, default=datetime.now) - - -class AppReview(Base): - __tablename__ = "app_reviews" - __table_args__ = (UniqueConstraint("user_id", "app_id"),) - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - app_id = Column(String(36), ForeignKey("market_apps.id"), nullable=False) - rating = Column(Integer, nullable=False) - content = Column(Text, default=None) - created_at = Column(DateTime, default=datetime.now) - - -class AppInstallation(Base): - __tablename__ = "app_installations" - __table_args__ = (UniqueConstraint("user_id", "app_id"),) - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - app_id = Column(String(36), ForeignKey("market_apps.id"), nullable=False) - installed_at = Column(DateTime, default=datetime.now) - - -# ============================================================ -# 付费与订单 -# ============================================================ - -class MembershipPlan(Base): - __tablename__ = "membership_plans" - - id = Column(String(36), primary_key=True, default=gen_uuid) - name = Column(String(100), nullable=False) - level = Column(Integer, nullable=False, default=0) - duration_type = Column(String(10), nullable=False) # month / quarter / year - price = Column(DECIMAL(10, 2), nullable=False) - daily_free_tokens = Column(Integer, default=0) - features = Column(JSON, default=None) - free_app_ids = Column(JSON, default=None) - free_model_ids = Column(JSON, default=None) - # 配额字段 - max_agents = Column(Integer, default=5) - max_workflows = Column(Integer, default=3) - max_knowledge_bases = Column(Integer, default=2) - max_storage_mb = Column(Integer, default=1024) - api_calls_per_day = Column(Integer, default=100) - api_calls_per_min = Column(Integer, default=10) - # 权限字段 - can_publish_apps = Column(Boolean, default=False) - can_create_api_keys = Column(Boolean, default=False) - can_use_workflow = Column(Boolean, default=False) - can_use_mcp = Column(Boolean, default=False) - can_use_code_exec = Column(Boolean, default=False) - can_use_privacy_mode = Column(Boolean, default=False) - can_use_dag = Column(Boolean, default=False) - allowed_model_tiers = Column(JSON, default=None) - permissions = Column(JSON, default=None) - is_active = Column(Boolean, default=True) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class MembershipSubscription(Base): - __tablename__ = "membership_subscriptions" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - plan_id = Column(String(36), ForeignKey("membership_plans.id"), nullable=False) - order_id = Column(String(36), default=None) - start_at = Column(DateTime, nullable=False) - end_at = Column(DateTime, nullable=False) - auto_renew = Column(Boolean, default=False) - status = Column(String(15), nullable=False, default="active") # active / expired / downgraded - created_at = Column(DateTime, default=datetime.now) - - -class Order(Base): - __tablename__ = "orders" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - order_type = Column(String(15), nullable=False) # membership / service / app - item_id = Column(String(36), default=None) - item_name = Column(String(200), default=None) - amount = Column(DECIMAL(10, 2), nullable=False) - coupon_id = Column(String(36), default=None) - discount_amount = Column(DECIMAL(10, 2), default=0.00) - payment_method = Column(String(10), default=None) # wechat / alipay - payment_id = Column(String(200), default=None) - status = Column(String(15), nullable=False, default="pending") # pending / paid / refunded / cancelled - paid_at = Column(DateTime, default=None) - receipt_number = Column(String(100), default=None) - receipt_type = Column(String(20), default=None) - receipt_title = Column(String(200), default=None) - receipt_tax_number = Column(String(50), default=None) - receipt_issued_at = Column(DateTime, default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class Coupon(Base): - __tablename__ = "coupons" - - id = Column(String(36), primary_key=True, default=gen_uuid) - code = Column(String(50), unique=True, nullable=False) - type = Column(String(10), nullable=False) # fixed / percent - value = Column(DECIMAL(10, 2), nullable=False) - min_amount = Column(DECIMAL(10, 2), default=0.00) - valid_from = Column(DateTime, nullable=False) - valid_to = Column(DateTime, nullable=False) - total_count = Column(Integer, default=None) - used_count = Column(Integer, default=0) - status = Column(String(10), nullable=False, default="active") # active / expired / disabled - created_at = Column(DateTime, default=datetime.now) - - -class UserCoupon(Base): - __tablename__ = "user_coupons" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - coupon_id = Column(String(36), ForeignKey("coupons.id"), nullable=False) - status = Column(String(10), nullable=False, default="unused") # unused / used / expired - used_at = Column(DateTime, default=None) - created_at = Column(DateTime, default=datetime.now) - - -class ValueAddedService(Base): - __tablename__ = "value_added_services" - - id = Column(String(36), primary_key=True, default=gen_uuid) - name = Column(String(200), nullable=False) - description = Column(Text, default=None) - price = Column(DECIMAL(10, 2), nullable=False) - type = Column(String(20), nullable=False) # app_custom / platform_proxy / data_custom / private_project - features = Column(JSON, default=None) # 服务特性 - status = Column(String(10), nullable=False, default="active") # active / inactive / deleted - assigned_agent_id = Column(String(36), default=None) # 应用定制服务绑定的Agent(市场应用ID) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class CustomServiceOrder(Base): - __tablename__ = "custom_service_orders" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - service_id = Column(String(36), default=None) - service_type = Column(String(10), nullable=False) - description = Column(Text, default=None) - status = Column(String(15), nullable=False, default="pending") - price = Column(DECIMAL(10, 2), default=0.00) - order_id = Column(String(36), default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class CustomServiceRequest(Base): - """增值服务工单表 - 用于数据定制服务和私有化项目服务""" - __tablename__ = "custom_service_requests" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - service_id = Column(String(36), ForeignKey("value_added_services.id"), nullable=False) - request_type = Column(String(20), nullable=False) # data_custom / private_project - title = Column(String(200), nullable=False) - description = Column(Text, default=None) - requirements = Column(JSON, default=None) # 需求详情JSON - status = Column(String(20), nullable=False, default="pending") # pending / processing / completed / rejected - assigned_agent_id = Column(String(36), default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -# ============================================================ -# Token与计费 -# ============================================================ - -class ModelProvider(Base): - __tablename__ = "model_providers" - - id = Column(String(36), primary_key=True, default=gen_uuid) - name = Column(String(100), nullable=False) - protocol = Column(String(30), nullable=False, default="openai_compatible") - api_key = Column(String(255), default=None) - api_base_url = Column(String(500), default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class AIModel(Base): - __tablename__ = "models" - - id = Column(String(36), primary_key=True, default=gen_uuid) - provider_id = Column(String(36), ForeignKey("model_providers.id"), nullable=False) - name = Column(String(100), nullable=False) - display_name = Column(String(200), default=None, comment="display name") - api_key = Column(String(255), default=None) - endpoint = Column(String(500), default=None) - capabilities = Column(JSON, default=None) - timeout = Column(Integer, default=60) - status = Column(String(10), nullable=False, default="paid") # free / paid - price_per_million_tokens = Column(DECIMAL(10, 4), nullable=False, default=0.0000) - is_enabled = Column(Boolean, default=True) - is_default = Column(Boolean, default=False) # 全局默认模型 - total_calls = Column(BigInteger, default=0) - total_tokens = Column(BigInteger, default=0) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class TokenUsageLog(Base): - __tablename__ = "token_usage_logs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - model_id = Column(String(36), default=None) - model_name = Column(String(100), default=None) - app_id = Column(String(36), default=None) - app_name = Column(String(200), default=None) - conversation_id = Column(String(36), default=None) - trace_id = Column(String(64), default=None) - prompt_tokens = Column(Integer, default=0) - completion_tokens = Column(Integer, default=0) - total_tokens = Column(Integer, default=0) - cost = Column(DECIMAL(10, 6), default=0.000000) - created_at = Column(DateTime, default=datetime.now) - - -class TokenBalance(Base): - __tablename__ = "token_balances" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), unique=True, nullable=False) - total_consumed = Column(BigInteger, default=0) - balance = Column(BigInteger, default=0) - daily_free_used = Column(BigInteger, default=0) - daily_free_date = Column(Date, default=None) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -# ============================================================ -# 通知与日志 -# ============================================================ - -class Notification(Base): - __tablename__ = "notifications" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - type = Column(String(15), nullable=False) - title = Column(String(200), nullable=False) - content = Column(Text, default=None) - is_read = Column(Boolean, default=False) - created_at = Column(DateTime, default=datetime.now) - - -class OperationLog(Base): - __tablename__ = "operation_logs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), nullable=False) - username = Column(String(50), default=None) - action = Column(String(100), nullable=False) - target = Column(String(100), default=None) - target_id = Column(String(36), default=None) - detail = Column(Text, default=None) - ip = Column(String(45), default=None) - created_at = Column(DateTime, default=datetime.now) - - -class AuditLog(Base): - __tablename__ = "audit_logs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - admin_id = Column(String(36), nullable=False) - admin_name = Column(String(50), default=None) - action = Column(String(100), nullable=False) - target_type = Column(String(50), default=None) - target_id = Column(String(36), default=None) - detail = Column(Text, default=None) - created_at = Column(DateTime, default=datetime.now) - - -class RiskRule(Base): - __tablename__ = "risk_rules" - - id = Column(String(36), primary_key=True, default=gen_uuid) - name = Column(String(100), nullable=False) - type = Column(String(15), nullable=False) - threshold = Column(Integer, default=0) - duration = Column(Integer, default=3600) - is_enabled = Column(Boolean, default=True) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -# ============================================================ -# 系统配置 -# ============================================================ - -class SiteSetting(Base): - __tablename__ = "site_settings" - - id = Column(String(36), primary_key=True, default=gen_uuid) - key = Column(String(100), unique=True, nullable=False) - value = Column(Text, default=None) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class EmailConfig(Base): - __tablename__ = "email_configs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - smtp_host = Column(String(200), nullable=False) - smtp_port = Column(Integer, nullable=False, default=587) - username = Column(String(200), nullable=False) - password = Column(String(200), nullable=False) - from_address = Column(String(200), nullable=False) - use_tls = Column(Boolean, default=True) - use_ssl = Column(Boolean, default=False) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class SmsConfig(Base): - __tablename__ = "sms_configs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - provider = Column(String(50), nullable=False) - api_key = Column(String(200), nullable=False) - api_secret = Column(String(200), nullable=False) - sign_name = Column(String(100), default=None) - template_code = Column(String(100), default=None) - # 短信宝 (SMSBao) 字段 - username = Column(String(100), default=None) # 短信宝用户名 u - password = Column(String(200), default=None) # 短信宝密码(明文存储,发送时 MD5 32位) - goods_id = Column(String(100), default=None) # 短信宝专属通道产品 ID g - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class PaymentConfig(Base): - __tablename__ = "payment_configs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - type = Column(String(10), nullable=False) # wechat / alipay - app_id = Column(String(100), nullable=False) - merchant_id = Column(String(100), default=None) # 微信: 商户号 - api_key = Column(String(255), nullable=False) # 微信: APIv2密钥 / 支付宝: 应用私钥 - api_v3_key = Column(String(255), default=None) # 微信: APIv3密钥 - cert_path = Column(String(500), default=None) # 微信: 证书路径 - cert_serial_no = Column(String(200), default=None) # 微信: 证书序列号 - alipay_public_key = Column(Text, default=None) # 支付宝: 支付宝公钥 - app_private_key = Column(Text, default=None) # 支付宝: 应用私钥 - gateway_url = Column(String(500), default=None) # 支付宝: 网关地址 - notify_url = Column(String(500), default=None) # 支付回调通知地址 - return_url = Column(String(500), default=None) # 支付宝: 同步跳转地址 - status = Column(String(10), nullable=False, default="inactive") # active / inactive - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class RegisterSetting(Base): - __tablename__ = "register_settings" - - id = Column(String(36), primary_key=True, default=gen_uuid) - phone_required = Column(Boolean, default=True) - phone_sms_required = Column(Boolean, default=True) - email_required = Column(Boolean, default=True) - email_verify_required = Column(Boolean, default=True) - real_name_required = Column(Boolean, default=False) - aliyun_access_key = Column(String(200), default=None) - aliyun_secret_key = Column(String(200), default=None) - aliyun_endpoint = Column(String(200), default=None) - sms_sign_name = Column(String(100), default=None) - sms_template_code = Column(String(50), default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -# ============================================================ -# 广告位 -# ============================================================ - -class AdBanner(Base): - __tablename__ = "ad_banners" - - id = Column(String(36), primary_key=True, default=gen_uuid) - title = Column(String(200), nullable=False) - image_url = Column(String(500), default=None) - link_app_id = Column(String(36), default=None) - link_app_name = Column(String(200), default=None) - sort_order = Column(Integer, default=0) - click_count = Column(Integer, default=0) - status = Column(String(10), nullable=False, default="active") # active / inactive - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -# ============================================================ -# 资产管理 -# ============================================================ - -class AssetCategory(Base): - __tablename__ = "asset_categories" - - id = Column(String(36), primary_key=True, default=gen_uuid) - name = Column(String(100), nullable=False) - type = Column(String(10), nullable=False) - icon = Column(String(200), default=None) - color = Column(String(20), default=None) - is_default = Column(Boolean, default=False) - user_id = Column(String(36), default=None) - created_at = Column(DateTime, default=datetime.now) - - -class Asset(Base): - __tablename__ = "assets" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - name = Column(String(200), nullable=False) - category = Column(String(10), nullable=False) - sub_category = Column(String(50), default=None) - file_type = Column(String(50), default=None) - size = Column(BigInteger, default=0) - description = Column(Text, default=None) - cover_url = Column(String(500), default=None) - url = Column(String(500), default=None) - content = Column(Text, default=None) - novel_meta = Column(JSON, default=None) - status = Column(String(10), nullable=False, default="active") # active / deleted - knowledge_doc_id = Column(String(36), default=None, comment="关联的知识库文档ID(由知识库同步资产时写入)") - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class NovelChapter(Base): - __tablename__ = "novel_chapters" - - id = Column(String(36), primary_key=True, default=gen_uuid) - asset_id = Column(String(36), ForeignKey("assets.id"), nullable=False) - title = Column(String(200), nullable=False) - word_count = Column(Integer, default=0) - sort_order = Column(Integer, default=0) - content = Column(Text(length=4294967295), default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -# ============================================================ -# 预留扩展 -# ============================================================ - -class Team(Base): - __tablename__ = "teams" - - id = Column(String(36), primary_key=True, default=gen_uuid) - owner_id = Column(String(36), ForeignKey("users.id"), nullable=False) - name = Column(String(100), nullable=False) - description = Column(Text, default=None) - status = Column(String(10), nullable=False, default="active") - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class Referral(Base): - __tablename__ = "referrals" - - id = Column(String(36), primary_key=True, default=gen_uuid) - referrer_id = Column(String(36), ForeignKey("users.id"), nullable=False) - referee_id = Column(String(36), ForeignKey("users.id"), nullable=False) - referral_code = Column(String(50), default=None) - status = Column(String(10), nullable=False, default="pending") - commission_rate = Column(DECIMAL(5, 2), default=0.00) - created_at = Column(DateTime, default=datetime.now) - - -class OpenApiClient(Base): - __tablename__ = "open_api_clients" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - name = Column(String(100), nullable=False) - client_id = Column(String(64), unique=True, nullable=False) - client_secret = Column(String(128), nullable=False) - allowed_models = Column(JSON, default=None) - rate_limit = Column(Integer, default=60) - status = Column(String(10), nullable=False, default="active") - created_at = Column(DateTime, default=datetime.now) - total_calls = Column(BigInteger, default=0, comment="总调用次数") - last_used_at = Column(DateTime, default=None, comment="最后使用时间") - - -# ============================================================ -# API调用统计 -# ============================================================ - -class ApiCallStat(Base): - """API调用统计表 - 按端点聚合""" - __tablename__ = "api_call_stats" - - id = Column(String(36), primary_key=True, default=gen_uuid) - endpoint = Column(String(200), nullable=False) - method = Column(String(10), nullable=False) - call_count = Column(BigInteger, default=0) - total_response_time = Column(Float, default=0.0) - error_count = Column(BigInteger, default=0) - last_called_at = Column(DateTime, default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - __table_args__ = ( - UniqueConstraint("endpoint", "method", name="uk_endpoint_method"), - ) - - -class SlowRequestLog(Base): - """慢请求记录表""" - __tablename__ = "slow_request_logs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - request_id = Column(String(64), default=None) - method = Column(String(10), nullable=False) - endpoint = Column(String(500), nullable=False) - status_code = Column(Integer, default=0) - response_time = Column(Float, default=0.0) - threshold = Column(Float, default=3.0) - created_at = Column(DateTime, default=datetime.now) - - __table_args__ = ( - Index("idx_srl_created", "created_at"), - ) - - -# ============================================================ -# AI自进化引擎 - 用户进化配置 -# ============================================================ -class UserEvolutionConfig(Base): - __tablename__ = "user_evolution_config" - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), unique=True, nullable=False, index=True) - skill_dir_path = Column(String(500), nullable=True, comment="用户Skill仓库根目录") - trajectory_dir_path = Column(String(500), nullable=True, comment="用户对话轨迹目录") - watcher_status = Column(String(20), default="stopped", comment="运行中/已停止/错误") - watcher_error = Column(String(500), nullable=True, comment="watcher异常信息") - pipeline_status = Column(String(20), default="idle", comment="流水线状态: running/idle/error") - pipeline_processed = Column(Integer, default=0, comment="已处理轨迹数") - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class EvolutionSkill(Base): - __tablename__ = "evolution_skills" - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) - skill_name = Column(String(200), nullable=False, comment="技能名称") - description = Column(Text, nullable=True) - tags = Column(JSON, nullable=True, comment="标签列表") - category = Column(String(100), nullable=True, comment="技能分类") - state = Column(String(20), default="baby", comment="baby/main/staging/frozen") - use_count = Column(Integer, default=0, comment="使用次数") - version = Column(Integer, default=1) - skill_md_path = Column(String(500), nullable=True, comment="SKILL.md文件路径") - git_commit_sha = Column(String(40), nullable=True) - last_used_at = Column(DateTime, nullable=True) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class EvolutionEvent(Base): - __tablename__ = "evolution_events" - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) - skill_name = Column(String(200), nullable=False) - event_type = Column(String(50), nullable=False, comment="created/promoted/rejected/frozen/unfrozen/edited/deleted/milestone") - description = Column(Text, nullable=True) - version_before = Column(Integer, nullable=True) - version_after = Column(Integer, nullable=True) - created_at = Column(DateTime, default=datetime.now) - - -class EvolutionTrajectory(Base): - __tablename__ = "evolution_trajectories" - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) - conversation_id = Column(String(36), nullable=True, comment="关联的对话ID") - file_path = Column(String(500), nullable=True, comment="轨迹文件路径") - status = Column(String(20), default="discovered", comment="discovered/splitting/split_done/indexed/clustering/done/error/filtered") - atom_count = Column(Integer, default=0) - token_used = Column(Integer, default=0) - error_msg = Column(String(500), nullable=True, comment="轨迹处理失败的错误信息") - retry_count = Column(Integer, default=0, comment="失败重试次数") - tasks_extracted = Column(Integer, default=0, comment="拆分的AtomTask数量") - created_at = Column(DateTime, default=datetime.now) - processed_at = Column(DateTime, nullable=True) - - -class SkillTrajectoryLink(Base): - """技能-轨迹关联表:记录技能与对话轨迹的关联关系""" - __tablename__ = "skill_trajectory_links" - id = Column(String(36), primary_key=True, default=gen_uuid) - skill_name = Column(String(200), nullable=False, index=True, comment="关联的技能名称") - user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) - trajectory_id = Column(String(36), nullable=True, comment="关联的轨迹ID") - atom_id = Column(String(36), nullable=True, comment="原子任务ID") - weightscore = Column(Integer, default=0, comment="关联权重分") - created_at = Column(DateTime, default=datetime.now) - - -# ============================================================ -# AI自进化v2 - 新增表 -# ============================================================ - -class SkillVersion(Base): - """技能版本历史表:存储每个版本的SKILL.md完整内容,支持回滚""" - __tablename__ = "skill_versions" - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - skill_name = Column(String(200), nullable=False) - version = Column(Integer, nullable=False, default=1) - skill_md_content = Column(Text(4294967295), nullable=True, comment="该版本的SKILL.md完整内容") - git_commit_sha = Column(String(40), nullable=True) - description = Column(Text, nullable=True, comment="版本变更说明") - created_at = Column(DateTime, default=datetime.now) - - __table_args__ = ( - Index("idx_sv_user_skill", "user_id", "skill_name"), - Index("idx_sv_version", "version"), - ) - - -class EvolutionReport(Base): - """进化报告表:存储周报/月报""" - __tablename__ = "evolution_reports" - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - report_type = Column(String(20), nullable=False, comment="weekly / monthly") - period_start = Column(Date, nullable=False) - period_end = Column(Date, nullable=False) - report_data = Column(JSON, nullable=True, comment="报告详细数据(JSON)") - created_at = Column(DateTime, default=datetime.now) - - __table_args__ = ( - Index("idx_er_user_type", "user_id", "report_type"), - Index("idx_er_period", "period_start", "period_end"), - ) - - -class SkillPublishRecord(Base): - """技能市场发布记录表""" - __tablename__ = "skill_publish_records" - id = Column(String(36), primary_key=True, default=gen_uuid) - skill_id = Column(String(36), nullable=False, comment="关联evolution_skills.id") - skill_name = Column(String(200), nullable=False) - user_id = Column(String(36), ForeignKey("users.id"), nullable=False) - market_app_id = Column(String(36), nullable=True, comment="关联market_apps.id") - price = Column(DECIMAL(10, 2), default=0.00) - market_description = Column(Text, nullable=True) - status = Column(String(20), nullable=False, default="published", comment="published/unpublished") - published_at = Column(DateTime, default=datetime.now) - created_at = Column(DateTime, default=datetime.now) - - __table_args__ = ( - Index("idx_spr_skill_id", "skill_id"), - Index("idx_spr_user_id", "user_id"), - Index("idx_spr_status", "status"), - ) - - -class TeamSharedSkill(Base): - """团队共享技能表""" - __tablename__ = "team_shared_skills" - id = Column(String(36), primary_key=True, default=gen_uuid) - team_id = Column(String(36), ForeignKey("teams.id"), nullable=False) - skill_id = Column(String(36), nullable=False, comment="关联evolution_skills.id") - skill_name = Column(String(200), nullable=False) - shared_by = Column(String(36), ForeignKey("users.id"), nullable=False) - shared_by_name = Column(String(100), nullable=True, comment="分享者昵称") - is_public = Column(Boolean, default=True, comment="团队内是否可见") - created_at = Column(DateTime, default=datetime.now) - - __table_args__ = ( - UniqueConstraint("team_id", "skill_id", name="uk_tss_team_skill"), - ) - - -class ExternalKBService(Base): - """外部知识库服务配置""" - __tablename__ = "external_kb_services" - - id = Column(String(36), primary_key=True, default=gen_uuid) - name = Column(String(200), nullable=False) - service_type = Column(String(20), nullable=False) - api_url = Column(String(500), nullable=False) - api_key = Column(String(255), default="") - config = Column(JSON, default=None) - is_enabled = Column(Boolean, default=True) - status = Column(String(20), default="disconnected") - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - -class OSSStorageConfig(Base): - __tablename__ = "oss_storage_configs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - name = Column(String(200), nullable=False) - provider = Column(String(20), nullable=False) - endpoint = Column(String(500), nullable=False) - access_key = Column(String(255), nullable=False) - secret_key = Column(String(255), nullable=False) - bucket = Column(String(200), nullable=False) - region = Column(String(100), default="") - base_path = Column(String(300), default="") - is_default = Column(Boolean, default=False) - is_enabled = Column(Boolean, default=True) - status = Column(String(20), default="disconnected") - config = Column(JSON, default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - - -class UserStorageQuota(Base): - __tablename__ = "user_storage_quotas" - - id = Column(String(36), primary_key=True, default=gen_uuid) - user_id = Column(String(36), nullable=False, unique=True) - quota_bytes = Column(BigInteger, nullable=False, default=1073741824) - used_bytes = Column(BigInteger, nullable=False, default=0) - quota_type = Column(String(20), nullable=False, default="membership") - membership_level = Column(Integer, default=0) - plan_id = Column(String(36), default=None) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class AutomatedTask(Base): - __tablename__ = "automated_tasks" - - id = Column(String(36), primary_key=True, default=gen_uuid) - name = Column(String(200), nullable=False) - description = Column(Text, default="") - trigger_type = Column(String(20), nullable=False) - cron_expression = Column(String(100), default=None) - event_condition = Column(JSON, default=None) - action_type = Column(String(30), nullable=False) - action_config = Column(JSON, default=None) - is_enabled = Column(Boolean, default=True) - status = Column(String(20), default="idle") - max_retries = Column(Integer, default=3) - timeout_seconds = Column(Integer, default=300) - last_run_at = Column(DateTime, default=None) - next_run_at = Column(DateTime, default=None) - total_runs = Column(Integer, default=0) - success_runs = Column(Integer, default=0) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - -class TaskExecutionLog(Base): - __tablename__ = "task_execution_logs" - - id = Column(String(36), primary_key=True, default=gen_uuid) - task_id = Column(String(36), nullable=False) - task_name = Column(String(200), default="") - status = Column(String(20), default="running") - trigger_type = Column(String(20), default="manual") - started_at = Column(DateTime, default=None) - finished_at = Column(DateTime, default=None) - duration_ms = Column(Integer, default=0) - result_summary = Column(Text, default="") - error_message = Column(Text, default="") - created_at = Column(DateTime, default=datetime.now) - - - -class MCPServer(Base): - __tablename__ = "mcp_servers" - - id = Column(String(36), primary_key=True, default=gen_uuid) - name = Column(String(100), nullable=False) - description = Column(Text, default="") - transport_type = Column(String(20), default="stdio") - command = Column(String(500), default="") - args = Column(Text, default="") - env_vars = Column(Text, default="") - url = Column(String(500), default="") - headers = Column(Text, default="") - is_enabled = Column(Boolean, default=True) - status = Column(String(20), default="disconnected") - last_connected_at = Column(DateTime, default=None) - tools_count = Column(Integer, default=0) - resources_count = Column(Integer, default=0) - created_at = Column(DateTime, default=datetime.now) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) - - -class SmsVerificationCode(Base): - __tablename__ = "sms_verification_codes" - - id = Column(String(36), primary_key=True, default=gen_uuid) - phone = Column(String(20), nullable=False, index=True) - code = Column(String(10), nullable=False) - purpose = Column(String(30), nullable=False) # register / reset_password - expires_at = Column(DateTime, nullable=False) - used = Column(Boolean, default=False) - created_at = Column(DateTime, default=datetime.now) - - - -class ConversationSummary(Base): - __tablename__ = "conversation_summaries" - - conversation_id = Column(String(64), primary_key=True) - summary_text = Column(Text, nullable=False, default="") - token_count = Column(Integer, default=0) - updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) +""" +AIFlow 数据库模型定义 - SQLAlchemy ORM +所有用户数据查询必须带 user_id 过滤实现物理隔离 +""" +import uuid +from datetime import datetime, date +from sqlalchemy import ( + Text, + Column, String, Integer, BigInteger, Float, Boolean, + DateTime, Date, Text, Enum, ForeignKey, Index, JSON, + DECIMAL, UniqueConstraint +) +from sqlalchemy.orm import relationship +from .database import Base + + +def gen_uuid() -> str: + return str(uuid.uuid4()) + + +# ============================================================ +# 用户与权限 +# ============================================================ + +class User(Base): + __tablename__ = "users" + + id = Column(String(36), primary_key=True, default=gen_uuid) + username = Column(String(50), unique=True, nullable=False) + email = Column(String(100), default=None) + phone = Column(String(20), default=None) + password_hash = Column(String(255), nullable=False) + avatar = Column(String(500), default=None) + role = Column(String(10), nullable=False, default="user") # user / admin + status = Column(String(10), nullable=False, default="active") # active / banned + real_name_status = Column(String(20), nullable=False, default="unverified") + real_name = Column(String(50), default=None) + id_card = Column(String(20), default=None) + membership_plan_id = Column(String(36), default=None) + membership_level = Column(Integer, default=0) + membership_expire_at = Column(DateTime, default=None) + auto_renew = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class LoginLog(Base): + __tablename__ = "login_logs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + ip = Column(String(45), default=None) + user_agent = Column(String(500), default=None) + login_at = Column(DateTime, default=datetime.now) + + +class ApiKey(Base): + __tablename__ = "api_keys" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + api_key = Column(String(64), unique=True, nullable=False) + secret = Column(String(128), nullable=False) + name = Column(String(100), default=None) + status = Column(String(10), nullable=False, default="active") # active / revoked + last_used_at = Column(DateTime, default=None) + created_at = Column(DateTime, default=datetime.now) + expired_at = Column(DateTime, default=None) + description = Column(String(500), default=None, comment="密钥描述") + rate_limit = Column(Integer, default=60, comment="速率限制(请求/分钟)") + total_calls = Column(BigInteger, default=0, comment="总调用次数") + + +# ============================================================ +# AI聊天与频道 +# ============================================================ + +class Conversation(Base): + __tablename__ = "conversations" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + title = Column(String(200), nullable=False, default="新对话") + model_id = Column(String(36), default=None) + status = Column(String(10), nullable=False, default="active") # active / archived + pinned = Column(Boolean, default=False) + knowledge_graph_count = Column(Integer, default=0) + knowledge_graph_base_id = Column(String(64), default=None, comment="已生成的知识图谱知识库ID") + source = Column(String(32), default=None, comment="对话来源,如 wechat_bot") + ext_agent_conversation_id = Column(String(128), default=None, comment="外部Agent(Dify/Coze)会话ID,用于维持多轮上下文") + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class Message(Base): + __tablename__ = "messages" + + id = Column(String(36), primary_key=True, default=gen_uuid) + conversation_id = Column(String(36), ForeignKey("conversations.id"), nullable=False) + role = Column(String(10), nullable=False) # user / assistant / system + content = Column(Text, nullable=False) + token_used = Column(Integer, default=0) + model_id = Column(String(36), default=None) + knowledge_ids = Column(JSON, default=None) + agent_ids = Column(JSON, default=None) + skill_ids = Column(JSON, default=None) + mcp_ids = Column(JSON, default=None, comment="该轮对话选择的MCP应用ID列表") + attachments = Column(JSON, default=None, comment="该轮对话附带的文件附件列表") + used_skills = Column(JSON, default=None, comment="该轮对话调用的进化技能名列表") + used_mcp = Column(JSON, default=None, comment="该轮对话调用的MCP应用名列表") + created_at = Column(DateTime, default=datetime.now) + + +class Channel(Base): + __tablename__ = "channels" + + id = Column(String(36), primary_key=True, default=gen_uuid) + owner_id = Column(String(36), ForeignKey("users.id"), nullable=False) + name = Column(String(100), nullable=False) + description = Column(Text, default=None) + platform = Column(String(20), nullable=False) + webhook_url = Column(String(500), default=None) + credential = Column(JSON, default=None) + oauth_state = Column(String(500), default=None) + settings = Column(JSON, default=None) + account_name = Column(String(100), default=None) + default_agent_id = Column(String(36), default=None) + default_skill_id = Column(String(36), default=None) + knowledge_base_id = Column(String(36), default=None) + bound_model_id = Column(String(36), default=None) + features = Column(JSON, default=None) + status = Column(String(20), nullable=False, default="pending") + is_active = Column(Boolean, default=True) + last_active = Column(DateTime, default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class ChannelMember(Base): + __tablename__ = "channel_members" + __table_args__ = (UniqueConstraint("channel_id", "user_id"),) + + id = Column(String(36), primary_key=True, default=gen_uuid) + channel_id = Column(String(36), ForeignKey("channels.id"), nullable=False) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + role = Column(String(10), nullable=False, default="member") # owner / member + joined_at = Column(DateTime, default=datetime.now) + + +class ChannelMessage(Base): + __tablename__ = "channel_messages" + + id = Column(String(36), primary_key=True, default=gen_uuid) + channel_id = Column(String(36), ForeignKey("channels.id"), nullable=False) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + role = Column(String(10), nullable=False) + content = Column(Text, nullable=False) + token_used = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.now) + + +# ============================================================ +# 知识库 +# ============================================================ + +class KnowledgeBase(Base): + __tablename__ = "knowledge_bases" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + name = Column(String(200), nullable=False) + description = Column(Text, default=None) + directory_path = Column(String(500), default="/") + document_count = Column(Integer, default=0) + knowledge_graph_count = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + is_official = Column(Boolean, default=False) + status = Column(String(20), default="active") + external_service_id = Column(String(36), default=None) + external_id = Column(String(100), default=None) + external_sync_status = Column(String(20), default="none") + external_config = Column(JSON, default=None) + + +class KnowledgeDirectory(Base): + __tablename__ = "knowledge_directories" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), default=None) + parent_id = Column(String(36), default=None) + name = Column(String(200), nullable=False) + created_at = Column(DateTime, default=datetime.now) + + +class KnowledgeDocument(Base): + __tablename__ = "knowledge_documents" + + id = Column(String(36), primary_key=True, default=gen_uuid) + knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False) + directory_id = Column(String(36), ForeignKey("knowledge_directories.id"), default=None) + filename = Column(String(255), nullable=False) + file_type = Column(String(20), nullable=False) + file_size = Column(BigInteger, default=0) + file_path = Column(String(500), default=None) + status = Column(String(20), nullable=False, default="uploading") + chunk_count = Column(Integer, default=0) + error_message = Column(Text, default=None) + content = Column(Text, default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + + + +class KnowledgeDocumentChunk(Base): + """知识库文档分段表 — Stage C.1""" + __tablename__ = "knowledge_document_chunks" + + id = Column(String(36), primary_key=True, default=gen_uuid) + document_id = Column(String(36), ForeignKey("knowledge_documents.id"), nullable=False) + knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False) + chunk_index = Column(Integer, nullable=False, default=0, comment="分段序号") + content = Column(Text, nullable=False, comment="分段文本内容") + start_char = Column(Integer, default=0, comment="起始字符位置") + end_char = Column(Integer, default=0, comment="结束字符位置") + chunk_metadata = Column(JSON, default=None, comment="分段元数据(标题、关键词等)") + token_count = Column(Integer, default=0, comment="分段token数估算") + created_at = Column(DateTime, default=datetime.now) + + __table_args__ = ( + Index("idx_kdc_doc_id", "document_id"), + Index("idx_kdc_kb_id", "knowledge_base_id"), + ) + +class KnowledgeGraphNode(Base): + __tablename__ = "knowledge_graph_nodes" + + id = Column(String(36), primary_key=True, default=gen_uuid) + knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False) + source_doc_id = Column(String(36), ForeignKey("knowledge_documents.id"), default=None) + label = Column(String(500), nullable=False) + type = Column(String(50), nullable=False, comment="entity/attribute/concept/event") + properties = Column(JSON, default=None) + description = Column(Text, default=None) + created_at = Column(DateTime, default=datetime.now) + + +class KnowledgeGraphEdge(Base): + __tablename__ = "knowledge_graph_edges" + + id = Column(String(36), primary_key=True, default=gen_uuid) + knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False) + source_node_id = Column(String(36), ForeignKey("knowledge_graph_nodes.id"), nullable=False) + target_node_id = Column(String(36), ForeignKey("knowledge_graph_nodes.id"), nullable=False) + relation = Column(String(200), nullable=False) + weight = Column(Float, default=1.0) + description = Column(Text, default=None) + created_at = Column(DateTime, default=datetime.now) + + +class KnowledgeGraphBuildLog(Base): + __tablename__ = "knowledge_graph_build_logs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False) + document_id = Column(String(36), ForeignKey("knowledge_documents.id"), nullable=False) + node_count = Column(Integer, default=0) + edge_count = Column(Integer, default=0) + status = Column(String(20), default="completed") + created_at = Column(DateTime, default=datetime.now) + + +# ============================================================ +# 应用市场 +# ============================================================ + +class MarketApp(Base): + __tablename__ = "market_apps" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(200), nullable=False) + type = Column(String(10), nullable=False) # skill / agent / mcp + source = Column(String(10), nullable=False, default="user") # official / user + publisher_id = Column(String(36), ForeignKey("users.id"), default=None) + publisher_name = Column(String(100), default=None) + description = Column(Text, default=None) + icon = Column(String(500), default=None) + tutorial = Column(Text, default=None) + price = Column(DECIMAL(10, 2), nullable=False, default=0.00) + is_member_free = Column(Boolean, default=False) + join_promotion = Column(Boolean, default=False) + status = Column(String(10), nullable=False, default="pending") # pending / published / removed + rating_avg = Column(DECIMAL(3, 2), default=0.00) + review_count = Column(Integer, default=0) + install_count = Column(Integer, default=0) + skill_content = Column(Text, default=None) + mcp_transport = Column(String(20), default=None) + platform = Column(String(20), default=None, comment="Agent 接入平台:dify/n8n/coze") + capability = Column(String(20), default=None, comment="能力分类:text/thinking/vision/tool/image/audio/asr/ocr/embedding/rerank/video") + agent_endpoint = Column(String(500), default=None) + agent_api_key = Column(Text, default=None, comment="Agent 接入 API Key(3.2 起 Fernet 加密存储)") + tool_policy = Column(JSON, default=None, comment="Agent 工具/MCP 权限沙箱策略(3.1)") + orchestration = Column(JSON, default=None, comment="Agent 多Agent编排图(3.3)") + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class AppPurchase(Base): + __tablename__ = "app_purchases" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + app_id = Column(String(36), ForeignKey("market_apps.id"), nullable=False) + price = Column(DECIMAL(10, 2), nullable=False, default=0.00) + order_id = Column(String(36), default=None) + status = Column(String(20), nullable=False, default="purchased") # purchased / installed + installed_at = Column(DateTime, default=None) + purchased_at = Column(DateTime, default=None) + created_at = Column(DateTime, default=datetime.now) + + +class AppReview(Base): + __tablename__ = "app_reviews" + __table_args__ = (UniqueConstraint("user_id", "app_id"),) + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + app_id = Column(String(36), ForeignKey("market_apps.id"), nullable=False) + rating = Column(Integer, nullable=False) + content = Column(Text, default=None) + created_at = Column(DateTime, default=datetime.now) + + +class AppInstallation(Base): + __tablename__ = "app_installations" + __table_args__ = (UniqueConstraint("user_id", "app_id"),) + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + app_id = Column(String(36), ForeignKey("market_apps.id"), nullable=False) + installed_at = Column(DateTime, default=datetime.now) + + +# ============================================================ +# 付费与订单 +# ============================================================ + +class MembershipPlan(Base): + __tablename__ = "membership_plans" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(100), nullable=False) + level = Column(Integer, nullable=False, default=0) + duration_type = Column(String(10), nullable=False) # month / quarter / year + price = Column(DECIMAL(10, 2), nullable=False) + daily_free_tokens = Column(Integer, default=0) + features = Column(JSON, default=None) + free_app_ids = Column(JSON, default=None) + free_model_ids = Column(JSON, default=None) + # 配额字段 + max_agents = Column(Integer, default=5) + max_workflows = Column(Integer, default=3) + max_knowledge_bases = Column(Integer, default=2) + max_storage_mb = Column(Integer, default=1024) + api_calls_per_day = Column(Integer, default=100) + api_calls_per_min = Column(Integer, default=10) + # 权限字段 + can_publish_apps = Column(Boolean, default=False) + can_create_api_keys = Column(Boolean, default=False) + can_use_workflow = Column(Boolean, default=False) + can_use_mcp = Column(Boolean, default=False) + can_use_code_exec = Column(Boolean, default=False) + can_use_privacy_mode = Column(Boolean, default=False) + can_use_dag = Column(Boolean, default=False) + allowed_model_tiers = Column(JSON, default=None) + permissions = Column(JSON, default=None) + is_active = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class MembershipSubscription(Base): + __tablename__ = "membership_subscriptions" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + plan_id = Column(String(36), ForeignKey("membership_plans.id"), nullable=False) + order_id = Column(String(36), default=None) + start_at = Column(DateTime, nullable=False) + end_at = Column(DateTime, nullable=False) + auto_renew = Column(Boolean, default=False) + status = Column(String(15), nullable=False, default="active") # active / expired / downgraded + created_at = Column(DateTime, default=datetime.now) + + +class Order(Base): + __tablename__ = "orders" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + order_type = Column(String(15), nullable=False) # membership / service / app + item_id = Column(String(36), default=None) + item_name = Column(String(200), default=None) + amount = Column(DECIMAL(10, 2), nullable=False) + coupon_id = Column(String(36), default=None) + discount_amount = Column(DECIMAL(10, 2), default=0.00) + payment_method = Column(String(10), default=None) # wechat / alipay + payment_id = Column(String(200), default=None) + status = Column(String(15), nullable=False, default="pending") # pending / paid / refunded / cancelled + paid_at = Column(DateTime, default=None) + receipt_number = Column(String(100), default=None) + receipt_type = Column(String(20), default=None) + receipt_title = Column(String(200), default=None) + receipt_tax_number = Column(String(50), default=None) + receipt_issued_at = Column(DateTime, default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class Coupon(Base): + __tablename__ = "coupons" + + id = Column(String(36), primary_key=True, default=gen_uuid) + code = Column(String(50), unique=True, nullable=False) + type = Column(String(10), nullable=False) # fixed / percent + value = Column(DECIMAL(10, 2), nullable=False) + min_amount = Column(DECIMAL(10, 2), default=0.00) + valid_from = Column(DateTime, nullable=False) + valid_to = Column(DateTime, nullable=False) + total_count = Column(Integer, default=None) + used_count = Column(Integer, default=0) + status = Column(String(10), nullable=False, default="active") # active / expired / disabled + created_at = Column(DateTime, default=datetime.now) + + +class UserCoupon(Base): + __tablename__ = "user_coupons" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + coupon_id = Column(String(36), ForeignKey("coupons.id"), nullable=False) + status = Column(String(10), nullable=False, default="unused") # unused / used / expired + used_at = Column(DateTime, default=None) + created_at = Column(DateTime, default=datetime.now) + + +class ValueAddedService(Base): + __tablename__ = "value_added_services" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(200), nullable=False) + description = Column(Text, default=None) + price = Column(DECIMAL(10, 2), nullable=False) + type = Column(String(20), nullable=False) # app_custom / platform_proxy / data_custom / private_project + features = Column(JSON, default=None) # 服务特性 + status = Column(String(10), nullable=False, default="active") # active / inactive / deleted + assigned_agent_id = Column(String(36), default=None) # 应用定制服务绑定的Agent(市场应用ID) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class CustomServiceOrder(Base): + __tablename__ = "custom_service_orders" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + service_id = Column(String(36), default=None) + service_type = Column(String(10), nullable=False) + description = Column(Text, default=None) + status = Column(String(15), nullable=False, default="pending") + price = Column(DECIMAL(10, 2), default=0.00) + order_id = Column(String(36), default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class CustomServiceRequest(Base): + """增值服务工单表 - 用于数据定制服务和私有化项目服务""" + __tablename__ = "custom_service_requests" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + service_id = Column(String(36), ForeignKey("value_added_services.id"), nullable=False) + request_type = Column(String(20), nullable=False) # data_custom / private_project + title = Column(String(200), nullable=False) + description = Column(Text, default=None) + requirements = Column(JSON, default=None) # 需求详情JSON + status = Column(String(20), nullable=False, default="pending") # pending / processing / completed / rejected + assigned_agent_id = Column(String(36), default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +# ============================================================ +# Token与计费 +# ============================================================ + +class ModelProvider(Base): + __tablename__ = "model_providers" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(100), nullable=False) + protocol = Column(String(30), nullable=False, default="openai_compatible") + api_key = Column(String(255), default=None) + api_base_url = Column(String(500), default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class AIModel(Base): + __tablename__ = "models" + + id = Column(String(36), primary_key=True, default=gen_uuid) + provider_id = Column(String(36), ForeignKey("model_providers.id"), nullable=False) + name = Column(String(100), nullable=False) + display_name = Column(String(200), default=None, comment="display name") + api_key = Column(String(255), default=None) + endpoint = Column(String(500), default=None) + capabilities = Column(JSON, default=None) + timeout = Column(Integer, default=60) + status = Column(String(10), nullable=False, default="paid") # free / paid + price_per_million_tokens = Column(DECIMAL(10, 4), nullable=False, default=0.0000) + is_enabled = Column(Boolean, default=True) + is_default = Column(Boolean, default=False) # 全局默认模型 + total_calls = Column(BigInteger, default=0) + total_tokens = Column(BigInteger, default=0) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class TokenUsageLog(Base): + __tablename__ = "token_usage_logs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + model_id = Column(String(36), default=None) + model_name = Column(String(100), default=None) + app_id = Column(String(36), default=None) + app_name = Column(String(200), default=None) + conversation_id = Column(String(36), default=None) + trace_id = Column(String(64), default=None) + prompt_tokens = Column(Integer, default=0) + completion_tokens = Column(Integer, default=0) + total_tokens = Column(Integer, default=0) + cost = Column(DECIMAL(10, 6), default=0.000000) + created_at = Column(DateTime, default=datetime.now) + + +class TokenBalance(Base): + __tablename__ = "token_balances" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), unique=True, nullable=False) + total_consumed = Column(BigInteger, default=0) + balance = Column(BigInteger, default=0) + daily_free_used = Column(BigInteger, default=0) + daily_free_date = Column(Date, default=None) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +# ============================================================ +# 通知与日志 +# ============================================================ + +class Notification(Base): + __tablename__ = "notifications" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + type = Column(String(15), nullable=False) + title = Column(String(200), nullable=False) + content = Column(Text, default=None) + is_read = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.now) + + +class OperationLog(Base): + __tablename__ = "operation_logs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), nullable=False) + username = Column(String(50), default=None) + action = Column(String(100), nullable=False) + target = Column(String(100), default=None) + target_id = Column(String(36), default=None) + detail = Column(Text, default=None) + ip = Column(String(45), default=None) + created_at = Column(DateTime, default=datetime.now) + + +class AuditLog(Base): + __tablename__ = "audit_logs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + admin_id = Column(String(36), nullable=False) + admin_name = Column(String(50), default=None) + action = Column(String(100), nullable=False) + target_type = Column(String(50), default=None) + target_id = Column(String(36), default=None) + detail = Column(Text, default=None) + created_at = Column(DateTime, default=datetime.now) + + +class RiskRule(Base): + __tablename__ = "risk_rules" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(100), nullable=False) + type = Column(String(15), nullable=False) + threshold = Column(Integer, default=0) + duration = Column(Integer, default=3600) + is_enabled = Column(Boolean, default=True) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +# ============================================================ +# 系统配置 +# ============================================================ + +class SiteSetting(Base): + __tablename__ = "site_settings" + + id = Column(String(36), primary_key=True, default=gen_uuid) + key = Column(String(100), unique=True, nullable=False) + value = Column(Text, default=None) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class EmailConfig(Base): + __tablename__ = "email_configs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + smtp_host = Column(String(200), nullable=False) + smtp_port = Column(Integer, nullable=False, default=587) + username = Column(String(200), nullable=False) + password = Column(String(200), nullable=False) + from_address = Column(String(200), nullable=False) + use_tls = Column(Boolean, default=True) + use_ssl = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class SmsConfig(Base): + __tablename__ = "sms_configs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + provider = Column(String(50), nullable=False) + api_key = Column(String(200), nullable=False) + api_secret = Column(String(200), nullable=False) + sign_name = Column(String(100), default=None) + template_code = Column(String(100), default=None) + # 短信宝 (SMSBao) 字段 + username = Column(String(100), default=None) # 短信宝用户名 u + password = Column(String(200), default=None) # 短信宝密码(明文存储,发送时 MD5 32位) + goods_id = Column(String(100), default=None) # 短信宝专属通道产品 ID g + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class PaymentConfig(Base): + __tablename__ = "payment_configs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + type = Column(String(10), nullable=False) # wechat / alipay + app_id = Column(String(100), nullable=False) + merchant_id = Column(String(100), default=None) # 微信: 商户号 + api_key = Column(String(255), nullable=False) # 微信: APIv2密钥 / 支付宝: 应用私钥 + api_v3_key = Column(String(255), default=None) # 微信: APIv3密钥 + cert_path = Column(String(500), default=None) # 微信: 证书路径 + cert_serial_no = Column(String(200), default=None) # 微信: 证书序列号 + alipay_public_key = Column(Text, default=None) # 支付宝: 支付宝公钥 + app_private_key = Column(Text, default=None) # 支付宝: 应用私钥 + gateway_url = Column(String(500), default=None) # 支付宝: 网关地址 + notify_url = Column(String(500), default=None) # 支付回调通知地址 + return_url = Column(String(500), default=None) # 支付宝: 同步跳转地址 + status = Column(String(10), nullable=False, default="inactive") # active / inactive + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class RegisterSetting(Base): + __tablename__ = "register_settings" + + id = Column(String(36), primary_key=True, default=gen_uuid) + phone_required = Column(Boolean, default=True) + phone_sms_required = Column(Boolean, default=True) + email_required = Column(Boolean, default=True) + email_verify_required = Column(Boolean, default=True) + real_name_required = Column(Boolean, default=False) + aliyun_access_key = Column(String(200), default=None) + aliyun_secret_key = Column(String(200), default=None) + aliyun_endpoint = Column(String(200), default=None) + sms_sign_name = Column(String(100), default=None) + sms_template_code = Column(String(50), default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +# ============================================================ +# 广告位 +# ============================================================ + +class AdBanner(Base): + __tablename__ = "ad_banners" + + id = Column(String(36), primary_key=True, default=gen_uuid) + title = Column(String(200), nullable=False) + image_url = Column(String(500), default=None) + link_app_id = Column(String(36), default=None) + link_app_name = Column(String(200), default=None) + sort_order = Column(Integer, default=0) + click_count = Column(Integer, default=0) + status = Column(String(10), nullable=False, default="active") # active / inactive + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +# ============================================================ +# 资产管理 +# ============================================================ + +class AssetCategory(Base): + __tablename__ = "asset_categories" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(100), nullable=False) + type = Column(String(10), nullable=False) + icon = Column(String(200), default=None) + color = Column(String(20), default=None) + is_default = Column(Boolean, default=False) + user_id = Column(String(36), default=None) + created_at = Column(DateTime, default=datetime.now) + + +class Asset(Base): + __tablename__ = "assets" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + name = Column(String(200), nullable=False) + category = Column(String(10), nullable=False) + sub_category = Column(String(50), default=None) + file_type = Column(String(50), default=None) + size = Column(BigInteger, default=0) + description = Column(Text, default=None) + cover_url = Column(String(500), default=None) + url = Column(String(500), default=None) + content = Column(Text, default=None) + novel_meta = Column(JSON, default=None) + status = Column(String(10), nullable=False, default="active") # active / deleted + knowledge_doc_id = Column(String(36), default=None, comment="关联的知识库文档ID(由知识库同步资产时写入)") + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class NovelChapter(Base): + __tablename__ = "novel_chapters" + + id = Column(String(36), primary_key=True, default=gen_uuid) + asset_id = Column(String(36), ForeignKey("assets.id"), nullable=False) + title = Column(String(200), nullable=False) + word_count = Column(Integer, default=0) + sort_order = Column(Integer, default=0) + content = Column(Text(length=4294967295), default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +# ============================================================ +# 预留扩展 +# ============================================================ + +class Team(Base): + __tablename__ = "teams" + + id = Column(String(36), primary_key=True, default=gen_uuid) + owner_id = Column(String(36), ForeignKey("users.id"), nullable=False) + name = Column(String(100), nullable=False) + description = Column(Text, default=None) + status = Column(String(10), nullable=False, default="active") + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class Referral(Base): + __tablename__ = "referrals" + + id = Column(String(36), primary_key=True, default=gen_uuid) + referrer_id = Column(String(36), ForeignKey("users.id"), nullable=False) + referee_id = Column(String(36), ForeignKey("users.id"), nullable=False) + referral_code = Column(String(50), default=None) + status = Column(String(10), nullable=False, default="pending") + commission_rate = Column(DECIMAL(5, 2), default=0.00) + created_at = Column(DateTime, default=datetime.now) + + +class OpenApiClient(Base): + __tablename__ = "open_api_clients" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + name = Column(String(100), nullable=False) + client_id = Column(String(64), unique=True, nullable=False) + client_secret = Column(String(128), nullable=False) + allowed_models = Column(JSON, default=None) + rate_limit = Column(Integer, default=60) + status = Column(String(10), nullable=False, default="active") + created_at = Column(DateTime, default=datetime.now) + total_calls = Column(BigInteger, default=0, comment="总调用次数") + last_used_at = Column(DateTime, default=None, comment="最后使用时间") + + +# ============================================================ +# API调用统计 +# ============================================================ + +class ApiCallStat(Base): + """API调用统计表 - 按端点聚合""" + __tablename__ = "api_call_stats" + + id = Column(String(36), primary_key=True, default=gen_uuid) + endpoint = Column(String(200), nullable=False) + method = Column(String(10), nullable=False) + call_count = Column(BigInteger, default=0) + total_response_time = Column(Float, default=0.0) + error_count = Column(BigInteger, default=0) + last_called_at = Column(DateTime, default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + __table_args__ = ( + UniqueConstraint("endpoint", "method", name="uk_endpoint_method"), + ) + + +class SlowRequestLog(Base): + """慢请求记录表""" + __tablename__ = "slow_request_logs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + request_id = Column(String(64), default=None) + method = Column(String(10), nullable=False) + endpoint = Column(String(500), nullable=False) + status_code = Column(Integer, default=0) + response_time = Column(Float, default=0.0) + threshold = Column(Float, default=3.0) + created_at = Column(DateTime, default=datetime.now) + + __table_args__ = ( + Index("idx_srl_created", "created_at"), + ) + + +# ============================================================ +# AI自进化引擎 - 用户进化配置 +# ============================================================ +class UserEvolutionConfig(Base): + __tablename__ = "user_evolution_config" + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), unique=True, nullable=False, index=True) + skill_dir_path = Column(String(500), nullable=True, comment="用户Skill仓库根目录") + trajectory_dir_path = Column(String(500), nullable=True, comment="用户对话轨迹目录") + watcher_status = Column(String(20), default="stopped", comment="运行中/已停止/错误") + watcher_error = Column(String(500), nullable=True, comment="watcher异常信息") + pipeline_status = Column(String(20), default="idle", comment="流水线状态: running/idle/error") + pipeline_processed = Column(Integer, default=0, comment="已处理轨迹数") + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class EvolutionSkill(Base): + __tablename__ = "evolution_skills" + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) + skill_name = Column(String(200), nullable=False, comment="技能名称") + description = Column(Text, nullable=True) + tags = Column(JSON, nullable=True, comment="标签列表") + category = Column(String(100), nullable=True, comment="技能分类") + state = Column(String(20), default="baby", comment="baby/main/staging/frozen") + use_count = Column(Integer, default=0, comment="使用次数") + version = Column(Integer, default=1) + skill_md_path = Column(String(500), nullable=True, comment="SKILL.md文件路径") + git_commit_sha = Column(String(40), nullable=True) + last_used_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class EvolutionEvent(Base): + __tablename__ = "evolution_events" + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) + skill_name = Column(String(200), nullable=False) + event_type = Column(String(50), nullable=False, comment="created/promoted/rejected/frozen/unfrozen/edited/deleted/milestone") + description = Column(Text, nullable=True) + version_before = Column(Integer, nullable=True) + version_after = Column(Integer, nullable=True) + created_at = Column(DateTime, default=datetime.now) + + +class EvolutionTrajectory(Base): + __tablename__ = "evolution_trajectories" + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) + conversation_id = Column(String(36), nullable=True, comment="关联的对话ID") + file_path = Column(String(500), nullable=True, comment="轨迹文件路径") + status = Column(String(20), default="discovered", comment="discovered/splitting/split_done/indexed/clustering/done/error/filtered") + atom_count = Column(Integer, default=0) + token_used = Column(Integer, default=0) + error_msg = Column(String(500), nullable=True, comment="轨迹处理失败的错误信息") + retry_count = Column(Integer, default=0, comment="失败重试次数") + tasks_extracted = Column(Integer, default=0, comment="拆分的AtomTask数量") + created_at = Column(DateTime, default=datetime.now) + processed_at = Column(DateTime, nullable=True) + + +class SkillTrajectoryLink(Base): + """技能-轨迹关联表:记录技能与对话轨迹的关联关系""" + __tablename__ = "skill_trajectory_links" + id = Column(String(36), primary_key=True, default=gen_uuid) + skill_name = Column(String(200), nullable=False, index=True, comment="关联的技能名称") + user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True) + trajectory_id = Column(String(36), nullable=True, comment="关联的轨迹ID") + atom_id = Column(String(36), nullable=True, comment="原子任务ID") + weightscore = Column(Integer, default=0, comment="关联权重分") + created_at = Column(DateTime, default=datetime.now) + + +# ============================================================ +# AI自进化v2 - 新增表 +# ============================================================ + +class SkillVersion(Base): + """技能版本历史表:存储每个版本的SKILL.md完整内容,支持回滚""" + __tablename__ = "skill_versions" + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + skill_name = Column(String(200), nullable=False) + version = Column(Integer, nullable=False, default=1) + skill_md_content = Column(Text(4294967295), nullable=True, comment="该版本的SKILL.md完整内容") + git_commit_sha = Column(String(40), nullable=True) + description = Column(Text, nullable=True, comment="版本变更说明") + created_at = Column(DateTime, default=datetime.now) + + __table_args__ = ( + Index("idx_sv_user_skill", "user_id", "skill_name"), + Index("idx_sv_version", "version"), + ) + + +class EvolutionReport(Base): + """进化报告表:存储周报/月报""" + __tablename__ = "evolution_reports" + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + report_type = Column(String(20), nullable=False, comment="weekly / monthly") + period_start = Column(Date, nullable=False) + period_end = Column(Date, nullable=False) + report_data = Column(JSON, nullable=True, comment="报告详细数据(JSON)") + created_at = Column(DateTime, default=datetime.now) + + __table_args__ = ( + Index("idx_er_user_type", "user_id", "report_type"), + Index("idx_er_period", "period_start", "period_end"), + ) + + +class SkillPublishRecord(Base): + """技能市场发布记录表""" + __tablename__ = "skill_publish_records" + id = Column(String(36), primary_key=True, default=gen_uuid) + skill_id = Column(String(36), nullable=False, comment="关联evolution_skills.id") + skill_name = Column(String(200), nullable=False) + user_id = Column(String(36), ForeignKey("users.id"), nullable=False) + market_app_id = Column(String(36), nullable=True, comment="关联market_apps.id") + price = Column(DECIMAL(10, 2), default=0.00) + market_description = Column(Text, nullable=True) + status = Column(String(20), nullable=False, default="published", comment="published/unpublished") + published_at = Column(DateTime, default=datetime.now) + created_at = Column(DateTime, default=datetime.now) + + __table_args__ = ( + Index("idx_spr_skill_id", "skill_id"), + Index("idx_spr_user_id", "user_id"), + Index("idx_spr_status", "status"), + ) + + +class TeamSharedSkill(Base): + """团队共享技能表""" + __tablename__ = "team_shared_skills" + id = Column(String(36), primary_key=True, default=gen_uuid) + team_id = Column(String(36), ForeignKey("teams.id"), nullable=False) + skill_id = Column(String(36), nullable=False, comment="关联evolution_skills.id") + skill_name = Column(String(200), nullable=False) + shared_by = Column(String(36), ForeignKey("users.id"), nullable=False) + shared_by_name = Column(String(100), nullable=True, comment="分享者昵称") + is_public = Column(Boolean, default=True, comment="团队内是否可见") + created_at = Column(DateTime, default=datetime.now) + + __table_args__ = ( + UniqueConstraint("team_id", "skill_id", name="uk_tss_team_skill"), + ) + + +class ExternalKBService(Base): + """外部知识库服务配置""" + __tablename__ = "external_kb_services" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(200), nullable=False) + service_type = Column(String(20), nullable=False) + api_url = Column(String(500), nullable=False) + api_key = Column(String(255), default="") + config = Column(JSON, default=None) + is_enabled = Column(Boolean, default=True) + status = Column(String(20), default="disconnected") + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + +class OSSStorageConfig(Base): + __tablename__ = "oss_storage_configs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(200), nullable=False) + provider = Column(String(20), nullable=False) + endpoint = Column(String(500), nullable=False) + access_key = Column(String(255), nullable=False) + secret_key = Column(String(255), nullable=False) + bucket = Column(String(200), nullable=False) + region = Column(String(100), default="") + base_path = Column(String(300), default="") + is_default = Column(Boolean, default=False) + is_enabled = Column(Boolean, default=True) + status = Column(String(20), default="disconnected") + config = Column(JSON, default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + + +class UserStorageQuota(Base): + __tablename__ = "user_storage_quotas" + + id = Column(String(36), primary_key=True, default=gen_uuid) + user_id = Column(String(36), nullable=False, unique=True) + quota_bytes = Column(BigInteger, nullable=False, default=1073741824) + used_bytes = Column(BigInteger, nullable=False, default=0) + quota_type = Column(String(20), nullable=False, default="membership") + membership_level = Column(Integer, default=0) + plan_id = Column(String(36), default=None) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class AutomatedTask(Base): + __tablename__ = "automated_tasks" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(200), nullable=False) + description = Column(Text, default="") + trigger_type = Column(String(20), nullable=False) + cron_expression = Column(String(100), default=None) + event_condition = Column(JSON, default=None) + action_type = Column(String(30), nullable=False) + action_config = Column(JSON, default=None) + is_enabled = Column(Boolean, default=True) + status = Column(String(20), default="idle") + max_retries = Column(Integer, default=3) + timeout_seconds = Column(Integer, default=300) + last_run_at = Column(DateTime, default=None) + next_run_at = Column(DateTime, default=None) + total_runs = Column(Integer, default=0) + success_runs = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + +class TaskExecutionLog(Base): + __tablename__ = "task_execution_logs" + + id = Column(String(36), primary_key=True, default=gen_uuid) + task_id = Column(String(36), nullable=False) + task_name = Column(String(200), default="") + status = Column(String(20), default="running") + trigger_type = Column(String(20), default="manual") + started_at = Column(DateTime, default=None) + finished_at = Column(DateTime, default=None) + duration_ms = Column(Integer, default=0) + result_summary = Column(Text, default="") + error_message = Column(Text, default="") + created_at = Column(DateTime, default=datetime.now) + + + +class MCPServer(Base): + __tablename__ = "mcp_servers" + + id = Column(String(36), primary_key=True, default=gen_uuid) + name = Column(String(100), nullable=False) + description = Column(Text, default="") + transport_type = Column(String(20), default="stdio") + command = Column(String(500), default="") + args = Column(Text, default="") + env_vars = Column(Text, default="") + url = Column(String(500), default="") + headers = Column(Text, default="") + is_enabled = Column(Boolean, default=True) + status = Column(String(20), default="disconnected") + last_connected_at = Column(DateTime, default=None) + tools_count = Column(Integer, default=0) + resources_count = Column(Integer, default=0) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + +class SmsVerificationCode(Base): + __tablename__ = "sms_verification_codes" + + id = Column(String(36), primary_key=True, default=gen_uuid) + phone = Column(String(20), nullable=False, index=True) + code = Column(String(10), nullable=False) + purpose = Column(String(30), nullable=False) # register / reset_password + expires_at = Column(DateTime, nullable=False) + used = Column(Boolean, default=False) + created_at = Column(DateTime, default=datetime.now) + + + +class ConversationSummary(Base): + __tablename__ = "conversation_summaries" + + conversation_id = Column(String(64), primary_key=True) + summary_text = Column(Text, nullable=False, default="") + token_count = Column(Integer, default=0) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) diff --git a/migrations/024_add_orchestration.sql b/migrations/024_add_orchestration.sql new file mode 100644 index 0000000..3a69619 --- /dev/null +++ b/migrations/024_add_orchestration.sql @@ -0,0 +1,15 @@ +-- Phase 3.3: MarketApp 增加 orchestration(Agent 多 Agent 编排图,JSON) +-- 幂等:仅当列不存在时才 ALTER +SET @db = 'aiflow'; +SET @tbl = 'market_apps'; +SET @col = 'orchestration'; +SET @exists = ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = @db AND table_name = @tbl AND column_name = @col +); +SET @sql = IF(@exists = 0, + "ALTER TABLE market_apps ADD COLUMN orchestration JSON NULL COMMENT 'Agent 多Agent编排图(3.3)'", + 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt;