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:
parent
88c5f2da1f
commit
5b6641567c
4 changed files with 266 additions and 23 deletions
|
|
@ -163,11 +163,24 @@ class HubService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Probe the configured provider with a tiny ping. Same
|
/// Probe a System-AI configuration with a tiny ping. Pass
|
||||||
/// error-kind taxonomy as [askAi]; "Test connection" buttons
|
/// the editor's current form values so "Test connection"
|
||||||
/// use this to surface "is it working?".
|
/// works *before* save. Empty fields fall back to the live
|
||||||
Future<AskAiResult> testSystemAi() async {
|
/// in-memory config.
|
||||||
final r = await _client.testSystemAi();
|
Future<AskAiResult> 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(
|
return AskAiResult(
|
||||||
errorKind: r.errorKind,
|
errorKind: r.errorKind,
|
||||||
text: r.text,
|
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<String> 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.
|
/// Active channel + per-channel daemon status snapshot.
|
||||||
Future<ChannelStatusSnapshot> channelStatus() async {
|
Future<ChannelStatusSnapshot> channelStatus() async {
|
||||||
final r = await _client.channelStatus();
|
final r = await _client.channelStatus();
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import 'widgets/widgets.dart';
|
||||||
/// Studio's own build version. Bump on every UI commit so the
|
/// Studio's own build version. Bump on every UI commit so the
|
||||||
/// running app self-identifies — visible in the sidebar header
|
/// running app self-identifies — visible in the sidebar header
|
||||||
/// and quick-glance proof that you're seeing the current build.
|
/// and quick-glance proof that you're seeing the current build.
|
||||||
const String kStudioVersion = '0.12.1';
|
const String kStudioVersion = '0.13.0';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,10 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
|
||||||
late String _privacyMode;
|
late String _privacyMode;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
bool _testing = false;
|
bool _testing = false;
|
||||||
|
bool _loadingModels = false;
|
||||||
|
bool _pulling = false;
|
||||||
|
List<String>? _models;
|
||||||
|
String? _modelsError;
|
||||||
AskAiResult? _testResult;
|
AskAiResult? _testResult;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
|
|
@ -197,17 +201,22 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _test() async {
|
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(() {
|
setState(() {
|
||||||
_testing = true;
|
_testing = true;
|
||||||
_testResult = null;
|
_testResult = null;
|
||||||
|
_error = null;
|
||||||
});
|
});
|
||||||
try {
|
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;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_testing = false;
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -271,17 +333,19 @@ class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.md),
|
const SizedBox(height: FaiSpace.md),
|
||||||
TextField(
|
_ModelPicker(
|
||||||
controller: _model,
|
controller: _model,
|
||||||
style: FaiTheme.mono(size: 12),
|
preset: _preset,
|
||||||
decoration: InputDecoration(
|
models: _models,
|
||||||
labelText: 'Model',
|
modelsError: _modelsError,
|
||||||
hintText: _preset.modelHint.isEmpty
|
loading: _loadingModels,
|
||||||
? 'model identifier the provider expects'
|
pulling: _pulling,
|
||||||
: _preset.modelHint,
|
onRefresh: _saving || _testing || _loadingModels || _pulling
|
||||||
border: const OutlineInputBorder(),
|
? null
|
||||||
isDense: true,
|
: _refreshModels,
|
||||||
),
|
onPull: _saving || _testing || _loadingModels || _pulling
|
||||||
|
? null
|
||||||
|
: _pullModel,
|
||||||
),
|
),
|
||||||
const SizedBox(height: FaiSpace.md),
|
const SizedBox(height: FaiSpace.md),
|
||||||
TextField(
|
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;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
name: fai_studio
|
name: fai_studio
|
||||||
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
description: "F∆I Studio — desktop GUI for the F∆I hub"
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 0.12.1
|
version: 0.13.0
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.0-200.1.beta
|
sdk: ^3.11.0-200.1.beta
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue