chain-studio/lib/widgets/fai_settings_dialog.dart
flemming-it d2a6ed32e2 feat(studio): n8n-endpoints editor in Settings (v0.23.0)
Mirror of the MCP-clients panel: Add / Remove / Refresh
with last-discovery health (workflow count or error_kind),
sister Add-dialog with name + base_url + optional
api_key_env + notes. HubService gains the four passthroughs
(`listN8nEndpoints`, `refreshN8nEndpoints`,
`addN8nEndpoint`, `removeN8nEndpoint`) and a
`N8nEndpointInfo` data class.

Settings dialog now hosts: hub endpoint, channels,
system AI, MCP clients, n8n endpoints — all behind the
existing 520×640 SingleChildScrollView so the layout
doesn't crowd.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-05-08 01:00:17 +02:00

1345 lines
42 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// FaiSettingsDialog — modal for changing the hub endpoint.
// Reads current values from HubService, persists on save via
// HubService.reconnect.
import 'package:fai_dart_sdk/fai_dart_sdk.dart';
import 'package:flutter/material.dart';
import '../data/hub.dart';
import '../data/system_actions.dart';
import '../theme/theme.dart';
import '../theme/tokens.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;
@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();
}
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);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added n8n endpoint "${draft.name}".')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Add failed: $e')),
);
}
}
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;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Remove failed: $e')),
);
}
}
Future<void> _refreshN8nEndpoints() async {
try {
final list = await HubService.instance.refreshN8nEndpoints();
if (!mounted) return;
setState(() => _n8nEndpoints = list);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Refresh failed: $e')),
);
}
}
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);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Added MCP server "${draft.name}".')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Add failed: $e')),
);
}
}
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;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Remove failed: $e')),
);
}
}
Future<void> _refreshMcpClients() async {
try {
final list = await HubService.instance.refreshMcpClients();
if (!mounted) return;
setState(() => _mcpClients = list);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Refresh failed: $e')),
);
}
}
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) {
setState(() {
_saving = false;
_error = 'port must be 165535';
});
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);
return AlertDialog(
title: const Text('Hub endpoint'),
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(
'Where should Studio connect? Default is the local hub at 127.0.0.1:50051.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.lg),
TextField(
controller: _host,
decoration: const InputDecoration(
labelText: 'Host',
border: OutlineInputBorder(),
isDense: true,
),
autofocus: true,
),
const SizedBox(height: FaiSpace.md),
Row(
children: [
Expanded(
flex: 2,
child: TextField(
controller: _port,
decoration: const InputDecoration(
labelText: 'Port',
border: OutlineInputBorder(),
isDense: true,
),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: FaiSpace.md),
Expanded(
flex: 3,
child: SwitchListTile(
contentPadding: EdgeInsets.zero,
dense: true,
title: const Text('TLS'),
subtitle: Text(
'https/grpc-secure',
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(
'CHANNELS',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(height: 2),
Text(
'Switch hub channel (writes ~/.fai/current-channel and '
'restarts the daemon). Connect just changes Studio\'s wire.',
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),
Container(
width: double.infinity,
padding: const EdgeInsets.all(FaiSpace.sm),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(FaiRadius.sm),
border: Border.all(color: theme.colorScheme.outlineVariant),
),
child: SelectableText(
_channelToast!,
style: FaiTheme.mono(
size: 11,
color: theme.colorScheme.onSurface,
),
),
),
],
],
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,
),
],
],
),
),
),
actions: [
TextButton(
onPressed: _saving ? null : () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: _saving ? null : _save,
child: _saving
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Save & connect'),
),
],
);
}
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);
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 ? 'running' : 'stopped',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
if (active)
const Padding(
padding: EdgeInsets.only(right: FaiSpace.sm),
child: FaiPill(label: 'active', tone: FaiPillTone.success),
),
PopupMenuButton<String>(
tooltip: 'Channel actions',
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: const _MenuRow(
icon: Icons.link,
text: 'Connect Studio to this channel',
),
),
PopupMenuItem(
value: 'switch',
enabled: !active && onSwitch != null,
child: const _MenuRow(
icon: Icons.swap_horiz,
text: 'Make this the active channel',
),
),
const PopupMenuDivider(),
PopupMenuItem(
value: 'enable',
enabled: onEnableAutostart != null,
child: const _MenuRow(
icon: Icons.play_circle_outline,
text: 'Enable autostart at login',
),
),
PopupMenuItem(
value: 'disable',
enabled: onDisableAutostart != null,
child: const _MenuRow(
icon: Icons.pause_circle_outline,
text: 'Disable autostart',
),
),
],
),
],
),
);
}
}
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);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'SYSTEM AI',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(width: FaiSpace.sm),
FaiPill(
label: status.enabled ? 'enabled' : 'off',
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 ? 'Edit…' : 'Configure…'),
),
],
),
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(
'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(
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.
/// This is *the* surface that wires the viral-bridge story to
/// the operator: configure a server here, watch the store
/// fill with synthetic capabilities. 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);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'MCP CLIENTS',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(width: FaiSpace.sm),
FaiPill(
label: '${clients.length} server${clients.length == 1 ? "" : "s"}',
tone: FaiPillTone.neutral,
),
const Spacer(),
IconButton(
icon: const Icon(Icons.refresh, size: 16),
tooltip: 'Re-run discovery',
visualDensity: VisualDensity.compact,
onPressed: onRefresh,
),
IconButton(
icon: const Icon(Icons.add, size: 16),
tooltip: 'Add MCP server',
visualDensity: VisualDensity.compact,
onPressed: () async {
final draft = await _AddMcpClientDialog.show(context);
if (draft != null) onAdd(draft);
},
),
],
),
const SizedBox(height: 2),
Text(
'External MCP servers federate their tools as `mcp.<server>.<tool>` capabilities. '
'Configure once, see them in the Store.',
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(
'No MCP servers configured. Click + to add one.',
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 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: '${client.toolCount} tools',
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: 'Remove server',
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();
@override
void dispose() {
_name.dispose();
_endpoint.dispose();
_apiKey.dispose();
_desc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AlertDialog(
title: const Text('Add MCP server'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tools the server advertises via `tools/list` appear in the Store as '
'`mcp.<name>.<tool>` capabilities.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
TextField(
controller: _name,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Name (no dots)',
hintText: 'filesystem',
border: OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: FaiSpace.sm),
TextField(
controller: _endpoint,
decoration: const InputDecoration(
labelText: 'Endpoint',
hintText: 'http://127.0.0.1:3001/mcp',
border: OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: FaiSpace.sm),
TextField(
controller: _apiKey,
decoration: const InputDecoration(
labelText: 'API key env var (optional)',
hintText: 'MCP_FILESYSTEM_TOKEN',
prefixText: '\$',
border: OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: FaiSpace.sm),
TextField(
controller: _desc,
decoration: const InputDecoration(
labelText: 'Notes (optional)',
border: OutlineInputBorder(),
isDense: true,
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, null),
child: const Text('Cancel'),
),
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: const Text('Add + discover'),
),
],
);
}
}
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);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'N8N ENDPOINTS',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
letterSpacing: 0.6,
fontSize: 10,
),
),
const SizedBox(width: FaiSpace.sm),
FaiPill(
label:
'${endpoints.length} endpoint${endpoints.length == 1 ? "" : "s"}',
tone: FaiPillTone.neutral,
),
const Spacer(),
IconButton(
icon: const Icon(Icons.refresh, size: 16),
tooltip: 'Re-run discovery',
visualDensity: VisualDensity.compact,
onPressed: onRefresh,
),
IconButton(
icon: const Icon(Icons.add, size: 16),
tooltip: 'Add n8n endpoint',
visualDensity: VisualDensity.compact,
onPressed: () async {
final draft = await _AddN8nEndpointDialog.show(context);
if (draft != null) onAdd(draft);
},
),
],
),
const SizedBox(height: 2),
Text(
'Active n8n workflows surface as `n8n.<endpoint>.<slug>` capabilities. '
'Configure once, see them in the Store.',
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(
'No n8n endpoints configured. Click + to add one.',
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 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: '${endpoint.workflowCount} workflows',
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: 'Remove endpoint',
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);
return AlertDialog(
title: const Text('Add n8n endpoint'),
content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Active workflows surface in the Store as '
'`n8n.<name>.<workflow_slug>` capabilities.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: FaiSpace.md),
TextField(
controller: _name,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Name (no dots)',
hintText: 'ops',
border: OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: FaiSpace.sm),
TextField(
controller: _baseUrl,
decoration: const InputDecoration(
labelText: 'Base URL (without /api/v1)',
hintText: 'https://n8n.example.com',
border: OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: FaiSpace.sm),
TextField(
controller: _apiKey,
decoration: const InputDecoration(
labelText: 'API key env var (optional)',
hintText: 'N8N_OPS_KEY',
prefixText: '\$',
border: OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: FaiSpace.sm),
TextField(
controller: _desc,
decoration: const InputDecoration(
labelText: 'Notes (optional)',
border: OutlineInputBorder(),
isDense: true,
),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, null),
child: const Text('Cancel'),
),
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: const Text('Add + discover'),
),
],
);
}
}