feat(studio): in-place System AI editor (v0.12.0)
Replaces the read-only System AI block in Settings with a real
editor. Operator never has to touch ~/.fai/config.yaml again
to change provider, endpoint, model, API-key env-var, or
privacy mode.
UX choices that align with the zero-learning-curve rule:
* Provider dropdown lists Ollama / OpenAI / LM Studio / vLLM
/ Custom by friendly name. Each selection auto-fills the
endpoint and api_key_env defaults — but only when the
field is still empty or matches a different preset's
default, so manual overrides are never clobbered.
* Each provider has a one-line description rendered under
the dropdown ("Ollama → Local `ollama serve`. Models stay
on your machine. No API key needed.") and a model-hint
placeholder ("gemma3:4b · llama3.2:3b · qwen2.5-coder:7b")
that goes away when a model is typed.
* Privacy mode is a vertical radio group with each option's
description in place — no doc-lookup needed.
* "Test connection" sends an `ok`-ping and renders the
same error-kind → fix-hint mapping the audit drill-down
uses.
* "Save" persists + hot-reloads the hub. No daemon restart
required. UI status badge flips to "enabled · <privacy>"
immediately.
`fai_dart_sdk` 0.5.0 carries the underlying `updateSystemAi` /
`testSystemAi` RPCs.
Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
4c4e2bb1eb
commit
15ea403c86
6 changed files with 601 additions and 5 deletions
|
|
@ -135,6 +135,46 @@ class HubService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persist a new System-AI configuration. The hub writes back
|
||||||
|
/// to `~/.fai/config.yaml` and hot-reloads the in-memory copy
|
||||||
|
/// in one round-trip; the caller gets the resulting status
|
||||||
|
/// for an immediate UI refresh.
|
||||||
|
Future<SystemAiStatus> updateSystemAi({
|
||||||
|
required String provider,
|
||||||
|
required String endpoint,
|
||||||
|
required String model,
|
||||||
|
required String apiKeyEnv,
|
||||||
|
required String privacyMode,
|
||||||
|
}) async {
|
||||||
|
final r = await _client.updateSystemAi(
|
||||||
|
provider: provider,
|
||||||
|
endpoint: endpoint,
|
||||||
|
model: model,
|
||||||
|
apiKeyEnv: apiKeyEnv,
|
||||||
|
privacyMode: privacyMode,
|
||||||
|
);
|
||||||
|
return SystemAiStatus(
|
||||||
|
enabled: r.enabled,
|
||||||
|
provider: r.provider,
|
||||||
|
endpoint: r.endpoint,
|
||||||
|
model: r.model,
|
||||||
|
privacyMode: r.privacyMode,
|
||||||
|
apiKeyEnv: r.apiKeyEnv,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
return AskAiResult(
|
||||||
|
errorKind: r.errorKind,
|
||||||
|
text: r.text,
|
||||||
|
latencyMs: r.latencyMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// 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.11.0';
|
const String kStudioVersion = '0.12.0';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import '../data/hub.dart';
|
||||||
import '../theme/theme.dart';
|
import '../theme/theme.dart';
|
||||||
import '../theme/tokens.dart';
|
import '../theme/tokens.dart';
|
||||||
import 'fai_pill.dart';
|
import 'fai_pill.dart';
|
||||||
|
import 'fai_system_ai_editor.dart';
|
||||||
|
|
||||||
class FaiSettingsDialog extends StatefulWidget {
|
class FaiSettingsDialog extends StatefulWidget {
|
||||||
const FaiSettingsDialog({super.key});
|
const FaiSettingsDialog({super.key});
|
||||||
|
|
@ -236,7 +237,18 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
||||||
],
|
],
|
||||||
if (_aiStatus != null) ...[
|
if (_aiStatus != null) ...[
|
||||||
const SizedBox(height: FaiSpace.lg),
|
const SizedBox(height: FaiSpace.lg),
|
||||||
_SystemAiPanel(status: _aiStatus!),
|
_SystemAiPanel(
|
||||||
|
status: _aiStatus!,
|
||||||
|
onEdit: () async {
|
||||||
|
final updated = await FaiSystemAiEditor.show(
|
||||||
|
context,
|
||||||
|
_aiStatus!,
|
||||||
|
);
|
||||||
|
if (updated != null && mounted) {
|
||||||
|
setState(() => _aiStatus = updated);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
@ -346,8 +358,9 @@ class _ChannelRow extends StatelessWidget {
|
||||||
|
|
||||||
class _SystemAiPanel extends StatelessWidget {
|
class _SystemAiPanel extends StatelessWidget {
|
||||||
final SystemAiStatus status;
|
final SystemAiStatus status;
|
||||||
|
final VoidCallback onEdit;
|
||||||
|
|
||||||
const _SystemAiPanel({required this.status});
|
const _SystemAiPanel({required this.status, required this.onEdit});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -379,6 +392,12 @@ class _SystemAiPanel extends StatelessWidget {
|
||||||
: FaiPillTone.neutral,
|
: FaiPillTone.neutral,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
const Spacer(),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: onEdit,
|
||||||
|
icon: const Icon(Icons.edit_outlined, size: 14),
|
||||||
|
label: Text(status.enabled ? 'Edit…' : 'Configure…'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|
@ -392,7 +411,10 @@ class _SystemAiPanel extends StatelessWidget {
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 2),
|
padding: const EdgeInsets.only(top: 2),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Off — explanations on failed events are disabled. Add a `system_llm:` block to ~/.fai/config.yaml and restart the daemon. See docs/architecture/system-ai.md.',
|
'Off — failure explanations on the audit page are disabled. '
|
||||||
|
'Click Configure… to pick a provider (Ollama / OpenAI / LM '
|
||||||
|
'Studio / vLLM / Custom). Operator config is rewritten in place; '
|
||||||
|
'no daemon restart needed.',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
color: theme.colorScheme.onSurfaceVariant,
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
533
lib/widgets/fai_system_ai_editor.dart
Normal file
533
lib/widgets/fai_system_ai_editor.dart
Normal file
|
|
@ -0,0 +1,533 @@
|
||||||
|
// 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/hub.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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
AskAiResult? _testResult;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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> _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;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final r = await HubService.instance.testSystemAi();
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_testing = false;
|
||||||
|
_testResult = r;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_testing = false;
|
||||||
|
_error = e.toString();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('System AI'),
|
||||||
|
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(
|
||||||
|
'The hub-internal LLM the platform itself uses for inline failure '
|
||||||
|
'explanations and operator help. Off by default; configure here. '
|
||||||
|
'See docs/architecture/system-ai.md for what crosses the wire.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: FaiSpace.lg),
|
||||||
|
_ProviderDropdown(
|
||||||
|
value: _preset,
|
||||||
|
onChanged: _onProviderChanged,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_preset.description,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: FaiSpace.md),
|
||||||
|
TextField(
|
||||||
|
controller: _endpoint,
|
||||||
|
style: FaiTheme.mono(size: 12),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Endpoint URL (up to /v1)',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: FaiSpace.md),
|
||||||
|
TextField(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: FaiSpace.md),
|
||||||
|
TextField(
|
||||||
|
controller: _apiKeyEnv,
|
||||||
|
style: FaiTheme.mono(size: 12),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: _preset.wire == 'openai'
|
||||||
|
? 'API key env var (required)'
|
||||||
|
: 'API key env var (optional)',
|
||||||
|
hintText: 'OPENAI_API_KEY',
|
||||||
|
prefixText: '\$',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Studio never reads or stores the key value — only its env-var name. '
|
||||||
|
'The hub reads `\$<name>` at request time.',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: FaiSpace.lg),
|
||||||
|
_PrivacyModeChips(
|
||||||
|
value: _privacyMode,
|
||||||
|
onChanged: (v) => setState(() => _privacyMode = v),
|
||||||
|
),
|
||||||
|
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: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _saving || _testing ? null : _test,
|
||||||
|
icon: const Icon(Icons.play_arrow, size: 16),
|
||||||
|
label: Text(_testing ? 'Testing…' : 'Test connection'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: _saving || _testing ? null : () => _save(),
|
||||||
|
child: _saving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Save'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
return DropdownButtonFormField<String>(
|
||||||
|
initialValue: value.wire,
|
||||||
|
isDense: true,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Provider',
|
||||||
|
border: 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);
|
||||||
|
const modes = <(String, String, String)>[
|
||||||
|
('off', 'Off', 'Feature disabled. No requests leave the hub.'),
|
||||||
|
(
|
||||||
|
'redacted',
|
||||||
|
'Redacted',
|
||||||
|
'Only event_type / error / module names. KRITIS-friendly default.',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'full',
|
||||||
|
'Full',
|
||||||
|
'Includes the audit detail JSON (hash-only — no raw payloads).',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'PRIVACY MODE',
|
||||||
|
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,
|
||||||
|
groupValue: value,
|
||||||
|
onChanged: (v) => onChanged(v ?? 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 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 ? 'Connection ok' : 'Connection failed',
|
||||||
|
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 ? 'Reply: ${result.text}' : result.text,
|
||||||
|
style: FaiTheme.mono(
|
||||||
|
size: 11,
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!ok && result.fixHint.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: FaiSpace.xs),
|
||||||
|
Text(
|
||||||
|
result.fixHint,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,3 +12,4 @@ export 'fai_module_sheet.dart';
|
||||||
export 'fai_pill.dart';
|
export 'fai_pill.dart';
|
||||||
export 'fai_settings_dialog.dart';
|
export 'fai_settings_dialog.dart';
|
||||||
export 'fai_status_dot.dart';
|
export 'fai_status_dot.dart';
|
||||||
|
export 'fai_system_ai_editor.dart';
|
||||||
|
|
|
||||||
|
|
@ -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.11.0
|
version: 0.12.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