切换 Tiantoken 并删除旧 Vidu 音效
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 5m31s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 5m31s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 5m47s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 5m48s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m43s
Project CI / AI game creator shell Rust crates (push) Successful in 2m3s
Project CI / Repository checks (push) Successful in 3m43s
Project CI / Frontend tests (push) Successful in 4m53s
Project CI / Native shell tests (push) Successful in 6m35s
Project CI / Backend tests (push) Successful in 7m25s
Project CI / AI game creator shell web tests (push) Successful in 2m35s

新增 TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY 配置并切换文本与图片链路

保留 Suno VectorEngine 路径,删除旧 Vidu submit/poll/builder 实现

同步脚本、文档、测试与运行时 provider 路由
This commit is contained in:
2026-09-15 19:43:35 +08:00
parent 687bad0adf
commit 29e84a77d1
32 changed files with 217 additions and 843 deletions
+10 -5
View File
@@ -1,8 +1,8 @@
# Server-side OpenAI-compatible LLM endpoint base URL.
LLM_BASE_URL="https://api.vectorengine.cn/v1"
LLM_BASE_URL="https://api.tiantoken.com/v1"
# Server-side API key used by the local Vite proxy.
# Recommended: set `LLM_API_KEY` locally, or use `VECTOR_ENGINE_API_KEY`
# Recommended: set `LLM_API_KEY` locally, or use `TIANTOKEN_API_KEY`
# through the Rust api-server proxy.
# Legacy compatibility: `VITE_LLM_API_KEY` is still supported by the proxy,
# but it should not be relied on by browser code.
@@ -122,7 +122,7 @@ WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=""
# Model name for chat completions.
VITE_LLM_MODEL="gpt-5.4-mini"
GENARRATIVE_LLM_PROVIDER="openai-compatible"
GENARRATIVE_LLM_BASE_URL="https://api.vectorengine.cn/v1"
GENARRATIVE_LLM_BASE_URL="https://api.tiantoken.com/v1"
GENARRATIVE_LLM_API_KEY=""
GENARRATIVE_LLM_MODEL="gpt-5.4-mini"
@@ -130,10 +130,15 @@ GENARRATIVE_LLM_MODEL="gpt-5.4-mini"
DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1"
DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY"
# VectorEngine LLM and GPT-image-2 / Gemini image generation config.
# Tiantoken LLM and GPT-image-2 / Gemini image generation config.
TIANTOKEN_BASE_URL="https://api.tiantoken.com"
TIANTOKEN_API_KEY=""
TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS="1000000"
# VectorEngine is retained for Suno audio generation only.
VECTOR_ENGINE_BASE_URL="https://api.vectorengine.cn"
VECTOR_ENGINE_API_KEY=""
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS="1000000"
VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS="180000"
# ElevenLabs editor sound-effect generation is server-side only.
ELEVENLABS_BASE_URL="https://api.elevenlabs.io"
+1 -1
View File
@@ -62,7 +62,7 @@ GENARRATIVE_SPACETIME_POOL_SIZE=8
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45
GENARRATIVE_LLM_PROVIDER=openai-compatible
GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1
GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1
GENARRATIVE_LLM_API_KEY=
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
+4 -2
View File
@@ -79,7 +79,7 @@ GENARRATIVE_SPACETIME_POOL_SIZE=8
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45
GENARRATIVE_LLM_PROVIDER=openai-compatible
GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1
GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1
GENARRATIVE_LLM_API_KEY=
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
# LLM Router 正式账号链路:production 固定使用官方地址/模型;管理员 Token 只读受保护文件。
@@ -87,9 +87,11 @@ GENARRATIVE_LLM_ROUTER_BASE_URL=https://router.genarrative.world/v1
GENARRATIVE_LLM_ROUTER_PROVISIONING_SECRET_FILE=/etc/genarrative/secrets/llm-router-provisioning.secret
GENARRATIVE_LLM_ROUTER_API_KEY_ENCRYPTION_SECRET_FILE=/etc/genarrative/secrets/llm-router-api-key-encryption.secret
GENARRATIVE_LLM_ROUTER_ADMIN_TOKEN_FILE=/etc/genarrative/secrets/llm-router-admin.token
TIANTOKEN_BASE_URL=https://api.tiantoken.com
TIANTOKEN_API_KEY=
TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS=1000000
VECTOR_ENGINE_BASE_URL=https://api.vectorengine.cn
VECTOR_ENGINE_API_KEY=
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1000000
VECTOR_ENGINE_AUDIO_REQUEST_TIMEOUT_MS=180000
ELEVENLABS_BASE_URL=https://api.elevenlabs.io
ELEVENLABS_API_KEY=
@@ -8740,3 +8740,14 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 决策(面板可关 + 非模态任务面板):两块生成浮层在提交期间放开 × / 遮罩 / Esc,提交按钮旁给「后台运行并关闭」;**关闭 ≠ 取消**(表单的 `await` 挂在该任务的终局上,不是面板生命周期)。新增「生成任务」非模态浮层(不铺遮罩、不做焦点陷阱、**不进** `isResourceCanvasFloatingPanelOpen` / `resourceCanvasHostGenerationPanelOpen` 遮挡判据),入口按钮 `aria-label="生成任务"`;已完成的条目按 `assetId` 复用既有 `pendingResourceFocusRef` 聚焦链定位素材卡。
- 影响范围:新增 `apps/ai-game-creator-shell/src-tauri/src/asset_generation_tasks.rs`+ `main.rs` 注册)、`src/features/resource-canvas/{resourceCanvasAssetGenerationTaskModel.ts,resourceCanvasAssetGenerationQueue.ts,ResourceCanvasAssetGenerationTasksPanelView.tsx}`;改动 `ResourceCanvasAssetGenerationPanelView.tsx` / `ResourceCanvasGenerationPanelView.tsx` / `src/view/project-development/index.tsx`;测试改动 `tests/{resourceCanvasAssetGenerationBackgroundClose.test.tsx,resourceCanvasAssetGenerationQueue.test.ts,resourceCanvasAssetGenerationTasksPanel.test.tsx}`(新增)与 `tests/appSurface/project-development.suite.ts`(把「每个入口一次 `generate_local_project_asset`」改成 `start_local_project_asset_generation` + `list_...` 轮询桩,载荷断言逐字不变)。**未动**external v1 / OpenAPI、`packages/`、SpacetimeDB、音频入口的 pending-edit 账本语义、生成参数与 IPC 载荷字段名。
- 关联文档:`docs/technical/【AGC】栏目画布底部工具栏入口矩阵-2026-09-13.md`(§4 / §4a / §8)、`docs/technical/【测试用例】AGC资源工作台V3端到端验收-2026-09-11.md`S11a / §7.3)。
## 2026-09-15 非 Suno 的 VectorEngine 能力切换到 Tiantoken
- 决策:新增本地私密环境变量 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY`(图片 timeout 可独立配置),承载原 VectorEngine 的文本和图片;`VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 仅保留给 Suno 背景音乐与 Suno 音效。编辑器 SFX V2 继续走 ElevenLabs。
- 实现边界:api-server 在创建状态时冻结 Tiantoken 配置,LLM、图片和旧版非 Suno 音频按该配置路由;Suno 的提交 / 轮询仍使用旧 VectorEngine 配置。旧 `vector_engine_*` 测试构造保留为 Tiantoken fallback,生产新环境变量优先。
- 验证:Tiantoken `/v1/models` 返回 HTTP 200126 个模型,含 `gpt-image-2``gpt-5.4-mini`);api-server Tiantoken 配置单测、platform-audio 全量测试、图片定向测试、前端 `apiClient` 定向测试、`npm run typecheck``npm run check:api-server-env`、编码 / fmt / diff 检查通过。未对音频上游提交生成任务,模型列表未列出 audio / Vidu 条目。
## 2026-09-15 删除旧版 Vidu 音效实现
- 决策:旧版 Vidu `audio1.0` 的 submit / poll / download builder、旧视觉小说与创建音效死代码、对应 platform-audio 请求类型和测试全部删除。历史素材的 `audio1.0` 展示与定价兼容数据保留;新编辑器音效仍只走 ElevenLabs,Suno 音乐链路不变。
- 验证:platform-audio 全量测试 55 条通过,api-server `cargo check` 通过,fmt / 编码 / diff 检查通过;仓库现役源码不再包含 `VIDU_AUDIO_MODEL``AudioTaskKind::SoundEffect` 或 Vidu submit/poll 实现。
File diff suppressed because one or more lines are too long
@@ -214,7 +214,7 @@ spacetime sql <database> "SELECT * FROM runtime_setting LIMIT 1" --server http:/
本地 `spacetime` CLI / standalone 版本必须和 `server-rs/Cargo.toml` 里锁定的 `spacetimedb` 版本一致;当前统一版本为 `2.8.3`CLI / standalone commit 固定核对为 `8e410d2842147bd8e5a32a9589cc00c19f7478e2`。若版本或 commit 错配,procedure 返回值可能在宿主侧触发 `Failed to BSATN deserialize procedure return value`api-server 最终表现为现役 settings、editor project 或 profile procedure 超时。排障时先运行 `spacetime --version`,再对照 `server-rs/Cargo.toml``spacetimedb = "..."`;其它版本可执行 `spacetime version install <version> && spacetime version use <version>`,升级后重启 `npm run dev:spacetime` 再重试。当前 `scripts/dev.mjs` 会把 tool version 和 commit 一起写入 `dev-spacetime-tool-version`,启动新 standalone 与复用已有本地进程时都要求 `2.8.3 + 8e410d28...` 同时匹配;旧版本或旧单行版本记录会拒绝复用并要求重启。2.6.1 修复了 procedure context 中调用者 `Identity` / `ConnectionId` 始终为空的回归,依赖 `ctx.sender` 鉴权时必须同时确认宿主已升级。
本地 `.env``.env.local``.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 VectorEngine 生成链路时,确认 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY``VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。`VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。业务模型和 VectorEngine provider 首选请求都使用 `gpt-image-2`,符合条件时才回退到兜底模型 `gpt-image-2-c`;图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image``api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image-2 family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。
本地 `.env``.env.local``.env.secrets.local` 修改后必须重启 `api-server` 才会生效;若已经通过 `npm run dev` 启动完整联调,可在该终端输入 `rs api-server`。排查图片编辑器 Tiantoken 生成链路时,确认 `TIANTOKEN_BASE_URL``TIANTOKEN_API_KEY``TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 只在本地或服务器密钥文件中配置,不能写入 Git。VectorEngine 配置仅保留给 Suno 音乐任务。`TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS` 是单次 attempt 的配置上限,默认 `1000000`;配置加载层允许显式值低于该默认值,不再在读取环境变量时强制抬高。业务模型和 Tiantoken provider 首选请求都使用 `gpt-image-2`,符合条件时才回退到兜底模型 `gpt-image-2-c`;图片协议、URL / base64 响应解析、远端图片下载和 provider 侧结构化日志在 `server-rs/crates/platform-image``api-server` 只做编辑器请求编排、OSS / asset 持久化、计费和失败审计落库。`platform-image` 会在 JSON 生成和 multipart 编辑请求发送前按同一 GPT-image-2 family 规则归一显式像素尺寸;若请求发送失败,先按同一 `request_id` 查看 provider 日志与 `external_api_call_failure.metadata_json.errorSource`,当前 multipart `/v1/images/edits` 单独强制 HTTP/1.1。
编辑器 ElevenLabs 音效生成只从服务端读取 `ELEVENLABS_BASE_URL``ELEVENLABS_API_KEY``ELEVENLABS_REQUEST_TIMEOUT_MS`timeout 默认 `180000ms`base URL 或 Key 缺失时失败关闭,不回退 Vidu。生产 API 与 external-generation worker 通过共享 API env 取得同一配置,模板见 `deploy/env/api-server.env.example`Key 不得进入 Web/Vite 环境、命令参数、日志、fixture 或仓库。普通测试只使用 loopback mock,禁止把真实付费请求作为 T3 自动验收。
@@ -790,14 +790,14 @@ PowerShell 下按测试文件头部示例依次设置三个必填变量,并按
该用例只从进程环境变量读取凭据,不读 `.env.secrets.local`,也不会写入任何文件。真实 API Key 一律不得提交进仓库,也不要写进 `docs/`、脚本默认值或测试 fixture;临时密钥用完应在上游及时吊销。
创意 Agent `gpt-5` 文本链路已从 APIMart 切到 VectorEngine`api-server` 读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀用于 Responses 协议。排查或切换密钥后,可在本地运行:
创意 Agent `gpt-5.4-mini` 文本链路已从 APIMart 切到 VectorEngine`api-server` 读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible``GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1``GENARRATIVE_LLM_MODEL=gpt-5.4-mini`,未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 `VECTOR_ENGINE_API_KEY`。排查或切换密钥后,可在本地运行:
创意 Agent 文本链路使用 Tiantoken`api-server` 读取 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀用于文本协议。排查或切换密钥后,可在本地运行:
创意 Agent `gpt-5.4-mini` 文本链路使用 Tiantoken`api-server` 读取 `TIANTOKEN_BASE_URL` / `TIANTOKEN_API_KEY` 构造 OpenAI-compatible LLM client,并自动补齐 `/v1` 前缀后请求 `/chat/completions`。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible``GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1``GENARRATIVE_LLM_MODEL=gpt-5.4-mini`,未单独配置 `GENARRATIVE_LLM_API_KEY` 时可复用 Tiantoken 凭据。排查或切换密钥后,可在本地运行:
```bash
node scripts/test-ve-llm.mjs
```
该脚本读取仓库根目录 `.env.secrets.local` 中的 `VECTOR_ENGINE_BASE_URL``VECTOR_ENGINE_API_KEY`,依次探测 `/v1/models``/v1/chat/completions``/v1/responses``gpt-5.4-mini` Chat Completions 和基础 JSON 输出能力;脚本只输出 HTTP 状态、耗时、模型和截断摘要,不应打印密钥。若 `.env.secrets.local` 不存在,先补本地 secrets 文件再运行,不要把 secrets 提交进仓库。
该脚本读取仓库根目录 `.env.secrets.local` 中的 `TIANTOKEN_BASE_URL``TIANTOKEN_API_KEY`,依次探测 `/v1/models``/v1/chat/completions``/v1/responses``gpt-5.4-mini` Chat Completions 和基础 JSON 输出能力;脚本只输出 HTTP 状态、耗时、模型和截断摘要,不应打印密钥。若 `.env.secrets.local` 不存在,先补本地 secrets 文件再运行,不要把 secrets 提交进仓库。
### 手机验证码短信
@@ -30,7 +30,7 @@ SFX V2 已完成产品与技术口径冻结及 T0–T6 工程实施;这不表
- `prompt`:用户在输入框确认的原始语言音效描述,语义为 `userPrompt`,继续复用对外请求和持久化字段 `prompt`。消费动作前按 ECMAScript `String.trim()` 语义只删除首尾空白和行终止符,包含 `U+FEFF`;不改写内部空白、换行、标点、零宽字符或 Unicode 形式。首尾 `U+0085` 不属于该删除集合。按 Unicode code point 计数,合法范围为 `1-2048`。空值和全空白不得回退“游戏音效”。
- `actualPrompt`Worker 在正式生成时对冻结的 `userPrompt` 进行统一英文化并严格验收后得到的英文 Prompt,继续复用持久化字段 `actual_prompt`。前端不提交 `actualPrompt`ElevenLabs 只接收验收通过的 `actualPrompt`
- `model`:新任务固定 `eleven_text_to_sound_v2`UI 以禁用态模型胶囊显示 `ElevenLabs`,客户端不决定模型。历史 `audio1.0` 只作旧素材展示和其它未迁移调用方的兼容标识,不是新编辑器 SFX 任务的 alias 或 fallback。
- `model`:新任务固定 `eleven_text_to_sound_v2`UI 以禁用态模型胶囊显示 `ElevenLabs`,客户端不决定模型。历史 `audio1.0` 只作旧素材展示标识,不是新编辑器 SFX 任务的 alias 或 fallback。
- `duration``null` 表示自动时长,有限数值表示手动时长。首次打开面板默认处于自动模式,并预置最近手动值为 `5s`;手动范围 `0.5-30s`UI 步进 `0.1s`。自动模式禁用 slider 但保留最近手动值,关闭自动后恢复该值。服务端只校验有限值与范围,不把 UI 步进扩大成 provider 精度限制。
- `loop`:独立布尔参数,默认 `false`,由页面开关原样冻结并传入 ElevenLabs。Prompt 是自由文本,系统不从 Prompt 推断、同步或校验 LoopPrompt 文本与 Loop 开关不建立业务一致性门禁。
- `prompt_influence`:服务端固定 `0.3`,不向前端开放滑杆或请求字段。输出格式固定为 query `output_format=mp3_44100_128`
@@ -468,7 +468,7 @@ POST / api / editor / audios / background - music / prompts / simplifications;
- 背景音乐在 body builder 边界防御性执行幂等 canonicalization,再按 canonical Prompt 检查至少一个有效字符和最多 200 个 Unicode code point;校验通过后用 canonical Prompt 构造 Suno body。不得复用语义不同的 `normalize_limited_text`,不得提供默认 Prompt。
- Suno 音乐接口路径固定为 `/suno/submit/music``VECTOR_ENGINE_BASE_URL` 即使配置为带 `/v1` 的图片接口根,也要在 `platform-audio` 中归一为根路径后再拼接,避免误请求 `/v1/suno/submit/music`
- 新编辑器音效使用独立 ElevenLabs 直接二进制 adapter,不伪装成 Vidu / Suno 的 submit + poll 任务。adapter 负责 endpoint 归一、`xi-api-key` header、固定 query / body、单次 POST、有界二进制读取、MP3 验证和时长探测。
- Vidu `audio1.0` 的 body builder、轮询和下载能力仅保留给历史展示和其它未迁移调用方;`audio-sound-effect` 任务不进入 `/ent/v2/text2audio``/ent/v2/tasks/{taskId}/creations`,也不使用 Suno `task: "sound"`
- 旧版 Vidu `audio1.0` 的 body builder、轮询和下载代码已移除;历史 `audio1.0` 只读展示,`audio-sound-effect` 任务不进入 `/ent/v2/text2audio``/ent/v2/tasks/{taskId}/creations`,也不使用 Suno `task: "sound"`
- Suno 提交成功后的任务 ID 兼容与 wav clip 轮询逻辑只保留给背景音乐链路;`/suno/fetch/{taskId}` 返回 `audiopipe.suno.ai/?item_id=...` 时,该地址只作为 clip id 来源,不作为最终下载文件,后端继续调用 `/suno/act/wav/{clipId}` 获取稳定 wav URL,避免 worker 在不完整 chunked body 上卡满超时。
- VectorEngine 音频响应的 `code` 需要兼容 `"success"``"ok"``"0"``"200"` 以及数字 `0` / `200`;HTTP 非 2xx 时后端错误信息应透出安全的上游状态和短响应摘要,避免前端只显示笼统提交失败。
-`api-server/src/vector_engine_audio_generation/generation.rs` 原地演进现有编辑器音频 generation handler;以下正式路由保持原路径和现有注册,不新增平行 BFF:
@@ -526,7 +526,7 @@ POST / api / editor / audios / background - music / prompts / simplifications;
- BGM 边界测试覆盖 200 / 201 个纯 Unicode `White_Space` 均归一为空并禁止三动作、大量边界空白包围 `A` 后只允许生成、`A` 加 199 个内部空格再加 `B` 后只允许简化、201 个 U+200B 或 U+FEFF 只允许简化、边界空白包围 200 个 `A` 后允许补全和生成,以及 TypeScript 与 Rust 对 U+0085、U+200B 和 U+FEFF 的一致行为。
- BGM 助手入口测试覆盖 canonical 2000 字允许简化、2001 字返回 `400` 且不调用 LLM;两个助手路由 body 超过 `32 KiB` 时返回 `413`;连续合法请求不因本功能新增限流器返回 `429`
- 两个助手成功路由分别产生 `editor_background_music_prompt_completion` / `editor_background_music_prompt_simplification` tracking event,均为 `module_key = editor`、User scope;失败响应沿用普通 route tracking 只记录成功的现状。
- BGM Suno body 仍只包含 `mv``gpt_description_prompt``make_instrumental`,固定 `Suno` 胶囊和动态泥点价格不变;历史和其它未迁移 Vidu 调用方的 builder / 轮询能力保持可用,但新编辑器 SFX 任务只调用 ElevenLabs。
- BGM Suno body 仍只包含 `mv``gpt_description_prompt``make_instrumental`,固定 `Suno` 胶囊和动态泥点价格不变;旧版 Vidu builder / 轮询能力已移除,历史素材只读展示,新编辑器 SFX 任务只调用 ElevenLabs。
- `audio-sound-effect``audio-background-music` 必须由同一个音频 composer 渲染,并在组件内通过 `isSoundEffect` 分支。SFX 不得渲染 BGM 的补全 / 简化、Suno 模型和 BGM 字符规则;BGM 不得渲染 SFX 的一键优化、Loop、ElevenLabs 模型和 SFX 时长控件。两个 mode 相互切换时,菜单、预设滚动、锁、快照和助手状态不得跨分支泄漏。
- SFX V2 翻译失败时 ElevenLabs 请求数为 0;成功时每个平台 job 最多一次 ElevenLabs POST`prompt / actual_prompt`、实际时长、Loop、model、provider 和 Task ID 在队列、素材、画布、响应、刷新和重绘后保持权威一致。
- SFX 两类 LLM 请求契约测试分别断言:一键优化每次请求为 Luna + Medium + `max_completion_tokens = 8192`,翻译每次业务尝试为 Luna + Low + `max_completion_tokens = 8192`,两者都不含 `max_tokens``max_output_tokens` 或 temperature;测试命名和说明必须把 `8192` 解释为包含 reasoning 的 completion tokens 总预算。优化遇到 `finish_reason = length` 直接失败且不写回;翻译首轮 `length` 只重试一次,第二轮 `length` 最终失败且 ElevenLabs 请求数为 0。
+3 -3
View File
@@ -24,7 +24,7 @@ describe('bgfilter worker smoke harness', () => {
GENARRATIVE_BGFILTER_INTERNAL_TOKEN: 'real-internal-token',
GENARRATIVE_EDITOR_BGFILTER_TOKEN: 'real-provider-token',
PATH: '/safe/bin',
VECTOR_ENGINE_API_KEY: 'real-vector-secret',
TIANTOKEN_API_KEY: 'real-tiantoken-secret',
},
providerBaseUrl: 'http://127.0.0.1:19001',
tempRoot: '/tmp/bgfilter-load-smoke-test',
@@ -40,10 +40,10 @@ describe('bgfilter worker smoke harness', () => {
assert.equal(env.ALIYUN_OSS_ENDPOINT, 'oss-cn-shanghai.invalid');
assert.notEqual(env.ALIYUN_OSS_ACCESS_KEY_SECRET, 'real-oss-secret');
assert.equal(env.GENARRATIVE_EDITOR_BGFILTER_TOKEN, undefined);
assert.equal(env.VECTOR_ENGINE_API_KEY, undefined);
assert.equal(env.TIANTOKEN_API_KEY, undefined);
assert.ok(!Object.values(env).includes('real-internal-token'));
assert.ok(!Object.values(env).includes('real-provider-token'));
assert.ok(!Object.values(env).includes('real-vector-secret'));
assert.ok(!Object.values(env).includes('real-tiantoken-secret'));
});
test('loopback mock 完整读取 multipart 后记录并发并返回合法 PNG 字节', async () => {
+2 -2
View File
@@ -1,8 +1,8 @@
import { mergeApiServerEnv } from './dev-utils.mjs';
const REQUIRED_FOR_PUZZLE_GENERATION = [
'VECTOR_ENGINE_BASE_URL',
'VECTOR_ENGINE_API_KEY',
'TIANTOKEN_BASE_URL',
'TIANTOKEN_API_KEY',
'ALIYUN_OSS_BUCKET',
'ALIYUN_OSS_ENDPOINT',
'ALIYUN_OSS_ACCESS_KEY_ID',
+3 -3
View File
@@ -514,11 +514,11 @@ GENARRATIVE_SPACETIME_POOL_SIZE=2
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=15
GENARRATIVE_LLM_PROVIDER=openai-compatible
GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1
GENARRATIVE_LLM_BASE_URL=https://api.tiantoken.com/v1
GENARRATIVE_LLM_API_KEY=
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
VECTOR_ENGINE_BASE_URL=
VECTOR_ENGINE_API_KEY=
TIANTOKEN_BASE_URL=
TIANTOKEN_API_KEY=
ALIYUN_OSS_BUCKET=
ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
ALIYUN_OSS_ACCESS_KEY_ID=
+8 -8
View File
@@ -52,12 +52,12 @@ function timestamp() {
function resolveEnv() {
const env = mergeApiServerEnv(repoRoot, process.env);
return {
baseUrl: String(env.VECTOR_ENGINE_BASE_URL || '')
baseUrl: String(env.TIANTOKEN_BASE_URL || '')
.trim()
.replace(/\/+$/u, ''),
apiKey: String(env.VECTOR_ENGINE_API_KEY || '').trim(),
apiKey: String(env.TIANTOKEN_API_KEY || '').trim(),
timeoutMs: Number.parseInt(
String(env.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
String(env.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
10,
),
};
@@ -160,12 +160,12 @@ async function fetchJson(url, options, timeoutMs) {
});
const text = await response.text();
if (!response.ok) {
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
throw new Error(`Tiantoken ${response.status}: ${text.slice(0, 600)}`);
}
return JSON.parse(text);
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
@@ -202,7 +202,7 @@ async function imageBytesFromPayload(payload, env) {
if (b64Images[0]) {
return Buffer.from(b64Images[0], 'base64');
}
throw new Error('VectorEngine returned no image');
throw new Error('Tiantoken returned no image');
}
async function generateImage(env, { prompt, negativePrompt, size, outPath }) {
@@ -320,7 +320,7 @@ async function main() {
{
mode: 'dry-run',
outDir,
message: '加 --live 才会真实调用 VectorEngine。',
message: '加 --live 才会真实调用 Tiantoken。',
prompts,
},
null,
@@ -332,7 +332,7 @@ async function main() {
const env = resolveEnv();
if (!env.baseUrl || !env.apiKey) {
throw new Error('Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY');
throw new Error('Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY');
}
console.log(`[match3d-export] 1/4 生成关卡整图 -> ${outDir}`);
@@ -127,12 +127,12 @@ function resolveEnv() {
...process.env,
};
return {
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
baseUrl: String(loaded.TIANTOKEN_BASE_URL || '')
.trim()
.replace(/\/+$/u, ''),
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(),
timeoutMs: Number.parseInt(
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
10,
),
};
@@ -249,12 +249,12 @@ async function fetchJson(url, options, timeoutMs) {
});
const text = await response.text();
if (!response.ok) {
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
throw new Error(`Tiantoken ${response.status}: ${text.slice(0, 600)}`);
}
return JSON.parse(text);
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
@@ -335,7 +335,7 @@ async function generateOne(env, concept, size, references) {
extension: inferExtensionFromBytes(bytes),
};
} else {
throw new Error(`VectorEngine returned no image for ${concept.id}`);
throw new Error(`Tiantoken returned no image for ${concept.id}`);
}
mkdirSync(outDir, { recursive: true });
@@ -401,7 +401,7 @@ if (!env.baseUrl || !env.apiKey) {
console.error(
JSON.stringify({
ok: false,
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY',
hasBaseUrl: Boolean(env.baseUrl),
hasApiKey: Boolean(env.apiKey),
}),
@@ -107,12 +107,12 @@ function resolveEnv() {
...process.env,
};
return {
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
baseUrl: String(loaded.TIANTOKEN_BASE_URL || '')
.trim()
.replace(/\/+$/u, ''),
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(),
timeoutMs: Number.parseInt(
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
10,
),
};
@@ -226,12 +226,12 @@ async function fetchJson(url, options, timeoutMs) {
});
const text = await response.text();
if (!response.ok) {
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
throw new Error(`Tiantoken ${response.status}: ${text.slice(0, 600)}`);
}
return JSON.parse(text);
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
@@ -314,7 +314,7 @@ async function generateOne(env, concept, size) {
extension: inferExtensionFromBytes(bytes),
};
} else {
throw new Error(`VectorEngine returned no image for ${concept.id}`);
throw new Error(`Tiantoken returned no image for ${concept.id}`);
}
mkdirSync(outDir, { recursive: true });
@@ -375,7 +375,7 @@ if (!env.baseUrl || !env.apiKey) {
console.error(
JSON.stringify({
ok: false,
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY',
hasBaseUrl: Boolean(env.baseUrl),
hasApiKey: Boolean(env.apiKey),
}),
@@ -87,12 +87,12 @@ function resolveEnv() {
...process.env,
};
return {
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
baseUrl: String(loaded.TIANTOKEN_BASE_URL || '')
.trim()
.replace(/\/+$/u, ''),
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
apiKey: String(loaded.TIANTOKEN_API_KEY || '').trim(),
timeoutMs: Number.parseInt(
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || timeoutMsDefault),
String(loaded.TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS || timeoutMsDefault),
10,
),
};
@@ -180,12 +180,12 @@ async function fetchJson(url, options, timeoutMs) {
});
const text = await response.text();
if (!response.ok) {
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
throw new Error(`Tiantoken ${response.status}: ${text.slice(0, 600)}`);
}
return JSON.parse(text);
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
throw new Error(`Tiantoken request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
@@ -235,7 +235,7 @@ async function generateChromaSource() {
throw new Error(
JSON.stringify({
ok: false,
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
error: 'Missing TIANTOKEN_BASE_URL or TIANTOKEN_API_KEY',
hasBaseUrl: Boolean(env.baseUrl),
hasApiKey: Boolean(env.apiKey),
}),
@@ -263,7 +263,7 @@ async function generateChromaSource() {
} else if (b64Images[0]) {
bytes = Buffer.from(b64Images[0], 'base64');
} else {
throw new Error('VectorEngine returned no image');
throw new Error('Tiantoken returned no image');
}
mkdirSync(outputDir, { recursive: true });
+7 -8
View File
@@ -28,12 +28,11 @@ function loadEnv(path) {
const env = loadEnv(resolve(root, '.env.secrets.local'));
const BASE =
env.VECTOR_ENGINE_BASE_URL?.replace(/\/+$/, '') ||
'https://api.vectorengine.cn';
const KEY = env.VECTOR_ENGINE_API_KEY || '';
env.TIANTOKEN_BASE_URL?.replace(/\/+$/, '') || 'https://api.tiantoken.com';
const KEY = env.TIANTOKEN_API_KEY || '';
if (!KEY) {
console.error('未找到 VECTOR_ENGINE_API_KEY');
console.error('未找到 TIANTOKEN_API_KEY');
process.exit(1);
}
@@ -90,7 +89,7 @@ async function test(name, method, path, body = null) {
}
}
console.log(`VectorEngine LLM 能力探测`);
console.log(`Tiantoken LLM 能力探测`);
console.log(`目标: ${BASE}\n`);
const tests = [
@@ -185,12 +184,12 @@ console.log(
// 结论
if (pass >= 3) {
console.log('\n✅ VectorEngine 支持 LLM 文本调用,可替代 Apimart。');
console.log('\n✅ Tiantoken 支持 LLM 文本调用,可替代 Apimart。');
console.log(
' 将 .env.secrets.local 中 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY 配好即可。',
' 将 .env.secrets.local 中 TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY 配好即可。',
);
} else if (pass <= 1) {
console.log('\n❌ VectorEngine 不支持 LLM 文本调用。');
console.log('\n❌ Tiantoken 不支持 LLM 文本调用。');
} else {
console.log('\n⚠️ 部分支持,需进一步评估。');
}
+56 -4
View File
@@ -1175,6 +1175,7 @@ impl AppConfig {
read_first_non_empty_env(&[
"GENARRATIVE_LLM_API_KEY",
"LLM_API_KEY",
"TIANTOKEN_API_KEY",
"VECTOR_ENGINE_API_KEY",
"ARK_API_KEY",
])
@@ -1183,6 +1184,7 @@ impl AppConfig {
"GENARRATIVE_LLM_API_KEY",
"LLM_API_KEY",
"ARK_API_KEY",
"TIANTOKEN_API_KEY",
"VECTOR_ENGINE_API_KEY",
])
};
@@ -1275,11 +1277,12 @@ impl AppConfig {
config.vector_engine_api_key = read_first_non_empty_env(&["VECTOR_ENGINE_API_KEY"]);
if let Some(vector_engine_image_request_timeout_ms) =
read_first_positive_u64_env(&["VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS"])
{
if let Some(tiantoken_image_request_timeout_ms) = read_first_positive_u64_env(&[
"TIANTOKEN_IMAGE_REQUEST_TIMEOUT_MS",
"VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS",
]) {
// 单次 attempt 上限允许按环境收短;worker 调用还会受整次任务的绝对 deadline 约束。
config.vector_engine_image_request_timeout_ms = vector_engine_image_request_timeout_ms;
config.vector_engine_image_request_timeout_ms = tiantoken_image_request_timeout_ms;
}
if let Some(vector_engine_audio_request_timeout_ms) =
@@ -1425,6 +1428,20 @@ impl AppConfig {
}
}
/// Tiantoken 是图片、文本和旧版非 Suno 音频生成的新 provider。
///
/// 这里保留对 `AppConfig.vector_engine_*` 的回退,方便测试构造的旧配置继续工作;
/// 生产环境一旦设置了新的 `TIANTOKEN_*` 变量,就不会再把非 Suno 请求发往 VectorEngine。
pub(crate) fn tiantoken_base_url(config: &AppConfig) -> String {
read_first_non_empty_env(&["TIANTOKEN_BASE_URL"])
.unwrap_or_else(|| config.vector_engine_base_url.clone())
}
pub(crate) fn tiantoken_api_key(config: &AppConfig) -> Option<String> {
read_first_non_empty_env(&["TIANTOKEN_API_KEY"])
.or_else(|| config.vector_engine_api_key.clone())
}
fn read_first_non_empty_env(keys: &[&str]) -> Option<String> {
keys.iter().find_map(|key| {
env::var(key).ok().and_then(|value| {
@@ -1730,6 +1747,7 @@ mod tests {
DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS,
DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS, ExternalGenerationMode,
LlmProvider, ProcessRole, parse_bool, parse_external_generation_mode, parse_process_role,
tiantoken_api_key, tiantoken_base_url,
};
use std::{
fs,
@@ -1822,6 +1840,40 @@ mod tests {
}
}
#[test]
fn tiantoken_provider_prefers_new_env_names_over_legacy_vector_engine_config() {
let _guard = ENV_LOCK
.get_or_init(|| Mutex::new(()))
.lock()
.expect("env lock should not poison");
let mut config = AppConfig::default();
config.vector_engine_base_url = "https://vector.example.invalid".to_string();
config.vector_engine_api_key = Some("legacy-vector-key".to_string());
unsafe {
std::env::set_var("TIANTOKEN_BASE_URL", " https://api.tiantoken.example/ ");
std::env::set_var("TIANTOKEN_API_KEY", " tiantoken-key ");
}
assert_eq!(
tiantoken_base_url(&config),
"https://api.tiantoken.example/"
);
assert_eq!(tiantoken_api_key(&config).as_deref(), Some("tiantoken-key"));
unsafe {
std::env::remove_var("TIANTOKEN_BASE_URL");
std::env::remove_var("TIANTOKEN_API_KEY");
}
assert_eq!(
tiantoken_base_url(&config),
"https://vector.example.invalid"
);
assert_eq!(
tiantoken_api_key(&config).as_deref(),
Some("legacy-vector-key")
);
}
#[test]
fn llm_router_key_encryption_secret_prefers_dedicated_secret_or_derives_from_jwt() {
let mut config = AppConfig::default();
@@ -117,7 +117,7 @@ pub(crate) async fn resolve_editor_screen_background_color(
.map(|report| report.allowed.clone())
.unwrap_or_else(|| EDITOR_SCREEN_BACKGROUND_COLORS.to_vec());
// 决策统一走 VectorEngine gpt-5-mini:有图用视觉档、无图用文本档(两个独立常量),
// 决策统一走 Tiantoken gpt-5-mini:有图用视觉档、无图用文本档(两个独立常量),
// 都不继承 Ark 默认文本模型(豆包,选色能力弱)。仅当 gpt5 客户端未配置时才降级回默认
// llm_client;该默认客户端是纯文本模型(Ark),收到图片分片会被上游 400 拒绝,故先丢弃图片分片。
let (llm_client, decision_model) = match vision_llm_client {
@@ -860,10 +860,10 @@ mod tests {
);
}
// 真机联调:按 build_editor_agent_llm_client 的方式组 VectorEngine 客户端,直接跑
// 真机联调:按 build_editor_agent_llm_client 的方式组 Tiantoken 客户端,直接跑
// resolve_editor_screen_background_color 的完整代码路径(无图文本档 + 有图视觉档),
// 验证决策请求真的打到 VectorEngine 并被解析成候选色(decision.fallback == false)。
// 凭证从仓库根 .env.local / .env.secrets.local 读,需要真实 VECTOR_ENGINE_* 才有意义。
// 验证决策请求真的打到 Tiantoken 并被解析成候选色(decision.fallback == false)。
// 凭证从仓库根 .env.local / .env.secrets.local 读,需要真实 TIANTOKEN_* 才有意义。
// 运行:cargo test -p api-server --manifest-path server-rs/Cargo.toml \
// editor_screen_background_decision::tests::live -- --ignored --nocapture
fn read_live_env(key: &str) -> Option<String> {
@@ -895,11 +895,11 @@ mod tests {
std::env::var(key).ok().or_else(|| map.get(key).cloned())
}
fn build_live_vector_engine_client() -> Option<LlmClient> {
fn build_live_tiantoken_client() -> Option<LlmClient> {
use platform_llm::{LlmConfig, LlmProvider};
let base_url = read_live_env("VECTOR_ENGINE_BASE_URL")?;
let api_key = read_live_env("VECTOR_ENGINE_API_KEY")?;
let base_url = read_live_env("TIANTOKEN_BASE_URL")?;
let api_key = read_live_env("TIANTOKEN_API_KEY")?;
// 与 state.rs build_editor_agent_llm_client 一致:规整到以 /v1 结尾。
let base_url = if base_url.trim_end_matches('/').ends_with("/v1") {
base_url.trim_end_matches('/').to_string()
@@ -915,8 +915,8 @@ mod tests {
0,
500,
)
.expect("live VectorEngine LlmConfig should build");
Some(LlmClient::new(config).expect("live VectorEngine LlmClient should build"))
.expect("live Tiantoken LlmConfig should build");
Some(LlmClient::new(config).expect("live Tiantoken LlmClient should build"))
}
fn solid_source_image_data_url() -> String {
@@ -938,10 +938,10 @@ mod tests {
}
#[tokio::test]
#[ignore = "真机联调:需要 .env.local / .env.secrets.local 中真实 VECTOR_ENGINE_* 凭证"]
async fn live_screen_background_decision_hits_vector_engine_without_image() {
let Some(client) = build_live_vector_engine_client() else {
panic!("缺少 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY,无法真机联调");
#[ignore = "真机联调:需要 .env.local / .env.secrets.local 中真实 TIANTOKEN_* 凭证"]
async fn live_screen_background_decision_hits_tiantoken_without_image() {
let Some(client) = build_live_tiantoken_client() else {
panic!("缺少 TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY,无法真机联调");
};
let decision = resolve_editor_screen_background_color(
None,
@@ -970,16 +970,16 @@ mod tests {
assert_eq!(decision.mode, EditorScreenBackgroundDecisionMode::Auto);
assert!(
!decision.fallback,
"若走到兜底说明 LLM 没答复(VectorEngine 未被成功调用或响应解析失败)"
"若走到兜底说明 LLM 没答复(Tiantoken 未被成功调用或响应解析失败)"
);
assert!(decision.attempts >= 1);
}
#[tokio::test]
#[ignore = "真机联调:需要 .env.local / .env.secrets.local 中真实 VECTOR_ENGINE_* 凭证"]
async fn live_screen_background_decision_hits_vector_engine_with_image() {
let Some(client) = build_live_vector_engine_client() else {
panic!("缺少 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY,无法真机联调");
#[ignore = "真机联调:需要 .env.local / .env.secrets.local 中真实 TIANTOKEN_* 凭证"]
async fn live_screen_background_decision_hits_tiantoken_with_image() {
let Some(client) = build_live_tiantoken_client() else {
panic!("缺少 TIANTOKEN_BASE_URL / TIANTOKEN_API_KEY,无法真机联调");
};
let decision = resolve_editor_screen_background_color(
None,
@@ -1008,7 +1008,7 @@ mod tests {
assert_eq!(decision.mode, EditorScreenBackgroundDecisionMode::Auto);
assert!(
!decision.fallback,
"若走到兜底说明视觉 LLM 没答复(VectorEngine 未被成功调用或响应解析失败)"
"若走到兜底说明视觉 LLM 没答复(Tiantoken 未被成功调用或响应解析失败)"
);
assert!(decision.attempts >= 1);
}
@@ -1905,8 +1905,8 @@ mod tests {
"code": "UPSTREAM_ERROR",
"message": "上游服务请求失败",
"details": {
"provider": "vector-engine",
"reason": "VECTOR_ENGINE_API_KEY 未配置",
"provider": "tiantoken",
"reason": "TIANTOKEN_API_KEY 未配置",
"message": "提交编辑器音效任务失败:missing field sound"
}
},
@@ -1918,7 +1918,7 @@ mod tests {
let message = response_error_message(response).await;
assert_eq!(message, "VECTOR_ENGINE_API_KEY 未配置");
assert_eq!(message, "TIANTOKEN_API_KEY 未配置");
}
#[tokio::test]
@@ -75,34 +75,29 @@ impl std::fmt::Debug for OpenAiImageSettings {
}
}
// 中文注释:api-server 只负责配置、审计和 HTTP envelopeVectorEngine 协议细节统一由 platform-image provider 承接。
// 中文注释:api-server 只负责配置、审计和 HTTP envelopeTiantoken 的 OpenAI-compatible
// 图片协议细节统一由 platform-image provider 承接。
pub(crate) fn require_openai_image_settings(
state: &AppState,
) -> Result<OpenAiImageSettings, AppError> {
let base_url = state
.config
.vector_engine_base_url
.trim()
.trim_end_matches('/');
let base_url = state.tiantoken_base_url().trim().trim_end_matches('/');
if base_url.is_empty() {
return Err(
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"reason": "VECTOR_ENGINE_BASE_URL 未配置",
"provider": "tiantoken",
"reason": "TIANTOKEN_BASE_URL 未配置",
})),
);
}
let api_key = state
.config
.vector_engine_api_key
.as_deref()
.tiantoken_api_key()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": VECTOR_ENGINE_PROVIDER,
"reason": "VECTOR_ENGINE_API_KEY 未配置",
"provider": "tiantoken",
"reason": "TIANTOKEN_API_KEY 未配置",
}))
})?;
+28 -9
View File
@@ -303,6 +303,9 @@ pub struct AppStateInner {
editor_generation_pricing_store: EditorGenerationPricingStore,
llm_client: Option<LlmClient>,
vector_engine_llm_client: Option<LlmClient>,
/// 非 Suno 的文本、图片和旧版音频生成 provider 配置。
tiantoken_base_url: String,
tiantoken_api_key: Option<String>,
matting_client: Option<MattingClient>,
bgfilter_provider_http_client: reqwest::Client,
bgfilter_worker_http_client: reqwest::Client,
@@ -600,8 +603,14 @@ impl AppState {
config.editor_generation_pricing_override_path.clone(),
)
.map_err(|error| AppStateInitError::DependencyUnavailable(error.to_string()))?;
let tiantoken_base_url = crate::config::tiantoken_base_url(&config);
let tiantoken_api_key = crate::config::tiantoken_api_key(&config);
let llm_client = build_llm_client(&config)?;
let vector_engine_llm_client = build_vector_engine_llm_client(&config)?;
let vector_engine_llm_client = build_vector_engine_llm_client(
&config,
&tiantoken_base_url,
tiantoken_api_key.as_deref(),
)?;
let matting_client = build_matting_client(&config)?;
let bgfilter_provider_http_client = build_bgfilter_provider_http_client(&config)?;
let bgfilter_worker_http_client = build_bgfilter_worker_http_client(&config)?;
@@ -677,6 +686,8 @@ impl AppState {
editor_generation_pricing_store,
llm_client,
vector_engine_llm_client,
tiantoken_base_url,
tiantoken_api_key,
matting_client,
bgfilter_provider_http_client,
bgfilter_worker_http_client,
@@ -1579,6 +1590,14 @@ impl AppState {
self.vector_engine_llm_client.as_ref()
}
pub fn tiantoken_base_url(&self) -> &str {
self.tiantoken_base_url.as_str()
}
pub fn tiantoken_api_key(&self) -> Option<&str> {
self.tiantoken_api_key.as_deref()
}
pub fn matting_client(&self) -> Option<&MattingClient> {
self.matting_client.as_ref()
}
@@ -2490,21 +2509,21 @@ fn build_llm_client(config: &AppConfig) -> Result<Option<LlmClient>, AppStateIni
fn build_vector_engine_llm_client(
config: &AppConfig,
tiantoken_base_url: &str,
tiantoken_api_key: Option<&str>,
) -> Result<Option<LlmClient>, AppStateInitError> {
// 中文注释:Apimart 已于 2026-06 弃用,LLM 文本调用统一迁移到 VectorEngine
let Some(api_key) = config
.vector_engine_api_key
.as_ref()
.map(|value| value.trim())
// 中文注释:Apimart 已于 2026-06 弃用,非 Suno 文本调用统一迁移到 Tiantoken
let Some(api_key) = tiantoken_api_key
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return Ok(None);
};
let base_url = if config.vector_engine_base_url.ends_with("/v1") {
config.vector_engine_base_url.clone()
let base_url = if tiantoken_base_url.trim_end_matches('/').ends_with("/v1") {
tiantoken_base_url.trim_end_matches('/').to_string()
} else {
format!("{}/v1", config.vector_engine_base_url.trim_end_matches('/'))
format!("{}/v1", tiantoken_base_url.trim_end_matches('/'))
};
let llm_config = LlmConfig::new(
@@ -1,215 +0,0 @@
use axum::{
Json,
extract::{Path, State, rejection::JsonRejection},
response::Response,
};
use platform_audio::{BackgroundMusicTaskRequest, SoundEffectTaskRequest};
use serde_json::Value;
use shared_contracts::{creation_audio, visual_novel as contract};
use crate::{
api_response::json_success_body, auth::AuthenticatedAccessToken,
request_context::RequestContext, state::AppState,
};
use super::{
errors::{map_platform_audio_error, parse_json_payload},
generation::normalize_creation_sound_effect_duration,
publish::publish_generated_audio_asset,
settings::require_vector_engine_audio_settings,
targets::{
build_creation_audio_target, build_visual_novel_audio_target,
creation_audio_generation_disabled_error,
creation_audio_generation_disabled_error_for_target,
},
types::AudioAssetSlot,
};
pub async fn create_visual_novel_background_music_task(
State(state): State<AppState>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
payload: Result<Json<contract::CreateVisualNovelBackgroundMusicRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = parse_json_payload(&request_context, payload)?;
let settings = require_vector_engine_audio_settings(&state)?;
let http_client = platform_audio::build_vector_engine_audio_http_client(&settings)
.map_err(map_platform_audio_error)?;
let task = platform_audio::submit_background_music_task(
&http_client,
&settings,
BackgroundMusicTaskRequest {
prompt: payload.prompt,
title: payload.title,
tags: payload.tags,
model: payload.model,
instrumental: true,
},
)
.await
.map_err(map_platform_audio_error)?;
Ok(json_success_body(
Some(&request_context),
contract::VisualNovelAudioGenerationTaskResponse {
kind: contract::VisualNovelAudioGenerationKind::BackgroundMusic,
task_id: task.task_id,
provider: task.provider,
status: task.status,
},
))
}
pub async fn create_background_music_task(
State(_state): State<AppState>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
payload: Result<Json<creation_audio::CreateBackgroundMusicRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let _ = parse_json_payload(&request_context, payload)?;
Err(creation_audio_generation_disabled_error()
.into_response_with_context(Some(&request_context)))
}
pub async fn create_visual_novel_sound_effect_task(
State(state): State<AppState>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
payload: Result<Json<contract::CreateVisualNovelSoundEffectRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = parse_json_payload(&request_context, payload)?;
let settings = require_vector_engine_audio_settings(&state)?;
let http_client = platform_audio::build_vector_engine_audio_http_client(&settings)
.map_err(map_platform_audio_error)?;
let task = platform_audio::submit_sound_effect_task(
&http_client,
&settings,
SoundEffectTaskRequest {
prompt: payload.prompt,
duration: normalize_creation_sound_effect_duration(payload.duration)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?,
seed: payload.seed,
},
)
.await
.map_err(map_platform_audio_error)?;
Ok(json_success_body(
Some(&request_context),
contract::VisualNovelAudioGenerationTaskResponse {
kind: contract::VisualNovelAudioGenerationKind::SoundEffect,
task_id: task.task_id,
provider: task.provider,
status: task.status,
},
))
}
pub async fn create_sound_effect_task(
State(_state): State<AppState>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
payload: Result<Json<creation_audio::CreateSoundEffectRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let _ = parse_json_payload(&request_context, payload)?;
Err(creation_audio_generation_disabled_error()
.into_response_with_context(Some(&request_context)))
}
pub async fn publish_visual_novel_background_music_asset(
State(state): State<AppState>,
Path(task_id): Path<String>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
payload: Result<Json<contract::PublishVisualNovelGeneratedAudioAssetRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let payload = parse_json_payload(&request_context, payload)?.0;
let target = build_visual_novel_audio_target(payload, AudioAssetSlot::BackgroundMusic)?;
publish_generated_audio_asset(
&state,
authenticated.claims().user_id(),
task_id,
AudioAssetSlot::BackgroundMusic,
target,
)
.await
.map(|payload| {
json_success_body(
Some(&request_context),
contract::VisualNovelGeneratedAudioAssetResponse {
kind: contract::VisualNovelAudioGenerationKind::BackgroundMusic,
task_id: payload.task_id,
provider: payload.provider,
status: payload.status,
asset_object_id: payload.asset_object_id,
asset_kind: payload.asset_kind,
audio_src: payload.audio_src,
},
)
})
.map_err(|error| error.into_response_with_context(Some(&request_context)))
}
pub async fn publish_visual_novel_sound_effect_asset(
State(state): State<AppState>,
Path(task_id): Path<String>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
payload: Result<Json<contract::PublishVisualNovelGeneratedAudioAssetRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let payload = parse_json_payload(&request_context, payload)?.0;
let target = build_visual_novel_audio_target(payload, AudioAssetSlot::SoundEffect)?;
publish_generated_audio_asset(
&state,
authenticated.claims().user_id(),
task_id,
AudioAssetSlot::SoundEffect,
target,
)
.await
.map(|payload| {
json_success_body(
Some(&request_context),
contract::VisualNovelGeneratedAudioAssetResponse {
kind: contract::VisualNovelAudioGenerationKind::SoundEffect,
task_id: payload.task_id,
provider: payload.provider,
status: payload.status,
asset_object_id: payload.asset_object_id,
asset_kind: payload.asset_kind,
audio_src: payload.audio_src,
},
)
})
.map_err(|error| error.into_response_with_context(Some(&request_context)))
}
pub async fn publish_background_music_asset(
State(_state): State<AppState>,
Path(_task_id): Path<String>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
axum::extract::Extension(_authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
payload: Result<Json<creation_audio::PublishGeneratedAudioAssetRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let payload = parse_json_payload(&request_context, payload)?.0;
Err(creation_audio_generation_disabled_error_for_target(payload)
.into_response_with_context(Some(&request_context)))
}
pub async fn publish_sound_effect_asset(
State(state): State<AppState>,
Path(task_id): Path<String>,
axum::extract::Extension(request_context): axum::extract::Extension<RequestContext>,
axum::extract::Extension(authenticated): axum::extract::Extension<AuthenticatedAccessToken>,
payload: Result<Json<creation_audio::PublishGeneratedAudioAssetRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let payload = parse_json_payload(&request_context, payload)?.0;
let target = build_creation_audio_target(payload, AudioAssetSlot::SoundEffect)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
publish_generated_audio_asset(
&state,
authenticated.claims().user_id(),
task_id,
AudioAssetSlot::SoundEffect,
target,
)
.await
.map(|payload| json_success_body(Some(&request_context), payload))
.map_err(|error| error.into_response_with_context(Some(&request_context)))
}
@@ -11,10 +11,7 @@ use super::{
errors::{map_platform_audio_error, vector_engine_bad_gateway},
persist::persist_generated_audio_asset,
settings::require_vector_engine_audio_settings,
types::{
AudioAssetBindingTarget, AudioAssetSlot, CREATION_BACKGROUND_MUSIC_POINTS_COST,
CREATION_SOUND_EFFECT_POINTS_COST,
},
types::{AudioAssetBindingTarget, AudioAssetSlot, CREATION_BACKGROUND_MUSIC_POINTS_COST},
};
#[cfg(any())]
@@ -208,6 +205,5 @@ pub(super) fn resolve_creation_audio_points_cost(
}
match slot {
AudioAssetSlot::BackgroundMusic => CREATION_BACKGROUND_MUSIC_POINTS_COST,
AudioAssetSlot::SoundEffect => CREATION_SOUND_EFFECT_POINTS_COST,
}
}
@@ -1,38 +0,0 @@
use platform_audio::SoundEffectTaskRequest;
use shared_contracts::creation_audio;
use crate::{http_error::AppError, state::AppState};
use super::{
errors::map_platform_audio_error, generation::normalize_creation_sound_effect_duration,
settings::require_vector_engine_audio_settings,
};
pub(super) async fn create_sound_effect_task_response(
state: &AppState,
prompt: String,
duration: Option<u8>,
seed: Option<u64>,
) -> Result<creation_audio::AudioGenerationTaskResponse, AppError> {
let settings = require_vector_engine_audio_settings(state)?;
let http_client = platform_audio::build_vector_engine_audio_http_client(&settings)
.map_err(map_platform_audio_error)?;
let task = platform_audio::submit_sound_effect_task(
&http_client,
&settings,
SoundEffectTaskRequest {
prompt,
duration: normalize_creation_sound_effect_duration(duration)?,
seed,
},
)
.await
.map_err(map_platform_audio_error)?;
Ok(creation_audio::AudioGenerationTaskResponse {
kind: creation_audio::CreationAudioGenerationKind::SoundEffect,
task_id: task.task_id,
provider: task.provider,
status: task.status,
})
}
@@ -1,212 +0,0 @@
use axum::http::StatusCode;
use platform_oss::LegacyAssetPrefix;
use shared_contracts::{assets, creation_audio};
use super::{
generation::{
normalize_creation_sound_effect_duration, normalize_editor_background_music_request,
normalize_editor_sound_effect_request,
},
publish::resolve_creation_audio_points_cost,
targets::{build_creation_audio_target, creation_audio_generation_disabled_error_for_target},
types::{AudioAssetBindingTarget, AudioAssetSlot},
};
#[test]
fn creation_audio_billing_uses_lower_cost_for_background_music() {
let target = AudioAssetBindingTarget {
entity_kind: "puzzle_work".to_string(),
entity_id: "puzzle-profile-1".to_string(),
slot: "background_music".to_string(),
asset_kind: "puzzle_background_music".to_string(),
profile_id: Some("puzzle-profile-1".to_string()),
storage_prefix: LegacyAssetPrefix::PuzzleAssets,
storage_scope: "puzzle_work".to_string(),
billing_points_cost: None,
};
assert_eq!(
resolve_creation_audio_points_cost(AudioAssetSlot::BackgroundMusic, &target),
5
);
assert_eq!(
resolve_creation_audio_points_cost(AudioAssetSlot::SoundEffect, &target),
10
);
}
#[test]
fn editor_audio_billing_uses_model_price_from_generation_request() {
let target = AudioAssetBindingTarget {
entity_kind: "editor_audio".to_string(),
entity_id: "task-editor-audio".to_string(),
slot: "background_music".to_string(),
asset_kind: "editor_background_music".to_string(),
profile_id: None,
storage_prefix: LegacyAssetPrefix::CharacterDrafts,
storage_scope: "editor_audio".to_string(),
billing_points_cost: Some(12),
};
assert_eq!(
resolve_creation_audio_points_cost(AudioAssetSlot::BackgroundMusic, &target),
12
);
}
#[test]
fn disabled_creation_audio_targets_return_gone_including_wooden_fish_sound_effects() {
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
entity_kind: "puzzle_work".to_string(),
entity_id: "puzzle-profile-1".to_string(),
slot: "background_music".to_string(),
asset_kind: "puzzle_background_music".to_string(),
profile_id: Some("puzzle-profile-1".to_string()),
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::PuzzleAssets),
};
let error = creation_audio_generation_disabled_error_for_target(payload);
assert_eq!(error.status_code(), StatusCode::GONE);
let payload = creation_audio::PublishGeneratedAudioAssetRequest {
entity_kind: "wooden_fish_work".to_string(),
entity_id: "wooden-fish-profile-1".to_string(),
slot: "hit_sound".to_string(),
asset_kind: "wooden_fish_hit_sound".to_string(),
profile_id: Some("wooden-fish-profile-1".to_string()),
storage_prefix: Some(creation_audio::CreationAudioStoragePrefix::WoodenFishAssets),
};
let error = build_creation_audio_target(payload, AudioAssetSlot::SoundEffect)
.expect_err("wooden fish hit sound target should be disabled");
assert_eq!(error.status_code(), StatusCode::GONE);
}
#[test]
fn editor_sound_effect_request_normalizes_prompt_duration_and_resolves_price() {
let normalized =
normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest {
prompt: " 金币掉落叮当声 ".to_string(),
model: None,
duration: 7,
project_id: None,
canvas_completion: None,
generation_inputs: None,
asset_folder_id: None,
asset_label: None,
})
.expect("editor sound effect request should normalize");
assert_eq!(normalized.prompt, "金币掉落叮当声");
assert_eq!(normalized.model, platform_audio::VIDU_AUDIO_MODEL);
assert_eq!(normalized.duration, 7);
assert_eq!(normalized.price_mud_points, 5);
}
#[test]
fn editor_sound_effect_request_accepts_only_vidu_audio_model() {
let normalized =
normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest {
prompt: "金币掉落叮当声".to_string(),
model: Some(" audio1.0 ".to_string()),
duration: 5,
project_id: None,
canvas_completion: None,
generation_inputs: None,
asset_folder_id: None,
asset_label: None,
})
.expect("Vidu audio model should be accepted");
assert_eq!(normalized.model, platform_audio::VIDU_AUDIO_MODEL);
let error = normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest {
prompt: "金币掉落叮当声".to_string(),
model: Some(platform_audio::SUNO_DEFAULT_MODEL.to_string()),
duration: 5,
project_id: None,
canvas_completion: None,
generation_inputs: None,
asset_folder_id: None,
asset_label: None,
})
.expect_err("Suno text-to-sound should be disabled for editor sound effects");
assert!(error.to_string().contains("Vidu"));
}
#[test]
fn editor_sound_effect_request_rejects_duration_outside_vidu_range() {
for duration in [1, 11] {
let error =
normalize_editor_sound_effect_request(assets::EditorSoundEffectGenerateRequest {
prompt: "按钮点击".to_string(),
model: None,
duration,
project_id: None,
canvas_completion: None,
generation_inputs: None,
asset_folder_id: None,
asset_label: None,
})
.expect_err("duration outside 2-10 seconds should fail");
assert!(error.to_string().contains("2-10"));
}
}
#[test]
fn creation_sound_effect_duration_rejects_outside_vidu_range() {
assert_eq!(
normalize_creation_sound_effect_duration(None).expect("default duration should pass"),
platform_audio::DEFAULT_SOUND_EFFECT_DURATION_SECONDS
);
assert_eq!(
normalize_creation_sound_effect_duration(Some(2)).expect("2 seconds should pass"),
2
);
assert_eq!(
normalize_creation_sound_effect_duration(Some(10)).expect("10 seconds should pass"),
10
);
for duration in [Some(1), Some(11)] {
let error = normalize_creation_sound_effect_duration(duration)
.expect_err("duration outside Vidu 2-10 seconds should fail");
assert!(error.to_string().contains("2-10"));
}
}
#[test]
fn editor_background_music_request_forces_instrumental_and_resolves_price() {
let normalized =
normalize_editor_background_music_request(assets::EditorBackgroundMusicGenerateRequest {
gpt_description_prompt: " 森林冒险背景音乐 ".to_string(),
make_instrumental: false,
project_id: None,
canvas_completion: None,
generation_inputs: None,
asset_folder_id: None,
asset_label: None,
})
.expect("editor background music request should normalize");
assert_eq!(normalized.gpt_description_prompt, "森林冒险背景音乐");
assert!(normalized.make_instrumental);
assert_eq!(normalized.price_mud_points, 12);
}
#[test]
fn editor_background_music_request_rejects_prompt_over_documented_limit() {
let error =
normalize_editor_background_music_request(assets::EditorBackgroundMusicGenerateRequest {
gpt_description_prompt: "".repeat(201),
make_instrumental: true,
project_id: None,
canvas_completion: None,
generation_inputs: None,
asset_folder_id: None,
asset_label: None,
})
.expect_err("Suno gpt_description_prompt should follow Apifox 200 char limit");
assert!(error.to_string().contains("gpt_description_prompt"));
}
@@ -14,7 +14,6 @@ pub(super) const MUSIC_SLOT: &str = "music";
#[cfg(any())]
pub(super) const AMBIENT_SOUND_SLOT: &str = "ambient_sound";
pub(super) const CREATION_BACKGROUND_MUSIC_POINTS_COST: u64 = 5;
pub(super) const CREATION_SOUND_EFFECT_POINTS_COST: u64 = 10;
#[derive(Clone, Debug)]
pub(super) struct AudioAssetBindingTarget {
@@ -31,14 +30,12 @@ pub(super) struct AudioAssetBindingTarget {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum AudioAssetSlot {
BackgroundMusic,
SoundEffect,
}
impl AudioAssetSlot {
pub(super) fn task_kind(self) -> AudioTaskKind {
match self {
Self::BackgroundMusic => AudioTaskKind::BackgroundMusic,
Self::SoundEffect => AudioTaskKind::SoundEffect,
}
}
@@ -65,7 +62,6 @@ impl AudioAssetSlot {
pub(super) fn creation_contract_kind(self) -> creation_audio::CreationAudioGenerationKind {
match self {
Self::BackgroundMusic => creation_audio::CreationAudioGenerationKind::BackgroundMusic,
Self::SoundEffect => creation_audio::CreationAudioGenerationKind::SoundEffect,
}
}
}

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