feat: ship ai gateway integration and ui polish
This commit is contained in:
parent
8cf7140f47
commit
3bf965ddfa
@ -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);
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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')],
|
||||
|
||||
@ -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});
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
29
pubspec.yaml
29
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
47
test/runtime/app_controller_ai_gateway_models_test.dart
Normal file
47
test/runtime/app_controller_ai_gateway_models_test.dart
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
},
|
||||
|
||||
199
test/runtime/settings_controller_ai_gateway_sync_test.dart
Normal file
199
test/runtime/settings_controller_ai_gateway_sync_test.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user