From a5c9bb129cabf14ec927e80f99322d21694caed7 Mon Sep 17 00:00:00 2001 From: ThomasOscar <2825232943@qq.com> Date: Fri, 3 Apr 2026 14:11:03 +0800 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 DELETE /api/tools/agent-tools/bulk API,支持批量删除 Agent 工具分配 - 添加 admin/super_admin 角色权限验证和租户隔离检查 - 实现部分失败处理机制,返回 deleted 数量和 errors 数组 - 添加请求数量限制(最多50条),防止滥用 - 添加操作日志记录,含时间戳便于审计追踪 - 前端 AgentDetail.tsx 添加批量选择 UI 和确认弹窗 - 前端 EnterpriseSettings.tsx 添加批量删除 UI - 新增国际化翻译 keys --- backend/app/api/tools.py | 75 +++++++++ frontend/src/i18n/zh.json | 18 +- frontend/src/pages/AgentDetail.tsx | 193 ++++++++++++++++++++-- frontend/src/pages/EnterpriseSettings.tsx | 187 ++++++++++++++++++--- 4 files changed, 435 insertions(+), 38 deletions(-) diff --git a/backend/app/api/tools.py b/backend/app/api/tools.py index 4481ba4c..2785fce8 100644 --- a/backend/app/api/tools.py +++ b/backend/app/api/tools.py @@ -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 @@ -555,6 +559,77 @@ 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 ("admin", "super_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() + + for agent_tool_id_str in request.agent_tool_ids: + try: + agent_tool_id = uuid.UUID(agent_tool_id_str) + except ValueError: + errors.append({"id": agent_tool_id_str, "error": "Invalid ID"}) + continue + + at_r = await db.execute(select(AgentTool).where(AgentTool.id == agent_tool_id)) + at = at_r.scalar_one_or_none() + if not at: + errors.append({"id": agent_tool_id_str, "error": "Not found"}) + continue + + # Check ownership (admin can only delete within their tenant) + if current_user.role != "super_admin" and at.agent.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): diff --git a/frontend/src/i18n/zh.json b/frontend/src/i18n/zh.json index a6996b11..f2c6df10 100644 --- a/frontend/src/i18n/zh.json +++ b/frontend/src/i18n/zh.json @@ -670,7 +670,15 @@ "reloadPage": "重新加载页面", "selectAgent": "— 选择数字员工 —", "failedToCreateSession": "创建会话失败", - "failed": "失败" + "failed": "失败", + "selectAll": "全选", + "bulkDelete": "批量删除", + "selectedCount": "已选择 {{count}} 项", + "bulkDeleteConfirm": "确定要删除选中的 {{count}} 个工具吗?此操作无法撤销。", + "bulkDeleteSuccess": "成功删除 {{count}} 个工具", + "deleteSuccess": "删除成功", + "toolsCount": "个工具", + "partialFailed": "部分失败" }, "toolCategories": { "file": "文件操作", @@ -1139,6 +1147,14 @@ "noAgentInstalledTools": "暂无 Agent 安装的工具。", "removeFromAgent": "从 Agent 中移除 \"{{name}}\"?", "delete": "删除", + "selectAll": "全选", + "bulkDelete": "批量删除", + "selectedCount": "已选择 {{count}} 项", + "bulkDeleteConfirm": "确定要删除选中的 {{count}} 个工具吗?此操作无法撤销。", + "bulkDeleteSuccess": "成功删除 {{count}} 个工具", + "deleteFailed": "删除失败", + "deleteSuccess": "删除成功", + "toolsCount": "个工具", "addMcpServer": "添加 MCP 服务器", "mcpServer": "MCP 服务器", "jsonConfig": "JSON 配置", diff --git a/frontend/src/pages/AgentDetail.tsx b/frontend/src/pages/AgentDetail.tsx index ff79d793..27812016 100644 --- a/frontend/src/pages/AgentDetail.tsx +++ b/frontend/src/pages/AgentDetail.tsx @@ -55,12 +55,21 @@ function ToolsManager({ agentId, canManage = false }: { agentId: string; canMana const [configSaving, setConfigSaving] = useState(false); const [toolTab, setToolTab] = useState<'company' | 'installed'>('company'); const [deletingToolId, setDeletingToolId] = useState(null); + const [selectedToolIds, setSelectedToolIds] = useState>(new Set()); + const [bulkDeleteConfirm, setBulkDeleteConfirm] = useState(null); + const [singleDeleteConfirm, setSingleDeleteConfirm] = useState<{ id: string; agentToolId: string; name: string } | null>(null); + const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); const [configCategory, setConfigCategory] = useState(null); const [focusedField, setFocusedField] = useState(null); // Global (company-level) config for the currently open modal — used to show // lock hints and prevent agent from overriding company-set fields. const [configGlobalData, setConfigGlobalData] = useState>({}); + const showToast = (message: string, type: 'success' | 'error' = 'success') => { + setToast({ message, type }); + setTimeout(() => setToast(null), 3000); + }; + const CATEGORY_CONFIG_SCHEMAS: Record = { agentbay: { title: 'AgentBay Settings', @@ -284,6 +293,37 @@ function ToolsManager({ agentId, canManage = false }: { agentId: string; canMana return (
+ {/* Checkbox for agent-installed tools when in installed tab */} + {toolTab === 'installed' && tool.source === 'agent' && tool.agent_tool_id && canManage && ( + + )} {tool.icon}
@@ -314,22 +354,10 @@ function ToolsManager({ agentId, canManage = false }: { agentId: string; canMana )} {canManage && tool.source === 'agent' && tool.agent_tool_id && ( )} @@ -385,7 +413,7 @@ function ToolsManager({ agentId, canManage = false }: { agentId: string; canMana {t('agent.tools.companyTools', 'Company Tools')} ({companyTools.length})
+ {/* Bulk Delete Bar for Agent-Installed Tools */} + {toolTab === 'installed' && agentInstalledTools.length > 0 && canManage && ( +
+ + {selectedToolIds.size > 0 ? ( +
+ + {t('agent.tools.selectedCount', { count: selectedToolIds.size, defaultValue: `已选择 ${selectedToolIds.size} 项` })} + + +
+ ) : ( + + {agentInstalledTools.length} {t('agent.tools.toolsCount', '个工具')} + + )} +
+ )} + + {/* Bulk Delete Confirm Modal */} + setBulkDeleteConfirm(null)} + onConfirm={async () => { + const count = bulkDeleteConfirm; + setBulkDeleteConfirm(null); + try { + const token = localStorage.getItem('token'); + const res = await fetch('/api/tools/agent-tools/bulk', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ agent_tool_ids: Array.from(selectedToolIds) }), + }); + if (res.ok) { + const result = await res.json(); + if (result.errors && result.errors.length > 0) { + // 有部分错误 + const errorMsg = result.errors.map((e: any) => e.error).join(', '); + showToast(`${t('agent.tools.bulkDeleteSuccess', { count: result.deleted })} (${result.errors.length} ${t('agent.tools.deleteFailed', '部分失败')})`, result.deleted > 0 ? 'success' : 'error'); + } else { + showToast(t('agent.tools.bulkDeleteSuccess', { count: result.deleted, defaultValue: `成功删除 ${result.deleted} 个工具` })); + } + setSelectedToolIds(new Set()); + loadTools(); + } else { + showToast(t('agent.tools.deleteFailed', '删除失败'), 'error'); + } + } catch (e) { + showToast(t('agent.tools.deleteFailed', '删除失败') + ': ' + e, 'error'); + } + }} + /> + + {/* Single Delete Confirm Modal */} + setSingleDeleteConfirm(null)} + onConfirm={async () => { + const target = singleDeleteConfirm; + setSingleDeleteConfirm(null); + if (!target) return; + setDeletingToolId(target.id); + try { + const token = localStorage.getItem('token'); + const res = await fetch(`/api/tools/agent-tool/${target.agentToolId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + showToast(t('agent.tools.deleteSuccess', '删除成功')); + loadTools(); + } else { + showToast(t('agent.tools.deleteFailed', '删除失败'), 'error'); + } + } catch (e) { + showToast(t('agent.tools.deleteFailed', '删除失败') + ': ' + e, 'error'); + } + setDeletingToolId(null); + }} + /> + + {/* Toast */} + {toast && ( +
+ {toast.message} +
+ )} + {/* Tool List */} {activeTools.length > 0 ? ( renderToolGroup(groupByCategory(activeTools)) diff --git a/frontend/src/pages/EnterpriseSettings.tsx b/frontend/src/pages/EnterpriseSettings.tsx index 7dd4dfc3..f011e310 100644 --- a/frontend/src/pages/EnterpriseSettings.tsx +++ b/frontend/src/pages/EnterpriseSettings.tsx @@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { enterpriseApi, skillApi } from '../services/api'; import PromptModal from '../components/PromptModal'; +import ConfirmModal from '../components/ConfirmModal'; import FileBrowser from '../components/FileBrowser'; import type { FileBrowserApi } from '../components/FileBrowser'; import { saveAccentColor, getSavedAccentColor, resetAccentColor, PRESET_COLORS } from '../utils/theme'; @@ -1788,6 +1789,15 @@ export default function EnterpriseSettings() { }; const [toolsView, setToolsView] = useState<'global' | 'agent-installed'>('global'); const [agentInstalledTools, setAgentInstalledTools] = useState([]); + const [selectedToolIds, setSelectedToolIds] = useState>(new Set()); + const [bulkDeleteConfirm, setBulkDeleteConfirm] = useState(null); + const [singleDeleteConfirm, setSingleDeleteConfirm] = useState<{ agentToolId: string; name: string } | null>(null); + const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null); + + const showToast = (message: string, type: 'success' | 'error' = 'success') => { + setToast({ message, type }); + setTimeout(() => setToast(null), 3000); + }; const loadAllTools = async () => { const tid = selectedTenantId; const data = await fetchJson(`/tools${tid ? `?tenant_id=${tid}` : ''}`); @@ -1798,6 +1808,7 @@ export default function EnterpriseSettings() { const tid = selectedTenantId; const data = await fetchJson(`/tools/agent-installed${tid ? `?tenant_id=${tid}` : ''}`); setAgentInstalledTools(data); + setSelectedToolIds(new Set()); // Reset selection on load } catch { } }; useEffect(() => { if (activeTab === 'tools') { loadAllTools(); loadAgentInstalledTools(); } }, [activeTab, selectedTenantId]); @@ -2552,31 +2563,165 @@ export default function EnterpriseSettings() { {agentInstalledTools.length === 0 ? (
{t('enterprise.tools.noAgentInstalledTools')}
) : ( -
- {agentInstalledTools.map((row: any) => ( -
-
-
- 🔌 {row.tool_display_name} - {row.mcp_server_name && MCP} -
-
- 🤖 {row.installed_by_agent_name || 'Unknown Agent'} - {row.installed_at && · {new Date(row.installed_at).toLocaleString()}} + <> + {/* Bulk actions bar */} +
+ + {selectedToolIds.size > 0 ? ( +
+ + {t('enterprise.tools.selectedCount', { count: selectedToolIds.size })} + + +
+ ) : ( + + {agentInstalledTools.length} {t('enterprise.tools.toolsCount', '个工具')} + + )} +
+ {/* Tool list with checkboxes */} +
+ {agentInstalledTools.map((row: any) => ( +
+
+ +
+
+ 🔌 {row.tool_display_name} + {row.mcp_server_name && MCP} +
+
+ 🤖 {row.installed_by_agent_name || 'Unknown Agent'} + {row.installed_at && · {new Date(row.installed_at).toLocaleString()}} +
+
+
-
+ + {/* Bulk Delete Confirm Modal */} + setBulkDeleteConfirm(null)} + onConfirm={async () => { + setBulkDeleteConfirm(null); + try { + const result = await fetchJson<{ deleted: number; errors?: Array<{ id: string; error: string }> }>('/tools/agent-tools/bulk', { + method: 'DELETE', + body: JSON.stringify({ agent_tool_ids: Array.from(selectedToolIds) }), + }); + if (result.errors && result.errors.length > 0) { + showToast(`${t('enterprise.tools.bulkDeleteSuccess', { count: result.deleted })} (${result.errors.length} ${t('enterprise.tools.deleteFailed', '部分失败')})`, result.deleted > 0 ? 'success' : 'error'); + } else { + showToast(t('enterprise.tools.bulkDeleteSuccess', { count: result.deleted })); } - loadAgentInstalledTools(); - }}>🗑️ {t('enterprise.tools.delete')} + } catch (e: any) { + showToast(e.message || t('enterprise.tools.deleteFailed', '删除失败'), 'error'); + } + loadAgentInstalledTools(); + }} + /> + + {/* Single Delete Confirm Modal */} + setSingleDeleteConfirm(null)} + onConfirm={async () => { + const target = singleDeleteConfirm; + setSingleDeleteConfirm(null); + if (!target) return; + try { + await fetchJson(`/tools/agent-tool/${target.agentToolId}`, { method: 'DELETE' }); + showToast(t('enterprise.tools.deleteSuccess', '删除成功')); + } catch { + showToast(t('enterprise.tools.deleteFailed', '删除失败'), 'error'); + } + loadAgentInstalledTools(); + }} + /> + + {/* Toast */} + {toast && ( +
+ {toast.message}
- ))} -
+ )} + )}
)} From 8cd064145846696524268d216e4bc193b0384e7f Mon Sep 17 00:00:00 2001 From: ThomasOscar <2825232943@qq.com> Date: Fri, 3 Apr 2026 18:34:53 +0800 Subject: [PATCH 2/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BC=81=E4=B8=9A?= =?UTF-8?q?=E5=BE=AE=E4=BF=A1=E6=B8=A0=E9=81=93Webhook=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题: - 配置Webhook模式后仍显示WebSocket连接状态 - Webhook URL无法正确显示 解决方案: - 后端:优先使用前端指定的connection_mode,避免自动检测错误 - 后端:添加消息发送结果日志,便于调试 - 前端:添加Webhook模式状态显示 - 前端:优雅处理Webhook URL获取失败 - 前端:确保查询失效完成再重置表单 测试: - WebSocket模式:使用Bot ID + Secret配置,显示WebSocket状态 - Webhook模式:使用CorpID + Secret + Token + EncodingAESKey配置,显示Webhook URL --- backend/app/api/wecom.py | 13 +++++++++++-- frontend/src/components/ChannelConfig.tsx | 14 +++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/backend/app/api/wecom.py b/backend/app/api/wecom.py index 41bb14b6..8d07fd72 100644 --- a/backend/app/api/wecom.py +++ b/backend/app/api/wecom.py @@ -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( @@ -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, @@ -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}") diff --git a/frontend/src/components/ChannelConfig.tsx b/frontend/src/components/ChannelConfig.tsx index 525f2ed8..853d92f1 100644 --- a/frontend/src/components/ChannelConfig.tsx +++ b/frontend/src/components/ChannelConfig.tsx @@ -386,7 +386,7 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values, }); const { data: wecomWebhook } = useQuery({ queryKey: ['wecom-webhook-url', agentId], - queryFn: () => fetchAuth(`/agents/${agentId}/wecom-channel/webhook-url`), + queryFn: () => fetchAuth(`/agents/${agentId}/wecom-channel/webhook-url`).catch(() => null), enabled: enabled, }); const { data: atlassianConfig } = useQuery({ @@ -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); @@ -794,6 +795,13 @@ export default function ChannelConfig({ mode, agentId, canManage = true, values,
)} + {/* WeCom webhook status */} + {ch.id === 'wecom' && configConnMode === 'webhook' && ( +
+
Mode: Webhook
+
CorpID: {config.app_id}
+
+ )} {/* Webhook URL (non-websocket channels) */} {ch.webhookLabel && !(ch.connectionMode && configConnMode === 'websocket') && ch.id !== 'dingtalk' && ch.id !== 'atlassian' && ( From 1dee9294b4f697108217bc8fc37c49b0e65cabe4 Mon Sep 17 00:00:00 2001 From: ThomasOscar <2825232943@qq.com> Date: Sat, 4 Apr 2026 00:26:29 +0800 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20PR=20=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E5=8F=8D=E9=A6=88=E7=9A=84=E6=89=B9=E9=87=8F=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E5=8A=9F=E8=83=BD=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 修改内容 ### 1. 修复角色名称 - 将 "admin", "super_admin" 改为 "org_admin", "platform_admin" - 原代码会导致所有合法管理员收到 403 错误 ### 2. 修复 N+1 查询和 MissingGreenlet 问题 - 将循环逐个查询改为单次 JOIN 查询 - 一次性获取 AgentTool 和 Agent,避免懒加载崩溃 ### 3. 添加英文翻译 - 补充 en.json 中批量删除功能的翻译 - 包含 agent.tools 和 enterprise.tools 命名空间 ### 4. 将 Emoji 替换为 Tabler 图标 - 将 🗑️、🤖、🔌 替换为 IconTrash、IconRobot、IconPlug - 遵循项目使用 Tabler 图标的规范 ## 为什么使用 JOIN 而非 selectinload? 审核者建议使用 selectinload(AgentTool.agent),但 AgentTool 模型 未定义 "agent" relationship。我们选择 JOIN 查询而非添加 relationship,原因如下: 1. 最小改动原则 - 无需修改数据模型 2. 项目中已有 JOIN 用法(如 list_agent_installed_tools 函数) 3. 避免添加 relationship 可能带来的副作用 --- backend/app/api/tools.py | 53 ++++++++++++++++------- frontend/src/i18n/en.json | 18 +++++++- frontend/src/pages/AgentDetail.tsx | 32 +++++++++----- frontend/src/pages/EnterpriseSettings.tsx | 9 ++-- 4 files changed, 79 insertions(+), 33 deletions(-) diff --git a/backend/app/api/tools.py b/backend/app/api/tools.py index 2785fce8..eeed4278 100644 --- a/backend/app/api/tools.py +++ b/backend/app/api/tools.py @@ -579,7 +579,7 @@ async def delete_agent_tools_bulk( ): """Admin: bulk delete agent-tool assignments. Also deletes tool records if no other agents use them.""" # Admin check - if current_user.role not in ("admin", "super_admin"): + if current_user.role not in ("org_admin", "platform_admin"): from fastapi import HTTPException raise HTTPException(status_code=403, detail="Admin permission required") @@ -587,27 +587,48 @@ async def delete_agent_tools_bulk( 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"}) - continue - - at_r = await db.execute(select(AgentTool).where(AgentTool.id == agent_tool_id)) - at = at_r.scalar_one_or_none() - if not at: - errors.append({"id": agent_tool_id_str, "error": "Not found"}) - continue - # Check ownership (admin can only delete within their tenant) - if current_user.role != "super_admin" and at.agent.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 + # 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() diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index b09e0bf8..b73d5e7c 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -589,7 +589,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", @@ -1026,6 +1034,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", diff --git a/frontend/src/pages/AgentDetail.tsx b/frontend/src/pages/AgentDetail.tsx index 27812016..e14d6c42 100644 --- a/frontend/src/pages/AgentDetail.tsx +++ b/frontend/src/pages/AgentDetail.tsx @@ -17,7 +17,7 @@ import { useAppStore } from '../stores'; import { useAuthStore } from '../stores'; import { copyToClipboard } from '../utils/clipboard'; import { formatFileSize } from '../utils/formatFileSize'; -import { IconPaperclip, IconSend } from '@tabler/icons-react'; +import { IconPaperclip, IconSend, IconTrash, IconRobot, IconPlug, IconMessage, IconBolt, IconSend2, IconFileText, IconAlertCircle, IconClock, IconHeart, IconBuildingArch } from '@tabler/icons-react'; const TABS = ['status', 'aware', 'mind', 'tools', 'skills', 'relationships', 'workspace', 'chat', 'activityLog', 'approvals', 'settings'] as const; @@ -465,7 +465,7 @@ function ToolsManager({ agentId, canManage = false }: { agentId: string; canMana style={{ color: 'var(--error)', fontSize: '12px', padding: '4px 12px' }} onClick={() => setBulkDeleteConfirm(selectedToolIds.size)} > - 🗑️ {t('agent.tools.bulkDelete', '批量删除')} + {t('agent.tools.bulkDelete', 'Bulk Delete')}
) : ( @@ -1885,7 +1885,7 @@ function AgentDetailInner() {
{isLeft ? (msg.sender_name ? msg.sender_name[0] : 'A') : 'U'}
- {isLeft && msg.sender_name &&
🤖 {msg.sender_name}
} + {isLeft && msg.sender_name &&
{msg.sender_name}
} {isImage ? (
{msg.fileName} @@ -3171,7 +3171,7 @@ function AgentDetailInner() { }} />
- {(session.title || 'Trigger execution').replace(/^🤖\s*/, '')} + {(session.title || 'Trigger execution').replace(/^.?\s*/, '')}
{new Date(session.created_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })} @@ -3867,7 +3867,7 @@ function AgentDetailInner() { pointerEvents: 'none', }} > - {activeSession.source_channel === 'agent' ? `🤖 Agent Conversation · ${activeSession.username || 'Agents'}` : `Read-only · ${activeSession.username || 'User'}`} + {activeSession.source_channel === 'agent' ? <> Agent Conversation · {activeSession.username || 'Agents'} : `Read-only · ${activeSession.username || 'User'}`}
{(() => { @@ -4238,11 +4238,19 @@ function AgentDetailInner() { {filteredLogs.length > 0 ? (
{filteredLogs.map((log: any) => { - const icons: Record = { - chat_reply: '💬', tool_call: '⚡', feishu_msg_sent: '📤', - agent_msg_sent: '🤖', web_msg_sent: '🌐', task_created: '📋', - task_updated: '✅', file_written: '📝', error: '❌', - schedule_run: '⏰', heartbeat: '💓', plaza_post: '🏛️', + const iconComponents: Record = { + chat_reply: , + tool_call: , + feishu_msg_sent: , + agent_msg_sent: , + web_msg_sent: , + task_created: , + task_updated: , + file_written: , + error: , + schedule_run: , + heartbeat: , + plaza_post: , }; const time = log.created_at ? new Date(log.created_at).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', @@ -4259,8 +4267,8 @@ function AgentDetailInner() { }} >
- - {icons[log.action_type] || '·'} + + {iconComponents[log.action_type] || '·'}
{log.summary}
diff --git a/frontend/src/pages/EnterpriseSettings.tsx b/frontend/src/pages/EnterpriseSettings.tsx index f011e310..90a2213e 100644 --- a/frontend/src/pages/EnterpriseSettings.tsx +++ b/frontend/src/pages/EnterpriseSettings.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useMemo } from 'react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; +import { IconTrash, IconPlug, IconRobot } from '@tabler/icons-react'; import { enterpriseApi, skillApi } from '../services/api'; import PromptModal from '../components/PromptModal'; import ConfirmModal from '../components/ConfirmModal'; @@ -2602,7 +2603,7 @@ export default function EnterpriseSettings() { style={{ color: 'var(--error)', fontSize: '12px', padding: '4px 12px' }} onClick={() => setBulkDeleteConfirm(selectedToolIds.size)} > - 🗑️ {t('enterprise.tools.bulkDelete')} + {t('enterprise.tools.bulkDelete')}
) : ( @@ -2646,16 +2647,16 @@ export default function EnterpriseSettings() {
- 🔌 {row.tool_display_name} + {row.tool_display_name} {row.mcp_server_name && MCP}
- 🤖 {row.installed_by_agent_name || 'Unknown Agent'} + {row.installed_by_agent_name || 'Unknown Agent'} {row.installed_at && · {new Date(row.installed_at).toLocaleString()}}
- +
))}
From 29c63adf614775ecbccc2c1142c14d5660744467 Mon Sep 17 00:00:00 2001 From: ThomasOscar <2825232943@qq.com> Date: Sat, 4 Apr 2026 01:02:53 +0800 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20=E8=A7=A3=E5=86=B3=E4=B8=8E=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=20PR=20#279=20=E7=9A=84=E5=90=88=E5=B9=B6=E5=86=B2?= =?UTF-8?q?=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并上游的 A2A (Agent-to-Agent) 会话发送者标签显示逻辑: - 引入 showSenderLabel 和 resolvedAvatarText 变量 - 保留 IconRobot 图标替换 emoji 的改进 - 在 Agent 对话中正确区分 "本 Agent" 和 "其他参与者" 关键变更: - ChatMessageItem 组件新增 thisAgentName 参数 - 当发送者不是当前 Agent 时显示发送者标签 - 头像文字根据发送者身份动态计算 --- frontend/src/pages/AgentDetail.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/AgentDetail.tsx b/frontend/src/pages/AgentDetail.tsx index e14d6c42..6af3c9bf 100644 --- a/frontend/src/pages/AgentDetail.tsx +++ b/frontend/src/pages/AgentDetail.tsx @@ -1859,11 +1859,18 @@ function AgentDetailInner() { return () => clearTimeout(timer); }, [historyMsgs, activeSession?.id]); // Memoized component for each chat message to avoid re-renders while typing - const ChatMessageItem = React.useMemo(() => React.memo(({ msg, i, isLeft, t }: { msg: any, i: number, isLeft: boolean, t: any }) => { + const ChatMessageItem = React.useMemo(() => React.memo(({ msg, i, isLeft, t, thisAgentName }: { msg: any, i: number, isLeft: boolean, t: any, thisAgentName?: string }) => { const fe = msg.fileName?.split('.').pop()?.toLowerCase() ?? ''; const fi = fe === 'pdf' ? '📄' : (fe === 'csv' || fe === 'xlsx' || fe === 'xls') ? '📊' : (fe === 'docx' || fe === 'doc') ? '📝' : '📎'; const isImage = msg.imageUrl && ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'].includes(fe); + // For A2A sessions, determine which participant is "this agent" (left side) + const resolvedAvatarText = isLeft + ? (thisAgentName && msg.sender_name === thisAgentName ? thisAgentName[0] : (msg.sender_name ? msg.sender_name[0] : 'A')) + : 'U'; + const showSenderLabel = isLeft && msg.sender_name && msg.sender_name !== thisAgentName; + const resolvedSenderLabel = msg.sender_name; + const timestampHtml = msg.timestamp ? (() => { const d = new Date(msg.timestamp); const now = new Date(); @@ -1883,9 +1890,9 @@ function AgentDetailInner() { return (
-
{isLeft ? (msg.sender_name ? msg.sender_name[0] : 'A') : 'U'}
+
{resolvedAvatarText}
- {isLeft && msg.sender_name &&
{msg.sender_name}
} + {showSenderLabel &&
{resolvedSenderLabel}
} {isImage ? (
{msg.fileName} @@ -1914,7 +1921,7 @@ function AgentDetailInner() {
); - }), [t]); + }), [t, thisAgentName]); const handleChatScroll = () => { const el = chatContainerRef.current; @@ -3932,7 +3939,7 @@ function AgentDetailInner() { return null; } return ( - + ); }); })()} @@ -3997,7 +4004,7 @@ function AgentDetailInner() { return null; } return ( - + ); })} {isWaiting && ( From e3f0264b410f4b2e479b61dfa021e7bd7c39721a Mon Sep 17 00:00:00 2001 From: ThomasOscar <2825232943@qq.com> Date: Sat, 4 Apr 2026 01:15:26 +0800 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=E9=87=87=E7=94=A8=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=20ChatMessageItem=20=E7=BB=84=E4=BB=B6=E6=96=B0=E5=8F=82?= =?UTF-8?q?=E6=95=B0=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并上游对 ChatMessageItem 组件的重构: - 使用 senderLabel, avatarText, forceSenderLabel 参数替代 thisAgentName - 组件内部逻辑简化,由调用方计算标签和头像文字 - 保留 IconRobot 图标替换 emoji 的改进 关键变更: - ChatMessageItem 组件参数更新为上游格式 - 在 A2A 会话中正确传递 senderLabel 和 forceSenderLabel - 普通聊天会话使用默认参数值 --- frontend/src/pages/AgentDetail.tsx | 40 +++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/frontend/src/pages/AgentDetail.tsx b/frontend/src/pages/AgentDetail.tsx index 6af3c9bf..a281b908 100644 --- a/frontend/src/pages/AgentDetail.tsx +++ b/frontend/src/pages/AgentDetail.tsx @@ -1859,17 +1859,24 @@ function AgentDetailInner() { return () => clearTimeout(timer); }, [historyMsgs, activeSession?.id]); // Memoized component for each chat message to avoid re-renders while typing - const ChatMessageItem = React.useMemo(() => React.memo(({ msg, i, isLeft, t, thisAgentName }: { msg: any, i: number, isLeft: boolean, t: any, thisAgentName?: string }) => { + const ChatMessageItem = React.useMemo(() => React.memo(({ + msg, i, isLeft, t, senderLabel, avatarText, forceSenderLabel = false, + }: { + msg: any; + i: number; + isLeft: boolean; + t: any; + senderLabel?: string; + avatarText?: string; + forceSenderLabel?: boolean; + }) => { const fe = msg.fileName?.split('.').pop()?.toLowerCase() ?? ''; const fi = fe === 'pdf' ? '📄' : (fe === 'csv' || fe === 'xlsx' || fe === 'xls') ? '📊' : (fe === 'docx' || fe === 'doc') ? '📝' : '📎'; const isImage = msg.imageUrl && ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'].includes(fe); - // For A2A sessions, determine which participant is "this agent" (left side) - const resolvedAvatarText = isLeft - ? (thisAgentName && msg.sender_name === thisAgentName ? thisAgentName[0] : (msg.sender_name ? msg.sender_name[0] : 'A')) - : 'U'; - const showSenderLabel = isLeft && msg.sender_name && msg.sender_name !== thisAgentName; - const resolvedSenderLabel = msg.sender_name; + const finalAvatarText = avatarText ?? (isLeft ? (msg.sender_name ? msg.sender_name[0] : 'A') : 'U'); + const showSenderLabel = forceSenderLabel || (isLeft && msg.sender_name); + const finalSenderLabel = senderLabel ?? msg.sender_name; const timestampHtml = msg.timestamp ? (() => { const d = new Date(msg.timestamp); @@ -1890,9 +1897,9 @@ function AgentDetailInner() { return (
-
{resolvedAvatarText}
+
{finalAvatarText}
- {showSenderLabel &&
{resolvedSenderLabel}
} + {showSenderLabel &&
{finalSenderLabel}
} {isImage ? (
{msg.fileName} @@ -1921,7 +1928,7 @@ function AgentDetailInner() {
); - }), [t, thisAgentName]); + }), [t, senderLabel, avatarText, forceSenderLabel]); const handleChatScroll = () => { const el = chatContainerRef.current; @@ -3939,7 +3946,16 @@ function AgentDetailInner() { return null; } return ( - + ); }); })()} @@ -4004,7 +4020,7 @@ function AgentDetailInner() { return null; } return ( - + ); })} {isWaiting && ( From faf35cdbd9467349ce82f0b3c527d7fc855d6481 Mon Sep 17 00:00:00 2001 From: ThomasOscar <2825232943@qq.com> Date: Sat, 4 Apr 2026 01:31:23 +0800 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E5=90=8D=E4=B8=BA=20resolvedAvatarText/resolvedSenderLabel=20?= =?UTF-8?q?=E4=BB=A5=E5=8C=B9=E9=85=8D=E4=B8=8A=E6=B8=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留 IconRobot 图标替换 emoji 的改进,同时使用上游一致的变量命名 --- frontend/src/pages/AgentDetail.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/AgentDetail.tsx b/frontend/src/pages/AgentDetail.tsx index a281b908..1fe9d8a6 100644 --- a/frontend/src/pages/AgentDetail.tsx +++ b/frontend/src/pages/AgentDetail.tsx @@ -1874,9 +1874,9 @@ function AgentDetailInner() { const fi = fe === 'pdf' ? '📄' : (fe === 'csv' || fe === 'xlsx' || fe === 'xls') ? '📊' : (fe === 'docx' || fe === 'doc') ? '📝' : '📎'; const isImage = msg.imageUrl && ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'].includes(fe); - const finalAvatarText = avatarText ?? (isLeft ? (msg.sender_name ? msg.sender_name[0] : 'A') : 'U'); + const resolvedAvatarText = avatarText ?? (isLeft ? (msg.sender_name ? msg.sender_name[0] : 'A') : 'U'); const showSenderLabel = forceSenderLabel || (isLeft && msg.sender_name); - const finalSenderLabel = senderLabel ?? msg.sender_name; + const resolvedSenderLabel = senderLabel ?? msg.sender_name; const timestampHtml = msg.timestamp ? (() => { const d = new Date(msg.timestamp); @@ -1897,9 +1897,9 @@ function AgentDetailInner() { return (
-
{finalAvatarText}
+
{resolvedAvatarText}
- {showSenderLabel &&
{finalSenderLabel}
} + {showSenderLabel &&
{resolvedSenderLabel}
} {isImage ? (
{msg.fileName} From 75e614171f573aad128ce967f79d518061fc7ab4 Mon Sep 17 00:00:00 2001 From: ThomasOscar <2825232943@qq.com> Date: Sat, 4 Apr 2026 01:45:27 +0800 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E5=90=8E=E9=87=8D=E5=A4=8D=E5=8F=98=E9=87=8F=E5=A3=B0=E6=98=8E?= =?UTF-8?q?=E5=AF=BC=E8=87=B4=E7=9A=84=E7=BC=96=E8=AF=91=E9=94=99=E8=AF=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移除合并冲突遗留的重复代码: - 删除重复的 resolvedAvatarText/resolvedSenderLabel/showSenderLabel 声明 - 修复 useMemo 依赖数组引用未定义变量的问题 --- frontend/src/pages/AgentDetail.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/frontend/src/pages/AgentDetail.tsx b/frontend/src/pages/AgentDetail.tsx index 02d1b37f..52bb86a2 100644 --- a/frontend/src/pages/AgentDetail.tsx +++ b/frontend/src/pages/AgentDetail.tsx @@ -2320,9 +2320,6 @@ function AgentDetailInner() { const fe = msg.fileName?.split('.').pop()?.toLowerCase() ?? ''; const fi = fe === 'pdf' ? '📄' : (fe === 'csv' || fe === 'xlsx' || fe === 'xls') ? '📊' : (fe === 'docx' || fe === 'doc') ? '📝' : '📎'; const isImage = msg.imageUrl && ['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp'].includes(fe); - const resolvedSenderLabel = msg.sender_name || senderLabel; - const resolvedAvatarText = avatarText || (resolvedSenderLabel ? resolvedSenderLabel[0] : (isLeft ? 'A' : 'U')); - const showSenderLabel = !!resolvedSenderLabel && (forceSenderLabel || !!msg.sender_name); const resolvedAvatarText = avatarText ?? (isLeft ? (msg.sender_name ? msg.sender_name[0] : 'A') : 'U'); const showSenderLabel = forceSenderLabel || (isLeft && msg.sender_name); @@ -2378,7 +2375,7 @@ function AgentDetailInner() {
); - }), [t, senderLabel, avatarText, forceSenderLabel]); + }), [t]); const handleChatScroll = () => { const el = chatContainerRef.current;