Some checks failed
Security / Security check (push) Failing after 1s
The Dart SDK package was renamed package: fai_dart_sdk → fai_client_sdk and the dir + Forgejo repo got a -dart language suffix per the three SDK families convention in fai/platform/docs/architecture/sdks.md. Updates the path dep, dep name, every `package:fai_dart_sdk/...` import, and the one comment that named the SDK. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2019 lines
63 KiB
Dart
2019 lines
63 KiB
Dart
// FaiSettingsDialog — modal for changing the hub endpoint.
|
|
// Reads current values from HubService, persists on save via
|
|
// HubService.reconnect.
|
|
|
|
import 'package:fai_client_sdk/fai_client_sdk.dart';
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../data/hub.dart';
|
|
import '../data/registry_token.dart';
|
|
import '../data/system_actions.dart';
|
|
import '../data/theme_plugin.dart';
|
|
import '../main.dart';
|
|
import '../l10n/app_localizations.dart';
|
|
import '../theme/theme.dart';
|
|
import '../theme/tokens.dart';
|
|
import 'fai_error_box.dart';
|
|
import 'fai_pill.dart';
|
|
import 'fai_system_ai_editor.dart';
|
|
|
|
class FaiSettingsDialog extends StatefulWidget {
|
|
const FaiSettingsDialog({super.key});
|
|
|
|
/// Convenience launcher used from the sidebar gear icon.
|
|
static Future<bool> show(BuildContext context) async {
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (_) => const FaiSettingsDialog(),
|
|
);
|
|
return ok ?? false;
|
|
}
|
|
|
|
@override
|
|
State<FaiSettingsDialog> createState() => _FaiSettingsDialogState();
|
|
}
|
|
|
|
class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|
late final TextEditingController _host;
|
|
late final TextEditingController _port;
|
|
bool _secure = false;
|
|
bool _saving = false;
|
|
String? _error;
|
|
String? _channelToast;
|
|
ChannelStatusSnapshot? _channels;
|
|
SystemAiStatus? _aiStatus;
|
|
List<McpClientInfo>? _mcpClients;
|
|
List<N8nEndpointInfo>? _n8nEndpoints;
|
|
/// Length of the configured registry token, or null when the
|
|
/// `~/.fai/registry-token` file is missing / empty. Reloaded
|
|
/// after save / clear.
|
|
int? _registryTokenChars;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final ep = HubService.instance.currentEndpoint;
|
|
_host = TextEditingController(text: ep.host);
|
|
_port = TextEditingController(text: ep.port.toString());
|
|
_secure = ep.secure;
|
|
_loadChannels();
|
|
_loadAiStatus();
|
|
_loadMcpClients();
|
|
_loadN8nEndpoints();
|
|
_loadRegistryToken();
|
|
}
|
|
|
|
Future<void> _loadRegistryToken() async {
|
|
try {
|
|
final n = await RegistryToken.charCount();
|
|
if (!mounted) return;
|
|
setState(() => _registryTokenChars = n);
|
|
} catch (_) {/* fail-quiet */}
|
|
}
|
|
|
|
Future<void> _saveRegistryToken(String token) async {
|
|
try {
|
|
await RegistryToken.write(token);
|
|
await _loadRegistryToken();
|
|
if (!mounted) return;
|
|
final l = AppLocalizations.of(context)!;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l.registryTokenSavedToast)),
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
final l = AppLocalizations.of(context)!;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l.registryTokenSaveFailedToast(e.toString()))),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _clearRegistryToken() async {
|
|
try {
|
|
await RegistryToken.delete();
|
|
await _loadRegistryToken();
|
|
if (!mounted) return;
|
|
final l = AppLocalizations.of(context)!;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l.registryTokenClearedToast)),
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
final l = AppLocalizations.of(context)!;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l.registryTokenSaveFailedToast(e.toString()))),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _loadN8nEndpoints() async {
|
|
try {
|
|
final list = await HubService.instance.listN8nEndpoints();
|
|
if (!mounted) return;
|
|
setState(() => _n8nEndpoints = list);
|
|
} catch (_) {/* fail-quiet */}
|
|
}
|
|
|
|
Future<void> _addN8nEndpoint(N8nEndpointDraft draft) async {
|
|
try {
|
|
final list = await HubService.instance.addN8nEndpoint(
|
|
name: draft.name,
|
|
baseUrl: draft.baseUrl,
|
|
apiKeyEnv: draft.apiKeyEnv,
|
|
description: draft.description,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() => _n8nEndpoints = list);
|
|
final l = AppLocalizations.of(context)!;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l.addedN8nToast(draft.name))),
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
final l = AppLocalizations.of(context)!;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l.addFailedToast(e.toString()))),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _removeN8nEndpoint(String name) async {
|
|
try {
|
|
final list = await HubService.instance.removeN8nEndpoint(name);
|
|
if (!mounted) return;
|
|
setState(() => _n8nEndpoints = list);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
final l = AppLocalizations.of(context)!;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l.removeFailedToast(e.toString()))),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _refreshN8nEndpoints() async {
|
|
try {
|
|
final list = await HubService.instance.refreshN8nEndpoints();
|
|
if (!mounted) return;
|
|
setState(() => _n8nEndpoints = list);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
final l = AppLocalizations.of(context)!;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l.refreshFailedToast(e.toString()))),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _loadMcpClients() async {
|
|
try {
|
|
final list = await HubService.instance.listMcpClients();
|
|
if (!mounted) return;
|
|
setState(() => _mcpClients = list);
|
|
} catch (_) {
|
|
// Same fail-quiet rule as channels.
|
|
}
|
|
}
|
|
|
|
Future<void> _addMcpClient(McpClientDraft draft) async {
|
|
try {
|
|
final list = await HubService.instance.addMcpClient(
|
|
name: draft.name,
|
|
endpoint: draft.endpoint,
|
|
apiKeyEnv: draft.apiKeyEnv,
|
|
description: draft.description,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() => _mcpClients = list);
|
|
final l = AppLocalizations.of(context)!;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l.addedMcpToast(draft.name))),
|
|
);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
final l = AppLocalizations.of(context)!;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l.addFailedToast(e.toString()))),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _removeMcpClient(String name) async {
|
|
try {
|
|
final list = await HubService.instance.removeMcpClient(name);
|
|
if (!mounted) return;
|
|
setState(() => _mcpClients = list);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
final l = AppLocalizations.of(context)!;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l.removeFailedToast(e.toString()))),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _refreshMcpClients() async {
|
|
try {
|
|
final list = await HubService.instance.refreshMcpClients();
|
|
if (!mounted) return;
|
|
setState(() => _mcpClients = list);
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
final l = AppLocalizations.of(context)!;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l.refreshFailedToast(e.toString()))),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _loadChannels() async {
|
|
try {
|
|
final snap = await HubService.instance.channelStatus();
|
|
if (!mounted) return;
|
|
setState(() => _channels = snap);
|
|
} catch (_) {
|
|
// Silent: channels block stays hidden when the hub is
|
|
// unreachable (the endpoint section is the operator's
|
|
// path back to a working connection).
|
|
}
|
|
}
|
|
|
|
Future<void> _loadAiStatus() async {
|
|
try {
|
|
final s = await HubService.instance.systemAiStatus();
|
|
if (!mounted) return;
|
|
setState(() => _aiStatus = s);
|
|
} catch (_) {
|
|
// Same fail-quiet rule as channels.
|
|
}
|
|
}
|
|
|
|
Future<void> _switchChannel(String name) async {
|
|
setState(() {
|
|
_saving = true;
|
|
_channelToast = null;
|
|
});
|
|
final r = await SystemActions.faiChannelSwitch(name);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_saving = false;
|
|
_channelToast = r.ok
|
|
? 'Switched active channel to "$name". Daemon restarted.\n${r.stdout.trim()}'
|
|
: 'Channel switch failed: ${r.stderr.isEmpty ? r.stdout : r.stderr}';
|
|
});
|
|
if (r.ok) await _loadChannels();
|
|
}
|
|
|
|
Future<void> _runDaemon(
|
|
String label,
|
|
Future<({bool ok, String stdout, String stderr})> Function() action,
|
|
) async {
|
|
setState(() {
|
|
_saving = true;
|
|
_channelToast = null;
|
|
});
|
|
final r = await action();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_saving = false;
|
|
_channelToast = r.ok
|
|
? 'OK · $label\n${r.stdout.trim()}'
|
|
: 'Failed · $label\n${(r.stderr.isEmpty ? r.stdout : r.stderr).trim()}';
|
|
});
|
|
}
|
|
|
|
Future<void> _connectToChannel(ChannelInfo ch) async {
|
|
setState(() {
|
|
_host.text = '127.0.0.1';
|
|
_port.text = ch.port.toString();
|
|
_secure = false;
|
|
});
|
|
await _save();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_host.dispose();
|
|
_port.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _save() async {
|
|
setState(() {
|
|
_saving = true;
|
|
_error = null;
|
|
});
|
|
final port = int.tryParse(_port.text.trim());
|
|
if (port == null || port <= 0 || port > 65535) {
|
|
final l = AppLocalizations.of(context)!;
|
|
setState(() {
|
|
_saving = false;
|
|
_error = l.settingsPortError;
|
|
});
|
|
return;
|
|
}
|
|
final endpoint = HubEndpoint(
|
|
host: _host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim(),
|
|
port: port,
|
|
secure: _secure,
|
|
);
|
|
try {
|
|
await HubService.instance.reconnect(endpoint);
|
|
if (!mounted) return;
|
|
Navigator.pop(context, true);
|
|
} catch (e) {
|
|
setState(() {
|
|
_saving = false;
|
|
_error = e.toString();
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return AlertDialog(
|
|
title: Text(l.settingsTitle),
|
|
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: 640),
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l.settingsHubEndpointHint,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.lg),
|
|
TextField(
|
|
controller: _host,
|
|
decoration: InputDecoration(
|
|
labelText: l.settingsHost,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
autofocus: true,
|
|
),
|
|
const SizedBox(height: FaiSpace.md),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 2,
|
|
child: TextField(
|
|
controller: _port,
|
|
decoration: InputDecoration(
|
|
labelText: l.settingsPort,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
),
|
|
),
|
|
const SizedBox(width: FaiSpace.md),
|
|
Expanded(
|
|
flex: 3,
|
|
child: SwitchListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
dense: true,
|
|
title: Text(l.settingsTls),
|
|
subtitle: Text(
|
|
l.settingsTlsSubtitle,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
value: _secure,
|
|
onChanged: (v) => setState(() => _secure = v),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (_error != null) ...[
|
|
const SizedBox(height: FaiSpace.md),
|
|
Text(
|
|
_error!,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.error,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: FaiSpace.md),
|
|
Container(
|
|
padding: const EdgeInsets.all(FaiSpace.sm),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHigh,
|
|
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.link,
|
|
size: 14,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
Text(
|
|
_previewUrl(),
|
|
style: FaiTheme.mono(
|
|
size: 11,
|
|
color: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (_channels != null) ...[
|
|
const SizedBox(height: FaiSpace.lg),
|
|
Text(
|
|
l.channelsHeader,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
letterSpacing: 0.6,
|
|
fontSize: 10,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
l.channelsBlurb,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.sm),
|
|
for (final ch in _channels!.channels)
|
|
_ChannelRow(
|
|
channel: ch,
|
|
active: ch.name == _channels!.active,
|
|
onConnect: _saving ? null : () => _connectToChannel(ch),
|
|
onSwitch: _saving ? null : () => _switchChannel(ch.name),
|
|
onEnableAutostart: _saving ? null : () => _runDaemon(
|
|
'enable autostart',
|
|
() => SystemActions.faiDaemonEnable(ch.name),
|
|
),
|
|
onDisableAutostart: _saving ? null : () => _runDaemon(
|
|
'disable autostart',
|
|
() => SystemActions.faiDaemonDisable(ch.name),
|
|
),
|
|
),
|
|
if (_channelToast != null) ...[
|
|
const SizedBox(height: FaiSpace.sm),
|
|
FaiErrorBox(text: _channelToast!, maxHeight: 200),
|
|
],
|
|
],
|
|
if (_aiStatus != null) ...[
|
|
const SizedBox(height: FaiSpace.lg),
|
|
_SystemAiPanel(
|
|
status: _aiStatus!,
|
|
onEdit: () async {
|
|
final updated = await FaiSystemAiEditor.show(
|
|
context,
|
|
_aiStatus!,
|
|
);
|
|
if (updated != null && mounted) {
|
|
setState(() => _aiStatus = updated);
|
|
}
|
|
},
|
|
),
|
|
],
|
|
if (_mcpClients != null) ...[
|
|
const SizedBox(height: FaiSpace.lg),
|
|
_McpClientsPanel(
|
|
clients: _mcpClients!,
|
|
onAdd: _addMcpClient,
|
|
onRemove: _removeMcpClient,
|
|
onRefresh: _refreshMcpClients,
|
|
),
|
|
],
|
|
if (_n8nEndpoints != null) ...[
|
|
const SizedBox(height: FaiSpace.lg),
|
|
_N8nEndpointsPanel(
|
|
endpoints: _n8nEndpoints!,
|
|
onAdd: _addN8nEndpoint,
|
|
onRemove: _removeN8nEndpoint,
|
|
onRefresh: _refreshN8nEndpoints,
|
|
),
|
|
],
|
|
const SizedBox(height: FaiSpace.lg),
|
|
_RegistryCredentialsPanel(
|
|
configuredChars: _registryTokenChars,
|
|
onSave: _saveRegistryToken,
|
|
onClear: _clearRegistryToken,
|
|
),
|
|
const SizedBox(height: FaiSpace.lg),
|
|
const _ThemePluginPanel(),
|
|
const SizedBox(height: FaiSpace.lg),
|
|
_MaintenancePanel(
|
|
onResetDone: () async {
|
|
// Daemon just restarted under the same channel +
|
|
// port. Bouncing the gRPC connection picks up the
|
|
// fresh state without the operator having to
|
|
// close+reopen Settings.
|
|
await HubService.instance.reconnect(
|
|
HubService.instance.currentEndpoint,
|
|
);
|
|
if (!mounted) return;
|
|
await _loadChannels();
|
|
await _loadAiStatus();
|
|
await _loadMcpClients();
|
|
await _loadN8nEndpoints();
|
|
await _loadRegistryToken();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: _saving ? null : () => Navigator.pop(context, false),
|
|
child: Text(l.buttonCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: _saving ? null : _save,
|
|
child: _saving
|
|
? const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Text(l.settingsSaveAndConnect),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
String _previewUrl() {
|
|
final scheme = _secure ? 'https' : 'http';
|
|
final host =
|
|
_host.text.trim().isEmpty ? '127.0.0.1' : _host.text.trim();
|
|
final port = _port.text.trim().isEmpty ? '50051' : _port.text.trim();
|
|
return '$scheme://$host:$port';
|
|
}
|
|
}
|
|
|
|
class _ChannelRow extends StatelessWidget {
|
|
final ChannelInfo channel;
|
|
final bool active;
|
|
final VoidCallback? onConnect;
|
|
/// Switches the active channel pointer (`fai channel switch`).
|
|
/// Distinct from [onConnect], which only re-points Studio's
|
|
/// gRPC wire at a different running daemon.
|
|
final VoidCallback? onSwitch;
|
|
final VoidCallback? onEnableAutostart;
|
|
final VoidCallback? onDisableAutostart;
|
|
|
|
const _ChannelRow({
|
|
required this.channel,
|
|
required this.active,
|
|
required this.onConnect,
|
|
required this.onSwitch,
|
|
required this.onEnableAutostart,
|
|
required this.onDisableAutostart,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
channel.running ? Icons.circle : Icons.circle_outlined,
|
|
size: 10,
|
|
color: channel.running
|
|
? FaiColors.success
|
|
: theme.colorScheme.outline,
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
SizedBox(
|
|
width: 88,
|
|
child: Text(
|
|
channel.name,
|
|
style: FaiTheme.mono(
|
|
size: 11,
|
|
weight: active ? FontWeight.w600 : FontWeight.w400,
|
|
color: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 64,
|
|
child: Text(
|
|
':${channel.port}',
|
|
style: FaiTheme.mono(
|
|
size: 11,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
channel.running ? l.channelsRunning : l.channelsStopped,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
if (active)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: FaiSpace.sm),
|
|
child: FaiPill(label: l.channelsActive, tone: FaiPillTone.success),
|
|
),
|
|
PopupMenuButton<String>(
|
|
tooltip: l.channelsActionsTooltip,
|
|
icon: const Icon(Icons.more_vert, size: 18),
|
|
onSelected: (v) {
|
|
switch (v) {
|
|
case 'connect':
|
|
onConnect?.call();
|
|
break;
|
|
case 'switch':
|
|
onSwitch?.call();
|
|
break;
|
|
case 'enable':
|
|
onEnableAutostart?.call();
|
|
break;
|
|
case 'disable':
|
|
onDisableAutostart?.call();
|
|
break;
|
|
}
|
|
},
|
|
itemBuilder: (_) => [
|
|
PopupMenuItem(
|
|
value: 'connect',
|
|
enabled: channel.running && onConnect != null,
|
|
child: _MenuRow(
|
|
icon: Icons.link,
|
|
text: l.channelsConnect,
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
value: 'switch',
|
|
enabled: !active && onSwitch != null,
|
|
child: _MenuRow(
|
|
icon: Icons.swap_horiz,
|
|
text: l.channelsSwitch,
|
|
),
|
|
),
|
|
const PopupMenuDivider(),
|
|
PopupMenuItem(
|
|
value: 'enable',
|
|
enabled: onEnableAutostart != null,
|
|
child: _MenuRow(
|
|
icon: Icons.play_circle_outline,
|
|
text: l.channelsEnableAutostart,
|
|
),
|
|
),
|
|
PopupMenuItem(
|
|
value: 'disable',
|
|
enabled: onDisableAutostart != null,
|
|
child: _MenuRow(
|
|
icon: Icons.pause_circle_outline,
|
|
text: l.channelsDisableAutostart,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MenuRow extends StatelessWidget {
|
|
final IconData icon;
|
|
final String text;
|
|
const _MenuRow({required this.icon, required this.text});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
children: [
|
|
Icon(icon, size: 14),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
Text(text),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SystemAiPanel extends StatelessWidget {
|
|
final SystemAiStatus status;
|
|
final VoidCallback onEdit;
|
|
|
|
const _SystemAiPanel({required this.status, required this.onEdit});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
l.systemAiHeader,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
letterSpacing: 0.6,
|
|
fontSize: 10,
|
|
),
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
FaiPill(
|
|
label: status.enabled ? l.systemAiEnabled : l.systemAiOff,
|
|
tone: status.enabled ? FaiPillTone.success : FaiPillTone.neutral,
|
|
),
|
|
if (status.enabled) ...[
|
|
const SizedBox(width: FaiSpace.xs),
|
|
FaiPill(
|
|
label: status.privacyMode,
|
|
tone: status.privacyMode == 'full'
|
|
? FaiPillTone.warning
|
|
: FaiPillTone.neutral,
|
|
),
|
|
],
|
|
const Spacer(),
|
|
TextButton.icon(
|
|
onPressed: onEdit,
|
|
icon: const Icon(Icons.edit_outlined, size: 14),
|
|
label: Text(status.enabled ? l.systemAiEdit : l.systemAiConfigure),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
if (status.enabled) ...[
|
|
_StatRow(label: 'provider', value: status.provider),
|
|
_StatRow(label: 'endpoint', value: status.endpoint, mono: true),
|
|
_StatRow(label: 'model', value: status.model, mono: true),
|
|
if (status.apiKeyEnv.isNotEmpty)
|
|
_StatRow(label: 'api_key_env', value: '\$${status.apiKeyEnv}', mono: true),
|
|
] else ...[
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 2),
|
|
child: Text(
|
|
l.systemAiOffBlurb,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatRow extends StatelessWidget {
|
|
final String label;
|
|
final String value;
|
|
final bool mono;
|
|
|
|
const _StatRow({
|
|
required this.label,
|
|
required this.value,
|
|
this.mono = false,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 1),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 96,
|
|
child: Text(
|
|
label,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
value.isEmpty ? '—' : value,
|
|
style: mono
|
|
? FaiTheme.mono(size: 11, color: theme.colorScheme.onSurface)
|
|
: theme.textTheme.bodySmall,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Draft passed back from the add-server dialog. Kept tiny so
|
|
/// the panel can stay stateless.
|
|
class McpClientDraft {
|
|
final String name;
|
|
final String endpoint;
|
|
final String apiKeyEnv;
|
|
final String description;
|
|
const McpClientDraft({
|
|
required this.name,
|
|
required this.endpoint,
|
|
required this.apiKeyEnv,
|
|
required this.description,
|
|
});
|
|
}
|
|
|
|
/// MCP-clients panel — shows every configured server with its
|
|
/// last-discovery health, plus Add / Remove / Refresh actions.
|
|
/// Configure a server here, the store gains synthetic
|
|
/// capabilities for every advertised tool. Mirrors `fai mcp …`
|
|
/// on the CLI.
|
|
class _McpClientsPanel extends StatelessWidget {
|
|
final List<McpClientInfo> clients;
|
|
final ValueChanged<McpClientDraft> onAdd;
|
|
final ValueChanged<String> onRemove;
|
|
final VoidCallback onRefresh;
|
|
|
|
const _McpClientsPanel({
|
|
required this.clients,
|
|
required this.onAdd,
|
|
required this.onRemove,
|
|
required this.onRefresh,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
l.mcpHeader,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
letterSpacing: 0.6,
|
|
fontSize: 10,
|
|
),
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
FaiPill(
|
|
label: l.mcpServersCount(clients.length),
|
|
tone: FaiPillTone.neutral,
|
|
),
|
|
const Spacer(),
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh, size: 16),
|
|
tooltip: l.mcpRediscoverTooltip,
|
|
visualDensity: VisualDensity.compact,
|
|
onPressed: onRefresh,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.add, size: 16),
|
|
tooltip: l.mcpAddTooltip,
|
|
visualDensity: VisualDensity.compact,
|
|
onPressed: () async {
|
|
final draft = await _AddMcpClientDialog.show(context);
|
|
if (draft != null) onAdd(draft);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
l.mcpHint,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.sm),
|
|
if (clients.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm),
|
|
child: Text(
|
|
l.mcpEmpty,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
)
|
|
else
|
|
for (final c in clients)
|
|
_McpClientRow(
|
|
client: c,
|
|
onRemove: () => onRemove(c.name),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _McpClientRow extends StatelessWidget {
|
|
final McpClientInfo client;
|
|
final VoidCallback onRemove;
|
|
const _McpClientRow({required this.client, required this.onRemove});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
final ok = client.healthy;
|
|
final dotColor = client.errorKind.isEmpty
|
|
? (ok ? FaiColors.success : FaiColors.muted)
|
|
: theme.colorScheme.error;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 6),
|
|
child: Icon(Icons.circle, size: 8, color: dotColor),
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
client.name,
|
|
style: FaiTheme.mono(
|
|
size: 12,
|
|
weight: FontWeight.w600,
|
|
color: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
if (client.toolCount > 0)
|
|
FaiPill(
|
|
label: l.mcpToolsCount(client.toolCount),
|
|
tone: FaiPillTone.success,
|
|
)
|
|
else if (client.errorKind.isNotEmpty)
|
|
FaiPill(
|
|
label: client.errorKind,
|
|
tone: FaiPillTone.danger,
|
|
),
|
|
],
|
|
),
|
|
Text(
|
|
client.endpoint,
|
|
style: FaiTheme.mono(
|
|
size: 10,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
if (client.message.isNotEmpty)
|
|
Text(
|
|
client.message,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: client.errorKind.isEmpty
|
|
? theme.colorScheme.onSurfaceVariant
|
|
: theme.colorScheme.error,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.delete_outline, size: 14),
|
|
tooltip: l.mcpRemoveTooltip,
|
|
visualDensity: VisualDensity.compact,
|
|
onPressed: onRemove,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AddMcpClientDialog extends StatefulWidget {
|
|
const _AddMcpClientDialog();
|
|
|
|
static Future<McpClientDraft?> show(BuildContext context) {
|
|
return showDialog<McpClientDraft>(
|
|
context: context,
|
|
builder: (_) => const _AddMcpClientDialog(),
|
|
);
|
|
}
|
|
|
|
@override
|
|
State<_AddMcpClientDialog> createState() => _AddMcpClientDialogState();
|
|
}
|
|
|
|
class _AddMcpClientDialogState extends State<_AddMcpClientDialog> {
|
|
final _name = TextEditingController();
|
|
final _endpoint = TextEditingController();
|
|
final _apiKey = TextEditingController();
|
|
final _desc = TextEditingController();
|
|
|
|
/// Pick a suggestion → fill the form. Operators still hit
|
|
/// "Add + discover" so nothing happens unprompted.
|
|
void _applySuggestion(_McpSuggestion s) {
|
|
final l = AppLocalizations.of(context)!;
|
|
setState(() {
|
|
_name.text = s.name;
|
|
_endpoint.text = s.endpoint;
|
|
_apiKey.text = s.apiKeyEnv;
|
|
_desc.text = s.resolveDescription(l);
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_name.dispose();
|
|
_endpoint.dispose();
|
|
_apiKey.dispose();
|
|
_desc.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return AlertDialog(
|
|
title: Text(l.addMcpTitle),
|
|
content: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 520),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l.addMcpIntro,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.md),
|
|
// Curated suggestions from the official Anthropic
|
|
// MCP servers + a few community staples. Click a
|
|
// chip to pre-fill the form. The operator still
|
|
// confirms via "Add + discover" — no unprompted
|
|
// subprocess spawn.
|
|
Text(
|
|
l.addMcpSuggestionsLabel,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
letterSpacing: 0.5,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Wrap(
|
|
spacing: FaiSpace.xs,
|
|
runSpacing: FaiSpace.xs,
|
|
children: [
|
|
for (final s in _kMcpSuggestions)
|
|
ActionChip(
|
|
avatar: Icon(s.icon, size: 14),
|
|
label: Text(s.name),
|
|
onPressed: () => _applySuggestion(s),
|
|
),
|
|
],
|
|
),
|
|
const Divider(height: FaiSpace.xl),
|
|
TextField(
|
|
controller: _name,
|
|
autofocus: true,
|
|
decoration: InputDecoration(
|
|
labelText: l.addMcpNameLabel,
|
|
hintText: l.addMcpNameHint,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.sm),
|
|
TextField(
|
|
controller: _endpoint,
|
|
decoration: InputDecoration(
|
|
labelText: l.addMcpEndpointLabel,
|
|
hintText: l.addMcpEndpointHint,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.sm),
|
|
TextField(
|
|
controller: _apiKey,
|
|
decoration: InputDecoration(
|
|
labelText: l.addMcpApiKeyLabel,
|
|
hintText: l.addMcpApiKeyHint,
|
|
prefixText: '\$',
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.sm),
|
|
TextField(
|
|
controller: _desc,
|
|
decoration: InputDecoration(
|
|
labelText: l.addMcpNotesLabel,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, null),
|
|
child: Text(l.buttonCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
if (_name.text.trim().isEmpty || _endpoint.text.trim().isEmpty) {
|
|
return;
|
|
}
|
|
Navigator.pop(
|
|
context,
|
|
McpClientDraft(
|
|
name: _name.text.trim(),
|
|
endpoint: _endpoint.text.trim(),
|
|
apiKeyEnv: _apiKey.text.trim(),
|
|
description: _desc.text.trim(),
|
|
),
|
|
);
|
|
},
|
|
child: Text(l.addMcpAddButton),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class N8nEndpointDraft {
|
|
final String name;
|
|
final String baseUrl;
|
|
final String apiKeyEnv;
|
|
final String description;
|
|
const N8nEndpointDraft({
|
|
required this.name,
|
|
required this.baseUrl,
|
|
required this.apiKeyEnv,
|
|
required this.description,
|
|
});
|
|
}
|
|
|
|
/// n8n-endpoints panel — sister of `_McpClientsPanel`. Shows
|
|
/// every configured n8n install with last-discovery health
|
|
/// (workflow count or error_kind), plus Add / Remove / Refresh.
|
|
class _N8nEndpointsPanel extends StatelessWidget {
|
|
final List<N8nEndpointInfo> endpoints;
|
|
final ValueChanged<N8nEndpointDraft> onAdd;
|
|
final ValueChanged<String> onRemove;
|
|
final VoidCallback onRefresh;
|
|
|
|
const _N8nEndpointsPanel({
|
|
required this.endpoints,
|
|
required this.onAdd,
|
|
required this.onRemove,
|
|
required this.onRefresh,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
l.n8nHeader,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
letterSpacing: 0.6,
|
|
fontSize: 10,
|
|
),
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
FaiPill(
|
|
label: l.n8nEndpointsCount(endpoints.length),
|
|
tone: FaiPillTone.neutral,
|
|
),
|
|
const Spacer(),
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh, size: 16),
|
|
tooltip: l.n8nRediscoverTooltip,
|
|
visualDensity: VisualDensity.compact,
|
|
onPressed: onRefresh,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.add, size: 16),
|
|
tooltip: l.n8nAddTooltip,
|
|
visualDensity: VisualDensity.compact,
|
|
onPressed: () async {
|
|
final draft = await _AddN8nEndpointDialog.show(context);
|
|
if (draft != null) onAdd(draft);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
l.n8nHint,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.sm),
|
|
if (endpoints.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm),
|
|
child: Text(
|
|
l.n8nEmpty,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
)
|
|
else
|
|
for (final e in endpoints)
|
|
_N8nEndpointRow(
|
|
endpoint: e,
|
|
onRemove: () => onRemove(e.name),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _N8nEndpointRow extends StatelessWidget {
|
|
final N8nEndpointInfo endpoint;
|
|
final VoidCallback onRemove;
|
|
const _N8nEndpointRow({required this.endpoint, required this.onRemove});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
final ok = endpoint.healthy;
|
|
final dotColor = endpoint.errorKind.isEmpty
|
|
? (ok ? FaiColors.success : FaiColors.muted)
|
|
: theme.colorScheme.error;
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 6),
|
|
child: Icon(Icons.circle, size: 8, color: dotColor),
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
endpoint.name,
|
|
style: FaiTheme.mono(
|
|
size: 12,
|
|
weight: FontWeight.w600,
|
|
color: theme.colorScheme.onSurface,
|
|
),
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
if (endpoint.workflowCount > 0)
|
|
FaiPill(
|
|
label: l.n8nWorkflowsCount(endpoint.workflowCount),
|
|
tone: FaiPillTone.success,
|
|
)
|
|
else if (endpoint.errorKind.isNotEmpty)
|
|
FaiPill(
|
|
label: endpoint.errorKind,
|
|
tone: FaiPillTone.danger,
|
|
),
|
|
],
|
|
),
|
|
Text(
|
|
endpoint.baseUrl,
|
|
style: FaiTheme.mono(
|
|
size: 10,
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
if (endpoint.message.isNotEmpty)
|
|
Text(
|
|
endpoint.message,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: endpoint.errorKind.isEmpty
|
|
? theme.colorScheme.onSurfaceVariant
|
|
: theme.colorScheme.error,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.delete_outline, size: 14),
|
|
tooltip: l.n8nRemoveTooltip,
|
|
visualDensity: VisualDensity.compact,
|
|
onPressed: onRemove,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AddN8nEndpointDialog extends StatefulWidget {
|
|
const _AddN8nEndpointDialog();
|
|
|
|
static Future<N8nEndpointDraft?> show(BuildContext context) {
|
|
return showDialog<N8nEndpointDraft>(
|
|
context: context,
|
|
builder: (_) => const _AddN8nEndpointDialog(),
|
|
);
|
|
}
|
|
|
|
@override
|
|
State<_AddN8nEndpointDialog> createState() => _AddN8nEndpointDialogState();
|
|
}
|
|
|
|
class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
|
|
final _name = TextEditingController();
|
|
final _baseUrl = TextEditingController();
|
|
final _apiKey = TextEditingController();
|
|
final _desc = TextEditingController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_name.dispose();
|
|
_baseUrl.dispose();
|
|
_apiKey.dispose();
|
|
_desc.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return AlertDialog(
|
|
title: Text(l.addN8nTitle),
|
|
content: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 480),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l.addN8nIntro,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.md),
|
|
TextField(
|
|
controller: _name,
|
|
autofocus: true,
|
|
decoration: InputDecoration(
|
|
labelText: l.addN8nNameLabel,
|
|
hintText: l.addN8nNameHint,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.sm),
|
|
TextField(
|
|
controller: _baseUrl,
|
|
decoration: InputDecoration(
|
|
labelText: l.addN8nBaseUrlLabel,
|
|
hintText: l.addN8nBaseUrlHint,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.sm),
|
|
TextField(
|
|
controller: _apiKey,
|
|
decoration: InputDecoration(
|
|
labelText: l.addN8nApiKeyLabel,
|
|
hintText: l.addN8nApiKeyHint,
|
|
prefixText: '\$',
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.sm),
|
|
TextField(
|
|
controller: _desc,
|
|
decoration: InputDecoration(
|
|
labelText: l.addN8nNotesLabel,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, null),
|
|
child: Text(l.buttonCancel),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
if (_name.text.trim().isEmpty || _baseUrl.text.trim().isEmpty) return;
|
|
Navigator.pop(
|
|
context,
|
|
N8nEndpointDraft(
|
|
name: _name.text.trim(),
|
|
baseUrl: _baseUrl.text.trim(),
|
|
apiKeyEnv: _apiKey.text.trim(),
|
|
description: _desc.text.trim(),
|
|
),
|
|
);
|
|
},
|
|
child: Text(l.addN8nAddButton),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// One curated MCP-server suggestion — the operator clicks
|
|
/// the chip to pre-fill the add-dialog. Strictly informational
|
|
/// metadata; nothing is auto-spawned. Trust model:
|
|
/// referencing public projects with their canonical install
|
|
/// command is the same legal / security shape as a package
|
|
/// Theme-plugin picker. Lists installed `studio.theme.*`
|
|
/// capabilities (via list_capabilities) and lets the operator
|
|
/// pick one or fall back to Studio's built-in palette. The
|
|
/// choice is persisted in SharedPreferences and applied at
|
|
/// the MaterialApp level via `StudioApp.of(context).setThemePlugin`.
|
|
///
|
|
/// When no theme plugin is installed the panel renders a
|
|
/// short hint pointing at the Store rather than a useless
|
|
/// empty dropdown.
|
|
class _ThemePluginPanel extends StatefulWidget {
|
|
const _ThemePluginPanel();
|
|
|
|
@override
|
|
State<_ThemePluginPanel> createState() => _ThemePluginPanelState();
|
|
}
|
|
|
|
class _ThemePluginPanelState extends State<_ThemePluginPanel> {
|
|
Future<List<String>>? _capsFuture;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_capsFuture = listThemePluginCapabilities();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
final app = StudioApp.of(context);
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l.themePluginHeader,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
letterSpacing: 0.6,
|
|
fontSize: 10,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
FutureBuilder<List<String>>(
|
|
future: _capsFuture,
|
|
builder: (context, snap) {
|
|
if (snap.connectionState != ConnectionState.done) {
|
|
return const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: FaiSpace.sm),
|
|
child: SizedBox(
|
|
width: 14,
|
|
height: 14,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
);
|
|
}
|
|
final caps = snap.data ?? const <String>[];
|
|
if (caps.isEmpty) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Text(
|
|
l.themePluginEmpty,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
// Dropdown shows "Built-in" + every installed
|
|
// studio.theme.* cap. Selection writes through
|
|
// StudioApp's notifier so the MaterialApp rebuilds
|
|
// with the new ColorScheme without a restart.
|
|
final active = app?.themePluginNotifier.value;
|
|
final items = <DropdownMenuItem<String?>>[
|
|
DropdownMenuItem<String?>(
|
|
value: null,
|
|
child: Text(l.themePluginNone),
|
|
),
|
|
for (final cap in caps)
|
|
DropdownMenuItem<String?>(
|
|
value: cap,
|
|
child: Text(cap, style: FaiTheme.mono(size: 12)),
|
|
),
|
|
];
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
DropdownButton<String?>(
|
|
value: active,
|
|
items: items,
|
|
isDense: true,
|
|
onChanged: app == null
|
|
? null
|
|
: (v) {
|
|
app.setThemePlugin(v);
|
|
setState(() {});
|
|
},
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
l.themePluginHint,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Hub maintenance panel — currently one action: reset operator
|
|
/// state via `fai reset --yes`. Lives at the bottom of Settings
|
|
/// so the everyday config controls aren't crowded by a destructive
|
|
/// button. The reset itself is gated by an interactive confirm
|
|
/// dialog before the CLI is invoked.
|
|
class _MaintenancePanel extends StatefulWidget {
|
|
/// Called after a successful reset so the parent Settings dialog
|
|
/// can bounce its gRPC channel and reload its panels.
|
|
final Future<void> Function() onResetDone;
|
|
|
|
const _MaintenancePanel({required this.onResetDone});
|
|
|
|
@override
|
|
State<_MaintenancePanel> createState() => _MaintenancePanelState();
|
|
}
|
|
|
|
class _MaintenancePanelState extends State<_MaintenancePanel> {
|
|
bool _keepModules = false;
|
|
bool _keepData = false;
|
|
bool _resetting = false;
|
|
String? _resultText;
|
|
bool _resultOk = false;
|
|
|
|
Future<void> _confirmAndReset() async {
|
|
final l = AppLocalizations.of(context)!;
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(l.maintenanceResetConfirmTitle),
|
|
content: Text(l.maintenanceResetConfirmBody),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx, false),
|
|
child: Text(l.buttonCancel),
|
|
),
|
|
FilledButton(
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: Theme.of(ctx).colorScheme.error,
|
|
),
|
|
onPressed: () => Navigator.pop(ctx, true),
|
|
child: Text(l.maintenanceResetConfirmButton),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed != true || !mounted) return;
|
|
setState(() {
|
|
_resetting = true;
|
|
_resultText = null;
|
|
});
|
|
final r = await SystemActions.faiReset(
|
|
keepModules: _keepModules,
|
|
keepData: _keepData,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_resetting = false;
|
|
_resultOk = r.ok;
|
|
_resultText = r.ok
|
|
? r.stdout.trim()
|
|
: (r.stderr.isEmpty ? r.stdout : r.stderr).trim();
|
|
});
|
|
if (r.ok) await widget.onResetDone();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l.maintenanceHeader,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
letterSpacing: 0.6,
|
|
fontSize: 10,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
l.maintenanceResetBlurb,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.sm),
|
|
CheckboxListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
dense: true,
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
value: _keepModules,
|
|
onChanged: _resetting
|
|
? null
|
|
: (v) => setState(() => _keepModules = v ?? false),
|
|
title: Text(l.maintenanceKeepModulesTitle),
|
|
subtitle: Text(
|
|
l.maintenanceKeepModulesSubtitle,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
CheckboxListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
dense: true,
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
value: _keepData,
|
|
onChanged: _resetting
|
|
? null
|
|
: (v) => setState(() => _keepData = v ?? false),
|
|
title: Text(l.maintenanceKeepDataTitle),
|
|
subtitle: Text(
|
|
l.maintenanceKeepDataSubtitle,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.sm),
|
|
Row(
|
|
children: [
|
|
FilledButton.icon(
|
|
icon: _resetting
|
|
? const SizedBox(
|
|
width: 14,
|
|
height: 14,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.restart_alt, size: 16),
|
|
label: Text(
|
|
_resetting
|
|
? l.maintenanceResetInProgress
|
|
: l.maintenanceResetButton,
|
|
),
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: theme.colorScheme.errorContainer,
|
|
foregroundColor: theme.colorScheme.onErrorContainer,
|
|
),
|
|
onPressed: _resetting ? null : _confirmAndReset,
|
|
),
|
|
],
|
|
),
|
|
if (_resultText != null) ...[
|
|
const SizedBox(height: FaiSpace.sm),
|
|
FaiErrorBox(
|
|
text: _resultText!,
|
|
isError: !_resultOk,
|
|
maxHeight: 240,
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Credentials panel for the operator's registry auth token.
|
|
/// The hub reads the same token via `~/.fai/registry-token`
|
|
/// (or the `FAI_REGISTRY_TOKEN` env var, which still wins)
|
|
/// when downloading `.fai` bundles from a registry behind a
|
|
/// signin wall — Forgejo with REQUIRE_SIGNIN_VIEW=true,
|
|
/// GitHub-private releases, etc.
|
|
///
|
|
/// The token itself is never round-tripped back into Studio
|
|
/// after save: `configuredChars` is the trimmed length, used
|
|
/// only as a "Configured (40 chars)" status display so the
|
|
/// operator knows something landed without seeing the secret.
|
|
class _RegistryCredentialsPanel extends StatefulWidget {
|
|
/// Trimmed length of the persisted token, or null when none
|
|
/// is configured.
|
|
final int? configuredChars;
|
|
final ValueChanged<String> onSave;
|
|
final Future<void> Function() onClear;
|
|
|
|
const _RegistryCredentialsPanel({
|
|
required this.configuredChars,
|
|
required this.onSave,
|
|
required this.onClear,
|
|
});
|
|
|
|
@override
|
|
State<_RegistryCredentialsPanel> createState() =>
|
|
_RegistryCredentialsPanelState();
|
|
}
|
|
|
|
class _RegistryCredentialsPanelState
|
|
extends State<_RegistryCredentialsPanel> {
|
|
final _controller = TextEditingController();
|
|
bool _obscured = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller.addListener(() {
|
|
if (mounted) setState(() {});
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final l = AppLocalizations.of(context)!;
|
|
final isConfigured = widget.configuredChars != null;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l.registryCredentialsHeader,
|
|
style: theme.textTheme.labelSmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
letterSpacing: 0.6,
|
|
fontSize: 10,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
l.registryCredentialsBlurb,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: FaiSpace.sm),
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
isConfigured ? Icons.check_circle : Icons.radio_button_unchecked,
|
|
size: 14,
|
|
color: isConfigured
|
|
? theme.colorScheme.primary
|
|
: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
const SizedBox(width: FaiSpace.xs),
|
|
Text(
|
|
isConfigured
|
|
? l.registryTokenStatusConfigured(widget.configuredChars!)
|
|
: l.registryTokenStatusNotSet,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: isConfigured
|
|
? theme.colorScheme.onSurface
|
|
: theme.colorScheme.onSurfaceVariant,
|
|
fontWeight: isConfigured ? FontWeight.w600 : null,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
if (isConfigured)
|
|
TextButton.icon(
|
|
icon: const Icon(Icons.delete_outline, size: 14),
|
|
onPressed: () async {
|
|
await widget.onClear();
|
|
_controller.clear();
|
|
},
|
|
label: Text(l.registryTokenClearButton),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: FaiSpace.sm),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _controller,
|
|
obscureText: _obscured,
|
|
decoration: InputDecoration(
|
|
labelText: l.registryTokenFieldLabel,
|
|
hintText: l.registryTokenFieldHint,
|
|
border: const OutlineInputBorder(),
|
|
isDense: true,
|
|
suffixIcon: IconButton(
|
|
icon: Icon(
|
|
_obscured ? Icons.visibility : Icons.visibility_off,
|
|
size: 16,
|
|
),
|
|
onPressed: () => setState(() => _obscured = !_obscured),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: FaiSpace.sm),
|
|
FilledButton.icon(
|
|
icon: const Icon(Icons.save_outlined, size: 14),
|
|
label: Text(l.registryTokenSaveButton),
|
|
onPressed: _controller.text.trim().isEmpty
|
|
? null
|
|
: () {
|
|
widget.onSave(_controller.text);
|
|
_controller.clear();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
l.registryTokenStorageHint,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
fontSize: 11,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _McpSuggestion {
|
|
final String name;
|
|
final IconData icon;
|
|
final String endpoint;
|
|
final String apiKeyEnv;
|
|
/// Localizable description. Use [resolveDescription] to fetch
|
|
/// the actual text in the active locale — the const list
|
|
/// can't hold a closure that takes [AppLocalizations], and
|
|
/// hardcoding English here is what got us into the
|
|
/// untranslated-helper-text bug.
|
|
String resolveDescription(AppLocalizations l) =>
|
|
_suggestionDescription(l, name);
|
|
const _McpSuggestion({
|
|
required this.name,
|
|
required this.icon,
|
|
required this.endpoint,
|
|
required this.apiKeyEnv,
|
|
});
|
|
}
|
|
|
|
/// Localizable lookup of an MCP-suggestion description. Keyed
|
|
/// on the same suggestion `name` field as the list below. New
|
|
/// suggestions need a matching .arb entry; the default-case
|
|
/// returns an empty string so an unconfigured suggestion fails
|
|
/// silently instead of leaking a key into the UI.
|
|
String _suggestionDescription(AppLocalizations l, String name) {
|
|
switch (name) {
|
|
case 'deepwiki':
|
|
return l.mcpSuggestionDeepwikiDesc;
|
|
case 'semgrep':
|
|
return l.mcpSuggestionSemgrepDesc;
|
|
case 'filesystem':
|
|
return l.mcpSuggestionFilesystemDesc;
|
|
case 'fetch':
|
|
return l.mcpSuggestionFetchDesc;
|
|
case 'github':
|
|
return l.mcpSuggestionGithubDesc;
|
|
case 'puppeteer':
|
|
return l.mcpSuggestionPuppeteerDesc;
|
|
case 'postgres':
|
|
return l.mcpSuggestionPostgresDesc;
|
|
case 'sqlite':
|
|
return l.mcpSuggestionSqliteDesc;
|
|
case 'brave-search':
|
|
return l.mcpSuggestionBraveSearchDesc;
|
|
case 'memory':
|
|
return l.mcpSuggestionMemoryDesc;
|
|
case 'time':
|
|
return l.mcpSuggestionTimeDesc;
|
|
default:
|
|
return '';
|
|
}
|
|
}
|
|
|
|
const _kMcpSuggestions = <_McpSuggestion>[
|
|
// ── HTTPS / streamable-HTTP servers (no Node, no API key) ──
|
|
// Descriptions are resolved via [_McpSuggestion.resolveDescription]
|
|
// against the active locale — see `mcpSuggestion*Desc` keys in
|
|
// app_en.arb / app_de.arb. Adding a suggestion here means
|
|
// adding a matching key in both .arb files plus a case to
|
|
// `_suggestionDescription` above.
|
|
_McpSuggestion(
|
|
name: 'deepwiki',
|
|
icon: Icons.menu_book_outlined,
|
|
endpoint: 'https://mcp.deepwiki.com/mcp',
|
|
apiKeyEnv: '',
|
|
),
|
|
_McpSuggestion(
|
|
name: 'semgrep',
|
|
icon: Icons.security,
|
|
endpoint: 'https://mcp.semgrep.ai/mcp',
|
|
apiKeyEnv: '',
|
|
),
|
|
// ── stdio servers (require Node + npx) ───────────────────
|
|
_McpSuggestion(
|
|
name: 'filesystem',
|
|
icon: Icons.folder_outlined,
|
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-filesystem /tmp',
|
|
apiKeyEnv: '',
|
|
),
|
|
_McpSuggestion(
|
|
name: 'fetch',
|
|
icon: Icons.cloud_download_outlined,
|
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-fetch',
|
|
apiKeyEnv: '',
|
|
),
|
|
_McpSuggestion(
|
|
name: 'github',
|
|
icon: Icons.code,
|
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-github',
|
|
apiKeyEnv: 'GITHUB_PERSONAL_ACCESS_TOKEN',
|
|
),
|
|
_McpSuggestion(
|
|
name: 'puppeteer',
|
|
icon: Icons.web,
|
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-puppeteer',
|
|
apiKeyEnv: '',
|
|
),
|
|
_McpSuggestion(
|
|
name: 'postgres',
|
|
icon: Icons.storage_outlined,
|
|
endpoint:
|
|
'stdio://npx -y @modelcontextprotocol/server-postgres postgresql://user:pw@host/db',
|
|
apiKeyEnv: '',
|
|
),
|
|
_McpSuggestion(
|
|
name: 'sqlite',
|
|
icon: Icons.dataset_outlined,
|
|
endpoint:
|
|
'stdio://npx -y @modelcontextprotocol/server-sqlite --db-path /tmp/db.sqlite',
|
|
apiKeyEnv: '',
|
|
),
|
|
_McpSuggestion(
|
|
name: 'brave-search',
|
|
icon: Icons.search,
|
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-brave-search',
|
|
apiKeyEnv: 'BRAVE_API_KEY',
|
|
),
|
|
_McpSuggestion(
|
|
name: 'memory',
|
|
icon: Icons.psychology_outlined,
|
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-memory',
|
|
apiKeyEnv: '',
|
|
),
|
|
_McpSuggestion(
|
|
name: 'time',
|
|
icon: Icons.schedule,
|
|
endpoint: 'stdio://npx -y @modelcontextprotocol/server-time',
|
|
apiKeyEnv: '',
|
|
),
|
|
];
|