feat: ship ai gateway integration and ui polish

This commit is contained in:
Haitao Pan 2026-03-13 15:21:23 +08:00
parent 8cf7140f47
commit 3bf965ddfa
16 changed files with 1931 additions and 441 deletions

View File

@ -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<String> 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<void> selectDefaultModel(String modelId) async {
final trimmed = modelId.trim();
if (trimmed.isEmpty || settings.defaultModel == trimmed) {
return;
}
await saveSettings(
settings.copyWith(defaultModel: trimmed),
refreshAfterSave: false,
);
}
Future<void> updateAiGatewaySelection(List<String> 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
? <String>[available.first]
: const <String>[];
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<AiGatewayProfile> syncAiGatewayCatalog(
AiGatewayProfile profile, {
String apiKeyOverride = '',
}) async {
final synced = await _settingsController.syncAiGatewayCatalog(
profile,
apiKeyOverride: apiKeyOverride,
);
_modelsController.restoreFromSettings(
_settingsController.snapshot.aiGateway,
);
_recomputeTasks();
return synced;
}
Future<void> 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<ApisixYamlProfile> validateApisixYaml(ApisixYamlProfile profile) {
return _settingsController.validateApisixYaml(profile);
}
List<DerivedTaskItem> 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);

View File

@ -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',

View File

@ -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<AssistantPage> {
final controller = widget.controller;
final messages = List<GatewayChatMessage>.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<AssistantPage> {
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<AssistantPage> {
onThinkingChanged: (value) {
setState(() => _thinkingLabel = value);
},
onModelChanged: controller.selectDefaultModel,
onRemoveAttachment: (attachment) {
setState(() {
_attachments = _attachments
@ -488,17 +487,18 @@ class _AssistantPageState extends State<AssistantPage> {
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<QuickAction> quickActions;
final AppController controller;
final TextEditingController inputController;
final FocusNode focusNode;
final String mode;
final String thinkingLabel;
final String modelLabel;
final List<String> modelOptions;
final List<_ComposerAttachment> attachments;
final String? autoAgentLabel;
final ValueChanged<String> onModeChanged;
final ValueChanged<String> onThinkingChanged;
final Future<void> Function(String modelId) onModelChanged;
final ValueChanged<_ComposerAttachment> onRemoveAttachment;
final VoidCallback onOpenGateway;
final Future<void> 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<String> modelOptions;
final List<_ComposerAttachment> attachments;
final String? autoAgentLabel;
final ValueChanged<String> onModeChanged;
final ValueChanged<String> onThinkingChanged;
final Future<void> Function(String modelId) onModelChanged;
final ValueChanged<_ComposerAttachment> onRemoveAttachment;
final VoidCallback onOpenGateway;
final Future<void> 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<String>(
tooltip: appText('模型', 'Model'),
onSelected: (value) {
onModelChanged(value);
},
itemBuilder: (context) => modelOptions
.map(
(value) => PopupMenuItem<String>(
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<String>(
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),
],
),
),
),
],

View File

@ -610,19 +610,19 @@ class _IosMobileShellState extends State<IosMobileShell> {
],
),
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(
<String>{
...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)
: <String>[...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<void> _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<void> _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<void> _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<String> _filterModels(List<String> 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(

View File

@ -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')],

View File

@ -22,17 +22,28 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> {
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<SettingsPage> {
@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<SettingsPage> {
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<SettingsPage> {
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<SettingsPage> {
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(
<String>{
...selectedModels,
...filteredModels,
}.toList(growable: false),
);
},
child: Text(appText('选择筛选结果', 'Select filtered')),
),
OutlinedButton(
key: const ValueKey('ai-gateway-reset-default'),
onPressed: () async {
await controller.updateAiGatewaySelection(
settings.aiGateway.availableModels
.take(5)
.toList(growable: false),
);
},
child: Text(appText('恢复默认 5 个', 'Reset default 5')),
),
],
),
const SizedBox(height: 12),
if (filteredModels.isEmpty)
Text(
appText('没有匹配的模型。', 'No matching models.'),
style: Theme.of(context).textTheme.bodySmall,
)
else
Wrap(
spacing: 8,
runSpacing: 8,
children: filteredModels
.map((modelId) {
final selected = selectedModels.contains(modelId);
return FilterChip(
label: Text(modelId),
selected: selected,
onSelected: (_) async {
final nextSelection = selected
? selectedModels
.where((item) => item != modelId)
.toList(growable: true)
: <String>[...selectedModels, modelId];
await controller.updateAiGatewaySelection(
nextSelection,
);
},
);
})
.toList(growable: false),
),
],
],
),
),
@ -927,6 +1126,129 @@ class _SettingsPageState extends State<SettingsPage> {
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<void> _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<void> _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<String> _filterAiGatewayModels(List<String> models) {
final query = _aiGatewayModelSearchController.text.trim().toLowerCase();
if (query.isEmpty) {
return models;
}
return models
.where((modelId) => modelId.toLowerCase().contains(query))
.toList(growable: false);
}
String _previewAiGatewayEndpoint(String rawUrl) {
final trimmed = rawUrl.trim();
if (trimmed.isEmpty) {
return '';
}
final candidate = trimmed.contains('://') ? trimmed : 'https://$trimmed';
final uri = Uri.tryParse(candidate);
if (uri == null || uri.host.trim().isEmpty) {
return '';
}
final pathSegments = uri.pathSegments
.where((item) => item.isNotEmpty)
.toList(growable: true);
if (pathSegments.isEmpty) {
pathSegments.add('v1');
}
if (pathSegments.last != 'models') {
pathSegments.add('models');
}
return uri
.replace(pathSegments: pathSegments, query: null, fragment: null)
.toString();
}
_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});

View File

@ -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<SecretAuditEntry> _auditTrail = const <SecretAuditEntry>[];
String _ollamaStatus = 'Idle';
String _vaultStatus = 'Idle';
String _aiGatewayStatus = 'Idle';
SettingsSnapshot get snapshot => _snapshot;
Map<String, String> get secureRefs => _secureRefs;
List<SecretAuditEntry> get auditTrail => _auditTrail;
String get ollamaStatus => _ollamaStatus;
String get vaultStatus => _vaultStatus;
String get aiGatewayStatus => _aiGatewayStatus;
Future<void> initialize() async {
_snapshot = await _store.loadSettingsSnapshot();
@ -154,6 +155,26 @@ class SettingsController extends ChangeNotifier {
notifyListeners();
}
Future<void> 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<void> appendAudit(SecretAuditEntry entry) async {
await _store.appendAudit(entry);
_auditTrail = await _store.loadAuditTrail();
@ -232,40 +253,162 @@ class SettingsController extends ChangeNotifier {
}
}
Future<ApisixYamlProfile> 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<AiGatewayProfile> 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<AiGatewayConnectionCheck> 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<List<GatewayModelSummary>> loadAiGatewayModels({
AiGatewayProfile? profile,
String apiKeyOverride = '',
}) async {
final activeProfile = profile ?? _snapshot.aiGateway;
final normalizedBaseUrl = _normalizeAiGatewayBaseUrl(activeProfile.baseUrl);
if (normalizedBaseUrl == null) {
return const <GatewayModelSummary>[];
}
final apiKey = apiKeyOverride.trim().isNotEmpty
? apiKeyOverride.trim()
: (await _store.loadAiGatewayApiKey())?.trim() ?? '';
if (apiKey.isEmpty) {
return const <GatewayModelSummary>[];
}
return _requestAiGatewayModels(
uri: _aiGatewayModelsUri(normalizedBaseUrl),
apiKey: apiKey,
);
}
List<SecretReferenceEntry> 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<String> _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 <String>['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<List<GatewayModelSummary>> _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<String, dynamic>
? [
...asList(decoded['data']),
if (asList(decoded['data']).isEmpty) ...asList(decoded['models']),
]
: const <Object>[];
final seen = <String>{};
final items = <GatewayModelSummary>[];
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<HttpClientResponse> _simpleGet(
Uri uri, {
required Map<String, String> 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<GatewayModelSummary> _items = const <GatewayModelSummary>[];
bool _loading = false;
@ -816,18 +1146,32 @@ class ModelsController extends ChangeNotifier {
bool get loading => _loading;
String? get error => _error;
Future<void> refresh() async {
if (!_runtime.isConnected) {
_items = const <GatewayModelSummary>[];
_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<void> 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<GatewayModelSummary> _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 {

View File

@ -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<String> availableModels;
final List<String> 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: <String>[],
selectedModels: <String>[],
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<String>? availableModels,
List<String>? 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
List<String> normalizeList(Object? value) {
if (value is! List) {
return const <String>[];
}
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<String, dynamic>() ?? const {},
),
apisix: ApisixYamlProfile.fromJson(
(json['apisix'] as Map?)?.cast<String, dynamic>() ?? const {},
aiGateway: AiGatewayProfile.fromJson(
(json['aiGateway'] as Map?)?.cast<String, dynamic>() ??
(json['apisix'] as Map?)?.cast<String, dynamic>() ??
const {},
),
experimentalCanvas: json['experimentalCanvas'] as bool? ?? false,
experimentalBridge: json['experimentalBridge'] as bool? ?? false,

View File

@ -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<void> saveVaultToken(String value) =>
_writeSecure(_vaultTokenKey, value);
Future<String?> loadAiGatewayApiKey() => _readSecure(_aiGatewayApiKeyKey);
Future<void> saveAiGatewayApiKey(String value) =>
_writeSecure(_aiGatewayApiKeyKey, value);
Future<void> clearAiGatewayApiKey() => _deleteSecure(_aiGatewayApiKeyKey);
Future<LocalDeviceIdentity?> 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
: <String, String>{'vault_token': vaultToken},
...?aiGatewayApiKey == null
? null
: <String, String>{'ai_gateway_api_key': aiGatewayApiKey},
};
}

View File

@ -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 <String>[
'.SF NS Text',
'.SF Pro Text',
'PingFang SC',
'Helvetica Neue',
],
_ => const <String>['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),
);
}
}

View File

@ -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

View File

@ -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

View File

@ -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);
});
}

View File

@ -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(<String, Object>{});
final controller = AppController();
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await controller.saveSettings(
controller.settings.copyWith(
aiGateway: controller.settings.aiGateway.copyWith(
availableModels: const <String>['gpt-5.4', 'o3-mini', 'claude-3.7'],
selectedModels: const <String>['o3-mini', 'gpt-5.4'],
),
defaultModel: 'o3-mini',
),
);
expect(controller.aiGatewayModelChoices, const <String>[
'o3-mini',
'gpt-5.4',
]);
expect(controller.resolvedDefaultModel, 'o3-mini');
},
);
}
Future<void> _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<void>.delayed(const Duration(milliseconds: 20));
}
}

View File

@ -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');
},

View File

@ -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(<String, Object>{});
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 <String>[
'gpt-5.4',
'o3-mini',
'claude-3.7',
'gemini-2.0',
'deepseek-r1',
'qwen-max',
]);
expect(result.selectedModels, const <String>[
'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(<String, Object>{});
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(<String, Object>{});
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(<String, Object>{});
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<void> close() => _server.close(force: true);
Future<void> _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(<String, dynamic>{
'error': <String, dynamic>{'message': 'invalid_api_key'},
}),
);
await request.response.close();
continue;
}
final body = jsonEncode(<String, dynamic>{
'data': <Map<String, dynamic>>[
<String, dynamic>{'id': 'gpt-5.4'},
<String, dynamic>{'id': 'o3-mini'},
<String, dynamic>{'id': 'claude-3.7'},
<String, dynamic>{'id': 'gemini-2.0'},
<String, dynamic>{'id': 'deepseek-r1'},
<String, dynamic>{'id': 'qwen-max'},
],
});
request.response.write(
appendFooterJson ? '$body\n{"Content-Type":"application/json"}' : body,
);
await request.response.close();
}
}
}