feat(studio): system-ai editor — test-without-save + model picker (v0.13.0)

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 <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-05-07 14:02:44 +02:00
parent 88c5f2da1f
commit 5b6641567c
4 changed files with 266 additions and 23 deletions

View file

@ -118,6 +118,10 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
late String _privacyMode;
bool _saving = false;
bool _testing = false;
bool _loadingModels = false;
bool _pulling = false;
List<String>? _models;
String? _modelsError;
AskAiResult? _testResult;
String? _error;
@ -197,17 +201,22 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
}
Future<void> _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<FaiSystemAiEditor> {
}
}
Future<void> _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<void> _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<FaiSystemAiEditor> {
),
),
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<String>? 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<TextEditingValue>(
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;
},
),
],
),
],
],
);
}
}