From 4d10e3e0cad3af606d9d931a26ed8a122ee8b94e Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sun, 19 Jul 2026 21:42:33 +0800 Subject: [PATCH 01/11] fix admin permissions module and roadmap --- docs/optimization-roadmap.md | 18 +- .../admin/PermissionModule.test.tsx | 58 ++++ frontend/__tests__/pages/AdminPage.test.tsx | 281 ++++++++++----- frontend/app/admin/page.tsx | 9 + .../components/admin/PermissionModule.tsx | 324 ++++++++++++++++++ 5 files changed, 604 insertions(+), 86 deletions(-) create mode 100644 frontend/__tests__/components/admin/PermissionModule.test.tsx create mode 100644 frontend/components/admin/PermissionModule.tsx diff --git a/docs/optimization-roadmap.md b/docs/optimization-roadmap.md index af1c394..2897f90 100644 --- a/docs/optimization-roadmap.md +++ b/docs/optimization-roadmap.md @@ -7,6 +7,9 @@ AgentHub is already past the "chat wrapper" stage. The current codebase has a us What is now true in the repository: - `frontend/app/page.tsx` is already thinner than before and now delegates message recovery, WebSocket URL building, and DAG state to helpers. +- `frontend/hooks/useSessionWebSocket.ts` and `frontend/hooks/useSessionRecovery.ts` now carry the connection lifecycle and reconnect / restore logic out of the page shell. +- `frontend/__tests__/components/chat/taskPreviewReplay.test.tsx` and `frontend/__tests__/components/chat/dagReplay.test.tsx` cover duplicate preview events, reconnect replay, and session switching. +- `frontend/components/admin/PermissionModule.tsx` now replaces the stale `权限` placeholder with a real permission-rule surface backed by `/api/admin/permissions/rules`. - Prompt/token control has started through `app/services/context_compaction.py`, `app/services/conversation_history.py`, `app/services/orchestrator_preprocessor.py`, `app/services/task_decomposer.py`, and `app/services/result_synthesizer.py`. - `app/api/websocket_message_flow.py` and `app/api/websocket.py` now share compact task preview construction. - The repo already carries the architecture needed for enterprise expansion, but it still has a few oversized hot modules. @@ -147,7 +150,14 @@ Success criteria: - Teams can manage tokens and permissions from the platform. - External developers can integrate without reading internal code first. -### 4.4 Long term +### 4.4 Near-term execution order + +1. Finish permission-rule CRUD and validation in the new admin module. +2. Keep thinning the last frontend coupling in message recovery and DAG replay. +3. Add route / agent pre-summary caches and shrink preview payloads one more layer. +4. Only after those are stable, move to DAG editor, template market, SDKs, and token management. + +### 4.5 Long term Focus: @@ -249,6 +259,11 @@ These are the concrete foundation pieces now in the repo: - `frontend/lib/messageRecovery.ts` - `frontend/lib/websocketUrl.ts` - `frontend/lib/outgoingMessageDraft.ts` +- `frontend/hooks/useSessionWebSocket.ts` +- `frontend/hooks/useSessionRecovery.ts` +- `frontend/components/admin/PermissionModule.tsx` +- `frontend/__tests__/components/chat/taskPreviewReplay.test.tsx` +- `frontend/__tests__/components/chat/dagReplay.test.tsx` ## 7. Rule of Thumb @@ -258,4 +273,3 @@ Do not expand feature surface until the core loop stays: - replayable, - observable, - and cheap enough to run repeatedly. - diff --git a/frontend/__tests__/components/admin/PermissionModule.test.tsx b/frontend/__tests__/components/admin/PermissionModule.test.tsx new file mode 100644 index 0000000..b63d870 --- /dev/null +++ b/frontend/__tests__/components/admin/PermissionModule.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import PermissionModule from '../../../components/admin/PermissionModule'; + +describe('PermissionModule', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('loads permission rules from the admin API instead of showing the placeholder', async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ([ + { + id: 1, + agentId: '*', + toolPattern: 'file_*', + pathPattern: '/workspace/**', + behavior: 'ask', + source: 'user', + priority: 10, + enabled: true, + createdAt: '2026-07-19T10:00:00', + }, + { + id: 2, + agentId: 'operator', + toolPattern: 'shell', + pathPattern: '*', + behavior: 'deny', + source: 'system', + priority: 20, + enabled: false, + createdAt: '2026-07-19T10:05:00', + }, + ]), + })); + vi.stubGlobal('fetch', fetchMock); + + render( + ({ Authorization: 'Bearer test' })} + setNotice={vi.fn()} + /> + ); + + expect(await screen.findByText('权限规则中心')).toBeInTheDocument(); + expect(await screen.findByText('file_*')).toBeInTheDocument(); + expect(screen.getByText('shell')).toBeInTheDocument(); + expect(screen.queryByText('该模块已独立,等待配置项接入。')).not.toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith('/api/admin/permissions/rules', expect.any(Object)); + }); +}); diff --git a/frontend/__tests__/pages/AdminPage.test.tsx b/frontend/__tests__/pages/AdminPage.test.tsx index 35cf929..17630a6 100644 --- a/frontend/__tests__/pages/AdminPage.test.tsx +++ b/frontend/__tests__/pages/AdminPage.test.tsx @@ -2,9 +2,15 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, act } from '@testing-library/react'; import React from 'react'; -// ── All mock setup uses vi.hoisted() for vitest's hoist mechanism ────── - -const { createMockStore, mockAuthState, mockAdminState, mockAgentState, mockMemoryStoreState, mockUserMgmtState, mockWorkflowState } = vi.hoisted(() => { +const { + createMockStore, + mockAuthState, + mockAdminState, + mockAgentState, + mockMemoryStoreState, + mockUserMgmtState, + mockWorkflowState, +} = vi.hoisted(() => { const createMockStore = (defaultState: Record) => { return vi.fn((selector?: (s: unknown) => unknown) => { if (typeof selector === 'function') return selector(defaultState); @@ -19,111 +25,200 @@ const { createMockStore, mockAuthState, mockAdminState, mockAgentState, mockMemo setUser: mockVoid, setToken: mockVoid, authHeaders: vi.fn(() => ({ Authorization: 'Bearer test' })), - fmtErr: vi.fn((d: unknown, f: string) => (typeof d === 'string' ? d : f)), + fmtErr: vi.fn((detail: unknown, fallback: string) => (typeof detail === 'string' ? detail : fallback)), }; const mockAdminState = { - activeMenu: '服务商', + activeMenu: '\u670d\u52a1\u5546', notice: '', setActiveMenu: mockVoid, setNotice: mockVoid, }; - const mockAgentState = { - agents: [], agentTests: {}, adapterOptions: [], - selectedAdapterInfo: null, editSelectedAdapterInfo: null, - defaultChatAgent: 'Orchestrator', isCreatingAgent: false, - showLocalAgentModal: false, editingAgentId: null, - newAgent: { - agentId: '', domain: '', adapterType: 'deepseek', baseModelName: '', - rankLevel: 'L1', dutyNote: '', displayName: '', avatarUrl: '', - capabilityTags: [], baseUrl: '', apiKey: '', - systemPrompt: '', userPrompt: '', assistantPrompt: '', - promptVariables: {}, - publicConfig: { enabled: false, welcomeMessage: '', placeholder: '', themeColor: '#6366f1', logoUrl: '', suggestedQuestions: [] }, + const emptyAgent = { + agentId: '', + domain: '', + adapterType: 'deepseek', + baseModelName: '', + rankLevel: 'L1', + dutyNote: '', + displayName: '', + avatarUrl: '', + capabilityTags: [], + baseUrl: '', + apiKey: '', + systemPrompt: '', + userPrompt: '', + assistantPrompt: '', + promptVariables: {}, + publicConfig: { + enabled: false, + welcomeMessage: '', + placeholder: '', + themeColor: '#6366f1', + logoUrl: '', + suggestedQuestions: [], }, - editAgent: { - agentId: '', domain: '', adapterType: 'deepseek', baseModelName: '', - rankLevel: 'L1', dutyNote: '', displayName: '', avatarUrl: '', - capabilityTags: [], baseUrl: '', apiKey: '', - systemPrompt: '', userPrompt: '', assistantPrompt: '', - promptVariables: {}, - publicConfig: { enabled: false, welcomeMessage: '', placeholder: '', themeColor: '#6366f1', logoUrl: '', suggestedQuestions: [] }, - }, - fetchAdapters: mockVoid, refresh: mockVoid, createAgent: mockVoid, - testAgent: mockVoid, removeAgent: mockVoid, startEditAgent: mockVoid, - cancelEditAgent: mockVoid, saveAgentEdit: mockVoid, - handleSetDefaultChatAgent: mockVoid, handleAdapterChange: mockVoid, - setNewAgent: mockVoid, setEditAgent: mockVoid, - setSelectedAdapterInfo: mockVoid, setEditSelectedAdapterInfo: mockVoid, - setIsCreatingAgent: mockVoid, setShowLocalAgentModal: mockVoid, + }; + + const mockAgentState = { + agents: [], + agentTests: {}, + adapterOptions: [], + selectedAdapterInfo: null, + editSelectedAdapterInfo: null, + defaultChatAgent: 'Orchestrator', + isCreatingAgent: false, + showLocalAgentModal: false, + editingAgentId: null, + newAgent: emptyAgent, + editAgent: emptyAgent, + fetchAdapters: mockVoid, + refresh: mockVoid, + createAgent: mockVoid, + testAgent: mockVoid, + removeAgent: mockVoid, + startEditAgent: mockVoid, + cancelEditAgent: mockVoid, + saveAgentEdit: mockVoid, + handleSetDefaultChatAgent: mockVoid, + handleAdapterChange: mockVoid, + setNewAgent: mockVoid, + setEditAgent: mockVoid, + setSelectedAdapterInfo: mockVoid, + setEditSelectedAdapterInfo: mockVoid, + setIsCreatingAgent: mockVoid, + setShowLocalAgentModal: mockVoid, setEditingAgentId: mockVoid, }; const mockMemoryStoreState = { - init: mockVoid, loadMemoryFiles: mockVoid, loadMemoryDetail: mockVoid, - saveMemoryDetail: mockVoid, setMemoryKeyword: mockVoid, - setActiveMemoryFile: mockVoid, setMemoryBodyDraft: mockVoid, - setMemoryDirty: mockVoid, setMemoryPreview: mockVoid, - setMemorySubTab: mockVoid, setShowTrash: mockVoid, - setShowDeleteConfirm: mockVoid, setPendingDeleteFile: mockVoid, - setConsolidationDryRun: mockVoid, setMemorySearchQuery: mockVoid, - setMemorySearchResults: mockVoid, handleExportMemory: mockVoid, - handleImportMemory: mockVoid, confirmDeleteMemory: mockVoid, - handleDeleteMemory: mockVoid, loadTrash: mockVoid, - handleRecoverFromTrash: mockVoid, handlePurgeFromTrash: mockVoid, - loadSessionSummaries: mockVoid, loadSessionDetail: mockVoid, - loadGlobalSummary: mockVoid, refreshGlobalSummary: mockVoid, - runConsolidation: mockVoid, runMemorySearch: mockVoid, + init: mockVoid, + loadMemoryFiles: mockVoid, + loadMemoryDetail: mockVoid, + saveMemoryDetail: mockVoid, + setMemoryKeyword: mockVoid, + setActiveMemoryFile: mockVoid, + setMemoryBodyDraft: mockVoid, + setMemoryDirty: mockVoid, + setMemoryPreview: mockVoid, + setMemorySubTab: mockVoid, + setShowTrash: mockVoid, + setShowDeleteConfirm: mockVoid, + setPendingDeleteFile: mockVoid, + setConsolidationDryRun: mockVoid, + setMemorySearchQuery: mockVoid, + setMemorySearchResults: mockVoid, + handleExportMemory: mockVoid, + handleImportMemory: mockVoid, + confirmDeleteMemory: mockVoid, + handleDeleteMemory: mockVoid, + loadTrash: mockVoid, + handleRecoverFromTrash: mockVoid, + handlePurgeFromTrash: mockVoid, + loadSessionSummaries: mockVoid, + loadSessionDetail: mockVoid, + loadGlobalSummary: mockVoid, + refreshGlobalSummary: mockVoid, + runConsolidation: mockVoid, + runMemorySearch: mockVoid, getFilteredMemoryFiles: vi.fn(() => []), - loadSessionMemoryList: mockVoid, loadSessionMemoryConversation: mockVoid, - consolidateSessionMemory: mockVoid, createMemorySession: mockVoid, + loadSessionMemoryList: mockVoid, + loadSessionMemoryConversation: mockVoid, + consolidateSessionMemory: mockVoid, + createMemorySession: mockVoid, updateSessionTopic: mockVoid, - memoryLoading: false, memoryError: null, memoryKeyword: '', - memoryFiles: [], activeMemoryFile: null, memoryDetail: null, - memoryBodyDraft: '', memoryDirty: false, memoryPreview: null, - memorySubTab: 'files', sessionList: [], sessionsLoading: false, - activeSessionId: null, activeSessionSummary: null, - globalSummary: null, globalSummaryLoading: false, - consolidationLoading: false, consolidationResult: null, - consolidationError: null, consolidationDryRun: false, - memorySearchQuery: '', memorySearchResults: null, - memorySearchLoading: false, showTrash: false, trashItems: [], - trashLoading: false, showDeleteConfirm: false, pendingDeleteFile: null, - sessionMemoryList: [], sessionMemoryLoading: false, - activeSessionMemoryId: null, sessionMemoryConversation: [], + memoryLoading: false, + memoryError: null, + memoryKeyword: '', + memoryFiles: [], + activeMemoryFile: null, + memoryDetail: null, + memoryBodyDraft: '', + memoryDirty: false, + memoryPreview: null, + memorySubTab: 'files', + sessionList: [], + sessionsLoading: false, + activeSessionId: null, + activeSessionSummary: null, + globalSummary: null, + globalSummaryLoading: false, + consolidationLoading: false, + consolidationResult: null, + consolidationError: null, + consolidationDryRun: false, + memorySearchQuery: '', + memorySearchResults: null, + memorySearchLoading: false, + showTrash: false, + trashItems: [], + trashLoading: false, + showDeleteConfirm: false, + pendingDeleteFile: null, + sessionMemoryList: [], + sessionMemoryLoading: false, + activeSessionMemoryId: null, + sessionMemoryConversation: [], sessionMemoryConversationLoading: false, }; const mockUserMgmtState = { - ...mockMemoryStoreState, - tokenData: null, tokenLoading: false, tokenError: null, - profileBio: '', profileEditingField: null, profileFieldDraft: '', - profileLocation: '', profileEmail: '', profileOrg: '', - profileAvatarUrl: '', profileUploading: false, - userList: [], userListLoading: false, userListError: null, - newUserName: '', newUserPassword: '', newUserRole: 'user', + tokenData: null, + tokenLoading: false, + tokenError: '', + profileBio: '', + profileEditingField: null, + profileFieldDraft: '', + profileLocation: '', + profileEmail: '', + profileOrg: '', + profileAvatarUrl: '', + profileUploading: false, + userList: [], + userListLoading: false, + userListError: '', + newUserName: '', + newUserPassword: '', + newUserRole: 'developer', creatingUser: false, - setProfileEditingField: mockVoid, setProfileFieldDraft: mockVoid, - setNewUserName: mockVoid, setNewUserPassword: mockVoid, + setProfileEditingField: mockVoid, + setProfileFieldDraft: mockVoid, + setNewUserName: mockVoid, + setNewUserPassword: mockVoid, setNewUserRole: mockVoid, - handleStartEditField: mockVoid, handleSaveField: mockVoid, - handleCancelEditField: mockVoid, handleUploadProfileAvatar: mockVoid, - handleCreateUser: mockVoid, handleChangeUserRole: mockVoid, - handleDeleteUser: mockVoid, loadTokenUsage: mockVoid, + handleStartEditField: mockVoid, + handleSaveField: mockVoid, + handleCancelEditField: mockVoid, + handleUploadProfileAvatar: mockVoid, + handleCreateUser: mockVoid, + handleChangeUserRole: mockVoid, + handleDeleteUser: mockVoid, + loadTokenUsage: mockVoid, + init: mockVoid, }; const mockWorkflowState = { - loading: false, error: null, workflows: [], - loadWorkflows: mockVoid, deleteWorkflow: mockVoid, - setDefault: mockVoid, toggleActive: mockVoid, + loading: false, + error: null, + workflows: [], + loadWorkflows: mockVoid, + deleteWorkflow: mockVoid, + setDefault: mockVoid, + toggleActive: mockVoid, }; - return { createMockStore, mockAuthState, mockAdminState, mockAgentState, mockMemoryStoreState, mockUserMgmtState, mockWorkflowState }; + return { + createMockStore, + mockAuthState, + mockAdminState, + mockAgentState, + mockMemoryStoreState, + mockUserMgmtState, + mockWorkflowState, + }; }); -// ── vi.mock calls (hoisted above imports) ────────────────────────────── - vi.mock('next/navigation', () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), back: vi.fn() }), useSearchParams: () => ({ get: vi.fn(() => null), has: vi.fn(() => false), toString: vi.fn(() => '') }), @@ -132,8 +227,7 @@ vi.mock('next/navigation', () => ({ })); vi.mock('next/dynamic', () => ({ - default: (_importFn: () => Promise, _opts?: unknown) => { - // Always return a simple placeholder — the real modules are too heavy for smoke tests. + default: () => { const Placeholder = () => React.createElement('div', { 'data-testid': 'dynamic-module' }, 'Module loaded'); Placeholder.displayName = 'DynamicMock'; return Placeholder; @@ -146,7 +240,14 @@ vi.mock('../../stores/authStore', () => ({ vi.mock('../../stores/adminStore', () => ({ useAdminStore: createMockStore(mockAdminState), - SETTINGS_MENU: ['服务商', '记忆', '技能', '通用', '审计日志', '用户管理', 'IM 接入', 'MCP', '工作流', '知识库', '模板市场', '工具市场', '工作空间', '上下文引擎', 'AgentNet', 'Agent 身份', 'Docker 沙箱', '多模态工作区', '集中日志', '模块连线', 'RAG 检索', '检索评估', 'A2A 互操作', 'A/B 测试', '成本分析', 'SLO 仪表板', '离线评估'], + SETTINGS_MENU: [ + '\u670d\u52a1\u5546', + '\u5de5\u4f5c\u6d41', + '\u6743\u9650', + '\u901a\u7528', + '\u8bb0\u5fc6', + '\u7528\u6237\u7ba1\u7406', + ], })); vi.mock('../../stores/agentStore', () => ({ @@ -165,13 +266,14 @@ vi.mock('../../stores/workflowStore', () => ({ useWorkflowStore: createMockStore(mockWorkflowState), })); -// Import page AFTER all mocks import AdminPage from '../../app/admin/page'; describe('AdminPage', () => { beforeEach(() => { vi.clearAllMocks(); window.localStorage.clear(); + mockAdminState.activeMenu = '\u670d\u52a1\u5546'; + mockAdminState.notice = ''; }); it('renders without crashing', async () => { @@ -187,4 +289,15 @@ describe('AdminPage', () => { }); expect(screen.queryByText(/warning/i)).toBeNull(); }); + + it('routes the permissions menu to a real module', async () => { + mockAdminState.activeMenu = '\u6743\u9650'; + + await act(async () => { + render(); + }); + + expect(screen.getByTestId('dynamic-module')).toBeInTheDocument(); + expect(screen.queryByText(/\u7b49\u5f85\u914d\u7f6e\u9879\u63a5\u5165/)).toBeNull(); + }); }); diff --git a/frontend/app/admin/page.tsx b/frontend/app/admin/page.tsx index 584b8be..660decf 100644 --- a/frontend/app/admin/page.tsx +++ b/frontend/app/admin/page.tsx @@ -40,6 +40,13 @@ const UserManagementModule = dynamic(() => import('../../components/admin/UserMa ), }); +const PermissionModule = dynamic(() => import('../../components/admin/PermissionModule'), { + ssr: false, loading: () => ( +
+
+
+ ), +}); const ServiceProviderModule = dynamic(() => import('../../components/admin/ServiceProviderModule'), { ssr: false, loading: () => (
@@ -463,6 +470,8 @@ export default function AdminPage(): JSX.Element { ); case '技能': return ; + case '权限': + return ; case '通用': return ; case '审计日志': diff --git a/frontend/components/admin/PermissionModule.tsx b/frontend/components/admin/PermissionModule.tsx new file mode 100644 index 0000000..248128d --- /dev/null +++ b/frontend/components/admin/PermissionModule.tsx @@ -0,0 +1,324 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState, type FormEvent, type JSX } from 'react'; +import { Plus, RefreshCw, ShieldAlert, ShieldCheck, ShieldEllipsis, Trash2, ToggleLeft, ToggleRight } from 'lucide-react'; +import type { PermissionRule } from '../../types'; + +interface PermissionModuleProps { + authHeaders: () => Record; + setNotice: (msg: string) => void; + fmtErr?: (detail: unknown, fallback: string) => string; +} + +type RuleDraft = { + agentId: string; + toolPattern: string; + pathPattern: string; + behavior: PermissionRule['behavior']; + priority: string; +}; + +type RuleInput = Partial & Record; + +const BEHAVIOR_META: Record = { + allow: { + label: '允许', + cls: 'bg-success-50 text-success-700 border-success-200', + icon: , + }, + deny: { + label: '拒绝', + cls: 'bg-danger-50 text-danger-700 border-danger-200', + icon: , + }, + ask: { + label: '确认', + cls: 'bg-warning-50 text-warning-700 border-warning-200', + icon: , + }, +}; + +function normalizeRule(rule: RuleInput): PermissionRule { + return { + id: Number(rule.id ?? 0), + agentId: String(rule.agentId ?? rule.agent_id ?? '*'), + toolPattern: String(rule.toolPattern ?? rule.tool_pattern ?? '*'), + pathPattern: String(rule.pathPattern ?? rule.path_pattern ?? '*'), + behavior: (String(rule.behavior ?? 'ask') as PermissionRule['behavior']), + source: String(rule.source ?? 'user'), + priority: Number(rule.priority ?? 0), + enabled: rule.enabled === undefined ? true : Boolean(rule.enabled), + createdAt: String(rule.createdAt ?? rule.created_at ?? ''), + }; +} + +function formatDate(value: string): string { + if (!value) return '-'; + const parsed = new Date(value); + return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString('zh-CN'); +} + +function formatAgent(agentId: string): string { + return agentId === '*' ? '全局' : agentId; +} + +export default function PermissionModule({ authHeaders, setNotice, fmtErr }: PermissionModuleProps): JSX.Element { + const [rules, setRules] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [savingId, setSavingId] = useState(null); + const [draft, setDraft] = useState({ + agentId: '*', + toolPattern: '*', + pathPattern: '*', + behavior: 'ask', + priority: '0', + }); + + const loadRules = useCallback(async () => { + setLoading(true); + setError(''); + try { + const res = await fetch('/api/admin/permissions/rules', { headers: { ...authHeaders() } }); + const data = await res.json(); + if (!res.ok) throw new Error((data && (data.detail || data.message)) || `HTTP ${res.status}`); + const list = Array.isArray(data) ? data : Array.isArray(data.rules) ? data.rules : []; + setRules(list.map((item: RuleInput) => normalizeRule(item))); + } catch (err) { + const message = fmtErr?.(err, '权限规则加载失败') ?? (err instanceof Error ? err.message : '权限规则加载失败'); + setError(message); + } finally { + setLoading(false); + } + }, [authHeaders, fmtErr]); + + useEffect(() => { + void loadRules(); + }, [loadRules]); + + const summary = useMemo(() => { + const enabled = rules.filter((rule) => rule.enabled); + return { + total: rules.length, + enabled: enabled.length, + allow: rules.filter((rule) => rule.behavior === 'allow').length, + deny: rules.filter((rule) => rule.behavior === 'deny').length, + ask: rules.filter((rule) => rule.behavior === 'ask').length, + }; + }, [rules]); + + const mutateRule = useCallback(async (ruleId: number, payload: Record) => { + setSavingId(ruleId); + setError(''); + try { + const res = await fetch(`/api/admin/permissions/rules/${ruleId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => null); + if (!res.ok) throw new Error((data && (data.detail || data.message)) || `HTTP ${res.status}`); + await loadRules(); + setNotice('权限规则已更新'); + } catch (err) { + setError(fmtErr?.(err, '更新权限规则失败') ?? (err instanceof Error ? err.message : '更新权限规则失败')); + } finally { + setSavingId(null); + } + }, [authHeaders, fmtErr, loadRules, setNotice]); + + const handleCreateRule = useCallback(async (event: FormEvent) => { + event.preventDefault(); + setError(''); + try { + const res = await fetch('/api/admin/permissions/rules', { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ + agent_id: draft.agentId.trim() || '*', + tool_pattern: draft.toolPattern.trim() || '*', + path_pattern: draft.pathPattern.trim() || '*', + behavior: draft.behavior, + priority: Number(draft.priority) || 0, + }), + }); + const data = await res.json().catch(() => null); + if (!res.ok) throw new Error((data && (data.detail || data.message)) || `HTTP ${res.status}`); + setDraft({ agentId: '*', toolPattern: '*', pathPattern: '*', behavior: 'ask', priority: '0' }); + await loadRules(); + setNotice('权限规则已创建'); + } catch (err) { + setError(fmtErr?.(err, '创建权限规则失败') ?? (err instanceof Error ? err.message : '创建权限规则失败')); + } + }, [authHeaders, draft, fmtErr, loadRules, setNotice]); + + const handleToggleEnabled = useCallback(async (rule: PermissionRule) => { + await mutateRule(rule.id, { enabled: !rule.enabled }); + }, [mutateRule]); + + const handleDelete = useCallback(async (rule: PermissionRule) => { + if (typeof window !== 'undefined' && !window.confirm(`删除权限规则 ${rule.toolPattern}?`)) return; + setSavingId(rule.id); + setError(''); + try { + const res = await fetch(`/api/admin/permissions/rules/${rule.id}`, { + method: 'DELETE', + headers: { ...authHeaders() }, + }); + const data = await res.json().catch(() => null); + if (!res.ok) throw new Error((data && (data.detail || data.message)) || `HTTP ${res.status}`); + await loadRules(); + setNotice('权限规则已删除'); + } catch (err) { + setError(fmtErr?.(err, '删除权限规则失败') ?? (err instanceof Error ? err.message : '删除权限规则失败')); + } finally { + setSavingId(null); + } + }, [authHeaders, fmtErr, loadRules, setNotice]); + + return ( +
+
+ + + + +
+ +
+
+
+

权限规则中心

+

接入后端 `/api/admin/permissions/rules`,用于管理工具执行的 allow / deny / ask 规则。

+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
{ void handleCreateRule(e); }}> + + + + +
+ + +
+
+ +
+ {loading ? ( +
正在加载权限规则...
+ ) : rules.length === 0 ? ( +
暂无权限规则。可以先添加一条 allow / ask / deny 规则。
+ ) : ( + + + + + + + + + + + + + + + + {rules.map((rule) => { + const behavior = BEHAVIOR_META[rule.behavior] || BEHAVIOR_META.ask; + const isBusy = savingId === rule.id; + return ( + + + + + + + + + + + + ); + })} + +
Agent工具路径动作优先级来源状态创建时间操作
{formatAgent(rule.agentId)}{rule.toolPattern}{rule.pathPattern} + + {behavior.icon} + {behavior.label} + + {rule.priority}{rule.source} + + {formatDate(rule.createdAt)} + +
+ )} +
+
+
+ ); +} + +function SummaryCard({ label, value, tone }: { label: string; value: number | string; tone: 'primary' | 'success' | 'warm' }): JSX.Element { + const toneClass: Record<'primary' | 'success' | 'warm', string> = { + primary: 'border-primary-100 bg-primary-50 text-primary-700', + success: 'border-success-100 bg-success-50 text-success-700', + warm: 'border-warm-200 bg-warm-50 text-warm-700', + }; + + return ( +
+
{label}
+
{value}
+
+ ); +} From 9b536783aa3e8901f3544ea21332f73451a970f5 Mon Sep 17 00:00:00 2001 From: Nat-xu <19707027371@163.com> Date: Sun, 19 Jul 2026 22:19:31 +0800 Subject: [PATCH 02/11] fix admin responsive layout and permission rules --- docs/optimization-roadmap.md | 4 + .../admin/PermissionModule.test.tsx | 78 ++++++++++- frontend/app/admin/layout.tsx | 14 +- .../components/admin/PermissionModule.tsx | 126 ++++++++++++++---- .../admin/ServiceProviderModule.tsx | 12 +- frontend/styles/globals.css | 56 +++++++- 6 files changed, 244 insertions(+), 46 deletions(-) diff --git a/docs/optimization-roadmap.md b/docs/optimization-roadmap.md index 2897f90..592dc92 100644 --- a/docs/optimization-roadmap.md +++ b/docs/optimization-roadmap.md @@ -10,6 +10,8 @@ What is now true in the repository: - `frontend/hooks/useSessionWebSocket.ts` and `frontend/hooks/useSessionRecovery.ts` now carry the connection lifecycle and reconnect / restore logic out of the page shell. - `frontend/__tests__/components/chat/taskPreviewReplay.test.tsx` and `frontend/__tests__/components/chat/dagReplay.test.tsx` cover duplicate preview events, reconnect replay, and session switching. - `frontend/components/admin/PermissionModule.tsx` now replaces the stale `权限` placeholder with a real permission-rule surface backed by `/api/admin/permissions/rules`. +- The admin shell now has explicit mobile, compact-laptop, and wide-desktop behavior; compact layouts collapse the sidebar and remove controls that cannot operate at that width. +- Permission rules now support validated create/edit/toggle/delete flows, request retry feedback, and focused interaction tests. - Prompt/token control has started through `app/services/context_compaction.py`, `app/services/conversation_history.py`, `app/services/orchestrator_preprocessor.py`, `app/services/task_decomposer.py`, and `app/services/result_synthesizer.py`. - `app/api/websocket_message_flow.py` and `app/api/websocket.py` now share compact task preview construction. - The repo already carries the architecture needed for enterprise expansion, but it still has a few oversized hot modules. @@ -174,6 +176,8 @@ Success criteria: ### Phase A: Stabilize +Current status: admin responsiveness and permission-rule completion are done. The remaining Phase A work is the final message-recovery and DAG-replay edge-case pass. + Priority: 1. Keep transport and state recovery correct. diff --git a/frontend/__tests__/components/admin/PermissionModule.test.tsx b/frontend/__tests__/components/admin/PermissionModule.test.tsx index b63d870..f767d62 100644 --- a/frontend/__tests__/components/admin/PermissionModule.test.tsx +++ b/frontend/__tests__/components/admin/PermissionModule.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import PermissionModule from '../../../components/admin/PermissionModule'; describe('PermissionModule', () => { @@ -55,4 +56,79 @@ describe('PermissionModule', () => { expect(screen.queryByText('该模块已独立,等待配置项接入。')).not.toBeInTheDocument(); expect(fetchMock).toHaveBeenCalledWith('/api/admin/permissions/rules', expect.any(Object)); }); + + it('validates required tool pattern and priority range before creating', async () => { + const user = userEvent.setup(); + const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => [] })); + vi.stubGlobal('fetch', fetchMock); + + render( ({})} setNotice={vi.fn()} />); + await screen.findByText('\u6682\u65e0\u6743\u9650\u89c4\u5219\u3002\u53ef\u4ee5\u5148\u6dfb\u52a0\u4e00\u6761 allow / ask / deny \u89c4\u5219\u3002'); + + const toolInput = screen.getByPlaceholderText('file_*'); + await user.clear(toolInput); + await user.click(screen.getByRole('button', { name: '\u521b\u5efa' })); + expect(await screen.findByRole('alert')).toHaveTextContent('\u5de5\u5177\u6a21\u5f0f\u4e0d\u80fd\u4e3a\u7a7a'); + expect(fetchMock).toHaveBeenCalledTimes(1); + + await user.type(toolInput, 'shell_*'); + const priorityInput = screen.getByRole('spinbutton'); + await user.clear(priorityInput); + await user.type(priorityInput, '10001'); + await user.click(screen.getByRole('button', { name: '\u521b\u5efa' })); + expect(await screen.findByRole('alert')).toHaveTextContent('-10000 \u5230 10000'); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('edits, toggles and deletes an existing rule through the API', async () => { + const user = userEvent.setup(); + const rule = { + id: 7, + agentId: '*', + toolPattern: 'file_*', + pathPattern: '/workspace/**', + behavior: 'ask', + source: 'user', + priority: 10, + enabled: true, + createdAt: '2026-07-19T10:00:00', + }; + const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => ({ + ok: true, + status: 200, + json: async () => init?.method ? { status: 'ok' } : [rule], + })); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(window, 'confirm').mockReturnValue(true); + + render( ({ Authorization: 'Bearer test' })} setNotice={vi.fn()} />); + await screen.findByText('file_*'); + + await user.click(screen.getByRole('button', { name: '\u7f16\u8f91\u6743\u9650\u89c4\u5219 file_*' })); + const editTool = screen.getByRole('textbox', { name: '\u7f16\u8f91\u5de5\u5177\u6a21\u5f0f' }); + await user.clear(editTool); + await user.type(editTool, 'shell_*'); + await user.selectOptions(screen.getByRole('combobox', { name: '\u7f16\u8f91\u6743\u9650\u52a8\u4f5c' }), 'deny'); + await user.click(screen.getByRole('button', { name: '\u4fdd\u5b58\u6743\u9650\u89c4\u5219' })); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( + '/api/admin/permissions/rules/7', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ tool_pattern: 'shell_*', path_pattern: '/workspace/**', behavior: 'deny', priority: 10 }), + }), + )); + + await user.click(screen.getByRole('button', { name: '\u542f\u7528' })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( + '/api/admin/permissions/rules/7', + expect.objectContaining({ method: 'PUT', body: JSON.stringify({ enabled: false }) }), + )); + + await user.click(screen.getByRole('button', { name: '\u5220\u9664\u6743\u9650\u89c4\u5219 file_*' })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledWith( + '/api/admin/permissions/rules/7', + expect.objectContaining({ method: 'DELETE' }), + )); + }); }); diff --git a/frontend/app/admin/layout.tsx b/frontend/app/admin/layout.tsx index ca6b0f6..aa8e993 100644 --- a/frontend/app/admin/layout.tsx +++ b/frontend/app/admin/layout.tsx @@ -62,15 +62,15 @@ export default function AdminLayout({ children }: { children: ReactNode }): JSX. useEffect(() => { const handleResize = () => { const w = window.innerWidth; - if (w < 1024) { + if (w < 1440) { setSidebarCollapsed(true); - } else if (w >= 1280) { + } else if (w >= 1600) { setSidebarCollapsed(false); } if (w >= 1024) { setMobileDrawerOpen(false); } - if (w < 1280) { + if (w < 1600) { setConsoleCollapsed(true); } }; @@ -196,7 +196,7 @@ export default function AdminLayout({ children }: { children: ReactNode }): JSX.
{/* ═══════════════════════════════════════════════════════ @@ -225,7 +225,7 @@ export default function AdminLayout({ children }: { children: ReactNode }): JSX.