Fix settings page layout and AI Gateway persistence
This commit is contained in:
parent
8067916b5b
commit
87a6d416f9
@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/app_controller.dart';
|
||||
@ -42,6 +44,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
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();
|
||||
@ -614,11 +619,23 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
AppController controller,
|
||||
SettingsSnapshot settings,
|
||||
) {
|
||||
_syncControllerValue(_aiGatewayNameController, settings.aiGateway.name);
|
||||
_syncControllerValue(_aiGatewayUrlController, settings.aiGateway.baseUrl);
|
||||
_syncControllerValue(
|
||||
_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
|
||||
@ -797,6 +814,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
key: const ValueKey('ai-gateway-name-field'),
|
||||
controller: _aiGatewayNameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('配置名称', 'Profile Name'),
|
||||
@ -805,6 +823,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
key: const ValueKey('ai-gateway-url-field'),
|
||||
controller: _aiGatewayUrlController,
|
||||
decoration: InputDecoration(
|
||||
labelText: appText('Gateway URL', 'Gateway URL'),
|
||||
@ -813,6 +832,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
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'),
|
||||
@ -820,6 +840,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
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()})',
|
||||
@ -844,6 +865,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
FilledButton.tonal(
|
||||
key: const ValueKey('ai-gateway-save-button'),
|
||||
onPressed: _aiGatewayTesting || _aiGatewaySyncing
|
||||
? null
|
||||
: () => _saveAiGatewayDraft(controller, settings),
|
||||
@ -874,14 +896,16 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
);
|
||||
setState(() => _aiGatewaySyncing = true);
|
||||
try {
|
||||
await _persistAiGatewayApiKeyIfNeeded(
|
||||
controller,
|
||||
hasStoredValue: hasStoredAiGatewayApiKey,
|
||||
);
|
||||
await _saveSettings(
|
||||
controller,
|
||||
settings.copyWith(aiGateway: draft),
|
||||
);
|
||||
unawaited(
|
||||
_persistAiGatewayApiKeyIfNeeded(
|
||||
controller,
|
||||
hasStoredValue: hasStoredAiGatewayApiKey,
|
||||
).catchError((_) {}),
|
||||
);
|
||||
final result = await controller.syncAiGatewayCatalog(
|
||||
draft,
|
||||
apiKeyOverride: apiKey,
|
||||
@ -891,11 +915,12 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
setState(() {
|
||||
_aiGatewayTestState = result.syncState;
|
||||
_aiGatewayTestMessage =
|
||||
'Catalog synced · ${result.availableModels.length} model(s) ready';
|
||||
_aiGatewayTestEndpoint = _previewAiGatewayEndpoint(
|
||||
draft.baseUrl,
|
||||
);
|
||||
_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)),
|
||||
@ -1291,36 +1316,54 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText('多 Agent 协作', 'Multi-Agent Collaboration'),
|
||||
style: theme.textTheme.titleLarge,
|
||||
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(
|
||||
'通过 Ollama 驱动多个 CLI 工具协同工作,实现 Architect → Engineer → Tester 的完整工作流。',
|
||||
'Orchestrate multiple CLI agents via Ollama for Architect → Engineer → Tester workflows.',
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
appText(
|
||||
'通过 Ollama 驱动多个 CLI 工具协同工作,实现 Architect → Engineer → Tester 的完整工作流。',
|
||||
'Orchestrate multiple CLI agents via Ollama for Architect → Engineer → Tester workflows.',
|
||||
),
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_SwitchRow(
|
||||
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),
|
||||
_InfoRow(label: 'Ollama', value: config.ollamaEndpoint),
|
||||
@ -1502,35 +1545,46 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
appText('发现与分发', 'Discovery & Distribution'),
|
||||
style: theme.textTheme.titleLarge,
|
||||
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.',
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton(
|
||||
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(
|
||||
@ -1644,17 +1698,16 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
|
||||
List<String> _getLocalModelOptions(SettingsSnapshot settings) {
|
||||
// 从 ollamaLocal 配置中获取可用模型
|
||||
final defaultModel = settings.ollamaLocal.defaultModel;
|
||||
if (defaultModel.isNotEmpty) {
|
||||
return [
|
||||
defaultModel,
|
||||
'qwen2.5-coder:latest',
|
||||
'gpt-oss:20b',
|
||||
'glm-4.7-flash',
|
||||
];
|
||||
}
|
||||
return const ['qwen2.5-coder:latest', 'gpt-oss:20b', 'glm-4.7-flash'];
|
||||
return <String>[
|
||||
settings.ollamaLocal.defaultModel,
|
||||
'qwen2.5-coder:latest',
|
||||
'gpt-oss:20b',
|
||||
'glm-4.7-flash',
|
||||
]
|
||||
.map((item) => item.trim())
|
||||
.where((item) => item.isNotEmpty)
|
||||
.toSet()
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<Widget> _buildExperimental(
|
||||
@ -1747,10 +1800,27 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
|
||||
AiGatewayProfile _buildAiGatewayDraft(SettingsSnapshot settings) {
|
||||
return settings.aiGateway.copyWith(
|
||||
name: _aiGatewayNameController.text.trim(),
|
||||
baseUrl: _aiGatewayUrlController.text.trim(),
|
||||
apiKeyRef: _aiGatewayApiKeyRefController.text.trim(),
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1758,16 +1828,27 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
AppController controller,
|
||||
SettingsSnapshot settings,
|
||||
) async {
|
||||
final draft = _buildAiGatewayDraft(settings);
|
||||
final hasStoredAiGatewayApiKey =
|
||||
controller.settingsController.secureRefs['ai_gateway_api_key'] != null;
|
||||
await _persistAiGatewayApiKeyIfNeeded(
|
||||
controller,
|
||||
hasStoredValue: hasStoredAiGatewayApiKey,
|
||||
);
|
||||
await _saveSettings(
|
||||
controller,
|
||||
settings.copyWith(aiGateway: _buildAiGatewayDraft(settings)),
|
||||
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(
|
||||
@ -1835,6 +1916,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
}
|
||||
|
||||
Widget _buildSecureField({
|
||||
Key? fieldKey,
|
||||
required TextEditingController controller,
|
||||
required String label,
|
||||
required bool hasStoredValue,
|
||||
@ -1853,6 +1935,7 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
final showMaskedPlaceholder =
|
||||
hasStoredValue && !fieldState.showPlaintext && !fieldState.hasDraft;
|
||||
return TextField(
|
||||
key: fieldKey,
|
||||
controller: controller,
|
||||
obscureText: !fieldState.showPlaintext && fieldState.hasDraft,
|
||||
autocorrect: false,
|
||||
@ -2086,6 +2169,22 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -2712,6 +2811,50 @@ class _MountTargetCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@ -2811,96 +2954,120 @@ class _AgentRoleCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
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: [
|
||||
Text(title, style: theme.textTheme.titleMedium),
|
||||
const SizedBox(height: 4),
|
||||
Text(description, style: theme.textTheme.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (cliOptions.length > 1)
|
||||
_SwitchRow(
|
||||
label: appText('启用', 'Enabled'),
|
||||
value: enabled,
|
||||
onChanged: onEnabledChanged,
|
||||
),
|
||||
],
|
||||
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),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: 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,
|
||||
),
|
||||
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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: 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: 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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
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),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
126
test/features/settings_ai_gateway_persistence_test.dart
Normal file
126
test/features/settings_ai_gateway_persistence_test.dart
Normal file
@ -0,0 +1,126 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:xworkmate/app/app_controller.dart';
|
||||
import 'package:xworkmate/features/settings/settings_page.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
import 'package:xworkmate/theme/app_theme.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('SettingsPage AI Gateway draft persists edited fields', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
late AppController controller;
|
||||
await tester.runAsync(() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
controller = AppController(
|
||||
store: SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
fallbackDirectoryPathResolver: () async =>
|
||||
'${Directory.systemTemp.path}/xworkmate-widget-tests',
|
||||
),
|
||||
);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
final staleGateway = controller.settings.aiGateway.copyWith(
|
||||
name: 'default',
|
||||
baseUrl: '',
|
||||
apiKeyRef: 'ai_gateway_api_key',
|
||||
availableModels: const <String>['stale-model'],
|
||||
selectedModels: const <String>['stale-model'],
|
||||
syncState: 'invalid',
|
||||
syncMessage: 'Missing AI Gateway URL',
|
||||
);
|
||||
await controller.saveSettings(
|
||||
controller.settings.copyWith(
|
||||
aiGateway: staleGateway,
|
||||
multiAgent: controller.settings.multiAgent.copyWith(autoSync: false),
|
||||
),
|
||||
refreshAfterSave: false,
|
||||
);
|
||||
});
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(1600, 1000);
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('zh'),
|
||||
supportedLocales: const [Locale('zh'), Locale('en')],
|
||||
localizationsDelegates: GlobalMaterialLocalizations.delegates,
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
home: Scaffold(body: SettingsPage(controller: controller)),
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
await tester.tap(find.text('集成'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('ai-gateway-name-field')),
|
||||
'default',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('ai-gateway-url-field')),
|
||||
'https://api.svc.plus/v1',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('ai-gateway-api-key-ref-field')),
|
||||
'ai_gateway_api_key',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('ai-gateway-api-key-field')),
|
||||
'live-secret',
|
||||
);
|
||||
|
||||
expect(
|
||||
tester
|
||||
.widget<TextField>(find.byKey(const ValueKey('ai-gateway-url-field')))
|
||||
.controller!
|
||||
.text,
|
||||
'https://api.svc.plus/v1',
|
||||
);
|
||||
tester
|
||||
.widget<FilledButton>(
|
||||
find.byKey(const ValueKey('ai-gateway-save-button')),
|
||||
)
|
||||
.onPressed!();
|
||||
await tester.pump();
|
||||
await tester.runAsync(() async {
|
||||
await _waitFor(
|
||||
() =>
|
||||
controller.settings.aiGateway.baseUrl == 'https://api.svc.plus/v1',
|
||||
);
|
||||
});
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
|
||||
expect(controller.settings.aiGateway.name, 'default');
|
||||
expect(controller.settings.aiGateway.baseUrl, 'https://api.svc.plus/v1');
|
||||
expect(controller.settings.aiGateway.apiKeyRef, 'ai_gateway_api_key');
|
||||
expect(controller.settings.aiGateway.availableModels, isEmpty);
|
||||
expect(controller.settings.aiGateway.selectedModels, isEmpty);
|
||||
expect(controller.settings.aiGateway.syncState, 'idle');
|
||||
expect(controller.settings.aiGateway.syncMessage, 'Ready to sync models');
|
||||
expect(find.text('Missing AI Gateway URL'), findsNothing);
|
||||
expect(find.text('Ready to sync models'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _waitFor(bool Function() predicate) async {
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 5));
|
||||
while (!predicate()) {
|
||||
if (DateTime.now().isAfter(deadline)) {
|
||||
fail('condition not met before timeout');
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
}
|
||||
@ -117,6 +117,35 @@ void main() {
|
||||
expect(find.text('切换到代理'), findsOneWidget);
|
||||
expect(find.text('连接隧道'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('SettingsPage multi-agent tab keeps header readable', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
final controller = await createTestController(tester);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: const SizedBox(width: 1100, height: 900, child: Placeholder()),
|
||||
);
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: SizedBox(
|
||||
width: 1100,
|
||||
height: 900,
|
||||
child: SettingsPage(controller: controller),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.tap(find.text('多 Agent'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final titleFinder = find.text('多 Agent 协作');
|
||||
expect(titleFinder, findsOneWidget);
|
||||
expect(tester.getSize(titleFinder).width, greaterThan(80));
|
||||
expect(find.text('启用协作模式'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('SettingsPage diagnostics tab filters and clears runtime logs', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
@ -132,10 +161,7 @@ void main() {
|
||||
message: 'pairing required',
|
||||
);
|
||||
|
||||
await pumpPage(
|
||||
tester,
|
||||
child: SettingsPage(controller: controller),
|
||||
);
|
||||
await pumpPage(tester, child: SettingsPage(controller: controller));
|
||||
|
||||
await tester.tap(find.text('诊断'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user