chore: move the server to an independent folder

This commit is contained in:
RainbowBird
2026-08-02 18:43:57 +08:00
parent 771ba3f8a7
commit 4ccde2c96e
293 changed files with 176 additions and 3020 deletions
+3
View File
@@ -1,4 +1,7 @@
**/node_modules
**/dist
**/.temp
**/.env
**/.env.*
!**/.env.example
target
-40
View File
@@ -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=""
-11
View File
@@ -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"
+1 -1
View File
@@ -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
+1 -1
View File
@@ -128,7 +128,7 @@ export function loadAnalyticsAdapter(loader: () => Promise<AnalyticsAdapter>): 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)
@@ -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,
+1 -1
View File
@@ -67,7 +67,7 @@ const signOutError = shallowRef<string | null>(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(
@@ -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-pushSSE / 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<Map<string,AiriCard>>('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 extensionlossless 兜底。
rawCard: jsonb('raw_card').notNull().$type<AiriCard>(),
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<typeof userCharacters>
export type NewUserCharacter = InferInsertModel<typeof userCharacters>
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<typeof userActiveCharacter>
```
### 5.2 Client Internal State
`useAiriCardStore` 现有 `cards: Map<string, AiriCard>` + `activeCardId: string` 不变。新增 internal
```ts
interface SyncOp { kind: 'upsert' | 'delete', clientId: string }
interface SyncState {
status: 'offline' | 'unauthenticated' | 'syncing' | 'synced' | 'error'
pendingOps: Map<string, SyncOp> // 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
- 否则 → 拒绝(罕见 caseclient 本地 clock 漂移)
## 7. 同步流程(α-min
### 7.1 First-Sync(登录后首次)
```
GET /user-characters
┌─────────────────────────────────┐
│ │
▼ │
client.cards (Map<clientId, AiriCard>) ──┐ │
├── 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 到 serverdebounced 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 issuesclient 把这张卡 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`
- CRUDPUT 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 范围。
-220
View File
@@ -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,每份文档关联一份可执行 artifactartifact 跑通就是 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.<kind>`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{...} <value>`
最小测试骨架:
```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 类必要的 secrettestcontainers 自身不需要 secret,只需要 docker daemonGitHub 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 文档的人工 narrativeroot 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,这部分实现成本较高,可以推到第三步的尾巴上单独立项。
@@ -1,113 +0,0 @@
---
date: 2026-05-30
topic: voice-pack
---
# Voice Pack 音色系统需求
## Summary
两件事一起做,号池负载均衡优先:
1. **TTS 号池负载均衡(最高优先)。** 一个上游 `app_id` 只有 10 并发,买并发贵,所以一个账号开 10 个 app10 个 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 metricstrace 已有 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 prosodySSML-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 已接 LangfuseOTel 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 需对齐。
@@ -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:<appid>`,且直接落在 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/keyExhaustedCountrouter 里已打点);池水位 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 在途计数<br/>pool:inflight:&lt;appid&gt;"]
QUERY --> RANK[按剩余并发余量排序 upstreams]
RANK --> PICK{有 upstream 还有余量?}
PICK -->|否| EXHAUST["fail-fast: mapUpstreamError<br/>带 triedKeys/triedUpstreams 上下文"]
PICK -->|是| ACQUIRE["Lua 原子: 检查容量 + INCR 占槽"]
ACQUIRE --> SEND[dispatchOneTtsUpstream → adapter.send]
SEND --> RESULT{结果}
RESULT -->|成功| RELEASE_OK["finally: DECR 释放槽"]
RESULT -->|429/失败| MARK["标记该 appid 短 TTL 已满 (熔断)<br/>+ DECR 释放槽"]
MARK --> PICK
RELEASE_OK --> DONE[返回音频]
```
### Voice Pack 身份 → 冻结快照 → 合成(source-of-truth
```mermaid
flowchart LR
TABLE["voice_packs 表 (货架)<br/>provider/model/voice/params/tier/enabled"] -->|admin CRUD| TABLE
TABLE -->|"列 enabled"| MARKET[最小绑定入口]
MARKET -->|绑定: 冻结解析后的值| CARD["角色卡 extensions.airi.modules.speech.voicePack<br/>(买走的快照, 不存表外键)"]
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, R3origin
- **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:<appId>`,未超上限则 `INCR` + `EXPIRE`(短 TTL 防泄漏) 返回成功,超上限返回失败;`release(appId)` = `DECR`(下限 0)。另出 `markSaturated(appId, ttl)`429 熔断用,set 一个 `pool:saturated:<appId>` 短 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` 用法)、:18TTL survives 注释思路)。
- **Test scenarios:**
- tryAcquire 在未达上限时 INCR 并返回成功;达上限返回失败且不 INCR。
- tryAcquire + release 配对后计数回到原值。
- 并发 N 个 tryAcquire 对同一 appid(用 Lua 原子性):成功数不超过 maxConcurrencycheck-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, R7origin
- **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-616fail-fast。key 级 `createKeyRotator` 不动(appid 在 upstream 级)。
- **Patterns to follow:** `router.ts:559`upstream 遍历)、:527-529finally 释放点)、:591-616exhaustion 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:** 上游回 429app_id 越界)时把该 appid 标记短 TTL「已满」,让主动路由一段时间内不再选它。
- **Requirements:** R6origin
- **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:** R5origin
- **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:<appid>`,仿 `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, R15origin
- **Dependencies:** 无(可与 Phase A 并行)
- **Files:**
- server 端 DB schema / migration(路径按现有迁移工具,见 Approach 待确认项)
- 对应 schema/migration 测试或快照
- **Approach:** 表列:`id``name``provider``model``voice_id``params`(jsonbpitch/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, R11origin
- **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, R11origin
- **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 返回 400schema 拒绝)。
- PATCH 编辑、POST/PATCH 禁用置 enabled=false。
- GET 列出(admin 看全部;enabled 过滤接口供客户端)。
- 未授权请求被 admin 鉴权拒绝(复用现有 admin guard)。
- **Verification:** 路由集成测试(Hono test client)断言状态码、鉴权、与域服务交互。
### Phase C — 角色卡绑定与合成
### U8. 角色卡 Voice Pack 快照契约
- **Goal:** 在角色卡 speech 扩展里定义冻结快照字段,写入即冻结、读取兼容缺失。
- **Requirements:** R12, R15origin
- **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, R14origin
- **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`(共享页 + `<route lang="yaml"> 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, R13origin
- **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-338generateSSML 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-widedashboard 必须 `avg()``sum()`;新增 gauge 沿用此约束并在注释写明。
- **角色卡 schema 变更(U8)进整卡 LWW 云同步**,需与 cloud-sync 设计对齐,避免破坏同步。
- **计费**:本轮 tier 不碰实际扣费;四档 meter 拆分是独立 billing 线,勿在本计划顺手改 meter。
## Scope Boundaries
### 本计划范围内
- Phase A 号池负载均衡(U1-U4)、Phase B Voice Pack 表与 admin APIU5-U7)、Phase C 绑定与合成(U8-U10)。
### Deferred for laterorigin 已列)
- 参考音频整块(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` — ttsMeterRedis)、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 云同步对齐。
@@ -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.
@@ -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<Record<string, unknown>>` 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.
@@ -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`
@@ -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 `<script setup>`, Pinia-light admin API module, Vitest, pnpm workspace filters.
## Global Constraints
- Do not create commits during implementation.
- Preserve existing dirty user changes.
- Use `@moeru/std` / existing error helpers for structured errors.
- Public listing and request execution must both enforce catalog `enabled` state.
- Existing router config and encrypted keys remain separate from catalog.
- Existing configured LLM/TTS/ASR models synced from runtime config default to enabled.
- TTS voices synced from providers default to disabled.
- Voice preview generation is out of scope for v1; store only provider/manual preview URLs.
---
### Task 1: Catalog Schema and Service
**Files:**
- Create: `apps/server/src/schemas/official-catalog.ts`
- Modify: `apps/server/src/schemas/index.ts`
- Create: `apps/server/src/services/domain/official-catalog/index.ts`
- Test: `apps/server/src/services/domain/official-catalog/index.test.ts`
- Create: `apps/server/drizzle/0016_official_provider_catalog.sql`
**Interfaces:**
- Produces: `createOfficialCatalogService(db, deps)` with methods:
- `syncAliasesFromRouterConfig(input: { surface: 'llm' | 'asr', modelIds: string[] }): Promise<OfficialProviderAlias[]>`
- `syncTtsModelsFromRouterConfig(input: { models: Record<string, { provider: string }> }): Promise<OfficialTtsModel[]>`
- `syncTtsVoices(input: { routerModelId: string, voices: OfficialTtsVoiceSyncInput[] }): Promise<OfficialTtsVoice[]>`
- `listEnabledTtsModels(): Promise<OfficialTtsModel[]>`
- `listEnabledTtsVoices(routerModelId: string): Promise<OfficialTtsVoice[]>`
- `resolveEnabledAlias(surface, aliasId): Promise<OfficialProviderAliasWithRoutes>`
- `assertTtsModelEnabled(routerModelId): Promise<OfficialTtsModel>`
- `assertTtsVoiceEnabled(routerModelId, providerVoiceId): Promise<OfficialTtsVoice>`
- [ ] Write service tests first for sync defaults, repeated sync preservation, enabled listing, alias lookup, and TTS voice gate errors.
- [ ] Add schema tables for aliases, alias routes, TTS models, and TTS voices.
- [ ] Implement service with explicit methods instead of leaking Drizzle query details into routes.
- [ ] Add manual migration SQL matching the schema.
- [ ] Run `pnpm exec vitest run apps/server/src/services/domain/official-catalog/index.test.ts`.
### Task 2: Public TTS Listing and Request Gate
**Files:**
- Modify: `apps/server/src/routes/openai/v1/types.ts`
- Modify: `apps/server/src/routes/openai/v1/operations/speech-catalog/index.ts`
- Modify: `apps/server/src/routes/openai/v1/operations/speech-generation/index.ts`
- Modify: `apps/server/src/app.ts`
- Test: `apps/server/src/routes/openai/v1/route.test.ts`
**Interfaces:**
- Consumes: `OfficialCatalogService`
- Produces: public TTS model and voice lists filtered by enabled catalog rows.
- [ ] Add failing route tests: disabled model hidden, disabled voice hidden, disabled model rejected in speech generation, disabled voice rejected in speech generation.
- [ ] Inject `officialCatalogService` into `V1RouteDeps`.
- [ ] In `listSpeechModels`, sync runtime TTS models then return enabled catalog rows in display order.
- [ ] In `listVoices`, fetch provider voices for sync, sync them as disabled-by-default, then return only enabled catalog voices while preserving `recommended`.
- [ ] In `speechGeneration`, validate model and voice against catalog after resolving `auto`.
- [ ] Run focused server route tests.
### Task 3: LLM Alias Gate
**Files:**
- Modify: `apps/server/src/routes/openai/v1/operations/chat-completions/index.ts`
- Test: `apps/server/src/routes/openai/v1/route.test.ts`
**Interfaces:**
- Consumes: `officialCatalogService.resolveEnabledAlias('llm', aliasId)`
- Produces: chat requests use client-visible alias and route to enabled alias primary target.
- [ ] Add failing tests: `auto` alias disabled rejects; missing alias rejects; enabled `auto` resolves to a real router model.
- [ ] Keep v1 client-visible model as alias (`auto` by default).
- [ ] Resolve alias before billing telemetry uses the real router model, while product analytics may retain alias in metadata.
- [ ] Run focused route tests.
### Task 4: Admin API
**Files:**
- Create: `apps/server/src/routes/admin/official-catalog/index.ts`
- Create: `apps/server/src/routes/admin/official-catalog/route.test.ts`
- Modify: `apps/server/src/app.ts`
**Interfaces:**
- Consumes: `OfficialCatalogService`, `LlmRouterService`, `ConfigKVService`
- Produces:
- `GET /api/admin/official-catalog/aliases`
- `POST /api/admin/official-catalog/aliases/sync`
- `PATCH /api/admin/official-catalog/aliases/:id`
- `PATCH /api/admin/official-catalog/aliases/:id/routes`
- `GET /api/admin/official-catalog/tts/models`
- `POST /api/admin/official-catalog/tts/models/sync`
- `PATCH /api/admin/official-catalog/tts/models/:id`
- `GET /api/admin/official-catalog/tts/models/:id/voices`
- `POST /api/admin/official-catalog/tts/models/:id/voices/sync`
- `PATCH /api/admin/official-catalog/tts/voices/:id`
- [ ] Add auth/admin guard tests following existing voice-pack route tests.
- [ ] Implement Valibot schemas for patch bodies.
- [ ] Implement sync endpoints from runtime config and provider voice catalog.
- [ ] Mount route under `/api/admin/official-catalog`.
- [ ] Run admin route tests.
### Task 5: Admin API Client and Forms
**Files:**
- Modify: `apps/ui-admin/src/modules/api.ts`
- Create: `apps/ui-admin/src/pages/ProvidersPage.vue`
- Create: `apps/ui-admin/src/pages/TtsCatalogPage.vue`
- Modify: `apps/ui-admin/src/main.ts`
- Modify: `apps/ui-admin/src/App.vue`
- Test: `apps/ui-admin/src/pages/ProvidersPage.test.ts`
- Test: `apps/ui-admin/src/pages/TtsCatalogPage.test.ts`
**Interfaces:**
- Consumes: admin official catalog endpoints.
- Produces: operator can sync aliases/models/voices, toggle enabled state, edit names/order/preview URLs.
- [ ] Add TypeScript interfaces and admin API methods.
- [ ] Add Providers page for v1 `auto` alias and route pool visibility.
- [ ] Add TTS page for model list and selected model voices.
- [ ] Add sidebar nav entries: Providers and TTS; keep Voice Packs separate.
- [ ] Add focused Vue tests around sync and toggle calls.
- [ ] Run `pnpm exec vitest run apps/ui-admin/src/pages/ProvidersPage.test.ts apps/ui-admin/src/pages/TtsCatalogPage.test.ts`.
### Task 6: Voice Pack Candidate Filtering
**Files:**
- Modify: `apps/ui-admin/src/pages/VoicePackFormPage.vue`
- Test: `apps/ui-admin/src/pages/VoicePackFormPage.test.ts`
**Interfaces:**
- Consumes: enabled official TTS models and voices from public/admin catalog.
- Produces: Voice Pack admin cannot create new packs from disabled catalog rows.
- [ ] Add tests showing disabled voices are not offered as candidates.
- [ ] Update catalog loading to use filtered public endpoints or admin enabled list.
- [ ] Keep editing existing packs resilient if a historical voice was later disabled.
- [ ] Run focused Voice Pack tests.
### Task 7: Verification Sweep
**Files:**
- All touched files.
- [ ] Run server focused tests:
`pnpm exec vitest run apps/server/src/services/domain/official-catalog/index.test.ts apps/server/src/routes/admin/official-catalog/route.test.ts apps/server/src/routes/openai/v1/route.test.ts`
- [ ] Run admin focused tests:
`pnpm exec vitest run apps/ui-admin/src/pages/ProvidersPage.test.ts apps/ui-admin/src/pages/TtsCatalogPage.test.ts apps/ui-admin/src/pages/VoicePackFormPage.test.ts`
- [ ] Run typechecks:
`pnpm -F @proj-airi/server typecheck`
`pnpm -F @proj-airi/ui-admin typecheck`
- [ ] Run targeted eslint on changed files.
- [ ] Report any unrelated pre-existing failures separately.
## Self-Review
- Spec coverage: LLM alias, TTS catalog, ASR-compatible alias structure, admin pages, strict request gate, sync defaults, and v2 preview generation deferral are represented.
- Placeholder scan: no TBD/TODO implementation placeholders are required by this plan; task details use exact paths and behavior.
- Type consistency: service names and route dependencies are consistent across tasks.
@@ -1,322 +0,0 @@
# TTS Voice Pack Model Option 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:** Add a local `Voice Pack` model option under the official TTS provider; when selected, the voice picker shows server-enabled Voice Packs instead of raw provider voices.
**Architecture:** Keep provider selection unchanged. Treat `Voice Pack` as a local UI-only model id in the Speech settings page, and resolve it to the selected pack snapshot only when binding and building speech requests. Existing real TTS model + raw voice behavior stays unchanged.
**Tech Stack:** Vue 3 Composition API, Pinia stores, Stage UI components, TypeScript, Vitest, UnoCSS.
## Global Constraints
- Do not create implementation commits.
- Do not add aliases to raw TTS voices.
- Do not filter raw provider catalogs through a new allowlist.
- Do not make Voice Pack the only official TTS path.
- Do not hardcode default male or female voices in the client.
- Do not change Voice Pack billing semantics.
- Do not change server-side `LLM_ROUTER_CONFIG` model aliases.
- Keep Voice Pack provider/model/voice ids hidden from the user-facing UI.
---
## File Structure
- Modify `packages/stage-pages/src/pages/settings/modules/speech.vue`
- Owns the user-facing Speech settings flow.
- Adds a local synthetic `Voice Pack` model option.
- Projects enabled Voice Packs into voice-card options when the synthetic option is selected.
- Removes the old separate Voice Pack section from the left column.
- Modify `packages/stage-ui/src/stores/modules/speech.ts`
- Exports the synthetic `VOICE_PACK_MODEL_ID`.
- Prevents the official raw-voice loader from requesting `/audio/voices?model=voice-pack`.
- Keeps the synthetic model selected when the official provider validates model state.
- Modify `packages/stage-ui/src/stores/modules/speech.test.ts`
- Adds regression coverage for existing Voice Pack request resolution.
- Confirms pack metadata stays attached to speech requests.
### Task 1: Integrate Voice Pack Into Model And Voice Selection
**Files:**
- Modify: `packages/stage-pages/src/pages/settings/modules/speech.vue`
- Modify: `packages/stage-ui/src/stores/modules/speech.ts`
**Interfaces:**
- Consumes: `useVoicePacksStore().packs`, `useAiriCardStore().bindVoicePackToActiveCard(pack)`, `activeSpeechModel`, `activeSpeechVoiceId`, `availableVoices`.
- Produces: exported `VOICE_PACK_MODEL_ID`, local `displayedProviderModels`, `isVoicePackModelSelected`, and `displayedVoiceOptions` values used by the template.
- [ ] **Step 1: Add the synthetic model id and display helpers**
Add to `packages/stage-ui/src/stores/modules/speech.ts` near the Voice Pack parameter constants:
```ts
export const VOICE_PACK_MODEL_ID = 'voice-pack'
```
Import it in `packages/stage-pages/src/pages/settings/modules/speech.vue`.
Add near the other page-level constants:
```ts
const VOICE_PACK_MODEL_OPTION = {
id: VOICE_PACK_MODEL_ID,
name: 'Voice Pack',
description: 'Server-curated voices',
}
```
Add computed helpers:
```ts
const isOfficialSpeechProvider = computed(() => activeSpeechProvider.value === OFFICIAL_SPEECH_PROVIDER_ID)
const isVoicePackModelSelected = computed(() => isOfficialSpeechProvider.value && activeSpeechModel.value === VOICE_PACK_MODEL_ID)
const displayedProviderModels = computed(() => {
if (!isOfficialSpeechProvider.value)
return providerModels.value
return [VOICE_PACK_MODEL_OPTION, ...providerModels.value]
})
```
- [ ] **Step 2: Replace the old separate Voice Pack section**
Delete the `<template v-if="shouldShowVoicePackSection">...</template>` block. Keep `voicePacksStore.load()` on mount.
- [ ] **Step 3: Project Voice Packs into voice-card options**
Add helpers:
```ts
function voicePackVoiceId(packId: string) {
return `voice-pack:${packId}`
}
function packIdFromVoicePackVoiceId(voiceId: string) {
return voiceId.startsWith('voice-pack:') ? voiceId.slice('voice-pack:'.length) : null
}
const selectedVoicePack = computed(() => {
const packId = packIdFromVoicePackVoiceId(activeSpeechVoiceId.value)
if (packId)
return voicePacks.value.find(pack => pack.id === packId) ?? null
const snapshot = activeCard.value?.extensions.airi.modules.speech.voicePack
return snapshot ? voicePacks.value.find(pack => pack.id === snapshot.packId) ?? null : null
})
const displayedVoiceOptions = computed(() => {
if (isVoicePackModelSelected.value) {
return voicePacks.value.map(pack => ({
id: voicePackVoiceId(pack.id),
name: pack.name,
description: pack.description ?? undefined,
previewURL: '',
customizable: false,
}))
}
return (availableVoices.value[activeSpeechProvider.value] ?? [])
.filter((voice) => {
if (!activeSpeechModel.value)
return true
return !voice.compatibleModels || voice.compatibleModels.includes(activeSpeechModel.value)
})
.map(voice => ({
id: voice.id,
name: voice.name,
description: voice.description,
previewURL: voice.previewURL,
customizable: false,
}))
})
```
- [ ] **Step 4: Handle Voice Pack voice selection**
Update `selectSpeechVoice` so selecting a projected Voice Pack binds the server pack and tracks the underlying pack:
```ts
async function selectSpeechVoice(voiceId: string | undefined) {
if (!voiceId)
return
const packId = packIdFromVoicePackVoiceId(voiceId)
if (isVoicePackModelSelected.value && packId) {
const pack = voicePacks.value.find(item => item.id === packId)
if (!pack)
return
await bindVoicePack(pack)
activeSpeechVoiceId.value = voiceId
activeSpeechVoice.value = {
id: voiceId,
name: pack.name,
description: pack.description ?? pack.name,
previewURL: '',
languages: [{ code: 'en', title: 'English' }],
provider: activeSpeechProvider.value,
gender: 'neutral',
}
return
}
trackVoiceSelected({
tts_provider_id: activeSpeechProvider.value || 'unknown',
tts_model_id: currentTtsModelId(),
...voiceAnalyticsPayload(voiceId),
source: 'settings',
})
}
```
- [ ] **Step 5: Keep model switching from sending `voice-pack` to `/audio/voices`**
Update the active model watcher:
```ts
watch(activeSpeechModel, async (model) => {
if (!activeSpeechProvider.value)
return
activeSpeechVoiceId.value = ''
activeSpeechVoice.value = undefined
if (model === VOICE_PACK_MODEL_ID)
return
await speechStore.loadVoicesForProvider(activeSpeechProvider.value, model || undefined)
})
```
- [ ] **Step 6: Use displayed model and voice lists in the template**
Change `providerModels` usages in the model picker to `displayedProviderModels`.
Change the voice picker condition to:
```vue
v-else-if="activeSpeechProvider !== 'openai-compatible-audio-speech' && displayedVoiceOptions.length > 0"
```
Change the voice picker `:voices` binding to:
```vue
:voices="displayedVoiceOptions"
```
Change the manual voice input condition so it does not show for the Voice Pack model:
```vue
v-if="!isVoicePackModelSelected && (activeSpeechProvider === 'openai-compatible-audio-speech' || !availableVoices[activeSpeechProvider] || availableVoices[activeSpeechProvider].length === 0)"
```
- [ ] **Step 7: Verify the page compiles**
Run:
```bash
pnpm -F @proj-airi/stage-pages typecheck
```
Expected: PASS, or unrelated pre-existing package errors documented with exact output.
### Task 2: Preserve Voice Pack Speech Request Semantics
**Files:**
- Modify: `packages/stage-pages/src/pages/settings/modules/speech.vue`
- Modify: `packages/stage-ui/src/stores/modules/speech.test.ts`
**Interfaces:**
- Consumes: `bindVoicePackToActiveCard`, `voicePackForSpeechProvider`, `resolveVoicePackSpeechInput`.
- Produces: regression proof that the UI display change does not break server validation metadata.
- [ ] **Step 1: Keep preview generation resolving snapshots**
Ensure `generateTestSpeech()` keeps this behavior:
```ts
const voicePack = boundVoicePack.value
if (voicePack) {
model = voicePack.ttsModelId
if (!voice || voice.id !== voicePack.voiceId)
voice = createVoicePackVoice(voicePack)
}
```
- [ ] **Step 2: Keep request metadata test coverage**
Confirm or add a test in `packages/stage-ui/src/stores/modules/speech.test.ts`:
```ts
it('passes Voice Pack snapshot billing metadata through adapter options', () => {
const speechStore = useSpeechStore()
const result = speechStore.resolveVoicePackSpeechInput({
text: 'hello',
voice: {
id: 'voice-a',
name: 'Voice A',
provider: OFFICIAL_SPEECH_PROVIDER_ID,
languages: [{ code: 'en-US', title: 'English' }],
},
voicePack: {
packId: 'vp-1',
costMultiplier: 1.5,
},
})
expect(result.providerConfig.extraBody).toEqual({
voice_pack: {
pack_id: 'vp-1',
cost_multiplier: 1.5,
},
})
})
```
- [ ] **Step 3: Run targeted tests**
Run:
```bash
pnpm exec vitest run packages/stage-ui/src/stores/modules/speech.test.ts
```
Expected: PASS.
### Task 3: Final Verification
**Files:**
- Check: `packages/stage-pages/src/pages/settings/modules/speech.vue`
- Check: `packages/stage-ui/src/stores/modules/speech.test.ts`
**Interfaces:**
- Consumes: all previous tasks.
- Produces: verified implementation ready for user review.
- [ ] **Step 1: Run lint**
Run:
```bash
pnpm lint
```
Expected: PASS, or unrelated pre-existing errors documented with exact output.
- [ ] **Step 2: Run typecheck**
Run:
```bash
pnpm type-check
```
Expected: PASS, or unrelated pre-existing errors documented with exact output.
- [ ] **Step 3: Inspect final diff**
Run:
```bash
git diff -- packages/stage-pages/src/pages/settings/modules/speech.vue packages/stage-ui/src/stores/modules/speech.test.ts
```
Expected: diff only contains the Voice Pack model-option integration and related tests.
@@ -1,204 +0,0 @@
# Auth UI New Backend Origin 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:** Make the production Auth UI honor OIDC redirects from `https://airi-server-next.up.railway.app` without broadening trust to arbitrary Railway origins.
**Architecture:** Keep the existing exact-origin trust model in `server-auth-context.ts`. Add one exact production origin and prove the redirect context resolves to it while existing untrusted-origin tests remain green.
**Tech Stack:** TypeScript, Vue/Vite, Vitest, pnpm, GitHub Actions, Cloudflare Pages
---
### Task 1: Trust the new Go backend origin
**Files:**
- Modify: `apps/ui-server-auth/src/modules/server-auth-context.test.ts:18`
- Modify: `apps/ui-server-auth/src/modules/server-auth-context.ts:17`
- [ ] **Step 1: Write the failing regression test**
Add this case after the existing trusted server-dev case:
```ts
it('uses the trusted new Go backend origin carried by standalone server redirects', () => {
const currentUrl = 'https://auth.airi.build/ui/sign-in?api_server_url=https%3A%2F%2Fairi-server-next.up.railway.app&client_id=airi-stage-pocket'
expect(resolveStandaloneServerAuthContext(
currentUrl,
'https://api.airi.build',
)).toEqual({
apiServerUrl: 'https://airi-server-next.up.railway.app',
currentUrl,
})
})
```
- [ ] **Step 2: Run the focused test and verify RED**
Run:
```bash
pnpm -F @proj-airi/ui-server-auth exec vitest run src/modules/server-auth-context.test.ts
```
Expected: one assertion fails because `resolveStandaloneServerAuthContext` returns `null` for the new backend origin.
- [ ] **Step 3: Add the exact trusted production origin**
Update the allowlist to:
```ts
const TRUSTED_STANDALONE_API_SERVER_ORIGINS = [
'https://api.airi.build',
'https://airi-server-next.up.railway.app',
'https://airi-server-dev.up.railway.app',
]
```
- [ ] **Step 4: Run the focused test and verify GREEN**
Run:
```bash
pnpm -F @proj-airi/ui-server-auth exec vitest run src/modules/server-auth-context.test.ts
```
Expected: the file passes with six tests, including the existing crafted-origin rejection.
- [ ] **Step 5: Commit the behavior change**
```bash
git add apps/ui-server-auth/src/modules/server-auth-context.ts apps/ui-server-auth/src/modules/server-auth-context.test.ts
git commit -m "fix(ui-server-auth): trust new Go backend origin"
```
### Task 2: Verify the Auth UI package
**Files:**
- Verify: `apps/ui-server-auth/**`
- [ ] **Step 1: Run the complete Auth UI test suite**
```bash
pnpm -F @proj-airi/ui-server-auth exec vitest run
```
Expected: all Auth UI test files pass.
- [ ] **Step 2: Run type checking**
```bash
pnpm -F @proj-airi/ui-server-auth typecheck
```
Expected: exit code 0 with no TypeScript errors.
- [ ] **Step 3: Run the production build**
```bash
VITE_SERVER_URL=https://api.airi.build pnpm -F @proj-airi/ui-server-auth build
```
Expected: Vite exits with code 0 and writes `apps/ui-server-auth/dist`.
- [ ] **Step 4: Inspect the built bundle**
```bash
rg -l 'airi-server-next\.up\.railway\.app' apps/ui-server-auth/dist/assets/*.js
```
Expected: at least one emitted JavaScript asset contains the exact new backend origin.
### Task 3: Publish, deploy, and validate production
**Files:**
- Verify: `.github/workflows/deploy-cloudflare-auth-ui.yml`
- [ ] **Step 1: Push the feature branch and open a pull request**
```bash
git push -u origin codex/auth-ui-new-backend
PR_URL="$(gh pr create --repo moeru-ai/airi --base main --head Neko-233:codex/auth-ui-new-backend --title "fix(ui-server-auth): trust new Go backend origin" --body $'## Summary\n- trust the new Railway Go backend in the standalone Auth UI\n- preserve exact-origin validation for untrusted redirects\n\n## Test plan\n- focused and complete Auth UI Vitest suites\n- Auth UI typecheck and production build\n- emitted bundle origin check')"
printf '%s' "$PR_URL" > /tmp/auth-ui-new-backend-pr-url
printf '%s\n' "$PR_URL"
```
Expected: GitHub returns a pull request URL targeting `moeru-ai/airi:main`.
- [ ] **Step 2: Merge after required checks pass**
```bash
PR_URL="$(cat /tmp/auth-ui-new-backend-pr-url)"
gh pr checks "$PR_URL" --watch
gh pr merge "$PR_URL" --squash --delete-branch
```
Expected: the pull request is merged into `main`.
- [ ] **Step 3: Wait for the production Auth UI deployment**
```bash
RUN_ID="$(gh run list --repo moeru-ai/airi --workflow deploy-cloudflare-auth-ui.yml --branch main --limit 1 --json databaseId --jq '.[0].databaseId')"
gh run watch --repo moeru-ai/airi "$RUN_ID"
```
Expected: `Cloudflare Pages (Auth UI)` completes successfully for the merge commit.
- [ ] **Step 4: Validate the deployed bundle and login boundary**
Fetch the current Auth UI HTML, resolve its hashed JavaScript assets, and verify one asset contains the exact new backend origin:
```bash
LIVE_DIR="$(mktemp -d /tmp/airi-auth-ui-live.XXXXXX)"
printf '%s' "$LIVE_DIR" > /tmp/airi-auth-ui-live-dir
mkdir -p "$LIVE_DIR/assets"
curl -fsSL https://auth.airi.build/ui/sign-in -o "$LIVE_DIR/index.html"
rg -o 'src="/assets/[^"]+\.js' "$LIVE_DIR/index.html" | sed 's/^src="//' | while read -r asset; do
curl -fsSL "https://auth.airi.build${asset}" -o "$LIVE_DIR/assets/$(basename "$asset")"
done
rg -l 'https://airi-server-next\.up\.railway\.app' "$LIVE_DIR"/assets/*.js
```
Repeat the new backend OIDC authorize redirect and confirm the final Auth UI location carries the new `api_server_url`:
```bash
LIVE_DIR="$(cat /tmp/airi-auth-ui-live-dir)"
curl -sS -L -o /dev/null -D "$LIVE_DIR/redirects.headers" -G 'https://airi-server-next.up.railway.app/api/auth/oauth2/authorize' \
--data-urlencode 'response_type=code' \
--data-urlencode 'client_id=airi-stage-pocket' \
--data-urlencode 'redirect_uri=ai.moeru.airi-pocket://links/auth/callback' \
--data-urlencode 'scope=openid profile email offline_access' \
--data-urlencode 'state=production-validation' \
--data-urlencode 'code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM' \
--data-urlencode 'code_challenge_method=S256' \
--data-urlencode 'resource=https://airi-server-next.up.railway.app'
rg -n 'location: https://auth\.airi\.build/.*api_server_url=https%3A%2F%2Fairi-server-next\.up\.railway\.app' -i "$LIVE_DIR/redirects.headers"
```
Send an OPTIONS preflight and a diagnostic POST to:
```text
https://airi-server-next.up.railway.app/api/auth/check-email
```
Run:
```bash
LIVE_DIR="$(cat /tmp/airi-auth-ui-live-dir)"
curl -sS -o /dev/null -D "$LIVE_DIR/preflight.headers" \
-X OPTIONS 'https://airi-server-next.up.railway.app/api/auth/check-email' \
-H 'Origin: https://auth.airi.build' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type'
rg -n 'HTTP/.* 204|access-control-allow-origin: https://auth\.airi\.build' -i "$LIVE_DIR/preflight.headers"
curl -sS -o "$LIVE_DIR/check-email.json" -w '%{http_code}\n' \
-X POST 'https://airi-server-next.up.railway.app/api/auth/check-email' \
-H 'Origin: https://auth.airi.build' \
-H 'Content-Type: application/json' \
--data '{"email":"codex-production-validation@example.com"}'
jq -e '.exists == false and .hasPassword == false' "$LIVE_DIR/check-email.json"
```
Expected: preflight returns `204` with `access-control-allow-origin: https://auth.airi.build`, and the diagnostic lookup returns `200` with a valid response body.
@@ -1,303 +0,0 @@
<title>AIRI Official Provider Catalog PRD</title>
<h1>背景</h1>
<p>AIRI 现在的官方 LLM / TTS / ASR 能力主要由运行时路由配置驱动。LLM 和 ASR 对客户端基本表现为 <code>auto</code>TTS 模型和声线则来自 <code>LLM_ROUTER_CONFIG</code>、provider catalog 和 <code>DEFAULT_TTS_VOICES</code> 推荐配置。</p>
<p>这套机制能跑通请求,但缺少一个产品层的官方能力目录。管理员不能统一控制主站展示哪些官方模型、声线是否启用、展示顺序,也不能阻止用户通过手写请求绕过前端 UI 直接调用底层 provider/model/voice。</p>
<callout emoji="✅" background-color="light-green" border-color="green">
<p><b>核心结论:</b>新增独立的 Official Provider Catalog。主站展示和 gateway 请求都必须经过 catalog 白名单。用户选择的是产品能力 alias 或 Voice Pack,不是底层供应商细节。</p>
</callout>
<h1>目标</h1>
<ul>
<li>在 admin 面板新增官方 Provider / TTS 管理能力。</li>
<li>让主站官方 LLM、TTS、ASR 展示内容由 catalog 控制,而不是直接暴露底层 router 配置。</li>
<li>支持 LLM alias,例如 v1 只开放 <code>auto</code>,后续可扩展 <code>fast</code><code>reasoning</code><code>deepseek</code></li>
<li>支持 TTS model 和 voice 的启用、禁用、排序、展示名、语言、标签和预览 URL 管理。</li>
<li>支持一键拉取 TTS provider 声线;新拉取声线默认禁用,需要管理员手动启用。</li>
<li>请求进入 gateway 前二次校验 catalog。禁用或不存在的 alias/model/voice 必须报错,不能直通底层 provider。</li>
</ul>
<h1>非目标</h1>
<ul>
<li>一期不做声线预览音频自动生成。</li>
<li>一期不新增对象存储或 CDN adapter。</li>
<li>一期不做人群灰度、租户级配置或 A/B 实验。</li>
<li>一期不重做现有 LLM Router 的密钥加密、fallback 真实执行逻辑。</li>
<li>一期不把 Voice Pack 替换成 raw voice 选择;Voice Pack 仍是面向用户的 TTS 产品能力抽象。</li>
</ul>
<h1>产品原则</h1>
<table>
<thead>
<tr>
<th background-color="light-gray">原则</th>
<th background-color="light-gray">说明</th>
</tr>
</thead>
<tbody>
<tr>
<td>用户选产品能力</td>
<td>客户端看到 <code>auto</code>、未来的 <code>fast</code> / <code>reasoning</code>、Voice Pack,而不是真实 provider/model/key。</td>
</tr>
<tr>
<td>Catalog 是展示白名单</td>
<td>主站只展示 catalog 中 enabled 的 alias/model/voice。</td>
</tr>
<tr>
<td>Catalog 也是请求白名单</td>
<td>用户绕过 UI 手写 disabled 或不存在的 model/voice,请求必须失败。</td>
</tr>
<tr>
<td>路由配置不等于产品目录</td>
<td><code>LLM_ROUTER_CONFIG</code> 负责真实路由和 keyOfficial Catalog 负责产品可见性、排序和 alias。</td>
</tr>
</tbody>
</table>
<h1>用户角色</h1>
<ul>
<li><b>管理员:</b>配置官方能力、启停模型和声线、同步 provider 声线、调整展示顺序。</li>
<li><b>普通用户:</b>在主站选择可用的官方能力,不需要理解 provider、model、voice 的真实路由。</li>
<li><b>系统:</b>在展示和请求执行前读取 catalog,保证禁用项不可见且不可调用。</li>
</ul>
<h1>一期范围</h1>
<h2>LLM Alias</h2>
<p>v1 只开放一个默认 alias<code>auto</code>。表和 API 按多 alias 设计,后续可以扩展更多产品能力。</p>
<ul>
<li>alias 有 <code>enabled</code> 状态。禁用后客户端不展示,请求也不能使用。</li>
<li>alias 可配置展示名和排序。</li>
<li>alias 下配置 primary pool 和 fallback pool。</li>
<li>alias 支持 fallback 开关和负载均衡开关。</li>
<li>真实候选 provider/model 从 <code>LLM_ROUTER_CONFIG.llm.models</code> 同步进管理候选池。</li>
</ul>
<h2>TTS Model 和 Voice</h2>
<ul>
<li>TTS 单独开 admin 页面,和 Voice Packs 并列。</li>
<li>TTS model 从 <code>LLM_ROUTER_CONFIG.tts.models</code> 同步。</li>
<li>现有运行配置同步出的 TTS model 默认启用,避免上线后突然不可用。</li>
<li>管理员可以启用、禁用、排序、重命名 TTS model。</li>
<li>每个 TTS model 下管理 voice catalog。</li>
<li>支持一键从对应 provider 拉取声线。</li>
<li>新拉取声线默认禁用。</li>
<li>voice 支持展示名、语言、标签、排序、预览 URL。</li>
<li>主站 <code>/api/v1/audio/voices</code> 只返回 enabled voices。</li>
</ul>
<h2>ASR</h2>
<ul>
<li>ASR 不直接暴露底层 provider 细节。</li>
<li>v1 可以只保留 <code>auto</code></li>
<li>真实候选从 <code>LLM_ROUTER_CONFIG.asr.models</code> 同步。</li>
<li>请求 ASR 前校验 alias/model 是否启用。</li>
</ul>
<h1>Admin 信息架构</h1>
<table>
<thead>
<tr>
<th background-color="light-gray">菜单</th>
<th background-color="light-gray">用途</th>
<th background-color="light-gray">一期能力</th>
</tr>
</thead>
<tbody>
<tr>
<td>Providers</td>
<td>管理 LLM / ASR alias。</td>
<td>查看和编辑 <code>auto</code>;同步真实 router model;配置 primary/fallback pool。</td>
</tr>
<tr>
<td>TTS</td>
<td>管理官方 TTS model 和 voice catalog。</td>
<td>同步模型、拉取声线、启停、排序、编辑显示信息。</td>
</tr>
<tr>
<td>Voice Packs</td>
<td>管理面向用户的 TTS 产品预设。</td>
<td>继续保留现有页面,但候选 model/voice 应来自 enabled catalog。</td>
</tr>
<tr>
<td>LLM Router</td>
<td>管理真实路由、key、fallback 底层配置。</td>
<td>继续负责真实 provider/model/key 写入,不负责主站展示白名单。</td>
</tr>
</tbody>
</table>
<h1>数据模型</h1>
<p>具体表名实现时可按 repo 命名规范调整,但职责边界保持如下。</p>
<h2><code>official_provider_aliases</code></h2>
<table>
<thead>
<tr>
<th background-color="light-gray">字段</th>
<th background-color="light-gray">说明</th>
</tr>
</thead>
<tbody>
<tr><td><code>id</code></td><td>主键。</td></tr>
<tr><td><code>surface</code></td><td><code>llm</code><code>asr</code></td></tr>
<tr><td><code>alias_id</code></td><td>客户端可见 alias,例如 <code>auto</code></td></tr>
<tr><td><code>display_name</code></td><td>展示名称。</td></tr>
<tr><td><code>enabled</code></td><td>是否展示和允许请求。</td></tr>
<tr><td><code>display_order</code></td><td>展示排序。</td></tr>
<tr><td><code>fallback_enabled</code></td><td>是否启用 fallback pool。</td></tr>
<tr><td><code>load_balancing_enabled</code></td><td>是否启用 primary pool 负载均衡。</td></tr>
<tr><td><code>created_at / updated_at</code></td><td>创建和更新时间。</td></tr>
</tbody>
</table>
<h2><code>official_provider_alias_routes</code></h2>
<table>
<thead>
<tr>
<th background-color="light-gray">字段</th>
<th background-color="light-gray">说明</th>
</tr>
</thead>
<tbody>
<tr><td><code>alias_id</code></td><td>关联 alias。</td></tr>
<tr><td><code>router_model_id</code></td><td>真实 <code>LLM_ROUTER_CONFIG</code> model key。</td></tr>
<tr><td><code>pool</code></td><td><code>primary</code><code>fallback</code></td></tr>
<tr><td><code>enabled</code></td><td>该真实候选是否参与路由。</td></tr>
<tr><td><code>weight</code></td><td>负载均衡权重,v1 可先保留默认值。</td></tr>
<tr><td><code>display_order</code></td><td>管理面排序。</td></tr>
</tbody>
</table>
<h2><code>official_tts_models</code></h2>
<table>
<thead>
<tr>
<th background-color="light-gray">字段</th>
<th background-color="light-gray">说明</th>
</tr>
</thead>
<tbody>
<tr><td><code>id</code></td><td>主键。</td></tr>
<tr><td><code>router_model_id</code></td><td>真实 TTS model key,例如 <code>alibaba/cosyvoice-v2</code></td></tr>
<tr><td><code>provider</code></td><td>底层 provider,例如 <code>dashscope-cosyvoice</code><code>azure</code><code>stepfun</code></td></tr>
<tr><td><code>display_name</code></td><td>展示名。</td></tr>
<tr><td><code>enabled</code></td><td>是否展示和允许请求。</td></tr>
<tr><td><code>display_order</code></td><td>展示排序。</td></tr>
<tr><td><code>last_synced_at</code></td><td>最近一次从 router config 同步时间。</td></tr>
</tbody>
</table>
<h2><code>official_tts_voices</code></h2>
<table>
<thead>
<tr>
<th background-color="light-gray">字段</th>
<th background-color="light-gray">说明</th>
</tr>
</thead>
<tbody>
<tr><td><code>id</code></td><td>主键。</td></tr>
<tr><td><code>tts_model_id</code></td><td>关联 <code>official_tts_models</code></td></tr>
<tr><td><code>provider_voice_id</code></td><td>provider 返回的真实 voice id。</td></tr>
<tr><td><code>display_name</code></td><td>展示名。</td></tr>
<tr><td><code>enabled</code></td><td>是否展示和允许请求。</td></tr>
<tr><td><code>display_order</code></td><td>展示排序。</td></tr>
<tr><td><code>languages</code></td><td>语言列表,JSON。</td></tr>
<tr><td><code>labels</code></td><td>provider labelsJSON。</td></tr>
<tr><td><code>preview_audio_url</code></td><td>provider 返回或 admin 手动填写的预览 URL。</td></tr>
<tr><td><code>source</code></td><td><code>provider-sync</code><code>manual</code></td></tr>
<tr><td><code>last_synced_at</code></td><td>最近一次拉取声线时间。</td></tr>
</tbody>
</table>
<h1>Admin API</h1>
<table>
<thead>
<tr>
<th background-color="light-gray">接口</th>
<th background-color="light-gray">说明</th>
</tr>
</thead>
<tbody>
<tr><td><code>GET /api/admin/official-catalog/aliases</code></td><td>列出 LLM / ASR aliases。</td></tr>
<tr><td><code>POST /api/admin/official-catalog/aliases/sync</code></td><td><code>LLM_ROUTER_CONFIG</code> 同步真实候选。</td></tr>
<tr><td><code>PATCH /api/admin/official-catalog/aliases/:id</code></td><td>更新 alias 展示、启用、fallback、负载均衡设置。</td></tr>
<tr><td><code>PATCH /api/admin/official-catalog/aliases/:id/routes</code></td><td>更新 alias primary/fallback pool。</td></tr>
<tr><td><code>GET /api/admin/official-catalog/tts/models</code></td><td>列出 TTS models。</td></tr>
<tr><td><code>POST /api/admin/official-catalog/tts/models/sync</code></td><td>从 router config 同步 TTS models。</td></tr>
<tr><td><code>PATCH /api/admin/official-catalog/tts/models/:id</code></td><td>更新 TTS model 启用、排序和展示名。</td></tr>
<tr><td><code>GET /api/admin/official-catalog/tts/models/:id/voices</code></td><td>列出某个 model 下的 voices。</td></tr>
<tr><td><code>POST /api/admin/official-catalog/tts/models/:id/voices/sync</code></td><td>从 provider 拉取 voices,新 voice 默认禁用。</td></tr>
<tr><td><code>PATCH /api/admin/official-catalog/tts/voices/:id</code></td><td>更新 voice 启用、排序、展示名、语言、标签和预览 URL。</td></tr>
<tr><td><code>POST /api/admin/official-catalog/tts/voices/bulk</code></td><td>批量启用、禁用或排序 voices。</td></tr>
</tbody>
</table>
<h1>Public API 调整</h1>
<ul>
<li><code>GET /api/v1/audio/models</code>:只返回 enabled TTS models,按 admin 排序。</li>
<li><code>GET /api/v1/audio/voices?model=...</code>:只返回该 model 下 enabled voices。</li>
<li><code>POST /api/v1/openai/chat/completions</code>:先把请求 model 当 alias 校验和解析。disabled / missing alias 直接报错。</li>
<li><code>POST /api/v1/audio/speech</code>:校验 TTS model enabled,再校验 voice 属于该 model 且 enabled。</li>
<li>ASR route:校验 ASR alias/model enabled 后再进入真实转写链路。</li>
</ul>
<h1>Gateway 校验规则</h1>
<table>
<thead>
<tr>
<th background-color="light-gray">场景</th>
<th background-color="light-gray">处理</th>
<th background-color="light-gray">错误码</th>
</tr>
</thead>
<tbody>
<tr><td>LLM alias 不存在</td><td>拒绝请求。</td><td><code>OFFICIAL_ALIAS_NOT_FOUND</code></td></tr>
<tr><td>LLM alias 禁用</td><td>拒绝请求。</td><td><code>OFFICIAL_ALIAS_DISABLED</code></td></tr>
<tr><td>TTS model 不存在或未同步</td><td>拒绝请求。</td><td><code>OFFICIAL_MODEL_NOT_FOUND</code></td></tr>
<tr><td>TTS model 禁用</td><td>拒绝请求。</td><td><code>OFFICIAL_MODEL_DISABLED</code></td></tr>
<tr><td>TTS voice 不存在于该 model</td><td>拒绝请求。</td><td><code>OFFICIAL_VOICE_NOT_FOUND</code></td></tr>
<tr><td>TTS voice 禁用</td><td>拒绝请求。</td><td><code>OFFICIAL_VOICE_DISABLED</code></td></tr>
<tr><td>ASR alias/model 禁用</td><td>拒绝请求。</td><td><code>OFFICIAL_ALIAS_DISABLED</code><code>OFFICIAL_MODEL_DISABLED</code></td></tr>
</tbody>
</table>
<h1>同步策略</h1>
<ul>
<li>现有运行配置同步出的 LLM/TTS/ASR model 默认启用,避免上线后把已有能力突然关闭。</li>
<li>TTS 声线拉取后默认禁用,必须管理员手动启用。</li>
<li>重复同步时保留管理员已经改过的 <code>enabled</code><code>display_order</code><code>display_name</code><code>preview_audio_url</code></li>
<li>provider 不再返回的旧 voice 不自动删除,可标记为 stale 或保留 <code>last_synced_at</code> 供 admin 判断。</li>
<li>同步失败必须展示明确错误,不写入半成品批次。</li>
</ul>
<h1>验收标准</h1>
<checkbox done="false">Admin 可以看到 Providers 页面,默认存在 enabled 的 LLM <code>auto</code> alias。</checkbox>
<checkbox done="false">Admin 可以同步 LLM / ASR router model 候选,并配置 alias primary/fallback pool。</checkbox>
<checkbox done="false">Admin 可以看到 TTS 页面,能同步 TTS models。</checkbox>
<checkbox done="false">Admin 可以对某个 TTS model 一键拉取 voices;新 voice 默认 disabled。</checkbox>
<checkbox done="false">Admin 启用 voice 后,主站 <code>/api/v1/audio/voices</code> 才返回该 voice。</checkbox>
<checkbox done="false">禁用 TTS model 后,主站模型列表不展示,请求该 model 报错。</checkbox>
<checkbox done="false">禁用 TTS voice 后,主站声线列表不展示,请求该 voice 报错。</checkbox>
<checkbox done="false">禁用 LLM alias 后,客户端不展示,请求该 alias 报错。</checkbox>
<checkbox done="false">Voice Pack 创建/编辑页面的候选 model 和 voice 不包含 disabled catalog 项。</checkbox>
<checkbox done="false">保留现有 router config 写入和 key 加密逻辑,不把密钥暴露给 catalog API 或 admin UI。</checkbox>
<h1>二期</h1>
<ul>
<li>一键生成缺失 voice preview 音频。</li>
<li>新增对象存储/CDN adapter,保存生成的预览音频。</li>
<li>alias 灰度开放和人群分组。</li>
<li>更完整的 alias 权重负载均衡 UI。</li>
<li>Catalog 变更审计日志。</li>
<li>provider 质量、成本、延迟指标回显。</li>
</ul>
<h1>实现提示</h1>
<ul>
<li>Catalog service 应独立于 LLM Router service。Router 负责真实转发,Catalog 负责产品白名单和 alias 解析。</li>
<li>请求校验要放在 server 侧,不只靠 admin 或主站 UI。</li>
<li>Public catalog endpoint 和 gateway 校验应复用同一个 domain service,避免展示和请求规则分叉。</li>
<li>新增测试应覆盖展示过滤、请求拦截、同步默认值、重复同步保留 admin 修改。</li>
</ul>
@@ -1,38 +0,0 @@
# Auth UI New Backend Origin Design
## Problem
The production Auth UI receives `api_server_url=https://airi-server-next.up.railway.app` from the new Go backend, but its standalone bootstrap allowlist does not recognize that origin. The Auth UI therefore falls back to `https://api.airi.build`. Browser requests then fail at the cross-origin boundary and the sign-in page reports `Load failed`.
The new Go backend itself is healthy: its email lookup endpoint returns successfully and explicitly allows the production Auth UI origin.
## Scope
This change is limited to `apps/ui-server-auth`:
- Trust the exact production origin `https://airi-server-next.up.railway.app`.
- Preserve the existing trust entries for the original production backend, server-dev, and localhost development.
- Preserve rejection of arbitrary external origins.
The change must not add wildcard Railway trust, alter iOS login behavior, or change backend CORS policy.
## Implementation
Add the new Go backend origin to `TRUSTED_STANDALONE_API_SERVER_ORIGINS` in `server-auth-context.ts`.
Add a focused regression test that passes a standalone Auth UI URL containing the new backend in `api_server_url` and expects the normalized new backend origin. Existing untrusted-origin coverage remains the security regression guard.
## Verification
Before implementation, the new regression test must fail because the new origin is not trusted. After the minimal allowlist change:
- Run the focused bootstrap-context tests.
- Run the complete Auth UI test suite.
- Run Auth UI type checking and production build.
- Confirm the production bundle contains the new trusted origin after deployment.
- Confirm the live OIDC redirect carries the new backend and the Auth UI selects it.
- Confirm the browser CORS preflight and email lookup succeed against the new backend.
## Delivery
Develop on an isolated branch based on the latest `upstream/main`. Push the branch, open a pull request, and merge only after checks pass. The existing `Cloudflare Pages (Auth UI)` workflow deploys production on pushes to `main`. After deployment, validate the live login boundary before considering the issue resolved.
+1 -1
View File
@@ -66,7 +66,7 @@ export default defineConfig({
'markdown/require-alt-text': 'off',
},
}, {
files: ['apps/server/**/*.ts'],
files: ['server/apps/api/**/*.ts'],
rules: {
'no-restricted-syntax': [
'error',
+4 -4
View File
@@ -18,7 +18,6 @@
"dev:ui": "pnpm -rF @proj-airi/stage-ui run story:dev",
"dev:web": "pnpm -rF @proj-airi/stage-web run dev",
"dev:web:https": "pnpm -rF @proj-airi/stage-web run dev:https",
"dev:server-auth": "pnpm -rF @proj-airi/ui-server-auth run dev",
"dev:pocket:ios": "pnpm -rF @proj-airi/stage-pocket run dev:ios",
"dev:pocket:android": "pnpm -rF @proj-airi/stage-pocket run dev:android",
"dev:server": "pnpm -rF @proj-airi/server-runtime run dev",
@@ -26,7 +25,7 @@
"dev:tamagotchi:xwayland": "pnpm -rF @proj-airi/stage-tamagotchi run dev:xwayland",
"dev:apps": "pnpm -rF=\"./apps/*\" run --parallel dev",
"dev:packages": "pnpm -rF=\"./packages/*\" --parallel run dev",
"build": "turbo run build -F=\"./packages/*\" -F=\"./apps/*\"",
"build": "turbo run build -F=\"./packages/*\" -F=\"./apps/*\" -F=\"./server/**\"",
"build:web": "turbo run build -F @proj-airi/stage-web",
"build:tamagotchi": "pnpm -rF @proj-airi/stage-tamagotchi run app:build",
"build:apps": "turbo run build -F=\"./apps/*\"",
@@ -42,7 +41,7 @@
"capture:tamagotchi": "pnpm exec vishot capture --target electron ./packages/scenarios-stage-tamagotchi-electron/src/scenarios/demo-controls-settings-chat-websocket/index.ts --app-entrypoint ./apps/stage-tamagotchi/out/main/index.js --cwd . --output-dir ./packages/scenarios-stage-tamagotchi-browser/artifacts/raw --format avif",
"to-avif": "tsx docs/scripts/avif.ts",
"sponsors:generate": "sponsorkit --output-dir docs/content/public/assets/sponsors",
"typecheck": "pnpm -rF=\"./packages/*\" -F=\"./apps/*\" -F=\"./docs\" --parallel typecheck",
"typecheck": "pnpm -rF=\"./packages/*\" -F=\"./apps/*\" -F=\"./server/**\" -F=\"./docs\" --parallel typecheck",
"typecheck:engines": "pnpm -rF=\"./engines/*\" --parallel typecheck",
"up": "taze -w -r -I && pnpm prune && pnpm dedupe",
"nolyfill": "pnpm dlx nolyfill",
@@ -103,7 +102,8 @@
"examples/**",
"docs/**",
"engines/**",
"apps/**"
"apps/**",
"server/**"
],
"simple-git-hooks": {
"pre-commit": "pnpm nano-staged"
@@ -38,8 +38,7 @@ const userAvatar = computed(() => user.value?.image ?? null)
// Gravatar fallback is decorated server-side onto `user.image`. We detect
// the fallback by URL prefix instead of carrying a redundant `imageSource`
// flag — Gravatar URL format is stable and prefix-matching keeps the API
// surface small. If the avatar source ever changes, both this constant
// and apps/server/src/libs/gravatar.ts must move together.
// surface small. Keep this prefix aligned with the server-generated fallback.
const GRAVATAR_AVATAR_PREFIX = 'https://www.gravatar.com/avatar/'
const usingGravatarFallback = computed(
() => userAvatar.value?.startsWith(GRAVATAR_AVATAR_PREFIX) ?? false,
@@ -61,7 +61,7 @@ const currencyOptions = computed(() => {
// NOTICE: Manual interface instead of hono InferResponseType because hono client
// type instantiation hits TS recursion limits ("excessively deep and possibly infinite").
// Keep in sync with the route response shape in apps/server/src/routes/flux.ts
// Keep this manual shape aligned with the API response.
interface AuditRecord {
id: string
type: string
@@ -78,7 +78,7 @@ watch(model, async () => {
})
// Synthesize via the streaming session helper. The page uses the SAME
// transport the runtime pipeline uses (ws apps/server proxy unspeech
// transport the runtime pipeline uses (ws API proxy unspeech
// bridge Volcengine v3 bidirectional) so the preview faithfully
// represents what the user hears in actual chat. The session is opened
// per-preview because there's no LLM token stream here we just send
+1 -1
View File
@@ -1,4 +1,4 @@
import type { AppType } from '../../../../apps/server/src/app'
import type { AppType } from '../../../../server/apps/api/src/app'
import { hc } from 'hono/client'
@@ -210,9 +210,8 @@ export function useAnalytics() {
* adapter choose a delivery mechanism that survives document unload.
*
* The funnel terminator `payment_completed` is forwarded to PostHog
* server-side by the product-events service (allowlist in
* `apps/server/src/services/domain/product-events.ts`), keyed by the
* Better Auth user id.
* server-side by the product-events service, keyed by the Better Auth
* user id.
*/
function trackCheckoutStarted(planId: string, properties: { entry_surface: string, checkout_session_id?: string, price_minor_unit?: number, currency?: string }) {
if (!canCapture())
@@ -368,7 +367,7 @@ export function useAnalytics() {
// ─── LLM round events (client-known fields only) ──────────────────────
// Source-of-truth for HTTP status / token usage / billing stage is the
// server (apps/server/src/routes/openai/v1), which records them as
// server, which records them as
// Postgres `product_events` rows — deliberately NOT forwarded to PostHog
// (per-request volume stays in DB/Grafana). These client emits supply the
// user-facing latency picture (TTFT, render time) the server cannot see.
@@ -119,10 +119,8 @@ describe('ws_CLOSE_UNAUTHORIZED', () => {
//
// The server accepts the upgrade and closes with this custom code so
// the client can distinguish "auth failed, stop reconnecting" from
// "network blip, keep retrying". The matching constant on the server
// lives at `apps/server/src/libs/ws-auth.ts:WS_CLOSE_UNAUTHORIZED` and
// is exercised by `apps/server/src/libs/ws-auth.test.ts`. If either
// value drifts the close-code contract breaks silently.
// "network blip, keep retrying". The matching server-side constant must
// stay aligned or the close-code contract breaks silently.
it('matches the server-side close code contract (4001, IANA private range)', () => {
expect(WS_CLOSE_UNAUTHORIZED).toBe(4001)
})
@@ -192,10 +190,8 @@ describe('wS_CLOSE_UNAUTHORIZED', () => {
//
// The server accepts the upgrade and closes with this custom code so
// the client can distinguish "auth failed, stop reconnecting" from
// "network blip, keep retrying". The matching constant on the server
// lives at `apps/server/src/libs/ws-auth.ts:WS_CLOSE_UNAUTHORIZED` and
// is exercised by `apps/server/src/libs/ws-auth.test.ts`. If either
// value drifts the close-code contract breaks silently.
// "network blip, keep retrying". The matching server-side constant must
// stay aligned or the close-code contract breaks silently.
it('matches the server-side close code contract (4001, IANA private range)', () => {
expect(WS_CLOSE_UNAUTHORIZED).toBe(4001)
})
@@ -20,8 +20,7 @@ const RECONNECT_RETRIES = -1
* Browsers swallow the HTTP 401 status when a WebSocket upgrade is rejected,
* so the only way for the server to distinguish "wrong token, stop retrying"
* from a transient network drop on the client is to accept the upgrade and
* close with a custom application code. The server emits this from
* `apps/server/src/app.ts` when `resolveRequestAuth` returns null.
* close with a custom application code after request authentication fails.
*/
export const WS_CLOSE_UNAUTHORIZED = 4001
@@ -298,8 +297,8 @@ export function createChatWsClient(options: CreateChatWsClientOptions): ChatWsCl
// retrying. When the server rejects auth, the only structured
// signal we get is the close `code` (the close `reason` body is
// also delivered but not used for routing here). 4001 is our
// contract with apps/server/src/app.ts for "this token will never
// succeed without rotation"; calling `ws.close()` here sets
// protocol signal for "this token will never succeed without
// rotation"; calling `ws.close()` here sets
// useWebSocket's internal `explicitlyClosed` flag so the next
// onclose path skips the reconnect schedule. The next time
// `urlRef` changes (token refresh), `watch(urlRef, open)` calls
@@ -116,8 +116,7 @@ export const useSharedAnalyticsStore = defineStore('analytics-shared', () => {
// Wire PostHog identity to auth state. Without this server-side events
// (`payment_completed` keyed on Better Auth `user.id`) and browser-side
// funnel events (anonymous `distinct_id` until identify) live on
// different person profiles and the funnel never joins. See
// `apps/server/docs/ai-context/metrics-ownership.md`.
// different person profiles and the funnel never joins.
const authStore = useAuthStore()
if (authStore.isAuthenticated && authStore.user?.id)
identifyAnalyticsUser(authStore.user.id)
+5 -5
View File
@@ -1410,7 +1410,7 @@ importers:
specifier: 'catalog:'
version: 3.2.6(typescript@5.9.3)
apps/server:
server/apps/api:
dependencies:
'@better-auth/drizzle-adapter':
specifier: 'catalog:'
@@ -1502,12 +1502,12 @@ importers:
'@proj-airi/drizzle-orm-browser-migrator':
specifier: 'catalog:'
version: 0.1.6(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))
'@proj-airi/server-schema':
'@proj-airi/drizzle-migration':
specifier: workspace:*
version: link:../../packages/server-schema
version: link:../../packages/drizzle-migration
'@proj-airi/server-sdk-shared':
specifier: workspace:*
version: link:../../packages/server-sdk-shared
version: link:../../../packages/server-sdk-shared
better-auth:
specifier: 'catalog:'
version: 1.6.5(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3))
@@ -3705,7 +3705,7 @@ importers:
specifier: 'catalog:'
version: 1.3.1(typescript@5.9.3)
packages/server-schema:
server/packages/drizzle-migration:
devDependencies:
'@proj-airi/unplugin-drizzle-orm-migrations':
specifier: 'catalog:'
+1
View File
@@ -9,6 +9,7 @@ packages:
- docs/**
- engines/**
- apps/**
- server/**
- '!**/dist/**'
overrides:
+29
View File
@@ -0,0 +1,29 @@
# AIRI server workspace
Backend deployables and backend-only packages live under this directory. Keeping them in one workspace makes the deployment boundary explicit while the repository root remains the shared pnpm workspace.
## Structure
- `apps/api`: Hono HTTP and WebSocket API, including auth, billing, chat synchronization, model gateway routing, and observability.
- `packages/drizzle-migration`: compiled Drizzle migrations consumed by the API at startup.
- `docker-compose.yml`: local API, PostgreSQL, and Redis stack.
Packages shared with browser, desktop, integrations, or plugins remain in the root `packages/` directory because they are not backend-only.
## Usage
From the repository root:
```sh
pnpm -F @proj-airi/api-server dev
pnpm -F @proj-airi/api-server typecheck
pnpm -F @proj-airi/api-server exec vitest run
pnpm -F @proj-airi/api-server build
pnpm dev:backend
```
Use the scoped package commands when PostgreSQL and Redis already exist. Use `pnpm dev:backend` to build and run the complete local Compose stack.
## Boundaries
Use `server/apps/api` for API-owned routes, services, schemas, and runtime composition. Use `server/packages` only for packages that are private to backend deployables. Cross-runtime contracts and SDKs belong in the root `packages/` workspace.
@@ -1,6 +1,6 @@
# Server CLAUDE.md
Agent-facing guide for `apps/server`. Detailed topic docs live in `docs/ai-context/` — read the relevant file before modifying that area.
Agent-facing guide for `server/apps/api`.
## Overview
@@ -21,16 +21,16 @@ Hono, Better Auth (OIDC provider, RS256 JWT), Drizzle ORM, PostgreSQL, Redis, St
## Commands
```sh
pnpm -F @proj-airi/server dev # dev with dotenvx (.env.local)
pnpm -F @proj-airi/server typecheck
pnpm -F @proj-airi/server exec vitest run # all server tests
pnpm exec vitest run apps/server/src/... # single test file
pnpm -F @proj-airi/server db:generate # drizzle-kit generate
pnpm -F @proj-airi/server db:push # drizzle-kit push
pnpm -F @proj-airi/server auth:generate # better-auth → src/schemas/accounts.ts
pnpm -F @proj-airi/api-server dev # dev with dotenvx (.env.local)
pnpm -F @proj-airi/api-server typecheck
pnpm -F @proj-airi/api-server exec vitest run # all API tests
pnpm exec vitest run server/apps/api/src/... # single test file
pnpm -F @proj-airi/api-server db:generate # drizzle-kit generate
pnpm -F @proj-airi/api-server db:push # drizzle-kit push
pnpm -F @proj-airi/api-server auth:generate # better-auth → src/schemas/accounts.ts
```
Local observability: `docker compose -f apps/server/docker-compose.otel.yml up -d`
Local API, PostgreSQL, and Redis: `pnpm dev:backend`
## Architecture Summary
@@ -39,7 +39,7 @@ Local observability: `docker compose -f apps/server/docker-compose.otel.yml up -
**Layering**:
- **Routes** (`src/routes/`): thin — param validation (Valibot), auth guards, error mapping. No business logic here.
- **Services** (`src/services/`): core business logic and DB transactions.
- **Schemas** (`src/schemas/`): Drizzle table definitions. Migrations in `@proj-airi/server-schema`.
- **Schemas** (`src/schemas/`): Drizzle table definitions. Migrations in `@proj-airi/drizzle-migration`.
**Middleware chain** (`/api/*`): CORS → hono/logger → optional otel → sessionMiddleware → bodyLimit(1MB) → per-route guards. WebSocket `/ws/chat` registered before bodyLimit.
@@ -53,17 +53,3 @@ Local observability: `docker compose -f apps/server/docker-compose.otel.yml up -
- **Redis is cache + pub/sub, not truth**: balance cache, app_settings read cache, WebSocket cross-instance pub/sub. Truth is always Postgres.
- **Auth**: Better Auth + OIDC. `sessionMiddleware` fills context but doesn't block; `authGuard` returns 401.
- **Multi-instance safe**: all writes go through Postgres transactions; cross-instance messaging uses Redis Pub/Sub. No async work, no in-process singletons — admin flux grants happen synchronously inside the POST that triggered them.
## Detailed Context Docs
See `docs/ai-context/README.md` for the full index. Key files:
- `architecture-overview.md` — entry, DI, assembly, boundaries
- `transport-and-routes.md` — API surface, route→service mapping
- `data-model-and-state.md` — tables, state ownership, caching
- `billing-architecture.md` — Flux/Stripe ledger
- `redis-boundaries-and-pubsub.md` — Redis key/channel boundaries
- `auth-and-oidc.md` — auth flows, OIDC, trusted clients
- `config-and-naming-conventions.md` — configKV, naming rules
- `workers-and-runtime.md` — single `api` role, no background loops, no fire-and-forget; everything is synchronous in-request
- `admin-flux-grants.md` — synchronous one-shot flux grant endpoint (no batch tables, no state machine)
- `observability-conventions.md` — OTel naming, custom attributes
@@ -8,15 +8,15 @@ RUN corepack enable
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./
COPY patches/ ./patches/
COPY apps/server apps/server
COPY packages/server-schema packages/server-schema
COPY server/apps/api server/apps/api
COPY server/packages/drizzle-migration server/packages/drizzle-migration
COPY packages/server-sdk-shared packages/server-sdk-shared
RUN --mount=type=cache,id=pnpm-store,target=/root/.pnpm-store \
pnpm install --frozen-lockfile --ignore-scripts
# NOTICE:
# Force a fresh build of server-schema and assert that
# Force a fresh build of drizzle-migration and assert that
# unplugin-drizzle-orm-migrations actually replaced the `virtual:drizzle-migrations.sql`
# import. Without this guard a layer built when the rolldown plugin silently
# no-op'd (intermittently observed on Railway with tsdown 0.21.9 + rolldown
@@ -25,20 +25,20 @@ RUN --mount=type=cache,id=pnpm-store,target=/root/.pnpm-store \
# ERR_UNSUPPORTED_ESM_URL_SCHEME. `rm -rf dist` keeps this RUN's command-string
# distinct from earlier cached variants and prevents a stale dist from masking
# a failed transform.
RUN rm -rf packages/server-schema/dist \
&& pnpm -F @proj-airi/server-schema run build \
&& if grep -qE "from ['\"]virtual:" packages/server-schema/dist/index.mjs; then \
echo "ERROR: server-schema dist/index.mjs still references a virtual: specifier — unplugin-drizzle-orm-migrations transform did not run." >&2; \
cat packages/server-schema/dist/index.mjs >&2; \
RUN rm -rf server/packages/drizzle-migration/dist \
&& pnpm -F @proj-airi/drizzle-migration run build \
&& if grep -qE "from ['\"]virtual:" server/packages/drizzle-migration/dist/index.mjs; then \
echo "ERROR: drizzle-migration dist/index.mjs still references a virtual: specifier — unplugin-drizzle-orm-migrations transform did not run." >&2; \
cat server/packages/drizzle-migration/dist/index.mjs >&2; \
exit 1; \
fi \
&& ls packages/server-schema/dist/virtual_drizzle-migrations-*.mjs >/dev/null
&& ls server/packages/drizzle-migration/dist/virtual_drizzle-migrations-*.mjs >/dev/null
RUN pnpm -F @proj-airi/server-sdk-shared run build
RUN pnpm -F @proj-airi/server run build
RUN pnpm -F @proj-airi/api-server run build
EXPOSE 3000
USER airi
CMD ["pnpm", "-F", "@proj-airi/server", "start"]
CMD ["pnpm", "-F", "@proj-airi/api-server", "start"]
@@ -1,4 +1,4 @@
# `@proj-airi/server`
# `@proj-airi/api-server`
HTTP and WebSocket backend for AIRI. This app owns auth, billing, chat synchronization, gateway forwarding, and server-side observability export.
@@ -15,15 +15,15 @@ HTTP and WebSocket backend for AIRI. This app owns auth, billing, chat synchroni
Install dependencies from the repo root and run scoped commands:
```sh
pnpm -F @proj-airi/server typecheck
pnpm -F @proj-airi/server exec vitest run
pnpm -F @proj-airi/server build
pnpm -F @proj-airi/api-server typecheck
pnpm -F @proj-airi/api-server exec vitest run
pnpm -F @proj-airi/api-server build
```
For local observability infrastructure, use:
To run the API together with local PostgreSQL and Redis, use:
```sh
docker compose -f apps/server/docker-compose.otel.yml up -d
pnpm dev:backend
```
## `AUTH_UI_URL`
@@ -55,7 +55,7 @@ canonical `X-Real-IP` instead of the gateway socket address.
## `ADDITIONAL_TRUSTED_ORIGINS` (LAN / Capacitor dev)
When the mobile dev server uses a non-localhost origin (for example `https://10.x.x.x:5273` from `cap copy ios` / `capacitor.config.json`), set **`ADDITIONAL_TRUSTED_ORIGINS`** in `apps/server/.env.local` to a comma-separated list of exact origins (parsed and normalized at startup). Example:
When the mobile dev server uses a non-localhost origin (for example `https://10.x.x.x:5273` from `cap copy ios` / `capacitor.config.json`), set **`ADDITIONAL_TRUSTED_ORIGINS`** in `server/apps/api/.env.local` to a comma-separated list of exact origins (parsed and normalized at startup). Example:
`ADDITIONAL_TRUSTED_ORIGINS=https://10.0.0.129:5273,https://198.18.0.1:5273`
@@ -1,5 +1,5 @@
{
"name": "@proj-airi/server",
"name": "@proj-airi/api-server",
"type": "module",
"version": "0.11.3",
"private": true,
@@ -9,11 +9,10 @@
"dev": "pnpm run apply:env -- tsx --import ./instrumentation.ts --watch src/bin/run.ts api",
"start": "pnpm run apply:env -- tsx --import ./instrumentation.ts src/bin/run.ts api",
"server": "pnpm run apply:env -- tsx --import ./instrumentation.ts src/bin/run.ts",
"build": "tsc -b",
"build": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:push": "pnpm run apply:env -- drizzle-kit push",
"otel:dashboards": "tsx otel/grafana/dashboards/build.ts"
"db:push": "pnpm run apply:env -- drizzle-kit push"
},
"dependencies": {
"@better-auth/drizzle-adapter": "catalog:",
@@ -45,8 +44,8 @@
"@opentelemetry/sdk-node": "catalog:",
"@opentelemetry/sdk-trace-node": "catalog:",
"@opentelemetry/semantic-conventions": "catalog:",
"@proj-airi/drizzle-migration": "workspace:*",
"@proj-airi/drizzle-orm-browser-migrator": "catalog:",
"@proj-airi/server-schema": "workspace:*",
"@proj-airi/server-sdk-shared": "workspace:*",
"better-auth": "catalog:",
"cac": "catalog:",
@@ -8,14 +8,14 @@ RUN corepack enable
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.json ./
COPY patches/ ./patches/
COPY apps/server apps/server
COPY packages/server-schema packages/server-schema
COPY server/apps/api server/apps/api
COPY server/packages/drizzle-migration server/packages/drizzle-migration
COPY packages/server-sdk-shared packages/server-sdk-shared
RUN pnpm install --frozen-lockfile --ignore-scripts
# NOTICE:
# Force a fresh build of server-schema and assert that
# Force a fresh build of drizzle-migration and assert that
# unplugin-drizzle-orm-migrations actually replaced the `virtual:drizzle-migrations.sql`
# import. Without this guard a layer built when the rolldown plugin silently
# no-op'd (intermittently observed on Railway with tsdown 0.21.9 + rolldown
@@ -24,20 +24,20 @@ RUN pnpm install --frozen-lockfile --ignore-scripts
# ERR_UNSUPPORTED_ESM_URL_SCHEME. `rm -rf dist` keeps this RUN's command-string
# distinct from earlier cached variants and prevents a stale dist from masking
# a failed transform.
RUN rm -rf packages/server-schema/dist \
&& pnpm -F @proj-airi/server-schema run build \
&& if grep -qE "from ['\"]virtual:" packages/server-schema/dist/index.mjs; then \
echo "ERROR: server-schema dist/index.mjs still references a virtual: specifier — unplugin-drizzle-orm-migrations transform did not run." >&2; \
cat packages/server-schema/dist/index.mjs >&2; \
RUN rm -rf server/packages/drizzle-migration/dist \
&& pnpm -F @proj-airi/drizzle-migration run build \
&& if grep -qE "from ['\"]virtual:" server/packages/drizzle-migration/dist/index.mjs; then \
echo "ERROR: drizzle-migration dist/index.mjs still references a virtual: specifier — unplugin-drizzle-orm-migrations transform did not run." >&2; \
cat server/packages/drizzle-migration/dist/index.mjs >&2; \
exit 1; \
fi \
&& ls packages/server-schema/dist/virtual_drizzle-migrations-*.mjs >/dev/null
&& ls server/packages/drizzle-migration/dist/virtual_drizzle-migrations-*.mjs >/dev/null
RUN pnpm -F @proj-airi/server-sdk-shared run build
RUN pnpm -F @proj-airi/server run build
RUN pnpm -F @proj-airi/api-server run build
EXPOSE 3000
USER airi
CMD ["pnpm", "-F", "@proj-airi/server", "start"]
CMD ["pnpm", "-F", "@proj-airi/api-server", "start"]
+12
View File
@@ -0,0 +1,12 @@
[build]
builder = "DOCKERFILE"
dockerfilePath = "/server/apps/api/production/railway/Dockerfile"
watchPatterns = [
"server/apps/api/**",
"server/packages/drizzle-migration/**",
"packages/**",
"pnpm-lock.yaml"
]
[deploy]
startCommand = "pnpm -F @proj-airi/api-server start"
@@ -194,7 +194,7 @@ export async function buildApp(deps: AppDeps) {
}))
// Bidirectional streaming TTS proxy. The handler factory builds one ws-to-ws
// bridge per connection: client ↔ apps/server ↔ unspeech ↔ upstream
// bridge per connection: client ↔ server/apps/api ↔ unspeech ↔ upstream
// (Volcengine bidirection etc.). Auth via ?token= mirrors /ws/chat —
// browsers can't set Authorization headers on WebSocket constructors.
const audioSpeechWsSetup = createAudioSpeechWsHandlers({
@@ -660,7 +660,7 @@ export async function createApp() {
// 1. give it a `deleteAllForUser(userId)` method
// 2. add one `service.register(...)` line below
// Domain knowledge stays inside each service instead of being copied into
// a parallel handler file. See `apps/server/docs/ai-context/account-deletion.md`.
// a parallel handler file. See `server/apps/api/docs/ai-context/account-deletion.md`.
const userDeletionService = injeca.provide('services:userDeletion', {
dependsOn: { stripeService, fluxService, providerService, characterService, chatService },
build: ({ dependsOn }) => {
@@ -174,7 +174,7 @@ export function oidcJwtBearer(env: Env): BetterAuthPlugin {
* recipe at node_modules/better-call/dist/crypto.mjs L27-32.
*
* Why inline (not import from better-call): better-call is a transitive
* via better-auth, not a direct dep of apps/server. Inlining a 3-line
* via better-auth, not a direct dep of server/apps/api. Inlining a 3-line
* helper avoids polluting package.json with what is, semantically, an
* internal of better-auth's bearer flow.
*/
@@ -578,7 +578,7 @@ export function createAuth(
// because retrying a partial deletion re-runs already-completed
// handlers as no-ops.
// Source: node_modules/better-auth/dist/api/routes/update-user.mjs L286-380
// Design: apps/server/docs/ai-context/account-deletion.md
// Design: server/apps/api/docs/ai-context/account-deletion.md
deleteUser: {
enabled: true,
async sendDeleteAccountVerification({ user, url }) {
@@ -3,8 +3,8 @@ import type { Env } from './env'
import pg from 'pg'
import { useLogger } from '@guiiai/logg'
import { migrations } from '@proj-airi/drizzle-migration'
import { migrate } from '@proj-airi/drizzle-orm-browser-migrator/pg'
import { migrations } from '@proj-airi/server-schema'
import { drizzle } from 'drizzle-orm/node-postgres'
import * as fullSchema from '../schemas'

Some files were not shown because too many files have changed in this diff Show More