import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; import '../../app/ui_feature_manifest.dart'; import '../../app/workspace_navigation.dart'; import '../../app/app_metadata.dart'; import '../../i18n/app_language.dart'; import '../../models/app_models.dart'; import '../../runtime/runtime_models.dart'; import '../../widgets/metric_card.dart'; import '../../widgets/section_header.dart'; import '../../widgets/section_tabs.dart'; import '../../widgets/status_badge.dart'; import '../../widgets/surface_card.dart'; import '../../widgets/top_bar.dart'; class ModulesPage extends StatefulWidget { const ModulesPage({ super.key, required this.controller, required this.onOpenDetail, this.initialTab, }); final AppController controller; final ValueChanged onOpenDetail; final ModulesTab? initialTab; @override State createState() => _ModulesPageState(); } class _ModulesPageState extends State { late ModulesTab _tab; ModulesTab _normalizeTab(ModulesTab tab) { final normalized = tab == ModulesTab.gateway ? ModulesTab.nodes : tab; if (_isTabVisible(normalized)) { return normalized; } return ModulesTab.skills; } bool _isTabVisible(ModulesTab tab) { if (tab == ModulesTab.clawHub) { final features = widget.controller.featuresFor(UiFeaturePlatform.desktop); return features.isEnabledPath(UiFeatureKeys.workspaceClawHub); } if (tab == ModulesTab.connectors) { final features = widget.controller.featuresFor(UiFeaturePlatform.desktop); return features.isEnabledPath(UiFeatureKeys.workspaceConnectors); } return true; } List get _visibleTabs => ModulesTab.values .where((item) => item != ModulesTab.gateway) .where(_isTabVisible) .toList(growable: false); ModulesTab _tabForLabel(String value) { return _visibleTabs.firstWhere( (item) => item.label == value, orElse: () => ModulesTab.skills, ); } @override void initState() { super.initState(); _tab = _normalizeTab(widget.initialTab ?? widget.controller.modulesTab); } @override void didUpdateWidget(covariant ModulesPage oldWidget) { super.didUpdateWidget(oldWidget); final nextTab = _normalizeTab( widget.initialTab ?? widget.controller.modulesTab, ); if (nextTab != _tab) { setState(() => _tab = nextTab); } } @override Widget build(BuildContext context) { final controller = widget.controller; final metrics = [ MetricSummary( label: appText('网关', 'Gateway'), value: controller.connection.status.label, caption: controller.connection.remoteAddress ?? kAppVersionLabel, icon: Icons.wifi_tethering_rounded, status: _connectionStatus(controller.connection.status), ), MetricSummary( label: appText('节点', 'Nodes'), value: '${controller.instances.length}', caption: appText( '${controller.instances.where((item) => item.mode == 'active').length} 个活跃实例', '${controller.instances.where((item) => item.mode == 'active').length} active', ), icon: Icons.developer_board_rounded, ), MetricSummary( label: appText('代理', 'Agents'), value: '${controller.agents.length}', caption: controller.activeAgentName, icon: Icons.hub_rounded, ), ]; return AnimatedBuilder( animation: controller, builder: (context, _) { return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(32, 32, 32, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TopBar( breadcrumbs: buildWorkspaceBreadcrumbs( controller: controller, rootLabel: appText('模块', 'Modules'), sectionLabel: _tab.label, ), title: appText('模块', 'Modules'), subtitle: appText( '管理代理、节点、技能和平台服务。', 'Manage agents, nodes, skills, and platform services.', ), trailing: Wrap( spacing: 12, runSpacing: 12, children: [ SizedBox( width: 220, child: TextField( decoration: InputDecoration( hintText: appText('搜索模块', 'Search modules'), prefixIcon: Icon(Icons.search_rounded), ), ), ), IconButton( onPressed: () async { await controller.refreshGatewayHealth(); await controller.refreshAgents(); await controller.refreshSessions(); await controller.instancesController.refresh(); await controller.skillsController.refresh( agentId: controller.selectedAgentId.isEmpty ? null : controller.selectedAgentId, ); await controller.modelsController.refresh(); await controller.cronJobsController.refresh(); }, icon: const Icon(Icons.refresh_rounded), ), FilledButton.tonalIcon( onPressed: () => controller.openSettings(tab: SettingsTab.gateway), icon: const Icon(Icons.add_rounded), label: Text(appText('打开设置中心', 'Open Settings')), ), ], ), ), const SizedBox(height: 24), SectionTabs( items: _visibleTabs.map((item) => item.label).toList(), value: _tab.label, onChanged: (value) => setState(() { _tab = _tabForLabel(value); controller.openModules(tab: _tab); }), ), const SizedBox(height: 24), LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth > 980 ? (constraints.maxWidth - 32) / 3 : constraints.maxWidth > 640 ? (constraints.maxWidth - 16) / 2 : constraints.maxWidth; return Wrap( spacing: 16, runSpacing: 16, children: metrics .map( (metric) => SizedBox( width: width, child: MetricCard(metric: metric), ), ) .toList(), ); }, ), const SizedBox(height: 28), switch (_tab) { ModulesTab.nodes => _NodesPanel( controller: controller, onOpenDetail: widget.onOpenDetail, ), ModulesTab.agents => _AgentsPanel( controller: controller, onOpenDetail: widget.onOpenDetail, ), ModulesTab.skills => _SkillsPanel( controller: controller, onOpenDetail: widget.onOpenDetail, ), ModulesTab.clawHub => _SkillsPanel( controller: controller, onOpenDetail: widget.onOpenDetail, ), ModulesTab.connectors => _SkillsPanel( controller: controller, onOpenDetail: widget.onOpenDetail, ), ModulesTab.gateway => _NodesPanel( controller: controller, onOpenDetail: widget.onOpenDetail, ), }, ], ), ); }, ); } } class _NodesPanel extends StatelessWidget { const _NodesPanel({required this.controller, required this.onOpenDetail}); final AppController controller; final ValueChanged onOpenDetail; @override Widget build(BuildContext context) { final items = controller.instances; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SectionHeader( title: appText('节点', 'Nodes'), subtitle: appText( '来自 Gateway 运行时的在线实例与存在性数据。', 'Live system-presence data from the gateway runtime.', ), ), const SizedBox(height: 16), if (items.isEmpty) SurfaceCard( child: Text( controller.connection.status == RuntimeConnectionStatus.connected ? appText('暂时还没有上报在线实例。', 'No live instances reported yet.') : appText( '连接 Gateway 后可加载实例与在线状态。', 'Connect a gateway to load instances / presence.', ), ), ) else ...items.map( (node) => Padding( padding: const EdgeInsets.only(bottom: 12), child: SurfaceCard( onTap: () => onOpenDetail( DetailPanelData( title: node.host ?? node.id, subtitle: appText('实例', 'Instance'), icon: Icons.developer_board_rounded, status: _instanceStatus(node), description: node.text, meta: [ node.platform ?? appText('未知', 'unknown'), node.deviceFamily ?? appText('未知', 'unknown'), ], actions: [appText('刷新', 'Refresh')], sections: [ DetailSection( title: appText('运行时', 'Runtime'), items: [ DetailItem(label: 'IP', value: node.ip ?? 'n/a'), DetailItem( label: 'Version', value: node.version ?? 'n/a', ), DetailItem( label: appText('模式', 'Mode'), value: node.mode ?? 'n/a', ), DetailItem( label: appText('最近输入', 'Last Input'), value: node.lastInputSeconds == null ? 'n/a' : '${node.lastInputSeconds}s', ), ], ), ], ), ), child: Row( children: [ Expanded( flex: 4, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( node.host ?? node.id, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 6), Text( '${node.platform ?? appText('未知', 'unknown')} · ${node.deviceFamily ?? appText('未知', 'unknown')}', style: Theme.of(context).textTheme.bodySmall, ), ], ), ), Expanded( flex: 2, child: StatusBadge(status: _instanceStatus(node)), ), Expanded(flex: 2, child: Text(node.version ?? 'n/a')), Expanded(flex: 2, child: Text(node.mode ?? 'n/a')), const Icon(Icons.chevron_right_rounded), ], ), ), ), ), ], ); } } class _AgentsPanel extends StatelessWidget { const _AgentsPanel({required this.controller, required this.onOpenDetail}); final AppController controller; final ValueChanged onOpenDetail; @override Widget build(BuildContext context) { final items = controller.agents; return LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth > 1220 ? (constraints.maxWidth - 32) / 3 : constraints.maxWidth > 760 ? (constraints.maxWidth - 16) / 2 : constraints.maxWidth; if (items.isEmpty) { return SurfaceCard( child: Text( controller.connection.status == RuntimeConnectionStatus.connected ? appText( '网关当前没有返回代理列表。', 'No agents reported by the gateway.', ) : appText( '连接 Gateway 后可加载代理。', 'Connect a gateway to load agents.', ), ), ); } return Wrap( spacing: 16, runSpacing: 16, children: items .map( (agent) => SizedBox( width: width, child: SurfaceCard( onTap: () => onOpenDetail( DetailPanelData( title: agent.name, subtitle: appText('代理', 'Agent'), icon: Icons.hub_rounded, status: controller.selectedAgentId == agent.id ? StatusInfo( appText('已选中', 'Selected'), StatusTone.accent, ) : StatusInfo( appText('可用', 'Available'), StatusTone.success, ), description: appText( '可用于会话路由的 Gateway 执行代理。', 'Gateway operator agent available for session routing.', ), meta: [agent.id, agent.theme], actions: [ appText('选择', 'Select'), appText('打开会话', 'Open Session'), ], sections: [ DetailSection( title: appText('身份信息', 'Identity'), items: [ DetailItem( label: appText('名称', 'Name'), value: agent.name, ), DetailItem(label: 'ID', value: agent.id), DetailItem( label: appText('主题', 'Theme'), value: agent.theme, ), ], ), ], ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( agent.name, style: Theme.of(context).textTheme.titleLarge, ), ), StatusBadge( status: controller.selectedAgentId == agent.id ? StatusInfo( appText('已选中', 'Selected'), StatusTone.accent, ) : StatusInfo( appText('就绪', 'Ready'), StatusTone.success, ), compact: true, ), ], ), const SizedBox(height: 10), Text( 'ID: ${agent.id}', style: Theme.of(context).textTheme.bodyMedium, ), const SizedBox(height: 14), Wrap( spacing: 8, runSpacing: 8, children: [ FilledButton.tonal( onPressed: () => controller.selectAgent(agent.id), child: Text(appText('选择', 'Select')), ), OutlinedButton( onPressed: () => controller.refreshSessions(), child: Text(appText('打开', 'Open')), ), ], ), ], ), ), ), ) .toList(), ); }, ); } } class _SkillsPanel extends StatelessWidget { const _SkillsPanel({required this.controller, required this.onOpenDetail}); final AppController controller; final ValueChanged onOpenDetail; @override Widget build(BuildContext context) { final items = controller.skills; final currentMode = controller.currentAssistantExecutionTarget; final modeCards = _buildModeCards(items, currentMode); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SectionHeader( title: appText('技能模式', 'Skill modes'), subtitle: appText( '用相同界面简洁区分单机智能体、本地 Gateway、远程 Gateway 三种模式,以及各自可用的技能包。', 'Keep the same page structure while separating single-agent, local gateway, and remote gateway skill packs.', ), ), const SizedBox(height: 16), LayoutBuilder( builder: (context, constraints) { final width = constraints.maxWidth > 1220 ? (constraints.maxWidth - 32) / 3 : constraints.maxWidth > 760 ? (constraints.maxWidth - 16) / 2 : constraints.maxWidth; return Wrap( spacing: 16, runSpacing: 16, children: modeCards .map( (card) => SizedBox( width: width, child: _SkillModeCard(data: card), ), ) .toList(), ); }, ), const SizedBox(height: 24), SectionHeader( title: appText('技能明细', 'Skill details'), subtitle: appText( '保留当前运行时返回的原始技能列表,便于查看状态、来源和依赖。', 'Keep the raw runtime skill list for status, source, and dependency inspection.', ), ), const SizedBox(height: 16), if (items.isEmpty) SurfaceCard( child: Text( controller.connection.status == RuntimeConnectionStatus.connected ? appText( '当前网关或代理没有加载技能。', 'No skills loaded for the active gateway / agent.', ) : appText( '连接 Gateway 后可加载技能。', 'Connect a gateway to load skills.', ), ), ) else ...items.map( (skill) => Padding( padding: const EdgeInsets.only(bottom: 12), child: SurfaceCard( onTap: () => onOpenDetail( DetailPanelData( title: skill.name, subtitle: appText('技能', 'Skill'), icon: Icons.extension_rounded, status: skill.disabled ? StatusInfo( appText('已禁用', 'Disabled'), StatusTone.warning, ) : StatusInfo( appText('已启用', 'Enabled'), StatusTone.success, ), description: skill.description, meta: [skill.source, skill.skillKey], actions: [appText('刷新', 'Refresh')], sections: [ DetailSection( title: appText('依赖要求', 'Requirements'), items: [ DetailItem( label: appText('缺失二进制', 'Missing bins'), value: skill.missingBins.isEmpty ? appText('无', 'None') : skill.missingBins.join(', '), ), DetailItem( label: appText('缺失环境变量', 'Missing env'), value: skill.missingEnv.isEmpty ? appText('无', 'None') : skill.missingEnv.join(', '), ), DetailItem( label: appText('缺失配置', 'Missing config'), value: skill.missingConfig.isEmpty ? appText('无', 'None') : skill.missingConfig.join(', '), ), ], ), ], ), ), child: Row( children: [ Expanded( flex: 4, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( skill.name, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 6), Text( skill.description, style: Theme.of(context).textTheme.bodySmall, ), ], ), ), Expanded( flex: 2, child: StatusBadge( status: skill.disabled ? StatusInfo( appText('已禁用', 'Disabled'), StatusTone.warning, ) : StatusInfo( appText('已启用', 'Enabled'), StatusTone.success, ), ), ), Expanded(flex: 2, child: Text(skill.source)), Expanded( flex: 2, child: Text(skill.primaryEnv ?? 'workspace'), ), const Icon(Icons.chevron_right_rounded), ], ), ), ), ), ], ); } List<_SkillModeCardData> _buildModeCards( List items, AssistantExecutionTarget currentMode, ) { final singleAgentSkills = items .where((item) => _isSingleAgentSkill(item)) .toList(growable: false); final gatewaySkills = items .where((item) => !_isSingleAgentSkill(item)) .toList(growable: false); return <_SkillModeCardData>[ _SkillModeCardData( title: appText('单机智能体', 'Single agent'), subtitle: appText( '直接挂载本地 / 已授权目录中的技能包,适合个人工作区快速调用。', 'Mount local or authorized skill packs directly for fast personal workspace use.', ), icon: Icons.auto_awesome_rounded, status: currentMode == AssistantExecutionTarget.singleAgent ? StatusInfo(appText('当前模式', 'Current mode'), StatusTone.accent) : StatusInfo(appText('可切换', 'Available'), StatusTone.success), chips: [ for (final provider in controller.availableSingleAgentProviders) provider.label, ], skills: singleAgentSkills.map((item) => item.name).toList(), emptyLabel: appText( '切换到单机智能体模式后,将显示本地技能包。', 'Switch to single-agent mode to inspect local skill packs.', ), ), _SkillModeCardData( title: appText('本地 Gateway', 'Local gateway'), subtitle: appText( '通过本地 OpenClaw Gateway 暴露运行时技能,适合本机节点与代理协作。', 'Expose runtime skill packs through the local OpenClaw Gateway for local nodes and agents.', ), icon: Icons.lan_rounded, status: currentMode == AssistantExecutionTarget.local ? StatusInfo(appText('当前模式', 'Current mode'), StatusTone.accent) : StatusInfo(appText('可切换', 'Available'), StatusTone.success), chips: [ appText('节点协作', 'Node orchestration'), appText('本机运行时', 'Local runtime'), ], skills: currentMode == AssistantExecutionTarget.local ? gatewaySkills.map((item) => item.name).toList() : const [], emptyLabel: appText( '切换到本地 Gateway 模式后,将显示当前本地网关返回的技能包。', 'Switch to local gateway mode to inspect the active local runtime skill packs.', ), ), _SkillModeCardData( title: appText('远程 Gateway', 'Remote gateway'), subtitle: appText( '通过远程 Gateway 暴露团队或服务端技能,适合共享节点、代理与平台能力。', 'Expose team or hosted skill packs through the remote gateway for shared nodes, agents, and services.', ), icon: Icons.cloud_done_rounded, status: currentMode == AssistantExecutionTarget.remote ? StatusInfo(appText('当前模式', 'Current mode'), StatusTone.accent) : StatusInfo(appText('可切换', 'Available'), StatusTone.success), chips: [ appText('团队共享', 'Team shared'), appText('平台服务', 'Platform services'), ], skills: currentMode == AssistantExecutionTarget.remote ? gatewaySkills.map((item) => item.name).toList() : const [], emptyLabel: appText( '切换到远程 Gateway 模式后,将显示当前远程网关返回的技能包。', 'Switch to remote gateway mode to inspect the active remote runtime skill packs.', ), ), ]; } bool _isSingleAgentSkill(GatewaySkillSummary item) { const gatewaySources = {'gateway', 'workspace', 'acp'}; return !gatewaySources.contains(item.source.trim().toLowerCase()); } } class _SkillModeCardData { const _SkillModeCardData({ required this.title, required this.subtitle, required this.icon, required this.status, required this.chips, required this.skills, required this.emptyLabel, }); final String title; final String subtitle; final IconData icon; final StatusInfo status; final List chips; final List skills; final String emptyLabel; } class _SkillModeCard extends StatelessWidget { const _SkillModeCard({required this.data}); final _SkillModeCardData data; @override Widget build(BuildContext context) { return SurfaceCard( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ CircleAvatar(radius: 20, child: Icon(data.icon, size: 20)), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( data.title, style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 4), StatusBadge(status: data.status, compact: true), ], ), ), ], ), const SizedBox(height: 14), Text(data.subtitle, style: Theme.of(context).textTheme.bodySmall), if (data.chips.isNotEmpty) ...[ const SizedBox(height: 14), Wrap( spacing: 8, runSpacing: 8, children: data.chips .map( (item) => Chip( label: Text(item), visualDensity: VisualDensity.compact, ), ) .toList(), ), ], const SizedBox(height: 14), Text( appText('可用技能包', 'Available skill packs'), style: Theme.of(context).textTheme.labelLarge, ), const SizedBox(height: 8), if (data.skills.isEmpty) Text(data.emptyLabel, style: Theme.of(context).textTheme.bodySmall) else Wrap( spacing: 8, runSpacing: 8, children: data.skills .map( (item) => Chip( label: Text(item), visualDensity: VisualDensity.compact, ), ) .toList(), ), ], ), ); } } StatusInfo _connectionStatus(RuntimeConnectionStatus status) => switch (status) { RuntimeConnectionStatus.connected => StatusInfo( appText('健康', 'Healthy'), StatusTone.success, ), RuntimeConnectionStatus.connecting => StatusInfo( appText('连接中', 'Connecting'), StatusTone.accent, ), RuntimeConnectionStatus.error => StatusInfo( appText('错误', 'Error'), StatusTone.danger, ), RuntimeConnectionStatus.offline => StatusInfo( appText('离线', 'Offline'), StatusTone.neutral, ), }; StatusInfo _instanceStatus(GatewayInstanceSummary item) { final mode = (item.mode ?? '').toLowerCase(); if (mode.contains('error') || mode.contains('warn')) { return StatusInfo(appText('告警', 'Warning'), StatusTone.warning); } if (mode.contains('active') || mode.contains('online')) { return StatusInfo(appText('在线', 'Online'), StatusTone.success); } return StatusInfo(appText('已发现', 'Seen'), StatusTone.neutral); }