""" AI 工作流 API """ import json import logging from typing import Any, List, Optional from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from sqlalchemy import select, func, or_, and_ from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel, Field from app.database import get_db from app.base_schema import PaginatedResponse, ResponseModel from ai_platform.models import AIWorkflow, AIWorkflowVersion, AIWorkflowRun from core.application.model import Application from ai_platform.schemas.workflow_schema import ( WorkflowCreate, WorkflowUpdate, WorkflowResponse, WorkflowListResponse, WorkflowRunInput, WorkflowRunResponse, WorkflowRunListResponse, WorkflowImportCheckIn, WorkflowImportCheckOut, WorkflowImportIn, NodeSchemaResponse, ) from ai_platform.services.workflow_import_export import ( WorkflowImportExportException, export_config as export_workflow_config, check_import as check_workflow_import, import_config as import_workflow_config, ) from ai_platform.nodes.registry import NodeRegistry logger = logging.getLogger(__name__) router = APIRouter(prefix="/workflow", tags=["AI-工作流"]) # ============ 节点 Schema API ============ @router.get("/nodes/schemas", response_model=List[NodeSchemaResponse], summary="获取节点 Schema 列表") async def get_node_schemas(): """获取所有已注册节点的 Schema""" return NodeRegistry.get_all_schemas() @router.get("/nodes/schemas/by-category", summary="按分类获取节点 Schema") async def get_node_schemas_by_category(): """按分类获取所有已注册节点的 Schema""" return NodeRegistry.get_schemas_by_category() # ============ 工作流 API ============ @router.get("/list", response_model=PaginatedResponse[WorkflowListResponse], summary="工作流列表") async def list_workflows( name: Optional[str] = Query(None, description="名称"), status: Optional[str] = Query(None, description="状态"), workflow_type: Optional[str] = Query(None, description="工作流类型"), application_id: Optional[str] = Query(None, alias="applicationId", description="所属应用ID"), page: int = Query(1, ge=1, description="页码"), page_size: int = Query(20, ge=1, le=1000, alias="pageSize", description="每页数量"), db: AsyncSession = Depends(get_db), ): """获取工作流列表(自动应用数据权限)""" from ai_platform.services.workflow_service import AIWorkflowService from app.data_scope_utils import get_data_scope_filter, apply_data_scope_to_conditions conditions = [AIWorkflow.is_deleted == False] if application_id: conditions.append(or_( AIWorkflow.application_id == application_id, and_(AIWorkflow.application_id.is_(None), AIWorkflow.is_global == True) )) if name: conditions.append(AIWorkflow.name.ilike(f"%{name}%")) if status: conditions.append(AIWorkflow.status == status) if workflow_type: conditions.append(AIWorkflow.workflow_type == workflow_type) # 获取数据权限过滤条件并应用 from ai_platform.services.workflow_service import RESOURCE_TYPE data_scope_filter = await get_data_scope_filter(db, RESOURCE_TYPE) scope_conditions = apply_data_scope_to_conditions(AIWorkflow, data_scope_filter) conditions.extend(scope_conditions) # 获取总数 query = select(AIWorkflow).where(and_(*conditions)) count_result = await db.execute(select(func.count()).select_from(query.subquery())) total = count_result.scalar() or 0 # 分页 offset = (page - 1) * page_size query = query.order_by(AIWorkflow.sort.desc(), AIWorkflow.sys_create_datetime.desc()) query = query.offset(offset).limit(page_size) result = await db.execute(query) workflows = result.scalars().all() # 批量查询应用名称 app_ids = list({w.application_id for w in workflows if w.application_id}) app_name_map = {} if app_ids: app_result = await db.execute( select(Application.id, Application.name).where(Application.id.in_(app_ids)) ) app_name_map = {row.id: row.name for row in app_result} items = [_build_workflow_list_response(w, app_name_map.get(w.application_id, "")) for w in workflows] return PaginatedResponse(items=items, total=total) # 全局运行记录路由须注册在 /{workflow_id} 之前,避免 "runs" 被当作 workflow_id @router.get("/runs", response_model=PaginatedResponse[WorkflowRunListResponse], summary="全局工作流运行记录") async def list_all_workflow_runs( workflow_id: Optional[str] = Query(None, alias="workflowId", description="工作流ID"), status: Optional[str] = Query(None, description="运行状态"), trigger_type: Optional[str] = Query(None, alias="triggerType", description="触发来源"), page: int = Query(1, ge=1, description="页码"), page_size: int = Query(20, ge=1, le=100, alias="pageSize", description="每页数量"), db: AsyncSession = Depends(get_db), ): """获取全局工作流运行记录(可按工作流、状态、触发来源筛选)""" conditions = [AIWorkflowRun.is_deleted == False] if workflow_id: conditions.append(AIWorkflowRun.workflow_id == workflow_id) if status: conditions.append(AIWorkflowRun.status == status) if trigger_type: conditions.append(AIWorkflowRun.trigger_type == trigger_type) query = select(AIWorkflowRun).where(*conditions).order_by( AIWorkflowRun.sys_create_datetime.desc() ) count_result = await db.execute(select(func.count()).select_from(query.subquery())) total = count_result.scalar() or 0 offset = (page - 1) * page_size result = await db.execute(query.offset(offset).limit(page_size)) runs = result.scalars().all() workflow_ids = {r.workflow_id for r in runs if r.workflow_id} workflow_name_map: dict[str, str] = {} if workflow_ids: wf_result = await db.execute( select(AIWorkflow.id, AIWorkflow.name).where(AIWorkflow.id.in_(workflow_ids)) ) workflow_name_map = {row[0]: row[1] for row in wf_result.all()} items = [ _build_run_list_item(r, workflow_name_map.get(r.workflow_id, "")) for r in runs ] return PaginatedResponse(items=items, total=total) @router.get("/runs/{run_id}", response_model=WorkflowRunResponse, summary="运行记录详情") async def get_workflow_run(run_id: str, db: AsyncSession = Depends(get_db)): """获取运行记录详情""" result = await db.execute( select(AIWorkflowRun).where(AIWorkflowRun.id == run_id, AIWorkflowRun.is_deleted == False) ) run = result.scalar_one_or_none() if not run: raise HTTPException(status_code=404, detail="运行记录不存在") workflow_result = await db.execute( select(AIWorkflow).where(AIWorkflow.id == run.workflow_id) ) workflow = workflow_result.scalar_one_or_none() workflow_name = workflow.name if workflow else "" return _build_run_detail(run, workflow_name) @router.post("/runs/{run_id}/stop", response_model=ResponseModel, summary="停止运行") async def stop_workflow_run(run_id: str, db: AsyncSession = Depends(get_db)): """停止工作流运行""" result = await db.execute( select(AIWorkflowRun).where(AIWorkflowRun.id == run_id, AIWorkflowRun.is_deleted == False) ) run = result.scalar_one_or_none() if not run: raise HTTPException(status_code=404, detail="运行记录不存在") if run.status not in ("pending", "running"): raise HTTPException(status_code=400, detail="工作流已完成,无法停止") run.status = "stopped" await db.commit() return ResponseModel(message="已停止") class ResumeWorkflowInput(BaseModel): """恢复工作流输入""" user_input: Any = Field(..., description="用户输入(可以是字符串、布尔值、对象等)") @router.post("/runs/{run_id}/resume", summary="恢复工作流运行") async def resume_workflow_run( run_id: str, data: ResumeWorkflowInput, db: AsyncSession = Depends(get_db), ): """恢复等待中的工作流运行(SSE)""" import json from ai_platform.services.workflow_service import AIWorkflowService async def generate(): workflow_service = AIWorkflowService(db) async for event in workflow_service.resume_workflow_stream_async( run_id=run_id, user_input=data.user_input, ): yield f"data: {json.dumps(event, ensure_ascii=False, default=str)}\n\n" yield "data: [DONE]\n\n" return StreamingResponse( generate(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", } ) @router.post("/runs/{run_id}/resume/stream", summary="流式恢复工作流运行") async def resume_workflow_run_stream( run_id: str, data: ResumeWorkflowInput, db: AsyncSession = Depends(get_db), ): """流式恢复等待中的工作流运行(SSE)""" import json from ai_platform.services.workflow_service import AIWorkflowService async def generate(): workflow_service = AIWorkflowService(db) async for event in workflow_service.resume_workflow_stream_async( run_id=run_id, user_input=data.user_input, ): yield f"data: {json.dumps(event, ensure_ascii=False, default=str)}\n\n" yield "data: [DONE]\n\n" return StreamingResponse( generate(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", } ) @router.get("/code/{code}", response_model=WorkflowResponse, summary="根据编码获取工作流") async def get_workflow_by_code(code: str, db: AsyncSession = Depends(get_db)): """根据编码获取工作流""" result = await db.execute( select(AIWorkflow).where(AIWorkflow.code == code, AIWorkflow.is_deleted == False) ) workflow = result.scalar_one_or_none() if not workflow: raise HTTPException(status_code=404, detail="工作流不存在") return _build_workflow_response(workflow) @router.get("/{workflow_id}", response_model=WorkflowResponse, summary="工作流详情") async def get_workflow(workflow_id: str, db: AsyncSession = Depends(get_db)): """获取工作流详情""" result = await db.execute( select(AIWorkflow).where(AIWorkflow.id == workflow_id, AIWorkflow.is_deleted == False) ) workflow = result.scalar_one_or_none() if not workflow: raise HTTPException(status_code=404, detail="工作流不存在") return _build_workflow_response(workflow) @router.post("", response_model=WorkflowResponse, summary="创建工作流") async def create_workflow(data: WorkflowCreate, db: AsyncSession = Depends(get_db)): """创建工作流""" # 检查编码是否重复 exists_result = await db.execute( select(AIWorkflow).where(AIWorkflow.code == data.code, AIWorkflow.is_deleted == False) ) if exists_result.scalar_one_or_none(): raise HTTPException(status_code=400, detail=f"工作流编码 {data.code} 已存在") workflow = AIWorkflow(**data.model_dump()) db.add(workflow) await db.commit() await db.refresh(workflow) return _build_workflow_response(workflow) @router.put("/{workflow_id}", response_model=WorkflowResponse, summary="更新工作流") async def update_workflow( workflow_id: str, data: WorkflowUpdate, db: AsyncSession = Depends(get_db), ): """更新工作流""" result = await db.execute( select(AIWorkflow).where(AIWorkflow.id == workflow_id, AIWorkflow.is_deleted == False) ) workflow = result.scalar_one_or_none() if not workflow: raise HTTPException(status_code=404, detail="工作流不存在") # 如果更新了 code,检查编码是否重复(排除自身) if data.code and data.code != workflow.code: exists_result = await db.execute( select(AIWorkflow).where( AIWorkflow.code == data.code, AIWorkflow.id != workflow_id, AIWorkflow.is_deleted == False ) ) if exists_result.scalar_one_or_none(): raise HTTPException(status_code=400, detail=f"工作流编码 {data.code} 已存在") update_data = data.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(workflow, key, value) # 更新草稿版本号 workflow.version = (workflow.version or 0) + 1 await db.commit() await db.refresh(workflow) return _build_workflow_response(workflow) @router.delete("/{workflow_id}", response_model=ResponseModel, summary="删除工作流") async def delete_workflow(workflow_id: str, db: AsyncSession = Depends(get_db)): """删除工作流""" result = await db.execute( select(AIWorkflow).where(AIWorkflow.id == workflow_id, AIWorkflow.is_deleted == False) ) workflow = result.scalar_one_or_none() if not workflow: raise HTTPException(status_code=404, detail="工作流不存在") workflow.is_deleted = True await db.commit() return ResponseModel(message="删除成功") @router.post("/{workflow_id}/copy", response_model=WorkflowResponse, summary="复制工作流") async def copy_workflow(workflow_id: str, db: AsyncSession = Depends(get_db)): """复制工作流""" # 获取原工作流 result = await db.execute( select(AIWorkflow).where(AIWorkflow.id == workflow_id, AIWorkflow.is_deleted == False) ) workflow = result.scalar_one_or_none() if not workflow: raise HTTPException(status_code=404, detail="工作流不存在") # 生成新的编码(原编码 + _copy + 时间戳) import time timestamp = int(time.time() * 1000) new_code = f"{workflow.code}_copy_{timestamp}" # 检查编码是否重复(理论上不会,但保险起见) exists_result = await db.execute( select(AIWorkflow).where(AIWorkflow.code == new_code, AIWorkflow.is_deleted == False) ) if exists_result.scalar_one_or_none(): new_code = f"{workflow.code}_copy_{timestamp}_{int(time.time())}" # 创建新工作流 new_workflow = AIWorkflow( name=f"{workflow.name} (副本)", code=new_code, description=workflow.description, workflow_type=workflow.workflow_type, definition=workflow.definition, # 复制工作流定义 input_variables=workflow.input_variables, output_variables=workflow.output_variables, status="draft", # 新工作流默认为草稿状态 version=1, published_version=None, published_at=None, published_definition=None, ) db.add(new_workflow) await db.commit() await db.refresh(new_workflow) return _build_workflow_response(new_workflow) @router.get("/{workflow_id}/export", summary="导出工作流配置") async def export_workflow( workflow_id: str, db: AsyncSession = Depends(get_db), ): """导出工作流配置为 JSON(草稿 definition)""" try: config = await export_workflow_config(db, workflow_id) content = json.dumps(config, ensure_ascii=False, indent=2) return StreamingResponse( iter([content]), media_type="application/json", headers={ "Content-Disposition": f'attachment; filename="{config["code"]}.json"' }, ) except WorkflowImportExportException as e: raise HTTPException(status_code=400, detail=str(e)) @router.post("/import/check", response_model=WorkflowImportCheckOut, summary="导入预检查") async def check_import_workflow( data: WorkflowImportCheckIn, db: AsyncSession = Depends(get_db), ): """导入预检查:检查工作流编码是否冲突""" try: return await check_workflow_import(db, data.code) except WorkflowImportExportException as e: raise HTTPException(status_code=400, detail=str(e)) @router.post("/import", response_model=WorkflowResponse, summary="导入工作流配置") async def import_workflow( data: WorkflowImportIn, db: AsyncSession = Depends(get_db), ): """导入工作流配置""" try: workflow = await import_workflow_config(db, data.model_dump()) await db.commit() await db.refresh(workflow) return _build_workflow_response(workflow) except WorkflowImportExportException as e: raise HTTPException(status_code=400, detail=str(e)) @router.post("/{workflow_id}/publish", response_model=WorkflowResponse, summary="发布工作流") async def publish_workflow( workflow_id: str, description: str = Query("", description="版本说明"), db: AsyncSession = Depends(get_db), ): """发布工作流""" result = await db.execute( select(AIWorkflow).where(AIWorkflow.id == workflow_id, AIWorkflow.is_deleted == False) ) workflow = result.scalar_one_or_none() if not workflow: raise HTTPException(status_code=404, detail="工作流不存在") # 创建版本记录 new_version = (workflow.published_version or 0) + 1 version = AIWorkflowVersion( workflow_id=workflow_id, version=new_version, definition=workflow.definition or {}, description=description, published_at=datetime.now(), ) db.add(version) # 更新工作流 workflow.status = "published" workflow.published_version = new_version workflow.published_at = datetime.now() workflow.published_definition = workflow.definition await db.commit() await db.refresh(workflow) return _build_workflow_response(workflow) @router.get("/{workflow_id}/versions", summary="获取版本历史") async def list_workflow_versions( workflow_id: str, page: int = Query(1, ge=1, description="页码"), page_size: int = Query(20, ge=1, le=100, alias="pageSize", description="每页数量"), db: AsyncSession = Depends(get_db), ): """获取工作流版本历史""" query = select(AIWorkflowVersion).where( AIWorkflowVersion.workflow_id == workflow_id, AIWorkflowVersion.is_deleted == False ).order_by(AIWorkflowVersion.version.desc()) # 获取总数 count_result = await db.execute(select(func.count()).select_from(query.subquery())) total = count_result.scalar() or 0 # 分页 offset = (page - 1) * page_size query = query.offset(offset).limit(page_size) result = await db.execute(query) versions = result.scalars().all() items = [ { "id": v.id, "version": v.version, "description": v.description or "", "published_at": v.published_at, "run_count": v.run_count or 0, "success_count": v.success_count or 0, } for v in versions ] return PaginatedResponse(items=items, total=total) @router.get("/{workflow_id}/runs", response_model=PaginatedResponse[WorkflowRunListResponse], summary="工作流运行记录") async def list_workflow_runs( workflow_id: str, page: int = Query(1, ge=1, description="页码"), page_size: int = Query(20, ge=1, le=100, alias="pageSize", description="每页数量"), db: AsyncSession = Depends(get_db), ): """获取工作流运行记录""" query = select(AIWorkflowRun).where( AIWorkflowRun.workflow_id == workflow_id, AIWorkflowRun.is_deleted == False ).order_by(AIWorkflowRun.sys_create_datetime.desc()) # 获取总数 count_result = await db.execute(select(func.count()).select_from(query.subquery())) total = count_result.scalar() or 0 # 分页 offset = (page - 1) * page_size query = query.offset(offset).limit(page_size) result = await db.execute(query) runs = result.scalars().all() # 获取工作流名称 workflow_result = await db.execute( select(AIWorkflow).where(AIWorkflow.id == workflow_id) ) workflow = workflow_result.scalar_one_or_none() workflow_name = workflow.name if workflow else "" items = [ _build_run_list_item(r, workflow_name) for r in runs ] return PaginatedResponse(items=items, total=total) def _build_run_list_item(run: AIWorkflowRun, workflow_name: str = "") -> dict: execution_log = run.execution_log or [] last_log = next( (log for log in reversed(execution_log) if isinstance(log, dict)), {}, ) return { "id": run.id, "workflow_id": run.workflow_id, "workflow_name": workflow_name, "status": run.status or "pending", "trigger_type": run.trigger_type or "api", "inputs": run.inputs or {}, "outputs": run.outputs or {}, "current_node_id": run.current_node_id or "", "last_node_id": last_log.get("node_id", ""), "last_node_label": last_log.get("node_label", ""), "last_node_type": last_log.get("node_type", ""), "total_steps": run.total_steps or 0, "total_tokens": run.total_tokens or 0, "elapsed_time": run.elapsed_time or 0, "error_message": (run.error_message or "")[:200], "started_at": run.started_at, "completed_at": run.completed_at, } def _build_run_detail(run: AIWorkflowRun, workflow_name: str = "") -> dict: return { "id": run.id, "workflow_id": run.workflow_id, "workflow_name": workflow_name, "status": run.status or "pending", "trigger_type": run.trigger_type or "api", "use_draft": bool(run.use_draft), "workflow_version": run.workflow_version, "definition_snapshot": run.definition_snapshot or {}, "inputs": run.inputs or {}, "outputs": run.outputs or {}, "execution_log": run.execution_log or [], "current_node_id": run.current_node_id or "", "waiting_config": run.waiting_config or {}, "error_message": run.error_message or "", "total_tokens": run.total_tokens or 0, "total_steps": run.total_steps or 0, "elapsed_time": run.elapsed_time or 0, "started_at": run.started_at, "completed_at": run.completed_at, "sys_create_datetime": run.sys_create_datetime, } class WorkflowRunInput(BaseModel): """工作流运行输入""" inputs: dict = Field(default_factory=dict, description="输入变量") use_draft: bool = Field(default=False, description="是否使用草稿版本") @router.post("/{workflow_id}/run", summary="运行工作流") async def run_workflow( workflow_id: str, data: WorkflowRunInput, db: AsyncSession = Depends(get_db), ): """运行工作流(非流式)""" from ai_platform.services.workflow_service import AIWorkflowService workflow_service = AIWorkflowService(db) run = await workflow_service.run_workflow( workflow_id=workflow_id, inputs=data.inputs, use_draft=data.use_draft, trigger_type='api', ) return { "id": run.id, "workflow_id": run.workflow_id, "status": run.status, "outputs": run.outputs or {}, "execution_log": run.execution_log or [], "total_tokens": run.total_tokens or 0, "total_steps": run.total_steps or 0, "elapsed_time": run.elapsed_time or 0, } @router.post("/{workflow_id}/run/stream", summary="流式运行工作流") async def run_workflow_stream( workflow_id: str, data: WorkflowRunInput, db: AsyncSession = Depends(get_db), ): """流式运行工作流(SSE)""" import json from ai_platform.services.workflow_service import AIWorkflowService async def generate(): workflow_service = AIWorkflowService(db) trigger_type = 'editor_draft' if data.use_draft else 'editor_published' async for event in workflow_service.run_workflow_stream_async( workflow_id=workflow_id, inputs=data.inputs, use_draft=data.use_draft, trigger_type=trigger_type, ): yield f"data: {json.dumps(event, ensure_ascii=False, default=str)}\n\n" yield "data: [DONE]\n\n" return StreamingResponse( generate(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", } ) def _build_workflow_response(workflow: AIWorkflow) -> dict: """构建工作流输出""" return { "id": workflow.id, "name": workflow.name, "code": workflow.code, "workflow_type": workflow.workflow_type or "general", "description": workflow.description or "", "status": workflow.status or "draft", "version": workflow.version or 1, "published_version": workflow.published_version, "published_at": workflow.published_at, "published_definition": workflow.published_definition, "definition": workflow.definition or {}, "input_variables": workflow.input_variables or [], "output_variables": workflow.output_variables or [], "run_count": workflow.run_count or 0, "success_count": workflow.success_count or 0, "sort": workflow.sort or 0, "sys_create_datetime": workflow.sys_create_datetime, "sys_update_datetime": workflow.sys_update_datetime, } def _build_workflow_list_response(workflow: AIWorkflow, application_name: str = "") -> dict: """构建工作流列表输出""" return { "id": workflow.id, "application_id": workflow.application_id, "application_name": application_name, "is_global": workflow.is_global or False, "name": workflow.name, "code": workflow.code, "workflow_type": workflow.workflow_type or "general", "description": workflow.description or "", "status": workflow.status or "draft", "version": workflow.version or 1, "run_count": workflow.run_count or 0, "success_count": workflow.success_count or 0, "sys_create_datetime": workflow.sys_create_datetime, }