## Summary\n- Pass the remaining waitForElement budget into each
frame-level CU_ACTION send so unresponsive frames cannot consume the
fixed 8s sendMessage timeout.\n- Use the remaining deadline for each
poll and stop polling immediately when the budget is exhausted.\n-
Reduce the bridge-side waitForElement grace from the legacy 9.5s buffer
to a small transport grace.\n- Add a regression test covering the
hanging-extension case.\n\n## Validation\n- pnpm -C
services/computer-use-mcp exec vitest run --config ./vitest.config.ts
src/browser-dom/extension-bridge.test.ts
---------
Co-authored-by: Neko <neko@ayaka.moe>
Co-authored-by: 刘梓恒 <160735726+3361559784@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
## Summary
This PR adds the first usable camera and view-state loop for the
experimental Godot stage.
The main design direction is that Godot owns the stage view state, while
Electron and the settings page act as lifecycle/transport/control
surfaces. The settings UI can now edit Godot camera state, and camera
changes from the Godot window can flow back to the UI.
## What Changed
- Added the shared Godot view-state contract for camera pose, avatar
bounds, snapshots, patches, and errors.
- Added Electron main bridge APIs for Godot status, scene input, view
snapshots, and view patches.
- Added Godot-side camera runtime:
- initial camera bootstrap from loaded avatar bounds
- avatar facing normalisation
- orbit, pan, wheel movement, and WASD-style camera movement
- idle persistence for camera position, yaw, pitch, and FOV
- Added settings-page Godot camera controls with coalesced updates,
restart-safe session handling, and stale request protection.
- Kept scene input simple: Electron does not cache the selected model
for replay. Each Godot start receives the current model selection from
the renderer.
## File Guide
Shared contracts:
- `packages/stage-shared/src/godot-stage/view-state.ts` defines the
Godot view-state contract and schema validation.
- `packages/stage-shared/src/godot-stage/index.ts` exports the Godot
shared contract surface.
- `packages/stage-shared/package.json` exposes the new shared entry.
Electron / renderer bridge:
- `apps/stage-tamagotchi/src/shared/eventa/index.ts` adds the Eventa
contracts for Godot status, scene input, view snapshot, patch, and error
events.
- `apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.ts`
owns the Godot sidecar lifecycle, local WebSocket bridge, scene input
forwarding, and view-state forwarding.
Settings page:
- `apps/stage-tamagotchi/src/renderer/pages/settings/models/index.vue`
wires the settings page to Godot status, scene input, snapshots, and
patches.
-
`apps/stage-tamagotchi/src/renderer/pages/settings/models/godot-view-patch-queue.ts`
coalesces high-frequency camera edits before sending them to Godot.
-
`apps/stage-tamagotchi/src/renderer/pages/settings/models/godot-view-session.ts`
tracks renderer-local Godot session epochs so stale async results are
ignored.
Shared settings UI:
-
`packages/stage-ui/src/components/scenarios/settings/model-settings/godot.vue`
renders the Godot camera controls.
-
`packages/stage-ui/src/components/scenarios/settings/model-settings/panel.vue`
routes the model settings panel between Live2D, VRM, and Godot settings.
-
`packages/stage-ui/src/components/scenarios/settings/model-settings/runtime.ts`
resolves model-settings runtime display state and Godot camera control
range.
- `packages/stage-ui/src/components/data-pane/property-number.vue`
updates number control behavior so config changes such as min/max/range
are reflected correctly.
Godot runtime:
- `engines/stage-tamagotchi-godot/scripts/StageRoot.cs` wires scene
loading, view runtime, and transport together.
- `engines/stage-tamagotchi-godot/scripts/scene/StageSceneController.cs`
loads VRM avatars and normalizes avatar facing direction.
- `engines/stage-tamagotchi-godot/scripts/view/StageViewRuntime.cs` owns
Godot view-state updates, snapshots, patch application, and idle
persistence.
-
`engines/stage-tamagotchi-godot/scripts/view/StageCameraPoseController.cs`
computes avatar bounds, bootstraps the camera, applies camera pose, and
handles camera movement math.
-
`engines/stage-tamagotchi-godot/scripts/view/StageCameraInputController.cs`
maps mouse and keyboard input into camera operations.
- `engines/stage-tamagotchi-godot/scripts/view/StageViewStateRules.cs`
centralizes view-state defaults and validation rules.
- `engines/stage-tamagotchi-godot/scripts/view/StageViewStateStore.cs`
persists and loads Godot camera view state.
- `engines/stage-tamagotchi-godot/scripts/transport/StageViewJson.cs`
parses and serializes view-state transport payloads.
-
`engines/stage-tamagotchi-godot/scripts/transport/StageViewPayloads.cs`
defines the C# payload records used by the transport layer.
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
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.
Frontend voice picker silently lost Azure voices because /audio/voices
was called without ?model=, so the server fell back to DEFAULT_TTS_MODEL
and only ever returned one model's catalog. /audio/models also hardcoded
a single `auto` alias, so the client could never request a specific
model id even if it wanted to. On top of that, the Azure adapter shipped
only 2 hand-typed voices in voices/azure.json — the rest of Microsoft's
catalog was unreachable.
Fixed in one pass:
- Drop voices/*.json. Every getVoiceCatalog now calls unspeech REST
(backend=microsoft|alibaba|volcengine). Azure proxies Microsoft's live
voices/list; cosyvoice + volcengine use unspeech's embedded catalogs.
- Drop direct upstream provider calls from send(). All three adapters
POST to <UNSPEECH_REST_BASE_URL>/v1/audio/speech with the openai-shaped
body unspeech expects (model: `<backend>/<id>`). Azure SSML still built
on our side so <prosody rate> speed survives — unspeech accepts
pre-built <speak> documents.
- Fail-fast on every voices endpoint: empty ?model= → 400 MISSING_MODEL
(no implicit DEFAULT_TTS_MODEL fallback), only `auto` resolves;
streaming upstream missing → 503; unspeech non-2xx / network err → 502;
malformed body → 502 with grepable message. No silent `{voices: []}`.
- /audio/models lists every LLM_ROUTER_CONFIG.tts.models key + `auto`.
- Frontend providerOfficialSpeech.listVoices passes ?model= and throws
on upstream failure instead of returning [].
- New UNSPEECH_REST_BASE_URL configKV entry (no default, missing → 503).
config-sync subscriber invalidates the voice cache when this key OR
LLM_ROUTER_CONFIG changes.
- Router voice catalog Redis cache covers all providers with per-provider
TTL (azure 6h live, alibaba/volcengine 24h since unspeech embeds them
at build time).
Pending billing-SKU verification: DashScope cosyvoice now goes through
unspeech's WS-internal alibaba backend (was REST `/SpeechSynthesizer`).
Functional output equivalent — confirm bill after deploy, revert via
git history if WS pricing is materially higher.
Streaming TTS model list was hardcoded in the official provider with two
Volcengine ids. This moves it server-side so operators control the
catalog without a client release.
Catalog lives on `STREAMING_TTS_UPSTREAM.models` (same configKV entry as
the ws upstream + key envelope) rather than a separate kv key — connection
target, keys, and surfaced ids are one deployment decision, splitting
them risks drift on rotation. `LLM_ROUTER_CONFIG.tts.models` is the
router/fallback domain for HTTP TTS; streaming is single-ws and has no
router semantics, so it gets its own `streamingTtsUpstreamSchema`
instead of reusing `ttsUpstreamSchema`.
New `GET /api/v1/audio/models/streaming` returns the configured list;
empty when upstream or models is unset (UI renders "no models" instead
of 5xx). Admin slice apply preserves existing `models` across key/connection
rotation so admin POSTs that only carry upstream+keys do not wipe the
catalog. Frontend `providerOfficialSpeechStreaming.listModels` fetches
the endpoint and throws on upstream errors (no silent empty array).
## Summary
Adds GitCode Release mirroring for AIRI release assets.
- Adds a reusable `.github/scripts/publish-gitcode-release.sh` helper
that creates or reuses the matching GitCode release, compares GitHub and
GitCode asset names, downloads only missing `AIRI-*` and `latest-*.yml`
assets, uploads them through GitCode's upload URL API, and verifies the
final asset list.
- Hooks the mirror step into desktop, Android, and iOS release workflows
so assets produced by separate release jobs can converge on the same
GitCode release.
- Keeps the mirror idempotent so repeated or concurrent release
workflows skip already mirrored files.
## Why
GitHub Releases are slow for many mainland China users. GitCode provides
a domestic fallback release page, but manually copying installers after
every release is error-prone and tedious.
## Validation
- `bash -n .github/scripts/publish-gitcode-release.sh`
- YAML parsing for:
- `.github/workflows/release-tamagotchi.yml`
- `.github/workflows/release-pocket-android.yml`
- `.github/workflows/release-pocket-ios.yml`
- `git diff --check -- .github/scripts/publish-gitcode-release.sh
.github/workflows/release-tamagotchi.yml
.github/workflows/release-pocket-android.yml
.github/workflows/release-pocket-ios.yml`
- Local idempotency check against existing `v0.10.1` GitHub/GitCode
releases:
- GitCode release already exists
- all mirrored assets already present
- final asset verification passed
## Setup Required
The target repository needs these GitHub Secrets:
- `GITCODE_TOKEN`
- `GITCODE_OWNER`
- `GITCODE_REPO`
For the current GitCode project, `GITCODE_OWNER` should remain `MoeruAI`
because GitCode's API and release download paths currently resolve that
namespace even though the UI displays `moeru-ai`.
## Summary
- change the sponsors workflow to create or update a generated-assets PR
instead of pushing directly to `main`
- publish generated SponsorKit assets to
`automation/update-sponsors-svg`
- use the existing `PAT_SLASH_COMMAND_DISPATCH` token when available so
the generated PR can trigger downstream checks, with `github.token` as a
fallback
## Why
The sponsors generation step now succeeds, but direct pushes to `main`
are rejected by repository rules because required checks are expected. A
PR-based update path keeps generated assets behind the normal branch
protection flow.
## Verification
- `ruby -e 'require "yaml";
YAML.load_file(".github/workflows/sponsors-svg.yml"); puts "yaml ok"'`
- `ruby -e 'require "yaml";
wf=YAML.load_file(".github/workflows/sponsors-svg.yml");
run=wf.fetch("jobs").fetch("generate").fetch("steps").find { |s|
s["name"] == "Create or update sponsors PR" }.fetch("run");
IO.popen(["bash", "-n"], "w") { |io| io.write(run) }; abort "bash -n
failed" unless $?.success?; puts "bash syntax ok"'`
- `git diff --check`
- `pnpm install --frozen-lockfile --ignore-scripts`
- `pnpm exec moeru-lint .github/workflows/sponsors-svg.yml` (workflow
file is ignored by repo lint config; command exited 0 with ignored-file
warning)
## Summary
- Repair two stale Vitest lockfile references left after the current
main branch merge state.
- Restore `pnpm install --frozen-lockfile` so scheduled workflows can
reach their actual jobs.
## Testing
- `pnpm install --frozen-lockfile --ignore-scripts`
- `git diff --check`
## Context
The manually triggered `Update Sponsors SVG` workflow failed before
SponsorKit ran because `pnpm install --frozen-lockfile` could not find
the lockfile entry for a Vitest peer snapshot.
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
- services/domain/llm-router and services/domain/user-deletion still had
colocated *.test.ts; moved them into the local tests/ subdir so every
service module follows the same layout as billing/, admin/*/, etc.
- routes/oidc/ wasn't an independent route group — it's wholly consumed
by routes/auth/index.ts (electron-callback relay + OIDC bearer token
helper). Moved to routes/auth/oidc/ to reflect that ownership.
- Fixed the relative imports in src/libs/tests/{auth,env,request-auth,
ws-auth}.test.ts. They were moved to libs/tests/ in the previous
commit without updating the './<source>' specifiers; typecheck only
caught it once the rewriter ran across the tree.
pnpm typecheck passes; eslint matches the main baseline.
Why
- Code, routes, service, worker, tests, and ai-context references for the
legacy flux_grant_batch flow were removed in the previous commit. The
schema file and the corresponding production tables were intentionally
left for a separate DDL-only PR (this one) so the destructive change is
easy to time and roll back.
What
- Delete src/schemas/flux-grant-batch.ts.
- Drop the re-export line in src/schemas/index.ts.
- drizzle-kit generate produced drizzle/0011_open_unus.sql:
DROP TABLE flux_grant_batch CASCADE;
DROP TABLE flux_grant_batch_recipient CASCADE;
CASCADE removes the 6 associated indexes in one shot.
- docs/ai-context/architecture-overview.md updated: the dead-code
reminder now points at the migration and explains the rollback story.
Also rolls in a pre-existing local move that was sitting uncommitted:
src/libs/{auth,env,request-auth,ws-auth}.test.ts → src/libs/tests/...
(aligning with the libs/tests/eventa-hono-adapter.test.ts placement that
was already on HEAD).
Deployment
- pnpm typecheck: passes.
- DO NOT run pnpm db:push on prod from this branch automatically. The
drop is intentionally a separate operator action that requires picking
a deploy window where no instance is still on an older image that
could try to read flux_grant_batch. Until 0011 is applied to prod the
table sits as an orphaned shell — safe to leave indefinitely.
Why
- src/services/ was an unordered mix of single-file services and module
directories with no shared classification axis, plus several long-dead
admin batch helpers that survived the move to the simpler synchronous
admin-flux-grants flow.
What
- services/ now has two top-level layers:
domain/ — DB state + business rules (billing, characters, chats,
flux, flux-transaction, llm-router, providers, request-log,
stripe, user-deletion, admin/{flux-grants,router-config})
adapters/ — thin wrappers over external SDKs / infra (config-kv, email,
posthog, tts/)
- admin/* moved under domain/admin/ with consistent plural names
(flux-grants, router-config).
- tts-adapters/ collapsed to adapters/tts/ (no redundant -adapters suffix
once nested under adapters/).
- 63 src files + scripts/e2e-llm-router.ts + tests/verifications/_harness.ts
had relative imports rewritten; git mv preserves blame.
- apps/server/CLAUDE.md and docs/ai-context/*.md updated to match new paths.
Dead code removed
- services/admin-flux-grant-batches/ (service + worker + tests, 1090 LOC) —
superseded by admin-flux-grants and never wired into app.ts.
- routes/admin/flux-grant-batches/ — same.
- utils/redis-compressed.ts + test — zero production call sites.
- llm-router/index.ts re-exports trimmed from 26 to 6; only symbols with
external consumers are kept.
Intentionally kept
- schemas/flux-grant-batch.ts and its schemas/index.ts export remain so the
drizzle-kit generate diff stays empty. Removing them is a separate PR
that owns the drop-table migration for flux_grant_batch /
flux_grant_batch_recipient.
Verification
- pnpm -F @proj-airi/server typecheck: passes.
- pnpm exec eslint apps/server: 49 errors, identical to main baseline
(all are pre-existing node/prefer-global/buffer in envelope-crypto and
scripts/e2e-llm-router; untouched by this change).
- Vitest passes per-file; the 6 mockDB hook timeouts under full-parallel
run are the known pushSchema-per-worker infra cost, not a regression.
The two seed scripts are fully superseded by the new admin endpoint
`POST /api/admin/config/router` — same encryption, same configKV
writes, same `configkv:invalidate` publish, plus auth + audit + body
limits. Keeping both code paths created a drift risk on the AAD label
and the merge semantics.
Doc + test fallout:
- `e2e-llm-router.ts` now points readers to the admin endpoint for
the prerequisite seed step.
- `docs/ai-context/verifications/llm-router.md` and
`streaming-tts.md` get curl-based seed instructions; the 2026-05-15
llm-router evidence stays intact with a note that the script it
used has since been removed.
- The U9 follow-up entry in `llm-router.md` flips from "not shipped"
to "partially shipped" — ETag + HMAC publish are still deferred,
so the `config_write` / `config_invalid_hmac` Grafana panels stay
parked.
- Self-edit on the admin route + `app.ts` docstrings to drop the
earlier "scripts stay as break-glass" wording.
Replaces routine use of `scripts/seed-router-config.ts` and
`scripts/seed-streaming-tts.ts` with `POST /api/admin/config/router`.
Operators can now patch one provider at a time without shelling into
the Railway runner; the seed scripts stay as break-glass tools for
cold-boot and disaster recovery.
The endpoint accepts a discriminated-union slice list (openrouter /
azure / dashscope-cosyvoice / streaming-tts), envelope-encrypts
plaintext keys in-process (never echoed back), and supports
merge/reset modes plus dryRun. Writes go through the existing
configKV + Redis `configkv:invalidate` channel so multi-instance
deployments pick up changes within the pub/sub propagation window.
Guarded by the existing `authGuard + adminGuard` pair
(`ADMIN_EMAILS` allowlist + verified email).
Why:
- Add a real bidirectional streaming TTS path: raw LLM tokens are
forwarded to the upstream model (Volcengine v3 via the unspeech ws
bridge) without client-side segmentation, so the model owns sentence
splitting and audio chunks play as they arrive.
- Move audio endpoints out of /api/v1/openai/. `/audio/voices`,
`/audio/models`, `/audio/voices/streaming` are not real OpenAI public
APIs, and the streaming TTS surface has nothing to do with OpenAI —
keeping them under /openai/ mislabelled the contract.
- Introduce `capabilities.speech.transport` on ProviderDefinition so
future streaming providers (ElevenLabs / Cartesia / OpenAI Realtime)
opt in without touching Stage.vue or the session factory.
- Unify Stage.vue's TTS path through a single StageTtsSession so the
chat-orchestrator hooks no longer branch on provider id.
What:
- apps/server: new ws proxy /api/v1/audio/speech/ws bridges client ↔
unspeech with auth, pre-flight flux check, billing from upstream
session.finished.usage, OTel spans.
- apps/server: audio routes moved from /api/v1/openai/audio/* to
/api/v1/audio/* (hard cutover; 404 sentinel tests added).
- apps/server: new /api/v1/audio/voices/streaming proxy reads voices
from unspeech /api/voices?provider=volcengine.
- apps/server: new STREAMING_TTS_UPSTREAM configKV entry +
scripts/seed-streaming-tts.ts.
- stage-ui: new libs/speech/streaming-pipeline.ts opens one ws per LLM
intent (appendText / finish / cancel + onSentence / onError / onDone).
- stage-ui: new libs/speech/tts-session.ts — StageTtsSession interface
with segmenter and streaming adapters; factory dispatches by
capabilities.speech.transport instead of hard-coded provider id.
- stage-ui: providerOfficialSpeechStreaming with capabilities.speech =
{ transport: 'bidirectional-ws' }; settings page with model/voice
picker + ws-based preview.
- stage-ui: Stage.vue chat hooks collapsed to a single currentSession;
hot-swap watcher cancels mid-session on provider/voice/model change;
unmount cancels and drains playback.
Tests:
- 9 streaming-pipeline tests (happy path / buffered / error / cancel /
truncation)
- 11 tts-session tests (factory branch coverage + adapter contracts)
- 4 audio-speech-ws route tests (forwarding / billing / pre-flight /
config-missing)
- 3 legacy-path 404 sentinels in v1 route tests
- Verification doc updated to reflect automated coverage.