From 3bf965ddfac35ff55f08f19d2e50ff237b535491 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Fri, 13 Mar 2026 15:21:23 +0800 Subject: [PATCH] feat: ship ai gateway integration and ui polish --- lib/app/app_controller.dart | 92 +++- lib/data/mock_data.dart | 4 +- lib/features/assistant/assistant_page.dart | 192 +++---- lib/features/mobile/ios_mobile_shell.dart | 443 ++++++++++++---- lib/features/modules/modules_page.dart | 17 +- lib/features/settings/settings_page.dart | 448 ++++++++++++++-- lib/runtime/runtime_controllers.dart | 488 +++++++++++++++--- lib/runtime/runtime_models.dart | 162 +++--- lib/runtime/secure_config_store.dart | 12 + lib/theme/app_theme.dart | 188 +++++-- pubspec.yaml | 29 ++ scripts/package-flutter-mac-app.sh | 20 +- test/features/assistant_page_test.dart | 29 +- ...app_controller_ai_gateway_models_test.dart | 47 ++ test/runtime/secure_config_store_test.dart | 2 + ...tings_controller_ai_gateway_sync_test.dart | 199 +++++++ 16 files changed, 1931 insertions(+), 441 deletions(-) create mode 100644 test/runtime/app_controller_ai_gateway_models_test.dart create mode 100644 test/runtime/settings_controller_ai_gateway_sync_test.dart diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index c96ba2ea..e72f1a75 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -24,7 +24,7 @@ class AppController extends ChangeNotifier { _instancesController = InstancesController(_runtime); _skillsController = SkillsController(_runtime); _connectorsController = ConnectorsController(_runtime); - _modelsController = ModelsController(_runtime); + _modelsController = ModelsController(_runtime, _settingsController); _cronJobsController = CronJobsController(_runtime); _devicesController = DevicesController(_runtime); _tasksController = DerivedTasksController(); @@ -104,6 +104,36 @@ class AppController extends ChangeNotifier { _settingsController.secureRefs.containsKey('gateway_token'); String? get storedGatewayTokenMask => _settingsController.secureRefs['gateway_token']; + List get aiGatewayModelChoices { + final selected = settings.aiGateway.selectedModels + .where(settings.aiGateway.availableModels.contains) + .toList(growable: false); + if (selected.isNotEmpty) { + return selected; + } + final available = settings.aiGateway.availableModels + .take(5) + .toList(growable: false); + if (available.isNotEmpty) { + return available; + } + return _modelsController.items + .map((item) => item.id) + .toList(growable: false); + } + + String get resolvedDefaultModel { + final current = settings.defaultModel.trim(); + final choices = aiGatewayModelChoices; + if (choices.contains(current)) { + return current; + } + if (choices.isNotEmpty) { + return choices.first; + } + return current; + } + bool get canQuickConnectGateway { final profile = settings.gateway; if (profile.useSetupCode && profile.setupCode.trim().isNotEmpty) { @@ -456,6 +486,60 @@ class AppController extends ChangeNotifier { ); } + Future selectDefaultModel(String modelId) async { + final trimmed = modelId.trim(); + if (trimmed.isEmpty || settings.defaultModel == trimmed) { + return; + } + await saveSettings( + settings.copyWith(defaultModel: trimmed), + refreshAfterSave: false, + ); + } + + Future updateAiGatewaySelection(List selectedModels) async { + final available = settings.aiGateway.availableModels; + final normalized = selectedModels + .map((item) => item.trim()) + .where((item) => item.isNotEmpty && available.contains(item)) + .toList(growable: false); + final fallbackSelection = normalized.isNotEmpty + ? normalized + : available.isNotEmpty + ? [available.first] + : const []; + final currentDefaultModel = settings.defaultModel.trim(); + final resolvedDefaultModel = fallbackSelection.contains(currentDefaultModel) + ? currentDefaultModel + : fallbackSelection.isNotEmpty + ? fallbackSelection.first + : ''; + await saveSettings( + settings.copyWith( + aiGateway: settings.aiGateway.copyWith( + selectedModels: fallbackSelection, + ), + defaultModel: resolvedDefaultModel, + ), + refreshAfterSave: false, + ); + } + + Future syncAiGatewayCatalog( + AiGatewayProfile profile, { + String apiKeyOverride = '', + }) async { + final synced = await _settingsController.syncAiGatewayCatalog( + profile, + apiKeyOverride: apiKeyOverride, + ); + _modelsController.restoreFromSettings( + _settingsController.snapshot.aiGateway, + ); + _recomputeTasks(); + return synced; + } + Future saveSettings( SettingsSnapshot snapshot, { bool refreshAfterSave = true, @@ -463,6 +547,7 @@ class AppController extends ChangeNotifier { setActiveAppLanguage(snapshot.appLanguage); await _settingsController.saveSnapshot(snapshot); _agentsController.restoreSelection(snapshot.gateway.selectedAgentId); + _modelsController.restoreFromSettings(snapshot.aiGateway); if (refreshAfterSave) { _recomputeTasks(); } @@ -480,10 +565,6 @@ class AppController extends ChangeNotifier { _runtime.clearLogs(); } - Future validateApisixYaml(ApisixYamlProfile profile) { - return _settingsController.validateApisixYaml(profile); - } - List taskItemsForTab(String tab) => switch (tab) { 'Queue' => _tasksController.queue, 'Running' => _tasksController.running, @@ -523,6 +604,7 @@ class AppController extends ChangeNotifier { if (seeded.toJsonString() != settings.toJsonString()) { await _settingsController.saveSnapshot(seeded); } + _modelsController.restoreFromSettings(settings.aiGateway); setActiveAppLanguage(settings.appLanguage); await _runtime.initialize(); _agentsController.restoreSelection(settings.gateway.selectedAgentId); diff --git a/lib/data/mock_data.dart b/lib/data/mock_data.dart index c219d670..4d8a59a8 100644 --- a/lib/data/mock_data.dart +++ b/lib/data/mock_data.dart @@ -188,7 +188,7 @@ class MockData { static const gatewayModules = [ ModuleSummary( - name: 'APISIX AI Gateway', + name: 'AI Gateway', description: 'Healthy · version $kAppVersion · 3 nodes · 12 active sessions', status: StatusInfo('Healthy', StatusTone.success), @@ -576,7 +576,7 @@ class MockData { SettingSummary( title: 'Gateway default route', description: '控制面启动后默认挂载的主路由。', - value: 'APISIX AI Gateway', + value: 'AI Gateway', ), SettingSummary( title: 'Session retention', diff --git a/lib/features/assistant/assistant_page.dart b/lib/features/assistant/assistant_page.dart index 8f9136c5..2a9f57f2 100644 --- a/lib/features/assistant/assistant_page.dart +++ b/lib/features/assistant/assistant_page.dart @@ -6,7 +6,6 @@ import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; import '../../app/app_metadata.dart'; -import '../../data/mock_data.dart'; import '../../i18n/app_language.dart'; import '../../models/app_models.dart'; import '../../runtime/runtime_models.dart'; @@ -68,9 +67,6 @@ class _AssistantPageState extends State { final controller = widget.controller; final messages = List.from(controller.chatMessages); final timelineItems = _buildTimelineItems(controller, messages); - final quickActions = MockData.quickActions - .take(6) - .toList(growable: false); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_conversationController.hasClients) { @@ -154,12 +150,14 @@ class _AssistantPageState extends State { SizedBox( height: composerHeight, child: _AssistantLowerPane( - quickActions: quickActions, inputController: _inputController, focusNode: _composerFocusNode, mode: _mode, thinkingLabel: _thinkingLabel, - modelLabel: controller.settings.defaultModel, + modelLabel: controller.resolvedDefaultModel.isEmpty + ? appText('未选择模型', 'No model selected') + : controller.resolvedDefaultModel, + modelOptions: controller.aiGatewayModelChoices, attachments: _attachments, autoAgentLabel: _lastAutoAgentLabel, controller: controller, @@ -167,6 +165,7 @@ class _AssistantPageState extends State { onThinkingChanged: (value) { setState(() => _thinkingLabel = value); }, + onModelChanged: controller.selectDefaultModel, onRemoveAttachment: (attachment) { setState(() { _attachments = _attachments @@ -488,17 +487,18 @@ class _AssistantPageState extends State { class _AssistantLowerPane extends StatelessWidget { const _AssistantLowerPane({ - required this.quickActions, required this.controller, required this.inputController, required this.focusNode, required this.mode, required this.thinkingLabel, required this.modelLabel, + required this.modelOptions, required this.attachments, required this.autoAgentLabel, required this.onModeChanged, required this.onThinkingChanged, + required this.onModelChanged, required this.onRemoveAttachment, required this.onOpenGateway, required this.onReconnectGateway, @@ -507,17 +507,18 @@ class _AssistantLowerPane extends StatelessWidget { required this.onSend, }); - final List quickActions; final AppController controller; final TextEditingController inputController; final FocusNode focusNode; final String mode; final String thinkingLabel; final String modelLabel; + final List modelOptions; final List<_ComposerAttachment> attachments; final String? autoAgentLabel; final ValueChanged onModeChanged; final ValueChanged onThinkingChanged; + final Future Function(String modelId) onModelChanged; final ValueChanged<_ComposerAttachment> onRemoveAttachment; final VoidCallback onOpenGateway; final Future Function() onReconnectGateway; @@ -529,47 +530,24 @@ class _AssistantLowerPane extends StatelessWidget { Widget build(BuildContext context) { return SingleChildScrollView( physics: const ClampingScrollPhysics(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Align( - alignment: Alignment.centerLeft, - child: Wrap( - spacing: 8, - runSpacing: 8, - children: quickActions - .map( - (action) => ActionChip( - avatar: Icon(action.icon, size: 16), - label: Text(action.title), - onPressed: () { - inputController.text = action.title; - onFocusComposer(); - }, - ), - ) - .toList(), - ), - ), - const SizedBox(height: 8), - _ComposerBar( - controller: controller, - inputController: inputController, - focusNode: focusNode, - mode: mode, - thinkingLabel: thinkingLabel, - modelLabel: modelLabel, - attachments: attachments, - autoAgentLabel: autoAgentLabel, - onModeChanged: onModeChanged, - onThinkingChanged: onThinkingChanged, - onRemoveAttachment: onRemoveAttachment, - onOpenGateway: onOpenGateway, - onReconnectGateway: onReconnectGateway, - onPickAttachments: onPickAttachments, - onSend: onSend, - ), - ], + child: _ComposerBar( + controller: controller, + inputController: inputController, + focusNode: focusNode, + mode: mode, + thinkingLabel: thinkingLabel, + modelLabel: modelLabel, + modelOptions: modelOptions, + attachments: attachments, + autoAgentLabel: autoAgentLabel, + onModeChanged: onModeChanged, + onThinkingChanged: onThinkingChanged, + onModelChanged: onModelChanged, + onRemoveAttachment: onRemoveAttachment, + onOpenGateway: onOpenGateway, + onReconnectGateway: onReconnectGateway, + onPickAttachments: onPickAttachments, + onSend: onSend, ), ); } @@ -883,10 +861,12 @@ class _ComposerBar extends StatelessWidget { required this.mode, required this.thinkingLabel, required this.modelLabel, + required this.modelOptions, required this.attachments, required this.autoAgentLabel, required this.onModeChanged, required this.onThinkingChanged, + required this.onModelChanged, required this.onRemoveAttachment, required this.onOpenGateway, required this.onReconnectGateway, @@ -900,10 +880,12 @@ class _ComposerBar extends StatelessWidget { final String mode; final String thinkingLabel; final String modelLabel; + final List modelOptions; final List<_ComposerAttachment> attachments; final String? autoAgentLabel; final ValueChanged onModeChanged; final ValueChanged onThinkingChanged; + final Future Function(String modelId) onModelChanged; final ValueChanged<_ComposerAttachment> onRemoveAttachment; final VoidCallback onOpenGateway; final Future Function() onReconnectGateway; @@ -1143,11 +1125,40 @@ class _ComposerBar extends StatelessWidget { ), ), const SizedBox(width: 8), - _ComposerToolbarChip( - icon: Icons.bolt_rounded, - label: modelLabel, - showChevron: true, - ), + modelOptions.isEmpty + ? _ComposerToolbarChip( + icon: Icons.bolt_rounded, + label: modelLabel, + showChevron: false, + ) + : PopupMenuButton( + tooltip: appText('模型', 'Model'), + onSelected: (value) { + onModelChanged(value); + }, + itemBuilder: (context) => modelOptions + .map( + (value) => PopupMenuItem( + value: value, + child: Row( + children: [ + Expanded(child: Text(value)), + if (value == modelLabel) + const Icon( + Icons.check_rounded, + size: 18, + ), + ], + ), + ), + ) + .toList(), + child: _ComposerToolbarChip( + icon: Icons.bolt_rounded, + label: modelLabel, + showChevron: true, + ), + ), const SizedBox(width: 8), PopupMenuButton( tooltip: appText('模式', 'Mode'), @@ -1200,42 +1211,45 @@ class _ComposerBar extends StatelessWidget { ), ), const SizedBox(width: 12), - FilledButton( - onPressed: connecting - ? null - : connected - ? onSend - : reconnectAvailable - ? () async { - await onReconnectGateway(); - } - : onOpenGateway, - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 14, - vertical: 10, - ), - minimumSize: const Size(92, 40), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(999), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - connected - ? (mode == 'ask' - ? Icons.arrow_upward_rounded - : Icons.play_arrow_rounded) - : reconnectAvailable - ? Icons.refresh_rounded - : Icons.link_rounded, - size: 18, + Tooltip( + message: submitLabel, + child: FilledButton( + onPressed: connecting + ? null + : connected + ? onSend + : reconnectAvailable + ? () async { + await onReconnectGateway(); + } + : onOpenGateway, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 10, ), - const SizedBox(width: 6), - Text(submitLabel), - ], + minimumSize: const Size(92, 40), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + connected + ? (mode == 'ask' + ? Icons.arrow_upward_rounded + : Icons.play_arrow_rounded) + : reconnectAvailable + ? Icons.refresh_rounded + : Icons.link_rounded, + size: 18, + ), + const SizedBox(width: 6), + Text(submitLabel), + ], + ), ), ), ], diff --git a/lib/features/mobile/ios_mobile_shell.dart b/lib/features/mobile/ios_mobile_shell.dart index b6148047..df682624 100644 --- a/lib/features/mobile/ios_mobile_shell.dart +++ b/lib/features/mobile/ios_mobile_shell.dart @@ -610,19 +610,19 @@ class _IosMobileShellState extends State { ], ), const SizedBox(height: 22), - const _SectionTitle('APISIX YAML'), + const _SectionTitle('AI Gateway'), const SizedBox(height: 14), _GroupCard( children: [ _GroupedRow( - title: settings.apisix.name, + title: settings.aiGateway.name, subtitle: - '${settings.apisix.filePath} · ${settings.apisix.validationState}', + '${settings.aiGateway.baseUrl.isEmpty ? 'Not configured' : settings.aiGateway.baseUrl} · ${settings.aiGateway.syncState}', onTap: () => _openSettingsEditor( - title: 'APISIX YAML', - child: _ApisixEditor( + title: 'AI Gateway', + child: _AiGatewayEditor( controller: controller, - profile: settings.apisix, + profile: settings.aiGateway, ), ), ), @@ -984,122 +984,373 @@ class _MobileChatSheetState extends State<_MobileChatSheet> { } } -class _ApisixEditor extends StatefulWidget { - const _ApisixEditor({required this.controller, required this.profile}); +class _AiGatewayEditor extends StatefulWidget { + const _AiGatewayEditor({required this.controller, required this.profile}); final AppController controller; - final ApisixYamlProfile profile; + final AiGatewayProfile profile; @override - State<_ApisixEditor> createState() => _ApisixEditorState(); + State<_AiGatewayEditor> createState() => _AiGatewayEditorState(); } -class _ApisixEditorState extends State<_ApisixEditor> { - late final TextEditingController _pathController; - late final TextEditingController _yamlController; +class _AiGatewayEditorState extends State<_AiGatewayEditor> { + late final TextEditingController _nameController; + late final TextEditingController _urlController; + late final TextEditingController _apiKeyRefController; + late final TextEditingController _apiKeyController; + late final TextEditingController _modelSearchController; + bool _testing = false; + bool _syncing = false; + String _testState = 'idle'; + String _testMessage = ''; + String _testEndpoint = ''; @override void initState() { super.initState(); - _pathController = TextEditingController(text: widget.profile.filePath); - _yamlController = TextEditingController(text: widget.profile.inlineYaml); + _nameController = TextEditingController(text: widget.profile.name); + _urlController = TextEditingController(text: widget.profile.baseUrl); + _apiKeyRefController = TextEditingController( + text: widget.profile.apiKeyRef, + ); + _apiKeyController = TextEditingController(); + _modelSearchController = TextEditingController(); } @override void dispose() { - _pathController.dispose(); - _yamlController.dispose(); + _nameController.dispose(); + _urlController.dispose(); + _apiKeyRefController.dispose(); + _apiKeyController.dispose(); + _modelSearchController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('APISIX YAML', style: Theme.of(context).textTheme.headlineSmall), - const SizedBox(height: 16), - _RoundedTextField( - initialValue: widget.profile.name, - icon: Icons.tag_rounded, - hintText: 'Profile Name', - onSubmitted: (value) => widget.controller.saveSettings( - widget.controller.settings.copyWith( - apisix: widget.controller.settings.apisix.copyWith(name: value), - ), - ), - ), - const SizedBox(height: 12), - TextField( - controller: _pathController, - decoration: _roundedInputDecoration( - hintText: widget.profile.filePath, - icon: Icons.folder_outlined, - ), - onSubmitted: (value) => widget.controller.saveSettings( - widget.controller.settings.copyWith( - apisix: widget.controller.settings.apisix.copyWith( - filePath: value, - ), - ), - ), - ), - const SizedBox(height: 12), - TextField( - controller: _yamlController, - minLines: 8, - maxLines: 12, - decoration: _roundedInputDecoration( - hintText: 'Inline YAML', - icon: Icons.code_rounded, - ), - ), - const SizedBox(height: 16), - Row( + return AnimatedBuilder( + animation: widget.controller, + builder: (context, _) { + final profile = widget.controller.settings.aiGateway; + final selectedModels = profile.selectedModels.isNotEmpty + ? profile.selectedModels + : profile.availableModels.take(5).toList(growable: false); + final filteredModels = _filterModels(profile.availableModels); + final feedbackTheme = _feedbackTheme( + _testMessage.isEmpty ? profile.syncState : _testState, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: OutlinedButton( - onPressed: () => widget.controller.saveSettings( - widget.controller.settings.copyWith( - apisix: widget.controller.settings.apisix.copyWith( - inlineYaml: _yamlController.text, - ), - ), + Text( + 'AI Gateway', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + TextField( + controller: _nameController, + decoration: _roundedInputDecoration( + hintText: 'Profile Name', + icon: Icons.tag_rounded, + ), + onSubmitted: (_) => _saveDraft(), + ), + const SizedBox(height: 12), + TextField( + controller: _urlController, + decoration: _roundedInputDecoration( + hintText: 'Gateway URL', + icon: Icons.link_rounded, + ), + onSubmitted: (_) => _saveDraft(), + ), + const SizedBox(height: 12), + TextField( + controller: _apiKeyRefController, + decoration: _roundedInputDecoration( + hintText: 'API Key Ref', + icon: Icons.vpn_key_outlined, + ), + onSubmitted: (_) => _saveDraft(), + ), + const SizedBox(height: 12), + TextField( + controller: _apiKeyController, + obscureText: true, + decoration: _roundedInputDecoration( + hintText: 'API Key', + icon: Icons.password_rounded, + ), + onSubmitted: + widget.controller.settingsController.saveAiGatewayApiKey, + ), + const SizedBox(height: 16), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + OutlinedButton( + onPressed: _testing || _syncing ? null : _saveDraft, + child: const Text('保存草稿'), ), - child: const Text('保存草稿'), - ), + OutlinedButton( + onPressed: _testing || _syncing ? null : _testConnection, + child: Text(_testing ? '测试中...' : '测试连接'), + ), + FilledButton.tonal( + onPressed: _testing || _syncing ? null : _syncModels, + child: Text(_syncing ? '同步中...' : profile.syncState), + ), + ], ), - const SizedBox(width: 12), - Expanded( - child: FilledButton.tonal( - onPressed: () async { - final messenger = ScaffoldMessenger.of(context); - final result = await widget.controller.validateApisixYaml( - widget.controller.settings.apisix.copyWith( - filePath: _pathController.text, - inlineYaml: _yamlController.text, + const SizedBox(height: 12), + Text( + profile.syncMessage, + style: const TextStyle(color: _textSecondary), + ), + if (_testMessage.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: feedbackTheme.$1, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: feedbackTheme.$2), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _testMessage, + style: TextStyle( + color: feedbackTheme.$3, + fontWeight: FontWeight.w600, + ), ), - ); - if (!mounted) { - return; - } - messenger.showSnackBar( - SnackBar(content: Text(result.validationMessage)), - ); - }, - child: Text(widget.profile.validationState), + if (_testEndpoint.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + _testEndpoint, + style: TextStyle(color: feedbackTheme.$3), + ), + ], + ], + ), ), - ), + ], + if (profile.availableModels.isNotEmpty) ...[ + const SizedBox(height: 16), + TextField( + controller: _modelSearchController, + decoration: _roundedInputDecoration( + hintText: 'Search models', + icon: Icons.search_rounded, + suffixIcon: _modelSearchController.text.trim().isEmpty + ? null + : IconButton( + onPressed: () { + _modelSearchController.clear(); + setState(() {}); + }, + icon: const Icon(Icons.close_rounded), + ), + ), + onChanged: (_) => setState(() {}), + ), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + Text( + '已选 ${selectedModels.length} / ${profile.availableModels.length}', + style: const TextStyle(color: _textSecondary), + ), + OutlinedButton( + onPressed: filteredModels.isEmpty + ? null + : () async { + await widget.controller.updateAiGatewaySelection( + { + ...selectedModels, + ...filteredModels, + }.toList(growable: false), + ); + }, + child: const Text('选择筛选结果'), + ), + OutlinedButton( + onPressed: () async { + await widget.controller.updateAiGatewaySelection( + profile.availableModels.take(5).toList(growable: false), + ); + }, + child: const Text('恢复默认 5 个'), + ), + ], + ), + const SizedBox(height: 12), + if (filteredModels.isEmpty) + const Text('没有匹配的模型。', style: TextStyle(color: _textSecondary)) + 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) + : [...selectedModels, modelId]; + await widget.controller.updateAiGatewaySelection( + nextSelection, + ); + }, + ); + }) + .toList(growable: false), + ), + ], ], - ), - const SizedBox(height: 12), - Text( - widget.profile.validationMessage, - style: const TextStyle(color: _textSecondary), - ), - ], + ); + }, ); } + + AiGatewayProfile get _draftProfile { + return widget.controller.settings.aiGateway.copyWith( + name: _nameController.text.trim(), + baseUrl: _urlController.text.trim(), + apiKeyRef: _apiKeyRefController.text.trim(), + ); + } + + Future _saveDraft() async { + final apiKey = _apiKeyController.text.trim(); + if (apiKey.isNotEmpty) { + await widget.controller.settingsController.saveAiGatewayApiKey(apiKey); + } + await widget.controller.saveSettings( + widget.controller.settings.copyWith(aiGateway: _draftProfile), + ); + } + + Future _testConnection() async { + final messenger = ScaffoldMessenger.of(context); + final apiKey = _apiKeyController.text.trim(); + setState(() => _testing = true); + try { + final result = await widget.controller.settingsController + .testAiGatewayConnection(_draftProfile, apiKeyOverride: apiKey); + if (!mounted) { + return; + } + setState(() { + _testState = result.state; + _testMessage = result.message; + _testEndpoint = result.endpoint; + }); + messenger.showSnackBar(SnackBar(content: Text(result.message))); + } finally { + if (mounted) { + setState(() => _testing = false); + } + } + } + + Future _syncModels() async { + final messenger = ScaffoldMessenger.of(context); + final apiKey = _apiKeyController.text.trim(); + setState(() => _syncing = true); + try { + if (apiKey.isNotEmpty) { + await widget.controller.settingsController.saveAiGatewayApiKey(apiKey); + } + await _saveDraft(); + final result = await widget.controller.syncAiGatewayCatalog( + _draftProfile, + apiKeyOverride: apiKey, + ); + if (!mounted) { + return; + } + setState(() { + _testState = result.syncState; + _testMessage = + 'Catalog synced · ${result.availableModels.length} model(s) ready'; + _testEndpoint = _previewEndpoint(_draftProfile.baseUrl); + }); + messenger.showSnackBar(SnackBar(content: Text(result.syncMessage))); + } finally { + if (mounted) { + setState(() => _syncing = false); + } + } + } + + List _filterModels(List models) { + final query = _modelSearchController.text.trim().toLowerCase(); + if (query.isEmpty) { + return models; + } + return models + .where((modelId) => modelId.toLowerCase().contains(query)) + .toList(growable: false); + } + + String _previewEndpoint(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(); + } + + (Color, Color, Color) _feedbackTheme(String state) { + return switch (state) { + 'ready' => ( + const Color(0xFFDCEFE2), + const Color(0xFF62C56A), + _textPrimary, + ), + 'empty' => ( + const Color(0xFFF5E7D9), + const Color(0xFFE1913E), + _textPrimary, + ), + 'error' || 'invalid' => ( + const Color(0xFFF8D9DE), + const Color(0xFFD14C68), + _textPrimary, + ), + _ => (_surfaceSoft, _stroke, _textPrimary), + }; + } } class _MobileSettingsEditor extends StatelessWidget { @@ -1682,10 +1933,12 @@ class _RoundedTextField extends StatelessWidget { InputDecoration _roundedInputDecoration({ required String hintText, required IconData icon, + Widget? suffixIcon, }) { return InputDecoration( hintText: hintText, prefixIcon: Icon(icon, color: _textSecondary, size: 30), + suffixIcon: suffixIcon, filled: true, fillColor: _surface, border: OutlineInputBorder( diff --git a/lib/features/modules/modules_page.dart b/lib/features/modules/modules_page.dart index 8dac9150..168205af 100644 --- a/lib/features/modules/modules_page.dart +++ b/lib/features/modules/modules_page.dart @@ -741,16 +741,19 @@ class _FallbackHubPanel extends StatelessWidget { Widget build(BuildContext context) { final items = controller.models; if (items.isEmpty) { + final hasAiGateway = controller.settings.aiGateway.baseUrl + .trim() + .isNotEmpty; return SurfaceCard( child: Text( - controller.connection.status == RuntimeConnectionStatus.connected + hasAiGateway ? appText( - '当前网关没有返回模型目录。', - 'No model catalog returned by the gateway.', + '当前 AI Gateway 没有返回模型目录。', + 'No model catalog returned by the AI Gateway.', ) : appText( - '连接 Gateway 后可加载模型能力目录。', - 'Connect a gateway to load the model catalog.', + '先在设置 -> 集成 中同步 AI Gateway 模型目录。', + 'Sync the AI Gateway model catalog from Settings -> Integrations.', ), ), ); @@ -770,8 +773,8 @@ class _FallbackHubPanel extends StatelessWidget { icon: Icons.psychology_alt_rounded, status: StatusInfo(model.provider, StatusTone.accent), description: appText( - '来自 OpenClaw Gateway 的可用模型目录项。', - 'Model catalog entry exposed by the OpenClaw gateway.', + '来自 AI Gateway 的可用模型目录项。', + 'Model catalog entry exposed by the AI Gateway.', ), meta: [model.id, model.provider], actions: [appText('刷新', 'Refresh')], diff --git a/lib/features/settings/settings_page.dart b/lib/features/settings/settings_page.dart index a7813e7a..f9d02142 100644 --- a/lib/features/settings/settings_page.dart +++ b/lib/features/settings/settings_page.dart @@ -22,17 +22,28 @@ class SettingsPage extends StatefulWidget { class _SettingsPageState extends State { SettingsTab _tab = SettingsTab.general; - late final TextEditingController _apisixYamlController; + 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 = ''; @override void initState() { super.initState(); - _apisixYamlController = TextEditingController( - text: widget.controller.settings.apisix.inlineYaml, - ); + _aiGatewayNameController = TextEditingController(); + _aiGatewayUrlController = TextEditingController(); + _aiGatewayApiKeyRefController = TextEditingController(); + _aiGatewayApiKeyController = TextEditingController(); + _aiGatewayModelSearchController = TextEditingController(); _vaultTokenController = TextEditingController(); _ollamaApiKeyController = TextEditingController(); _runtimeLogFilterController = TextEditingController(); @@ -40,7 +51,11 @@ class _SettingsPageState extends State { @override void dispose() { - _apisixYamlController.dispose(); + _aiGatewayNameController.dispose(); + _aiGatewayUrlController.dispose(); + _aiGatewayApiKeyRefController.dispose(); + _aiGatewayApiKeyController.dispose(); + _aiGatewayModelSearchController.dispose(); _vaultTokenController.dispose(); _ollamaApiKeyController.dispose(); _runtimeLogFilterController.dispose(); @@ -398,6 +413,26 @@ class _SettingsPageState extends State { AppController controller, SettingsSnapshot settings, ) { + _syncControllerValue(_aiGatewayNameController, settings.aiGateway.name); + _syncControllerValue(_aiGatewayUrlController, settings.aiGateway.baseUrl); + _syncControllerValue( + _aiGatewayApiKeyRefController, + settings.aiGateway.apiKeyRef, + ); + 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 statusTheme = _aiGatewayFeedbackTheme( + context, + _aiGatewayTestMessage.isEmpty + ? settings.aiGateway.syncState + : _aiGatewayTestState, + ); return [ SurfaceCard( child: Column( @@ -538,51 +573,50 @@ class _SettingsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - appText('APISIX YAML', 'APISIX YAML'), + appText('AI Gateway', 'AI Gateway'), style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 16), - _EditableField( - label: appText('配置名称', 'Profile Name'), - value: settings.apisix.name, - onSubmitted: (value) => _saveSettings( - controller, - settings.copyWith( - apisix: settings.apisix.copyWith(name: value), - ), + TextField( + controller: _aiGatewayNameController, + decoration: InputDecoration( + labelText: appText('配置名称', 'Profile Name'), ), + onSubmitted: (_) => _saveAiGatewayDraft(controller, settings), ), - _EditableField( - label: appText('来源类型', 'Source Type'), - value: settings.apisix.sourceType, - onSubmitted: (value) => _saveSettings( - controller, - settings.copyWith( - apisix: settings.apisix.copyWith(sourceType: value), - ), + const SizedBox(height: 14), + TextField( + controller: _aiGatewayUrlController, + decoration: InputDecoration( + labelText: appText('Gateway URL', 'Gateway URL'), ), + onSubmitted: (_) => _saveAiGatewayDraft(controller, settings), ), - _EditableField( - label: appText('文件路径', 'File Path'), - value: settings.apisix.filePath, - onSubmitted: (value) => _saveSettings( - controller, - settings.copyWith( - apisix: settings.apisix.copyWith(filePath: value), - ), + const SizedBox(height: 14), + TextField( + controller: _aiGatewayApiKeyRefController, + decoration: InputDecoration( + labelText: appText('API Key 引用', 'API Key Ref'), ), + onSubmitted: (_) => _saveAiGatewayDraft(controller, settings), ), TextField( - controller: _apisixYamlController, - minLines: 6, - maxLines: 10, + controller: _aiGatewayApiKeyController, + obscureText: true, decoration: InputDecoration( - labelText: appText('内联 YAML', 'Inline YAML'), - hintText: appText( - '粘贴 APISIX 路由或 upstream YAML 用于校验', - 'Paste APISIX route / upstream YAML for validation', - ), + labelText: + '${appText('API Key', 'API Key')} (${_aiGatewayApiKeyRefController.text.trim().isEmpty ? settings.aiGateway.apiKeyRef : _aiGatewayApiKeyRefController.text.trim()})', + helperText: hasStoredAiGatewayApiKey + ? appText( + '已安全保存,可直接同步模型。', + 'Stored securely and ready to sync.', + ) + : appText( + '输入后点击保存或同步模型。', + 'Save or sync to persist securely.', + ), ), + onSubmitted: controller.settingsController.saveAiGatewayApiKey, ), const SizedBox(height: 12), Wrap( @@ -590,41 +624,206 @@ class _SettingsPageState extends State { runSpacing: 10, children: [ FilledButton.tonal( - onPressed: () => _saveSettings( - controller, - settings.copyWith( - apisix: settings.apisix.copyWith( - inlineYaml: _apisixYamlController.text, - ), - ), - ), + 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 { - final messenger = ScaffoldMessenger.of(context); - final updated = settings.apisix.copyWith( - inlineYaml: _apisixYamlController.text, - ); - final result = await controller.validateApisixYaml(updated); - if (!mounted) { + if (_aiGatewayTesting || _aiGatewaySyncing) { return; } - messenger.showSnackBar( - SnackBar(content: Text(result.validationMessage)), - ); + final messenger = ScaffoldMessenger.of(context); + final draft = _buildAiGatewayDraft(settings); + final apiKey = _aiGatewayApiKeyController.text.trim(); + setState(() => _aiGatewaySyncing = true); + try { + if (apiKey.isNotEmpty) { + await controller.settingsController.saveAiGatewayApiKey( + apiKey, + ); + } + await _saveSettings( + controller, + settings.copyWith(aiGateway: draft), + ); + final result = await controller.syncAiGatewayCatalog( + draft, + apiKeyOverride: apiKey, + ); + if (!mounted) { + return; + } + setState(() { + _aiGatewayTestState = result.syncState; + _aiGatewayTestMessage = + 'Catalog synced · ${result.availableModels.length} model(s) ready'; + _aiGatewayTestEndpoint = _previewAiGatewayEndpoint( + draft.baseUrl, + ); + }); + messenger.showSnackBar( + SnackBar(content: Text(result.syncMessage)), + ); + } finally { + if (mounted) { + setState(() => _aiGatewaySyncing = false); + } + } }, child: Text( - '${appText('校验', 'Validate')} · ${settings.apisix.validationState}', + _aiGatewaySyncing + ? appText('同步中...', 'Syncing...') + : '${appText('同步模型', 'Sync Models')} · ${settings.aiGateway.syncState}', ), ), ], ), const SizedBox(height: 12), Text( - settings.apisix.validationMessage, + 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( + { + ...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) + : [...selectedModels, modelId]; + await controller.updateAiGatewaySelection( + nextSelection, + ); + }, + ); + }) + .toList(growable: false), + ), + ], ], ), ), @@ -927,6 +1126,129 @@ class _SettingsPageState extends State { return controller.saveSettings(snapshot); } + AiGatewayProfile _buildAiGatewayDraft(SettingsSnapshot settings) { + return settings.aiGateway.copyWith( + name: _aiGatewayNameController.text.trim(), + baseUrl: _aiGatewayUrlController.text.trim(), + apiKeyRef: _aiGatewayApiKeyRefController.text.trim(), + ); + } + + Future _saveAiGatewayDraft( + AppController controller, + SettingsSnapshot settings, + ) async { + final apiKey = _aiGatewayApiKeyController.text.trim(); + if (apiKey.isNotEmpty) { + await controller.settingsController.saveAiGatewayApiKey(apiKey); + } + await _saveSettings( + controller, + settings.copyWith(aiGateway: _buildAiGatewayDraft(settings)), + ); + } + + Future _testAiGatewayConnection( + AppController controller, + SettingsSnapshot settings, + ) async { + final messenger = ScaffoldMessenger.of(context); + final draft = _buildAiGatewayDraft(settings); + final apiKey = _aiGatewayApiKeyController.text.trim(); + 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 _filterAiGatewayModels(List 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(); + } + + _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, + ); + } + bool _matchesRuntimeLogFilter(RuntimeLogEntry entry) { final query = _runtimeLogFilterController.text.trim().toLowerCase(); if (query.isEmpty) { @@ -1466,6 +1788,18 @@ class _SwitchRow extends StatelessWidget { } } +class _AiGatewayFeedbackTheme { + const _AiGatewayFeedbackTheme({ + required this.background, + required this.border, + required this.foreground, + }); + + final Color background; + final Color border; + final Color foreground; +} + class _InfoRow extends StatelessWidget { const _InfoRow({required this.label, required this.value}); diff --git a/lib/runtime/runtime_controllers.dart b/lib/runtime/runtime_controllers.dart index 46cc21d1..9cf17954 100644 --- a/lib/runtime/runtime_controllers.dart +++ b/lib/runtime/runtime_controllers.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:yaml/yaml.dart'; import 'gateway_runtime.dart'; import 'runtime_models.dart'; @@ -19,12 +18,14 @@ class SettingsController extends ChangeNotifier { List _auditTrail = const []; String _ollamaStatus = 'Idle'; String _vaultStatus = 'Idle'; + String _aiGatewayStatus = 'Idle'; SettingsSnapshot get snapshot => _snapshot; Map get secureRefs => _secureRefs; List get auditTrail => _auditTrail; String get ollamaStatus => _ollamaStatus; String get vaultStatus => _vaultStatus; + String get aiGatewayStatus => _aiGatewayStatus; Future initialize() async { _snapshot = await _store.loadSettingsSnapshot(); @@ -154,6 +155,26 @@ class SettingsController extends ChangeNotifier { notifyListeners(); } + Future saveAiGatewayApiKey(String value) async { + final trimmed = value.trim(); + if (trimmed.isEmpty) { + return; + } + await _store.saveAiGatewayApiKey(trimmed); + await appendAudit( + SecretAuditEntry( + timeLabel: _timeLabel(), + action: 'Updated', + provider: 'AI Gateway', + target: _snapshot.aiGateway.apiKeyRef, + module: 'Settings', + status: 'Success', + ), + ); + await _reloadDerivedState(); + notifyListeners(); + } + Future appendAudit(SecretAuditEntry entry) async { await _store.appendAudit(entry); _auditTrail = await _store.loadAuditTrail(); @@ -232,40 +253,162 @@ class SettingsController extends ChangeNotifier { } } - Future validateApisixYaml( - ApisixYamlProfile profile, - ) async { - final sourceText = profile.inlineYaml.trim().isNotEmpty - ? profile.inlineYaml - : await _loadYamlFromProfile(profile); - try { - final yaml = loadYaml(sourceText); - final root = yaml is YamlMap ? yaml : null; - final routeCount = _yamlSequenceLength(root?['routes']); - final upstreamCount = _yamlSequenceLength(root?['upstreams']); - final message = [ - if (routeCount > 0) '$routeCount route(s)', - if (upstreamCount > 0) '$upstreamCount upstream(s)', - if (routeCount == 0 && upstreamCount == 0) 'YAML parsed successfully', - ].join(' · '); + Future syncAiGatewayCatalog( + AiGatewayProfile profile, { + String apiKeyOverride = '', + }) async { + final normalizedBaseUrl = _normalizeAiGatewayBaseUrl(profile.baseUrl); + if (normalizedBaseUrl == null) { final next = profile.copyWith( - validationState: 'valid', - validationMessage: message, + syncState: 'invalid', + syncMessage: 'Missing AI Gateway URL', ); - _snapshot = _snapshot.copyWith(apisix: next); - await _store.saveSettingsSnapshot(_snapshot); - notifyListeners(); - return next; - } catch (error) { - final next = profile.copyWith( - validationState: 'invalid', - validationMessage: error.toString(), - ); - _snapshot = _snapshot.copyWith(apisix: next); + _aiGatewayStatus = next.syncMessage; + _snapshot = _snapshot.copyWith(aiGateway: next); await _store.saveSettingsSnapshot(_snapshot); notifyListeners(); return next; } + final apiKey = apiKeyOverride.trim().isNotEmpty + ? apiKeyOverride.trim() + : (await _store.loadAiGatewayApiKey())?.trim() ?? ''; + if (apiKey.isEmpty) { + final next = profile.copyWith( + baseUrl: normalizedBaseUrl.toString(), + syncState: 'invalid', + syncMessage: 'Missing AI Gateway API key', + ); + _aiGatewayStatus = next.syncMessage; + _snapshot = _snapshot.copyWith(aiGateway: next); + await _store.saveSettingsSnapshot(_snapshot); + notifyListeners(); + return next; + } + try { + final models = await loadAiGatewayModels( + profile: profile.copyWith(baseUrl: normalizedBaseUrl.toString()), + apiKeyOverride: apiKey, + ); + final availableModels = models + .map((item) => item.id) + .toList(growable: false); + final retainedSelected = profile.selectedModels + .where(availableModels.contains) + .toList(growable: false); + final selectedModels = retainedSelected.isNotEmpty + ? retainedSelected + : availableModels.take(5).toList(growable: false); + final currentDefaultModel = _snapshot.defaultModel.trim(); + final resolvedDefaultModel = selectedModels.contains(currentDefaultModel) + ? currentDefaultModel + : selectedModels.isNotEmpty + ? selectedModels.first + : availableModels.isNotEmpty + ? availableModels.first + : ''; + final next = profile.copyWith( + baseUrl: normalizedBaseUrl.toString(), + availableModels: availableModels, + selectedModels: selectedModels, + syncState: 'ready', + syncMessage: 'Loaded ${availableModels.length} model(s)', + ); + _aiGatewayStatus = 'Ready (${availableModels.length})'; + _snapshot = _snapshot.copyWith( + aiGateway: next, + defaultModel: resolvedDefaultModel, + ); + await _store.saveSettingsSnapshot(_snapshot); + await _reloadDerivedState(); + notifyListeners(); + return next; + } catch (error) { + final next = profile.copyWith( + baseUrl: normalizedBaseUrl.toString(), + syncState: 'error', + syncMessage: _networkErrorLabel(error), + ); + _aiGatewayStatus = next.syncMessage; + _snapshot = _snapshot.copyWith(aiGateway: next); + await _store.saveSettingsSnapshot(_snapshot); + notifyListeners(); + return next; + } + } + + Future testAiGatewayConnection( + AiGatewayProfile profile, { + String apiKeyOverride = '', + }) async { + final normalizedBaseUrl = _normalizeAiGatewayBaseUrl(profile.baseUrl); + if (normalizedBaseUrl == null) { + return const AiGatewayConnectionCheck( + state: 'invalid', + message: 'Missing AI Gateway URL', + endpoint: '', + modelCount: 0, + ); + } + final apiKey = apiKeyOverride.trim().isNotEmpty + ? apiKeyOverride.trim() + : (await _store.loadAiGatewayApiKey())?.trim() ?? ''; + final endpoint = _aiGatewayModelsUri(normalizedBaseUrl).toString(); + if (apiKey.isEmpty) { + return AiGatewayConnectionCheck( + state: 'invalid', + message: 'Missing AI Gateway API key', + endpoint: endpoint, + modelCount: 0, + ); + } + try { + final models = await _requestAiGatewayModels( + uri: _aiGatewayModelsUri(normalizedBaseUrl), + apiKey: apiKey, + ); + if (models.isEmpty) { + return AiGatewayConnectionCheck( + state: 'empty', + message: 'Authenticated but no models were returned', + endpoint: endpoint, + modelCount: 0, + ); + } + return AiGatewayConnectionCheck( + state: 'ready', + message: 'Authenticated · ${models.length} model(s) available', + endpoint: endpoint, + modelCount: models.length, + ); + } catch (error) { + return AiGatewayConnectionCheck( + state: 'error', + message: _networkErrorLabel(error), + endpoint: endpoint, + modelCount: 0, + ); + } + } + + Future> loadAiGatewayModels({ + AiGatewayProfile? profile, + String apiKeyOverride = '', + }) async { + final activeProfile = profile ?? _snapshot.aiGateway; + final normalizedBaseUrl = _normalizeAiGatewayBaseUrl(activeProfile.baseUrl); + if (normalizedBaseUrl == null) { + return const []; + } + final apiKey = apiKeyOverride.trim().isNotEmpty + ? apiKeyOverride.trim() + : (await _store.loadAiGatewayApiKey())?.trim() ?? ''; + if (apiKey.isEmpty) { + return const []; + } + return _requestAiGatewayModels( + uri: _aiGatewayModelsUri(normalizedBaseUrl), + apiKey: apiKey, + ); } List buildSecretReferences() { @@ -280,11 +423,13 @@ class SettingsController extends ChangeNotifier { ), ), SecretReferenceEntry( - name: _snapshot.apisix.name, - provider: 'APISIX YAML', + name: _snapshot.aiGateway.name, + provider: 'AI Gateway', module: 'Settings', - maskedValue: _snapshot.apisix.filePath, - status: _snapshot.apisix.validationState, + maskedValue: _snapshot.aiGateway.baseUrl.trim().isEmpty + ? 'Not set' + : _snapshot.aiGateway.baseUrl, + status: _snapshot.aiGateway.syncState, ), ]; return entries; @@ -299,28 +444,6 @@ class SettingsController extends ChangeNotifier { _auditTrail = await _store.loadAuditTrail(); } - Future _loadYamlFromProfile(ApisixYamlProfile profile) async { - final path = profile.filePath.trim(); - if (path.isEmpty) { - throw const FormatException('Missing YAML source'); - } - final file = File(path); - if (!await file.exists()) { - throw FileSystemException('YAML file not found', path); - } - return file.readAsString(); - } - - int _yamlSequenceLength(Object? value) { - if (value is YamlList) { - return value.length; - } - if (value is List) { - return value.length; - } - return 0; - } - String _providerNameForSecret(String key) { if (key.contains('vault')) { return 'Vault'; @@ -328,6 +451,9 @@ class SettingsController extends ChangeNotifier { if (key.contains('ollama')) { return 'Ollama Cloud'; } + if (key.contains('ai_gateway')) { + return 'AI Gateway'; + } if (key.contains('gateway')) { return 'Gateway'; } @@ -341,12 +467,205 @@ class SettingsController extends ChangeNotifier { if (key.contains('ollama')) { return 'Settings'; } + if (key.contains('ai_gateway')) { + return 'Settings'; + } if (key.contains('vault')) { return 'Secrets'; } return 'Workspace'; } + Uri? _normalizeAiGatewayBaseUrl(String raw) { + final trimmed = raw.trim(); + if (trimmed.isEmpty) { + return null; + } + final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed'; + final uri = Uri.tryParse(candidate); + if (uri == null || uri.host.trim().isEmpty) { + return null; + } + final pathSegments = uri.pathSegments.where((item) => item.isNotEmpty); + return uri.replace( + pathSegments: pathSegments.isEmpty ? const ['v1'] : pathSegments, + query: null, + fragment: null, + ); + } + + Uri _aiGatewayModelsUri(Uri baseUrl) { + final pathSegments = baseUrl.pathSegments + .where((item) => item.isNotEmpty) + .toList(growable: true); + if (pathSegments.isEmpty) { + pathSegments.add('v1'); + } + if (pathSegments.last != 'models') { + pathSegments.add('models'); + } + return baseUrl.replace( + pathSegments: pathSegments, + query: null, + fragment: null, + ); + } + + Future> _requestAiGatewayModels({ + required Uri uri, + required String apiKey, + }) async { + final client = HttpClient()..connectionTimeout = const Duration(seconds: 6); + try { + final request = await client + .getUrl(uri) + .timeout(const Duration(seconds: 6)); + request.headers.set(HttpHeaders.acceptHeader, 'application/json'); + request.headers.set(HttpHeaders.authorizationHeader, 'Bearer $apiKey'); + request.headers.set('x-api-key', apiKey); + final response = await request.close().timeout( + const Duration(seconds: 6), + ); + final body = await response.transform(utf8.decoder).join(); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw _AiGatewayResponseException( + statusCode: response.statusCode, + message: _aiGatewayHttpErrorLabel( + response.statusCode, + _extractAiGatewayErrorDetail(body), + ), + ); + } + final decoded = jsonDecode(_extractFirstJsonDocument(body)); + final rawModels = decoded is Map + ? [ + ...asList(decoded['data']), + if (asList(decoded['data']).isEmpty) ...asList(decoded['models']), + ] + : const []; + final seen = {}; + final items = []; + for (final item in rawModels) { + final map = asMap(item); + final modelId = + stringValue(map['id']) ?? stringValue(map['name']) ?? ''; + if (modelId.trim().isEmpty || !seen.add(modelId)) { + continue; + } + items.add( + GatewayModelSummary( + id: modelId, + name: stringValue(map['name']) ?? modelId, + provider: + stringValue(map['provider']) ?? + stringValue(map['owned_by']) ?? + 'AI Gateway', + contextWindow: + intValue(map['contextWindow']) ?? + intValue(map['context_window']), + maxOutputTokens: + intValue(map['maxOutputTokens']) ?? + intValue(map['max_output_tokens']), + ), + ); + } + return items; + } finally { + client.close(force: true); + } + } + + String _networkErrorLabel(Object error) { + if (error is _AiGatewayResponseException) { + return error.message; + } + if (error is SocketException) { + return 'Unable to reach the AI Gateway'; + } + if (error is HandshakeException) { + return 'TLS handshake failed'; + } + if (error is TimeoutException) { + return 'Connection timed out'; + } + if (error is FormatException) { + return 'AI Gateway returned invalid JSON'; + } + return 'Failed: $error'; + } + + String _aiGatewayHttpErrorLabel(int statusCode, String detail) { + final base = switch (statusCode) { + 400 => 'Bad request (400)', + 401 => 'Authentication failed (401)', + 403 => 'Access denied (403)', + 404 => 'Model catalog endpoint not found (404)', + 429 => 'Rate limited by AI Gateway (429)', + >= 500 => 'AI Gateway unavailable ($statusCode)', + _ => 'AI Gateway responded $statusCode', + }; + return detail.isEmpty ? base : '$base · $detail'; + } + + String _extractAiGatewayErrorDetail(String body) { + if (body.trim().isEmpty) { + return ''; + } + try { + final decoded = jsonDecode(_extractFirstJsonDocument(body)); + final map = asMap(decoded); + final error = asMap(map['error']); + return (stringValue(error['message']) ?? + stringValue(map['message']) ?? + stringValue(map['detail']) ?? + '') + .trim(); + } on FormatException { + return ''; + } + } + + String _extractFirstJsonDocument(String body) { + final trimmed = body.trimLeft(); + if (trimmed.isEmpty) { + throw const FormatException('Empty response body'); + } + final start = trimmed.indexOf(RegExp(r'[\{\[]')); + if (start < 0) { + throw const FormatException('Missing JSON document'); + } + var depth = 0; + var inString = false; + var escaped = false; + for (var index = start; index < trimmed.length; index++) { + final char = trimmed[index]; + if (escaped) { + escaped = false; + continue; + } + if (char == r'\') { + escaped = true; + continue; + } + if (char == '"') { + inString = !inString; + continue; + } + if (inString) { + continue; + } + if (char == '{' || char == '[') { + depth += 1; + } else if (char == '}' || char == ']') { + depth -= 1; + if (depth == 0) { + return trimmed.substring(start, index + 1); + } + } + } + throw const FormatException('Unterminated JSON document'); + } + Future _simpleGet( Uri uri, { required Map headers, @@ -371,6 +690,16 @@ class SettingsController extends ChangeNotifier { } } +class _AiGatewayResponseException implements Exception { + const _AiGatewayResponseException({ + required this.statusCode, + required this.message, + }); + + final int statusCode; + final String message; +} + class GatewayAgentsController extends ChangeNotifier { GatewayAgentsController(this._runtime); @@ -804,9 +1133,10 @@ class ConnectorsController extends ChangeNotifier { } class ModelsController extends ChangeNotifier { - ModelsController(this._runtime); + ModelsController(this._runtime, this._settingsController); final GatewayRuntime _runtime; + final SettingsController _settingsController; List _items = const []; bool _loading = false; @@ -816,18 +1146,32 @@ class ModelsController extends ChangeNotifier { bool get loading => _loading; String? get error => _error; - Future refresh() async { - if (!_runtime.isConnected) { - _items = const []; - _error = null; - notifyListeners(); + void restoreFromSettings(AiGatewayProfile profile) { + final models = _modelsFromProfile(profile); + if (models.length == _items.length && + models.every( + (item) => _items.any((current) => current.id == item.id), + )) { return; } + _items = models; + notifyListeners(); + } + + Future refresh() async { _loading = true; _error = null; notifyListeners(); try { - _items = await _runtime.listModels(); + final profile = _settingsController.snapshot.aiGateway; + if (profile.baseUrl.trim().isNotEmpty) { + final synced = await _settingsController.syncAiGatewayCatalog(profile); + _items = _modelsFromProfile(synced); + } else if (_runtime.isConnected) { + _items = await _runtime.listModels(); + } else { + _items = _modelsFromProfile(profile); + } } catch (error) { _error = error.toString(); } finally { @@ -835,6 +1179,26 @@ class ModelsController extends ChangeNotifier { notifyListeners(); } } + + List _modelsFromProfile(AiGatewayProfile profile) { + final selected = profile.selectedModels + .where(profile.availableModels.contains) + .toList(growable: false); + final candidates = selected.isNotEmpty + ? selected + : profile.availableModels.take(5).toList(growable: false); + return candidates + .map( + (item) => GatewayModelSummary( + id: item, + name: item, + provider: 'AI Gateway', + contextWindow: null, + maxOutputTokens: null, + ), + ) + .toList(growable: false); + } } class CronJobsController extends ChangeNotifier { diff --git a/lib/runtime/runtime_models.dart b/lib/runtime/runtime_models.dart index d0843baf..fa1a4d61 100644 --- a/lib/runtime/runtime_models.dart +++ b/lib/runtime/runtime_models.dart @@ -367,82 +367,120 @@ class VaultConfig { } } -class ApisixYamlProfile { - const ApisixYamlProfile({ +class AiGatewayProfile { + const AiGatewayProfile({ required this.name, - required this.sourceType, - required this.filePath, - required this.inlineYaml, - required this.validationState, - required this.validationMessage, + required this.baseUrl, + required this.apiKeyRef, + required this.availableModels, + required this.selectedModels, + required this.syncState, + required this.syncMessage, }); final String name; - final String sourceType; - final String filePath; - final String inlineYaml; - final String validationState; - final String validationMessage; + final String baseUrl; + final String apiKeyRef; + final List availableModels; + final List selectedModels; + final String syncState; + final String syncMessage; - factory ApisixYamlProfile.defaults() { - return const ApisixYamlProfile( - name: 'default', - sourceType: 'workspace-file', - filePath: '/opt/data/apisix/openclaw.yaml', - inlineYaml: '', - validationState: 'idle', - validationMessage: 'Ready to validate', + factory AiGatewayProfile.defaults() { + return const AiGatewayProfile( + name: 'AI Gateway', + baseUrl: '', + apiKeyRef: 'ai_gateway_api_key', + availableModels: [], + selectedModels: [], + syncState: 'idle', + syncMessage: 'Ready to sync models', ); } - ApisixYamlProfile copyWith({ + AiGatewayProfile copyWith({ String? name, - String? sourceType, - String? filePath, - String? inlineYaml, - String? validationState, - String? validationMessage, + String? baseUrl, + String? apiKeyRef, + List? availableModels, + List? selectedModels, + String? syncState, + String? syncMessage, }) { - return ApisixYamlProfile( + return AiGatewayProfile( name: name ?? this.name, - sourceType: sourceType ?? this.sourceType, - filePath: filePath ?? this.filePath, - inlineYaml: inlineYaml ?? this.inlineYaml, - validationState: validationState ?? this.validationState, - validationMessage: validationMessage ?? this.validationMessage, + baseUrl: baseUrl ?? this.baseUrl, + apiKeyRef: apiKeyRef ?? this.apiKeyRef, + availableModels: availableModels ?? this.availableModels, + selectedModels: selectedModels ?? this.selectedModels, + syncState: syncState ?? this.syncState, + syncMessage: syncMessage ?? this.syncMessage, ); } Map toJson() { return { 'name': name, - 'sourceType': sourceType, - 'filePath': filePath, - 'inlineYaml': inlineYaml, - 'validationState': validationState, - 'validationMessage': validationMessage, + 'baseUrl': baseUrl, + 'apiKeyRef': apiKeyRef, + 'availableModels': availableModels, + 'selectedModels': selectedModels, + 'syncState': syncState, + 'syncMessage': syncMessage, }; } - factory ApisixYamlProfile.fromJson(Map json) { - return ApisixYamlProfile( - name: json['name'] as String? ?? ApisixYamlProfile.defaults().name, - sourceType: - json['sourceType'] as String? ?? - ApisixYamlProfile.defaults().sourceType, - filePath: - json['filePath'] as String? ?? ApisixYamlProfile.defaults().filePath, - inlineYaml: json['inlineYaml'] as String? ?? '', - validationState: - json['validationState'] as String? ?? - ApisixYamlProfile.defaults().validationState, - validationMessage: - json['validationMessage'] as String? ?? - ApisixYamlProfile.defaults().validationMessage, + factory AiGatewayProfile.fromJson(Map json) { + List normalizeList(Object? value) { + if (value is! List) { + return const []; + } + return value + .map((item) => item.toString().trim()) + .where((item) => item.isNotEmpty) + .toList(growable: false); + } + + final defaults = AiGatewayProfile.defaults(); + final availableModels = normalizeList(json['availableModels']); + final selectedModels = normalizeList(json['selectedModels']) + .where( + (item) => availableModels.isEmpty || availableModels.contains(item), + ) + .toList(growable: false); + final legacyFilePath = json['filePath'] as String?; + final legacyBaseUrl = + legacyFilePath != null && legacyFilePath.trim().startsWith('http') + ? legacyFilePath.trim() + : null; + return AiGatewayProfile( + name: json['name'] as String? ?? defaults.name, + baseUrl: json['baseUrl'] as String? ?? legacyBaseUrl ?? defaults.baseUrl, + apiKeyRef: json['apiKeyRef'] as String? ?? defaults.apiKeyRef, + availableModels: availableModels, + selectedModels: selectedModels, + syncState: json['syncState'] as String? ?? defaults.syncState, + syncMessage: json['syncMessage'] as String? ?? defaults.syncMessage, ); } } +class AiGatewayConnectionCheck { + const AiGatewayConnectionCheck({ + required this.state, + required this.message, + required this.endpoint, + required this.modelCount, + }); + + final String state; + final String message; + final String endpoint; + final int modelCount; + + bool get success => state == 'ready' || state == 'empty'; +} + class SettingsSnapshot { const SettingsSnapshot({ required this.appLanguage, @@ -458,7 +496,7 @@ class SettingsSnapshot { required this.ollamaLocal, required this.ollamaCloud, required this.vault, - required this.apisix, + required this.aiGateway, required this.experimentalCanvas, required this.experimentalBridge, required this.experimentalDebug, @@ -483,7 +521,7 @@ class SettingsSnapshot { final OllamaLocalConfig ollamaLocal; final OllamaCloudConfig ollamaCloud; final VaultConfig vault; - final ApisixYamlProfile apisix; + final AiGatewayProfile aiGateway; final bool experimentalCanvas; final bool experimentalBridge; final bool experimentalDebug; @@ -503,13 +541,13 @@ class SettingsSnapshot { workspacePath: '/opt/data', remoteProjectRoot: '/opt/data/workspace', cliPath: 'openclaw', - defaultModel: 'gpt-5.4', + defaultModel: '', defaultProvider: 'gateway', gateway: GatewayConnectionProfile.defaults(), ollamaLocal: OllamaLocalConfig.defaults(), ollamaCloud: OllamaCloudConfig.defaults(), vault: VaultConfig.defaults(), - apisix: ApisixYamlProfile.defaults(), + aiGateway: AiGatewayProfile.defaults(), experimentalCanvas: false, experimentalBridge: false, experimentalDebug: false, @@ -536,7 +574,7 @@ class SettingsSnapshot { OllamaLocalConfig? ollamaLocal, OllamaCloudConfig? ollamaCloud, VaultConfig? vault, - ApisixYamlProfile? apisix, + AiGatewayProfile? aiGateway, bool? experimentalCanvas, bool? experimentalBridge, bool? experimentalDebug, @@ -561,7 +599,7 @@ class SettingsSnapshot { ollamaLocal: ollamaLocal ?? this.ollamaLocal, ollamaCloud: ollamaCloud ?? this.ollamaCloud, vault: vault ?? this.vault, - apisix: apisix ?? this.apisix, + aiGateway: aiGateway ?? this.aiGateway, experimentalCanvas: experimentalCanvas ?? this.experimentalCanvas, experimentalBridge: experimentalBridge ?? this.experimentalBridge, experimentalDebug: experimentalDebug ?? this.experimentalDebug, @@ -591,7 +629,7 @@ class SettingsSnapshot { 'ollamaLocal': ollamaLocal.toJson(), 'ollamaCloud': ollamaCloud.toJson(), 'vault': vault.toJson(), - 'apisix': apisix.toJson(), + 'aiGateway': aiGateway.toJson(), 'experimentalCanvas': experimentalCanvas, 'experimentalBridge': experimentalBridge, 'experimentalDebug': experimentalDebug, @@ -638,8 +676,10 @@ class SettingsSnapshot { vault: VaultConfig.fromJson( (json['vault'] as Map?)?.cast() ?? const {}, ), - apisix: ApisixYamlProfile.fromJson( - (json['apisix'] as Map?)?.cast() ?? const {}, + aiGateway: AiGatewayProfile.fromJson( + (json['aiGateway'] as Map?)?.cast() ?? + (json['apisix'] as Map?)?.cast() ?? + const {}, ), experimentalCanvas: json['experimentalCanvas'] as bool? ?? false, experimentalBridge: json['experimentalBridge'] as bool? ?? false, diff --git a/lib/runtime/secure_config_store.dart b/lib/runtime/secure_config_store.dart index 8e1cc47b..ecec7be2 100644 --- a/lib/runtime/secure_config_store.dart +++ b/lib/runtime/secure_config_store.dart @@ -24,6 +24,7 @@ class SecureConfigStore { static const _deviceIdentityFallbackFileName = 'gateway-device-identity.json'; static const _ollamaCloudApiKeyKey = 'xworkmate.ollama.cloud.api_key'; static const _vaultTokenKey = 'xworkmate.vault.token'; + static const _aiGatewayApiKeyKey = 'xworkmate.ai_gateway.api_key'; SharedPreferences? _prefs; FlutterSecureStorage? _secureStorage; @@ -115,6 +116,13 @@ class SecureConfigStore { Future saveVaultToken(String value) => _writeSecure(_vaultTokenKey, value); + Future loadAiGatewayApiKey() => _readSecure(_aiGatewayApiKeyKey); + + Future saveAiGatewayApiKey(String value) => + _writeSecure(_aiGatewayApiKeyKey, value); + + Future clearAiGatewayApiKey() => _deleteSecure(_aiGatewayApiKeyKey); + Future loadDeviceIdentity() async { await initialize(); final deviceId = await _readSecure(_gatewayDeviceIdKey); @@ -206,6 +214,7 @@ class SecureConfigStore { ); final ollamaKey = await loadOllamaCloudApiKey(); final vaultToken = await loadVaultToken(); + final aiGatewayApiKey = await loadAiGatewayApiKey(); return { ...?gatewayToken == null ? null @@ -222,6 +231,9 @@ class SecureConfigStore { ...?vaultToken == null ? null : {'vault_token': vaultToken}, + ...?aiGatewayApiKey == null + ? null + : {'ai_gateway_api_key': aiGatewayApiKey}, }; } diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 793054f7..1b2ef513 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -62,9 +62,13 @@ class AppTheme { return base.copyWith( splashFactory: NoSplash.splashFactory, + visualDensity: isDesktop + ? const VisualDensity(horizontal: -1, vertical: -1) + : VisualDensity.standard, dividerColor: palette.strokeSoft, hoverColor: palette.hover, textTheme: tunedTextTheme, + primaryTextTheme: tunedTextTheme, appBarTheme: const AppBarTheme( backgroundColor: Colors.transparent, elevation: 0, @@ -88,6 +92,51 @@ class AppTheme { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(999)), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), ), + filledButtonTheme: FilledButtonThemeData( + style: FilledButton.styleFrom( + textStyle: tunedTextTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 16 : 18, + vertical: isDesktop ? 12 : 13, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + ), + ), + ), + outlinedButtonTheme: OutlinedButtonThemeData( + style: OutlinedButton.styleFrom( + foregroundColor: palette.textPrimary, + textStyle: tunedTextTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 16 : 18, + vertical: isDesktop ? 12 : 13, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(999), + ), + side: BorderSide(color: palette.strokeSoft), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + foregroundColor: palette.textPrimary, + textStyle: tunedTextTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 12 : 14, + vertical: isDesktop ? 10 : 11, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), iconButtonTheme: IconButtonThemeData( style: IconButton.styleFrom( foregroundColor: palette.textSecondary, @@ -105,9 +154,12 @@ class AppTheme { hintStyle: tunedTextTheme.bodyMedium?.copyWith( color: palette.textMuted, ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 16, + labelStyle: tunedTextTheme.bodyMedium?.copyWith( + color: palette.textMuted, + ), + contentPadding: EdgeInsets.symmetric( + horizontal: isDesktop ? 16 : 18, + vertical: isDesktop ? 14 : 15, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), @@ -159,54 +211,110 @@ class AppTheme { required AppPalette palette, required bool isDesktop, }) { + final fallbackFonts = switch (defaultTargetPlatform) { + TargetPlatform.macOS || TargetPlatform.iOS => const [ + '.SF NS Text', + '.SF Pro Text', + 'PingFang SC', + 'Helvetica Neue', + ], + _ => const ['Inter', 'Noto Sans CJK SC', 'PingFang SC'], + }; + + TextStyle withUiFont(TextStyle? style) { + return (style ?? const TextStyle()).copyWith( + fontFamilyFallback: fallbackFonts, + package: null, + ); + } + return base.copyWith( - displaySmall: base.displaySmall?.copyWith( - fontSize: isDesktop ? 32 : 34, - fontWeight: FontWeight.w600, - letterSpacing: -0.9, + displaySmall: withUiFont( + base.displaySmall?.copyWith( + fontSize: isDesktop ? 30 : 32, + fontWeight: FontWeight.w700, + letterSpacing: -0.8, + height: 1.08, + ), ), - headlineSmall: base.headlineSmall?.copyWith( - fontSize: isDesktop ? 22 : 24, - fontWeight: FontWeight.w600, - letterSpacing: -0.45, + headlineSmall: withUiFont( + base.headlineSmall?.copyWith( + fontSize: isDesktop ? 20 : 22, + fontWeight: FontWeight.w700, + letterSpacing: -0.38, + height: 1.14, + ), ), - titleLarge: base.titleLarge?.copyWith( - fontSize: isDesktop ? 18 : 20, - fontWeight: FontWeight.w600, - letterSpacing: -0.2, + titleLarge: withUiFont( + base.titleLarge?.copyWith( + fontSize: isDesktop ? 17 : 18, + fontWeight: FontWeight.w600, + letterSpacing: -0.18, + height: 1.2, + ), ), - titleMedium: base.titleMedium?.copyWith( - fontSize: isDesktop ? 15 : 16, - fontWeight: FontWeight.w600, + titleMedium: withUiFont( + base.titleMedium?.copyWith( + fontSize: isDesktop ? 15 : 16, + fontWeight: FontWeight.w600, + height: 1.24, + ), ), - titleSmall: base.titleSmall?.copyWith( - fontSize: isDesktop ? 13 : 14, - fontWeight: FontWeight.w600, + titleSmall: withUiFont( + base.titleSmall?.copyWith( + fontSize: isDesktop ? 13 : 14, + fontWeight: FontWeight.w600, + height: 1.2, + ), ), - bodyLarge: base.bodyLarge?.copyWith( - fontSize: isDesktop ? 14 : 15, - height: 1.45, - color: palette.textPrimary, + bodyLarge: withUiFont( + base.bodyLarge?.copyWith( + fontSize: isDesktop ? 14 : 15, + fontWeight: FontWeight.w400, + height: 1.5, + letterSpacing: -0.02, + color: palette.textPrimary, + ), ), - bodyMedium: base.bodyMedium?.copyWith( - fontSize: isDesktop ? 13 : 14, - height: 1.4, - color: palette.textSecondary, + bodyMedium: withUiFont( + base.bodyMedium?.copyWith( + fontSize: isDesktop ? 13 : 14, + fontWeight: FontWeight.w400, + height: 1.46, + letterSpacing: -0.01, + color: palette.textSecondary, + ), ), - bodySmall: base.bodySmall?.copyWith( - fontSize: isDesktop ? 12 : 12, - height: 1.35, - color: palette.textMuted, + bodySmall: withUiFont( + base.bodySmall?.copyWith( + fontSize: isDesktop ? 12 : 13, + fontWeight: FontWeight.w400, + height: 1.4, + color: palette.textMuted, + ), ), - labelLarge: base.labelLarge?.copyWith( - fontSize: isDesktop ? 13 : 14, - fontWeight: FontWeight.w600, + labelLarge: withUiFont( + base.labelLarge?.copyWith( + fontSize: isDesktop ? 13 : 14, + fontWeight: FontWeight.w600, + height: 1.15, + letterSpacing: -0.02, + ), ), - labelMedium: base.labelMedium?.copyWith( - fontSize: isDesktop ? 12 : 12, - fontWeight: FontWeight.w600, + labelMedium: withUiFont( + base.labelMedium?.copyWith( + fontSize: isDesktop ? 12 : 12, + fontWeight: FontWeight.w600, + height: 1.12, + ), + ), + labelSmall: withUiFont( + base.labelSmall?.copyWith( + fontSize: 11, + fontWeight: FontWeight.w600, + height: 1.1, + ), ), - labelSmall: base.labelSmall?.copyWith(fontSize: isDesktop ? 11 : 11), ); } } diff --git a/pubspec.yaml b/pubspec.yaml index 3babcd24..1babe46d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -6,3 +6,32 @@ version: latest build-date: 2026-03-12 build-id: acc3a06 +environment: + sdk: ^3.11.0 + +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + cupertino_icons: ^1.0.8 + cryptography: ^2.6.1 + crypto: ^3.0.6 + device_info_plus: ^11.5.0 + file_selector: ^1.0.3 + flutter_secure_storage: ^9.2.4 + package_info_plus: ^8.3.1 + path_provider: ^2.1.5 + shared_preferences: ^2.5.3 + web_socket_channel: ^3.0.3 + yaml: ^3.1.3 + +dev_dependencies: + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + flutter_lints: ^6.0.0 + +flutter: + uses-material-design: true diff --git a/scripts/package-flutter-mac-app.sh b/scripts/package-flutter-mac-app.sh index 549aa39a..77a90794 100755 --- a/scripts/package-flutter-mac-app.sh +++ b/scripts/package-flutter-mac-app.sh @@ -33,14 +33,22 @@ DIST_DMG_PATH="$DIST_DIR/$APP_NAME-$APP_VERSION.dmg" mkdir -p "$DIST_DIR" echo "Building $APP_NAME $APP_VERSION ($APP_BUILD) for macOS..." +BUILD_ARGS=( + flutter build macos + "--$BUILD_MODE" + --build-name="$APP_VERSION" + --build-number="$APP_BUILD" + --dart-define="XWORKMATE_DISPLAY_VERSION=$APP_VERSION" + --dart-define="XWORKMATE_BUILD_NUMBER=$APP_BUILD" +) + +if [[ -f "$APP_DIR/.dart_tool/package_config.json" ]]; then + BUILD_ARGS+=(--no-pub) +fi + ( cd "$APP_DIR" - flutter build macos \ - "--$BUILD_MODE" \ - --build-name="$APP_VERSION" \ - --build-number="$APP_BUILD" \ - --dart-define="XWORKMATE_DISPLAY_VERSION=$APP_VERSION" \ - --dart-define="XWORKMATE_BUILD_NUMBER=$APP_BUILD" + "${BUILD_ARGS[@]}" ) if [[ ! -d "$BUILD_APP_PATH" ]]; then diff --git a/test/features/assistant_page_test.dart b/test/features/assistant_page_test.dart index 5f89727a..82c74f5b 100644 --- a/test/features/assistant_page_test.dart +++ b/test/features/assistant_page_test.dart @@ -4,24 +4,19 @@ import 'package:xworkmate/features/assistant/assistant_page.dart'; import '../test_support.dart'; void main() { - testWidgets( - 'AssistantPage quick action fills composer and offline send opens gateway dialog', - (WidgetTester tester) async { - final controller = await createTestController(tester); + testWidgets('AssistantPage offline submit control opens gateway dialog', ( + WidgetTester tester, + ) async { + final controller = await createTestController(tester); - await pumpPage( - tester, - child: AssistantPage(controller: controller, onOpenDetail: (_) {}), - ); + await pumpPage( + tester, + child: AssistantPage(controller: controller, onOpenDetail: (_) {}), + ); - await tester.tap(find.text('写代码')); - await tester.pumpAndSettle(); - expect(find.text('写代码'), findsWidgets); + await tester.tap(find.byTooltip('连接')); + await tester.pumpAndSettle(); - await tester.tap(find.text('连接')); - await tester.pumpAndSettle(); - - expect(find.text('Gateway 访问'), findsOneWidget); - }, - ); + expect(find.text('Gateway 访问'), findsOneWidget); + }); } diff --git a/test/runtime/app_controller_ai_gateway_models_test.dart b/test/runtime/app_controller_ai_gateway_models_test.dart new file mode 100644 index 00000000..817f5772 --- /dev/null +++ b/test/runtime/app_controller_ai_gateway_models_test.dart @@ -0,0 +1,47 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:xworkmate/app/app_controller.dart'; + +void main() { + test( + 'AppController exposes selected AI Gateway models to the assistant', + () async { + SharedPreferences.setMockInitialValues({}); + final controller = AppController(); + addTearDown(controller.dispose); + + await _waitFor(() => !controller.initializing); + + await controller.saveSettings( + controller.settings.copyWith( + aiGateway: controller.settings.aiGateway.copyWith( + availableModels: const ['gpt-5.4', 'o3-mini', 'claude-3.7'], + selectedModels: const ['o3-mini', 'gpt-5.4'], + ), + defaultModel: 'o3-mini', + ), + ); + + expect(controller.aiGatewayModelChoices, const [ + 'o3-mini', + 'gpt-5.4', + ]); + expect(controller.resolvedDefaultModel, 'o3-mini'); + }, + ); +} + +Future _waitFor( + bool Function() condition, { + Duration timeout = const Duration(seconds: 5), +}) async { + final deadline = DateTime.now().add(timeout); + while (!condition()) { + if (DateTime.now().isAfter(deadline)) { + throw TimeoutException('condition not met within $timeout'); + } + await Future.delayed(const Duration(milliseconds: 20)); + } +} diff --git a/test/runtime/secure_config_store_test.dart b/test/runtime/secure_config_store_test.dart index 3ee9bbbd..998b4030 100644 --- a/test/runtime/secure_config_store_test.dart +++ b/test/runtime/secure_config_store_test.dart @@ -25,6 +25,7 @@ void main() { await store.saveGatewayToken('token-secret'); await store.saveGatewayPassword('password-secret'); await store.saveVaultToken('vault-secret'); + await store.saveAiGatewayApiKey('ai-gateway-secret'); final loadedSnapshot = await store.loadSettingsSnapshot(); final secureRefs = await store.loadSecureRefs(); @@ -36,6 +37,7 @@ void main() { expect(secureRefs['gateway_token'], 'token-secret'); expect(secureRefs['gateway_password'], 'password-secret'); expect(secureRefs['vault_token'], 'vault-secret'); + expect(secureRefs['ai_gateway_api_key'], 'ai-gateway-secret'); expect(SecureConfigStore.maskValue('token-secret'), 'tok••••ret'); expect(SecureConfigStore.maskValue(''), 'Not set'); }, diff --git a/test/runtime/settings_controller_ai_gateway_sync_test.dart b/test/runtime/settings_controller_ai_gateway_sync_test.dart new file mode 100644 index 00000000..b95414ab --- /dev/null +++ b/test/runtime/settings_controller_ai_gateway_sync_test.dart @@ -0,0 +1,199 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:xworkmate/runtime/runtime_controllers.dart'; +import 'package:xworkmate/runtime/runtime_models.dart'; +import 'package:xworkmate/runtime/secure_config_store.dart'; + +void main() { + test( + 'SettingsController syncs AI Gateway models with an inline API key override', + () async { + SharedPreferences.setMockInitialValues({}); + final server = await _FakeAiGatewayServer.start(); + addTearDown(server.close); + + final store = SecureConfigStore(); + final controller = SettingsController(store); + await controller.initialize(); + await controller.saveSnapshot( + SettingsSnapshot.defaults().copyWith( + aiGateway: AiGatewayProfile.defaults().copyWith( + baseUrl: server.baseUrl, + ), + ), + ); + + final result = await controller.syncAiGatewayCatalog( + controller.snapshot.aiGateway, + apiKeyOverride: 'live-inline-key', + ); + + expect(server.lastAuthorization, 'Bearer live-inline-key'); + expect(result.availableModels, const [ + 'gpt-5.4', + 'o3-mini', + 'claude-3.7', + 'gemini-2.0', + 'deepseek-r1', + 'qwen-max', + ]); + expect(result.selectedModels, const [ + 'gpt-5.4', + 'o3-mini', + 'claude-3.7', + 'gemini-2.0', + 'deepseek-r1', + ]); + expect(controller.snapshot.defaultModel, 'gpt-5.4'); + expect(await store.loadAiGatewayApiKey(), isNull); + }, + ); + + test( + 'SettingsController tolerates OpenAI-compatible model payloads with a trailing JSON footer', + () async { + SharedPreferences.setMockInitialValues({}); + final server = await _FakeAiGatewayServer.start(appendFooterJson: true); + addTearDown(server.close); + + final store = SecureConfigStore(); + final controller = SettingsController(store); + await controller.initialize(); + await controller.saveSnapshot( + SettingsSnapshot.defaults().copyWith( + aiGateway: AiGatewayProfile.defaults().copyWith( + baseUrl: server.baseUrl, + ), + ), + ); + + final result = await controller.syncAiGatewayCatalog( + controller.snapshot.aiGateway, + apiKeyOverride: 'live-inline-key', + ); + + expect(result.syncState, 'ready'); + expect(result.availableModels.first, 'gpt-5.4'); + expect(result.availableModels.last, 'qwen-max'); + expect(await store.loadAiGatewayApiKey(), isNull); + }, + ); + + test( + 'SettingsController tests AI Gateway auth without persisting draft values', + () async { + SharedPreferences.setMockInitialValues({}); + final server = await _FakeAiGatewayServer.start( + expectedAuthorization: 'Bearer trusted-inline-key', + ); + addTearDown(server.close); + + final store = SecureConfigStore(); + final controller = SettingsController(store); + await controller.initialize(); + + final result = await controller.testAiGatewayConnection( + AiGatewayProfile.defaults().copyWith(baseUrl: server.baseUrl), + apiKeyOverride: 'trusted-inline-key', + ); + + expect(result.state, 'ready'); + expect(result.message, 'Authenticated · 6 model(s) available'); + expect(result.endpoint, '${server.baseUrl}/models'); + expect(controller.snapshot.aiGateway.baseUrl, ''); + expect(await store.loadAiGatewayApiKey(), isNull); + }, + ); + + test( + 'SettingsController reports AI Gateway auth failures with a detailed message', + () async { + SharedPreferences.setMockInitialValues({}); + final server = await _FakeAiGatewayServer.start( + expectedAuthorization: 'Bearer trusted-inline-key', + ); + addTearDown(server.close); + + final store = SecureConfigStore(); + final controller = SettingsController(store); + await controller.initialize(); + + final result = await controller.testAiGatewayConnection( + AiGatewayProfile.defaults().copyWith(baseUrl: server.baseUrl), + apiKeyOverride: 'wrong-key', + ); + + expect(result.state, 'error'); + expect(result.message, 'Authentication failed (401) · invalid_api_key'); + expect(await store.loadAiGatewayApiKey(), isNull); + }, + ); +} + +class _FakeAiGatewayServer { + _FakeAiGatewayServer._( + this._server, + this.expectedAuthorization, + this.appendFooterJson, + ); + + final HttpServer _server; + final String expectedAuthorization; + final bool appendFooterJson; + String? lastAuthorization; + + String get baseUrl => 'http://127.0.0.1:${_server.port}/v1'; + + static Future<_FakeAiGatewayServer> start({ + String expectedAuthorization = 'Bearer live-inline-key', + bool appendFooterJson = false, + }) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final fake = _FakeAiGatewayServer._( + server, + expectedAuthorization, + appendFooterJson, + ); + unawaited(fake._serve()); + return fake; + } + + Future close() => _server.close(force: true); + + Future _serve() async { + await for (final request in _server) { + lastAuthorization = request.headers.value( + HttpHeaders.authorizationHeader, + ); + request.response.headers.contentType = ContentType.json; + if (lastAuthorization != expectedAuthorization) { + request.response.statusCode = HttpStatus.unauthorized; + request.response.write( + jsonEncode({ + 'error': {'message': 'invalid_api_key'}, + }), + ); + await request.response.close(); + continue; + } + final body = jsonEncode({ + 'data': >[ + {'id': 'gpt-5.4'}, + {'id': 'o3-mini'}, + {'id': 'claude-3.7'}, + {'id': 'gemini-2.0'}, + {'id': 'deepseek-r1'}, + {'id': 'qwen-max'}, + ], + }); + request.response.write( + appendFooterJson ? '$body\n{"Content-Type":"application/json"}' : body, + ); + await request.response.close(); + } + } +}