diff --git a/flocks/hub/catalog.py b/flocks/hub/catalog.py index 87a45ce51..d298ffec9 100644 --- a/flocks/hub/catalog.py +++ b/flocks/hub/catalog.py @@ -800,6 +800,7 @@ def _entry_from_manifest(manifest: HubPluginManifest) -> HubCatalogEntry: riskLevel=manifest.risk.level, state=state, installedVersion=installed_version, + enabled=record.enabled if record else True, source=manifest.source.kind, manifestPath=str(manifest_path(manifest.type, manifest.id).relative_to(get_bundled_hub_root())), installPath=str(install_path) if install_path else None, @@ -858,6 +859,7 @@ def _entry_from_index( riskLevel=item.riskLevel, state=state, installedVersion=installed_version, + enabled=record.enabled if record else True, source="bundled", manifestPath=item.manifestPath, installPath=str(install_path) if install_path else None, @@ -883,6 +885,7 @@ def _entry_from_system_manifest(manifest: HubPluginManifest, root: Path) -> HubC riskLevel=manifest.risk.level, state="installed", installedVersion=manifest.version, + enabled=True, source="system", manifestPath=_system_manifest_path(manifest.type, manifest.id), installPath=str(root), @@ -950,6 +953,7 @@ def _entry_from_bundled_tool( riskLevel=manifest.risk.level, state=state, installedVersion=installed_version, + enabled=record.enabled if record else True, source="bundled", manifestPath=manifest_rel, installPath=str(install_path) if install_path else None, diff --git a/flocks/hub/installer.py b/flocks/hub/installer.py index eb894126c..3c7c61c94 100644 --- a/flocks/hub/installer.py +++ b/flocks/hub/installer.py @@ -651,6 +651,33 @@ async def update_plugin(plugin_type: PluginType, plugin_id: str, *, scope: str = return await install_plugin(plugin_type, plugin_id, scope=scope) +def _set_api_services_enabled(storage_keys: list[str], enabled: bool) -> None: + if not storage_keys: + return + from flocks.config.config_writer import ConfigWriter + + for storage_key in storage_keys: + current = ConfigWriter.get_api_service_raw(storage_key) + service_config = dict(current) if isinstance(current, dict) else {} + service_config["enabled"] = enabled + ConfigWriter.set_api_service(storage_key, service_config) + + +async def set_plugin_enabled(plugin_type: PluginType, plugin_id: str, enabled: bool) -> InstalledPluginRecord: + record = local.set_installed_record_enabled(plugin_type, plugin_id, enabled) + install_path = Path(record.installPath) if record.installPath else local.infer_local_install(plugin_type, plugin_id) + + if plugin_type == "skill": + from flocks.skill.skill import Skill + + Skill.set_disabled(plugin_id, not enabled) + elif plugin_type in {"tool", "device"} and install_path is not None: + _set_api_services_enabled(_collect_storage_keys(install_path), enabled) + + await _refresh_runtime(plugin_type) + return record + + def _collect_storage_keys(install_path: Path) -> list[str]: """Return ``api_services`` storage keys declared inside *install_path*. diff --git a/flocks/hub/local.py b/flocks/hub/local.py index e7f5d2580..e3ce502bd 100644 --- a/flocks/hub/local.py +++ b/flocks/hub/local.py @@ -83,6 +83,27 @@ def save_installed_record(record: InstalledPluginRecord) -> None: path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") +def set_installed_record_enabled(plugin_type: PluginType, plugin_id: str, enabled: bool) -> InstalledPluginRecord: + record = get_record(plugin_type, plugin_id) + install_path = Path(record.installPath) if record and record.installPath else infer_local_install(plugin_type, plugin_id) + if install_path is None: + raise FileNotFoundError(f"Plugin is not installed: {plugin_type}:{plugin_id}") + if record is None: + record = make_record( + plugin_type=plugin_type, + plugin_id=plugin_id, + version="0.0.0", + source="local", + install_path=install_path, + enabled=enabled, + scope="project" if _project_plugins_root().resolve() in install_path.resolve().parents else "global", + ) + else: + record.enabled = enabled + save_installed_record(record) + return record + + def remove_installed_record(plugin_type: PluginType, plugin_id: str) -> None: import json diff --git a/flocks/hub/models.py b/flocks/hub/models.py index a9640d21a..53118afdb 100644 --- a/flocks/hub/models.py +++ b/flocks/hub/models.py @@ -164,6 +164,7 @@ class HubCatalogEntry(BaseModel): riskLevel: str = "low" state: PluginState = "available" installedVersion: Optional[str] = None + enabled: bool = True source: str = "bundled" manifestPath: str installPath: Optional[str] = None diff --git a/flocks/server/routes/hub.py b/flocks/server/routes/hub.py index 92da6378d..d6dc84cc6 100644 --- a/flocks/server/routes/hub.py +++ b/flocks/server/routes/hub.py @@ -19,7 +19,7 @@ load_taxonomy, ) from flocks.hub.files import file_tree, read_file_content -from flocks.hub.installer import install_plugin, uninstall_plugin, update_plugin +from flocks.hub.installer import install_plugin, set_plugin_enabled, uninstall_plugin, update_plugin from flocks.hub.models import ( HubCatalogEntry, HubFileContent, @@ -41,6 +41,10 @@ class HubInstallRequest(BaseModel): scope: str = Field(default="global", description="'global' only") +class HubEnableRequest(BaseModel): + enabled: bool + + class HubCatalogFacets(BaseModel): type: dict[str, int] = Field(default_factory=dict) category: dict[str, int] = Field(default_factory=dict) @@ -292,6 +296,18 @@ async def hub_update_plugin( raise HTTPException(status_code=422, detail=str(exc)) from exc +@router.patch("/hub/plugins/{plugin_type}/{plugin_id}/enabled", response_model=InstalledPluginRecord) +async def hub_set_plugin_enabled(plugin_type: PluginType, plugin_id: str, req: HubEnableRequest): + _guard_legacy_removed_plugin(plugin_type, plugin_id) + try: + return await set_plugin_enabled(plugin_type, plugin_id, req.enabled) + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except Exception as exc: + log.error("hub.enable.failed", {"type": plugin_type, "id": plugin_id, "enabled": req.enabled, "error": str(exc)}) + raise HTTPException(status_code=422, detail=str(exc)) from exc + + @router.delete("/hub/plugins/{plugin_type}/{plugin_id}") async def hub_uninstall_plugin( plugin_type: PluginType, diff --git a/tests/hub/test_hub_catalog.py b/tests/hub/test_hub_catalog.py index bdb479b2e..2ad40fb03 100644 --- a/tests/hub/test_hub_catalog.py +++ b/tests/hub/test_hub_catalog.py @@ -823,6 +823,17 @@ def test_hub_routes_cover_catalog_files_install_and_uninstall(isolated_hub_env): assert installed.status_code == 200 assert installed.json()["id"] == "ndr-alert-analysis" + disabled = client.patch("/api/hub/plugins/skill/ndr-alert-analysis/enabled", json={"enabled": False}) + assert disabled.status_code == 200 + assert disabled.json()["enabled"] is False + disabled_catalog = client.get("/api/hub/catalog", params={"state": "installed"}).json() + disabled_entry = next(item for item in disabled_catalog if item["id"] == "ndr-alert-analysis") + assert disabled_entry["enabled"] is False + + enabled = client.patch("/api/hub/plugins/skill/ndr-alert-analysis/enabled", json={"enabled": True}) + assert enabled.status_code == 200 + assert enabled.json()["enabled"] is True + installed_catalog = client.get("/api/hub/catalog", params={"state": "installed"}).json() assert any(item["id"] == "ndr-alert-analysis" for item in installed_catalog) diff --git a/webui/src/api/hub.ts b/webui/src/api/hub.ts index b65dce47e..fdb7b61e8 100644 --- a/webui/src/api/hub.ts +++ b/webui/src/api/hub.ts @@ -26,6 +26,7 @@ export interface HubCatalogEntry { riskLevel: string; state: HubPluginState; installedVersion?: string; + enabled: boolean; source: string; manifestPath: string; installPath?: string; @@ -222,6 +223,9 @@ export const hubAPI = { uninstall: (type: HubPluginType, id: string) => client.delete(`/api/hub/plugins/${type}/${id}`), + setEnabled: (type: HubPluginType, id: string, enabled: boolean) => + client.patch(`/api/hub/plugins/${type}/${id}/enabled`, { enabled }), + refresh: () => client.post('/api/hub/refresh'), }; diff --git a/webui/src/components/layout/AIWorkbenchNavigation.tsx b/webui/src/components/layout/AIWorkbenchNavigation.tsx new file mode 100644 index 000000000..faec4159f --- /dev/null +++ b/webui/src/components/layout/AIWorkbenchNavigation.tsx @@ -0,0 +1,265 @@ +import { useEffect, useMemo, useState } from 'react'; +import { ChevronDown, ChevronRight, FolderGit2, MessageSquare } from 'lucide-react'; +import { Link, useLocation } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import client from '@/api/client'; +import { sessionApi } from '@/api/session'; +import type { Session } from '@/types'; + +const TASK_SESSION_GROUP_ID = 'tasks'; +const SESSION_PAGE_SIZE = 6; +const WORKBENCH_NAVIGATION_REFRESH_EVENT = 'flocks:workbench-navigation-refresh'; + +type ProjectSummary = { + id: string; + worktree: string; + name?: string | null; + isDefault?: boolean; + sessionCount?: number; + lastActivityAt?: number | null; +}; + +function projectLabel(project: ProjectSummary): string { + const explicitName = project.name?.trim(); + if (explicitName) return explicitName; + const normalizedPath = project.worktree.replace(/[\\/]+$/, ''); + return normalizedPath.split(/[\\/]/).pop() || project.worktree; +} + +function SessionLink({ + session, + nested = false, + selected, + onSelect, + onNavigate, +}: { + session: Session; + nested?: boolean; + selected: boolean; + onSelect: () => void; + onNavigate: () => void; +}) { + return ( + { + onSelect(); + onNavigate(); + }} + className={`flex h-8 min-w-0 items-center gap-2 rounded-lg pr-2 text-xs transition-colors ${ + nested ? 'pl-7' : 'pl-3' + } ${ + selected + ? 'bg-white font-semibold text-zinc-900 shadow-sm dark:bg-zinc-800 dark:text-zinc-50' + : 'text-zinc-500 hover:bg-white/60 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-900 dark:hover:text-zinc-50' + }`} + title={session.title} + > + + {session.title} + + ); +} + +export default function AIWorkbenchNavigation({ + collapsed, + onNavigate, +}: { + collapsed: boolean; + onNavigate: () => void; +}) { + const location = useLocation(); + const { t } = useTranslation('session'); + const [projects, setProjects] = useState([]); + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(true); + const [collapsedProjectIds, setCollapsedProjectIds] = useState>(() => new Set()); + const [projectsCollapsed, setProjectsCollapsed] = useState(false); + const [tasksCollapsed, setTasksCollapsed] = useState(false); + const [refreshVersion, setRefreshVersion] = useState(0); + const sessionParam = new URLSearchParams(location.search).get('session'); + const [activeSessionId, setActiveSessionId] = useState(sessionParam); + + useEffect(() => { + if (sessionParam) setActiveSessionId(sessionParam); + }, [sessionParam]); + + useEffect(() => { + const refresh = () => setRefreshVersion((version) => version + 1); + window.addEventListener(WORKBENCH_NAVIGATION_REFRESH_EVENT, refresh); + return () => window.removeEventListener(WORKBENCH_NAVIGATION_REFRESH_EVENT, refresh); + }, []); + + useEffect(() => { + if (location.pathname === '/sessions') { + return undefined; + } + let cancelled = false; + const loadNavigation = async () => { + try { + const response = await client.get('/api/project'); + const nextProjects = Array.isArray(response.data) + ? response.data.filter((project: ProjectSummary) => ( + project.id !== TASK_SESSION_GROUP_ID && !project.isDefault + )) + : []; + const projectIds = [TASK_SESSION_GROUP_ID, ...nextProjects.map((project) => project.id)]; + const sessionGroups = await Promise.all(projectIds.map((projectID) => sessionApi.list({ + view: 'list', + manager: true, + roots: true, + limit: SESSION_PAGE_SIZE, + projectID, + }))); + if (cancelled) return; + setProjects(nextProjects); + setSessions(sessionGroups.flatMap((group) => Array.isArray(group) ? group : [])); + } catch { + if (!cancelled) { + setProjects([]); + setSessions([]); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + void loadNavigation(); + return () => { + cancelled = true; + }; + }, [location.pathname, refreshVersion]); + + const sessionsByProject = useMemo(() => { + const grouped = new Map(); + sessions.forEach((session) => { + const projectId = session.effectiveProjectID || session.projectID || TASK_SESSION_GROUP_ID; + const group = grouped.get(projectId) ?? []; + group.push(session); + grouped.set(projectId, group); + }); + return grouped; + }, [sessions]); + const sortedProjects = useMemo(() => [...projects].sort((left, right) => { + const activityDelta = (right.lastActivityAt ?? 0) - (left.lastActivityAt ?? 0); + return activityDelta || projectLabel(left).localeCompare(projectLabel(right)); + }), [projects]); + const taskSessions = sessionsByProject.get(TASK_SESSION_GROUP_ID) ?? []; + + if (collapsed) { + return ( + + + + ); + } + + if (location.pathname === '/sessions') return null; + + const toggleProject = (projectId: string) => { + setCollapsedProjectIds((current) => { + const next = new Set(current); + if (next.has(projectId)) next.delete(projectId); + else next.add(projectId); + return next; + }); + }; + + return ( +
+
+ + {!projectsCollapsed && ( +
+ {sortedProjects.map((project) => { + const projectSessions = sessionsByProject.get(project.id) ?? []; + const projectCollapsed = collapsedProjectIds.has(project.id); + return ( +
+ + {!projectCollapsed && projectSessions.map((session) => ( + setActiveSessionId(session.id)} + onNavigate={onNavigate} + /> + ))} + {!projectCollapsed && !loading && projectSessions.length === 0 && ( +
+ {t('noProjectSessions')} +
+ )} +
+ ); + })} +
+ )} +
+ +
+ + {!tasksCollapsed && ( +
+ {taskSessions.map((session) => ( + setActiveSessionId(session.id)} + onNavigate={onNavigate} + /> + ))} +
+ )} +
+
+ ); +} diff --git a/webui/src/components/layout/Layout.test.tsx b/webui/src/components/layout/Layout.test.tsx index 8428a991b..1dd43f3fe 100644 --- a/webui/src/components/layout/Layout.test.tsx +++ b/webui/src/components/layout/Layout.test.tsx @@ -15,6 +15,7 @@ const { onboardingAPI, providerAPI, sessionApi, + clientGet, getActiveNotifications, ackNotification, getNotificationAckStatus, @@ -44,7 +45,9 @@ const { }, sessionApi: { create: vi.fn(), + list: vi.fn(() => Promise.resolve([])), }, + clientGet: vi.fn(() => Promise.resolve({ data: [] })), getActiveNotifications: vi.fn(), ackNotification: vi.fn(), getNotificationAckStatus: vi.fn(), @@ -96,6 +99,12 @@ vi.mock('@/api/session', () => ({ sessionApi, })); +vi.mock('@/api/client', () => ({ + default: { + get: clientGet, + }, +})); + vi.mock('@/api/update', () => ({ checkUpdate, })); @@ -881,6 +890,8 @@ describe('Layout WebUI contract pages navigation', () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); + clientGet.mockResolvedValue({ data: [] }); + sessionApi.list.mockResolvedValue([]); checkUpdate.mockResolvedValue({ has_update: false, latest_version: null, @@ -916,57 +927,97 @@ describe('Layout WebUI contract pages navigation', () => { consoleUpgradeApi.getProPackageStatus.mockResolvedValue({ pro_enabled: false }); }); - it('renders custom WebUI contract page links under the home section', async () => { - renderHomeWithLayout(); - expect(await screen.findByRole('link', { name: '自定义仪表盘' })).toHaveAttribute( + it('renders the requested flat primary navigation in order', async () => { + localStorage.setItem('flocks_onboarding_dismissed', 'true'); + clientGet.mockResolvedValue({ + data: [{ + id: 'project-1', + worktree: '/workspace/flocks', + name: 'Flocks project', + sessionCount: 1, + }], + }); + sessionApi.list.mockImplementation(({ projectID }: { projectID?: string }) => Promise.resolve( + projectID === 'project-1' + ? [{ + id: 'project-session', + title: 'Project session', + projectID: 'project-1', + effectiveProjectID: 'project-1', + }] + : projectID === 'tasks' + ? [{ id: 'task-session', title: 'Task session', projectID: 'tasks' }] + : [], + )); + const { container } = renderHomeWithLayout(); + await screen.findByRole('link', { name: 'flocksHome' }); + const sidebarNav = container.querySelector('aside nav') as HTMLElement; + const primaryLinks = within(sidebarNav).getAllByRole('link') + .map((link) => link.textContent) + .slice(0, 4); + + expect(primaryLinks).toEqual(['flocksHome', 'tasks', 'plugins', 'workspace']); + expect(screen.getByRole('button', { name: 'aiWorkbench' })).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByRole('button', { name: /projectsSection/ })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /tasksSection/ })).toBeInTheDocument(); + expect(await screen.findByText('Flocks project')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Project session' })).toHaveAttribute( + 'href', + '/sessions?session=project-session', + ); + expect(screen.getByRole('link', { name: 'Task session' })).toHaveAttribute( 'href', - '/contracts/webui/dash-1', + '/sessions?session=task-session', ); + expect(screen.queryByRole('link', { name: 'sessions' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'agentHub' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: '自定义仪表盘' })).not.toBeInTheDocument(); }); - it('keeps sidebar workspace groups expanded by default and allows collapsing each group', async () => { + it('keeps the scene workspace group expanded by default and allows collapsing it', async () => { const user = userEvent.setup(); localStorage.setItem('flocks_onboarding_dismissed', 'true'); renderHomeWithLayout(); - const aiWorkbenchToggle = await screen.findByRole('button', { name: 'aiWorkbench' }); - const sceneWorkspacesToggle = screen.getByRole('button', { name: 'sceneWorkspaces' }); - const agentHubToggle = screen.getByRole('button', { name: 'agentHub' }); + const sceneWorkspacesToggle = await screen.findByRole('button', { name: 'sceneWorkspaces' }); + const aiWorkbenchToggle = screen.getByRole('button', { name: 'aiWorkbench' }); - expect(aiWorkbenchToggle).toHaveAttribute('aria-expanded', 'true'); expect(sceneWorkspacesToggle).toHaveAttribute('aria-expanded', 'true'); - expect(agentHubToggle).toHaveAttribute('aria-expanded', 'true'); - expect(screen.getByRole('link', { name: 'sessions' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'deviceIntegration' })).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'agents' })).toBeInTheDocument(); - - await user.click(aiWorkbenchToggle); - expect(aiWorkbenchToggle).toHaveAttribute('aria-expanded', 'false'); - expect(localStorage.getItem('flocks_layout_collapsed_nav_sections')).toBe(JSON.stringify(['aiWorkbench'])); - expect(screen.queryByRole('link', { name: 'sessions' })).not.toBeInTheDocument(); + expect(aiWorkbenchToggle).toHaveAttribute('aria-expanded', 'true'); + expect(sceneWorkspacesToggle.closest('h3')).toHaveClass( + 'text-sm', + 'font-medium', + 'text-zinc-600', + 'dark:text-zinc-400', + ); + expect(aiWorkbenchToggle.closest('h3')).toHaveClass( + 'text-sm', + 'font-medium', + 'text-zinc-600', + 'dark:text-zinc-400', + ); + expect(sceneWorkspacesToggle.closest('h3')).not.toHaveClass( + 'text-xs', + 'uppercase', + 'text-zinc-400', + 'dark:text-zinc-500', + ); + expect(screen.getByRole('button', { name: /projectsSection/ })).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'deviceIntegration' })).toBeInTheDocument(); - await user.click(aiWorkbenchToggle); - expect(localStorage.getItem('flocks_layout_collapsed_nav_sections')).toBeNull(); - expect(screen.getByRole('link', { name: 'sessions' })).toBeInTheDocument(); await user.click(sceneWorkspacesToggle); expect(sceneWorkspacesToggle).toHaveAttribute('aria-expanded', 'false'); expect(localStorage.getItem('flocks_layout_collapsed_nav_sections')).toBe(JSON.stringify(['sceneWorkspaces'])); expect(screen.queryByRole('link', { name: 'deviceIntegration' })).not.toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'agents' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /projectsSection/ })).toBeInTheDocument(); await user.click(sceneWorkspacesToggle); expect(localStorage.getItem('flocks_layout_collapsed_nav_sections')).toBeNull(); expect(screen.getByRole('link', { name: 'deviceIntegration' })).toBeInTheDocument(); - await user.click(agentHubToggle); - expect(agentHubToggle).toHaveAttribute('aria-expanded', 'false'); - expect(localStorage.getItem('flocks_layout_collapsed_nav_sections')).toBe(JSON.stringify(['agentHub'])); - expect(screen.queryByRole('link', { name: 'agents' })).not.toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'sessions' })).toBeInTheDocument(); - await user.click(agentHubToggle); - expect(localStorage.getItem('flocks_layout_collapsed_nav_sections')).toBeNull(); - expect(screen.getByRole('link', { name: 'agents' })).toBeInTheDocument(); + await user.click(aiWorkbenchToggle); + expect(aiWorkbenchToggle).toHaveAttribute('aria-expanded', 'false'); + expect(document.getElementById('ai-workbench-navigation-slot')).toHaveClass('hidden'); }); it('restores collapsed sidebar workspace groups after refresh', async () => { @@ -977,10 +1028,8 @@ describe('Layout WebUI contract pages navigation', () => { expect(await screen.findByRole('button', { name: 'sceneWorkspaces' })).toHaveAttribute('aria-expanded', 'false'); expect(screen.queryByRole('link', { name: 'deviceIntegration' })).not.toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'aiWorkbench' })).toHaveAttribute('aria-expanded', 'true'); - expect(screen.getByRole('link', { name: 'sessions' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'agentHub' })).toHaveAttribute('aria-expanded', 'true'); - expect(screen.getByRole('link', { name: 'agents' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /projectsSection/ })).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'plugins' })).toBeInTheDocument(); }); it('does not render WebUI contract page links until their build is ready', async () => { @@ -1016,7 +1065,7 @@ describe('Layout WebUI contract pages navigation', () => { renderHomeWithLayout(); - expect(await screen.findByRole('link', { name: '可用页面' })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: '可用页面' })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: '失败页面' })).not.toBeInTheDocument(); }); @@ -1115,7 +1164,8 @@ describe('Layout WebUI contract pages navigation', () => { const sectionHeadings = Array.from(container.querySelectorAll('h3')).map((element) => element.textContent); expect(sectionHeadings.indexOf('sceneWorkspaces')).toBeGreaterThanOrEqual(0); - expect(sectionHeadings.indexOf('sceneWorkspaces')).toBeLessThan(sectionHeadings.indexOf('agentHub')); + expect(sectionHeadings.indexOf('sceneWorkspaces')).toBeLessThan(sectionHeadings.indexOf('aiWorkbench')); + expect(sectionHeadings).not.toContain('agentHub'); expect(sectionHeadings).not.toContain('systemCenter'); const sceneSection = Array.from(container.querySelectorAll('h3')) @@ -1124,12 +1174,8 @@ describe('Layout WebUI contract pages navigation', () => { expect(sceneSection?.querySelector('a[href="/contracts/webui/workspaces/scene_workspace"]')).not.toBeNull(); expect(sceneSection?.querySelector('a[href="/devices"]')).not.toBeNull(); - const agentSection = Array.from(container.querySelectorAll('h3')) - .find((heading) => heading.textContent === 'agentHub') - ?.parentElement; - expect(agentSection?.querySelector('a[href="/devices"]')).toBeNull(); - expect(agentSection?.querySelector('a[href="/models"]')).not.toBeNull(); - expect(agentSection?.querySelector('a[href="/channels"]')).not.toBeNull(); + expect(container.querySelector('a[href="/models"]')).toBeNull(); + expect(container.querySelector('a[href="/channels"]')).toBeNull(); await user.click(workspaceLink); diff --git a/webui/src/components/layout/Layout.tsx b/webui/src/components/layout/Layout.tsx index f361c9a41..09bc1507a 100644 --- a/webui/src/components/layout/Layout.tsx +++ b/webui/src/components/layout/Layout.tsx @@ -1,14 +1,7 @@ import { Outlet, Link, useLocation, matchPath, useNavigate } from 'react-router-dom'; import { Home, - MessageSquare, - Bot, - Brain, - Workflow, ListTodo, - Wrench, - BookOpen, - Radio, X, ChevronLeft, ChevronRight, @@ -16,7 +9,7 @@ import { Menu, FolderOpen, Sparkles, - Archive, + PackageCheck, ServerCog, LogOut, Settings, @@ -106,6 +99,7 @@ import { useToast } from '@/components/common/Toast'; import LazyLoadErrorBoundary from '@/components/common/LazyLoadErrorBoundary'; import type { WebUIContractWorkspaceListItem } from '@/api/webuiContractPages'; import { recoverLazyLoad } from '@/utils/chunkLoadRecovery'; +import AIWorkbenchNavigation from './AIWorkbenchNavigation'; const UPDATE_CHECK_INTERVAL_MS = 3_600_000; const UPDATE_CHECK_MIN_GAP_MS = 600_000; @@ -220,7 +214,7 @@ export default function Layout() { const notificationGateReady = flocksproStatusReady && (!canManageUpdates || hasCompletedUpdateCheck); const canCreateWorkspaceCustomPage = user?.role === 'admin'; - const { pages: webuiContractPages, workspaces: webuiContractWorkspaces = [] } = useWebUIContractPages(); + const { workspaces: webuiContractWorkspaces = [] } = useWebUIContractPages(); const [openWorkspaceMenuId, setOpenWorkspaceMenuId] = useState(null); const [collapsedNavSectionIds, setCollapsedNavSectionIds] = useState>(readCollapsedNavSectionIds); const [collapsedWorkspaceSectionIds, setCollapsedWorkspaceSectionIds] = useState>(() => new Set()); @@ -522,24 +516,9 @@ export default function Layout() { name: '', items: [ { name: t('flocksHome'), href: '/', icon: Home }, - ...webuiContractPages - .filter((page) => !page.workspaceId && page.enabled && page.placement === 'home.after' && page.buildStatus === 'ready') - .map((page) => ({ - name: getLocalizedWebUIContractTitle(page, i18n.language), - href: page.route, - icon: resolveWebUIContractPageIcon(page.icon), - })), - ], - }, - { - id: 'aiWorkbench', - name: t('aiWorkbench'), - collapsible: true, - items: [ - { name: t('sessions'), href: '/sessions', icon: MessageSquare }, - { name: t('workspace'), href: '/workspace', icon: FolderOpen }, { name: t('tasks'), href: '/tasks', icon: ListTodo }, - { name: t('workflows'), href: '/workflows', icon: Workflow }, + { name: t('plugins'), href: '/plugins', icon: PackageCheck }, + { name: t('workspace'), href: '/workspace', icon: FolderOpen }, ], }, { @@ -552,21 +531,14 @@ export default function Layout() { ], }, { - id: 'agentHub', - name: t('agentHub'), + id: 'aiWorkbench', + name: t('aiWorkbench'), collapsible: true, - items: [ - { name: t('agents'), href: '/agents', icon: Bot }, - { name: t('skills'), href: '/skills', icon: BookOpen }, - { name: t('tools'), href: '/tools', icon: Wrench }, - { name: t('hub', { productName }), href: '/hub', icon: Archive }, - { name: t('models'), href: '/models', icon: Brain }, - { name: t('channels'), href: '/channels', icon: Radio }, - ], + items: [], }, ]; }, - [i18n.language, productName, webuiContractPages, webuiContractWorkspaces, t], + [i18n.language, webuiContractWorkspaces, t], ); const isFullScreenPage = @@ -813,12 +785,12 @@ export default function Layout() { return (
{!collapsed && section.name && ( -

+

{section.collapsible ? (

)} {collapsed &&
} - {!sectionCollapsed && ( + {(!sectionCollapsed || section.id === 'aiWorkbench') && (
+ {section.id === 'aiWorkbench' && ( +
+ setSidebarOpen(false)} + /> +
+ )} {section.items.map((item) => { const isActive = location.pathname === item.href || (item.href !== '/' && location.pathname.startsWith(`${item.href}/`)); diff --git a/webui/src/locales/en-US/nav.json b/webui/src/locales/en-US/nav.json index 8c42318b3..7cf4ad3fc 100644 --- a/webui/src/locales/en-US/nav.json +++ b/webui/src/locales/en-US/nav.json @@ -3,10 +3,11 @@ "flocksHome": "Home", "aiWorkbench": "AI Workbench", "sceneWorkspaces": "Scene Workspaces", - "sessions": "Workbench", + "sessions": "Session Management", "tasks": "Task Center", - "workspace": "File Directory", + "workspace": "Workspace", "agentHub": "Agent Studio", + "plugins": "Plugins", "agents": "Agents", "workflows": "Workflows", "hub": "{{productName}} Hub", @@ -27,7 +28,8 @@ "settingsTitle": "Settings", "settingsDescription": "Manage preferences, account, and system settings", "settingsPreferences": "Preferences", - "settingsPreferencesDescription": "Adjust workspace display, language, theme, and tool failure handling.", + "settingsPreferencesDescription": "Manage workspace display and behavior preferences.", + "settingsGeneral": "General", "settingsGroupPreferences": "Preferences", "settingsGroupData": "Data Management", "archivedData": "Archived Tasks", diff --git a/webui/src/locales/zh-CN/nav.json b/webui/src/locales/zh-CN/nav.json index 59590281e..4b37dc030 100644 --- a/webui/src/locales/zh-CN/nav.json +++ b/webui/src/locales/zh-CN/nav.json @@ -3,10 +3,11 @@ "flocksHome": "首页", "aiWorkbench": "AI 工作台", "sceneWorkspaces": "场景工作区", - "sessions": "工作台", + "sessions": "会话管理", "tasks": "任务中心", - "workspace": "文件目录", + "workspace": "工作空间", "agentHub": "智能体工作室", + "plugins": "插件管理", "agents": "智能体", "workflows": "工作流", "hub": "插件广场", @@ -27,7 +28,8 @@ "settingsTitle": "设置", "settingsDescription": "管理偏好、账号与系统配置", "settingsPreferences": "偏好设置", - "settingsPreferencesDescription": "调整当前工作台的显示、语言、主题和工具失败处理。", + "settingsPreferencesDescription": "管理工作台显示与行为偏好。", + "settingsGeneral": "通用", "settingsGroupPreferences": "偏好", "settingsGroupData": "数据管理", "archivedData": "已归档任务", diff --git a/webui/src/pages/Agent/index.test.tsx b/webui/src/pages/Agent/index.test.tsx index a394accab..519734075 100644 --- a/webui/src/pages/Agent/index.test.tsx +++ b/webui/src/pages/Agent/index.test.tsx @@ -72,6 +72,23 @@ function makeAgent(overrides: Record) { } describe('AgentPage cards', () => { + it('插件管理嵌入模式仅展示子 Agent', () => { + mockUseAgents.mockReturnValue({ + agents: [ + makeAgent({ name: 'rex', nameCn: 'Rex 主智能体', mode: 'primary', native: true }), + makeAgent({ name: 'analyst', nameCn: '分析智能体', delegatable: true }), + ], + loading: false, + error: null, + refetch: vi.fn(), + }); + + render(); + + expect(screen.queryByText('Rex 主智能体')).not.toBeInTheDocument(); + expect(screen.getByText('分析智能体')).toBeInTheDocument(); + }); + it('使用与工作流卡片一致的纯色扁平样式', () => { mockUseAgents.mockReturnValue({ agents: [ diff --git a/webui/src/pages/Agent/index.tsx b/webui/src/pages/Agent/index.tsx index 68d1dab20..8a09f9a6e 100644 --- a/webui/src/pages/Agent/index.tsx +++ b/webui/src/pages/Agent/index.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo } from 'react'; -import { Bot, Plus, Cpu, RefreshCw, Pencil, Trash2, Shield, Zap, Loader2 } from 'lucide-react'; +import { Bot, Plus, Cpu, RefreshCw, Pencil, Trash2, Shield, Zap, Loader2, Search } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import PageHeader from '@/components/common/PageHeader'; import LoadingSpinner from '@/components/common/LoadingSpinner'; @@ -13,7 +13,11 @@ import AgentSheet from './AgentSheet'; // Main Page Component // ============================================================================ -export default function AgentPage() { +interface AgentPageProps { + embedded?: boolean; +} + +export default function AgentPage({ embedded = false }: AgentPageProps = {}) { const { t, i18n } = useTranslation('agent'); const [editingAgent, setEditingAgent] = useState(null); const [showCreateSheet, setShowCreateSheet] = useState(false); @@ -98,17 +102,21 @@ export default function AgentPage() { return (
- } - /> + {!embedded && ( + } + /> + )} {/* Toolbar — mirrors the Skill page toolbar style */}
- - {t('totalCount', { total: primaryAgents.length + subAgents.length })} - + {!embedded && ( + + {t('totalCount', { total: primaryAgents.length + subAgents.length })} + + )}
+
+
+ ); +} + // ============================================================================ // Agent Card // ============================================================================ @@ -476,11 +564,11 @@ function AgentCard({ return (
e.stopPropagation()} > - {/* Delete — disabled for built-in agents */} - {agent.native ? ( - - ) : ( +
+ {!agent.native && ( )} +
{showDelegatableToggle && ( diff --git a/webui/src/pages/Hub/index.tsx b/webui/src/pages/Hub/index.tsx index 4b0ed8f07..5a740556a 100644 --- a/webui/src/pages/Hub/index.tsx +++ b/webui/src/pages/Hub/index.tsx @@ -270,7 +270,11 @@ const EMPTY_HUB_FACETS: HubCatalogFacets = { riskLevel: {}, }; -export default function HubPage() { +interface HubPageProps { + embedded?: boolean; +} + +export default function HubPage({ embedded = false }: HubPageProps = {}) { const { i18n } = useTranslation(); const { user } = useAuth(); const { productName } = useProductName(); @@ -565,44 +569,77 @@ export default function HubPage() { return (
- } - action={ -
-
- - { - setQuery(e.target.value); - setPage(1); - }} - placeholder={text.searchPlaceholder} - className="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-lg text-sm outline-none bg-white/90 focus:ring-2 focus:ring-slate-200 focus:border-slate-400" - /> -
+ {embedded ? ( +
+
+ + { + setQuery(e.target.value); + setPage(1); + }} + placeholder={text.searchPlaceholder} + className="w-full rounded-lg border border-gray-200 bg-white py-1.5 pl-9 pr-3 text-sm outline-none focus:border-slate-400 focus:ring-1 focus:ring-slate-300" + /> +
+
{canManageHub && ( )}
- } - /> +
+ ) : ( + } + action={ +
+
+ + setQuery(e.target.value)} + placeholder={text.searchPlaceholder} + className="w-full pl-9 pr-3 py-2 border border-gray-300 rounded-lg text-sm outline-none bg-white/90 focus:ring-2 focus:ring-slate-200 focus:border-slate-400" + /> +
+ + {canManageHub && ( + + )} +
+ } + /> + )}
diff --git a/webui/src/pages/PluginManager/index.tsx b/webui/src/pages/PluginManager/index.tsx new file mode 100644 index 000000000..444bd3cbe --- /dev/null +++ b/webui/src/pages/PluginManager/index.tsx @@ -0,0 +1,1108 @@ +import { Suspense, lazy, useEffect, useMemo, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { + Bot, + Boxes, + CheckCircle, + ChevronRight, + Download, + FileText, + GitBranch, + Loader2, + PackageCheck, + Power, + PowerOff, + RefreshCw, + Search, + ServerCog, + Shield, + Sparkles, + Trash2, + Wrench, + Workflow, + X, +} from 'lucide-react'; +import LoadingSpinner from '@/components/common/LoadingSpinner'; +import { useToast } from '@/components/common/Toast'; +import { hubAPI, type HubCatalogEntry, type HubPluginType } from '@/api/hub'; + +type PluginView = 'installed' | 'marketplace'; +type ActionKind = 'install' | 'update' | 'uninstall'; +type PluginFamily = 'all' | 'agent' | 'skill' | 'mcp' | 'apiTool' | 'pythonTool' | 'generatedTool' | 'tool' | 'device' | 'workflow' | 'webui' | 'component'; +type PluginSection = 'overview' | 'tools' | 'skills' | 'agents' | 'workflows' | 'marketplace'; +type PluginMode = 'assets' | 'discover'; + +const ToolPage = lazy(() => import('@/pages/Tool')); +const SkillPage = lazy(() => import('@/pages/Skill')); +const AgentPage = lazy(() => import('@/pages/Agent')); +const WorkflowPage = lazy(() => import('@/pages/Workflow')); +const HubPage = lazy(() => import('@/pages/Hub')); + +interface PluginText { + title: string; + description: string; + installedAssets: string; + discoverPlugins: string; + assetType: string; + installSource: string; + hubSource: string; + installed: string; + marketplace: string; + installedHint: string; + marketplaceHint: string; + searchPlaceholder: string; + all: string; + refresh: string; + family: string; + sections: Record; + sectionDescriptions: Record; + emptyTitle: string; + emptyHint: string; + openWorkspace: string; + pluginActions: string; + enabled: string; + disabled: string; + nextStep: string; + permissions: string; + risk: string; + version: string; + source: string; + installPath: string; + noInstallPath: string; + actions: Record; + states: Record; + types: Record; + families: Record; + next: Record; + toast: { + refreshed: string; + actionDone: string; + actionFailed: string; + }; +} + +const TEXT: Record<'zh' | 'en', PluginText> = { + zh: { + title: '插件管理', + description: '统一管理智能体、技能、工具、设备和工作流插件,安装后直接进入对应配置。', + installedAssets: '已安装插件', + discoverPlugins: '发现插件', + assetType: '插件类型', + installSource: '安装来源', + hubSource: 'Flocks Hub', + installed: '已安装', + marketplace: '插件广场', + installedHint: '当前可用、可配置、可更新的插件资产。', + marketplaceHint: '浏览可安装插件,并在安装前查看权限和风险。', + searchPlaceholder: '搜索名称、描述、标签或使用场景', + all: '全部', + refresh: '刷新', + family: 'Flocks 分类', + sections: { + overview: '总览', + tools: '工具', + skills: '技能', + agents: '智能体', + workflows: '工作流', + marketplace: '发现插件', + }, + sectionDescriptions: { + overview: '统一查看插件安装状态,并执行开关、安装、卸载等插件级操作。', + tools: '管理 MCP、API Tool、本地 Python Tool、设备工具等 Flocks 工具能力。', + skills: '管理 Rex 和子 Agent 可加载的技能,包含启用、禁用、依赖安装和编辑。', + agents: '管理子 Agent 配置、能力边界、工具白名单和创建入口。', + workflows: '管理工作流插件、运行配置与自动化编排。', + marketplace: '从 Flocks Hub 浏览可安装插件,安装前查看 manifest、依赖、权限和文件内容。', + }, + emptyTitle: '没有匹配的插件', + emptyHint: '换一个类型或清空搜索条件。', + openWorkspace: '完整页面', + pluginActions: '插件操作', + enabled: '已启用', + disabled: '已停用', + nextStep: '下一步', + permissions: '权限', + risk: '风险', + version: '版本', + source: '来源', + installPath: '安装位置', + noInstallPath: '未安装', + actions: { + install: '安装', + update: '更新', + uninstall: '卸载', + }, + states: { + available: '可安装', + installed: '已安装', + updateAvailable: '可更新', + localOnly: '仅本地', + broken: '异常', + incompatible: '不兼容', + }, + types: { + agent: '智能体', + skill: '技能', + tool: '工具', + device: '设备', + workflow: '工作流', + webui: '界面插件', + component: '场景套件', + }, + families: { + all: '全部分类', + agent: '智能体', + skill: '技能', + mcp: 'MCP', + apiTool: 'API Tool', + pythonTool: 'Python Tool', + generatedTool: 'Generated', + tool: '其他工具', + device: '设备', + workflow: '工作流', + webui: '界面插件', + component: '场景套件', + }, + next: { + agent: '创建会话或调整智能体配置', + skill: '查看触发说明和依赖状态', + tool: '测试工具或配置 API/MCP 服务', + device: '设备接入保留为独立主入口,可在插件安装后添加设备实例并测试凭据', + workflow: '打开工作流并运行验证', + webui: '在场景工作区中打开并验证界面', + component: '查看套件包含的插件并完成场景配置', + }, + toast: { + refreshed: '插件列表已刷新', + actionDone: '操作完成', + actionFailed: '操作失败', + }, + }, + en: { + title: 'Plugin Management', + description: 'Manage agents, skills, tools, devices, and workflow plugins from one workspace.', + installedAssets: 'Installed plugins', + discoverPlugins: 'Discover plugins', + assetType: 'Plugin type', + installSource: 'Install source', + hubSource: 'Flocks Hub', + installed: 'Installed', + marketplace: 'Marketplace', + installedHint: 'Assets that are ready to use, configure, or update.', + marketplaceHint: 'Browse installable plugins and inspect permissions before installing.', + searchPlaceholder: 'Search names, descriptions, tags, or use cases', + all: 'All', + refresh: 'Refresh', + family: 'Flocks family', + sections: { + overview: 'Overview', + tools: 'Tools', + skills: 'Skills', + agents: 'Agents', + workflows: 'Workflows', + marketplace: 'Discover', + }, + sectionDescriptions: { + overview: 'Review plugin install state and run plugin-level enable, install, and uninstall actions.', + tools: 'Manage MCP, API Tool, local Python Tool, device tools, and other Flocks tool capabilities.', + skills: 'Manage skills that Rex and sub-agents can load, including enablement, dependencies, and editing.', + agents: 'Manage sub-agent configuration, boundaries, tool allowlists, and creation flows.', + workflows: 'Manage workflow plugins, run configuration, and automation orchestration.', + marketplace: 'Browse Flocks Hub plugins and inspect manifests, dependencies, permissions, and files before installing.', + }, + emptyTitle: 'No matching plugins', + emptyHint: 'Try another type or clear the search.', + openWorkspace: 'Full page', + pluginActions: 'Plugin actions', + enabled: 'Enabled', + disabled: 'Disabled', + nextStep: 'Next step', + permissions: 'Permissions', + risk: 'Risk', + version: 'Version', + source: 'Source', + installPath: 'Install path', + noInstallPath: 'Not installed', + actions: { + install: 'Install', + update: 'Update', + uninstall: 'Uninstall', + }, + states: { + available: 'Available', + installed: 'Installed', + updateAvailable: 'Update available', + localOnly: 'Local only', + broken: 'Broken', + incompatible: 'Incompatible', + }, + types: { + agent: 'Agent', + skill: 'Skill', + tool: 'Tool', + device: 'Device', + workflow: 'Workflow', + webui: 'Web UI', + component: 'Scenario suite', + }, + families: { + all: 'All families', + agent: 'Agent', + skill: 'Skill', + mcp: 'MCP', + apiTool: 'API Tool', + pythonTool: 'Python Tool', + generatedTool: 'Generated', + tool: 'Other tools', + device: 'Device', + workflow: 'Workflow', + webui: 'Web UI', + component: 'Scenario suite', + }, + next: { + agent: 'Create a session or adjust agent settings', + skill: 'Review trigger instructions and dependency status', + tool: 'Test the tool or configure API/MCP services', + device: 'Device Integration remains a primary entry. Add instances and test credentials there after installing plugins', + workflow: 'Open the workflow and validate a run', + webui: 'Open and validate the UI in Scene Workspaces', + component: 'Review bundled plugins and finish scenario configuration', + }, + toast: { + refreshed: 'Plugin list refreshed', + actionDone: 'Action complete', + actionFailed: 'Action failed', + }, + }, +}; + +const TYPE_ORDER: HubPluginType[] = ['agent', 'skill', 'tool', 'device', 'workflow', 'webui', 'component']; +const FAMILY_ORDER: PluginFamily[] = ['agent', 'skill', 'mcp', 'apiTool', 'pythonTool', 'generatedTool', 'tool', 'device', 'workflow', 'webui', 'component']; +const ASSET_SECTION_ORDER: Array> = ['agents', 'skills', 'tools', 'workflows']; + +const TYPE_META: Record = { + agent: { + icon: Bot, + tone: 'bg-cyan-50 text-cyan-700 border-cyan-200 dark:bg-cyan-950/40 dark:text-cyan-200 dark:border-cyan-800', + href: '/agents', + }, + skill: { + icon: FileText, + tone: 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-200 dark:border-emerald-800', + href: '/skills', + }, + tool: { + icon: Wrench, + tone: 'bg-indigo-50 text-indigo-700 border-indigo-200 dark:bg-indigo-950/40 dark:text-indigo-200 dark:border-indigo-800', + href: '/tools', + }, + device: { + icon: ServerCog, + tone: 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/40 dark:text-amber-200 dark:border-amber-800', + href: '/devices', + }, + workflow: { + icon: Workflow, + tone: 'bg-rose-50 text-rose-700 border-rose-200 dark:bg-rose-950/40 dark:text-rose-200 dark:border-rose-800', + href: '/plugins/workflows', + }, + webui: { + icon: Boxes, + tone: 'bg-sky-50 text-sky-700 border-sky-200 dark:bg-sky-950/40 dark:text-sky-200 dark:border-sky-800', + href: '/plugins/marketplace', + }, + component: { + icon: Sparkles, + tone: 'bg-violet-50 text-violet-700 border-violet-200 dark:bg-violet-950/40 dark:text-violet-200 dark:border-violet-800', + href: '/plugins/marketplace', + }, +}; + +function isInstalledState(state: HubCatalogEntry['state']) { + return state === 'installed' || state === 'updateAvailable' || state === 'localOnly' || state === 'broken'; +} + +function actionFor(entry: HubCatalogEntry): ActionKind | null { + if (entry.state === 'available') return 'install'; + if (entry.state === 'updateAvailable') return 'update'; + if (entry.state === 'installed' || entry.state === 'localOnly' || entry.state === 'broken') return 'uninstall'; + return null; +} + +function isZh(language: string) { + return language.toLowerCase().startsWith('zh'); +} + +function resolvePluginSection(section?: string): PluginSection { + if ( + section === 'tools' + || section === 'skills' + || section === 'agents' + || section === 'workflows' + || section === 'marketplace' + ) { + return section; + } + return 'agents'; +} + +function descriptionFor(entry: HubCatalogEntry, language: string) { + return isZh(language) ? (entry.descriptionCn || entry.description) : (entry.description || entry.descriptionCn || ''); +} + +function riskClass(level: string) { + if (level === 'high') return 'bg-red-50 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-200 dark:border-red-800'; + if (level === 'medium') return 'bg-amber-50 text-amber-700 border-amber-200 dark:bg-amber-950/40 dark:text-amber-200 dark:border-amber-800'; + return 'bg-zinc-50 text-zinc-600 border-zinc-200 dark:bg-zinc-900 dark:text-zinc-300 dark:border-zinc-700'; +} + +function stateClass(state: string) { + if (state === 'installed') return 'bg-emerald-50 text-emerald-700 border-emerald-200 dark:bg-emerald-950/40 dark:text-emerald-200 dark:border-emerald-800'; + if (state === 'updateAvailable') return 'bg-blue-50 text-blue-700 border-blue-200 dark:bg-blue-950/40 dark:text-blue-200 dark:border-blue-800'; + if (state === 'broken') return 'bg-red-50 text-red-700 border-red-200 dark:bg-red-950/40 dark:text-red-200 dark:border-red-800'; + if (state === 'incompatible') return 'bg-zinc-100 text-zinc-400 border-zinc-200 dark:bg-zinc-900 dark:text-zinc-500 dark:border-zinc-800'; + return 'bg-white text-zinc-600 border-zinc-200 dark:bg-zinc-900 dark:text-zinc-300 dark:border-zinc-700'; +} + +function pluginKey(entry: HubCatalogEntry) { + return `${entry.type}:${entry.id}`; +} + +function pluginFamily(entry: HubCatalogEntry): PluginFamily { + if (entry.type !== 'tool') return entry.type; + const path = `${entry.manifestPath || ''} ${entry.installPath || ''}`.toLowerCase(); + const tags = entry.tags.map(tag => tag.toLowerCase()); + if (path.includes('/mcp/') || tags.includes('mcp')) return 'mcp'; + if (path.includes('/api/') || tags.includes('api')) return 'apiTool'; + if (path.includes('/python/') || tags.includes('python')) return 'pythonTool'; + if (path.includes('/generated/') || tags.includes('generated')) return 'generatedTool'; + return 'tool'; +} + +function buildCounts(items: HubCatalogEntry[]) { + const counts: Record = { + all: items.length, + agent: 0, + skill: 0, + tool: 0, + device: 0, + workflow: 0, + webui: 0, + component: 0, + }; + items.forEach(item => { + counts[item.type] += 1; + }); + return counts; +} + +function buildFamilyCounts(items: HubCatalogEntry[]) { + const counts: Record = { + all: items.length, + agent: 0, + skill: 0, + mcp: 0, + apiTool: 0, + pythonTool: 0, + generatedTool: 0, + tool: 0, + device: 0, + workflow: 0, + webui: 0, + component: 0, + }; + items.forEach(item => { + counts[pluginFamily(item)] += 1; + }); + return counts; +} + +export default function PluginManagerPage() { + const params = useParams(); + const { i18n } = useTranslation(); + const text = isZh(i18n.language) ? TEXT.zh : TEXT.en; + const sectionParam = params.section; + const activeSection = resolvePluginSection(sectionParam); + const activeMode: PluginMode = activeSection === 'marketplace' ? 'discover' : 'assets'; + const { success: showSuccess, error: showError } = useToast(); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [view, setView] = useState('installed'); + const [typeFilter, setTypeFilter] = useState('all'); + const [familyFilter, setFamilyFilter] = useState('all'); + const [query, setQuery] = useState(''); + const [selected, setSelected] = useState(null); + const [actionKey, setActionKey] = useState(null); + + const loadCatalog = async ({ silent = false }: { silent?: boolean } = {}) => { + try { + if (!silent) setLoading(true); + const res = await hubAPI.catalog(); + const nextItems = Array.isArray(res.data) ? res.data : []; + setItems(nextItems); + setSelected(current => { + if (!current) return current; + return nextItems.find(item => pluginKey(item) === pluginKey(current)) ?? current; + }); + return nextItems; + } finally { + if (!silent) setLoading(false); + } + }; + + useEffect(() => { + if (activeSection === 'overview' && items.length === 0) { + void loadCatalog(); + } + }, [activeSection, items.length]); + + const baseItems = useMemo( + () => items.filter(item => (view === 'installed' ? isInstalledState(item.state) : item.state !== 'installed')), + [items, view], + ); + + const counts = useMemo(() => buildCounts(baseItems), [baseItems]); + const familyCounts = useMemo(() => buildFamilyCounts(baseItems), [baseItems]); + const installedCount = useMemo(() => items.filter(item => isInstalledState(item.state)).length, [items]); + const updateCount = useMemo(() => items.filter(item => item.state === 'updateAvailable').length, [items]); + const availableCount = useMemo(() => items.filter(item => item.state === 'available').length, [items]); + + const filteredItems = useMemo(() => { + const normalizedQuery = query.trim().toLowerCase(); + return baseItems.filter(item => { + if (typeFilter !== 'all' && item.type !== typeFilter) return false; + if (familyFilter !== 'all' && pluginFamily(item) !== familyFilter) return false; + if (!normalizedQuery) return true; + const haystack = [ + item.id, + item.name, + item.description, + item.descriptionCn ?? '', + item.category, + item.source, + ...item.tags, + ...item.useCases, + ...item.capabilities, + ].join(' ').toLowerCase(); + return haystack.includes(normalizedQuery); + }); + }, [baseItems, familyFilter, query, typeFilter]); + + const selectedAction = selected ? actionFor(selected) : null; + + useEffect(() => { + if (!selected) return; + if (!filteredItems.some(item => pluginKey(item) === pluginKey(selected))) { + setSelected(null); + } + }, [filteredItems, selected]); + + const runAction = async (entry: HubCatalogEntry, action: ActionKind) => { + const key = `${pluginKey(entry)}:${action}`; + setActionKey(key); + try { + if (action === 'install') await hubAPI.install(entry.type, entry.id); + if (action === 'update') await hubAPI.update(entry.type, entry.id); + if (action === 'uninstall') await hubAPI.uninstall(entry.type, entry.id); + const nextItems = await loadCatalog({ silent: true }); + const updated = nextItems?.find(item => pluginKey(item) === pluginKey(entry)); + if (updated) setSelected(updated); + showSuccess(text.toast.actionDone); + } catch (err) { + showError(text.toast.actionFailed, err instanceof Error ? err.message : undefined); + } finally { + setActionKey(null); + } + }; + + const runEnabledAction = async (entry: HubCatalogEntry, enabled: boolean) => { + const key = `${pluginKey(entry)}:enabled`; + setActionKey(key); + try { + await hubAPI.setEnabled(entry.type, entry.id, enabled); + const nextItems = await loadCatalog({ silent: true }); + const updated = nextItems?.find(item => pluginKey(item) === pluginKey(entry)); + if (updated) setSelected(updated); + showSuccess(text.toast.actionDone); + } catch (err) { + showError(text.toast.actionFailed, err instanceof Error ? err.message : undefined); + } finally { + setActionKey(null); + } + }; + + if (activeSection === 'overview' && loading) { + return
; + } + + return ( +
+
+
+
+
+ +
+
+
+

{text.title}

+ {text.sections[activeSection]} +
+

+ {text.sectionDescriptions[activeSection]} +

+
+
+ +
+
+
+ {activeMode === 'assets' ? text.assetType : text.installSource} +
+ {activeMode === 'assets' ? ( + + ) : ( + + )} +
+
+ +
+ {activeSection !== 'overview' ? ( +
+
}> + + + + ) : ( + <> +
+
+
+
+
+ {view === 'installed' ? text.installed : text.marketplace} +
+
+ {view === 'installed' ? text.installedHint : text.marketplaceHint} +
+
+
+ {(['installed', 'marketplace'] as PluginView[]).map(tab => ( + + ))} +
+
+
+ } /> + } /> + } /> +
+
+ +
+
+ + setQuery(event.target.value)} + placeholder={text.searchPlaceholder} + className="w-full rounded-lg border border-zinc-300 bg-white py-1.5 pl-9 pr-3 text-sm text-zinc-900 outline-none transition focus:border-zinc-500 focus:ring-2 focus:ring-zinc-200 dark:border-zinc-700 dark:bg-zinc-950 dark:text-zinc-50 dark:focus:border-zinc-500 dark:focus:ring-zinc-800" + /> +
+
+ setTypeFilter('all')} + /> + {TYPE_ORDER.map(type => ( + setTypeFilter(type)} + /> + ))} +
+
+
{text.family}
+
+ setFamilyFilter('all')} + /> + {FAMILY_ORDER.map(family => ( + setFamilyFilter(family)} + /> + ))} +
+
+
+
+ +
+
+ {filteredItems.length === 0 ? ( +
+ +
{text.emptyTitle}
+
{text.emptyHint}
+
+ ) : ( +
+ {filteredItems.map(entry => ( + setSelected(entry)} + onAction={(action) => void runAction(entry, action)} + onToggleEnabled={(enabled) => void runEnabledAction(entry, enabled)} + /> + ))} +
+ )} +
+ + {selected && ( + + )} +
+ + )} +
+
+ ); +} + +function PluginModeNav({ activeMode, text }: { activeMode: PluginMode; text: PluginText }) { + const modes: Array<{ mode: PluginMode; label: string; href: string }> = [ + { mode: 'assets', label: text.installedAssets, href: '/plugins/agents' }, + { mode: 'discover', label: text.discoverPlugins, href: '/plugins/marketplace' }, + ]; + + return ( + + ); +} + +function PluginAssetNav({ activeSection, text }: { activeSection: PluginSection; text: PluginText }) { + return ( + + ); +} + +function PluginSourceNav({ text }: { text: PluginText }) { + return ( +
+ + {text.hubSource} + +
+ ); +} + +function PluginSectionContent({ section }: { section: PluginSection }) { + if (section === 'tools') return ; + if (section === 'skills') return ; + if (section === 'agents') return ; + if (section === 'workflows') return ; + if (section === 'marketplace') return ; + return null; +} + +function Metric({ label, value, icon }: { label: string; value: number; icon: React.ReactNode }) { + return ( +
+
+ {icon} + {label} +
+
{value}
+
+ ); +} + +function TypeFilterButton({ + active, + label, + count, + type, + onClick, +}: { + active: boolean; + label: string; + count: number; + type?: HubPluginType; + onClick: () => void; +}) { + const Icon = type ? TYPE_META[type].icon : Boxes; + return ( + + ); +} + +function FamilyFilterButton({ + active, + label, + count, + onClick, +}: { + active: boolean; + label: string; + count: number; + onClick: () => void; +}) { + return ( + + ); +} + +function ToggleEnabledButton({ + enabled, + loading, + text, + onClick, +}: { + enabled: boolean; + loading: boolean; + text: PluginText; + onClick: () => void; +}) { + return ( + + ); +} + +function PluginRow({ + entry, + text, + language, + selected, + actionKey, + onSelect, + onAction, + onToggleEnabled, +}: { + entry: HubCatalogEntry; + text: PluginText; + language: string; + selected: boolean; + actionKey: string | null; + onSelect: () => void; + onAction: (action: ActionKind) => void; + onToggleEnabled: (enabled: boolean) => void; +}) { + const action = actionFor(entry); + const running = action ? actionKey === `${pluginKey(entry)}:${action}` : false; + const enableRunning = actionKey === `${pluginKey(entry)}:enabled`; + return ( +
+ +
+ {isInstalledState(entry.state) && !entry.native && ( + onToggleEnabled(!entry.enabled)} + /> + )} + {action && ( + + )} +
+
+ ); +} + +function PluginIdentity({ entry, text, language }: { entry: HubCatalogEntry; text: PluginText; language: string }) { + const meta = TYPE_META[entry.type]; + const Icon = meta.icon; + return ( +
+
+ +
+
+
+

{entry.name}

+ + {text.types[entry.type]} + +
+
+ {entry.id} + {entry.version} + {entry.descriptionCn && !isZh(language) ? CN : null} +
+
+
+ ); +} + +function InfoTile({ label, value }: { label: string; value?: string | null }) { + return ( +
+
{label}
+
{value || '-'}
+
+ ); +} + +function InfoBlock({ label, value, danger = false }: { label: string; value: string; danger?: boolean }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +function actionIcon(action: ActionKind) { + if (action === 'uninstall') return ; + if (action === 'update') return ; + return ; +} diff --git a/webui/src/pages/Session/index.test.tsx b/webui/src/pages/Session/index.test.tsx index 6cecc37c6..5ac0a98ec 100644 --- a/webui/src/pages/Session/index.test.tsx +++ b/webui/src/pages/Session/index.test.tsx @@ -570,7 +570,7 @@ describe('SessionPage session actions menu', () => { expect(screen.getByText('Original Session')).toBeInTheDocument(); }); - it('keeps the workbench canvas, sidebar, selected row, and dark palette classes stable', async () => { + it('renders the selected session full-width without the legacy secondary navigation', async () => { renderSessionPage('/sessions?session=session-1'); const workbenchSidebar = screen.getByLabelText('managementTitle'); @@ -580,11 +580,8 @@ describe('SessionPage session actions menu', () => { const selectedRow = sessionTitle.closest('div.group'); expect(workbenchCanvas).toHaveClass('bg-[#fcfcfd]', 'dark:bg-[#303842]'); - expect(workbenchSidebar).toHaveClass('bg-gray-50', 'dark:bg-[#252c35]'); - expect(workbenchSidebar).toHaveClass('h-full', 'border-r'); - expect(workbenchSidebar).toHaveClass('border-black/[0.10]'); - expect(workbenchSidebar).not.toHaveClass('rounded-2xl'); - expect(workbenchSidebar.className).not.toContain('shadow-'); + expect(workbenchSidebar).toHaveClass('session-workbench-portal'); + expect(screen.queryByRole('button', { name: 'hideHistory' })).not.toBeInTheDocument(); expect(mainCanvas).toHaveClass('bg-[#fcfcfd]', 'dark:bg-[#303842]'); expect(within(mainCanvas as HTMLElement).getByRole('heading', { level: 2 })).toHaveClass('text-[#555a61]'); await waitFor(() => { diff --git a/webui/src/pages/Session/index.tsx b/webui/src/pages/Session/index.tsx index f88cb914e..9028aa668 100644 --- a/webui/src/pages/Session/index.tsx +++ b/webui/src/pages/Session/index.tsx @@ -1,8 +1,9 @@ -import { memo, useState, useEffect, useMemo, useCallback, useRef, type RefObject } from 'react'; +import { memo, useState, useEffect, useMemo, useCallback, useRef, type ReactNode, type RefObject } from 'react'; +import { createPortal } from 'react-dom'; import { Plus, Trash2, Archive, ChevronDown, ChevronRight, Sparkles, Shield, Search, AlertTriangle, - PanelLeftClose, PanelLeft, Bot, Loader2, + Bot, Loader2, Workflow as WorkflowIcon, Settings2, CheckSquare, MoreHorizontal, PencilLine, Download, Share2, Cpu, Info, X, FolderGit2, FolderPlus, FolderOpen, Copy, ArrowUp, HardDrive, @@ -62,11 +63,22 @@ const SESSION_PAGE_VISITED_STORAGE_KEY = 'flocks:sessions:visited'; const SOC_WORKSPACE_COMPONENT_ID = 'soc-workspace'; const INSTALLED_HUB_STATES = new Set(['installed', 'localOnly', 'updateAvailable']); const SESSION_UPDATE_REFETCH_DEBOUNCE_MS = 500; +const WORKBENCH_NAVIGATION_REFRESH_EVENT = 'flocks:workbench-navigation-refresh'; const AUTO_MODEL_KEY = '__flocks_auto__'; const TASK_SESSION_GROUP_ID = 'tasks'; const SESSION_EXECUTION_MODES: SessionExecutionMode[] = ['build', 'plan', 'goal']; type AgentSourceFilter = 'all' | 'builtin' | 'custom'; +function WorkbenchNavigationPortal({ + target, + children, +}: { + target: HTMLElement | null; + children: ReactNode; +}) { + return target ? createPortal(children, target) : children; +} + function ExecutionModeIcon({ mode, className = 'h-3 w-3', @@ -480,7 +492,7 @@ export default function SessionPage() { const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); const [selectedSessionId, setSelectedSessionId] = useState(null); - const [sidebarCollapsed, setSidebarCollapsed] = useState(false); + const [workbenchNavigationTarget, setWorkbenchNavigationTarget] = useState(null); const [selectedAgent, setSelectedAgent] = useState('rex'); const [showAgentOptions, setShowAgentOptions] = useState(false); const [selectedExecutionMode, setSelectedExecutionMode] = useState( @@ -566,6 +578,10 @@ export default function SessionPage() { const projectListRequestSeqRef = useRef(0); const toast = useToast(); + useEffect(() => { + setWorkbenchNavigationTarget(document.getElementById('ai-workbench-navigation-slot')); + }, []); + const sessionProjectIds = useMemo( () => [TASK_SESSION_GROUP_ID, ...projects.map((project) => project.id)], [projects], @@ -838,6 +854,10 @@ export default function SessionPage() { const canCollapseTaskSessions = !taskSessionsCollapsedToFirstPage && taskSessionGroup.sessions.length > sessionListPageSize; + useEffect(() => { + window.dispatchEvent(new Event(WORKBENCH_NAVIGATION_REFRESH_EVENT)); + }, [projects, sessions]); + const selectedProjectIDForCreate = selectedProjectId && selectedProjectId !== TASK_SESSION_GROUP_ID ? selectedProjectId : null; @@ -2002,12 +2022,9 @@ export default function SessionPage() { return (
{/* ── Sidebar ── */} +
{/* Header:始终显示标题、新建与搜索 */} @@ -2424,22 +2441,12 @@ export default function SessionPage() {
)}
+ {/* ── Main area ── */}
{/* Header */}
-
- -
-

{selectedSession?.title || t('newSession')} diff --git a/webui/src/pages/Settings/index.test.tsx b/webui/src/pages/Settings/index.test.tsx index a9ded5cb3..9ce3835cd 100644 --- a/webui/src/pages/Settings/index.test.tsx +++ b/webui/src/pages/Settings/index.test.tsx @@ -154,17 +154,26 @@ describe('SettingsPage', () => { }); }); - it('redirects legacy model and channel settings URLs to workspace pages', async () => { + it('redirects legacy model and channel settings URLs into preferences', async () => { const { unmount } = renderSettings('/settings/models'); expect(await screen.findByText('models page')).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'models' })).not.toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'channels' })).not.toBeInTheDocument(); + expect(screen.getAllByRole('link', { name: 'models' })[0]).toHaveAttribute( + 'href', + '/settings/preferences?tab=models', + ); + expect(screen.getAllByRole('link', { name: 'models' })[0]).toHaveClass('bg-zinc-100'); + expect(screen.getByRole('heading', { name: 'settingsGroupIntegrations' })).toBeInTheDocument(); + expect(screen.getAllByRole('link', { name: 'channels' })[0]).toHaveAttribute( + 'href', + '/settings/preferences?tab=channels', + ); unmount(); renderSettings('/settings/channels'); expect(await screen.findByText('channels page')).toBeInTheDocument(); + expect(screen.getAllByRole('link', { name: 'channels' })[0]).toHaveClass('bg-zinc-100'); }); it('returns to the page captured before opening settings', async () => { @@ -198,8 +207,14 @@ describe('SettingsPage', () => { const mobileNav = screen.getByRole('navigation', { name: 'settingsTitle' }); expect(within(mobileNav).getByRole('link', { name: 'accountManagement' })).toHaveAttribute('href', '/settings/account'); expect(within(mobileNav).getByRole('link', { name: 'auditLogs' })).toHaveAttribute('href', '/settings/audit-logs'); - expect(within(mobileNav).queryByRole('link', { name: 'models' })).not.toBeInTheDocument(); - expect(within(mobileNav).queryByRole('link', { name: 'channels' })).not.toBeInTheDocument(); + expect(within(mobileNav).getByRole('link', { name: 'models' })).toHaveAttribute( + 'href', + '/settings/preferences?tab=models', + ); + expect(within(mobileNav).getByRole('link', { name: 'channels' })).toHaveAttribute( + 'href', + '/settings/preferences?tab=channels', + ); }); it('renders audit logs in settings for Flocks Pro admins', async () => { diff --git a/webui/src/pages/Settings/index.tsx b/webui/src/pages/Settings/index.tsx index 7ab6bbd67..11d9c963e 100644 --- a/webui/src/pages/Settings/index.tsx +++ b/webui/src/pages/Settings/index.tsx @@ -6,11 +6,13 @@ import { ArrowLeft, ArrowUpCircle, Archive, + Brain, Check, ImageIcon, Languages, Moon, RotateCcw, + Radio, ScrollText, Save, Settings as SettingsIcon, @@ -47,8 +49,18 @@ const SystemLogPage = lazySettingsPage(() => import('@/pages/SystemLog')); const FlocksproUpgradePage = lazySettingsPage(() => import('@/pages/FlocksproUpgrade'), ['flockspro']); const AuditLogsPage = lazySettingsPage(() => import('@/pages/AuditLogs'), ['flockspro']); const ArchivedDataPage = lazySettingsPage(() => import('./ArchivedDataPanel'), ['session']); - -type SettingsSectionId = 'preferences' | 'archived-data' | 'account' | 'system-logs' | 'audit-logs' | 'flockspro'; +const ModelPage = lazySettingsPage(() => import('@/pages/Model'), ['model']); +const ChannelPage = lazySettingsPage(() => import('@/pages/Channel'), ['channel']); + +type SettingsSectionId = + | 'preferences' + | 'models' + | 'channels' + | 'archived-data' + | 'account' + | 'system-logs' + | 'audit-logs' + | 'flockspro'; interface ReturnLocation { pathname: string; @@ -64,6 +76,7 @@ interface SettingsSection { id: SettingsSectionId; name: string; icon: LucideIcon; + href?: string; adminOnly?: boolean; requiresFlockspro?: boolean; } @@ -76,6 +89,8 @@ interface SettingsGroup { function isSettingsSectionId(value: string | undefined): value is SettingsSectionId { return ( value === 'preferences' || + value === 'models' || + value === 'channels' || value === 'archived-data' || value === 'account' || value === 'system-logs' || @@ -192,8 +207,11 @@ function PreferenceSwitch({ ); } +type PreferencesTab = 'general' | 'models' | 'channels'; + function PreferencesPanel() { const { t, i18n } = useTranslation('nav'); + const location = useLocation(); const { theme, setTheme } = useContext(ThemeContext); const { productName, @@ -216,6 +234,10 @@ function PreferencesPanel() { const normalizedDisplayName = displayNameDraft.trim(); const displayNameChanged = normalizedDisplayName !== (configuredDisplayName ?? ''); const toolFailureSettingLoadFailedMessage = t('toolFailureSettingLoadFailed'); + const requestedTab = new URLSearchParams(location.search).get('tab'); + const activeTab: PreferencesTab = requestedTab === 'models' || requestedTab === 'channels' + ? requestedTab + : 'general'; useEffect(() => { setDisplayNameDraft(configuredDisplayName ?? ''); @@ -318,6 +340,18 @@ function PreferencesPanel() { } }; + if (activeTab !== 'general') { + return ( +
+
+ }> + {activeTab === 'models' ? : } + +
+
+ ); + } + return (
@@ -548,6 +582,23 @@ export default function SettingsPage() { { id: 'preferences', name: t('settingsPreferences'), icon: SettingsIcon }, ], }, + { + name: t('settingsGroupIntegrations'), + items: [ + { + id: 'models', + name: t('models'), + href: '/settings/preferences?tab=models', + icon: Brain, + }, + { + id: 'channels', + name: t('channels'), + href: '/settings/preferences?tab=channels', + icon: Radio, + }, + ], + }, { name: t('settingsGroupData'), items: [ @@ -583,18 +634,27 @@ export default function SettingsPage() { } if (sectionId === 'models') { - return ; + return ; } if (sectionId === 'channels') { - return ; + return ; } if (!isSettingsSectionId(sectionId)) { return ; } - const currentSection = visibleGroups.flatMap((group) => group.items).find((item) => item.id === sectionId); + const requestedPreferenceTab = new URLSearchParams(location.search).get('tab'); + const activeNavigationSectionId: SettingsSectionId = ( + sectionId === 'preferences' + && (requestedPreferenceTab === 'models' || requestedPreferenceTab === 'channels') + ) + ? requestedPreferenceTab + : sectionId; + const currentSection = visibleGroups + .flatMap((group) => group.items) + .find((item) => item.id === activeNavigationSectionId); if (!currentSection) { return ; @@ -624,11 +684,11 @@ export default function SettingsPage() {
{group.items.map((item) => { const Icon = item.icon; - const active = item.id === sectionId; + const active = item.id === activeNavigationSectionId; return ( {visibleGroups.flatMap((group) => group.items).map((item) => { const Icon = item.icon; - const active = item.id === sectionId; + const active = item.id === activeNavigationSectionId; return ( ([]); const [loading, setLoading] = useState(true); @@ -295,11 +299,13 @@ export default function SkillPage() { return (
- } - /> + {!embedded && ( + } + /> + )} {/* Toolbar: 搜索 · 来源 chips · 刷新/安装/创建 */}
diff --git a/webui/src/pages/Tool/index.tsx b/webui/src/pages/Tool/index.tsx index 145828e34..ccc7d0d0a 100644 --- a/webui/src/pages/Tool/index.tsx +++ b/webui/src/pages/Tool/index.tsx @@ -158,7 +158,11 @@ function mergeFacetKeys(facets: Record, activeValues: Set - {/* Page Header */} -
-
-
- -
-
-

{t('pageTitle')}

-

- {enabledSummary.active} {t('statusBadge.active')} - · - {enabledSummary.inactive} {t('statusBadge.inactive')} -

-
-
-
- - - - -
-
- - {/* Tabs row with search */} -
-
- - - {/* Search - right aligned, same row */} -
- +
+ {embedded ? ( +
+
+ handleSearchChange(e.target.value)} - className="w-64 pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white" + className="w-full rounded-lg border border-gray-200 bg-white py-1.5 pl-9 pr-3 text-sm outline-none focus:border-slate-400 focus:ring-1 focus:ring-slate-300" />
+ +
+ {TABS.map((tab) => { + const active = activeTab === tab.key; + return ( + + ); + })} +
+ +
+ + + + +
-
+ ) : ( + <> + {/* Page Header */} +
+
+
+ +
+
+

{t('pageTitle')}

+

+ {tools.length} {t('statusBadge.active')} + · + {catalogEntries.length} {t('statusBadge.inactive')} +

+
+
+
+ + + + +
+
+ + {/* Tabs row with search */} +
+
+ + + {/* Search - right aligned, same row */} +
+ + handleSearchChange(e.target.value)} + className="w-64 pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-slate-400 focus:border-transparent bg-white" + /> +
+
+
+ + )} {error && (
diff --git a/webui/src/pages/Workflow/index.tsx b/webui/src/pages/Workflow/index.tsx index ae175ad8a..c941ea98d 100644 --- a/webui/src/pages/Workflow/index.tsx +++ b/webui/src/pages/Workflow/index.tsx @@ -77,7 +77,11 @@ const CAPABILITY_DETAIL_LABEL_KEYS = { // WorkflowPage // --------------------------------------------------------------------------- -export default function WorkflowPage() { +interface WorkflowPageProps { + embedded?: boolean; +} + +export default function WorkflowPage({ embedded = false }: WorkflowPageProps = {}) { const { t } = useTranslation('workflow'); const navigate = useNavigate(); const { workflows, loading, error, refetch } = useWorkflows(); @@ -148,14 +152,16 @@ export default function WorkflowPage() { return (
- } - // Refresh / create actions intentionally moved to the toolbar below so - // the page header stays uniform with Skill/Agent pages and the - // segmented source filter shares a row with its primary actions. - /> + {!embedded && ( + } + // Refresh / create actions intentionally moved to the toolbar below so + // the page header stays uniform with Skill/Agent pages and the + // segmented source filter shares a row with its primary actions. + /> + )} {/* Toolbar */}
diff --git a/webui/src/routes/index.tsx b/webui/src/routes/index.tsx index 72e3113b1..02a8ffef1 100644 --- a/webui/src/routes/index.tsx +++ b/webui/src/routes/index.tsx @@ -33,20 +33,14 @@ function lazyPage( } const SessionPage = lazyPage(() => import('@/pages/Session'), ['session']); -const AgentPage = lazyPage(() => import('@/pages/Agent'), ['agent']); const LoginPage = lazyPage(() => import('@/pages/Login')); const SetupAdminPage = lazyPage(() => import('@/pages/SetupAdmin')); const ForceChangePasswordPage = lazyPage(() => import('@/pages/ForceChangePassword')); -const WorkflowListPage = lazyPage(() => import('@/pages/Workflow'), ['workflow']); const WorkflowCreate = lazyPage(() => import('@/pages/WorkflowCreate'), ['workflow']); const WorkflowEditor = lazyPage(() => import('@/pages/WorkflowEditor'), ['workflow']); const WorkflowDetail = lazyPage(() => import('@/pages/WorkflowDetail'), ['workflow']); const TaskPage = lazyPage(() => import('@/pages/Task'), ['task']); -const ToolPage = lazyPage(() => import('@/pages/Tool'), ['tool']); -const HubPage = lazyPage(() => import('@/pages/Hub')); -const SkillPage = lazyPage(() => import('@/pages/Skill'), ['skill']); -const ModelPage = lazyPage(() => import('@/pages/Model'), ['model']); -const ChannelPage = lazyPage(() => import('@/pages/Channel'), ['channel']); +const PluginManagerPage = lazyPage(() => import('@/pages/PluginManager')); const PermissionPage = lazyPage(() => import('@/pages/Permission'), ['permission']); const MonitoringPage = lazyPage(() => import('@/pages/Monitoring'), ['monitoring']); const WorkspacePage = lazyPage(() => import('@/pages/Workspace'), ['workspace']); @@ -166,8 +160,8 @@ export function Routes() { {/* AI 工作台 */} } /> - } /> - } /> + } /> + } /> } /> } /> } /> @@ -178,17 +172,19 @@ export function Routes() { } /> {/* Agent Smith */} - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> {/* MCP 已整合到工具清单页面 */} - } /> + } /> } /> } /> } /> - } /> + } /> } /> } /> } />