// 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 '../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 show(BuildContext context) async { final ok = await showDialog( context: context, builder: (_) => const FaiSettingsDialog(), ); return ok ?? false; } @override State createState() => _FaiSettingsDialogState(); } class _FaiSettingsDialogState extends State { late final TextEditingController _host; late final TextEditingController _port; bool _secure = false; bool _saving = false; String? _error; String? _channelToast; ChannelStatusSnapshot? _channels; SystemAiStatus? _aiStatus; List? _mcpClients; List? _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 _loadN8nEndpoints() async { try { final list = await HubService.instance.listN8nEndpoints(); if (!mounted) return; setState(() => _n8nEndpoints = list); } catch (_) {/* fail-quiet */} } Future _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 _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 _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 _loadMcpClients() async { try { final list = await HubService.instance.listMcpClients(); if (!mounted) return; setState(() => _mcpClients = list); } catch (_) { // Same fail-quiet rule as channels. } } Future _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 _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 _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 _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 _loadAiStatus() async { try { final s = await HubService.instance.systemAiStatus(); if (!mounted) return; setState(() => _aiStatus = s); } catch (_) { // Same fail-quiet rule as channels. } } Future _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 _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 _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 _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, ), ], ], ), ), ), 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( 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 clients; final ValueChanged onAdd; final ValueChanged 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 show(BuildContext context) { return showDialog( 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) { setState(() { _name.text = s.name; _endpoint.text = s.endpoint; _apiKey.text = s.apiKeyEnv; _desc.text = s.description; }); } @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 endpoints; final ValueChanged onAdd; final ValueChanged 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 show(BuildContext context) { return showDialog( 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 /// manager listing a third-party repo. /// /// All entries point at official Anthropic MCP servers from /// `github.com/modelcontextprotocol/servers` plus a few /// community-curated standards. Refresh the list when the /// upstream README changes. class _McpSuggestion { final String name; final IconData icon; final String endpoint; final String apiKeyEnv; final String description; const _McpSuggestion({ required this.name, required this.icon, required this.endpoint, required this.apiKeyEnv, required this.description, }); } const _kMcpSuggestions = <_McpSuggestion>[ _McpSuggestion( name: 'filesystem', icon: Icons.folder_outlined, endpoint: 'stdio://npx -y @modelcontextprotocol/server-filesystem /tmp', apiKeyEnv: '', description: 'Anthropic — read/write files in /tmp. Edit the path before saving.', ), _McpSuggestion( name: 'fetch', icon: Icons.cloud_download_outlined, endpoint: 'stdio://npx -y @modelcontextprotocol/server-fetch', apiKeyEnv: '', description: 'Anthropic — fetch arbitrary HTTP(S) URLs as markdown.', ), _McpSuggestion( name: 'github', icon: Icons.code, endpoint: 'stdio://npx -y @modelcontextprotocol/server-github', apiKeyEnv: 'GITHUB_PERSONAL_ACCESS_TOKEN', description: 'Anthropic — issues, PRs, repo files. Needs a GitHub PAT in the env var.', ), _McpSuggestion( name: 'puppeteer', icon: Icons.web, endpoint: 'stdio://npx -y @modelcontextprotocol/server-puppeteer', apiKeyEnv: '', description: 'Anthropic — headless browser automation for screenshots / scraping.', ), _McpSuggestion( name: 'postgres', icon: Icons.storage_outlined, endpoint: 'stdio://npx -y @modelcontextprotocol/server-postgres postgresql://user:pw@host/db', apiKeyEnv: '', description: 'Anthropic — read-only SQL queries against a Postgres database. Edit the URL.', ), _McpSuggestion( name: 'sqlite', icon: Icons.dataset_outlined, endpoint: 'stdio://npx -y @modelcontextprotocol/server-sqlite --db-path /tmp/db.sqlite', apiKeyEnv: '', description: 'Anthropic — query a local SQLite file. Edit the path.', ), _McpSuggestion( name: 'brave-search', icon: Icons.search, endpoint: 'stdio://npx -y @modelcontextprotocol/server-brave-search', apiKeyEnv: 'BRAVE_API_KEY', description: 'Anthropic — web search via Brave. API key required.', ), _McpSuggestion( name: 'memory', icon: Icons.psychology_outlined, endpoint: 'stdio://npx -y @modelcontextprotocol/server-memory', apiKeyEnv: '', description: 'Anthropic — persistent knowledge graph for an agent\'s memory.', ), _McpSuggestion( name: 'time', icon: Icons.schedule, endpoint: 'stdio://npx -y @modelcontextprotocol/server-time', apiKeyEnv: '', description: 'Anthropic — current time + timezone conversion.', ), ];