Skip to content
96 changes: 96 additions & 0 deletions backend/app/api/tools.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
"""Tool management API — CRUD for tools and per-agent assignments."""

import uuid
import logging
from datetime import datetime, timezone

logger = logging.getLogger(__name__)

from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
Expand Down Expand Up @@ -555,6 +559,98 @@ async def delete_agent_tool(
return {"ok": True}


class BulkDeleteAgentToolsRequest(BaseModel):
agent_tool_ids: list[str]

# Validation: limit to 50 items per request to prevent abuse
model_config = {"json_schema_extra": {"maxItems": 50}}


class BulkDeleteResult(BaseModel):
deleted: int
errors: list[dict] = []


@router.delete("/agent-tools/bulk", response_model=BulkDeleteResult)
async def delete_agent_tools_bulk(
request: BulkDeleteAgentToolsRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Admin: bulk delete agent-tool assignments. Also deletes tool records if no other agents use them."""
# Admin check
if current_user.role not in ("org_admin", "platform_admin"):
from fastapi import HTTPException
raise HTTPException(status_code=403, detail="Admin permission required")

deleted_count = 0
errors: list[dict] = []
tools_to_check: set[uuid.UUID] = set()

# Parse and validate IDs
valid_ids: list[uuid.UUID] = []
for agent_tool_id_str in request.agent_tool_ids:
try:
agent_tool_id = uuid.UUID(agent_tool_id_str)
valid_ids.append(agent_tool_id)
except ValueError:
errors.append({"id": agent_tool_id_str, "error": "Invalid ID"})

# Batch-fetch AgentTool with Agent using JOIN (avoids N+1 and MissingGreenlet)
from app.models.agent import Agent
if valid_ids:
results = await db.execute(
select(AgentTool, Agent)
.join(Agent, AgentTool.agent_id == Agent.id)
.where(AgentTool.id.in_(valid_ids))
)
# Map agent_tool_id -> (AgentTool, Agent) for tenant check
agent_tool_map = {str(at.id): (at, ag) for at, ag in results.all()}

# Process each requested ID
for agent_tool_id_str in request.agent_tool_ids:
# Skip invalid IDs (already added to errors above)
try:
uuid.UUID(agent_tool_id_str)
except ValueError:
continue

pair = agent_tool_map.get(agent_tool_id_str)
if not pair:
errors.append({"id": agent_tool_id_str, "error": "Not found"})
continue

at, ag = pair
# Check ownership (admin can only delete within their tenant)
if current_user.role != "platform_admin" and ag.tenant_id != current_user.tenant_id:
errors.append({"id": agent_tool_id_str, "error": "Access denied"})
continue

tools_to_check.add(at.tool_id)
await db.delete(at)
deleted_count += 1

await db.flush()

# Log the bulk delete operation for audit trail
logger.info(
f"[{datetime.now(timezone.utc).isoformat()}] Bulk delete agent-tools: user={current_user.id}, tenant={current_user.tenant_id}, "
f"deleted={deleted_count}, errors={len(errors)}, tools_cleaned={len(tools_to_check)}"
)

# Check if any tools should be deleted (no remaining agents use them)
for tool_id in tools_to_check:
remaining_r = await db.execute(select(AgentTool).where(AgentTool.tool_id == tool_id).limit(1))
if not remaining_r.scalar_one_or_none():
tool_r = await db.execute(select(Tool).where(Tool.id == tool_id))
tool = tool_r.scalar_one_or_none()
if tool and tool.type == "mcp":
await db.delete(tool)

await db.commit()
return BulkDeleteResult(deleted=deleted_count, errors=errors)


# ─── Per-Agent Tool Config ───────────────────────────────────

class AgentToolConfigUpdate(BaseModel):
Expand Down
13 changes: 11 additions & 2 deletions backend/app/api/wecom.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,11 +117,19 @@ async def configure_wecom_channel(
detail="Either bot_id+bot_secret (WebSocket) or corp_id+secret+token+encoding_aes_key (Webhook) required"
)

# Priority: use explicit connection_mode from frontend, then auto-detect
requested_mode = data.get("connection_mode", "").strip()
if requested_mode in ("websocket", "webhook"):
connection_mode = requested_mode
else:
# Auto-detect based on available fields (WebSocket takes priority if both present)
connection_mode = "websocket" if has_ws_mode else "webhook"

extra_config = {
"wecom_agent_id": wecom_agent_id,
"bot_id": bot_id,
"bot_secret": bot_secret,
"connection_mode": "websocket" if has_ws_mode else "webhook",
"connection_mode": connection_mode,
}

result = await db.execute(
Expand Down Expand Up @@ -580,7 +588,7 @@ async def _process_wecom_text(
logger.info(f"[WeCom KF] send_msg result: {res_send.json()}")
else:
# Default legacy Send as text
await client.post(
res_send = await client.post(
f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}",
json={
"touser": from_user,
Expand All @@ -589,6 +597,7 @@ async def _process_wecom_text(
"text": {"content": reply_text},
},
)
logger.info(f"[WeCom] message/send result: {res_send.json()}")
except Exception as e:
logger.error(f"[WeCom] Failed to send reply: {e}")

Expand Down
14 changes: 11 additions & 3 deletions frontend/src/components/ChannelConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -386,7 +386,7 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values,
});
const { data: wecomWebhook } = useQuery({
queryKey: ['wecom-webhook-url', agentId],
queryFn: () => fetchAuth<any>(`/agents/${agentId}/wecom-channel/webhook-url`),
queryFn: () => fetchAuth<any>(`/agents/${agentId}/wecom-channel/webhook-url`).catch(() => null),
enabled: enabled,
});
const { data: atlassianConfig } = useQuery({
Expand Down Expand Up @@ -435,11 +435,12 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values,
}
return fetchAuth(`/agents/${agentId}/${ch.apiSlug}`, { method: 'POST', body: JSON.stringify(data) });
},
onSuccess: (_d, { ch }) => {
onSuccess: async (_d, { ch }) => {
const keys = ch.useChannelApi
? [['channel', agentId]]
: [[`${ch.apiSlug}`, agentId], [`${ch.id}-webhook-url`, agentId]];
keys.forEach(k => queryClient.invalidateQueries({ queryKey: k }));
// Invalidate and wait for refetch to complete
await Promise.all(keys.map(k => queryClient.invalidateQueries({ queryKey: k })));
// Reset form
setForms(prev => ({ ...prev, [ch.id]: {} }));
setEditing(ch.id, false);
Expand Down Expand Up @@ -794,6 +795,13 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values,
</div>
</div>
)}
{/* WeCom webhook status */}
{ch.id === 'wecom' && configConnMode === 'webhook' && (
<div style={{ fontSize: '12px', color: 'var(--text-tertiary)', marginBottom: '8px' }}>
<div style={{ marginBottom: '4px' }}>Mode: <strong>Webhook</strong></div>
<div>CorpID: <code>{config.app_id}</code></div>
</div>
)}

{/* Webhook URL (non-websocket channels) */}
{ch.webhookLabel && !(ch.connectionMode && configConnMode === 'websocket') && ch.id !== 'dingtalk' && ch.id !== 'atlassian' && (
Expand Down
18 changes: 17 additions & 1 deletion frontend/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -598,7 +598,15 @@
"reloadPage": "Reload Page",
"selectAgent": "— Select Agent —",
"failedToCreateSession": "Failed to create session",
"failed": "Failed"
"failed": "Failed",
"selectAll": "Select All",
"bulkDelete": "Bulk Delete",
"selectedCount": "{{count}} selected",
"bulkDeleteConfirm": "Are you sure you want to delete {{count}} selected tools? This action cannot be undone.",
"bulkDeleteSuccess": "Successfully deleted {{count}} tools",
"deleteSuccess": "Delete successful",
"toolsCount": "tools",
"partialFailed": "Some operations failed"
},
"toolCategories": {
"file": "File Operations",
Expand Down Expand Up @@ -1035,6 +1043,14 @@
"noAgentInstalledTools": "No agent-installed tools yet.",
"removeFromAgent": "Remove \"{{name}}\" from agent?",
"delete": "Delete",
"selectAll": "Select All",
"bulkDelete": "Bulk Delete",
"selectedCount": "{{count}} selected",
"bulkDeleteConfirm": "Are you sure you want to delete {{count}} selected tools? This action cannot be undone.",
"bulkDeleteSuccess": "Successfully deleted {{count}} tools",
"deleteFailed": "Delete failed",
"deleteSuccess": "Delete successful",
"toolsCount": "tools",
"addMcpServer": "Add MCP Server",
"mcpServer": "MCP Server",
"jsonConfig": "JSON Config",
Expand Down
18 changes: 17 additions & 1 deletion frontend/src/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -679,7 +679,15 @@
"reloadPage": "重新加载页面",
"selectAgent": "— 选择数字员工 —",
"failedToCreateSession": "创建会话失败",
"failed": "失败"
"failed": "失败",
"selectAll": "全选",
"bulkDelete": "批量删除",
"selectedCount": "已选择 {{count}} 项",
"bulkDeleteConfirm": "确定要删除选中的 {{count}} 个工具吗?此操作无法撤销。",
"bulkDeleteSuccess": "成功删除 {{count}} 个工具",
"deleteSuccess": "删除成功",
"toolsCount": "个工具",
"partialFailed": "部分失败"
},
"toolCategories": {
"file": "文件操作",
Expand Down Expand Up @@ -1148,6 +1156,14 @@
"noAgentInstalledTools": "暂无 Agent 安装的工具。",
"removeFromAgent": "从 Agent 中移除 \"{{name}}\"?",
"delete": "删除",
"selectAll": "全选",
"bulkDelete": "批量删除",
"selectedCount": "已选择 {{count}} 项",
"bulkDeleteConfirm": "确定要删除选中的 {{count}} 个工具吗?此操作无法撤销。",
"bulkDeleteSuccess": "成功删除 {{count}} 个工具",
"deleteFailed": "删除失败",
"deleteSuccess": "删除成功",
"toolsCount": "个工具",
"addMcpServer": "添加 MCP 服务器",
"mcpServer": "MCP 服务器",
"jsonConfig": "JSON 配置",
Expand Down
Loading