Skip to content

feat: live choice lists for model, voice and crosstalk shows (PR-11, STORY-479/482) - #852

Merged
genwave-radio merged 7 commits into
mainfrom
host/live-choices-479
Sep 25, 2026
Merged

genwave-radio merged 7 commits into
mainfrom
host/live-choices-479

Conversation

@genwave-radio

Copy link
Copy Markdown
Collaborator

🎯 What

Three settings now offer only real choices, read live from the station instead of typed by hand (F205.7, STORY-479, STORY-482):

  • AI writer model (Llm:Model) lists the models the LLM server reports on /v1/models.
  • Station voice (Station:Voice) lists the voices Kokoro reports.
  • Shows with two-host banter (Crosstalk:Shows) shows a checkbox per show, where it used to be a hand-typed JSON array.

No text box remains on any of these fields. A saved value that is no longer in the list still shows, as " (not found)".

This PR also fixes a bug already on main: no setting could be saved from the settings page. Details are under 🐛 below.

Refs #778. This is the last PR of the settings rework.

✨ Changes

  • T579 probe cache. List results are cached for 60 s, and a failed lookup is cached too. Each lookup times out after 2 s. The last good list is kept per endpoint, so a list that stops loading still shows its last contents, marked as possibly out of date.
  • T580 model and voice lists in GET /api/settings.
    • New fields: choices, choicesStale and choicesFailed.
    • A boot check fails startup if a setting names a list source that doesn't exist.
    • PUT validation is unchanged: a value is never rejected because the list doesn't contain it.
    • Nothing is looked up at boot or while the station is idle, so Story125's rule that an idle station makes no LLM requests still holds.
  • T585 shows catalog and the multi-choice kind.
    • The show list is read from the shows table on every request.
    • If that read fails, only Crosstalk:Shows reports the failure; the rest of the page still loads.
    • The saved value keeps its format (a JSON array of show slugs), so no migration is needed.
  • T581 dropdown states.
    • An out-of-date list shows the note "This list may be out of date."
    • A list that failed to load shows a disabled dropdown that still holds the saved value, with the note "Couldn't load the list."
    • A blank value shows a placeholder that can't be picked.
    • VoiceSettingControl and its free-text fallback are deleted.
  • T586 show checkboxes. One labelled checkbox per show. Save sends the checked shows in list order; unticking every show saves an empty value. The checkboxes are disabled if the list failed to load.

🐛 Fix: fractional number settings blocked every save

T576 (#847) gave the settings number fields min and max limits but no step. The browser then only accepted whole numbers, and the shipped value 0.5 for Library:CueDetection:MinSilenceDurationSec failed its check. As a result, every "Save settings" click silently did nothing. The browser sent no request and showed no message.

The fields now accept decimals (step="any"), and the server still checks each setting's range. This bug is on main but not in any release. The T587 wire check found it.

✅ Verification

  • Test suites:

    • admin-ui: tsc, typecheck:specs and jest all pass (126 suites, 1371 tests).
    • .NET: dotnet build finished with 0 errors. Test counts:
    Project Passed Skipped
    Host 3177 13
    Tts 930 2
    Orchestration 564 3
    Ads 231 0
    Architecture 192 0
    Core 189 0
    MediaLibrary 177 0
    Context 78 0
    Plugins 53 0
  • Wire check (T587) on the dev station, running images built from this branch:

    1. ✅ The model list is the same 12 ids that runes' /v1/models returns.
    2. ✅ With the LLM server unreachable for over 60 s, the page showed the same 12 models plus the out-of-date note. The list recovered within 30 s of the server coming back.
    3. ✅ After an api restart with the LLM server down, the dropdown was disabled, held mistral:7b (not found), showed "Couldn't load the list." and had no text box.
    4. ✅ The voice list is the same 68 voices that /api/voices returns.
    5. ✅ Unticking the only show and saving stored "". Ticking it again and saving stored ["nova-late-nite"], back to the original value.
  • In check 2 the outage was simulated in the api container only: runes pointed at an unroutable address in its /etc/hosts. Ollama on runes kept running.

Re-planned live choice lists: model + voice probes (T579/T580/T581) and
Crosstalk:Shows as checkboxes (T585/T586). Skipped facts + it.todo until
each task turns its scenarios green.
ProbedChoiceCache holds one entry per probe, keyed on the probe's endpoint.
Results stay fresh for 60 s, failures included, so a down backend costs at
most one call a minute. Each attempt is single-flight with a 2 s timeout on
TimeProvider. A failure after a success on the same endpoint serves the last
good list as Stale; an endpoint change drops it. Un-skips the cache-level
STORY-479 facts (AC4-AC6, AC11-AC15) plus a concurrent single-flight scenario.
…Y-479)

Llm:Model and Station:Voice become Choice settings whose lists come from
the servers themselves: GET {Llm:Endpoint}/v1/models (new OpenAiModelLister
behind ILlmModelLister in Core) and the existing Kokoro voice listing. Both
go through ProbedChoiceCache (60 s, last-good per endpoint, 2 s timeout).

ISettingChoiceResolver replaces the controller's ChoicesFor/
LocalizedChoicesFor and resolves only the keys a response carries, so a PUT
of an unrelated key never waits on the LLM or TTS server. SettingDto gains
choicesStale and choicesFailed. A saved value missing from its list is
appended as "{0} (not found)" (resx Choice.NotFound) - this now applies to
Station:Theme and Station:IconPack as well, per F205.7e. PUT validation is
unchanged: a model or voice not in the list is still accepted.

Boot fails when an allowlist ChoiceSource names a probe that DI did not
register. The probes take their listers lazily so booting makes no LLM or
TTS request. Llm:Model help copy no longer says "free text".
…API (STORY-482)

Crosstalk:Shows becomes SettingKind.MultiChoice (wire "multi-choice") sourced
from the new "shows" IChoiceCatalog, which reads IShowStore on every request
and returns (slug, name) pairs. A store failure flags choicesFailed for that
key only; only the caller's own cancellation propagates. Each saved slug
missing from the list is appended once as "<slug> (not found)"; blanks never.
The stored value and PUT validation are unchanged. The boot check now takes
catalog names from DI, KindWireValue is exhaustive, and an architecture law
pins F205.7h (MultiChoice needs a live source or a choices list).
…Y-479)

ChoiceSettingControl reads the new choicesStale/choicesFailed flags: a stale
list adds "This list may be out of date."; a failed list disables the select,
still holding the saved value, with "Couldn't load the list." A blank value
shows a disabled "Choose…" placeholder (or the flagged station default), so
nothing blank is offered and no path renders a text box. VoiceSettingControl
and its free-text fallback are deleted; Station:Voice now uses the generic
dropdown fed by the settings API. useVoiceList and /api/voices stay for the
other pages. The retired voice spec's badge fact moves to the choice spec.
A new MultiChoiceSettingControl shows one labelled checkbox per choice.
The checkboxes sit in a role="group" that the field label names. On save
the control sends the checked slugs as a JSON array, in list order. When
nothing is checked it sends "", so the unset default stays clean. Under
choicesFailed every checkbox is disabled. The stale and failed notes
match ChoiceSettingControl. No path renders a text input.

SettingsForm now routes kind "multi-choice" to the new control, and the
kind union in settings-types.ts gains "multi-choice". Story482 AC4, AC5
and AC8 are real specs now.
T576 gave the settings number input min and max but no step, so the
browser's default step of 1 applied. The shipped value 0.5 for
Library:CueDetection:MinSilenceDurationSec then failed native
constraint validation. Every "Save settings" click stopped before the
PUT, with no message. Found during the T587 wire check on the dev
station.

The input now uses step="any". The server still validates each key's
range. A new descriptor-form scenario pins the attribute and checks
that the form is valid with 0.5.
@genwave-radio
genwave-radio merged commit d7bafd7 into main Sep 25, 2026
11 checks passed
@genwave-radio
genwave-radio deleted the host/live-choices-479 branch September 25, 2026 11:35
@github-actions github-actions Bot locked and limited conversation to collaborators Sep 25, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant