Add reusable tools and assistant bindings

- 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.
This commit is contained in:
Xin Wang
2026-07-10 10:05:41 +08:00
parent 919325505a
commit 3ed9e1b388
14 changed files with 1815 additions and 22 deletions

View File

@@ -2,7 +2,13 @@
import uuid
from db.models import Assistant, AssistantModelBinding, ModelResource
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
@@ -83,6 +89,46 @@ async def _resource_ids(session: AsyncSession, assistant_id: str) -> dict[str, s
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,
@@ -95,6 +141,7 @@ async def _to_out(session: AsyncSession, assistant: Assistant) -> AssistantOut:
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),
@@ -120,10 +167,12 @@ async def create_assistant(
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)
@@ -165,6 +214,9 @@ async def duplicate_assistant(
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)
@@ -183,10 +235,12 @@ async def update_assistant(
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)

118
backend/routes/tools.py Normal file
View File

@@ -0,0 +1,118 @@
"""Reusable tool CRUD. Tool execution is intentionally not wired to pipelines yet."""
import uuid
from db.models import AssistantToolBinding, Tool
from db.session import get_session
from fastapi import APIRouter, Depends, HTTPException
from schemas import ToolOut, ToolUpsert
from services.auth import require_admin
from services.masking import mask_secrets, merge_secrets
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter(
prefix="/api/tools",
tags=["tools"],
dependencies=[Depends(require_admin)],
)
def _to_out(tool: Tool) -> ToolOut:
return ToolOut(
id=tool.id,
name=tool.name,
function_name=tool.function_name,
type=tool.type,
description=tool.description,
definition=tool.definition,
secrets=mask_secrets(tool.secrets or {}),
status=tool.status,
updated_at=tool.updated_at.isoformat() if tool.updated_at else None,
)
def _payload(body: ToolUpsert, stored_secrets: dict | None = None) -> dict:
definition = body.definition.model_dump()
secrets = (
merge_secrets(body.secrets, stored_secrets or {})
if definition["type"] == "http"
else {}
)
return {
"name": body.name.strip(),
"function_name": body.function_name,
"type": definition["type"],
"description": body.description.strip(),
"definition": definition,
"secrets": secrets,
"status": body.status,
}
async def _commit(session: AsyncSession, tool: Tool) -> ToolOut:
try:
await session.commit()
except IntegrityError as exc:
await session.rollback()
raise HTTPException(409, "工具函数名已存在") from exc
await session.refresh(tool)
return _to_out(tool)
@router.get("", response_model=list[ToolOut])
async def list_tools(session: AsyncSession = Depends(get_session)):
rows = (
await session.execute(select(Tool).order_by(Tool.updated_at.desc()))
).scalars().all()
return [_to_out(tool) for tool in rows]
@router.post("", response_model=ToolOut)
async def create_tool(body: ToolUpsert, session: AsyncSession = Depends(get_session)):
tool = Tool(id=f"tool_{uuid.uuid4().hex[:12]}", **_payload(body))
session.add(tool)
return await _commit(session, tool)
@router.get("/{tool_id}", response_model=ToolOut)
async def get_tool(tool_id: str, session: AsyncSession = Depends(get_session)):
tool = await session.get(Tool, tool_id)
if not tool:
raise HTTPException(404, "工具不存在")
return _to_out(tool)
@router.put("/{tool_id}", response_model=ToolOut)
async def update_tool(
tool_id: str,
body: ToolUpsert,
session: AsyncSession = Depends(get_session),
):
tool = await session.get(Tool, tool_id)
if not tool:
raise HTTPException(404, "工具不存在")
for key, value in _payload(body, tool.secrets or {}).items():
setattr(tool, key, value)
return await _commit(session, tool)
@router.delete("/{tool_id}")
async def delete_tool(tool_id: str, session: AsyncSession = Depends(get_session)):
tool = await session.get(Tool, tool_id)
if not tool:
raise HTTPException(404, "工具不存在")
in_use = (
await session.execute(
select(AssistantToolBinding.assistant_id)
.where(AssistantToolBinding.tool_id == tool_id)
.limit(1)
)
).scalar_one_or_none()
if in_use:
raise HTTPException(409, "工具正被助手引用,请先解绑")
await session.delete(tool)
await session.commit()
return {"ok": True}