Add a form-first LLM/TTS router config editor and a redacted admin config snapshot so operators can inspect configKV state before applying changes.
Preserve existing encrypted key entries when loaded slices are submitted without new plaintext keys.
Signed-off-by: RainbowBird <git@luoling.moe>
Commit-Message-Assisted-by: Claude (via Claude Code)
- Added `prompt: 'select_account'` for Google and GitHub providers to force account selection during OAuth linking.
- Introduced localized error messages for account linking issues in multiple languages.
- Implemented a function to resolve OAuth error codes to specific i18n keys for better user feedback.
- Updated account settings page to display relevant error messages based on OAuth linking status.
Add PostHog product events for manual TTS stop clicks and conversation controls across Web, mobile, and Electron chat surfaces. Document the events in metrics ownership and cover the analytics API with Vitest.
Signed-off-by: RainbowBird <git@luoling.moe>
Commit-Message-Assisted-by: Codex
Track per-app TTS concurrency in Redis, route capped upstreams by
available pool capacity, and surface pool saturation metrics. Document
the Voice Pack plan so the remaining backend and card-binding work has
an explicit implementation map.
Upgrade @moeru/eventa to v1.0.0-beta.8 through the pnpm catalog and remove the local Hono adapter copy.
Keep AIRI on business-level Eventa usage while adapter contract tests live upstream in @moeru/eventa.
Signed-off-by: RainbowBird <git@luoling.moe>
Commit-Message-Assisted-by: Claude (via Claude Code)
Separate OpenAI-compatible route wiring from chat, speech, catalog, billing, and telemetry pipeline code.
Signed-off-by: RainbowBird <git@luoling.moe>
Commit-Message-Assisted-by: Claude (via Claude Code)
The packaged Electron renderer loads from file://, so its Stripe checkout
and portal requests carry no Referer and an opaque/absent Origin.
resolveTrustedRequestOrigin() returned undefined and the routes threw
INVALID_ORIGIN ("Missing trusted request origin"), blocking FLUX purchases
on desktop. Web and mobile were unaffected because they send a trusted web
origin. CORS was not the blocker: file:// requests already reach the API
(balance, providers, characters all work), so only the checkout-specific
redirect-base dependency failed.
Stripe success_url/cancel_url/return_url must be http(s) URLs, so file://
can never be a valid redirect base. Replace the trusted-origin requirement
with resolveCheckoutRedirectBase(), which prefers the request's trusted
origin (web/mobile return to where they started) and falls back to a
configured canonical web app URL (WEB_APP_URL, default https://airi.moeru.ai)
for origin-less clients like the desktop app.
On the client, the desktop app opens checkout in the system browser via
window.open (routed through setWindowOpenHandler -> shell.openExternal)
instead of navigating the file:// window to Stripe, and refreshes the FLUX
balance on window focus since it never receives the success_url redirect.
The streaming speech provider settings page was seeding its model
picker with a hardcoded `volcengine/seed-tts-2.0`, which contradicts
the just-landed "streaming model catalog comes from the server"
contract. Now the default also comes from configKV.
- `UNSPEECH_UPSTREAM.streaming` gains `defaultModel?: string`.
- `GET /api/v1/audio/models/streaming` response gains `default: string
| null` reading that field.
- admin slice apply: `streaming.defaultModel` survives key/URL
rotation alongside `streaming.models`.
- `providerOfficialSpeechStreaming.listModels` stashes the value in a
module-scope ref, exposed via `getDefaultStreamingModel()`.
- Streaming speech settings page reads the helper instead of the
hardcoded id; falls back to the first server-returned model if the
operator hasn't curated a default. `handleGenerateSpeech` no longer
has a `seed-tts-2.0` backstop — a missing backend prefix in the
selected model id now throws instead of silently defaulting.
One unspeech deployment = one configKV entry. The split into two keys
modelled a hypothetical split deployment (operators pointing REST and
WS at different unspeech instances) that nobody actually runs, and the
explanatory comments justifying the split were exactly the migration-
narrative anti-pattern we just banned from source.
Schema:
UNSPEECH_UPSTREAM = {
restBaseURL: string, // required, http(s)://host:port
streaming?: { // optional, only when ws TTS is on
baseURL: string, // ws(s)://host:port/...
keys: [{ id, ciphertext }],
adapterParams: {},
models?: [{ id, name?, description? }],
},
}
Admin slice surface flattens to one `kind: 'unspeech'`:
{ kind: 'unspeech', restBaseURL,
streaming?: { upstreamURL, plaintextKey, keyEntryId? } }
Read-site changes:
- routeTts + listTtsVoices read UNSPEECH_UPSTREAM.restBaseURL via
getOrThrow; absent entry → 503 CONFIG_NOT_SET.
- audio-speech-ws dials UNSPEECH_UPSTREAM.streaming and 1008-closes
with streaming_tts_not_configured when the subtree is absent.
- handleListStreamingVoices reads .restBaseURL directly (no more
ws→http scheme swap) and 503s on missing streaming subtree.
- handleListStreamingTTSModels reads .streaming.models.
- config-sync-subscriber listens for UNSPEECH_UPSTREAM invalidations
instead of UNSPEECH_REST_BASE_URL.
Drive-by: tighten ConfigKVService.getOrThrow return to
`Exclude<ConfigDefinitions[K], undefined>` so call sites stop needing
non-null assertions on optional schema entries — the runtime already
throws, the type now reflects it.
Migration: operators repost a single admin slice to rewrite the merged
entry. Old STREAMING_TTS_UPSTREAM and UNSPEECH_REST_BASE_URL rows
become dead data and can be deleted from configKV; nothing reads them.