From 491f31ceadf575d6a3e37a475c8fc4510b5d71da Mon Sep 17 00:00:00 2001 From: QihuAI Design Date: Sun, 19 Jul 2026 09:29:57 +0800 Subject: [PATCH] =?UTF-8?q?refactor(context):=20Phase2=20=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E5=BC=95=E6=93=8E=E5=8D=87=E7=BA=A7=20?= =?UTF-8?q?=E5=88=86=E5=B1=82=E8=AE=B0=E5=BF=86+=E5=8E=8B=E7=BC=A9+?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=20[v2026.07.19.4]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 44 +- backend/python/app/api/chat.py | 2770 +++++------ backend/python/app/api/chat_context.py | 162 +- backend/python/app/api/chat_service.py | 4054 ++++++++--------- backend/python/app/models.py | 2263 ++++----- backend/python/tests/test_context_engine.py | 114 + .../021_create_conversation_summaries.sql | 7 + 7 files changed, 4813 insertions(+), 4601 deletions(-) create mode 100644 backend/python/tests/test_context_engine.py create mode 100644 migrations/021_create_conversation_summaries.sql diff --git a/VERSION b/VERSION index 3b4e73c..dd9c3ca 100644 --- a/VERSION +++ b/VERSION @@ -1,43 +1 @@ -# 七虎AI PaaS 平台 — 版本清单 (VERSION) -# 版本规范见 docs/VERSIONING.md -# 版本号格式: vYYYY.MM.DD.N (年.月.日.当日递增序号,从 1 开始) -# 环境标签: prod = 正式版 | test = 测试版 - -VERSION=v2026.07.19.3 -ENV=prod -DATE=2026-07-19 -BASE_IMAGE=qihu-aiflow:with-deps -BACKUP_IMAGE=qihu-aiflow:backup-20260719-003 - -## 本版本变更 (v2026.07.19.3 / prod) -- Phase2.2 成本归因:token_usage_logs 新增 trace_id 列(迁移 020_add_trace_id_token_usage_logs.sql,幂等 ALTER),打通「链路 -> 成本」归集 -- _write_media_token_log(媒体图片/视频)注入 get_trace_id(),每条媒体 TokenUsageLog 现可回溯到请求链路 -- LogService.log_token_usage 增加 trace_id 参数(为后续文本 LLM token 记账接通成本归因预留) -- chat_service / chat_media 在 LLM 补全、MCP 工具拉取/调用、技能执行、AGNES 媒体生成处加 span 子链路(async with span),一次请求可下钻耗时分布 -- 注意:_generate_media_task 为后台 fire-and-forget,其 span 会因处于 asyncio.create_task 表达式内而非法,已移除;真实媒体耗时由 chat_media 内 media.agnes_call span 覆盖 -- 纯增量埋点,不改接口返回结构与业务行为;pytest 9 passed + vitest 4 passed 门禁绿 -- 部署前已按 SOP 备份 rollback 点 qihu-aiflow:backup-20260719-003 - -## 本版本变更 (v2026.07.19.1 / prod) -- Phase1 chat.py 巨石拆分收官:10070 行单体拆分为 chat_{utils,media,agents,skill,mcp,context,knowledge,service}.py 共 8 个子模块,controller 层 chat.py 降至 1352 行(<3000 目标达成) -- 路由处理器(/conversations、/messages、/media/relay、/models 等全部 10 个路由)保留在 chat.py,业务/服务函数迁至子模块并通过 import 再导出 -- 关键修复:此前拆分误删了全部 @router 装饰器导致路由为 0(重启即全 404),本版本以 AST 比对原始提交恢复装饰器,并补回 chat_service._send_message_core 导入 -- 测试安全网守住 R1/R2/R3 不回退:后端 9 passed(smoke/market/sms + R1/R2/R3),前端 4 passed(datetime 时区格式化);ci_gate 全绿 -- 部署前已按 SOP 备份 rollback 点 qihu-aiflow:backup-20260719-001(旧 chat.py 10070 行) - -## 本版本变更 (v2026.07.19.2 / prod) -- Phase2.1 统一可观测基座:新增 app/core/observability.py(trace_id contextvar + TraceIdFilter + span 计时器 + 可选 JSON formatter),零侵入为全平台日志注入链路 ID -- main.py APITrackingMiddleware 复用既有 request_id 作为 trace_id,并为每个响应注入 X-Trace-Id 响应头(前端可关联排查) -- config.py setup_file_logging 接入 TraceIdFilter 并将日志格式升级为 [tid:%(trace_id)s],所有 getLogger(__name__) 日志自动带链路 ID -- chat.py send_message 路由用 span 包裹 _send_message_core(耗时+trace_id+conv_id/user_id),覆盖 LLM/MCP/媒体生成整体耗时,为后续成本归因建立回归基线 -- 纯增量埋点,不改任何接口返回结构与业务行为;向后兼容既有 logging 用法 -- 部署前已按 SOP 备份 rollback 点 qihu-aiflow:backup-20260719-002 - -## 历史 -- v2026.07.18.5 (prod):Phase0 收官 — 最小 CI 门禁(ci_gate.py + pre-push 钩子 + 休眠 Gitea workflow)。后端 9 passed,前端 4 passed。 -- v2026.07.18.4 (prod):前端 vitest 基建落地(4 passed,datetime 时区格式化)。 -- v2026.07.18.3 (prod):回归用例 R1/R2/R3(9 passed)— 微信Bot截断、技能/MCP覆盖规则、媒体生成超时回写。 -- v2026.07.18.2 (prod):后端 pytest 基建落地(6 passed)— 冒烟/市场14类/短信宝MD5。 -- v2026.07.18.1 (prod):Phase0 启动 — 部署前镜像备份 SOP + 版本号规范 + 引入测试基建。 -- 旧方案:镜像曾用 AIFlow v2.0.1 / v2026.07.14 / v2026.07.14-nav;git 曾用 v2026.07.12-recover / v2026.07.12-ui-recover / v2026.07.13-recover。自 v2026.07.18.1 起统一为 vYYYY.MM.DD.N + prod/test,便于正式版推广后与测试版做环境切割。 -- 历史功能:AI聊天(real API + tool-call function calling)、知识库文件存储 + AI 图谱、频道二维码、邮件/短信配置 + AI 生成、支付配置、平台驾驶舱(token 累计 + 审计/操作日志)、Agent 资源 API、evolution pipeline 自动恢复、全局默认模型、广告位图片上传 + AI 绘图、应用市场安装/卸载/搜索/去重、资产文件类型分类 + 在线预览。迁移 001-015。 +v2026.07.19.4 diff --git a/backend/python/app/api/chat.py b/backend/python/app/api/chat.py index 2d053bc..af6fbaa 100644 --- a/backend/python/app/api/chat.py +++ b/backend/python/app/api/chat.py @@ -1,1385 +1,1385 @@ -from fastapi import APIRouter, Depends, HTTPException, Query, Response -from sqlalchemy.orm import Session -from sqlalchemy import and_, desc, asc, func -from pydantic import BaseModel, Field -from typing import Optional -import uuid -from datetime import datetime, timezone, timedelta -import re -import json -import asyncio -import math -import httpx -import os -import logging -from ..database import get_db, SessionLocal -from ..models import ( - - - - User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, - - - - KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, - - - - EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, - - - - MCPServer, - - - - gen_uuid - - - -) -from ..auth import get_current_user -from ..platform_knowledge import get_platform_knowledge -from ..mcp_client import list_mcp_tools, call_mcp_tool -from ..middleware.log_service import LogService -from ..capability_registry import resolve_auto_model -from app.api.chat_agents import (_call_coze_agent, _call_dify_agent, _call_external_agent, _call_n8n_agent, _normalize_dify_chat_url) -from app.api.chat_context import (_build_system_prompt, _get_conversation_context, _ingest_answer_to_dify, _retrieve_kb_context) -from app.api.chat_knowledge import (_build_knowledge_graph) -from app.api.chat_mcp import (_list_enabled_mcp_servers, _resolve_active_mcp_servers, _resolve_market_mcp) -from app.api.chat_media import (_agnes_post_json, _friendly_media_err, _generate_media, _generate_media_task, _optimize_media_prompt, _write_media_token_log, detect_ambiguous_creative_intent, detect_media_intent) -from app.api.chat_skill import (_bm25_search, _discover_installed_skills, _execute_skill_tool, _parse_skill_content, _resolve_market_agents, _resolve_market_skills) -from app.api.chat_utils import (CST_TZ, CreateConversationRequest, SendMessageRequest, UpdateConversationRequest, _HK_PROXY, _extract_text_from_json, _html_escape, _outbound_proxy, _proxy_for_url, cst_now, logger) - -""" - - - -增强版 Chat API — Phase 2.5 - - - -包含: Skill ID统一(MarketApp映射), 真实Skill执行, Agent工作流注入, - - - - 多轮上下文保持, BM25 KB检索, Function Calling循环增强 - - - -""" - -from app.api.chat_service import _send_message_core - -router = APIRouter() - -_MEDIA_RELAY_ALLOWED = ("agnes-ai.com", "agnes-ai.space", "qihuyun.com") - -@router.get("/media/relay") - - - -async def media_relay(url: str = Query(...)): - - - - """中转 AGNES 媒体(图片/视频)到平台域名,供微信/iLink 内联渲染。 - - - - - - - - 仅允许白名单域名;否则 400。返回的媒体流保持原 content-type。 - - - - """ - - - - import urllib.parse as _up - - - - host = (_up.urlparse(url).hostname or "").lower() - - - - if not any(allowed in host for allowed in _MEDIA_RELAY_ALLOWED): - - - - raise HTTPException(status_code=400, detail="unsupported media host") - - - - try: - - - - async with httpx.AsyncClient(timeout=180, proxy=_proxy_for_url(url), trust_env=False) as client: - - - - r = await client.get(url) - - - - if r.status_code != 200: - - - - raise HTTPException(status_code=r.status_code, detail="upstream error") - - - - content_type = r.headers.get("content-type", "application/octet-stream") - - - - data = await r.aread() - - - - return Response(content=data, media_type=content_type) - - - - except HTTPException: - - - - raise - - - - except Exception as e: - - - - raise HTTPException(status_code=502, detail=f"relay failed: {e}") - -@router.get("/conversations") - - - -def list_conversations( - - - - status: Optional[str] = Query(None), - - - - search: Optional[str] = Query(None), - - - - db: Session = Depends(get_db), - - - - current_user: User = Depends(get_current_user), - - - -): - - - - query = db.query(Conversation).filter(Conversation.user_id == current_user.id) - - - - - - - - if status: - - - - query = query.filter(Conversation.status == status) - - - - else: - - - - query = query.filter(Conversation.status != "archived") - - - - - - - - if search: - - - - query = query.filter(Conversation.title.ilike(f"%{search}%")) - - - - - - - - query = query.order_by(desc(Conversation.pinned), desc(Conversation.updated_at)) - - - - - - - - conversations = query.all() - - - - result = [] - - - - for conv in conversations: - - - - result.append({ - - - - "id": conv.id, - - - - "title": conv.title, - - - - "model_id": conv.model_id, - - - - "status": conv.status, - - - - "pinned": conv.pinned, - - - - "knowledge_graph_count": conv.knowledge_graph_count or 0, - - - - "source": getattr(conv, "source", None), - - - - "created_at": str(conv.created_at) if conv.created_at else None, - - - - "updated_at": str(conv.updated_at) if conv.updated_at else None, - - - - }) - - - - - - - - return {"code": 0, "data": result, "message": "ok"} - -@router.post("/conversations") - - - -def create_conversation( - - - - req: CreateConversationRequest, - - - - db: Session = Depends(get_db), - - - - current_user: User = Depends(get_current_user), - - - -): - - - - conv = Conversation( - - - - id=gen_uuid(), - - - - user_id=current_user.id, - - - - title=req.title, - - - - model_id=req.model_id, - - - - status="active", - - - - pinned=False, - - - - knowledge_graph_count=0, - - - - created_at=cst_now(), - - - - updated_at=cst_now(), - - - - ) - - - - db.add(conv) - - - - db.commit() - - - - db.refresh(conv) - - - - - - - - return { - - - - "code": 0, - - - - "data": { - - - - "id": conv.id, - - - - "title": conv.title, - - - - "model_id": conv.model_id, - - - - "status": conv.status, - - - - "pinned": conv.pinned, - - - - "knowledge_graph_count": conv.knowledge_graph_count, - - - - "created_at": str(conv.created_at) if conv.created_at else None, - - - - "updated_at": str(conv.updated_at) if conv.updated_at else None, - - - - }, - - - - "message": "创建成功", - - - - } - -@router.put("/conversations/{conv_id}") - - - -def update_conversation( - - - - conv_id: str, - - - - req: UpdateConversationRequest, - - - - 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="对话不存在") - - - - - - - - if req.title is not None: - - - - conv.title = req.title - - - - if req.status is not None: - - - - conv.status = req.status - - - - if req.pinned is not None: - - - - conv.pinned = req.pinned - - - - # Stage B.2: 保存agent/skill/kb选择到对话上下文 - - - - # (存储在第一条system消息的knowledge_ids/agent_ids/skill_ids字段中) - - - - conv.updated_at = cst_now() - - - - - - - - db.commit() - - - - db.refresh(conv) - - - - - - - - return {"code": 0, "data": { - - - - "id": conv.id, "title": conv.title, "status": conv.status, "pinned": conv.pinned, - - - - "updated_at": str(conv.updated_at), - - - - }, "message": "更新成功"} - -@router.delete("/conversations/{conv_id}") - - - -def delete_conversation( - - - - conv_id: str, - - - - 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="对话不存在") - - - - - - - - conv.status = "archived" - - - - conv.updated_at = cst_now() - - - - db.commit() - - - - - - - - return {"code": 0, "message": "删除成功"} - -@router.get("/conversations/{conv_id}/messages") - - - -def get_messages( - - - - conv_id: str, - - - - 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="对话不存在") - - - - - - - - messages = ( - - - - db.query(Message) - - - - .filter(Message.conversation_id == conv_id) - - - - .order_by(asc(Message.created_at), asc(Message.id)) - - - - .all() - - - - ) - - - - - - - - result = [] - - - - for msg in messages: - - - - result.append({ - - - - "id": msg.id, - - - - "conversation_id": msg.conversation_id, - - - - "role": msg.role, - - - - "content": msg.content, - - - - "token_used": msg.token_used or 0, - - - - "model_id": msg.model_id, - - - - "used_skills": msg.used_skills or [], - "used_mcp": msg.used_mcp or [], - - - - "created_at": str(msg.created_at) if msg.created_at else None, - - - - }) - - - - - - - - return {"code": 0, "data": result, "message": "ok"} - -@router.post("/conversations/{conv_id}/messages") - - - -async def send_message( - - - - conv_id: str, - - - - req: SendMessageRequest, - - - - db: Session = Depends(get_db), - - - - current_user: User = Depends(get_current_user), - - - -): - from app.core.observability import span - async with span("chat.send_message", conv_id=conv_id, user_id=getattr(current_user, "id", None)): - return await _send_message_core(conv_id, req, db, current_user) - - -@router.post("/conversations/{conv_id}/clear") - - - -def clear_conversation( - - - - conv_id: str, - - - - 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="对话不存在") - - - - - - - - db.query(Message).filter( - - - - Message.conversation_id == conv_id - - - - ).delete() - - - - - - - - conv.knowledge_graph_count = 0 - - - - conv.updated_at = cst_now() - - - - db.commit() - - - - - - - - return {"code": 0, "message": "对话上下文已清空"} - -@router.post("/conversations/{conv_id}/to-knowledge-graph") - - - -def convert_to_knowledge_graph( - - - - conv_id: str, - - - - 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="对话不存在") - - - - - - - - messages = ( - - - - db.query(Message) - - - - .filter(Message.conversation_id == conv_id) - - - - .order_by(asc(Message.created_at), asc(Message.id)) - - - - .all() - - - - ) - - - - - - - - if not messages: - - - - raise HTTPException(status_code=400, detail="对话中没有消息, 无法生成知识图谱") - - - - - - - - # 幂等:若该对话已生成过知识图谱且知识库仍存在,则直接刷新返回 - - - - if conv.knowledge_graph_base_id: - - - - existing_kb = db.query(KnowledgeBase).filter( - - - - KnowledgeBase.id == conv.knowledge_graph_base_id - - - - ).first() - - - - if existing_kb: - - - - graph_data = _build_knowledge_graph(db, existing_kb) - - - - conv.knowledge_graph_count = 1 - - - - conv.updated_at = cst_now() - - - - db.commit() - - - - return { - - - - "code": 0, - - - - "data": { - - - - "knowledge_base_id": existing_kb.id, - - - - "knowledge_base_name": existing_kb.name, - - - - "graph": graph_data, - - - - "knowledge_graph_count": 1, - - - - "already_exists": True, - - - - }, - - - - "message": "知识图谱已存在, 已刷新展示", - - - - } - - - - - - - - kb_name = f"对话图谱-{conv.title}" - - - - kb = KnowledgeBase( - - - - id=gen_uuid(), - - - - user_id=current_user.id, - - - - name=kb_name, - - - - description=f"从对话「{conv.title}」自动生成的知识图谱", - - - - directory_path="/", - - - - document_count=0, - - - - created_at=cst_now(), - - - - updated_at=cst_now(), - - - - ) - - - - db.add(kb) - - - - db.flush() - - - - - - - - doc_count = 0 - - - - for idx, msg in enumerate(messages): - - - - role_label = "用户" if msg.role == "user" else "AI" - - - - preview = re.sub(r'[\\n\\r\\t]+', ' ', msg.content[:40]).strip() - - - - filename = f"msg_{idx+1:03d}_{role_label}_{preview}.txt" - - - - - - - - doc = KnowledgeDocument( - - - - id=gen_uuid(), - - - - knowledge_base_id=kb.id, - - - - filename=filename, - - - - file_type="txt", - - - - file_size=len(msg.content.encode('utf-8')), - - - - content=msg.content, - - - - status="completed", - - - - chunk_count=max(1, len(msg.content) // 500 + 1), - - - - created_at=cst_now(), - - - - updated_at=cst_now(), - - - - ) - - - - db.add(doc) - - - - doc_count += 1 - - - - - - - - kb.document_count = doc_count - - - - db.flush() - - - - - - - - # 构建知识图谱节点和边 - - - - graph_data = _build_knowledge_graph(db, kb) - - - - conv.knowledge_graph_base_id = kb.id - - - - conv.knowledge_graph_count = 1 - - - - conv.updated_at = cst_now() - - - - - - - - db.commit() - - - - - - - - LogService.log_operation( - - - - user_id=current_user.id, username=current_user.username, action="convert_to_graph", - - - - target="conversation", target_id=conv_id, - - - - detail=f"将对话 {conv.title} 转换为知识图谱 {kb.name}", - - - - ) - - - - - - - - return { - - - - "code": 0, - - - - "data": { - - - - "knowledge_base_id": kb.id, - - - - "knowledge_base_name": kb.name, - - - - "graph": graph_data, - - - - "knowledge_graph_count": 1, - - - - "already_exists": False, - - - - }, - - - - "message": f"知识图谱构建完成", - - - - } - -@router.get("/models") - - - -def get_models(db: Session = Depends(get_db)): - - - - models = db.query(AIModel).filter(AIModel.is_enabled == True).all() - - - - result = [] - - - - for m in models: - - - - provider = db.query(ModelProvider).filter(ModelProvider.id == m.provider_id).first() - - - - result.append({ - - - - "id": m.id, - - - - "name": m.name, - - - - "display_name": getattr(m, "display_name", None) or m.name, - - - - "provider_id": m.provider_id, - - - - "provider_name": provider.name if provider else "", - - - - "capabilities": m.capabilities or [], - - - - "status": m.status, - - - - "price_per_million_tokens": float(m.price_per_million_tokens or 0), - - - - "is_enabled": m.is_enabled, - - - - "is_default": bool(m.is_default) if hasattr(m, 'is_default') else False, - - - - "total_calls": m.total_calls or 0, - - - - "total_tokens": m.total_tokens or 0, - - - - "created_at": str(m.created_at) if m.created_at else None, - - - - }) - - - - result.sort(key=lambda x: (0 if x["is_default"] else 1, x["name"])) - - - - return {"code": 0, "data": result, "message": "ok"} +from fastapi import APIRouter, Depends, HTTPException, Query, Response +from sqlalchemy.orm import Session +from sqlalchemy import and_, desc, asc, func +from pydantic import BaseModel, Field +from typing import Optional +import uuid +from datetime import datetime, timezone, timedelta +import re +import json +import asyncio +import math +import httpx +import os +import logging +from ..database import get_db, SessionLocal +from ..models import ( + + + + User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, + + + + KnowledgeBase, KnowledgeDocument, KnowledgeDocumentChunk, EvolutionSkill, + + + + EvolutionTrajectory, MarketApp, AppPurchase, AppInstallation, + + + + MCPServer, + + + + gen_uuid + + + +) +from ..auth import get_current_user +from ..platform_knowledge import get_platform_knowledge +from ..mcp_client import list_mcp_tools, call_mcp_tool +from ..middleware.log_service import LogService +from ..capability_registry import resolve_auto_model +from app.api.chat_agents import (_call_coze_agent, _call_dify_agent, _call_external_agent, _call_n8n_agent, _normalize_dify_chat_url) +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_knowledge import (_build_knowledge_graph) +from app.api.chat_mcp import (_list_enabled_mcp_servers, _resolve_active_mcp_servers, _resolve_market_mcp) +from app.api.chat_media import (_agnes_post_json, _friendly_media_err, _generate_media, _generate_media_task, _optimize_media_prompt, _write_media_token_log, detect_ambiguous_creative_intent, detect_media_intent) +from app.api.chat_skill import (_bm25_search, _discover_installed_skills, _execute_skill_tool, _parse_skill_content, _resolve_market_agents, _resolve_market_skills) +from app.api.chat_utils import (CST_TZ, CreateConversationRequest, SendMessageRequest, UpdateConversationRequest, _HK_PROXY, _extract_text_from_json, _html_escape, _outbound_proxy, _proxy_for_url, cst_now, logger) + +""" + + + +增强版 Chat API — Phase 2.5 + + + +包含: Skill ID统一(MarketApp映射), 真实Skill执行, Agent工作流注入, + + + + 多轮上下文保持, BM25 KB检索, Function Calling循环增强 + + + +""" + +from app.api.chat_service import _send_message_core + +router = APIRouter() + +_MEDIA_RELAY_ALLOWED = ("agnes-ai.com", "agnes-ai.space", "qihuyun.com") + +@router.get("/media/relay") + + + +async def media_relay(url: str = Query(...)): + + + + """中转 AGNES 媒体(图片/视频)到平台域名,供微信/iLink 内联渲染。 + + + + + + + + 仅允许白名单域名;否则 400。返回的媒体流保持原 content-type。 + + + + """ + + + + import urllib.parse as _up + + + + host = (_up.urlparse(url).hostname or "").lower() + + + + if not any(allowed in host for allowed in _MEDIA_RELAY_ALLOWED): + + + + raise HTTPException(status_code=400, detail="unsupported media host") + + + + try: + + + + async with httpx.AsyncClient(timeout=180, proxy=_proxy_for_url(url), trust_env=False) as client: + + + + r = await client.get(url) + + + + if r.status_code != 200: + + + + raise HTTPException(status_code=r.status_code, detail="upstream error") + + + + content_type = r.headers.get("content-type", "application/octet-stream") + + + + data = await r.aread() + + + + return Response(content=data, media_type=content_type) + + + + except HTTPException: + + + + raise + + + + except Exception as e: + + + + raise HTTPException(status_code=502, detail=f"relay failed: {e}") + +@router.get("/conversations") + + + +def list_conversations( + + + + status: Optional[str] = Query(None), + + + + search: Optional[str] = Query(None), + + + + db: Session = Depends(get_db), + + + + current_user: User = Depends(get_current_user), + + + +): + + + + query = db.query(Conversation).filter(Conversation.user_id == current_user.id) + + + + + + + + if status: + + + + query = query.filter(Conversation.status == status) + + + + else: + + + + query = query.filter(Conversation.status != "archived") + + + + + + + + if search: + + + + query = query.filter(Conversation.title.ilike(f"%{search}%")) + + + + + + + + query = query.order_by(desc(Conversation.pinned), desc(Conversation.updated_at)) + + + + + + + + conversations = query.all() + + + + result = [] + + + + for conv in conversations: + + + + result.append({ + + + + "id": conv.id, + + + + "title": conv.title, + + + + "model_id": conv.model_id, + + + + "status": conv.status, + + + + "pinned": conv.pinned, + + + + "knowledge_graph_count": conv.knowledge_graph_count or 0, + + + + "source": getattr(conv, "source", None), + + + + "created_at": str(conv.created_at) if conv.created_at else None, + + + + "updated_at": str(conv.updated_at) if conv.updated_at else None, + + + + }) + + + + + + + + return {"code": 0, "data": result, "message": "ok"} + +@router.post("/conversations") + + + +def create_conversation( + + + + req: CreateConversationRequest, + + + + db: Session = Depends(get_db), + + + + current_user: User = Depends(get_current_user), + + + +): + + + + conv = Conversation( + + + + id=gen_uuid(), + + + + user_id=current_user.id, + + + + title=req.title, + + + + model_id=req.model_id, + + + + status="active", + + + + pinned=False, + + + + knowledge_graph_count=0, + + + + created_at=cst_now(), + + + + updated_at=cst_now(), + + + + ) + + + + db.add(conv) + + + + db.commit() + + + + db.refresh(conv) + + + + + + + + return { + + + + "code": 0, + + + + "data": { + + + + "id": conv.id, + + + + "title": conv.title, + + + + "model_id": conv.model_id, + + + + "status": conv.status, + + + + "pinned": conv.pinned, + + + + "knowledge_graph_count": conv.knowledge_graph_count, + + + + "created_at": str(conv.created_at) if conv.created_at else None, + + + + "updated_at": str(conv.updated_at) if conv.updated_at else None, + + + + }, + + + + "message": "创建成功", + + + + } + +@router.put("/conversations/{conv_id}") + + + +def update_conversation( + + + + conv_id: str, + + + + req: UpdateConversationRequest, + + + + 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="对话不存在") + + + + + + + + if req.title is not None: + + + + conv.title = req.title + + + + if req.status is not None: + + + + conv.status = req.status + + + + if req.pinned is not None: + + + + conv.pinned = req.pinned + + + + # Stage B.2: 保存agent/skill/kb选择到对话上下文 + + + + # (存储在第一条system消息的knowledge_ids/agent_ids/skill_ids字段中) + + + + conv.updated_at = cst_now() + + + + + + + + db.commit() + + + + db.refresh(conv) + + + + + + + + return {"code": 0, "data": { + + + + "id": conv.id, "title": conv.title, "status": conv.status, "pinned": conv.pinned, + + + + "updated_at": str(conv.updated_at), + + + + }, "message": "更新成功"} + +@router.delete("/conversations/{conv_id}") + + + +def delete_conversation( + + + + conv_id: str, + + + + 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="对话不存在") + + + + + + + + conv.status = "archived" + + + + conv.updated_at = cst_now() + + + + db.commit() + + + + + + + + return {"code": 0, "message": "删除成功"} + +@router.get("/conversations/{conv_id}/messages") + + + +def get_messages( + + + + conv_id: str, + + + + 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="对话不存在") + + + + + + + + messages = ( + + + + db.query(Message) + + + + .filter(Message.conversation_id == conv_id) + + + + .order_by(asc(Message.created_at), asc(Message.id)) + + + + .all() + + + + ) + + + + + + + + result = [] + + + + for msg in messages: + + + + result.append({ + + + + "id": msg.id, + + + + "conversation_id": msg.conversation_id, + + + + "role": msg.role, + + + + "content": msg.content, + + + + "token_used": msg.token_used or 0, + + + + "model_id": msg.model_id, + + + + "used_skills": msg.used_skills or [], + "used_mcp": msg.used_mcp or [], + + + + "created_at": str(msg.created_at) if msg.created_at else None, + + + + }) + + + + + + + + return {"code": 0, "data": result, "message": "ok"} + +@router.post("/conversations/{conv_id}/messages") + + + +async def send_message( + + + + conv_id: str, + + + + req: SendMessageRequest, + + + + db: Session = Depends(get_db), + + + + current_user: User = Depends(get_current_user), + + + +): + from app.core.observability import span + async with span("chat.send_message", conv_id=conv_id, user_id=getattr(current_user, "id", None)): + return await _send_message_core(conv_id, req, db, current_user) + + +@router.post("/conversations/{conv_id}/clear") + + + +def clear_conversation( + + + + conv_id: str, + + + + 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="对话不存在") + + + + + + + + db.query(Message).filter( + + + + Message.conversation_id == conv_id + + + + ).delete() + + + + + + + + conv.knowledge_graph_count = 0 + + + + conv.updated_at = cst_now() + + + + db.commit() + + + + + + + + return {"code": 0, "message": "对话上下文已清空"} + +@router.post("/conversations/{conv_id}/to-knowledge-graph") + + + +def convert_to_knowledge_graph( + + + + conv_id: str, + + + + 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="对话不存在") + + + + + + + + messages = ( + + + + db.query(Message) + + + + .filter(Message.conversation_id == conv_id) + + + + .order_by(asc(Message.created_at), asc(Message.id)) + + + + .all() + + + + ) + + + + + + + + if not messages: + + + + raise HTTPException(status_code=400, detail="对话中没有消息, 无法生成知识图谱") + + + + + + + + # 幂等:若该对话已生成过知识图谱且知识库仍存在,则直接刷新返回 + + + + if conv.knowledge_graph_base_id: + + + + existing_kb = db.query(KnowledgeBase).filter( + + + + KnowledgeBase.id == conv.knowledge_graph_base_id + + + + ).first() + + + + if existing_kb: + + + + graph_data = _build_knowledge_graph(db, existing_kb) + + + + conv.knowledge_graph_count = 1 + + + + conv.updated_at = cst_now() + + + + db.commit() + + + + return { + + + + "code": 0, + + + + "data": { + + + + "knowledge_base_id": existing_kb.id, + + + + "knowledge_base_name": existing_kb.name, + + + + "graph": graph_data, + + + + "knowledge_graph_count": 1, + + + + "already_exists": True, + + + + }, + + + + "message": "知识图谱已存在, 已刷新展示", + + + + } + + + + + + + + kb_name = f"对话图谱-{conv.title}" + + + + kb = KnowledgeBase( + + + + id=gen_uuid(), + + + + user_id=current_user.id, + + + + name=kb_name, + + + + description=f"从对话「{conv.title}」自动生成的知识图谱", + + + + directory_path="/", + + + + document_count=0, + + + + created_at=cst_now(), + + + + updated_at=cst_now(), + + + + ) + + + + db.add(kb) + + + + db.flush() + + + + + + + + doc_count = 0 + + + + for idx, msg in enumerate(messages): + + + + role_label = "用户" if msg.role == "user" else "AI" + + + + preview = re.sub(r'[\\n\\r\\t]+', ' ', msg.content[:40]).strip() + + + + filename = f"msg_{idx+1:03d}_{role_label}_{preview}.txt" + + + + + + + + doc = KnowledgeDocument( + + + + id=gen_uuid(), + + + + knowledge_base_id=kb.id, + + + + filename=filename, + + + + file_type="txt", + + + + file_size=len(msg.content.encode('utf-8')), + + + + content=msg.content, + + + + status="completed", + + + + chunk_count=max(1, len(msg.content) // 500 + 1), + + + + created_at=cst_now(), + + + + updated_at=cst_now(), + + + + ) + + + + db.add(doc) + + + + doc_count += 1 + + + + + + + + kb.document_count = doc_count + + + + db.flush() + + + + + + + + # 构建知识图谱节点和边 + + + + graph_data = _build_knowledge_graph(db, kb) + + + + conv.knowledge_graph_base_id = kb.id + + + + conv.knowledge_graph_count = 1 + + + + conv.updated_at = cst_now() + + + + + + + + db.commit() + + + + + + + + LogService.log_operation( + + + + user_id=current_user.id, username=current_user.username, action="convert_to_graph", + + + + target="conversation", target_id=conv_id, + + + + detail=f"将对话 {conv.title} 转换为知识图谱 {kb.name}", + + + + ) + + + + + + + + return { + + + + "code": 0, + + + + "data": { + + + + "knowledge_base_id": kb.id, + + + + "knowledge_base_name": kb.name, + + + + "graph": graph_data, + + + + "knowledge_graph_count": 1, + + + + "already_exists": False, + + + + }, + + + + "message": f"知识图谱构建完成", + + + + } + +@router.get("/models") + + + +def get_models(db: Session = Depends(get_db)): + + + + models = db.query(AIModel).filter(AIModel.is_enabled == True).all() + + + + result = [] + + + + for m in models: + + + + provider = db.query(ModelProvider).filter(ModelProvider.id == m.provider_id).first() + + + + result.append({ + + + + "id": m.id, + + + + "name": m.name, + + + + "display_name": getattr(m, "display_name", None) or m.name, + + + + "provider_id": m.provider_id, + + + + "provider_name": provider.name if provider else "", + + + + "capabilities": m.capabilities or [], + + + + "status": m.status, + + + + "price_per_million_tokens": float(m.price_per_million_tokens or 0), + + + + "is_enabled": m.is_enabled, + + + + "is_default": bool(m.is_default) if hasattr(m, 'is_default') else False, + + + + "total_calls": m.total_calls or 0, + + + + "total_tokens": m.total_tokens or 0, + + + + "created_at": str(m.created_at) if m.created_at else None, + + + + }) + + + + result.sort(key=lambda x: (0 if x["is_default"] else 1, x["name"])) + + + + return {"code": 0, "data": result, "message": "ok"} \ No newline at end of file diff --git a/backend/python/app/api/chat_context.py b/backend/python/app/api/chat_context.py index 8d7c24c..fed8117 100644 --- a/backend/python/app/api/chat_context.py +++ b/backend/python/app/api/chat_context.py @@ -20,7 +20,7 @@ from ..models import ( - User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, + User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog, ConversationSummary, @@ -1230,3 +1230,163 @@ def _get_conversation_context(db: Session, conv_id: str) -> dict: return context + + + +# ===== Phase 2 上下文引擎升级:分层记忆 + 压缩 + 隔离 ===== +logger = logging.getLogger(__name__) + + +def _estimate_tokens(text): + """粗略 token 估算:中英文混排约 2 字符/token。""" + if not text: + return 0 + return max(1, len(text) // 2) + + +def _build_chat_history(db, conv_id, max_tokens=6000, max_messages=40, + exclude_latest_user=True, include_summary=True): + """会话层上下文引擎:预算感知的历史组装 + 可选压缩摘要前置。 + + - 若 conversation_summaries 存在摘要,以 system 角色「对话摘要」前置(压缩层)。 + - 按 token 估算从新到旧累加,超预算即截断(隔离:不溢出上下文窗口)。 + - exclude_latest_user=True 时丢弃最新一条 user 消息(当前轮次另行发送)。 + """ + summary_block = None + if include_summary: + try: + summ = db.query(ConversationSummary).filter( + ConversationSummary.conversation_id == conv_id + ).first() + if summ and summ.summary_text: + summary_block = { + "role": "system", + "content": "【对话历史摘要,请据此理解用户此前的意图与已确认结论】\n" + summ.summary_text, + } + except Exception: + pass + rows = db.query(Message).filter( + Message.conversation_id == conv_id, + Message.role.in_(["user", "assistant"]), + ).order_by(desc(Message.created_at)).limit(max_messages).all() + if exclude_latest_user: + for i, m in enumerate(rows): + if m.role == "user": + rows = rows[:i] + rows[i + 1:] + break + kept = [] + used = 0 + for m in rows: # 新->旧 + t = _estimate_tokens(m.content or "") + if kept and used + t > max_tokens: + break + kept.append({"role": m.role, "content": m.content}) + used += t + kept.reverse() # 时间正序 + out = [] + if summary_block: + out.append(summary_block) + out.extend(kept) + return out + + +async def _summarize_via_llm(prompt): + """调用默认文本模型生成对话摘要;失败返回 None(压缩为可选项,不阻塞)。""" + try: + from ..capability_registry import resolve_auto_model + from app.api.chat_utils import _proxy_for_url + sdb = SessionLocal() + try: + ai_model = resolve_auto_model("text", sdb) + if not ai_model: + return None + provider = sdb.query(ModelProvider).filter( + ModelProvider.id == ai_model.provider_id + ).first() + api_key = ai_model.api_key or (provider.api_key if provider else None) + if not provider or not provider.api_base_url or not api_key: + return None + api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions" + body = { + "model": ai_model.name, + "messages": [ + {"role": "system", "content": "你是专业的对话摘要助手,输出简洁中文摘要。"}, + {"role": "user", "content": prompt}, + ], + "max_tokens": 512, + "temperature": 0.2, + } + async with httpx.AsyncClient(proxy=_proxy_for_url(api_url), trust_env=False, timeout=60) as client: + resp = await client.post( + api_url, + headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}, + json=body, + ) + if resp.status_code != 200: + return None + data = resp.json() + return data.get("choices", [{}])[0].get("message", {}).get("content", "").strip() + finally: + sdb.close() + except Exception as e: + logger.warning(f"[context] summarize llm failed: {e}") + return None + + +async def _maybe_compress_history(conv_id): + """后台异步压缩:将受保护窗口外的旧消息摘要落库 conversation_summaries。 + + 失败静默回滚,绝不阻塞用户本次响应(fire-and-forget)。 + """ + sdb = None + try: + sdb = SessionLocal() + all_msgs = sdb.query(Message).filter( + Message.conversation_id == conv_id, + Message.role.in_(["user", "assistant"]), + ).order_by(asc(Message.created_at)).all() + PROTECTED = 20 + if len(all_msgs) <= PROTECTED: + return + compressible = all_msgs[: len(all_msgs) - PROTECTED] + if len(compressible) < 8: + return + chunk = compressible[:30] + text = "\n".join(f"{m.role}: {m.content}" for m in chunk) + existing = sdb.query(ConversationSummary).filter( + ConversationSummary.conversation_id == conv_id + ).first() + prior = existing.summary_text if existing else "" + prompt = ( + "请把以下对话片段浓缩为简明摘要,保留用户核心诉求、已确认结论、关键事实与待办。" + "若提供『已有摘要』,请合并而非重复。\n\n" + f"已有摘要:\n{prior}\n\n待摘要片段:\n{text}" + ) + summary = await _summarize_via_llm(prompt) + if not summary: + return + if existing: + existing.summary_text = summary + existing.token_count = _estimate_tokens(summary) + existing.updated_at = datetime.now() + else: + sdb.add(ConversationSummary( + conversation_id=conv_id, + summary_text=summary, + token_count=_estimate_tokens(summary), + updated_at=datetime.now(), + )) + sdb.commit() + except Exception as e: + logger.warning(f"[context] compress history failed conv={conv_id}: {e}") + if sdb is not None: + try: + sdb.rollback() + except Exception: + pass + finally: + if sdb is not None: + try: + sdb.close() + except Exception: + pass diff --git a/backend/python/app/api/chat_service.py b/backend/python/app/api/chat_service.py index 66e5f24..94955df 100644 --- a/backend/python/app/api/chat_service.py +++ b/backend/python/app/api/chat_service.py @@ -1,2045 +1,2009 @@ -# 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 -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) -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 等无平台会话态的服务维持多轮上下文) - - - - _hist_rows = db.query(Message).filter( - - - - Message.conversation_id == conv_id - - - - ).order_by(asc(Message.created_at)).limit(10).all() - - - - _history = [{"role": h.role, "content": h.content} - - - - for h in _hist_rows if h.role in ("user", "assistant")] - - - - 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}] - - - - - - - - # 加载对话历史(最多10条) - - - - history = db.query(Message).filter( - - - - Message.conversation_id == conv_id - - - - ).order_by(asc(Message.created_at)).limit(10).all() - - - - for h in history: - - - - if h.role == "user" or h.role == "assistant": - - - - messages.append({"role": h.role, "content": h.content}) - - - - - - - - # Issue 1: 必须把附件内容(attachment_block)一并发给模型, - - - - # 否则用户在对话窗上传文件后模型“看不到”文件内容。 - - - - messages.append({"role": "user", "content": req.content + attachment_block}) - - - - - - - - # Stage B.3: Function Calling循环(最多5轮) - - - - max_tool_rounds = 5 - - - - for round_num in range(max_tool_rounds): - - - - 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", "") - - - - 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, - - - - 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() - - - - 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": "发送成功", - - - - } +# 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 +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 + + + + for round_num in range(max_tool_rounds): + + + + 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", "") + + + + 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, + + + + 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": "发送成功", + + + + } \ No newline at end of file diff --git a/backend/python/app/models.py b/backend/python/app/models.py index f832b11..992092b 100644 --- a/backend/python/app/models.py +++ b/backend/python/app/models.py @@ -1,1127 +1,1136 @@ -""" -AIFlow 数据库模型定义 - SQLAlchemy ORM -所有用户数据查询必须带 user_id 过滤实现物理隔离 -""" -import uuid -from datetime import datetime, date -from sqlalchemy import ( - 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(String(500), default=None, comment="单个Agent接入的API Key") - 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) - +""" +AIFlow 数据库模型定义 - SQLAlchemy ORM +所有用户数据查询必须带 user_id 过滤实现物理隔离 +""" +import uuid +from datetime import datetime, date +from sqlalchemy import ( + 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(String(500), default=None, comment="单个Agent接入的API Key") + 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/backend/python/tests/test_context_engine.py b/backend/python/tests/test_context_engine.py new file mode 100644 index 0000000..dbcd7f1 --- /dev/null +++ b/backend/python/tests/test_context_engine.py @@ -0,0 +1,114 @@ +"""Phase 2 context-engine regression: _build_chat_history budget/layering/summary. + +Pure unit test with a fake DB session — no network, no real model. Guards the +layered-memory + compression + isolation behaviour against regressions. +""" +from app.api.chat_context import _estimate_tokens, _build_chat_history + + +class _Msg: + def __init__(self, role, content, cid="c1"): + self.role = role + self.content = content + self.conversation_id = cid + + +class _Summary: + def __init__(self, text): + self.summary_text = text + + +class _Query: + def __init__(self, rows): + self._rows = list(rows) + + def filter(self, *a, **k): + return self + + def order_by(self, *a, **k): + # test pre-sorts newest-first to match desc(created_at) + return self + + def limit(self, n=None): + return self + + def all(self): + return self._rows + + def first(self): + return self._rows[0] if self._rows else None + + +class _DB: + def __init__(self, messages, summary=None): + self._messages = messages + self._summary = summary + + def query(self, model): + if getattr(model, "__name__", "") == "ConversationSummary": + return _Query([self._summary] if self._summary else []) + return _Query(self._messages) + + +def _newest_first(msgs): + # _build_chat_history expects desc(created_at) -> newest first. + # Test authors write the list already newest-first, so return as-is. + return list(msgs) + + +def test_estimate_tokens(): + assert _estimate_tokens("") == 0 + assert _estimate_tokens(None) == 0 + assert _estimate_tokens("abcd") == 2 # 4//2 + assert _estimate_tokens("a" * 5) == 2 # max(1, 5//2)=2 + # just ensure positive monotonic + assert _estimate_tokens("x" * 100) == 50 + + +def test_history_excludes_latest_user(): + # newest-first: [current user, prev assistant, older user] + msgs = _newest_first([ + _Msg("user", "current question"), + _Msg("assistant", "previous answer"), + _Msg("user", "older question"), + ]) + db = _DB(msgs) + out = _build_chat_history(db, "c1", max_tokens=10000, exclude_latest_user=True) + roles = [m["role"] for m in out] + # current user dropped; chronological order = [older user, previous assistant] + assert roles == ["user", "assistant"] + assert out[0]["content"] == "older question" + + +def test_summary_injected_as_system_first(): + msgs = _newest_first([ + _Msg("user", "hi"), + _Msg("assistant", "hello"), + ]) + db = _DB(msgs, summary=_Summary("用户想做图片生成")) + out = _build_chat_history(db, "c1", max_tokens=10000, exclude_latest_user=True) + assert out[0]["role"] == "system" + assert out[0]["content"].startswith("【对话历史摘要") + assert "用户想做图片生成" in out[0]["content"] + # after summary: chronological history (assistant then user 'hi' excluded? exclude_latest_user drops 'hi') + assert out[1]["role"] == "assistant" + + +def test_budget_truncation(): + # 5 assistant messages each ~200 tokens; budget small -> truncated + msgs = _newest_first([_Msg("assistant", "x" * 400) for _ in range(5)]) + db = _DB(msgs) + out = _build_chat_history(db, "c1", max_tokens=500, exclude_latest_user=False) + # each msg ~200 tokens; budget 500 -> keep newest ~2 + assert len(out) <= 3 + total = sum(_estimate_tokens(m["content"]) for m in out) + assert total <= 500 + 200 # within budget (+1 msg slack) + + +def test_no_summary_when_absent(): + msgs = _newest_first([_Msg("user", "hi"), _Msg("assistant", "hello")]) + db = _DB(msgs) + out = _build_chat_history(db, "c1", max_tokens=10000, exclude_latest_user=True) + # 'hi' excluded; only assistant remains, no system summary + assert all(m["role"] != "system" for m in out) + assert [m["role"] for m in out] == ["assistant"] diff --git a/migrations/021_create_conversation_summaries.sql b/migrations/021_create_conversation_summaries.sql new file mode 100644 index 0000000..9d222c4 --- /dev/null +++ b/migrations/021_create_conversation_summaries.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS conversation_summaries ( + conversation_id VARCHAR(64) NOT NULL, + summary_text TEXT NULL, + token_count INT NULL DEFAULT 0, + updated_at DATETIME(3) NULL, + PRIMARY KEY (conversation_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;