"""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, SUPPORTED_ASSISTANT_TYPES, ) from services.auth import require_admin from services.masking import mask, resolve_incoming_key from services.node_specs import copy_graph, graph_references, 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 = copy_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)) task_tools = [tool for tool in tools.values() if tool.type == "task"] if task_tools and (body.type != "prompt" or body.runtime_mode != "pipeline"): raise HTTPException(400, "后台任务工具仅支持提示词 Pipeline") if len(task_tools) > 1: raise HTTPException(400, "每个助手最多绑定一个后台任务工具") 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 {}, ) if tool.type == "task" and binding.result_assignments: raise HTTPException(400, "后台任务结果通过任务通知返回,不支持结果变量映射") if tool.type == "task" and scope.startswith(("Action ", "Startup ")): raise HTTPException(400, "后台任务工具不支持启动 Action") 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: if assistant.type not in SUPPORTED_ASSISTANT_TYPES: raise HTTPException(410, "该助手类型已下线") 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=( copy_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) .where(Assistant.type.in_(SUPPORTED_ASSISTANT_TYPES)) .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, "助手不存在") if source.type not in SUPPORTED_ASSISTANT_TYPES: raise HTTPException(410, "该助手类型已下线") 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}