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
|
||||
/// error-kind taxonomy as [askAi]; "Test connection" buttons
|
||||
/// use this to surface "is it working?".
|
||||
Future<AskAiResult> 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<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(
|
||||
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<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.
|
||||
Future<ChannelStatusSnapshot> channelStatus() async {
|
||||
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
|
||||
/// 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<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue