feat(ai): make the language model observable, and the composer honest

"I click the AI button in the composer and nothing happens" turned out not to be
about the model at all. #editor() queried data-compose--toolbar-target while the
composer renders data-compose--compose-toolbar-target, so run() returned at its
first guard: no request, no status, no error. The feature had never worked once.
#subject() was wrong the same way and quieter — every prompt went out with an
empty subject. And the status element lived INSIDE the dropdown that the same
click closes, so even once working it would have written into a hidden subtree.

The rest of this is what makes the next such thing findable.

RECORDING
One row per model call, tagged by the workload that caused it, and nothing about
what was in it — no prompt, no completion, no message id, no subject, no
address. error_kind is a category from a closed set for the same reason: an HTTP
client's exception message routinely quotes the request body back, and here that
is somebody's mail. OllamaClient was discarding Ollama's whole timing block; it
now returns it, and AiAssistant — the only place that knows both the feature and
the model — writes the row.

DBAL and deliberately no entity, following EmbeddingStore: BackfillEmbeddings
clears the EntityManager after every chunk, so an unflushed ORM row would be
detached and silently dropped, fifty at a time, for as long as a backfill runs —
which is exactly the window the panel exists to show.

AiFeature could not carry this. A four-character search query somebody is
waiting on and two thousand characters of mail body written unattended a hundred
thousand times both went through embed() with no discriminator, and averaged
together the second buries the first — the panel would report backfill
throughput under the heading "search". AiCallFeature splits them.

THE PANEL
Live state from /api/ps, which is passive and safe to poll. Six states that look
different, and the one that justifies the work is "partly on the CPU":
size_vram < size means layers spilled, throughput collapses, and the model still
answers correctly — invisible everywhere else, so everything is simply slow
forever.

THE COMPOSER
Tokens stream into a provisional surface and commit in one operation, because
hundreds of per-token insertions destroy the undo stack and Ctrl+Z is the first
thing anyone tries. Waiting, generating, done and failed are distinct states
with reasons: outside the keep-alive window the first thirteen seconds produce
nothing while 20 GiB loads, and a featureless spinner for that IS the bug.
Cancellation propagates to the host — an abandoned draft otherwise holds the GPU
and everything else queues behind it.

SEARCH
Silent degradation to keyword-only is replaced by the reason it degraded, and
rows found by meaning say so. The semantic arm and its join are now matched on
model AND width: vectors from different models are not comparable, and after
somebody changes the search model every existing vector is dead weight. On a
pgvector install comparing different widths raises an error — a 500 on
/mail/search caused by an admin dropdown — and on the shipped loop it silently
compares the overlap, which is worse. Coverage is stated where results are shown,
because semantic search over an 8%-indexed mailbox is a trap.

BACKFILL
Progress is counted from the database, never accumulated, so a killed worker
resumes. It stands aside while anyone is using the AI and comes back on its own:
both compete for one integrated GPU, and a batch in flight when somebody presses
the button puts them straight back to "nothing happens".

Also carries the cron line for app:jobs:reap from the commit before this one.

Verified against the real host: reachable, both configured models installed,
timings parsed, 1024-dimension embeddings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-27 00:38:43 +02:00
1 parent 92004ee576
commit 377d6026b8
73 files changed
+9508 -289

No files matched your search

+159 -7
View File
@@ -1013,23 +1013,176 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
ask for a first draft. #}
{% if ai_writing_help_enabled() %}
<div data-controller="ui--dropdown compose--ai-assist"
data-compose--ai-assist-url-value="{{ path('app_compose_assist') }}"
data-compose--ai-assist-url-value="{{ path('app_compose_assist_stream') }}"
{# ctx.replyTo, not a variable of our own: the context object is what
already survives the round trip through both undo streams, so a
reply drafted after a cancelled send still knows what it answers. #}
data-compose--ai-assist-in-reply-to-value="{{ ctx.replyTo }}"
{# One value per state, and the states are the point. A single
"busy" label was the whole reported bug: fifteen silent seconds
and a spinner are indistinguishable from a dead button, and the
composer cannot tell them apart without words for each. #}
data-compose--ai-assist-sent-label-value="{{ 'compose.ai.state.sent'|trans }}"
data-compose--ai-assist-waiting-label-value="{{ 'compose.ai.slow_hint'|trans }}"
data-compose--ai-assist-busy-label-value="{{ 'compose.ai.working'|trans }}"
data-compose--ai-assist-failed-label-value="{{ 'compose.ai.no_answer'|trans }}">
data-compose--ai-assist-done-label-value="{{ 'compose.ai.state.done'|trans }}"
data-compose--ai-assist-stopped-label-value="{{ 'compose.ai.state.stopped'|trans }}"
{# %count% and %rate% are substituted in the browser. They change
many times a second and Twig only runs once, so the template
supplies the SENTENCE and the controller supplies the numbers. #}
data-compose--ai-assist-rate-label-value="{{ 'compose.ai.rate'|trans }}"
data-compose--ai-assist-timing-label-value="{{ 'compose.ai.timing'|trans }}"
data-compose--ai-assist-failed-label-value="{{ 'compose.ai.failed'|trans }}"
data-compose--ai-assist-no-answer-label-value="{{ 'compose.ai.no_answer'|trans }}"
data-compose--ai-assist-disabled-label-value="{{ 'compose.ai.disabled'|trans }}"
{# The three failures a person can actually do something about:
pull the model, look at the host's log, wait and retry. The
other three collapse into `failed`, which is honest about
them — see the controller's #sentenceFor(). #}
data-compose--ai-assist-timeout-label-value="{{ 'compose.ai.error.timeout'|trans }}"
data-compose--ai-assist-not-pulled-label-value="{{ 'compose.ai.error.not_pulled'|trans }}"
data-compose--ai-assist-refused-label-value="{{ 'compose.ai.error.refused'|trans }}">
<button type="button"
data-action="click->ui--dropdown#toggle"
title="{{ 'compose.ai.label'|trans }}"
aria-label="{{ 'compose.ai.label'|trans }}"
class="{{ ic }}"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i></button>
{# `rise` and `bottom-full` for the reason the menu
below gives: it comes UP out of the action bar,
and a menu sliding down into the button that
opened it reads as the wrong thing arriving. #}
{# ── The live surface ──────────────────────────────
It deliberately does NOT live inside the menu. It used
to, and the same click that starts the request also
closes the menu — so every word this controller said
was written into a subtree that had just been hidden,
and the writer saw literally nothing happen. That is
the whole of the "I press it and nothing happens"
report: the request was running the entire time and
there was no surface left on screen to say so.
A sibling of the menu, positioned the same way, so it
appears where the eye already is — and it outlives the
menu, which is the entire point.
WHY THE TOKENS LAND HERE AND NOT IN THE EDITOR
──────────────────────────────────────────────
Streaming into the contenteditable would mean hundreds
of separate DOM insertions, and the browser's undo
stack records them as hundreds of steps — so the first
thing anybody tries, Ctrl+Z to take the generated text
back out, would undo one token and then start eating
what they had written themselves. It also puts a
moving target under a caret the writer may be using:
they can keep typing while this runs, and the saved
range is the fragile piece in every one of the
composer's other insertions.
So it accumulates HERE, plainly provisional, and goes
into the document as ONE operation when accepted —
one insertion, one undo step. Nothing is in the draft
until somebody says so.
Full width rather than the menu's md:w-64: this holds
prose, and a paragraph of reply in a 16rem column is
unreadable. It anchors to the toolbar BLOCK, which
spans the window (see the `relative` wrapper above),
so it is on screen by construction at every width.
`rise` and `bottom-full` for the reason the menu below
gives: it comes UP out of the action bar, and a panel
sliding down into the button that opened it reads as
the wrong thing arriving. The attribute is the only
animation on this surface, and motion.css already
zeroes it under prefers-reduced-motion — the tokens
arriving are the progress indicator, and text
appearing is not motion anybody asked to opt out of. #}
<div data-compose--ai-assist-target="panel" hidden
data-enter="rise"
class="absolute bottom-full mb-2 left-2 right-2 md:left-3 md:right-3
z-30 popover flex flex-col">
<div class="flex items-start gap-2 px-3 py-2">
<div class="min-w-0 flex-1">
{# `role="status"` with aria-live so the STATE TRANSITIONS
are announced rather than only drawn. Only transitions:
the token counter beside it changes about twenty times a
second, and putting that in a live region would read a
screen reader user a new number instead of letting them
hear anything else. Hidden while empty so an idle
composer carries no furniture. #}
<p data-compose--ai-assist-target="status"
role="status"
aria-live="polite"
hidden
class="text-[11px] leading-snug text-ink-muted"></p>
{# The numbers. aria-hidden for the reason above; the
final count is repeated in the status line's `title`
where it can be read on purpose rather than at 18 Hz. #}
<p data-compose--ai-assist-target="meter"
aria-hidden="true"
hidden
class="mt-0.5 text-[11px] leading-snug text-ink-faint tabular-nums"></p>
</div>
{# A real <button>, so it is in the tab order and answers
Enter and Space without anything being written here to
make it. Stopping matters more than it looks: an
abandoned generation keeps a 20 GiB model busy on a
machine with one GPU, and everything else queues behind
it. #}
<button type="button"
data-compose--ai-assist-target="stop"
hidden
data-action="click->compose--ai-assist#stop"
class="{{ tb }} shrink-0 text-[11px] hover:text-ink">
<i class="fa-solid fa-stop text-[9px] mr-1" aria-hidden="true"></i>{{ 'compose.ai.stop'|trans }}
</button>
</div>
{# Not contenteditable, and never focusable for editing: this is a
quotation of what the model said, not a second place to write.
`tabindex="0"` only so a keyboard can scroll a long draft.
`whitespace-pre-wrap` because the model answers plain text with
real newlines and they are the paragraphing. #}
<div data-compose--ai-assist-target="preview"
hidden
tabindex="0"
role="region"
aria-label="{{ 'compose.ai.preview'|trans }}"
class="px-3 pb-2 max-h-[32vh] overflow-y-auto
text-sm leading-relaxed text-ink
whitespace-pre-wrap break-words"></div>
{# Offered only once there is something to accept. Insert first
and leftmost because it is what the writer came for; discard
last because it is the one that throws work away. #}
<div data-compose--ai-assist-target="actions"
hidden
class="flex items-center gap-1 px-2 py-1.5 border-t border-line">
<button type="button"
data-action="click->compose--ai-assist#accept"
class="{{ tb }} text-[11px] text-accent hover:text-accent">
<i class="fa-solid fa-check text-[9px] mr-1" aria-hidden="true"></i>{{ 'compose.ai.accept'|trans }}
</button>
<button type="button"
data-action="click->compose--ai-assist#regenerate"
class="{{ tb }} text-[11px]">
<i class="fa-solid fa-rotate-right text-[9px] mr-1" aria-hidden="true"></i>{{ 'compose.ai.regenerate'|trans }}
</button>
<button type="button"
data-action="click->compose--ai-assist#discard"
class="{{ tb }} text-[11px] ml-auto">
{{ 'compose.ai.discard'|trans }}
</button>
</div>
{# Said on the surface the text is actually read from, not only
in the menu that started it — by the time somebody is deciding
whether to insert this, the menu has been shut for a minute. #}
<p class="px-3 pb-2 text-[11px] leading-snug text-ink-faint">
{{ 'compose.ai.disclaimer'|trans }}
</p>
</div>
<div data-ui--dropdown-target="menu" hidden
data-enter="rise"
class="absolute bottom-full mb-2 left-2 right-2
@@ -1058,7 +1211,6 @@ Variables: form, message, ctx (an App\Domain\DTO\Mail\ComposeContext)
and "a machine wrote this and you are about to
send it" is not a thing to mention quietly. #}
<p class="px-3 py-2 text-[11px] leading-snug text-ink-faint">
<span data-compose--ai-assist-target="status" class="block text-ink-muted"></span>
{{ 'compose.ai.disclaimer'|trans }}
</p>
</div>