447 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d893452d9a |
feat: the site's Nextcloud is filled in, not typed — the settings page is one button
nextcloud.url (or NEXTCLOUD_URL) names the site's Nextcloud. With it set, the address field is hidden and the page reads "Sign in with Nextcloud", with the app-password route underneath for whoever needs it; both routes use the default when no address is given. Without it, nothing changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
28877ba0c4 |
feat: My Resources takes Details — what to cover, at length, folded away until needed
Topic is one line and Instructions say how; neither is the place for a list of twelve topics or a case to build around. Details is: optional, collapsed by default, up to 12,000 characters, quoted into the prompt as the author's own account of what the resource must cover — material, not commands, in both the deck and the markdown shapes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
e306c3ce28 |
feat: a link into a tab — app.pedshub.com/#resources opens My Resources
The app has no pages and is not getting any. Another site can still send someone straight to a tab by naming it in the hash: it is read once at load, stored where sign-in looks for the last tab, and dropped from the URL, so it survives the round trip through the SSO and does not stick to the address bar. #resources, #deck and #decks all mean My Resources. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
f95f9c586d |
fix: the wait shows its steps and nothing else — the title line repeated the step in progress
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
e58aa1b996 |
refactor: sign-in codes and registration invitations leave; the SSO has both
Sign-in is email → code at sso.pedshub.com, and new accounts come from an invitation link minted there, so the app's own code emails and invite codes recorded a path nobody can take. Gone: the login-code routes and their rate limiters, the invite admin API and card, the invite field on the register form, the "email me a code / use my password" choice on the sign-in screen (an email now leads straight to the password), both utility modules, and the invite-only setting. A migration drops login_codes and registration_invites. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
d94dc0e357 |
feat: the wait is a list of steps, ticked by the stream's own events
One line of changing text is now a list the reader can follow: Analyzing the question → Searching the clinical library → Found 7 sources → Writing the answer, plus any step the server reports (looking at an image, drawing one, completing a cut-off reply). Steps are added when the stream says work began and ticked when it says it ended, so the list is a record of the real work, not an animation on a timer; nothing delays the answer. The one hand-off the server cannot signal — retrieval runs before the stream opens — is done by the stylesheet, not a JS timer, so the autosave debounce keeps its clock to itself. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
fa9970458f |
fix: the export's last table column wraps again, and citations-off strips every marker form
The export styles pinned the last column of every table to one unbreakable line (width:1%; white-space:nowrap) — right for a numbers column, wrong for a two-column clinical table whose second column is the whole finding: the table grew to the width of its longest sentence and iOS then inflated the text in those cells, so the "Finding" column read in a bigger font than the "Feature" column. The pin goes, and the sheet opts out of text inflation. With citations switched off the answer is still grounded in the retrieved sources; only the markers are removed. The stripper now also removes the escaped \[1, 2\] form and [src] placeholders, which used to survive as text. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
cb5bce13e3 |
fix: the unfinished block streams as markdown, and a deployment's JS is never an hour stale
The tail — the block still arriving — was shown as raw text until its blank line came, which is the flash of asterisks, pipes and brackets the reader saw on every paragraph. It is now parsed each frame like the finished part (Open WebUI never shows the flash because it re-renders the whole message per token; ours re-renders one block). Two exceptions stay text: a block inside an unclosed code fence, so a half-written diagram is not handed to its renderer every frame, and a table header with no body row yet. JS, CSS and component HTML were cached for an hour, and the assistant's ES module imports carry no version query, so every open browser kept the old citation renderer for an hour after the deploy that replaced it. They now revalidate on every load (no-cache with the ETag), which is a 304. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
07161c6fa8 |
feat: speech models have a roster; each brings its voices, users choose across all of them
Discover lists speech models with the voices each accepts and a + Add that puts the model on tts.roster. The Roster card lists every model with a voice picker, Test, Make default and Remove. Test on any row (or a discovered model not yet added) fills the test panel's voice list with that model's voices, so Orpheus and Kokoro can be heard one voice at a time before either is chosen. The default is a pair — PUT /config/tts/default sets tts.model and tts.voice together and refuses a voice the model does not accept, naming the ones it does. The generic setter no longer takes tts.model/tts.voice one at a time, which is how a Kokoro voice got paired with Orpheus. A default that leaves the roster stops being the default. Users pick from the voices of every roster model, grouped by model in Settings; the stored value is "model|voice" so read-aloud sends the voice to the model that accepts it. A bare voice saved before there was a roster is read as a voice of the default model. chooseTTS is the one place the pair is decided, shared by read-aloud, the admin test and the settings options. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
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 |
||
|
|
e376f69502 |
fix: each TTS model offers only the voices it will accept
"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
|
||
|
|
3e8972348a |
feat: a revoked invitation can be deleted
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 |
||
|
|
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 |
||
|
|
ae602a1852 |
fix: My Resources uses the image model you chose for it, and says so on save
"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 |
||
|
|
302736af70 |
fix: the Search Sources card loads its saved settings again
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
|
||
|
|
1242b01286 |
feat: Exa as a search source; the speech buttons say what they do
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 |
||
|
|
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 |
||
|
|
c14fa25c3f |
fix: the image settings card can save again; Learning Hub is out of the admin
"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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
191b3b433b | wip: invite codes stay copyable | ||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
07d1c541a0 |
fix: a deck the model fumbles once is asked for again, not abandoned
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 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 23s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
A presentation is generated as a typed deck — the model chooses each slide's layout — and that deck is what scripts/render_pptx.py renders. When the reply did not parse as a deck, the route fell straight back to asking for markdown, and slideSpec.build() then inferred slides from it. Both paths go through python-pptx, but the fallback's layout is guessed from heading and list structure rather than chosen, so everything lands as title-and-bullets. Measured on the stored library: since decks landed, 7 of 8 generations produced one and 1 did not. Models are stochastic, so one unlucky reply was costing the whole layout. It now asks a second time with the same prompt before giving up. The fallback was also invisible. It warned to the console, where the person who would simply have generated again could not see it, so they kept the plainer deck without knowing a better one was one click away. The response now carries deckFallback and the UI says it came out as plain slides, and why. Fixed the reason heuristic while adding tests for it: truncation was claimed for any reply not ending in "}", which is every prose refusal. It is now only claimed for a reply that began as JSON and stopped. The four generate tests run the handler. Verified against a mutation: removing the retry fails tests 6 and 7 and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
e69eb9a9f7 |
fix: modifying a presentation failed whenever illustration was ticked
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 53s
Forgejo Docker Build / Root app tests (push) Successful in 54s
Forgejo Android APK / Build signed APK (push) Successful in 1m56s
Forgejo Docker Build / Build Docker image (push) Successful in 22s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
existingDeck was declared below the branch that reads it, so `var` hoisting made it undefined there. With illustration on, a deck modification therefore appended the *markdown* instruction — "Returning the markdown is still required; a tool call is not a substitute for it" — to a prompt whose body asked for deck JSON. The model was told to produce two different artifacts in one reply, the reply parsed as neither, and deckBuild.parse returned null, so the handler answered 502 "That change could not be applied." Moved the declaration above its first reader. Same class of fault as the savedFigureIds one, in the same file. Two things made it hard to see, both fixed: - The library row read created_at, so a modification that did apply left the visible timestamp on the generation time. That timestamp is what led to "modification doesn't work" — it was the only signal available, and it was reading the wrong column. Rows now show the modified time when there is one. - A model can also return the document back unchanged. That was logged server-side and answered "Applied. Download it to see the result", which sent people to download an identical file. The response now carries `unchanged` and the UI says so, keeping the instruction in the box so it can be reworded. Also surfaced has_deck on the library list: 28 of 38 stored presentations have no deck and go through the weaker flat-markdown path, and nothing in the UI distinguished them. They now read "plain text, no slide layout". test/my-resources-refine.test.js runs the handler rather than reading it, since all three faults were invisible to source reading. Verified against a mutation: putting the declaration back where it was fails test 1 and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
67e416c6d9 |
docs: merge the duplicate pairs and correct them against the running app
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 2m38s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Three pairs of docs described the same thing twice, and the copies had drifted
apart. Merged each into one file, keeping the unique content from both:
- ARCHITECTURE.md -> architecture.md (its operational map: ownership, request
flow, runtime boundaries, source of truth, deployment shape)
- DEVELOPMENT.md -> developer-guide.md (change workflow, Clinical Assistant
high-risk areas, frontend rendering rules, deployment checks)
- transcription-options.md -> speech.md (the clinic setup table, and the list
of browser-Whisper paths that must stay removed)
Then audited what remained against the code and the live database rather than
against the previous docs. Corrected:
- Google Vertex was still documented as a provider across nine files. The SDK
is gone; AI_PROVIDER=vertex now logs an advisory and falls back to
OpenRouter, and Gemini is reached through LiteLLM. Fixed the provider
selection order to match src/utils/ai.js, which starts from LITELLM_API_BASE.
- promptSafe was documented on 8 routes; it is on 13.
- Node 20 -> 24, "24 vanilla JS modules" -> no fixed count, and
transcribe.js/tts.js -> sttProvider.js/ttsProvider.js, which is what exists.
- STT/TTS are LiteLLM-only; README listed direct Google, AWS Transcribe and
ElevenLabs paths that are not in the runtime.
- Learning Hub PPTX export was documented as pptxgenjs, which is not a
dependency. It is pandoc against a reference deck.
- POST /api/admin/milestones/seed does not exist; it is /bulk-import.
- NEXTCLOUD_URL and NTFY_TOPIC are not read anywhere. Nextcloud is per-user in
the users table, and the ntfy topic is derived as pedscribe-{userId}.
- A prose paragraph sat inside the Clinical Assistant settings table, so half
the rows rendered as text.
Filled the gaps the audit exposed:
- database.md was missing 12 of 29 tables, including user_resources,
personal_notes, login_codes, registration_invites and generated_image_jobs.
- developer-guide.md was missing 11 routers and 10 frontend modules.
- api-reference.md detailed 121 of 244 endpoints and said so, but whole
features were absent. Added an endpoint index covering Clinical Assistant,
My Resources, Notes, Diagrams, ED Encounters, invites and sign-in codes.
- configuration.md was missing METRICS_TOKEN, REDIS_URL, API_RATE_LIMIT_MAX,
the LITELLM_* model variables, the DB_* ones maintenance.js reads, and the
per-purpose S3 resolution scheme.
- clinical-assistant.md documented 2 of its 17 environment variables.
- features-explained.md had no entry for My Resources or Clinical Assistant.
Renamed the three remaining SHOUTING filenames to kebab-case, which is what the
docs viewer's prettyName() was working around, and rewrote README's index,
which listed architecture.md twice and omitted nine files.
Noted but not changed: the Turnstile site key is hardcoded in index.html rather
than read from TURNSTILE_SITE_KEY, and /api/health/detailed can report
tts: 'elevenlabs' though no ElevenLabs path exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
|
||
|
|
2c3fbbcf37 |
fix: decks were falling back to markdown, so no figure could ever be requested
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 2m3s
Forgejo Docker Build / Build Docker image (push) Successful in 22s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
A deck's JSON is several times the size of the prose it holds, and generation used the default 4000-token budget — raised for refine and for slide review, but never here. A sixteen-slide deck ran past it, came back truncated, failed to parse, and fell back to markdown. Markdown has no way to ask for a figure, so the model described one instead and the slide rendered a literal "![Placeholder: Flow diagram — "Neonate with rash" → ...]" as its first bullet, above the steps it was meant to illustrate. That is why no generated deck was arriving with an image. Deck generation now gets room for a deck. The fallback also says how the reply failed — empty, cut short at N characters, or simply not a deck — because those want different fixes and "not usable" covered all three. Image markup is stripped wherever text enters a slide, on both the deck and markdown paths, since a described figure is not a figure and a bullet of raw markdown is worse than no bullet. The model is also told plainly: if a figure is wanted say so with image_prompt, and if that is not on offer, write the slide without one rather than describing the picture you would have drawn. Separately, the Documentation list showed ARCHITECTURE, CLINICAL_ASSISTANT, DEVELOPMENT, MODULE_CONVENTIONS and SCALING shouting in caps with underscores intact: the label builder replaced hyphens but not underscores, and uppercased the first letter of each word rather than normalising the case, so a SHOUTING_FILENAME stayed shouting. It now reads "Clinical Assistant", keeps acronyms as acronyms (AI, API, OpenID, LiteLLM) and leaves joining words lower. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
9d307fd442 |
feat: a vision model looks at the rendered deck and fixes the layout
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 50s
Forgejo Docker Build / Root app tests (push) Successful in 1m2s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 37s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The model that writes a deck never sees it. It cannot tell that slide four overflowed, that a nine-item list would read better in two columns, or that two labelled groups want to be a comparison — those are facts about the rendered page, not about the text. So each generated deck is now rendered to PDF through Gotenberg, rasterised to one image per slide with pdftoppm, and shown to a vision model. Off unless an administrator names a reviewer, in its own admin card because it is the one setting that spends money on every generation without a user having asked for anything. One pass, on generation only: a second pass costs as much as the first and fixes far less, and refining is a text edit. It returns a patch, not a deck. Asking for the corrected deck back put the reply in proportion to the deck rather than to the number of problems, and a fourteen-slide deck came back cut off mid-object at every output budget the provider would honour — measured twice before changing shape. The patch is better for a second reason. The reviewer names a slide and an action — two columns, one column, split after bullet N, compare with these two labels — and the server moves the text it already has. The words never pass through the model, so a review cannot reword, drop or invent a single bullet. That is a stronger guarantee than instructing it not to and checking afterwards. The check runs anyway, because a bug in applyChanges would be as bad as a model rewriting the words and worse for being trusted: body text must come out the same multiset, figures the same set, and a heading may only be reused or extended. A continuation heading is the reviewer's one piece of text and is replaced when it does not continue anything. Nothing here can fail a generation — no reviewer, an unreachable one, an unparseable reply, a deck too long to look at, or a patch that applies to nothing each return the deck that was written. Verified end to end against a deck with a deliberately overloaded slide: three slides rendered and sent, one change returned, ten bullets split into five and five under "Stepwise Management … (continued)", text intact. Left switched off; enable it under Admin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
f3c3f47d99 |
fix: voice mode reads the answer that just arrived, and reads what the page shows
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 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 10s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Two bugs, one cause each. It read the previous answer. setBusy(false) is what announces assistant-answer-done, and it ran before lastAnswer was assigned — so every listener was handed the answer before last. It now fires after the answer exists both in that variable and on the page. A test asserts the order, because the order is the whole bug. And it read the markdown. The better answer than unpicking the markup is not to have any: the rendered bubble is already the answer with its headings, emphasis and tables resolved, so voice mode reads that. It cannot drift from what the reader is looking at, and it needs no rules about what "##" sounds like. Read from a clone, with the parts that are not the answer removed — the action buttons, the sources list, the follow-up suggestions, code blocks and tables — so the page itself is untouched. A bubble still thinking is never read. speakableText() stays as the fallback for when the bubble cannot be found, since raw markdown read aloud is worse than silence. Separately: e2e/seed.js hashed with bcrypt directly, so seeded accounts did not exercise the argon2id path production writes. It uses the app's own hasher now. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
0e17f553fc |
fix: hanging up the voice call stops the voice, and the answer is read not its markup
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 2m3s
Forgejo Docker Build / Build Docker image (push) Successful in 20s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Ending the call stopped listening and aborted the request but never stopped playback, and the <audio> element was a local variable with no handle kept — so nothing could stop it and the answer talked on until the page was reloaded. conversationMode now holds whatever is speaking, whichever of the two players it is, and hanging up silences it before anything else. Starting a new turn does the same, because speaking over the previous answer is how an assistant talks past you, and a reply that arrives after the overlay has closed no longer starts talking into a closed call. The other half: the raw answer went straight to the speaker. It is markdown, so a browser voice reads "#" and "**" aloud or stumbles over them. speakableText() now reduces it to what a person would read out — headings and bullets become sentences, links keep their words, emphasis and code fences are dropped, tables are dropped entirely because a table read aloud is noise, and a removed citation marker leaves no gap before the punctuation it preceded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
b98ffc61c7 |
fix: expired invitations can be cleared too, revoked ones still cannot
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 51s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
An expired code is as dead as a used one and just as accounted for, so it is now deletable. The rule the code enforces is the one that matters: a code that could still be redeemed is never deleted, because that 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. One condition, shared by the single delete and the bulk clear: (used_at IS NOT NULL OR (revoked_at IS NULL AND expires_at <= NOW())) Written that way rather than as "used OR past its date" because the second form also catches a revoked code whose date has since passed — a row the list still labels revoked and offers no delete on, so the button and the query would have disagreed about the same row. Revoked codes keep their rows. Revoking records a decision somebody took, and a handful of them is not the clutter a pile of expired codes is. Verified against the live database across every state: active refused, used deleted, expired deleted, revoked refused, and revoked-with-a-past-date refused rather than slipping through as expired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
7b084c7edf |
fix: an invitation can only be deleted once it has been used
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Docker Build / Root app tests (push) Successful in 46s
Forgejo Android APK / Build signed APK (push) Successful in 2m2s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 1s
The delete button was offered on every invitation regardless of state, and the query behind it deleted any row it was given. Deleting an unused code takes it off the list without taking it out of anybody's inbox: the person still holds something that looks like a valid invitation, it silently stops working, and there is no longer a record of who it went to or why. Revoke is what stops a live code — it leaves the row behind, marked. So the delete is now for spent codes only, in three places rather than one: the query carries AND used_at IS NOT NULL, the route answers 409 with the reason instead of pretending the row is missing, and the button is rendered only on a used row. A "Clear N used" control alongside, since the complaint was clutter and clearing them one at a time is not much of an answer. Same rule — nothing unused or revoked is touched — and it confirms first, because it is still a delete. The bulk route is declared before /invites/:id, or Express reads "used" as an id. Verified against the live database: deleting an unused invitation is refused and the row survives, deleting a used one works, the bulk clear removes only used ones, and the unused probe row was still there afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |
||
|
|
22683f3584 |
feat: sign in with a code emailed to you, offered beside the password
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 46s
Forgejo Docker Build / Root app tests (push) Successful in 56s
Forgejo Android APK / Build signed APK (push) Successful in 2m6s
Forgejo Docker Build / Build Docker image (push) Successful in 15s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
The sign-in screen asks for an email first, then offers both ways in together: a six-digit code sent to that address, or the password. Beside rather than instead — a code depends on mail being delivered and a password does not, so neither may be the only route. "Use a different email" goes back a step, and creating an account stays where it was. What keeps it from being a second, weaker front door: - Only a bcrypt hash is stored, so a code read out of the database is not a working credential. - Ten minutes, single use, marked used before the session is issued so a replay cannot race it, and requesting a new one deletes the old. - Five wrong guesses burn it. Six digits is a million possibilities, which is plenty against a person and nothing against a script with unlimited tries. - Requesting a code answers identically whether or not the address exists, and every verify failure returns one message. A sign-in screen that says "no such account" is a way of finding out who has one. - Two-factor still applies: a code proves you can read the mailbox, which is one factor, and an account that asked for a second still wants it. - Its own rate limits, tighter for requesting than for attempting, because requesting sends mail to someone else's address. These had to be separate limiters: Express matches app.use paths on segment boundaries, so /api/auth/login does not cover /api/auth/login-code — checked against a real router rather than assumed. Two bugs found while building it, both mine: authFetch keeps an allowlist of endpoints callable with no verified owner and rejects everything else before it is sent. The new endpoints were not on it, so the request never left the browser and surfaced as "Connection error". reveal() hid elements by appending 'hidden' to className and showed them with a non-global replace, so hiding twice left two copies and showing stripped one. The "use a different email" link never reappeared. It uses classList now, which is idempotent. Verified against the running server: correct code signs in, the same code again is refused, a superseded code is refused, five wrong guesses burn it, an expired one is refused, and the stored value is a hash. In the browser: requesting a code advances the screen, a wrong code is refused without losing the screen, and the password route still signs in. Not yet demonstrated: a correct code typed into the browser. The harness keeps racing the one-live-code rule — the page's own request supersedes whatever code the test holds, and with SMTP off the delivered one cannot be read. The same request reaches the server on the wrong-code path, and the endpoint itself is verified, but that last step is untested end to end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU |