mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
feat(ai): the setup wizard asks once, and takes no for an answer
The AI step, sitting with the other two admin steps because it configures the install rather than a person's mailbox — and last of the three, because it is the only one that can reasonably be answered with "no". Mail credentials and integrations decide whether the app works at all. SATISFIED MEANS ANSWERED, NOT ENABLED "No thank you" is a complete answer and has to count as one, or the wizard nags forever at every install that does not want this — which will be most of them. So persist() writes the row even when every box is empty, and isSatisfied() asks only whether a row exists. That is deliberately weaker than the sibling steps, and it has to be: the strongest available signal of "decided against" is indistinguishable from "never opened" unless something is written down when they say no. The row IS that record — the only way one appears is somebody pressing the button here or in Admin -> AI. FOUR PLACES, NOT ONE Adding a step touches the enum case (the ordering), the icon match, the requiresAdmin match, and OnboardingController::STEP_PATTERN. The last is the one that does not update itself, and the comment above it is blunt about the cost: a new case without it 404s AND takes the whole wizard down with it, because the progress rail generates a URL for every applicable step. The icon match is worse in a quieter way — non-exhaustive is a fatal error, not a missing icon. OnboardingStepCoverageTest exists to catch exactly this and passes. The step renders the admin panel's own form, so there is one description of these fields rather than two that drift. The aside spends half its words on permission to skip, because that is the answer most installs should give and a wizard that only explains how to say yes is a wizard that pressures people into saying it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
64ac8f3704
commit
d0d18e3059
8 files changed
+177
-2
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service\Onboarding\Step;
|
||||
|
||||
use App\Domain\Enum\Onboarding\OnboardingStep;
|
||||
use App\Entity\User\User;
|
||||
use App\Form\Admin\AiSettingsType;
|
||||
use App\Repository\Ai\AiSettingsRepository;
|
||||
use App\Service\Onboarding\OnboardingStepHandlerInterface;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Form\FormFactoryInterface;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
|
||||
/**
|
||||
* The optional one: whether this installation talks to a language model.
|
||||
*
|
||||
* It sits with the other two admin steps because it configures the install
|
||||
* rather than a person's mailbox, and it sits LAST of the three because it is
|
||||
* the only one an administrator can reasonably answer with "no" — mail
|
||||
* credentials and integrations decide whether the app works at all, and this
|
||||
* decides whether it does something extra.
|
||||
*
|
||||
* SATISFIED MEANS ANSWERED, NOT ENABLED
|
||||
* ─────────────────────────────────────
|
||||
* "No thank you" is a complete answer here and has to count as one, or the
|
||||
* wizard would nag forever at every install that does not want this — which
|
||||
* will be most of them. So the step is satisfied once somebody has either
|
||||
* switched it on with a host, or explicitly recorded that they looked: an
|
||||
* ai_settings row existing at all is that record, because the only way one
|
||||
* appears is somebody pressing the button on this page or in Admin → AI.
|
||||
*
|
||||
* That is deliberately weaker than the other admin steps. It has to be: the
|
||||
* strongest available signal of "decided against" is indistinguishable from
|
||||
* "never opened" unless we write something down when they say no.
|
||||
*/
|
||||
final readonly class AdminAiStepHandler implements OnboardingStepHandlerInterface
|
||||
{
|
||||
public function __construct(
|
||||
private FormFactoryInterface $formFactory,
|
||||
private UrlGeneratorInterface $urlGenerator,
|
||||
private AiSettingsRepository $settings,
|
||||
private EntityManagerInterface $entityManager,
|
||||
) {
|
||||
}
|
||||
|
||||
public function step(): OnboardingStep
|
||||
{
|
||||
return OnboardingStep::AdminAi;
|
||||
}
|
||||
|
||||
public function isApplicable(User $user): bool
|
||||
{
|
||||
// Never whether they have done it — see the interface. Only: may they.
|
||||
return true === in_array(User::ROLE_ADMIN, $user->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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
#}
|
||||
<p class="text-sm text-ink-soft leading-relaxed">
|
||||
{{ 'onboarding.step.admin-ai.intro'|trans }}
|
||||
</p>
|
||||
|
||||
{{ form_widget(form) }}
|
||||
@@ -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. #}
|
||||
<p class="font-medium text-ink-soft">{{ 'onboarding.aside.admin_ai.heading'|trans }}</p>
|
||||
<p class="mt-2">{{ 'onboarding.aside.admin_ai.body'|trans }}</p>
|
||||
|
||||
<div class="mt-4 pt-4 border-t border-line">
|
||||
<p class="font-medium text-ink-soft">{{ 'onboarding.aside.admin_ai.skip_heading'|trans }}</p>
|
||||
<p class="mt-2">{{ 'onboarding.aside.admin_ai.skip_body'|trans }}</p>
|
||||
</div>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user