Commit graph

816 commits

Author SHA1 Message Date
Daniel
8a6a4df121 refactor: citations are a markdown-it token, and the numbers you see are display order
The old renderer rewrote the text: it found "[n]" with regexes, renumbered
them, and swapped the result back in — which broke inside `arr[2][1]`, inside
HTML attributes, and whenever two turns disagreed about what "[3]" meant. It
also had a fallback markdown renderer of its own for when the rewrite
produced something markdown-it would not parse.

Now "[n]" is an inline rule registered on the same markdown-it instance that
renders everything else. The parser decides what is prose and what is code, a
link, or a URL, so the rule never sees "[1]" inside a code span, and it steps
aside for "[1](url)". Math is two more rules on the same parser instead of a
regex pre-pass, so "$" inside a URL is no longer math.

Identity vs display: the stored "[n]" and each card's id are the source's
identity (sourceNumber) and are never rewritten. The number a reader sees is
the order of first appearance, computed at render time from the token stream
(orderSourcesByCitation), so "one, then seven" cannot happen and a saved chat
re-opens pointing at the same cards it was saved with. Stored messages and
sources are untouched; export and the modal resolve by identity.

Translated HTML gets the same links through a TreeWalker over text nodes
(linkCitationsInHtml) rather than a regex over markup.

Deleted: renderCitationLinks, normalizeAdjacentCitationClusters, the
fallback renderer (fallbackMarkdown/renderMixedList/renderFallbackTable),
renderLatexText, CITATION_SCAN. Tests that asserted rewritten text now assert
token output; harnesses that render for real are given a parser.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 04:41:40 +02:00
Daniel
e376f69502 fix: each TTS model offers only the voices it will accept
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Docker Build / Build Docker image (push) Successful in 21s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s
"These settings don't work" — picking a model and testing with a voice
returned 500 every time. The gateway said why, once asked directly:

    voice must be one of the following voices: [autumn diana hannah austin daniel troy]

The screen was listing twelve Orpheus voices and six Kokoro ones in a
single flat list with no indication of which model would accept which,
because LITELLM_TTS_VOICES — written for one model — was treated as a
list that applied to all of them, and the Orpheus lists were pushed in
beside it. Choosing Orpheus and testing it with a Kokoro voice is not a
configuration; it is an error, and it was the default outcome.

LiteLLM cannot supply this. /model/info reports mode audio_speech for
all four models and carries no voice field for any of them. So the
mapping lives here, keyed by family so the gateway alias and the
upstream id resolve to one list, and every list was taken from the
provider rather than from documentation:

  Groq Orpheus English   autumn diana hannah austin daniel troy   (stated by Groq)
  Groq Orpheus Arabic    abdullah fahad sultan lulwa noura aisha  (stated by Groq)
  Fish s2.1-pro          alloy                                    (alloy returns audio; the rest 400)
  Kokoro                 sherpa/kokoro:* from LITELLM_TTS_VOICES  (the gateway's own list)

The environment still wins for the model it was written for, so the
local gateway's voices can change without a code change — but it
answers for that model only. A model with no list at all is offered
nothing rather than another model's voices, and a voice known to belong
to a different family is refused.

There were two copies of this knowledge before: getLiteLLMTTSVoicesForModel
branched by family and fell through to the env list for any model it did
not recognise — which is how Fish came to be offered six Kokoro voices.
One table now.

Also in this commit: citation renumbering skips fenced code, inline code
and math, so arr[2][1] in a code block is never rewritten. Renumbering at
render time was tried and reverted — it also has to skip HTML attributes,
and every such region is another regex branch. It stays at the answer
boundary, and the saved-chat boundary is next.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 03:54:31 +02:00
Daniel
3e8972348a feat: a revoked invitation can be deleted
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Docker Build / Build Docker image (push) Successful in 14s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 9s
Revoked codes had a Revoke button and nothing else — the row stayed on
the list for good. The reasoning was that revoke stops a live code and
leaves the row behind as a record, which is right about revoke and wrong
about what comes after: a revoked code is already dead. It cannot be
redeemed by anyone, so keeping it only fills the list.

The rule that matters is unchanged, and it is the only one that ever
mattered: a code that could still be redeemed is never deletable.
Deleting one takes it off the list without taking it out of anybody's
inbox — the holder keeps something that looks valid, it quietly stops
working, and nothing is left to say who had it. Revoke is what stops
such a code; deleting it afterwards is the second half of the same
thought, and it was missing.

Also removed a note that had been wrong since codes became copyable:
"The code is shown once, here. Only its hash is stored, so it cannot be
read again afterwards." Both sentences stopped being true when the
cipher was added so an invitation could be handed to somebody later than
the moment it was made.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 03:30:05 +02:00
Daniel
0e1e74882e feat: sources are numbered in the order the answer cites them
Borrowed from the quiz app's AI Mode, where validating citations and
ordering them fall out of the same pass: it collects the sources an
answer actually used into an insertion-ordered map, so the list comes
back in first-citation order for free.

Ours listed sources in retrieval order — an order the reader never sees
and has no way to follow. An answer whose first citation was [7] opened
a list that began at [1], so matching a marker to a source meant
hunting. Reference lists in published writing are numbered by first
appearance for exactly this reason.

Cited sources now come first, renumbered by first appearance, and the
markers in the text are rewritten to match. Anything retrieved and not
cited keeps its place after them, labelled "not cited" — the panel is
also a view of what the search returned, which is worth keeping, but it
should not sit among the numbers the answer used.

The marker itself now shows its number instead of the word "src". Every
citation read identically, so the only way to tell one from another was
to hover it — which made the numbered list beneath useless to match
against. The export has shown numbers since the day "src" was
introduced, with no recorded reason for the difference.

Renumbering happens once the whole answer is known, never while
streaming: the order is the order of first citation, so a citation that
has not arrived yet cannot take its place, and numbers would shuffle
under the reader mid-sentence. The text is rewritten in a single pass —
number by number would turn 2 into 1 and then that 1 into whatever 1
maps to.

An invented citation reserves no position and is left exactly as it was.
It is still not turned into a link, and citation_audit still records it;
what matters here is that it cannot push a real source down the list.

Accuracy was already held: a marker with no matching source never
becomes a link. This changes what a reader can do with the ones that are
real.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 03:26:17 +02:00
Daniel
109ab3951c fix: the Save for a card stays in view while you are inside that card
You could change an image model, scroll past it, and never learn there
was a button. The Availability card runs to about 1,300 pixels: the
image models sit above the slide reviewer, which sits above the Save, so
the fields and the thing that saves them were never on screen together.

The save row is now sticky. It pins to the bottom of the window while
you are anywhere inside its card and settles back into place at the end.
That class is already on four cards, so Clinical Assistant, Search
Sources, SMTP and OIDC get it too.

Two things were stopping sticky from working, and both were invisible:

.card sets overflow:hidden to clip its rounded header, and overflow
hidden on an ancestor silently disables position:sticky in every
descendant. clip does the same clipping without making the card a scroll
container.

Then #admin-tab sets overflow:auto as part of the tab shell — which
makes it the nearest scrolling ancestor for everything inside it while
it never actually scrolls, because the document does. bottom:0 resolved
against a box 7,700px tall, which is to say it did not stick at all.
Admin now opts out of that overflow the way #assistant-tab already does;
nothing in there needs the clipping, since every wide thing already
scrolls in a container of its own.

Checked in a browser rather than reasoned about: with the image fields
on screen the bar's bottom edge is at 820px in an 820px viewport.

Audited the other cards while here. Registration, Users and Discover &
test have no Save because they have nothing to save — they are actions
and search boxes. Roster's is called "Set default".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 02:54:56 +02:00
Daniel
ae602a1852 fix: My Resources uses the image model you chose for it, and says so on save
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 5s
"The model is chosen per request, where the deck is generated" described
something that happens nowhere. There is no per-request picker, and
asking where it was is what exposed the real bug.

My Resources read clinical_assistant.image_model while the admin screen
saved my_resources.image_model. That setting was stored, returned by the
API and rendered into the form — and never used by anything. Somebody
noticed the field did nothing and disabled it rather than finding out
why, which left a control that could not be changed and a note
explaining a mechanism that does not exist. My note repeating it was
wrong too.

The generator now reads its own setting and falls back to the
Assistant's, so an install that only ever set one model keeps working
untouched, and the field is enabled again with "leave blank to use the
Clinical Assistant's" — which is now true rather than a rationalisation.

Saving also says what it saved. "Saved 8:31:59 PM. Decks will be
reviewed by ..." answered a different question from the one an admin
actually has, which is whether the model they just picked is the one
that will draw. It now names each workflow's model and fallbacks back,
and spells out the blank case rather than leaving a gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 02:37:57 +02:00
Daniel
0287fd091b feat: a key per search provider, and the sign-in code email looks like our mail
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s
Two things, both about not making someone redo work.

One key per provider. There was a single websearch.api_key shared by all
of them, so trying a different provider meant pasting a new key over the
working one and pasting the old one back to return — and the keys are not
interchangeable, so a wrong pairing fails as an authentication error that
reads like a dead provider. Each now has its own slot. The old shared key
is still read as a fallback: whatever was configured before this is the
right key for whichever provider was selected at the time.

The sign-in code email went out raw, while every other message this app
sends goes through emailWrapper — so the one mail a person receives while
locked out was the one that looked least like it came from us. It now
uses the same wrapper, and the body is built around the thing the reader
actually needs: the code, alone, large, monospaced so a 0 cannot be read
as an O, in a box of its own.

It also names the address it signs into. A code arriving at a shared
mailbox, or to someone with two accounts, is otherwise a number with no
indication of what it opens — and that line is the one thing that lets a
person notice a sign-in they did not start. The address is escaped; it is
the only part of that mail that did not come from us.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 02:30:17 +02:00
Daniel
302736af70 fix: the Search Sources card loads its saved settings again
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 7s
PubMed read "Disabled" after every refresh while the database said true,
and the Test button reported the truth — so the card was showing its
defaults, not its settings.

The browser said why, once asked:

    [Admin] Settings load failed:
    ReferenceError: loadWebSearch is not defined

This file is a series of bare { } blocks, and a function declared in one
is not in scope in another. loadWebSearch lives in the block at 322 and
was called from the settings loader in the block at 42. Worse than not
running: the call sat inside that loader's .then(), so it threw and took
the rest of the handler with it.

It now loads itself on tabChanged, which is what every other block in
this file already does.

There is a comment at the top of loadAdmin saying loadOidcConfig cannot
be called from there for exactly this reason. Someone met this hazard
before; the web search call was added later without seeing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 02:12:17 +02:00
Daniel
1242b01286 feat: Exa as a search source; the speech buttons say what they do
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 1m0s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 6s
Two unrelated small things in the admin.

Exa searches by meaning rather than keywords, which suits a clinical
question asked as a question — the shape the assistant actually sends.
It is also the only provider that returns the page text in the same
call, and the snippet is the part the model reads, so asking for it
there saves a second fetch per result. type is 'auto' rather than
'neural': pinning neural makes it worse at the keyword-shaped queries
the other four handle well.

One adapter, one dropdown entry, no other change — the route already
validated against webSearch.PROVIDERS, so the backend accepted it the
moment the list grew. A test now walks both lists in both directions: a
provider the server takes but the admin never offers is unreachable, and
one the admin offers but the server rejects is a setting that cannot be
saved.

The speech and transcription buttons said "Set". Directly beneath them
is a Roster card headed "What has been added", and Set is the one thing
that does not add anything — there is a single voice and a single model,
and choosing one replaces the last. They now say "Make default", and the
note says plainly that nothing joins a list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 01:58:20 +02:00
Daniel
d33d355627 feat: a streaming table renders as a table, and the wait says what it is doing
Measured against Open WebUI on the same question, sampling every 250ms:
it never showed a raw pipe. A real <table> appeared with 2 rows at
t+3.25s and grew to 4, then 6, as they arrived.

The blank-line rule alone cannot do that, and a table is the case it
serves worst: a markdown table contains no blank line, so the whole of
it stayed in the tail as plain text until the line *after* it landed,
then snapped into place. That is the markdown flash, on exactly the
content where it shows most.

So a table is now rendered while it is still arriving: once there is a
header, the |---| rule and one body row, the tail is rendered as
markdown rather than held as text, and every complete row that follows
joins it. A half-typed row is left out and appears a frame later, which
is what makes the table grow a row at a time.

Re-rendered each frame rather than appended, unlike a settled block: the
rows arriving next carry no header of their own, so they cannot be
parsed as a separate chunk. The tail is small, so the cost is small.

The status was the other half of that trace: Open WebUI keeps a skeleton
beside the streaming content until the answer is done. Ours removed the
status the instant the first token landed — the moment it becomes most
useful, because the answer is arriving *and* the assistant is still
working, drawing a figure or completing a cut-off reply. It now sits
above the partial answer, shimmering, until the final render replaces
the bubble.

And the wait itself says what is happening. Retrieval finishes before
the stream opens — deliberately, so a bad request still returns an error
rather than a stream — which makes that first line the only thing a
reader has during the slowest part. "Retrieving and synthesizing
references" described the software; "Searching the clinical library"
describes the work.

Mutation-tested: dropping the table rule, rendering the incomplete
trailing row, showing a table with no body row, or failing to restore
the status each frame all fail a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 01:55:09 +02:00
Daniel
c14fa25c3f fix: the image settings card can save again; Learning Hub is out of the admin
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / End-to-end (browser) (push) Successful in 20s
"Not all of it was saved: Workflow not found" on every press of Save
availability. The card sent image settings for three workflows, and the
server has only had two since Learning Hub was removed — the DB
constraint allows clinical_assistant and my_resources and nothing else.
One rejection failed the whole save, so the two settings that were valid
looked unsaved as well.

The frontend was the only place that still believed in it. Also gone:
the learning_hub.image_behavior prompt, its Learning prompts section in
the admin — which held that one prompt and nothing else — and the
theme's card tints, which never applied.

While there: My Resources had a Model dropdown labelled "set per
request", permanently disabled and permanently empty. A control that can
never do anything reads as broken rather than as not applicable, so it
is now a sentence saying where the model is actually chosen. Its
fallbacks stay — those apply to whichever model the request picked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 01:39:15 +02:00
Daniel
3a40ff9b6d fix: the theme reaches the renderer, so changing it changes the deck
Picking a theme on a saved resource did nothing. The picker saved the
choice, the route wrote deck.theme, and the renderer knew how to apply
it — the theme was lost in between.

attachFigures, the step that puts drawn figures back onto slides,
rebuilds the deck as a fresh object:

    var out = { title, subtitle, date, slides };

A fresh object keeps only the fields it names, and theme was not one.
So every export rendered in the default palette, whatever the picker
said. Rendering the same deck under clinical-blue, teaching-amber and
high-contrast produced three byte-identical files; it now produces
three different ones, and they look different.

Silent, because nothing downstream could tell the difference between a
deck with no theme and a deck whose theme had been dropped — both mean
"use the default", which is also the right behaviour for an unknown id.

Known and not fixed here: the two tinted cards on a compare slide are
hardcoded blue and amber rather than taken from the theme, so those
stay the same colour under every palette. The headings, accent, rules
and bullets do change. That is a gap in the theme definition, not in
this path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 01:25:31 +02:00
Daniel
993442f98a feat: the API describes itself, at /api/openapi.json
docs/api-reference.md was hand-written, and by the time anyone checked
it was documenting twenty-three endpoints that answer 404 while missing
others that exist. That is what hand-written reference material does: it
is correct on the day it is written and silently wrong afterwards. A
second hand-written document, in YAML this time, would rot the same way.

So paths, methods and mount points are read from the Express router
stack at request time. They cannot disagree with the app, because they
are the app: 186 paths, 215 operations, and — checked — no /learning
endpoints, which is what the prose version went on claiming for weeks
after that feature was deleted.

What introspection cannot know is what an endpoint is *for*. That half
lives in src/utils/openapiRoutes.js, keyed by "METHOD /path", and it is
the half that rots, so it is the half that is enforced: a Playwright
spec fetches the live document and fails when the number of operations
without a summary rises above 199 — the debt as measured today. A
ratchet, not a target. Adding an endpoint pushes the count over and
fails the build; describing one lowers the number. The failure lists the
operations by name, so it says what to write.

Whether an operation is public is stated per route rather than inferred
from middleware. Guessing wrong there is worse in both directions:
calling a public endpoint protected hides a hole, and the reverse
invites a bug report.

The contract spec lives in e2e rather than the unit suite because it
needs the whole app mounted, and requiring server.js from node:test
pulls in the database pool and hangs the run — that has happened here
before.

Also: e2e now runs in CI on dev, gated by a shell check inside the step
rather than a job-level "if", which this Forgejo dispatches anyway and
then kills with "Early termination".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 00:52:55 +02:00
Daniel
6b039c4d87 test: bring the e2e specs up to date with the app they test
Three specs were asserting screens that no longer exist. They passed for
as long as they did only because the e2e stack shared production's
database and its configuration; against a clean one they failed honestly.

Signing in is a stepped flow now — email, then a choice between a
password and an emailed code — so #login-password is in the DOM but
hidden until that choice is made. The spec asserted it visible on the
landing screen. Replaced with one test for the landing step and a new
one that walks the transition, which nothing covered before.

The register link is hidden only when registration is disabled. This
install has it enabled and invite-gated, so the link shows and the
invite field is required; the spec asserted display:none.

Connecting Nextcloud by signing in to Nextcloud is now the offered path,
with the username and app-password fields folded behind "Use an app
password instead". The spec asserted all three visible at once; it now
checks the primary path and then opens the fallback.

learning-tab.spec.js is deleted and `learning` is out of the smoke tab
list — that feature was removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 00:49:37 +02:00
Daniel
5a666f5ca5 test: e2e runs against its own throwaway database, not production's
The e2e stack shared production's Postgres — same server, same database,
same table. Seeded robots sat in `users` beside real clinicians, and
anything a test wrote, or a migration under test changed, landed on real
data. Nothing about "run the tests" should be able to reach an account
belonging to a person.

Now it has a Postgres and a Redis of its own, both on tmpfs: created
empty on every run, held in RAM, gone on teardown. scripts/e2e.sh is one
command that recreates the stack, seeds it, runs the browser and leaves
the app up at 127.0.0.1:3553 so it can be clicked around in, with the
report served at :3554.

Two bugs fell out of it immediately, both of which only a database that
did not already exist could have found:

The schema could not be built from nothing. The entrypoint migrated
before the app created its baseline tables, so the first migration
failed on saved_encounters not existing. It never showed because every
database this has ever run against already had the baseline. Then, one
layer down, 1777800000000_generated-images creates a table with a
foreign key to learning_content — which the baseline stopped creating
when Learning Hub was removed. Restoring into a brand-new database could
not have booted. The entrypoint now stands aside when the database is
empty and lets the app do it in the order it already gets right, and the
foreign key is only created where its target is. All 20 migrations
replay from empty, producing the same 23 tables production has.

Configuration lives in the database, so a throwaway one starts at
defaults — 14 settings against production's 49. That is why every model
picker was empty: models.custom did not exist. The tests were right and
the environment was incomplete, so the seed now states what the suite
depends on, with fictional model ids: a test should not pass because of
a setting somebody changed on the live system last week.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-13 00:49:25 +02:00
Daniel
38b8fd584a fix: the sign-in code email no longer mentions the password
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Docker Build / Build Docker image (push) Successful in 6s
"Your password still works as usual" is not reassurance for everyone who
gets this mail. An account created through an invite has no password at
all, and one signing in with a code may never use theirs — for them the
sentence raises a question rather than settling one.

What the paragraph is for is saying that ignoring the mail is safe, and
it says that without the second sentence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 23:34:14 +02:00
Daniel
36cb742ce7 ci: fix the failing job, split deploy out, and drop the Android build
All checks were successful
Forgejo Docker Build / Root app tests (push) Successful in 54s
Forgejo Docker Build / Build Docker image (push) Successful in 7s
Three things, one subject: making CI say the truth about this repo.

## The red on every run was ours, not the runners'

Every docker-build run came back success, success, failure — the same
shape for weeks. The failing job was `deploy`, and it was failing to
*not run*:

    if: ${{ github.event.inputs.deploy == 'true' }}

On a push there is no github.event.inputs at all. This Forgejo does not
treat that as false and skip; it dispatches the job, the runner cannot
resolve it, and the task ends in "Early termination". The runners were
never at fault, and nothing about them needed changing.

The `'runs-on' key not defined` line is a red herring: the `build` job
prints it too and succeeds. It names the job's *needs* target, not the
job, and the old android-apk workflow used `needs:` happily for months.

Deploy is now its own workflow with only workflow_dispatch — no
condition to evaluate, so nothing can be dispatched by mistake. No job
in either file now carries a job-level `if`. The one conditional left is
a *step* (push to registry), and step conditions are evaluated by the
runner once the job is already running, which is why that one has always
worked.

## dev and main

docker-build now runs on `dev` as well. Both branches prove the same two
things — tests pass, image builds — and only `main` publishes the image,
so nothing on `dev` can be mistaken for something deployable. Deploying
stays a person pressing a button after looking at the change.
CONTRIBUTING.md documents the flow.

## Android

Removed: the mobile/ Capacitor project, docs/mobile-build.md, and the
Android bits of scripts/release.sh. All of it is in git history — 4613a278
is the last commit that had it — for when it is rebuilt.

src/utils/platform.js stays. isMobileClient only decides token lifetime,
it is twelve lines, and it is the contract a future app would come back
to; deleting it would be a change to auth for no gain.

.github/workflows/ went too — all five. There is no GitHub remote on
this repository, so none of them has ever run, and two of them wrote
into mobile/ paths that no longer exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 23:30:52 +02:00
Daniel
ceccd18387 docs: take the Learning Hub out of the docs
Some checks failed
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Docker Build / Build Docker image (push) Successful in 22s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
The feature was removed; the docs still described it as live. Worst of
it was api-reference.md, which documented nine /api/learning endpoints
and fourteen /api/admin/learning CMS endpoints — routes that answer 404
— plus POST /api/user/webdav-path, whose column was dropped by
migration. Anyone reading them was reading fiction.

Checked against the running system rather than assumed: no learning
table exists, users.webdav_learning_path is gone, generated_image_links
is gone, and no route mounts /api/learning or /api/admin/learning.

Two things that look like Learning Hub and are not, so they stay:

- learningRetrieval.js is live — My Resources uses it. Its settings keep
  the learning.* names because renaming them would orphan whatever an
  administrator has already set. retrieval-tuning.md now says so instead
  of listing the rows under two different feature names.
- the moderator role is still assignable. It gated the CMS and now
  grants nothing; authentication.md says that rather than implying
  powers it does not have. moderatorMiddleware has no callers left,
  which is worth removing on its own.

auth-admin-learning.md is now auth-admin.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 22:24:29 +02:00
Daniel
8ae47feeff build: drop the Android APK workflows; CI is the Docker image and the web app
Requested: no more Android building, focus on the Docker app build and
the web app.

android-apk.yml also ran on every push to every branch, so every commit
started an APK build beside the one workflow anyone was watching. Worse,
its runner — ped-ai-android-runner — advertises the same forgejo-local
label as the general runner, so it was also picking up the Docker build
job and dying instantly with "'runs-on' key not defined". That is why
docker-build showed a failure next to a success on the same commit.

Removed: .forgejo/workflows/android-apk.yml, and the two GitHub APK
workflows, which never ran at all — there is no GitHub remote on this
repo, only forgejo.

The Capacitor project under mobile/ is untouched and still builds by
hand; docs/mobile-build.md now says plainly that nothing builds it for
you.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 22:24:17 +02:00
Daniel
5f738fbe30 feat: a theme is shown by a sample deck you download, not a picture
A picture of one slide answers a narrower question than the one a theme
picker is asked. What a person wants to know is what a deck will look
like in this theme — all of it, at the size it will be shown, with the
fonts substituted the way they will be. One rendered slide showed one
layout, in content someone then read instead of looking at.

So: a sample deck per theme. Every layout the renderer can draw — title,
bullets with a sub-point, both two-column forms, table, callout, figure,
full-slide figure, section divider, a custom slide with shapes, an arrow
and a chart, and a references slide — with filler text throughout.
Download it, open it, see the theme.

The text is deliberately meaningless. Clinical content in a specimen
invites you to read it, and then you are judging the teaching rather
than the type; that is what the old croup slide got wrong.

No engine. The previous preview needed a Gotenberg round trip and a
pdftoppm to produce a PNG, cached on disk because of what it cost, and
could fail in ways a missing picture cannot explain. python-pptx builds
the file in ~330ms and PowerPoint draws it. The link is a plain anchor,
so it is there whether or not anything on the server is well.

The figure placeholder rides the same path a generated figure does —
attachFigures downgrades a figure slide with no picture to bullets and
an image slide to a section, so without it the sample would silently
stop showing those two layouts.

Verified: all five themes build, 11 slides, the chart is a real chart
and the placeholder embeds as real media.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 22:24:05 +02:00
Daniel
8602c7bd14 fix: the theme preview says what it is doing, and says when it fails
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 57s
Forgejo Android APK / Build signed APK (push) Successful in 2m4s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
The box was hidden until an image loaded and hidden again on error, so a
preview that failed and a preview that did not exist looked identical —
an empty gap under the dropdown. That is what it looked like from the
outside, and it cost a diagnosis: the endpoint renders a correct 934x525
PNG in 0.66s when called exactly as the browser calls it, cookie and
all, and nothing on the page could have told anyone that.

The first view of a theme is a pptx render, a Gotenberg round trip and a
pdftoppm — a second or so before anything appears, and then cached on
disk. A second of silence reads as a feature that is not there, so it
now says it is drawing, and on failure says the look itself is
unaffected, which is true: the preview is a picture of the export, not
the thing that produces it.

img.src is assigned after both handlers. A cached preview can complete
before a handler attached below the assignment exists, which would leave
the box stuck on "Drawing...".

describeTheme already ends by calling it, so the call sitting beside it
fired a second identical request on every load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 21:56:22 +02:00
Daniel
a505244b97 refactor: remove the embedding settings, whose only consumer is gone
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Embeddings existed here for Learning Hub semantic search — the card said
so itself. Learning Hub was removed, and nothing took its place: the
clinical corpus is embedded by the indexing service, not by this app.
What was left was a settings page that configured a model, tested it,
reported its dimensions, and fed nothing.

src/utils/embeddings.js had exactly one importer, src/routes/adminConfig
.js, which used it for the three routes this deletes. Outside those, the
only mentions of embedding in the server were a comment and a settings
prefix.

Gone: the module, its three admin routes, the dimension probe, the
Discover & test kind and its two panels, the admin.js block behind them,
the embeddings. prefix from both the writable-settings allowlist and the
lockdown list (it can no longer be written at all, so locking it says
nothing), and docs/embeddings-setup.md, which documented Learning Hub
search end to end.

'embedding' stays in NON_CHAT_MODES — that is the filter keeping
embedding models out of the chat-model list, and the gateway still
serves them.

Docs still describe nine /api/learning endpoints that no longer exist,
left from the Learning Hub removal. Not touched here; that is its own
subject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 21:47:09 +02:00
Daniel
9df2230479 refactor: rebuild the admin settings page around what each card does
The page had grown by accretion: model discovery scattered across five
cards with a search box each, one Save writing eight keys from the
bottom of a card that also held a second Save for something else, and a
banner apologising that a button "applies only the settings above it".

Now it reads in groups — Accounts, Models, Assistant & prompts, Site —
and every card is a <details> that folds, so Save & Close means
something. The rule is that each card saves exactly what it shows,
which is what removed the need for the banner.

Models is one workflow in three steps. Discover & test has a single
search box and a kind switch (chat / image / speech / transcription /
embedding); the five discovery calls are unchanged, the switch only
decides which one answers. Roster is what has been added, including the
image roster, which had no visible list before. Availability is the
chat model, the two allowed lists, the per-workflow image settings and
the slide reviewer, under one Save.

Splitting the eight-key save follows from that rule: Save & Close writes
the five retrieval and citation keys; Save availability writes the chat
model, both allowed lists, the reviewer and the three image-settings
PUTs. No route, request shape or setting key changed.

Switching kind clears the results first — a row button would otherwise
add an image model to the chat roster.

The kind switch dispatches its event through document.defaultView's
CustomEvent. jsdom refuses one built from another realm, and the
existing announceModelsChanged() has exactly that bug: its event is
built from the Node global, dispatchEvent refuses it, and a try/catch
swallows the error — so models-changed propagation has only ever been
source-grepped, never actually tested. Left alone here to keep this
change to one subject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 21:42:27 +02:00
Daniel
c68e3a6219 fix: "Sign in with Nextcloud" opens the sign-in page
The button did nothing. It claimed a blank tab during the click and
pointed it at the login URL once the request returned — the standard way
around a popup blocker, and it cannot work in this app: helmet sends
Cross-Origin-Opener-Policy: same-origin, which severs the handle to that
tab the moment it goes cross-origin. Assigning its location was a no-op.
A blank tab opened, nothing else happened.

The handle was never needed. window.open with 'noopener' asks for none,
and a click's user activation outlives the fetch, so the browser does
not treat it as a popup. The status line now also carries the sign-in
URL as an ordinary link, so there is a way through whatever any
particular browser decides about opening windows.

Verified the server side against the real Nextcloud first: the flow
starts, both returned URLs pass the SSRF guard and the same-host check.
The fault was entirely in the browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 21:36:51 +02:00
Daniel
89e71b3b86 fix: stream the assistant's answer a block at a time
Markdown only means anything once a block is finished. Half a table is a
row of pipes; half a fence is a stray ```. Re-parsing the whole partial
answer every 180ms therefore flickered between a broken parse and the
real thing, and the previous answer to that was to abandon markdown
past 3500 characters or eight pipe rows and dump raw text into a <pre>
that had no CSS at all — inheriting the browser's black monospace
default. That is the dark block people saw while a table streamed.

Now the text is split at the last finished block — a blank line outside
a code fence — and everything before it is rendered once and *appended*.
Only the unfinished tail is plain text, styled as prose. Settled content
is never re-parsed and never rebuilt, so a diagram or chart that has
already drawn is not thrown away by the next token.

Two blank lines are not boundaries: the gap inside a loose list, which
would render one list as two each restarting at 1, and one inside
indented code. Telling the first from the perfectly good boundary
between a sentence and the list it introduces takes looking at both
sides of the gap, not just ahead.

renderEmbeddedBlocks now marks what it has drawn. mermaid.render is
async and replaces the element; a second Chart on one canvas throws.
Marked before the await, not after, or two frames race the same element.

Streaming is a preview: 'done' still renders the whole answer from
scratch, so anything transient here is settled by the final pass. That
is what makes appending safe.

Mutation-tested: removing fence tracking, the list guard, the backward
half of that guard, the append, the drawn marker, or the state reset in
fillMessageBubble each fail a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 21:36:51 +02:00
Daniel
a7132c218e feat: a ciphertext now only decrypts in the row it was written for
enc1 said what a value was but not where it belonged. The blob in one
user's nextcloud_token would decrypt perfectly if copied onto another
user's row, and that account's exports would then go into someone else's
storage. Nothing about the ciphertext objected.

Adds enc2, which binds a context string — 'users:nextcloud_token:41' —
as AES-GCM additional authenticated data. The auth tag covers it, so a
moved ciphertext fails to open rather than opening somewhere wrong. The
context is authenticated, not encrypted: it is not a secret, it is a
claim about location.

enc1 is still read, unchanged and forever — every existing row is enc1.
Bound rows are distinguishable (isBound), so the Nextcloud token is
rebound in place the next time it is successfully used, the same way a
legacy plaintext one was already upgraded. That gate moved from
isEncrypted to isBound; isEncrypted is true for enc1, so it would have
skipped exactly the rows that need rebinding.

Bound so far: Nextcloud app passwords, and invite codes — which bind to
their own code_hash, since unlike the row id that exists at INSERT time.
Decrypting an invite moved into list(), so the cipher and the hash that
opens it no longer leave the module together.

Mutation-tested: dropping the AAD, dropping the guard, gating on
isEncrypted, or dropping a context at a call site each fail a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 21:25:24 +02:00
Daniel
ba66a80491 chore: checkpoint before AAD binding and incremental streaming render
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 21:17:35 +02:00
Daniel
191b3b433b wip: invite codes stay copyable 2026-09-12 21:00:22 +02:00
Daniel
46112e1221 feat: connect Nextcloud by signing in to Nextcloud
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 59s
Forgejo Android APK / Build signed APK (push) Successful in 2m7s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Asking someone to find Settings → Security → Create new app password is a poor
first run, and it is the step people give up on. Nextcloud has its own answer:
Login Flow v2. The person enters their server address, signs in on Nextcloud the
way they normally do — SSO, 2FA, a password manager — and Nextcloud generates an
app password for this app. We never see their real password.

Pasting an app password still works, behind "Use an app password instead". It is
the fallback, not the front door.

The security of this is all in what is trusted. The remote server chooses both
the login URL and the poll endpoint, so both are SSRF-checked and both must be
on the host the person actually typed — an endpoint pointing elsewhere would
make this a request-forgery gadget aimed at whatever it named. The server
Nextcloud reports at the end is re-checked before it is stored. The poll token
is a credential, so polling happens server-side and the browser holds only an
opaque handle bound to its own account.

Flows live in memory with a 20 minute life, matching Nextcloud's own expiry: a
login lasts minutes, and a restart mid-flow is a retry rather than a loss.
Starting a second flow replaces the first, which is what clicking again means.

The tab is opened from the click itself, before the request — opening it after
an await is what a popup blocker stops.

Removed with Learning Hub: the WebDAV browse path. Its field, its route and its
column are gone, since nothing browses Nextcloud any more. nextcloud_folder is a
different column and still in use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 20:55:00 +02:00
Daniel
24c8d71b7e feat: send a resource to your own Nextcloud, as the rendered file
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 52s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 11s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A .pptx landing in someone's own storage is worth having; a markdown blob is
not, and it is not what they would have downloaded. So this renders through
exactly the path the download uses — stored deck, its figures, the chosen
theme — and PUTs the bytes. The file never travels through the browser.

Offered only when a Nextcloud is connected: an action that always fails is
worse than one that is not offered. An article offers Word, a deck PowerPoint,
and asking for slides from an article is refused with the reason.

Putting a file in Nextcloud now lives in src/utils/nextcloudFiles.js. Two
callers want it and neither should grow its own copy of the WebDAV dance — make
the dated folder a segment at a time, PUT, migrate a legacy plaintext token —
because it reaches into storage that is not ours and a second slightly
different copy is how the two drift. It also replaces a route importing another
route.

Also: a model that leaves the roster now leaves every list that names it.
clinical_assistant.allowed_models and the image roster are advisory copies of
the roster, and a stale id there was invisible until someone asked a clinical
question and the request failed at the gateway. Removing or disabling a model
prunes it; clearing the roster clears them. Re-enabling deliberately does not
re-allow it — that is a separate decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 20:34:58 +02:00
Daniel
025290d64a feat: retire Learning Hub
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m1s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
My Resources generates better slides than Learning Hub ever did — a typed deck
the model fills in, rendered by python-pptx with fit-to-slide text, figures, a
vision review and themes, against Learning Hub's markdown-through-pandoc — and
the articles and quizzes now live in the quiz app. Keeping a second, weaker
generator and a whole CMS beside it was not earning its maintenance.

Removed: three routers, the Learning Hub and Content Manager tabs, their
components and frontend modules, the five database tables, the WebDAV browser,
the content embedding column and its vector index.

Content was exported first — every article as markdown plus a full SQL dump of
all five tables — to ops-backups/learning-hub-export-*. That export is the
restore path; the migration's down() can recreate the shape but never the rows,
and says so.

Two things this simplifies rather than merely deletes:

generated_image_links existed only to record which published content an image
appeared in, and it was the sole reason a generated image could be read by
someone who did not make it. Images are now owner-only — the visibility rule is
one WHERE clause instead of a join across two tables and a published flag.

embeddings.js keeps the model discovery the admin panel uses and loses
searchSimilar and generateContentEmbedding, which queried a table that no longer
exists.

Kept deliberately: Nextcloud connect, disconnect and export, which are how a
generated note reaches a real filesystem and have nothing to do with Learning
Hub; learningRetrieval, which despite its name is the clinical corpus search My
Resources depends on; and the pandoc reference deck, still the fallback when the
python renderer fails, moved from assets/learning to assets/deck now that the
old name misleads.

Tests: four Learning-Hub-only files removed, and the individual cases inside
shared files that asserted its behaviour. Where a test used a Learning endpoint
only as a convenient example — the account-boundary token test, the policy
matrix — it now uses one that still exists, so the property it proves is
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 20:14:20 +02:00
Daniel
59226f2109 feat: deck themes — a palette you pick, previewed by the renderer itself
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m13s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A deck had exactly one look. The slide vocabulary is structural — bullets,
compare, table, callout, figure — and none of it carries a colour, so "make it
yellow" had nowhere to land but the image prompts, and produced yellow figures
on a blue deck.

A theme is a palette and a typeface in assets/deck-themes.json. render_pptx.py
rebinds INK, MUTED, ACCENT, RULE and PAPER from it in one place, so every slide
builder follows without a line changing in any of them — five themes restyle ten
slide types for free. An unusable theme leaves the default standing, because a
deck in the wrong colours beats a deck that will not render.

The theme rides on the deck, which is already the renderer's spec, so nothing
has to thread it through. It is validated against the same catalogue the
renderer reads: an id the renderer would ignore is never stored, so a deck
cannot claim a look it does not have.

PUT /my-resources/:id/theme re-skins a stored deck — a column write, no model
call, nothing that can reword a slide — and the next download is in the new
colours. Offered in the library only on rows that have a deck; flat markdown has
no palette.

Previews are rendered by the renderer, one representative compare slide per
theme, cached because each costs a pptx render, a Gotenberg round trip and a
rasterise. Drawn rather than mocked up: a hand-made swatch drifts the moment a
palette or a layout changes, and a preview that is not true is worse than none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 19:40:19 +02:00
Daniel
bf4f895f2c fix: article uploads are 10 MB, type-checked both ways, and sniffed
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Successful in 1m55s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The ceiling was 100 MB per file with ten files allowed at once, and every file
is held whole in memory to be parsed — so the old limit let a single request ask
for a gigabyte of heap. A source article that size is not a thing anyone
uploads here. Now 10 MB, defined once and used by both the multer limit and the
post-upload check.

The filter accepted `allowed mime OR allowed extension`, so naming a file .pdf
was enough on its own, whatever it declared — and the extension is chosen by
whoever uploads. Both are required now.

Neither of those sees any bytes: multer filters on the headers, before the file
has arrived. verifySources() runs once the buffer exists and refuses a file
whose contents are not what its type claims, using the same helper as documents,
S3 uploads and assistant attachments. It runs before extraction, because an
extractor handed a malformed file is where the damage would happen.

The CMS screen said 100 MB and listed four of the ten accepted formats; it now
says what the server actually does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 19:25:41 +02:00
Daniel
bd8e413bc7 fix: an assistant attachment must be the image type it claims to be
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 59s
Forgejo Docker Build / Root app tests (push) Successful in 50s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The MIME type was taken on trust here. Anything at all could be posted as
image/png: it passed the size and base64 checks, was stored in the saved chat,
and was handed to a provider as a data URI. Documents and S3 uploads have always
been sniffed by fileType.js; this was the one upload path that was not.

Now sniffed with the same helper, so there is one idea of what a PNG looks like.
A PHP payload, a shell script, an ELF or PE binary, a zip, or a real PDF
labelled image/png are all refused with a message that says what is wrong.

What this does not claim: bytes hidden after a valid PNG header still make a
valid PNG, and no sniffer can promise otherwise. The protection is that the file
is never executed and never served as anything but an image.

Existing fixtures used buffers of 0x07 as stand-in images, which are correctly
refused now. They carry real file headers instead — a fixture should be the
thing it claims to be, exactly like a real upload.

Also adds the deck theme system: five palettes in assets/deck-themes.json,
render_pptx.py rebinding its palette from the theme rather than hardcoding it,
the theme carried on the deck and validated against the same catalogue the
renderer reads, a picker on the generate form, and PUT /my-resources/:id/theme
to re-skin a stored deck with no model call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 19:00:10 +02:00
Daniel
cda74e1fe2 fix: trim the account-boundary message to what a reader needs
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 18:36:37 +02:00
Daniel
ac1adddd50 fix: the account boundary explains itself instead of reading as an SSO failure
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 1m0s
Forgejo Android APK / Build signed APK (push) Successful in 2m3s
Forgejo Docker Build / Build Docker image (push) Successful in 13s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Tapping SSO on a frozen realm did nothing at all — no navigation, no message.
The guard is correct: a page bound to one account must not start a login for
another. But silence is indistinguishable from a broken button, and it was
reported as "single sign on error" when no SSO request had been made; the
server logged no OIDC activity because the click never left the page.

The button now says what is happening and names the control that clears it.
The guard itself is unchanged — same checks, same order, same outcomes.

The recovery screen said "your saved credentials no longer match this session",
which describes the mechanism rather than the situation. The ordinary cause is
signing in as a different account on a device that already held one. It now
says that, and says plainly that nothing was lost and no data was mixed —
which is the reassurance the safeguard has earned and was not giving.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 18:24:14 +02:00
Daniel
74aa0c1b89 feat: Enter sends in the assistant, and the person chooses
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 54s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m23s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Enter made a newline and Ctrl+Enter sent, which is backwards from every chat
people use. Enter now sends by default, with a toggle in the composer's + menu
to put it back.

Two rules hold whatever is chosen, because they are the habits people arrive
with and a setting that broke either would be worse than no setting:
Shift+Enter is always a newline, Ctrl/Cmd+Enter always sends. Both are checked
before the preference, so neither can be switched off.

A keystroke during IME composition never sends. Enter accepts a candidate word
in Chinese, Japanese and Korean, and on predictive Android keyboards; sending
there would cut a sentence off mid-word.

Stored per device rather than per account, because a keyboard preference
belongs to the keyboard: Enter-to-send suits a desk and usually does not suit a
phone, where Enter is how you get a second line. Unset, it defaults by device
class — send where there is a real keyboard, newline on a touch screen — and a
blocked localStorage falls through to that default rather than throwing.

The composer's tooltip says which key sends, where someone already looks when
they wonder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 18:05:36 +02:00
Daniel
b1e039d834 fix: a newly added model reaches every picker, including the user-facing ones
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 56s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 1m53s
Forgejo Docker Build / Build Docker image (push) Successful in 8s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
The first pass at this covered the two admin cards. It missed the pickers that
matter most: the per-tab model selectors in app.js, which every clinical tab
uses, and the My Resources model dropdown. Both were filled once at page load,
so a model added in Admin was still invisible where people actually choose one.

app.js's boot fetch is now a named loadModelList() that also runs on
models-changed; My Resources re-runs loadOptions(), which is the same call that
decides whether the model row is shown at all.

Both rebuilds keep a choice already made. These selects can be rebuilt while
someone is halfway through a form, and silently moving them off the model they
picked would be worse than not refreshing.

Verified against a mutation: removing the app.js listener fails the test that
says every picker listens.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 17:46:18 +02:00
Daniel
7305443243 fix: the last run's illustration no longer sits under an empty form
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 56s
Forgejo Docker Build / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Has been cancelled
app.js loads a tab's component once and marks it data-loaded, so the DOM
survives leaving and returning. Nothing cleared the result area, and an
illustration from a previous generation stayed on screen under a blank form as
though it were output for a topic nobody had typed. A full page refresh rebuilt
the component and cleared it, which is why it looked like a leak that fixed
itself.

Cleared at the start of a generation, at the start of a modification, and on
re-entering the tab — not on the first visit, where there is nothing to clear.
Covers the illustration area, the searches line, the image-failure line and the
status text.

Also documented what a modification can actually change. The deck vocabulary is
structural — bullets, compare, table, callout, figure, image, section, title —
and none of those carries a colour; the palette is fixed in render_pptx.py and
the model never sees it. So "make it yellow" lands on the only field that takes
a colour, image_prompt, and yellow figures appear on an otherwise blue deck.
That is not modify reaching only the images; it is the model using the one lever
the schema gives it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 17:43:19 +02:00
Daniel
31e634e0ce fix: a model added in Admin now appears everywhere models are chosen
Some checks failed
Forgejo Docker Build / Build Docker image (push) Blocked by required conditions
Forgejo Docker Build / Deploy to the host (push) Blocked by required conditions
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Has been cancelled
Adding a discovered model refreshed exactly one dropdown — the default-model
one, in the same card. Every other picker had been filled when the admin tab
loaded, behind a guard that makes its loader run once per visit, so the
Clinical Assistant chat model, the allowed-models list, the slide reviewer and
the image-model selects all kept the roster they were given. The model was
genuinely added; it simply could not be selected until the page was reloaded,
which reads as the add having failed.

Every mutation of the roster — add, remove, clear-all, enable/disable — now
dispatches `models-changed`, and the cards that list models listen and refetch.
The event carries no payload: a listener re-reads the list itself, so there is
one source of truth rather than a copy to keep in step. Same pattern as the
existing `assistant-image-roster` event.

Each listener clears its own guard before re-running, and returns early if a
load is already in flight. The assistant loader keeps unsaved drafts, so
re-running it costs nothing but a refreshed set of options.

The add toast said "now select it as default and click Set Default" — advice
that only made sense when the default dropdown was the one thing that updated.

Verified against a mutation: removing the guard reset fails the propagation
test, because the listener then fires into a loader that returns early.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 17:39:49 +02:00
Daniel
ff2b2bc9d3 chore: Kitten and Supertonic retired; Kokoro is the local voice
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 53s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m12s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Both were removed from the gateway in favour of Kokoro, so the app's curated
voice lists for them went too. Kokoro's own check no longer has to exclude
them — it names its own voices and the list is open, so it accepts anything
that is not another family's voice.

Voices stay curated per family rather than discovered. Models are discovered
from /model/info; voices are not, because no TTS provider exposes its voice list
consistently, and a list guessed from a model id is how a picker ends up
offering a voice the provider rejects.

Tests that pinned the retired voices now pin the families that remain.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 16:45:05 +02:00
Daniel
fce05a2749 fix: the active library view is unmistakable, and a leading question cannot mislead the vision model
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 57s
Forgejo Android APK / Build signed APK (push) Successful in 1m58s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Documents and Images were two buttons of the same weight, which reads as two
buttons rather than as a current view and an alternative. The active one now
carries the surface, the weight and a hairline — keyed off aria-selected, so the
visible highlight and what a screen reader announces cannot disagree.

The look_at_image question is written by a model that has not seen the image,
from what the user said, so it can presume something that is not there —
"describe this rash" about a photograph of a drug chart. Answering the
presumption would send it back as fact. The seeing model is now told the
question may assume something absent, and to say so first and describe what is
actually there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 16:27:21 +02:00
Daniel
1f06a19007 feat: a text-only model can ask a model that can see; and the image regex is gone
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 18s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
**The regex is gone.** The route ran a pattern over the user's message and
enqueued an image from the answer text when the model had not called the tool.
It was a compatibility path for models without tool calling and it did more harm
than good: it decided in English only, it could not see the conversation, and
"image summary" fell through it while reading as an obvious image request to the
model itself — which was measured, not assumed. A second and worse
decision-maker sitting behind the first. Whether a message deserves a picture is
now the model's call, made from the tool description, which is the only place it
ever belonged.

**Lending eyes.** The same shape, for a different capability. When someone
attaches a photograph and the chat model cannot accept image input, the
attachment was either refused by the provider or silently dropped — an answer
about a picture nobody had looked at, which is worse than a refusal.

The chat model is now offered look_at_image beside the image tool and decides
when to use it. The attachment goes to clinical_assistant.vision_model, whose
description comes back as a tool result, and the chat model answers in its own
voice with its own sources. Only the seeing is delegated; the clinical reasoning
stays with the model an administrator chose. The seeing model is told to report
and not to diagnose, because it has a picture and no context and an opinion from
it would carry weight it has not earned.

Delegation triggers only on an explicit supports_vision: false from the gateway.
An unknown is left alone — most of a roster reports nothing, and treating
silence as blindness would route good models through a detour. The capability
lookup moved to its own module, is cached for five minutes because it runs on
exactly the requests that are already slowest, and is never inferred from the
model id. liteLLMBaseUrl moved from the admin route to litellm.js, where the
other gateway helpers live.

The new setting is guarded like the slide reviewer: a model the gateway calls
text-only cannot be saved as the one that looks at images.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 15:43:36 +02:00
Daniel
03621752e8 feat: image fallback chains for every workflow, and a library worth looking at
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 55s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
**Fallbacks.** One image model meant a refusal, a rate limit or a model the
gateway had since dropped ended as a missing picture. Every workflow now tries
its model, then each fallback in order, stopping at the first that produces an
image. Primary plus two, capped: each hop is a paid request, and a chain long
enough to need a cap is long enough to surprise someone.

My Resources previously had no fallback at all — only the Clinical Assistant
did, and only one. That is backwards: a missing figure is most visible in a
deck, where it leaves a hole in a slide.

The retry rule is now a classifier that says *why*, rather than a boolean.
Transient faults, a 404 for a model the gateway does not have, and a content
refusal all move to the next model — a refusal because policy is a vendor
decision, not a fact about the request. 401/403 stop immediately (one gateway,
one set of credentials, the next model fails identically), as do 413 and any
other 4xx, which are malformed everywhere. Refusals are recognised from the
message: no provider sends a machine-readable reason and the status varies.

Each hop re-leases the job, so a chain cannot outlive its claim and let a second
worker repeat the same paid work, and the row records the model actually being
paid for so a picture made by the third model is not attributed to the first.

The old singular `fallback_image_model` is still read, so an existing
configuration keeps working without anyone re-entering it.

**Library.** Documents/Images tabs in My Resources, with a real grid: fixed
aspect tiles so the rows line up whatever shape the pictures are, a source badge
on the picture, two-line prompt, hover lift, shimmer skeletons while thumbnails
land, and a lightbox that closes on Escape or the backdrop and restores focus.
Actions are hidden on hover only behind `@media (hover:hover)` — hiding delete
behind :hover would put it out of reach on touch and keyboard.

Downloads go through privateImageBlob rather than a bare `<a download href>`: a
mobile client's session is a bearer token an anchor cannot send, and these
assets are served no-store on purpose.

The gallery lives in My Resources only. Assistant images appear in it, which was
the point; the assistant page does not grow a gallery of its own, and a test
asserts no assistant module lists the endpoint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 14:53:04 +02:00
Daniel
259b4858be feat: an image library — every picture this account has made
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 19s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A figure outlives the deck it was drawn for: the deck gets replaced, the diagram
is still good. Until now a generated image could only be seen in the resource it
was made for, and there was no way to find one again or to delete it.

Library → Images is a grid of every finished image the account owns, across all
three workflows, newest first. GET /api/generated-images is scoped by owner_id
in the statement rather than filtered after, returns only finished jobs — an
unfinished one is a broken frame in a gallery — and pages by keyset, because a
gallery that grows while you scroll repeats or skips rows under OFFSET.

Most of this already existed. Thumbnails were already rendered at 256 and 640 by
sharp and already served by ?w=, with their own checksum so the client's
tamper check passes on a derived copy; hydrateImage already handles auth, the
account boundary and caching. The tiles ask for the 256px preview, so thirty of
them cost a few kB each rather than thirty full-size downloads, and the prompt
is decrypted for the caption because it is the only human-readable label an
image has.

Deleting needed new work. The storage interface had no remove at all, so a
delete that dropped the row would have left the object and both previews in the
bucket — paid for, and still readable by anything with credentials. Storage now
removes all three keys, and the bytes go before the row: a row pointing at a
missing object is a broken image in a gallery, while an object without its row
is only wasted space, and unreachable storage refuses the delete outright rather
than reporting a success that left the picture behind.

THUMB_WIDTHS now has one definition, in generatedImageStorage. Two copies drift,
and the drift that matters is a width that gets written and never deleted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 14:36:30 +02:00
Daniel
83206e907c fix: a second deck reused the first deck's pictures
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 48s
Forgejo Docker Build / Root app tests (push) Successful in 51s
Forgejo Android APK / Build signed APK (push) Successful in 2m16s
Forgejo Docker Build / Build Docker image (push) Successful in 20s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The image job key was the request-body hash plus the figure's position — slide
index for a deck, reply index for a document. Two generations from the same form
produce the same body hash, so figure 4 of the second generation collided with
figure 4 of the first on the unique (owner, workflow, idempotency_key). The
constraint handed back the existing job, and the new deck displayed the old
deck's artwork. The decks are not even the same length, so the reused picture
could land on a slide about something else entirely.

Keyed on what is being drawn now: the body hash stays, so submitting the
identical request twice still dedupes rather than billing twice, and a hash of
the prompt (plus layout and shape) is what makes two different pictures two
different jobs. Same fix in deckBuild and resourceImages.

Also split fileLog out of logger. logger requires the database at module load,
so importing it to record a diagnostic pulls in a connection pool — wrong on its
own terms, and it hung the whole test suite when imageTool started logging its
refusals: a unit test that never touches a database inherited an open pool
handle and never exited. logger.file now delegates to fileLog, so there is still
one implementation of where a line goes and how it is redacted.

With that in place, every image-tool refusal is recorded durably. There are five
of them, they want five different fixes, and until now none of them left any
trace once the container was replaced.

Verified against a mutation: restoring the index-based key fails two of the four
collision tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 08:26:09 +02:00
Daniel
94f320f140 fix: an empty answer from the model was the one failure never caught
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 48s
Forgejo Docker Build / Root app tests (push) Successful in 57s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The Clinical Assistant answered "image summary" with a zero-length string, saved
that as the turn, and drew it as an empty bubble. No error was raised, nothing
was logged, and the empty turn stayed in the conversation history where it
degrades every answer after it. Confirmed by decrypting the saved chat: the
final assistant turn is content length 0.

The cause is one line. finalizeAssistantAnswer only regenerates an answer that
shouldRegenerateTruncatedAnswer flags, and that function opens with
`if (!answer) return false` — there is no dangling conjunction to detect in a
zero-length string, so empty was classified as "not truncated" and returned as a
result. Every other failure mode had a path; this one had none.

An empty answer is now asked for once more — a model returning nothing is
usually transient — and if it comes back empty again it raises 502
`empty_answer`, which both callers already turn into a visible error. Whitespace
counts as empty, and so does an answer that strips to nothing. Without a callAI
to retry with it still raises rather than returning empty.

Verified against a mutation: removing the new branch fails five of the six
tests.

This is separate from the image question in the same request. No image job was
created, because "image summary" does not match the text fallback pattern —
which requires a verb (create/generate/draw/…) before the noun — and the model
did not call the tool. That is left alone for now: the request is genuinely
ambiguous, and guessing at it is how an assistant starts making pictures nobody
asked for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 07:41:04 +02:00
Daniel
4dcd0b4fc7 docs: the generation comment still said "one pass, on generation only"
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 1m52s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Modifying now reviews its own result, so that line described the rule as it was
a commit ago. Also says what the generation pass can actually see: the deck is
rendered there without its figures, because they were enqueued a few lines
earlier and are still being drawn, so it judges the space a figure will occupy
rather than the artwork that lands in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 07:29:01 +02:00
Daniel
e244ee5240 feat: modifying a deck can see it
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 1m0s
Forgejo Docker Build / Root app tests (push) Successful in 47s
Forgejo Android APK / Build signed APK (push) Successful in 2m13s
Forgejo Docker Build / Build Docker image (push) Successful in 24s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The model editing a deck could not see it, which made most of what people
actually ask for unanswerable: "that slide is too crowded", "the diagram is in
the wrong place", "this one looks empty" are facts about the rendered page, not
about the JSON.

When a vision model is configured, modifying now renders the current deck —
with its figures, unlike the review pass, which runs while they are still being
drawn — and hands the model one image per slide alongside the JSON. Same
pipeline as review, reused rather than reimplemented: pptx, Gotenberg, PDF,
pdftoppm, capped at MAX_SLIDES.

The vision model then does the editing, which is a second and separately
measured benefit. On a real 20-slide deck, ds-deepseek-v4-flash returned the
deck unchanged for "make it better" — the echo reported yesterday — while
openrouter-gemini-3.8-flash applied it. So the stronger model fixes the echo
even without sight. A model the author picks explicitly still wins over both.

The result is rendered and reviewed again. Generation-only was the old rule, on
the reasoning that refining is a text edit; it is not. The edit is made against
how the deck looked before it, so a slide that gains two bullets only overflows
once it is rendered again. The reviewer may reposition but is held to the same
words, so a verification pass cannot quietly undo what was just asked for.

Whether an instruction achieved anything is judged on the model's edit, before
the reviewer runs, or a reviewer nudging a slide into two columns would mask an
instruction that did nothing.

Sight is an upgrade, never a dependency: no vision model, Gotenberg down, a
render that fails — each falls through to editing blind, and a test covers each
of those paths. Verified against two mutations: keeping the text model when
images are attached, and dropping the verification pass, each fail exactly one
test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 06:55:16 +02:00
Daniel
973f9d554f feat: a text-only model cannot be set as the slide reviewer
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 14s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
The reviewer is shown rendered images of the deck, so a text-only model there
fails on every generation — at request time, long after the one moment an
administrator could have picked differently. Nothing checked it.

Saving my_resources.review_model now asks the gateway what it reports for that
model and refuses only an explicit supports_vision === false.

Three answers, not two. Most of this roster carries no supports_vision at all
(every openrouter-* entry here), and refusing unknowns would block the reviewer
this deployment already runs on. An unreachable gateway is not evidence about a
model either, so it never blocks the save. Empty means review is off and skips
the lookup entirely.

The capability is read from the gateway, never inferred from the model id.

Verified against a mutation: relaxing `canSee === false` to `canSee !== true`
fails the two tests that say unknown must stay allowed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-12 06:42:44 +02:00