接入VectorEngine画布Agent规划
切换画布 Agent 与通用 LLM 代理到 VectorEngine gpt-5.4-mini。 为画布 Agent 注入上一轮生成结果并默认承接上一张图编辑。 补齐规范图工具分流、规范展板 prompt 要求和 JSON 残片回复兜底。 同步前后端契约、测试脚本、环境示例和项目文档。
This commit is contained in:
+11
-11
@@ -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"
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
Vendored
+2
-6
@@ -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-5;function-calling 注册五类工具。
|
||||
- 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 规划请求固定使用 VectorEngine `gpt-5.4-mini` Chat Completions;function-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;
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user