363 lines
14 KiB
Python
363 lines
14 KiB
Python
"""Assistant CRUD backed by capability-to-model-resource bindings."""
|
|
|
|
import uuid
|
|
|
|
from db.models import (
|
|
Assistant,
|
|
AssistantModelBinding,
|
|
AssistantToolBinding,
|
|
KnowledgeBase,
|
|
ModelResource,
|
|
Tool,
|
|
)
|
|
from db.session import get_session
|
|
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 graph_references, normalize_graph, validate_graph
|
|
from services.workflow_engine import WorkflowEngine
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
router = APIRouter(
|
|
prefix="/api/assistants",
|
|
tags=["assistants"],
|
|
dependencies=[Depends(require_admin)],
|
|
)
|
|
CAPABILITIES = ("LLM", "ASR", "TTS", "Realtime", "Embedding", "Agent")
|
|
|
|
|
|
def _validate_workflow(body: AssistantUpsert) -> None:
|
|
"""workflow 类型:保存前校验图结构,不通过则 400。其他类型跳过。"""
|
|
if body.type != "workflow":
|
|
return
|
|
body.graph = normalize_graph(body.graph or {})
|
|
errors = validate_graph(body.graph)
|
|
if errors:
|
|
raise HTTPException(400, "工作流校验失败:" + ";".join(errors))
|
|
# Graph settings are the source of truth. The flat flag is only a session
|
|
# capability summary used by clients to decide whether to open the camera.
|
|
body.vision_enabled = WorkflowEngine(body.graph).uses_vision()
|
|
body.vision_model_resource_id = None
|
|
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
|
|
engine = WorkflowEngine(graph)
|
|
settings = graph.get("settings") or {}
|
|
resource_expectations: dict[str, str] = {}
|
|
vision_resource_ids: set[str] = set()
|
|
for key, capability in (
|
|
("defaultLlmResourceId", "LLM"),
|
|
("defaultAsrResourceId", "ASR"),
|
|
("defaultTtsResourceId", "TTS"),
|
|
):
|
|
if settings.get(key):
|
|
resource_expectations[str(settings[key])] = capability
|
|
knowledge_ids: set[str] = (
|
|
{str(settings["knowledgeBaseId"])}
|
|
if settings.get("knowledgeBaseId")
|
|
else set()
|
|
)
|
|
for node in graph.get("nodes") or []:
|
|
data = node.get("data") or {}
|
|
if node.get("type") == "agent" and data.get("inheritGlobalConfig", True):
|
|
continue
|
|
if data.get("llmResourceId"):
|
|
resource_expectations[str(data["llmResourceId"])] = "LLM"
|
|
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 node_id, node in engine.nodes.items():
|
|
if node.get("type") != "agent":
|
|
continue
|
|
stage = engine.agent_stage_config(node_id)
|
|
if not stage.vision_enabled:
|
|
continue
|
|
resource_id = (
|
|
stage.vision_model_resource_id or stage.llm_resource_id
|
|
)
|
|
if not resource_id:
|
|
raise HTTPException(
|
|
400,
|
|
f"Agent 节点 {node_id} 开启视觉理解时必须选择"
|
|
"支持图片输入的大语言模型或视觉模型",
|
|
)
|
|
resource_expectations[resource_id] = "LLM"
|
|
vision_resource_ids.add(resource_id)
|
|
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}")
|
|
if resource_id in vision_resource_ids and not resource.support_image_input:
|
|
raise HTTPException(
|
|
400,
|
|
f"Workflow 视觉模型必须支持图片输入:{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(
|
|
session: AsyncSession, body: AssistantUpsert
|
|
) -> None:
|
|
# Workflow 的视觉模型属于图中的全局/Agent 配置,由上面的图引用校验处理。
|
|
if body.type == "workflow":
|
|
return
|
|
if body.vision_enabled:
|
|
if body.vision_model_resource_id:
|
|
resource = await session.get(ModelResource, body.vision_model_resource_id)
|
|
else:
|
|
resource_id = body.model_resource_ids.get("LLM")
|
|
resource = (
|
|
await session.get(ModelResource, resource_id) if resource_id else None
|
|
)
|
|
if not resource or resource.capability != "LLM":
|
|
raise HTTPException(400, "视觉模型必须引用 LLM 模型资源")
|
|
if not resource.support_image_input:
|
|
raise HTTPException(400, "视觉模型必须支持图片输入")
|
|
|
|
|
|
async def _validate_knowledge_base(session: AsyncSession, body: AssistantUpsert) -> None:
|
|
if body.runtime_mode != "pipeline" or body.type not in {"prompt", "workflow"}:
|
|
body.knowledge_base_id = None
|
|
return
|
|
if body.knowledge_base_id and not await session.get(KnowledgeBase, body.knowledge_base_id):
|
|
raise HTTPException(400, "知识库不存在")
|
|
|
|
|
|
async def _sync_bindings(
|
|
session: AsyncSession, assistant_id: str, resource_ids: dict[str, str]
|
|
) -> None:
|
|
for capability in CAPABILITIES:
|
|
resource_id = resource_ids.get(capability)
|
|
binding = await session.get(AssistantModelBinding, (assistant_id, capability))
|
|
if not resource_id:
|
|
if binding:
|
|
await session.delete(binding)
|
|
continue
|
|
resource = await session.get(ModelResource, resource_id)
|
|
if not resource or resource.capability != capability:
|
|
raise HTTPException(400, f"{capability} 绑定必须引用同能力的模型资源")
|
|
if binding:
|
|
binding.model_resource_id = resource_id
|
|
else:
|
|
session.add(
|
|
AssistantModelBinding(
|
|
assistant_id=assistant_id,
|
|
capability=capability,
|
|
model_resource_id=resource_id,
|
|
config={},
|
|
)
|
|
)
|
|
|
|
|
|
async def _resource_ids(session: AsyncSession, assistant_id: str) -> dict[str, str]:
|
|
bindings = (
|
|
await session.execute(
|
|
select(AssistantModelBinding).where(
|
|
AssistantModelBinding.assistant_id == assistant_id
|
|
)
|
|
)
|
|
).scalars().all()
|
|
return {binding.capability: binding.model_resource_id for binding in bindings}
|
|
|
|
|
|
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 in {"prompt", "workflow"}
|
|
else []
|
|
)
|
|
if requested:
|
|
tools = (
|
|
await session.execute(select(Tool).where(Tool.id.in_(requested)))
|
|
).scalars().all()
|
|
found = {tool.id for tool in tools if tool.status == "active"}
|
|
missing = [tool_id for tool_id in requested if tool_id not in found]
|
|
if missing:
|
|
raise HTTPException(400, f"工具不存在或未启用: {', '.join(missing)}")
|
|
|
|
existing = (
|
|
await session.execute(
|
|
select(AssistantToolBinding).where(
|
|
AssistantToolBinding.assistant_id == assistant_id
|
|
)
|
|
)
|
|
).scalars().all()
|
|
existing_by_id = {binding.tool_id: binding for binding in existing}
|
|
for tool_id, binding in existing_by_id.items():
|
|
if tool_id not in requested:
|
|
await session.delete(binding)
|
|
for tool_id in requested:
|
|
if tool_id not in existing_by_id:
|
|
session.add(AssistantToolBinding(assistant_id=assistant_id, tool_id=tool_id))
|
|
|
|
|
|
async def _tool_ids(session: AsyncSession, assistant_id: str) -> list[str]:
|
|
rows = (
|
|
await session.execute(
|
|
select(AssistantToolBinding.tool_id)
|
|
.where(AssistantToolBinding.assistant_id == assistant_id)
|
|
.order_by(AssistantToolBinding.created_at)
|
|
)
|
|
).scalars().all()
|
|
return list(rows)
|
|
|
|
|
|
async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
|
|
return AssistantOut(
|
|
id=assistant.id,
|
|
name=assistant.name,
|
|
type=assistant.type, # type: ignore[arg-type]
|
|
runtime_mode=assistant.runtime_mode, # type: ignore[arg-type]
|
|
greeting=assistant.greeting,
|
|
enable_interrupt=assistant.enable_interrupt,
|
|
turn_config=assistant.turn_config or {},
|
|
vision_enabled=assistant.vision_enabled,
|
|
vision_model_resource_id=assistant.vision_model_resource_id,
|
|
model_resource_ids=await _resource_ids(session, assistant.id),
|
|
knowledge_base_id=assistant.knowledge_base_id,
|
|
knowledge_retrieval_config=assistant.knowledge_retrieval_config or {},
|
|
tool_ids=await _tool_ids(session, assistant.id),
|
|
prompt=assistant.prompt,
|
|
dynamic_variable_definitions=assistant.dynamic_variable_definitions or {},
|
|
api_url=assistant.api_url,
|
|
api_key=mask(assistant.api_key),
|
|
app_id=assistant.app_id,
|
|
graph=(
|
|
normalize_graph(assistant.graph or {})
|
|
if assistant.type == "workflow"
|
|
else {}
|
|
),
|
|
updated_at=assistant.updated_at.isoformat() if assistant.updated_at else None,
|
|
)
|
|
|
|
|
|
@router.get("", response_model=list[AssistantOut])
|
|
async def list_assistants(session: AsyncSession = Depends(get_session)):
|
|
rows = (
|
|
await session.execute(select(Assistant).order_by(Assistant.updated_at.desc()))
|
|
).scalars().all()
|
|
return [await _to_out(session, assistant) for assistant in rows]
|
|
|
|
|
|
@router.post("", response_model=AssistantOut)
|
|
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()
|
|
resource_ids = data.pop("model_resource_ids")
|
|
tool_ids = data.pop("tool_ids")
|
|
assistant = Assistant(id=f"asst_{uuid.uuid4().hex[:12]}", **data)
|
|
session.add(assistant)
|
|
await session.flush()
|
|
await _sync_bindings(session, assistant.id, resource_ids)
|
|
await _sync_tool_bindings(session, assistant.id, assistant.type, tool_ids)
|
|
await session.commit()
|
|
await session.refresh(assistant)
|
|
return await _to_out(session, assistant)
|
|
|
|
|
|
@router.get("/{assistant_id}", response_model=AssistantOut)
|
|
async def get_assistant(
|
|
assistant_id: str, session: AsyncSession = Depends(get_session)
|
|
):
|
|
assistant = await session.get(Assistant, assistant_id)
|
|
if not assistant:
|
|
raise HTTPException(404, "助手不存在")
|
|
return await _to_out(session, assistant)
|
|
|
|
|
|
@router.post("/{assistant_id}/duplicate", response_model=AssistantOut)
|
|
async def duplicate_assistant(
|
|
assistant_id: str, session: AsyncSession = Depends(get_session)
|
|
):
|
|
source = await session.get(Assistant, assistant_id)
|
|
if not source:
|
|
raise HTTPException(404, "助手不存在")
|
|
assistant = Assistant(
|
|
id=f"asst_{uuid.uuid4().hex[:12]}",
|
|
name=f"{source.name} 副本",
|
|
type=source.type,
|
|
runtime_mode=source.runtime_mode,
|
|
greeting=source.greeting,
|
|
enable_interrupt=source.enable_interrupt,
|
|
turn_config=dict(source.turn_config or {}),
|
|
vision_enabled=source.vision_enabled,
|
|
vision_model_resource_id=source.vision_model_resource_id,
|
|
knowledge_base_id=source.knowledge_base_id,
|
|
knowledge_retrieval_config=dict(source.knowledge_retrieval_config or {}),
|
|
prompt=source.prompt,
|
|
dynamic_variable_definitions=dict(source.dynamic_variable_definitions or {}),
|
|
api_url=source.api_url,
|
|
api_key=source.api_key,
|
|
app_id=source.app_id,
|
|
graph=dict(source.graph or {}),
|
|
)
|
|
session.add(assistant)
|
|
await session.flush()
|
|
await _sync_bindings(session, assistant.id, await _resource_ids(session, source.id))
|
|
await _sync_tool_bindings(
|
|
session, assistant.id, assistant.type, await _tool_ids(session, source.id)
|
|
)
|
|
await session.commit()
|
|
await session.refresh(assistant)
|
|
return await _to_out(session, assistant)
|
|
|
|
|
|
@router.put("/{assistant_id}", response_model=AssistantOut)
|
|
async def update_assistant(
|
|
assistant_id: str,
|
|
body: AssistantUpsert,
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
assistant = await session.get(Assistant, assistant_id)
|
|
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()
|
|
resource_ids = data.pop("model_resource_ids")
|
|
tool_ids = data.pop("tool_ids")
|
|
data["api_key"] = resolve_incoming_key(data["api_key"], assistant.api_key)
|
|
for key, value in data.items():
|
|
setattr(assistant, key, value)
|
|
await _sync_bindings(session, assistant.id, resource_ids)
|
|
await _sync_tool_bindings(session, assistant.id, assistant.type, tool_ids)
|
|
await session.commit()
|
|
await session.refresh(assistant)
|
|
return await _to_out(session, assistant)
|
|
|
|
|
|
@router.delete("/{assistant_id}")
|
|
async def delete_assistant(
|
|
assistant_id: str, session: AsyncSession = Depends(get_session)
|
|
):
|
|
assistant = await session.get(Assistant, assistant_id)
|
|
if not assistant:
|
|
raise HTTPException(404, "助手不存在")
|
|
await session.delete(assistant)
|
|
await session.commit()
|
|
return {"ok": True}
|