无图背景色决策改走独立文本档 gpt-5-mini
无源图路径此前继承 state.llm_client()(Ark/豆包),选色能力弱、且线上从未 调用过 VectorEngine。新增独立常量 EDITOR_SCREEN_BACKGROUND_TEXT_LLM_MODEL, 让无图决策也走 VectorEngine gpt-5-mini(Responses 协议 + reasoning=low),与 有图视觉档区分、便于各自调参;gpt5 客户端未配置时才降级回默认文本客户端。 附两个 #[ignore] 真机联调测试,验证有图/无图两条路径真实调用 VectorEngine。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -94,25 +94,28 @@ pub(crate) async fn resolve_editor_screen_background_color(
|
||||
.map(|report| report.allowed.clone())
|
||||
.unwrap_or_else(|| EDITOR_SCREEN_BACKGROUND_COLORS.to_vec());
|
||||
|
||||
// 默认 llm_client 是纯文本模型(Ark),收到图片分片会被上游 400 拒绝;
|
||||
// 带图决策必须走 VectorEngine 视觉客户端,没有视觉客户端时降级为纯文本决策。
|
||||
let (llm_client, vision_model) = if has_source_image {
|
||||
match vision_llm_client {
|
||||
Some(client) => (
|
||||
Some(client),
|
||||
Some(crate::llm_model_routing::EDITOR_SCREEN_BACKGROUND_VISION_LLM_MODEL),
|
||||
),
|
||||
None => {
|
||||
// 决策统一走 VectorEngine gpt-5-mini:有图用视觉档、无图用文本档(两个独立常量),
|
||||
// 都不继承 Ark 默认文本模型(豆包,选色能力弱)。仅当 gpt5 客户端未配置时才降级回默认
|
||||
// llm_client;该默认客户端是纯文本模型(Ark),收到图片分片会被上游 400 拒绝,故先丢弃图片分片。
|
||||
let (llm_client, decision_model) = match vision_llm_client {
|
||||
Some(client) if has_source_image => (
|
||||
Some(client),
|
||||
Some(crate::llm_model_routing::EDITOR_SCREEN_BACKGROUND_VISION_LLM_MODEL),
|
||||
),
|
||||
Some(client) => (
|
||||
Some(client),
|
||||
Some(crate::llm_model_routing::EDITOR_SCREEN_BACKGROUND_TEXT_LLM_MODEL),
|
||||
),
|
||||
None => {
|
||||
if has_source_image {
|
||||
warn!(
|
||||
kind = input.kind.label(),
|
||||
"editor_screen_background_vision_client_missing_downgrade_to_text"
|
||||
);
|
||||
input.source_image_data_url = None;
|
||||
(llm_client, None)
|
||||
}
|
||||
(llm_client, None)
|
||||
}
|
||||
} else {
|
||||
(llm_client, None)
|
||||
};
|
||||
|
||||
// 默认兜底色被过滤掉时,改用危险度最小的候选兜底。
|
||||
@@ -152,15 +155,15 @@ pub(crate) async fn resolve_editor_screen_background_color(
|
||||
None => LlmMessage::user(user_prompt.as_str()),
|
||||
};
|
||||
// 预算要够推理模型(如 gpt-5-mini)先花几百 token 推理、再吐 JSON 答案;
|
||||
// 实测 low 档推理约 320~384 token,取 1024 留足余量。非推理模型遇 stop 提前结束,不会多花。
|
||||
// 实测 low 档推理约 320~384 token,取 1024 留足余量。降级客户端遇 stop 提前结束,不会多花。
|
||||
let mut request = LlmTextRequest::new(vec![LlmMessage::system(system_prompt), user_message])
|
||||
.with_max_tokens(1024)
|
||||
.with_request_timeout_ms(EDITOR_SCREEN_BACKGROUND_DECISION_TIMEOUT_MS);
|
||||
if let Some(vision_model) = vision_model {
|
||||
// 视觉模型 gpt-5-mini 是推理模型:走 Responses 协议并压到 low 推理档,
|
||||
// 否则默认档会把预算全烧在推理上、返回空答案。
|
||||
if let Some(decision_model) = decision_model {
|
||||
// gpt-5-mini 是推理模型(有图视觉档 / 无图文本档均适用):走 Responses 协议并压到 low
|
||||
// 推理档,否则默认档会把预算全烧在推理上、返回空答案。
|
||||
request = request
|
||||
.with_model(vision_model)
|
||||
.with_model(decision_model)
|
||||
.with_responses_api()
|
||||
.with_response_reasoning_effort(LlmResponseReasoningEffort::Low);
|
||||
}
|
||||
@@ -526,4 +529,143 @@ mod tests {
|
||||
"默认兜底色与前景撞色时应改用危险度最小的候选"
|
||||
);
|
||||
}
|
||||
|
||||
// 真机联调:按 build_creative_agent_gpt5_client 的方式组 VectorEngine 客户端,直接跑
|
||||
// resolve_editor_screen_background_color 的完整代码路径(无图文本档 + 有图视觉档),
|
||||
// 验证决策请求真的打到 VectorEngine 并被解析成候选色(decision.fallback == false)。
|
||||
// 凭证从仓库根 .env.local / .env.secrets.local 读,需要真实 VECTOR_ENGINE_* 才有意义。
|
||||
// 运行: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> {
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("..");
|
||||
let mut map: BTreeMap<String, String> = BTreeMap::new();
|
||||
for name in [".env.local", ".env.secrets.local", ".env"] {
|
||||
let path = repo_root.join(name);
|
||||
let Ok(content) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') || !trimmed.contains('=') {
|
||||
continue;
|
||||
}
|
||||
let (k, v) = trimmed.split_once('=').unwrap();
|
||||
let v = v.trim().trim_matches('"').trim_matches('\'');
|
||||
// 先出现的文件优先(.env.local > .env.secrets.local > .env),与服务端 dotenv 顺序一致。
|
||||
map.entry(k.trim().to_string()).or_insert_with(|| v.to_string());
|
||||
}
|
||||
}
|
||||
std::env::var(key).ok().or_else(|| map.get(key).cloned())
|
||||
}
|
||||
|
||||
fn build_live_vector_engine_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")?;
|
||||
// 与 state.rs build_creative_agent_gpt5_client 一致:规整到以 /v1 结尾。
|
||||
let base_url = if base_url.trim_end_matches('/').ends_with("/v1") {
|
||||
base_url.trim_end_matches('/').to_string()
|
||||
} else {
|
||||
format!("{}/v1", base_url.trim_end_matches('/'))
|
||||
};
|
||||
let config = LlmConfig::new(
|
||||
LlmProvider::OpenAiCompatible,
|
||||
base_url,
|
||||
api_key,
|
||||
crate::llm_model_routing::EDITOR_SCREEN_BACKGROUND_TEXT_LLM_MODEL.to_string(),
|
||||
60_000,
|
||||
0,
|
||||
500,
|
||||
)
|
||||
.expect("live VectorEngine LlmConfig should build");
|
||||
Some(LlmClient::new(config).expect("live VectorEngine LlmClient should build"))
|
||||
}
|
||||
|
||||
fn solid_source_image_data_url() -> String {
|
||||
use base64::Engine as _;
|
||||
use image::{Rgba, RgbaImage};
|
||||
|
||||
let image = RgbaImage::from_pixel(64, 64, Rgba([120, 180, 120, 255]));
|
||||
let mut bytes = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(image)
|
||||
.write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png)
|
||||
.expect("test image should encode");
|
||||
format!(
|
||||
"data:image/png;base64,{}",
|
||||
base64::engine::general_purpose::STANDARD.encode(&bytes)
|
||||
)
|
||||
}
|
||||
|
||||
#[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,无法真机联调");
|
||||
};
|
||||
let decision = resolve_editor_screen_background_color(
|
||||
None,
|
||||
Some(&client),
|
||||
EditorScreenBackgroundDecisionInput {
|
||||
kind: EditorScreenBackgroundDecisionKind::Character,
|
||||
screen_color: Some("auto".to_string()),
|
||||
prompt: "赛博朋克风格的机械猫,霓虹蓝紫主体".to_string(),
|
||||
icon_descriptions: Vec::new(),
|
||||
reference_count: 1,
|
||||
source_image_data_url: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("live 无图决策应成功");
|
||||
|
||||
eprintln!(
|
||||
"[live 无图] mode={:?} hex={} label={} attempts={} fallback={}",
|
||||
decision.mode, decision.color.hex, decision.color.label, decision.attempts, decision.fallback
|
||||
);
|
||||
assert_eq!(decision.mode, EditorScreenBackgroundDecisionMode::Auto);
|
||||
assert!(
|
||||
!decision.fallback,
|
||||
"若走到兜底说明 LLM 没答复(VectorEngine 未被成功调用或响应解析失败)"
|
||||
);
|
||||
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,无法真机联调");
|
||||
};
|
||||
let decision = resolve_editor_screen_background_color(
|
||||
None,
|
||||
Some(&client),
|
||||
EditorScreenBackgroundDecisionInput {
|
||||
kind: EditorScreenBackgroundDecisionKind::CharacterAnimation,
|
||||
screen_color: Some("auto".to_string()),
|
||||
prompt: "角色起跳动作".to_string(),
|
||||
icon_descriptions: Vec::new(),
|
||||
reference_count: 1,
|
||||
source_image_data_url: Some(solid_source_image_data_url()),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("live 有图决策应成功");
|
||||
|
||||
eprintln!(
|
||||
"[live 有图] mode={:?} hex={} label={} attempts={} fallback={}",
|
||||
decision.mode, decision.color.hex, decision.color.label, decision.attempts, decision.fallback
|
||||
);
|
||||
assert_eq!(decision.mode, EditorScreenBackgroundDecisionMode::Auto);
|
||||
assert!(
|
||||
!decision.fallback,
|
||||
"若走到兜底说明视觉 LLM 没答复(VectorEngine 未被成功调用或响应解析失败)"
|
||||
);
|
||||
assert!(decision.attempts >= 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
pub(crate) const RPG_STORY_LLM_MODEL: &str = "doubao-seed-character-251128";
|
||||
pub(crate) const CREATION_TEMPLATE_LLM_MODEL: &str = "deepseek-v3-2-251201";
|
||||
pub(crate) const PUZZLE_LEVEL_NAME_VISION_LLM_MODEL: &str = "gpt-4o-mini";
|
||||
// 抠图背景色决策的视觉模型。gpt-5-mini 是推理模型,会先花若干 token 做推理,
|
||||
// 抠图背景色决策的视觉模型(有源图)。gpt-5-mini 是推理模型,会先花若干 token 做推理,
|
||||
// 故决策请求的 max_tokens 需留足推理开销(见 editor_screen_background_decision)。
|
||||
pub(crate) const EDITOR_SCREEN_BACKGROUND_VISION_LLM_MODEL: &str = "gpt-5-mini";
|
||||
// 抠图背景色决策的文本模型(无源图)。独立于视觉档,便于两条路径分别调参;
|
||||
// 硬编码而非继承 Ark 默认文本模型(豆包,选色能力弱)。同为 gpt-5-mini 推理模型,同样留足推理预算。
|
||||
pub(crate) const EDITOR_SCREEN_BACKGROUND_TEXT_LLM_MODEL: &str = "gpt-5-mini";
|
||||
|
||||
Reference in New Issue
Block a user