feat(studio): MCP suggestions + Approvals/Audit/Settings i18n (v0.26.0)
- Add curated catalogue of 9 official Anthropic MCP servers as click-to-prefill chips in the Add-MCP-server dialog. Operator still confirms via "Add + discover"; nothing is auto-spawned, preserving the regulated-environment trust model. - Localize Approvals page: pending/history empty states, status pills, payload preview, approve/reject toasts, reject dialog, history detail dialog, expires-in pill. - Localize Audit page: filter chips, clear-log dialog and toasts, no-events / hub-unreachable empty states, live-status bar with pluralized event count, hash-chain-verified badge, System AI panel (cached pill, latency, regenerate tooltip, asking state), event-detail dialog actions. - Localize Settings dialog: hub-endpoint section, channel blurb + popup-menu items, System AI panel headers and off-blurb, MCP/N8N panel headers + pluralized counts + refresh/add/remove tooltips, Add-MCP and Add-n8n dialogs (intro, suggestions label, all field labels and hints, save buttons, toasts). Field labels that map directly to JSON keys (event_id, flow, step, module, provider, endpoint, model) stay English by design — they're technical identifiers, not UI copy. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
349619d68e
commit
d25b4c87ae
9 changed files with 2022 additions and 209 deletions
|
|
@ -7,6 +7,7 @@ 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_pill.dart';
|
||||
|
|
@ -71,13 +72,15 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _n8nEndpoints = list);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Added n8n endpoint "${draft.name}".')),
|
||||
SnackBar(content: Text(l.addedN8nToast(draft.name))),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Add failed: $e')),
|
||||
SnackBar(content: Text(l.addFailedToast(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -89,8 +92,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
setState(() => _n8nEndpoints = list);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Remove failed: $e')),
|
||||
SnackBar(content: Text(l.removeFailedToast(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -102,8 +106,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
setState(() => _n8nEndpoints = list);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Refresh failed: $e')),
|
||||
SnackBar(content: Text(l.refreshFailedToast(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -128,13 +133,15 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _mcpClients = list);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Added MCP server "${draft.name}".')),
|
||||
SnackBar(content: Text(l.addedMcpToast(draft.name))),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Add failed: $e')),
|
||||
SnackBar(content: Text(l.addFailedToast(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -146,8 +153,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
setState(() => _mcpClients = list);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Remove failed: $e')),
|
||||
SnackBar(content: Text(l.removeFailedToast(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -159,8 +167,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
setState(() => _mcpClients = list);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
final l = AppLocalizations.of(context)!;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Refresh failed: $e')),
|
||||
SnackBar(content: Text(l.refreshFailedToast(e.toString()))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -244,9 +253,10 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
});
|
||||
final port = int.tryParse(_port.text.trim());
|
||||
if (port == null || port <= 0 || port > 65535) {
|
||||
final l = AppLocalizations.of(context)!;
|
||||
setState(() {
|
||||
_saving = false;
|
||||
_error = 'port must be 1–65535';
|
||||
_error = l.settingsPortError;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -270,8 +280,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return AlertDialog(
|
||||
title: const Text('Hub endpoint'),
|
||||
title: Text(l.settingsTitle),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(FaiRadius.md),
|
||||
),
|
||||
|
|
@ -287,7 +298,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Where should Studio connect? Default is the local hub at 127.0.0.1:50051.',
|
||||
l.settingsHubEndpointHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -295,9 +306,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
const SizedBox(height: FaiSpace.lg),
|
||||
TextField(
|
||||
controller: _host,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Host',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
labelText: l.settingsHost,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
autofocus: true,
|
||||
|
|
@ -309,9 +320,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
flex: 2,
|
||||
child: TextField(
|
||||
controller: _port,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Port',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
labelText: l.settingsPort,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
|
|
@ -323,9 +334,9 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
child: SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
dense: true,
|
||||
title: const Text('TLS'),
|
||||
title: Text(l.settingsTls),
|
||||
subtitle: Text(
|
||||
'https/grpc-secure',
|
||||
l.settingsTlsSubtitle,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -373,7 +384,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
if (_channels != null) ...[
|
||||
const SizedBox(height: FaiSpace.lg),
|
||||
Text(
|
||||
'CHANNELS',
|
||||
l.channelsHeader,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
|
|
@ -382,8 +393,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Switch hub channel (writes ~/.fai/current-channel and '
|
||||
'restarts the daemon). Connect just changes Studio\'s wire.',
|
||||
l.channelsBlurb,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -464,7 +474,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
actions: [
|
||||
TextButton(
|
||||
onPressed: _saving ? null : () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(l.buttonCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
|
|
@ -474,7 +484,7 @@ class _FaiSettingsDialogState extends State<FaiSettingsDialog> {
|
|||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('Save & connect'),
|
||||
: Text(l.settingsSaveAndConnect),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
@ -512,6 +522,7 @@ class _ChannelRow extends StatelessWidget {
|
|||
@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(
|
||||
|
|
@ -547,19 +558,19 @@ class _ChannelRow extends StatelessWidget {
|
|||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
channel.running ? 'running' : 'stopped',
|
||||
channel.running ? l.channelsRunning : l.channelsStopped,
|
||||
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),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: FaiSpace.sm),
|
||||
child: FaiPill(label: l.channelsActive, tone: FaiPillTone.success),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
tooltip: 'Channel actions',
|
||||
tooltip: l.channelsActionsTooltip,
|
||||
icon: const Icon(Icons.more_vert, size: 18),
|
||||
onSelected: (v) {
|
||||
switch (v) {
|
||||
|
|
@ -581,34 +592,34 @@ class _ChannelRow extends StatelessWidget {
|
|||
PopupMenuItem(
|
||||
value: 'connect',
|
||||
enabled: channel.running && onConnect != null,
|
||||
child: const _MenuRow(
|
||||
child: _MenuRow(
|
||||
icon: Icons.link,
|
||||
text: 'Connect Studio to this channel',
|
||||
text: l.channelsConnect,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'switch',
|
||||
enabled: !active && onSwitch != null,
|
||||
child: const _MenuRow(
|
||||
child: _MenuRow(
|
||||
icon: Icons.swap_horiz,
|
||||
text: 'Make this the active channel',
|
||||
text: l.channelsSwitch,
|
||||
),
|
||||
),
|
||||
const PopupMenuDivider(),
|
||||
PopupMenuItem(
|
||||
value: 'enable',
|
||||
enabled: onEnableAutostart != null,
|
||||
child: const _MenuRow(
|
||||
child: _MenuRow(
|
||||
icon: Icons.play_circle_outline,
|
||||
text: 'Enable autostart at login',
|
||||
text: l.channelsEnableAutostart,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'disable',
|
||||
enabled: onDisableAutostart != null,
|
||||
child: const _MenuRow(
|
||||
child: _MenuRow(
|
||||
icon: Icons.pause_circle_outline,
|
||||
text: 'Disable autostart',
|
||||
text: l.channelsDisableAutostart,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -645,13 +656,14 @@ class _SystemAiPanel extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'SYSTEM AI',
|
||||
l.systemAiHeader,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
|
|
@ -660,7 +672,7 @@ class _SystemAiPanel extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
FaiPill(
|
||||
label: status.enabled ? 'enabled' : 'off',
|
||||
label: status.enabled ? l.systemAiEnabled : l.systemAiOff,
|
||||
tone: status.enabled ? FaiPillTone.success : FaiPillTone.neutral,
|
||||
),
|
||||
if (status.enabled) ...[
|
||||
|
|
@ -676,7 +688,7 @@ class _SystemAiPanel extends StatelessWidget {
|
|||
TextButton.icon(
|
||||
onPressed: onEdit,
|
||||
icon: const Icon(Icons.edit_outlined, size: 14),
|
||||
label: Text(status.enabled ? 'Edit…' : 'Configure…'),
|
||||
label: Text(status.enabled ? l.systemAiEdit : l.systemAiConfigure),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -691,10 +703,7 @@ class _SystemAiPanel extends StatelessWidget {
|
|||
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.',
|
||||
l.systemAiOffBlurb,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -765,10 +774,9 @@ class McpClientDraft {
|
|||
|
||||
/// 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.
|
||||
/// 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;
|
||||
|
|
@ -785,13 +793,14 @@ class _McpClientsPanel extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'MCP CLIENTS',
|
||||
l.mcpHeader,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
|
|
@ -800,19 +809,19 @@ class _McpClientsPanel extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
FaiPill(
|
||||
label: '${clients.length} server${clients.length == 1 ? "" : "s"}',
|
||||
label: l.mcpServersCount(clients.length),
|
||||
tone: FaiPillTone.neutral,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
tooltip: 'Re-run discovery',
|
||||
tooltip: l.mcpRediscoverTooltip,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: onRefresh,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
tooltip: 'Add MCP server',
|
||||
tooltip: l.mcpAddTooltip,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () async {
|
||||
final draft = await _AddMcpClientDialog.show(context);
|
||||
|
|
@ -823,8 +832,7 @@ class _McpClientsPanel extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'External MCP servers federate their tools as `mcp.<server>.<tool>` capabilities. '
|
||||
'Configure once, see them in the Store.',
|
||||
l.mcpHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -834,7 +842,7 @@ class _McpClientsPanel extends StatelessWidget {
|
|||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm),
|
||||
child: Text(
|
||||
'No MCP servers configured. Click + to add one.',
|
||||
l.mcpEmpty,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -859,6 +867,7 @@ class _McpClientRow extends StatelessWidget {
|
|||
@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)
|
||||
|
|
@ -890,7 +899,7 @@ class _McpClientRow extends StatelessWidget {
|
|||
const SizedBox(width: FaiSpace.sm),
|
||||
if (client.toolCount > 0)
|
||||
FaiPill(
|
||||
label: '${client.toolCount} tools',
|
||||
label: l.mcpToolsCount(client.toolCount),
|
||||
tone: FaiPillTone.success,
|
||||
)
|
||||
else if (client.errorKind.isNotEmpty)
|
||||
|
|
@ -921,7 +930,7 @@ class _McpClientRow extends StatelessWidget {
|
|||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 14),
|
||||
tooltip: 'Remove server',
|
||||
tooltip: l.mcpRemoveTooltip,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: onRemove,
|
||||
),
|
||||
|
|
@ -951,6 +960,17 @@ class _AddMcpClientDialogState extends State<_AddMcpClientDialog> {
|
|||
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();
|
||||
|
|
@ -963,59 +983,85 @@ class _AddMcpClientDialogState extends State<_AddMcpClientDialog> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return AlertDialog(
|
||||
title: const Text('Add MCP server'),
|
||||
title: Text(l.addMcpTitle),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
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.',
|
||||
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: const InputDecoration(
|
||||
labelText: 'Name (no dots)',
|
||||
hintText: 'filesystem',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
labelText: l.addMcpNameLabel,
|
||||
hintText: l.addMcpNameHint,
|
||||
border: const 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(),
|
||||
decoration: InputDecoration(
|
||||
labelText: l.addMcpEndpointLabel,
|
||||
hintText: l.addMcpEndpointHint,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
TextField(
|
||||
controller: _apiKey,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'API key env var (optional)',
|
||||
hintText: 'MCP_FILESYSTEM_TOKEN',
|
||||
decoration: InputDecoration(
|
||||
labelText: l.addMcpApiKeyLabel,
|
||||
hintText: l.addMcpApiKeyHint,
|
||||
prefixText: '\$',
|
||||
border: OutlineInputBorder(),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
TextField(
|
||||
controller: _desc,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Notes (optional)',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
labelText: l.addMcpNotesLabel,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
|
|
@ -1025,7 +1071,7 @@ class _AddMcpClientDialogState extends State<_AddMcpClientDialog> {
|
|||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, null),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(l.buttonCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
|
|
@ -1042,7 +1088,7 @@ class _AddMcpClientDialogState extends State<_AddMcpClientDialog> {
|
|||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Add + discover'),
|
||||
child: Text(l.addMcpAddButton),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
|
@ -1081,13 +1127,14 @@ class _N8nEndpointsPanel extends StatelessWidget {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'N8N ENDPOINTS',
|
||||
l.n8nHeader,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
letterSpacing: 0.6,
|
||||
|
|
@ -1096,20 +1143,19 @@ class _N8nEndpointsPanel extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(width: FaiSpace.sm),
|
||||
FaiPill(
|
||||
label:
|
||||
'${endpoints.length} endpoint${endpoints.length == 1 ? "" : "s"}',
|
||||
label: l.n8nEndpointsCount(endpoints.length),
|
||||
tone: FaiPillTone.neutral,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, size: 16),
|
||||
tooltip: 'Re-run discovery',
|
||||
tooltip: l.n8nRediscoverTooltip,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: onRefresh,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
tooltip: 'Add n8n endpoint',
|
||||
tooltip: l.n8nAddTooltip,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: () async {
|
||||
final draft = await _AddN8nEndpointDialog.show(context);
|
||||
|
|
@ -1120,8 +1166,7 @@ class _N8nEndpointsPanel extends StatelessWidget {
|
|||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Active n8n workflows surface as `n8n.<endpoint>.<slug>` capabilities. '
|
||||
'Configure once, see them in the Store.',
|
||||
l.n8nHint,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -1131,7 +1176,7 @@ class _N8nEndpointsPanel extends StatelessWidget {
|
|||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.sm),
|
||||
child: Text(
|
||||
'No n8n endpoints configured. Click + to add one.',
|
||||
l.n8nEmpty,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -1156,6 +1201,7 @@ class _N8nEndpointRow extends StatelessWidget {
|
|||
@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)
|
||||
|
|
@ -1187,7 +1233,7 @@ class _N8nEndpointRow extends StatelessWidget {
|
|||
const SizedBox(width: FaiSpace.sm),
|
||||
if (endpoint.workflowCount > 0)
|
||||
FaiPill(
|
||||
label: '${endpoint.workflowCount} workflows',
|
||||
label: l.n8nWorkflowsCount(endpoint.workflowCount),
|
||||
tone: FaiPillTone.success,
|
||||
)
|
||||
else if (endpoint.errorKind.isNotEmpty)
|
||||
|
|
@ -1218,7 +1264,7 @@ class _N8nEndpointRow extends StatelessWidget {
|
|||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 14),
|
||||
tooltip: 'Remove endpoint',
|
||||
tooltip: l.n8nRemoveTooltip,
|
||||
visualDensity: VisualDensity.compact,
|
||||
onPressed: onRemove,
|
||||
),
|
||||
|
|
@ -1260,8 +1306,9 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
|
|||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final l = AppLocalizations.of(context)!;
|
||||
return AlertDialog(
|
||||
title: const Text('Add n8n endpoint'),
|
||||
title: Text(l.addN8nTitle),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 480),
|
||||
child: Column(
|
||||
|
|
@ -1269,8 +1316,7 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
|
|||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Active workflows surface in the Store as '
|
||||
'`n8n.<name>.<workflow_slug>` capabilities.',
|
||||
l.addN8nIntro,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
|
|
@ -1279,40 +1325,40 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
|
|||
TextField(
|
||||
controller: _name,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Name (no dots)',
|
||||
hintText: 'ops',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
labelText: l.addN8nNameLabel,
|
||||
hintText: l.addN8nNameHint,
|
||||
border: const 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(),
|
||||
decoration: InputDecoration(
|
||||
labelText: l.addN8nBaseUrlLabel,
|
||||
hintText: l.addN8nBaseUrlHint,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
TextField(
|
||||
controller: _apiKey,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'API key env var (optional)',
|
||||
hintText: 'N8N_OPS_KEY',
|
||||
decoration: InputDecoration(
|
||||
labelText: l.addN8nApiKeyLabel,
|
||||
hintText: l.addN8nApiKeyHint,
|
||||
prefixText: '\$',
|
||||
border: OutlineInputBorder(),
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: FaiSpace.sm),
|
||||
TextField(
|
||||
controller: _desc,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Notes (optional)',
|
||||
border: OutlineInputBorder(),
|
||||
decoration: InputDecoration(
|
||||
labelText: l.addN8nNotesLabel,
|
||||
border: const OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
),
|
||||
|
|
@ -1322,7 +1368,7 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
|
|||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, null),
|
||||
child: const Text('Cancel'),
|
||||
child: Text(l.buttonCancel),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
|
|
@ -1337,9 +1383,106 @@ class _AddN8nEndpointDialogState extends State<_AddN8nEndpointDialog> {
|
|||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Add + discover'),
|
||||
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.',
|
||||
),
|
||||
];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue