修复图标图集审查中的边界问题

校验 LLM 完成原因并规范化 Agent 图标描述
统一前后端图标规范输入、错误返回和精确撤销行为
收紧图集拆分授权并拒绝引用 ID 歧义
修正快照映射诊断和表单标签可访问性
补充定向测试与编辑器设计文档
This commit is contained in:
2026-08-06 19:15:10 +08:00
parent 2b8ced9195
commit 2266d7fe89
17 changed files with 194 additions and 45 deletions
@@ -40,7 +40,7 @@
- 生成视频:`你希望生成什么视频?`
8. 多输入框面板必须保留每个字段标题和输入框边界,例如生成规范。图标素材生成不再使用多描述列表,改为复用角色形象生成面板同款单文本输入框;该完整文本按 Unicode 字符限制为 `200`,输入时按 code point 截断,不能用 UTF-16 `maxLength` 误截 emoji。
9. 生成规范下的角色规范、图标规范和自定义规范都使用同一生成类 shell:首行参考图区域、中央字段区、底部生成按钮区,不再出现缺首行参考区或单独 footer 样式。
10. 图标规范只使用 `specType="icon"`,历史 `specType="ui"` 快照在恢复边界迁移为 `icon`。表单字段使用 `playSetting / artStyle`,界面标题继续使用「玩法设定 / 美术风格」。两项初始为空且必填;每项独立支持一键优化、处理中锁定自身、成功后单次撤销,操作行最右侧按 Unicode 字符实时显示 `当前数/200`手工编辑后立即清除该字段已经失效的撤销快照,失败只保留当前文本与仍然有效的旧撤销快照。LLM 返回空文本、超长文本Markdown / 结构化内容时,后续有界重试必须携带上次无效输出和对应修正要求,不能原样重放同一请求。优化请求必须绑定发起时的生成对象 ID 和请求代次;活动对象身份只在 React effect 提交后更新,并在 cleanup 中失效,丢弃的并发 render 不得改变请求归属;对象切换或新请求取代旧请求后,旧成功或失败结果都不得更新当前面板。任一项处理中或任一项为空时禁用生成。控件继续使用平台默认样式,不新增图标规范专属 CSS。
10. 图标规范只使用 `specType="icon"`,历史 `specType="ui"` 快照在恢复边界迁移为 `icon`。表单字段使用 `playSetting / artStyle`,界面标题继续使用「玩法设定 / 美术风格」。两项初始为空且必填,客户端提交前统一 trim 并拒绝空白值;每项独立支持一键优化、处理中锁定自身、成功后单次撤销,操作行最右侧按 Unicode 字符实时显示 `当前数/200`。撤销必须恢复优化前的原始输入(包括首尾空白);手工编辑后立即清除该字段已经失效的撤销快照,失败只保留当前文本与仍然有效的旧撤销快照。LLM 返回空文本、超长文本Markdown / 结构化内容,或 finish reason 明确表示截断、过滤、失败时,后续有界重试必须携带上次无效输出和对应修正要求,不能把未完成前缀当作成功结果。优化请求必须绑定发起时的生成对象 ID 和请求代次;活动对象身份只在 React effect 提交后更新,并在 cleanup 中失效,丢弃的并发 render 不得改变请求归属;对象切换或新请求取代旧请求后,旧成功或失败结果都不得更新当前面板。任一项处理中或任一项为空时禁用生成。字段标题使用真实 label 关联 textarea,不得把优化 / 撤销按钮包进 label。控件继续使用平台默认样式,不新增图标规范专属 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。
12. 图片快速编辑不展示额外参考图入口;原图或绘制了红框和序号的标注图始终作为 `/api/editor/images/edits``sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`
13. 快速编辑打开后,画布视口应调整到原图完整展示,且面板位于原图下方并不遮挡原图;原图右侧显示竖向框选工具,支持矩形、椭圆和画笔自由框选。快速编辑进入时不默认启用框选工具,点击工具后出现选中态并保持高亮,再点同一工具取消启用;红色圈选框使用细描边。每完成一次框选,红色圈选框按完成顺序标注 `1 / 2 / 3...`,并在快速编辑提示词中追加一行 `对N号红色圈选框里的内容做以下修改:`
@@ -16,7 +16,7 @@
- 图标素材面板锚定在占位图下方,和现有生成输入框同一层级展示。
- 透明背景处理正常成功后删除占位态:透明 spritesheet 作为主图(`assetKind: "icon-spritesheet"``generatedLayerId` 锚点)放入画布,provider 带背景原图作为第二个同类型图层放在透明主图右侧,按 alpha 连通域成功拆出的 `assetKind: "icon"` 素材从原图右侧继续铺放;透明背景处理最终失败时,后端完成快照只用 provider 原图替换占位态。
- 选中 `assetKind: "icon-spritesheet"` 图层时,图片浮动工具栏显示 `拆分图集`;手动拆分只追加独立素材,不复制原图集。
- 用户把现有图层手动标记为“图集”时,必须先持久化一条 `assetKind: "icon-spritesheet"` 的项目资源并把返回的 `resourceId` 写回图层;项目资源只能在媒体来源和 `assetKind` 都相同时复用,不得因同源图片而返回旧类型资源。持久化完成前必须禁用“拆分图集”,持久化失败时回滚到上一个已确认的素材标签和资源引用,并失效该轮未确认的标签撤销记录。
- 用户把现有图层手动标记为“图集”时,必须先持久化一条 `assetKind: "icon-spritesheet"` 的项目资源并把返回的 `resourceId` 写回图层;项目资源只能在媒体来源和 `assetKind` 都相同时复用,不得因同源图片而返回旧类型资源。持久化完成前必须禁用“拆分图集”,持久化失败时回滚到上一个已确认的素材标签和资源引用,并失效该轮未确认的标签撤销记录。后端拆分授权只信任该项目资源的 `assetKind`,图层 `assetKindOverride` 仅用于展示,不能把其它资源提升为可拆分图集。
- 图标规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。
## 面板结构
@@ -41,7 +41,7 @@
- 前端提交到 `POST /api/editor/icon-spritesheets/generations`
- 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`
- worker 解析主 `referenceImageSrc` 时必须通过 `spacetime-client` 的通用窄查询 `resolve_editor_reference` 在同一事务快照内完成引用解析和 owner 校验:资源 ID / 素材 ID 走主键,对象键按规范化 `image_src="/<objectKey>"` 索引定位单条资源或素材,并通过 `asset_object(bucket, object_key)` 复合索引校验对象 owner。同一 ID 若同时命中项目资源和素材必须按协议歧义拒绝,不得静默偏向任一表。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`;当前画布前端固定把完整文本作为唯一数组元素提交。数组长度只表达请求文本,不作为自动拆分数量;单项、聚合字符和 UTF-8 字节上限按上一条 prompt 合同执行。
@@ -722,7 +722,7 @@ impl EditorAgentTool for EditImageTool {
impl EditorAgentTool for GenerateIconSpritesheetTool {
fn validate_args(&self, args: &Value) -> Result<Value, EditorAgentToolError> {
let args: GenerateIconSpritesheetToolArgs = parse_invalid_args(Self::NAME, args)?;
GenerateIconSpritesheetTool::validate_args(self, &args)
let args = GenerateIconSpritesheetTool::normalize_args(self, args)
.map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?;
serialize_normalized_args(Self::NAME, &args)
}
@@ -16111,7 +16111,7 @@ mod tests {
}
#[test]
fn editor_icon_spritesheet_slice_uses_authoritative_layer_override() {
fn editor_icon_spritesheet_slice_uses_authoritative_resource_kind() {
let layers = json!([
{
"layerId": "layer-default",
@@ -16146,8 +16146,8 @@ mod tests {
"resource-image",
Some("character"),
)
.expect("layer override should win"),
Some("icon-spritesheet")
.expect("layer override must not promote the resource"),
Some("character")
);
assert_eq!(
resolve_editor_icon_spritesheet_source_layer_asset_kind(
@@ -16156,8 +16156,8 @@ mod tests {
"resource-atlas",
Some("icon-spritesheet"),
)
.expect("blocking override should win"),
Some("character")
.expect("layer override must not demote the resource"),
Some("icon-spritesheet")
);
assert!(
resolve_editor_icon_spritesheet_source_layer_asset_kind(
@@ -1832,7 +1832,8 @@ pub(crate) fn resolve_editor_icon_spritesheet_source_layer_asset_kind<'a>(
);
}
match source_layer.get("assetKindOverride") {
Some(Value::String(value)) => Ok(Some(value.trim())),
// 图层 override 只控制画布展示;手工拆分授权必须以服务端资源类型为准。
Some(Value::String(_)) => Ok(resource_asset_kind),
Some(Value::Null) | None => Ok(resource_asset_kind),
Some(_) => Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
@@ -101,6 +101,7 @@ enum RefinedTextError {
Empty,
TooLong,
InvalidFormat,
Incomplete,
}
impl RefinedTextError {
@@ -109,6 +110,7 @@ impl RefinedTextError {
Self::Empty => "LLM 未返回可用文本".to_string(),
Self::TooLong => format!("LLM 返回文本超过 {PROMPT_MAX_LEN} 个字符"),
Self::InvalidFormat => "LLM 返回内容不是可直接使用的纯文本".to_string(),
Self::Incomplete => "LLM 返回内容未完整结束".to_string(),
}
}
@@ -122,10 +124,26 @@ impl RefinedTextError {
"上次输出包含 Markdown、结构化数据或解释性前缀。请只输出可直接使用的纯文本正文。"
.to_string()
}
Self::Incomplete => {
"上次输出因长度、内容过滤或上游中止而未完整结束。请重新输出完整的纯文本正文。"
.to_string()
}
}
}
}
fn normalize_refined_text_response(
value: &str,
finish_reason: Option<&str>,
) -> Result<String, RefinedTextError> {
if finish_reason.is_some_and(|reason| {
platform_llm::is_incomplete_finish_reason(platform_llm::LlmApiKind::OpenAiChat, reason)
}) {
return Err(RefinedTextError::Incomplete);
}
normalize_refined_text(value)
}
fn normalize_refined_text(value: &str) -> Result<String, RefinedTextError> {
let value = value.trim();
if value.is_empty() {
@@ -170,7 +188,10 @@ async fn run_refine_text_llm(state: &AppState, prompt: String) -> Result<String,
for attempt in 1..=ICON_SPEC_LLM_MAX_ATTEMPTS {
match llm_client.run(request.clone()).await {
Ok(response) => match normalize_refined_text(response.text.as_str()) {
Ok(response) => match normalize_refined_text_response(
response.text.as_str(),
response.finish_reason.as_deref(),
) {
Ok(text) => return Ok(text),
Err(error) if attempt == ICON_SPEC_LLM_MAX_ATTEMPTS => {
return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(
@@ -286,6 +307,19 @@ mod tests {
);
}
#[test]
fn refined_text_rejects_incomplete_provider_completion() {
assert_eq!(
normalize_refined_text_response("看似完整但实际被截断", Some("length")).unwrap_err(),
RefinedTextError::Incomplete
);
assert_eq!(
normalize_refined_text_response("完整结果", Some("stop"))
.expect("stop completion should pass"),
"完整结果"
);
}
#[test]
fn refine_request_bounds_provider_output() {
let request = build_refine_text_llm_request("优化玩法".to_string());
@@ -215,6 +215,20 @@ impl GenerateIconSpritesheetTool {
pub const MAX_ICON_DESCRIPTIONS_TOTAL_CHARS: usize = 2_000;
pub const MAX_ICON_DESCRIPTIONS_TOTAL_UTF8_BYTES: usize = 6 * 1024;
pub fn normalize_args(
&self,
mut args: GenerateIconSpritesheetToolArgs,
) -> Result<GenerateIconSpritesheetToolArgs, GenerateIconSpritesheetError> {
args.icon_descriptions = args
.icon_descriptions
.into_iter()
.map(|description| description.trim().to_string())
.filter(|description| !description.is_empty())
.collect();
self.validate_args(&args)?;
Ok(args)
}
pub fn validate_args(
&self,
args: &GenerateIconSpritesheetToolArgs,
@@ -427,4 +441,20 @@ mod tests {
Err(GenerateIconSpritesheetError::DescriptionsTooManyBytes(_))
));
}
#[test]
fn normalizes_icon_descriptions_before_propagation() {
let padded = format!("{}背包{}", " ".repeat(8_000), "\n".repeat(8_000));
let (tool, args) = tool_and_args(vec![" ".to_string(), padded]);
let normalized = tool
.normalize_args(args)
.expect("trimmed description should satisfy the contract");
assert_eq!(normalized.icon_descriptions, vec!["背包"]);
assert_eq!(
serde_json::to_value(&normalized).expect("normalized args should serialize")["icon_descriptions"],
json!(["背包"])
);
}
}
@@ -1096,9 +1096,12 @@ pub(crate) fn map_editor_reference_procedure_result(
(Some(resource), None) => map_editor_project_resource_snapshot(resource)
.map(EditorReferenceRecord::ProjectResource),
(None, Some(asset)) => map_editor_asset_snapshot(asset).map(EditorReferenceRecord::Asset),
_ => Err(SpacetimeClientError::missing_snapshot(
(None, None) => Err(SpacetimeClientError::missing_snapshot(
"编辑器引用资源或素材快照",
)),
(Some(_), Some(_)) => Err(SpacetimeClientError::procedure_failed(Some(
"SpacetimeDB procedure 同时返回了编辑器资源与素材快照".to_string(),
))),
}
}
@@ -1095,9 +1095,12 @@ pub(crate) fn map_editor_reference_procedure_result(
(Some(resource), None) => map_editor_project_resource_snapshot(resource)
.map(EditorReferenceRecord::ProjectResource),
(None, Some(asset)) => map_editor_asset_snapshot(asset).map(EditorReferenceRecord::Asset),
_ => Err(SpacetimeClientError::missing_snapshot(
(None, None) => Err(SpacetimeClientError::missing_snapshot(
"编辑器引用资源或素材快照",
)),
(Some(_), Some(_)) => Err(SpacetimeClientError::procedure_failed(Some(
"SpacetimeDB procedure 同时返回了编辑器资源与素材快照".to_string(),
))),
}
}
@@ -6328,23 +6328,24 @@ fn resolve_editor_reference(
if reference_id.is_empty() {
return Err("编辑器引用的 reference_id 不能为空".to_string());
}
if let Some(resource) = ctx
let resource = ctx
.db
.editor_project_resource()
.resource_id()
.find(&reference_id.to_string())
{
return resolve_editor_reference_resource(ctx, bucket, owner_user_id, &resource);
}
if let Some(asset) = ctx
.find(&reference_id.to_string());
let asset = ctx
.db
.editor_asset()
.asset_id()
.find(&reference_id.to_string())
{
return resolve_editor_reference_asset(ctx, bucket, owner_user_id, asset);
.find(&reference_id.to_string());
match (resource, asset) {
(Some(_), Some(_)) => Err("编辑器引用 ID 同时命中项目资源与素材,无法唯一解析".to_string()),
(Some(resource), None) => {
resolve_editor_reference_resource(ctx, bucket, owner_user_id, &resource)
}
(None, Some(asset)) => resolve_editor_reference_asset(ctx, bucket, owner_user_id, asset),
(None, None) => Err("编辑器引用不存在".to_string()),
}
Err("编辑器引用不存在".to_string())
}
fn resolve_editor_reference_resource(
@@ -697,6 +697,25 @@ describe('ImageCanvasGenerationSubmissionModel', () => {
});
});
it('returns an icon spritesheet error when the spec reference is not registered', () => {
const plan = buildIconSpritesheetGenerationSubmissionPlan({
mode: 'icon',
prompt: '返回按钮',
status: 'idle',
iconSpecReference: {
id: 'local-icon-spec',
label: '本地图标规范',
src: 'data:image/png;base64,spec',
resourceId: 'local-resource-icon-spec',
},
});
expect(plan).toEqual({
ok: false,
errorMessage: '参考图尚未登记为项目资源或素材,请重新选择或上传后再试',
});
});
it('returns an icon spritesheet error when descriptions are empty', () => {
const plan = buildIconSpritesheetGenerationSubmissionPlan({
mode: 'icon',
@@ -686,6 +686,18 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
errorMessage: `素材描述不能超过 ${EDITOR_ICON_DESCRIPTION_MAX_CHARS} 个字符`,
};
}
let referenceId: string;
try {
referenceId = resolveRegisteredEditorReferenceId(dialog.iconSpecReference);
} catch (error) {
return {
ok: false,
errorMessage:
error instanceof Error && error.message.trim()
? error.message
: '图标规范尚未完成资源登记,请重新选择或上传后再试',
};
}
const rememberImageModel = normalizeEditorImageModel(dialog.imageModel);
const screenColor = DEFAULT_EDITOR_GENERATION_BACKGROUND_COLOR;
@@ -694,7 +706,7 @@ export function buildIconSpritesheetGenerationSubmissionPlan(
ok: true,
iconDescriptions,
input: {
referenceId: resolveRegisteredEditorReferenceId(dialog.iconSpecReference),
referenceId,
...(dialog.generationReferences?.length
? {
referenceImageSrcs: dialog.generationReferences.map((reference) =>
@@ -532,6 +532,26 @@ describe('ImageCanvasSpecGenerationPanelView', () => {
).toBe('手动修改玩法');
});
it('restores the exact pre-optimization value including surrounding whitespace', async () => {
iconSpecClientMocks.refineGamePlay.mockResolvedValueOnce('优化后的玩法');
render(<IconSpecHarness playSetting=" 回合制占点 " />);
fireEvent.click(screen.getByRole('button', { name: '一键优化玩法设定' }));
await waitFor(() =>
expect(
(screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value,
).toBe('优化后的玩法'),
);
expect(iconSpecClientMocks.refineGamePlay).toHaveBeenCalledWith(
'回合制占点',
);
fireEvent.click(screen.getByRole('button', { name: '撤销玩法设定优化' }));
expect(
(screen.getByLabelText('玩法设定') as HTMLTextAreaElement).value,
).toBe(' 回合制占点 ');
});
it('clears the field undo snapshot after a manual edit', async () => {
iconSpecClientMocks.refineGamePlay.mockResolvedValueOnce('成功优化');
render(<IconSpecHarness />);
@@ -206,7 +206,8 @@ export function ImageCanvasSpecGenerationPanelView({
const key: keyof SpecFormValues = isPlaySetting
? 'playSetting'
: 'artStyle';
const value = dialog.specValues?.[key]?.trim() ?? '';
const undoValue = dialog.specValues?.[key] ?? '';
const value = undoValue.trim();
if (!value) {
return;
}
@@ -229,7 +230,7 @@ export function ImageCanvasSpecGenerationPanelView({
return;
}
onUpdateSpecFormValue(key, refined);
setOptimization({ optimizing: false, undoValue: value });
setOptimization({ optimizing: false, undoValue });
} catch (error) {
if (
activeOptimizationDialogKeyRef.current !== requestDialogKey ||
@@ -472,18 +473,22 @@ export function ImageCanvasSpecGenerationPanelView({
] as const
).map((item) => {
const value = dialog.specValues?.[item.key] ?? '';
const fieldId = `image-canvas-icon-spec-${dialog.id ?? 'active'}-${item.field}`;
return (
<label
<div
key={item.field}
className="image-canvas-editor__field-block"
>
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
{item.title}
</PlatformFieldLabel>
<label htmlFor={fieldId}>
<PlatformFieldLabel
variant="form"
className="image-canvas-editor__field-title"
>
{item.title}
</PlatformFieldLabel>
</label>
<AutoGrowTextArea
id={fieldId}
aria-label={item.title}
value={value}
placeholder={item.placeholder}
@@ -562,7 +567,7 @@ export function ImageCanvasSpecGenerationPanelView({
{EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH}
</span>
</div>
</label>
</div>
);
})}
</>
@@ -83,7 +83,6 @@ import {
buildIconSpritesheetGenerationSubmissionPlan,
buildImageGenerationSubmissionPlan,
resolveGenerationAssetLabel,
resolveRegisteredEditorReferenceId,
} from './ImageCanvasGenerationSubmissionModel';
import type {
UiAssetExtractionMark,
@@ -1341,11 +1340,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({
if (!canvasDialog) {
return;
}
const iconSpecReference = dialog.iconSpecReference;
if (!iconSpecReference) {
return;
}
setSubmittingIconDialog({
...canvasDialog,
iconDescriptions: submissionPlan.iconDescriptions,
@@ -1357,7 +1351,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({
try {
const canvasCompletionPlaceholder =
getGeneratingDialogPlaceholder(dialog);
const referenceId = resolveRegisteredEditorReferenceId(iconSpecReference);
const referenceImageSrcs = await Promise.all(
(dialog.generationReferences ?? []).map((reference) =>
resolveEditorGenerationMediaReference(
@@ -1370,7 +1363,6 @@ export function useImageCanvasGenerationSubmissionWorkflow({
const generated = await runEditorGenerationWithWalletRefresh(
generateEditorIconSpritesheet({
...submissionPlan.input,
referenceId,
...(referenceImageSrcs.length ? { referenceImageSrcs } : {}),
projectId,
generationInputs: submissionPlan.generationInputs,
@@ -11,11 +11,11 @@ import {
deleteEditorAssetFolder,
deleteEditorProject,
editEditorImage,
EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS,
EDITOR_ICON_DESCRIPTION_MAX_CHARS,
EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_CHARS,
EDITOR_ICON_DESCRIPTIONS_MAX_TOTAL_UTF8_BYTES,
EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH,
EDITOR_BACKGROUND_MUSIC_PROMPT_ASSIST_TIMEOUT_MS,
extractEditorUiDesignAssets,
generateEditorBackgroundMusic,
generateEditorCharacterAnimation,
@@ -1596,6 +1596,31 @@ describe('editorProjectClient', () => {
);
});
it('normalizes icon spec fields and rejects blank values before requesting', async () => {
await expect(refineEditorIconSpecPlaySetting(' \n ')).rejects.toThrow(
'玩法设定不能为空',
);
await expect(
generateEditorIconSpec({
playSetting: '回合制',
artStyle: ' ',
}),
).rejects.toThrow('美术风格不能为空');
expect(requestJsonMock).not.toHaveBeenCalled();
requestJsonMock.mockResolvedValueOnce({ playSetting: ' 优化玩法 ' });
await expect(refineEditorIconSpecPlaySetting(' 原玩法 ')).resolves.toBe(
'优化玩法',
);
expect(requestJsonMock).toHaveBeenCalledWith(
'/api/editor/llm/icon-specs/refine-game-play',
expect.objectContaining({
body: JSON.stringify({ playSetting: '原玩法' }),
}),
'优化玩法设定失败',
);
});
it('rejects overlong icon spec generation fields before dispatch', async () => {
await expect(
generateEditorIconSpec({
@@ -76,12 +76,16 @@ function assertStableEditorMediaReferences(
}
function requireEditorIconSpecPromptLength(value: string, fieldLabel: string) {
if (Array.from(value).length > EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH) {
const normalized = value.trim();
if (!normalized) {
throw new Error(`${fieldLabel}不能为空`);
}
if (Array.from(normalized).length > EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH) {
throw new Error(
`${fieldLabel}不能超过 ${EDITOR_ICON_SPEC_PROMPT_MAX_LENGTH} 个字符`,
);
}
return value;
return normalized;
}
function resolveEditorProviderReferenceLimit(model: string | null | undefined) {