796 lines
31 KiB
Python
796 lines
31 KiB
Python
"""Assistant CRUD backed by capability-to-model-resource bindings."""
|
||
|
||
import uuid
|
||
|
||
from models import VISION_PROMPT_MODES, RuntimeTool, RuntimeToolBinding
|
||
from db.models import (
|
||
Assistant,
|
||
AssistantModelBinding,
|
||
AssistantToolBinding,
|
||
KnowledgeBase,
|
||
ModelResource,
|
||
Tool,
|
||
InterfaceDefinition,
|
||
)
|
||
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.state_expressions import StateExpressionError, validate_update_rules
|
||
from services.system_tools import RESERVED_TOOL_NAMES
|
||
from services.tool_policy import policy_for_tool
|
||
from services.tool_executor import ToolExecutor
|
||
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")
|
||
|
||
|
||
async def _validate_prompt_realtime_tool_capabilities(
|
||
session: AsyncSession,
|
||
body: AssistantUpsert,
|
||
) -> None:
|
||
"""Gate Prompt tools by the selected realtime interface definition."""
|
||
|
||
if body.type != "prompt" or body.runtime_mode != "realtime":
|
||
return
|
||
resource_id = str(body.model_resource_ids.get("Realtime") or "")
|
||
resource = await session.get(ModelResource, resource_id) if resource_id else None
|
||
if not resource or not resource.enabled or resource.capability != "Realtime":
|
||
raise HTTPException(400, "Prompt Realtime 必须选择可用的 Realtime 模型")
|
||
definition = await session.get(InterfaceDefinition, resource.interface_type)
|
||
features = (
|
||
((definition.field_schema or {}).get("features") or {}).get(
|
||
"realtimePromptTools"
|
||
)
|
||
if definition and definition.enabled
|
||
else None
|
||
) or {}
|
||
external_types = set(features.get("externalTypes") or [])
|
||
built_ins = set(features.get("builtIns") or [])
|
||
tool_ids = [binding.tool_id for binding in body.tool_bindings]
|
||
rows = (
|
||
(
|
||
await session.execute(select(Tool).where(Tool.id.in_(tool_ids)))
|
||
).scalars().all()
|
||
if tool_ids
|
||
else []
|
||
)
|
||
unsupported_external = sorted(
|
||
{tool.type for tool in rows if tool.type not in external_types}
|
||
)
|
||
requested_built_ins = {
|
||
key
|
||
for key, value in body.built_in_tools.model_dump(
|
||
by_alias=True,
|
||
exclude_none=True,
|
||
).items()
|
||
if value is not None
|
||
}
|
||
unsupported_built_ins = sorted(requested_built_ins - built_ins)
|
||
if unsupported_external or unsupported_built_ins:
|
||
details = []
|
||
if unsupported_external:
|
||
details.append("外部工具:" + ",".join(unsupported_external))
|
||
if unsupported_built_ins:
|
||
details.append("内建工具:" + ",".join(unsupported_built_ins))
|
||
raise HTTPException(
|
||
400,
|
||
f"Realtime 接口 {resource.interface_type} 不支持 " + ";".join(details),
|
||
)
|
||
|
||
|
||
def _validate_workflow(body: AssistantUpsert) -> None:
|
||
"""workflow 类型:保存前校验图结构,不通过则 400。其他类型跳过。"""
|
||
if body.type != "workflow":
|
||
return
|
||
if body.graph and str(body.graph.get("specVersion") or "") != "4":
|
||
raise HTTPException(
|
||
400,
|
||
"Workflow API 仅接受 specVersion=4;旧图请先执行数据库迁移",
|
||
)
|
||
body.graph = normalize_graph(body.graph or {})
|
||
errors = validate_graph(body.graph)
|
||
settings = body.graph.get("settings") or {}
|
||
graph_runtime_mode = str(settings.get("runtimeMode") or "pipeline")
|
||
if graph_runtime_mode != body.runtime_mode:
|
||
errors.append("工作流全局运行模式与 Assistant runtimeMode 不一致")
|
||
graph_realtime_id = str(settings.get("defaultRealtimeResourceId") or "")
|
||
bound_realtime_id = str(body.model_resource_ids.get("Realtime") or "")
|
||
if graph_realtime_id != bound_realtime_id:
|
||
errors.append("工作流 Realtime 模型与 Assistant Realtime 绑定不一致")
|
||
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
|
||
body.vision_prompt_mode = "runtime"
|
||
body.vision_prompt = ""
|
||
|
||
|
||
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 {}
|
||
runtime_mode = str(settings.get("runtimeMode") or "pipeline")
|
||
resource_expectations: dict[str, str] = {}
|
||
vision_resource_ids: set[str] = set()
|
||
for key, capability in (
|
||
("defaultRealtimeResourceId", "Realtime"),
|
||
("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
|
||
if stage.vision_model_resource_id:
|
||
resource_id = stage.vision_model_resource_id
|
||
capability = "LLM"
|
||
elif runtime_mode == "realtime":
|
||
resource_id = str(settings.get("defaultRealtimeResourceId") or "")
|
||
capability = "Realtime"
|
||
else:
|
||
resource_id = stage.llm_resource_id
|
||
capability = "LLM"
|
||
if not resource_id:
|
||
raise HTTPException(
|
||
400,
|
||
f"Agent 节点 {node_id} 开启视觉理解时必须选择"
|
||
"支持图片输入的当前模型或独立视觉模型",
|
||
)
|
||
resource_expectations[resource_id] = capability
|
||
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_tool_bindings(
|
||
session: AsyncSession,
|
||
body: AssistantUpsert,
|
||
) -> None:
|
||
"""Validate reusable resources and every usage-specific binding."""
|
||
|
||
raw_bindings: list[tuple[str, dict]] = []
|
||
if body.type == "prompt":
|
||
raw_bindings.extend(
|
||
("Prompt", binding.model_dump()) for binding in body.tool_bindings
|
||
)
|
||
for action in body.startup.actions:
|
||
raw_bindings.append(
|
||
(f"Startup {action.id}", action.tool_binding.model_dump())
|
||
)
|
||
elif body.type == "workflow":
|
||
settings = body.graph.get("settings") or {}
|
||
raw_bindings.extend(
|
||
("Workflow 全局", binding)
|
||
for binding in settings.get("toolBindings") or []
|
||
)
|
||
for node in body.graph.get("nodes") or []:
|
||
data = node.get("data") or {}
|
||
if node.get("type") == "agent" and not data.get(
|
||
"inheritGlobalConfig", True
|
||
):
|
||
raw_bindings.extend(
|
||
(f"Agent {node.get('id')}", binding)
|
||
for binding in data.get("toolBindings") or []
|
||
)
|
||
elif node.get("type") == "action":
|
||
raw_bindings.append(
|
||
(f"Action {node.get('id')}", data.get("toolBinding") or {})
|
||
)
|
||
tool_ids = {
|
||
str(binding.get("tool_id") or binding.get("toolId") or "")
|
||
for _, binding in raw_bindings
|
||
if isinstance(binding, dict)
|
||
}
|
||
tool_ids.discard("")
|
||
rows = (
|
||
await session.execute(select(Tool).where(Tool.id.in_(tool_ids)))
|
||
).scalars().all() if tool_ids else []
|
||
tools = {tool.id: tool for tool in rows if tool.status == "active"}
|
||
missing = sorted(tool_ids - set(tools))
|
||
if missing:
|
||
raise HTTPException(400, "工具不存在或未启用: " + ", ".join(missing))
|
||
reserved = sorted(
|
||
tool.function_name
|
||
for tool in tools.values()
|
||
if tool.function_name in RESERVED_TOOL_NAMES
|
||
)
|
||
if reserved:
|
||
raise HTTPException(
|
||
400,
|
||
"外部工具使用了平台保留函数名: " + ", ".join(reserved),
|
||
)
|
||
declared_variables = set(body.dynamic_variable_definitions)
|
||
system_variables = {
|
||
"system__agent_id",
|
||
"system__conversation_id",
|
||
"system__time",
|
||
"system__time_utc",
|
||
"system__timezone",
|
||
"system__agent_turns",
|
||
"system__conversation_history",
|
||
"system__last_action_status",
|
||
"system__last_action_invocation_id",
|
||
"system__last_action_duration_ms",
|
||
"system__last_action_error",
|
||
"system__last_action_error_code",
|
||
"system__handoff_status",
|
||
}
|
||
for scope, raw in raw_bindings:
|
||
if not isinstance(raw, dict):
|
||
raise HTTPException(400, "工具绑定必须是对象")
|
||
binding = RuntimeToolBinding(**raw)
|
||
tool = tools.get(binding.tool_id)
|
||
if tool is None:
|
||
continue
|
||
runtime_tool = RuntimeTool(
|
||
id=tool.id,
|
||
name=tool.name,
|
||
function_name=tool.function_name,
|
||
type=tool.type,
|
||
definition=tool.definition or {},
|
||
)
|
||
properties, required = ToolExecutor.input_schema(runtime_tool)
|
||
unknown = sorted(set(binding.argument_bindings) - set(properties))
|
||
if unknown:
|
||
raise HTTPException(400, "工具绑定引用了未知参数: " + ", ".join(unknown))
|
||
absent = sorted(set(required) - set(binding.argument_bindings))
|
||
if absent:
|
||
raise HTTPException(400, "工具绑定缺少必填参数: " + ", ".join(absent))
|
||
if scope.startswith(("Action ", "Startup ")):
|
||
model_parameters = sorted(
|
||
name
|
||
for name, source in binding.argument_bindings.items()
|
||
if str((source or {}).get("source") or "") == "model"
|
||
)
|
||
if model_parameters:
|
||
raise HTTPException(
|
||
400,
|
||
f"{scope} 是确定性调用,参数不能来自模型: "
|
||
+ ", ".join(model_parameters),
|
||
)
|
||
variable_sources = {
|
||
str(source.get("variable_name") or source.get("variableName") or "")
|
||
for source in [
|
||
*binding.argument_bindings.values(),
|
||
*binding.template_bindings.values(),
|
||
]
|
||
if str((source or {}).get("source") or "") == "variable"
|
||
}
|
||
invalid_sources = sorted(variable_sources - declared_variables - system_variables)
|
||
if invalid_sources:
|
||
raise HTTPException(
|
||
400,
|
||
f"{scope} 引用了未声明动态变量: " + ", ".join(invalid_sources),
|
||
)
|
||
config = (tool.definition or {}).get("config") or {}
|
||
if tool.type == "client" and not config.get(
|
||
"wait_for_response", config.get("waitForResponse", True)
|
||
) and binding.result_assignments:
|
||
raise HTTPException(
|
||
400,
|
||
f"{scope} 的 fire-and-forget Client 工具不能配置结果映射",
|
||
)
|
||
template_inputs = config.get("template_inputs") or []
|
||
template_names = {
|
||
str(item.get("name")) for item in template_inputs if item.get("name")
|
||
}
|
||
unknown_templates = sorted(set(binding.template_bindings) - template_names)
|
||
missing_templates = sorted(
|
||
{
|
||
str(item.get("name"))
|
||
for item in template_inputs
|
||
if item.get("name") and item.get("required", True)
|
||
}
|
||
- set(binding.template_bindings)
|
||
)
|
||
if unknown_templates:
|
||
raise HTTPException(
|
||
400,
|
||
"工具绑定引用了未知模板输入: " + ", ".join(unknown_templates),
|
||
)
|
||
if missing_templates:
|
||
raise HTTPException(
|
||
400,
|
||
"工具绑定缺少模板输入: " + ", ".join(missing_templates),
|
||
)
|
||
undeclared = sorted(
|
||
set(binding.result_assignments) - set(body.dynamic_variable_definitions)
|
||
)
|
||
if undeclared:
|
||
raise HTTPException(400, "结果映射目标变量未声明: " + ", ".join(undeclared))
|
||
|
||
|
||
def _validate_update_state_config(body: AssistantUpsert) -> None:
|
||
"""Validate Prompt and Workflow built-ins against declared variables."""
|
||
|
||
configs: list[tuple[str, dict]] = []
|
||
if body.type == "prompt":
|
||
configs.append(("Prompt", body.built_in_tools.model_dump()))
|
||
elif body.type == "workflow":
|
||
settings = body.graph.get("settings") or {}
|
||
configs.append(("Workflow 全局", settings.get("builtInTools") or {}))
|
||
for node in body.graph.get("nodes") or []:
|
||
data = node.get("data") or {}
|
||
if node.get("type") == "agent" and not data.get(
|
||
"inheritGlobalConfig", True
|
||
):
|
||
configs.append(
|
||
(f"Agent {node.get('id')}", data.get("builtInTools") or {})
|
||
)
|
||
elif node.get("type") == "update_state":
|
||
try:
|
||
validate_update_rules(
|
||
data.get("updates") or [],
|
||
body.dynamic_variable_definitions,
|
||
allow_llm=False,
|
||
)
|
||
except StateExpressionError as exc:
|
||
raise HTTPException(400, f"Update State 节点配置无效: {exc}") from exc
|
||
for scope, config in configs:
|
||
update_state = config.get("update_state") or config.get("updateState")
|
||
if not update_state:
|
||
continue
|
||
try:
|
||
validate_update_rules(
|
||
update_state.get("updates") or [],
|
||
body.dynamic_variable_definitions,
|
||
allow_llm=True,
|
||
)
|
||
except StateExpressionError as exc:
|
||
raise HTTPException(400, f"{scope} 更新状态配置无效: {exc}") from exc
|
||
|
||
|
||
async def _validate_startup_actions(
|
||
session: AsyncSession,
|
||
body: AssistantUpsert,
|
||
) -> None:
|
||
"""Keep startup deterministic and reject unsupported lifecycle/tool pairs."""
|
||
|
||
actions = body.startup.actions
|
||
if not actions:
|
||
return
|
||
for action in actions:
|
||
binding = action.tool_binding
|
||
tool = await session.get(Tool, binding.tool_id)
|
||
if not tool or tool.status != "active":
|
||
raise HTTPException(400, f"启动 Action 引用了无效工具:{binding.tool_id}")
|
||
runtime_tool = RuntimeTool(
|
||
id=tool.id,
|
||
name=tool.name,
|
||
function_name=tool.function_name,
|
||
type=tool.type,
|
||
definition=tool.definition or {},
|
||
)
|
||
if action.phase == "preflight" and runtime_tool.type not in {"http", "mcp"}:
|
||
raise HTTPException(400, "preflight Action 仅支持 HTTP 或 MCP 工具")
|
||
if action.phase == "opening" and runtime_tool.type not in {
|
||
"http",
|
||
"mcp",
|
||
"client",
|
||
}:
|
||
raise HTTPException(400, "opening Action 不支持该工具类型")
|
||
if runtime_tool.type != "client":
|
||
continue
|
||
policy = policy_for_tool(runtime_tool)
|
||
if action.required and not policy.wait_for_response:
|
||
raise HTTPException(400, "必需的 Client 启动 Action 必须等待客户端响应")
|
||
if runtime_tool.function_name != "show_message":
|
||
continue
|
||
if policy.response_wait_mode != "session":
|
||
raise HTTPException(400, "show_message 启动 Action 必须使用会话内等待")
|
||
literal_arguments = {
|
||
name: source.value
|
||
for name, source in binding.argument_bindings.items()
|
||
if getattr(source, "source", "") == "literal"
|
||
}
|
||
message = str(literal_arguments.get("message") or "").strip()
|
||
buttons = literal_arguments.get("actions")
|
||
if not message or len(message) > 2000:
|
||
raise HTTPException(400, "show_message 重要信息必须为 1-2000 个字符")
|
||
if not isinstance(buttons, list) or not buttons:
|
||
raise HTTPException(400, "show_message 至少需要一个确认按钮")
|
||
first_button = buttons[0] if isinstance(buttons[0], dict) else {}
|
||
if not str(first_button.get("id") or "").strip() or not str(
|
||
first_button.get("label") or ""
|
||
).strip():
|
||
raise HTTPException(400, "show_message 确认按钮必须配置 id 和文字")
|
||
if action.required and literal_arguments.get("dismissible") is not False:
|
||
raise HTTPException(400, "必需的 show_message 启动 Action 不允许跳过确认")
|
||
|
||
|
||
async def _validate_vision_model(
|
||
session: AsyncSession, body: AssistantUpsert
|
||
) -> None:
|
||
# Workflow 的视觉模型属于图中的全局/Agent 配置,由上面的图引用校验处理。
|
||
if body.type == "workflow":
|
||
return
|
||
if body.vision_enabled:
|
||
if body.vision_prompt_mode not in VISION_PROMPT_MODES:
|
||
raise HTTPException(400, "视觉提示词模式无效")
|
||
if (
|
||
body.vision_prompt_mode in {"fixed", "guided"}
|
||
and not body.vision_prompt.strip()
|
||
):
|
||
raise HTTPException(400, "固定或引导模式必须填写视觉提示词")
|
||
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_analysis_model(
|
||
session: AsyncSession, body: AssistantUpsert
|
||
) -> None:
|
||
config = body.analysis_config
|
||
if not config.enabled:
|
||
return
|
||
resource = await session.get(ModelResource, config.model_resource_id)
|
||
if not resource or not resource.enabled or resource.capability != "LLM":
|
||
raise HTTPException(400, "分析模型必须引用已启用的 LLM 模型资源")
|
||
|
||
|
||
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_bindings,
|
||
graph: dict | None = None,
|
||
) -> None:
|
||
requested_configs = {
|
||
binding.tool_id: {
|
||
"argument_bindings": {
|
||
name: source.model_dump()
|
||
for name, source in binding.argument_bindings.items()
|
||
},
|
||
"template_bindings": {
|
||
name: source.model_dump()
|
||
for name, source in binding.template_bindings.items()
|
||
},
|
||
"result_assignments": dict(binding.result_assignments),
|
||
}
|
||
for binding in tool_bindings
|
||
} if assistant_type == "prompt" else {}
|
||
if assistant_type == "workflow":
|
||
refs = graph_references(graph or {})
|
||
requested_configs = {tool_id: {} for tool_id in refs["tools"]}
|
||
requested = list(requested_configs)
|
||
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 in existing_by_id:
|
||
existing_by_id[tool_id].config = requested_configs[tool_id]
|
||
else:
|
||
session.add(
|
||
AssistantToolBinding(
|
||
assistant_id=assistant_id,
|
||
tool_id=tool_id,
|
||
config=requested_configs[tool_id],
|
||
)
|
||
)
|
||
|
||
|
||
async def _tool_bindings(session: AsyncSession, assistant_id: str) -> list[dict]:
|
||
rows = (
|
||
await session.execute(
|
||
select(AssistantToolBinding)
|
||
.where(AssistantToolBinding.assistant_id == assistant_id)
|
||
.order_by(AssistantToolBinding.created_at)
|
||
)
|
||
).scalars().all()
|
||
return [
|
||
{"tool_id": row.tool_id, **(row.config or {})}
|
||
for row in rows
|
||
if row.config
|
||
]
|
||
|
||
|
||
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 {},
|
||
startup=assistant.startup or {},
|
||
vision_enabled=assistant.vision_enabled,
|
||
vision_model_resource_id=assistant.vision_model_resource_id,
|
||
vision_prompt_mode=assistant.vision_prompt_mode or "runtime",
|
||
vision_prompt=assistant.vision_prompt or "",
|
||
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_bindings=(
|
||
await _tool_bindings(session, assistant.id)
|
||
if assistant.type == "prompt"
|
||
else []
|
||
),
|
||
built_in_tools=assistant.built_in_tools or {},
|
||
prompt=assistant.prompt,
|
||
dynamic_variable_definitions=assistant.dynamic_variable_definitions or {},
|
||
analysis_config=assistant.analysis_config 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)
|
||
_validate_update_state_config(body)
|
||
await _validate_tool_bindings(session, body)
|
||
await _validate_prompt_realtime_tool_capabilities(session, body)
|
||
await _validate_startup_actions(session, body)
|
||
await _validate_vision_model(session, body)
|
||
await _validate_analysis_model(session, body)
|
||
await _validate_knowledge_base(session, body)
|
||
data = body.model_dump()
|
||
resource_ids = data.pop("model_resource_ids")
|
||
tool_bindings = body.tool_bindings
|
||
data.pop("tool_bindings")
|
||
data["built_in_tools"] = body.built_in_tools.model_dump(exclude_none=True)
|
||
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_bindings,
|
||
body.graph,
|
||
)
|
||
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 {}),
|
||
startup=dict(source.startup or {}),
|
||
vision_enabled=source.vision_enabled,
|
||
vision_model_resource_id=source.vision_model_resource_id,
|
||
vision_prompt_mode=source.vision_prompt_mode or "runtime",
|
||
vision_prompt=source.vision_prompt or "",
|
||
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 {}),
|
||
built_in_tools=dict(source.built_in_tools or {}),
|
||
analysis_config=dict(source.analysis_config 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))
|
||
source_bindings = await _tool_bindings(session, source.id)
|
||
from schemas import ToolBinding
|
||
await _sync_tool_bindings(
|
||
session,
|
||
assistant.id,
|
||
assistant.type,
|
||
[ToolBinding(**binding) for binding in source_bindings],
|
||
assistant.graph,
|
||
)
|
||
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)
|
||
_validate_update_state_config(body)
|
||
await _validate_tool_bindings(session, body)
|
||
await _validate_prompt_realtime_tool_capabilities(session, body)
|
||
await _validate_startup_actions(session, body)
|
||
await _validate_vision_model(session, body)
|
||
await _validate_analysis_model(session, body)
|
||
await _validate_knowledge_base(session, body)
|
||
data = body.model_dump()
|
||
resource_ids = data.pop("model_resource_ids")
|
||
tool_bindings = body.tool_bindings
|
||
data.pop("tool_bindings")
|
||
data["built_in_tools"] = body.built_in_tools.model_dump(exclude_none=True)
|
||
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_bindings,
|
||
body.graph,
|
||
)
|
||
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}
|