- Connection-aware Welcome: when the hub is down, show a hero with a primary "Start hub" CTA + install fallback instead of a dead, all-unchecked onboarding checklist (the first-run cliff). - Actionable binary-not-found (file picker + install link, not a "set FAI_BIN" dead end) and a connect-failure banner after repeated failed health polls. - Localize six hardcoded English error/toast clusters (DE+EN ARB). - Bundle Inter + JetBrains Mono as assets; drop the runtime google_fonts fetch (air-gap / KRITIS safe, no font-swap flash). Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
1161 lines
37 KiB
Dart
1161 lines
37 KiB
Dart
// FaiSystemAiEditor — modal for configuring the hub-internal
|
|
// System AI from inside Studio. Mirrors the YAML block but with
|
|
// provider presets, in-place explainers, and a "Test connection"
|
|
// button. On save, calls HubAdmin.UpdateSystemAi which both
|
|
// persists to ~/.fai/config.yaml and hot-reloads the live hub.
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../data/error_presentation.dart';
|
|
import '../data/hub.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
import '../theme/theme.dart';
|
|
import '../theme/tokens.dart';
|
|
import 'fai_pill.dart';
|
|
|
|
/// Provider preset metadata kept in sync with
|
|
/// `fai_hub::operator_config::SystemLlmProvider`. Anything that
|
|
/// is operator-visible (label, description, default endpoint,
|
|
/// suggested env-var) lives here so the dropdown self-explains
|
|
/// without needing to read the source.
|
|
class _ProviderPreset {
|
|
final String wire;
|
|
final String label;
|
|
final String defaultEndpoint;
|
|
final String defaultApiKeyEnv;
|
|
final String description;
|
|
final String modelHint;
|
|
|
|
const _ProviderPreset({
|
|
required this.wire,
|
|
required this.label,
|
|
required this.defaultEndpoint,
|
|
required this.defaultApiKeyEnv,
|
|
required this.description,
|
|
required this.modelHint,
|
|
});
|
|
|
|
static const all = <_ProviderPreset>[
|
|
_ProviderPreset(
|
|
wire: 'ollama',
|
|
label: 'Ollama',
|
|
defaultEndpoint: 'http://127.0.0.1:11434/v1',
|
|
defaultApiKeyEnv: '',
|
|
description:
|
|
'Local `ollama serve`. Models stay on your machine. No API key needed.',
|
|
modelHint: 'gemma3:4b · llama3.2:3b · qwen2.5-coder:7b',
|
|
),
|
|
_ProviderPreset(
|
|
wire: 'openai',
|
|
label: 'OpenAI',
|
|
defaultEndpoint: 'https://api.openai.com/v1',
|
|
defaultApiKeyEnv: 'OPENAI_API_KEY',
|
|
description:
|
|
'OpenAI hosted API. Requires an API key. Data leaves your machine.',
|
|
modelHint: 'gpt-4o-mini · gpt-4o · o1-mini',
|
|
),
|
|
_ProviderPreset(
|
|
wire: 'lmstudio',
|
|
label: 'LM Studio',
|
|
defaultEndpoint: 'http://127.0.0.1:1234/v1',
|
|
defaultApiKeyEnv: '',
|
|
description:
|
|
'Local LM Studio server. Models stay on your machine. No API key needed.',
|
|
modelHint: 'whichever model you loaded in LM Studio',
|
|
),
|
|
_ProviderPreset(
|
|
wire: 'vllm',
|
|
label: 'vLLM (self-hosted)',
|
|
defaultEndpoint: '',
|
|
defaultApiKeyEnv: '',
|
|
description:
|
|
'Self-hosted vLLM or any other OpenAI-compatible server. Endpoint required.',
|
|
modelHint: 'depends on what your vLLM has loaded',
|
|
),
|
|
_ProviderPreset(
|
|
wire: 'custom',
|
|
label: 'Custom (OpenAI-compatible)',
|
|
defaultEndpoint: '',
|
|
defaultApiKeyEnv: '',
|
|
description:
|
|
'Anything else that speaks OpenAI-compatible chat-completions wire (LiteLLM proxy, internal mirror, …).',
|
|
modelHint: '',
|
|
),
|
|
];
|
|
|
|
static _ProviderPreset byWire(String wire) {
|
|
return all.firstWhere((p) => p.wire == wire, orElse: () => all.first);
|
|
}
|
|
|
|
/// Localized help body for this provider. Falls back to the
|
|
/// const English [description] for any future wire that has no
|
|
/// ARB key yet.
|
|
String descriptionFor(AppLocalizations l) {
|
|
switch (wire) {
|
|
case 'ollama':
|
|
return l.providerDescOllama;
|
|
case 'openai':
|
|
return l.providerDescOpenai;
|
|
case 'lmstudio':
|
|
return l.providerDescLmstudio;
|
|
case 'vllm':
|
|
return l.providerDescVllm;
|
|
case 'custom':
|
|
return l.providerDescCustom;
|
|
default:
|
|
return description;
|
|
}
|
|
}
|
|
}
|
|
|
|
class FaiSystemAiEditor extends StatefulWidget {
|
|
final SystemAiStatus initial;
|
|
|
|
const FaiSystemAiEditor({super.key, required this.initial});
|
|
|
|
/// Convenience launcher. Returns the new status when the
|
|
/// operator saved, null on cancel.
|
|
static Future<SystemAiStatus?> show(
|
|
BuildContext context,
|
|
SystemAiStatus initial,
|
|
) {
|
|
return showDialog<SystemAiStatus>(
|
|
context: context,
|
|
builder: (_) => FaiSystemAiEditor(initial: initial),
|
|
);
|
|
}
|
|
|
|
@override
|
|
State<FaiSystemAiEditor> createState() => _FaiSystemAiEditorState();
|
|
}
|
|
|
|
class _FaiSystemAiEditorState extends State<FaiSystemAiEditor> {
|
|
late _ProviderPreset _preset;
|
|
late final TextEditingController _endpoint;
|
|
late final TextEditingController _model;
|
|
late final TextEditingController _apiKeyEnv;
|
|
late String _privacyMode;
|
|
bool _saving = false;
|
|
bool _testing = false;
|
|
bool _loadingModels = false;
|
|
bool _pulling = false;
|
|
List<String>? _models;
|
|
String? _modelsError;
|
|
AskAiResult? _testResult;
|
|
String? _error;
|
|
HardwareSnapshot? _hw;
|
|
CuratedSnapshot? _curated;
|
|
Map<String, CuratedModelInfo> _curatedById = const {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final init = widget.initial;
|
|
_preset = _ProviderPreset.byWire(
|
|
init.provider.isEmpty ? 'ollama' : init.provider,
|
|
);
|
|
_endpoint = TextEditingController(
|
|
text: init.endpoint.isEmpty ? _preset.defaultEndpoint : init.endpoint,
|
|
);
|
|
_model = TextEditingController(text: init.model);
|
|
_apiKeyEnv = TextEditingController(
|
|
text: init.apiKeyEnv.isEmpty ? _preset.defaultApiKeyEnv : init.apiKeyEnv,
|
|
);
|
|
_privacyMode = init.privacyMode.isEmpty ? 'redacted' : init.privacyMode;
|
|
// Pull the model list immediately so the operator sees the
|
|
// available models when the dialog opens — no Refresh click
|
|
// needed. Fail-quiet: if the provider isn't running, the
|
|
// dropdown stays empty and the operator can fill the field
|
|
// manually or hit Refresh later.
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (_endpoint.text.trim().isNotEmpty) {
|
|
_refreshModels();
|
|
}
|
|
_loadHardwareAndCurated();
|
|
});
|
|
}
|
|
|
|
Future<void> _loadHardwareAndCurated() async {
|
|
try {
|
|
final results = await Future.wait([
|
|
HubService.instance.hardwareInfo(),
|
|
HubService.instance.listSystemAiCuratedModels(),
|
|
]);
|
|
if (!mounted) return;
|
|
final hw = results[0] as HardwareSnapshot;
|
|
final curated = results[1] as CuratedSnapshot;
|
|
setState(() {
|
|
_hw = hw;
|
|
_curated = curated;
|
|
_curatedById = {for (final m in curated.models) m.id: m};
|
|
});
|
|
} catch (_) {
|
|
// Non-fatal: editor still works using the size-only heuristic.
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_endpoint.dispose();
|
|
_model.dispose();
|
|
_apiKeyEnv.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onProviderChanged(_ProviderPreset p) {
|
|
setState(() {
|
|
_preset = p;
|
|
// Only auto-fill the field when the operator hasn't typed
|
|
// anything custom yet (i.e. it still matches a different
|
|
// preset's default or is empty).
|
|
if (_endpoint.text.isEmpty || _isAnyDefaultEndpoint(_endpoint.text)) {
|
|
_endpoint.text = p.defaultEndpoint;
|
|
}
|
|
if (_apiKeyEnv.text.isEmpty || _isAnyDefaultApiKeyEnv(_apiKeyEnv.text)) {
|
|
_apiKeyEnv.text = p.defaultApiKeyEnv;
|
|
}
|
|
_testResult = null;
|
|
_models = null;
|
|
_modelsError = null;
|
|
});
|
|
// New provider → new model list. Fire a refresh so the
|
|
// chips update before the operator wonders what's available.
|
|
if (_endpoint.text.trim().isNotEmpty) {
|
|
_refreshModels();
|
|
}
|
|
}
|
|
|
|
bool _isAnyDefaultEndpoint(String t) =>
|
|
_ProviderPreset.all.any((p) => p.defaultEndpoint == t);
|
|
bool _isAnyDefaultApiKeyEnv(String t) =>
|
|
_ProviderPreset.all.any((p) => p.defaultApiKeyEnv == t);
|
|
|
|
Future<void> _save({bool keepOpen = false}) async {
|
|
setState(() {
|
|
_saving = true;
|
|
_error = null;
|
|
});
|
|
try {
|
|
final status = await HubService.instance.updateSystemAi(
|
|
provider: _preset.wire,
|
|
endpoint: _endpoint.text.trim(),
|
|
model: _model.text.trim(),
|
|
apiKeyEnv: _apiKeyEnv.text.trim(),
|
|
privacyMode: _privacyMode,
|
|
);
|
|
if (!mounted) return;
|
|
if (keepOpen) {
|
|
setState(() => _saving = false);
|
|
} else {
|
|
Navigator.pop(context, status);
|
|
}
|
|
} catch (e) {
|
|
setState(() {
|
|
_saving = false;
|
|
_error = e.toString();
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _clearCache() async {
|
|
try {
|
|
final purged = await HubService.instance.clearSystemLlmCache();
|
|
if (!mounted) return;
|
|
final l = AppLocalizations.of(context)!;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l.systemAiCacheClearedToast(purged))),
|
|
);
|
|
setState(() {
|
|
// Force a rebuild that hides the row; cheap.
|
|
});
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
showFaiErrorSnack(context, 'settings.system-ai.cache-clear', e);
|
|
}
|
|
}
|
|
|
|
Future<void> _test() async {
|
|
setState(() {
|
|
_testing = true;
|
|
_testResult = null;
|
|
_error = null;
|
|
});
|
|
try {
|
|
// 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;
|
|
_testResult = r;
|
|
});
|
|
} catch (e) {
|
|
setState(() {
|
|
_testing = false;
|
|
_error = e.toString();
|
|
});
|
|
}
|
|
}
|
|
|
|
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();
|
|
final l = AppLocalizations.of(context)!;
|
|
if (wanted.isEmpty) {
|
|
setState(() => _error = l.systemAiPullEmptyError);
|
|
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 = l.systemAiPullFailedError(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);
|
|
final l = AppLocalizations.of(context)!;
|
|
return AlertDialog(
|
|
title: Text(l.systemAiTitle),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(FaiRadius.md),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: FaiSpace.xl,
|
|
vertical: FaiSpace.lg,
|
|
),
|
|
content: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 520, maxHeight: 600),
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l.systemAiIntro,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
if (_hw != null) ...[
|
|
const SizedBox(height: FaiSpace.sm),
|
|
_HardwareBanner(hw: _hw!, lastReviewed: _curated?.lastReviewed),
|
|
],
|
|
const SizedBox(height: FaiSpace.lg),
|
|
_ProviderDropdown(value: _preset, onChanged: _onProviderChanged),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
_preset.descriptionFor(l),
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.md),
|
|
TextField(
|
|
controller: _endpoint,
|
|
style: FaiTheme.mono(size: 12),
|
|
decoration: InputDecoration(
|
|
labelText: l.systemAiEndpointLabel,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.md),
|
|
_ModelPicker(
|
|
controller: _model,
|
|
preset: _preset,
|
|
models: _models,
|
|
modelsError: _modelsError,
|
|
loading: _loadingModels,
|
|
pulling: _pulling,
|
|
hw: _hw,
|
|
curatedById: _curatedById,
|
|
onRefresh: _saving || _testing || _loadingModels || _pulling
|
|
? null
|
|
: _refreshModels,
|
|
onPull: _saving || _testing || _loadingModels || _pulling
|
|
? null
|
|
: _pullModel,
|
|
),
|
|
const SizedBox(height: FaiSpace.md),
|
|
TextField(
|
|
controller: _apiKeyEnv,
|
|
style: FaiTheme.mono(size: 12),
|
|
decoration: InputDecoration(
|
|
labelText: _preset.wire == 'openai'
|
|
? l.systemAiApiKeyRequired
|
|
: l.systemAiApiKeyOptional,
|
|
hintText: 'OPENAI_API_KEY',
|
|
prefixText: '\$',
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
l.systemAiApiKeyDisclaimer('<name>'),
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.lg),
|
|
_PrivacyModeChips(
|
|
value: _privacyMode,
|
|
onChanged: (v) => setState(() => _privacyMode = v),
|
|
),
|
|
if (widget.initial.cacheCount > 0) ...[
|
|
const SizedBox(height: FaiSpace.md),
|
|
_CacheStatusRow(
|
|
cacheCount: widget.initial.cacheCount,
|
|
onClear: _saving || _testing ? null : _clearCache,
|
|
),
|
|
],
|
|
if (_error != null) ...[
|
|
const SizedBox(height: FaiSpace.md),
|
|
Text(
|
|
_error!,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.error,
|
|
),
|
|
),
|
|
],
|
|
if (_testResult != null) ...[
|
|
const SizedBox(height: FaiSpace.md),
|
|
_TestResultPanel(result: _testResult!),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: _saving || _testing
|
|
? null
|
|
: () => Navigator.pop(context, null),
|
|
child: Text(l.buttonCancel),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: _saving || _testing ? null : _test,
|
|
icon: const Icon(Icons.play_arrow, size: 16),
|
|
label: Text(_testing ? l.systemAiTesting : l.systemAiTestConnection),
|
|
),
|
|
FilledButton(
|
|
onPressed: _saving || _testing ? null : () => _save(),
|
|
child: _saving
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Text(l.systemAiSave),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ProviderDropdown extends StatelessWidget {
|
|
final _ProviderPreset value;
|
|
final void Function(_ProviderPreset) onChanged;
|
|
|
|
const _ProviderDropdown({required this.value, required this.onChanged});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l = AppLocalizations.of(context)!;
|
|
return DropdownButtonFormField<String>(
|
|
initialValue: value.wire,
|
|
isDense: true,
|
|
decoration: InputDecoration(
|
|
labelText: l.systemAiProviderLabel,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
items: _ProviderPreset.all
|
|
.map((p) => DropdownMenuItem(value: p.wire, child: Text(p.label)))
|
|
.toList(),
|
|
onChanged: (v) {
|
|
if (v == null) return;
|
|
onChanged(_ProviderPreset.byWire(v));
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PrivacyModeChips extends StatelessWidget {
|
|
final String value;
|
|
final ValueChanged<String> onChanged;
|
|
|
|
const _PrivacyModeChips({required this.value, required this.onChanged});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
final modes = <(String, String, String)>[
|
|
('off', l.systemAiPrivacyOffLabel, l.systemAiPrivacyOffDesc),
|
|
(
|
|
'redacted',
|
|
l.systemAiPrivacyRedactedLabel,
|
|
l.systemAiPrivacyRedactedDesc,
|
|
),
|
|
('full', l.systemAiPrivacyFullLabel, l.systemAiPrivacyFullDesc),
|
|
];
|
|
// Material 3 deprecated per-Radio `groupValue` / `onChanged`
|
|
// after Flutter 3.32. The modern pattern wraps the radios in
|
|
// a RadioGroup<T> ancestor that owns the selection + change
|
|
// callback; the individual Radio widgets only declare their
|
|
// value. Same UX, no deprecation warnings.
|
|
return RadioGroup<String>(
|
|
groupValue: value,
|
|
onChanged: (v) {
|
|
if (v != null) onChanged(v);
|
|
},
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l.systemAiPrivacyHeader,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
letterSpacing: 0.6,
|
|
fontSize: 10,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
for (final (wire, label, desc) in modes)
|
|
InkWell(
|
|
onTap: () => onChanged(wire),
|
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: FaiSpace.sm,
|
|
horizontal: FaiSpace.sm,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Radio<String>(value: wire),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(label, style: theme.textTheme.bodyMedium),
|
|
Text(
|
|
desc,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TestResultPanel extends StatelessWidget {
|
|
final AskAiResult result;
|
|
|
|
const _TestResultPanel({required this.result});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
final ok = result.isSuccess;
|
|
final color = ok ? FaiColors.success : theme.colorScheme.error;
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(FaiSpace.md),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
border: Border.all(color: color.withValues(alpha: 0.3)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
ok ? Icons.check_circle_outline : Icons.error_outline,
|
|
size: 14,
|
|
color: color,
|
|
),
|
|
const SizedBox(width: FaiSpace.xs),
|
|
Text(
|
|
ok ? l.systemAiConnectionOk : l.systemAiConnectionFailed,
|
|
style: theme.textTheme.bodyMedium?.copyWith(color: color),
|
|
),
|
|
const Spacer(),
|
|
if (ok && result.latencyMs > 0)
|
|
FaiPill(
|
|
label: '${result.latencyMs} ms',
|
|
tone: FaiPillTone.neutral,
|
|
monospace: true,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
SelectableText(
|
|
ok ? l.systemAiReplyPrefix(result.text) : result.text,
|
|
style: FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface),
|
|
),
|
|
if (!ok && result.fixHint(l).isNotEmpty) ...[
|
|
const SizedBox(height: FaiSpace.xs),
|
|
Text(
|
|
result.fixHint(l),
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ModelPicker extends StatelessWidget {
|
|
final TextEditingController controller;
|
|
final _ProviderPreset preset;
|
|
final List<String>? models;
|
|
final String? modelsError;
|
|
final bool loading;
|
|
final bool pulling;
|
|
|
|
/// Detected host hardware. Used by [_suitabilityFor] to mark
|
|
/// curated models as "recommended" when their `min_hw_tier` is
|
|
/// at or below the operator's tier. Null while the hub call is
|
|
/// in flight or unreachable — chips fall back to the size-only
|
|
/// heuristic.
|
|
final HardwareSnapshot? hw;
|
|
|
|
/// Curated DB indexed by model id. Empty while the hub call is
|
|
/// in flight; chips then fall back to the size-only heuristic.
|
|
final Map<String, CuratedModelInfo> curatedById;
|
|
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.hw,
|
|
required this.curatedById,
|
|
required this.onRefresh,
|
|
required this.onPull,
|
|
});
|
|
|
|
bool get _isOllama => preset.wire == 'ollama';
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.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: l.systemAiModelLabel,
|
|
hintText: preset.modelHint.isEmpty
|
|
? l.systemAiModelHintFallback
|
|
: preset.modelHint,
|
|
helperText: controller.text.trim().isEmpty
|
|
? (_isOllama
|
|
? l.systemAiModelHelperOllama
|
|
: l.systemAiModelHelperBase)
|
|
: 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: Text(l.systemAiRefresh),
|
|
),
|
|
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 ? l.systemAiPulling : l.systemAiPull),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
if (modelsError != null) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
l.systemAiCouldNotListModels(modelsError!),
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.error,
|
|
),
|
|
),
|
|
],
|
|
if (models != null) ...[
|
|
const SizedBox(height: FaiSpace.sm),
|
|
if (models!.isEmpty)
|
|
Text(
|
|
_isOllama ? l.systemAiNoModelsOllama : l.systemAiNoModelsGeneric,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
)
|
|
else ...[
|
|
Wrap(
|
|
spacing: FaiSpace.xs,
|
|
runSpacing: FaiSpace.xs,
|
|
children: [
|
|
for (final id in _sortedBySuitability(
|
|
models!,
|
|
hw: hw,
|
|
curatedById: curatedById,
|
|
))
|
|
_ModelChip(
|
|
id: id,
|
|
suitability: _suitabilityFor(
|
|
id,
|
|
hw: hw,
|
|
curatedById: curatedById,
|
|
),
|
|
curated: curatedById[id.trim().toLowerCase()],
|
|
onTap: () => controller.text = id,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
_SuitabilityLegend(hw: hw),
|
|
],
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Heuristic suitability rating for a model id, used to colour
|
|
/// the chips in the model picker. Pure id-based — no /api/show
|
|
/// round-trip; works for Ollama tags (`gemma3:4b`), OpenAI names
|
|
/// (`gpt-4o-mini`), Anthropic-style (`claude-haiku-4-5`).
|
|
enum _Suitability {
|
|
recommended, // tested with F∆I System AI
|
|
balanced, // 4-8B params or known-good cloud model
|
|
small, // <4B params; quality may be insufficient
|
|
large, // 9-15B; slower but ok
|
|
huge, // >15B; usually too slow on a developer laptop
|
|
unknown, // can't tell from the id
|
|
}
|
|
|
|
extension _SuitabilityCopy on _Suitability {
|
|
String labelFor(BuildContext context) {
|
|
final l = AppLocalizations.of(context)!;
|
|
switch (this) {
|
|
case _Suitability.recommended:
|
|
return l.systemAiSuitabilityRecommended;
|
|
case _Suitability.balanced:
|
|
return l.systemAiSuitabilityBalanced;
|
|
case _Suitability.small:
|
|
return l.systemAiSuitabilitySmall;
|
|
case _Suitability.large:
|
|
return l.systemAiSuitabilityLarge;
|
|
case _Suitability.huge:
|
|
return l.systemAiSuitabilityHuge;
|
|
case _Suitability.unknown:
|
|
return l.systemAiSuitabilityUnknown;
|
|
}
|
|
}
|
|
|
|
Color color(ColorScheme cs) {
|
|
switch (this) {
|
|
case _Suitability.recommended:
|
|
return FaiColors.success;
|
|
case _Suitability.balanced:
|
|
return cs.primary;
|
|
case _Suitability.small:
|
|
return FaiColors.warning;
|
|
case _Suitability.large:
|
|
return FaiColors.warning;
|
|
case _Suitability.huge:
|
|
return cs.error;
|
|
case _Suitability.unknown:
|
|
return cs.outline;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Suitability rating for a model id. Curated DB + detected
|
|
/// hardware win over the size-only heuristic when both are
|
|
/// available; otherwise we fall back to parsing the param count
|
|
/// out of the id.
|
|
///
|
|
/// Lookup order:
|
|
/// 1. Curated entry → use `quality_tier` AND compare
|
|
/// `min_hw_tier` against detected hardware. Only `good` or
|
|
/// `excellent` curated entries that the hardware can actually
|
|
/// run get the green star.
|
|
/// 2. Size-only heuristic — same fallback logic as before.
|
|
_Suitability _suitabilityFor(
|
|
String rawId, {
|
|
HardwareSnapshot? hw,
|
|
Map<String, CuratedModelInfo> curatedById = const {},
|
|
}) {
|
|
final id = rawId.trim().toLowerCase();
|
|
final curated = curatedById[id];
|
|
if (curated != null) {
|
|
final hwOk = hw == null || hw.rank < 0 || curated.minHwRank <= hw.rank;
|
|
if ((curated.qualityTier == 'good' || curated.qualityTier == 'excellent') &&
|
|
hwOk) {
|
|
return _Suitability.recommended;
|
|
}
|
|
if (curated.qualityTier == 'good' || curated.qualityTier == 'excellent') {
|
|
// Curated quality is fine, but hardware is below `min_hw_tier`.
|
|
// Mark as large (orange) to signal "may be slow on this host".
|
|
return _Suitability.large;
|
|
}
|
|
// Curated as `basic` — quality may be insufficient.
|
|
return _Suitability.small;
|
|
}
|
|
// Fallback: try to extract a parameter count from common naming
|
|
// conventions: `gemma3:4b`, `llama3.2-70b-instruct`, `qwen-1.5b`,
|
|
// `:13b`, `-3.2b`, etc. Match a number (with optional decimal)
|
|
// followed by `b`, bordered by a non-letter on both sides.
|
|
final m = RegExp(r'(?<![a-z])(\d+(?:\.\d+)?)\s*b(?![a-z])').firstMatch(id);
|
|
if (m != null) {
|
|
final n = double.tryParse(m.group(1)!);
|
|
if (n != null) {
|
|
if (n < 3) return _Suitability.small;
|
|
if (n <= 8) return _Suitability.balanced;
|
|
if (n <= 15) return _Suitability.large;
|
|
return _Suitability.huge;
|
|
}
|
|
}
|
|
// OpenAI / Anthropic without size in the name — known-family
|
|
// heuristics.
|
|
if (id.contains('mini') || id.contains('haiku') || id.contains('flash')) {
|
|
return _Suitability.balanced;
|
|
}
|
|
if (id.contains('opus') || id.contains('o1') || id.contains('large')) {
|
|
return _Suitability.large;
|
|
}
|
|
return _Suitability.unknown;
|
|
}
|
|
|
|
/// Sort by suitability tier first (recommended → balanced →
|
|
/// large → small → huge → unknown), then alphabetically — so
|
|
/// the operator's eye lands on the green chips immediately.
|
|
List<String> _sortedBySuitability(
|
|
List<String> ids, {
|
|
HardwareSnapshot? hw,
|
|
Map<String, CuratedModelInfo> curatedById = const {},
|
|
}) {
|
|
int rank(_Suitability s) {
|
|
switch (s) {
|
|
case _Suitability.recommended:
|
|
return 0;
|
|
case _Suitability.balanced:
|
|
return 1;
|
|
case _Suitability.large:
|
|
return 2;
|
|
case _Suitability.small:
|
|
return 3;
|
|
case _Suitability.huge:
|
|
return 4;
|
|
case _Suitability.unknown:
|
|
return 5;
|
|
}
|
|
}
|
|
|
|
final out = [...ids];
|
|
out.sort((a, b) {
|
|
final ra = rank(_suitabilityFor(a, hw: hw, curatedById: curatedById));
|
|
final rb = rank(_suitabilityFor(b, hw: hw, curatedById: curatedById));
|
|
if (ra != rb) return ra.compareTo(rb);
|
|
return a.compareTo(b);
|
|
});
|
|
return out;
|
|
}
|
|
|
|
class _ModelChip extends StatelessWidget {
|
|
final String id;
|
|
final _Suitability suitability;
|
|
|
|
/// Curated entry for this id, when one exists. Drives the
|
|
/// tooltip — operators get the editorial note ("Solid 4B
|
|
/// all-rounder…") rather than just the suitability label.
|
|
final CuratedModelInfo? curated;
|
|
final VoidCallback onTap;
|
|
|
|
const _ModelChip({
|
|
required this.id,
|
|
required this.suitability,
|
|
required this.curated,
|
|
required this.onTap,
|
|
});
|
|
|
|
String _tooltipFor(BuildContext context) {
|
|
final lines = <String>[suitability.labelFor(context)];
|
|
final c = curated;
|
|
if (c != null) {
|
|
lines.add(
|
|
'${c.qualityTier} · ${c.paramsB > 0 ? "${_fmtParams(c.paramsB)} params · " : ""}'
|
|
'${c.contextK}K ctx · min hw: ${c.minHwTier}',
|
|
);
|
|
if (c.notes.isNotEmpty) lines.add(c.notes);
|
|
if (c.license.isNotEmpty) lines.add('license: ${c.license}');
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
static String _fmtParams(double b) {
|
|
if (b == b.roundToDouble()) return '${b.toInt()}B';
|
|
return '${b.toStringAsFixed(1)}B';
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final color = suitability.color(theme.colorScheme);
|
|
return Tooltip(
|
|
message: _tooltipFor(context),
|
|
child: ActionChip(
|
|
avatar: Icon(
|
|
suitability == _Suitability.recommended ? Icons.star : Icons.circle,
|
|
size: suitability == _Suitability.recommended ? 14 : 9,
|
|
color: color,
|
|
),
|
|
label: Text(id, style: FaiTheme.mono(size: 11)),
|
|
side: BorderSide(color: color.withValues(alpha: 0.4)),
|
|
onPressed: onTap,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Compact banner that shows the detected hardware tier plus the
|
|
/// curated DB's `last_reviewed` date, so the operator knows which
|
|
/// "recommended" judgement they are reading.
|
|
class _HardwareBanner extends StatelessWidget {
|
|
final HardwareSnapshot hw;
|
|
final String? lastReviewed;
|
|
|
|
const _HardwareBanner({required this.hw, required this.lastReviewed});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
final lr = (lastReviewed != null && lastReviewed!.isNotEmpty)
|
|
? l.systemAiHwReviewed(lastReviewed!)
|
|
: '';
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.memory,
|
|
size: 13,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(
|
|
'${l.systemAiHwDetected(hw.summary)}$lr',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SuitabilityLegend extends StatelessWidget {
|
|
final HardwareSnapshot? hw;
|
|
const _SuitabilityLegend({required this.hw});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
Widget dot(Color c, String label) => Padding(
|
|
padding: const EdgeInsets.only(right: FaiSpace.md),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.circle, size: 8, color: c),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
label,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
final tierTag = (hw == null || hw!.tier == 'unknown')
|
|
? ''
|
|
: ' (${hw!.tier})';
|
|
return Wrap(
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: FaiSpace.md),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.star, size: 11, color: FaiColors.success),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
l.systemAiLegendRecommended(tierTag),
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
dot(theme.colorScheme.primary, l.systemAiLegendBalanced),
|
|
dot(FaiColors.warning, l.systemAiLegendSmallLarge),
|
|
dot(theme.colorScheme.error, l.systemAiLegendHuge),
|
|
dot(theme.colorScheme.outline, l.systemAiLegendUnknown),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Displays the System-AI cache count plus a "Clear cache"
|
|
/// button. Hidden when the cache is empty so the dialog stays
|
|
/// quiet for fresh installs. Lives inline so the operator sees
|
|
/// the cache exists, what it costs, and how to drop it without
|
|
/// reading docs.
|
|
class _CacheStatusRow extends StatelessWidget {
|
|
final int cacheCount;
|
|
final VoidCallback? onClear;
|
|
|
|
const _CacheStatusRow({required this.cacheCount, required this.onClear});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: FaiSpace.sm, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
border: Border.all(color: theme.colorScheme.outlineVariant),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.bolt, size: 14, color: theme.colorScheme.onSurfaceVariant),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(
|
|
l.systemAiCacheRow(cacheCount),
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
TextButton.icon(
|
|
onPressed: onClear,
|
|
icon: const Icon(Icons.delete_outline, size: 14),
|
|
label: Text(l.systemAiCacheClear),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|