From 4ccde2c96e7755d5993f39d44242cbcae28901eb Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Sun, 2 Aug 2026 18:43:07 +0800 Subject: [PATCH] chore: move the server to an independent folder --- .dockerignore | 3 + apps/server/.env | 40 -- apps/server/railway.toml | 11 - apps/ui-server-auth/README.md | 2 +- apps/ui-server-auth/src/modules/analytics.ts | 2 +- apps/ui-server-auth/src/modules/auth-fetch.ts | 2 +- apps/ui-server-auth/src/pages/profile.vue | 2 +- ...05-09-character-cards-cloud-sync-design.md | 300 --------------- docs/ai/context/verification-automation.md | 220 ----------- .../2026-05-30-voice-pack-requirements.md | 113 ------ ...30-001-feat-voice-pack-tts-pool-lb-plan.md | 308 --------------- ...001-feat-observability-diagnostics-plan.md | 283 -------------- ...-10-001-feat-llm-router-admin-form-plan.md | 210 ----------- ...-06-14-001-feat-doubao-asr-gateway-plan.md | 355 ------------------ .../2026-07-01-official-provider-catalog.md | 168 --------- ...-07-01-tts-voice-pack-model-option-plan.md | 322 ---------------- .../2026-07-10-auth-ui-new-backend-origin.md | 204 ---------- ...26-07-01-official-provider-catalog-prd.xml | 303 --------------- ...07-10-auth-ui-new-backend-origin-design.md | 38 -- eslint.config.ts | 2 +- package.json | 8 +- .../account/account-settings-page.vue | 3 +- .../stage-pages/src/pages/settings/flux.vue | 2 +- .../official-provider-speech-streaming.vue | 2 +- packages/stage-ui/src/composables/api.ts | 2 +- .../stage-ui/src/composables/use-analytics.ts | 7 +- .../src/libs/chat-sync/ws-client.test.ts | 12 +- .../stage-ui/src/libs/chat-sync/ws-client.ts | 7 +- .../stage-ui/src/stores/analytics/index.ts | 3 +- pnpm-lock.yaml | 10 +- pnpm-workspace.yaml | 1 + server/README.md | 29 ++ {apps/server => server/apps/api}/.gitignore | 0 {apps/server => server/apps/api}/CLAUDE.md | 34 +- {apps/server => server/apps/api}/Dockerfile | 22 +- {apps/server => server/apps/api}/README.md | 14 +- .../server => server/apps/api}/docs/.gitkeep | 0 .../apps/api}/drizzle.config.ts | 0 .../api}/drizzle/0000_mean_slipstream.sql | 0 .../api}/drizzle/0001_magenta_skrulls.sql | 0 .../apps/api}/drizzle/0002_mean_tigra.sql | 0 .../apps/api}/drizzle/0003_old_titania.sql | 0 .../apps/api}/drizzle/0004_bouncy_devos.sql | 0 .../drizzle/0005_tough_living_tribunal.sql | 0 .../0006_overconfident_susan_delgado.sql | 0 .../apps/api}/drizzle/0007_red_nicolaos.sql | 0 .../apps/api}/drizzle/0008_gray_xavin.sql | 0 .../api}/drizzle/0009_perpetual_lilandra.sql | 0 .../apps/api}/drizzle/0010_sudden_bastion.sql | 0 .../drizzle/0011_common_doctor_octopus.sql | 0 .../api}/drizzle/0012_clumsy_the_stranger.sql | 0 .../apps/api}/drizzle/0013_naive_groot.sql | 0 .../drizzle/0014_vengeful_blonde_phantom.sql | 0 .../drizzle/0015_concerned_piledriver.sql | 0 .../apps/api}/drizzle/0016_tired_dagger.sql | 0 .../apps/api}/drizzle/0017_nappy_dagger.sql | 0 .../apps/api}/drizzle/meta/0000_snapshot.json | 0 .../apps/api}/drizzle/meta/0001_snapshot.json | 0 .../apps/api}/drizzle/meta/0002_snapshot.json | 0 .../apps/api}/drizzle/meta/0003_snapshot.json | 0 .../apps/api}/drizzle/meta/0004_snapshot.json | 0 .../apps/api}/drizzle/meta/0005_snapshot.json | 0 .../apps/api}/drizzle/meta/0006_snapshot.json | 0 .../apps/api}/drizzle/meta/0007_snapshot.json | 0 .../apps/api}/drizzle/meta/0008_snapshot.json | 0 .../apps/api}/drizzle/meta/0009_snapshot.json | 0 .../apps/api}/drizzle/meta/0010_snapshot.json | 0 .../apps/api}/drizzle/meta/0011_snapshot.json | 0 .../apps/api}/drizzle/meta/0012_snapshot.json | 0 .../apps/api}/drizzle/meta/0013_snapshot.json | 0 .../apps/api}/drizzle/meta/0014_snapshot.json | 0 .../apps/api}/drizzle/meta/0015_snapshot.json | 0 .../apps/api}/drizzle/meta/0016_snapshot.json | 0 .../apps/api}/drizzle/meta/0017_snapshot.json | 0 .../apps/api}/drizzle/meta/_journal.json | 0 .../apps/api}/instrumentation.ts | 0 {apps/server => server/apps/api}/package.json | 9 +- .../apps/api}/production/railway/Dockerfile | 22 +- server/apps/api/railway.toml | 12 + {apps/server => server/apps/api}/sql/init.sql | 0 .../apps/api}/src/app.test.ts | 0 {apps/server => server/apps/api}/src/app.ts | 4 +- .../server => server/apps/api}/src/bin/run.ts | 0 .../src/libs/auth-plugins/oidc-jwt-bearer.ts | 2 +- .../apps/api}/src/libs/auth.ts | 2 +- .../server => server/apps/api}/src/libs/db.ts | 2 +- .../apps/api}/src/libs/env.ts | 0 .../apps/api}/src/libs/external-dependency.ts | 0 .../apps/api}/src/libs/gravatar.ts | 0 .../apps/api}/src/libs/mock-db.ts | 0 .../apps/api}/src/libs/redis.ts | 0 .../apps/api}/src/libs/request-auth.ts | 0 .../apps/api}/src/libs/tests/auth.test.ts | 0 .../apps/api}/src/libs/tests/env.test.ts | 0 .../api}/src/libs/tests/request-auth.test.ts | 0 .../apps/api}/src/libs/tests/ws-auth.test.ts | 0 .../apps/api}/src/libs/ws-auth.ts | 0 .../apps/api}/src/middlewares/admin-guard.ts | 0 .../apps/api}/src/middlewares/auth.ts | 0 .../apps/api}/src/middlewares/config-guard.ts | 0 .../apps/api}/src/middlewares/rate-limit.ts | 0 .../src/middlewares/tests/admin-guard.test.ts | 0 .../api}/src/otel/gauges/active-sessions.ts | 0 .../src/otel/gauges/distinct-active-users.ts | 0 .../otel/gauges/rolling-active-users.test.ts | 0 .../src/otel/gauges/rolling-active-users.ts | 0 .../api}/src/otel/gauges/total-users.test.ts | 0 .../apps/api}/src/otel/gauges/total-users.ts | 0 .../api}/src/otel/gauges/tts-pool.test.ts | 0 .../apps/api}/src/otel/gauges/tts-pool.ts | 0 .../src/otel/gauges/ws-online-users.test.ts | 0 .../api}/src/otel/gauges/ws-online-users.ts | 0 .../apps/api}/src/otel/index.ts | 4 +- .../apps/api}/src/routes/admin-ui.ts | 0 .../routes/admin/capability-aliases/index.ts | 0 .../admin/capability-aliases/route.test.ts | 0 .../src/routes/admin/config/router/index.ts | 0 .../routes/admin/config/router/route.test.ts | 0 .../src/routes/admin/flux-grants/index.ts | 0 .../apps/api}/src/routes/admin/index.ts | 0 .../routes/admin/provider-catalog/index.ts | 0 .../admin/provider-catalog/route.test.ts | 0 .../apps/api}/src/routes/admin/users/index.ts | 0 .../api}/src/routes/admin/users/route.test.ts | 0 .../src/routes/admin/voice-packs/index.ts | 0 .../routes/admin/voice-packs/route.test.ts | 0 .../api}/src/routes/audio-speech-ws/index.ts | 0 .../src/routes/audio-speech-ws/protocol.ts | 0 .../src/routes/audio-speech-ws/route.test.ts | 0 .../src/routes/audio-speech-ws/session.ts | 0 .../api}/src/routes/audio-speech-ws/types.ts | 0 .../audio-transcription-stream/route.test.ts | 0 .../audio-transcription-stream/route.ts | 0 .../session.test.ts | 0 .../audio-transcription-stream/session.ts | 0 .../src/routes/auth/auth-rate-limit.test.ts | 0 .../api}/src/routes/auth/email-identifier.ts | 0 .../apps/api}/src/routes/auth/index.ts | 0 .../src/routes/auth/oidc-userinfo-ban.test.ts | 0 .../src/routes/auth/oidc/electron-callback.ts | 0 .../api}/src/routes/auth/oidc/token-auth.ts | 0 .../apps/api}/src/routes/auth/ui-routes.ts | 0 .../apps/api}/src/routes/characters/index.ts | 0 .../api}/src/routes/characters/route.test.ts | 0 .../apps/api}/src/routes/characters/schema.ts | 0 .../apps/api}/src/routes/chat-ws/broadcast.ts | 0 .../src/routes/chat-ws/connection-registry.ts | 0 .../apps/api}/src/routes/chat-ws/index.ts | 0 .../apps/api}/src/routes/chat-ws/rpc.ts | 0 .../apps/api}/src/routes/chats/index.ts | 0 .../apps/api}/src/routes/chats/schema.ts | 0 .../apps/api}/src/routes/flux/index.ts | 0 .../apps/api}/src/routes/flux/route.test.ts | 0 .../api}/src/routes/openai/v1/analytics.ts | 0 .../apps/api}/src/routes/openai/v1/gateway.ts | 0 .../src/routes/openai/v1/http/response.ts | 0 .../apps/api}/src/routes/openai/v1/index.ts | 0 .../routes/openai/v1/middlewares/billing.ts | 0 .../src/routes/openai/v1/middlewares/index.ts | 0 .../routes/openai/v1/middlewares/telemetry.ts | 0 .../openai/v1/middlewares/traffic-control.ts | 0 .../v1/operations/chat-completions/index.ts | 0 .../v1/operations/speech-catalog/index.ts | 0 .../v1/operations/speech-generation/index.ts | 0 .../api}/src/routes/openai/v1/route.test.ts | 4 +- .../apps/api}/src/routes/openai/v1/types.ts | 0 .../apps/api}/src/routes/providers/index.ts | 0 .../api}/src/routes/providers/route.test.ts | 0 .../apps/api}/src/routes/providers/schema.ts | 0 .../apps/api}/src/routes/stripe/index.ts | 0 .../src/routes/stripe/operations/checkout.ts | 0 .../src/routes/stripe/operations/webhook.ts | 0 .../api}/src/routes/stripe/price-catalog.ts | 0 .../apps/api}/src/routes/stripe/route.test.ts | 0 .../apps/api}/src/routes/stripe/schema.ts | 0 .../apps/api}/src/routes/voice-packs/index.ts | 0 .../api}/src/routes/voice-packs/route.test.ts | 0 .../apps/api}/src/schemas/accounts.ts | 0 .../apps/api}/src/schemas/characters.ts | 2 +- .../apps/api}/src/schemas/chats.ts | 0 .../apps/api}/src/schemas/flux-transaction.ts | 2 +- .../apps/api}/src/schemas/flux.ts | 2 +- .../apps/api}/src/schemas/index.ts | 0 .../apps/api}/src/schemas/llm-request-log.ts | 0 .../apps/api}/src/schemas/product-events.ts | 0 .../apps/api}/src/schemas/provider-catalog.ts | 0 .../apps/api}/src/schemas/providers.ts | 2 +- .../apps/api}/src/schemas/stripe.ts | 2 +- .../apps/api}/src/schemas/user-character.ts | 2 +- .../apps/api}/src/schemas/voice-packs.ts | 0 .../apps/api}/src/scripts/auth.ts | 0 .../apps/api}/src/scripts/otel/http-smoke.ts | 2 +- .../apps/api}/src/scripts/otel/smoke.ts | 2 +- .../apps/api}/src/scripts/otel/ws-smoke.ts | 6 +- .../src/services/adapters/config-kv.test.ts | 0 .../api}/src/services/adapters/config-kv.ts | 0 .../apps/api}/src/services/adapters/email.ts | 4 +- .../api}/src/services/adapters/posthog.ts | 0 .../src/services/adapters/tts/audio-format.ts | 0 .../api}/src/services/adapters/tts/azure.ts | 0 .../adapters/tts/dashscope-cosyvoice.test.ts | 0 .../adapters/tts/dashscope-cosyvoice.ts | 0 .../src/services/adapters/tts/index.test.ts | 0 .../api}/src/services/adapters/tts/index.ts | 0 .../api}/src/services/adapters/tts/stepfun.ts | 0 .../api}/src/services/adapters/tts/types.ts | 0 .../src/services/adapters/tts/unspeech.ts | 0 .../src/services/adapters/tts/volcengine.ts | 0 .../domain/admin/flux-grants/index.ts | 0 .../tests/admin-flux-grants.test.ts | 0 .../domain/admin/router-config/index.ts | 2 +- .../tests/admin-router-config.test.ts | 0 .../src/services/domain/admin/users/index.ts | 0 .../domain/billing/billing-service.ts | 0 .../src/services/domain/billing/billing.ts | 0 .../src/services/domain/billing/flux-meter.ts | 0 .../billing/tests/billing-service.test.ts | 0 .../domain/billing/tests/billing.test.ts | 0 .../domain/billing/tests/flux-meter.test.ts | 0 .../src/services/domain/characters.test.ts | 0 .../api}/src/services/domain/characters.ts | 0 .../api}/src/services/domain/chats.test.ts | 0 .../apps/api}/src/services/domain/chats.ts | 0 .../api}/src/services/domain/flux-balance.ts | 0 .../services/domain/flux-transaction.test.ts | 0 .../src/services/domain/flux-transaction.ts | 0 .../api}/src/services/domain/flux.test.ts | 0 .../apps/api}/src/services/domain/flux.ts | 2 +- .../domain/llm-router/concurrency-ledger.ts | 0 .../domain/llm-router/config-loader.ts | 0 .../llm-router/config-sync-subscriber.ts | 0 .../domain/llm-router/error-mapping.ts | 0 .../src/services/domain/llm-router/index.ts | 0 .../services/domain/llm-router/key-rotator.ts | 0 .../src/services/domain/llm-router/router.ts | 2 +- .../tests/concurrency-ledger.test.ts | 0 .../llm-router/tests/config-loader.test.ts | 0 .../llm-router/tests/error-mapping.test.ts | 0 .../llm-router/tests/key-rotator.test.ts | 0 .../domain/llm-router/tests/router.test.ts | 0 .../src/services/domain/llm-router/types.ts | 2 +- .../services/domain/llm-tracing/index.test.ts | 0 .../src/services/domain/llm-tracing/index.ts | 0 .../services/domain/openai-speech/index.ts | 0 .../services/domain/product-events.test.ts | 0 .../src/services/domain/product-events.ts | 0 .../domain/provider-catalog/index.test.ts | 0 .../services/domain/provider-catalog/index.ts | 0 .../provider-catalog/provider-voices.ts | 0 .../src/services/domain/providers.test.ts | 0 .../api}/src/services/domain/providers.ts | 0 .../api}/src/services/domain/request-log.ts | 0 .../api}/src/services/domain/stripe.test.ts | 0 .../apps/api}/src/services/domain/stripe.ts | 4 +- .../services/domain/user-deletion/index.ts | 0 .../domain/user-deletion/tests/index.test.ts | 0 .../tests/service-deletion.test.ts | 0 .../services/domain/user-deletion/types.ts | 0 .../src/services/domain/users/resolve-user.ts | 0 .../services/domain/voice-packs/index.test.ts | 0 .../src/services/domain/voice-packs/index.ts | 0 .../api}/src/types/character-avatar-model.ts | 0 .../api}/src/types/character-capability.ts | 0 .../apps/api}/src/types/hono.ts | 0 .../apps/api}/src/utils/auth-ui.ts | 0 .../apps/api}/src/utils/chat-broadcast.ts | 0 .../api}/src/utils/envelope-crypto.test.ts | 0 .../apps/api}/src/utils/envelope-crypto.ts | 2 +- .../apps/api}/src/utils/error-message.ts | 0 .../apps/api}/src/utils/error.ts | 0 .../apps/api}/src/utils/http-query.ts | 0 .../apps/api}/src/utils/id.ts | 0 .../apps/api}/src/utils/observability.ts | 0 .../apps/api}/src/utils/origin.ts | 0 .../apps/api}/src/utils/redis-keys.ts | 0 .../apps/api}/src/utils/server-admin-ui.ts | 0 .../apps/api}/src/utils/tests/auth-ui.test.ts | 0 .../src/utils/tests/chat-broadcast.test.ts | 0 .../api}/src/utils/tests/http-query.test.ts | 0 .../apps/api}/src/utils/tests/origin.test.ts | 0 .../api}/src/utils/tests/redis-keys.test.ts | 0 .../src/utils/tests/server-admin-ui.test.ts | 0 .../server => server/apps/api}/tsconfig.json | 3 +- .../apps/api}/vitest.config.ts | 0 {apps/server => server}/docker-compose.yml | 19 +- server/packages/drizzle-migration/README.md | 13 + .../packages/drizzle-migration}/package.json | 10 +- .../packages/drizzle-migration}/src/index.ts | 0 .../packages/drizzle-migration}/tsconfig.json | 0 .../drizzle-migration}/tsdown.config.ts | 2 +- .../drizzle-migration}/vitest.config.ts | 0 turbo.json | 2 +- vitest.config.ts | 2 +- 293 files changed, 176 insertions(+), 3020 deletions(-) delete mode 100644 apps/server/.env delete mode 100644 apps/server/railway.toml delete mode 100644 docs/ai/context/plans/2026-05-09-character-cards-cloud-sync-design.md delete mode 100644 docs/ai/context/verification-automation.md delete mode 100644 docs/brainstorms/2026-05-30-voice-pack-requirements.md delete mode 100644 docs/plans/2026-05-30-001-feat-voice-pack-tts-pool-lb-plan.md delete mode 100644 docs/plans/2026-06-07-001-feat-observability-diagnostics-plan.md delete mode 100644 docs/plans/2026-06-10-001-feat-llm-router-admin-form-plan.md delete mode 100644 docs/plans/2026-06-14-001-feat-doubao-asr-gateway-plan.md delete mode 100644 docs/superpowers/plans/2026-07-01-official-provider-catalog.md delete mode 100644 docs/superpowers/plans/2026-07-01-tts-voice-pack-model-option-plan.md delete mode 100644 docs/superpowers/plans/2026-07-10-auth-ui-new-backend-origin.md delete mode 100644 docs/superpowers/specs/2026-07-01-official-provider-catalog-prd.xml delete mode 100644 docs/superpowers/specs/2026-07-10-auth-ui-new-backend-origin-design.md create mode 100644 server/README.md rename {apps/server => server/apps/api}/.gitignore (100%) rename {apps/server => server/apps/api}/CLAUDE.md (67%) rename {apps/server => server/apps/api}/Dockerfile (57%) rename {apps/server => server/apps/api}/README.md (82%) rename {apps/server => server/apps/api}/docs/.gitkeep (100%) rename {apps/server => server/apps/api}/drizzle.config.ts (100%) rename {apps/server => server/apps/api}/drizzle/0000_mean_slipstream.sql (100%) rename {apps/server => server/apps/api}/drizzle/0001_magenta_skrulls.sql (100%) rename {apps/server => server/apps/api}/drizzle/0002_mean_tigra.sql (100%) rename {apps/server => server/apps/api}/drizzle/0003_old_titania.sql (100%) rename {apps/server => server/apps/api}/drizzle/0004_bouncy_devos.sql (100%) rename {apps/server => server/apps/api}/drizzle/0005_tough_living_tribunal.sql (100%) rename {apps/server => server/apps/api}/drizzle/0006_overconfident_susan_delgado.sql (100%) rename {apps/server => server/apps/api}/drizzle/0007_red_nicolaos.sql (100%) rename {apps/server => server/apps/api}/drizzle/0008_gray_xavin.sql (100%) rename {apps/server => server/apps/api}/drizzle/0009_perpetual_lilandra.sql (100%) rename {apps/server => server/apps/api}/drizzle/0010_sudden_bastion.sql (100%) rename {apps/server => server/apps/api}/drizzle/0011_common_doctor_octopus.sql (100%) rename {apps/server => server/apps/api}/drizzle/0012_clumsy_the_stranger.sql (100%) rename {apps/server => server/apps/api}/drizzle/0013_naive_groot.sql (100%) rename {apps/server => server/apps/api}/drizzle/0014_vengeful_blonde_phantom.sql (100%) rename {apps/server => server/apps/api}/drizzle/0015_concerned_piledriver.sql (100%) rename {apps/server => server/apps/api}/drizzle/0016_tired_dagger.sql (100%) rename {apps/server => server/apps/api}/drizzle/0017_nappy_dagger.sql (100%) rename {apps/server => server/apps/api}/drizzle/meta/0000_snapshot.json (100%) rename {apps/server => server/apps/api}/drizzle/meta/0001_snapshot.json (100%) rename {apps/server => server/apps/api}/drizzle/meta/0002_snapshot.json (100%) rename {apps/server => server/apps/api}/drizzle/meta/0003_snapshot.json (100%) rename {apps/server => server/apps/api}/drizzle/meta/0004_snapshot.json (100%) rename {apps/server => server/apps/api}/drizzle/meta/0005_snapshot.json (100%) rename {apps/server => server/apps/api}/drizzle/meta/0006_snapshot.json (100%) rename {apps/server => server/apps/api}/drizzle/meta/0007_snapshot.json (100%) rename {apps/server => server/apps/api}/drizzle/meta/0008_snapshot.json (100%) rename {apps/server => server/apps/api}/drizzle/meta/0009_snapshot.json (100%) rename {apps/server => server/apps/api}/drizzle/meta/0010_snapshot.json (100%) rename {apps/server => server/apps/api}/drizzle/meta/0011_snapshot.json (100%) rename {apps/server => server/apps/api}/drizzle/meta/0012_snapshot.json (100%) rename {apps/server => server/apps/api}/drizzle/meta/0013_snapshot.json (100%) rename {apps/server => server/apps/api}/drizzle/meta/0014_snapshot.json (100%) rename {apps/server => server/apps/api}/drizzle/meta/0015_snapshot.json (100%) rename {apps/server => server/apps/api}/drizzle/meta/0016_snapshot.json (100%) rename {apps/server => server/apps/api}/drizzle/meta/0017_snapshot.json (100%) rename {apps/server => server/apps/api}/drizzle/meta/_journal.json (100%) rename {apps/server => server/apps/api}/instrumentation.ts (100%) rename {apps/server => server/apps/api}/package.json (92%) rename {apps/server => server/apps/api}/production/railway/Dockerfile (55%) create mode 100644 server/apps/api/railway.toml rename {apps/server => server/apps/api}/sql/init.sql (100%) rename {apps/server => server/apps/api}/src/app.test.ts (100%) rename {apps/server => server/apps/api}/src/app.ts (99%) rename {apps/server => server/apps/api}/src/bin/run.ts (100%) rename {apps/server => server/apps/api}/src/libs/auth-plugins/oidc-jwt-bearer.ts (99%) rename {apps/server => server/apps/api}/src/libs/auth.ts (99%) rename {apps/server => server/apps/api}/src/libs/db.ts (95%) rename {apps/server => server/apps/api}/src/libs/env.ts (100%) rename {apps/server => server/apps/api}/src/libs/external-dependency.ts (100%) rename {apps/server => server/apps/api}/src/libs/gravatar.ts (100%) rename {apps/server => server/apps/api}/src/libs/mock-db.ts (100%) rename {apps/server => server/apps/api}/src/libs/redis.ts (100%) rename {apps/server => server/apps/api}/src/libs/request-auth.ts (100%) rename {apps/server => server/apps/api}/src/libs/tests/auth.test.ts (100%) rename {apps/server => server/apps/api}/src/libs/tests/env.test.ts (100%) rename {apps/server => server/apps/api}/src/libs/tests/request-auth.test.ts (100%) rename {apps/server => server/apps/api}/src/libs/tests/ws-auth.test.ts (100%) rename {apps/server => server/apps/api}/src/libs/ws-auth.ts (100%) rename {apps/server => server/apps/api}/src/middlewares/admin-guard.ts (100%) rename {apps/server => server/apps/api}/src/middlewares/auth.ts (100%) rename {apps/server => server/apps/api}/src/middlewares/config-guard.ts (100%) rename {apps/server => server/apps/api}/src/middlewares/rate-limit.ts (100%) rename {apps/server => server/apps/api}/src/middlewares/tests/admin-guard.test.ts (100%) rename {apps/server => server/apps/api}/src/otel/gauges/active-sessions.ts (100%) rename {apps/server => server/apps/api}/src/otel/gauges/distinct-active-users.ts (100%) rename {apps/server => server/apps/api}/src/otel/gauges/rolling-active-users.test.ts (100%) rename {apps/server => server/apps/api}/src/otel/gauges/rolling-active-users.ts (100%) rename {apps/server => server/apps/api}/src/otel/gauges/total-users.test.ts (100%) rename {apps/server => server/apps/api}/src/otel/gauges/total-users.ts (100%) rename {apps/server => server/apps/api}/src/otel/gauges/tts-pool.test.ts (100%) rename {apps/server => server/apps/api}/src/otel/gauges/tts-pool.ts (100%) rename {apps/server => server/apps/api}/src/otel/gauges/ws-online-users.test.ts (100%) rename {apps/server => server/apps/api}/src/otel/gauges/ws-online-users.ts (100%) rename {apps/server => server/apps/api}/src/otel/index.ts (99%) rename {apps/server => server/apps/api}/src/routes/admin-ui.ts (100%) rename {apps/server => server/apps/api}/src/routes/admin/capability-aliases/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/admin/capability-aliases/route.test.ts (100%) rename {apps/server => server/apps/api}/src/routes/admin/config/router/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/admin/config/router/route.test.ts (100%) rename {apps/server => server/apps/api}/src/routes/admin/flux-grants/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/admin/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/admin/provider-catalog/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/admin/provider-catalog/route.test.ts (100%) rename {apps/server => server/apps/api}/src/routes/admin/users/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/admin/users/route.test.ts (100%) rename {apps/server => server/apps/api}/src/routes/admin/voice-packs/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/admin/voice-packs/route.test.ts (100%) rename {apps/server => server/apps/api}/src/routes/audio-speech-ws/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/audio-speech-ws/protocol.ts (100%) rename {apps/server => server/apps/api}/src/routes/audio-speech-ws/route.test.ts (100%) rename {apps/server => server/apps/api}/src/routes/audio-speech-ws/session.ts (100%) rename {apps/server => server/apps/api}/src/routes/audio-speech-ws/types.ts (100%) rename {apps/server => server/apps/api}/src/routes/audio-transcription-stream/route.test.ts (100%) rename {apps/server => server/apps/api}/src/routes/audio-transcription-stream/route.ts (100%) rename {apps/server => server/apps/api}/src/routes/audio-transcription-stream/session.test.ts (100%) rename {apps/server => server/apps/api}/src/routes/audio-transcription-stream/session.ts (100%) rename {apps/server => server/apps/api}/src/routes/auth/auth-rate-limit.test.ts (100%) rename {apps/server => server/apps/api}/src/routes/auth/email-identifier.ts (100%) rename {apps/server => server/apps/api}/src/routes/auth/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/auth/oidc-userinfo-ban.test.ts (100%) rename {apps/server => server/apps/api}/src/routes/auth/oidc/electron-callback.ts (100%) rename {apps/server => server/apps/api}/src/routes/auth/oidc/token-auth.ts (100%) rename {apps/server => server/apps/api}/src/routes/auth/ui-routes.ts (100%) rename {apps/server => server/apps/api}/src/routes/characters/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/characters/route.test.ts (100%) rename {apps/server => server/apps/api}/src/routes/characters/schema.ts (100%) rename {apps/server => server/apps/api}/src/routes/chat-ws/broadcast.ts (100%) rename {apps/server => server/apps/api}/src/routes/chat-ws/connection-registry.ts (100%) rename {apps/server => server/apps/api}/src/routes/chat-ws/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/chat-ws/rpc.ts (100%) rename {apps/server => server/apps/api}/src/routes/chats/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/chats/schema.ts (100%) rename {apps/server => server/apps/api}/src/routes/flux/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/flux/route.test.ts (100%) rename {apps/server => server/apps/api}/src/routes/openai/v1/analytics.ts (100%) rename {apps/server => server/apps/api}/src/routes/openai/v1/gateway.ts (100%) rename {apps/server => server/apps/api}/src/routes/openai/v1/http/response.ts (100%) rename {apps/server => server/apps/api}/src/routes/openai/v1/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/openai/v1/middlewares/billing.ts (100%) rename {apps/server => server/apps/api}/src/routes/openai/v1/middlewares/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/openai/v1/middlewares/telemetry.ts (100%) rename {apps/server => server/apps/api}/src/routes/openai/v1/middlewares/traffic-control.ts (100%) rename {apps/server => server/apps/api}/src/routes/openai/v1/operations/chat-completions/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/openai/v1/operations/speech-catalog/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/openai/v1/operations/speech-generation/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/openai/v1/route.test.ts (99%) rename {apps/server => server/apps/api}/src/routes/openai/v1/types.ts (100%) rename {apps/server => server/apps/api}/src/routes/providers/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/providers/route.test.ts (100%) rename {apps/server => server/apps/api}/src/routes/providers/schema.ts (100%) rename {apps/server => server/apps/api}/src/routes/stripe/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/stripe/operations/checkout.ts (100%) rename {apps/server => server/apps/api}/src/routes/stripe/operations/webhook.ts (100%) rename {apps/server => server/apps/api}/src/routes/stripe/price-catalog.ts (100%) rename {apps/server => server/apps/api}/src/routes/stripe/route.test.ts (100%) rename {apps/server => server/apps/api}/src/routes/stripe/schema.ts (100%) rename {apps/server => server/apps/api}/src/routes/voice-packs/index.ts (100%) rename {apps/server => server/apps/api}/src/routes/voice-packs/route.test.ts (100%) rename {apps/server => server/apps/api}/src/schemas/accounts.ts (100%) rename {apps/server => server/apps/api}/src/schemas/characters.ts (99%) rename {apps/server => server/apps/api}/src/schemas/chats.ts (100%) rename {apps/server => server/apps/api}/src/schemas/flux-transaction.ts (95%) rename {apps/server => server/apps/api}/src/schemas/flux.ts (89%) rename {apps/server => server/apps/api}/src/schemas/index.ts (100%) rename {apps/server => server/apps/api}/src/schemas/llm-request-log.ts (100%) rename {apps/server => server/apps/api}/src/schemas/product-events.ts (100%) rename {apps/server => server/apps/api}/src/schemas/provider-catalog.ts (100%) rename {apps/server => server/apps/api}/src/schemas/providers.ts (97%) rename {apps/server => server/apps/api}/src/schemas/stripe.ts (98%) rename {apps/server => server/apps/api}/src/schemas/user-character.ts (97%) rename {apps/server => server/apps/api}/src/schemas/voice-packs.ts (100%) rename {apps/server => server/apps/api}/src/scripts/auth.ts (100%) rename {apps/server => server/apps/api}/src/scripts/otel/http-smoke.ts (98%) rename {apps/server => server/apps/api}/src/scripts/otel/smoke.ts (96%) rename {apps/server => server/apps/api}/src/scripts/otel/ws-smoke.ts (96%) rename {apps/server => server/apps/api}/src/services/adapters/config-kv.test.ts (100%) rename {apps/server => server/apps/api}/src/services/adapters/config-kv.ts (100%) rename {apps/server => server/apps/api}/src/services/adapters/email.ts (99%) rename {apps/server => server/apps/api}/src/services/adapters/posthog.ts (100%) rename {apps/server => server/apps/api}/src/services/adapters/tts/audio-format.ts (100%) rename {apps/server => server/apps/api}/src/services/adapters/tts/azure.ts (100%) rename {apps/server => server/apps/api}/src/services/adapters/tts/dashscope-cosyvoice.test.ts (100%) rename {apps/server => server/apps/api}/src/services/adapters/tts/dashscope-cosyvoice.ts (100%) rename {apps/server => server/apps/api}/src/services/adapters/tts/index.test.ts (100%) rename {apps/server => server/apps/api}/src/services/adapters/tts/index.ts (100%) rename {apps/server => server/apps/api}/src/services/adapters/tts/stepfun.ts (100%) rename {apps/server => server/apps/api}/src/services/adapters/tts/types.ts (100%) rename {apps/server => server/apps/api}/src/services/adapters/tts/unspeech.ts (100%) rename {apps/server => server/apps/api}/src/services/adapters/tts/volcengine.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/admin/flux-grants/index.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/admin/flux-grants/tests/admin-flux-grants.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/admin/router-config/index.ts (99%) rename {apps/server => server/apps/api}/src/services/domain/admin/router-config/tests/admin-router-config.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/admin/users/index.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/billing/billing-service.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/billing/billing.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/billing/flux-meter.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/billing/tests/billing-service.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/billing/tests/billing.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/billing/tests/flux-meter.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/characters.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/characters.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/chats.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/chats.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/flux-balance.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/flux-transaction.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/flux-transaction.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/flux.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/flux.ts (98%) rename {apps/server => server/apps/api}/src/services/domain/llm-router/concurrency-ledger.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/llm-router/config-loader.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/llm-router/config-sync-subscriber.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/llm-router/error-mapping.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/llm-router/index.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/llm-router/key-rotator.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/llm-router/router.ts (99%) rename {apps/server => server/apps/api}/src/services/domain/llm-router/tests/concurrency-ledger.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/llm-router/tests/config-loader.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/llm-router/tests/error-mapping.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/llm-router/tests/key-rotator.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/llm-router/tests/router.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/llm-router/types.ts (98%) rename {apps/server => server/apps/api}/src/services/domain/llm-tracing/index.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/llm-tracing/index.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/openai-speech/index.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/product-events.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/product-events.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/provider-catalog/index.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/provider-catalog/index.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/provider-catalog/provider-voices.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/providers.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/providers.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/request-log.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/stripe.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/stripe.ts (98%) rename {apps/server => server/apps/api}/src/services/domain/user-deletion/index.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/user-deletion/tests/index.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/user-deletion/tests/service-deletion.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/user-deletion/types.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/users/resolve-user.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/voice-packs/index.test.ts (100%) rename {apps/server => server/apps/api}/src/services/domain/voice-packs/index.ts (100%) rename {apps/server => server/apps/api}/src/types/character-avatar-model.ts (100%) rename {apps/server => server/apps/api}/src/types/character-capability.ts (100%) rename {apps/server => server/apps/api}/src/types/hono.ts (100%) rename {apps/server => server/apps/api}/src/utils/auth-ui.ts (100%) rename {apps/server => server/apps/api}/src/utils/chat-broadcast.ts (100%) rename {apps/server => server/apps/api}/src/utils/envelope-crypto.test.ts (100%) rename {apps/server => server/apps/api}/src/utils/envelope-crypto.ts (99%) rename {apps/server => server/apps/api}/src/utils/error-message.ts (100%) rename {apps/server => server/apps/api}/src/utils/error.ts (100%) rename {apps/server => server/apps/api}/src/utils/http-query.ts (100%) rename {apps/server => server/apps/api}/src/utils/id.ts (100%) rename {apps/server => server/apps/api}/src/utils/observability.ts (100%) rename {apps/server => server/apps/api}/src/utils/origin.ts (100%) rename {apps/server => server/apps/api}/src/utils/redis-keys.ts (100%) rename {apps/server => server/apps/api}/src/utils/server-admin-ui.ts (100%) rename {apps/server => server/apps/api}/src/utils/tests/auth-ui.test.ts (100%) rename {apps/server => server/apps/api}/src/utils/tests/chat-broadcast.test.ts (100%) rename {apps/server => server/apps/api}/src/utils/tests/http-query.test.ts (100%) rename {apps/server => server/apps/api}/src/utils/tests/origin.test.ts (100%) rename {apps/server => server/apps/api}/src/utils/tests/redis-keys.test.ts (100%) rename {apps/server => server/apps/api}/src/utils/tests/server-admin-ui.test.ts (100%) rename {apps/server => server/apps/api}/tsconfig.json (88%) rename {apps/server => server/apps/api}/vitest.config.ts (100%) rename {apps/server => server}/docker-compose.yml (63%) create mode 100644 server/packages/drizzle-migration/README.md rename {packages/server-schema => server/packages/drizzle-migration}/package.json (70%) rename {packages/server-schema => server/packages/drizzle-migration}/src/index.ts (100%) rename {packages/server-schema => server/packages/drizzle-migration}/tsconfig.json (100%) rename {packages/server-schema => server/packages/drizzle-migration}/tsdown.config.ts (90%) rename {packages/server-schema => server/packages/drizzle-migration}/vitest.config.ts (100%) diff --git a/.dockerignore b/.dockerignore index 7b4529602..8b58e15e4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,7 @@ **/node_modules **/dist **/.temp +**/.env +**/.env.* +!**/.env.example target diff --git a/apps/server/.env b/apps/server/.env deleted file mode 100644 index 73fad2f69..000000000 --- a/apps/server/.env +++ /dev/null @@ -1,40 +0,0 @@ -DATABASE_URL="postgresql://postgres:example-PAssw0rd-xHjDYR.b7N@db:5432/postgres" -REDIS_URL="redis://localhost:6379" - -BETTER_AUTH_SECRET="" - -AUTH_GOOGLE_CLIENT_ID="" -AUTH_GOOGLE_CLIENT_SECRET="" - -AUTH_GITHUB_CLIENT_ID="" -AUTH_GITHUB_CLIENT_SECRET="" - -STRIPE_SECRET_KEY="" -STRIPE_WEBHOOK_SECRET="" - -API_SERVER_URL="" - -# Trust Railway's canonical X-Real-IP only when this service is deployed behind -# Railway/Caddy and cannot be reached through an untrusted direct proxy. -# RATE_LIMIT_TRUSTED_PROXY="railway" - -# Comma-separated browser origins for CORS (/api/*) and Stripe return URLs. -# Required when the Capacitor dev server uses a LAN IP (see ios/App/App/capacitor.config.json), -# e.g. ADDITIONAL_TRUSTED_ORIGINS="https://10.0.0.129:5273,https://198.18.0.1:5273" - -# OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318" - -# Master key for the in-process LLM/TTS router envelope crypto. -# Base64 of 32 random bytes. Required at boot: missing or wrong-length value -# fails env parsing and the server refuses to start (no graceful skip). -# Do NOT commit a real key here — put the value in `.env.local` (gitignored). -# -# Generate: -# openssl rand -base64 32 -# -# Rotation: when replacing the key, copy the old value to -# LLM_ROUTER_MASTER_KEY_PREVIOUS first, set LLM_ROUTER_MASTER_KEY to the new -# one, redeploy, then run a re-wrap of every stored ciphertext before -# dropping PREVIOUS. See `apps/server/src/utils/envelope-crypto.ts`. -LLM_ROUTER_MASTER_KEY="" -# LLM_ROUTER_MASTER_KEY_PREVIOUS="" diff --git a/apps/server/railway.toml b/apps/server/railway.toml deleted file mode 100644 index 28975201d..000000000 --- a/apps/server/railway.toml +++ /dev/null @@ -1,11 +0,0 @@ -[build] -builder = "DOCKERFILE" -dockerfilePath = "/apps/server/production/railway/Dockerfile" -watchPatterns = [ - "apps/server/**", - "packages/**", - "pnpm-lock.yaml" -] - -[deploy] -startCommand = "pnpm -F @proj-airi/server start" diff --git a/apps/ui-server-auth/README.md b/apps/ui-server-auth/README.md index 9411a43f9..41c0fbbe4 100644 --- a/apps/ui-server-auth/README.md +++ b/apps/ui-server-auth/README.md @@ -1,6 +1,6 @@ # AIRI Server Auth UI -Auth UI for the hosted AIRI server. It is a Vue/Vite app deployed separately from `apps/server` and used for Better Auth sign-in, email verification, password reset, profile, and Electron OIDC callback relay flows. +Auth UI for the hosted AIRI server. It is a Vue/Vite app deployed separately from `server/apps/api` and used for Better Auth sign-in, email verification, password reset, profile, and Electron OIDC callback relay flows. ## Use When diff --git a/apps/ui-server-auth/src/modules/analytics.ts b/apps/ui-server-auth/src/modules/analytics.ts index 94abb67a3..a948db7de 100644 --- a/apps/ui-server-auth/src/modules/analytics.ts +++ b/apps/ui-server-auth/src/modules/analytics.ts @@ -128,7 +128,7 @@ export function loadAnalyticsAdapter(loader: () => Promise): P /** * Merge this browser's anonymous events with the Better Auth user person. * `userId` must be the Better Auth `user.id` — the same value the server - * uses as `distinctId` (see `apps/server` product events forwarding). + * uses as `distinctId` (see `server/apps/api` product events forwarding). */ export function identifyAuthUser(userId: string): void { analytics.identify(userId) diff --git a/apps/ui-server-auth/src/modules/auth-fetch.ts b/apps/ui-server-auth/src/modules/auth-fetch.ts index df7958b00..9e29d3199 100644 --- a/apps/ui-server-auth/src/modules/auth-fetch.ts +++ b/apps/ui-server-auth/src/modules/auth-fetch.ts @@ -1,5 +1,5 @@ /** - * Shared HTTP plumbing for the ui-server-auth → apps/server auth surface. + * Shared HTTP plumbing for the ui-server-auth → server/apps/api auth surface. * * Use when: * - Hitting any `/api/auth/...` endpoint from the UI (sign-in, sign-up, diff --git a/apps/ui-server-auth/src/pages/profile.vue b/apps/ui-server-auth/src/pages/profile.vue index fcf65d19b..a6debdfaa 100644 --- a/apps/ui-server-auth/src/pages/profile.vue +++ b/apps/ui-server-auth/src/pages/profile.vue @@ -67,7 +67,7 @@ const signOutError = shallowRef(null) // set / provider URL or a Gravatar fallback URL. We detect the fallback by // URL prefix so the server doesn't need to ship a redundant `imageSource` // flag — gravatar URLs are stable enough that prefix-matching is fine. -// See apps/server/src/routes/oidc/token-auth.ts for the server-side build. +// See server/apps/api/src/routes/oidc/token-auth.ts for the server-side build. const GRAVATAR_AVATAR_PREFIX = 'https://www.gravatar.com/avatar/' const avatarUrl = computed(() => user.value?.image ?? null) const usingGravatarFallback = computed( diff --git a/docs/ai/context/plans/2026-05-09-character-cards-cloud-sync-design.md b/docs/ai/context/plans/2026-05-09-character-cards-cloud-sync-design.md deleted file mode 100644 index e619571c4..000000000 --- a/docs/ai/context/plans/2026-05-09-character-cards-cloud-sync-design.md +++ /dev/null @@ -1,300 +0,0 @@ -# Character Cards Cloud Sync — Phase 1 Design - -> Date: 2026-05-09 -> Status: Design (pending approval) -> Owner: @RainbowBird -> Track: Phase 1 of A (大整合 — 三 store 合一 + 云同步) - -## 1. Goal & Non-Goals - -### Goal - -让所有 stage 入口(stage-tamagotchi / stage-web / stage-pocket)的角色卡数据从纯本地 `localStorage` 升级为「**本地永远是 source of truth on device + 登录后云同步**」,并清理「三 store 并存」技术债的第一刀(废 `stores/characters.ts` 孤岛页)。 - -### Non-Goals(本 Phase 不做) - -- **Phase 2**:删 [`stores/character/index.ts`](../../../packages/stage-ui/src/stores/character/index.ts) facade,统一 character 调用入口 -- **Phase 3**:rename `useAiriCardStore` → `useCharacterStore`,命名对齐 server -- Settings 云同步(独立 spec,等本 spec 的 sync engine 落地后再接) -- Marketplace 上架路径(`user_characters` → `characters` 的 explicit publish transform) -- 多设备并发实时编辑(per-field LWW / vector clock / 冲突 UI — α-full 范围) -- Server-push(SSE / WS)同步推送 — 本 Phase 是被动 pull on focus / login - -## 2. 现状 - -3 个并行 store 在管「角色卡」概念: - -| # | Store | 数据形态 | 存储 | 用户路径 | -|---|-------|---------|------|---------| -| 1 | `useAiriCardStore` ([`stores/modules/airi-card.ts`](../../../packages/stage-ui/src/stores/modules/airi-card.ts)) | CCv3 jsonb + airi extension | `useLocalStorageManualReset>('airi-cards')` | **事实上的运行时角色卡** — 三端 App.vue / chat / Stage / profile-switcher / artistry / sessions-drawer,调用点 25+ | -| 2 | `useCharacterStore` ([`stores/characters.ts`](../../../packages/stage-ui/src/stores/characters.ts)) | 关系化 (character + i18n + capabilities + avatar + cover) | server `/characters` API + `@pinia/colada` | **孤岛**:仅 [`apps/stage-web/src/pages/settings/characters/`](../../../apps/stage-web/src/pages/settings/characters/) | -| 3 | `useCharacterStore` ([`stores/character/index.ts`](../../../packages/stage-ui/src/stores/character/index.ts)) ← 同名!| facade of #1 | — | [v2/index.vue](../../../packages/stage-pages/src/pages/v2/index.vue) / devtools/context-flow | - -Server `characters` 表([`apps/server/src/schemas/characters.ts`](../../../apps/server/src/schemas/characters.ts))已经是 marketplace 形态(`likesCount` / `forksCount` / `priceCredit` / `character_i18n` 多语言 / `character_capabilities` / `avatar_model` / `character_covers`),跟 client AiriCard CCv3 schema 完全不同。 - -## 3. 终态(Phase 1 完成后) - -- `useAiriCardStore` 内部存储从 `localStorage`-only 升级到「**localStorage 主路径 + sync engine 后台同步到 server**」 -- 25+ 调用面 path(`activeCard.x.y.z`)**完全不变**——只换内部存储和加 sync 层 -- 现有 `stores/characters.ts` 孤岛被废:删 store/service/model 文件,[`apps/stage-web/src/pages/settings/characters/`](../../../apps/stage-web/src/pages/settings/characters/) 改用统一的 [`packages/stage-pages/src/pages/settings/airi-card/`](../../../packages/stage-pages/src/pages/settings/airi-card/) 页(stage-tamagotchi 已经在用) -- Server 新建 `user_characters` + `user_active_character` 表 -- Server 现有 `/characters` 路由和 `characters` 表**保留不动**(marketplace 用,未来 spec 接入) - -## 4. 关键设计决策 - -| # | 决策 | 选择 | 拒绝理由 | -|---|------|------|---------| -| D1 | 范围 | A 拆 3 Phase,本次只做 Phase 1 | 不拆 = PR 太大风险高;只做 B(不合 store)= 留二次重构债 | -| D2 | 未登录态 | **α-min**:本地可写 + 登录后 union by clientId 上传 | β(强制登录)破坏离线 UX、炸老用户;γ(不 merge)默默丢卡 | -| D3 | `activeCardId` 同步粒度 | per-user | per-device 违反「养一个 AI 角色」产品直觉 | -| D4 | server schema | 两张表(`user_characters` + 现存 `characters`) | 单表 + visibility 字段:marketplace/private 权限边界易漏;多语言关系字段对私有卡冗余 | -| D5 | Delete 语义 | soft delete(`deletedAt` tombstone)参与 LWW | hard delete = 多设备 race 复活已删卡 | -| D6 | 同步触发 | `watchDebounced` 自动后台 sync (2s) + retry 队列 | 手动按钮 = 用户感知不一致状态 | -| D7 | 多设备并发 | 整卡 LWW by `updatedAt` | per-field LWW / vector clock = α-full 范围,过度工程 | -| D8 | CCv3 import/export | 复用现有 `addCard` 路径,import 后自动入 sync 队列 | — | - -## 5. 数据模型 - -### 5.1 Server Schema - -新建文件 `apps/server/src/schemas/user-characters.ts`: - -```ts -import type { AiriCard } from '@proj-airi/stage-ui/types/airi-card' -import type { InferInsertModel, InferSelectModel } from 'drizzle-orm' - -import { index, jsonb, pgTable, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core' - -import { nanoid } from '../utils/id' - -// NOTICE: bare ownerId is intentional — no FK to user.id. better-auth hard-deletes -// the user row; a cascade would wipe these soft-delete archive rows. -// See `apps/server/docs/ai-context/account-deletion.md`. -export const userCharacters = pgTable( - 'user_characters', - { - id: text('id').primaryKey().$defaultFn(() => nanoid()), - ownerId: text('owner_id').notNull(), - - // client 端 nanoid,跨设备稳定标识同一张卡。server 端 PUT 用它做 idempotency。 - clientId: text('client_id').notNull(), - - // 完整 CCv3 + airi extension,lossless 兜底。 - rawCard: jsonb('raw_card').notNull().$type(), - - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), - deletedAt: timestamp('deleted_at'), - }, - table => ({ - ownerClientUniq: uniqueIndex('user_characters_owner_client_uniq').on(table.ownerId, table.clientId), - ownerIdx: index('user_characters_owner_idx').on(table.ownerId), - }), -) - -export type UserCharacter = InferSelectModel -export type NewUserCharacter = InferInsertModel - -export const userActiveCharacter = pgTable( - 'user_active_character', - { - ownerId: text('owner_id').primaryKey(), - activeClientId: text('active_client_id').notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), - }, -) - -export type UserActiveCharacter = InferSelectModel -``` - -### 5.2 Client Internal State - -`useAiriCardStore` 现有 `cards: Map` + `activeCardId: string` 不变。新增 internal: - -```ts -interface SyncOp { kind: 'upsert' | 'delete', clientId: string } - -interface SyncState { - status: 'offline' | 'unauthenticated' | 'syncing' | 'synced' | 'error' - pendingOps: Map // by clientId, 最后一笔操作覆盖前面 - lastSyncedAt: number | null - lastError: string | null -} -``` - -`pendingOps` 持久化到 `localStorage` 一个独立 key(`airi-cards-pending-ops`),App 重启后能继续 flush。 - -## 6. API 设计 - -新建 `apps/server/src/routes/user-characters/`: - -| Method | Path | 用途 | Body | -|--------|------|------|------| -| GET | `/user-characters` | 列出当前用户全部卡 (含 tombstone) | — | -| PUT | `/user-characters/:clientId` | upsert 一张卡(按 ownerId+clientId 唯一);LWW by `updatedAt` | `{ rawCard: AiriCard, updatedAt: string }` | -| DELETE | `/user-characters/:clientId` | soft delete (set `deletedAt = now()`) | — | -| GET | `/user-characters/active` | 取当前 activeClientId | — | -| PUT | `/user-characters/active` | 设置 activeClientId | `{ activeClientId: string }` | - -所有路由走 `authGuard`,按 `ownerId = currentUser.id` 过滤。 - -PUT `/user-characters/:clientId` 的 LWW 逻辑: -- 不存在 → INSERT -- 存在且 `deletedAt IS NULL`: - - `incoming.updatedAt > existing.updatedAt` → UPDATE - - `incoming.updatedAt <= existing.updatedAt` → 返回 200 + existing(不覆盖;客户端发现 server 比自己新会拉回来) -- 存在且 `deletedAt IS NOT NULL`(tombstone): - - `incoming.updatedAt > existing.deletedAt` → **复活**:清 `deletedAt` + UPDATE 内容(last operation wins,不论是 edit 还是 delete) - - `incoming.updatedAt <= existing.deletedAt` → 返回 200 + tombstone(编辑发生在删除前,不复活;客户端拉回 tombstone 后会本地删除) - -DELETE 走相同的 LWW,比较 `incoming.deletedAt` (= now()) 与 `existing.updatedAt`: -- `incoming.deletedAt > existing.updatedAt` → 设置 tombstone -- 否则 → 拒绝(罕见 case:client 本地 clock 漂移) - -## 7. 同步流程(α-min) - -### 7.1 First-Sync(登录后首次) - -``` - GET /user-characters - ┌─────────────────────────────────┐ - │ │ - ▼ │ -client.cards (Map) ──┐ │ - ├── union ──▶│ -server.user_characters (列表) ───────────┘ by │ - clientId │ - ▼ │ - ┌───────────────────────────────────┘ - │ - ├─ local-only 卡(server 没有同 clientId)─▶ PUT 上传 - ├─ server-only 卡 (deletedAt IS NULL) ─▶ 加入 local Map - ├─ server-only 卡 (deletedAt IS NOT NULL) ─▶ 忽略(tombstone,不需要回放到 local) - └─ 同 clientId 两边都有 ─▶ 比较 max(local.updatedAt) vs max(server.updatedAt, server.deletedAt) - ├─ local 时间戳更新 ─▶ PUT 上传(server 接受 LWW,可能复活 tombstone) - ├─ server.updatedAt 更新 ─▶ 写入 local Map - └─ server.deletedAt 更新 ─▶ 从 local Map 删除 -``` - -### 7.2 切账号(logout → login 不同账号) - -logout 触发时: -1. 检查 `pendingOps` 非空 OR 本地有未同步过的卡(无 server 记录的 clientId) -2. 非空 → 弹 modal:「未同步的 N 张卡 — [归当前账号 (上传后再 logout)] [丢弃] [取消]」 -3. 用户选「归当前账号」→ 等 sync queue flush 完成 → logout -4. 用户选「丢弃」→ wipe local `cards` Map + `pendingOps` → logout -5. login 后走 First-Sync - -### 7.3 后续 Reconcile(登录态正常运行) - -- `cards` Map watchDebounced(2s) 触发 → diff 出变更 → enqueue → flush -- `activeCardId` 变更 → debounce 1s → PUT `/user-characters/active` -- 失败 → 指数 backoff (1s → 2s → 4s ... cap 30s) → 网络恢复后 flush -- 离线 → ops 留在 `pendingOps`(已持久化),上线后 flush -- 启动 / window focus → GET `/user-characters` + `/user-characters/active` 拉一次(被动 pull) - -## 8. Client 改造 - -### 8.1 `useAiriCardStore` 内部改造(调用面不变) - -新增 `packages/stage-shared/src/sync/airi-cards-sync-engine.ts`(放 `stage-shared` 而非 `stage-ui`,理由:未来 settings sync 复用同一 engine — per AGENTS.md「shared logic in packages/」): - -- `enqueueUpsert(clientId)` / `enqueueDelete(clientId)`:立即把 op 推进 `pendingOps`,**不**触发网络请求 -- `flush()`:把 `pendingOps` 批量 PUT/DELETE 到 server(debounced 2s) -- `firstSync(authedUserId)`:登录后调用一次,按 §7.1 算法 -- `pullFromServer()`:focus / 启动调用,GET `/user-characters` + `/user-characters/active` - -`useAiriCardStore` 内部: -- `addCard` / `updateCard` / `removeCard` 写完 `cards` Map 后**立即** `enqueueUpsert/enqueueDelete`(同步、无延迟) -- `flush()` 由 watchDebounced(2s) 在 `cards` 或 `pendingOps` 任一变化时触发 -- 新增 internal `_hydrateFromServer(serverCards)` 用于 first-sync -- `pendingOps` 持久化到独立 localStorage key(`airi-cards-pending-ops`),App 启动时恢复,登录后第一件事是 flush - -### 8.2 废 `stores/characters.ts` 孤岛 - -删除(不留 deprecation 包装层): -- `packages/stage-ui/src/stores/characters.ts` -- `packages/stage-ui/src/services/characters.ts` -- `packages/stage-ui/src/models/characters.ts` -- `packages/stage-ui/src/types/character.ts`(除非 server route 仍引用,需先确认) -- `apps/stage-web/src/pages/settings/characters/`(整个目录) -- `apps/stage-web/src/pages/settings/characters/components/` - -stage-web 的 `/settings/characters` 路由改重定向到 `/settings/airi-card`(已经是 stage-tamagotchi 在用的统一页)。 - -### 8.3 `stores/character/index.ts` facade 不动 - -Phase 2 处理。本 Phase 不动 [v2/index.vue](../../../packages/stage-pages/src/pages/v2/index.vue) 和 devtools/context-flow 的调用方。 - -## 9. 错误处理 - -| 场景 | 处理 | -|------|------| -| 网络错误 | sync 进 retry 队列;UI 在 settings/airi-card 顶部小 banner 显示「云同步暂时挂了,本地仍可编辑」 | -| 401 认证失效 | 清空 sync 队列;触发 logout flow(不弹切账号 modal — 因为不是用户主动 logout) | -| 400 schema 校验失败 | server 返回 valibot issues;client 把这张卡 mark `syncStatus=error`,pendingOps 中移除(避免无限重试),devtools 暴露原始 error | -| 5xx | retry queue + 指数 backoff | -| 启动时 server 不可达 | 进 `offline` 状态,本地照常使用,恢复后 first-sync | - -## 10. Migration - -- 旧用户升级版本:`useAiriCardStore` 加载现有 `localStorage['airi-cards']` Map(不变) -- 用户登录 → first-sync 把整个 Map 上传 -- 不需要写一次性 migration script -- 现有 `localStorage` key 保留:`airi-cards`, `airi-card-active-id` - -## 11. 测试策略 - -### Unit (Vitest) - -- `airi-cards-sync-engine.spec.ts` - - first-sync union 算法(all-local-only / all-server-only / mixed / 同 clientId LWW) - - tombstone 抑制复活:local 修改 < server.deletedAt → server wins - - enqueue/flush 队列幂等 - - `pendingOps` 持久化 + 启动恢复 - - 切账号 modal 三个分支(归当前 / 丢弃 / 取消) - -- `routes/user-characters/route.test.ts` - - CRUD(PUT idempotent by clientId) - - ownership 隔离(用户 A 看不到用户 B 的卡) - - LWW: 旧 updatedAt 的 PUT 不覆盖 server - - soft delete 行为 - -### Integration - -- `useAiriCardStore` first-sync 端到端(mock fetch + memdb) -- 401 触发的 silent logout flow - -### Verification(端到端用户路径) - -落到 `docs/ai/context/verifications/character-cards-cloud-sync-phase-1.md`。每条用户路径一个文件。Phase 1 必须通过的: - -| # | 用户路径 | 验证命令/操作 | 预期 | -|---|---------|--------------|------| -| V1 | 未登录用户继续创建/编辑卡 | 启动 stage-tamagotchi 不登录 → 创建卡 ARIA → 重启 | ARIA 仍在 | -| V2 | 首次登录上传本地卡 | V1 之后登录账号 X → web 端登录账号 X | web 端看到 ARIA | -| V3 | 多设备增量同步 | 桌面编辑 ARIA personality → 等 watchDebounced(2s) flush → web 端切回 tab 触发 focus pull | web 端看到更新 | -| V4 | 多设备删除同步 | 桌面 delete ARIA → 等 flush → web 端切回 tab 触发 focus pull | ARIA 不见 | -| V5 | 切账号确认 modal | 已登录 X 创建未同步本地卡 B → logout | 弹 modal「B 归 X / 丢弃 / 取消」 | -| V6 | activeCard 多端切换 | 桌面切到 ARIA → web 端 reload | web 端 active 是 ARIA | -| V7 | 离线编辑 + 上线 flush | 离线创建/编辑卡 → 上线 5s | 云端可见 | -| V8 | 孤岛页清理 | 升级前在 stage-web/settings/characters 用过该页 | 升级后路由 redirect 到 /settings/airi-card,孤岛页不存在 | - -## 12. Open Questions(写 plan 时再钉) - -- **Q1**:现有 `stores/characters.ts` 孤岛页用户已创建的关系化数据(`character` + `character_i18n` + `character_capabilities`)怎么处理? - - 选项 A:写 transform script `relational → AiriCard CCv3` 一次性 migrate 进 `user_characters` - - 选项 B:冷处理 + 在迁移说明里告知「此页面已停用,原数据请重新创建」 - - 倾向 B(孤岛页用户极少,transform 边界 case 多易出 bug)。需用户确认。 - -- **Q2**:`activeClientId` 为什么单独一张表而不是给 `user_characters` 加 `isActive` 字段? - - 单独表:每用户至多一行,PK = ownerId,更新简单;不需要清旧 active - - 加字段:要保证「至多一行 isActive=true」需要 partial unique index + 切换时事务 - - 倾向单独表。需用户确认。 - -- **Q3**:(已在 §8.1 决定 sync engine 放 `packages/stage-shared/src/sync/`,理由:未来 settings sync 复用 + AGENTS.md 「shared logic in packages/」) - ---- - -> **Next**:approve 后调用 `superpowers:writing-plans` skill 生成实现计划。 -> Phase 2 / 3 / settings-sync 不在本 spec 范围。 diff --git a/docs/ai/context/verification-automation.md b/docs/ai/context/verification-automation.md deleted file mode 100644 index b60dc8f98..000000000 --- a/docs/ai/context/verification-automation.md +++ /dev/null @@ -1,220 +0,0 @@ -# Verification 自动化方案 - -设计稿,未实施。落到这里是为了让 verification 流程从「人工跑命令贴输出」走向「机器跑断言贴 evidence」,同时保留 AGENTS.md 里 Iron Law 的语义。 - -## TL;DR - -1. **原因**:现有 5 份 verification 文档结构清晰,但执行步骤需要人工跑命令、人工贴输出、人工记录「最后验证」日期。一旦超过 30 天,AGENTS.md 规定默认 unverified,没有机制能识别这种过期。 -2. **猜想**:verification 文档继续作为 single source of truth,每份文档关联一份可执行 artifact,artifact 跑通就是 evidence,跑通时间就是「最后验证」。 -3. **决策**:分三层实施,集成测试覆盖 in-repo 可重现路径,live verifier 覆盖只能在已部署环境验证的路径,CI 守护过期时间。 - -## 背景 - -`apps/server/docs/ai-context/verifications/` 下 5 份文档,结构基本统一: - -- `场景 / 用户路径`:写明用户敲 X,预期得到 Y -- `命令 / 步骤`:手工敲的 curl、SQL、UI 操作 -- `预期 / 实际输出`:贴 response body、log 节选、screenshot 路径 -- `Evidence`:commit SHA、行号引用、测试文件路径 -- `Status` 与 `最后验证`:人工维护 - -其中 3 份文档(`flux-unbilled-exploit-fix`、`flux-unbilled-reconciliation`、`admin-flux-grants`)引用了已落库的 vitest 单测,剩下 2 份(`email-auth`、`account-deletion`)以手工 curl + 真实 Resend / 真实数据库为主。 - -## 拆解现状 - -把 5 份文档里的步骤按「证据来源」拆开,能看到三类: - -1. **纯代码路径**,例如 partial-debit 的数值逻辑、ledger 行写入。这类已经被 vitest 单测覆盖,证据来源是 `expect()` 断言。 -2. **跨外部边界的用户路径**,例如「N 个并发 LLM completion 触发 pre-flight 拒绝 + ledger 写入 + metric 上报」。这类需要 pg、redis、Hono app、Prometheus `/metrics` 端点同时在场,目前没有自动化覆盖。 -3. **依赖部署环境的路径**,例如 Resend 真实投递、Stripe webhook 回调、Grafana panel 斜率、Better Auth 跨域 OIDC handoff。这类无论在 PR CI 还是本地都无法完整跑通,必须在 staging 或 prod 上验证。 - -第 1 类已经自动化,第 2、3 类是空缺。 - -## 提出猜想 - -verification 文档的「用户路径」描述天然适合作为测试用例标题。如果给每份文档加一份配套 artifact,artifact 类型按上面三类分发: - -- 纯代码路径,归到 `*.test.ts`,已经这样做 -- 跨边界的用户路径,归到 `*.integration.test.ts`,testcontainers 起依赖 -- 依赖部署环境的路径,归到 `*.verifier.ts`,针对 staging URL 跑,post-deploy 触发 - -每份文档头部加一段 frontmatter,机器读取后能回答三个问题: - -1. 这份文档对应的 feature 是什么 -2. 自动化 artifact 在哪里 -3. 上次自动化跑通是什么时候 - -## 分节解答 - -### 一、frontmatter schema - -```yaml ---- -feature: flux-unbilled-exploit-fix -owner: rbxin2003@gmail.com -automated_by: - - kind: unit - path: apps/server/src/services/billing/tests/billing-service.test.ts - cases: - - 'rejects pre-flight when balance is below FLUX_PER_REQUEST' - - 'non-streaming completion drains partial balance and logs charged' - - kind: integration - path: apps/server/tests/verifications/flux-unbilled.integration.test.ts - - kind: live - path: apps/server/tests/verifications/flux-unbilled.verifier.ts - schedule: post-deploy -last_verified: - unit: 2026-05-15 - integration: 2026-05-15 - live: 2026-05-14 -expires_after_days: 30 ---- -``` - -字段语义钉死: - -- `feature`:文档 slug,与文件名同名 -- `automated_by[].kind`:`unit` / `integration` / `live`,三选一 -- `automated_by[].path`:可执行文件路径,CI 跑通后能写回 `last_verified` -- `last_verified.`:YYYY-MM-DD,由 CI 自动写回,人不手动改 -- `expires_after_days`:默认 30,与 AGENTS.md 一致 - -### 二、集成测试 harness - -放在每个 app 下的 `tests/verifications/` 目录,例如 `apps/server/tests/verifications/`。harness 提供: - -1. testcontainers 起 Postgres 16 + Redis 7,注入与 `.env.example` 同 schema 的环境变量 -2. `createApp()` 直接 mount,不走真实端口,调用 `app.request(...)` -3. 三种断言入口: - - HTTP 响应,按现有 `app.test.ts` 范式 - - DB 状态,通过 drizzle 查 `flux_transaction` / `user_flux` - - Metric 状态,scrape `/metrics` 文本,匹配 `airi_billing_flux_unbilled_total{...} ` - -最小测试骨架: - -```ts -describe('verification: flux-unbilled-exploit-fix', () => { - let ctx: VerificationContext - - beforeAll(async () => { - ctx = await startVerificationContext() - }) - - afterAll(async () => { - await ctx.stop() - }) - - it('concurrent partial-balance requests yield one partial debit and N-1 pre-flight 402', async () => { - await ctx.seedUser({ id: 'u1', balance: 5 }) - await ctx.setConfig({ FLUX_PER_REQUEST: 100 }) - - const responses = await Promise.all( - Array.from({ length: 5 }, () => ctx.app.request('/api/v1/openai/...')), - ) - - expect(responses.filter(r => r.status === 402)).toHaveLength(5) - const ledger = await ctx.db.query.fluxTransaction.findMany({ where: { userId: 'u1' } }) - expect(ledger).toHaveLength(0) - - const metrics = await ctx.scrapeMetrics() - expect(metrics).toMatchMetric('airi_billing_flux_unbilled_total', { - labels: { reason: 'partial_debit_drained' }, - delta: 0, - }) - }) -}) -``` - -`MatchMetric` 与 `scrapeMetrics` 这两个 helper 放在 `packages/server-runtime` 或 `apps/server/src/testing/`,由集成测试和 live verifier 共用。 - -### 三、live verifier - -针对 staging / prod。形态选 vitest 也可以,选独立 CLI 也可以,差别在「是否需要被 CI 用 `--include` pattern 隔离」。建议直接沿用 vitest,给文件后缀 `.verifier.ts`,配 `vitest.config.ts` 的 `include` / `exclude` 把它们与 unit / integration 隔离。 - -live verifier 的断言对象不再是「mount 的 Hono app」,是「真实 URL」: - -```ts -describe('live verifier: flux-unbilled-exploit-fix', () => { - it('panel-43 slope is below alert threshold over the last 5 minutes', async () => { - const slope = await prometheusQuery( - 'increase(airi_billing_flux_unbilled_total[5m])', - { url: process.env.PROM_URL! }, - ) - expect(slope).toBeLessThan(0.5) - }) -}) -``` - -需要凭据的项目(Prometheus、Resend、Stripe)通过 env 注入,与 `secrets-management` 规则一致,不写进文件。 - -### 四、CI 编排 - -三条 GitHub Actions workflow: - -1. **`verification-unit.yml`**:PR 触发,跑全部 `*.test.ts`。现状已有,作为 baseline。 -2. **`verification-integration.yml`**:PR 触发,跑全部 `*.integration.test.ts`。预计单跑 60 至 180 秒(testcontainers 启动),用 matrix 拆分到多个 worker。仅在改动触及 `apps/server/**` 或 `packages/server-*/**` 时跑,其他改动 skip。 -3. **`verification-live.yml`**:post-deploy 触发(Railway deploy hook → GitHub repository_dispatch),针对 staging URL 跑全部 `*.verifier.ts`。跑通后自动 PR 一份更新 `last_verified.live` 的提交,或者直接 commit 回 main(按团队偏好选)。 - -第 2 类必要的 secret:testcontainers 自身不需要 secret,只需要 docker daemon,GitHub Actions runner 默认带。第 3 类需要 `PROM_URL`、`PROM_TOKEN`、`STRIPE_TEST_KEY`、`RESEND_API_KEY` 等,放到 GitHub Actions secrets。 - -### 五、过期守护 - -新增 `scripts/verification-doctor.ts`,在 `verification-unit.yml` 末尾跑: - -```ts -// 遍历所有 verification 文档 -// 读 frontmatter.last_verified -// 与 frontmatter.expires_after_days 比较 -// 超期 -> stderr 报告 + exit 1 -``` - -CI 失败时输出形如: - -``` -✗ flux-unbilled-reconciliation: last_verified.integration = 2025-12-01 (expired 165 days) -✗ email-auth: last_verified.live = (none) -``` - -主分支跑过期检查也跑,跑失败不阻塞 main,只发到 Slack / Lark 通知频道,避免老文档过期把全员卡住。 - -## 回指前文 - -回到 TL;DR 的三条决策: - -1. 「集成测试覆盖 in-repo 可重现路径」对应第二节,testcontainers + drizzle + metric scrape 是这一层的最小工具集。 -2. 「live verifier 覆盖只能在已部署环境验证的路径」对应第三节,针对真实 URL 跑 Prometheus query、Stripe test mode、Resend dashboard API。 -3. 「CI 守护过期时间」对应第五节,frontmatter 的 `last_verified` 由 CI 写回,doctor 脚本扫超期。 - -三层加起来,verification 文档从「人工 claim」变成「机器 claim + 人工 narrative」。 - -## 影响面 - -| 维度 | 影响 | -|---|---| -| 单测时间 | 不变 | -| PR CI 时间 | 新增 60 至 180 秒(取决于 testcontainers 并发 + matrix 拆分) | -| 本地开发 | 默认 `pnpm exec vitest run` 不跑 integration,要显式跑 `pnpm verify:integration` | -| docker 依赖 | 本地跑 integration 需要 docker daemon,已有 `docker-compose.otel.yml` 范式 | -| Secret 管理 | live verifier 需要 4 至 6 个 staging secret,放 GitHub Actions secrets | -| 文档维护 | verification 文档新增 frontmatter,原有 markdown 正文不变 | -| AGENTS.md | 加一段「如何写 verification artifact」,引用本文 | - -## 可观测性 / eval - -实施后用三个指标判断方案有效: - -1. **集成测试覆盖率**:5 份文档里有几份对应有 `*.integration.test.ts`,目标 100% -2. **live verifier 触发频率**:post-deploy 一次必跑,跑失败的次数与生产 incident 的相关性 -3. **doctor 报告超期数**:每周扫一次,超期数应当趋近 0 - -第 3 个指标如果长期不为 0,说明 verification 流程仍需要人工介入太多,要回头看 frontmatter 设计是否合适。 - -## 收束 - -这份方案保留 verification 文档的人工 narrative(root cause、why、tradeoff),把可执行部分挪到代码,把过期检测交给 CI。实施分三步: - -1. 先做 frontmatter schema 与 doctor 脚本,零代码改动,立即能识别已有 5 份文档的过期状态。 -2. 再做 `flux-unbilled-exploit-fix` 的集成测试样板,跑通一个 case 形成模板。 -3. 最后逐份补齐 integration 与 live verifier。 - -如果某一份文档(例如 `email-auth`)的 live 验证依赖 Resend 真实投递,确认收件状态需要轮询 Resend `/emails` API,这部分实现成本较高,可以推到第三步的尾巴上单独立项。 diff --git a/docs/brainstorms/2026-05-30-voice-pack-requirements.md b/docs/brainstorms/2026-05-30-voice-pack-requirements.md deleted file mode 100644 index 6b81ae170..000000000 --- a/docs/brainstorms/2026-05-30-voice-pack-requirements.md +++ /dev/null @@ -1,113 +0,0 @@ ---- -date: 2026-05-30 -topic: voice-pack ---- - -# Voice Pack 音色系统需求 - -## Summary - -两件事一起做,号池负载均衡优先: - -1. **TTS 号池负载均衡(最高优先)。** 一个上游 `app_id` 只有 10 并发,买并发贵,所以一个账号开 10 个 app(10 个 app_id)= 100 并发。需要一个容量感知的号池:实时追踪每个 app_id 的在途请求数,把流量路由到还有并发余量的号,并对池子整体水位做监控。 -2. **Voice Pack 音色系统。** 引入服务端 `voice_packs` 表,把 `provider + model + voice + 参数覆盖` 收敛成用户只选一个「声线」。绑定到角色卡时把**解析后的值快照冻结**进卡,之后改表永不影响已绑定的卡。一个 Voice Pack pin 一个 tts model id,该 model 的 upstreams/keys 就是上面那个号池。 - -## Problem Frame - -**号池并发约束。** 上游 TTS 服务按 `app_id` 限制并发(典型 10),扩并发额度很贵。绕开的办法是同一账号注册多个 app 拿到多个 `app_id` 凑并发。但当前服务端 `routeTts` 的 `createKeyRotator`(`apps/server/src/services/.../router.ts:429`)是**盲轮转**:不追踪每个号的在途请求数,会把某个号打爆到并发上限、别的号还闲着;跨 upstream 更是固定顺序、不分摊。结果是 100 并发的理论容量用不满,还会因为单号超限触发 429。 - -**音色被动漂移。** 当前音色是全局 UI 状态:`active-provider` + `active-model` + `voice` 三个独立 localStorage key(`packages/stage-ui/src/stores/modules/speech.ts:32-35`),不绑定角色卡、不是快照。voice catalog 是 per-model 的,上游 model 下线、默认音色被改、目录调整时,用户选好的音色会悄悄变成另一个甚至失效。`DEFAULT_TTS_VOICES`(commit `95915923e`)已把 per-model 默认音色配置化、并要求 caller 必须显式传 voice,但「绑定后永不变」这层语义还不存在。 - -**用户被迫理解 provider 拓扑。** 选音色要先懂 Microsoft / 阿里云 等各自的 model 和 voice id 格式,对用户是无关负担。 - -## Key Decisions - -- **号池负载均衡排在最前,且与 Voice Pack 解耦。** 它是 TTS 基建,惠及所有 TTS 合成,不依赖 voice_packs。Voice Pack 只是 pin 一个 tts model id,那个 model 的 upstreams/keys 即号池。一个计划覆盖两块,unit 顺序把号池 LB 放最前。 -- **容量感知而非盲轮转。** 调度按每个 app_id 的在途并发余量挑号,不是 round-robin 盲转。并发计数若服务端多副本则必须放共享存储(Redis,与现有 flux meter 同源),否则各副本各算、号池超卖。(部署拓扑规划时实测确认。) -- **Voice Pack library 是服务端 `voice_packs` 表(复数表名),管理员策展。** 不是前端 localStorage。本轮只装「云提供商音色」= `provider + model + voice + 参数覆盖`,同时覆盖标准 voice 与阿里云克隆 model id 两类(结构相同)。软禁用用 `enabled` 列,不删行。 -- **参数覆盖是 pack 身份的一部分。** 同一 `provider+model+voice` 配不同 pitch / 响度 = 不同 Voice Pack,用户分别可选(Neuro-sama 那个 Pitch +20%、响度 +5% 的例子)。 -- **绑定冻结的是解析后的值,不是表外键。** 角色卡冻结 `provider/model/voice/params/tier + pin 的 tts model id` 进 `extensions.airi.modules.speech.voicePack`(扩 `airi-card.ts:173-176` 现有 speech 快照点)。存外键会导致改表连带改卡,回到漂移。`resolveAiriExtension`(`airi-card.ts:161`)处理字段缺失,不加 backward-compat guard。 -- **failover 复用现有 routeTts。** 等价后端容灾(耗尽 fail-fast、带上下文、绝不静默换音色)复用 `routeTts` 现有跨 upstream/key 重试。R7「等价判定」(同音色、可复现参数)是服务端不校验的新语义,只能在 `voice_packs` 定义层把关。 -- **tier 复用 `tts-billing-tiers.md` 的 lite/standard/pro/premium 命名。** `voice_packs` 一列,冻进快照。本轮只有一个 meter(`FLUX_PER_1K_CHARS_TTS` 单值),四档 meter 拆分属 billing 独立线,所以本轮 tier 是**展示 + 数据**,「按最高档取价」暂无真实差价效果。 - -## Key Flows - -- F1. **号池容量感知路由** - - **Trigger:** 一次 TTS 合成请求进入服务端路由。 - - **Steps:** 解析目标 tts model 的号池(upstreams/keys,每个 key 对应一个 app_id)→ 读各 app_id 当前在途并发数 → 挑还有并发余量的号 → 占用一个并发槽 → 发起合成 → 完成/失败释放槽。全部号满 → 排队或返回容量错误(不静默吞)。 - - **Covered by:** R1, R2, R3 -- F2. **绑定流程** - - **Trigger:** 用户选定一个 Voice Pack 绑定到某角色卡。 - - **Steps:** 从 `voice_packs` 读 enabled 的 pack → 把解析后的值快照冻结写入角色卡 extensions → 角色卡此后只读自己的冻结快照。 - - **Covered by:** R8, R9 -- F3. **合成读快照 + 容灾** - - **Trigger:** 角色卡触发 TTS 合成。 - - **Steps:** 读角色卡冻结快照 → 映射 tts model id → 参数走 SSML prosody / adapter options → 经号池 LB(F1)挑号合成 → 后端不可用在等价后端间 failover,耗尽 fail-fast。 - - **Covered by:** R10, R11 - -## Requirements - -**TTS 号池负载均衡(最高优先)** - -- R1. 服务端追踪号池内每个 `app_id`(key)的实时在途请求数,路由时挑还有并发余量的号,不用盲轮转。 -- R2. 并发计数在服务端多副本部署下跨副本共享一致(避免超卖);单副本则进程内即可。最终方案以实测部署拓扑为准。 -- R3. 号池全满时不静默降级:要么排队等空位,要么返回带上下文的容量错误(可 grep),让调用方看见。 -- R4. 跨 upstream 的多个号都参与负载均衡,不是固定优先第一个 upstream。 -- R5. 监控号池水位:每个 app_id 的并发利用率、饱和、429、池子整体使用率,出到现有可观测栈(指标走 Prometheus/OTel metrics,trace 已有 Langfuse)。 -- R6. 容量感知调度跳过最近失败/限流的号一段时间(轻量 reactive 健康判定),避免反复打到坏号。 -- R7. 一个号(app_id)打满或失败时,failover 到池内其它号;全池耗尽 fail-fast,带 `triedKeys/triedUpstreams` 类上下文,复用现有 `mapUpstreamError` 模式。 - -**Voice Pack 表与管理** - -- R8. 服务端 `voice_packs` 表存「云提供商音色」:`provider + model + voice_id + 参数覆盖(pitch/rate/volume 等)+ tier + enabled`。同时覆盖标准 voice 与云端克隆 model id 两类。 -- R9. 参数覆盖是 pack 身份的一部分:同 `provider+model+voice` 不同参数 = 不同 pack。 -- R10. admin CRUD HTTP API:新增 / 编辑 / 禁用(软禁用)/ 列出 pack,复用现有 admin + injeca 机制。本轮不做管理 UI。 -- R11. 市场侧只列 `enabled` 的 pack。 - -**角色卡绑定与合成** - -- R12. 角色卡绑定 Voice Pack 时,冻结**解析后的值**(provider/model/voice/params/tier + pin 的 tts model id)进 `extensions.airi.modules.speech.voicePack`;改表不影响已绑定卡。 -- R13. 合成读冻结快照,参数走 SSML prosody(SSML-capable provider)或 adapter speed/extraOptions;某参数无法在目标后端应用时 fail-fast 报错,不静默丢弃。 -- R14. 提供最小绑定入口(复用现有 speech 设置页选 pack → 触发冻结),保证端到端可绑可合成可验证。 -- R15. tier:`voice_packs` 一列,复用 lite/standard/pro/premium,冻进快照(本轮展示 + 数据,不碰实际扣费)。 - -## Acceptance Examples - -- AE1. **号池容量感知(覆盖 R1、R4)。** 池内 10 个 app_id 各上限 10 并发。并发打到 50 路时,请求被摊到多个号(如每号约 5 路),不是把前几个号打满到 10 再溢出。 -- AE2. **不超卖 + 不静默(覆盖 R2、R3)。** 多副本下并发计数共享:100 路全满时第 101 路排队或收到容量错误,不会因为副本各算各的把某号打到 11 并发。 -- AE3. **坏号退避(覆盖 R6、R7)。** 某 app_id 连续 429/失败 → 一段时间内不再被选中,流量转到健康号;全池耗尽才 fail-fast 带上下文。 -- AE4. **绑定后不漂移(覆盖 R12)。** 绑定 Voice Pack A 到角色卡 → 之后在 `voice_packs` 编辑 A(换 voice、改参数)或禁用 A → 角色卡音色不变,仍用绑定时快照。 -- AE5. **参数不可应用 fail-fast(覆盖 R13)。** 冻结快照带某 provider 不支持的参数 → 合成报错指出该参数无法应用,而非静默出声丢参数。 - -## Scope Boundaries - -**Deferred for later(第二轮或独立线)** - -- 参考音频整块(含 materialize 字节存储、随机 roll、情绪标签)。未来落 `voice_pack_reference` 子表(FK → `voice_packs`,一个 pack 多条参考音频);`voice_packs` 永远是唯一身份/市场/计费实体,市场/绑定/合成只读它、不做多态双表读。本轮只把这个形状记进文档,不建表。 -- emotion embedding 内容类型(百分比向量)。 -- 声音克隆 `upload → 调云端 clone API → 轮询 model id` 流程;本轮只消费已克隆好的 model id。 -- 四档计量器拆分(lite/standard/pro/premium 各一个 `ttsMeter`),属 `tts-billing-tiers.md` 线。 -- 用户侧精选市场浏览页(声线卡片列表 + tier badge filter)。本轮只做最小绑定入口。 -- Voice Pack 管理 UI 页面(本轮 admin 只出 HTTP API)。 -- 可分发市场(发布、下载、分享他人的 Voice Pack)。 - -## Dependencies / Assumptions - -- 号池并发计数的存储方案依赖 server 部署拓扑(多副本 → Redis 共享,复用 flux meter 的 Redis pattern;单副本 → 进程内)。规划/实现时实测确认。 -- 现有 `routeTts` 跨 upstream/key 重试、`mapUpstreamError`、`fallbackHttpCodes`(含 429)是号池 failover 的复用基础。 -- `app_id` / access token 在 `ttsUpstreamSchema`(`config-kv.ts:57-61`)的落位(keys[] 还是 adapterParams)需按 Volcengine adapter 实测确认,决定「一个号」对应 schema 哪个粒度。 -- 监控指标出口:trace 已接 Langfuse(OTel SpanProcessor),并发 gauge/counter 类指标需确认现有 Prometheus/OTel metrics 注册点。 -- `packages/ccc` 的 `Extensions` 开放可扩展(`extensions.ts:1`),冻结快照扩 `extensions.airi.modules.speech`。角色卡正在上整卡 LWW 云同步(`docs/ai/context/plans/2026-05-09-character-cards-cloud-sync-design.md`),快照 schema 改动会被同步带走,需对齐。 -- `tts-billing-tiers.md` 的四档命名是 tier 取值来源;该文档当前在 main worktree 未提交,本分支引用时注意同步。 - -## Sources / Research - -- `apps/server/src/services/.../router.ts:413-617` — `routeTts` 主循环、`dispatchOneTtsUpstream`、`createKeyRotator`(盲轮转,号池 LB 的改造点)。 -- `apps/server/src/app.ts:616-632` — `ttsMeter` = `createFluxMeter`(Redis 用法,号池并发计数可复用的 Redis pattern)。 -- `apps/server/src/services/adapters/config-kv.ts:57-61, 83-87` — `ttsUpstreamSchema` / `ttsModelSchema`(多 upstreams/keys 结构,号池建模点)。 -- `apps/server/src/routes/openai/v1/index.ts:489-642, 738` — `handleTTS`、`/audio/voices` catalog、`ttsGuard`。 -- `packages/stage-ui/src/stores/modules/airi-card.ts:161-215` — 角色卡 speech 快照写入/读取(冻结快照落点)。 -- `packages/stage-ui/src/stores/modules/speech.ts:32-35, 298-338` — 当前全局 voice 状态、`generateSSML`(pitch/rate/volume)。 -- `packages/ccc/src/export/types/extensions.ts:1` — 开放 extensions。 -- `docs/ai/context/tts-billing-tiers.md` — 四档 tier 命名来源。 -- `docs/ai/context/plans/2026-05-09-character-cards-cloud-sync-design.md` — 整卡 LWW 云同步,快照 schema 需对齐。 diff --git a/docs/plans/2026-05-30-001-feat-voice-pack-tts-pool-lb-plan.md b/docs/plans/2026-05-30-001-feat-voice-pack-tts-pool-lb-plan.md deleted file mode 100644 index 08bc1aeba..000000000 --- a/docs/plans/2026-05-30-001-feat-voice-pack-tts-pool-lb-plan.md +++ /dev/null @@ -1,308 +0,0 @@ ---- -title: "feat: TTS 号池负载均衡 + Voice Pack 音色系统" -status: active -date: 2026-05-30 -type: feat -origin: docs/brainstorms/2026-05-30-voice-pack-requirements.md ---- - -# feat: TTS 号池负载均衡 + Voice Pack 音色系统 - -## Summary - -一个计划两块,号池负载均衡排最前。先做 TTS 号池容量感知负载均衡(Redis 在途计数 + 容量感知路由 + 429 反哺 + 监控),让「一账号 10 app × 10 并发 = 100 并发」的理论容量真正用满;再做 Voice Pack 音色系统(服务端 `voice_packs` 表 + admin CRUD API + 角色卡冻结快照 + 合成读快照)。两块解耦:Voice Pack 只 pin 一个 tts model id,那个 model 的 upstreams 即号池。 - -## Problem Frame - -号池侧:上游按 `app_id` 限并发(典型 10),扩额贵,绕法是一账号开多 app 凑并发。但现有 `routeTts` 的 `createKeyRotator`(`apps/server/src/services/.../router.ts:429`)是无状态盲轮转,每请求新建、纯按 config 顺序遍历;upstream 主循环(`router.ts:559`)也是固定顺序,永远先打满第一个再降级。结果是 100 并发理论容量用不满,且单号越界触发 429。这层不是负载均衡,是 failover 顺序表。 - -音色侧:当前音色是全局 localStorage 状态(`packages/stage-ui/src/stores/modules/speech.ts:32-35`),不绑角色卡、不是快照。voice catalog per-model,上游变化时已选音色会被动漂移。用户还被迫理解 provider 拓扑。 - -详见 origin 需求文档(`docs/brainstorms/2026-05-30-voice-pack-requirements.md`)的 Problem Frame 与 Key Decisions。 - -## Key Technical Decisions - -- **并发计数放 Redis,按多副本设计。** 代码已有多副本假设(`apps/server/src/.../gauges/active-sessions.ts:23-26` 明确 cluster-wide gauge、必须 `avg()` 不 `sum()`;`otel/index.ts:76-90` 选 ObservableGauge 避免多副本重复计数)。进程内内存会让各副本各算、号池超卖。复用 flux meter 的 Lua 原子模式(`flux-meter.ts:23-31` INCRBY+EXPIRE+条件 DECRBY),TTL 兜底防崩溃副本永久占槽。(see origin: docs/brainstorms/2026-05-30-voice-pack-requirements.md R1/R2) - -- **建模:一个 app_id = 一个 upstream。** volcengine 的 `appid` 取自 `ctx.adapterParams.appid`(`adapters/tts/volcengine.ts:49-51`,upstream 级),token 走 `keys[].ciphertext`。要表达 10 个 app_id 就配 10 个 `upstreams[]`(每个一个 appid + 它的 token)。这样 appid 已在路由层可见(`upstream.adapterParams.appid`),并发计数 key 自然是 `pool:inflight:`,且直接落在 routeTts 已有的 upstream 遍历上。否决「appid 下沉到 key 级」方案:要改 adapter 取值位置且注入面更大。 - -- **容量感知做在 routeTts 路由层,不加 route gate。** 「哪个 app_id 还有余量」是路由决策不是请求准入。现有 `ttsMeter.assertCanAfford`(计费余额)和 `ttsGuard`(配置存在性,`config-guard.ts:12`)都不是并发 gate,不复用它们做并发。全池满 → 走现有 exhaustion fail-fast(`router.ts:591-616` `mapUpstreamError`),不静默降级。 - -- **429 是安全网不是主信号。** `fallbackHttpCodes` 默认含 429(`router.ts:549`),现状是上游回 429 才被动切号。主动层(Redis 计数)在派发前就跳过满号;429 仍作兜底,且收到 429 时把该 appid 标记短 TTL「已满」反哺主动层,避免继续往坏号派(对应 origin R6 坏号退避)。 - -- **监控复用现有 OTel metrics,零新基建。** counter 加进 `GatewayMetrics`(`otel/index.ts:202-244`,已有 fallbackCount/upstreamErrors/keyExhaustedCount,router 里已打点);池水位 gauge 仿 `gauges/active-sessions.ts:42-102` 把数据源从 Postgres 换 Redis。Langfuse 是 trace 不碰。 - -- **Voice Pack library 是服务端 `voice_packs` 表(复数),管理员策展。** 本轮只装云提供商音色 = `provider + model + voice + 参数覆盖`,覆盖标准 voice 与云端克隆 model id 两类。参数覆盖是 pack 身份的一部分(同 voice 不同参数 = 不同 pack)。未来参考音频落 `voice_pack_reference` 子表(FK → voice_packs),`voice_packs` 永远是唯一身份/市场/计费实体,本轮不建子表只记形状。 - -- **绑定冻结解析后的值,不存表外键。** 角色卡冻结 `provider/model/voice/params/tier + pin 的 tts model id` 进 `extensions.airi.modules.speech.voicePack`(扩 `airi-card.ts:173-215` 现有 speech 快照点)。存外键会让改表连带改卡,回到漂移。`resolveAiriExtension`(`airi-card.ts:161`)处理字段缺失,不加 backward-compat guard。 - -- **tier 本轮是展示 + 数据。** `voice_packs` 一列,复用 `tts-billing-tiers.md` 的 lite/standard/pro/premium 命名,冻进快照。本轮只有单 meter(`FLUX_PER_1K_CHARS_TTS`),四档拆分属 billing 独立线,「按最高档取价」暂无真实差价效果。 - -## High-Level Technical Design - -### 号池容量感知路由(Phase A 核心) - -```mermaid -flowchart TB - REQ[TTS 合成请求进入 routeTts] --> RESOLVE[解析目标 tts model 的 upstreams] - RESOLVE --> QUERY["读 Redis 各 upstream.adapterParams.appid 在途计数
pool:inflight:<appid>"] - QUERY --> RANK[按剩余并发余量排序 upstreams] - RANK --> PICK{有 upstream 还有余量?} - PICK -->|否| EXHAUST["fail-fast: mapUpstreamError
带 triedKeys/triedUpstreams 上下文"] - PICK -->|是| ACQUIRE["Lua 原子: 检查容量 + INCR 占槽"] - ACQUIRE --> SEND[dispatchOneTtsUpstream → adapter.send] - SEND --> RESULT{结果} - RESULT -->|成功| RELEASE_OK["finally: DECR 释放槽"] - RESULT -->|429/失败| MARK["标记该 appid 短 TTL 已满 (熔断)
+ DECR 释放槽"] - MARK --> PICK - RELEASE_OK --> DONE[返回音频] -``` - -### Voice Pack 身份 → 冻结快照 → 合成(source-of-truth) - -```mermaid -flowchart LR - TABLE["voice_packs 表 (货架)
provider/model/voice/params/tier/enabled"] -->|admin CRUD| TABLE - TABLE -->|"列 enabled"| MARKET[最小绑定入口] - MARKET -->|绑定: 冻结解析后的值| CARD["角色卡 extensions.airi.modules.speech.voicePack
(买走的快照, 不存表外键)"] - CARD -->|合成读快照| SYNTH[映射 tts model id] - SYNTH --> POOL[经号池 LB 挑号合成] - TABLE -.改表/禁用.-> TABLE - CARD -.不受改表影响.-> CARD -``` - -## Implementation Units - -### Phase A — TTS 号池负载均衡(最高优先) - -### U1. Redis 在途并发计数账本 - -- **Goal:** 提供「按 appid 原子占用/释放一个并发槽」的 Redis 账本,作为容量感知路由的底层。 -- **Requirements:** R1, R2, R3(origin) -- **Dependencies:** 无 -- **Files:** - - `apps/server/src/services/tts/concurrency-ledger.ts`(新建,命名待 review,避免 `manager`/`pool` 泛词;候选 `concurrency-ledger` / `inflight-slots`) - - `apps/server/src/services/tts/concurrency-ledger.test.ts`(新建) -- **Approach:** 仿 `flux-meter.ts:23-31` 的 Lua 原子脚本:`tryAcquire(appId, maxConcurrency)` = Lua 内 `GET pool:inflight:`,未超上限则 `INCR` + `EXPIRE`(短 TTL 防泄漏) 返回成功,超上限返回失败;`release(appId)` = `DECR`(下限 0)。另出 `markSaturated(appId, ttl)`(429 熔断用,set 一个 `pool:saturated:` 短 TTL flag)与 `currentInflight(appId)` 读数。复用现有 Redis client injeca(与 `createFluxMeter` 同源,`app.ts:616` 附近)。 -- **Patterns to follow:** `apps/server/src/services/.../flux-meter.ts:23-31`(Lua INCRBY+EXPIRE+条件 DECRBY)、:106(`redis.eval` 用法)、:18(TTL survives 注释思路)。 -- **Test scenarios:** - - tryAcquire 在未达上限时 INCR 并返回成功;达上限返回失败且不 INCR。 - - tryAcquire + release 配对后计数回到原值。 - - 并发 N 个 tryAcquire 对同一 appid(用 Lua 原子性):成功数不超过 maxConcurrency(check-then-incr 无竞态)。 - - release 在计数为 0 时不变成负数。 - - markSaturated 后 saturated flag 存在且在 TTL 后消失(用短 TTL + 等待或 fakeable clock;若不可控用最小真实 TTL 断言存在性)。 - - 槽未释放时 TTL 到期后计数自动清零(防崩溃副本占槽)。 -- **Verification:** 单测覆盖 acquire/release/saturate 的原子性与边界;Redis 用测试实例或 ioredis-mock 等价物(按 server 现有测试惯例)。 - -### U2. 容量感知 upstream 路由 - -- **Goal:** 把 `routeTts` 的固定顺序 upstream 遍历换成按 appid 剩余并发余量挑选,并在派发前后占/放槽。 -- **Requirements:** R1, R4, R7(origin) -- **Dependencies:** U1 -- **Files:** - - `apps/server/src/services/.../router.ts`(改 `routeTts` upstream 选择 :559、`dispatchOneTtsUpstream` 槽位获取/释放 :413-534) - - `apps/server/src/services/.../router.test.ts`(新增/扩展号池路由用例) -- **Approach:** 路由前查 U1 账本各 `upstream.adapterParams.appid` 的在途数与 saturated flag,过滤掉满号/熔断号,按剩余余量排序后遍历(替代 :559 的 `for i in 0..length` 固定顺序)。进入 `adapter.send`(:450)前 `tryAcquire`;失败(该号刚好满)则跳到下一个候选。槽释放放 `dispatchOneTtsUpstream` 已有的 finally(:527-529,与 `key.plaintext.fill(0)` 同块)。所有候选都满/耗尽 → 现有 exhaustion 路径 `mapUpstreamError`(:591-616)fail-fast。key 级 `createKeyRotator` 不动(appid 在 upstream 级)。 -- **Patterns to follow:** `router.ts:559`(upstream 遍历)、:527-529(finally 释放点)、:591-616(exhaustion fail-fast)、`mapUpstreamError`(`error-mapping.ts:62`)。 -- **Test scenarios:** - - Covers AE1. 池内多 upstream(多 appid)各上限 10,并发 50 路被摊到多个号(每号约均匀),不是打满前几个再溢出。 - - 单个 upstream(appid)满时路由跳过它选下一个有余量的。 - - 全池满时 fail-fast 抛带上下文错误(triedUpstreams 等),不静默挂起、不静默降级。 - - 成功路径在 finally 释放槽;异常路径也释放槽(不泄漏)。 - - 只有一个 upstream 且未满时行为与改造前一致(不回归)。 -- **Verification:** router 单测用 mock adapter + mock/test Redis 断言「派发分布跨 appid」「满号被跳过」「耗尽 fail-fast」「槽必释放」。 - -### U3. 429 反哺主动层(reactive 熔断) - -- **Goal:** 上游回 429(app_id 越界)时把该 appid 标记短 TTL「已满」,让主动路由一段时间内不再选它。 -- **Requirements:** R6(origin) -- **Dependencies:** U1, U2 -- **Files:** - - `apps/server/src/services/.../router.ts`(429 fallback 分支 :506-524 处调用 `markSaturated`) - - `apps/server/src/services/.../router.test.ts`(扩展) -- **Approach:** 现有 fallback 判断(:518 `fallbackHttpCodes.includes(rawStatus)`)命中 429 时,除继续切号外,调用 U1 的 `markSaturated(appid, shortTtl)`。U2 的候选过滤已读 saturated flag,自然跳过。区分 429(并发/限流,熔断该号)与其它 fallback 码(如 500/502,按现有逻辑切号但不必熔断),避免把临时网络错误误判成号满。 -- **Patterns to follow:** `router.ts:506-524`(fallback 分支与状态判断)、`fallbackHttpCodes`(:549)。 -- **Test scenarios:** - - Covers AE3. 某 appid 连续 429 → 被 markSaturated → 后续路由窗口期内不再选它,流量转健康号。 - - 熔断 TTL 过后该 appid 重新可被选中。 - - 非 429 的 fallback 码(如 502)触发切号但不 markSaturated。 - - 全池都被熔断时 fail-fast,不静默挂起。 -- **Verification:** 单测断言「429 后该号进入 saturated 窗口被跳过」「TTL 后恢复」「非 429 不熔断」。 - -### U4. 号池监控指标 - -- **Goal:** 暴露号池水位与饱和指标到现有 OTel metrics pipeline。 -- **Requirements:** R5(origin) -- **Dependencies:** U1 -- **Files:** - - `apps/server/src/.../otel/index.ts`(`GatewayMetrics` 接口 :202-244 加字段 + 实例化 :442-458) - - `apps/server/src/.../gauges/tts-pool.ts`(新建,仿 active-sessions gauge) - - `apps/server/src/app.ts`(注册 gauge,仿 :695-712 `registerActiveSessionsGauge`) - - `apps/server/src/.../gauges/tts-pool.test.ts`(新建) -- **Approach:** counter:在 `GatewayMetrics` 加 `poolSaturationCount`(429-as-full 次数)、`slotAcquireFailCount`(主动层判满拒派次数),打点位置复用 router 现有 counter 打点处(U2/U3 内)。gauge:池水位 ObservableGauge,回调读 Redis 各 `pool:inflight:`,仿 `gauges/active-sessions.ts:42-102`(10s 缓存 + in-flight 去重 + 失败不 observe 让 staleness 报警)。给 gauge 加 `app_id` label。 -- **Patterns to follow:** `otel/index.ts:202-244`(GatewayMetrics 定义与打点)、`gauges/active-sessions.ts:42-102`(cluster-wide ObservableGauge 模板)、`app.ts:695-712`(注册)。 -- **Test scenarios:** - - gauge 回调读 Redis 多个 appid 在途数并 observe 对应值 + 正确 label。 - - Redis 读失败时回调不 observe(让 Prometheus staleness 生效),不抛崩回调。 - - 10s 缓存命中时不重复打 Redis;in-flight 去重不并发重复读。 - - counter 在 markSaturated / 判满拒派时各 +1。 -- **Verification:** 单测覆盖 gauge 回调读数/失败/缓存与 counter 自增;多副本语义在注释与 dashboard 说明(avg 不 sum)。 - -### Phase B — Voice Pack 表与管理 - -### U5. `voice_packs` 表 + 迁移 - -- **Goal:** 建 `voice_packs` 表存云提供商音色定义。 -- **Requirements:** R8, R9, R15(origin) -- **Dependencies:** 无(可与 Phase A 并行) -- **Files:** - - server 端 DB schema / migration(路径按现有迁移工具,见 Approach 待确认项) - - 对应 schema/migration 测试或快照 -- **Approach:** 表列:`id`、`name`、`provider`、`model`、`voice_id`、`params`(jsonb:pitch/rate/volume 等)、`tier`(picklist lite/standard/pro/premium)、`enabled`(bool 软禁用)、`created_at`/`updated_at`。参数覆盖入 jsonb(同 voice 不同参数 = 不同行 = 不同 pack)。**待确认(实现期):** apps/server 的迁移工具与既有表定义位置(cloud-sync 设计提到 `characters` 表,沿用同一 ORM/迁移机制);确认后按现有 migration 约定落表。 -- **Patterns to follow:** 现有 server 表/迁移定义(与 `characters` 表同机制);列命名贴近域、复数表名(`voice_packs`)。 -- **Test scenarios:** - - 迁移可正向应用建表,列与约束符合预期(enabled 默认值、tier 枚举约束、jsonb 默认)。 - - Test expectation: 以迁移/schema 校验为主;无业务逻辑分支。 -- **Verification:** 迁移在测试库正向应用成功,schema 与计划列一致。 - -### U6. voice_packs valibot schema + 域服务 - -- **Goal:** 提供 voice_packs 的校验 schema 与 CRUD 域服务(含 list-enabled)。 -- **Requirements:** R8, R9, R10, R11(origin) -- **Dependencies:** U5 -- **Files:** - - `apps/server/src/services/domain/voice-packs/`(新建域服务 + valibot schema) - - 对应 `*.test.ts` -- **Approach:** valibot schema 定义 pack 形状(provider/model/voice/params/tier/enabled),在外部边界(API 入参、DB 行)各做一次校验,内部不重复防御。域服务出 `create/update/disable/list/listEnabled`,injeca 注入 DB(仅 DB 边界用 DI,不建 pass-through service)。复用现有 admin router-config 服务的组织方式(`services/domain/admin/router-config`)。 -- **Patterns to follow:** `apps/server/src/services/adapters/config-kv.ts:25-153`(valibot 用法)、`services/domain/admin/router-config`(域服务 + injeca)。 -- **Test scenarios:** - - create 持久化一行并通过 schema 校验;非法 tier / 缺字段被 schema 拒绝。 - - update 改 params 产生新形状;不影响其它行。 - - disable 置 enabled=false,行仍在。 - - listEnabled 只返回 enabled=true 的行;list 返回全部。 - - 参数覆盖:同 provider/model/voice 不同 params 是两条独立记录。 -- **Verification:** 域服务单测覆盖 CRUD + listEnabled + schema 边界;DB 用测试库或等价。 - -### U7. admin CRUD HTTP API - -- **Goal:** 暴露 admin 路由:新增 / 编辑 / 禁用 / 列出 voice pack。 -- **Requirements:** R10, R11(origin) -- **Dependencies:** U6 -- **Files:** - - `apps/server/src/routes/admin/voice-packs/`(新建路由) - - 路由挂载处(仿现有 admin config 路由注册) - - 对应 `*.test.ts` -- **Approach:** 复用现有 admin 鉴权/路由 pattern(`apps/server/src/routes/admin/config/`),路由调 U6 域服务。入参 valibot 校验(外部边界)。列表接口供最小绑定入口读 enabled pack。 -- **Patterns to follow:** `apps/server/src/routes/admin/config/router/index.ts`(admin 路由 + body schema)、injeca 服务注入(`app.ts:648` adminRouterConfig)。 -- **Test scenarios:** - - POST 新增返回创建的 pack;非法 body 返回 400(schema 拒绝)。 - - PATCH 编辑、POST/PATCH 禁用置 enabled=false。 - - GET 列出(admin 看全部;enabled 过滤接口供客户端)。 - - 未授权请求被 admin 鉴权拒绝(复用现有 admin guard)。 -- **Verification:** 路由集成测试(Hono test client)断言状态码、鉴权、与域服务交互。 - -### Phase C — 角色卡绑定与合成 - -### U8. 角色卡 Voice Pack 快照契约 - -- **Goal:** 在角色卡 speech 扩展里定义冻结快照字段,写入即冻结、读取兼容缺失。 -- **Requirements:** R12, R15(origin) -- **Dependencies:** U6(快照形状需与 pack 解析值对齐) -- **Files:** - - `packages/stage-ui/src/stores/modules/airi-card.ts`(扩 `AiriExtension.modules.speech` 加 `voicePack` 子对象 :19-74;写入 :173-215;读取 `resolveAiriExtension` :161) - - `packages/stage-ui/src/stores/modules/airi-card.test.ts`(新增/扩展) -- **Approach:** `voicePack` 快照 = `{ packId, name, provider, model, voiceId, params, tier, ttsModelId }`(解析后的值,非表外键)。绑定写入这个对象;`resolveAiriExtension` 对缺失返回 undefined 分支(不加 backward-compat guard,缺失即「未绑定 Voice Pack」走旧 speech 字段)。注意与整卡 LWW 云同步(`docs/ai/context/plans/2026-05-09-character-cards-cloud-sync-design.md`)对齐:快照随整卡同步,schema 改动不破坏 LWW。 -- **Patterns to follow:** `airi-card.ts:161-215`(speech 快照读写)、`resolveAiriExtension`(:161 缺失兼容)、`packages/ccc/src/export/types/extensions.ts:1`(开放 extensions)。 -- **Test scenarios:** - - 写入 voicePack 快照后读回值一致。 - - Covers AE4. 写入后再「改 library 值」(模拟另一份 pack 定义)不改变已写入快照(快照是值拷贝,无表引用)。 - - 卡内无 voicePack 字段时 resolveAiriExtension 返回未绑定分支,不抛错(旧卡兼容靠默认路径非 guard)。 - - 快照含 ttsModelId,供合成映射。 -- **Verification:** store 单测断言快照值拷贝语义、缺失兼容、字段完整。 - -### U9. 最小绑定入口 - -- **Goal:** 在现有 speech 设置页让用户选一个 enabled Voice Pack,触发冻结快照写入当前角色卡。 -- **Requirements:** R11, R14(origin) -- **Dependencies:** U7, U8 -- **Files:** - - `packages/stage-pages/src/pages/settings/modules/speech.vue`(加 Voice Pack 选择 → 调绑定) - - `packages/stage-ui/src/stores/modules/`(绑定动作:读 listEnabled API → 冻结快照入卡,复用 speech store / airi-card store) - - i18n key 加到 `packages/i18n`(注意 `@` 转义 `{'@'}`) - - 对应 `*.test.ts` -- **Approach:** 复用 `VoiceCardManySelect` / speech.vue 现有结构(`stage-pages` 共享页,双端生效),列 enabled pack(带 tier badge 展示,复用属性 chips 落点 `voice-card.vue:167-180`)。选中即调 U8 的绑定动作冻结快照。本轮不做独立市场浏览页。tier 仅展示。 -- **Patterns to follow:** `packages/stage-pages/src/pages/settings/modules/speech.vue`(共享页 + ` layout: settings`)、`components/menu/voice-card.vue`(badge/chips)、`use-modules-list.ts:59-64`(模块入口)。 -- **Test scenarios:** - - 选中一个 enabled pack → 角色卡写入对应冻结快照(断言 store 调用与快照值)。 - - 列表只展示 enabled pack。 - - tier badge 正确渲染(展示层)。 - - 双端共享页:组件在 stage-web/tamagotchi 同一实现(不双写)。 -- **Verification:** 组件/store 单测 + 真实浏览器实测绑定流程(screenshot/console,前端改动按 CLAUDE.md 需浏览器证据)。 - -### U10. 合成读快照 + 参数应用 - -- **Goal:** 合成时读角色卡冻结快照,映射 tts model 并应用参数;不支持的参数 fail-fast。 -- **Requirements:** R12, R13(origin) -- **Dependencies:** U8;(路由经 U2 号池 LB) -- **Files:** - - `packages/stage-ui/src/stores/modules/speech.ts`(读快照构造合成请求;参数 → `generateSSML` prosody :298-338 或 adapter options) - - server 合成入参处理(`apps/server/src/routes/openai/v1/index.ts:489-614` handleTTS,参数透传/校验) - - 对应 `*.test.ts` -- **Approach:** 绑定卡合成时读 `voicePack` 快照,用 `ttsModelId` 作为 model(经服务端 routeTts → 号池 LB)。参数覆盖:SSML-capable provider 走 `generateSSML`(pitch/rate/volume);其它走 adapter `speed`/`extraOptions`。目标后端无法应用某参数时 fail-fast 报带上下文错误(可 grep),不静默丢参数(符合禁止静默降级)。 -- **Patterns to follow:** `speech.ts:280-296`(合成入口)、:298-338(generateSSML prosody)、`adapters/tts/types.ts:12-30`(TtsInput speed/extraOptions)、`errorMessageFrom`(`speech.ts:113`)。 -- **Test scenarios:** - - 绑定卡合成用快照的 ttsModelId + voice + 参数构造请求。 - - SSML-capable provider 的 pitch/volume 进 SSML prosody。 - - Covers AE5. 目标后端不支持的参数 → fail-fast 抛带上下文错误,不静默出声丢参数。 - - 未绑定卡走旧 speech 字段路径(不回归)。 -- **Verification:** 单测覆盖快照→请求构造、参数映射、不支持参数 fail-fast;触外部边界(合成)需一次真实合成命令/日志证据(按 CLAUDE.md Iron Law)。 - -## System-Wide Impact - -- **TTS 路由层行为变更(U2/U3)影响所有走 routeTts 的 TTS 合成**,不止 Voice Pack。改造需保「单 upstream/未满」场景不回归。 -- **多副本一致性**:并发计数与 gauge 是 cluster-wide,dashboard 必须 `avg()` 不 `sum()`;新增 gauge 沿用此约束并在注释写明。 -- **角色卡 schema 变更(U8)进整卡 LWW 云同步**,需与 cloud-sync 设计对齐,避免破坏同步。 -- **计费**:本轮 tier 不碰实际扣费;四档 meter 拆分是独立 billing 线,勿在本计划顺手改 meter。 - -## Scope Boundaries - -### 本计划范围内 -- Phase A 号池负载均衡(U1-U4)、Phase B Voice Pack 表与 admin API(U5-U7)、Phase C 绑定与合成(U8-U10)。 - -### Deferred for later(origin 已列) -- 参考音频整块(materialize、随机 roll、情绪标签);未来 `voice_pack_reference` 子表,本轮只记形状不建表。 -- emotion embedding 内容类型。 -- 声音克隆 upload→clone API 流程(本轮只消费已克隆 model id)。 -- 四档 meter 拆分(billing 独立线)。 -- 用户侧精选市场浏览页(本轮只最小绑定入口)。 -- Voice Pack 管理 UI 页面(本轮只 admin HTTP API)。 -- 可分发市场(发布/下载/分享)。 - -### Deferred to Follow-Up Work(计划期发现,本轮不顺手做) -- 「排队等空位」语义(现框架无等待逻辑):本轮全池满直接 fail-fast,不实现排队。 -- key 级 appid 建模(appid 下沉到 key):本轮用 app_id = upstream,不重构 schema 粒度。 - -## Risks & Dependencies - -- **Redis 计数与上游实际并发不同步**:主动层估算可能偏差,靠 429 兜底 + 熔断反哺(U3)收敛;TTL 防泄漏。风险可控但需监控(U4)验证实际命中率。 -- **迁移工具未确认(U5)**:实现期需先定位 apps/server 迁移机制(与 `characters` 表同源),再落表。 -- **app_id 落位假设(建模)**:基于 volcengine `adapterParams.appid`(`volcengine.ts:49`);若实际有 provider 把 app 凭据放别处,需在 U2 路由前确认 appid 提取统一。 -- **云同步对齐(U8)**:快照 schema 改动需与 cloud-sync 设计联动,避免 LWW 整卡同步破坏。 -- **`tts-billing-tiers.md` 不在本分支**:tier 命名来源文档当前在 main worktree 未提交,引用时同步。 - -## Sources & Research - -- `apps/server/src/services/.../router.ts:413-617` — routeTts、dispatchOneTtsUpstream、createKeyRotator、upstream 遍历(号池 LB 改造点)、exhaustion fail-fast。 -- `apps/server/src/services/.../flux-meter.ts:18, 23-31, 106` — Lua INCRBY+EXPIRE+条件 DECRBY + redis.eval(并发账本复用模式)。 -- `apps/server/src/.../gauges/active-sessions.ts:23-26, 42-102` — cluster-wide ObservableGauge 模板 + 多副本 avg 约束。 -- `apps/server/src/.../otel/index.ts:76-90, 202-244, 442-458` — GatewayMetrics、ObservableGauge 选型。 -- `apps/server/src/services/adapters/tts/volcengine.ts:49-55, 90` — appid 取自 adapterParams、token 走 keyPlaintext。 -- `apps/server/src/services/adapters/config-kv.ts:33-40, 57-61, 83-87, 118, 134` — keyEntry/ttsUpstream/ttsModel schema、FLUX_PER_1K_CHARS_TTS、DEFAULT_TTS_VOICES。 -- `apps/server/src/routes/openai/v1/index.ts:489-642, 738, 759` — handleTTS、/audio/voices、ttsGuard、/speech 路由挂载。 -- `apps/server/src/app.ts:616-632, 695-712` — ttsMeter(Redis)、registerActiveSessionsGauge。 -- `apps/server/railway.toml:1-11` — Railway 部署(无 replicas 字段,副本数控制台侧)。 -- `packages/stage-ui/src/stores/modules/airi-card.ts:19-74, 161-215` — speech 快照读写(冻结快照落点)。 -- `packages/stage-ui/src/stores/modules/speech.ts:32-35, 280-296, 298-338` — 全局 voice 状态、合成入口、generateSSML。 -- `packages/ccc/src/export/types/extensions.ts:1` — 开放 extensions。 -- `docs/ai/context/tts-billing-tiers.md` — tier 命名来源。 -- `docs/ai/context/plans/2026-05-09-character-cards-cloud-sync-design.md` — 整卡 LWW 云同步对齐。 diff --git a/docs/plans/2026-06-07-001-feat-observability-diagnostics-plan.md b/docs/plans/2026-06-07-001-feat-observability-diagnostics-plan.md deleted file mode 100644 index f4755bb60..000000000 --- a/docs/plans/2026-06-07-001-feat-observability-diagnostics-plan.md +++ /dev/null @@ -1,283 +0,0 @@ ---- -title: "feat: Full-flow observability diagnostics" -status: active -date: 2026-06-07 -type: feat ---- - -# feat: Full-flow observability diagnostics - -## Summary - -Add a shared server-side diagnostics layer for OpenAI-compatible chat, HTTP TTS, streaming TTS WebSocket, router attempts, upstream adapters, billing, request logs, product events, logs, traces, and metrics. The immediate acceptance sample is the CosyVoice incident where AIRI returned 502 while UnSpeech/DashScope returned 400, but the implementation should cover the full generation flow rather than only TTS errors. - -## Problem Frame - -During the TTS incident, Grafana showed a concentrated burst of `POST /api/v1/audio/speech` 502 responses for `alibaba/cosyvoice-v1` while Tempo exposed an internal `POST https://unspeech-production.up.railway.app/v1/audio/speech` span returning upstream HTTP 400. Product events recorded `speech_failed` with final `http_status: 502` and reason `BAD_GATEWAY`, but Loki did not contain structured fields such as upstream HTTP status, upstream provider, upstream error code, response body snippet, or request input snippet. - -The result is an operational dead end: we can identify that one user repeatedly triggered the failure, but we cannot answer why DashScope returned 400 without replaying, guessing, or obtaining upstream-side logs. This plan turns each generation request into a correlated diagnostic record that survives across logs, traces, product events, and request logs. - ---- - -## Requirements - -**Correlation** - -- R1. Every chat, HTTP TTS, and TTS WebSocket request must carry a stable `requestId` through route logs, spans, product events, request logs, router attempts, adapter failures, billing, and final response handling. -- R2. Operators must be able to start from any one of `requestId`, `trace_id`, `userId`, product event row, or request log row and reconstruct the generation flow. -- R3. Diagnostic records must include route-level context: user id, session id when available, source, trigger, feature, action, final HTTP status, model, voice for TTS, input character count, request duration, and billing outcome. - -**Upstream Failure Detail** - -- R4. Router and adapter failures must preserve structured upstream diagnostics: upstream service, provider host, upstream URL or route name, upstream HTTP status, upstream error code, upstream error message, bounded upstream response body snippet, key id, upstream index, attempt count, and fallback decision. -- R5. The specific non-fallback TTS 400 case must be logged before the router breaks out of the fallback loop; a raw upstream 400 must not disappear just because it is not in `fallbackHttpCodes`. -- R6. AIRI may continue mapping upstream 4xx/5xx failures to client-safe 502/503 responses, but server-side diagnostics must retain the original upstream status and response details. - -**Input Diagnostics** - -- R7. Failure diagnostics must include a bounded request input snippet or payload summary for chat, HTTP TTS, and TTS WebSocket text input. -- R8. Raw input snippets and upstream response snippets must not become Prometheus labels. They belong in structured logs, trace attributes/events, product event metadata, or request-log diagnostics where cardinality and payload size are controlled. -- R9. Diagnostic snippets must be bounded by code-level defaults and environment-configurable caps to prevent large auto-TTS loops from creating unbounded log volume. - -**Storage And Metrics** - -- R10. `llm_request_log` must become drilldown-capable by storing request id, operation/source, provider, reason, input length, upstream status, and structured diagnostics for failures. -- R11. `product_events` metadata must receive scalar drilldown fields for failure diagnosis while respecting the current primitive metadata type. -- R12. Metrics must stay low-cardinality: no user ids, request ids, raw input, error messages, or response body snippets in Prometheus labels. -- R13. Observability docs must name the destination rules for logs, traces, product events, request logs, and metrics so future instrumentation does not drift again. - ---- - -## Key Technical Decisions - -- **Create one diagnostic envelope module.** A shared module should normalize route context, input summaries, upstream attempts, final status, and destination-specific projections. This prevents chat, HTTP TTS, and TTS WebSocket from each inventing field names. -- **Keep final response safety separate from server diagnostics.** `mapUpstreamError` can still return client-safe 502/503 errors, while `ApiError.cause`, structured logs, spans, product events, and request logs keep the upstream 400/429/500 details. -- **Use bounded snippets, not unbounded prompt dumps.** Default caps should be explicit, such as 512 characters for request input snippets and 2048 bytes for upstream body snippets, with env overrides. This is primarily a log-volume and storage-control boundary. -- **Store full attempts where JSON is natural, flatten where query speed matters.** Structured logs and request-log diagnostics can carry an attempts array. Product event metadata should store scalar fields such as `upstream_attempt_count`, `upstream_http_status`, `upstream_error_code`, and `input_snippet` because `ProductEventMetadata` currently allows only primitive values. -- **Route lifecycle logs are first-class, not only global error fallback.** `app.ts` global `onError` remains a safety net, but each route should emit request started, blocked, upstream failed, billing failed, succeeded, and failed events with the same diagnostic envelope. -- **Prometheus remains aggregate-only.** Counters and histograms should use low-cardinality labels such as operation, provider, model, final status, upstream status class, and fallback decision. User-level drilldown belongs in Postgres, Loki, and Tempo. -- **Fix misleading fallback accounting while adding diagnostics.** The current TTS router increments `fallbackCount` before knowing whether a status will actually fallback. The implementation should either move the increment behind the fallback decision or add a distinct attempt-failure counter so dashboards do not call non-fallback 400s "fallbacks". - ---- - -## High-Level Technical Design - -```mermaid -flowchart TB - REQ[Route receives generation request] --> CTX[Create DiagnosticContext] - CTX --> START[Log and product event: requested] - CTX --> ROUTER[LLM/TTS router] - ROUTER --> ADAPTER[Provider adapter] - ADAPTER --> UPSTREAM[UnSpeech / DashScope / other upstream] - UPSTREAM -->|non-2xx / error| ATTEMPT[Build UpstreamDiagnostic] - ATTEMPT --> ROUTER - ROUTER -->|exhausted / non-fallback| FINAL[Build final DiagnosticEnvelope] - FINAL --> LOGS[Loki structured logs] - FINAL --> TRACE[Tempo span attrs/events] - FINAL --> PRODUCT[product_events scalar metadata] - FINAL --> REQLOG[llm_request_log diagnostics jsonb] - FINAL --> METRICS[Prometheus low-cardinality metrics] - FINAL --> CLIENT[Client-safe response] -``` - -```mermaid -sequenceDiagram - participant Route as Route handler - participant Diag as Diagnostics module - participant Router as LLM/TTS router - participant Adapter as Adapter - participant Upstream as Upstream API - participant Sinks as Logs/Trace/DB/Metrics - - Route->>Diag: newContext(requestId, userId, model, source, inputSummary) - Route->>Sinks: requested event - Route->>Router: route with DiagnosticContext - Router->>Adapter: dispatch attempt - Adapter->>Upstream: HTTP request - Upstream-->>Adapter: HTTP 400 with JSON body - Adapter-->>Router: UpstreamDiagnostic(status, code, message, bodySnippet) - Router-->>Diag: attempt failed, fallback decision - Diag->>Sinks: upstream_failed diagnostics - Router-->>Route: ApiError 502 with diagnostic cause - Route->>Sinks: failed event + request log + span attrs - Route-->>Client: sanitized 502 -``` - ---- - -## Implementation Units - -### U1. Diagnostic envelope and field conventions - -- **Goal:** Define one shared representation for correlation, input summaries, upstream attempts, billing outcomes, and destination-specific projections. -- **Requirements:** R1, R2, R3, R7, R8, R9, R12, R13 -- **Files:** - - `apps/server/src/services/domain/observability-diagnostics.ts` or `apps/server/src/services/domain/observability-diagnostics/index.ts` - - `apps/server/src/services/domain/observability-diagnostics.test.ts` - - `apps/server/src/utils/observability.ts` - - `apps/server/docs/ai-context/observability-conventions.md` -- **Approach:** Add types such as `DiagnosticContext`, `InputDiagnostic`, `UpstreamDiagnostic`, `GenerationFailureDiagnostic`, and projection helpers for logs, span attributes, product event metadata, request-log diagnostics, and metric labels. Keep destination rules in code, not scattered at call sites. -- **Patterns to follow:** `apps/server/src/utils/observability.ts` for existing `airi.*` attribute naming; `apps/server/docs/ai-context/observability-conventions.md` for low-cardinality rules. -- **Test scenarios:** - - Input snippets are truncated to the configured cap and preserve `input_chars`. - - Upstream body snippets are truncated independently from input snippets. - - Product event projection contains only primitive metadata values. - - Metric projection excludes `userId`, `requestId`, raw input, error message, and body snippet. - - Log/request-log projection retains diagnostic fields needed for incident drilldown. - -### U2. Structured upstream diagnostics in router and adapters - -- **Goal:** Preserve upstream status, parsed error code/message, body snippet, and fallback decision through router failures. -- **Requirements:** R4, R5, R6, R12 -- **Files:** - - `apps/server/src/services/adapters/tts/unspeech.ts` - - `apps/server/src/services/domain/llm-router/router.ts` - - `apps/server/src/services/domain/llm-router/error-mapping.ts` - - `apps/server/src/services/domain/llm-router/tests/router.test.ts` -- **Approach:** Replace string-only TTS adapter errors with structured diagnostic fields attached to the thrown error or returned attempt failure. Parse `UnSpeechAPIError.responseBody` as JSON when possible and extract provider error code/message. Keep raw `bodySnippet` bounded. Ensure non-fallback 400s are logged and recorded before the router breaks. Revisit `fallbackCount` so it records real fallback decisions rather than all failed attempts. -- **Patterns to follow:** Existing chat non-2xx handling in `apps/server/src/services/domain/llm-router/router.ts`, which already reads `bodySnippet`; existing `UpstreamAttempt` cause shape in `apps/server/src/services/domain/llm-router/error-mapping.ts`. -- **Test scenarios:** - - UnSpeech/DashScope 400 JSON body becomes `upstream_http_status: 400`, parsed `upstream_error_code`, parsed `upstream_error_message`, and bounded `upstream_body_snippet`. - - TTS 400 that is not in `fallbackHttpCodes` still emits an upstream failure log and attaches the attempt to `ApiError.cause`. - - TTS 429 still records fallback decision and remains distinguishable from non-fallback 400. - - Chat upstream non-2xx continues preserving `bodySnippet` and now projects the same diagnostic field names. - - Metrics do not receive high-cardinality diagnostic payloads. - -### U3. Unified lifecycle diagnostics for OpenAI chat and HTTP TTS - -- **Goal:** Make non-streaming chat and HTTP TTS emit the same request lifecycle shape across logs, spans, product events, request logs, and metrics. -- **Requirements:** R1, R2, R3, R6, R7, R10, R11 -- **Files:** - - `apps/server/src/routes/openai/v1/middlewares/telemetry.ts` - - `apps/server/src/routes/openai/v1/operations/chat-completions/index.ts` - - `apps/server/src/routes/openai/v1/operations/speech-generation/index.ts` - - `apps/server/src/services/domain/openai-speech/index.ts` - - `apps/server/src/routes/openai/v1/route.test.ts` -- **Approach:** Extend `createRouteTelemetry` so both chat and speech can create a `DiagnosticContext`, record lifecycle events, and write failure request logs. Move duplicated TTS analytics fields into the shared helper where practical. Preserve existing success accounting and billing semantics. -- **Patterns to follow:** Current `createRouteTelemetry` in `apps/server/src/routes/openai/v1/middlewares/telemetry.ts`; current TTS product event sequence in `apps/server/src/services/domain/openai-speech/index.ts`. -- **Test scenarios:** - - HTTP TTS upstream 400 produces `speech_failed` metadata with request id, input chars, input snippet, upstream provider, upstream status, error code/message, body snippet, final status 502, and duration. - - Chat router exhaustion produces `completion_failed` metadata with request id, model, input summary, upstream status/body snippet, final status, and duration. - - Billing block/failure logs request id and does not pretend an upstream call happened. - - Successful chat and TTS requests keep existing request-log and product-event behavior while adding request id/source/provider fields. - - Client responses remain sanitized and do not include upstream body snippets. - -### U4. Streaming TTS WebSocket diagnostics - -- **Goal:** Bring `routes/audio-speech-ws` to the same diagnostic standard as HTTP TTS. -- **Requirements:** R1, R2, R3, R7, R10, R11 -- **Files:** - - `apps/server/src/routes/audio-speech-ws/session.ts` - - `apps/server/src/routes/audio-speech-ws/types.ts` - - `apps/server/src/routes/audio-speech-ws/route.test.ts` -- **Approach:** Thread `requestId` into start, upstream dial, upstream control event, upstream error, billing failure, close, success, product event, and request-log paths. Accumulate a bounded input snippet from text frames and record input character counts. Map upstream control errors into the shared diagnostic envelope. -- **Patterns to follow:** Existing WebSocket product event writes in `apps/server/src/routes/audio-speech-ws/session.ts`; existing request-log success write near the end of the session lifecycle. -- **Test scenarios:** - - Upstream WebSocket error records request id, user id, model, voice, input chars, input snippet, upstream code/message, and final close status. - - Upstream control error produces `speech_failed` product metadata with diagnostic fields. - - Billing failure includes request id, units, reason, and source. - - Success path writes request log with request id and operation/source. - - Input snippet cap is respected for long streaming text. - -### U5. Drilldown-capable request logs and product event metadata - -- **Goal:** Store enough persistent diagnostic data to query incidents after volatile logs age out. -- **Requirements:** R2, R3, R10, R11 -- **Files:** - - `apps/server/src/schemas/llm-request-log.ts` - - `apps/server/src/services/domain/request-log.ts` - - `apps/server/drizzle/0016_*.sql` - - `apps/server/drizzle/meta/_journal.json` - - `apps/server/drizzle/meta/0016_snapshot.json` - - `apps/server/src/schemas/product-events.ts` - - `apps/server/src/routes/openai/v1/route.test.ts` - - `apps/server/src/routes/audio-speech-ws/route.test.ts` -- **Approach:** Add request-log columns such as `request_id`, `operation`, `source`, `provider`, `reason`, `input_chars`, `upstream_status`, and `diagnostics` jsonb. Add indexes for `request_id`, `(user_id, created_at)`, and `(provider, upstream_status, created_at)` if query plans warrant them. Keep `product_events` schema stable unless type widening is needed; write scalar diagnostic metadata through U1 projections. -- **Patterns to follow:** Existing Drizzle table definitions in `apps/server/src/schemas/*.ts`; existing migration numbering under `apps/server/drizzle/`. -- **Test scenarios:** - - Failed HTTP TTS writes request log with request id, operation, provider, final status, upstream status, reason, input chars, and diagnostics jsonb. - - Failed chat writes equivalent request-log fields. - - Successful requests still write existing flux/token fields. - - Product event metadata remains primitive and query-friendly. - - Migration applies cleanly to an existing table without requiring historical rows to have request ids. - -### U6. Metrics and documentation update - -- **Goal:** Make dashboards and future instrumentation use the new diagnostic contract correctly. -- **Requirements:** R8, R12, R13 -- **Files:** - - `apps/server/src/otel/index.ts` - - `apps/server/src/utils/observability.ts` - - `apps/server/docs/ai-context/observability-conventions.md` - - `apps/server/docs/ai-context/observability-metrics.md` - - `apps/server/src/services/domain/llm-router/tests/router.test.ts` -- **Approach:** Add or revise counters for upstream attempt failures, real fallback decisions, and final route failures using low-cardinality labels. Document Loki, Tempo, Postgres, and Prometheus query patterns for request-level drilldown. Update metric docs to explain why user ids and snippets are excluded from Prometheus. -- **Patterns to follow:** Current `GatewayMetrics` in `apps/server/src/otel/index.ts`; existing metric naming conventions in `apps/server/src/utils/observability.ts`. -- **Test scenarios:** - - Upstream attempt failure increments an attempt-failure counter with provider/model/status-class labels. - - Real fallback increments fallback counter only when the router actually proceeds to another key/upstream. - - Non-fallback 400 does not appear as a fallback. - - Metric attribute helpers reject or omit high-cardinality fields. - -### U7. Incident runbook acceptance queries - -- **Goal:** Make the next incident answerable from Grafana/Loki/Tempo/Postgres without code spelunking. -- **Requirements:** R2, R13 -- **Files:** - - `apps/server/docs/ai-context/observability-runbook.md` - - `apps/server/docs/ai-context/observability-conventions.md` -- **Approach:** Document concrete query shapes: from user id to recent failed requests, from request id to Loki logs, from trace id to upstream span, from product event to request log, and from provider/status to aggregate Prometheus trends. Include the TTS 400-to-502 incident as the worked example. -- **Patterns to follow:** Existing server docs under `apps/server/docs/ai-context/`. -- **Test scenarios:** Documentation-only unit; verify manually during implementation by running the queries against a staging or production time window after deployment. - ---- - -## Acceptance Examples - -- AE1. Given DashScope returns a JSON 400 through UnSpeech during HTTP TTS, when AIRI returns client-safe 502, then Loki, Tempo, `product_events`, and `llm_request_log` expose request id, user id, source, trigger, model, voice, input chars, input snippet, upstream provider, upstream HTTP 400, parsed upstream code/message, body snippet, final 502, and duration. -- AE2. Given a chat completion upstream returns non-2xx with a response body, when the router exhausts, then `completion_failed` and request logs preserve upstream diagnostics while the client response stays sanitized. -- AE3. Given TTS WebSocket text frames are sent and the upstream control channel reports an error, then the session logs and product event include request id, input snippet, upstream code/message, close status, and billing outcome. -- AE4. Given a billing block happens before any upstream call, then diagnostics show billing reason and final status but do not fabricate upstream fields. -- AE5. Given an operator starts with a high-frequency `userId`, then they can query product events/request logs for request ids, jump to Loki by request id, and jump to Tempo by trace id without relying on raw application memory. - ---- - -## Scope Boundaries - -- In scope: server-side logs, traces, metrics, product events, request logs, router/adapters, HTTP chat, HTTP TTS, TTS WebSocket, and documentation/runbook. -- In scope: bounded failure-time input snippets and bounded upstream body snippets. -- Out of scope: front-end product analytics UI, admin dashboards, replay tooling, long-term data retention policy, and full prompt capture for every successful request. -- Out of scope: changing the client-facing error response contract except where tests need to confirm diagnostics remain server-side. - ---- - -## System-Wide Impact - -This change touches the generation hot path, observability conventions, Postgres schema, and dashboard semantics. It also changes the meaning or interpretation of fallback metrics if `fallbackCount` is corrected. The implementation should update metric docs in the same unit as metric behavior to avoid confusing existing dashboards. - -The request-log migration must be backward compatible with existing rows. New columns should be nullable unless there is a safe default. Indexes should be chosen for incident queries, not for every possible metadata field. - ---- - -## Risks & Dependencies - -- **Log volume:** Auto-TTS loops can generate hundreds of failures in minutes. Caps, failure-only snippets, and destination projections are required. -- **Metric cardinality:** Accidentally placing user ids, request ids, snippets, or raw upstream messages in labels would harm Prometheus. U1 and U6 tests should catch this. -- **Security material:** Do not log API keys, Authorization headers, encrypted key ciphertext, or full request headers. This remains a security boundary even when request text snippets are allowed. -- **Schema churn:** `llm_request_log` changes require Drizzle migration files and test updates across HTTP and WebSocket routes. -- **Partial instrumentation drift:** Implementing only TTS would leave chat and WebSocket incidents with the same blind spots. U3 and U4 should land before the plan is considered complete. - ---- - -## Sources / Research - -- `apps/server/src/app.ts` currently has global `onError` logging, but route-level upstream diagnostics are not guaranteed. -- `apps/server/src/services/domain/openai-speech/index.ts` already emits TTS request logs and product events, but failure metadata only carries final status/duration/trigger. -- `apps/server/src/routes/openai/v1/operations/chat-completions/index.ts` emits chat lifecycle product events, but router failures do not expose upstream diagnostics in product metadata. -- `apps/server/src/routes/audio-speech-ws/session.ts` has WebSocket product events and request logs, but upstream errors do not consistently include request id or input diagnostics. -- `apps/server/src/services/domain/llm-router/router.ts` already captures chat upstream `bodySnippet`; the TTS path mostly collapses adapter errors into strings and can skip logging non-fallback 400s. -- `apps/server/src/services/adapters/tts/unspeech.ts` sees `UnSpeechAPIError.responseBody`, but does not expose parsed upstream code/message as structured fields. -- `apps/server/src/services/domain/llm-router/error-mapping.ts` keeps upstream attempts server-side in `ApiError.cause`, which is the right place to preserve detail while sanitizing client responses. -- `apps/server/src/schemas/product-events.ts` stores product event metadata as primitive jsonb values and already has indexes for feature/action/time and user/time queries. -- `apps/server/src/schemas/llm-request-log.ts` is currently too thin for incident drilldown: no request id, operation/source, provider, reason, upstream status, or diagnostics jsonb. -- `apps/server/src/utils/observability.ts`, `apps/server/src/otel/index.ts`, and `apps/server/docs/ai-context/observability-conventions.md` define the existing OTel and metric conventions this plan should extend. diff --git a/docs/plans/2026-06-10-001-feat-llm-router-admin-form-plan.md b/docs/plans/2026-06-10-001-feat-llm-router-admin-form-plan.md deleted file mode 100644 index df3430370..000000000 --- a/docs/plans/2026-06-10-001-feat-llm-router-admin-form-plan.md +++ /dev/null @@ -1,210 +0,0 @@ ---- -title: "feat: Replace LLM Router JSON editor with admin form" -type: feat -date: 2026-06-10 -origin: apps/server/docs/brainstorms/2026-05-15-llm-router-replacement-requirements.md ---- - -# feat: Replace LLM Router JSON editor with admin form - -## Summary - -Replace the LLM Router admin page's raw request JSON editor with a provider-aware form that builds the existing `POST /api/admin/config/router` body for OpenRouter, Azure, DashScope cosyvoice, StepFun, unspeech, and default aliases. Keep preview/apply as the safety gate and preserve an advanced JSON view as an escape hatch, not the default editing path. - ---- - -## Problem Frame - -The current LLM Router admin page exposes the backend request body directly as JSON. That matches the server contract but makes routine operator work brittle: admins must remember each slice kind's required fields, know which URLs are HTTP or WebSocket roots, and avoid mistakes around merge/reset and default aliases while handling plaintext provider keys. - -The backend already owns validation, encryption, merge semantics, and redacted previews. This plan keeps that backend boundary intact and improves the admin UI so the common configuration flows are discoverable, structured, and reviewable before apply. - ---- - -## Requirements - -**Form Coverage** - -- R1. The page must let admins compose every currently supported slice kind: `openrouter`, `azure`, `dashscope-cosyvoice`, `stepfun`, and `unspeech`. -- R2. The page must expose defaults for chat model, TTS model, and recommended TTS voices without requiring manual JSON editing. -- R3. The page must preserve `merge` versus `reset` mode as an explicit, visible choice before preview or apply. -- R4. The page must support multiple LLM/TTS slices in one request while keeping unspeech constrained to one slice. - -**Safety And Review** - -- R5. The UI must build the same `AdminRouterConfigRequest` shape the server validates today; the backend remains the source of truth for encryption and final validation. -- R6. Plaintext keys must stay in input state only long enough to submit; preview and apply panels must only show the server's redacted response. -- R7. Preview remains the primary review step and apply must be disabled while client-side required fields are incomplete. -- R8. Advanced JSON must remain available for inspection or emergency unsupported fields, but the default path must not require writing JSON. - -**Operator Experience** - -- R9. Provider-specific fields must be grouped and labeled by operational meaning, including endpoint defaults, key entry id, model alias, upstream model, region, and streaming settings. -- R10. The page must show a compact summary of pending slices, touched config keys, previewed changes, and last apply result. -- R11. The layout must stay usable on narrow admin viewports without overlapping controls or forcing JSON-editor-sized panes. - ---- - -## Key Technical Decisions - -- **Keep the backend route contract unchanged.** The form should compile UI state into `AdminRouterConfigRequest` and call `adminApi.applyRouterConfig` exactly as the JSON page does. This avoids duplicating encryption, merge, validation, and invalidation behavior in the browser. -- **Extract request-building logic into a pure UI module.** A small builder module should own form-state defaults, provider-kind projections, validation messages, and JSON import/export. This gives the risky payload conversion focused Vitest coverage without introducing component-test dependencies the admin app does not currently use. -- **Use existing admin and UI primitives.** Follow the Voice Pack form pattern with `@proj-airi/ui` primitives, `DatalistField`, global `.panel`/`.badge` styling, and Vue class arrays. Do not invent a separate mini design system for this one page. -- **Treat advanced JSON as a synchronized escape hatch.** The default view is the form. Advanced JSON can import into the form when it matches supported slice kinds, and export the current form payload for audit/debugging. Unsupported advanced edits should be previewable only through an explicit advanced-submit path so normal form state stays typed. -- **Do client-side validation for ergonomics, not authority.** Client checks should catch empty required fields, invalid URL schemes, duplicate unspeech slices, and missing defaults early. Server Valibot validation remains authoritative, and server errors still surface through the existing toast path. - ---- - -## High-Level Technical Design - -```mermaid -flowchart TB - FORM[Provider-aware form state] --> BUILD[Request builder module] - BUILD --> ERRORS[Client validation summary] - BUILD --> PAYLOAD[AdminRouterConfigRequest] - PAYLOAD --> PREVIEW[Preview submit with dryRun true] - PAYLOAD --> APPLY[Apply submit with dryRun false] - PREVIEW --> REDACTED[Server redacted preview panel] - APPLY --> REDACTED - FORM --> EXPORT[Advanced JSON export] - IMPORT[Advanced JSON import] --> BUILD - ADVANCED[Advanced JSON submit] --> PREVIEW -``` - -The form state is the primary editing model. The request builder is the single bridge from UI concepts to the server body. Preview and apply both use the same built payload so admins do not review one shape and apply another. - ---- - -## Scope Boundaries - -- This plan does not change `POST /api/admin/config/router`, its Valibot schemas, encryption behavior, configKV writes, or Redis invalidation. -- This plan does not add provider discovery, key health management, cost routing, or enable/disable controls. Those remain router operational follow-ups from the original router scope. -- This plan does not introduce a new component library or component-testing dependency. If implementation discovers component-level assertions are necessary, prefer a narrow repo-consistent mounting pattern before adding dependencies. -- This plan does not extend the public stage-web, stage-tamagotchi, or mobile app surfaces. `apps/ui-admin` is the admin surface; responsive behavior still needs narrow viewport verification. - ---- - -## Implementation Units - -### U1. Router config form state and request builder - -- **Goal:** Define typed UI state, provider defaults, validation, import/export, and `AdminRouterConfigRequest` projection outside the Vue page. -- **Requirements:** R1, R2, R3, R4, R5, R6, R8, R9 -- **Dependencies:** None -- **Files:** - - `apps/ui-admin/src/modules/api.ts` - - `apps/ui-admin/src/modules/router-config-form.ts` - - `apps/ui-admin/src/modules/router-config-form.test.ts` -- **Approach:** Replace `Array>` for router slices with a discriminated union mirroring the admin route's existing slice kinds. Add form-facing state that can represent editable drafts, provider defaults, validation errors, and the compiled request payload. Keep plaintext keys out of previews and summaries. -- **Execution note:** Start with request-builder tests before replacing the page, because payload drift is the main regression risk. -- **Patterns to follow:** `apps/server/src/routes/admin/config/router/index.ts` for required fields and URL rules; `apps/server/src/services/domain/admin/router-config/index.ts` for default key entry ids and provider defaults; `apps/ui-admin/src/pages/VoicePackFormPage.vue` for form-state normalization. -- **Test scenarios:** - - OpenRouter draft with model alias, override model, plaintext key, default base URL, and chat default compiles to one `openrouter` slice and `defaults.chatModel`. - - Azure draft compiles region, default voice, key entry id, and TTS default without adding OpenRouter-only fields. - - DashScope cosyvoice draft preserves `intl` versus `cn` region and upstream model. - - StepFun draft defaults missing upstream model to the server-supported default only when the UI chooses to omit it, and preserves explicit instruction/default voice fields. - - Unspeech REST-only draft compiles without `streaming`; unspeech streaming draft compiles WebSocket URL, key, models, and default model. - - Validation reports missing required plaintext keys, invalid HTTP/WS URL schemes, empty aliases, and more than one unspeech draft. - - Exported JSON round-trips through import for supported slice kinds and defaults. -- **Verification:** The builder produces server-schema-compatible payloads for every supported provider kind and reports client-side errors before page code submits. - -### U2. Provider-aware LLM Router form UI - -- **Goal:** Replace the raw textarea-first page with a form-first interface for adding, editing, duplicating, and removing router slices. -- **Requirements:** R1, R2, R3, R4, R7, R9, R11 -- **Dependencies:** U1 -- **Files:** - - `apps/ui-admin/src/pages/LlmRouterPage.vue` - - `apps/ui-admin/src/components/llm-router/RouterSliceEditor.vue` - - `apps/ui-admin/src/components/llm-router/RouterDefaultsEditor.vue` - - `apps/ui-admin/src/components/llm-router/RouterModeControl.vue` - - `apps/ui-admin/src/pages/LlmRouterPage.test.ts` - - `apps/ui-admin/src/modules/router-config-form.test.ts` -- **Approach:** Split the page into a main form column and an operations sidebar. Use a provider-kind selector to add slices, render provider-specific fields in compact panels, and keep defaults in their own section. Make reset mode visually distinct because it drops existing router models not included in the request. -- **Patterns to follow:** `apps/ui-admin/src/pages/FluxPage.vue` for preview/apply action flow; `apps/ui-admin/src/pages/VoicePackFormPage.vue` for `@proj-airi/ui` fields, status badges, `Callout`, and grouped class arrays; `docs/ai/context/ui-components.md` for primitive props. -- **Test scenarios:** - - Empty page starts with a useful OpenRouter draft matching the current screenshot's common chat-default path. - - Adding each provider kind shows only that provider's relevant fields. - - Removing a slice updates validation and pending summary. - - Reset mode displays a warning state while merge mode remains the normal path. - - Apply and preview buttons are disabled while validation errors exist or a request is in flight. - - Narrow viewport stacks form and sidebar without overlapping labels, buttons, or preview output. -- **Verification:** The form can create each provider kind, validation state updates as fields change, and the built request is identical to the builder output covered in U1. - -### U3. Preview, apply, and advanced JSON workflow - -- **Goal:** Preserve the existing dry-run/apply behavior while making preview output easier to scan and keeping JSON available as a controlled advanced path. -- **Requirements:** R5, R6, R7, R8, R10, R11 -- **Dependencies:** U1, U2 -- **Files:** - - `apps/ui-admin/src/pages/LlmRouterPage.vue` - - `apps/ui-admin/src/components/llm-router/RouterPreviewPanel.vue` - - `apps/ui-admin/src/components/llm-router/RouterAdvancedJsonPanel.vue` - - `apps/ui-admin/src/pages/LlmRouterPage.test.ts` - - `apps/ui-admin/src/modules/router-config-form.test.ts` -- **Approach:** Submit built form payloads through `adminApi.applyRouterConfig`. Render `applied`, `invalidatedKeys`, and `preview` as separate scan-friendly sections before the raw JSON block. Add advanced JSON export/import and an explicit advanced preview/apply path for cases the typed form cannot represent yet. -- **Patterns to follow:** Existing `formatJson` panels in `LlmRouterPage.vue` and `FluxPage.vue`; server response shape in `apps/server/src/services/domain/admin/router-config/index.ts`. -- **Test scenarios:** - - Preview sends `dryRun: true`, stores preview result, leaves last apply untouched, and renders invalidated keys as empty for dry run. - - Apply sends `dryRun: false`, updates both preview and last apply state, and renders invalidated keys from the server. - - Server validation errors surface through the existing toast path without clearing form input. - - Preview panel never renders plaintext keys from form state. - - Advanced JSON export matches the built form payload. - - Advanced JSON import rejects non-object JSON and unsupported slice kind with actionable errors. -- **Verification:** Preview/apply calls use the same compiled payload, server errors preserve form state, and advanced JSON cannot silently diverge from the visible form without an explicit advanced action. - -### U4. Admin styling, responsive polish, and verification notes - -- **Goal:** Make the new form feel like a dense operations tool rather than a marketing page or raw schema editor. -- **Requirements:** R9, R10, R11 -- **Dependencies:** U2, U3 -- **Files:** - - `apps/ui-admin/src/styles/main.css` - - `apps/ui-admin/src/pages/LlmRouterPage.vue` - - `docs/ai/context/ui-components.md` only if implementation changes `packages/ui` primitives -- **Approach:** Reuse existing admin panels, badges, buttons, and field styling. Add only page-specific layout classes if repeated class arrays become unreadable. Keep cards for individual slice editors and result panels, not nested decorative sections. Verify desktop and narrow viewport behavior after implementation. -- **Patterns to follow:** `apps/ui-admin/src/styles/main.css` for admin shell primitives; `VoicePackFormPage.vue` for responsive form density. -- **Test scenarios:** - - Test expectation: none -- this unit is visual/layout polish; automated behavioral coverage lives in U1-U3. -- **Verification:** Use local admin app rendering to inspect the LLM Router page at desktop and narrow widths; confirm fields, buttons, badges, and preview panels remain readable and non-overlapping. - ---- - -## Acceptance Examples - -- AE1. Given an admin wants the screenshot's OpenRouter setup, when they fill chat model alias, upstream model, OpenRouter key, and base URL in the form, preview sends the same request shape the JSON editor previously contained and returns a redacted preview. -- AE2. Given an admin adds Azure, DashScope, StepFun, and unspeech entries in one merge request, when they preview, the pending summary lists each slice and the server response separates `LLM_ROUTER_CONFIG`, `UNSPEECH_UPSTREAM`, and default aliases. -- AE3. Given an admin selects reset mode, when they prepare to preview or apply, the UI displays reset as a destructive configuration mode and still requires a valid slice or defaults entry. -- AE4. Given the form contains a plaintext key, when preview or apply finishes, the page does not echo that key in summaries, JSON preview panels, or last apply panels. -- AE5. Given an unsupported future field is needed before the form catches up, when the admin opens advanced JSON, they can export the current payload, edit it, and submit through an explicit advanced path without corrupting normal typed form state. - ---- - -## System-Wide Impact - -| Surface | Impact | -|---|---| -| `apps/ui-admin` | Primary user-facing change; LLM Router page becomes form-first with typed request building. | -| `apps/server` | No planned runtime changes; route schemas and service builders remain the contract the UI mirrors. | -| ConfigKV / Redis invalidation | No behavior change; preview/apply still goes through the existing admin endpoint. | -| Public web, Electron, mobile | No direct product UI change; only admin app responsive behavior is in scope. | - ---- - -## Risks & Dependencies - -- **Schema drift risk:** The UI will mirror server slice fields. Mitigate by deriving names from current server route/service code during implementation and keeping request-builder tests focused on every provider kind. -- **Plaintext key handling risk:** Browser state necessarily contains keys before submit. Mitigate by never copying form keys into preview summaries, logs, or exported results unless the admin explicitly exports advanced JSON. -- **Advanced JSON ambiguity:** A JSON escape hatch can accidentally preserve the old complexity. Mitigate by keeping it collapsed/secondary and requiring explicit advanced submit for unsupported edits. -- **Testing gap:** `apps/ui-admin` currently has only module-level Vitest coverage. Mitigate with pure request-builder tests plus manual browser verification; add component tests only if implementation introduces behavior that cannot be covered through the builder. - ---- - -## Sources & Research - -- `apps/ui-admin/src/pages/LlmRouterPage.vue` currently owns the raw JSON textarea, preview, and apply flow. -- `apps/server/src/routes/admin/config/router/index.ts` defines the Valibot body schema and supported slice kinds. -- `apps/server/src/services/domain/admin/router-config/index.ts` defines provider defaults, request application semantics, redacted previews, and invalidated key behavior. -- `apps/ui-admin/src/pages/VoicePackFormPage.vue` shows the current admin form pattern with `@proj-airi/ui` primitives and responsive class arrays. -- `apps/ui-admin/src/pages/FluxPage.vue` shows the preview-first admin mutation pattern. -- `docs/ai/context/ui-components.md` documents the UI primitives to reuse if implementation touches shared components. diff --git a/docs/plans/2026-06-14-001-feat-doubao-asr-gateway-plan.md b/docs/plans/2026-06-14-001-feat-doubao-asr-gateway-plan.md deleted file mode 100644 index 2e18fe347..000000000 --- a/docs/plans/2026-06-14-001-feat-doubao-asr-gateway-plan.md +++ /dev/null @@ -1,355 +0,0 @@ ---- -title: "feat: Add Doubao ASR gateway" -type: feat -date: 2026-06-14 ---- - -# feat: Add Doubao ASR gateway - -## Summary - -**Superseded direction as of 2026-06-14:** do not use Volcengine AUC as AIRI's primary realtime ASR path. AUC requires URL-based recorded-file submission and is unsuitable for low-latency Hearing. The current implementation direction is to ship an official server-side realtime ASR proxy first, starting with Aliyun NLS because AIRI already has a working streaming transcription executor, then revisit Doubao streaming ASR (`/api/v3/sauc/bigmodel_async`) as a follow-up. - -Add server-side official recorded-file transcription for AIRI through Doubao/Volcengine ASR. The client uploads a recorded audio file to AIRI, AIRI authenticates the user, stages the audio behind a short-lived public URL because Volcengine AUC accepts audio URLs, submits and polls the standard AUC API, maps the result back to an OpenAI-shaped transcription response, and bills successful requests through a new STT FluxMeter debt ledger. - -This plan intentionally does not add realtime streaming ASR, client-side BYOK credentials, or a multi-provider ASR pool. The first user-facing path is recorded audio file transcription through the existing Hearing module and a new Official Transcription provider. - ---- - -## Problem Frame - -AIRI already has official hosted chat and TTS providers, plus client-side transcription providers for OpenAI, OpenAI-compatible endpoints, Aliyun NLS streaming, browser Web Speech, CometAPI, MiMo, and local audio paths. It does not yet have an official AIRI-hosted ASR provider that lets normal signed-in users transcribe recordings without bringing their own ASR credentials. - -The server also does not expose a mounted transcription route today. The current AIRI audio surface is `/api/v1/audio` with speech, voices, and speech model catalog routes. The OpenAI public route surface under `/api/v1/openai` is kept for actual OpenAI-compatible chat endpoints, so ASR should extend the AIRI audio surface rather than adding another extension under `/api/v1/openai`. - -Volcengine's recorded-file ASR APIs are asynchronous and require an online audio URL in the submit body. That means AIRI can present a normal multipart file upload to its clients, but the server needs a transient audio staging boundary before it can call the Doubao ASR upstream. - ---- - -## Requirements - -**Product Behavior** - -- R1. Signed-in AIRI users can choose an Official Transcription provider in the Hearing module and transcribe a recorded audio file without entering Volcengine credentials. -- R2. The client-facing endpoint accepts an OpenAI-shaped multipart transcription request with `file`, `model`, optional `language`, optional `response_format`, and optional provider options. -- R3. The first supported mode is recorded file transcription. Realtime streaming ASR, idle 24h batch jobs, and client BYOK credentials are out of scope for this version. -- R4. The endpoint returns `json` and best-effort `verbose_json` responses compatible with the existing Hearing confidence filter. When upstream utterances are available, map them to segments with confidence and timing where possible. - -**Server Gateway** - -- R5. The route lives under the AIRI audio surface as `POST /api/v1/audio/transcriptions`, not under `/api/v1/openai`. -- R6. The route uses the existing v1 gateway lifecycle: auth, session context, request id, operation middleware, config checks, product events, request logs, tracing, and metrics. -- R7. Server-managed Volcengine credentials, resource id, model name, endpoint, timeout, and retry/poll settings are configured through ConfigKV/admin surfaces. Client requests never include upstream provider keys. -- R8. AIRI stages uploaded audio to a temporary, externally reachable object URL before submitting to Volcengine AUC, then deletes or expires the object through a retention policy. -- R9. Raw audio bytes must not be written to logs, traces, product events, request logs, or metrics. - -**Billing And Operations** - -- R10. Successful transcription usage is billed through a new STT FluxMeter using audio duration seconds, not through minimum whole-request Flux billing. -- R11. The server performs a balance preflight before upstream spend using server-derived or server-verified audio duration metadata. -- R12. If the upstream never reaches a successful result within the synchronous poll budget, AIRI returns a clear gateway timeout/error response and does not bill the user for a successful transcription. -- R13. Admins can configure the Doubao ASR router slice and default ASR model through the existing router config admin workflow. - ---- - -## Key Technical Decisions - -- **Use AIRI's audio extension route.** Add `POST /api/v1/audio/transcriptions` beside `/api/v1/audio/speech`. This matches the current route split in `apps/server/src/routes/openai/v1/index.ts` where only actual OpenAI public endpoints stay under `/api/v1/openai`. -- **Use Volcengine standard AUC first.** The standard recorded-file API documented at `https://www.volcengine.com/docs/6561/1354868` has submit and query endpoints intended for normal recorded-file recognition. The idle variant at `https://www.volcengine.com/docs/6561/1840838` may complete within a 24h window, so it is not a good first fit for the synchronous Hearing settings test and recording workflow. -- **Expose a multipart upload to AIRI clients, stage URL internally.** The official Volcengine AUC contract requires an audio URL, so the AIRI route should hide that provider-specific detail from clients and own temporary object storage. -- **Extend `LLM_ROUTER_CONFIG` with ASR.** Add an `asr` slice beside existing `llm` and `tts` models instead of creating a separate router config key. This reuses envelope key encryption, config cache invalidation, admin preview/apply semantics, model defaults, and router ownership. -- **Add `routeAsr` rather than bypassing the router.** ASR should become a first-class gateway operation, e.g. `transcription.generate`, with its own adapter contract and metrics. This keeps chat, TTS, and ASR diagnostics consistent. -- **Use server-side duration for STT billing.** Preflight and final billing should use trusted duration derived by the server from uploaded audio metadata and/or upstream `audio_info.duration`. Do not trust a client-supplied duration for billing. -- **Prefer new-console `X-Api-Key` credentials for v1.** The standard AUC docs support `X-Api-Key`. Start there, with resource id configured per model, and defer old-console `X-Api-App-Key` plus `X-Api-Access-Key` support unless operations needs it. -- **Keep synchronous polling bounded.** The client-facing route should poll standard AUC up to a configurable budget suitable for short recordings. Long-running batch/idle jobs need a later job API or callback workflow. - ---- - -## High-Level Technical Design - -```mermaid -flowchart TB - CLIENT[Stage Hearing module] --> PROVIDER[Official Transcription provider] - PROVIDER --> ROUTE[POST /api/v1/audio/transcriptions] - ROUTE --> GW[V1 gateway operation: transcription.generate] - GW --> PARSE[Parse multipart file and options] - PARSE --> STAGE[Stage audio to temporary public URL] - STAGE --> PREFLIGHT[STT Flux preflight by duration] - PREFLIGHT --> ROUTER[llmRouter.routeAsr] - ROUTER --> ADAPTER[Doubao ASR adapter] - ADAPTER --> SUBMIT[Volcengine AUC submit] - SUBMIT --> QUERY[Volcengine AUC query polling] - QUERY --> MAP[Map text, utterances, duration] - MAP --> BILL[sttMeter.accumulate] - BILL --> RESPONSE[OpenAI-shaped transcription response] - STAGE --> CLEANUP[Best-effort delete or TTL expiry] -``` - -```mermaid -sequenceDiagram - participant Client as Stage UI - participant Route as AIRI audio route - participant Staging as Audio staging - participant Router as LLM router ASR - participant Doubao as Volcengine AUC - participant Billing as STT FluxMeter - - Client->>Route: multipart file, model auto, response_format - Route->>Staging: upload temporary object - Staging-->>Route: short-lived audio URL - Route->>Billing: assertCanAfford(duration seconds) - Route->>Router: routeAsr(model, audio URL, options) - Router->>Doubao: submit task - Doubao-->>Router: task id in response headers - loop bounded poll - Router->>Doubao: query task - Doubao-->>Router: processing, queued, success, or error - end - Router-->>Route: text, utterances, duration - Route->>Billing: accumulate(duration seconds) - Route-->>Client: json or verbose_json transcription - Route->>Staging: best-effort cleanup -``` - ---- - -## Scope Boundaries - -- In scope: server-side official recorded-file transcription, standard Volcengine AUC, authenticated AIRI audio route, temporary audio staging, STT Flux billing, admin configuration, shared Stage UI provider wiring, and docs/tests for those paths. -- Out of scope: realtime/streaming ASR, idle 24h batch mode, user-provided Volcengine credentials, ASR provider fallback pools, diarization UI, long-running job status APIs, webhook/callback processing, and client direct calls to Volcengine. -- Deferred follow-ups: idle batch transcription with background jobs, streaming ASR provider, multi-provider ASR routing, old-console Volcengine credential mode, advanced ASR options UI, and transcript persistence/history. - ---- - -## Implementation Units - -### U1. ASR config schema and router contract - -- **Goal:** Make ASR a first-class router model kind beside LLM and TTS. -- **Requirements:** R5, R6, R7, R13 -- **Dependencies:** None -- **Files:** - - `apps/server/src/services/adapters/config-kv.ts` - - `apps/server/src/services/domain/llm-router/config-loader.ts` - - `apps/server/src/services/domain/llm-router/router.ts` - - `apps/server/src/services/domain/llm-router/types.ts` - - `apps/server/src/services/domain/llm-router/tests/router.test.ts` -- **Approach:** Extend `LLM_ROUTER_CONFIG` with an `asr.models` record. Add `asrProviderSchema` with a first provider value of `volcengine-asr`; use Doubao ASR as the user-facing/admin label. Add `DEFAULT_ASR_MODEL`, `FLUX_PER_MINUTE_STT`, and `STT_DEBT_TTL_SECONDS` ConfigKV entries. Add `routeAsr` and an `AsrRouteContext` that carries provider, model alias, upstream model/resource id, key entry id, timeout, and poll settings. -- **Execution note:** Update config validation tests before wiring the route, because schema drift here would break admin preview/apply and runtime loading. -- **Test scenarios:** - - `LLM_ROUTER_CONFIG` accepts `llm`, `tts`, and `asr` records with independent model ids. - - Missing `DEFAULT_ASR_MODEL` makes `model: "auto"` fail with `CONFIG_NOT_SET`. - - `routeAsr` resolves model aliases, decrypts the configured key, and passes provider-specific adapter params without exposing ciphertext. - - Router cache invalidation clears ASR config together with LLM/TTS config. -- **Verification:** Typecheck and router tests prove ASR config can be loaded, validated, cached, invalidated, and routed without changing chat/TTS behavior. - -### U2. Doubao ASR adapter for Volcengine standard AUC - -- **Goal:** Implement the upstream submit/query adapter for recorded-file recognition. -- **Requirements:** R3, R4, R7, R12 -- **Dependencies:** U1 -- **Files:** - - `apps/server/src/services/adapters/asr/types.ts` - - `apps/server/src/services/adapters/asr/volcengine.ts` - - `apps/server/src/services/adapters/asr/index.ts` - - `apps/server/src/services/adapters/asr/volcengine.test.ts` - - `apps/server/src/services/domain/llm-router/router.ts` -- **Approach:** Add an adapter contract that accepts a staged `audioUrl`, file format, optional language, response format, upstream model name, and adapter params. Implement standard AUC submit/query using `https://openspeech.bytedance.com/api/v3/auc/bigmodel/submit` and `/query` by default, with endpoint overrides for tests and operations. Read status from the documented response headers and map success, processing, queued, silent audio, invalid request, empty audio, bad format, oversize, and busy states into AIRI gateway errors. -- **Execution note:** Unit tests should mock `fetch` and exercise both header-level task status and JSON body mapping. Do not include real audio bytes in fixtures. -- **Test scenarios:** - - Submit sends `X-Api-Key`, resource id, request id, sequence header, model name, audio URL, and format. - - Query maps success to `{ text, utterances, durationMs }`. - - Processing/queued statuses continue polling until the poll budget ends. - - Silent audio returns an empty or explicit silent transcription response according to route policy. - - Invalid format, oversize, busy, and malformed upstream responses map to structured gateway errors with safe client messages and detailed server diagnostics. -- **Verification:** Adapter tests cover success, pending, timeout, and documented upstream error statuses without hitting Volcengine. - -### U3. Temporary audio staging boundary - -- **Goal:** Provide the short-lived public audio URL required by Volcengine without leaking raw upload handling into the route. -- **Requirements:** R8, R9, R11 -- **Dependencies:** None, but this unit has an ops choice before implementation. -- **Files:** - - `apps/server/src/services/domain/audio-staging/index.ts` - - `apps/server/src/services/domain/audio-staging/index.test.ts` - - `apps/server/src/app.ts` - - `apps/server/src/services/adapters/config-kv.ts` - - docs under `apps/server/docs/ai-context/` -- **Approach:** Add an `AudioStagingService` interface with `stage({ requestId, userId, file, contentType }) -> { url, objectKey, expiresAt }` and `cleanup(objectKey)`. Implement the first concrete backend only after choosing the deployment storage target. The repository does not currently show a server-side object storage/presigned URL boundary; `unstorage` appears only as a package dependency for `packages/stage-ui`, not as server upload infrastructure. -- **Implementation prerequisite:** Choose the temporary object storage backend and SDK/config shape before coding this unit. Candidate deployment-compatible backends are Volcengine TOS, S3-compatible object storage, or Cloudflare R2. The implementation must not pick a new storage dependency without user/ops confirmation. -- **Execution note:** Keep route code dependent only on the interface, so the selected storage backend is isolated to this unit. -- **Test scenarios:** - - Staging rejects unsupported content types and files over configured size limits before upstream spend. - - Staging returns a URL and expiry without logging raw bytes. - - Cleanup runs on success, upstream error, route error, and timeout, while TTL expiry remains the safety net. - - Object keys include request id or random entropy but do not expose user email, raw filename, or transcript content. -- **Verification:** Unit tests cover interface behavior with a fake backend; integration verification for the real backend is env-guarded and uses a tiny audio fixture. - -### U4. Audio transcription route and domain service - -- **Goal:** Add the authenticated AIRI route that accepts uploaded recordings and coordinates parsing, staging, routing, billing, tracing, and response mapping. -- **Requirements:** R1, R2, R4, R5, R6, R8, R9, R10, R11, R12 -- **Dependencies:** U1, U2, U3 -- **Files:** - - `apps/server/src/routes/openai/v1/index.ts` - - `apps/server/src/routes/openai/v1/gateway.ts` - - `apps/server/src/routes/openai/v1/types.ts` - - `apps/server/src/routes/openai/v1/operations/transcription-generation/index.ts` - - `apps/server/src/services/domain/openai-transcription/index.ts` - - `apps/server/src/routes/openai/v1/route.test.ts` - - `apps/server/src/app.ts` -- **Approach:** Mirror the TTS service shape in `apps/server/src/services/domain/openai-speech/index.ts`. Parse multipart form data, resolve `model: "auto"` through `DEFAULT_ASR_MODEL`, stage the audio, derive trusted duration metadata for preflight, call `llmRouter.routeAsr`, map upstream result to OpenAI-style `json` or `verbose_json`, bill successful seconds through `sttMeter`, and emit request logs/product events/metrics. Add a route-specific upload limit so the global 1 MB body limit in `app.ts` does not silently reject normal audio files. -- **Execution note:** If reliable duration extraction requires a new dependency, pause for the storage/duration library decision rather than trusting client-provided duration. -- **Test scenarios:** - - Authenticated multipart request with `model=auto` routes to `DEFAULT_ASR_MODEL` and returns `{ text }`. - - `verbose_json` request maps upstream utterances into segments and sets duration. - - Missing file, unsupported content type, oversized file, missing config, and insufficient balance return safe structured errors. - - Upstream timeout returns a gateway timeout without calling `sttMeter.accumulate`. - - Successful transcription calls `sttMeter.accumulate` with ceil seconds derived from trusted duration. - - Cleanup is attempted for success and failure paths. -- **Verification:** Route tests cover request parsing, config resolution, billing, timeout, and response shape through mocked staging/router services. - -### U5. STT billing, tracing, metrics, and request logs - -- **Goal:** Add ASR-specific observability and usage accounting consistent with chat and TTS. -- **Requirements:** R6, R9, R10, R11, R12 -- **Dependencies:** U4 -- **Files:** - - `apps/server/src/app.ts` - - `apps/server/src/services/domain/billing/flux-meter.ts` - - `apps/server/src/services/domain/llm-tracing/index.ts` - - `apps/server/src/services/domain/product-events.ts` - - `apps/server/src/utils/observability.ts` - - `apps/server/docs/ai-context/flux-meter.md` - - `apps/server/docs/ai-context/observability-conventions.md` -- **Approach:** Instantiate `sttMeter` with `service: "stt"`, `FLUX_PER_MINUTE_STT`, and `STT_DEBT_TTL_SECONDS`. Add tracing helpers such as `startTranscriptionGeneration` and OTel operation labels for `transcription.generate`. Record low-cardinality metrics by operation, provider, model, status, and duration bucket. Request/product logs can include request id, provider, model, file metadata, duration seconds, and status, but not raw audio or full transcript unless a deliberate transcript logging policy is added later. -- **Execution note:** Avoid adding transcript text to traces by default. A transcript can contain sensitive user speech and should be treated differently from bounded diagnostic snippets. -- **Test scenarios:** - - STT debt accumulation behaves like TTS dust billing but uses seconds/minutes instead of characters. - - Successful route logs include duration seconds and Flux consumed. - - Failed and timed-out route logs include provider/status/error code without raw audio or transcript text. - - Metrics projections exclude request id, user id, raw file names, and transcript text. -- **Verification:** Existing billing tests plus new STT service tests prove sub-Flux debt accounting and safe observability projections. - -### U6. Admin router config support for ASR - -- **Goal:** Let operators configure Doubao ASR without editing raw ConfigKV JSON by hand. -- **Requirements:** R7, R13 -- **Dependencies:** U1 -- **Files:** - - `apps/server/src/routes/admin/config/router/index.ts` - - `apps/server/src/services/domain/admin/router-config/index.ts` - - `apps/server/src/services/domain/admin/router-config/index.test.ts` - - `apps/ui-admin/src/modules/api.ts` - - `apps/ui-admin/src/modules/router-config-form.ts` - - `apps/ui-admin/src/modules/router-config-form.test.ts` - - `apps/ui-admin/src/components/llm-router/RouterSliceEditor.vue` -- **Approach:** Add an ASR slice kind `volcengine-asr` labeled as Doubao ASR in the admin UI. It compiles to `LLM_ROUTER_CONFIG.asr.models[modelName]`. Expose fields for model alias, upstream model name, resource id, endpoint overrides, API key, key entry id, timeout/poll settings, and default ASR model. Preserve the existing preview/apply/redaction flow. -- **Execution note:** Keep credential redaction server-owned. The UI should never render plaintext API keys after submit. -- **Test scenarios:** - - Admin request with one ASR slice creates encrypted key entries and an ASR model config. - - Preview redacts ASR keys and lists `LLM_ROUTER_CONFIG` plus `DEFAULT_ASR_MODEL` as touched keys. - - Reset/merge semantics preserve existing LLM/TTS config according to the current admin route behavior. - - UI builder exports/imports an ASR slice and validates missing API key, missing resource id, and invalid endpoints. -- **Verification:** Server and UI admin tests show ASR config round-trips through preview/apply without regressing existing LLM/TTS slices. - -### U7. Stage UI Official Transcription provider - -- **Goal:** Surface the server-side ASR route as the official provider for the Hearing module across web, Electron, and mobile shared Stage UI. -- **Requirements:** R1, R2, R3, R4 -- **Dependencies:** U4 -- **Files:** - - `packages/stage-ui/src/libs/providers/providers/official/index.ts` - - `packages/stage-ui/src/libs/providers/providers/official/shared.ts` - - `packages/stage-ui/src/composables/use-auth-provider-sync.ts` - - `packages/stage-ui/src/stores/providers.ts` - - `packages/stage-pages/src/pages/settings/providers/transcription/official-provider-transcription.vue` - - `packages/i18n/src/locales/*/settings.yaml` - - `packages/stage-ui/src/libs/providers/providers/official/index.test.ts` - - `packages/stage-ui/src/stores/modules/hearing.test.ts` -- **Approach:** Add `OFFICIAL_TRANSCRIPTION_PROVIDER_ID` and `providerOfficialTranscription` using `createOfficialAudioProvider()` plus `withCredentials()`. Ensure the provider's transcription method posts through the existing `@xsai/generate-transcription` flow to `/api/v1/audio/transcriptions`. Add it to auth provider sync for the `hearing` module and provide `auto` model discovery if the server exposes an ASR model catalog, or a static `auto` model if it does not. Add a simple provider settings page with the existing transcription playground and no credential fields. -- **Execution note:** The existing i18n files already contain official transcription title/description strings in at least English; verify all locales touched by provider metadata and fill missing keys centrally. -- **Test scenarios:** - - Signed-in auth sync activates official transcription for Hearing when no hearing provider is set. - - Official transcription provider injects bearer token and `x-airi-session-id`. - - A recording file uses the existing Hearing `transcribeForRecording` path and requests `model: "auto"`. - - `verbose_json` confidence filtering works when the server returns segments and produces the current unsupported warning when it does not. -- **Verification:** Stage UI unit tests cover provider config/auth and Hearing integration. Manual endpoint checks should include stage-web/shared Stage UI, Electron/Tamagotchi, and mobile-responsive settings screens. - -### U8. Documentation and verification matrix - -- **Goal:** Keep server docs and operational docs aligned with the new ASR surface. -- **Requirements:** R3, R5, R7, R8, R9, R10, R11, R12, R13 -- **Dependencies:** U1-U7 -- **Files:** - - `apps/server/docs/ai-context/architecture-overview.md` - - `apps/server/docs/ai-context/transport-and-routes.md` - - `apps/server/docs/ai-context/flux-meter.md` - - `apps/server/docs/ai-context/observability-conventions.md` - - `apps/server/docs/ai-context/verifications/doubao-asr.md` -- **Approach:** Update stale audio route documentation, record the new `/api/v1/audio/transcriptions` surface, document the temporary audio staging requirement, and add an env-guarded verification note for real Volcengine AUC tests. -- **Execution note:** Fix existing references that still mention `/api/v1/openai/audio/speech` while editing audio route docs. -- **Test scenarios:** None; this unit is documentation, but it should point to the concrete automated and env-guarded verification commands. -- **Verification:** Docs state the current route topology, config keys, billing units, privacy rules, and live-test prerequisites. - ---- - -## Acceptance Examples - -- AE1. Given a signed-in user with no hearing provider selected, when AIRI auth sync runs after login, the Hearing module selects Official Transcription with model `auto`. -- AE2. Given a short WAV recording, when the user runs the Hearing settings transcription test, the client posts multipart audio to `/api/v1/audio/transcriptions`, AIRI stages it, Doubao returns text, and the UI displays the transcript. -- AE3. Given the user enables confidence filtering, when Doubao returns utterances, AIRI maps them into `verbose_json` segments so low-confidence text can be filtered by the existing Hearing store. -- AE4. Given the user's balance cannot cover the server-derived audio duration, when they submit a recording, AIRI rejects the request before calling Volcengine. -- AE5. Given Volcengine stays queued/processing beyond the synchronous poll budget, when AIRI times out, the response is a safe gateway timeout, no successful STT billing is recorded, and the staged object is cleaned up or left to TTL expiry. -- AE6. Given an admin previews a Doubao ASR slice, when the server returns the redacted preview, the API key is not visible and touched keys include `LLM_ROUTER_CONFIG` and `DEFAULT_ASR_MODEL`. - ---- - -## System-Wide Impact - -| Surface | Impact | -|---|---| -| `apps/server` routes | Adds `POST /api/v1/audio/transcriptions` and operation `transcription.generate`; route-specific upload limit must avoid the current global 1 MB body limit problem. | -| `apps/server` router/config | Extends `LLM_ROUTER_CONFIG` with `asr`, adds ASR model defaults and adapter params. | -| Billing | Adds `sttMeter` using seconds/minutes and a sub-Flux debt ledger, parallel to TTS chars. | -| Observability | Adds ASR tracing, metrics, request logs, and product events without raw audio or transcript text by default. | -| Admin | Adds ASR slice support to router config preview/apply and UI builder modules. | -| Stage UI | Adds official transcription provider reused by stage-web, stage-tamagotchi/Electron, and stage-pocket/mobile through shared provider wiring. | -| Infrastructure | Requires temporary object storage with externally reachable URLs and a deletion/TTL policy. | - ---- - -## Risks And Dependencies - -- **Temporary storage is a real dependency.** The repo does not currently expose a server-side object storage or presigned URL service. Implementation needs an explicit backend choice before U3 can be completed. -- **Volcengine AUC is async.** A synchronous AIRI endpoint can time out even after upstream accepts the task. This is acceptable for short recordings but not for long batch transcription; batch needs a later job API. -- **Preflight billing depends on trusted duration.** Billing should not trust client duration. If existing server code cannot derive audio duration, implementation must choose a duration extraction strategy before upstream spend. -- **Provider cost can occur before success.** On timeout, AIRI may have spent upstream quota without a successful client response. Keep the poll budget and file duration limits conservative for v1. -- **Audio privacy matters.** Temporary objects must expire quickly, object keys must not expose user data, logs must not include raw audio or transcripts, and cleanup must run on all route exits. -- **Route body limits need care.** The current global server body limit is too small for normal audio uploads. The route needs explicit multipart handling and upload limits so failures are intentional and explainable. -- **Credential/resource id mismatch is easy.** Volcengine standard AUC supports different resource ids for model versions. Admin validation and docs should make `volc.seedasr.auc` versus `volc.bigasr.auc` explicit. - ---- - -## Open Questions - -- Which temporary object storage backend should AIRI use for ASR audio staging in production: Volcengine TOS, S3-compatible storage, Cloudflare R2, or an existing internal upload service not present in this repo? -- Should v1 support only new-console `X-Api-Key`, or must it also support old-console `X-Api-App-Key` plus `X-Api-Access-Key` credentials? -- What should the initial synchronous poll budget and maximum accepted recording duration be for stage settings tests and normal Hearing usage? -- Which server-side duration extraction strategy is acceptable for preflight billing if no existing duration parser is available? -- Should transcript text be excluded from all server-side logs/traces by default, or should there be an explicit debug-only redacted transcript policy? - ---- - -## Sources And Research - -- `apps/server/src/routes/openai/v1/index.ts` defines the current route split: `/api/v1/openai` for OpenAI chat and `/api/v1/audio` for AIRI audio extensions. -- `apps/server/src/routes/openai/v1/gateway.ts` currently lists `chat.completions` and `speech.generate`; ASR needs a new operation. -- `apps/server/src/services/domain/openai-speech/index.ts` is the closest domain-service pattern for routing, tracing, billing, request logs, product events, and metrics. -- `apps/server/src/services/adapters/config-kv.ts` owns `LLM_ROUTER_CONFIG`, `DEFAULT_TTS_MODEL`, and TTS billing config; ASR config belongs near these definitions. -- `apps/server/docs/ai-context/flux-meter.md` and `apps/server/docs/ai-context/billing-architecture.md` already describe STT as a sub-Flux service category. -- `packages/stage-ui/src/stores/modules/hearing.ts` uses `@xsai/generate-transcription` for file-based transcription and already handles `json`/`verbose_json`. -- `packages/stage-ui/src/libs/providers/providers/official/index.ts` and `shared.ts` show how official providers attach AIRI auth and use `/api/v1/audio`. -- `packages/stage-ui/src/composables/use-auth-provider-sync.ts` is the auth-driven provider activation point that needs Hearing support. -- Volcengine standard recorded-file ASR docs: `https://www.volcengine.com/docs/6561/1354868` -- Volcengine idle recorded-file ASR docs: `https://www.volcengine.com/docs/6561/1840838` diff --git a/docs/superpowers/plans/2026-07-01-official-provider-catalog.md b/docs/superpowers/plans/2026-07-01-official-provider-catalog.md deleted file mode 100644 index 6fec78ce4..000000000 --- a/docs/superpowers/plans/2026-07-01-official-provider-catalog.md +++ /dev/null @@ -1,168 +0,0 @@ -# Official Provider Catalog Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Build an official catalog layer that controls AIRI's official LLM aliases, TTS models, TTS voices, and ASR aliases for both public listing and gateway request authorization. - -**Architecture:** Add database-backed catalog tables and a focused `official-catalog` domain service. Public routes and gateway operations read the same service so UI visibility and handwritten request authorization cannot drift. Admin UI writes catalog state; `LLM_ROUTER_CONFIG` remains the source for real provider/key routing. - -**Tech Stack:** TypeScript, Hono, Drizzle ORM, Valibot, Vue 3 `