拆分图标规范生成预检与执行

在文本模型调用前完成图片引用、Provider与定价参数预检
补齐最终提示词后复用现有图片生成内联与队列分流
在图集业务层校验图标规范类型并解析游戏类型
补充执行顺序、任务契约与失败关闭回归测试
同步更新生成链路文档与长期决策记录
This commit is contained in:
2026-08-05 14:04:54 +08:00
parent f76d338e69
commit 755f58ddfb
4 changed files with 255 additions and 94 deletions
@@ -5935,3 +5935,9 @@
- Agent 发现:新增公开 `agent-integration.json``skill/SKILL.md``skill.zip`。manifest 同时声明 MCP、OpenAPI、完整 Skill archive、SHA-256 和包内清单;archive 必须包含 `SKILL.md`、上述四篇 references、stdlib Python helper 和 `agents/openai.yaml` 七个声明文件,不能只提供 OpenAPI JSON,也不能包含 API Key、本机路径或个人配置。完整 `skill.zip` 只供不支持 MCP 或需要本地文件上传编排的 Agent 使用,不作为 MCP resource。
- 兼容边界:这是基于「截至 2026-07-31 尚无外部第三方存量调用方」接受的 v1 原地 breaking change;一旦出现外部活跃 Key、公开契约或联调方,后续破坏性变更必须保留兼容、经过弃用期或升级 `/api/external/v2`
- 关联文档:`docs/【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md``docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md``.codex/skills/genarrative-external-editor-api/SKILL.md`
## 2026-08-05 图标规范生成拆分请求预检与最终执行,图集规范引用改为窄事务查询
- 图标规范 HTTP handler 在调用文本 LLM 前,先通过可复用图片请求预检完成参考图稳定性、owner 授权、Provider 配置与运行时定价校验;随后补齐 `ExtraParam` 与最终 prompt,并继续走既有 `editor_image_generation` inline / queue 分流,不新增图标规范专用 worker job。最终执行仍重新校验请求,以处理排队期间发生的权限或资源变化。
- 图标图集主规范引用由通用 SpacetimeDB procedure `resolve_editor_reference_and_return` 在同一事务快照内解析和校验 owner。ID 使用资源 / 素材主键;objectKey 按规范化 `image_src="/<objectKey>"` 索引读取唯一行,并使用 `asset_object(bucket, object_key)` 复合索引校验对象 owner。procedure 不接收业务 / 存储类型 allowlist,复用既有 `EditorProjectResourceSnapshot` / `EditorAssetSnapshot` 返回完整单行,不新增图标专属 DTO,也不再拉取完整项目和素材库;`icon-spec` 业务类型与游戏类型由 API 业务代码校验和提取。缺失引用、跨 owner、asset object 不匹配、业务类型错误、元数据解析与数据库错误全部失败关闭;仅“合法记录没有 genre”允许返回 `None`
- 关联文档:`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md``docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md``docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`
@@ -41,7 +41,7 @@
8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框。
9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。
10. 图标规范只使用 `specType="icon"`,历史 `specType="ui"` 快照在恢复边界迁移为 `icon`。表单字段使用 `playSetting / artStyle`,界面标题继续使用「玩法设定 / 美术风格」。两项初始为空且必填;每项独立支持一键优化、处理中锁定自身、成功后单次撤销、失败保留文本与旧撤销快照。优化请求必须绑定发起时的生成对象 ID 和请求代次;对象切换或新请求取代旧请求后,旧成功或失败结果都不得更新当前面板。任一项处理中或任一项为空时禁用生成。控件继续使用平台默认样式,不新增图标规范专属 CSS。
11. 图标规范最终生成改走 `POST /api/editor/icon-specs/generations`。前端只提交业务字段和统一参考图 / 项目完成包络,不拼最终 prompt,不提交 `kind / assetKind / ExtraParam`;后端固定图片参数并复用 `editor_image_generation`
11. 图标规范最终生成改走 `POST /api/editor/icon-specs/generations`。前端只提交业务字段和统一参考图 / 项目完成包络,不拼最终 prompt,不提交 `kind / assetKind / ExtraParam`;后端固定图片参数。HTTP handler 先调用可复用的图片请求预检,完成参考图稳定性、owner 授权、Provider 配置和运行时定价校验;全部通过后才调用文本 LLM 补齐 `ExtraParam` 和最终 prompt,再把完整图片请求交给既有 `editor_image_generation` inline / queue 分流。不得为图标规范新增独立外部任务类型;最终 worker 仍按执行时事实重新校验,避免排队期间状态变化产生 TOCTOU
12. 图片快速编辑不展示额外参考图入口;原图或绘制了红框和序号的标注图始终作为 `/api/editor/images/edits``sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`
13. 快速编辑打开后,画布视口应调整到原图完整展示,且面板位于原图下方并不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。快速编辑进入时不默认启用框选工具,点击工具后出现选中态并保持高亮,再点同一工具取消启用;红色圈选框使用细描边。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`
@@ -79,7 +79,7 @@
- 本次消耗泥点必须显示在生成按钮内部。
- 生成按钮内明确显示 `N泥点`,例如 `生成12泥点``生成40泥点`;不使用泥点图标替代文字。
- 画板内所有会提交外部生成任务的按钮,展示价格都必须从模型定价配置函数推导,不允许在按钮文案中散落固定泥点数字;生成请求不提交 `priceMudPoints`,修改后端模型定价配置后,后端实际扣费和前端下一次拉取到的按钮展示应同步变化。
- 后端所有编辑器外部生成入口必须按运行时模型定价配置计算价格后进入 `execute_billable_asset_operation_with_cost``/api/editor/images/generations``/api/editor/images/edits``/api/editor/icon-spritesheets/generations``/api/editor/ui-designs/assets/extractions``/api/editor/videos/generations``/api/editor/character-animations/generations``/api/editor/audios/sound-effects/generations``/api/editor/audios/background-music/generations` 都不能只展示价格而不真实预扣钱包。
- 后端所有编辑器外部生成入口必须按运行时模型定价配置计算价格后进入 `execute_billable_asset_operation_with_cost``/api/editor/icon-specs/generations``/api/editor/images/generations``/api/editor/images/edits``/api/editor/icon-spritesheets/generations``/api/editor/ui-designs/assets/extractions``/api/editor/videos/generations``/api/editor/character-animations/generations``/api/editor/audios/sound-effects/generations``/api/editor/audios/background-music/generations` 都不能只展示价格而不真实预扣钱包。
- 当前前端展示价统一收口在 `ImageCanvasGenerationModel.ts`:生成图片、生成角色、快速编辑、重绘、宣发素材走 `calculateEditorImageModelPrice` / `calculateEditorImageGenerationPrice`;生成图标素材走 `calculateEditorIconSpritesheetPrice`;生成 UI 设计图走 `calculateEditorUiDesignPrice`;生成规范走 `calculateEditorSpecGenerationPrice`;生成视频走 `calculateEditorVideoPrice`;角色动作走 `calculateCharacterAnimationPrice`;音效 / 背景音乐分别走 `calculateEditorSoundEffectPrice` / `calculateEditorBackgroundMusicPrice`。这些函数启动时会被后端下发配置覆盖,接口失败时才使用内置兜底。定价配置只按模型区分,不按图片 / 规范、视频 / 动作用途拆分;图片类价格必须同时传入模型和 `imageSize`,规范固定读取 `gpt-image-2``2K` 定价。
- 泥点配置默认值独立收口到 `server-rs/crates/api-server/config/editor-generation-pricing.default.json`JSON 结构为 `models[model] = { unit, price | prices }`;后台“模型定价”页面通过 `POST /admin/api/editor-generation-pricing` 保存完整配置到 SpacetimeDB `editor_generation_pricing_config` 全局表,主站通过 `GET /api/editor/generation-pricing` 动态读取当前配置。后台必须展示定价单位:`perGeneration` 显示“按次”,`perSecond` 显示“按秒”。
- 生成图标素材、生成视频、角色动画、音效和背景音乐请求只提交生成参数,不提交价格字段;后端按归一后的模型、清晰度、时长或音频模型重新计算并扣费。
+199 -73
View File
@@ -47,6 +47,7 @@ use spacetime_client::{
EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput,
EditorProjectResourceRecord, EditorProjectResourceShowcaseUpdateRecordInput,
EditorReferenceRecord, EditorReferenceResolveRecordInput,
EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput,
EditorShowcaseAssetRecord, EditorShowcaseAssetSubmitRecordInput,
EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord,
@@ -385,7 +386,7 @@ pub(crate) struct EditorGenerationCaller {
}
impl EditorGenerationCaller {
fn from_authenticated(authenticated: &AuthenticatedAccessToken) -> Self {
pub(crate) fn from_authenticated(authenticated: &AuthenticatedAccessToken) -> Self {
let owner_user_id = authenticated.claims().user_id().to_string();
Self {
audit_subject_user_id: Some(owner_user_id.clone()),
@@ -1674,6 +1675,80 @@ pub(crate) async fn enqueue_editor_image_generation_for_owner(
.await
}
/// Runs the non-generating validation and authorization required before a caller performs
/// preparatory provider work such as icon-spec metadata completion. Final dispatch deliberately
/// validates again because queued execution can happen later in another process.
pub(crate) async fn validate_editor_image_generation_parameters_for_owner(
state: &AppState,
request_context: &RequestContext,
caller: &EditorGenerationCaller,
payload: &EditorImageGenerationRequest,
) -> Result<(), AppError> {
ensure_editor_reference_image_sources_are_stable(
payload.reference_image_srcs.as_deref(),
"editor-image-generation",
"referenceImageSrcs",
"生成参考图",
)?;
let normalized_kind = payload.kind.as_deref().map(str::trim);
if matches!(normalized_kind, Some("character")) {
parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?;
}
let is_ui_design_generation = matches!(normalized_kind, Some("ui-design"));
let is_publication_material_generation =
matches!(normalized_kind, Some("publication-material"));
let generation_options = normalize_editor_generation_options(
if is_ui_design_generation || is_publication_material_generation {
Some(GPT_IMAGE_2_MODEL)
} else {
payload.model.as_deref()
},
payload.aspect_ratio.as_deref(),
payload.image_size.as_deref(),
);
let reference_limit = if matches!(normalized_kind, Some("quick-edit")) {
9
} else {
5
};
for source in payload
.reference_image_srcs
.as_deref()
.unwrap_or_default()
.iter()
.map(|source| source.trim())
.filter(|source| !source.is_empty())
.take(reference_limit)
{
parse_editor_reference_image(state, caller.owner_user_id.as_str(), source).await?;
}
let settings = require_openai_image_settings(state)?.with_external_api_audit_context(
request_context,
caller.audit_subject_user_id.clone(),
caller
.audit_project_id
.clone()
.or_else(|| payload.project_id.clone()),
);
build_openai_image_http_client(&settings)?;
state
.editor_generation_pricing()
.await
.map_err(|error| {
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
"provider": "editor-generation-pricing",
"message": error.to_string(),
}))
})?
.image_generation_mud_points(
normalized_kind,
Some(generation_options.model),
Some(generation_options.image_size),
);
Ok(())
}
pub(crate) async fn generate_editor_image_for_owner(
state: &AppState,
request_context: &RequestContext,
@@ -4926,12 +5001,20 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
)?;
let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?;
let user_prompt = icon_descriptions.join("\n");
let icon_spec_genre = resolve_editor_icon_spec_game_genre(
let resolved_icon_spec = resolve_editor_icon_spec_reference(
state,
caller.owner_user_id.as_str(),
payload.reference_image_src.as_str(),
)
.await?;
.await
.map_err(|error| {
error.with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "referenceImageSrc",
"message": "图标素材规范必须是当前用户已登记的 icon-spec 资源或素材。",
}))
})?;
let icon_spec_genre = resolved_icon_spec.genre;
let (image_style, mut generation_warning) =
normalize_editor_image_generation_style(payload.style.as_deref(), true);
// 背景色决策挪到预扣泥点之后(见下方 execute_billable 闭包),避免余额不足 / 生成注定失败时
@@ -4941,21 +5024,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
let seg_model = parse_editor_bgfilter_seg_model(payload.seg_model.as_deref())?;
let mut reference_images =
Vec::with_capacity(1 + payload.reference_image_srcs.as_ref().map_or(0, Vec::len));
reference_images.push(
parse_editor_reference_image(
state,
caller.owner_user_id.as_str(),
payload.reference_image_src.as_str(),
)
.await
.map_err(|error| {
error.with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "referenceImageSrc",
"message": "图标素材规范必须是 objectKey、资源或素材引用。",
}))
})?,
);
reference_images.push(resolved_icon_spec.image);
for source in
normalize_editor_reference_image_sources(payload.reference_image_srcs.as_deref(), 8)
{
@@ -8705,68 +8774,82 @@ pub(crate) async fn parse_editor_reference_image(
read_editor_reference_image_object(state, object_key.as_str()).await
}
async fn resolve_editor_icon_spec_game_genre(
struct ResolvedEditorIconSpecReference {
image: OpenAiReferenceImage,
genre: Option<crate::editor_project_icon_specs::GameGenre>,
}
async fn resolve_editor_icon_spec_reference(
state: &AppState,
owner_user_id: &str,
reference_source: &str,
) -> Result<Option<crate::editor_project_icon_specs::GameGenre>, AppError> {
) -> Result<ResolvedEditorIconSpecReference, AppError> {
let reference_source = reference_source.trim();
if reference_source.is_empty() {
return Ok(None);
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "referenceImageSrc",
"message": "图标素材规范引用不能为空。",
})),
);
}
let normalized_object_key = normalize_editor_reference_object_key(reference_source).ok();
let projects = state
.spacetime_client()
.list_editor_projects(owner_user_id.to_string())
.await
.map_err(map_editor_project_error)?;
let resource_genre = projects
.iter()
.flat_map(|project| project.resources.iter())
.filter(|resource| resource.asset_kind.as_deref() == Some("icon-spec"))
.filter(|resource| {
resource.resource_id.trim() == reference_source
|| normalized_object_key.as_deref().is_some_and(|object_key| {
editor_record_object_key_matches(
resource.object_key.as_deref(),
resource.image_src.as_str(),
object_key,
)
})
})
.find_map(|resource| {
crate::editor_project_icon_specs::game_genre_from_generation_inputs(
resource.generation_inputs.as_ref(),
)
});
if resource_genre.is_some() {
return Ok(resource_genre);
}
let library = state
.spacetime_client()
.get_editor_asset_library(owner_user_id.to_string(), current_utc_micros())
.await
.map_err(map_editor_project_error)?;
Ok(library
.assets
.iter()
.filter(|asset| asset.asset_kind.as_deref() == Some("icon-spec"))
.filter(|asset| {
asset.asset_id.trim() == reference_source
|| normalized_object_key.as_deref().is_some_and(|object_key| {
editor_record_object_key_matches(
asset.object_key.as_deref(),
asset.image_src.as_str(),
object_key,
)
})
})
.find_map(|asset| {
crate::editor_project_icon_specs::game_genre_from_generation_inputs(
asset.generation_inputs.as_ref(),
)
let oss_client = state.oss_client().ok_or_else(|| {
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
"provider": "aliyun-oss",
"reason": "OSS 未完成环境变量配置",
}))
})?;
let (reference_id, object_key) = match normalize_editor_reference_object_key(reference_source) {
Ok(object_key) => (None, Some(object_key)),
Err(_) => (Some(reference_source.to_string()), None),
};
let resolved = state
.spacetime_client()
.resolve_editor_reference(EditorReferenceResolveRecordInput {
owner_user_id: owner_user_id.to_string(),
bucket: oss_client.config_bucket().to_string(),
reference_id,
object_key,
})
.await
.map_err(map_editor_project_error)?;
let (object_key, generation_inputs, asset_kind) = match resolved {
EditorReferenceRecord::ProjectResource(resource) => (
resource.object_key,
resource.generation_inputs,
resource.asset_kind,
),
EditorReferenceRecord::Asset(asset) => {
(asset.object_key, asset.generation_inputs, asset.asset_kind)
}
};
if asset_kind.as_deref().map(str::trim) != Some("icon-spec") {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "referenceImageSrc",
"message": "图标素材规范引用必须指向 icon-spec 资源或素材。",
})),
);
}
let object_key = object_key.ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": "已验证的编辑器引用缺少 object_key。",
}))
})?;
let genre = crate::editor_project_icon_specs::game_genre_from_generation_inputs(
generation_inputs.as_ref(),
)
.map_err(|()| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "spacetimedb",
"message": "图标规范引用保存了无效的游戏类型。",
}))
})?;
let image = read_editor_reference_image_object(state, object_key.as_str()).await?;
Ok(ResolvedEditorIconSpecReference { image, genre })
}
pub(crate) async fn resolve_editor_reference_object_key_for_owner(
@@ -11626,6 +11709,49 @@ mod tests {
assert!(!no_reference_prompt.contains("参考图1为图标素材规范"));
}
#[test]
fn icon_spec_reference_resolution_uses_narrow_verified_lookup() {
let source = include_str!("editor_project.rs");
assert_function_contains(
source,
"async fn resolve_editor_icon_spec_reference",
"pub(crate) async fn resolve_editor_reference_object_key_for_owner",
&[
".resolve_editor_reference(",
"read_editor_reference_image_object(",
],
);
assert_function_not_contains(
source,
"async fn resolve_editor_icon_spec_reference",
"pub(crate) async fn resolve_editor_reference_object_key_for_owner",
&[
"list_editor_projects",
"get_editor_asset_library",
"parse_editor_reference_image",
],
);
}
#[test]
fn image_generation_preflight_checks_references_provider_and_pricing() {
let source = include_str!("editor_project.rs");
assert_function_contains(
source,
"pub(crate) async fn validate_editor_image_generation_parameters_for_owner",
"pub(crate) async fn generate_editor_image_for_owner",
&[
"ensure_editor_reference_image_sources_are_stable(",
"parse_editor_reference_image(",
"require_openai_image_settings(",
"build_openai_image_http_client(",
".editor_generation_pricing()",
],
);
}
#[test]
fn editor_ui_design_generation_keeps_ui_controls_out_of_negative_prompt() {
let ui_design_negative_prompt = editor_image_generation_negative_prompt(true);
@@ -15,8 +15,9 @@ use tokio::time::sleep;
use crate::{
auth::AuthenticatedAccessToken,
editor_project::{
EditorImageGenerationRequest, generate_editor_image,
EditorGenerationCaller, EditorImageGenerationRequest, generate_editor_image,
sanitize_editor_client_generation_inputs,
validate_editor_image_generation_parameters_for_owner,
},
http_error::AppError,
platform_errors::{is_retryable_llm_error, map_llm_error},
@@ -195,6 +196,20 @@ pub(crate) async fn generate_icon_spec(
payload.request_param.art_style =
require_icon_spec_generation_text(payload.request_param.art_style, "artStyle", "美术风格")?;
let preflight_request = build_icon_spec_image_request(
payload.clone(),
String::new(),
payload.generation_inputs.clone(),
);
let caller = EditorGenerationCaller::from_authenticated(&authenticated);
validate_editor_image_generation_parameters_for_owner(
&state,
&request_context,
&caller,
&preflight_request,
)
.await?;
let extra_prompt = build_extra_param_prompt(&payload.request_param);
let extra_param = run_icon_spec_extra_param_llm(&state, extra_prompt).await?;
let final_prompt = get_gen_icon_spec_prompt(&payload.request_param, &extra_param);
@@ -203,8 +218,8 @@ pub(crate) async fn generate_icon_spec(
extra_param.genre,
payload.generation_inputs.take(),
);
let image_request = build_icon_spec_image_request(payload, final_prompt, generation_inputs);
let image_request =
build_icon_spec_image_request(payload, final_prompt, Some(generation_inputs));
generate_editor_image(
State(state),
@@ -218,7 +233,7 @@ pub(crate) async fn generate_icon_spec(
fn build_icon_spec_image_request(
payload: EditorIconSpecGenerationRequest,
prompt: String,
generation_inputs: Value,
generation_inputs: Option<Value>,
) -> EditorImageGenerationRequest {
EditorImageGenerationRequest {
prompt,
@@ -233,7 +248,7 @@ fn build_icon_spec_image_request(
reference_image_srcs: payload.reference_image_srcs,
project_id: payload.project_id,
asset_kind: Some("icon-spec".to_string()),
generation_inputs: Some(generation_inputs),
generation_inputs,
asset_folder_id: payload.asset_folder_id,
asset_label: payload.asset_label,
source_resource_id: payload.source_resource_id,
@@ -372,16 +387,26 @@ fn build_icon_spec_generation_inputs(
})
}
pub(crate) fn game_genre_from_generation_inputs(value: Option<&Value>) -> Option<GameGenre> {
value?
.get("fields")?
.as_array()?
.iter()
.find(|field| field.get("title").and_then(Value::as_str) == Some("游戏类型"))?
.get("value")?
.as_str()?
.parse()
.ok()
pub(crate) fn game_genre_from_generation_inputs(
value: Option<&Value>,
) -> Result<Option<GameGenre>, ()> {
let raw_genre = value
.and_then(|value| value.get("fields"))
.and_then(Value::as_array)
.and_then(|fields| {
fields
.iter()
// TODO use id field
.find(|field| field.get("title").and_then(Value::as_str) == Some("游戏类型"))
})
.and_then(|field| field.get("value"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty());
let Some(raw_genre) = raw_genre else {
return Ok(None);
};
raw_genre.parse().map(Some)
}
#[cfg(test)]
@@ -517,7 +542,7 @@ mod tests {
{ "title": "游戏类型", "value": "动作冒险" }
]
}))),
Some(GameGenre::ActionAdventure)
Ok(Some(GameGenre::ActionAdventure))
);
assert_eq!(
game_genre_from_generation_inputs(Some(&json!({
@@ -526,13 +551,13 @@ mod tests {
{ "title": "游戏类型", "value": "action_adventure" }
]
}))),
None
Err(())
);
assert_eq!(
game_genre_from_generation_inputs(Some(&json!({
"fields": [{ "title": "玩法设定", "value": "rpg" }]
}))),
None
Ok(None)
);
}
@@ -553,10 +578,14 @@ mod tests {
canvas_completion: None,
},
"最终图标规范 prompt".to_string(),
json!({ "fields": [], "references": [] }),
Some(json!({ "fields": [], "references": [] })),
);
assert_eq!(image_request.prompt, "最终图标规范 prompt");
assert_eq!(
image_request.generation_inputs,
Some(json!({ "fields": [], "references": [] }))
);
assert_eq!(image_request.kind.as_deref(), Some("spec"));
assert_eq!(image_request.asset_kind.as_deref(), Some("icon-spec"));
assert_eq!(image_request.model.as_deref(), Some(ICON_SPEC_MODEL));