xworkmate-app/lib/features/settings/settings_page.dart

3431 lines
121 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../app/app_metadata.dart';
import '../../app/ui_feature_manifest.dart';
import '../../app/workspace_navigation.dart';
import '../ai_gateway/ai_gateway_page.dart';
import '../../i18n/app_language.dart';
import '../../models/app_models.dart';
import '../../runtime/runtime_controllers.dart';
import '../../runtime/runtime_models.dart';
import '../../widgets/gateway_connect_dialog.dart';
import '../../widgets/section_tabs.dart';
import '../../widgets/surface_card.dart';
import '../../widgets/top_bar.dart';
class SettingsPage extends StatefulWidget {
const SettingsPage({
super.key,
required this.controller,
this.initialTab = SettingsTab.general,
this.initialDetail,
this.navigationContext,
});
final AppController controller;
final SettingsTab initialTab;
final SettingsDetailPage? initialDetail;
final SettingsNavigationContext? navigationContext;
@override
State<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends State<SettingsPage> {
static const _storedSecretMask = '****';
late SettingsTab _tab;
SettingsDetailPage? _detail;
SettingsNavigationContext? _navigationContext;
late final TextEditingController _aiGatewayNameController;
late final TextEditingController _aiGatewayUrlController;
late final TextEditingController _aiGatewayApiKeyRefController;
late final TextEditingController _aiGatewayApiKeyController;
late final TextEditingController _aiGatewayModelSearchController;
late final TextEditingController _vaultTokenController;
late final TextEditingController _ollamaApiKeyController;
late final TextEditingController _runtimeLogFilterController;
bool _aiGatewayTesting = false;
bool _aiGatewaySyncing = false;
String _aiGatewayTestState = 'idle';
String _aiGatewayTestMessage = '';
String _aiGatewayTestEndpoint = '';
String _aiGatewayNameSyncedValue = '';
String _aiGatewayUrlSyncedValue = '';
String _aiGatewayApiKeyRefSyncedValue = '';
_SecretFieldUiState _aiGatewayApiKeyState = const _SecretFieldUiState();
_SecretFieldUiState _vaultTokenState = const _SecretFieldUiState();
_SecretFieldUiState _ollamaApiKeyState = const _SecretFieldUiState();
@override
void initState() {
super.initState();
_tab = widget.initialTab;
_detail = widget.initialDetail;
_navigationContext = widget.navigationContext;
_aiGatewayNameController = TextEditingController();
_aiGatewayUrlController = TextEditingController();
_aiGatewayApiKeyRefController = TextEditingController();
_aiGatewayApiKeyController = TextEditingController();
_aiGatewayModelSearchController = TextEditingController();
_vaultTokenController = TextEditingController();
_ollamaApiKeyController = TextEditingController();
_runtimeLogFilterController = TextEditingController();
}
@override
void didUpdateWidget(covariant SettingsPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.initialTab != _tab) {
_tab = widget.initialTab;
}
if (widget.initialDetail != _detail) {
_detail = widget.initialDetail;
}
if (widget.navigationContext != _navigationContext) {
_navigationContext = widget.navigationContext;
}
}
@override
void dispose() {
_aiGatewayNameController.dispose();
_aiGatewayUrlController.dispose();
_aiGatewayApiKeyRefController.dispose();
_aiGatewayApiKeyController.dispose();
_aiGatewayModelSearchController.dispose();
_vaultTokenController.dispose();
_ollamaApiKeyController.dispose();
_runtimeLogFilterController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final controller = widget.controller;
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
final featurePlatform = resolveUiFeaturePlatformFromContext(context);
final uiFeatures = controller.featuresFor(featurePlatform);
final availableTabs = uiFeatures.availableSettingsTabs;
_tab = uiFeatures.sanitizeSettingsTab(controller.settingsTab);
_detail = controller.settingsDetail;
_navigationContext = controller.settingsNavigationContext;
final settings = controller.settings;
final showingDetail = _detail != null;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(32, 32, 32, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TopBar(
breadcrumbs: buildSettingsBreadcrumbs(
controller,
tab: _tab,
detail: _detail,
navigationContext: _navigationContext,
),
title: appText('设置', 'Settings'),
subtitle: showingDetail
? appText(
'当前正在编辑详细设置参数,保存后会回写到对应状态页。',
'You are editing detailed settings. Saved values flow back to the related status page.',
)
: appText(
'配置 $kProductBrandName 工作区、网关默认项、界面与诊断选项',
'Configure workspace, gateway defaults, appearance, and diagnostics for $kProductBrandName.',
),
trailing: SizedBox(
width: showingDetail ? 168 : 220,
child: showingDetail
? OutlinedButton.icon(
onPressed: () {
controller.closeSettingsDetail();
setState(() {
_detail = null;
_navigationContext = null;
});
},
icon: const Icon(Icons.arrow_back_rounded),
label: Text(appText('返回概览', 'Back to overview')),
)
: TextField(
decoration: InputDecoration(
hintText: appText('搜索设置', 'Search settings'),
prefixIcon: Icon(Icons.search_rounded),
),
),
),
),
const SizedBox(height: 24),
if (!showingDetail) ...[
SectionTabs(
items: availableTabs.map((item) => item.label).toList(),
value: _tab.label,
onChanged: (value) => setState(() {
_tab = availableTabs.firstWhere(
(item) => item.label == value,
);
_detail = null;
_navigationContext = null;
controller.setSettingsTab(_tab);
}),
),
const SizedBox(height: 24),
],
..._buildContentForCurrentState(
context,
controller,
settings,
uiFeatures,
),
],
),
);
},
);
}
List<Widget> _buildContentForCurrentState(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
UiFeatureAccess uiFeatures,
) {
if (_detail != null) {
return _buildDetailContent(context, controller, settings, _detail!);
}
return switch (_tab) {
SettingsTab.general => _buildGeneral(context, controller, settings),
SettingsTab.workspace => _buildWorkspace(context, controller, settings),
SettingsTab.gateway => _buildGateway(context, controller, settings),
SettingsTab.agents => _buildAgents(context, controller, settings),
SettingsTab.appearance => _buildAppearance(context, controller),
SettingsTab.diagnostics => _buildDiagnostics(context, controller),
SettingsTab.experimental => _buildExperimental(
context,
controller,
settings,
uiFeatures,
),
SettingsTab.about => _buildAbout(context, controller),
};
}
List<Widget> _buildDetailContent(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
SettingsDetailPage detail,
) {
final gatewaySections = _buildGateway(context, controller, settings);
final workspaceSections = _buildWorkspace(context, controller, settings);
return switch (detail) {
SettingsDetailPage.gatewayConnection => <Widget>[
_buildDetailIntro(
context,
title: detail.label,
description: appText(
'集中编辑 Gateway 连接、设备配对和会话级连接入口。',
'Edit gateway connection, device pairing, and session-level connection entry points in one place.',
),
),
const SizedBox(height: 16),
...gatewaySections.take(3),
],
SettingsDetailPage.aiGatewayIntegration => <Widget>[
_buildDetailIntro(
context,
title: detail.label,
description: appText(
'统一管理 AI Gateway 地址、API Key、模型目录同步和默认选择。',
'Manage AI Gateway endpoint, API key, model catalog sync, and default selections from one screen.',
),
),
const SizedBox(height: 16),
if (gatewaySections.isNotEmpty) gatewaySections.last,
],
SettingsDetailPage.vaultProvider => <Widget>[
_buildDetailIntro(
context,
title: detail.label,
description: appText(
'只在这里维护 Vault 地址、命名空间和安全 token 引用。',
'Maintain Vault endpoint, namespace, and secure token references here.',
),
),
const SizedBox(height: 16),
if (gatewaySections.length > 4) gatewaySections[4],
],
SettingsDetailPage.ollamaProvider => <Widget>[
_buildDetailIntro(
context,
title: detail.label,
description: appText(
'本地与云端 Ollama 提供方参数统一放在这个 detail 页面中维护。',
'Local and cloud Ollama provider settings live in this dedicated detail page.',
),
),
const SizedBox(height: 16),
...workspaceSections.skip(1),
],
SettingsDetailPage.externalAgents => <Widget>[
_buildDetailIntro(
context,
title: detail.label,
description: appText(
'多 Agent 协作、角色编排和外部 CLI 工具的详细参数集中在这里。',
'Detailed multi-agent collaboration, role orchestration, and external CLI settings are edited here.',
),
),
const SizedBox(height: 16),
..._buildAgents(context, controller, settings),
const SizedBox(height: 16),
CodexIntegrationCard(controller: controller),
],
SettingsDetailPage.diagnosticsAdvanced => <Widget>[
_buildDetailIntro(
context,
title: detail.label,
description: appText(
'高级诊断集中展示网关诊断、运行日志和设备信息。',
'Advanced diagnostics centralize gateway diagnostics, runtime logs, and device information.',
),
),
const SizedBox(height: 16),
..._buildDiagnostics(context, controller),
],
};
}
Widget _buildDetailIntro(
BuildContext context, {
required String title,
required String description,
}) {
return SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 10),
Text(description, style: Theme.of(context).textTheme.bodyMedium),
],
),
);
}
List<Widget> _buildGeneral(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
) {
return [
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Application', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: 16),
_SwitchRow(
label: appText('启用工作台外壳', 'Active workspace shell'),
value: settings.appActive,
onChanged: (value) => _saveSettings(
controller,
settings.copyWith(appActive: value),
),
),
_SwitchRow(
label: appText('开机启动', 'Launch at login'),
value: settings.launchAtLogin,
onChanged: (value) => _saveSettings(
controller,
settings.copyWith(launchAtLogin: value),
),
),
_SwitchRow(
label: controller.supportsDesktopIntegration
? appText('显示托盘图标', 'Show tray icon')
: appText('显示 Dock 图标', 'Show dock icon'),
value: settings.showDockIcon,
onChanged: (value) => _saveSettings(
controller,
settings.copyWith(showDockIcon: value),
),
),
_SwitchRow(
label: appText('账号本地模式', 'Account local mode'),
value: settings.accountLocalMode,
onChanged: (value) => _saveSettings(
controller,
settings.copyWith(accountLocalMode: value),
),
),
],
),
),
if (controller.supportsDesktopIntegration)
_buildLinuxDesktopIntegration(context, controller, settings),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('账号访问', 'Account Access'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
_EditableField(
label: appText('账号服务地址', 'Account Base URL'),
value: settings.accountBaseUrl,
onSubmitted: (value) => _saveSettings(
controller,
settings.copyWith(accountBaseUrl: value),
),
),
_EditableField(
label: appText('账号用户名', 'Account Username'),
value: settings.accountUsername,
onSubmitted: (value) => _saveSettings(
controller,
settings.copyWith(accountUsername: value),
),
),
_EditableField(
label: appText('工作区名称', 'Workspace Label'),
value: settings.accountWorkspace,
onSubmitted: (value) => _saveSettings(
controller,
settings.copyWith(accountWorkspace: value),
),
),
],
),
),
];
}
Widget _buildLinuxDesktopIntegration(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
) {
final desktop = controller.desktopIntegration;
final config = settings.linuxDesktop;
final theme = Theme.of(context);
return SurfaceCard(
key: const ValueKey('linux-desktop-integration-card'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('Linux 桌面集成', 'Linux Desktop Integration'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
appText(
'统一管理 GNOME / KDE 的代理模式、隧道连接、托盘菜单与开机自启。',
'Manage GNOME / KDE proxy mode, tunnel session, tray menu, and autostart from one surface.',
),
style: theme.textTheme.bodyMedium,
),
const SizedBox(height: 16),
_InfoRow(
label: appText('桌面环境', 'Desktop'),
value: desktop.environment.label,
),
_InfoRow(
label: 'NetworkManager',
value: desktop.networkManagerAvailable
? appText('可用', 'Available')
: appText('不可用', 'Unavailable'),
),
_InfoRow(
label: appText('当前模式', 'Current Mode'),
value: desktop.mode.label,
),
_InfoRow(
label: appText('隧道状态', 'Tunnel'),
value: desktop.tunnel.connected
? appText('已连接', 'Connected')
: desktop.tunnel.available
? appText('可连接', 'Ready')
: appText('未检测到配置', 'No profile detected'),
),
_InfoRow(
label: appText('系统代理', 'System Proxy'),
value: desktop.systemProxy.enabled
? '${desktop.systemProxy.host}:${desktop.systemProxy.port}'
: appText('未启用', 'Disabled'),
),
_SwitchRow(
label: appText('开机启动', 'Launch at login'),
value: settings.launchAtLogin,
onChanged: (value) => controller.setLaunchAtLogin(value),
),
_SwitchRow(
label: appText('托盘菜单', 'Tray menu'),
value: config.trayEnabled,
onChanged: (value) => controller.saveLinuxDesktopConfig(
config.copyWith(trayEnabled: value),
),
),
_EditableField(
label: appText('隧道连接名称', 'Tunnel Connection Name'),
value: config.vpnConnectionName,
onSubmitted: (value) => controller.saveLinuxDesktopConfig(
config.copyWith(vpnConnectionName: value.trim()),
),
),
Row(
children: [
Expanded(
child: _EditableField(
label: appText('代理主机', 'Proxy Host'),
value: config.proxyHost,
onSubmitted: (value) => controller.saveLinuxDesktopConfig(
config.copyWith(proxyHost: value.trim()),
),
),
),
const SizedBox(width: 12),
Expanded(
child: _EditableField(
label: appText('代理端口', 'Proxy Port'),
value: config.proxyPort.toString(),
onSubmitted: (value) {
final parsed = int.tryParse(value.trim());
if (parsed == null || parsed <= 0) {
return;
}
controller.saveLinuxDesktopConfig(
config.copyWith(proxyPort: parsed),
);
},
),
),
],
),
const SizedBox(height: 6),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
FilledButton.tonal(
onPressed: controller.desktopPlatformBusy
? null
: () => controller.setDesktopVpnMode(VpnMode.proxy),
child: Text(appText('切换到代理', 'Use Proxy')),
),
FilledButton.tonal(
onPressed: controller.desktopPlatformBusy
? null
: () => controller.setDesktopVpnMode(VpnMode.tunnel),
child: Text(appText('切换到隧道', 'Use Tunnel')),
),
OutlinedButton(
onPressed: controller.desktopPlatformBusy
? null
: controller.connectDesktopTunnel,
child: Text(appText('连接隧道', 'Connect Tunnel')),
),
OutlinedButton(
onPressed: controller.desktopPlatformBusy
? null
: controller.disconnectDesktopTunnel,
child: Text(appText('断开隧道', 'Disconnect Tunnel')),
),
OutlinedButton(
onPressed: controller.desktopPlatformBusy
? null
: controller.refreshDesktopIntegration,
child: Text(appText('刷新状态', 'Refresh Status')),
),
],
),
if (desktop.statusMessage.trim().isNotEmpty) ...[
const SizedBox(height: 16),
_buildNotice(
context,
tone: theme.colorScheme.surfaceContainerHighest,
title: appText('桌面状态', 'Desktop Status'),
message: desktop.statusMessage,
),
],
],
),
);
}
List<Widget> _buildWorkspace(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
) {
final hasStoredOllamaApiKey =
controller.settingsController.secureRefs['ollama_cloud_api_key'] !=
null;
return [
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('工作区', 'Workspace'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
_EditableField(
label: appText('工作区路径', 'Workspace Path'),
value: settings.workspacePath,
onSubmitted: (value) => _saveSettings(
controller,
settings.copyWith(workspacePath: value),
),
),
_EditableField(
label: appText('远程项目根目录', 'Remote Project Root'),
value: settings.remoteProjectRoot,
onSubmitted: (value) => _saveSettings(
controller,
settings.copyWith(remoteProjectRoot: value),
),
),
_EditableField(
label: appText('CLI 路径', 'CLI Path'),
value: settings.cliPath,
onSubmitted: (value) =>
_saveSettings(controller, settings.copyWith(cliPath: value)),
),
_EditableField(
label: appText('默认模型', 'Default Model'),
value: settings.defaultModel,
onSubmitted: (value) => _saveSettings(
controller,
settings.copyWith(defaultModel: value),
),
),
_EditableField(
label: appText('默认提供方', 'Default Provider'),
value: settings.defaultProvider,
onSubmitted: (value) => _saveSettings(
controller,
settings.copyWith(defaultProvider: value),
),
),
],
),
),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('本地 Ollama', 'Ollama Local'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
_EditableField(
label: appText('服务地址', 'Endpoint'),
value: settings.ollamaLocal.endpoint,
onSubmitted: (value) => _saveSettings(
controller,
settings.copyWith(
ollamaLocal: settings.ollamaLocal.copyWith(endpoint: value),
),
),
),
_EditableField(
label: appText('默认模型', 'Default Model'),
value: settings.ollamaLocal.defaultModel,
onSubmitted: (value) => _saveSettings(
controller,
settings.copyWith(
ollamaLocal: settings.ollamaLocal.copyWith(
defaultModel: value,
),
),
),
),
_SwitchRow(
label: appText('自动发现', 'Auto Discover'),
value: settings.ollamaLocal.autoDiscover,
onChanged: (value) => _saveSettings(
controller,
settings.copyWith(
ollamaLocal: settings.ollamaLocal.copyWith(
autoDiscover: value,
),
),
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton(
onPressed: () => controller.testOllamaConnection(cloud: false),
child: Text(
'${appText('测试连接', 'Test Connection')} · ${controller.settingsController.ollamaStatus}',
),
),
),
],
),
),
const SizedBox(height: 16),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('Ollama Cloud', 'Ollama Cloud'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
_EditableField(
label: appText('基础地址', 'Base URL'),
value: settings.ollamaCloud.baseUrl,
onSubmitted: (value) => _saveSettings(
controller,
settings.copyWith(
ollamaCloud: settings.ollamaCloud.copyWith(baseUrl: value),
),
),
),
_EditableField(
label: appText('工作区 / 组织', 'Workspace / Org'),
value:
'${settings.ollamaCloud.organization} / ${settings.ollamaCloud.workspace}',
onSubmitted: (value) {
final parts = value.split('/');
_saveSettings(
controller,
settings.copyWith(
ollamaCloud: settings.ollamaCloud.copyWith(
organization: parts.isNotEmpty ? parts.first.trim() : '',
workspace: parts.length > 1 ? parts[1].trim() : '',
),
),
);
},
),
_EditableField(
label: appText('默认模型', 'Default Model'),
value: settings.ollamaCloud.defaultModel,
onSubmitted: (value) => _saveSettings(
controller,
settings.copyWith(
ollamaCloud: settings.ollamaCloud.copyWith(
defaultModel: value,
),
),
),
),
_buildSecureField(
controller: _ollamaApiKeyController,
label:
'${appText('API Key', 'API Key')} (${settings.ollamaCloud.apiKeyRef})',
hasStoredValue: hasStoredOllamaApiKey,
fieldState: _ollamaApiKeyState,
onStateChanged: (value) =>
setState(() => _ollamaApiKeyState = value),
loadValue: controller.settingsController.loadOllamaCloudApiKey,
onSubmitted: controller.settingsController.saveOllamaCloudApiKey,
storedHelperText: appText(
'已安全保存,默认以 **** 显示,点击查看后读取真实值。',
'Stored securely. Shows as **** until you reveal it.',
),
emptyHelperText: appText(
'输入后会安全保存到本机密钥存储。',
'Saving writes to secure local key storage.',
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton(
onPressed: () async {
await _persistOllamaApiKeyIfNeeded(
controller,
hasStoredValue: hasStoredOllamaApiKey,
);
await controller.testOllamaConnection(cloud: true);
},
child: Text(
'${appText('测试云端', 'Test Cloud')} · ${controller.settingsController.ollamaStatus}',
),
),
),
],
),
),
];
}
List<Widget> _buildGateway(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
) {
_syncDraftControllerValue(
_aiGatewayNameController,
settings.aiGateway.name,
syncedValue: _aiGatewayNameSyncedValue,
onSyncedValueChanged: (value) => _aiGatewayNameSyncedValue = value,
);
_syncDraftControllerValue(
_aiGatewayUrlController,
settings.aiGateway.baseUrl,
syncedValue: _aiGatewayUrlSyncedValue,
onSyncedValueChanged: (value) => _aiGatewayUrlSyncedValue = value,
);
_syncDraftControllerValue(
_aiGatewayApiKeyRefController,
settings.aiGateway.apiKeyRef,
syncedValue: _aiGatewayApiKeyRefSyncedValue,
onSyncedValueChanged: (value) => _aiGatewayApiKeyRefSyncedValue = value,
);
final selectedModels = settings.aiGateway.selectedModels.isNotEmpty
? settings.aiGateway.selectedModels
: settings.aiGateway.availableModels.take(5).toList(growable: false);
final filteredModels = _filterAiGatewayModels(
settings.aiGateway.availableModels,
);
final hasStoredAiGatewayApiKey =
controller.settingsController.secureRefs['ai_gateway_api_key'] != null;
final hasStoredVaultToken =
controller.settingsController.secureRefs['vault_token'] != null;
final statusTheme = _aiGatewayFeedbackTheme(
context,
_aiGatewayTestMessage.isEmpty
? settings.aiGateway.syncState
: _aiGatewayTestState,
);
return [
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'OpenClaw Gateway',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Text(
'${controller.connection.status.label} · ${controller.connection.remoteAddress ?? '${settings.gateway.host}:${settings.gateway.port}'}',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
FilledButton.tonal(
onPressed: () => showDialog<void>(
context: context,
builder: (context) => GatewayConnectDialog(
controller: controller,
onDone: () => Navigator.of(context).pop(),
),
),
child: Text(appText('打开连接面板', 'Open Connect Panel')),
),
OutlinedButton(
onPressed: controller.refreshGatewayHealth,
child: Text(appText('刷新健康状态', 'Refresh Health')),
),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
initialValue: controller.selectedAgentId.isEmpty
? ''
: controller.selectedAgentId,
decoration: InputDecoration(
labelText: appText('当前代理', 'Selected Agent'),
),
items: [
DropdownMenuItem<String>(
value: '',
child: Text(appText('主代理', 'Main')),
),
...controller.agents.map(
(agent) => DropdownMenuItem<String>(
value: agent.id,
child: Text(agent.name),
),
),
],
onChanged: controller.selectAgent,
),
],
),
),
const SizedBox(height: 16),
_buildDeviceSecurityCard(context, controller),
const SizedBox(height: 16),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('Vault 服务', 'Vault Server'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
_EditableField(
label: appText('地址', 'Address'),
value: settings.vault.address,
onSubmitted: (value) => _saveSettings(
controller,
settings.copyWith(
vault: settings.vault.copyWith(address: value),
),
),
),
_EditableField(
label: appText('命名空间', 'Namespace'),
value: settings.vault.namespace,
onSubmitted: (value) => _saveSettings(
controller,
settings.copyWith(
vault: settings.vault.copyWith(namespace: value),
),
),
),
_EditableField(
label: appText('认证模式', 'Auth Mode'),
value: settings.vault.authMode,
onSubmitted: (value) => _saveSettings(
controller,
settings.copyWith(
vault: settings.vault.copyWith(authMode: value),
),
),
),
_EditableField(
label: appText('Token 引用', 'Token Ref'),
value: settings.vault.tokenRef,
onSubmitted: (value) => _saveSettings(
controller,
settings.copyWith(
vault: settings.vault.copyWith(tokenRef: value),
),
),
),
_buildSecureField(
controller: _vaultTokenController,
label:
'${appText('Vault Token', 'Vault Token')} (${settings.vault.tokenRef})',
hasStoredValue: hasStoredVaultToken,
fieldState: _vaultTokenState,
onStateChanged: (value) =>
setState(() => _vaultTokenState = value),
loadValue: controller.settingsController.loadVaultToken,
onSubmitted: controller.settingsController.saveVaultToken,
storedHelperText: appText(
'已安全保存,默认以 **** 显示,点击查看后读取真实值。',
'Stored securely. Shows as **** until you reveal it.',
),
emptyHelperText: appText(
'输入后会安全保存到本机密钥存储。',
'Saving writes to secure local key storage.',
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton(
onPressed: () async {
await _persistVaultTokenIfNeeded(
controller,
hasStoredValue: hasStoredVaultToken,
);
await controller.testVaultConnection();
},
child: Text(
'${appText('测试 Vault', 'Test Vault')} · ${controller.settingsController.vaultStatus}',
),
),
),
],
),
),
const SizedBox(height: 16),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('AI Gateway', 'AI Gateway'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
TextField(
key: const ValueKey('ai-gateway-name-field'),
controller: _aiGatewayNameController,
decoration: InputDecoration(
labelText: appText('配置名称', 'Profile Name'),
),
onSubmitted: (_) => _saveAiGatewayDraft(controller, settings),
),
const SizedBox(height: 14),
TextField(
key: const ValueKey('ai-gateway-url-field'),
controller: _aiGatewayUrlController,
decoration: InputDecoration(
labelText: appText('Gateway URL', 'Gateway URL'),
),
onSubmitted: (_) => _saveAiGatewayDraft(controller, settings),
),
const SizedBox(height: 14),
TextField(
key: const ValueKey('ai-gateway-api-key-ref-field'),
controller: _aiGatewayApiKeyRefController,
decoration: InputDecoration(
labelText: appText('API Key 引用', 'API Key Ref'),
),
onSubmitted: (_) => _saveAiGatewayDraft(controller, settings),
),
_buildSecureField(
fieldKey: const ValueKey('ai-gateway-api-key-field'),
controller: _aiGatewayApiKeyController,
label:
'${appText('API Key', 'API Key')} (${_aiGatewayApiKeyRefController.text.trim().isEmpty ? settings.aiGateway.apiKeyRef : _aiGatewayApiKeyRefController.text.trim()})',
hasStoredValue: hasStoredAiGatewayApiKey,
fieldState: _aiGatewayApiKeyState,
onStateChanged: (value) =>
setState(() => _aiGatewayApiKeyState = value),
loadValue: controller.settingsController.loadAiGatewayApiKey,
onSubmitted: controller.settingsController.saveAiGatewayApiKey,
storedHelperText: appText(
'已安全保存,默认以 **** 显示;可直接测试/同步,也可点击查看。',
'Stored securely. Test or sync directly, or reveal it on demand.',
),
emptyHelperText: appText(
'输入后点击保存或同步模型。',
'Save or sync to persist securely.',
),
),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
FilledButton.tonal(
key: const ValueKey('ai-gateway-save-button'),
onPressed: _aiGatewayTesting || _aiGatewaySyncing
? null
: () => _saveAiGatewayDraft(controller, settings),
child: Text(appText('保存草稿', 'Save Draft')),
),
OutlinedButton(
key: const ValueKey('ai-gateway-test-button'),
onPressed: _aiGatewayTesting || _aiGatewaySyncing
? null
: () => _testAiGatewayConnection(controller, settings),
child: Text(
_aiGatewayTesting
? appText('测试中...', 'Testing...')
: appText('测试连接', 'Test Connection'),
),
),
OutlinedButton(
key: const ValueKey('ai-gateway-sync-button'),
onPressed: () async {
if (_aiGatewayTesting || _aiGatewaySyncing) {
return;
}
final messenger = ScaffoldMessenger.of(context);
final draft = _buildAiGatewayDraft(settings);
final apiKey = _secretOverride(
_aiGatewayApiKeyController,
_aiGatewayApiKeyState,
);
setState(() => _aiGatewaySyncing = true);
try {
await _saveSettings(
controller,
settings.copyWith(aiGateway: draft),
);
unawaited(
_persistAiGatewayApiKeyIfNeeded(
controller,
hasStoredValue: hasStoredAiGatewayApiKey,
).catchError((_) {}),
);
final result = await controller.syncAiGatewayCatalog(
draft,
apiKeyOverride: apiKey,
);
if (!mounted) {
return;
}
setState(() {
_aiGatewayTestState = result.syncState;
_aiGatewayTestMessage = result.syncState == 'ready'
? 'Catalog synced · ${result.availableModels.length} model(s) ready'
: result.syncMessage;
_aiGatewayTestEndpoint = result.syncState == 'ready'
? _previewAiGatewayEndpoint(draft.baseUrl)
: '';
});
messenger.showSnackBar(
SnackBar(content: Text(result.syncMessage)),
);
} finally {
if (mounted) {
setState(() => _aiGatewaySyncing = false);
}
}
},
child: Text(
_aiGatewaySyncing
? appText('同步中...', 'Syncing...')
: '${appText('同步模型', 'Sync Models')} · ${settings.aiGateway.syncState}',
),
),
],
),
const SizedBox(height: 12),
Text(
settings.aiGateway.syncMessage,
style: Theme.of(context).textTheme.bodySmall,
),
if (_aiGatewayTestMessage.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
key: const ValueKey('ai-gateway-test-feedback'),
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: statusTheme.background,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: statusTheme.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_aiGatewayTestMessage,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: statusTheme.foreground,
fontWeight: FontWeight.w600,
),
),
if (_aiGatewayTestEndpoint.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
_aiGatewayTestEndpoint,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: statusTheme.foreground,
),
),
],
],
),
),
],
if (settings.aiGateway.availableModels.isNotEmpty) ...[
const SizedBox(height: 16),
TextField(
key: const ValueKey('ai-gateway-model-search'),
controller: _aiGatewayModelSearchController,
decoration: InputDecoration(
labelText: appText('搜索模型', 'Search models'),
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon:
_aiGatewayModelSearchController.text.trim().isEmpty
? null
: IconButton(
tooltip: appText('清空搜索', 'Clear search'),
onPressed: () {
_aiGatewayModelSearchController.clear();
setState(() {});
},
icon: const Icon(Icons.close_rounded),
),
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 12),
Wrap(
spacing: 10,
runSpacing: 10,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
appText(
'已选 ${selectedModels.length} / ${settings.aiGateway.availableModels.length}',
'Selected ${selectedModels.length} / ${settings.aiGateway.availableModels.length}',
),
style: Theme.of(context).textTheme.bodySmall,
),
OutlinedButton(
key: const ValueKey('ai-gateway-select-filtered'),
onPressed: filteredModels.isEmpty
? null
: () async {
await controller.updateAiGatewaySelection(
<String>{
...selectedModels,
...filteredModels,
}.toList(growable: false),
);
},
child: Text(appText('选择筛选结果', 'Select filtered')),
),
OutlinedButton(
key: const ValueKey('ai-gateway-reset-default'),
onPressed: () async {
await controller.updateAiGatewaySelection(
settings.aiGateway.availableModels
.take(5)
.toList(growable: false),
);
},
child: Text(appText('恢复默认 5 个', 'Reset default 5')),
),
],
),
const SizedBox(height: 12),
if (filteredModels.isEmpty)
Text(
appText('没有匹配的模型。', 'No matching models.'),
style: Theme.of(context).textTheme.bodySmall,
)
else
Wrap(
spacing: 8,
runSpacing: 8,
children: filteredModels
.map((modelId) {
final selected = selectedModels.contains(modelId);
return FilterChip(
label: Text(modelId),
selected: selected,
onSelected: (_) async {
final nextSelection = selected
? selectedModels
.where((item) => item != modelId)
.toList(growable: true)
: <String>[...selectedModels, modelId];
await controller.updateAiGatewaySelection(
nextSelection,
);
},
);
})
.toList(growable: false),
),
],
],
),
),
];
}
List<Widget> _buildAppearance(
BuildContext context,
AppController controller,
) {
return [
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('主题', 'Theme'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
ChoiceChip(
label: Text(appText('浅色', 'Light')),
selected: controller.themeMode == ThemeMode.light,
onSelected: (_) => controller.setThemeMode(ThemeMode.light),
),
ChoiceChip(
label: Text(appText('深色', 'Dark')),
selected: controller.themeMode == ThemeMode.dark,
onSelected: (_) => controller.setThemeMode(ThemeMode.dark),
),
ChoiceChip(
label: Text(appText('跟随系统', 'System')),
selected: controller.themeMode == ThemeMode.system,
onSelected: (_) => controller.setThemeMode(ThemeMode.system),
),
],
),
],
),
),
];
}
List<Widget> _buildDiagnostics(
BuildContext context,
AppController controller,
) {
final runtimeLogs = controller.runtimeLogs
.where(_matchesRuntimeLogFilter)
.toList(growable: false)
.reversed
.toList(growable: false);
return [
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('网关诊断', 'Gateway Diagnostics'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
_InfoRow(
label: appText('连接', 'Connection'),
value: controller.connection.status.label,
),
_InfoRow(
label: appText('地址', 'Address'),
value:
controller.connection.remoteAddress ??
appText('离线', 'Offline'),
),
_InfoRow(
label: appText('代理', 'Agent'),
value: controller.activeAgentName,
),
_InfoRow(
label: appText('认证模式', 'Auth Mode'),
value:
controller.connection.connectAuthMode ??
appText('未发起', 'Not attempted'),
),
_InfoRow(
label: appText('认证诊断', 'Auth Diagnostics'),
value: controller.connection.connectAuthSummary,
),
_InfoRow(
label: appText('健康负载', 'Health Payload'),
value: controller.connection.healthPayload == null
? appText('不可用', 'Unavailable')
: encodePrettyJson(controller.connection.healthPayload!),
),
_InfoRow(
label: appText('状态负载', 'Status Payload'),
value: controller.connection.statusPayload == null
? appText('不可用', 'Unavailable')
: encodePrettyJson(controller.connection.statusPayload!),
),
],
),
),
const SizedBox(height: 16),
SurfaceCard(
key: const ValueKey('runtime-log-card'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('运行日志', 'Runtime Logs'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 6),
Text(
appText(
'只记录本机运行期的连接、鉴权、配对和 socket 诊断,不写入密钥明文。',
'Shows local runtime diagnostics for connection, auth, pairing, and socket events without logging secret values.',
),
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: runtimeLogs.isEmpty
? null
: () => controller.clearRuntimeLogs(),
child: Text(appText('清空', 'Clear')),
),
],
),
const SizedBox(height: 16),
TextField(
key: const ValueKey('runtime-log-filter'),
controller: _runtimeLogFilterController,
decoration: InputDecoration(
labelText: appText('筛选日志', 'Filter Logs'),
hintText: appText(
'按级别、分类或关键字过滤',
'Filter by level, category, or keyword',
),
prefixIcon: const Icon(Icons.manage_search_rounded),
),
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
if (runtimeLogs.isEmpty)
Text(
appText('当前没有运行日志。', 'No runtime logs yet.'),
style: Theme.of(context).textTheme.bodyMedium,
)
else
Container(
constraints: const BoxConstraints(maxHeight: 320),
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: SelectionArea(
child: ListView.separated(
itemCount: runtimeLogs.length,
shrinkWrap: true,
itemBuilder: (context, index) {
final entry = runtimeLogs[index];
return SelectableText(
entry.line,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
);
},
separatorBuilder: (context, index) =>
const SizedBox(height: 8),
),
),
),
],
),
),
const SizedBox(height: 16),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('设备', 'Device'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
_InfoRow(
label: appText('平台', 'Platform'),
value: controller.runtime.deviceInfo.platformLabel,
),
_InfoRow(
label: appText('设备类型', 'Device Family'),
value: controller.runtime.deviceInfo.deviceFamily,
),
_InfoRow(
label: appText('型号标识', 'Model Identifier'),
value: controller.runtime.deviceInfo.modelIdentifier,
),
],
),
),
];
}
List<Widget> _buildAgents(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
) {
final orchestrator = controller.multiAgentOrchestrator;
final config = settings.multiAgent;
final theme = Theme.of(context);
final mountTargets = List<ManagedMountTargetState>.from(config.mountTargets)
..sort(
(left, right) =>
left.label.toLowerCase().compareTo(right.label.toLowerCase()),
);
final managedSkillCount = config.managedSkills
.where((item) => item.selected)
.length;
final managedMcpCount = config.managedMcpServers
.where((item) => item.enabled)
.length;
return [
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 760;
final info = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('多 Agent 协作', 'Multi-Agent Collaboration'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
appText(
'限定在多 Agent 协作Architect 负责调度/文档Lead Engineer 负责主程Worker/Review 负责并行 worker 与复审;第一批外部桥接走 ollama launch。',
'Multi-agent only: Architect handles orchestration/docs, Lead Engineer owns the critical path, Worker/Review handles parallel workers and review; first-batch external bridges run through ollama launch.',
),
style: theme.textTheme.bodyMedium,
),
],
);
final toggle = _InlineSwitchField(
label: appText('启用协作模式', 'Enable Collaboration'),
value: config.enabled,
onChanged: (value) => _saveMultiAgentConfig(
controller,
config.copyWith(enabled: value),
),
);
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [info, const SizedBox(height: 16), toggle],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: info),
const SizedBox(width: 20),
Flexible(
child: Align(
alignment: Alignment.topRight,
child: toggle,
),
),
],
);
},
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
key: ValueKey('multi-agent-framework-${config.framework.name}'),
initialValue: config.framework.name,
decoration: InputDecoration(
labelText: appText('协作框架', 'Framework'),
),
items: MultiAgentFramework.values
.map(
(framework) => DropdownMenuItem<String>(
value: framework.name,
child: Text(framework.label),
),
)
.toList(growable: false),
onChanged: (value) {
if (value == null) {
return;
}
final framework = MultiAgentFrameworkCopy.fromJsonValue(value);
_saveMultiAgentConfig(
controller,
config.copyWith(
framework: framework,
arisEnabled: framework == MultiAgentFramework.aris,
),
);
},
),
const SizedBox(height: 12),
_InfoRow(label: 'Ollama', value: config.ollamaEndpoint),
_InfoRow(
label: appText('文档 Lane', 'Doc Lane'),
value:
'${config.architect.cliTool} · ${config.architect.model.isEmpty ? '' : config.architect.model}',
),
_InfoRow(
label: appText('主程 Lane', 'Lead Lane'),
value:
'${config.engineer.cliTool} · ${config.engineer.model.isEmpty ? '' : config.engineer.model}',
),
_InfoRow(
label: appText('Worker Lane', 'Worker Lane'),
value:
'${config.tester.cliTool} · ${config.tester.model.isEmpty ? '' : config.tester.model}',
),
_InfoRow(
label: appText('超时时间', 'Timeout'),
value: '${config.timeoutSeconds}s',
),
_InfoRow(
label: 'ARIS',
value: config.usesAris
? [
config.arisCompatStatus,
if (config.arisBundleVersion.trim().isNotEmpty)
config.arisBundleVersion.trim(),
].join(' · ')
: appText('未启用', 'Disabled'),
),
_InfoRow(
label: appText('运行状态', 'Runtime'),
value: orchestrator.isRunning
? appText('协作执行中', 'Collaboration running')
: config.enabled
? appText('已启用', 'Enabled')
: appText('已停用', 'Disabled'),
),
],
),
),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('角色配置', 'Role Configuration'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 16),
_AgentRoleCard(
title:
'🧭 ${appText('Architect调度/文档)', 'Architect (Docs / Scheduler)')}',
description: appText(
'负责 requirements -> acceptance evidence、架构选项排序、文档与调度。',
'Owns requirements -> acceptance evidence, option ranking, docs, and orchestration.',
),
cliTool: config.architect.cliTool,
model: config.architect.model,
enabled: config.architect.enabled,
cliOptions: _mergeOptions(config.architect.cliTool, const [
'claude',
'codex',
'opencode',
'gemini',
]),
modelOptions: _getArchitectModelOptions(settings, config),
onCliChanged: (tool) => _saveMultiAgentConfig(
controller,
config.copyWith(
architect: config.architect.copyWith(cliTool: tool),
),
),
onModelChanged: (model) => _saveMultiAgentConfig(
controller,
config.copyWith(
architect: config.architect.copyWith(model: model),
),
),
onEnabledChanged: (enabled) => _saveMultiAgentConfig(
controller,
config.copyWith(
architect: config.architect.copyWith(enabled: enabled),
),
),
),
const SizedBox(height: 12),
_AgentRoleCard(
title: '🔧 ${appText('Lead Engineer主程', 'Lead Engineer')}',
description: appText(
'负责关键实现、重构、集成收口,默认走 codex + minimax-m2.7:cloud。',
'Owns critical implementation, refactors, and integration. Defaults to codex + minimax-m2.7:cloud.',
),
cliTool: config.engineer.cliTool,
model: config.engineer.model,
enabled: config.engineer.enabled,
cliOptions: _mergeOptions(config.engineer.cliTool, const [
'codex',
'claude',
'opencode',
'gemini',
]),
modelOptions: _getLeadModelOptions(settings, config),
onCliChanged: (tool) => _saveMultiAgentConfig(
controller,
config.copyWith(
engineer: config.engineer.copyWith(cliTool: tool),
),
),
onModelChanged: (model) => _saveMultiAgentConfig(
controller,
config.copyWith(
engineer: config.engineer.copyWith(model: model),
),
),
onEnabledChanged: (enabled) => _saveMultiAgentConfig(
controller,
config.copyWith(
engineer: config.engineer.copyWith(enabled: enabled),
),
),
),
const SizedBox(height: 12),
_AgentRoleCard(
title:
'🧪 ${appText('Worker/ReviewWorker 池)', 'Worker/Review Pool')}',
description: appText(
'负责 glm/qwen worker lane、回归审阅和补充建议。',
'Owns glm/qwen worker lanes, review, regression checks, and follow-up notes.',
),
cliTool: config.tester.cliTool,
model: config.tester.model,
enabled: config.tester.enabled,
cliOptions: _mergeOptions(config.tester.cliTool, const [
'opencode',
'codex',
'claude',
'gemini',
]),
modelOptions: _getWorkerModelOptions(settings, config),
onCliChanged: (tool) => _saveMultiAgentConfig(
controller,
config.copyWith(tester: config.tester.copyWith(cliTool: tool)),
),
onModelChanged: (model) => _saveMultiAgentConfig(
controller,
config.copyWith(tester: config.tester.copyWith(model: model)),
),
onEnabledChanged: (enabled) => _saveMultiAgentConfig(
controller,
config.copyWith(
tester: config.tester.copyWith(enabled: enabled),
),
),
),
],
),
),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('审阅策略', 'Review Strategy'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _EditableField(
label: appText('最大迭代次数', 'Max Iterations'),
value: config.maxIterations.toString(),
onSubmitted: (value) {
final parsed = int.tryParse(value.trim());
if (parsed != null && parsed > 0) {
_saveMultiAgentConfig(
controller,
config.copyWith(maxIterations: parsed),
);
}
},
),
),
const SizedBox(width: 16),
Expanded(
child: _EditableField(
label: appText('最低达标分数', 'Min Acceptable Score'),
value: config.minAcceptableScore.toString(),
onSubmitted: (value) {
final parsed = int.tryParse(value.trim());
if (parsed != null && parsed >= 1 && parsed <= 10) {
_saveMultiAgentConfig(
controller,
config.copyWith(minAcceptableScore: parsed),
);
}
},
),
),
],
),
const SizedBox(height: 8),
Text(
appText(
'当 Worker/Review 评分低于最低分数时,将进入迭代审阅循环。最多迭代指定次数。',
'When the Worker/Review score is below minimum, the iteration loop runs until max iterations or the score passes.',
),
style: theme.textTheme.bodySmall,
),
],
),
),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 760;
final info = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('发现与分发', 'Discovery & Distribution'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 4),
Text(
appText(
'App 作为统一发现与分发中心,维护托管 skills、MCP server list 和 AI Gateway 默认注入,但不会覆盖用户原有 CLI 配置。',
'The app acts as the discovery and distribution center for managed skills, MCP server lists, and AI Gateway defaults without overwriting existing CLI config.',
),
style: theme.textTheme.bodyMedium,
),
],
);
final refreshButton = OutlinedButton(
onPressed: () =>
controller.refreshMultiAgentMounts(sync: config.autoSync),
child: Text(appText('刷新挂载', 'Refresh Mounts')),
);
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [info, const SizedBox(height: 12), refreshButton],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: info),
const SizedBox(width: 16),
refreshButton,
],
);
},
),
const SizedBox(height: 16),
_SwitchRow(
label: appText('自动同步托管配置', 'Auto-sync managed config'),
value: config.autoSync,
onChanged: (value) => _saveMultiAgentConfig(
controller,
config.copyWith(autoSync: value),
),
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
key: ValueKey(
'multi-agent-injection-${config.aiGatewayInjectionPolicy.name}',
),
initialValue: config.aiGatewayInjectionPolicy.name,
decoration: InputDecoration(
labelText: appText('AI Gateway 注入策略', 'AI Gateway Injection'),
),
items: AiGatewayInjectionPolicy.values
.map(
(policy) => DropdownMenuItem<String>(
value: policy.name,
child: Text(policy.label),
),
)
.toList(growable: false),
onChanged: (value) {
if (value == null) {
return;
}
_saveMultiAgentConfig(
controller,
config.copyWith(
aiGatewayInjectionPolicy:
AiGatewayInjectionPolicyCopy.fromJsonValue(value),
),
);
},
),
const SizedBox(height: 16),
_InfoRow(
label: appText('托管 Skills', 'Managed Skills'),
value: '$managedSkillCount',
),
_InfoRow(
label: appText('托管 MCP', 'Managed MCP'),
value: '$managedMcpCount',
),
if (config.usesAris) ...[
const SizedBox(height: 4),
Text(
appText(
'ARIS 模式会把内嵌 skills 与 Go bridge reviewer 作为本地 Ollama 协作增强层,不会覆盖你原有的 CLI 全局配置。',
'ARIS mode injects embedded skills and the Go bridge reviewer for local Ollama collaboration without overwriting your existing CLI global config.',
),
style: theme.textTheme.bodySmall,
),
],
const SizedBox(height: 16),
...mountTargets.map(
(target) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _MountTargetCard(target: target),
),
),
],
),
),
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('协作流程概览', 'Workflow Overview'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 12),
_WorkflowStep(
label: '1',
emoji: '🧭',
title: appText(
'Architect调度/文档)',
'Architect (Docs / Scheduler)',
),
desc: appText(
'收敛 requirements -> acceptance evidence并冻结里程碑。',
'Freeze requirements -> acceptance evidence and milestones.',
),
),
_WorkflowStep(
label: '2',
emoji: '🔧',
title: appText('Lead Engineer主程', 'Lead Engineer'),
desc: appText(
'主程执行关键路径与集成收口。',
'Lead engineer executes the critical path and integration.',
),
),
_WorkflowStep(
label: '3',
emoji: '🧪',
title: appText('Worker/ReviewWorker 池)', 'Worker/Review Pool'),
desc: appText(
'并行 worker 补切片review lane 给出复审与回归建议。',
'Parallel workers handle bounded slices while the review lane returns critique and regression guidance.',
),
),
_WorkflowStep(
label: '',
emoji: '🔄',
title: appText('迭代(如需要)', 'Iterate (if needed)'),
desc: appText(
'主程修复 -> Worker/Review 重新审阅',
'Lead engineer fixes -> Worker/Review re-reviews',
),
),
const SizedBox(height: 8),
Text(
appText(
'首批支持的外部启动模式:`ollama launch claude --model kimi-k2.5:cloud --yes -- -p ...`、`ollama launch codex --model minimax-m2.7:cloud -- exec ...`、`ollama launch opencode --model glm-5:cloud -- run ...`。',
'First-batch launch bridges: `ollama launch claude --model kimi-k2.5:cloud --yes -- -p ...`, `ollama launch codex --model minimax-m2.7:cloud -- exec ...`, and `ollama launch opencode --model glm-5:cloud -- run ...`.',
),
style: theme.textTheme.bodySmall,
),
],
),
),
];
}
List<String> _getLocalModelOptions(SettingsSnapshot settings) {
return <String>[
settings.ollamaLocal.defaultModel,
'qwen3.5',
'glm-4.7-flash',
]
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toSet()
.toList(growable: false);
}
List<String> _mergeOptions(String current, List<String> defaults) {
return <String>[current.trim(), ...defaults]
.map((item) => item.trim())
.where((item) => item.isNotEmpty)
.toSet()
.toList(growable: false);
}
List<String> _getArchitectModelOptions(
SettingsSnapshot settings,
MultiAgentConfig config,
) {
return _mergeOptions(config.architect.model, <String>[
'kimi-k2.5:cloud',
'qwen3.5:cloud',
'glm-5:cloud',
..._getLocalModelOptions(settings),
]);
}
List<String> _getLeadModelOptions(
SettingsSnapshot settings,
MultiAgentConfig config,
) {
return _mergeOptions(config.engineer.model, <String>[
'minimax-m2.7:cloud',
'qwen3.5:cloud',
'glm-5:cloud',
..._getLocalModelOptions(settings),
]);
}
List<String> _getWorkerModelOptions(
SettingsSnapshot settings,
MultiAgentConfig config,
) {
return _mergeOptions(config.tester.model, <String>[
'glm-5:cloud',
'qwen3.5:cloud',
'glm-4.7-flash',
'qwen3.5',
..._getLocalModelOptions(settings),
]);
}
List<Widget> _buildExperimental(
BuildContext context,
AppController controller,
SettingsSnapshot settings,
UiFeatureAccess uiFeatures,
) {
final toggles = <Widget>[
if (uiFeatures.allowsExperimentalSetting(
UiFeatureKeys.settingsExperimentalCanvas,
))
_SwitchRow(
label: appText('Canvas 宿主', 'Canvas host'),
value: settings.experimentalCanvas,
onChanged: (value) => _saveSettings(
controller,
settings.copyWith(experimentalCanvas: value),
),
),
if (uiFeatures.allowsExperimentalSetting(
UiFeatureKeys.settingsExperimentalBridge,
))
_SwitchRow(
label: appText('桥接模式', 'Bridge mode'),
value: settings.experimentalBridge,
onChanged: (value) => _saveSettings(
controller,
settings.copyWith(experimentalBridge: value),
),
),
if (uiFeatures.allowsExperimentalSetting(
UiFeatureKeys.settingsExperimentalDebug,
))
_SwitchRow(
label: appText('调试运行时', 'Debug runtime'),
value: settings.experimentalDebug,
onChanged: (value) => _saveSettings(
controller,
settings.copyWith(experimentalDebug: value),
),
),
];
return [
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('实验特性', 'Experimental'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
if (toggles.isEmpty)
Text(
appText(
'当前发布配置未开放额外实验开关。',
'This build does not expose additional experimental toggles.',
),
),
...toggles,
],
),
),
];
}
List<Widget> _buildAbout(BuildContext context, AppController controller) {
return [
SurfaceCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('关于', 'About'),
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
_InfoRow(label: appText('应用', 'App'), value: kSystemAppName),
_InfoRow(
label: appText('版本', 'Version'),
value: controller.runtime.packageInfo.version,
),
_InfoRow(
label: appText('构建号', 'Build'),
value: controller.runtime.packageInfo.buildNumber,
),
_InfoRow(
label: appText('包名', 'Package'),
value: controller.runtime.packageInfo.packageName,
),
],
),
),
];
}
Future<void> _saveSettings(
AppController controller,
SettingsSnapshot snapshot,
) {
return controller.saveSettings(snapshot);
}
Future<void> _saveMultiAgentConfig(
AppController controller,
MultiAgentConfig config,
) {
return controller.saveMultiAgentConfig(config);
}
AiGatewayProfile _buildAiGatewayDraft(SettingsSnapshot settings) {
final draftName = _aiGatewayNameController.text.trim();
final draftBaseUrl = _aiGatewayUrlController.text.trim();
final draftApiKeyRef = _aiGatewayApiKeyRefController.text.trim();
final current = settings.aiGateway;
final defaults = AiGatewayProfile.defaults();
final connectionChanged =
draftBaseUrl != current.baseUrl || draftApiKeyRef != current.apiKeyRef;
return current.copyWith(
name: draftName,
baseUrl: draftBaseUrl,
apiKeyRef: draftApiKeyRef,
availableModels: connectionChanged
? defaults.availableModels
: current.availableModels,
selectedModels: connectionChanged
? defaults.selectedModels
: current.selectedModels,
syncState: connectionChanged ? defaults.syncState : current.syncState,
syncMessage: connectionChanged
? defaults.syncMessage
: current.syncMessage,
);
}
Future<void> _saveAiGatewayDraft(
AppController controller,
SettingsSnapshot settings,
) async {
final draft = _buildAiGatewayDraft(settings);
final hasStoredAiGatewayApiKey =
controller.settingsController.secureRefs['ai_gateway_api_key'] != null;
await _saveSettings(controller, settings.copyWith(aiGateway: draft));
unawaited(
_persistAiGatewayApiKeyIfNeeded(
controller,
hasStoredValue: hasStoredAiGatewayApiKey,
).catchError((_) {}),
);
if (!mounted) {
return;
}
setState(() {
_aiGatewayNameSyncedValue = draft.name;
_aiGatewayUrlSyncedValue = draft.baseUrl;
_aiGatewayApiKeyRefSyncedValue = draft.apiKeyRef;
_aiGatewayTestState = draft.syncState;
_aiGatewayTestMessage = '';
_aiGatewayTestEndpoint = '';
});
}
Future<void> _testAiGatewayConnection(
AppController controller,
SettingsSnapshot settings,
) async {
final messenger = ScaffoldMessenger.of(context);
final draft = _buildAiGatewayDraft(settings);
final apiKey = _secretOverride(
_aiGatewayApiKeyController,
_aiGatewayApiKeyState,
);
setState(() => _aiGatewayTesting = true);
try {
final result = await controller.settingsController
.testAiGatewayConnection(draft, apiKeyOverride: apiKey);
if (!mounted) {
return;
}
setState(() {
_aiGatewayTestState = result.state;
_aiGatewayTestMessage = result.message;
_aiGatewayTestEndpoint = result.endpoint;
});
messenger.showSnackBar(SnackBar(content: Text(result.message)));
} finally {
if (mounted) {
setState(() => _aiGatewayTesting = false);
}
}
}
List<String> _filterAiGatewayModels(List<String> models) {
final query = _aiGatewayModelSearchController.text.trim().toLowerCase();
if (query.isEmpty) {
return models;
}
return models
.where((modelId) => modelId.toLowerCase().contains(query))
.toList(growable: false);
}
String _previewAiGatewayEndpoint(String rawUrl) {
final trimmed = rawUrl.trim();
if (trimmed.isEmpty) {
return '';
}
final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed';
final uri = Uri.tryParse(candidate);
if (uri == null || uri.host.trim().isEmpty) {
return '';
}
final pathSegments = uri.pathSegments
.where((item) => item.isNotEmpty)
.toList(growable: true);
if (pathSegments.isEmpty) {
pathSegments.add('v1');
}
if (pathSegments.last != 'models') {
pathSegments.add('models');
}
return uri
.replace(pathSegments: pathSegments, query: null, fragment: null)
.toString();
}
Widget _buildSecureField({
Key? fieldKey,
required TextEditingController controller,
required String label,
required bool hasStoredValue,
required _SecretFieldUiState fieldState,
required ValueChanged<_SecretFieldUiState> onStateChanged,
required Future<String> Function() loadValue,
required Future<void> Function(String) onSubmitted,
required String storedHelperText,
required String emptyHelperText,
}) {
_primeSecureFieldController(
controller,
hasStoredValue: hasStoredValue,
fieldState: fieldState,
);
final showMaskedPlaceholder =
hasStoredValue && !fieldState.showPlaintext && !fieldState.hasDraft;
return TextField(
key: fieldKey,
controller: controller,
obscureText: !fieldState.showPlaintext && fieldState.hasDraft,
autocorrect: false,
enableSuggestions: false,
decoration: InputDecoration(
labelText: label,
helperText: hasStoredValue ? storedHelperText : emptyHelperText,
suffixIcon: fieldState.loading
? const Padding(
padding: EdgeInsets.all(12),
child: SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: IconButton(
tooltip: fieldState.showPlaintext
? appText('隐藏', 'Hide')
: appText('查看', 'Reveal'),
onPressed: () => _toggleSecureFieldVisibility(
controller: controller,
hasStoredValue: hasStoredValue,
fieldState: fieldState,
onStateChanged: onStateChanged,
loadValue: loadValue,
),
icon: Icon(
fieldState.showPlaintext
? Icons.visibility_off_rounded
: Icons.visibility_rounded,
),
),
),
onTap: () {
if (!showMaskedPlaceholder) {
return;
}
controller.clear();
onStateChanged(fieldState.copyWith(hasDraft: true));
},
onChanged: (value) {
if (value == _storedSecretMask) {
return;
}
final nextHasDraft = value.trim().isNotEmpty;
if (nextHasDraft == fieldState.hasDraft) {
return;
}
onStateChanged(fieldState.copyWith(hasDraft: nextHasDraft));
},
onSubmitted: (_) => _persistSecureFieldIfNeeded(
controller: controller,
hasStoredValue: hasStoredValue,
fieldState: fieldState,
onStateChanged: onStateChanged,
onSubmitted: onSubmitted,
),
);
}
Future<void> _toggleSecureFieldVisibility({
required TextEditingController controller,
required bool hasStoredValue,
required _SecretFieldUiState fieldState,
required ValueChanged<_SecretFieldUiState> onStateChanged,
required Future<String> Function() loadValue,
}) async {
if (fieldState.showPlaintext) {
if (fieldState.hasDraft) {
onStateChanged(fieldState.copyWith(showPlaintext: false));
return;
}
if (hasStoredValue) {
_syncControllerValue(controller, _storedSecretMask);
} else {
controller.clear();
}
onStateChanged(const _SecretFieldUiState());
return;
}
if (fieldState.hasDraft || !hasStoredValue) {
onStateChanged(fieldState.copyWith(showPlaintext: true, loading: false));
return;
}
onStateChanged(fieldState.copyWith(loading: true));
final value = (await loadValue()).trim();
if (!mounted) {
return;
}
if (value.isNotEmpty) {
_syncControllerValue(controller, value);
} else {
controller.clear();
}
onStateChanged(
const _SecretFieldUiState(showPlaintext: true, hasDraft: false),
);
}
Future<void> _persistSecureFieldIfNeeded({
required TextEditingController controller,
required bool hasStoredValue,
required _SecretFieldUiState fieldState,
required ValueChanged<_SecretFieldUiState> onStateChanged,
required Future<void> Function(String) onSubmitted,
}) async {
final value = _normalizeSecretValue(controller.text);
if (value.isEmpty) {
return;
}
if (!fieldState.hasDraft && hasStoredValue) {
return;
}
await onSubmitted(value);
if (!mounted) {
return;
}
_syncControllerValue(controller, _storedSecretMask);
onStateChanged(const _SecretFieldUiState());
}
Future<void> _persistAiGatewayApiKeyIfNeeded(
AppController controller, {
required bool hasStoredValue,
}) {
return _persistSecureFieldIfNeeded(
controller: _aiGatewayApiKeyController,
hasStoredValue: hasStoredValue,
fieldState: _aiGatewayApiKeyState,
onStateChanged: (value) => setState(() => _aiGatewayApiKeyState = value),
onSubmitted: controller.settingsController.saveAiGatewayApiKey,
);
}
Future<void> _persistVaultTokenIfNeeded(
AppController controller, {
required bool hasStoredValue,
}) {
return _persistSecureFieldIfNeeded(
controller: _vaultTokenController,
hasStoredValue: hasStoredValue,
fieldState: _vaultTokenState,
onStateChanged: (value) => setState(() => _vaultTokenState = value),
onSubmitted: controller.settingsController.saveVaultToken,
);
}
Future<void> _persistOllamaApiKeyIfNeeded(
AppController controller, {
required bool hasStoredValue,
}) {
return _persistSecureFieldIfNeeded(
controller: _ollamaApiKeyController,
hasStoredValue: hasStoredValue,
fieldState: _ollamaApiKeyState,
onStateChanged: (value) => setState(() => _ollamaApiKeyState = value),
onSubmitted: controller.settingsController.saveOllamaCloudApiKey,
);
}
void _primeSecureFieldController(
TextEditingController controller, {
required bool hasStoredValue,
required _SecretFieldUiState fieldState,
}) {
if (fieldState.showPlaintext || fieldState.hasDraft) {
return;
}
final nextValue = hasStoredValue ? _storedSecretMask : '';
if (controller.text == nextValue) {
return;
}
_syncControllerValue(controller, nextValue);
}
String _secretOverride(
TextEditingController controller,
_SecretFieldUiState fieldState,
) {
if (!fieldState.showPlaintext && !fieldState.hasDraft) {
return '';
}
return _normalizeSecretValue(controller.text);
}
String _normalizeSecretValue(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty || trimmed == _storedSecretMask) {
return '';
}
return trimmed;
}
_AiGatewayFeedbackTheme _aiGatewayFeedbackTheme(
BuildContext context,
String state,
) {
final colorScheme = Theme.of(context).colorScheme;
return switch (state) {
'ready' => _AiGatewayFeedbackTheme(
background: colorScheme.primaryContainer,
border: colorScheme.primary,
foreground: colorScheme.onPrimaryContainer,
),
'empty' => _AiGatewayFeedbackTheme(
background: colorScheme.secondaryContainer,
border: colorScheme.secondary,
foreground: colorScheme.onSecondaryContainer,
),
'error' || 'invalid' => _AiGatewayFeedbackTheme(
background: colorScheme.errorContainer,
border: colorScheme.error,
foreground: colorScheme.onErrorContainer,
),
_ => _AiGatewayFeedbackTheme(
background: colorScheme.surfaceContainerHighest,
border: colorScheme.outlineVariant,
foreground: colorScheme.onSurfaceVariant,
),
};
}
void _syncControllerValue(TextEditingController controller, String value) {
if (controller.text == value) {
return;
}
controller.value = controller.value.copyWith(
text: value,
selection: TextSelection.collapsed(offset: value.length),
composing: TextRange.empty,
);
}
void _syncDraftControllerValue(
TextEditingController controller,
String value, {
required String syncedValue,
required ValueChanged<String> onSyncedValueChanged,
}) {
final hasLocalDraft = controller.text != syncedValue;
if (hasLocalDraft && controller.text != value) {
return;
}
_syncControllerValue(controller, value);
if (syncedValue != value) {
onSyncedValueChanged(value);
}
}
bool _matchesRuntimeLogFilter(RuntimeLogEntry entry) {
final query = _runtimeLogFilterController.text.trim().toLowerCase();
if (query.isEmpty) {
return true;
}
final haystack = '${entry.level} ${entry.category} ${entry.message}'
.toLowerCase();
return haystack.contains(query);
}
Widget _buildDeviceSecurityCard(
BuildContext context,
AppController controller,
) {
final theme = Theme.of(context);
final connection = controller.connection;
final devices = controller.devices;
final pending = devices.pending;
final paired = devices.paired;
final authScopes = connection.authScopes.isEmpty
? appText('未协商', 'Not negotiated')
: connection.authScopes.join(', ');
return SurfaceCard(
key: const ValueKey('gateway-device-security-card'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('设备配对与角色令牌', 'Device Pairing & Role Tokens'),
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 6),
Text(
appText(
'对齐 OpenClaw 的 Devices 安全机制,处理 pairing requests 和按角色下发的 device token。',
'Match OpenClaw device security: pairing requests and per-role device tokens.',
),
style: theme.textTheme.bodyMedium,
),
],
),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: controller.runtime.isConnected
? () => controller.refreshDevices()
: null,
child: Text(appText('刷新', 'Refresh')),
),
],
),
const SizedBox(height: 16),
_InfoRow(
label: appText('本机 Device ID', 'Local Device ID'),
value: connection.deviceId ?? appText('未初始化', 'Not initialized'),
),
_InfoRow(
label: appText('当前角色', 'Current Role'),
value: connection.authRole ?? 'operator',
),
_InfoRow(label: appText('授权范围', 'Granted Scopes'), value: authScopes),
if (connection.pairingRequired) ...[
const SizedBox(height: 8),
_buildNotice(
context,
tone: theme.colorScheme.tertiaryContainer,
title: appText('需要设备审批', 'Pairing Required'),
message: appText(
'当前设备已经向 Gateway 发起配对。请在已授权的 operator 设备上审批该请求,然后重新连接。',
'This device has requested pairing. Approve it from an authorized operator device, then reconnect.',
),
),
] else if (connection.gatewayTokenMissing) ...[
const SizedBox(height: 8),
_buildNotice(
context,
tone: theme.colorScheme.errorContainer,
title: appText('缺少共享 Token', 'Shared Token Missing'),
message: appText(
'当前连接没有通过共享 token 或已配对 device token 完成鉴权。先输入共享 Token 建立首次配对,后续可切换为 device token。',
'The current connection is missing shared-token or paired device-token auth. Use a shared token for the first pairing, then continue with the device token.',
),
),
],
if ((controller.devicesController.error ?? '').isNotEmpty) ...[
const SizedBox(height: 8),
_buildNotice(
context,
tone: theme.colorScheme.errorContainer,
title: appText('设备列表错误', 'Devices Error'),
message: controller.devicesController.error!,
),
],
const SizedBox(height: 16),
if (!controller.runtime.isConnected) ...[
Text(
appText(
'连接 Gateway 后,这里会显示待审批设备、已配对设备和角色令牌。',
'Connect the gateway to load pending devices, paired devices, and role tokens.',
),
style: theme.textTheme.bodyMedium,
),
] else ...[
Text(
appText('待审批请求', 'Pending Requests'),
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 10),
if (pending.isEmpty)
Text(
appText('当前没有待审批设备。', 'No pending pairing requests.'),
style: theme.textTheme.bodyMedium,
)
else
...pending.map(
(item) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildPendingDeviceCard(context, controller, item),
),
),
const SizedBox(height: 20),
Text(
appText('已配对设备', 'Paired Devices'),
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 10),
if (paired.isEmpty)
Text(
appText('当前没有已配对设备。', 'No paired devices yet.'),
style: theme.textTheme.bodyMedium,
)
else
...paired.map(
(item) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _buildPairedDeviceCard(context, controller, item),
),
),
],
],
),
);
}
Widget _buildPendingDeviceCard(
BuildContext context,
AppController controller,
GatewayPendingDevice item,
) {
final theme = Theme.of(context);
final metadata = <String>[
if ((item.role ?? '').isNotEmpty) 'role: ${item.role}',
if (item.scopes.isNotEmpty) item.scopes.join(', '),
if ((item.remoteIp ?? '').isNotEmpty) item.remoteIp!,
_relativeTime(item.requestedAtMs),
if (item.isRepair) appText('修复请求', 'repair'),
];
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.label, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
SelectableText(
item.deviceId,
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 8),
Text(metadata.join(' · '), style: theme.textTheme.bodySmall),
],
),
),
const SizedBox(width: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonal(
onPressed: () =>
controller.approveDevicePairing(item.requestId),
child: Text(appText('批准', 'Approve')),
),
OutlinedButton(
onPressed: () async {
final confirmed = await _confirmDeviceAction(
context,
title: appText('拒绝配对请求', 'Reject Pairing Request'),
message: appText(
'确定拒绝 ${item.label} 的配对请求吗?',
'Reject the pairing request from ${item.label}?',
),
);
if (confirmed == true) {
await controller.rejectDevicePairing(item.requestId);
}
},
child: Text(appText('拒绝', 'Reject')),
),
],
),
],
),
),
);
}
Widget _buildPairedDeviceCard(
BuildContext context,
AppController controller,
GatewayPairedDevice item,
) {
final theme = Theme.of(context);
final meta = <String>[
if (item.roles.isNotEmpty) 'roles: ${item.roles.join(', ')}',
if (item.scopes.isNotEmpty) 'scopes: ${item.scopes.join(', ')}',
if ((item.remoteIp ?? '').isNotEmpty) item.remoteIp!,
if (item.currentDevice) appText('当前设备', 'current device'),
];
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(item.label, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
SelectableText(
item.deviceId,
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 8),
Text(meta.join(' · '), style: theme.textTheme.bodySmall),
],
),
),
const SizedBox(width: 12),
OutlinedButton(
onPressed: () async {
final confirmed = await _confirmDeviceAction(
context,
title: appText('移除已配对设备', 'Remove Paired Device'),
message: appText(
'确定移除 ${item.label} 吗?这会使该设备需要重新配对。',
'Remove ${item.label}? The device will need pairing again.',
),
);
if (confirmed == true) {
await controller.removePairedDevice(item.deviceId);
}
},
child: Text(appText('移除', 'Remove')),
),
],
),
const SizedBox(height: 12),
if (item.tokens.isEmpty)
Text(
appText('当前没有角色令牌。', 'No role tokens.'),
style: theme.textTheme.bodySmall,
)
else
Padding(
padding: const EdgeInsets.only(top: 10),
child: _buildTokenRow(
context,
controller,
item,
_latestDeviceToken(item.tokens),
),
),
],
),
),
);
}
GatewayDeviceTokenSummary _latestDeviceToken(
List<GatewayDeviceTokenSummary> tokens,
) {
final sorted = List<GatewayDeviceTokenSummary>.from(tokens)
..sort((left, right) {
final rightTime = _deviceTokenStatusTime(right);
final leftTime = _deviceTokenStatusTime(left);
return rightTime.compareTo(leftTime);
});
return sorted.first;
}
int _deviceTokenStatusTime(GatewayDeviceTokenSummary token) {
return token.lastUsedAtMs ??
token.rotatedAtMs ??
token.revokedAtMs ??
token.createdAtMs ??
0;
}
Widget _buildTokenRow(
BuildContext context,
AppController controller,
GatewayPairedDevice device,
GatewayDeviceTokenSummary token,
) {
final theme = Theme.of(context);
final details = <String>[
token.revoked ? appText('已撤销', 'revoked') : appText('有效', 'active'),
if (token.scopes.isNotEmpty) token.scopes.join(', '),
_relativeTime(_deviceTokenStatusTime(token)),
];
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(14),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(token.role, style: theme.textTheme.titleSmall),
const SizedBox(height: 4),
Text(details.join(' · '), style: theme.textTheme.bodySmall),
],
),
),
const SizedBox(width: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilledButton.tonal(
onPressed: () async {
final nextToken = await controller.rotateDeviceRoleToken(
deviceId: device.deviceId,
role: token.role,
scopes: token.scopes,
);
if (!context.mounted ||
nextToken == null ||
nextToken.isEmpty) {
return;
}
await _showRotatedTokenDialog(
context,
device: device,
role: token.role,
token: nextToken,
);
},
child: Text(appText('轮换', 'Rotate')),
),
if (!token.revoked)
OutlinedButton(
onPressed: () async {
final confirmed = await _confirmDeviceAction(
context,
title: appText('撤销角色令牌', 'Revoke Role Token'),
message: appText(
'确定撤销 ${device.label}${token.role} 令牌吗?',
'Revoke the ${token.role} token for ${device.label}?',
),
);
if (confirmed == true) {
await controller.revokeDeviceRoleToken(
deviceId: device.deviceId,
role: token.role,
);
}
},
child: Text(appText('撤销', 'Revoke')),
),
],
),
],
),
),
);
}
Widget _buildNotice(
BuildContext context, {
required Color tone,
required String title,
required String message,
}) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: tone,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
const SizedBox(height: 6),
SelectableText(message, style: theme.textTheme.bodyMedium),
],
),
);
}
Future<bool?> _confirmDeviceAction(
BuildContext context, {
required String title,
required String message,
}) {
return showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text(appText('取消', 'Cancel')),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(appText('确认', 'Confirm')),
),
],
),
);
}
Future<void> _showRotatedTokenDialog(
BuildContext context, {
required GatewayPairedDevice device,
required String role,
required String token,
}) {
return showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text(appText('新的角色令牌', 'New Role Token')),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText(
'${device.label}$role 令牌已轮换,请立即安全保存。',
'Rotated the $role token for ${device.label}. Store it securely now.',
),
),
const SizedBox(height: 12),
SelectableText(token),
],
),
actions: [
FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(appText('关闭', 'Close')),
),
],
),
);
}
String _relativeTime(int? timestampMs) {
if (timestampMs == null || timestampMs <= 0) {
return appText('时间未知', 'time unknown');
}
final delta = DateTime.now().difference(
DateTime.fromMillisecondsSinceEpoch(timestampMs),
);
if (delta.inMinutes < 1) {
return appText('刚刚', 'just now');
}
if (delta.inHours < 1) {
return appText('${delta.inMinutes} 分钟前', '${delta.inMinutes}m ago');
}
if (delta.inDays < 1) {
return appText('${delta.inHours} 小时前', '${delta.inHours}h ago');
}
return appText('${delta.inDays} 天前', '${delta.inDays}d ago');
}
}
class _EditableField extends StatelessWidget {
const _EditableField({
required this.label,
required this.value,
required this.onSubmitted,
});
final String label;
final String value;
final ValueChanged<String> onSubmitted;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 14),
child: TextFormField(
key: ValueKey('$label:$value'),
initialValue: value,
decoration: InputDecoration(labelText: label),
onFieldSubmitted: onSubmitted,
),
);
}
}
class _SwitchRow extends StatelessWidget {
const _SwitchRow({
required this.label,
required this.value,
required this.onChanged,
});
final String label;
final bool value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
return SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: Text(label),
value: value,
onChanged: onChanged,
);
}
}
class _MountTargetCard extends StatelessWidget {
const _MountTargetCard({required this.target});
final ManagedMountTargetState target;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final statusColor = target.available
? theme.colorScheme.primary
: theme.colorScheme.outline;
final summary = <String>[
'${appText('发现', 'Discovery')}: ${target.discoveryState}',
'${appText('同步', 'Sync')}: ${target.syncState}',
if (target.supportsSkills)
'${appText('技能', 'Skills')}: ${target.discoveredSkillCount}',
if (target.supportsMcp)
'${appText('MCP', 'MCP')}: ${target.discoveredMcpCount}',
if (target.supportsMcp)
'${appText('托管', 'Managed')}: ${target.managedMcpCount}',
];
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(18),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: statusColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(target.label, style: theme.textTheme.titleMedium),
),
Text(
target.available
? appText('可用', 'Available')
: appText('未安装', 'Missing'),
style: theme.textTheme.bodySmall,
),
],
),
const SizedBox(height: 8),
Text(summary.join(' · '), style: theme.textTheme.bodySmall),
if (target.detail.trim().isNotEmpty) ...[
const SizedBox(height: 8),
Text(target.detail, style: theme.textTheme.bodyMedium),
],
],
),
),
);
}
}
class _InlineSwitchField extends StatelessWidget {
const _InlineSwitchField({
required this.label,
required this.value,
required this.onChanged,
});
final String label;
final bool value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(14),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 10, 10, 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
label,
style: theme.textTheme.labelLarge,
softWrap: true,
),
),
const SizedBox(width: 12),
Switch.adaptive(
value: value,
onChanged: onChanged,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
],
),
),
);
}
}
class _AiGatewayFeedbackTheme {
const _AiGatewayFeedbackTheme({
required this.background,
required this.border,
required this.foreground,
});
final Color background;
final Color border;
final Color foreground;
}
class _SecretFieldUiState {
const _SecretFieldUiState({
this.showPlaintext = false,
this.hasDraft = false,
this.loading = false,
});
final bool showPlaintext;
final bool hasDraft;
final bool loading;
_SecretFieldUiState copyWith({
bool? showPlaintext,
bool? hasDraft,
bool? loading,
}) {
return _SecretFieldUiState(
showPlaintext: showPlaintext ?? this.showPlaintext,
hasDraft: hasDraft ?? this.hasDraft,
loading: loading ?? this.loading,
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 140,
child: Text(label, style: Theme.of(context).textTheme.labelLarge),
),
const SizedBox(width: 16),
Expanded(child: SelectableText(value)),
],
),
);
}
}
/// Agent 角色配置卡片
class _AgentRoleCard extends StatelessWidget {
const _AgentRoleCard({
required this.title,
required this.description,
required this.cliTool,
required this.model,
required this.enabled,
required this.cliOptions,
required this.modelOptions,
required this.onCliChanged,
required this.onModelChanged,
required this.onEnabledChanged,
});
final String title;
final String description;
final String cliTool;
final String model;
final bool enabled;
final List<String> cliOptions;
final List<String> modelOptions;
final ValueChanged<String> onCliChanged;
final ValueChanged<String> onModelChanged;
final ValueChanged<bool> onEnabledChanged;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 720;
final info = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
const SizedBox(height: 4),
Text(description, style: theme.textTheme.bodySmall),
],
);
final toggle = _InlineSwitchField(
label: appText('启用', 'Enabled'),
value: enabled,
onChanged: onEnabledChanged,
);
if (cliOptions.length <= 1) {
return info;
}
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [info, const SizedBox(height: 12), toggle],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: info),
const SizedBox(width: 16),
Flexible(
child: Align(alignment: Alignment.topRight, child: toggle),
),
],
);
},
),
const SizedBox(height: 12),
LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 720;
final cliField = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('CLI', style: theme.textTheme.labelMedium),
const SizedBox(height: 4),
DropdownButtonFormField<String>(
initialValue: cliOptions.contains(cliTool)
? cliTool
: cliOptions.first,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
items: cliOptions
.map((t) => DropdownMenuItem(value: t, child: Text(t)))
.toList(),
onChanged: (v) {
if (v != null) onCliChanged(v);
},
),
],
);
final modelField = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
appText('模型', 'Model'),
style: theme.textTheme.labelMedium,
),
const SizedBox(height: 4),
DropdownButtonFormField<String>(
initialValue: modelOptions.contains(model)
? model
: modelOptions.first,
decoration: const InputDecoration(
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
items: modelOptions
.map(
(m) => DropdownMenuItem(
value: m,
child: Text(m, overflow: TextOverflow.ellipsis),
),
)
.toList(),
onChanged: (v) {
if (v != null) onModelChanged(v);
},
),
],
);
if (compact) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [cliField, const SizedBox(height: 12), modelField],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: cliField),
const SizedBox(width: 12),
Expanded(flex: 2, child: modelField),
],
);
},
),
],
),
);
}
}
/// 工作流步骤展示
class _WorkflowStep extends StatelessWidget {
const _WorkflowStep({
required this.label,
required this.emoji,
required this.title,
required this.desc,
});
final String label;
final String emoji;
final String title;
final String desc;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Container(
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.colorScheme.primaryContainer,
),
child: Text(label, style: theme.textTheme.labelSmall),
),
const SizedBox(width: 12),
Text(emoji, style: const TextStyle(fontSize: 16)),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.labelLarge),
Text(desc, style: theme.textTheme.bodySmall),
],
),
),
],
),
);
}
}