From c2aee68513fbb959c88d8b4d080befb9bafe715b Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Wed, 1 Jul 2026 16:07:33 +0800 Subject: [PATCH] feat: add upstreamVoiceId to voice packs and related services - Introduced upstreamVoiceId field in voice pack schema and database. - Updated voice pack service to handle upstreamVoiceId in CRUD operations. - Modified API routes and tests to accommodate upstreamVoiceId. - Enhanced UI components to include upstreamVoiceId in forms and displays. - Adjusted speech processing logic to utilize upstreamVoiceId where applicable. - Updated related tests to ensure proper functionality with new field. --- apps/server/drizzle/0016_tired_dagger.sql | 3 + apps/server/drizzle/meta/0016_snapshot.json | 3032 +++++++++++++++++ apps/server/drizzle/meta/_journal.json | 9 +- .../routes/admin/voice-packs/route.test.ts | 3 + .../server/src/routes/openai/v1/route.test.ts | 12 +- apps/server/src/routes/voice-packs/index.ts | 16 +- .../src/routes/voice-packs/route.test.ts | 28 +- apps/server/src/schemas/voice-packs.ts | 1 + .../services/domain/openai-speech/index.ts | 71 +- .../services/domain/voice-packs/index.test.ts | 6 + .../src/services/domain/voice-packs/index.ts | 3 + apps/ui-admin/src/modules/api.ts | 2 + apps/ui-admin/src/pages/VoicePackFormPage.vue | 39 +- apps/ui-admin/src/pages/VoicePacksPage.vue | 6 +- .../src/pages/settings/modules/speech.vue | 43 +- .../stage-ui/src/components/scenes/Stage.vue | 2 +- .../src/stores/modules/airi-card.test.ts | 13 +- .../stage-ui/src/stores/modules/airi-card.ts | 16 +- .../src/stores/modules/speech.test.ts | 9 +- .../stage-ui/src/stores/modules/speech.ts | 3 +- 20 files changed, 3221 insertions(+), 96 deletions(-) create mode 100644 apps/server/drizzle/0016_tired_dagger.sql create mode 100644 apps/server/drizzle/meta/0016_snapshot.json diff --git a/apps/server/drizzle/0016_tired_dagger.sql b/apps/server/drizzle/0016_tired_dagger.sql new file mode 100644 index 000000000..53e17da2d --- /dev/null +++ b/apps/server/drizzle/0016_tired_dagger.sql @@ -0,0 +1,3 @@ +ALTER TABLE "voice_packs" ADD COLUMN "upstream_voice_id" text; +UPDATE "voice_packs" SET "upstream_voice_id" = "voice_id"; +ALTER TABLE "voice_packs" ALTER COLUMN "upstream_voice_id" SET NOT NULL; diff --git a/apps/server/drizzle/meta/0016_snapshot.json b/apps/server/drizzle/meta/0016_snapshot.json new file mode 100644 index 000000000..4beb20b2b --- /dev/null +++ b/apps/server/drizzle/meta/0016_snapshot.json @@ -0,0 +1,3032 @@ +{ + "id": "e5ca3143-6f8a-46c8-8f82-114d37d6c475", + "prevId": "d44a483a-4be0-4120-80e2-2f88a978479d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jwks": { + "name": "jwks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_access_token": { + "name": "oauth_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_id": { + "name": "refresh_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_access_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_access_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_session_id_session_id_fk": { + "name": "oauth_access_token_session_id_session_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_access_token_user_id_user_id_fk": { + "name": "oauth_access_token_user_id_user_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_access_token_refresh_id_oauth_refresh_token_id_fk": { + "name": "oauth_access_token_refresh_id_oauth_refresh_token_id_fk", + "tableFrom": "oauth_access_token", + "tableTo": "oauth_refresh_token", + "columnsFrom": [ + "refresh_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_access_token_token_unique": { + "name": "oauth_access_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret": { + "name": "client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "disabled": { + "name": "disabled", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "skip_consent": { + "name": "skip_consent", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enable_end_session": { + "name": "enable_end_session", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "subject_type": { + "name": "subject_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uri": { + "name": "uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contacts": { + "name": "contacts", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "tos": { + "name": "tos", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "policy": { + "name": "policy", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_id": { + "name": "software_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_version": { + "name": "software_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "software_statement": { + "name": "software_statement", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "redirect_uris": { + "name": "redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "post_logout_redirect_uris": { + "name": "post_logout_redirect_uris", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grant_types": { + "name": "grant_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "response_types": { + "name": "response_types", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "require_pkce": { + "name": "require_pkce", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_client_user_id_user_id_fk": { + "name": "oauth_client_user_id_user_id_fk", + "tableFrom": "oauth_client", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "oauth_client_client_id_unique": { + "name": "oauth_client_client_id_unique", + "nullsNotDistinct": false, + "columns": [ + "client_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_consent": { + "name": "oauth_consent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_consent_client_id_oauth_client_client_id_fk": { + "name": "oauth_consent_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_consent_user_id_user_id_fk": { + "name": "oauth_consent_user_id_user_id_fk", + "tableFrom": "oauth_consent", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_refresh_token": { + "name": "oauth_refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session_id": { + "name": "session_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked": { + "name": "revoked", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "auth_time": { + "name": "auth_time", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scopes": { + "name": "scopes", + "type": "text[]", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "oauth_refresh_token_client_id_oauth_client_client_id_fk": { + "name": "oauth_refresh_token_client_id_oauth_client_client_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "oauth_client", + "columnsFrom": [ + "client_id" + ], + "columnsTo": [ + "client_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "oauth_refresh_token_session_id_session_id_fk": { + "name": "oauth_refresh_token_session_id_session_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "session", + "columnsFrom": [ + "session_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "oauth_refresh_token_user_id_user_id_fk": { + "name": "oauth_refresh_token_user_id_user_id_fk", + "tableFrom": "oauth_refresh_token", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.avatar_model": { + "name": "avatar_model", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "avatar_model_character_id_characters_id_fk": { + "name": "avatar_model_character_id_characters_id_fk", + "tableFrom": "avatar_model", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.characters": { + "name": "characters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cover_url": { + "name": "cover_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_id": { + "name": "creator_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_role": { + "name": "creator_role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "price_credit": { + "name": "price_credit", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "likes_count": { + "name": "likes_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bookmarks_count": { + "name": "bookmarks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "interactions_count": { + "name": "interactions_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "forks_count": { + "name": "forks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_capabilities": { + "name": "character_capabilities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_capabilities_character_id_characters_id_fk": { + "name": "character_capabilities_character_id_characters_id_fk", + "tableFrom": "character_capabilities", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_covers": { + "name": "character_covers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "foreground_url": { + "name": "foreground_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "background_url": { + "name": "background_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_covers_character_id_characters_id_fk": { + "name": "character_covers_character_id_characters_id_fk", + "tableFrom": "character_covers", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_i18n": { + "name": "character_i18n", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "character_i18n_character_id_characters_id_fk": { + "name": "character_i18n_character_id_characters_id_fk", + "tableFrom": "character_i18n", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.character_prompts": { + "name": "character_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "character_prompts_character_id_characters_id_fk": { + "name": "character_prompts_character_id_characters_id_fk", + "tableFrom": "character_prompts", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_members": { + "name": "chat_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "member_type": { + "name": "member_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_members_chat_id_chats_id_fk": { + "name": "chat_members_chat_id_chats_id_fk", + "tableFrom": "chat_members", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "media_ids": { + "name": "media_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "sticker_ids": { + "name": "sticker_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "reply_message_id": { + "name": "reply_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forward_from_message_id": { + "name": "forward_from_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sticker_packs": { + "name": "sticker_packs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stickers": { + "name": "stickers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.flux_transaction": { + "name": "flux_transaction", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "balance_before": { + "name": "balance_before", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "balance_after": { + "name": "balance_after", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "flux_tx_user_id_idx": { + "name": "flux_tx_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flux_tx_created_at_idx": { + "name": "flux_tx_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "flux_tx_user_request_uniq": { + "name": "flux_tx_user_request_uniq", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "request_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_flux": { + "name": "user_flux", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "flux": { + "name": "flux", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.llm_request_log": { + "name": "llm_request_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "flux_consumed": { + "name": "flux_consumed", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.product_events": { + "name": "product_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "feature": { + "name": "feature", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "product_events_feature_action_created_at_idx": { + "name": "product_events_feature_action_created_at_idx", + "columns": [ + { + "expression": "feature", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_events_user_id_created_at_idx": { + "name": "product_events_user_id_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "product_events_created_at_idx": { + "name": "product_events_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_provider_configs": { + "name": "system_provider_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "validated": { + "name": "validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "validation_bypassed": { + "name": "validation_bypassed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_provider_configs": { + "name": "user_provider_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "definition_id": { + "name": "definition_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "validated": { + "name": "validated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "validation_bypassed": { + "name": "validation_bypassed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_checkout_session": { + "name": "stripe_checkout_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_session_id": { + "name": "stripe_session_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_total": { + "name": "amount_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "success_url": { + "name": "success_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cancel_url": { + "name": "cancel_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_payment_intent_id": { + "name": "stripe_payment_intent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "flux_credited": { + "name": "flux_credited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_checkout_session_stripe_session_id_unique": { + "name": "stripe_checkout_session_stripe_session_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_session_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_customer": { + "name": "stripe_customer", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_customer_stripe_customer_id_unique": { + "name": "stripe_customer_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_invoice": { + "name": "stripe_invoice", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_invoice_id": { + "name": "stripe_invoice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount_due": { + "name": "amount_due", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "amount_paid": { + "name": "amount_paid", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invoice_url": { + "name": "invoice_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invoice_pdf": { + "name": "invoice_pdf", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "flux_credited": { + "name": "flux_credited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_invoice_stripe_invoice_id_unique": { + "name": "stripe_invoice_stripe_invoice_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_invoice_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.stripe_subscription": { + "name": "stripe_subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_price_id": { + "name": "stripe_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "stripe_subscription_stripe_subscription_id_unique": { + "name": "stripe_subscription_stripe_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_character_bookmarks": { + "name": "user_character_bookmarks", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_character_bookmarks_character_id_characters_id_fk": { + "name": "user_character_bookmarks_character_id_characters_id_fk", + "tableFrom": "user_character_bookmarks", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_character_bookmarks_user_id_character_id_pk": { + "name": "user_character_bookmarks_user_id_character_id_pk", + "columns": [ + "user_id", + "character_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_character_likes": { + "name": "user_character_likes", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "character_id": { + "name": "character_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_character_likes_character_id_characters_id_fk": { + "name": "user_character_likes_character_id_characters_id_fk", + "tableFrom": "user_character_likes", + "tableTo": "characters", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_character_likes_user_id_character_id_pk": { + "name": "user_character_likes_user_id_character_id_pk", + "columns": [ + "user_id", + "character_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.voice_packs": { + "name": "voice_packs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "voice_id": { + "name": "voice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "upstream_voice_id": { + "name": "upstream_voice_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tts_model_id": { + "name": "tts_model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cost_multiplier": { + "name": "cost_multiplier", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index f12e37de9..3a082fea9 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1780498188308, "tag": "0015_concerned_piledriver", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1782847276369, + "tag": "0016_tired_dagger", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/apps/server/src/routes/admin/voice-packs/route.test.ts b/apps/server/src/routes/admin/voice-packs/route.test.ts index 63cc28f06..bfb6f8511 100644 --- a/apps/server/src/routes/admin/voice-packs/route.test.ts +++ b/apps/server/src/routes/admin/voice-packs/route.test.ts @@ -25,6 +25,7 @@ function createService() { provider: 'volcengine', model: 'seed-tts-2.0', voiceId: 'voice-neuro', + upstreamVoiceId: 'voice-neuro-upstream', ttsModelId: 'volcengine/neuro-pool', params: {}, costMultiplier: 1.5, @@ -119,6 +120,7 @@ describe('admin voice packs — CRUD', () => { provider: 'volcengine', model: 'seed-tts-2.0', voiceId: 'voice-neuro', + upstreamVoiceId: 'voice-neuro-upstream', ttsModelId: 'volcengine/neuro-pool', params: { pitch: '+20%' }, costMultiplier: 1.5, @@ -149,6 +151,7 @@ describe('admin voice packs — CRUD', () => { provider: 'volcengine', model: 'seed-tts-2.0', voiceId: 'voice-neuro', + upstreamVoiceId: 'voice-neuro-upstream', ttsModelId: 'volcengine/neuro-pool', params: {}, costMultiplier: -1, diff --git a/apps/server/src/routes/openai/v1/route.test.ts b/apps/server/src/routes/openai/v1/route.test.ts index 24e5b732a..a311d5c6b 100644 --- a/apps/server/src/routes/openai/v1/route.test.ts +++ b/apps/server/src/routes/openai/v1/route.test.ts @@ -710,7 +710,8 @@ describe('v1CompletionsRoutes', () => { description: null, provider: 'azure', model: 'microsoft/v1', - voiceId: 'en-US-AvaMultilingualNeural', + voiceId: 'friendly-azure', + upstreamVoiceId: 'en-US-AvaMultilingualNeural', ttsModelId: 'microsoft/v1', params: {}, costMultiplier: 1.5, @@ -728,12 +729,11 @@ describe('v1CompletionsRoutes', () => { body: JSON.stringify({ model: 'auto', input: 'test', - voice: 'en-US-AvaMultilingualNeural', + voice: 'friendly-azure', speed: 1.2, extra_body: { voice_pack: { pack_id: 'vp-azure', - cost_multiplier: 1.5, pitch: 20, volume: 5, }, @@ -784,7 +784,7 @@ describe('v1CompletionsRoutes', () => { /** * @example - * POST /api/v1/audio/speech { "input": "hello", "extra_body": { "voice_pack": { "cost_multiplier": 2 } } } + * POST /api/v1/audio/speech { "input": "hello", "extra_body": { "voice_pack": { "pack_id": "vp-premium" } } } */ it('uses Voice Pack cost multiplier for affordability and billing units', async () => { globalThis.fetch = vi.fn(async () => new Response(new Uint8Array([1]), { @@ -801,6 +801,7 @@ describe('v1CompletionsRoutes', () => { provider: 'azure', model: 'microsoft/v1', voiceId: 'alloy', + upstreamVoiceId: 'upstream-alloy', ttsModelId: 'tts-1', params: {}, costMultiplier: 2, @@ -832,7 +833,6 @@ describe('v1CompletionsRoutes', () => { extra_body: { voice_pack: { pack_id: 'vp-premium', - cost_multiplier: 2, }, }, }), @@ -868,6 +868,7 @@ describe('v1CompletionsRoutes', () => { provider: 'azure', model: 'microsoft/v1', voiceId: 'alloy', + upstreamVoiceId: 'upstream-alloy', ttsModelId: 'tts-1', params: {}, costMultiplier: 2, @@ -899,7 +900,6 @@ describe('v1CompletionsRoutes', () => { extra_body: { voice_pack: { pack_id: 'vp-premium', - cost_multiplier: 2, }, airi_analytics: { source: 'manual_preview', diff --git a/apps/server/src/routes/voice-packs/index.ts b/apps/server/src/routes/voice-packs/index.ts index b2d7a32c1..3dfdc5e6e 100644 --- a/apps/server/src/routes/voice-packs/index.ts +++ b/apps/server/src/routes/voice-packs/index.ts @@ -5,6 +5,20 @@ import { Hono } from 'hono' import { authGuard } from '../../middlewares/auth' +function publicVoicePack(pack: Awaited>[number]) { + return { + id: pack.id, + name: pack.name, + description: pack.description, + voiceId: pack.voiceId, + params: pack.params, + costMultiplier: pack.costMultiplier, + enabled: pack.enabled, + createdAt: pack.createdAt, + updatedAt: pack.updatedAt, + } +} + /** * User-facing Voice Pack routes. * @@ -17,6 +31,6 @@ export function createVoicePackRoutes(service: VoicePackService) { .use('*', authGuard) .get('/', async (c) => { const packs = await service.listEnabled() - return c.json(packs) + return c.json(packs.map(publicVoicePack)) }) } diff --git a/apps/server/src/routes/voice-packs/route.test.ts b/apps/server/src/routes/voice-packs/route.test.ts index 538051ba1..b0fae11c5 100644 --- a/apps/server/src/routes/voice-packs/route.test.ts +++ b/apps/server/src/routes/voice-packs/route.test.ts @@ -23,7 +23,21 @@ function createTestApp(service: VoicePackService, user: { id: string } | null) { function createService() { return { - listEnabled: vi.fn(async () => [{ id: 'vp-1', name: 'Enabled', enabled: true }]), + listEnabled: vi.fn(async () => [{ + id: 'vp-1', + name: 'Enabled', + description: 'Public description', + provider: 'azure', + model: 'microsoft/v1', + voiceId: 'friendly-voice', + upstreamVoiceId: 'en-US-AvaMultilingualNeural', + ttsModelId: 'microsoft/v1', + params: { pitch: '+10%' }, + costMultiplier: 2, + enabled: true, + createdAt: new Date('2026-01-01T00:00:00.000Z'), + updatedAt: new Date('2026-01-02T00:00:00.000Z'), + }]), list: vi.fn(), create: vi.fn(), update: vi.fn(), @@ -50,7 +64,17 @@ describe('voice packs routes', () => { const res = await app.request('/api/v1/voice-packs') expect(res.status).toBe(200) - expect(await res.json()).toEqual([{ id: 'vp-1', name: 'Enabled', enabled: true }]) + expect(await res.json()).toEqual([{ + id: 'vp-1', + name: 'Enabled', + description: 'Public description', + voiceId: 'friendly-voice', + params: { pitch: '+10%' }, + costMultiplier: 2, + enabled: true, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }]) expect(service.listEnabled).toHaveBeenCalled() }) }) diff --git a/apps/server/src/schemas/voice-packs.ts b/apps/server/src/schemas/voice-packs.ts index c5b0e1f4f..26ae54787 100644 --- a/apps/server/src/schemas/voice-packs.ts +++ b/apps/server/src/schemas/voice-packs.ts @@ -16,6 +16,7 @@ export const voicePacks = pgTable( provider: text('provider').notNull(), model: text('model').notNull(), voiceId: text('voice_id').notNull(), + upstreamVoiceId: text('upstream_voice_id').notNull(), ttsModelId: text('tts_model_id').notNull(), params: jsonb('params').notNull().$type().default({}), costMultiplier: real('cost_multiplier').notNull().default(1), diff --git a/apps/server/src/services/domain/openai-speech/index.ts b/apps/server/src/services/domain/openai-speech/index.ts index 139eed93c..63eee7f75 100644 --- a/apps/server/src/services/domain/openai-speech/index.ts +++ b/apps/server/src/services/domain/openai-speech/index.ts @@ -90,20 +90,23 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { async function handleSpeechRequest(input: OpenAiSpeechRequest): Promise { const requestId = nanoid() - let requestModel = typeof input.body.model === 'string' ? input.body.model : 'auto' + const requestedModel = typeof input.body.model === 'string' ? input.body.model : 'auto' + let requestModel = requestedModel + const requestVoice = typeof input.body.voice === 'string' ? input.body.voice : undefined const inputText = typeof input.body.input === 'string' ? input.body.input : '' const analytics = ttsAnalyticsContext(input.body) - if (requestModel === 'auto') - requestModel = await deps.configKV.getOrThrow('DEFAULT_TTS_MODEL') - const voicePackRequest = await voicePackRequestOptions(input.body, { - model: requestModel, - voice: typeof input.body.voice === 'string' ? input.body.voice : undefined, + requestedModel, + voice: requestVoice, voicePackService: deps.voicePackService, }) + requestModel = voicePackRequest.model ?? requestModel + if (requestModel === 'auto') + requestModel = await deps.configKV.getOrThrow('DEFAULT_TTS_MODEL') + const routedVoice = voicePackRequest.voice ?? requestVoice const voiceMetadata = ttsVoiceMetadata({ - voice: typeof input.body.voice === 'string' ? input.body.voice : undefined, + voice: requestVoice, voicePackId: voicePackRequest.voicePackId, voiceType: analytics.voiceType, }) @@ -114,7 +117,7 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { userId: input.userId, model: requestModel, inputChars: inputText.length, - voice: typeof input.body.voice === 'string' ? input.body.voice : undefined, + voice: requestVoice, }).log('tts speech request') void deps.productEventService.track({ @@ -171,7 +174,7 @@ export function createOpenAiSpeechService(deps: OpenAiSpeechServiceDeps) { const ttsInput = { text: inputText, - voice: typeof input.body.voice === 'string' ? input.body.voice : undefined, + voice: routedVoice, speed: typeof input.body.speed === 'number' ? input.body.speed : undefined, responseFormat: typeof input.body.response_format === 'string' ? input.body.response_format : undefined, extraOptions: voicePackRequest.extraOptions, @@ -405,19 +408,22 @@ function ttsVoiceMetadata(input: { async function voicePackRequestOptions( body: Record, context: { - model: string + requestedModel: string voice?: string voicePackService: VoicePackService }, -): Promise<{ extraOptions: Record | undefined, costMultiplier: number, voicePackId?: string }> { +): Promise<{ + extraOptions: Record | undefined + costMultiplier: number + voicePackId?: string + model?: string + voice?: string +}> { const extraBody = asRecord(body.extra_body) const voicePackOptions = asRecord(extraBody?.voice_pack) const pitch = readOptionalNumber(voicePackOptions, 'pitch') const volume = readOptionalNumber(voicePackOptions, 'volume') - const costMultiplier = await resolveVoicePackCostMultiplier(voicePackOptions, context) - const voicePackId = typeof voicePackOptions?.pack_id === 'string' && voicePackOptions.pack_id.trim() - ? voicePackOptions.pack_id - : undefined + const voicePack = await resolveVoicePackRequest(voicePackOptions, context) const extraOptions: Record = {} if (pitch != null) extraOptions.pitch = pitch @@ -426,40 +432,49 @@ async function voicePackRequestOptions( return { extraOptions: Object.keys(extraOptions).length > 0 ? extraOptions : undefined, - costMultiplier, - voicePackId, + costMultiplier: voicePack?.costMultiplier ?? 1, + voicePackId: voicePack?.id, + model: voicePack?.ttsModelId, + voice: voicePack?.upstreamVoiceId, } } -async function resolveVoicePackCostMultiplier( +async function resolveVoicePackRequest( voicePackOptions: Record | undefined, context: { - model: string + requestedModel: string voice?: string voicePackService: VoicePackService }, -): Promise { +): Promise> | null> { const packId = voicePackOptions?.pack_id - const value = voicePackOptions?.cost_multiplier - if (packId == null && value == null) - return 1 + if (voicePackOptions?.cost_multiplier != null) { + throw createBadRequestError('voice_pack.cost_multiplier is server-managed', 'INVALID_VOICE_PACK', { + field: 'voice_pack.cost_multiplier', + }) + } + if (packId == null) + return null if (typeof packId !== 'string' || !packId.trim()) throw createBadRequestError('voice_pack.pack_id is required when Voice Pack billing metadata is provided', 'INVALID_VOICE_PACK') const pack = await context.voicePackService.findById(packId) if (!pack) throw createBadRequestError('Voice Pack not found', 'INVALID_VOICE_PACK', { packId }) - if (pack.ttsModelId !== context.model || pack.voiceId !== context.voice) { + if (context.requestedModel !== 'auto' && pack.ttsModelId !== context.requestedModel) { + throw createBadRequestError('Voice Pack does not match requested model and voice', 'INVALID_VOICE_PACK', { + packId, + actualModel: context.requestedModel, + }) + } + if (pack.voiceId !== context.voice) { throw createBadRequestError('Voice Pack does not match requested model and voice', 'INVALID_VOICE_PACK', { packId, - expectedModel: pack.ttsModelId, - actualModel: context.model, - expectedVoice: pack.voiceId, actualVoice: context.voice, }) } - return pack.costMultiplier + return pack } function routerFailure(error: unknown): { status: number, reason: string, message: string } { diff --git a/apps/server/src/services/domain/voice-packs/index.test.ts b/apps/server/src/services/domain/voice-packs/index.test.ts index 97ed4acd0..851c076df 100644 --- a/apps/server/src/services/domain/voice-packs/index.test.ts +++ b/apps/server/src/services/domain/voice-packs/index.test.ts @@ -27,6 +27,7 @@ describe('voicePackService', () => { provider: 'volcengine', model: 'seed-tts-2.0', voiceId: 'voice-neuro', + upstreamVoiceId: 'voice-neuro-upstream', ttsModelId: 'volcengine/neuro-pool', params: { pitch: '+20%', volume: '+5%' }, costMultiplier: 1.5, @@ -37,6 +38,7 @@ describe('voicePackService', () => { expect(pack.provider).toBe('volcengine') expect(pack.model).toBe('seed-tts-2.0') expect(pack.voiceId).toBe('voice-neuro') + expect(pack.upstreamVoiceId).toBe('voice-neuro-upstream') expect(pack.ttsModelId).toBe('volcengine/neuro-pool') expect(pack.params).toEqual({ pitch: '+20%', volume: '+5%' }) expect(pack.costMultiplier).toBe(1.5) @@ -50,6 +52,7 @@ describe('voicePackService', () => { provider: 'volcengine', model: 'seed-tts-2.0', voiceId: 'voice-a', + upstreamVoiceId: 'voice-a-upstream', ttsModelId: 'volcengine/pool', params: {}, costMultiplier: 1, @@ -60,6 +63,7 @@ describe('voicePackService', () => { provider: 'volcengine', model: 'seed-tts-2.0', voiceId: 'voice-a', + upstreamVoiceId: 'voice-a-upstream', ttsModelId: 'volcengine/pool', params: { pitch: '+20%' }, costMultiplier: 1, @@ -78,6 +82,7 @@ describe('voicePackService', () => { provider: 'azure', model: 'v1', voiceId: 'en-US-AvaMultilingualNeural', + upstreamVoiceId: 'en-US-AvaMultilingualNeural', ttsModelId: 'microsoft/v1', params: {}, costMultiplier: 1, @@ -103,6 +108,7 @@ describe('voicePackService', () => { provider: 'dashscope-cosyvoice', model: 'cosyvoice-v2', voiceId: 'longxiaochun_v2', + upstreamVoiceId: 'longxiaochun_v2', ttsModelId: 'alibaba/cosyvoice-v2', params: {}, costMultiplier: 1, diff --git a/apps/server/src/services/domain/voice-packs/index.ts b/apps/server/src/services/domain/voice-packs/index.ts index bab712477..efa8c472c 100644 --- a/apps/server/src/services/domain/voice-packs/index.ts +++ b/apps/server/src/services/domain/voice-packs/index.ts @@ -24,6 +24,7 @@ export const CreateVoicePackInputSchema = object({ provider: pipe(string(), nonEmpty('provider is required'), maxLength(100)), model: pipe(string(), nonEmpty('model is required'), maxLength(200)), voiceId: pipe(string(), nonEmpty('voiceId is required'), maxLength(200)), + upstreamVoiceId: pipe(string(), nonEmpty('upstreamVoiceId is required'), maxLength(200)), ttsModelId: pipe(string(), nonEmpty('ttsModelId is required'), maxLength(200)), params: optional(VoicePackParamsSchema, {}), costMultiplier: VoicePackCostMultiplierSchema, @@ -36,6 +37,7 @@ export const UpdateVoicePackInputSchema = object({ provider: optional(pipe(string(), nonEmpty('provider must not be empty'), maxLength(100))), model: optional(pipe(string(), nonEmpty('model must not be empty'), maxLength(200))), voiceId: optional(pipe(string(), nonEmpty('voiceId must not be empty'), maxLength(200))), + upstreamVoiceId: optional(pipe(string(), nonEmpty('upstreamVoiceId must not be empty'), maxLength(200))), ttsModelId: optional(pipe(string(), nonEmpty('ttsModelId must not be empty'), maxLength(200))), params: optional(VoicePackParamsSchema), costMultiplier: optional(VoicePackCostMultiplierSchema), @@ -74,6 +76,7 @@ export function createVoicePackService(db: Database) { provider: input.provider, model: input.model, voiceId: input.voiceId, + upstreamVoiceId: input.upstreamVoiceId, ttsModelId: input.ttsModelId, params: input.params, costMultiplier: input.costMultiplier, diff --git a/apps/ui-admin/src/modules/api.ts b/apps/ui-admin/src/modules/api.ts index 8c30de26d..0ad31dbbf 100644 --- a/apps/ui-admin/src/modules/api.ts +++ b/apps/ui-admin/src/modules/api.ts @@ -180,6 +180,7 @@ export interface VoicePack { provider: string model: string voiceId: string + upstreamVoiceId: string ttsModelId: string params: VoicePackParams costMultiplier: number @@ -194,6 +195,7 @@ export interface VoicePackPayload { provider: string model: string voiceId: string + upstreamVoiceId: string ttsModelId: string params?: VoicePackParams costMultiplier: number diff --git a/apps/ui-admin/src/pages/VoicePackFormPage.vue b/apps/ui-admin/src/pages/VoicePackFormPage.vue index dc311ee7b..b725acb12 100644 --- a/apps/ui-admin/src/pages/VoicePackFormPage.vue +++ b/apps/ui-admin/src/pages/VoicePackFormPage.vue @@ -39,6 +39,7 @@ const form = reactive({ provider: '', model: '', voiceId: '', + upstreamVoiceId: '', ttsModelId: '', paramsJson: DEFAULT_PARAMS, costMultiplier: 1, @@ -113,7 +114,9 @@ const formError = computed(() => { if (!form.ttsModelId.trim()) return 'TTS model ID is required' if (!form.voiceId.trim()) - return 'Voice ID is required' + return 'Voice alias is required' + if (!form.upstreamVoiceId.trim()) + return 'Upstream voice ID is required' if (!Number.isFinite(Number(form.costMultiplier)) || Number(form.costMultiplier) < 0) return 'Cost multiplier must be a non-negative number' return null @@ -125,7 +128,7 @@ onMounted(async () => { fillSelectedPack() else resetForm() - await loadVoices(form.ttsModelId, { autoPick: !form.voiceId.trim() }) + await loadVoices(form.ttsModelId, { autoPick: !form.upstreamVoiceId.trim() }) modelChangeVoiceLoadingEnabled.value = true }) @@ -194,8 +197,11 @@ async function loadVoices(model: string, options: { autoPick: boolean }) { const result = await adminApi.speechVoices(model.trim()) voices.value = result.voices recommendedVoices.value = result.recommended - if (options.autoPick && !form.voiceId.trim()) - form.voiceId = firstRecommendedVoiceId(result.recommended) ?? result.voices[0]?.id ?? '' + if (options.autoPick && !form.upstreamVoiceId.trim()) { + form.upstreamVoiceId = firstRecommendedVoiceId(result.recommended) ?? result.voices[0]?.id ?? '' + if (!form.voiceId.trim()) + form.voiceId = form.upstreamVoiceId + } } catch (error) { voices.value = [] @@ -223,6 +229,7 @@ function fillForm(pack: VoicePack) { form.provider = pack.provider form.model = pack.model form.voiceId = pack.voiceId + form.upstreamVoiceId = pack.upstreamVoiceId form.ttsModelId = pack.ttsModelId form.paramsJson = JSON.stringify(pack.params ?? {}, null, 2) form.costMultiplier = pack.costMultiplier @@ -238,6 +245,7 @@ function resetForm() { form.provider = modelParts.provider form.model = modelParts.model form.voiceId = '' + form.upstreamVoiceId = '' form.ttsModelId = modelId form.paramsJson = DEFAULT_PARAMS form.costMultiplier = 1 @@ -279,6 +287,7 @@ function payload(): VoicePackPayload { provider: form.provider.trim(), model: form.model.trim(), voiceId: form.voiceId.trim(), + upstreamVoiceId: form.upstreamVoiceId.trim(), ttsModelId: form.ttsModelId.trim(), params: parseParams(), costMultiplier: Number(form.costMultiplier), @@ -340,7 +349,7 @@ async function testVoicePack() { const body = { model: form.ttsModelId.trim(), input: text, - voice: form.voiceId.trim(), + voice: form.upstreamVoiceId.trim(), speed: normalizeRateOption(params.rate), extra_body: voicePackExtraBody(params), } @@ -494,19 +503,27 @@ function normalizeRateOption(value: string | number | boolean | null | undefined :placeholder="ttsModelPlaceholder" required /> -
+ Name Routing - Voice + Voice Alias + Upstream Voice Cost Status Updated @@ -118,6 +119,9 @@ function editPack(pack: VoicePack) { {{ pack.voiceId }} + + {{ pack.upstreamVoiceId }} + {{ formatMultiplier(pack.costMultiplier) }} diff --git a/packages/stage-pages/src/pages/settings/modules/speech.vue b/packages/stage-pages/src/pages/settings/modules/speech.vue index 4979695a6..d000272b4 100644 --- a/packages/stage-pages/src/pages/settings/modules/speech.vue +++ b/packages/stage-pages/src/pages/settings/modules/speech.vue @@ -75,6 +75,8 @@ const errorMessage = ref('') const selectedSpeechSource = ref(null) const VOICE_PACK_SOURCE_ID = 'voice-pack' +const VOICE_PACK_REQUEST_MODEL_ID = 'auto' +const VOICE_PACK_ANALYTICS_MODEL_ID = 'voice_pack' const STREAMING_MODEL_OPTION_PREFIX = 'streaming:' const isOfficialSpeechProvider = computed(() => activeSpeechProvider.value === OFFICIAL_SPEECH_PROVIDER_ID) @@ -198,6 +200,15 @@ function createVoicePackVoice(voicePack: VoicePackSnapshot): VoiceInfo { } } +function formatVoicePackCostMultiplier(costMultiplier: number) { + return `Flux cost: ${costMultiplier}x` +} + +function voicePackDescription(description: string | null | undefined, costMultiplier: number) { + const cost = formatVoicePackCostMultiplier(costMultiplier) + return description ? `${description} · ${cost}` : cost +} + function voicePackVoiceId(packId: string) { return `voice-pack:${packId}` } @@ -211,7 +222,7 @@ const displayedVoiceOptions = computed(() => { const options = voicePacks.value.map(pack => ({ id: voicePackVoiceId(pack.id), name: pack.name, - description: pack.description ?? undefined, + description: voicePackDescription(pack.description, pack.costMultiplier), previewURL: '', customizable: false, })) @@ -221,7 +232,7 @@ const displayedVoiceOptions = computed(() => { options.unshift({ id: frozenVoiceId, name: voicePack.name, - description: voicePack.name, + description: voicePackDescription(voicePack.name, voicePack.costMultiplier), previewURL: '', customizable: false, }) @@ -265,6 +276,12 @@ const currentSpeechVoiceId = computed(() => { return activeSpeechVoiceId.value || '' }) +const currentVoicePackCostMultiplier = computed(() => { + if (isVoicePackSourceSelected.value && boundVoicePack.value) + return formatVoicePackCostMultiplier(boundVoicePack.value.costMultiplier) + return '' +}) + function syncBoundVoicePackSelection() { const voicePack = boundVoicePack.value if (!voicePack) @@ -272,7 +289,7 @@ function syncBoundVoicePackSelection() { selectedSpeechSource.value = VOICE_PACK_SOURCE_ID activeSpeechProvider.value = OFFICIAL_SPEECH_PROVIDER_ID - activeSpeechModel.value = voicePack.ttsModelId + activeSpeechModel.value = VOICE_PACK_REQUEST_MODEL_ID activeSpeechVoiceId.value = voicePack.voiceId activeSpeechVoice.value = createVoicePackVoice(voicePack) return true @@ -283,7 +300,7 @@ function syncBoundVoicePackSelection() { */ function currentTtsModelId() { if (isVoicePackSourceSelected.value && boundVoicePack.value) - return boundVoicePack.value.ttsModelId + return VOICE_PACK_ANALYTICS_MODEL_ID return activeSpeechModel.value || 'unknown' } @@ -396,7 +413,7 @@ function selectSpeechSource(sourceId: string) { activeSpeechProvider.value = OFFICIAL_SPEECH_PROVIDER_ID const voicePack = boundVoicePack.value if (voicePack) { - activeSpeechModel.value = voicePack.ttsModelId + activeSpeechModel.value = VOICE_PACK_REQUEST_MODEL_ID activeSpeechVoiceId.value = voicePack.voiceId activeSpeechVoice.value = createVoicePackVoice(voicePack) return @@ -481,12 +498,12 @@ async function bindVoicePack(pack: (typeof voicePacks.value)[number]) { selectedSpeechSource.value = VOICE_PACK_SOURCE_ID activeSpeechProvider.value = OFFICIAL_SPEECH_PROVIDER_ID - activeSpeechModel.value = pack.ttsModelId + activeSpeechModel.value = VOICE_PACK_REQUEST_MODEL_ID activeSpeechVoiceId.value = pack.voiceId activeSpeechVoice.value = { id: pack.voiceId, name: pack.name, - description: pack.description ?? pack.name, + description: voicePackDescription(pack.description ?? pack.name, pack.costMultiplier), previewURL: '', languages: [{ code: 'en', title: 'English' }], provider: activeSpeechProvider.value, @@ -495,14 +512,14 @@ async function bindVoicePack(pack: (typeof voicePacks.value)[number]) { trackVoicePackBound({ tts_provider_id: activeSpeechProvider.value || 'unknown', - tts_model_id: pack.ttsModelId, + tts_model_id: VOICE_PACK_ANALYTICS_MODEL_ID, voice_id: pack.voiceId, voice_pack_id: pack.id, source: 'settings', }) trackVoiceSelected({ tts_provider_id: activeSpeechProvider.value || 'unknown', - tts_model_id: pack.ttsModelId, + tts_model_id: VOICE_PACK_ANALYTICS_MODEL_ID, voice_id: pack.voiceId, voice_type: 'voice_pack', voice_pack_id: pack.id, @@ -603,7 +620,7 @@ async function generateTestSpeech() { const voicePack = boundVoicePack.value if (voicePack) { - model = voicePack.ttsModelId + model = VOICE_PACK_REQUEST_MODEL_ID if (!voice || voice.id !== voicePack.voiceId) voice = createVoicePackVoice(voicePack) } @@ -928,7 +945,10 @@ function handleDeleteProvider(providerId: string) {
Customize how your AI assistant speaks - Current voice: {{ currentSpeechVoiceId }} + + Current voice: {{ currentSpeechVoiceId }} + · {{ currentVoicePackCostMultiplier }} +
@@ -964,7 +984,6 @@ function handleDeleteProvider(providerId: string) { ({ const voicePack = voicePackForSpeechProvider(activeSpeechProvider.value, activeCard.value?.extensions.airi.modules.speech.voicePack) if (voicePack) { - model = voicePack.ttsModelId + model = 'auto' if (!voice || voice.id !== voicePack.voiceId) voice = createVoicePackVoice(voicePack) } diff --git a/packages/stage-ui/src/stores/modules/airi-card.test.ts b/packages/stage-ui/src/stores/modules/airi-card.test.ts index 0c53482b9..09cfa15b9 100644 --- a/packages/stage-ui/src/stores/modules/airi-card.test.ts +++ b/packages/stage-ui/src/stores/modules/airi-card.test.ts @@ -117,10 +117,7 @@ describe('airi-card store', () => { const pack = { id: 'vp-1', name: 'Neuro Sama', - provider: 'volcengine', - model: 'seed-tts-2.0', voiceId: 'voice-neuro', - ttsModelId: 'volcengine/neuro-pool', params: { pitch: '+20%', volume: '+5%' }, costMultiplier: 1.5, } @@ -130,15 +127,12 @@ describe('airi-card store', () => { expect(bound).toBe(true) expect(cardStore.activeCard?.extensions.airi.modules.speech).toMatchObject({ provider: OFFICIAL_SPEECH_PROVIDER_ID, - model: 'volcengine/neuro-pool', + model: 'auto', voice_id: 'voice-neuro', voicePack: { packId: 'vp-1', name: 'Neuro Sama', - provider: 'volcengine', - model: 'seed-tts-2.0', voiceId: 'voice-neuro', - ttsModelId: 'volcengine/neuro-pool', params: { pitch: '+20%', volume: '+5%' }, costMultiplier: 1.5, }, @@ -157,12 +151,9 @@ describe('airi-card store', () => { cardStore.bindVoicePackToActiveCard({ id: 'vp-1', name: 'Frozen', - provider: 'volcengine', - model: 'seed-tts-2.0', voiceId: 'voice-a', - ttsModelId: 'volcengine/pool-a', params, - costMultiplier: 1, + costMultiplier: 2, }) params.pitch = '-10%' diff --git a/packages/stage-ui/src/stores/modules/airi-card.ts b/packages/stage-ui/src/stores/modules/airi-card.ts index a9f1a8869..adab3cf6c 100644 --- a/packages/stage-ui/src/stores/modules/airi-card.ts +++ b/packages/stage-ui/src/stores/modules/airi-card.ts @@ -23,10 +23,7 @@ export type VoicePackParams = Record export interface VoicePackBindingInput { id: string name: string - provider: string - model: string voiceId: string - ttsModelId: string params: VoicePackParams costMultiplier: number } @@ -34,10 +31,7 @@ export interface VoicePackBindingInput { export interface VoicePackSnapshot { packId: string name: string - provider: string - model: string voiceId: string - ttsModelId: string params: VoicePackParams costMultiplier: number } @@ -210,8 +204,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { return updateActiveCardModules(({ modules }) => { const existingVoicePack = modules.speech.voicePack const shouldKeepVoicePack = speech.provider === OFFICIAL_SPEECH_PROVIDER_ID - && existingVoicePack?.ttsModelId === speech.model - && existingVoicePack.voiceId === speech.voice_id + && existingVoicePack?.voiceId === speech.voice_id return { speech: { @@ -368,10 +361,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { const voicePack: VoicePackSnapshot = { packId: pack.id, name: pack.name, - provider: pack.provider, - model: pack.model, voiceId: pack.voiceId, - ttsModelId: pack.ttsModelId, params: { ...pack.params }, costMultiplier: pack.costMultiplier, } @@ -379,7 +369,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { const speech: AiriExtension['modules']['speech'] = { ...extension.modules.speech, provider: OFFICIAL_SPEECH_PROVIDER_ID, - model: pack.ttsModelId, + model: 'auto', voice_id: pack.voiceId, voicePack, } @@ -399,7 +389,7 @@ export const useAiriCardStore = defineStore('airi-card', () => { }) activeSpeechProvider.value = OFFICIAL_SPEECH_PROVIDER_ID - activeSpeechModel.value = pack.ttsModelId + activeSpeechModel.value = 'auto' activeSpeechVoiceId.value = pack.voiceId return true diff --git a/packages/stage-ui/src/stores/modules/speech.test.ts b/packages/stage-ui/src/stores/modules/speech.test.ts index 3560d8ba8..33c37e108 100644 --- a/packages/stage-ui/src/stores/modules/speech.test.ts +++ b/packages/stage-ui/src/stores/modules/speech.test.ts @@ -66,10 +66,7 @@ describe('speech store helpers', () => { const voicePack = { packId: 'vp-1', name: 'Frozen', - provider: 'volcengine', - model: 'seed-tts-2.0', voiceId: 'voice-a', - ttsModelId: 'volcengine/pool-a', params: {}, costMultiplier: 1, } @@ -198,9 +195,9 @@ describe('speech store helpers', () => { /** * @example - * speechStore.resolveVoicePackSpeechInput({ text, voice, voicePack: { packId: 'vp-1', costMultiplier: 1.5 } }) + * speechStore.resolveVoicePackSpeechInput({ text, voice, voicePack: { packId: 'vp-1' } }) */ - it('passes Voice Pack snapshot billing metadata through adapter options', () => { + it('passes only Voice Pack identity through adapter options', () => { const speechStore = useSpeechStore() const voice = { id: 'voice-1', @@ -215,7 +212,6 @@ describe('speech store helpers', () => { params: {}, voicePack: { packId: 'vp-1', - costMultiplier: 1.5, }, supportsAdapterProsody: true, }) @@ -223,7 +219,6 @@ describe('speech store helpers', () => { expect(request.providerConfig.extraBody).toEqual({ voice_pack: { pack_id: 'vp-1', - cost_multiplier: 1.5, }, }) }) diff --git a/packages/stage-ui/src/stores/modules/speech.ts b/packages/stage-ui/src/stores/modules/speech.ts index 95722252c..651c9a299 100644 --- a/packages/stage-ui/src/stores/modules/speech.ts +++ b/packages/stage-ui/src/stores/modules/speech.ts @@ -29,7 +29,7 @@ interface VoicePackSpeechInputOptions { voice: VoiceInfo providerConfig?: Record params?: VoicePackParams - voicePack?: Pick + voicePack?: Pick forceSSML?: boolean supportsSSML?: boolean supportsAdapterProsody?: boolean @@ -517,7 +517,6 @@ export const useSpeechStore = defineStore('speech', () => { ...(providerConfig.extraBody as Record | undefined), voice_pack: { pack_id: options.voicePack.packId, - cost_multiplier: options.voicePack.costMultiplier, ...(needsProsody && options.supportsAdapterProsody ? { pitch, volume } : {}),