收紧图标图集提示词输入契约

新增独立的 spritesheet prompt 单项字符、聚合字符和 UTF-8 字节上限。

以已验证类型约束 prompt builder,并覆盖 inline、队列与 worker 执行边界。

同步画布校验、Editor Agent 工具合同、External v1 OpenAPI、文档和边界测试。
This commit is contained in:
2026-08-06 16:44:33 +08:00
parent 0b3475ef98
commit 55ff47c6c4
15 changed files with 424 additions and 32 deletions
@@ -3378,11 +3378,14 @@
},
"iconDescriptions": {
"type": "array",
"description": "图标生成需求文本数组,供 prompt 组装使用;数组长度不控制自动切片数量。画布前端把完整用户提示词作为唯一数组元素提交;其它调用方可继续提交 1 到 100 条非空文本。",
"description": "图标生成需求文本数组,供 prompt 组装使用;数组长度不控制自动切片数量。画布前端把完整用户提示词作为唯一数组元素提交;其它调用方可继续提交 1 到 100 条非空文本。服务端去空并以换行拼接后,单条最多 200 个 Unicode 字符,合计最多 2000 个 Unicode 字符且不超过 6144 个 UTF-8 字节;超限同步返回 400,不会入队或计费。",
"minItems": 1,
"maxItems": 100,
"x-genarrative-maxTotalCharacters": 2000,
"x-genarrative-maxTotalUtf8Bytes": 6144,
"items": {
"type": "string"
"type": "string",
"maxLength": 200
}
},
"screenColor": {
@@ -243,7 +243,7 @@ npm run check:server-rs-ddd
6. 编辑器图片生成 / 图片修改 / 图标 spritesheet / UI 设计图提取素材 / 视频 / 角色动作 / 音效 / 背景音乐必须在后端计算模型价格后使用 `execute_billable_asset_operation_with_cost` 预扣泥点;预扣失败必须 fail-closed,不得继续提交 VectorEngine、Ark、Suno 或 Vidu 上游任务。
7. 队列任务按 `job_id + claim_attempt` 使用独立 consume/refund ledger。新 attempt 结算旧 attempt 时必须先写 `asset_operation_wallet_settlement`:旧 consume 已存在则原子退款,尚不存在则写取消 intent;迟到 consume 在同一 SpacetimeDB 事务内看到 intent 后必须失败关闭。重复 consume/refund 只有用户、金额、来源和配对 ledger 全部一致时才可视为幂等成功。lease 过期时只有 `attempt < max_attempts` 才能递增并重领;最终 attempt 已耗尽时,claim transaction 必须直接把 job 收口为 `failed`、清理 lease、写失败事件并结算当前 attempt,不能再把任务返回 worker 或调用 provider。
8. 音频生成的编辑器链路虽然任务提交和结果发布分离,仍必须把提交时后端计算出的模型价格写入 `AudioAssetBindingTarget.billing_points_cost`,最终发布落资产时按该价格扣费;创作音频目标未提供该字段时才使用旧的创作音频固定成本。
9. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。无效普通字符串可以入队,但必须在签名和 provider 调用前失败;本次不增加 API 侧数据库查询或同步 owner 校验。若以后要求无效引用同步返回 400,应作为独立改造。objectKey 最终必须归属于当前账号的 `editor_project_resource``editor_asset``asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标规范分析、润色、规范图生图和图标 spritesheet 提示词中的请求文本必须统一按 XML 文本节点转义 `& < > " '`,不得把只经过 trim 或长度检查的原始值直接插入标签或后续生图提示词。图标素材、图片快速编辑和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照。普通图片生成最多 5 张参考图;图片修改、图标素材和 UI 提取的额外参考图上限还必须与所选 provider 的总容量共同取最小值:GPT-image-2 总计 5 张,nanobanana2 总计 14 张。前端添加和提交、api-server 入队 / 扣费前以及 `platform-image` provider 边界都必须明确拒绝超限,禁止用 `.take(...)` 静默截断。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。
9. 编辑器进入外部生成持久队列的图片生成、图片修改、去背景、图标 spritesheet、UI 设计图提取、角色动作和视频参考图,调用方必须提交 `objectKey` / `resourceId` / `assetId` 候选引用;BFF 只做内联媒体与 payload 门禁,登记状态和归属由 worker 统一解析。任务 `request_payload_json` / `result_payload_json` 任意层级都禁止 `data:` / `blob:`,并受统一字节上限保护。无效普通字符串可以入队,但必须在签名和 provider 调用前失败;本次不增加 API 侧数据库查询或同步 owner 校验。若以后要求无效引用同步返回 400,应作为独立改造。objectKey 最终必须归属于当前账号的 `editor_project_resource``editor_asset``asset_object`,由 worker 在解析后、签名读取 OSS 前完成归属校验。本地红框序号标注图必须先上传并确认对象,再把 objectKey 入队;不得把既有 objectKey 下载成 Data URL 后写入任务。图标规范分析、润色、规范图生图和图标 spritesheet 提示词中的请求文本必须统一按 XML 文本节点转义 `& < > " '`,不得把只经过 trim 或长度检查的原始值直接插入标签或后续生图提示词。图标 spritesheet 的 `iconDescriptions` 还必须在请求边界执行独立合同:去空后 `1..100` 条、单条最多 `200` 个 Unicode 字符、换行拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节;只有 `ValidatedEditorIconSpritesheetPrompt` 能进入 prompt builderExternal v1 超限同步返回 `400`。图标素材、图片快速编辑和 UI 素材提取的额外参考图必须真正传入 provider,不得只写入 `generationInputs` 展示快照。普通图片生成最多 5 张参考图;图片修改、图标素材和 UI 提取的额外参考图上限还必须与所选 provider 的总容量共同取最小值:GPT-image-2 总计 5 张,nanobanana2 总计 14 张。前端添加和提交、api-server 入队 / 扣费前以及 `platform-image` provider 边界都必须明确拒绝超限,禁止用 `.take(...)` 静默截断。同步且不持久化的历史兼容入口即使仍能解析 Data URL,也不能把该值转存到工程、素材、元数据、审计或任务表。
10. 已有静态图片的 `POST /api/editor/images/pixel-art-snaps` 是免费 inline 派生操作,不调用外部 provider、不创建 `external_generation_job`、不读写泥点 ledger,也不进入任务侧栏。免费不放宽 owner、稳定引用、输入上限、持久化或处理阶段零持久化门禁。
11. 主站编辑器生成队列使用同一次前端请求稳定复用的 `x-request-id`,按 namespace + owner + job kind + request id 生成唯一 `dedupe_key`;首次请求已入队但响应丢失时,重试必须返回原任务。同一幂等键携带不同 payload 返回 `409`,不得创建第二个任务或串到旧结果。外部 v1 的 `Idempotency-Key` 使用独立 namespace,不能与主站请求标识碰撞。幂等 payload 比较只对本次已迁移 sanitizer 的图片生成、图片修改、去背景、图标图集和 UI 提取任务,兼容“升级前旧任务仍含客户端 `generationInputs.references`、当前请求已删除该字段”的单向形状;当前请求仍含 references,或 job kind 属于音频 / 视频 / 角色动作等未迁移任务时必须完整比较,其余请求字段始终完全一致。
12. `generationInputs.references` 是最终资产的服务端权威行引用,不接受客户端自报 provenance。图片生成类请求入队、完美像素及直接创建资源 / 素材时删除客户端 referencesworker 和 inline 路径按本次真实参考图、当前 owner 的项目资源 / 素材记录重建 `refType/refId` 后再持久化。仅能证明 owned objectKey、但找不到对应资源或素材行时可以参与生成,不得制造虚假行引用;`title/label` 只作为展示快照,不提升为资源身份。完美像素为兼容升级前的未知结果重放,可继续用旧版 canonical 客户端输入计算 operation fingerprint;新操作持久化元数据只能使用服务端重建值,检测到 owner 项目中已存在同一稳定 task/resource 的历史结果时则复用该服务端既存 metadata 完成精确 compare-and-return。
@@ -38,7 +38,7 @@
- 生成角色:`你希望角色如何设计?`
- 生成 UI`你希望这个 UI 长什么样?`
- 生成视频:`你希望生成什么视频?`
8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框。
8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框;该完整文本按 Unicode 字符限制为 `200`,输入时按 code point 截断,不能用 UTF-16 `maxLength` 误截 emoji
9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。
10. 图标规范只使用 `specType="icon"`,历史 `specType="ui"` 快照在恢复边界迁移为 `icon`。表单字段使用 `playSetting / artStyle`,界面标题继续使用「玩法设定 / 美术风格」。两项初始为空且必填;每项独立支持一键优化、处理中锁定自身、成功后单次撤销,手工编辑后立即清除该字段已经失效的撤销快照,失败只保留当前文本与仍然有效的旧撤销快照。LLM 返回空文本、超长文本或 Markdown / 结构化内容时,后续有界重试必须携带上次无效输出和对应修正要求,不能原样重放同一请求。优化请求必须绑定发起时的生成对象 ID 和请求代次;活动对象身份只在 React effect 提交后更新,并在 cleanup 中失效,丢弃的并发 render 不得改变请求归属;对象切换或新请求取代旧请求后,旧成功或失败结果都不得更新当前面板。任一项处理中或任一项为空时禁用生成。控件继续使用平台默认样式,不新增图标规范专属 CSS。
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。
@@ -40,11 +40,11 @@
## 生成契约
- 前端提交到 `POST /api/editor/icon-spritesheets/generations`
- inline 与持久队列入口共用同一份 `iconDescriptions` 归一化:去除空白项后必须保留 `1..100`队列入口必须在定价和任务持久化前同步拒绝可预测的数量错误,不能把无效任务留给 worker 延迟失败。
- inline 与持久队列入口共用同一份 `iconDescriptions` prompt 合同:去除空白项后必须保留 `1..100`,单条最多 `200` 个 Unicode 字符,以换行拼接后合计最多 `2000` 个 Unicode 字符且不超过 `6144` 个 UTF-8 字节。请求边界校验成功后生成 `ValidatedEditorIconSpritesheetPrompt`,后续 prompt builder 不接受裸字符串。队列入口必须在引用解析、定价和任务持久化前同步拒绝可预测错误,不能把无效任务留给 worker 延迟失败。
- worker 解析主 `referenceImageSrc` 时必须通过 `spacetime-client` 的通用窄查询 `resolve_editor_reference` 在同一事务快照内完成引用解析和 owner 校验:资源 ID / 素材 ID 走主键,对象键按规范化 `image_src="/<objectKey>"` 索引定位单条资源或素材,并通过 `asset_object(bucket, object_key)` 复合索引校验对象 owner。procedure 复用既有 `EditorProjectResourceSnapshot``EditorAssetSnapshot` 返回唯一已验证行,不接收图标业务类型参数、不新建图标专属快照,也不得拉取当前用户的完整工程列表或素材库。`assetKind="icon-spec"``genre` 都由图标图集业务代码从返回行校验和提取。引用不存在、owner 不匹配、asset object 不存在或数据库调用失败时 procedure 直接失败;业务类型不符或保存的游戏类型无效时 API 失败;合法规范没有已保存游戏类型时允许 `genre=None`
- 请求字段:
- `referenceImageSrc`:图标规范的稳定引用(当前账号的 `objectKey`、项目资源 ID 或素材 ID);本地临时图必须先上传 OSS,禁止 Data URL / Blob URL。
- `iconDescriptions`:兼容现有接口的图标需求数组,`1..100`;当前画布前端固定把完整文本作为唯一数组元素提交。数组长度只表达请求文本,不作为自动拆分数量。
- `iconDescriptions`:兼容现有接口的图标需求数组,`1..100`;当前画布前端固定把完整文本作为唯一数组元素提交。数组长度只表达请求文本,不作为自动拆分数量;单项、聚合字符和 UTF-8 字节上限按上一条 prompt 合同执行
- `model`:支持 `gemini-3.1-flash-image-preview`UI 显示 `nanobanana2`)和 `gpt-image-2`,默认 `nanobanana2`
- `aspectRatio`:按 `x:y` 展示,选项跟随模型。
- `imageSize`:按 `0.5K / 1K / 2K` 展示,选项跟随模型。
@@ -14535,20 +14535,39 @@ mod tests {
"设置按钮".to_string(),
])
.expect("valid descriptions should pass");
assert_eq!(descriptions, vec!["返回按钮", "设置按钮"]);
assert_eq!(descriptions.descriptions(), ["返回按钮", "设置按钮"]);
assert_eq!(descriptions.as_str(), "返回按钮\n设置按钮");
let too_many = (0..101)
.map(|index| format!("图标{index}"))
.collect::<Vec<_>>();
assert!(normalize_icon_descriptions(too_many).is_err());
let too_long = vec!["".repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1)];
let error =
normalize_icon_descriptions(too_long).expect_err("overlong description should fail");
assert!(error.body_text().contains("每条图标素材描述"));
let aggregate_too_long = (0..EDITOR_ICON_DESCRIPTION_LIMIT)
.map(|_| "".repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS))
.collect::<Vec<_>>();
let error = normalize_icon_descriptions(aggregate_too_long)
.expect_err("overlong aggregate should fail");
assert!(error.body_text().contains("合计"));
let utf8_too_large = vec!["😀".repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS); 8];
let error = normalize_icon_descriptions(utf8_too_large)
.expect_err("oversized UTF-8 aggregate should fail");
assert!(error.body_text().contains("UTF-8"));
let source = include_str!("editor_project_icon.rs");
assert_function_contains_in_order(
source,
"pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner",
"pub(crate) async fn generate_editor_icon_spritesheet_for_owner",
&[
"payload.icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?;",
"payload.icon_descriptions =",
"normalize_icon_descriptions(payload.icon_descriptions)?.into_descriptions();",
"resolve_editor_icon_spec_reference(",
"resolve_editor_icon_spritesheet_price(",
"enqueue_editor_generation_job_for_caller(",
@@ -66,7 +66,10 @@ const ICON_SPEC_IMAGE_SIZE: &str = "2K";
const ICON_SPEC_SIZE: &str = "2048x1152";
const ICON_SPEC_LLM_MAX_ATTEMPTS: u32 = 3;
const ICON_SPEC_EXTRA_PARAM_MAX_LEN: usize = 200;
const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100;
pub(crate) const EDITOR_ICON_DESCRIPTION_LIMIT: usize = 100;
pub(crate) const EDITOR_ICON_DESCRIPTION_MAX_CHARS: usize = 200;
pub(crate) const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS: usize = 2_000;
pub(crate) const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES: usize = 6 * 1024;
pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_DIMENSION: u32 = 4096;
pub(crate) const EDITOR_ICON_SPRITESHEET_MAX_PIXELS: u64 = 2048 * 2048;
const EDITOR_ICON_SPRITESHEET_MAX_SLICES: usize = 64;
@@ -111,25 +114,114 @@ static EDITOR_ICON_SPRITESHEET_MEMORY_LIMITER: std::sync::LazyLock<
))
});
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ValidatedEditorIconSpritesheetPrompt {
descriptions: Vec<String>,
prompt: String,
}
impl ValidatedEditorIconSpritesheetPrompt {
pub(crate) fn descriptions(&self) -> &[String] {
self.descriptions.as_slice()
}
pub(crate) fn as_str(&self) -> &str {
self.prompt.as_str()
}
pub(crate) fn into_descriptions(self) -> Vec<String> {
self.descriptions
}
}
pub(crate) fn normalize_icon_descriptions(
descriptions: Vec<String>,
) -> Result<Vec<String>, AppError> {
let normalized = descriptions
.into_iter()
.map(|description| description.trim().to_string())
.filter(|description| !description.is_empty())
.take(EDITOR_ICON_DESCRIPTION_LIMIT.saturating_add(1))
.collect::<Vec<_>>();
if normalized.is_empty() || normalized.len() > EDITOR_ICON_DESCRIPTION_LIMIT {
) -> Result<ValidatedEditorIconSpritesheetPrompt, AppError> {
let mut normalized = Vec::with_capacity(descriptions.len().min(EDITOR_ICON_DESCRIPTION_LIMIT));
let mut total_chars = 0usize;
let mut total_utf8_bytes = 0usize;
for (item_index, description) in descriptions.into_iter().enumerate() {
let description = description.trim();
if description.is_empty() {
continue;
}
if normalized.len() >= EDITOR_ICON_DESCRIPTION_LIMIT {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "iconDescriptions",
"maxItems": EDITOR_ICON_DESCRIPTION_LIMIT,
"message": "图标素材描述数量必须在 1 到 100 个之间。",
})),
);
}
let actual_length = description.chars().count();
if actual_length > EDITOR_ICON_DESCRIPTION_MAX_CHARS {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "iconDescriptions",
"itemIndex": item_index,
"maxLength": EDITOR_ICON_DESCRIPTION_MAX_CHARS,
"actualLength": actual_length,
"message": format!(
"每条图标素材描述不能超过 {EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符。"
),
})),
);
}
let separator_length = usize::from(!normalized.is_empty());
let next_total_chars = total_chars
.saturating_add(separator_length)
.saturating_add(actual_length);
if next_total_chars > EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "iconDescriptions",
"maxTotalLength": EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS,
"actualTotalLength": next_total_chars,
"message": format!(
"图标素材描述合计不能超过 {EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS} 个字符。"
),
})),
);
}
let next_total_utf8_bytes = total_utf8_bytes
.saturating_add(separator_length)
.saturating_add(description.len());
if next_total_utf8_bytes > EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "iconDescriptions",
"maxTotalUtf8Bytes": EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES,
"actualTotalUtf8Bytes": next_total_utf8_bytes,
"message": format!(
"图标素材描述合计不能超过 {EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES} 个 UTF-8 字节。"
),
})),
);
}
total_chars = next_total_chars;
total_utf8_bytes = next_total_utf8_bytes;
normalized.push(description.to_string());
}
if normalized.is_empty() {
return Err(
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
"provider": "editor-icon-spritesheet",
"field": "iconDescriptions",
"minItems": 1,
"message": "图标素材描述数量必须在 1 到 100 个之间。",
})),
);
}
Ok(normalized)
let prompt = normalized.join("\n");
Ok(ValidatedEditorIconSpritesheetPrompt {
descriptions: normalized,
prompt,
})
}
pub(crate) async fn resolve_editor_icon_spritesheet_price(
@@ -1099,7 +1191,8 @@ pub(crate) async fn enqueue_editor_icon_spritesheet_generation_for_owner(
) -> Result<ExternalGenerationJobRecord, AppError> {
payload.generation_inputs =
sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.take());
payload.icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?;
payload.icon_descriptions =
normalize_icon_descriptions(payload.icon_descriptions)?.into_descriptions();
resolve_editor_icon_spec_reference(
state,
caller.owner_user_id.as_str(),
@@ -1198,8 +1291,8 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
),
)
.await?;
let icon_descriptions = normalize_icon_descriptions(payload.icon_descriptions)?;
let user_prompt = icon_descriptions.join("\n");
let spritesheet_prompt = normalize_icon_descriptions(payload.icon_descriptions)?;
let user_prompt = spritesheet_prompt.as_str().to_string();
let resolved_icon_spec = resolve_editor_icon_spec_reference(
state,
caller.owner_user_id.as_str(),
@@ -1270,8 +1363,8 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
EditorScreenBackgroundDecisionInput {
kind: EditorScreenBackgroundDecisionKind::IconSpritesheet,
screen_color: requested_screen_color.clone(),
prompt: user_prompt.clone(),
icon_descriptions: icon_descriptions.clone(),
prompt: spritesheet_prompt.as_str().to_string(),
icon_descriptions: spritesheet_prompt.descriptions().to_vec(),
reference_count,
source_image_data_url: None,
},
@@ -1291,7 +1384,7 @@ pub(crate) async fn generate_editor_icon_spritesheet_for_owner(
.await?;
let screen_color = screen_background_decision.color;
let prompt = crate::prompt::icon_spec::build_spritesheet_prompt(
user_prompt.as_str(),
&spritesheet_prompt,
screen_color,
icon_spec_genre,
);
@@ -1383,6 +1383,24 @@ mod tests {
.get("referenceImageSrc")
.is_none()
);
let icon_descriptions = &icon_spritesheet_request["properties"]["iconDescriptions"];
assert_eq!(
icon_descriptions["items"]["maxLength"],
crate::editor_project_icon::EDITOR_ICON_DESCRIPTION_MAX_CHARS
);
assert_eq!(
icon_descriptions["x-genarrative-maxTotalCharacters"],
crate::editor_project_icon::EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS
);
assert_eq!(
icon_descriptions["x-genarrative-maxTotalUtf8Bytes"],
crate::editor_project_icon::EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES
);
assert!(
icon_descriptions["description"]
.as_str()
.is_some_and(|description| description.contains("同步返回 400"))
);
let icon_style_schema = &parsed["components"]["schemas"]["EditorIconSpritesheetGenerationRequest"]
["properties"]["style"];
assert_eq!(icon_style_schema["anyOf"][0]["type"], "string");
@@ -1,4 +1,6 @@
use super::super::editor_project_icon::{ExtraParam, GameGenre, RequestParam};
use super::super::editor_project_icon::{
ExtraParam, GameGenre, RequestParam, ValidatedEditorIconSpritesheetPrompt,
};
use crate::editor_green_screen::EditorScreenBackgroundColor;
pub const PROMPT_MAX_LEN: usize = 200;
@@ -176,11 +178,11 @@ pub(crate) fn get_gen_icon_spec_prompt(
}
pub(crate) fn build_spritesheet_prompt(
user_prompt: &str,
user_prompt: &ValidatedEditorIconSpritesheetPrompt,
screen_color: EditorScreenBackgroundColor,
genre: Option<GameGenre>,
) -> String {
let normalized_prompt = escape_xml_prompt_data(user_prompt.trim());
let normalized_prompt = escape_xml_prompt_data(user_prompt.as_str());
let background_color = format!(
"{label} {hex}",
label = screen_color.label,
@@ -331,8 +333,11 @@ mod tests {
assert!(extra_prompt.contains(format!("<playSetting>{escaped}</playSetting>").as_str()));
assert!(extra_prompt.contains(format!("<artStyle>{escaped}</artStyle>").as_str()));
let spritesheet_input =
crate::editor_project_icon::normalize_icon_descriptions(vec![malicious.to_string()])
.expect("bounded spritesheet prompt should pass");
let spritesheet_prompt = build_spritesheet_prompt(
malicious,
&spritesheet_input,
crate::editor_green_screen::default_editor_screen_background_color(),
Some(GameGenre::Strategy),
);
@@ -28,6 +28,9 @@ pub enum GenerateIconSpritesheetError {
ReferenceNotProvided,
DescriptionsNotProvided,
TooManyDescriptions(usize),
DescriptionTooLong { index: usize, actual_chars: usize },
DescriptionsTooLong(usize),
DescriptionsTooManyBytes(usize),
AssetNotFound(ImageId),
}
@@ -51,6 +54,24 @@ impl Display for GenerateIconSpritesheetError {
"icon description count must be between 1 and {}, got {count}",
GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS
),
Self::DescriptionTooLong {
index,
actual_chars,
} => write!(
f,
"icon description at index {index} must not exceed {} Unicode characters, got {actual_chars}",
GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTION_CHARS
),
Self::DescriptionsTooLong(actual_chars) => write!(
f,
"icon descriptions must not exceed {} Unicode characters in total, got {actual_chars}",
GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS_TOTAL_CHARS
),
Self::DescriptionsTooManyBytes(actual_bytes) => write!(
f,
"icon descriptions must not exceed {} UTF-8 bytes in total, got {actual_bytes}",
GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS_TOTAL_UTF8_BYTES
),
Self::AssetNotFound(image_id) => write!(f, "asset {image_id} not found in context"),
}
}
@@ -131,7 +152,18 @@ impl Tool for GenerateIconSpritesheetTool {
"reference_image_id": { "type": "string", "description": "必填的图标规范或风格参考图 ID。" },
"model": image_model_parameter_schema(),
"reference_image_ids": { "type": "array", "items": { "type": "string" }, "description": "image_id(s) for desc" },
"icon_descriptions": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS, "description": "要生成的多个图标描述,数量必须在 1 到 100 个之间。" },
"icon_descriptions": {
"type": "array",
"items": {
"type": "string",
"maxLength": GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTION_CHARS
},
"minItems": 1,
"maxItems": GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS,
"x-genarrative-maxTotalCharacters": GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS_TOTAL_CHARS,
"x-genarrative-maxTotalUtf8Bytes": GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS_TOTAL_UTF8_BYTES,
"description": "要生成的多个图标描述。去空后必须为 1 到 100 条;单条最多 200 个 Unicode 字符,拼接换行后合计最多 2000 个 Unicode 字符且不超过 6144 个 UTF-8 字节。"
},
"aspect_ratio": image_aspect_ratio_parameter_schema(),
"image_size": image_size_parameter_schema()
},
@@ -167,7 +199,10 @@ impl Tool for GenerateIconSpritesheetTool {
| GenerateIconSpritesheetError::InvalidImageSize { .. }
| GenerateIconSpritesheetError::ReferenceNotProvided
| GenerateIconSpritesheetError::DescriptionsNotProvided
| GenerateIconSpritesheetError::TooManyDescriptions(_) => {
| GenerateIconSpritesheetError::TooManyDescriptions(_)
| GenerateIconSpritesheetError::DescriptionTooLong { .. }
| GenerateIconSpritesheetError::DescriptionsTooLong(_)
| GenerateIconSpritesheetError::DescriptionsTooManyBytes(_) => {
ToolFailure::invalid_args(error.to_string())
}
}
@@ -176,6 +211,9 @@ impl Tool for GenerateIconSpritesheetTool {
impl GenerateIconSpritesheetTool {
pub const MAX_ICON_DESCRIPTIONS: usize = 100;
pub const MAX_ICON_DESCRIPTION_CHARS: usize = 200;
pub const MAX_ICON_DESCRIPTIONS_TOTAL_CHARS: usize = 2_000;
pub const MAX_ICON_DESCRIPTIONS_TOTAL_UTF8_BYTES: usize = 6 * 1024;
pub fn validate_args(
&self,
@@ -207,6 +245,40 @@ impl GenerateIconSpritesheetTool {
description_count,
));
}
let mut normalized_count = 0usize;
let mut total_chars = 0usize;
let mut total_utf8_bytes = 0usize;
for (index, description) in args.icon_descriptions.iter().enumerate() {
let description = description.trim();
if description.is_empty() {
continue;
}
let actual_chars = description.chars().count();
if actual_chars > Self::MAX_ICON_DESCRIPTION_CHARS {
return Err(GenerateIconSpritesheetError::DescriptionTooLong {
index,
actual_chars,
});
}
let separator_length = usize::from(normalized_count > 0);
total_chars = total_chars
.saturating_add(separator_length)
.saturating_add(actual_chars);
if total_chars > Self::MAX_ICON_DESCRIPTIONS_TOTAL_CHARS {
return Err(GenerateIconSpritesheetError::DescriptionsTooLong(
total_chars,
));
}
total_utf8_bytes = total_utf8_bytes
.saturating_add(separator_length)
.saturating_add(description.len());
if total_utf8_bytes > Self::MAX_ICON_DESCRIPTIONS_TOTAL_UTF8_BYTES {
return Err(GenerateIconSpritesheetError::DescriptionsTooManyBytes(
total_utf8_bytes,
));
}
normalized_count += 1;
}
for image_id in &args.reference_image_ids {
if !self.context.contains_image(image_id) {
return Err(GenerateIconSpritesheetError::AssetNotFound(
@@ -309,5 +381,50 @@ mod tests {
schema["properties"]["icon_descriptions"]["maxItems"],
json!(100)
);
assert_eq!(
schema["properties"]["icon_descriptions"]["items"]["maxLength"],
json!(GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTION_CHARS)
);
assert_eq!(
schema["properties"]["icon_descriptions"]["x-genarrative-maxTotalCharacters"],
json!(GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS_TOTAL_CHARS)
);
assert_eq!(
schema["properties"]["icon_descriptions"]["x-genarrative-maxTotalUtf8Bytes"],
json!(GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTIONS_TOTAL_UTF8_BYTES)
);
}
#[test]
fn validates_icon_description_item_and_aggregate_limits() {
let (tool, args) = tool_and_args(vec![
"".repeat(GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTION_CHARS + 1),
]);
assert!(matches!(
tool.validate_args(&args),
Err(GenerateIconSpritesheetError::DescriptionTooLong { index: 0, .. })
));
let (tool, args) = tool_and_args(vec![
"".repeat(
GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTION_CHARS
);
11
]);
assert!(matches!(
tool.validate_args(&args),
Err(GenerateIconSpritesheetError::DescriptionsTooLong(_))
));
let (tool, args) = tool_and_args(vec![
"😀".repeat(
GenerateIconSpritesheetTool::MAX_ICON_DESCRIPTION_CHARS
);
8
]);
assert!(matches!(
tool.validate_args(&args),
Err(GenerateIconSpritesheetError::DescriptionsTooManyBytes(_))
));
}
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient';
import type { CanvasLayer } from './ImageCanvasEditorTypes';
import {
IMAGE_MODEL_GPT_IMAGE_2,
@@ -716,6 +717,25 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('returns an icon spritesheet error when the complete prompt is too long', () => {
const plan = buildIconSpritesheetGenerationSubmissionPlan({
mode: 'icon',
prompt: '图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1),
status: 'idle',
iconSpecReference: {
id: 'icon-spec',
label: '图标规范',
src: 'data:image/png;base64,spec',
resourceId: 'resource-icon-spec',
},
});
expect(plan).toEqual({
ok: false,
errorMessage: `素材描述不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`,
});
});
it('builds icon spritesheet plans with the complete prompt and references', () => {
const plan = buildIconSpritesheetGenerationSubmissionPlan({
mode: 'icon',
@@ -8,6 +8,7 @@ import type {
EditorSoundEffectGenerationInput,
EditorVideoGenerationInput,
} from '../../services/image-editor/editorProjectClient';
import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient';
import type {
CanvasGenerationInputs,
CanvasLayer,
@@ -675,6 +676,12 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
errorMessage: '请填写素材描述',
};
}
if (Array.from(normalizedPrompt).length > EDITOR_ICON_DESCRIPTION_MAX_CHARS) {
return {
ok: false,
errorMessage: `素材描述不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`,
};
}
const rememberImageModel = normalizeEditorImageModel(dialog.imageModel);
const screenColor = DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR;
@@ -4,6 +4,7 @@ import { fireEvent, render, screen, within } from '@testing-library/react';
import { createRef, useState } from 'react';
import { describe, expect, it, vi } from 'vitest';
import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient';
import type {
GenerateDialogState,
UploadTarget,
@@ -206,6 +207,26 @@ describe('ImageCanvasIconSpritesheetComposerView', () => {
expect(submitIconGeneration).toHaveBeenCalledWith(dialog);
});
it('limits description input by Unicode characters', () => {
const updateIconDescriptionText = vi.fn();
render(
<IconComposerHarness
initialDialog={createIconDialog({ prompt: '' })}
onUpdateIconDescriptionText={updateIconDescriptionText}
/>,
);
fireEvent.change(screen.getByLabelText('素材描述'), {
target: {
value: '😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1),
},
});
expect(updateIconDescriptionText).toHaveBeenCalledWith(
'😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
);
});
it('keeps image option changes inside the icon dialog', () => {
const rememberImageModel = vi.fn();
render(
@@ -7,6 +7,7 @@ import {
type SetStateAction,
} from 'react';
import { EDITOR_ICON_DESCRIPTION_MAX_CHARS } from '../../services/image-editor/editorProjectClient';
import { AutoGrowTextArea } from '../common/AutoGrowTextArea';
import {
PlatformFloatingMenu,
@@ -23,6 +24,10 @@ import { calculateEditorIconSpritesheetPrice } from './ImageCanvasGenerationMode
import { ImageCanvasReferenceSlot } from './ImageCanvasReferenceSlot';
import { useImageCanvasFloatingOptionDismiss } from './useImageCanvasFloatingOptionDismiss';
function limitIconDescriptionText(value: string) {
return Array.from(value).slice(0, EDITOR_ICON_DESCRIPTION_MAX_CHARS).join('');
}
type ImageCanvasIconSpritesheetComposerViewProps = {
dialog: GenerateDialogState;
style: CSSProperties;
@@ -216,7 +221,11 @@ export function ImageCanvasIconSpritesheetComposerView({
disabled={dialog.status === 'generating'}
placeholder="你需要哪些图标?"
className="image-canvas-editor__generation-prompt"
onChange={(event) => onUpdateIconDescriptionText(event.target.value)}
onChange={(event) =>
onUpdateIconDescriptionText(
limitIconDescriptionText(event.target.value),
)
}
/>
</label>
{dialog.status === 'failed' ? (
@@ -9,6 +9,9 @@ import {
deleteEditorAssetFolder,
deleteEditorProject,
editEditorImage,
EDITOR_ICON_DESCRIPTION_MAX_CHARS,
EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS,
EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES,
EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH,
extractEditorUiDesignAssets,
generateEditorBackgroundMusic,
@@ -1150,6 +1153,42 @@ describe('editorProjectClient', () => {
);
});
it('rejects icon descriptions outside the item and aggregate contracts before requesting', async () => {
await expect(
generateEditorIconSpritesheet({
referenceId: 'editor-resource-spec',
iconDescriptions: ['图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS + 1)],
}),
).rejects.toThrow(`不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`);
await expect(
generateEditorIconSpritesheet({
referenceId: 'editor-resource-spec',
iconDescriptions: [
'图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
...Array.from({ length: 10 }, () =>
'图'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
),
],
}),
).rejects.toThrow(
`合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS} 个字符`,
);
await expect(
generateEditorIconSpritesheet({
referenceId: 'editor-resource-spec',
iconDescriptions: Array.from({ length: 8 }, () =>
'😀'.repeat(EDITOR_ICON_DESCRIPTION_MAX_CHARS),
),
}),
).rejects.toThrow(
`合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES} 个 UTF-8 字节`,
);
expect(requestJsonMock).not.toHaveBeenCalled();
});
it('passes image model options to icon spritesheet generation', async () => {
requestJsonMock.mockResolvedValueOnce({
spritesheetImageSrc: 'data:image/png;base64,sheet',
@@ -14,6 +14,10 @@ const EDITOR_ICON_SPEC_REFINE_GAME_PLAY_API =
const EDITOR_ICON_SPEC_REFINE_ART_STYLE_API =
'/api/editor/llm/icon-specs/refine-art-style';
export const EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH = 200;
export const EDITOR_ICON_DESCRIPTION_LIMIT = 100;
export const EDITOR_ICON_DESCRIPTION_MAX_CHARS = 200;
export const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS = 2_000;
export const EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES = 6 * 1024;
const EDITOR_IMAGE_EDIT_API = '/api/editor/images/edits';
const EDITOR_BACKGROUND_REMOVAL_API = '/api/editor/images/background-removals';
const EDITOR_PIXEL_ART_SNAP_API = '/api/editor/images/pixel-art-snaps';
@@ -87,6 +91,40 @@ function assertEditorReferenceLimit(
}
}
function normalizeEditorIconDescriptions(values: readonly string[]) {
const normalized = values.map((value) => value.trim()).filter(Boolean);
if (
normalized.length < 1 ||
normalized.length > EDITOR_ICON_DESCRIPTION_LIMIT
) {
throw new Error(
`图标素材描述数量必须在 1 到 ${EDITOR_ICON_DESCRIPTION_LIMIT} 个之间`,
);
}
normalized.forEach((description, index) => {
const actualLength = Array.from(description).length;
if (actualLength > EDITOR_ICON_DESCRIPTION_MAX_CHARS) {
throw new Error(
`${index + 1} 条图标素材描述不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`,
);
}
});
const prompt = normalized.join('\n');
const totalLength = Array.from(prompt).length;
if (totalLength > EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS) {
throw new Error(
`图标素材描述合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS} 个字符`,
);
}
const totalUtf8Bytes = new TextEncoder().encode(prompt).byteLength;
if (totalUtf8Bytes > EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES) {
throw new Error(
`图标素材描述合计不能超过 ${EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES} 个 UTF-8 字节`,
);
}
return normalized;
}
function assertStableEditorVideoReferences(input: EditorVideoGenerationInput) {
const references = [
...(input.referenceImageSrcs ?? []),
@@ -1134,6 +1172,9 @@ export async function generateEditorIconSpec(
export async function generateEditorIconSpritesheet(
input: EditorIconSpritesheetGenerationInput,
) {
const iconDescriptions = normalizeEditorIconDescriptions(
input.iconDescriptions,
);
const model = input.model?.trim() || EDITOR_IMAGE_MODEL_NANOBANANA2;
assertStableEditorMediaReferences(input.referenceImageSrcs, '图标素材参考图');
assertEditorReferenceLimit(
@@ -1151,7 +1192,7 @@ export async function generateEditorIconSpritesheet(
...(input.referenceImageSrcs?.length
? { referenceImageSrcs: input.referenceImageSrcs }
: {}),
iconDescriptions: input.iconDescriptions,
iconDescriptions,
model,
...(input.screenColor ? { screenColor: input.screenColor } : {}),
...(input.segModel ? { segModel: input.segModel } : {}),