- Implement a new API endpoint for duplicating tools in the backend. - Add a duplicate_tool function in ComponentsToolsPage to handle tool duplication requests. - Update the UI to include a dropdown menu option for duplicating tools, improving user experience. - Refactor the remove function to accept a resource object instead of an ID for better clarity and maintainability.
153 lines
4.8 KiB
Python
153 lines
4.8 KiB
Python
"""Reusable tool CRUD. Runtime execution is implemented per supported tool type."""
|
|
|
|
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,
|
|
}
|
|
|
|
|
|
def _duplicate_function_name(source: str) -> str:
|
|
candidate = f"{source}_copy"
|
|
if len(candidate) <= 64:
|
|
return candidate
|
|
return f"{source[:57]}_copy"
|
|
|
|
|
|
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.post("/{tool_id}/duplicate", response_model=ToolOut)
|
|
async def duplicate_tool(tool_id: str, session: AsyncSession = Depends(get_session)):
|
|
source = await session.get(Tool, tool_id)
|
|
if not source:
|
|
raise HTTPException(404, "工具不存在")
|
|
function_name = _duplicate_function_name(source.function_name)
|
|
existing = (
|
|
await session.execute(select(Tool).where(Tool.function_name == function_name))
|
|
).scalar_one_or_none()
|
|
if existing:
|
|
suffix = uuid.uuid4().hex[:6]
|
|
max_base = 64 - len(suffix) - 1
|
|
function_name = f"{source.function_name[:max_base]}_{suffix}"
|
|
tool = Tool(
|
|
id=f"tool_{uuid.uuid4().hex[:12]}",
|
|
name=f"{source.name} 副本",
|
|
function_name=function_name,
|
|
type=source.type,
|
|
description=source.description,
|
|
definition=dict(source.definition or {}),
|
|
secrets=dict(source.secrets or {}),
|
|
status=source.status,
|
|
)
|
|
session.add(tool)
|
|
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}
|