接入VectorEngine画布Agent规划

切换画布 Agent 与通用 LLM 代理到 VectorEngine gpt-5.4-mini。

为画布 Agent 注入上一轮生成结果并默认承接上一张图编辑。

补齐规范图工具分流、规范展板 prompt 要求和 JSON 残片回复兜底。

同步前后端契约、测试脚本、环境示例和项目文档。
This commit is contained in:
2026-07-05 17:42:27 +08:00
parent a4a7aed024
commit d7d98acfd4
20 changed files with 945 additions and 116 deletions
+11 -11
View File
@@ -1,11 +1,12 @@
# Server-side OpenAI-compatible LLM endpoint base URL.
LLM_BASE_URL="https://ark.cn-beijing.volces.com/api/v3"
LLM_BASE_URL="https://api.vectorengine.cn/v1"
# Server-side API key used by the local Vite proxy.
# Recommended: set `LLM_API_KEY` or `ARK_API_KEY`.
# Recommended: set `LLM_API_KEY` locally, or use `VECTOR_ENGINE_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.
LLM_API_KEY="YOUR_API_KEY"
LLM_API_KEY=""
# Optional frontend override for the local proxy path.
VITE_LLM_PROXY_BASE_URL="/api/llm"
@@ -116,7 +117,11 @@ WECHAT_MINIPROGRAM_GENERATION_RESULT_TEMPLATE_ID="m5z7BkkBhJGbcH0cdDeHaeRU2tViDE
WECHAT_MINIPROGRAM_SUBSCRIBE_MESSAGE_STATE="formal"
# Model name for chat completions.
VITE_LLM_MODEL="doubao-1-5-pro-32k-character-250715"
VITE_LLM_MODEL="gpt-5.4-mini"
GENARRATIVE_LLM_PROVIDER="openai-compatible"
GENARRATIVE_LLM_BASE_URL="https://api.vectorengine.cn/v1"
GENARRATIVE_LLM_API_KEY=""
GENARRATIVE_LLM_MODEL="gpt-5.4-mini"
# Optional: enable upstream web search for RPG story text generation.
RPG_LLM_WEB_SEARCH_ENABLED="true"
@@ -125,13 +130,8 @@ RPG_LLM_WEB_SEARCH_ENABLED="true"
DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1"
DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY"
# APIMart Responses config for creative-agent text/multimodal understanding.
APIMART_BASE_URL="https://api.apimart.ai/v1"
APIMART_API_KEY="YOUR_APIMART_API_KEY"
APIMART_IMAGE_REQUEST_TIMEOUT_MS="180000"
# VectorEngine GPT-image-2 / Gemini image generation config.
VECTOR_ENGINE_BASE_URL="https://api.vectorengine.ai"
# VectorEngine LLM and GPT-image-2 / Gemini image generation config.
VECTOR_ENGINE_BASE_URL="https://api.vectorengine.cn"
VECTOR_ENGINE_API_KEY=""
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS="1000000"
+2 -2
View File
@@ -49,8 +49,8 @@ GENARRATIVE_SPACETIME_POOL_SIZE=8
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45
GENARRATIVE_LLM_PROVIDER=openai-compatible
GENARRATIVE_LLM_BASE_URL=
GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1
GENARRATIVE_LLM_API_KEY=
GENARRATIVE_LLM_MODEL=
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=
+2 -6
View File
@@ -57,16 +57,12 @@ GENARRATIVE_SPACETIME_POOL_SIZE=8
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=45
GENARRATIVE_LLM_PROVIDER=openai-compatible
GENARRATIVE_LLM_BASE_URL=
GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1
GENARRATIVE_LLM_API_KEY=
GENARRATIVE_LLM_MODEL=
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED=false
GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=false
APIMART_BASE_URL=
APIMART_API_KEY=
APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000
VECTOR_ENGINE_BASE_URL=https://api.vectorengine.cn
VECTOR_ENGINE_API_KEY=
VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS=1000000
@@ -2126,7 +2126,7 @@
## 2026-05-09 GPT-image-2 图片生成统一迁移到 VectorEngine
- 背景:仓库内 RPG、拼图、方洞和本地模板脚本的 GPT-image-2 生图此前依赖 APIMart 图片网关;团队要求参考 VectorEngine Apifox `api-448710071`,后续不再使用 APIMart 执行 GPT-image-2 图片生成。
- 决策:所有 GPT-image-2 无参考图生图请求统一走 VectorEngine `POST /v1/images/generations`,有参考图请求走 `POST /v1/images/edits` multipart,基础配置读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` / `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,上游模型使用 `gpt-image-2`,请求体不再携带 `official_fallback`。APIMart 保留给创意 Agent 的 `gpt-5` Responses 文本/多模态链路。
- 决策:所有 GPT-image-2 无参考图生图请求统一走 VectorEngine `POST /v1/images/generations`,有参考图请求走 `POST /v1/images/edits` multipart,基础配置读取 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` / `VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`,上游模型使用 `gpt-image-2`,请求体不再携带 `official_fallback`当时 APIMart 保留给创意 Agent 的 `gpt-5` Responses 文本/多模态链路;该文本链路已被 2026-07-05 VectorEngine Chat Completions `gpt-5.4-mini` 决策覆盖
- 影响范围:`api-server` 共享图片 helper、拼图图片生成、角色主图、RPG 场景图、开局 CG 故事板、方洞视觉资产、生产环境示例、gpt-image-2 本地 skill 和相关技术文档。
- 验证方式:执行 `npm run check:encoding``cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml``cargo test -p api-server puzzle --manifest-path server-rs/Cargo.toml``cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml``cargo test -p api-server character_visual --manifest-path server-rs/Cargo.toml`,并用 `npm run dev:api-server` + `/healthz` 做后端 smoke。
- 关联文档:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md``docs/technical/API_SERVER_EXTERNAL_SERVICE_ENV_CONFIG_2026-05-07.md`
@@ -2149,7 +2149,7 @@
## 2026-05-08 APIMart 接口统一携带 `official_fallback`
> 2026-05-09 追认:本决策中的图片生成部分已被“GPT-image-2 图片生成统一迁移到 VectorEngine”覆盖;当前只保留 APIMart `gpt-5` Responses 文本/多模态链路继续显式携带 `official_fallback`
> 2026-05-09 追认:本决策中的图片生成部分已被“GPT-image-2 图片生成统一迁移到 VectorEngine”覆盖;2026-07-05 后 APIMart `gpt-5` Responses 文本/多模态链路也被 VectorEngine Chat Completions `gpt-5.4-mini` 覆盖,不再携带 `official_fallback`
- 背景:APIMart 的图片生成和 Responses 接口在仓库内分散于 `api-server``platform-llm` 和本地 skill 脚本,若只修单点,容易出现不同入口的上游请求体不一致。
- 决策:凡是仓库内调用 APIMart 的 OpenAI 兼容接口,请求体统一携带 `official_fallback: true`;其中图片生成请求直接固定写入,`platform-llm` 的 APIMart GPT-5 client 通过显式开关开启,不默认扩散到 Ark 等其它 provider。
@@ -2304,7 +2304,7 @@
## 2026-05-05 creative-agent Task E API / SSE facade 已落地
- 背景:Phase 1 需要先把创意 Agent 的 HTTP/SSE 门面接入 Rust `api-server`,用于前端工作区调用和拼图模板确认闭环。
- 决策:`api-server` 挂载 `/api/runtime/creative-agent/*` 六个鉴权路由;creative session 在 Task D 表未收口前暂存在 `api-server` 运行态并按 authenticated user 校验 owner;未确认模板前不创建拼图 session,`confirm-template` 后才通过既有 `spacetime-client` 创建/编译 `puzzle_agent_session``gpt-5` 请求只从 `APIMART_BASE_URL` / `APIMART_API_KEY` 构造专用 Responses client,不复用通用 `GENARRATIVE_LLM_API_KEY`
- 决策:`api-server` 挂载 `/api/runtime/creative-agent/*` 六个鉴权路由;creative session 在 Task D 表未收口前暂存在 `api-server` 运行态并按 authenticated user 校验 owner;未确认模板前不创建拼图 session,`confirm-template` 后才通过既有 `spacetime-client` 创建/编译 `puzzle_agent_session`当时 `gpt-5` 请求只从 `APIMART_BASE_URL` / `APIMART_API_KEY` 构造专用 Responses client,不复用通用 `GENARRATIVE_LLM_API_KEY`该 LLM 来源已被 2026-07-05 VectorEngine Chat Completions `gpt-5.4-mini` 决策覆盖。
- 影响范围:`server-rs/crates/api-server/src/creative_agent.rs``creative_agent_sse.rs``app.rs``state.rs``module-puzzle` creative template/tool、Phase 1 PRD。
- 验证方式:`cargo check -p api-server``cargo test -p module-puzzle creative``cargo test -p api-server creative_agent``npm run dev:api-server` 后检查 `/healthz``POST /api/runtime/creative-agent/sessions``POST /api/runtime/creative-agent/sessions/{sessionId}/messages/stream`
- 关联文档:`docs/prd/CREATIVE_INTERACTIVE_AGENT_PHASE1_LANGCHAIN_RUST_PUZZLE_LOOP_PRD_2026-05-05.md`
@@ -3821,3 +3821,12 @@
- 决策:兑换码使用校验中,公共码继续按 `max_uses` 控制单用户可兑次数;唯一码和私有码改为同一 `code + user_id` 只能成功兑换一次。私有码仍先校验 `allowed_user_ids`,命中允许名单后再按该用户历史使用次数拒绝重复兑换;`global_used_count` 只保留为统计字段,不再作为唯一码 / 私有码的兑换阻断条件。
- 影响范围:`module-runtime::validate_runtime_profile_redeem_code_usage``profile_redeem_code_usage` 计次语义。
- 验证方式:`cargo test -p module-runtime --manifest-path server-rs/Cargo.toml runtime_profile_redeem_code_usage_validation_matches_modes``npm run check:encoding``git diff --check`
## 2026-07-05 VectorEngine LLM 默认使用 `gpt-5.4-mini`
- 背景:VectorEngine Apifox `api-349239079` 暴露 OpenAI-compatible `POST /v1/chat/completions`;创意 Agent 和通用 LLM 代理需要统一到 VectorEngine 文本服务,并将默认文本模型切换为 `gpt-5.4-mini`
- 决策:创意 Agent 的 `CREATIVE_AGENT_GPT5_MODEL` 固定为 `gpt-5.4-mini`,协议切到 Chat Completions,不再携带旧 APIMart `official_fallback` 字段;画布 Agent 侧边栏聊天规划请求也复用该模型和 Chat Completions 协议,不再显式使用 `gpt-4o` / Responses。通用 `/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` 时,api-server 可复用 `VECTOR_ENGINE_API_KEY`;前端 LLM 客户端必须兼容 OpenAI `choices`、api-server raw `{content}` 和项目 envelope `{ok,data:{content}}` 三种非流式响应,以及 OpenAI SSE delta 和 api-server `event: delta` 两种流式响应。
- 决策补充:画布 Agent 的 planning prompt 必须自动注入上一条已完成生成结果的 `latestGeneratedImage`,来源为上一轮 generation 的 `summary` / `toolName` / `resourceId` / `objectKey` 等轻量摘要。用户用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代上一张图或继续编辑时,规划默认调用 `edit_image` 并引用该结果;不能因为本轮没有手动附件而退回 `generate_image`
- 决策补充:画布 Agent 侧边栏的“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”是 Agent 规划 prompt 和 function-calling 工具选择约束,不是侧边栏 UI 说明文案。此类请求默认走 `generate_image`,prompt 必须要求规范展板包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等视觉规范元素;角色规范图若是规范展板也走 `generate_image`,只有实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`
- 影响范围:`server-rs/crates/platform-agent``server-rs/crates/api-server/src/config.rs``src/services/llmClient.ts``.env.example``deploy/env/api-server.env.example``scripts/test-ve-llm.mjs`
- 验证方式:`npm run test -- src/services/llmClient.test.ts``cargo test -p api-server --manifest-path server-rs/Cargo.toml from_env_reads_non_public_models_and_urls app_state_builds_creative_agent_gpt5_client_from_vector_engine_settings llm_chat_completions editor_agent_llm_request_uses_vector_engine_chat_model``cargo test -p platform-agent --manifest-path server-rs/Cargo.toml``npm run check:encoding``git diff --check`
@@ -1370,7 +1370,7 @@
## GPT-image-2 不再读 APIMart 图片配置
- 现象:配置了 `APIMART_BASE_URL` / `APIMART_API_KEY` 后,RPG、拼图或方洞的 GPT-image-2 生图仍返回缺配置,或请求体里还出现 `official_fallback` / `image_urls`
- 原因:2026-05-21 后 GPT-image-2 图片生成按 VectorEngine 创建/编辑接口分流APIMart 只保留给创意 Agent 的 `gpt-5` Responses 文本/多模态链路
- 原因:2026-05-21 后 GPT-image-2 图片生成按 VectorEngine 创建/编辑接口分流2026-07-05 后创意 Agent 文本链路也改为 VectorEngine Chat Completions `gpt-5.4-mini`,APIMart 不再作为当前创意 Agent 来源
- 处理:为图片生成配置 `VECTOR_ENGINE_BASE_URL=https://api.vectorengine.ai``VECTOR_ENGINE_API_KEY``VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS`;排查请求体时确认无参考图路径为 `/v1/images/generations`、有参考图路径为 `/v1/images/edits`,模型为 `gpt-image-2`
- 验证:运行 `cargo test -p api-server openai_image --manifest-path server-rs/Cargo.toml` 和相关玩法图片生成测试;真实联调只在本地私密环境放置 VectorEngine key。
- 关联:`docs/technical/VECTOR_ENGINE_GPT_IMAGE_2_GENERATION_2026-05-09.md``server-rs/crates/api-server/src/openai_image_generation.rs`
@@ -216,7 +216,7 @@ npm run check:server-rs-ddd
## 外部服务与资产
- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5` Responses / Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/responses``APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5 客户端来源;后续排障时优先确认 VectorEngine `/v1/models``/v1/chat/completions``/v1/responses` 可用性。
- LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/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``APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models``/v1/chat/completions``/v1/responses` 可用性。
- 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event``event_key = external_generation_run`metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations``/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required``request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt``max_attempts``retry_delay_ms``reference_image_bytes_total``request_params`,不要把 `SendRequest` 当成上游业务错误。
- Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2``2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。
- Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。
@@ -455,13 +455,13 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日
结构化创作 / RPG 的 Responses JSON 链路默认不打开 `web_search`;本地和生产如需联网增强,必须显式配置 `GENARRATIVE_RPG_LLM_WEB_SEARCH_ENABLED=true``GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=true`。如果上游未开通工具,Responses 可能先吐自然语言再返回 `ToolNotOpen`,这类报错应按工具不可用排查,不要先当成 JSON 解析 bug。
创意 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`。排查或切换密钥后,可在本地运行:
```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` Chat Completions 和基础 JSON 输出能力;脚本只输出 HTTP 状态、耗时、模型和截断摘要,不应打印密钥。若 `.env.secrets.local` 不存在,先补本地 secrets 文件再运行,不要把 secrets 提交进仓库。
该脚本读取仓库根目录 `.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 提交进仓库。
### 手机验证码短信
@@ -21,6 +21,9 @@
| 生成 UI 设计图 | 既有 UI 设计图生成入口对应接口 |
- 意图解析与工具编排在后端 api-server,前端只渲染状态,不承接业务规则。
- 下面的工具选择口径属于 Agent 规划 prompt / function-calling 约束,不是侧边栏 UI 说明文案;侧边栏面板不展示这些规则解释。
- 用户要求“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”时,规划默认选择 `generate_image`,并在 prompt 中明确要求生成规范展板,包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等可落地的视觉规范元素。
- 用户要求“角色规范图”且语义是角色的规范展板、风格展板或设定板时,仍走 `generate_image`,不要误分流到 `generate_character`;只有实际生成角色立绘、角色主形象或角色视觉资产时才走 `generate_character`。用户要求多个图标素材、图集或 spritesheet 时才走 `generate_icon_spritesheet`
- 所有生成必须走 `execute_billable_asset_operation_with_cost` 与模型定价配置,禁止绕过定价收口。
- 视频 / 音频 / 图层操作等其余画板功能第一期不进入对话工具面,仍走现有面板。
@@ -67,9 +70,12 @@
## LLM 与计费
- 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 的轻量规划请求显式使用 `gpt-4o`,避免把简单 JSON 意图识别交给容易长 reasoning 的 gpt-5function-calling 注册五类工具。
- 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 规划请求固定使用 VectorEngine `gpt-5.4-mini` Chat Completionsfunction-calling 注册五类工具。
- 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、请求失败或返回格式不可解析时,后端写入明确错误消息,不使用本地关键词或“收到:...”回显兜底。
- 画布 Agent 规划请求使用 Responses API、1024 `max_output_tokens` 和 60 秒 Agent 专用请求超时;生成图片/编辑图片仍走对应生成工具和模型计费
- 规划 prompt 必须自动带入上一条已完成生成结果的 `latestGeneratedImage` 引用,内容只包含上一轮 generation 的 `summary` / `toolName` / `resourceId` / `objectKey` / `assetObjectId` 等轻量元数据,不把私有签名 URL 或大图内容塞进 prompt
- 用户使用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代或编辑上一张结果图时,LLM 默认选择 `edit_image` 并引用 `latestGeneratedImage` 作为源图;除非用户明确要求全新生成,否则不能因为本轮没有重新上传附件而降级为 `generate_image`
- 规划 prompt 必须显式区分“规范展板”和“实际素材产出”:规范图、视觉规范图、风格规范图、素材规范展板、角色规范图等规范展板请求走 `generate_image`,并补齐统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等要求;实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`
- 画布 Agent 规划请求使用 Chat Completions、1024 `max_tokens` 和 60 秒 Agent 专用请求超时;生成图片/编辑图片仍走对应生成工具和模型计费。
- **对话回合免费**(聊天、分析回复不扣泥点),仅 Agent 实际触发生成工具时按对应模型定价扣泥点。
- 工具调用前后端校验泥点余额;不足时该次生成失败并在对话中以明确错误气泡告知,对话本身可继续。
@@ -59,6 +59,7 @@ export type EditorAgentGenerationStatus = 'generating' | 'completed' | 'failed';
export interface EditorAgentGenerationRecord {
toolCallId: string;
toolName: EditorAgentToolName;
summary?: string | null;
taskId: string | null;
status: EditorAgentGenerationStatus;
model: string | null;
+2 -2
View File
@@ -464,9 +464,9 @@ GENARRATIVE_SPACETIME_POOL_SIZE=2
GENARRATIVE_SPACETIME_PROCEDURE_TIMEOUT_SECONDS=15
GENARRATIVE_LLM_PROVIDER=openai-compatible
GENARRATIVE_LLM_BASE_URL=
GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1
GENARRATIVE_LLM_API_KEY=
GENARRATIVE_LLM_MODEL=
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
VECTOR_ENGINE_BASE_URL=
VECTOR_ENGINE_API_KEY=
ALIYUN_OSS_BUCKET=
+8 -8
View File
@@ -83,32 +83,32 @@ const tests = [
method: 'POST',
path: '/v1/chat/completions',
body: {
model: 'gpt-4o',
model: 'gpt-5.4-mini',
messages: [{ role: 'user', content: '回复 ok,不要解释' }],
max_tokens: 10,
},
},
// 3. Responses - Apimart 当前使用的协议
// 3. Responses - 仅作兼容探测,creative_agent 默认走 Chat Completions
{
name: 'POST /v1/responses (Responses)',
method: 'POST',
path: '/v1/responses',
body: {
model: 'gpt-4o',
model: 'gpt-5.4-mini',
input: [
{ role: 'user', content: [{ type: 'input_text', text: '回复 ok,不要解释' }] },
],
},
},
// 4. 测试 gpt-5 (creative_agent 模型)
// 4. 测试 gpt-5.4-mini (creative_agent 模型)
{
name: 'POST /v1/chat/completions (gpt-5, Chat)',
name: 'POST /v1/chat/completions (gpt-5.4-mini, Chat)',
method: 'POST',
path: '/v1/chat/completions',
body: {
model: 'gpt-5',
model: 'gpt-5.4-mini',
messages: [{ role: 'user', content: '回复 ok' }],
max_tokens: 10,
},
@@ -120,7 +120,7 @@ const tests = [
method: 'POST',
path: '/v1/chat/completions',
body: {
model: 'gpt-4o',
model: 'gpt-5.4-mini',
messages: [
{ role: 'system', content: '你是抓大鹅游戏编辑,只返回 JSON。' },
{ role: 'user', content: '题材:水果。请生成 JSON{"gameName":"水果切切乐","items":[{"name":"苹果","itemSize":"中"},{"name":"西瓜","itemSize":"大"}]}' },
@@ -155,7 +155,7 @@ console.log(`=== 结果: ${pass}/${tests.length} 通过, ${fail}/${tests.length}
// 结论
if (pass >= 3) {
console.log('\n✅ VectorEngine 支持 LLM 文本调用,可替代 Apimart。');
console.log(' 将 .env.secrets.local 中 APIMART_BASE_URL 改为 VectorEngine 地址即可。');
console.log(' 将 .env.secrets.local 中 VECTOR_ENGINE_BASE_URL / VECTOR_ENGINE_API_KEY 配好即可。');
} else if (pass <= 1) {
console.log('\n❌ VectorEngine 不支持 LLM 文本调用。');
} else {
+26 -2
View File
@@ -940,8 +940,21 @@ impl AppConfig {
config.llm_base_url = DEFAULT_ARK_BASE_URL.to_string();
}
config.llm_api_key =
read_first_non_empty_env(&["GENARRATIVE_LLM_API_KEY", "LLM_API_KEY", "ARK_API_KEY"]);
config.llm_api_key = if config.llm_provider == LlmProvider::OpenAiCompatible {
read_first_non_empty_env(&[
"GENARRATIVE_LLM_API_KEY",
"LLM_API_KEY",
"VECTOR_ENGINE_API_KEY",
"ARK_API_KEY",
])
} else {
read_first_non_empty_env(&[
"GENARRATIVE_LLM_API_KEY",
"LLM_API_KEY",
"ARK_API_KEY",
"VECTOR_ENGINE_API_KEY",
])
};
if let Some(llm_model) =
read_first_non_empty_env(&["GENARRATIVE_LLM_MODEL", "LLM_MODEL", "VITE_LLM_MODEL"])
@@ -1629,10 +1642,14 @@ mod tests {
unsafe {
std::env::remove_var("GENARRATIVE_LLM_PROVIDER");
std::env::remove_var("GENARRATIVE_LLM_BASE_URL");
std::env::remove_var("GENARRATIVE_LLM_API_KEY");
std::env::remove_var("LLM_API_KEY");
std::env::remove_var("ARK_API_KEY");
std::env::remove_var("GENARRATIVE_LLM_MODEL");
std::env::remove_var("APIMART_BASE_URL");
std::env::remove_var("APIMART_IMAGE_REQUEST_TIMEOUT_MS");
std::env::remove_var("VECTOR_ENGINE_BASE_URL");
std::env::remove_var("VECTOR_ENGINE_API_KEY");
std::env::remove_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS");
std::env::remove_var("HYPER3D_BASE_URL");
std::env::remove_var("DASHSCOPE_SCENE_IMAGE_MODEL");
@@ -1648,7 +1665,9 @@ mod tests {
std::env::set_var("GENARRATIVE_LLM_MODEL", "internal-text-model");
std::env::set_var("APIMART_BASE_URL", "https://responses.internal.example/v1");
std::env::set_var("APIMART_IMAGE_REQUEST_TIMEOUT_MS", "190000");
std::env::set_var("ARK_API_KEY", "ark-key");
std::env::set_var("VECTOR_ENGINE_BASE_URL", "https://vector.internal.example");
std::env::set_var("VECTOR_ENGINE_API_KEY", "vector-engine-key");
std::env::set_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS", "210000");
std::env::set_var("HYPER3D_BASE_URL", "https://model.internal.example/api/v2");
std::env::set_var("DASHSCOPE_SCENE_IMAGE_MODEL", "scene-model");
@@ -1664,6 +1683,7 @@ mod tests {
let config = AppConfig::from_env();
assert_eq!(config.llm_provider, LlmProvider::OpenAiCompatible);
assert_eq!(config.llm_base_url, "https://llm.internal.example/v1");
assert_eq!(config.llm_api_key.as_deref(), Some("vector-engine-key"));
assert_eq!(config.llm_model, "internal-text-model");
// assert_eq!(
// config.apimart_base_url,
@@ -1694,10 +1714,14 @@ mod tests {
unsafe {
std::env::remove_var("GENARRATIVE_LLM_PROVIDER");
std::env::remove_var("GENARRATIVE_LLM_BASE_URL");
std::env::remove_var("GENARRATIVE_LLM_API_KEY");
std::env::remove_var("LLM_API_KEY");
std::env::remove_var("ARK_API_KEY");
std::env::remove_var("GENARRATIVE_LLM_MODEL");
std::env::remove_var("APIMART_BASE_URL");
std::env::remove_var("APIMART_IMAGE_REQUEST_TIMEOUT_MS");
std::env::remove_var("VECTOR_ENGINE_BASE_URL");
std::env::remove_var("VECTOR_ENGINE_API_KEY");
std::env::remove_var("VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS");
std::env::remove_var("HYPER3D_BASE_URL");
std::env::remove_var("DASHSCOPE_SCENE_IMAGE_MODEL");
File diff suppressed because it is too large Load Diff
+12 -11
View File
@@ -191,13 +191,14 @@ mod tests {
body: r#"{"id":"resp_api_server_01","model":"ark-router-test","choices":[{"message":{"content":""},"finish_reason":"stop"}]}"#.to_string(),
extra_headers: Vec::new(),
}]);
let state = seed_authenticated_state(AppConfig {
let (state, user_id) = seed_authenticated_state(AppConfig {
llm_base_url: server_url,
llm_api_key: Some("test-key".to_string()),
llm_model: "test-model".to_string(),
..AppConfig::default()
})
.await;
let token = issue_access_token(&state);
let token = issue_access_token(&state, &user_id);
let app = build_router(state);
let response = app
@@ -266,13 +267,14 @@ mod tests {
.to_string(),
extra_headers: vec![("x-request-id", "req_llm_stream_01")],
}]);
let state = seed_authenticated_state(AppConfig {
let (state, user_id) = seed_authenticated_state(AppConfig {
llm_base_url: server_url,
llm_api_key: Some("test-key".to_string()),
llm_model: "test-model".to_string(),
..AppConfig::default()
})
.await;
let token = issue_access_token(&state);
let token = issue_access_token(&state, &user_id);
let app = build_router(state);
let response = app
@@ -322,21 +324,20 @@ mod tests {
assert!(body_text.contains("data: [DONE]"));
}
async fn seed_authenticated_state(config: AppConfig) -> AppState {
async fn seed_authenticated_state(config: AppConfig) -> (AppState, String) {
let state = AppState::new(config).expect("state should build");
state
let user_id = state
.seed_test_phone_user_with_password("13800138101", "secret123")
.await
.id;
state
(state, user_id)
}
fn issue_access_token(state: &AppState) -> String {
fn issue_access_token(state: &AppState, user_id: &str) -> String {
let claims = AccessTokenClaims::from_input(
AccessTokenClaimsInput {
user_id: "user_00000001".to_string(),
session_id: state
.seed_test_refresh_session_for_user_id("user_00000001", "sess_llm_proxy"),
user_id: user_id.to_string(),
session_id: state.seed_test_refresh_session_for_user_id(user_id, "sess_llm_proxy"),
provider: AuthProvider::Password,
roles: vec!["user".to_string()],
token_version: 2,
+4 -5
View File
@@ -1507,8 +1507,7 @@ fn build_creative_agent_gpt5_client(
config.llm_request_timeout_ms,
0,
config.llm_retry_backoff_ms,
)?
.with_official_fallback(true);
)?;
Ok(Some(LlmClient::new(llm_config)?))
}
@@ -1641,10 +1640,10 @@ mod tests {
platform_agent::CREATIVE_AGENT_GPT5_MODEL
);
assert_eq!(
client.config().responses_url(),
"https://api.vectorengine.test/v1/responses"
client.config().chat_completions_url(),
"https://api.vectorengine.test/v1/chat/completions"
);
assert!(client.config().official_fallback());
assert!(!client.config().official_fallback());
}
#[test]
@@ -5,7 +5,7 @@ use platform_llm::{
use crate::error::PlatformAgentError;
pub const CREATIVE_AGENT_GPT5_MODEL: &str = "gpt-5";
pub const CREATIVE_AGENT_GPT5_MODEL: &str = "gpt-5.4-mini";
#[derive(Clone)]
pub struct Gpt5ResponsesAgentClient {
@@ -54,7 +54,7 @@ pub fn build_gpt5_multimodal_request(
max_tokens: None,
request_timeout_ms: None,
enable_web_search: false,
protocol: LlmTextProtocol::Responses,
protocol: LlmTextProtocol::ChatCompletions,
response_reasoning_effort: None,
response_text_verbosity: None,
}
@@ -111,6 +111,8 @@ pub struct EditorAgentGenerationRecord {
pub tool_call_id: String,
pub tool_name: EditorAgentToolName,
#[serde(default)]
pub summary: Option<String>,
#[serde(default)]
pub task_id: Option<String>,
pub status: EditorAgentGenerationStatus,
#[serde(default)]
@@ -150,6 +150,7 @@ function upsertGenerationRecord(
? {
...record,
...nextRecord,
summary: nextRecord.summary ?? record.summary,
taskId: nextRecord.taskId ?? record.taskId,
model: nextRecord.model ?? record.model,
images: nextRecord.images.length ? nextRecord.images : record.images,
@@ -416,6 +417,7 @@ export function useEditorAgentConversation({
const nextRecord: EditorAgentGenerationRecord = {
toolCallId: event.data.toolCallId,
toolName: event.data.toolName,
summary: event.data.summary ?? null,
taskId: event.data.taskId ?? null,
status,
model: event.data.model ?? null,
@@ -450,6 +452,7 @@ export function useEditorAgentConversation({
const nextRecord: EditorAgentGenerationRecord = {
toolCallId: event.data.toolCallId,
toolName: event.data.toolName,
summary: null,
taskId: null,
status: 'completed',
model: event.data.model,
+61 -1
View File
@@ -1,6 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { streamPlainTextCompletion } from './llmClient';
import {
requestPlainTextCompletion,
streamPlainTextCompletion,
} from './llmClient';
function createSseResponse(body: string) {
const encoder = new TextEncoder();
@@ -48,4 +51,61 @@ describe('llmClient streamPlainTextCompletion', () => {
expect(onUpdate).toHaveBeenNthCalledWith(2, '溪上春风');
expect(onUpdate).toHaveBeenCalledTimes(2);
});
it('reads api-server SSE delta events', async () => {
const onUpdate = vi.fn();
const fetchMock = vi.fn().mockResolvedValue(
createSseResponse(
[
'event: delta\r\n',
'data: {"delta":"你","content":"你","finishReason":null}\r\n\r\n',
'event: delta\r\n',
'data: {"delta":"好","content":"你好","finishReason":null}\r\n\r\n',
'event: complete\r\n',
'data: {"id":"resp_01","model":"gpt-5.4-mini","content":"你好","finishReason":"stop"}\r\n\r\n',
'data: [DONE]\r\n\r\n',
].join(''),
),
);
vi.stubGlobal('fetch', fetchMock);
const result = await streamPlainTextCompletion('system', 'user', {
onUpdate,
});
expect(result).toBe('你好');
expect(onUpdate).toHaveBeenNthCalledWith(1, '你');
expect(onUpdate).toHaveBeenNthCalledWith(2, '你好');
expect(onUpdate).toHaveBeenCalledTimes(2);
});
});
describe('llmClient requestPlainTextCompletion', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('reads api-server response envelope content', async () => {
const fetchMock = vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
ok: true,
data: {
id: 'resp_01',
model: 'gpt-5.4-mini',
content: '代理成功',
finishReason: 'stop',
},
error: null,
meta: {requestId: 'req_01'},
}),
),
);
vi.stubGlobal('fetch', fetchMock);
const result = await requestPlainTextCompletion('system', 'user');
expect(result).toBe('代理成功');
});
});
+104 -20
View File
@@ -1,5 +1,5 @@
import type {TextStreamOptions} from './aiTypes';
import { fetchWithApiAuth } from './apiClient';
import { fetchWithApiAuth, type ApiRequestOptions } from './apiClient';
import { parseSseJsonObject, readSseStream } from './sseStream';
const ENV: Partial<ImportMetaEnv> = import.meta.env ?? {};
@@ -65,6 +65,67 @@ function readLlmStreamDeltaContent(parsed: Record<string, unknown>) {
return typeof content === 'string' && content.length > 0 ? content : null;
}
function readProjectLlmDeltaContent(parsed: Record<string, unknown>) {
const delta = parsed.delta;
return typeof delta === 'string' && delta.length > 0 ? delta : null;
}
function readProjectLlmCompleteContent(parsed: Record<string, unknown>) {
const content = parsed.content;
return typeof content === 'string' && content.length > 0 ? content : null;
}
function readOpenAiMessageContent(parsed: Record<string, unknown>) {
const choices = parsed.choices;
if (!Array.isArray(choices)) {
return null;
}
const [firstChoice] = choices;
if (typeof firstChoice !== 'object' || firstChoice === null) {
return null;
}
const message = (firstChoice as {message?: unknown}).message;
if (typeof message !== 'object' || message === null) {
return null;
}
const content = (message as {content?: unknown}).content;
return typeof content === 'string' && content.length > 0 ? content : null;
}
function readLlmResponseContent(parsed: unknown) {
if (typeof parsed !== 'object' || parsed === null) {
return null;
}
const record = parsed as Record<string, unknown>;
const directContent = readProjectLlmCompleteContent(record);
if (directContent) {
return directContent;
}
const openAiContent = readOpenAiMessageContent(record);
if (openAiContent) {
return openAiContent;
}
const data = record.data;
if (typeof data === 'object' && data !== null) {
return readLlmResponseContent(data);
}
return null;
}
function readLlmStreamErrorMessage(parsed: Record<string, unknown>) {
const message = parsed.message;
return typeof message === 'string' && message.trim()
? message.trim()
: 'LLM stream returned an error event.';
}
const NODE_ENV = getNodeEnv();
const IS_SERVER_RUNTIME = typeof window === 'undefined';
const SERVER_API_KEY =
@@ -145,7 +206,11 @@ function normalizeLlmError(error: unknown): never {
throw error;
}
function requestLlmEndpoint(input: string, init: RequestInit = {}) {
function requestLlmEndpoint(
input: string,
init: RequestInit = {},
options: ApiRequestOptions = {},
) {
const headers = resolveHeaders(init.headers);
if (IS_SERVER_RUNTIME && SERVER_API_KEY.trim()) {
headers.Authorization = `Bearer ${SERVER_API_KEY.trim()}`;
@@ -158,7 +223,7 @@ function requestLlmEndpoint(input: string, init: RequestInit = {}) {
return IS_SERVER_RUNTIME
? fetch(input, nextInit)
: fetchWithApiAuth(input, nextInit);
: fetchWithApiAuth(input, nextInit, options);
}
export function isLlmConnectivityError(error: unknown): error is LlmConnectivityError {
@@ -221,8 +286,8 @@ async function requestMessageContent(
}
const data = JSON.parse(rawResponseText);
const content = data?.choices?.[0]?.message?.content;
if (!content || typeof content !== 'string') {
const content = readLlmResponseContent(data);
if (!content) {
throw new Error('LLM response did not include message content.');
}
@@ -279,19 +344,23 @@ export async function streamPlainTextCompletion(
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await requestLlmEndpoint(`${API_BASE_URL}/chat/completions`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
model: MODEL,
stream: true,
messages: [
{role: 'system' as const, content: systemPrompt},
{role: 'user' as const, content: userPrompt},
],
}),
signal: controller.signal,
});
const response = await requestLlmEndpoint(
`${API_BASE_URL}/chat/completions`,
{
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
model: MODEL,
stream: true,
messages: [
{role: 'system' as const, content: systemPrompt},
{role: 'user' as const, content: userPrompt},
],
}),
signal: controller.signal,
},
{omitEnvelopeHeader: true},
);
if (!response.ok) {
const rawResponseText = await response.text();
@@ -314,13 +383,28 @@ export async function streamPlainTextCompletion(
let accumulatedText = '';
await readSseStream(response, ({ data }) => {
await readSseStream(response, ({ data, eventName }) => {
if (data === '[DONE]') {
return false;
}
const parsed = parseSseJsonObject(data);
const delta = parsed ? readLlmStreamDeltaContent(parsed) : null;
if (parsed && eventName === 'error') {
throw new Error(readLlmStreamErrorMessage(parsed));
}
if (parsed && eventName === 'complete') {
const content = readLlmResponseContent(parsed);
if (content && content !== accumulatedText) {
accumulatedText = content;
options.onUpdate?.(accumulatedText);
}
return;
}
const delta = parsed
? (readLlmStreamDeltaContent(parsed) ?? readProjectLlmDeltaContent(parsed))
: null;
if (delta) {
accumulatedText += delta;
options.onUpdate?.(accumulatedText);