diff --git a/server-rs/crates/api-server/src/editor_screen_background_decision.rs b/server-rs/crates/api-server/src/editor_screen_background_decision.rs index 12f2bb8a1..28fa38d23 100644 --- a/server-rs/crates/api-server/src/editor_screen_background_decision.rs +++ b/server-rs/crates/api-server/src/editor_screen_background_decision.rs @@ -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 { + use std::collections::BTreeMap; + use std::path::PathBuf; + + let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join(".."); + let mut map: BTreeMap = 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 { + 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); + } } diff --git a/server-rs/crates/api-server/src/llm_model_routing.rs b/server-rs/crates/api-server/src/llm_model_routing.rs index 49f8e6740..e3485fe09 100644 --- a/server-rs/crates/api-server/src/llm_model_routing.rs +++ b/server-rs/crates/api-server/src/llm_model_routing.rs @@ -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";