diff --git a/src/Controller/Onboarding/OnboardingController.php b/src/Controller/Onboarding/OnboardingController.php index 09d40b22..b92bba1c 100644 --- a/src/Controller/Onboarding/OnboardingController.php +++ b/src/Controller/Onboarding/OnboardingController.php @@ -47,7 +47,7 @@ final class OnboardingController extends AbstractController * progress rail generates a URL for every applicable step. Adding a case * without adding it here is what OnboardingStepCoverageTest now checks. */ - public const string STEP_PATTERN = 'admin-mail|admin-integrations|account|profile|security|appearance|integrations'; + public const string STEP_PATTERN = 'admin-mail|admin-integrations|admin-ai|account|profile|security|appearance|integrations'; public function __construct( private readonly OnboardingFlow $flow, diff --git a/src/Domain/Enum/Onboarding/OnboardingStep.php b/src/Domain/Enum/Onboarding/OnboardingStep.php index 9b5ddf43..a3855308 100644 --- a/src/Domain/Enum/Onboarding/OnboardingStep.php +++ b/src/Domain/Enum/Onboarding/OnboardingStep.php @@ -30,6 +30,10 @@ enum OnboardingStep: string { case AdminMailCredentials = 'admin-mail'; case AdminIntegrationCredentials = 'admin-integrations'; + // After the two credential steps and before anything a person sets up for + // themselves: it configures the install, and it is the only one of the + // three an admin can reasonably answer with "no". + case AdminAi = 'admin-ai'; case Account = 'account'; case Integrations = 'integrations'; case Profile = 'profile'; @@ -48,6 +52,7 @@ enum OnboardingStep: string return match ($this) { self::AdminMailCredentials => 'fa-solid fa-key', self::AdminIntegrationCredentials => 'fa-solid fa-plug-circle-bolt', + self::AdminAi => 'fa-solid fa-wand-magic-sparkles', self::Account => 'fa-solid fa-envelope', self::Profile => 'fa-solid fa-user', self::Security => 'fa-solid fa-shield-halved', @@ -63,7 +68,7 @@ enum OnboardingStep: string public function requiresAdmin(): bool { return match ($this) { - self::AdminMailCredentials, self::AdminIntegrationCredentials => true, + self::AdminMailCredentials, self::AdminIntegrationCredentials, self::AdminAi => true, default => false, }; } diff --git a/src/Service/Onboarding/Step/AdminAiStepHandler.php b/src/Service/Onboarding/Step/AdminAiStepHandler.php new file mode 100644 index 00000000..6e8de314 --- /dev/null +++ b/src/Service/Onboarding/Step/AdminAiStepHandler.php @@ -0,0 +1,121 @@ +getRoles(), true); + } + + public function isSatisfied(User $user): bool + { + // A row exists only because somebody pressed the button on this page or + // in Admin → AI, so its presence is the record that the question was + // put and answered — including answered "no". + return null !== $this->settings->current(); + } + + public function failureMessage(User $user): ?string + { + // Nothing here can be verified without reaching another machine, and a + // wizard step that fails because a container is not up yet would block + // setup on something that has nothing to do with setup. The Test button + // in Admin → AI is where that question belongs. + return null; + } + + public function template(): string + { + return 'onboarding/steps/_admin_ai.html.twig'; + } + + public function createForm(User $user, Request $request): FormInterface + { + return $this->formFactory->create(AiSettingsType::class, $this->settings->currentOrDefault(), [ + // Explicit, because a form rendered into a turbo-frame otherwise + // posts to the document URL — which here is whatever page the + // wizard was opened over. + 'action' => $this->urlGenerator->generate('app_onboarding_step', [ + 'step' => $this->step()->value, + ]), + ]); + } + + public function viewData(User $user, Request $request): array + { + return [ + 'settings' => $this->settings->currentOrDefault(), + 'aside_template' => 'onboarding/steps/_admin_ai_aside.html.twig', + ]; + } + + public function persist(User $user, FormInterface $form): void + { + $settings = $form->getData(); + + // Unmapped, so an empty box leaves a stored token alone rather than + // clearing it — the same rule the admin page follows. + $token = (string) $form->get('apiToken')->getData(); + + if ('' !== trim($token)) { + $settings->apiToken = $token; + } + + // Persisted even when every box is empty, and that is the point: the + // row IS the record that somebody was asked and said no. Without it the + // step would be unsatisfiable by anybody who does not want the feature. + $this->entityManager->persist($settings); + $this->entityManager->flush(); + } +} diff --git a/templates/onboarding/steps/_admin_ai.html.twig b/templates/onboarding/steps/_admin_ai.html.twig new file mode 100644 index 00000000..e47e169c --- /dev/null +++ b/templates/onboarding/steps/_admin_ai.html.twig @@ -0,0 +1,13 @@ +{# +The optional step. Everything here may be left exactly as it is, and pressing +Next with nothing filled in is a complete answer — see AdminAiStepHandler on why +that has to be recorded rather than merely tolerated. + +The form is the admin panel's own, so there is one description of these fields +rather than two that drift apart. +#} +

+ {{ 'onboarding.step.admin-ai.intro'|trans }} +

+ +{{ form_widget(form) }} diff --git a/templates/onboarding/steps/_admin_ai_aside.html.twig b/templates/onboarding/steps/_admin_ai_aside.html.twig new file mode 100644 index 00000000..2cfa8915 --- /dev/null +++ b/templates/onboarding/steps/_admin_ai_aside.html.twig @@ -0,0 +1,9 @@ +{# What somebody needs to have ready before this step is answerable, and + permission to skip it — which is the more important half. #} +

{{ 'onboarding.aside.admin_ai.heading'|trans }}

+

{{ 'onboarding.aside.admin_ai.body'|trans }}

+ +
+

{{ 'onboarding.aside.admin_ai.skip_heading'|trans }}

+

{{ 'onboarding.aside.admin_ai.skip_body'|trans }}

+
diff --git a/translations/messages.de.yaml b/translations/messages.de.yaml index e74599a4..90b92dc6 100644 --- a/translations/messages.de.yaml +++ b/translations/messages.de.yaml @@ -1804,6 +1804,11 @@ onboarding: oauth: >- Für Gmail und Outlook nimm stattdessen die Anmelde-Buttons unter Einstellungen → Mail-Konten — dort brauchst du keine Serveradressen. + admin_ai: + body: 'Die Adresse eines Ollama-Containers in deinem Netz und die Namen zweier Modelle, die dort liegen — eines zum Schreiben, eines für die Suche. Es verlässt nichts dein Netz.' + heading: 'Was du brauchst' + skip_body: 'Klick auf Weiter, ohne etwas auszufüllen. plMail ist auch ohne das ein vollständiger Mail-Client, und du kannst später jederzeit unter Administration → KI zurückkommen.' + skip_heading: 'Kein Interesse?' admin_integrations: body: Registriere plMail beim Dienst und trage die erhaltenen Zugangsdaten hier ein. heading: So richtest du das ein @@ -1864,6 +1869,10 @@ onboarding: account: lead: Verbinde das Postfach, das du wirklich liest — plMail beginnt dann zu synchronisieren. title: Mail-Konto + admin-ai: + intro: 'plMail kann ein Sprachmodell benutzen, das du selbst betreibst — zum Suchen nach Bedeutung, zum Einsortieren und als Hilfe beim Schreiben. Das ist völlig optional, du darfst hier alles leer lassen.' + lead: 'Optional. Lass es leer, wenn du es nicht willst.' + title: 'Sprachmodell' admin-integrations: lead: Zugangsdaten für die Dienste, aus denen hier alle Dateien anhängen können. title: Integrations-Zugangsdaten diff --git a/translations/messages.en.yaml b/translations/messages.en.yaml index 03418879..406c1f30 100644 --- a/translations/messages.en.yaml +++ b/translations/messages.en.yaml @@ -1796,6 +1796,11 @@ onboarding: oauth: >- For Gmail and Outlook, use the sign-in buttons under Settings → Mail accounts instead — no server addresses needed. + admin_ai: + body: 'The address of an Ollama container on your network, and the names of two models it is holding — one for writing, one for search. Nothing leaves your network.' + heading: 'What you need' + skip_body: 'Press Next with everything empty. plMail is a complete mail client without any of this, and you can come back to it later in Admin → AI.' + skip_heading: 'Not interested?' admin_integrations: body: Register plMail with the service, then paste the credentials it gives you. heading: Setting this up @@ -1855,6 +1860,10 @@ onboarding: account: lead: Connect the mailbox you actually read, and plMail starts syncing it. title: Mail account + admin-ai: + intro: 'plMail can use a language model you run yourself — for searching by meaning, sorting mail, and help with writing. It is entirely optional, and you can leave every box below empty.' + lead: 'Optional. Leave it empty if you do not want it.' + title: 'Language model' admin-integrations: lead: Credentials for the services everyone here can attach files from. title: Integration credentials diff --git a/translations/messages.en_PI.yaml b/translations/messages.en_PI.yaml index cadb8b88..bd3f9686 100644 --- a/translations/messages.en_PI.yaml +++ b/translations/messages.en_PI.yaml @@ -1494,6 +1494,11 @@ onboarding: oauth: >- For Gmail an' Outlook, use the sign-aboard buttons under Riggin' → Mailbags instead — no addresses needed. + admin_ai: + body: "The berth of an Ollama box on yer network, an' the names o' two models it holds — one for writin', one for searchin'. Nowt leaves yer waters." + heading: 'What ye need' + skip_body: "Press Next with all of it empty. plMail be a whole mail client without any o' this, an' ye can come back later under Admin -> Oracle." + skip_heading: 'Not for ye?' admin_integrations: body: Register yer ship with the port, then paste the keys it hands ye. heading: Riggin' this up @@ -1553,6 +1558,10 @@ onboarding: account: lead: Hitch up the mailbag ye actually read, an' plMail starts haulin' it in. title: Mailbag + admin-ai: + intro: "plMail can consult an oracle ye run yerself — for searchin' by meanin', sortin' mail, an' help with writin'. Wholly optional; leave ev'ry box below empty if ye like." + lead: "Optional. Leave it be if ye don't want it." + title: 'Oracle' admin-integrations: lead: Keys for the ports every soul aboard can load cargo from. title: Port keys