- Introduce a new `Tool` model and `AssistantToolBinding` for managing reusable tools within the application. - Implement CRUD operations for tools in the new `tools` route, allowing for the creation, retrieval, updating, and deletion of tools. - Update the `Assistant` model to include a list of tool IDs, enabling assistants to utilize these tools. - Enhance the backend routes to synchronize tool bindings with assistants, ensuring proper management of tool associations. - Add frontend components for tool management, including a tool picker in the assistant configuration, improving user experience in tool selection. - Create a mobile call page to facilitate video calls, integrating camera and microphone selection for enhanced communication capabilities. - Update API definitions to include tool-related types and operations, ensuring consistency across the application. - Add a migration script to create the necessary database tables for tools and bindings, supporting the new functionality.
259 lines
9.4 KiB
Python
259 lines
9.4 KiB
Python
"""Assistant CRUD backed by capability-to-model-resource bindings."""
|
|
|
|
import uuid
|
|
|
|
from db.models import (
|
|
Assistant,
|
|
AssistantModelBinding,
|
|
AssistantToolBinding,
|
|
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 validate_graph
|
|
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
|
|
errors = validate_graph(body.graph or {})
|
|
if errors:
|
|
raise HTTPException(400, "工作流校验失败:" + ";".join(errors))
|
|
|
|
|
|
async def _validate_vision_model(
|
|
session: AsyncSession, body: AssistantUpsert
|
|
) -> None:
|
|
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 _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 == "prompt" 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,
|
|
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,
|
|
tool_ids=await _tool_ids(session, assistant.id),
|
|
prompt=assistant.prompt,
|
|
api_url=assistant.api_url,
|
|
api_key=mask(assistant.api_key),
|
|
app_id=assistant.app_id,
|
|
graph=assistant.graph or {},
|
|
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_vision_model(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,
|
|
vision_enabled=source.vision_enabled,
|
|
vision_model_resource_id=source.vision_model_resource_id,
|
|
knowledge_base_id=source.knowledge_base_id,
|
|
prompt=source.prompt,
|
|
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_vision_model(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}
|