xworkmate-app/lib/features/modules/modules_page.dart
2026-03-22 17:40:34 +08:00

833 lines
31 KiB
Dart

import 'package:flutter/material.dart';
import '../../app/app_controller.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<DetailPanelData> onOpenDetail;
final ModulesTab? initialTab;
@override
State<ModulesPage> createState() => _ModulesPageState();
}
class _ModulesPageState extends State<ModulesPage> {
late ModulesTab _tab;
ModulesTab _normalizeTab(ModulesTab tab) {
return tab == ModulesTab.gateway ? ModulesTab.nodes : tab;
}
@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.connectorsController.refresh();
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: ModulesTab.values
.where((item) => item != ModulesTab.gateway)
.map((item) => item.label)
.toList(),
value: _tab.label,
onChanged: (value) => setState(() {
_tab = ModulesTab.values.firstWhere(
(item) => item.label == 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 => _FallbackHubPanel(
controller: controller,
onOpenDetail: widget.onOpenDetail,
),
ModulesTab.connectors => _FallbackConnectorsPanel(
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<DetailPanelData> 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<DetailPanelData> 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<DetailPanelData> onOpenDetail;
@override
Widget build(BuildContext context) {
final items = controller.skills;
if (items.isEmpty) {
return 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.',
),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: 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),
],
),
),
),
)
.toList(),
);
}
}
class _FallbackHubPanel extends StatelessWidget {
const _FallbackHubPanel({
required this.controller,
required this.onOpenDetail,
});
final AppController controller;
final ValueChanged<DetailPanelData> onOpenDetail;
@override
Widget build(BuildContext context) {
final items = controller.models;
if (items.isEmpty) {
final hasAiGateway = controller.settings.aiGateway.baseUrl
.trim()
.isNotEmpty;
return SurfaceCard(
child: Text(
hasAiGateway
? appText(
'当前 AI Gateway 没有返回模型目录。',
'No model catalog returned by the AI Gateway.',
)
: appText(
'先在设置 -> 集成 中同步 AI Gateway 模型目录。',
'Sync the AI Gateway model catalog from Settings -> Integrations.',
),
),
);
}
return Wrap(
spacing: 16,
runSpacing: 16,
children: items
.map(
(model) => SizedBox(
width: 360,
child: SurfaceCard(
onTap: () => onOpenDetail(
DetailPanelData(
title: model.name,
subtitle: appText('模型', 'Model'),
icon: Icons.psychology_alt_rounded,
status: StatusInfo(model.provider, StatusTone.accent),
description: appText(
'来自 AI Gateway 的可用模型目录项。',
'Model catalog entry exposed by the AI Gateway.',
),
meta: [model.id, model.provider],
actions: [appText('刷新', 'Refresh')],
sections: [
DetailSection(
title: appText('能力', 'Capabilities'),
items: [
DetailItem(label: 'ID', value: model.id),
DetailItem(
label: appText('提供方', 'Provider'),
value: model.provider,
),
DetailItem(
label: appText('上下文窗口', 'Context Window'),
value: '${model.contextWindow ?? 0}',
),
DetailItem(
label: appText('最大输出', 'Max Output'),
value: '${model.maxOutputTokens ?? 0}',
),
],
),
],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
model.name,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text('${model.provider} · ${model.id}'),
],
),
),
),
)
.toList(),
);
}
}
class _FallbackConnectorsPanel extends StatelessWidget {
const _FallbackConnectorsPanel({
required this.controller,
required this.onOpenDetail,
});
final AppController controller;
final ValueChanged<DetailPanelData> onOpenDetail;
@override
Widget build(BuildContext context) {
final connectors = controller.connectors;
if (connectors.isEmpty) {
return SurfaceCard(
child: Text(
controller.connection.status == RuntimeConnectionStatus.connected
? appText(
'当前网关没有返回连接器状态。',
'No connector status returned by the gateway.',
)
: appText(
'连接 Gateway 后可加载连接器状态。',
'Connect a gateway to load connector status.',
),
),
);
}
return 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: connectors
.map(
(connector) => SizedBox(
width: width,
child: SurfaceCard(
onTap: () => onOpenDetail(
DetailPanelData(
title: connector.label,
subtitle: 'Connector',
icon: Icons.cable_rounded,
status: _connectorStatus(connector),
description:
connector.lastError ?? connector.detailLabel,
meta: [
if (connector.accountName != null)
connector.accountName!,
...connector.meta,
],
actions: const ['Refresh'],
sections: [
DetailSection(
title: 'Connector',
items: [
DetailItem(
label: appText('状态', 'Status'),
value: connector.status,
),
DetailItem(
label: appText('账号', 'Account'),
value: connector.accountName ?? 'default',
),
DetailItem(
label: appText('配置', 'Configured'),
value: '${connector.configured}',
),
DetailItem(
label: appText('连接中', 'Connected'),
value: '${connector.connected}',
),
],
),
],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
connector.label,
style: Theme.of(context).textTheme.titleLarge,
),
),
StatusBadge(
status: _connectorStatus(connector),
compact: true,
),
],
),
const SizedBox(height: 10),
Text(
connector.accountName == null
? connector.detailLabel
: '${connector.detailLabel} · ${connector.accountName}',
),
],
),
),
),
)
.toList(),
);
},
);
}
}
StatusInfo _connectorStatus(GatewayConnectorSummary connector) {
return switch (connector.status) {
'error' => StatusInfo(appText('异常', 'Error'), StatusTone.danger),
'connected' => StatusInfo(appText('已连接', 'Connected'), StatusTone.success),
'running' => StatusInfo(appText('运行中', 'Running'), StatusTone.accent),
'configured' => StatusInfo(
appText('已配置', 'Configured'),
StatusTone.warning,
),
_ => StatusInfo(appText('空闲', 'Idle'), StatusTone.neutral),
};
}
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);
}