拼图候选图改为增加而非替换
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,6 +8,7 @@ coverage/
|
|||||||
/.codex-cargo-home-*/
|
/.codex-cargo-home-*/
|
||||||
/.codex-cache*/
|
/.codex-cache*/
|
||||||
/.tmp*/
|
/.tmp*/
|
||||||
|
/.idea/
|
||||||
.preview.*
|
.preview.*
|
||||||
tmp_*
|
tmp_*
|
||||||
tmp/
|
tmp/
|
||||||
|
|||||||
8
.idea/Genarrative.iml
generated
8
.idea/Genarrative.iml
generated
@@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="WEB_MODULE" version="4">
|
|
||||||
<component name="NewModuleRootManager">
|
|
||||||
<content url="file://$MODULE_DIR$" />
|
|
||||||
<orderEntry type="inheritedJdk" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
||||||
@@ -313,6 +313,8 @@ interface PuzzleAnchorPack {
|
|||||||
1. `api-server` 写入 SpacetimeDB 的候选图 JSON 必须使用 `module-puzzle::PuzzleGeneratedImageCandidate` 持久化结构。
|
1. `api-server` 写入 SpacetimeDB 的候选图 JSON 必须使用 `module-puzzle::PuzzleGeneratedImageCandidate` 持久化结构。
|
||||||
2. 持久化字段名保持 Rust 侧 `snake_case`,例如 `candidate_id`、`image_src`、`asset_id`、`actual_prompt`、`source_type`。
|
2. 持久化字段名保持 Rust 侧 `snake_case`,例如 `candidate_id`、`image_src`、`asset_id`、`actual_prompt`、`source_type`。
|
||||||
3. 面向前端的 HTTP 响应仍由 `shared-contracts` 单独映射为 `camelCase`,不能把响应层字段名直接写入 SpacetimeDB JSON。
|
3. 面向前端的 HTTP 响应仍由 `shared-contracts` 单独映射为 `camelCase`,不能把响应层字段名直接写入 SpacetimeDB JSON。
|
||||||
|
4. 多次生成候选图时必须追加到当前候选池,不能清空已有候选图;已有正式选择保持不变,新追加候选图默认不抢占 `selected` 状态。
|
||||||
|
5. 追加生成时 `candidate_id` 必须按当前候选数量续号,避免前端列表 key 与后端选择动作命中旧候选图。
|
||||||
|
|
||||||
## 7.6 拼图图片资产要求
|
## 7.6 拼图图片资产要求
|
||||||
|
|
||||||
|
|||||||
@@ -2549,12 +2549,24 @@ mod tests {
|
|||||||
description: Some("古老礁石上的半沉神殿。".to_string()),
|
description: Some("古老礁石上的半沉神殿。".to_string()),
|
||||||
};
|
};
|
||||||
let manual_prompt = build_custom_world_scene_image_prompt(
|
let manual_prompt = build_custom_world_scene_image_prompt(
|
||||||
&profile_input,
|
SceneImagePromptParams {
|
||||||
&landmark,
|
profile: SceneImagePromptProfile {
|
||||||
user_prompt,
|
name: profile_input.name.as_deref().unwrap_or_default(),
|
||||||
false,
|
subtitle: profile_input.subtitle.as_deref().unwrap_or_default(),
|
||||||
Some("礁石神殿"),
|
tone: profile_input.tone.as_deref().unwrap_or_default(),
|
||||||
"雾海群岛",
|
player_goal: profile_input.player_goal.as_deref().unwrap_or_default(),
|
||||||
|
summary: profile_input.summary.as_deref().unwrap_or_default(),
|
||||||
|
setting_text: profile_input.setting_text.as_deref().unwrap_or_default(),
|
||||||
|
},
|
||||||
|
landmark: SceneImagePromptLandmark {
|
||||||
|
name: landmark.name.as_deref().unwrap_or_default(),
|
||||||
|
description: landmark.description.as_deref().unwrap_or_default(),
|
||||||
|
},
|
||||||
|
user_prompt,
|
||||||
|
has_reference_image: false,
|
||||||
|
fallback_landmark_name: Some("礁石神殿"),
|
||||||
|
fallback_world_name: "雾海群岛",
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
let normalized = normalize_scene_image_request(CustomWorldSceneImageRequest {
|
let normalized = normalize_scene_image_request(CustomWorldSceneImageRequest {
|
||||||
|
|||||||
@@ -468,6 +468,7 @@ pub async fn execute_puzzle_agent_action(
|
|||||||
.filter(|value| !value.trim().is_empty())
|
.filter(|value| !value.trim().is_empty())
|
||||||
.unwrap_or_else(|| draft.summary.clone());
|
.unwrap_or_else(|| draft.summary.clone());
|
||||||
let candidate_count = payload.candidate_count.unwrap_or(2).clamp(1, 2);
|
let candidate_count = payload.candidate_count.unwrap_or(2).clamp(1, 2);
|
||||||
|
let candidate_start_index = draft.candidates.len();
|
||||||
let candidates = generate_puzzle_image_candidates(
|
let candidates = generate_puzzle_image_candidates(
|
||||||
&state,
|
&state,
|
||||||
owner_user_id.as_str(),
|
owner_user_id.as_str(),
|
||||||
@@ -475,6 +476,7 @@ pub async fn execute_puzzle_agent_action(
|
|||||||
&draft.level_name,
|
&draft.level_name,
|
||||||
&prompt,
|
&prompt,
|
||||||
candidate_count,
|
candidate_count,
|
||||||
|
candidate_start_index,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(SpacetimeClientError::Runtime);
|
.map_err(SpacetimeClientError::Runtime);
|
||||||
@@ -1474,6 +1476,7 @@ async fn compile_puzzle_draft_with_initial_cover(
|
|||||||
&draft.level_name,
|
&draft.level_name,
|
||||||
&draft.summary,
|
&draft.summary,
|
||||||
2,
|
2,
|
||||||
|
draft.candidates.len(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(SpacetimeClientError::Runtime)?;
|
.map_err(SpacetimeClientError::Runtime)?;
|
||||||
@@ -1616,6 +1619,7 @@ async fn generate_puzzle_image_candidates(
|
|||||||
level_name: &str,
|
level_name: &str,
|
||||||
prompt: &str,
|
prompt: &str,
|
||||||
candidate_count: u32,
|
candidate_count: u32,
|
||||||
|
candidate_start_index: usize,
|
||||||
) -> Result<Vec<PuzzleGeneratedImageCandidateRecord>, String> {
|
) -> Result<Vec<PuzzleGeneratedImageCandidateRecord>, String> {
|
||||||
let count = candidate_count.clamp(1, 2);
|
let count = candidate_count.clamp(1, 2);
|
||||||
let settings =
|
let settings =
|
||||||
@@ -1635,7 +1639,7 @@ async fn generate_puzzle_image_candidates(
|
|||||||
let mut items = Vec::with_capacity(generated.images.len());
|
let mut items = Vec::with_capacity(generated.images.len());
|
||||||
|
|
||||||
for (index, image) in generated.images.into_iter().enumerate() {
|
for (index, image) in generated.images.into_iter().enumerate() {
|
||||||
let candidate_id = format!("{session_id}-candidate-{}", index + 1);
|
let candidate_id = format!("{session_id}-candidate-{}", candidate_start_index + index + 1);
|
||||||
let asset = persist_puzzle_generated_asset(
|
let asset = persist_puzzle_generated_asset(
|
||||||
state,
|
state,
|
||||||
owner_user_id,
|
owner_user_id,
|
||||||
@@ -1655,7 +1659,7 @@ async fn generate_puzzle_image_candidates(
|
|||||||
prompt: prompt.to_string(),
|
prompt: prompt.to_string(),
|
||||||
actual_prompt: Some(prompt.to_string()),
|
actual_prompt: Some(prompt.to_string()),
|
||||||
source_type: "generated".to_string(),
|
source_type: "generated".to_string(),
|
||||||
selected: index == 0,
|
selected: candidate_start_index == 0 && index == 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1729,6 +1733,7 @@ async fn build_local_next_puzzle_run(
|
|||||||
&draft.level_name,
|
&draft.level_name,
|
||||||
&draft.summary,
|
&draft.summary,
|
||||||
2,
|
2,
|
||||||
|
draft.candidates.len(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|message| {
|
.map_err(|message| {
|
||||||
|
|||||||
@@ -684,7 +684,7 @@ fn save_puzzle_generated_images_tx(
|
|||||||
if candidates.is_empty() {
|
if candidates.is_empty() {
|
||||||
return Err("拼图候选图不能为空".to_string());
|
return Err("拼图候选图不能为空".to_string());
|
||||||
}
|
}
|
||||||
draft.candidates = candidates;
|
append_generated_candidates(&mut draft, candidates);
|
||||||
draft.generation_status = "ready".to_string();
|
draft.generation_status = "ready".to_string();
|
||||||
if let Some(selected) = draft
|
if let Some(selected) = draft
|
||||||
.candidates
|
.candidates
|
||||||
@@ -1507,6 +1507,23 @@ fn increment_puzzle_profile_play_count(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn append_generated_candidates(
|
||||||
|
draft: &mut PuzzleResultDraft,
|
||||||
|
candidates: Vec<PuzzleGeneratedImageCandidate>,
|
||||||
|
) {
|
||||||
|
let has_selected_candidate = draft.candidates.iter().any(|entry| entry.selected);
|
||||||
|
// 再次生成图片是扩充候选池,不覆盖创作者已经看到或已经选择的候选图。
|
||||||
|
// 若已有正式选择,新追加候选图保持未选中,避免同一草稿出现多个 selected。
|
||||||
|
draft
|
||||||
|
.candidates
|
||||||
|
.extend(candidates.into_iter().map(|mut candidate| {
|
||||||
|
if has_selected_candidate {
|
||||||
|
candidate.selected = false;
|
||||||
|
}
|
||||||
|
candidate
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
fn list_published_puzzle_profiles(ctx: &TxContext) -> Result<Vec<PuzzleWorkProfile>, String> {
|
fn list_published_puzzle_profiles(ctx: &TxContext) -> Result<Vec<PuzzleWorkProfile>, String> {
|
||||||
ctx.db
|
ctx.db
|
||||||
.puzzle_work_profile()
|
.puzzle_work_profile()
|
||||||
@@ -1613,6 +1630,40 @@ mod tests {
|
|||||||
assert!(preview.publish_ready);
|
assert!(preview.publish_ready);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn puzzle_generated_images_are_appended_without_clearing_existing_candidates() {
|
||||||
|
let anchor_pack = infer_anchor_pack("蒸汽城市雨夜猫咪", Some("蒸汽城市雨夜猫咪"));
|
||||||
|
let mut draft = compile_result_draft(&anchor_pack, &[]);
|
||||||
|
draft.candidates = vec![PuzzleGeneratedImageCandidate {
|
||||||
|
candidate_id: "session-1-candidate-1".to_string(),
|
||||||
|
image_src: "/generated-puzzle-assets/session-1/old/cover.png".to_string(),
|
||||||
|
asset_id: "asset-old".to_string(),
|
||||||
|
prompt: "旧提示词".to_string(),
|
||||||
|
actual_prompt: Some("旧提示词".to_string()),
|
||||||
|
source_type: "generated".to_string(),
|
||||||
|
selected: true,
|
||||||
|
}];
|
||||||
|
|
||||||
|
append_generated_candidates(
|
||||||
|
&mut draft,
|
||||||
|
vec![PuzzleGeneratedImageCandidate {
|
||||||
|
candidate_id: "session-1-candidate-2".to_string(),
|
||||||
|
image_src: "/generated-puzzle-assets/session-1/new/cover.png".to_string(),
|
||||||
|
asset_id: "asset-new".to_string(),
|
||||||
|
prompt: "新提示词".to_string(),
|
||||||
|
actual_prompt: Some("新提示词".to_string()),
|
||||||
|
source_type: "generated".to_string(),
|
||||||
|
selected: true,
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(draft.candidates.len(), 2);
|
||||||
|
assert_eq!(draft.candidates[0].candidate_id, "session-1-candidate-1");
|
||||||
|
assert!(draft.candidates[0].selected);
|
||||||
|
assert_eq!(draft.candidates[1].candidate_id, "session-1-candidate-2");
|
||||||
|
assert!(!draft.candidates[1].selected);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn puzzle_recommendation_score_prefers_same_author_weight() {
|
fn puzzle_recommendation_score_prefers_same_author_weight() {
|
||||||
let left = PuzzleWorkProfile {
|
let left = PuzzleWorkProfile {
|
||||||
|
|||||||
Reference in New Issue
Block a user