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 (
+
+
+
+
+ {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 已整合到工具清单页面 */}
- } />
+ } />
} />
} />
} />
- } />
+ } />
} />
} />
} />