Add workflow support and enhance runtime configuration in models and services

- Introduce RuntimeModelResource and RuntimeKnowledgeBase classes to manage workflow resources.
- Update AssistantConfig to include workflow_model_resources and workflow_knowledge_bases for better integration.
- Refactor validation and processing logic in routes and services to accommodate workflow types.
- Implement dynamic variable support for workflow assistants and enhance graph normalization.
- Add ToolExecutor for reusable tool execution across different assistant types.
- Update various services to ensure compatibility with new workflow features and improve error handling.
This commit is contained in:
Xin Wang
2026-07-13 16:13:27 +08:00
parent 6108b00007
commit 32aef14ddb
27 changed files with 2563 additions and 910 deletions

View File

@@ -15,7 +15,7 @@ from fastapi import APIRouter, Depends, HTTPException
from schemas import AssistantOut, AssistantUpsert
from services.auth import require_admin
from services.masking import mask, resolve_incoming_key
from services.node_specs import validate_graph
from services.node_specs import graph_references, normalize_graph, validate_graph
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -31,9 +31,45 @@ def _validate_workflow(body: AssistantUpsert) -> None:
"""workflow 类型:保存前校验图结构,不通过则 400。其他类型跳过。"""
if body.type != "workflow":
return
errors = validate_graph(body.graph or {})
body.graph = normalize_graph(body.graph or {})
errors = validate_graph(body.graph)
if errors:
raise HTTPException(400, "工作流校验失败:" + ";".join(errors))
refs = graph_references(body.graph)
body.tool_ids = list(dict.fromkeys([*body.tool_ids, *sorted(refs["tools"])]))
async def _validate_workflow_references(
session: AsyncSession, body: AssistantUpsert
) -> None:
if body.type != "workflow" or not body.graph.get("nodes"):
return
graph = body.graph
settings = graph.get("settings") or {}
resource_expectations: dict[str, str] = {}
for key, capability in (
("defaultAsrResourceId", "ASR"),
("defaultTtsResourceId", "TTS"),
):
if settings.get(key):
resource_expectations[str(settings[key])] = capability
knowledge_ids: set[str] = set()
for node in graph.get("nodes") or []:
data = node.get("data") or {}
if data.get("asrResourceId"):
resource_expectations[str(data["asrResourceId"])] = "ASR"
if data.get("ttsResourceId"):
resource_expectations[str(data["ttsResourceId"])] = "TTS"
if data.get("knowledgeBaseId"):
knowledge_ids.add(str(data["knowledgeBaseId"]))
for resource_id, capability in resource_expectations.items():
resource = await session.get(ModelResource, resource_id)
if not resource or not resource.enabled or resource.capability != capability:
raise HTTPException(400, f"Workflow 引用了无效的 {capability} 资源:{resource_id}")
for knowledge_id in knowledge_ids:
knowledge = await session.get(KnowledgeBase, knowledge_id)
if not knowledge or knowledge.status != "active":
raise HTTPException(400, f"Workflow 引用了无效知识库:{knowledge_id}")
async def _validate_vision_model(
@@ -101,7 +137,11 @@ async def _resource_ids(session: AsyncSession, assistant_id: str) -> dict[str, s
async def _sync_tool_bindings(
session: AsyncSession, assistant_id: str, assistant_type: str, tool_ids: list[str]
) -> None:
requested = list(dict.fromkeys(tool_ids)) if assistant_type == "prompt" else []
requested = (
list(dict.fromkeys(tool_ids))
if assistant_type in {"prompt", "workflow"}
else []
)
if requested:
tools = (
await session.execute(select(Tool).where(Tool.id.in_(requested)))
@@ -158,7 +198,11 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
api_url=assistant.api_url,
api_key=mask(assistant.api_key),
app_id=assistant.app_id,
graph=assistant.graph or {},
graph=(
normalize_graph(assistant.graph or {})
if assistant.type == "workflow"
else {}
),
updated_at=assistant.updated_at.isoformat() if assistant.updated_at else None,
)
@@ -176,6 +220,7 @@ async def create_assistant(
body: AssistantUpsert, session: AsyncSession = Depends(get_session)
):
_validate_workflow(body)
await _validate_workflow_references(session, body)
await _validate_vision_model(session, body)
await _validate_knowledge_base(session, body)
data = body.model_dump()
@@ -248,6 +293,7 @@ async def update_assistant(
if not assistant:
raise HTTPException(404, "助手不存在")
_validate_workflow(body)
await _validate_workflow_references(session, body)
await _validate_vision_model(session, body)
await _validate_knowledge_base(session, body)
data = body.model_dump()

View File

@@ -15,6 +15,7 @@ from schemas import (
)
from services.auth import require_admin
from services.knowledge import create_document, delete_storage_object, process_document, search
from services.node_specs import graph_references
import settings
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
@@ -123,6 +124,14 @@ async def delete_knowledge_base(
)).scalar_one_or_none()
if referenced:
raise HTTPException(409, "知识库正被助手引用,无法删除")
workflows = (
await session.execute(select(Assistant).where(Assistant.type == "workflow"))
).scalars().all()
if any(
kb_id in graph_references(assistant.graph or {})["knowledge_bases"]
for assistant in workflows
):
raise HTTPException(409, "知识库正被 Workflow 节点引用,无法删除")
documents = (await session.execute(
select(KnowledgeDocument).where(KnowledgeDocument.knowledge_base_id == kb_id)
)).scalars().all()

View File

@@ -3,6 +3,7 @@
import uuid
from db.models import (
Assistant,
AssistantModelBinding,
InterfaceDefinition,
KnowledgeBase,
@@ -20,6 +21,7 @@ from services.auth import require_admin
from services.interface_catalog import validate_fields
from services.masking import mask_secrets, merge_secrets
from services.model_resource_tester import test_model_resource
from services.node_specs import graph_references
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -102,6 +104,14 @@ async def _clear_incompatible_references(
) -> None:
if capability == resource.capability:
return
workflows = (
await session.execute(select(Assistant).where(Assistant.type == "workflow"))
).scalars().all()
if any(
resource.id in graph_references(assistant.graph or {})["model_resources"]
for assistant in workflows
):
raise HTTPException(409, "模型资源正被 Workflow 节点引用,不能修改能力类型")
await session.execute(
delete(AssistantModelBinding).where(
AssistantModelBinding.model_resource_id == resource.id
@@ -263,6 +273,14 @@ async def delete_model_resource(
).scalar_one_or_none()
if in_use:
raise HTTPException(409, "该模型资源仍被助手引用")
workflows = (
await session.execute(select(Assistant).where(Assistant.type == "workflow"))
).scalars().all()
if any(
resource_id in graph_references(assistant.graph or {})["model_resources"]
for assistant in workflows
):
raise HTTPException(409, "该模型资源仍被 Workflow 节点引用")
await session.delete(row)
await session.commit()
return {"ok": True}