1052 lines
38 KiB
Dart
1052 lines
38 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_controllers.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;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tab = widget.initialTab ?? widget.controller.modulesTab;
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(covariant ModulesPage oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
final nextTab = 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(
|
|
'管理 Gateway、代理、节点、技能和平台服务。',
|
|
'Manage gateway, 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('接入模块', 'Add Module')),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
SectionTabs(
|
|
items: ModulesTab.values.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.gateway => _GatewayPanel(
|
|
controller: controller,
|
|
onOpenDetail: widget.onOpenDetail,
|
|
),
|
|
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,
|
|
),
|
|
},
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
SettingsNavigationContext _modulesNavigationContext(ModulesTab tab) {
|
|
return SettingsNavigationContext(
|
|
rootLabel: appText('模块', 'Modules'),
|
|
destination: WorkspaceDestination.nodes,
|
|
sectionLabel: tab.label,
|
|
modulesTab: tab,
|
|
);
|
|
}
|
|
|
|
SettingsDetailPage _modulesDetailForTab(ModulesTab tab) {
|
|
return switch (tab) {
|
|
ModulesTab.agents => SettingsDetailPage.externalAgents,
|
|
_ => SettingsDetailPage.gatewayConnection,
|
|
};
|
|
}
|
|
|
|
class _GatewayPanel extends StatelessWidget {
|
|
const _GatewayPanel({required this.controller, required this.onOpenDetail});
|
|
|
|
final AppController controller;
|
|
final ValueChanged<DetailPanelData> onOpenDetail;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final connection = controller.connection;
|
|
final metrics = [
|
|
MetricSummary(
|
|
label: appText('模式', 'Mode'),
|
|
value: controller.settings.gateway.mode.label,
|
|
caption: controller.settings.gateway.useSetupCode
|
|
? appText('配置码', 'Setup code')
|
|
: appText('手动配置', 'Manual profile'),
|
|
icon: Icons.link_rounded,
|
|
),
|
|
MetricSummary(
|
|
label: appText('活跃会话', 'Active Sessions'),
|
|
value: '${controller.sessions.length}',
|
|
caption: appText(
|
|
'当前 Key ${controller.currentSessionKey}',
|
|
'Current key ${controller.currentSessionKey}',
|
|
),
|
|
icon: Icons.chat_bubble_outline_rounded,
|
|
),
|
|
MetricSummary(
|
|
label: appText('今日运行', 'Today Runs'),
|
|
value:
|
|
'${controller.tasksController.running.length + controller.tasksController.history.length}',
|
|
caption: appText('根据实时会话活动计算', 'Derived from live session activity'),
|
|
icon: Icons.bolt_rounded,
|
|
),
|
|
MetricSummary(
|
|
label: appText('技能', 'Skills'),
|
|
value: '${controller.skills.length}',
|
|
caption: appText('来自网关加载', 'Loaded from gateway'),
|
|
icon: Icons.extension_rounded,
|
|
),
|
|
];
|
|
|
|
final statusPayload = connection.statusPayload ?? const <String, dynamic>{};
|
|
final healthPayload = connection.healthPayload ?? const <String, dynamic>{};
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final width = constraints.maxWidth > 1180
|
|
? (constraints.maxWidth - 48) / 4
|
|
: constraints.maxWidth > 860
|
|
? (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: 20),
|
|
SurfaceCard(
|
|
onTap: () => onOpenDetail(
|
|
DetailPanelData(
|
|
title: appText('网关概览', 'Gateway Overview'),
|
|
subtitle: appText('运行时', 'Runtime'),
|
|
icon: Icons.wifi_tethering_rounded,
|
|
status: _connectionStatus(connection.status),
|
|
description: appText(
|
|
'与 macOS 工作台保持一致的实时 Gateway 控制面摘要。',
|
|
'Live gateway control plane summary aligned with the macOS workspace shell.',
|
|
),
|
|
meta: [
|
|
connection.remoteAddress ?? appText('未连接目标', 'No target'),
|
|
controller.activeAgentName,
|
|
],
|
|
actions: [
|
|
appText('刷新', 'Refresh'),
|
|
appText('打开设置', 'Open Settings'),
|
|
],
|
|
sections: [
|
|
DetailSection(
|
|
title: appText('连接', 'Connection'),
|
|
items: [
|
|
DetailItem(
|
|
label: appText('状态', 'Status'),
|
|
value: connection.status.label,
|
|
),
|
|
DetailItem(
|
|
label: appText('地址', 'Address'),
|
|
value:
|
|
connection.remoteAddress ?? appText('离线', 'Offline'),
|
|
),
|
|
DetailItem(
|
|
label: appText('模式', 'Mode'),
|
|
value: controller.settings.gateway.mode.label,
|
|
),
|
|
DetailItem(
|
|
label: appText('代理', 'Agent'),
|
|
value: controller.activeAgentName,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
appText('网关', 'Gateway'),
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 10),
|
|
Text(
|
|
'${connection.status.label} · ${connection.remoteAddress ?? appText('未连接目标', 'No target')} · ${controller.activeAgentName}',
|
|
style: Theme.of(context).textTheme.bodyLarge,
|
|
),
|
|
const SizedBox(height: 14),
|
|
Wrap(
|
|
spacing: 10,
|
|
runSpacing: 10,
|
|
children: [
|
|
OutlinedButton(
|
|
onPressed: controller.refreshGatewayHealth,
|
|
child: Text(appText('刷新状态', 'Refresh status')),
|
|
),
|
|
OutlinedButton(
|
|
onPressed: controller.refreshSessions,
|
|
child: Text(appText('刷新会话', 'Refresh sessions')),
|
|
),
|
|
OutlinedButton(
|
|
onPressed: () => controller.openSettings(
|
|
detail: _modulesDetailForTab(ModulesTab.gateway),
|
|
navigationContext: _modulesNavigationContext(
|
|
ModulesTab.gateway,
|
|
),
|
|
),
|
|
child: Text(appText('编辑设置', 'Edit settings')),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
SurfaceCard(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
appText('状态摘要', 'Status Summary'),
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 14),
|
|
_KeyValueLine(
|
|
label: 'Health',
|
|
value: healthPayload.isEmpty
|
|
? appText('不可用', 'Unavailable')
|
|
: encodePrettyJson(healthPayload),
|
|
),
|
|
const SizedBox(height: 12),
|
|
_KeyValueLine(
|
|
label: 'Status',
|
|
value: statusPayload.isEmpty
|
|
? appText('不可用', 'Unavailable')
|
|
: encodePrettyJson(statusPayload),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
class _KeyValueLine extends StatelessWidget {
|
|
const _KeyValueLine({required this.label, required this.value});
|
|
|
|
final String label;
|
|
final String value;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 90,
|
|
child: Text(label, style: Theme.of(context).textTheme.labelLarge),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: SelectableText(
|
|
value,
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|