From 323c8d780891bb64524acdc43d913519e725db29 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Wed, 1 Jul 2026 13:17:52 +0800 Subject: [PATCH 1/7] feat: consolidate plugin management UI --- flocks/hub/catalog.py | 4 + flocks/hub/installer.py | 27 + flocks/hub/local.py | 21 + flocks/hub/models.py | 1 + flocks/server/routes/hub.py | 18 +- tests/hub/test_hub_catalog.py | 11 + webui/src/api/hub.ts | 4 + webui/src/components/layout/Layout.tsx | 10 +- webui/src/locales/en-US/nav.json | 1 + webui/src/locales/zh-CN/nav.json | 1 + webui/src/pages/Agent/index.tsx | 175 +++- webui/src/pages/Hub/index.tsx | 81 +- webui/src/pages/PluginManager/index.tsx | 1011 +++++++++++++++++++++++ webui/src/pages/Skill/index.tsx | 18 +- webui/src/pages/Tool/index.tsx | 255 ++++-- webui/src/routes/index.tsx | 17 +- 16 files changed, 1500 insertions(+), 155 deletions(-) create mode 100644 webui/src/pages/PluginManager/index.tsx diff --git a/flocks/hub/catalog.py b/flocks/hub/catalog.py index 5378d3aae..036fa5cfb 100644 --- a/flocks/hub/catalog.py +++ b/flocks/hub/catalog.py @@ -563,6 +563,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, @@ -616,6 +617,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, @@ -640,6 +642,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), @@ -704,6 +707,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 7f37d2b9a..c871994dc 100644 --- a/flocks/hub/installer.py +++ b/flocks/hub/installer.py @@ -180,6 +180,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 68e4ddc3a..861e3a1bc 100644 --- a/flocks/hub/local.py +++ b/flocks/hub/local.py @@ -79,6 +79,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 8227336ea..b1cd983de 100644 --- a/flocks/hub/models.py +++ b/flocks/hub/models.py @@ -126,6 +126,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 e2bc03d47..f089aab6b 100644 --- a/flocks/server/routes/hub.py +++ b/flocks/server/routes/hub.py @@ -9,7 +9,7 @@ from flocks.hub.catalog import category_counts, legacy_removed_plugin_message, list_catalog, load_manifest 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, @@ -29,6 +29,10 @@ class HubInstallRequest(BaseModel): scope: str = Field(default="global", description="'global' only") +class HubEnableRequest(BaseModel): + enabled: bool + + def _split_csv(value: Optional[str | list[str]]) -> Optional[list[str]]: if value is None: return None @@ -125,6 +129,18 @@ async def hub_update_plugin(plugin_type: PluginType, plugin_id: str, req: HubIns 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, plugin_id: str): _guard_legacy_removed_plugin(plugin_type, plugin_id) diff --git a/tests/hub/test_hub_catalog.py b/tests/hub/test_hub_catalog.py index ae4911a1d..9917d1043 100644 --- a/tests/hub/test_hub_catalog.py +++ b/tests/hub/test_hub_catalog.py @@ -192,6 +192,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 e9a93aa1e..569d73c75 100644 --- a/webui/src/api/hub.ts +++ b/webui/src/api/hub.ts @@ -25,6 +25,7 @@ export interface HubCatalogEntry { riskLevel: string; state: HubPluginState; installedVersion?: string; + enabled: boolean; source: string; manifestPath: string; installPath?: string; @@ -106,6 +107,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/Layout.tsx b/webui/src/components/layout/Layout.tsx index 982265ab6..a67dc2f2d 100644 --- a/webui/src/components/layout/Layout.tsx +++ b/webui/src/components/layout/Layout.tsx @@ -2,12 +2,9 @@ import { Outlet, Link, useLocation, matchPath } from 'react-router-dom'; import { Home, MessageSquare, - Bot, Workflow, ListTodo, - Wrench, Brain, - BookOpen, X, ChevronLeft, ChevronRight, @@ -17,7 +14,7 @@ import { Sparkles, ArrowUpCircle, UserCog, - Archive, + PackageCheck, ServerCog, ScrollText, ShieldCheck, @@ -436,11 +433,8 @@ export default function Layout() { { name: t('agentHub'), items: [ - { name: t('agents'), href: '/agents', icon: Bot }, - { name: t('skills'), href: '/skills', icon: BookOpen }, - { name: t('tools'), href: '/tools', icon: Wrench }, + { name: t('plugins'), href: '/plugins', icon: PackageCheck }, { name: t('deviceIntegration'), href: '/devices', icon: ServerCog }, - { name: t('hub'), href: '/hub', icon: Archive }, { name: t('models'), href: '/models', icon: Brain }, { name: t('channels'), href: '/channels', icon: Radio }, ], diff --git a/webui/src/locales/en-US/nav.json b/webui/src/locales/en-US/nav.json index 4eb050c04..3bb95e23e 100644 --- a/webui/src/locales/en-US/nav.json +++ b/webui/src/locales/en-US/nav.json @@ -6,6 +6,7 @@ "tasks": "Task Center", "workspace": "Workspace", "agentHub": "Agent Studio", + "plugins": "Plugins", "agents": "Agents", "workflows": "Workflows", "hub": "Flocks Hub", diff --git a/webui/src/locales/zh-CN/nav.json b/webui/src/locales/zh-CN/nav.json index d5759ed70..e941d7429 100644 --- a/webui/src/locales/zh-CN/nav.json +++ b/webui/src/locales/zh-CN/nav.json @@ -6,6 +6,7 @@ "tasks": "任务中心", "workspace": "工作空间", "agentHub": "智能体工作室", + "plugins": "插件管理", "agents": "智能体", "workflows": "工作流", "hub": "插件广场", diff --git a/webui/src/pages/Agent/index.tsx b/webui/src/pages/Agent/index.tsx index 9114144c9..5e4284107 100644 --- a/webui/src/pages/Agent/index.tsx +++ b/webui/src/pages/Agent/index.tsx @@ -48,7 +48,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); @@ -133,17 +137,28 @@ export default function AgentPage() { return (
- } - /> + {!embedded && ( + } + /> + )} {/* Toolbar — mirrors the Skill page toolbar style */}
- - {t('totalCount', { total: primaryAgents.length + subAgents.length })} - + {embedded && primaryAgents[0] && ( + setEditingAgent(primaryAgents[0])} + /> + )} + {!embedded && ( + + {t('totalCount', { total: primaryAgents.length + subAgents.length })} + + )}
+ ); +} + +// ============================================================================ +// Primary Agent Row +// ============================================================================ + +function PrimaryAgentRow({ + agent, + displayLang, + isSelected, + onClick, +}: Pick) { + const { t } = useTranslation('agent'); + const displayName = getAgentDisplayName(agent, displayLang); + const displayDesc = getAgentDisplayDescription(agent, displayLang); + + return ( +
+
+ +
+ +
+
+ {displayName} + + {t('badge.native')} + + {agent.model && ( + + + {agent.model.modelID} + + )} +
+

+ {displayDesc || t('common:empty.noDescription')} +

+
+ +
e.stopPropagation()}> + +
+
+ ); +} + // ============================================================================ // Agent Card // ============================================================================ diff --git a/webui/src/pages/Hub/index.tsx b/webui/src/pages/Hub/index.tsx index 4f53d116c..b918bc363 100644 --- a/webui/src/pages/Hub/index.tsx +++ b/webui/src/pages/Hub/index.tsx @@ -249,7 +249,11 @@ function buildFacetCounts(items: HubCatalogEntry[], filters: HubFilterSnapshot): return counts; } -export default function HubPage() { +interface HubPageProps { + embedded?: boolean; +} + +export default function HubPage({ embedded = false }: HubPageProps = {}) { const { i18n } = useTranslation(); const [searchParams] = useSearchParams(); const text = i18n.language.toLowerCase().startsWith('zh') ? HUB_TEXT.zh : HUB_TEXT.en; @@ -383,39 +387,70 @@ export default function HubPage() { return (
- } - 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" - /> -
+ {embedded ? ( +
+
+ + setQuery(e.target.value)} + 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" + /> +
+
- } - /> +
+ ) : ( + } + 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" + /> +
+ + +
+ } + /> + )}
diff --git a/webui/src/pages/PluginManager/index.tsx b/webui/src/pages/PluginManager/index.tsx new file mode 100644 index 000000000..1e964026a --- /dev/null +++ b/webui/src/pages/PluginManager/index.tsx @@ -0,0 +1,1011 @@ +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'; +type PluginSection = 'overview' | 'tools' | 'skills' | 'agents' | 'marketplace'; + +const ToolPage = lazy(() => import('@/pages/Tool')); +const SkillPage = lazy(() => import('@/pages/Skill')); +const AgentPage = lazy(() => import('@/pages/Agent')); +const HubPage = lazy(() => import('@/pages/Hub')); + +interface PluginText { + title: string; + description: 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: '统一管理智能体、技能、工具、设备和工作流插件,安装后直接进入对应配置。', + installed: '已安装', + marketplace: '插件广场', + installedHint: '当前可用、可配置、可更新的插件资产。', + marketplaceHint: '浏览可安装插件,并在安装前查看权限和风险。', + searchPlaceholder: '搜索名称、描述、标签或使用场景', + all: '全部', + refresh: '刷新', + family: 'Flocks 分类', + sections: { + overview: '总览', + tools: '工具', + skills: '技能', + agents: '智能体', + marketplace: '插件广场', + }, + sectionDescriptions: { + overview: '统一查看插件安装状态,并执行开关、安装、卸载等插件级操作。', + tools: '管理 MCP、API Tool、本地 Python Tool、设备工具等 Flocks 工具能力。', + skills: '管理 Rex 和子 Agent 可加载的技能,包含启用、禁用、依赖安装和编辑。', + agents: '管理子 Agent 配置、能力边界、工具白名单和创建入口。', + marketplace: '浏览可安装插件,安装前查看 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: '工作流', + }, + families: { + all: '全部分类', + agent: '智能体', + skill: '技能', + mcp: 'MCP', + apiTool: 'API Tool', + pythonTool: 'Python Tool', + generatedTool: 'Generated', + tool: '其他工具', + device: '设备', + workflow: '工作流', + }, + next: { + agent: '创建会话或调整智能体配置', + skill: '查看触发说明和依赖状态', + tool: '测试工具或配置 API/MCP 服务', + device: '设备接入保留为独立主入口,可在插件安装后添加设备实例并测试凭据', + workflow: '打开工作流并运行验证', + }, + toast: { + refreshed: '插件列表已刷新', + actionDone: '操作完成', + actionFailed: '操作失败', + }, + }, + en: { + title: 'Plugin Management', + description: 'Manage agents, skills, tools, devices, and workflow plugins from one workspace.', + 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', + marketplace: 'Marketplace', + }, + 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.', + marketplace: 'Browse installable plugins and inspect manifests, dependencies, permissions, and files.', + }, + 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', + }, + 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', + }, + 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', + }, + toast: { + refreshed: 'Plugin list refreshed', + actionDone: 'Action complete', + actionFailed: 'Action failed', + }, + }, +}; + +const TYPE_ORDER: HubPluginType[] = ['agent', 'skill', 'tool', 'device', 'workflow']; +const FAMILY_ORDER: PluginFamily[] = ['agent', 'skill', 'mcp', 'apiTool', 'pythonTool', 'generatedTool', 'tool', 'device', 'workflow']; +const SECTION_ORDER: PluginSection[] = ['agents', 'skills', 'tools', 'marketplace']; + +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: '/workflows', + }, +}; + +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 === '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, + }; + 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, + }; + 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 { 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]} +

+
+
+
+ +
+ +
+ +
+ {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 PluginSectionNav({ activeSection, text }: { activeSection: PluginSection; text: PluginText }) { + return ( + + ); +} + +function PluginSectionContent({ section }: { section: PluginSection }) { + if (section === 'tools') return ; + if (section === 'skills') return ; + if (section === 'agents') 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/Skill/index.tsx b/webui/src/pages/Skill/index.tsx index f90f38c48..6db4bfbdc 100644 --- a/webui/src/pages/Skill/index.tsx +++ b/webui/src/pages/Skill/index.tsx @@ -26,7 +26,11 @@ import SkillInstallDialog from './SkillInstallDialog'; const PAGE_SIZE = 25; -export default function SkillPage() { +interface SkillPageProps { + embedded?: boolean; +} + +export default function SkillPage({ embedded = false }: SkillPageProps = {}) { const { t } = useTranslation('skill'); const [skills, setSkills] = useState([]); 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 e0f6eae94..48865cdae 100644 --- a/webui/src/pages/Tool/index.tsx +++ b/webui/src/pages/Tool/index.tsx @@ -142,7 +142,11 @@ const EMPTY_FILTERS: ColumnFilters = { // Main Page // ============================================================================ -export default function ToolPage() { +interface ToolPageProps { + embedded?: boolean; +} + +export default function ToolPage({ embedded = false }: ToolPageProps = {}) { const { t, i18n } = useTranslation('tool'); const TABS: TabConfig[] = [ @@ -432,99 +436,177 @@ export default function ToolPage() { } return ( -
- {/* Page Header */} -
-
-
- -
-
-

{t('pageTitle')}

-

- {tools.length} {t('statusBadge.active')} - · - {catalogEntries.length} {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" + /> +
+
+
+ + )} {/* Tab Content */} {activeTab === 'mcp' ? ( @@ -3814,4 +3896,3 @@ const LANG_COLORS: Record = { // CatalogBrowser removed — catalog UI is now inline in MCPTabContent / APITabContent // (CatalogBrowser component removed — catalog UI is inline in MCPTabContent / APITabContent) - diff --git a/webui/src/routes/index.tsx b/webui/src/routes/index.tsx index 87c146129..ede0671c9 100644 --- a/webui/src/routes/index.tsx +++ b/webui/src/routes/index.tsx @@ -13,7 +13,6 @@ import { useAuth } from '@/contexts/AuthContext'; // transitive deps (SessionChat ~2.7k LOC + react-markdown + rehype/remark + // highlight.js) are not pulled into the main entry chunk. const SessionPage = lazy(() => import('@/pages/Session')); -const AgentPage = lazy(() => import('@/pages/Agent')); const LoginPage = lazy(() => import('@/pages/Login')); const SetupAdminPage = lazy(() => import('@/pages/SetupAdmin')); const ForceChangePasswordPage = lazy(() => import('@/pages/ForceChangePassword')); @@ -22,10 +21,8 @@ const WorkflowCreate = lazy(() => import('@/pages/WorkflowCreate')); const WorkflowEditor = lazy(() => import('@/pages/WorkflowEditor')); const WorkflowDetail = lazy(() => import('@/pages/WorkflowDetail')); const TaskPage = lazy(() => import('@/pages/Task')); -const ToolPage = lazy(() => import('@/pages/Tool')); -const HubPage = lazy(() => import('@/pages/Hub')); +const PluginManagerPage = lazy(() => import('@/pages/PluginManager')); const ModelPage = lazy(() => import('@/pages/Model')); -const SkillPage = lazy(() => import('@/pages/Skill')); const ConfigPage = lazy(() => import('@/pages/Config')); const ChannelPage = lazy(() => import('@/pages/Channel')); const PermissionPage = lazy(() => import('@/pages/Permission')); @@ -137,7 +134,7 @@ export function Routes() { {/* AI 工作台 */} } /> - } /> + } /> } /> } /> } /> @@ -149,12 +146,14 @@ export function Routes() { } /> {/* Agent Smith */} - } /> - } /> + } /> + } /> + } /> + } /> } /> - } /> + } /> {/* MCP 已整合到工具清单页面 */} - } /> + } /> {/* 系统中心 */} } /> From 2044f461bc232b991ea1ed29596c25b0961815a7 Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Wed, 1 Jul 2026 13:31:20 +0800 Subject: [PATCH 2/7] refactor: refine agent management layout --- webui/src/pages/Agent/index.tsx | 146 ++++++++++---------------------- 1 file changed, 47 insertions(+), 99 deletions(-) diff --git a/webui/src/pages/Agent/index.tsx b/webui/src/pages/Agent/index.tsx index 5e4284107..287c83ff8 100644 --- a/webui/src/pages/Agent/index.tsx +++ b/webui/src/pages/Agent/index.tsx @@ -1,40 +1,5 @@ import { useState, useEffect, useMemo } from 'react'; -import { Bot, Plus, Cpu, RefreshCw, Pencil, Trash2, Shield, Zap, Loader2 } from 'lucide-react'; - -// --------------------------------------------------------------------------- -// Color helpers -// --------------------------------------------------------------------------- - -// Muted-but-distinct palette — enough personality without being loud. -const AGENT_PALETTE = [ - '#3b82f6', // blue-500 - '#8b5cf6', // violet-500 - '#06b6d4', // cyan-500 - '#10b981', // emerald-500 - '#f59e0b', // amber-500 - '#ef4444', // red-500 - '#ec4899', // pink-500 - '#6366f1', // indigo-500 -]; - -function resolveAgentColor(agent: Agent): string { - if (agent.color) return agent.color; - let h = 0; - for (let i = 0; i < agent.name.length; i++) { - h = agent.name.charCodeAt(i) + ((h << 5) - h); - } - return AGENT_PALETTE[Math.abs(h) % AGENT_PALETTE.length]; -} - -/** hex → rgba string at `alpha` (0–1). Works for 3-char and 6-char hex. */ -function hexAlpha(hex: string, alpha: number): string { - const h = hex.replace('#', ''); - const full = h.length === 3 ? h.split('').map(c => c + c).join('') : h; - const r = parseInt(full.slice(0, 2), 16); - const g = parseInt(full.slice(2, 4), 16); - const b = parseInt(full.slice(4, 6), 16); - return `rgba(${r},${g},${b},${alpha})`; -} +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'; @@ -206,7 +171,6 @@ export default function AgentPage({ embedded = false }: AgentPageProps = {}) { {!embedded && primaryAgents.length > 0 && ( } agents={primaryAgents} displayLang={i18n.language} @@ -220,7 +184,6 @@ export default function AgentPage({ embedded = false }: AgentPageProps = {}) { {subAgents.length > 0 && ( } agents={subAgents} displayLang={i18n.language} @@ -337,7 +300,6 @@ function PaginationBar({ interface AgentSectionProps { title: string; - subtitle: string; icon: React.ReactNode; agents: Agent[]; displayLang: string; @@ -352,7 +314,6 @@ interface AgentSectionProps { function AgentSection({ title, - subtitle, icon, agents, displayLang, @@ -366,6 +327,7 @@ function AgentSection({ }: AgentSectionProps) { const { t } = useTranslation('agent'); const [sourceFilter, setSourceFilter] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); const [page, setPage] = useState(1); // Per-source counts for the filter chips @@ -373,14 +335,21 @@ function AgentSection({ const customCount = useMemo(() => agents.filter(a => !a.native).length, [agents]); const filtered = useMemo( - () => showSourceFilter - ? agents.filter((a) => { - if (sourceFilter === 'builtin') return a.native; - if (sourceFilter === 'custom') return !a.native; - return true; - }) - : agents, - [agents, showSourceFilter, sourceFilter], + () => { + const q = searchQuery.trim().toLowerCase(); + return agents.filter((a) => { + if (showSourceFilter) { + if (sourceFilter === 'builtin' && !a.native) return false; + if (sourceFilter === 'custom' && a.native) return false; + } + if (!q) return true; + const name = getAgentDisplayName(a, displayLang).toLowerCase(); + const desc = getAgentDisplayDescription(a, displayLang).toLowerCase(); + const model = a.model?.modelID?.toLowerCase() ?? ''; + return name.includes(q) || desc.includes(q) || model.includes(q); + }); + }, + [agents, displayLang, searchQuery, showSourceFilter, sourceFilter], ); const totalPages = paginate ? Math.max(1, Math.ceil(filtered.length / SUB_AGENT_PAGE_SIZE)) : 1; @@ -391,7 +360,7 @@ function AgentSection({ }, [totalPages, page]); // Reset to page 1 when filter changes - useEffect(() => { setPage(1); }, [sourceFilter]); + useEffect(() => { setPage(1); }, [searchQuery, sourceFilter]); const displayed = paginate ? filtered.slice((page - 1) * SUB_AGENT_PAGE_SIZE, page * SUB_AGENT_PAGE_SIZE) @@ -410,26 +379,16 @@ function AgentSection({ return (
- {/* Section header: left accent stripe */} {showSourceFilter && ( -
- {icon} -
-
-

{title}

- - {agents.length} - -
-

{subtitle}

+
+
+ {icon} +

{title}

+ + {agents.length} +
-
- )} - - {/* Source filter — segmented control, same style as Skill page */} - {showSourceFilter && ( -
-
+
{filterChips.map((chip, idx) => { const active = chip.key === sourceFilter; return ( @@ -455,6 +414,16 @@ function AgentSection({ ); })}
+
+ + setSearchQuery(e.target.value)} + placeholder={displayLang.toLowerCase().startsWith('zh') ? '搜索 Agent...' : 'Search agents...'} + 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" + /> +
)} @@ -639,34 +608,26 @@ function AgentCard({ const { t } = useTranslation('agent'); const displayName = getAgentDisplayName(agent, displayLang); const displayDesc = getAgentDisplayDescription(agent, displayLang); - const color = resolveAgentColor(agent); const showDelegatableToggle = agent.mode === 'subagent'; return (
- {/* Top accent bar — 3 px strip, full-width, same radius as card */} -
- {/* Card body */} -
+
{/* Avatar + Name row */}
- {/* Colored avatar */} -
- +
+
@@ -706,11 +667,8 @@ function AgentCard({ {/* Model chip */} {agent.model && ( -
- +
+ {agent.model.modelID} @@ -723,19 +681,8 @@ function AgentCard({ className="border-t border-gray-100 px-3 py-1.5 flex items-center justify-between" onClick={(e) => e.stopPropagation()} > - {/* Delete — disabled for built-in agents */} - {agent.native ? ( - - ) : ( +
+ {!agent.native && ( )} +
{showDelegatableToggle && ( From 71076ed3c35f71e4b18514985dc119010847c73d Mon Sep 17 00:00:00 2001 From: xiami762 <> Date: Wed, 1 Jul 2026 16:08:11 +0800 Subject: [PATCH 3/7] refactor: clarify plugin manager navigation --- webui/src/pages/PluginManager/index.tsx | 87 +++++++++++++++++++++---- 1 file changed, 73 insertions(+), 14 deletions(-) diff --git a/webui/src/pages/PluginManager/index.tsx b/webui/src/pages/PluginManager/index.tsx index 1e964026a..2b4d40acc 100644 --- a/webui/src/pages/PluginManager/index.tsx +++ b/webui/src/pages/PluginManager/index.tsx @@ -31,6 +31,7 @@ type PluginView = 'installed' | 'marketplace'; type ActionKind = 'install' | 'update' | 'uninstall'; type PluginFamily = 'all' | 'agent' | 'skill' | 'mcp' | 'apiTool' | 'pythonTool' | 'generatedTool' | 'tool' | 'device' | 'workflow'; type PluginSection = 'overview' | 'tools' | 'skills' | 'agents' | 'marketplace'; +type PluginMode = 'assets' | 'discover'; const ToolPage = lazy(() => import('@/pages/Tool')); const SkillPage = lazy(() => import('@/pages/Skill')); @@ -40,6 +41,11 @@ 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; @@ -79,6 +85,11 @@ const TEXT: Record<'zh' | 'en', PluginText> = { zh: { title: '插件管理', description: '统一管理智能体、技能、工具、设备和工作流插件,安装后直接进入对应配置。', + installedAssets: '已安装插件', + discoverPlugins: '发现插件', + assetType: '插件类型', + installSource: '安装来源', + hubSource: 'Flocks Hub', installed: '已安装', marketplace: '插件广场', installedHint: '当前可用、可配置、可更新的插件资产。', @@ -92,14 +103,14 @@ const TEXT: Record<'zh' | 'en', PluginText> = { tools: '工具', skills: '技能', agents: '智能体', - marketplace: '插件广场', + marketplace: '发现插件', }, sectionDescriptions: { overview: '统一查看插件安装状态,并执行开关、安装、卸载等插件级操作。', tools: '管理 MCP、API Tool、本地 Python Tool、设备工具等 Flocks 工具能力。', skills: '管理 Rex 和子 Agent 可加载的技能,包含启用、禁用、依赖安装和编辑。', agents: '管理子 Agent 配置、能力边界、工具白名单和创建入口。', - marketplace: '浏览可安装插件,安装前查看 manifest、依赖、权限和文件内容。', + marketplace: '从 Flocks Hub 浏览可安装插件,安装前查看 manifest、依赖、权限和文件内容。', }, emptyTitle: '没有匹配的插件', emptyHint: '换一个类型或清空搜索条件。', @@ -162,6 +173,11 @@ const TEXT: Record<'zh' | 'en', PluginText> = { 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.', @@ -175,14 +191,14 @@ const TEXT: Record<'zh' | 'en', PluginText> = { tools: 'Tools', skills: 'Skills', agents: 'Agents', - marketplace: 'Marketplace', + 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.', - marketplace: 'Browse installable plugins and inspect manifests, dependencies, permissions, and files.', + 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.', @@ -246,7 +262,7 @@ const TEXT: Record<'zh' | 'en', PluginText> = { const TYPE_ORDER: HubPluginType[] = ['agent', 'skill', 'tool', 'device', 'workflow']; const FAMILY_ORDER: PluginFamily[] = ['agent', 'skill', 'mcp', 'apiTool', 'pythonTool', 'generatedTool', 'tool', 'device', 'workflow']; -const SECTION_ORDER: PluginSection[] = ['agents', 'skills', 'tools', 'marketplace']; +const ASSET_SECTION_ORDER: Array> = ['agents', 'skills', 'tools']; const TYPE_META: Record([]); const [loading, setLoading] = useState(false); @@ -489,7 +506,7 @@ export default function PluginManagerPage() { return (
-
+
@@ -504,10 +521,17 @@ export default function PluginManagerPage() {

-
- + +
+
+
+ {activeMode === 'assets' ? text.assetType : text.installSource}
-
@@ -742,16 +766,41 @@ export default function PluginManagerPage() { ); } -function PluginSectionNav({ activeSection, text }: { activeSection: PluginSection; text: PluginText }) { +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 ( -