From d25b4c87aee3c017931ccec460532180120d9e32 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Fri, 8 May 2026 02:38:11 +0200 Subject: [PATCH] feat(studio): MCP suggestions + Approvals/Audit/Settings i18n (v0.26.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- lib/l10n/app_de.arb | 144 +++++++ lib/l10n/app_en.arb | 144 +++++++ lib/l10n/app_localizations.dart | 600 +++++++++++++++++++++++++++ lib/l10n/app_localizations_de.dart | 378 +++++++++++++++++ lib/l10n/app_localizations_en.dart | 387 +++++++++++++++++ lib/pages/approvals.dart | 112 ++--- lib/pages/audit.dart | 105 ++--- lib/widgets/fai_settings_dialog.dart | 359 +++++++++++----- pubspec.yaml | 2 +- 9 files changed, 2022 insertions(+), 209 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index 07c6444..65c0627 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -36,6 +36,62 @@ "settingsSaveAndConnect": "Speichern & verbinden", "settingsLanguage": "Sprache", "settingsLanguageHint": "Sprache der App. Wirkt auf alle Seiten; mehrsprachige Store-Einträge folgen ebenfalls.", + "settingsHubEndpointHint": "Wohin soll Studio sich verbinden? Standard ist der lokale Hub auf 127.0.0.1:50051.", + "settingsTlsSubtitle": "https/grpc-secure", + "settingsPortError": "Port muss 1–65535 sein", + "channelsBlurb": "Hub-Kanal wechseln (schreibt ~/.fai/current-channel und startet den Daemon neu). Verbinden ändert nur die Wire von Studio.", + "channelsActionsTooltip": "Kanal-Aktionen", + "systemAiHeader": "SYSTEM-AI", + "systemAiEnabled": "aktiv", + "systemAiOff": "aus", + "systemAiEdit": "Bearbeiten…", + "systemAiConfigure": "Konfigurieren…", + "systemAiOffBlurb": "Aus — Fehlererklärungen auf der Protokoll-Seite sind deaktiviert. Klicke Konfigurieren…, um einen Provider zu wählen (Ollama / OpenAI / LM Studio / vLLM / Eigene). Die Operator-Konfig wird direkt überschrieben; kein Daemon-Neustart nötig.", + "mcpServersCount": "{n} {n, plural, =1{Server} other{Server}}", + "@mcpServersCount": { "placeholders": { "n": { "type": "int" } } }, + "mcpRediscoverTooltip": "Discovery neu ausführen", + "mcpAddTooltip": "MCP-Server hinzufügen", + "mcpRemoveTooltip": "Server entfernen", + "mcpToolsCount": "{n} Tools", + "@mcpToolsCount": { "placeholders": { "n": { "type": "int" } } }, + "n8nEndpointsCount": "{n} {n, plural, =1{Endpunkt} other{Endpunkte}}", + "@n8nEndpointsCount": { "placeholders": { "n": { "type": "int" } } }, + "n8nWorkflowsCount": "{n} Workflows", + "@n8nWorkflowsCount": { "placeholders": { "n": { "type": "int" } } }, + "n8nRediscoverTooltip": "Discovery neu ausführen", + "n8nAddTooltip": "n8n-Endpunkt hinzufügen", + "n8nRemoveTooltip": "Endpunkt entfernen", + "addMcpTitle": "MCP-Server hinzufügen", + "addMcpIntro": "Tools, die der Server via `tools/list` anbietet, erscheinen im Store als `mcp..`-Capabilities.", + "addMcpSuggestionsLabel": "Vorschläge — zum Vorausfüllen klicken", + "addMcpNameLabel": "Name (keine Punkte)", + "addMcpNameHint": "filesystem", + "addMcpEndpointLabel": "Endpunkt", + "addMcpEndpointHint": "http://127.0.0.1:3001/mcp", + "addMcpApiKeyLabel": "API-Key-Umgebungsvariable (optional)", + "addMcpApiKeyHint": "MCP_FILESYSTEM_TOKEN", + "addMcpNotesLabel": "Notizen (optional)", + "addMcpAddButton": "Hinzufügen + Discovery", + "addN8nTitle": "n8n-Endpunkt hinzufügen", + "addN8nIntro": "Aktive Workflows erscheinen im Store als `n8n..`-Capabilities.", + "addN8nNameLabel": "Name (keine Punkte)", + "addN8nNameHint": "ops", + "addN8nBaseUrlLabel": "Basis-URL (ohne /api/v1)", + "addN8nBaseUrlHint": "https://n8n.example.com", + "addN8nApiKeyLabel": "API-Key-Umgebungsvariable (optional)", + "addN8nApiKeyHint": "N8N_OPS_KEY", + "addN8nNotesLabel": "Notizen (optional)", + "addN8nAddButton": "Hinzufügen + Discovery", + "addedMcpToast": "MCP-Server „{name}\" hinzugefügt.", + "@addedMcpToast": { "placeholders": { "name": { "type": "String" } } }, + "addedN8nToast": "n8n-Endpunkt „{name}\" hinzugefügt.", + "@addedN8nToast": { "placeholders": { "name": { "type": "String" } } }, + "addFailedToast": "Hinzufügen fehlgeschlagen: {error}", + "@addFailedToast": { "placeholders": { "error": { "type": "String" } } }, + "removeFailedToast": "Entfernen fehlgeschlagen: {error}", + "@removeFailedToast": { "placeholders": { "error": { "type": "String" } } }, + "refreshFailedToast": "Aktualisieren fehlgeschlagen: {error}", + "@refreshFailedToast": { "placeholders": { "error": { "type": "String" } } }, "channelsHeader": "KANÄLE", "channelsActive": "aktiv", @@ -68,6 +124,56 @@ "auditTitle": "Protokoll", "auditNoEvents": "Noch keine Ereignisse", "auditNoEventsHint": "Starte einen Flow, um den Audit-Stream zu füllen.\nEreignisse erscheinen innerhalb von Sekunden.", + "auditFilterAll": "alle", + "auditFilterFlow": "Flow", + "auditFilterStep": "Step", + "auditFilterModule": "Modul", + "auditClearLogTooltip": "Protokoll löschen (nur local/dev Kanal)", + "auditClearedToast": "{n} Ereignisse auf Kanal „{channel}\" gelöscht. Chain mit Marker neu geseedet.", + "@auditClearedToast": { + "placeholders": { + "n": { "type": "int" }, + "channel": { "type": "String" } + } + }, + "auditClearFailed": "Löschen fehlgeschlagen: {detail}", + "@auditClearFailed": { "placeholders": { "detail": { "type": "String" } } }, + "auditExplain": "Erklären", + "auditReask": "Neu fragen", + "auditAsking": "Frage läuft…", + "auditSystemAi": "SYSTEM-AI", + "auditCachedPill": "gecacht", + "auditOriginalLatency": "ursprünglich {ms} ms", + "@auditOriginalLatency": { "placeholders": { "ms": { "type": "int" } } }, + "auditLatency": "{ms} ms", + "@auditLatency": { "placeholders": { "ms": { "type": "int" } } }, + "auditRegenerateTooltip": "Neu generieren — Cache überspringen, Modell erneut fragen", + "auditLiveStatus": "live · Abfrage alle 2s · {n} {n, plural, =1{Ereignis} other{Ereignisse}}", + "@auditLiveStatus": { "placeholders": { "n": { "type": "int" } } }, + "auditDisconnected": "getrennt · {error}", + "@auditDisconnected": { "placeholders": { "error": { "type": "String" } } }, + "auditHashChainVerified": "Hash-Chain verifiziert", + "auditDetailHeader": "DETAIL", + "auditAskingFull": "Frage an die konfigurierte System-AI läuft…", + "auditFixLabel": "FIX", + "auditCachedTooltipKnown": "Aus dem lokalen Cache geliefert. Ursprünglich erzeugt am {at}{hits}.", + "@auditCachedTooltipKnown": { + "placeholders": { + "at": { "type": "String" }, + "hits": { "type": "String" } + } + }, + "auditCachedTooltipUnknown": "Aus dem lokalen Cache geliefert. Ursprünglicher Zeitpunkt unbekannt{hits}.", + "@auditCachedTooltipUnknown": { "placeholders": { "hits": { "type": "String" } } }, + "auditCachedTooltipHits": " · {n} Treffer", + "@auditCachedTooltipHits": { "placeholders": { "n": { "type": "int" } } }, + "auditConfigureSystemAi": "System-AI unter Einstellungen konfigurieren (Cmd+,)", + "auditClearDialogTitle": "Audit-Log löschen?", + "auditClearDialogBody": "Löscht jedes Audit-Event auf dem aktiven Kanal und seedet einen neuen chain.reset-Marker mit Prüfer und Begründung. Auf beta / production verweigert. Nicht umkehrbar.", + "auditClearReviewerLabel": "Prüfer", + "auditClearReasonLabel": "Begründung (im chain.reset-Marker festgehalten)", + "auditClearReasonHelper": "Erforderlich — Auditoren werden das lesen.", + "auditClearLogButton": "Log löschen", "modulesTitle": "Module", "modulesNoneTitle": "Noch keine Module", @@ -103,6 +209,44 @@ "approvalsTabHistory": "Verlauf", "approvalsInboxZero": "Posteingang leer", "approvalsInboxHint": "Alles erledigt. Neue `system.approval@^0`-Schritte landen automatisch hier.", + "approvalsReloadTooltip": "Aktualisieren", + "approvalsPillPending": "offen", + "approvalsPillApproved": "freigegeben", + "approvalsPillRejected": "abgelehnt", + "approvalsPillExpired": "abgelaufen", + "approvalsPayloadPreview": "PAYLOAD-VORSCHAU", + "approvalsApproveButton": "Freigeben", + "approvalsRejectButton": "Ablehnen", + "approvalsRejectDialogTitle": "Freigabe ablehnen", + "approvalsRejectReasonLabel": "Begründung (im Audit-Log festgehalten)", + "approvalsApprovedToast": "freigegeben · {flow} › {step}", + "@approvalsApprovedToast": { + "placeholders": { + "flow": { "type": "String" }, + "step": { "type": "String" } + } + }, + "approvalsRejectedToast": "abgelehnt · {flow} › {step}", + "@approvalsRejectedToast": { + "placeholders": { + "flow": { "type": "String" }, + "step": { "type": "String" } + } + }, + "approvalsApproveFailed": "Freigabe fehlgeschlagen: {error}", + "@approvalsApproveFailed": { "placeholders": { "error": { "type": "String" } } }, + "approvalsRejectFailed": "Ablehnung fehlgeschlagen: {error}", + "@approvalsRejectFailed": { "placeholders": { "error": { "type": "String" } } }, + "approvalsHistoryEmpty": "Noch kein Verlauf", + "approvalsHistoryEmptyHint": "Entschiedene Freigaben (freigegeben / abgelehnt / abgelaufen) erscheinen hier.\nDas Audit-Log enthält den vollständigen hash-chained Datensatz.", + "approvalsDialogDecided": "entschieden", + "approvalsDialogCreated": "erstellt", + "approvalsDialogReason": "Begründung", + "approvalsDialogPrompt": "PROMPT", + "approvalsExpiresIn": "{minutes}m", + "@approvalsExpiresIn": { "placeholders": { "minutes": { "type": "int" } } }, + "approvalsExpiresInHours": "{hours}h", + "@approvalsExpiresInHours": { "placeholders": { "hours": { "type": "int" } } }, "doctorTitle": "Diagnose", "doctorRecheckTooltip": "Erneut prüfen", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 94c08cc..d913050 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -37,6 +37,62 @@ "settingsSaveAndConnect": "Save & connect", "settingsLanguage": "Language", "settingsLanguageHint": "App language. Affects every page; bilingual store entries also flip via this toggle.", + "settingsHubEndpointHint": "Where should Studio connect? Default is the local hub at 127.0.0.1:50051.", + "settingsTlsSubtitle": "https/grpc-secure", + "settingsPortError": "port must be 1–65535", + "channelsBlurb": "Switch hub channel (writes ~/.fai/current-channel and restarts the daemon). Connect just changes Studio's wire.", + "channelsActionsTooltip": "Channel actions", + "systemAiHeader": "SYSTEM AI", + "systemAiEnabled": "enabled", + "systemAiOff": "off", + "systemAiEdit": "Edit…", + "systemAiConfigure": "Configure…", + "systemAiOffBlurb": "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.", + "mcpServersCount": "{n} {n, plural, =1{server} other{servers}}", + "@mcpServersCount": { "placeholders": { "n": { "type": "int" } } }, + "mcpRediscoverTooltip": "Re-run discovery", + "mcpAddTooltip": "Add MCP server", + "mcpRemoveTooltip": "Remove server", + "mcpToolsCount": "{n} {n, plural, =1{tool} other{tools}}", + "@mcpToolsCount": { "placeholders": { "n": { "type": "int" } } }, + "n8nEndpointsCount": "{n} {n, plural, =1{endpoint} other{endpoints}}", + "@n8nEndpointsCount": { "placeholders": { "n": { "type": "int" } } }, + "n8nWorkflowsCount": "{n} {n, plural, =1{workflow} other{workflows}}", + "@n8nWorkflowsCount": { "placeholders": { "n": { "type": "int" } } }, + "n8nRediscoverTooltip": "Re-run discovery", + "n8nAddTooltip": "Add n8n endpoint", + "n8nRemoveTooltip": "Remove endpoint", + "addMcpTitle": "Add MCP server", + "addMcpIntro": "Tools the server advertises via `tools/list` appear in the Store as `mcp..` capabilities.", + "addMcpSuggestionsLabel": "Suggested servers — click to pre-fill", + "addMcpNameLabel": "Name (no dots)", + "addMcpNameHint": "filesystem", + "addMcpEndpointLabel": "Endpoint", + "addMcpEndpointHint": "http://127.0.0.1:3001/mcp", + "addMcpApiKeyLabel": "API key env var (optional)", + "addMcpApiKeyHint": "MCP_FILESYSTEM_TOKEN", + "addMcpNotesLabel": "Notes (optional)", + "addMcpAddButton": "Add + discover", + "addN8nTitle": "Add n8n endpoint", + "addN8nIntro": "Active workflows surface in the Store as `n8n..` capabilities.", + "addN8nNameLabel": "Name (no dots)", + "addN8nNameHint": "ops", + "addN8nBaseUrlLabel": "Base URL (without /api/v1)", + "addN8nBaseUrlHint": "https://n8n.example.com", + "addN8nApiKeyLabel": "API key env var (optional)", + "addN8nApiKeyHint": "N8N_OPS_KEY", + "addN8nNotesLabel": "Notes (optional)", + "addN8nAddButton": "Add + discover", + "addedMcpToast": "Added MCP server \"{name}\".", + "@addedMcpToast": { "placeholders": { "name": { "type": "String" } } }, + "addedN8nToast": "Added n8n endpoint \"{name}\".", + "@addedN8nToast": { "placeholders": { "name": { "type": "String" } } }, + "addFailedToast": "Add failed: {error}", + "@addFailedToast": { "placeholders": { "error": { "type": "String" } } }, + "removeFailedToast": "Remove failed: {error}", + "@removeFailedToast": { "placeholders": { "error": { "type": "String" } } }, + "refreshFailedToast": "Refresh failed: {error}", + "@refreshFailedToast": { "placeholders": { "error": { "type": "String" } } }, "channelsHeader": "CHANNELS", "channelsActive": "active", @@ -69,6 +125,56 @@ "auditTitle": "Audit", "auditNoEvents": "No events yet", "auditNoEventsHint": "Run a flow to populate the audit stream.\nEvents appear here within seconds.", + "auditFilterAll": "all", + "auditFilterFlow": "flow", + "auditFilterStep": "step", + "auditFilterModule": "module", + "auditClearLogTooltip": "Clear log (local/dev channels only)", + "auditClearedToast": "Cleared {n} events on channel \"{channel}\". Chain reseeded with marker.", + "@auditClearedToast": { + "placeholders": { + "n": { "type": "int" }, + "channel": { "type": "String" } + } + }, + "auditClearFailed": "Clear failed: {detail}", + "@auditClearFailed": { "placeholders": { "detail": { "type": "String" } } }, + "auditExplain": "Explain", + "auditReask": "Re-ask", + "auditAsking": "Asking…", + "auditSystemAi": "SYSTEM AI", + "auditCachedPill": "cached", + "auditOriginalLatency": "orig {ms} ms", + "@auditOriginalLatency": { "placeholders": { "ms": { "type": "int" } } }, + "auditLatency": "{ms} ms", + "@auditLatency": { "placeholders": { "ms": { "type": "int" } } }, + "auditRegenerateTooltip": "Regenerate — skip cache, ask the model again", + "auditLiveStatus": "live · polling 2s · {n} {n, plural, =1{event} other{events}}", + "@auditLiveStatus": { "placeholders": { "n": { "type": "int" } } }, + "auditDisconnected": "disconnected · {error}", + "@auditDisconnected": { "placeholders": { "error": { "type": "String" } } }, + "auditHashChainVerified": "hash chain verified", + "auditDetailHeader": "DETAIL", + "auditAskingFull": "Asking the configured System AI…", + "auditFixLabel": "FIX", + "auditCachedTooltipKnown": "Served from the local cache. Originally generated on {at}{hits}.", + "@auditCachedTooltipKnown": { + "placeholders": { + "at": { "type": "String" }, + "hits": { "type": "String" } + } + }, + "auditCachedTooltipUnknown": "Served from the local cache. Originally generated at unknown time{hits}.", + "@auditCachedTooltipUnknown": { "placeholders": { "hits": { "type": "String" } } }, + "auditCachedTooltipHits": " · {n} hits", + "@auditCachedTooltipHits": { "placeholders": { "n": { "type": "int" } } }, + "auditConfigureSystemAi": "Configure System AI in Settings (Cmd+,)", + "auditClearDialogTitle": "Clear audit log?", + "auditClearDialogBody": "Wipes every audit event on the active channel and seeds a fresh chain.reset marker carrying reviewer + reason. Refused on beta / production. Irreversible.", + "auditClearReviewerLabel": "Reviewer", + "auditClearReasonLabel": "Reason (recorded in chain.reset marker)", + "auditClearReasonHelper": "Required — auditors will read this.", + "auditClearLogButton": "Clear log", "modulesTitle": "Modules", "modulesNoneTitle": "No modules yet", @@ -104,6 +210,44 @@ "approvalsTabHistory": "History", "approvalsInboxZero": "Inbox zero", "approvalsInboxHint": "All caught up. New `system.approval@^0` steps land here automatically.", + "approvalsReloadTooltip": "Reload", + "approvalsPillPending": "pending", + "approvalsPillApproved": "approved", + "approvalsPillRejected": "rejected", + "approvalsPillExpired": "expired", + "approvalsPayloadPreview": "PAYLOAD PREVIEW", + "approvalsApproveButton": "Approve", + "approvalsRejectButton": "Reject", + "approvalsRejectDialogTitle": "Reject approval", + "approvalsRejectReasonLabel": "Reason (recorded in audit log)", + "approvalsApprovedToast": "approved · {flow} › {step}", + "@approvalsApprovedToast": { + "placeholders": { + "flow": { "type": "String" }, + "step": { "type": "String" } + } + }, + "approvalsRejectedToast": "rejected · {flow} › {step}", + "@approvalsRejectedToast": { + "placeholders": { + "flow": { "type": "String" }, + "step": { "type": "String" } + } + }, + "approvalsApproveFailed": "approve failed: {error}", + "@approvalsApproveFailed": { "placeholders": { "error": { "type": "String" } } }, + "approvalsRejectFailed": "reject failed: {error}", + "@approvalsRejectFailed": { "placeholders": { "error": { "type": "String" } } }, + "approvalsHistoryEmpty": "No history yet", + "approvalsHistoryEmptyHint": "Decided approvals (approved / rejected / expired) appear here.\nThe audit log keeps the full hash-chained record.", + "approvalsDialogDecided": "decided", + "approvalsDialogCreated": "created", + "approvalsDialogReason": "reason", + "approvalsDialogPrompt": "PROMPT", + "approvalsExpiresIn": "{minutes}m", + "@approvalsExpiresIn": { "placeholders": { "minutes": { "type": "int" } } }, + "approvalsExpiresInHours": "{hours}h", + "@approvalsExpiresInHours": { "placeholders": { "hours": { "type": "int" } } }, "doctorTitle": "Doctor", "doctorRecheckTooltip": "Re-check", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index f791e4f..f819b65 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -284,6 +284,288 @@ abstract class AppLocalizations { /// **'App language. Affects every page; bilingual store entries also flip via this toggle.'** String get settingsLanguageHint; + /// No description provided for @settingsHubEndpointHint. + /// + /// In en, this message translates to: + /// **'Where should Studio connect? Default is the local hub at 127.0.0.1:50051.'** + String get settingsHubEndpointHint; + + /// No description provided for @settingsTlsSubtitle. + /// + /// In en, this message translates to: + /// **'https/grpc-secure'** + String get settingsTlsSubtitle; + + /// No description provided for @settingsPortError. + /// + /// In en, this message translates to: + /// **'port must be 1–65535'** + String get settingsPortError; + + /// No description provided for @channelsBlurb. + /// + /// In en, this message translates to: + /// **'Switch hub channel (writes ~/.fai/current-channel and restarts the daemon). Connect just changes Studio\'s wire.'** + String get channelsBlurb; + + /// No description provided for @channelsActionsTooltip. + /// + /// In en, this message translates to: + /// **'Channel actions'** + String get channelsActionsTooltip; + + /// No description provided for @systemAiHeader. + /// + /// In en, this message translates to: + /// **'SYSTEM AI'** + String get systemAiHeader; + + /// No description provided for @systemAiEnabled. + /// + /// In en, this message translates to: + /// **'enabled'** + String get systemAiEnabled; + + /// No description provided for @systemAiOff. + /// + /// In en, this message translates to: + /// **'off'** + String get systemAiOff; + + /// No description provided for @systemAiEdit. + /// + /// In en, this message translates to: + /// **'Edit…'** + String get systemAiEdit; + + /// No description provided for @systemAiConfigure. + /// + /// In en, this message translates to: + /// **'Configure…'** + String get systemAiConfigure; + + /// No description provided for @systemAiOffBlurb. + /// + /// In en, this message translates to: + /// **'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.'** + String get systemAiOffBlurb; + + /// No description provided for @mcpServersCount. + /// + /// In en, this message translates to: + /// **'{n} {n, plural, =1{server} other{servers}}'** + String mcpServersCount(int n); + + /// No description provided for @mcpRediscoverTooltip. + /// + /// In en, this message translates to: + /// **'Re-run discovery'** + String get mcpRediscoverTooltip; + + /// No description provided for @mcpAddTooltip. + /// + /// In en, this message translates to: + /// **'Add MCP server'** + String get mcpAddTooltip; + + /// No description provided for @mcpRemoveTooltip. + /// + /// In en, this message translates to: + /// **'Remove server'** + String get mcpRemoveTooltip; + + /// No description provided for @mcpToolsCount. + /// + /// In en, this message translates to: + /// **'{n} {n, plural, =1{tool} other{tools}}'** + String mcpToolsCount(int n); + + /// No description provided for @n8nEndpointsCount. + /// + /// In en, this message translates to: + /// **'{n} {n, plural, =1{endpoint} other{endpoints}}'** + String n8nEndpointsCount(int n); + + /// No description provided for @n8nWorkflowsCount. + /// + /// In en, this message translates to: + /// **'{n} {n, plural, =1{workflow} other{workflows}}'** + String n8nWorkflowsCount(int n); + + /// No description provided for @n8nRediscoverTooltip. + /// + /// In en, this message translates to: + /// **'Re-run discovery'** + String get n8nRediscoverTooltip; + + /// No description provided for @n8nAddTooltip. + /// + /// In en, this message translates to: + /// **'Add n8n endpoint'** + String get n8nAddTooltip; + + /// No description provided for @n8nRemoveTooltip. + /// + /// In en, this message translates to: + /// **'Remove endpoint'** + String get n8nRemoveTooltip; + + /// No description provided for @addMcpTitle. + /// + /// In en, this message translates to: + /// **'Add MCP server'** + String get addMcpTitle; + + /// No description provided for @addMcpIntro. + /// + /// In en, this message translates to: + /// **'Tools the server advertises via `tools/list` appear in the Store as `mcp..` capabilities.'** + String get addMcpIntro; + + /// No description provided for @addMcpSuggestionsLabel. + /// + /// In en, this message translates to: + /// **'Suggested servers — click to pre-fill'** + String get addMcpSuggestionsLabel; + + /// No description provided for @addMcpNameLabel. + /// + /// In en, this message translates to: + /// **'Name (no dots)'** + String get addMcpNameLabel; + + /// No description provided for @addMcpNameHint. + /// + /// In en, this message translates to: + /// **'filesystem'** + String get addMcpNameHint; + + /// No description provided for @addMcpEndpointLabel. + /// + /// In en, this message translates to: + /// **'Endpoint'** + String get addMcpEndpointLabel; + + /// No description provided for @addMcpEndpointHint. + /// + /// In en, this message translates to: + /// **'http://127.0.0.1:3001/mcp'** + String get addMcpEndpointHint; + + /// No description provided for @addMcpApiKeyLabel. + /// + /// In en, this message translates to: + /// **'API key env var (optional)'** + String get addMcpApiKeyLabel; + + /// No description provided for @addMcpApiKeyHint. + /// + /// In en, this message translates to: + /// **'MCP_FILESYSTEM_TOKEN'** + String get addMcpApiKeyHint; + + /// No description provided for @addMcpNotesLabel. + /// + /// In en, this message translates to: + /// **'Notes (optional)'** + String get addMcpNotesLabel; + + /// No description provided for @addMcpAddButton. + /// + /// In en, this message translates to: + /// **'Add + discover'** + String get addMcpAddButton; + + /// No description provided for @addN8nTitle. + /// + /// In en, this message translates to: + /// **'Add n8n endpoint'** + String get addN8nTitle; + + /// No description provided for @addN8nIntro. + /// + /// In en, this message translates to: + /// **'Active workflows surface in the Store as `n8n..` capabilities.'** + String get addN8nIntro; + + /// No description provided for @addN8nNameLabel. + /// + /// In en, this message translates to: + /// **'Name (no dots)'** + String get addN8nNameLabel; + + /// No description provided for @addN8nNameHint. + /// + /// In en, this message translates to: + /// **'ops'** + String get addN8nNameHint; + + /// No description provided for @addN8nBaseUrlLabel. + /// + /// In en, this message translates to: + /// **'Base URL (without /api/v1)'** + String get addN8nBaseUrlLabel; + + /// No description provided for @addN8nBaseUrlHint. + /// + /// In en, this message translates to: + /// **'https://n8n.example.com'** + String get addN8nBaseUrlHint; + + /// No description provided for @addN8nApiKeyLabel. + /// + /// In en, this message translates to: + /// **'API key env var (optional)'** + String get addN8nApiKeyLabel; + + /// No description provided for @addN8nApiKeyHint. + /// + /// In en, this message translates to: + /// **'N8N_OPS_KEY'** + String get addN8nApiKeyHint; + + /// No description provided for @addN8nNotesLabel. + /// + /// In en, this message translates to: + /// **'Notes (optional)'** + String get addN8nNotesLabel; + + /// No description provided for @addN8nAddButton. + /// + /// In en, this message translates to: + /// **'Add + discover'** + String get addN8nAddButton; + + /// No description provided for @addedMcpToast. + /// + /// In en, this message translates to: + /// **'Added MCP server \"{name}\".'** + String addedMcpToast(String name); + + /// No description provided for @addedN8nToast. + /// + /// In en, this message translates to: + /// **'Added n8n endpoint \"{name}\".'** + String addedN8nToast(String name); + + /// No description provided for @addFailedToast. + /// + /// In en, this message translates to: + /// **'Add failed: {error}'** + String addFailedToast(String error); + + /// No description provided for @removeFailedToast. + /// + /// In en, this message translates to: + /// **'Remove failed: {error}'** + String removeFailedToast(String error); + + /// No description provided for @refreshFailedToast. + /// + /// In en, this message translates to: + /// **'Refresh failed: {error}'** + String refreshFailedToast(String error); + /// No description provided for @channelsHeader. /// /// In en, this message translates to: @@ -440,6 +722,192 @@ abstract class AppLocalizations { /// **'Run a flow to populate the audit stream.\nEvents appear here within seconds.'** String get auditNoEventsHint; + /// No description provided for @auditFilterAll. + /// + /// In en, this message translates to: + /// **'all'** + String get auditFilterAll; + + /// No description provided for @auditFilterFlow. + /// + /// In en, this message translates to: + /// **'flow'** + String get auditFilterFlow; + + /// No description provided for @auditFilterStep. + /// + /// In en, this message translates to: + /// **'step'** + String get auditFilterStep; + + /// No description provided for @auditFilterModule. + /// + /// In en, this message translates to: + /// **'module'** + String get auditFilterModule; + + /// No description provided for @auditClearLogTooltip. + /// + /// In en, this message translates to: + /// **'Clear log (local/dev channels only)'** + String get auditClearLogTooltip; + + /// No description provided for @auditClearedToast. + /// + /// In en, this message translates to: + /// **'Cleared {n} events on channel \"{channel}\". Chain reseeded with marker.'** + String auditClearedToast(int n, String channel); + + /// No description provided for @auditClearFailed. + /// + /// In en, this message translates to: + /// **'Clear failed: {detail}'** + String auditClearFailed(String detail); + + /// No description provided for @auditExplain. + /// + /// In en, this message translates to: + /// **'Explain'** + String get auditExplain; + + /// No description provided for @auditReask. + /// + /// In en, this message translates to: + /// **'Re-ask'** + String get auditReask; + + /// No description provided for @auditAsking. + /// + /// In en, this message translates to: + /// **'Asking…'** + String get auditAsking; + + /// No description provided for @auditSystemAi. + /// + /// In en, this message translates to: + /// **'SYSTEM AI'** + String get auditSystemAi; + + /// No description provided for @auditCachedPill. + /// + /// In en, this message translates to: + /// **'cached'** + String get auditCachedPill; + + /// No description provided for @auditOriginalLatency. + /// + /// In en, this message translates to: + /// **'orig {ms} ms'** + String auditOriginalLatency(int ms); + + /// No description provided for @auditLatency. + /// + /// In en, this message translates to: + /// **'{ms} ms'** + String auditLatency(int ms); + + /// No description provided for @auditRegenerateTooltip. + /// + /// In en, this message translates to: + /// **'Regenerate — skip cache, ask the model again'** + String get auditRegenerateTooltip; + + /// No description provided for @auditLiveStatus. + /// + /// In en, this message translates to: + /// **'live · polling 2s · {n} {n, plural, =1{event} other{events}}'** + String auditLiveStatus(int n); + + /// No description provided for @auditDisconnected. + /// + /// In en, this message translates to: + /// **'disconnected · {error}'** + String auditDisconnected(String error); + + /// No description provided for @auditHashChainVerified. + /// + /// In en, this message translates to: + /// **'hash chain verified'** + String get auditHashChainVerified; + + /// No description provided for @auditDetailHeader. + /// + /// In en, this message translates to: + /// **'DETAIL'** + String get auditDetailHeader; + + /// No description provided for @auditAskingFull. + /// + /// In en, this message translates to: + /// **'Asking the configured System AI…'** + String get auditAskingFull; + + /// No description provided for @auditFixLabel. + /// + /// In en, this message translates to: + /// **'FIX'** + String get auditFixLabel; + + /// No description provided for @auditCachedTooltipKnown. + /// + /// In en, this message translates to: + /// **'Served from the local cache. Originally generated on {at}{hits}.'** + String auditCachedTooltipKnown(String at, String hits); + + /// No description provided for @auditCachedTooltipUnknown. + /// + /// In en, this message translates to: + /// **'Served from the local cache. Originally generated at unknown time{hits}.'** + String auditCachedTooltipUnknown(String hits); + + /// No description provided for @auditCachedTooltipHits. + /// + /// In en, this message translates to: + /// **' · {n} hits'** + String auditCachedTooltipHits(int n); + + /// No description provided for @auditConfigureSystemAi. + /// + /// In en, this message translates to: + /// **'Configure System AI in Settings (Cmd+,)'** + String get auditConfigureSystemAi; + + /// No description provided for @auditClearDialogTitle. + /// + /// In en, this message translates to: + /// **'Clear audit log?'** + String get auditClearDialogTitle; + + /// No description provided for @auditClearDialogBody. + /// + /// In en, this message translates to: + /// **'Wipes every audit event on the active channel and seeds a fresh chain.reset marker carrying reviewer + reason. Refused on beta / production. Irreversible.'** + String get auditClearDialogBody; + + /// No description provided for @auditClearReviewerLabel. + /// + /// In en, this message translates to: + /// **'Reviewer'** + String get auditClearReviewerLabel; + + /// No description provided for @auditClearReasonLabel. + /// + /// In en, this message translates to: + /// **'Reason (recorded in chain.reset marker)'** + String get auditClearReasonLabel; + + /// No description provided for @auditClearReasonHelper. + /// + /// In en, this message translates to: + /// **'Required — auditors will read this.'** + String get auditClearReasonHelper; + + /// No description provided for @auditClearLogButton. + /// + /// In en, this message translates to: + /// **'Clear log'** + String get auditClearLogButton; + /// No description provided for @modulesTitle. /// /// In en, this message translates to: @@ -554,6 +1022,138 @@ abstract class AppLocalizations { /// **'All caught up. New `system.approval@^0` steps land here automatically.'** String get approvalsInboxHint; + /// No description provided for @approvalsReloadTooltip. + /// + /// In en, this message translates to: + /// **'Reload'** + String get approvalsReloadTooltip; + + /// No description provided for @approvalsPillPending. + /// + /// In en, this message translates to: + /// **'pending'** + String get approvalsPillPending; + + /// No description provided for @approvalsPillApproved. + /// + /// In en, this message translates to: + /// **'approved'** + String get approvalsPillApproved; + + /// No description provided for @approvalsPillRejected. + /// + /// In en, this message translates to: + /// **'rejected'** + String get approvalsPillRejected; + + /// No description provided for @approvalsPillExpired. + /// + /// In en, this message translates to: + /// **'expired'** + String get approvalsPillExpired; + + /// No description provided for @approvalsPayloadPreview. + /// + /// In en, this message translates to: + /// **'PAYLOAD PREVIEW'** + String get approvalsPayloadPreview; + + /// No description provided for @approvalsApproveButton. + /// + /// In en, this message translates to: + /// **'Approve'** + String get approvalsApproveButton; + + /// No description provided for @approvalsRejectButton. + /// + /// In en, this message translates to: + /// **'Reject'** + String get approvalsRejectButton; + + /// No description provided for @approvalsRejectDialogTitle. + /// + /// In en, this message translates to: + /// **'Reject approval'** + String get approvalsRejectDialogTitle; + + /// No description provided for @approvalsRejectReasonLabel. + /// + /// In en, this message translates to: + /// **'Reason (recorded in audit log)'** + String get approvalsRejectReasonLabel; + + /// No description provided for @approvalsApprovedToast. + /// + /// In en, this message translates to: + /// **'approved · {flow} › {step}'** + String approvalsApprovedToast(String flow, String step); + + /// No description provided for @approvalsRejectedToast. + /// + /// In en, this message translates to: + /// **'rejected · {flow} › {step}'** + String approvalsRejectedToast(String flow, String step); + + /// No description provided for @approvalsApproveFailed. + /// + /// In en, this message translates to: + /// **'approve failed: {error}'** + String approvalsApproveFailed(String error); + + /// No description provided for @approvalsRejectFailed. + /// + /// In en, this message translates to: + /// **'reject failed: {error}'** + String approvalsRejectFailed(String error); + + /// No description provided for @approvalsHistoryEmpty. + /// + /// In en, this message translates to: + /// **'No history yet'** + String get approvalsHistoryEmpty; + + /// No description provided for @approvalsHistoryEmptyHint. + /// + /// In en, this message translates to: + /// **'Decided approvals (approved / rejected / expired) appear here.\nThe audit log keeps the full hash-chained record.'** + String get approvalsHistoryEmptyHint; + + /// No description provided for @approvalsDialogDecided. + /// + /// In en, this message translates to: + /// **'decided'** + String get approvalsDialogDecided; + + /// No description provided for @approvalsDialogCreated. + /// + /// In en, this message translates to: + /// **'created'** + String get approvalsDialogCreated; + + /// No description provided for @approvalsDialogReason. + /// + /// In en, this message translates to: + /// **'reason'** + String get approvalsDialogReason; + + /// No description provided for @approvalsDialogPrompt. + /// + /// In en, this message translates to: + /// **'PROMPT'** + String get approvalsDialogPrompt; + + /// No description provided for @approvalsExpiresIn. + /// + /// In en, this message translates to: + /// **'{minutes}m'** + String approvalsExpiresIn(int minutes); + + /// No description provided for @approvalsExpiresInHours. + /// + /// In en, this message translates to: + /// **'{hours}h'** + String approvalsExpiresInHours(int hours); + /// No description provided for @doctorTitle. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index abc2db2..921913d 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -102,6 +102,182 @@ class AppLocalizationsDe extends AppLocalizations { String get settingsLanguageHint => 'Sprache der App. Wirkt auf alle Seiten; mehrsprachige Store-Einträge folgen ebenfalls.'; + @override + String get settingsHubEndpointHint => + 'Wohin soll Studio sich verbinden? Standard ist der lokale Hub auf 127.0.0.1:50051.'; + + @override + String get settingsTlsSubtitle => 'https/grpc-secure'; + + @override + String get settingsPortError => 'Port muss 1–65535 sein'; + + @override + String get channelsBlurb => + 'Hub-Kanal wechseln (schreibt ~/.fai/current-channel und startet den Daemon neu). Verbinden ändert nur die Wire von Studio.'; + + @override + String get channelsActionsTooltip => 'Kanal-Aktionen'; + + @override + String get systemAiHeader => 'SYSTEM-AI'; + + @override + String get systemAiEnabled => 'aktiv'; + + @override + String get systemAiOff => 'aus'; + + @override + String get systemAiEdit => 'Bearbeiten…'; + + @override + String get systemAiConfigure => 'Konfigurieren…'; + + @override + String get systemAiOffBlurb => + 'Aus — Fehlererklärungen auf der Protokoll-Seite sind deaktiviert. Klicke Konfigurieren…, um einen Provider zu wählen (Ollama / OpenAI / LM Studio / vLLM / Eigene). Die Operator-Konfig wird direkt überschrieben; kein Daemon-Neustart nötig.'; + + @override + String mcpServersCount(int n) { + String _temp0 = intl.Intl.pluralLogic( + n, + locale: localeName, + other: 'Server', + one: 'Server', + ); + return '$n $_temp0'; + } + + @override + String get mcpRediscoverTooltip => 'Discovery neu ausführen'; + + @override + String get mcpAddTooltip => 'MCP-Server hinzufügen'; + + @override + String get mcpRemoveTooltip => 'Server entfernen'; + + @override + String mcpToolsCount(int n) { + return '$n Tools'; + } + + @override + String n8nEndpointsCount(int n) { + String _temp0 = intl.Intl.pluralLogic( + n, + locale: localeName, + other: 'Endpunkte', + one: 'Endpunkt', + ); + return '$n $_temp0'; + } + + @override + String n8nWorkflowsCount(int n) { + return '$n Workflows'; + } + + @override + String get n8nRediscoverTooltip => 'Discovery neu ausführen'; + + @override + String get n8nAddTooltip => 'n8n-Endpunkt hinzufügen'; + + @override + String get n8nRemoveTooltip => 'Endpunkt entfernen'; + + @override + String get addMcpTitle => 'MCP-Server hinzufügen'; + + @override + String get addMcpIntro => + 'Tools, die der Server via `tools/list` anbietet, erscheinen im Store als `mcp..`-Capabilities.'; + + @override + String get addMcpSuggestionsLabel => 'Vorschläge — zum Vorausfüllen klicken'; + + @override + String get addMcpNameLabel => 'Name (keine Punkte)'; + + @override + String get addMcpNameHint => 'filesystem'; + + @override + String get addMcpEndpointLabel => 'Endpunkt'; + + @override + String get addMcpEndpointHint => 'http://127.0.0.1:3001/mcp'; + + @override + String get addMcpApiKeyLabel => 'API-Key-Umgebungsvariable (optional)'; + + @override + String get addMcpApiKeyHint => 'MCP_FILESYSTEM_TOKEN'; + + @override + String get addMcpNotesLabel => 'Notizen (optional)'; + + @override + String get addMcpAddButton => 'Hinzufügen + Discovery'; + + @override + String get addN8nTitle => 'n8n-Endpunkt hinzufügen'; + + @override + String get addN8nIntro => + 'Aktive Workflows erscheinen im Store als `n8n..`-Capabilities.'; + + @override + String get addN8nNameLabel => 'Name (keine Punkte)'; + + @override + String get addN8nNameHint => 'ops'; + + @override + String get addN8nBaseUrlLabel => 'Basis-URL (ohne /api/v1)'; + + @override + String get addN8nBaseUrlHint => 'https://n8n.example.com'; + + @override + String get addN8nApiKeyLabel => 'API-Key-Umgebungsvariable (optional)'; + + @override + String get addN8nApiKeyHint => 'N8N_OPS_KEY'; + + @override + String get addN8nNotesLabel => 'Notizen (optional)'; + + @override + String get addN8nAddButton => 'Hinzufügen + Discovery'; + + @override + String addedMcpToast(String name) { + return 'MCP-Server „$name\" hinzugefügt.'; + } + + @override + String addedN8nToast(String name) { + return 'n8n-Endpunkt „$name\" hinzugefügt.'; + } + + @override + String addFailedToast(String error) { + return 'Hinzufügen fehlgeschlagen: $error'; + } + + @override + String removeFailedToast(String error) { + return 'Entfernen fehlgeschlagen: $error'; + } + + @override + String refreshFailedToast(String error) { + return 'Aktualisieren fehlgeschlagen: $error'; + } + @override String get channelsHeader => 'KANÄLE'; @@ -186,6 +362,128 @@ class AppLocalizationsDe extends AppLocalizations { String get auditNoEventsHint => 'Starte einen Flow, um den Audit-Stream zu füllen.\nEreignisse erscheinen innerhalb von Sekunden.'; + @override + String get auditFilterAll => 'alle'; + + @override + String get auditFilterFlow => 'Flow'; + + @override + String get auditFilterStep => 'Step'; + + @override + String get auditFilterModule => 'Modul'; + + @override + String get auditClearLogTooltip => 'Protokoll löschen (nur local/dev Kanal)'; + + @override + String auditClearedToast(int n, String channel) { + return '$n Ereignisse auf Kanal „$channel\" gelöscht. Chain mit Marker neu geseedet.'; + } + + @override + String auditClearFailed(String detail) { + return 'Löschen fehlgeschlagen: $detail'; + } + + @override + String get auditExplain => 'Erklären'; + + @override + String get auditReask => 'Neu fragen'; + + @override + String get auditAsking => 'Frage läuft…'; + + @override + String get auditSystemAi => 'SYSTEM-AI'; + + @override + String get auditCachedPill => 'gecacht'; + + @override + String auditOriginalLatency(int ms) { + return 'ursprünglich $ms ms'; + } + + @override + String auditLatency(int ms) { + return '$ms ms'; + } + + @override + String get auditRegenerateTooltip => + 'Neu generieren — Cache überspringen, Modell erneut fragen'; + + @override + String auditLiveStatus(int n) { + String _temp0 = intl.Intl.pluralLogic( + n, + locale: localeName, + other: 'Ereignisse', + one: 'Ereignis', + ); + return 'live · Abfrage alle 2s · $n $_temp0'; + } + + @override + String auditDisconnected(String error) { + return 'getrennt · $error'; + } + + @override + String get auditHashChainVerified => 'Hash-Chain verifiziert'; + + @override + String get auditDetailHeader => 'DETAIL'; + + @override + String get auditAskingFull => 'Frage an die konfigurierte System-AI läuft…'; + + @override + String get auditFixLabel => 'FIX'; + + @override + String auditCachedTooltipKnown(String at, String hits) { + return 'Aus dem lokalen Cache geliefert. Ursprünglich erzeugt am $at$hits.'; + } + + @override + String auditCachedTooltipUnknown(String hits) { + return 'Aus dem lokalen Cache geliefert. Ursprünglicher Zeitpunkt unbekannt$hits.'; + } + + @override + String auditCachedTooltipHits(int n) { + return ' · $n Treffer'; + } + + @override + String get auditConfigureSystemAi => + 'System-AI unter Einstellungen konfigurieren (Cmd+,)'; + + @override + String get auditClearDialogTitle => 'Audit-Log löschen?'; + + @override + String get auditClearDialogBody => + 'Löscht jedes Audit-Event auf dem aktiven Kanal und seedet einen neuen chain.reset-Marker mit Prüfer und Begründung. Auf beta / production verweigert. Nicht umkehrbar.'; + + @override + String get auditClearReviewerLabel => 'Prüfer'; + + @override + String get auditClearReasonLabel => + 'Begründung (im chain.reset-Marker festgehalten)'; + + @override + String get auditClearReasonHelper => + 'Erforderlich — Auditoren werden das lesen.'; + + @override + String get auditClearLogButton => 'Log löschen'; + @override String get modulesTitle => 'Module'; @@ -253,6 +551,86 @@ class AppLocalizationsDe extends AppLocalizations { String get approvalsInboxHint => 'Alles erledigt. Neue `system.approval@^0`-Schritte landen automatisch hier.'; + @override + String get approvalsReloadTooltip => 'Aktualisieren'; + + @override + String get approvalsPillPending => 'offen'; + + @override + String get approvalsPillApproved => 'freigegeben'; + + @override + String get approvalsPillRejected => 'abgelehnt'; + + @override + String get approvalsPillExpired => 'abgelaufen'; + + @override + String get approvalsPayloadPreview => 'PAYLOAD-VORSCHAU'; + + @override + String get approvalsApproveButton => 'Freigeben'; + + @override + String get approvalsRejectButton => 'Ablehnen'; + + @override + String get approvalsRejectDialogTitle => 'Freigabe ablehnen'; + + @override + String get approvalsRejectReasonLabel => + 'Begründung (im Audit-Log festgehalten)'; + + @override + String approvalsApprovedToast(String flow, String step) { + return 'freigegeben · $flow › $step'; + } + + @override + String approvalsRejectedToast(String flow, String step) { + return 'abgelehnt · $flow › $step'; + } + + @override + String approvalsApproveFailed(String error) { + return 'Freigabe fehlgeschlagen: $error'; + } + + @override + String approvalsRejectFailed(String error) { + return 'Ablehnung fehlgeschlagen: $error'; + } + + @override + String get approvalsHistoryEmpty => 'Noch kein Verlauf'; + + @override + String get approvalsHistoryEmptyHint => + 'Entschiedene Freigaben (freigegeben / abgelehnt / abgelaufen) erscheinen hier.\nDas Audit-Log enthält den vollständigen hash-chained Datensatz.'; + + @override + String get approvalsDialogDecided => 'entschieden'; + + @override + String get approvalsDialogCreated => 'erstellt'; + + @override + String get approvalsDialogReason => 'Begründung'; + + @override + String get approvalsDialogPrompt => 'PROMPT'; + + @override + String approvalsExpiresIn(int minutes) { + return '${minutes}m'; + } + + @override + String approvalsExpiresInHours(int hours) { + return '${hours}h'; + } + @override String get doctorTitle => 'Diagnose'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 749cb3c..457c2a1 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -102,6 +102,194 @@ class AppLocalizationsEn extends AppLocalizations { String get settingsLanguageHint => 'App language. Affects every page; bilingual store entries also flip via this toggle.'; + @override + String get settingsHubEndpointHint => + 'Where should Studio connect? Default is the local hub at 127.0.0.1:50051.'; + + @override + String get settingsTlsSubtitle => 'https/grpc-secure'; + + @override + String get settingsPortError => 'port must be 1–65535'; + + @override + String get channelsBlurb => + 'Switch hub channel (writes ~/.fai/current-channel and restarts the daemon). Connect just changes Studio\'s wire.'; + + @override + String get channelsActionsTooltip => 'Channel actions'; + + @override + String get systemAiHeader => 'SYSTEM AI'; + + @override + String get systemAiEnabled => 'enabled'; + + @override + String get systemAiOff => 'off'; + + @override + String get systemAiEdit => 'Edit…'; + + @override + String get systemAiConfigure => 'Configure…'; + + @override + String get systemAiOffBlurb => + '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.'; + + @override + String mcpServersCount(int n) { + String _temp0 = intl.Intl.pluralLogic( + n, + locale: localeName, + other: 'servers', + one: 'server', + ); + return '$n $_temp0'; + } + + @override + String get mcpRediscoverTooltip => 'Re-run discovery'; + + @override + String get mcpAddTooltip => 'Add MCP server'; + + @override + String get mcpRemoveTooltip => 'Remove server'; + + @override + String mcpToolsCount(int n) { + String _temp0 = intl.Intl.pluralLogic( + n, + locale: localeName, + other: 'tools', + one: 'tool', + ); + return '$n $_temp0'; + } + + @override + String n8nEndpointsCount(int n) { + String _temp0 = intl.Intl.pluralLogic( + n, + locale: localeName, + other: 'endpoints', + one: 'endpoint', + ); + return '$n $_temp0'; + } + + @override + String n8nWorkflowsCount(int n) { + String _temp0 = intl.Intl.pluralLogic( + n, + locale: localeName, + other: 'workflows', + one: 'workflow', + ); + return '$n $_temp0'; + } + + @override + String get n8nRediscoverTooltip => 'Re-run discovery'; + + @override + String get n8nAddTooltip => 'Add n8n endpoint'; + + @override + String get n8nRemoveTooltip => 'Remove endpoint'; + + @override + String get addMcpTitle => 'Add MCP server'; + + @override + String get addMcpIntro => + 'Tools the server advertises via `tools/list` appear in the Store as `mcp..` capabilities.'; + + @override + String get addMcpSuggestionsLabel => 'Suggested servers — click to pre-fill'; + + @override + String get addMcpNameLabel => 'Name (no dots)'; + + @override + String get addMcpNameHint => 'filesystem'; + + @override + String get addMcpEndpointLabel => 'Endpoint'; + + @override + String get addMcpEndpointHint => 'http://127.0.0.1:3001/mcp'; + + @override + String get addMcpApiKeyLabel => 'API key env var (optional)'; + + @override + String get addMcpApiKeyHint => 'MCP_FILESYSTEM_TOKEN'; + + @override + String get addMcpNotesLabel => 'Notes (optional)'; + + @override + String get addMcpAddButton => 'Add + discover'; + + @override + String get addN8nTitle => 'Add n8n endpoint'; + + @override + String get addN8nIntro => + 'Active workflows surface in the Store as `n8n..` capabilities.'; + + @override + String get addN8nNameLabel => 'Name (no dots)'; + + @override + String get addN8nNameHint => 'ops'; + + @override + String get addN8nBaseUrlLabel => 'Base URL (without /api/v1)'; + + @override + String get addN8nBaseUrlHint => 'https://n8n.example.com'; + + @override + String get addN8nApiKeyLabel => 'API key env var (optional)'; + + @override + String get addN8nApiKeyHint => 'N8N_OPS_KEY'; + + @override + String get addN8nNotesLabel => 'Notes (optional)'; + + @override + String get addN8nAddButton => 'Add + discover'; + + @override + String addedMcpToast(String name) { + return 'Added MCP server \"$name\".'; + } + + @override + String addedN8nToast(String name) { + return 'Added n8n endpoint \"$name\".'; + } + + @override + String addFailedToast(String error) { + return 'Add failed: $error'; + } + + @override + String removeFailedToast(String error) { + return 'Remove failed: $error'; + } + + @override + String refreshFailedToast(String error) { + return 'Refresh failed: $error'; + } + @override String get channelsHeader => 'CHANNELS'; @@ -192,6 +380,126 @@ class AppLocalizationsEn extends AppLocalizations { String get auditNoEventsHint => 'Run a flow to populate the audit stream.\nEvents appear here within seconds.'; + @override + String get auditFilterAll => 'all'; + + @override + String get auditFilterFlow => 'flow'; + + @override + String get auditFilterStep => 'step'; + + @override + String get auditFilterModule => 'module'; + + @override + String get auditClearLogTooltip => 'Clear log (local/dev channels only)'; + + @override + String auditClearedToast(int n, String channel) { + return 'Cleared $n events on channel \"$channel\". Chain reseeded with marker.'; + } + + @override + String auditClearFailed(String detail) { + return 'Clear failed: $detail'; + } + + @override + String get auditExplain => 'Explain'; + + @override + String get auditReask => 'Re-ask'; + + @override + String get auditAsking => 'Asking…'; + + @override + String get auditSystemAi => 'SYSTEM AI'; + + @override + String get auditCachedPill => 'cached'; + + @override + String auditOriginalLatency(int ms) { + return 'orig $ms ms'; + } + + @override + String auditLatency(int ms) { + return '$ms ms'; + } + + @override + String get auditRegenerateTooltip => + 'Regenerate — skip cache, ask the model again'; + + @override + String auditLiveStatus(int n) { + String _temp0 = intl.Intl.pluralLogic( + n, + locale: localeName, + other: 'events', + one: 'event', + ); + return 'live · polling 2s · $n $_temp0'; + } + + @override + String auditDisconnected(String error) { + return 'disconnected · $error'; + } + + @override + String get auditHashChainVerified => 'hash chain verified'; + + @override + String get auditDetailHeader => 'DETAIL'; + + @override + String get auditAskingFull => 'Asking the configured System AI…'; + + @override + String get auditFixLabel => 'FIX'; + + @override + String auditCachedTooltipKnown(String at, String hits) { + return 'Served from the local cache. Originally generated on $at$hits.'; + } + + @override + String auditCachedTooltipUnknown(String hits) { + return 'Served from the local cache. Originally generated at unknown time$hits.'; + } + + @override + String auditCachedTooltipHits(int n) { + return ' · $n hits'; + } + + @override + String get auditConfigureSystemAi => + 'Configure System AI in Settings (Cmd+,)'; + + @override + String get auditClearDialogTitle => 'Clear audit log?'; + + @override + String get auditClearDialogBody => + 'Wipes every audit event on the active channel and seeds a fresh chain.reset marker carrying reviewer + reason. Refused on beta / production. Irreversible.'; + + @override + String get auditClearReviewerLabel => 'Reviewer'; + + @override + String get auditClearReasonLabel => 'Reason (recorded in chain.reset marker)'; + + @override + String get auditClearReasonHelper => 'Required — auditors will read this.'; + + @override + String get auditClearLogButton => 'Clear log'; + @override String get modulesTitle => 'Modules'; @@ -259,6 +567,85 @@ class AppLocalizationsEn extends AppLocalizations { String get approvalsInboxHint => 'All caught up. New `system.approval@^0` steps land here automatically.'; + @override + String get approvalsReloadTooltip => 'Reload'; + + @override + String get approvalsPillPending => 'pending'; + + @override + String get approvalsPillApproved => 'approved'; + + @override + String get approvalsPillRejected => 'rejected'; + + @override + String get approvalsPillExpired => 'expired'; + + @override + String get approvalsPayloadPreview => 'PAYLOAD PREVIEW'; + + @override + String get approvalsApproveButton => 'Approve'; + + @override + String get approvalsRejectButton => 'Reject'; + + @override + String get approvalsRejectDialogTitle => 'Reject approval'; + + @override + String get approvalsRejectReasonLabel => 'Reason (recorded in audit log)'; + + @override + String approvalsApprovedToast(String flow, String step) { + return 'approved · $flow › $step'; + } + + @override + String approvalsRejectedToast(String flow, String step) { + return 'rejected · $flow › $step'; + } + + @override + String approvalsApproveFailed(String error) { + return 'approve failed: $error'; + } + + @override + String approvalsRejectFailed(String error) { + return 'reject failed: $error'; + } + + @override + String get approvalsHistoryEmpty => 'No history yet'; + + @override + String get approvalsHistoryEmptyHint => + 'Decided approvals (approved / rejected / expired) appear here.\nThe audit log keeps the full hash-chained record.'; + + @override + String get approvalsDialogDecided => 'decided'; + + @override + String get approvalsDialogCreated => 'created'; + + @override + String get approvalsDialogReason => 'reason'; + + @override + String get approvalsDialogPrompt => 'PROMPT'; + + @override + String approvalsExpiresIn(int minutes) { + return '${minutes}m'; + } + + @override + String approvalsExpiresInHours(int hours) { + return '${hours}h'; + } + @override String get doctorTitle => 'Doctor'; diff --git a/lib/pages/approvals.dart b/lib/pages/approvals.dart index ce451e6..75ed42d 100644 --- a/lib/pages/approvals.dart +++ b/lib/pages/approvals.dart @@ -59,24 +59,26 @@ class _ApprovalsPageState extends State } Future _approve(ApprovalRecord a) async { + final l = AppLocalizations.of(context)!; try { await HubService.instance.approve(a.id, _reviewer); - _toast('approved · ${a.flowName} › ${a.stepId}'); + _toast(l.approvalsApprovedToast(a.flowName, a.stepId)); _refresh(); } catch (e) { - _toast('approve failed: $e'); + _toast(l.approvalsApproveFailed(e.toString())); } } Future _reject(ApprovalRecord a) async { + final l = AppLocalizations.of(context)!; final reason = await _promptReason(context); if (reason == null || reason.isEmpty) return; try { await HubService.instance.reject(a.id, _reviewer, reason); - _toast('rejected · ${a.flowName} › ${a.stepId}'); + _toast(l.approvalsRejectedToast(a.flowName, a.stepId)); _refresh(); } catch (e) { - _toast('reject failed: $e'); + _toast(l.approvalsRejectFailed(e.toString())); } } @@ -87,22 +89,23 @@ class _ApprovalsPageState extends State Future _promptReason(BuildContext context) async { final controller = TextEditingController(); + final l = AppLocalizations.of(context)!; return showDialog( context: context, builder: (ctx) => AlertDialog( - title: const Text('Reject approval'), + title: Text(l.approvalsRejectDialogTitle), content: TextField( controller: controller, autofocus: true, - decoration: const InputDecoration( - labelText: 'Reason (recorded in audit log)', - border: OutlineInputBorder(), + decoration: InputDecoration( + labelText: l.approvalsRejectReasonLabel, + border: const OutlineInputBorder(), ), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, null), - child: const Text('Cancel'), + child: Text(l.buttonCancel), ), FilledButton( onPressed: () => Navigator.pop(ctx, controller.text), @@ -110,7 +113,7 @@ class _ApprovalsPageState extends State backgroundColor: Theme.of(ctx).colorScheme.error, foregroundColor: Theme.of(ctx).colorScheme.onError, ), - child: const Text('Reject'), + child: Text(l.approvalsRejectButton), ), ], ), @@ -134,7 +137,7 @@ class _ApprovalsPageState extends State actions: [ IconButton( icon: const Icon(Icons.refresh, size: 18), - tooltip: 'Reload', + tooltip: AppLocalizations.of(context)!.approvalsReloadTooltip, onPressed: _refresh, ), const SizedBox(width: FaiSpace.sm), @@ -175,6 +178,7 @@ class _PendingList extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; return FutureBuilder>( future: future, builder: (context, snapshot) { @@ -185,21 +189,20 @@ class _PendingList extends StatelessWidget { return FaiEmptyState( icon: Icons.cloud_off_outlined, iconColor: theme.colorScheme.error, - title: 'Hub unreachable', - hint: 'Start the hub with `fai serve`.', + title: l.hubUnreachable, + hint: l.hubUnreachableHint, action: FilledButton.tonal( onPressed: onRetry, - child: const Text('Retry'), + child: Text(l.buttonRetry), ), ); } final pending = snapshot.data ?? []; if (pending.isEmpty) { - return const FaiEmptyState( + return FaiEmptyState( icon: Icons.task_alt_outlined, - title: 'Inbox zero', - hint: - 'All caught up. New `system.approval@^0` steps land here automatically.', + title: l.approvalsInboxZero, + hint: l.approvalsInboxHint, ); } return ListView.separated( @@ -229,6 +232,7 @@ class _HistoryList extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; return FutureBuilder>( future: future, builder: (context, snapshot) { @@ -239,21 +243,20 @@ class _HistoryList extends StatelessWidget { return FaiEmptyState( icon: Icons.cloud_off_outlined, iconColor: theme.colorScheme.error, - title: 'Hub unreachable', - hint: 'Start the hub with `fai serve`.', + title: l.hubUnreachable, + hint: l.hubUnreachableHint, action: FilledButton.tonal( onPressed: onRetry, - child: const Text('Retry'), + child: Text(l.buttonRetry), ), ); } final decided = snapshot.data ?? []; if (decided.isEmpty) { - return const FaiEmptyState( + return FaiEmptyState( icon: Icons.history, - title: 'No history yet', - hint: - 'Decided approvals (approved / rejected / expired) appear here.\nThe audit log keeps the full hash-chained record.', + title: l.approvalsHistoryEmpty, + hint: l.approvalsHistoryEmptyHint, ); } return ListView.separated( @@ -284,6 +287,7 @@ class _ApprovalCard extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; return FaiCard( accentTop: true, child: Column( @@ -291,8 +295,8 @@ class _ApprovalCard extends StatelessWidget { children: [ Row( children: [ - const FaiPill( - label: 'pending', + FaiPill( + label: l.approvalsPillPending, tone: FaiPillTone.warning, icon: Icons.pending_outlined, ), @@ -307,7 +311,7 @@ class _ApprovalCard extends StatelessWidget { ), if (approval.expiresAt != null) FaiPill( - label: _expiresIn(approval.expiresAt!), + label: _expiresInLabel(context, approval.expiresAt!), tone: FaiPillTone.neutral, icon: Icons.schedule, ), @@ -323,7 +327,7 @@ class _ApprovalCard extends StatelessWidget { Padding( padding: const EdgeInsets.only(bottom: 4), child: Text( - 'PAYLOAD PREVIEW', + l.approvalsPayloadPreview, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, @@ -362,7 +366,7 @@ class _ApprovalCard extends StatelessWidget { OutlinedButton.icon( onPressed: onReject, icon: const Icon(Icons.close, size: 16), - label: const Text('Reject'), + label: Text(l.approvalsRejectButton), style: OutlinedButton.styleFrom( foregroundColor: theme.colorScheme.error, side: BorderSide( @@ -374,7 +378,7 @@ class _ApprovalCard extends StatelessWidget { FilledButton.icon( onPressed: onApprove, icon: const Icon(Icons.check, size: 16), - label: const Text('Approve'), + label: Text(l.approvalsApproveButton), ), ], ), @@ -393,13 +397,13 @@ class _ApprovalCard extends StatelessWidget { } } - String _expiresIn(DateTime t) { + String _expiresInLabel(BuildContext context, DateTime t) { + final l = AppLocalizations.of(context)!; final remaining = t.difference(DateTime.now()); - if (remaining.isNegative) return 'expired'; + if (remaining.isNegative) return l.approvalsPillExpired; final minutes = remaining.inMinutes; - if (minutes < 60) return '${minutes}m'; - final hours = remaining.inHours; - return '${hours}h'; + if (minutes < 60) return l.approvalsExpiresIn(minutes); + return l.approvalsExpiresInHours(remaining.inHours); } } @@ -414,7 +418,7 @@ class _HistoryRow extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final (tone, icon, label) = _statusPresentation(record.status); + final (tone, icon, label) = _statusPresentation(context, record.status); final decided = record.decidedAt; final timeStr = decided == null ? '' : _formatTimestamp(decided.toLocal()); @@ -467,14 +471,22 @@ class _HistoryRow extends StatelessWidget { ); } - static (FaiPillTone, IconData, String) _statusPresentation(String status) { + static (FaiPillTone, IconData, String) _statusPresentation( + BuildContext context, + String status, + ) { + final l = AppLocalizations.of(context)!; switch (status) { case 'approved': - return (FaiPillTone.success, Icons.check, 'approved'); + return (FaiPillTone.success, Icons.check, l.approvalsPillApproved); case 'rejected': - return (FaiPillTone.danger, Icons.close, 'rejected'); + return (FaiPillTone.danger, Icons.close, l.approvalsPillRejected); case 'expired': - return (FaiPillTone.neutral, Icons.timer_off_outlined, 'expired'); + return ( + FaiPillTone.neutral, + Icons.timer_off_outlined, + l.approvalsPillExpired, + ); default: return (FaiPillTone.neutral, Icons.help_outline, status); } @@ -488,7 +500,8 @@ class _HistoryDialog extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - final (tone, icon, label) = _HistoryRow._statusPresentation(record.status); + final (tone, icon, label) = + _HistoryRow._statusPresentation(context, record.status); return AlertDialog( title: Row( children: [ @@ -511,16 +524,17 @@ class _HistoryDialog extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ if (record.decidedAt != null) - _kv(theme, 'decided', + _kv(theme, AppLocalizations.of(context)!.approvalsDialogDecided, '${_formatTimestamp(record.decidedAt!.toLocal())} ' - '· by ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}'), - _kv(theme, 'created', + '· ${record.decidedBy.isEmpty ? "(unknown)" : record.decidedBy}'), + _kv(theme, AppLocalizations.of(context)!.approvalsDialogCreated, _formatTimestamp(record.createdAt.toLocal())), if (record.reason.isNotEmpty) - _kv(theme, 'reason', record.reason), + _kv(theme, AppLocalizations.of(context)!.approvalsDialogReason, + record.reason), const SizedBox(height: FaiSpace.md), Text( - 'PROMPT', + AppLocalizations.of(context)!.approvalsDialogPrompt, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, @@ -534,7 +548,7 @@ class _HistoryDialog extends StatelessWidget { if (record.payloadPreview != null) ...[ const SizedBox(height: FaiSpace.md), Text( - 'PAYLOAD PREVIEW', + AppLocalizations.of(context)!.approvalsPayloadPreview, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, @@ -570,7 +584,7 @@ class _HistoryDialog extends StatelessWidget { actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Close'), + child: Text(AppLocalizations.of(context)!.buttonClose), ), ], ); diff --git a/lib/pages/audit.dart b/lib/pages/audit.dart index 29a4ae6..9394c27 100644 --- a/lib/pages/audit.dart +++ b/lib/pages/audit.dart @@ -41,6 +41,7 @@ class _AuditPageState extends State { } Future _onClearPressed() async { + final l = AppLocalizations.of(context)!; final outcome = await _ClearAuditDialog.show(context); if (outcome == null || !mounted) return; try { @@ -50,18 +51,13 @@ class _AuditPageState extends State { ); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Cleared ${r.purged} events on channel "${r.channel}". ' - 'Chain reseeded with marker.', - ), - ), + SnackBar(content: Text(l.auditClearedToast(r.purged, r.channel))), ); _refresh(); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Clear failed: ${_friendly(e)}')), + SnackBar(content: Text(l.auditClearFailed(_friendly(e)))), ); } } @@ -115,7 +111,7 @@ class _AuditPageState extends State { ), IconButton( icon: const Icon(Icons.delete_sweep_outlined, size: 18), - tooltip: 'Clear log (local/dev channels only)', + tooltip: AppLocalizations.of(context)!.auditClearLogTooltip, onPressed: _onClearPressed, ), const SizedBox(width: FaiSpace.sm), @@ -131,15 +127,15 @@ class _AuditPageState extends State { ? FaiEmptyState( icon: Icons.cloud_off_outlined, iconColor: theme.colorScheme.error, - title: 'Hub unreachable', - hint: 'Start the hub with `fai serve`.', + title: AppLocalizations.of(context)!.hubUnreachable, + hint: AppLocalizations.of(context)!.hubUnreachableHint, ) : filtered.isEmpty - ? const FaiEmptyState( + ? FaiEmptyState( icon: Icons.timeline_outlined, - title: 'No events yet', + title: AppLocalizations.of(context)!.auditNoEvents, hint: - 'Run a flow to populate the audit stream.\nEvents appear here within seconds.', + AppLocalizations.of(context)!.auditNoEventsHint, ) : ListView.separated( padding: const EdgeInsets.all(FaiSpace.xl), @@ -220,11 +216,12 @@ class _FilterChips extends StatelessWidget { @override Widget build(BuildContext context) { - const items = [ - ('all', 'all'), - ('flow.', 'flow'), - ('step.', 'step'), - ('module.', 'module'), + final l = AppLocalizations.of(context)!; + final items = [ + ('all', l.auditFilterAll), + ('flow.', l.auditFilterFlow), + ('step.', l.auditFilterStep), + ('module.', l.auditFilterModule), ]; return Row( children: [ @@ -317,6 +314,7 @@ class _LiveStatusBar extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; final live = error == null; return Container( width: double.infinity, @@ -339,8 +337,8 @@ class _LiveStatusBar extends StatelessWidget { const SizedBox(width: FaiSpace.sm), Text( live - ? 'live · polling 2s · $eventCount event${eventCount == 1 ? '' : 's'}' - : 'disconnected · $error', + ? l.auditLiveStatus(eventCount) + : l.auditDisconnected(error!), style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -356,7 +354,7 @@ class _LiveStatusBar extends StatelessWidget { ), const SizedBox(width: 4), Text( - 'hash chain verified', + l.auditHashChainVerified, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.w500, @@ -445,6 +443,7 @@ class _EventDetailDialogState extends State<_EventDetailDialog> { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; String prettyDetail() { final raw = event.detail; if (raw == null || raw.isEmpty) return ''; @@ -511,7 +510,7 @@ class _EventDetailDialogState extends State<_EventDetailDialog> { if (detail.isNotEmpty) ...[ const SizedBox(height: FaiSpace.md), Text( - 'DETAIL', + l.auditDetailHeader, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, @@ -556,24 +555,24 @@ class _EventDetailDialogState extends State<_EventDetailDialog> { icon: const Icon(Icons.auto_awesome, size: 16), label: Text( _explaining - ? 'Asking…' + ? l.auditAsking : _explanation == null - ? 'Explain' - : 'Re-ask', + ? l.auditExplain + : l.auditReask, ), ) else if (_aiStatus != null && !_aiStatus!.enabled && (event.error != null)) Tooltip( - message: 'Configure System AI in Settings (Cmd+,)', + message: l.auditConfigureSystemAi, child: OutlinedButton.icon( onPressed: null, icon: const Icon(Icons.auto_awesome_outlined, size: 16), - label: const Text('Explain'), + label: Text(l.auditExplain), ), ), TextButton( onPressed: () => Navigator.pop(context), - child: const Text('Close'), + child: Text(l.buttonClose), ), ], ); @@ -648,6 +647,7 @@ class _ExplanationPanel extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; final ok = result?.isSuccess ?? false; final color = explaining ? theme.colorScheme.primary @@ -670,7 +670,7 @@ class _ExplanationPanel extends StatelessWidget { Icon(Icons.auto_awesome, size: 14, color: color), const SizedBox(width: FaiSpace.xs), Text( - 'SYSTEM AI', + l.auditSystemAi, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, @@ -686,12 +686,16 @@ class _ExplanationPanel extends StatelessWidget { if (result != null && result!.isSuccess && result!.cached) ...[ const SizedBox(width: FaiSpace.xs), Tooltip( - message: - 'Served from the local cache. Originally generated ' - '${result!.cachedAt.isEmpty ? "at unknown time" : "on ${result!.cachedAt}"}' - '${result!.cacheHits > 1 ? " · ${result!.cacheHits} hits" : ""}.', - child: const FaiPill( - label: 'cached', + message: () { + final hits = result!.cacheHits > 1 + ? l.auditCachedTooltipHits(result!.cacheHits) + : ''; + return result!.cachedAt.isEmpty + ? l.auditCachedTooltipUnknown(hits) + : l.auditCachedTooltipKnown(result!.cachedAt, hits); + }(), + child: FaiPill( + label: l.auditCachedPill, tone: FaiPillTone.success, icon: Icons.bolt, ), @@ -701,8 +705,8 @@ class _ExplanationPanel extends StatelessWidget { if (result != null && result!.isSuccess && result!.latencyMs > 0) Text( result!.cached - ? 'orig ${result!.latencyMs} ms' - : '${result!.latencyMs} ms', + ? l.auditOriginalLatency(result!.latencyMs) + : l.auditLatency(result!.latencyMs), style: FaiTheme.mono( size: 10, color: theme.colorScheme.onSurfaceVariant, @@ -711,7 +715,7 @@ class _ExplanationPanel extends StatelessWidget { if (result != null && result!.isSuccess && onRegenerate != null) ...[ const SizedBox(width: FaiSpace.sm), Tooltip( - message: 'Regenerate — skip cache, ask the model again', + message: l.auditRegenerateTooltip, child: IconButton( icon: const Icon(Icons.refresh, size: 14), visualDensity: VisualDensity.compact, @@ -732,7 +736,7 @@ class _ExplanationPanel extends StatelessWidget { ), const SizedBox(width: FaiSpace.sm), Text( - 'Asking the configured System AI…', + l.auditAskingFull, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -749,7 +753,7 @@ class _ExplanationPanel extends StatelessWidget { if (result!.fixHint.isNotEmpty) ...[ const SizedBox(height: FaiSpace.sm), Text( - 'FIX', + l.auditFixLabel, style: theme.textTheme.labelSmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, letterSpacing: 0.6, @@ -820,8 +824,9 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final l = AppLocalizations.of(context)!; return AlertDialog( - title: const Text('Clear audit log?'), + title: Text(l.auditClearDialogTitle), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 460), child: Column( @@ -829,9 +834,7 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Wipes every audit event on the active channel and seeds a ' - 'fresh chain.reset marker carrying reviewer + reason. ' - 'Refused on beta / production. Irreversible.', + l.auditClearDialogBody, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), @@ -839,9 +842,9 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> { const SizedBox(height: FaiSpace.lg), TextField( controller: _reviewer, - decoration: const InputDecoration( - labelText: 'Reviewer', - border: OutlineInputBorder(), + decoration: InputDecoration( + labelText: l.auditClearReviewerLabel, + border: const OutlineInputBorder(), isDense: true, ), ), @@ -852,9 +855,9 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> { controller: _reason, autofocus: true, decoration: InputDecoration( - labelText: 'Reason (recorded in chain.reset marker)', + labelText: l.auditClearReasonLabel, helperText: _reason.text.trim().isEmpty - ? 'Required — auditors will read this.' + ? l.auditClearReasonHelper : null, border: const OutlineInputBorder(), isDense: true, @@ -867,7 +870,7 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> { actions: [ TextButton( onPressed: () => Navigator.pop(context, null), - child: const Text('Cancel'), + child: Text(l.buttonCancel), ), ValueListenableBuilder( valueListenable: _reason, @@ -885,7 +888,7 @@ class _ClearAuditDialogState extends State<_ClearAuditDialog> { backgroundColor: Theme.of(context).colorScheme.error, foregroundColor: Theme.of(context).colorScheme.onError, ), - child: const Text('Clear log'), + child: Text(l.auditClearLogButton), ), ), ], diff --git a/lib/widgets/fai_settings_dialog.dart b/lib/widgets/fai_settings_dialog.dart index e5cfe3d..5e9531b 100644 --- a/lib/widgets/fai_settings_dialog.dart +++ b/lib/widgets/fai_settings_dialog.dart @@ -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 { ); 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 { 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 { 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 { ); 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 { 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 { 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 { }); 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 { @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 { 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 { 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 { 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 { 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 { 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 { ), 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 { 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 { 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( - 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 clients; final ValueChanged 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..` 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..` 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..` 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..` 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.', + ), +]; diff --git a/pubspec.yaml b/pubspec.yaml index 7d88930..fe05860 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: fai_studio description: "F∆I Studio — desktop GUI for the F∆I hub" publish_to: 'none' -version: 0.25.0 +version: 0.26.0 environment: sdk: ^3.11.0-200.1.beta