## Summary
The apps did not set `<html lang>` to the UI locale. stage-tamagotchi
had no `lang`, and stage-web and stage-pocket kept `lang="en"`. The base
font stack has no CJK font. So Chromium picks the fallback font for CJK
text from the document language, and without a matching `lang`, the OS
language decides.
On a macOS with Japanese first in the language list, Simplified Chinese
settings labels mixed two fonts. Hiragino Kaku Gothic W3 rendered the
characters that it has. Simplified-only characters, such as 设, 备, and 对,
fell back to PingFang SC Medium, so they looked bold.
The document language now follows the UI locale. Chromium then picks one
font for each locale, for example PingFang SC for `zh-Hans` and PingFang
TC for `zh-Hant`.
## Changes
- `modules/i18n.ts` of each app sets `document.documentElement.lang` to
the initial locale.
- The code that applies a language change also sets it. This is
`useLanguage` in stage-tamagotchi, and the `settings.language` watcher
in `App.vue` of stage-web and stage-pocket.
- `use-language.test.ts` moves to browser mode as
`use-language.browser.test.ts`. `useLanguage` now uses `document`, and
the node test project has no DOM. The test now uses the real
`localStorage` instead of a `globalThis` stub.
## Verification
Measured on macOS with the language list `ja-US, en-US, zh-Hans-US`. The
Electron 43 build of this repository rendered one Simplified Chinese
label under each `lang` on a test page. `CSS.getPlatformFontsForNode`
reported these fonts:
| `lang` | Fonts for "向所有设备开放 配对令牌" |
| --- | --- |
| none, `en` | Hiragino Kaku Gothic ProN W3 ×8, PingFang SC Medium ×3 |
| `zh-Hans`, `zh-CN` | PingFang SC Medium ×11 |
| `zh-Hant` | PingFang TC Medium ×11 |
- The commit hook ran `moeru-lint --fix`.
- Typecheck and tests did not run locally. CI runs them, including the
moved browser test.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
The settings search from #3112 decided at build time what it lists. Its
Vite plugin found `<page>.search.ts` files by name and read provider
definitions to find their views. The route meta fixed the settings of
each page. So a feature flag or a cloud policy could not add a feature
to the search or remove one.
This PR moves that decision to runtime. The build reads facts of the
templates only. Modules register to a registry, as services do with
injeca.
The PR also adds arrow keys to the results and makes the search field
larger.
## How it works
- **Registry**: `settingsSearch` from
`@proj-airi/stage-ui/libs/settings-search`.
- A module holds settings routes. A route also holds the routes below
it, unless another module names one of them.
- A route can list runtime items, such as MCP servers. It can also give
a condition to a setting on its page.
- `when` turns off a whole module, for example behind a feature flag.
Search then lists none of its routes, settings, or items. Search reads
`when` each time the results change.
- A route is in the search only when a module holds it, and every module
that holds it is on.
- **Composition**: each app provides its modules in `main.ts`.
- `settingsSearchModules` from `@proj-airi/stage-pages/settings-search`
has the modules of the shared pages.
- An app that replaces a shared page provides its own module under the
same name.
- stage-tamagotchi adds MCP and Home Assistant. kirie adds MCP, and its
data page lists no items. stage-web adds `/settings/characters`.
- **Build**: `createSettingsSearch` keeps the template scan, the gates,
and the row markers.
- It no longer finds `.search.ts` files or reads `defineProvider`. The
virtual provider table module is gone.
- A `?settings-search` import gives the settings of one component.
Providers list the settings of their views in `viewSearchEntries`.
- It writes `searchItemRows` when a page marks rows of runtime items.
- **Search**: `useSettingsSearch(query)` reads the registry only. The
provider pages moved from it to the providers module.
- **Checks**: in development, the settings home reports routes that no
module holds. It also reports pages that mark item rows when their
module lists no items. A production build no longer checks the item
lists, because the build does not know the modules.
- **Arrow keys**: the results use the roving focus of reka-ui.
- Tab enters at the last focused result. Home and End go to the first
and the last result.
- The arrow keys move by position, because the cards stand in columns.
Up and Down stay in the column. Left and Right go to the nearest column.
- **Search field**: `SettingsSearchField` gets `size`.
- The default `lg` has the height and the text size of the result rows
below it.
- The character cards page uses `md`, at the height of its sort select.
## Visual changes
Captured on stage-web at 1440 × 900. The before image comes from the
first commit of this branch, where `SettingsSearchField` is the same as
on `main`.
| Before | After |
|---|---|
|

|

|
| Settings / Search results for "model" | Settings / Search results for
"model" |
## Verification
- `pnpm exec vite build` in `apps/stage-web`, three times: passed.
- The `?settings-search` modules hold the settings of the Sherpaw and
Apple Speech views.
- `searchItemRows` is on 14 routes.
- With the hashes removed from file names, two builds differ only in the
build time from `unplugin-info`, and in `sw.js`, which lists the hashed
files.
- The commit hook ran `moeru-lint --fix` on each commit.
- New unit test: `packages/stage-ui/src/libs/settings-search.test.ts`.
CI runs it with the typecheck.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
The settings home lists the settings that keep a feature in use from
working. #3131 limited it to providers in use. This PR adds the other
cases where a feature in use stops working, and nothing tells the person
why.
- **Character without a speech model or voice**: the speech of a
character has a usable provider, but no model or no voice.
`createOutput` throws, `Stage.vue` logs the error, and the replies stay
text only.
- **Provider that a module cannot use**: a character, or the Hearing
module, selects a provider that its module page does not list, for
example a removed provider. Removing a provider clears only the defaults
and the active character, so other characters keep it. The list shows
it, and the character keeps the selection, because the person can add
the provider again.
- **Voice input without transcription**: in the Always on mode, the
microphone listens, but the voice store starts an input only when
`hearing.configured` is true. So speech starts nothing. Push to Talk and
dictation already explain this when the person presses them, so they do
not show here.
- An empty field that a catalog fills later is pending, not missing. The
store picks a voice for an official provider, and a streaming provider
takes its default model, after the catalog loads. So the list does not
show an item before the catalogs load.
- `speechFieldStatus()` in the speech store gives the speech check.
`createOutput` resolves the selection the same way, so the two cannot
disagree.
- Each item has a title, a detail, and a blocking flag. A row in a list
of several items shows the title, so "not set up" now shows in that list
too. The list title for several items is "{count} items need attention".
- **Edit** opens the module page for the active character and for
Hearing, because these pages edit the active character. For another
character, it opens the character editor at its modules.
- The provider checks page becomes the developer page of this list,
`/devtools/needs-attention`. It lists every item, also the dismissed
ones. It shows the state that each kind of item reads: provider checks,
the provider of each module selection, the speech of each character, and
the voice input. It can make the list show each kind on purpose, and a
simulated item says so in its detail.
## Verification
Manual test in the stage-web dev server, with test local storage:
| Scenario | Result |
|---|---|
| The active character uses MiniMax Speech, with no model and no voice |
"ReLU has no speech model or voice", detail "Replies are text only ·
MiniMax Speech" |
| Only the voice is missing | "ReLU has no voice" |
| The model and the voice are set | No item |
| Two characters lack them | "2 items need attention". Each row shows
its title |
| The provider is not configured | Only "MiniMax Speech is not set up" |
| A character selects ElevenLabs for speech and OpenAI for chat, both
not added | Two items, for example "The Speech provider of Tester cannot
be used". No speech item for Tester |
| Hearing selects a transcription provider that is not added | "The
Hearing provider cannot be used" |
| Always on, microphone on, no transcription provider | "Voice input is
on, but no transcription provider is selected". It goes away in Push to
Talk, or with the microphone off |
| Edit on the active character, on another character, and on a Hearing
item | `/settings/modules/speech`,
`/settings/airi-card/<id>/edit?section=modules`,
`/settings/modules/hearing/transcriber` |
| `speechFieldStatus()`: official voice and streaming model, before and
after their catalogs load | Pending before, missing after when still
empty. A default model or a config value fills the field |
| Developer page: treat as no voice, dismiss on the home, undo | The
item shows as simulated, then shows as dismissed with a count of 1, then
shows again |
| Developer page: treat voice input as on without transcription, and
treat a usable Hearing provider as unusable | Both items show, with
"Simulated from the developer tools" in the detail |
The Browser pane blocks the microphone. For the voice input case, a
silent local stream stood in for `getUserMedia`. I tested in Simplified
Chinese. The table uses the English strings.
- `pnpm -F @proj-airi/stage-ui typecheck` passed.
- `pnpm -F @proj-airi/stage-pages typecheck` passed.
- The commit hook ran `moeru-lint --fix` on the changed files.
- I did not run the tests locally. CI runs them.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
The settings home lists the providers whose last check in this session
failed. The list sits under the search field and scrolls with the page,
because it is secondary to the search.
- **One provider** shows in one card: the title, one quiet line with the
reason of the check, and **Edit** and **Dismiss**. The quiet line also
names the modules that use the provider, if any.
- **More providers** share one summary line with their icons and the
modules that they affect. It opens the list with the same rows.
- **Edit** opens the settings page of the provider. **Dismiss** hides
the provider on this device until its config or its failure reason
changes.
- A provider shows only after a check in this session failed. The stored
status from the last session does not show it before the startup check.
- A provider without a brand icon shows the icon of its module kind.
## Changes outside the list
- **Provider store**: it keeps the failure reason and the recent check
times of each provider. It can make a provider fail its check on
purpose. That choice is runtime state, so it ends when the app quits.
- **Developer tools**: a new page, `/devtools/provider-checks`, and an
entry on each developer page.
- It shows when the checks run, with or without a sign-in, and how often
a provider checks itself.
- It checks all providers now, fails chosen providers on purpose, and
shows dismissed providers again.
- **Provider brand icons**: `uno.config.ts` now scans
`provider-inference`. The brand icon classes were only in its built
files, so they had no CSS since #2444. This also fixes the empty icons
on the providers page.
- **Provider routes**: `providerSettingsRouteOf` gives the settings page
of a provider. Most definitions have no `to`.
- Search skipped those providers. It now finds them.
- The provider cards, search, and **Edit** use the same helper, so they
open the same page.
- The web, pocket, desktop, and Kirie settings homes use the new list.
## Visual changes
The settings home had no such list before, so the before images are
absent.
| Before | After |
|---|---|
| absent |

|
| Settings / Home, one provider failed | Settings / Home, one provider
failed |
| absent |

|
| Settings / Home, two providers failed, list open | Settings / Home,
two providers failed, list open |
## Verification
- Captured on the stage-web dev server with Playwright. OpenAI and
DeepSeek were added without an API key. **Check all now** on the
provider checks page failed both with `API key is required.`, and the
settings home showed them. **Dismiss** removed DeepSeek and left the
one-provider card.
- The commit hook ran `moeru-lint --fix` on each commit.
- Typecheck and tests did not run locally. CI runs them.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
- Built-in tools show their results as widgets in the unfolded row of
the tool call panel. A "Raw data" button switches the row to the
arguments and the result.
- A widget reads its result with a valibot schema. Every field that the
schema does not name shows under the widget as "Other data", so no field
is lost. A result that does not match the schema shows the generic view.
- A developer option (Settings > System > Developer) shows tool payloads
as a `vue-json-pretty` tree instead of the formatted view.
## Widgets
| Tool | Widget |
| --- | --- |
| `web_search` | One card per source with the site icon, the title, and
the snippet. The tool asks Tavily for `include_favicon`. |
| `get_weather` (desktop) | The current weather and each forecast day as
drawers. One drawer is open at a time. |
| Home Assistant list, state, and service calls | Device rows as on the
settings page, with the same state labels. |
| `computer_use_read_image` (desktop) | The screenshot. For a chat model
without vision, the screenshot from its file and the description of the
vision model. |
| `computer_use` (desktop) | A card for every AUV 0.0.28 command, read
from the real `--json` output of each command. A failed command shows
its failure. |
## Change map
- `stage-ui`: the widget contract (`ChatToolCallWidget`,
`readToolResult`), the panel, the search and Home Assistant widgets,
`ChatToolCallScrollArea`, `useHomeAssistantStateLabel`,
`parseWebSearchResults`, and `parseToolImageDescription`.
- `stage-tamagotchi`: the weather, read image, and computer use widgets.
The weather widget and the weather card share one table of condition
icons.
- `i18n`: English and Simplified Chinese.
- New dependency: `vue-json-pretty` 2.6.0.
## Roadmap
- This PR gives the built-in tools pre-made widgets.
- A later PR lets the AI draw a widget for other tools, such as external
tools and tools that the AI creates. References to study: A2UI and
json-render.
- AI-written summaries of tool calls stay on the roadmap. They are not
in this PR.
## Verification
- `vitest run --config packages/stage-ui/vitest.config.ts --project
browser
packages/stage-ui/src/components/scenarios/chat/components/tool-call-group.browser.test.ts`:
2 passed.
- `vue-tsc --noEmit -p packages/stage-ui/tsconfig.json`: exit 0.
- The 40 AUV outputs of all invoke commands, read through the computer
use widget reader: 37 command cards and 3 failure cards.
- `vue-tsc --noEmit -p apps/stage-tamagotchi/tsconfig.json`: exit 0.
- Built `stage-tamagotchi` with `electron-vite build` and checked that
the weather condition icon classes are in the CSS.
## Visual changes
Before is the merge base on `main`. After is this branch. Both use the
same fixture chat, injected into the chat session store. The desktop
captures run a built app with its own profile, and the screenshot is a
synthetic image.
| Before | After |
|---|---|
|

|

|
| stage-web / Tool call panel: web search and Home Assistant | stage-web
/ Tool call panel: web search and Home Assistant |
|

|

|
| Desktop / Tool call panel: weather, screen capture, and read image |
Desktop / Tool call panel: weather, screen capture, and read image |
|

|

|
| Desktop / Tool call panel: keys, click, permissions, and media |
Desktop / Tool call panel: keys, click, permissions, and media |
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
This PR gives the AIRI character skills, decisions, background tasks,
and long-term memory. An external scene, such as a Discord channel, gets
its own persistent session. The
[ADR](https://github.com/chiba233/airi/blob/HoshinoYumeka/feat/cognitive-scheduler/docs/ai/adr/2026-10-10-skills-automations-and-memory.md)
records the design.
- **Skills.** A recipe works like a skill in a coding agent. The model
loads its steps with `builtIn_useRecipe` and follows them. A keyword in
the owner's message works like a slash command, so a style skill such as
`i-have-adhd` keeps working in later replies. A recipe marked background
runs as a task in its own session. Every recipe run can use every
registered tool.
- **Decisions.** A keyword invokes a decision recipe. The main model
answers its question through `builtIn_judge` in the same turn and always
picks one answer. Code then runs that answer's action: reply as usual,
read without replying, add a hint, or use another recipe.
- **Background tasks.** Each task runs in its own hidden session without
voice, and tasks run at the same time. The chat lists open tasks, and
the owner can stop them. The whole result reaches the conversation as a
notice that history keeps.
- **Long-term memory.** An index plus one entry per fact. The index
lists every entry. The character reads, writes, and forgets entries with
tools. Each entry belongs to one character card, and the owner can make
an entry general.
- **Scenes.** An input can name a scene `binding`, and the host keeps
one persistent session for each binding. Scene replies and background
tasks never speak on the host.
Recipes do not start on their own. The Auto-run tab in Settings shows
the idea in one sentence and a work-in-progress banner, so contributors
can see where it goes.
## How prompts reach the model
Every recipe prompt goes into one of the places below. Recipe steps
never go into the system prompt of the owner conversation.
| Prompt part | Where it goes | When | Text |
| --- | --- | --- | --- |
| Format rules and persona identity | System prompt | Every run |
`composeSystemPrompt` |
| Memory rules and the memory index | System prompt, after the identity
| Memory is on | `composeMemoryPrompt`: the rules for the three memory
tools, then one index line per entry |
| Recipe list | Description of the `builtIn_useRecipe` tool | Every run
in an owner conversation | `describeRecipesForRun`: usable recipes by
name and purpose, the names of decision recipes, and proposals that
wait. A call loads the steps. |
| Skill invoked by keyword | Appended to the owner's message | The
message has a keyword of the recipe | `[The owner used the skill
"<name>". Follow its steps in this reply and in later replies while they
fit.]` and then the steps. The text is stored with the message, so later
turns keep it. |
| Background skill invoked by keyword | Same place | Same | One line
instead of the steps: the recipe runs in the background, so reply
briefly and do not do its task. |
| Decision invoked by keyword | Same place | Same | `judgmentSkillText`:
the question and the answers, without what each answer leads to. The
model calls `builtIn_judge` with one answer. |
| `builtIn_judge` | Tool list of one turn | Only the turn whose owner
message invoked a decision | The result gives the answer and the next
step that code chose: reply, stay quiet, keep a hint in mind, or use
another recipe. |
| Recipe space | System prompt of the task session, after the identity |
Every run of the task | `composeRecipeSpacePrompt`: `You run the recipe
"<name>" in its own space ...`, then the recipe's purpose and steps. |
| Task message | User message of the task session | The task starts |
From a keyword: `The owner said: <message>`. From `builtIn_useRecipe`:
the task that the model wrote. |
| Task result | Notice in the owner conversation | The task ends |
`[Notice from recipe:<name>, not a message from the owner.]`, then `The
background task "<name>" finished.` and the whole result. The turn ends
with `[Speak about this notice only if it fits now, or stay quiet.]` |
Every task session gets every registered tool and the memory tools. A
task session cannot start or propose recipes, and it cannot choose
silence.
## Change map
| Module | Before | After | Description |
| --- | --- | --- | --- |
| `core-agent` runtime | Identity stored in history. Every reply spoke.
| Identity read per session at request time. Host send options for
notices, background work, scenes, and invoked skills. The stream context
carries `speaks`. | Scenes and tasks need their own identity, and they
never speak for the owner. |
| `core-agent` context registry | A module's entries stayed after it
left. | `removeWriter` drops the entries of a module that leaves. | The
prompt never keeps the observations of a module that is gone. |
| `core-agent` recipes | None | Recipes, decisions with `judgeDecision`,
and `MODEL_DECIDES_STEPS`. | Pure rules that the host and the tests
share. |
| `stage-ui` chat store | One send path for every input. | Recipe tools
(`builtIn_useRecipe`, `builtIn_proposeRecipe`, `builtIn_judge`), memory
tools, keyword skills, and background tasks with notices. A notice and
the reply to it never sync to the cloud. | Recipes, memory, and tasks
use the existing orchestrator, so every surface sends the same way. |
| `stage-ui` chat surfaces | None | Recipe and judgment blocks, notices,
and the background task list. | The owner sees which recipe ran, what a
judgment decided, and which tasks are open. |
| `stage-ui` character orchestrator | Every window that started it
handled spark notifications. | Only the renderer leader handles them.
The apps pass their synced Pinia runtime when they start it. | Several
windows never handle the same notification. |
| Settings | Long-term memory showed a work-in-progress notice. |
Long-term memory lists recipes and memory entries. The owner writes,
approves, and deletes recipes. The owner makes a memory general or
forgets it. The Auto-run tab shows its idea and a work-in-progress
banner. | The owner reviews what the model proposes and what it
remembers. |
| `plugin-protocol` | An input named only a session. | `input:text` can
name a scene `binding` in `overrides`. | The host maps each binding to
one persistent session. |
| `discord-bot` | Sessions keyed by guild or DM. | Each text or voice
channel binds to its own persistent session. | Each channel keeps its
own history, apart from the owner's chat. |
## Boundaries and risks
| Invariant or boundary | Failure mode | Protection | Evidence or gap |
| --- | --- | --- | --- |
| Model proposals need approval | A model saves a recipe that runs
without the owner | A proposal is unapproved and disabled until the
owner approves it. | `propose-recipe.test.ts`, `recipes.test.ts` |
| A judgment always decides | The model hedges and no action runs |
`builtIn_judge` takes one answer id and no confidence. Code runs that
answer's action. | `judge-recipe.test.ts`, `recipe.test.ts` |
| A notice is not owner speech | History stores a task result as owner
speech | Notices are stored with their source and projected as notices |
`chat-orchestrator-host-options.test.ts`,
`chat-background-tasks.browser.test.ts` |
| A notice stays on this device | Another device reads a notice as owner
speech | `isCloudSyncableMessage` keeps a notice and its reply local,
for live sends and for the backfill of a new cloud chat |
`wire-message.test.ts` |
| A task session stays hidden | Deleting a conversation selects a hidden
task session | The replacement after a delete skips hidden sessions |
`session-store.browser.test.ts` |
| Only replies to the owner speak | A scene reply or a task reads aloud
on the host | `speaks` per send, checked by the stage |
`chat-orchestrator-host-options.test.ts`, `chat.contract.test.ts`. Live
speech is not verified. |
| Spark notifications run once | Several windows handle the same
notification | Only the renderer leader handles them | Not verified live
|
| Scene privacy | A scene reply reaches every connected module, and a
scene prompt reads every shared context | None. Both match `main`. |
Known gap. A later PR handles it. |
## Verification
All commands ran on 48e0ad756.
```text
pnpm -F @proj-airi/core-agent build # passed
pnpm -F @proj-airi/core-agent exec vitest run src/runtime # 9 files, 176 tests passed
pnpm -F @proj-airi/stage-ui exec vitest run --project node src/tools src/stores/chat.contract.test.ts src/stores/chat/prompt-recipe.test.ts src/stores/chat/context-store.test.ts src/stores/memory.test.ts src/stores/recipes.test.ts src/stores/character/orchestrator/index.test.ts
# 14 files, 154 tests passed
pnpm typecheck # 54 of 54 tasks passed
pnpm exec eslint <files that this PR changes> # 0 errors
```
Browser tests and the full unit suite run in CI.
Not verified:
- Live chat with real models, Discord channel replies, and speech
output.
## Visual changes
All captures use stage-web at 1440x900, light theme, English, and the
same seeded recipes and memories. Before is upstream `main` at
778f767fc.
| Before | After |
| --- | --- |
|

|

|
| Settings / Long-term memory | Settings / Long-term memory |
| Absent |

|
| Settings / Recipes / Conversation | Settings / Recipes / Conversation
|
| Absent |

|
| Settings / Recipes / Auto-run | Settings / Recipes / Auto-run |
| Absent |

|
| Settings / Memories | Settings / Memories |
| Absent |

|
| Chat / Recipe call, judgment, notice, and background tasks | Chat /
Recipe call, judgment, notice, and background tasks |
## Rollout and follow-up
- Recipes that start on their own are not part of this PR. The Auto-run
tab keeps the idea open for discussion.
- A later PR adds skills that the model writes as code. The code runs in
a QuickJS sandbox (`quickjs-emscripten`). Settings show each code skill
as a card that the model describes, and the owner can only delete it.
This PR does not depend on it.
- A later PR handles scene privacy: chat output that returns only to its
source, and shared context that a scene cannot read.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
## Summary
Speaker tracking needs the CAM++ speaker embedding model and a Sherpaw
speaker tracker Worker. This PR adds both, so that voiceprint enrollment
can build on them. No feature acquires the tracker yet, so users see no
change.
This PR is independent. It builds on the Sherpaw 0.3.0 upgrade in #2971,
which is on `main`.
- **Speaker model.** `@proj-airi/stage-shared/speaker-model` pins the
CAM++ preload pack (`moeru-ai/sherpaw-campplus-zh-en-advanced`, about 28
MB). The pack lives in `stage-shared` because the Electron main process
downloads it, and main does not import `stage-ui`.
- **Delivery.** Every host exposes the pack as a remote model. No build
bundles it. Web and Pocket install it in OPFS. Desktop downloads it in
the main process. The download starts only when a feature first acquires
the tracker.
- **Tracker runtime.** `SpeakerTrackerRuntime` loads one tracker Worker
on demand and shares it. It enrolls the voiceprints in order, so speaker
number `n` maps to voiceprint `n`. `useSpeakerTrackerStore` exposes it
with a preparation state for UI.
- **Shared pack loading.** `fetchSherpawModelPack` reads and validates a
complete preload pack. The wake word model now uses it too. Its error
messages do not change.
## Change map
| Module | Before | After | Description |
| --- | --- | --- | --- |
| `stage-shared` speaker model | None | Pins the CAM++ pack ID,
repository, revision, and directory | The Vite plugins and the desktop
main process read one definition. |
| Host Vite configs (web, Pocket, desktop) | Expose the transcription
and wake word packs | Also expose the speaker pack as remote | The pack
is never in `bundledModels`. |
| Desktop model downloads (main) | Download the transcription models |
Also download the speaker pack | The renderer delegates non-bundled
packs to main, as before. |
| `stage-ui` Sherpaw model assets | The wake word module reads its own
pack | `fetchSherpawModelPack(id, name)` reads any pack and checks its
size | One loader for the wake word and speaker packs. |
| `stage-ui` voice | None | `loadSpeakerTracker`,
`SpeakerTrackerRuntime`, `useSpeakerTrackerStore` | The client, the
Worker, and the model load only on first `acquire`. |
| Data settings and the desktop resource island | Name the wake word
pack | Also name the speaker pack "Voiceprint model" | The name shows
only while the pack downloads or after it is installed. |
| Dependencies | None | `@sherpaw/speaker-diarization` 0.3.0 | Two more
temporary release-age exceptions. |
## Architecture and behavior
### Acquire and enrollment
```mermaid
sequenceDiagram
participant F as Feature (later PR)
participant S as useSpeakerTrackerStore
participant R as SpeakerTrackerRuntime
participant A as Sherpaw model assets
participant W as Tracker Worker
F->>S: acquire()
S->>R: acquire()
R-->>S: preparation = preparing
R->>A: fetchSherpawModelPack(speakerModel.id)
A-->>R: data + metadata (size checked)
R->>W: createSpeakerTracker({ model, signal })
R->>W: reset({ forgetEnrolled: true })
loop each voiceprint, in order
R->>W: enrollVoiceprint(voiceprint)
end
alt load or enrollment fails
R->>W: dispose()
R-->>S: preparation = error
else success
R-->>S: preparation = ready
R-->>F: tracker
end
```
- Concurrent `acquire` calls share one load.
- `setVoiceprints` on a loaded tracker runs `reset` and the enrollment
again. Enrollment calls run one after another, so the last call decides
the enrolled speakers.
- `release` aborts a pending load, disposes the Worker, and reports
`idle`. The voiceprints stay, and the next `acquire` enrolls them again.
### Preparation state
```mermaid
stateDiagram-v2
[*] --> idle
idle --> preparing: acquire
preparing --> ready: tracker created and voiceprints enrolled
preparing --> error: download, Worker, or enrollment fails
ready --> error: setVoiceprints fails
error --> preparing: acquire
preparing --> idle: release
ready --> idle: release
```
## Boundaries and risks
| Invariant or boundary | Failure mode | Protection | Evidence or gap |
| --- | --- | --- | --- |
| One Worker per renderer | Concurrent callers start two Workers |
`acquire` returns the pending load | `speaker-tracker.test.ts` |
| Speaker numbers match the voiceprint order | A late enrollment mixes
old and new speakers | `reset({ forgetEnrolled: true })` before each
enrollment. Enrollments run in sequence | `speaker-tracker.test.ts` |
| No orphan Worker after release | A load finishes after `release` and
keeps the Worker | Abort signal, and dispose after the load resolves |
`speaker-tracker.test.ts` |
| A failure does not leave a broken tracker | A failed enrollment keeps
a half-enrolled Worker | Dispose, report `error`, retry on the next
`acquire` | `speaker-tracker.test.ts` |
| A partial download never reaches the Worker | A truncated pack crashes
the WebAssembly runtime | Size check against `remote_package_size` |
Same check as the wake word loader before this PR |
| Release-age policy | 0.3.0 packages are younger than 72 hours |
`minimumReleaseAgeExclude` with a NOTICE and a removal time | Remove
after 2026-10-15 17:36 UTC |
| Worker and WebAssembly bundling | The build does not emit the Worker
or the `.wasm` file | Same `?worker` and `new URL(…, import.meta.url)`
pattern as the wake word Worker | Not verified. No caller exists, so the
build removes the tracker code |
## Verification
```text
pnpm -F @proj-airi/stage-shared typecheck # pass
pnpm -F @proj-airi/stage-ui typecheck # pass
pnpm -F @proj-airi/stage-pages typecheck # pass
pnpm -F @proj-airi/stage-web typecheck # pass
pnpm -F @proj-airi/stage-pocket typecheck # pass
pnpm -F @proj-airi/stage-tamagotchi typecheck # pass
pnpm typecheck # 54/54 tasks pass
pnpm lint # 0 errors, 654 warnings (the same count as main)
```
```text
# packages/stage-ui
pnpm exec vitest run src/libs/voice/ # 10 files, 55 tests pass (6 new runtime tests)
# packages/stage-pages
pnpm exec vitest run src/pages/settings/data # 3 tests pass
# apps/stage-tamagotchi
pnpm exec vitest run sherpaw model-asset # 8 tests pass
# packages/vite-plugin-sherpaw
pnpm exec vitest run # 11 tests pass
```
- The runtime tests use a fake tracker. They cover the shared load, the
enrollment order, `reset` before re-enrollment, load and enrollment
failures, a load that finishes after `release`, and `acquire` after
`release`.
- `pnpm -F @proj-airi/stage-web build` passed on this commit before the
rebase onto #2971. It was not run again after the rebase.
- Not verified: a real model download and a real tracker Worker in any
app. The packaged Electron app. Device OPFS quota for a 28 MB pack.
## Rollout and follow-up
- If this PR merges before 2026-10-15 17:36 UTC, a follow-up PR removes
all 0.3.0 release-age exceptions. If it merges later, this PR removes
them before the merge.
- The next PR adds voiceprint enrollment. It is the first caller of
`acquire`. It must verify the Worker and WebAssembly bundling in the
web, desktop, and Pocket builds.
## Summary
Fixes#1851.
The animation settings use unrelated icons that make their selected
state unclear. Replace them with the existing shared switch in Web,
desktop, and Pocket.
- Show the selected state and support keyboard input.
- Prevent changes to the page-specific setting when stage transitions
are disabled.
- Keep the switch track visible beside long descriptions and on dark
backgrounds.
- Correct the Simplified Chinese label to match `disableTransitions`.
The settings store keeps the same saved values and persistence keys. The
browser test setup prebundles UI dependencies to prevent reloads during
startup.
## Verification
- `pnpm lint` — passed with zero errors. Existing warnings remain in
unchanged files.
- `pnpm typecheck --concurrency=2 --continue` — all 53 tasks passed.
- `pnpm exec vitest run --config vitest.animation.local.ts --project
browser src/components/scenarios/settings/check-bar.browser.test.ts
src/components/scenarios/settings/model-settings/live2d.browser.test.ts`
— four tests passed across two files. The local config inherits the
package browser project and selects the installed Chromium.
- Browser regression tests cover selected state, mouse and Space input,
disabled label interaction, and switch width beside long descriptions.
- The regression failed against the original component because it had no
visible switch.
- Web runtime checks passed in English/light at 1280×900 and Simplified
Chinese/dark at 390×900. Verified clicks, Space input, the disabled
dependent setting, and saved state after reload.
- Desktop production builds passed for the merge base and proposed
change.
Payment completed and Flux was credited, but returning from Stripe
showed `ERR_FAILED`. Edge reported that the service worker replayed a
redirected response for a navigation request. Cloudflare redirects
`/index.html` to `/`.
Precache the canonical root URL and use it as the navigation fallback.
The new cache key also avoids reusing the old redirected app shell.
Validation: generated a service worker with the installed Workbox
version and verified both its precache entry and navigation handler use
`./`; ESLint passed. Production payment credited 500 Flux, and bypassing
the service worker restored the page. Normal-cache production
verification follows deployment.