diff --git a/ui/web/src/i18n/locales/en/agents.json b/ui/web/src/i18n/locales/en/agents.json index 0168c2b2..7fa0fe5e 100644 --- a/ui/web/src/i18n/locales/en/agents.json +++ b/ui/web/src/i18n/locales/en/agents.json @@ -40,6 +40,7 @@ "delete": { "title": "Delete Agent", "description": "Permanently delete this agent, all its context files, sessions, and configuration. This action cannot be undone.", + "deleteWarning": "This will permanently delete the agent and ALL related data including: sessions, messages, cron jobs, heartbeats, memories, knowledge graph, skills, channel configurations, teams, workspace files, and permissions. This action cannot be undone.", "detailDescription": "Are you sure you want to delete \"{{name}}\"? All context files, sessions, and configuration will be permanently removed.", "confirmLabel": "Delete", "defaultCannotDelete": "The default agent cannot be deleted." @@ -470,16 +471,31 @@ "logsPagination": "{{from}}–{{to}} of {{total}}" }, "permissions": { - "title": "Config Permissions", - "description": "Control who can modify heartbeat, cron, and context files via chat.", - "scope": "Scope", - "configType": "Config Type", - "userId": "User ID", - "permission": "Permission", - "allTypes": "All (*)", - "empty": "No permission rules configured. Agent owner has full access by default.", + "title": "Permissions", + "description": "Control who can modify agent config and files. Owner always has full access.", "addRule": "Add Rule", - "userIdPlaceholder": "Search contacts or type ID..." + "fileWriters": "File Writers", + "configPerms": "Config Permissions", + "noRules": "No permission rules. Owner has implicit full access.", + "userIdPlaceholder": "Search contacts or type ID...", + "scopePlaceholder": "Select or type scope...", + "types": { + "file_writer": "File Writer", + "file_writer_desc": "Controls who can edit files and manage cron in group chats. First user auto-added.", + "heartbeat": "Heartbeat", + "heartbeat_desc": "Controls who can configure heartbeat schedule and settings via chat.", + "cron": "Cron", + "cron_desc": "Controls who can create and manage scheduled cron jobs via chat.", + "context_files": "Context Files", + "context_files_desc": "Controls who can modify agent identity files (SOUL.md, etc.) via chat.", + "all": "All (*)", + "all_desc": "Full access to all configuration types." + }, + "scopes": { + "agent": "Agent (DM only)", + "group_all": "All Groups", + "global": "Global (all contexts)" + } }, "toast": { "created": "Agent created", diff --git a/ui/web/src/i18n/locales/vi/agents.json b/ui/web/src/i18n/locales/vi/agents.json index 7bcacc08..0e076195 100644 --- a/ui/web/src/i18n/locales/vi/agents.json +++ b/ui/web/src/i18n/locales/vi/agents.json @@ -40,6 +40,7 @@ "delete": { "title": "Xóa agent", "description": "Xóa vĩnh viễn agent này, tất cả tệp ngữ cảnh, session và cấu hình. Hành động này không thể hoàn tác.", + "deleteWarning": "Thao tác này sẽ xóa vĩnh viễn agent và TẤT CẢ dữ liệu liên quan bao gồm: phiên chat, tin nhắn, cron job, heartbeat, bộ nhớ, đồ thị tri thức, kỹ năng, cấu hình kênh, đội nhóm, file workspace và quyền hạn. Không thể hoàn tác.", "detailDescription": "Bạn có chắc chắn muốn xóa \"{{name}}\"? Tất cả tệp ngữ cảnh, session và cấu hình sẽ bị xóa vĩnh viễn.", "confirmLabel": "Xóa", "defaultCannotDelete": "Agent mặc định không thể bị xóa." @@ -470,16 +471,31 @@ "logsPagination": "{{from}}–{{to}} / {{total}}" }, "permissions": { - "title": "Phân quyền cấu hình", - "description": "Quản lý ai có thể thay đổi heartbeat, cron, context files qua chat.", - "scope": "Phạm vi", - "configType": "Loại cấu hình", - "userId": "ID người dùng", - "permission": "Quyền", - "allTypes": "Tất cả (*)", - "empty": "Chưa có quy tắc phân quyền. Chủ sở hữu agent có toàn quyền mặc định.", + "title": "Quyền hạn", + "description": "Quản lý ai được phép thay đổi cấu hình agent và file. Chủ sở hữu luôn có quyền đầy đủ.", "addRule": "Thêm quy tắc", - "userIdPlaceholder": "Tìm liên hệ hoặc nhập ID..." + "fileWriters": "Người viết file", + "configPerms": "Quyền cấu hình", + "noRules": "Chưa có quy tắc. Chủ sở hữu mặc định có đầy đủ quyền.", + "userIdPlaceholder": "Tìm liên hệ hoặc nhập ID...", + "scopePlaceholder": "Chọn hoặc nhập phạm vi...", + "types": { + "file_writer": "Quyền viết file", + "file_writer_desc": "Ai được chỉnh sửa file và quản lý cron trong nhóm chat. Người đầu tiên tự động được thêm.", + "heartbeat": "Heartbeat", + "heartbeat_desc": "Ai được cấu hình lịch heartbeat qua chat.", + "cron": "Cron", + "cron_desc": "Ai được tạo và quản lý cron job qua chat.", + "context_files": "File ngữ cảnh", + "context_files_desc": "Ai được chỉnh sửa file nhận dạng agent (SOUL.md, v.v.) qua chat.", + "all": "Tất cả (*)", + "all_desc": "Toàn quyền cho mọi loại cấu hình." + }, + "scopes": { + "agent": "Agent (riêng tư)", + "group_all": "Tất cả nhóm", + "global": "Toàn cục" + } }, "toast": { "created": "Đã tạo agent", diff --git a/ui/web/src/i18n/locales/zh/agents.json b/ui/web/src/i18n/locales/zh/agents.json index fef1a2a5..315c4fdf 100644 --- a/ui/web/src/i18n/locales/zh/agents.json +++ b/ui/web/src/i18n/locales/zh/agents.json @@ -40,6 +40,7 @@ "delete": { "title": "删除Agent", "description": "永久删除此Agent及其所有上下文文件、Session和配置。此操作无法撤销。", + "deleteWarning": "此操作将永久删除代理及所有相关数据,包括:会话、消息、定时任务、心跳检测、记忆、知识图谱、技能、渠道配置、团队、工作区文件和权限。此操作不可撤销。", "detailDescription": "确定要删除「{{name}}」吗?所有上下文文件、Session和配置将被永久删除。", "confirmLabel": "删除", "defaultCannotDelete": "默认Agent无法删除。" @@ -470,16 +471,31 @@ "logsPagination": "{{from}}–{{to}} / 共 {{total}}" }, "permissions": { - "title": "配置权限", - "description": "控制谁可以通过聊天修改心跳、定时任务和上下文文件。", - "scope": "范围", - "configType": "配置类型", - "userId": "用户 ID", - "permission": "权限", - "allTypes": "全部 (*)", - "empty": "未配置权限规则。代理所有者默认拥有完全权限。", + "title": "权限管理", + "description": "控制谁可以修改代理配置和文件。所有者始终拥有完全访问权限。", "addRule": "添加规则", - "userIdPlaceholder": "搜索联系人或输入 ID..." + "fileWriters": "文件编辑者", + "configPerms": "配置权限", + "noRules": "暂无权限规则。所有者默认拥有完全访问权限。", + "userIdPlaceholder": "搜索联系人或输入ID...", + "scopePlaceholder": "选择或输入范围...", + "types": { + "file_writer": "文件编辑", + "file_writer_desc": "控制谁可以在群聊中编辑文件和管理定时任务。首位用户自动添加。", + "heartbeat": "心跳检测", + "heartbeat_desc": "控制谁可以通过聊天配置心跳检测计划和设置。", + "cron": "定时任务", + "cron_desc": "控制谁可以通过聊天创建和管理定时任务。", + "context_files": "上下文文件", + "context_files_desc": "控制谁可以通过聊天修改代理身份文件(SOUL.md等)。", + "all": "全部 (*)", + "all_desc": "所有配置类型的完全访问权限。" + }, + "scopes": { + "agent": "代理(私聊)", + "group_all": "所有群组", + "global": "全局" + } }, "toast": { "created": "代理已创建", diff --git a/ui/web/src/pages/agents/agent-detail/agent-permissions-tab.tsx b/ui/web/src/pages/agents/agent-detail/agent-permissions-tab.tsx index 8c9fe3f8..10b96003 100644 --- a/ui/web/src/pages/agents/agent-detail/agent-permissions-tab.tsx +++ b/ui/web/src/pages/agents/agent-detail/agent-permissions-tab.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo } from "react"; -import { Plus, Trash2, Loader2, Shield } from "lucide-react"; +import { Plus, Trash2, Loader2, Shield, FolderOpen, RefreshCw } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -7,20 +7,33 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; -import { useConfigPermissions } from "../hooks/use-config-permissions"; +import { useConfigPermissions, type ConfigPermission } from "../hooks/use-config-permissions"; import { useContactSearch } from "../hooks/use-contact-search"; +import type { ChannelContact } from "@/types/contact"; const CONFIG_TYPES = [ - { value: "heartbeat", label: "Heartbeat" }, - { value: "cron", label: "Cron" }, - { value: "context_files", label: "Context Files" }, - { value: "*", label: "All (*)" }, -] as const; + { value: "file_writer", label: "File Writer", descKey: "permissions.types.file_writer_desc" }, + { value: "heartbeat", label: "Heartbeat", descKey: "permissions.types.heartbeat_desc" }, + { value: "cron", label: "Cron", descKey: "permissions.types.cron_desc" }, + { value: "context_files", label: "Context Files", descKey: "permissions.types.context_files_desc" }, + { value: "*", label: "All (*)", descKey: "permissions.types.all_desc" }, +]; -const SCOPES = [ - { value: "agent", label: "Agent" }, - { value: "*", label: "Global (*)" }, -] as const; +function getScopeOptions(configType: string, existingScopes: string[]): ComboboxOption[] { + if (configType === "file_writer") { + const dynamic = existingScopes.map((s) => ({ value: s, label: s })); + return [ + { value: "group:*", label: "All Groups" }, + ...dynamic, + { value: "*", label: "Global (*)" }, + ]; + } + return [ + { value: "agent", label: "Agent (DM)" }, + { value: "group:*", label: "All Groups" }, + { value: "*", label: "Global (*)" }, + ]; +} interface AgentPermissionsTabProps { agentId: string; @@ -31,120 +44,257 @@ export function AgentPermissionsTab({ agentId }: AgentPermissionsTabProps) { const { permissions, loading, load, grant, revoke } = useConfigPermissions(agentId); const [userId, setUserId] = useState(""); - const [configType, setConfigType] = useState("heartbeat"); - const [scope, setScope] = useState("agent"); + const [configType, setConfigType] = useState("file_writer"); + const [scope, setScope] = useState("group:*"); const [permission, setPermission] = useState("allow"); const [adding, setAdding] = useState(false); + const [selectedContact, setSelectedContact] = useState(null); const { contacts } = useContactSearch(userId); + const contactOptions: ComboboxOption[] = useMemo(() => contacts.map((c) => { const name = c.display_name || c.sender_id; const username = c.username ? ` @${c.username}` : ""; const channel = c.channel_type ? ` [${c.channel_type}]` : ""; - return { - value: c.sender_id, - label: `${name}${username} (${c.sender_id})${channel}`, - }; + return { value: c.sender_id, label: `${name}${username} (${c.sender_id})${channel}` }; }), [contacts], ); + // Collect existing file_writer scopes for dynamic scope options + const existingFileWriterScopes = useMemo(() => + [...new Set( + permissions + .filter((p) => p.configType === "file_writer") + .map((p) => p.scope) + )], + [permissions], + ); + + const scopeOptions = useMemo( + () => getScopeOptions(configType, existingFileWriterScopes), + [configType, existingFileWriterScopes], + ); + + // Reset scope when configType changes + useEffect(() => { + if (configType === "file_writer") { + setScope("group:*"); + } else { + setScope("agent"); + } + }, [configType]); + useEffect(() => { load(); }, [load]); + const handleUserChange = (val: string) => { + setUserId(val); + const contact = contacts.find((c) => c.sender_id === val); + setSelectedContact(contact ?? null); + }; + const handleAdd = async () => { if (!userId.trim()) return; setAdding(true); - await grant(scope, configType, userId.trim(), permission); + const meta = + configType === "file_writer" && selectedContact + ? { + displayName: selectedContact.display_name ?? "", + username: selectedContact.username ?? "", + } + : undefined; + await grant(scope, configType, userId.trim(), permission, meta); setUserId(""); + setSelectedContact(null); setAdding(false); }; - return ( -
-
-

- - {t("permissions.title")} -

-

{t("permissions.description")}

-
+ // Split permissions into two sections + const fileWriters = useMemo( + () => permissions.filter((p) => p.configType === "file_writer"), + [permissions], + ); + const configPerms = useMemo( + () => permissions.filter((p) => p.configType !== "file_writer"), + [permissions], + ); - {/* Inline add row */} -
- - - - -
+ {/* Add Rule form */} +
+
+ + + + + +
+ {currentDescKey && ( +

{t(currentDescKey)}

+ )} +
+ {/* Rules list */} - {loading ? ( + {loading && permissions.length === 0 ? (
) : permissions.length === 0 ? ( -

{t("permissions.empty")}

+

{t("permissions.noRules")}

) : ( -
- {permissions.map((p) => ( -
-
- - {p.permission} - - {p.userId} - {p.configType} - @ {p.scope} +
+ {/* File Writers section */} + {fileWriters.length > 0 && ( +
+

+ {t("permissions.fileWriters")} ({fileWriters.length}) +

+
+ {[...fileWritersByScope.entries()].map(([scopeKey, writers]) => ( +
+
+ + {scopeKey} +
+ {writers.map((p) => { + const displayName = p.metadata?.displayName || p.userId; + const username = p.metadata?.username ? ` @${p.metadata.username}` : ""; + return ( +
+
+ + {p.permission} + + {displayName} + {username && ( + {username} + )} + ({p.userId}) +
+ +
+ ); + })} +
+ ))}
-
- ))} + )} + + {/* Config Permissions section */} + {configPerms.length > 0 && ( +
+

+ {t("permissions.configPerms")} ({configPerms.length}) +

+
+ {configPerms.map((p) => ( +
+
+ + {p.permission} + + {p.userId} + {p.configType} + @ {p.scope} +
+ +
+ ))} +
+
+ )}
)}
diff --git a/ui/web/src/pages/agents/hooks/use-config-permissions.ts b/ui/web/src/pages/agents/hooks/use-config-permissions.ts index 2adb09fa..06a1d3c6 100644 --- a/ui/web/src/pages/agents/hooks/use-config-permissions.ts +++ b/ui/web/src/pages/agents/hooks/use-config-permissions.ts @@ -11,6 +11,7 @@ export interface ConfigPermission { userId: string; permission: string; // "allow" | "deny" grantedBy?: string; + metadata?: Record; // {displayName, username} createdAt: string; updatedAt: string; } @@ -37,11 +38,11 @@ export function useConfigPermissions(agentId: string | undefined) { }, [ws, agentId]); const grant = useCallback( - async (scope: string, configType: string, userId: string, permission: string) => { + async (scope: string, configType: string, userId: string, permission: string, metadata?: Record) => { if (!agentId) return; try { await ws.call(Methods.CONFIG_PERMISSIONS_GRANT, { - agentId, scope, configType, userId, permission, + agentId, scope, configType, userId, permission, metadata, }); toast.success("Permission granted"); await load();