From 5b6641567ced61259b6413c6d4e1b4bfa52df983 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Thu, 7 May 2026 14:02:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(studio):=20system-ai=20editor=20=E2=80=94?= =?UTF-8?q?=20test-without-save=20+=20model=20picker=20(v0.13.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three operator-facing improvements driven by Stefan's testing: * **Test Connection** runs against the form's *current* values and no longer requires saving first. Previously we save-then- test which caused a "system AI is disabled" message when the form's model field was still empty. Now the test prompt travels with the request body so unsaved tweaks can be validated. * **Model picker** replaces the free-text Model field. A Refresh button calls HubAdmin.ListSystemAiModels (provider's `/v1/models`); installed models show up as clickable chips that fill the field with one tap. The text input stays for cases where the operator already knows the id. * **Pull** button (Ollama only) downloads a model via `/api/pull`. Type `gemma3:4b`, click Pull, spinner, then the new model lights up in the chip list. No more "switch to a terminal to ollama-pull" detour. Helper text updates for the empty-model case ("Required: pick from the list (Refresh) or type one. Use Pull to download from Ollama.") so a fresh operator never wonders why Save / Test errors out. Signed-off-by: flemming-it --- lib/data/hub.dart | 65 +++++++- lib/main.dart | 2 +- lib/widgets/fai_system_ai_editor.dart | 220 ++++++++++++++++++++++++-- pubspec.yaml | 2 +- 4 files changed, 266 insertions(+), 23 deletions(-) diff --git a/lib/data/hub.dart b/lib/data/hub.dart index 0461629..a76c9c3 100644 --- a/lib/data/hub.dart +++ b/lib/data/hub.dart @@ -163,11 +163,24 @@ class HubService { ); } - /// Probe the configured provider with a tiny ping. Same - /// error-kind taxonomy as [askAi]; "Test connection" buttons - /// use this to surface "is it working?". - Future testSystemAi() async { - final r = await _client.testSystemAi(); + /// Probe a System-AI configuration with a tiny ping. Pass + /// the editor's current form values so "Test connection" + /// works *before* save. Empty fields fall back to the live + /// in-memory config. + Future testSystemAi({ + String provider = '', + String endpoint = '', + String model = '', + String apiKeyEnv = '', + String privacyMode = '', + }) async { + final r = await _client.testSystemAi( + provider: provider, + endpoint: endpoint, + model: model, + apiKeyEnv: apiKeyEnv, + privacyMode: privacyMode, + ); return AskAiResult( errorKind: r.errorKind, text: r.text, @@ -175,6 +188,48 @@ class HubService { ); } + /// List the models the provider exposes via `GET /v1/models`. + /// Studio's editor uses this to populate a dropdown instead + /// of forcing the operator to type the id manually. + Future<({String errorKind, String text, List ids})> + listSystemAiModels({ + String provider = '', + String endpoint = '', + String apiKeyEnv = '', + }) async { + final r = await _client.listSystemAiModels( + provider: provider, + endpoint: endpoint, + apiKeyEnv: apiKeyEnv, + ); + return ( + errorKind: r.errorKind, + text: r.text, + ids: r.models.map((m) => m.id).toList(), + ); + } + + /// Pull (download) an Ollama model via /api/pull. + /// Synchronous; can take minutes for large models. Errors + /// come back as `errorKind`. + Future<({String errorKind, String text, int elapsedMs})> + pullSystemAiModel({ + required String endpoint, + required String model, + String apiKeyEnv = '', + }) async { + final r = await _client.pullSystemAiModel( + endpoint: endpoint, + model: model, + apiKeyEnv: apiKeyEnv, + ); + return ( + errorKind: r.errorKind, + text: r.text, + elapsedMs: r.elapsedMs, + ); + } + /// Active channel + per-channel daemon status snapshot. Future channelStatus() async { final r = await _client.channelStatus(); diff --git a/lib/main.dart b/lib/main.dart index c4ebbd2..8d61f34 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,7 +23,7 @@ import 'widgets/widgets.dart'; /// Studio's own build version. Bump on every UI commit so the /// running app self-identifies — visible in the sidebar header /// and quick-glance proof that you're seeing the current build. -const String kStudioVersion = '0.12.1'; +const String kStudioVersion = '0.13.0'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); diff --git a/lib/widgets/fai_system_ai_editor.dart b/lib/widgets/fai_system_ai_editor.dart index acec2be..dd511f3 100644 --- a/lib/widgets/fai_system_ai_editor.dart +++ b/lib/widgets/fai_system_ai_editor.dart @@ -118,6 +118,10 @@ class _FaiSystemAiEditorState extends State { late String _privacyMode; bool _saving = false; bool _testing = false; + bool _loadingModels = false; + bool _pulling = false; + List? _models; + String? _modelsError; AskAiResult? _testResult; String? _error; @@ -197,17 +201,22 @@ class _FaiSystemAiEditorState extends State { } Future _test() async { - // Save first so the hub uses the values currently in the - // form. Otherwise "Test" would probe the previous config and - // confuse the operator about which values are being verified. - await _save(keepOpen: true); - if (!mounted || _error != null) return; setState(() { _testing = true; _testResult = null; + _error = null; }); try { - final r = await HubService.instance.testSystemAi(); + // Pass the current form values directly — Test must work + // *before* Save so the operator can validate without + // committing. + final r = await HubService.instance.testSystemAi( + provider: _preset.wire, + endpoint: _endpoint.text.trim(), + model: _model.text.trim(), + apiKeyEnv: _apiKeyEnv.text.trim(), + privacyMode: _privacyMode, + ); if (!mounted) return; setState(() { _testing = false; @@ -221,6 +230,59 @@ class _FaiSystemAiEditorState extends State { } } + Future _refreshModels() async { + setState(() { + _loadingModels = true; + _models = null; + _modelsError = null; + }); + final r = await HubService.instance.listSystemAiModels( + provider: _preset.wire, + endpoint: _endpoint.text.trim(), + apiKeyEnv: _apiKeyEnv.text.trim(), + ); + if (!mounted) return; + setState(() { + _loadingModels = false; + if (r.errorKind.isEmpty) { + _models = r.ids; + } else { + _modelsError = r.text; + } + }); + } + + Future _pullModel() async { + final wanted = _model.text.trim(); + if (wanted.isEmpty) { + setState(() => _error = 'Type a model id (e.g. gemma3:4b) first.'); + return; + } + setState(() { + _pulling = true; + _error = null; + }); + final r = await HubService.instance.pullSystemAiModel( + endpoint: _endpoint.text.trim(), + model: wanted, + apiKeyEnv: _apiKeyEnv.text.trim(), + ); + if (!mounted) return; + setState(() { + _pulling = false; + if (r.errorKind.isNotEmpty) { + _error = 'Pull failed: ${r.text}'; + } else { + _error = null; + } + }); + if (r.errorKind.isEmpty) { + // Refresh the model list so the just-pulled model appears + // in the dropdown immediately. + await _refreshModels(); + } + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -271,17 +333,19 @@ class _FaiSystemAiEditorState extends State { ), ), const SizedBox(height: FaiSpace.md), - TextField( + _ModelPicker( controller: _model, - style: FaiTheme.mono(size: 12), - decoration: InputDecoration( - labelText: 'Model', - hintText: _preset.modelHint.isEmpty - ? 'model identifier the provider expects' - : _preset.modelHint, - border: const OutlineInputBorder(), - isDense: true, - ), + preset: _preset, + models: _models, + modelsError: _modelsError, + loading: _loadingModels, + pulling: _pulling, + onRefresh: _saving || _testing || _loadingModels || _pulling + ? null + : _refreshModels, + onPull: _saving || _testing || _loadingModels || _pulling + ? null + : _pullModel, ), const SizedBox(height: FaiSpace.md), TextField( @@ -531,3 +595,127 @@ class _TestResultPanel extends StatelessWidget { ); } } + +class _ModelPicker extends StatelessWidget { + final TextEditingController controller; + final _ProviderPreset preset; + final List? models; + final String? modelsError; + final bool loading; + final bool pulling; + final VoidCallback? onRefresh; + final VoidCallback? onPull; + + const _ModelPicker({ + required this.controller, + required this.preset, + required this.models, + required this.modelsError, + required this.loading, + required this.pulling, + required this.onRefresh, + required this.onPull, + }); + + bool get _isOllama => preset.wire == 'ollama'; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ValueListenableBuilder( + valueListenable: controller, + builder: (_, _, _) => TextField( + controller: controller, + style: FaiTheme.mono(size: 12), + decoration: InputDecoration( + labelText: 'Model (required)', + hintText: preset.modelHint.isEmpty + ? 'model identifier the provider expects' + : preset.modelHint, + helperText: controller.text.trim().isEmpty + ? 'Required: pick from the list (Refresh) or type one. ${_isOllama ? "Use Pull to download from Ollama." : ""}' + : null, + helperMaxLines: 2, + border: const OutlineInputBorder(), + isDense: true, + ), + ), + ), + ), + const SizedBox(width: FaiSpace.sm), + OutlinedButton.icon( + onPressed: onRefresh, + icon: loading + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh, size: 16), + label: const Text('Refresh'), + ), + if (_isOllama) ...[ + const SizedBox(width: FaiSpace.sm), + OutlinedButton.icon( + onPressed: onPull, + icon: pulling + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download, size: 16), + label: Text(pulling ? 'Pulling…' : 'Pull'), + ), + ], + ], + ), + if (modelsError != null) ...[ + const SizedBox(height: 4), + Text( + 'Could not list models: $modelsError', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + if (models != null) ...[ + const SizedBox(height: FaiSpace.sm), + if (models!.isEmpty) + Text( + _isOllama + ? 'No models on the Ollama server yet. Type one above and click Pull.' + : 'Provider returned no models.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ) + else + Wrap( + spacing: FaiSpace.xs, + runSpacing: FaiSpace.xs, + children: [ + for (final id in models!) + ActionChip( + label: Text( + id, + style: FaiTheme.mono(size: 11), + ), + onPressed: () { + controller.text = id; + }, + ), + ], + ), + ], + ], + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index ac8fed7..d2755b2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.12.1 +version: 0.13.0 environment: sdk: ^3.11.0-200.1.beta