This commit is contained in:
2026-04-24 17:59:48 +08:00
parent 929febb4fe
commit 6cb3efae61
55 changed files with 2373 additions and 435 deletions

View File

@@ -65,6 +65,32 @@ pub fn list_big_fish_works(
}
}
#[spacetimedb::procedure]
pub fn delete_big_fish_work(
ctx: &mut ProcedureContext,
input: BigFishWorkDeleteInput,
) -> BigFishWorksProcedureResult {
match ctx.try_with_tx(|tx| delete_big_fish_work_tx(tx, input.clone())) {
Ok(items) => match serde_json::to_string(&items) {
Ok(items_json) => BigFishWorksProcedureResult {
ok: true,
items_json: Some(items_json),
error_message: None,
},
Err(error) => BigFishWorksProcedureResult {
ok: false,
items_json: None,
error_message: Some(error.to_string()),
},
},
Err(message) => BigFishWorksProcedureResult {
ok: false,
items_json: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn submit_big_fish_message(
ctx: &mut ProcedureContext,
@@ -225,6 +251,69 @@ pub(crate) fn list_big_fish_works_tx(
Ok(items)
}
pub(crate) fn delete_big_fish_work_tx(
ctx: &ReducerContext,
input: BigFishWorkDeleteInput,
) -> Result<Vec<BigFishWorkSummarySnapshot>, String> {
validate_session_get_input(&BigFishSessionGetInput {
session_id: input.session_id.clone(),
owner_user_id: input.owner_user_id.clone(),
})
.map_err(|error| error.to_string())?;
let session = ctx
.db
.big_fish_creation_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "big_fish_creation_session 不存在".to_string())?;
// 删除作品时同步清理 Agent 消息、素材槽与运行快照,避免创作页消失后残留孤儿数据。
ctx.db
.big_fish_creation_session()
.session_id()
.delete(&session.session_id);
for message in ctx
.db
.big_fish_agent_message()
.iter()
.filter(|row| row.session_id == input.session_id)
.collect::<Vec<_>>()
{
ctx.db
.big_fish_agent_message()
.message_id()
.delete(&message.message_id);
}
for slot in ctx
.db
.big_fish_asset_slot()
.iter()
.filter(|row| row.session_id == input.session_id)
.collect::<Vec<_>>()
{
ctx.db.big_fish_asset_slot().slot_id().delete(&slot.slot_id);
}
for run in ctx
.db
.big_fish_runtime_run()
.iter()
.filter(|row| {
row.session_id == input.session_id && row.owner_user_id == input.owner_user_id
})
.collect::<Vec<_>>()
{
ctx.db.big_fish_runtime_run().run_id().delete(&run.run_id);
}
list_big_fish_works_tx(
ctx,
BigFishWorksListInput {
owner_user_id: input.owner_user_id,
},
)
}
pub(crate) fn submit_big_fish_message_tx(
ctx: &ReducerContext,
input: BigFishMessageSubmitInput,

View File

@@ -453,14 +453,22 @@ fn submit_custom_world_agent_message_tx(
{
return Err("custom_world_agent_message.message_id 已存在".to_string());
}
if ctx
if let Some(existing_operation) = ctx
.db
.custom_world_agent_operation()
.operation_id()
.find(&input.operation_id)
.is_some()
{
return Err("custom_world_agent_operation.operation_id 已存在".to_string());
let can_reuse_running_draft_operation = input.action.trim() == "draft_foundation"
&& existing_operation.session_id == input.session_id
&& existing_operation.operation_type == RpgAgentOperationType::DraftFoundation
&& matches!(
existing_operation.status,
RpgAgentOperationStatus::Queued | RpgAgentOperationStatus::Running
);
if !can_reuse_running_draft_operation {
return Err("custom_world_agent_operation.operation_id 已存在".to_string());
}
}
let submitted_at = Timestamp::from_micros_since_unix_epoch(input.submitted_at_micros);
@@ -829,6 +837,25 @@ pub fn list_custom_world_works(
}
}
#[spacetimedb::procedure]
pub fn delete_custom_world_agent_session(
ctx: &mut ProcedureContext,
input: CustomWorldAgentSessionGetInput,
) -> CustomWorldWorksListResult {
match ctx.try_with_tx(|tx| delete_custom_world_agent_session_tx(tx, input.clone())) {
Ok(items) => CustomWorldWorksListResult {
ok: true,
items,
error_message: None,
},
Err(message) => CustomWorldWorksListResult {
ok: false,
items: Vec::new(),
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn get_custom_world_agent_card_detail(
ctx: &mut ProcedureContext,
@@ -1531,6 +1558,73 @@ fn list_custom_world_work_snapshots(
Ok(items)
}
fn delete_custom_world_agent_session_tx(
ctx: &ReducerContext,
input: CustomWorldAgentSessionGetInput,
) -> Result<Vec<CustomWorldWorkSummarySnapshot>, String> {
validate_custom_world_agent_session_get_input(&input).map_err(|error| error.to_string())?;
let session = ctx
.db
.custom_world_agent_session()
.session_id()
.find(&input.session_id)
.filter(|row| row.owner_user_id == input.owner_user_id)
.ok_or_else(|| "custom_world_agent_session 不存在".to_string())?;
if session.stage == RpgAgentStage::Published {
return Err("已发布 RPG 作品请通过 profile 删除".to_string());
}
// 删除纯 Agent 草稿时同步清理消息、操作与草稿卡,避免作品列表消失后残留孤儿数据。
ctx.db
.custom_world_agent_session()
.session_id()
.delete(&session.session_id);
for message in ctx
.db
.custom_world_agent_message()
.iter()
.filter(|row| row.session_id == input.session_id)
.collect::<Vec<_>>()
{
ctx.db
.custom_world_agent_message()
.message_id()
.delete(&message.message_id);
}
for operation in ctx
.db
.custom_world_agent_operation()
.iter()
.filter(|row| row.session_id == input.session_id)
.collect::<Vec<_>>()
{
ctx.db
.custom_world_agent_operation()
.operation_id()
.delete(&operation.operation_id);
}
for card in ctx
.db
.custom_world_draft_card()
.iter()
.filter(|row| row.session_id == input.session_id)
.collect::<Vec<_>>()
{
ctx.db
.custom_world_draft_card()
.card_id()
.delete(&card.card_id);
}
list_custom_world_work_snapshots(
ctx,
CustomWorldWorksListInput {
owner_user_id: input.owner_user_id,
},
)
}
fn get_custom_world_agent_card_detail_tx(
ctx: &ReducerContext,
input: CustomWorldAgentCardDetailGetInput,
@@ -1601,6 +1695,156 @@ fn execute_custom_world_agent_action_tx(
}
}
fn execute_generate_entities_action(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
input: &CustomWorldAgentActionExecuteInput,
payload: &JsonMap<String, JsonValue>,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
ensure_refining_stage(session.stage, input.action.as_str())?;
let mut draft_profile = parse_optional_session_object(session.draft_profile_json.as_deref())
.ok_or_else(|| format!("{} requires an existing draft foundation", input.action))?;
// 结果页只消费服务端 resultPreview这里必须先写回草稿真相再刷新预览。
let (payload_key, profile_key, card_kind, operation_type, entity_label) =
resolve_generate_entities_target(input.action.as_str(), payload)?;
let generated_entities = payload
.get(payload_key)
.and_then(JsonValue::as_array)
.cloned()
.ok_or_else(|| format!("{} requires payload.{payload_key}", input.action))?;
if generated_entities.is_empty() {
return Err(format!("{} requires at least one generated entity", input.action));
}
let mut appended_entities = Vec::new();
for (index, entity) in generated_entities.into_iter().enumerate() {
let normalized_entity = ensure_generated_entity_id(entity, card_kind, index);
if normalized_entity.as_object().is_none() {
return Err(format!("{payload_key} entries must be objects"));
}
upsert_generated_entity_card(
ctx,
&session.session_id,
card_kind,
&normalized_entity,
input.submitted_at_micros,
)?;
appended_entities.push(normalized_entity);
}
let entries = draft_profile
.entry(profile_key.to_string())
.or_insert_with(|| JsonValue::Array(Vec::new()))
.as_array_mut()
.ok_or_else(|| format!("draftProfile.{profile_key} must be array"))?;
entries.extend(appended_entities.iter().cloned());
let gate = summarize_publish_gate_from_json(
&session.session_id,
session.stage,
Some(&draft_profile),
&parse_json_array_or_empty(&session.quality_findings_json),
);
let quality_findings = parse_json_array_or_empty(&session.quality_findings_json);
let next_session = rebuild_custom_world_agent_session_row(
session,
CustomWorldAgentSessionPatch {
draft_profile_json: Some(Some(serialize_json_value(&JsonValue::Object(
draft_profile.clone(),
))?)),
last_assistant_reply: Some(Some(format!(
"已新增 {}{},并刷新结果预览。",
appended_entities.len(),
entity_label,
))),
publish_gate_json: Some(Some(serialize_json_value(&publish_gate_to_json_value(
&gate,
))?)),
result_preview_json: Some(build_result_preview_json(
Some(&draft_profile),
&gate,
&quality_findings,
input.submitted_at_micros,
)?),
checkpoints_json: Some(append_checkpoint_json(
&session.checkpoints_json,
&build_session_checkpoint_value(
input.action.as_str(),
&format!("新增{}", entity_label),
session,
),
)?),
updated_at_micros: Some(input.submitted_at_micros),
..CustomWorldAgentSessionPatch::default()
},
)?;
replace_custom_world_agent_session(ctx, session, next_session);
append_custom_world_action_result_message(
ctx,
&session.session_id,
&input.operation_id,
&format!("已新增 {}{}", appended_entities.len(), entity_label),
input.submitted_at_micros,
);
let operation = build_and_insert_custom_world_operation(
ctx,
&input.operation_id,
&session.session_id,
operation_type,
"新增内容已写入",
&format!(
"{} 已追加到 draftProfile.{}resultPreview 已刷新。",
entity_label, profile_key,
),
input.submitted_at_micros,
);
Ok(build_custom_world_agent_operation_snapshot(&operation))
}
fn resolve_generate_entities_target(
action: &str,
payload: &JsonMap<String, JsonValue>,
) -> Result<(
&'static str,
&'static str,
RpgAgentDraftCardKind,
RpgAgentOperationType,
&'static str,
), String> {
match action {
"generate_characters" => {
let profile_key = match payload.get("roleType").and_then(JsonValue::as_str).map(str::trim) {
Some("playable") => "playableNpcs",
_ => "storyNpcs",
};
let entity_label = if profile_key == "playableNpcs" {
"可扮演角色"
} else {
"场景角色"
};
Ok((
"generatedCharacters",
profile_key,
RpgAgentDraftCardKind::Character,
RpgAgentOperationType::GenerateCharacters,
entity_label,
))
}
"generate_landmarks" => Ok((
"generatedLandmarks",
"landmarks",
RpgAgentDraftCardKind::Landmark,
RpgAgentOperationType::GenerateLandmarks,
"场景",
)),
other => Err(format!("custom world action `{other}` 当前尚未支持生成实体")),
}
}
fn execute_draft_foundation_action(
ctx: &ReducerContext,
session: &CustomWorldAgentSession,
@@ -1665,6 +1909,23 @@ fn execute_draft_foundation_action(
updated_at,
);
if let Some(existing_operation) = ctx
.db
.custom_world_agent_operation()
.operation_id()
.find(&input.operation_id)
{
if existing_operation.session_id != session.session_id
|| existing_operation.operation_type != RpgAgentOperationType::DraftFoundation
{
return Err("custom_world_agent_operation 与 draft_foundation 写回不匹配".to_string());
}
ctx.db
.custom_world_agent_operation()
.operation_id()
.delete(&input.operation_id);
}
let operation = build_and_insert_custom_world_operation(
ctx,
&input.operation_id,

View File

@@ -1497,7 +1497,8 @@ fn upsert_custom_world_agent_operation_progress_tx(
ctx: &ReducerContext,
input: CustomWorldAgentOperationProgressInput,
) -> Result<CustomWorldAgentOperationSnapshot, String> {
validate_custom_world_agent_operation_progress_input(&input).map_err(|error| error.to_string())?;
validate_custom_world_agent_operation_progress_input(&input)
.map_err(|error| error.to_string())?;
ctx.db
.custom_world_agent_session()
.session_id()
@@ -1529,18 +1530,20 @@ fn upsert_custom_world_agent_operation_progress_tx(
replace_custom_world_agent_operation(ctx, &current, next.clone());
next
} else {
ctx.db.custom_world_agent_operation().insert(CustomWorldAgentOperation {
operation_id: input.operation_id.clone(),
session_id: input.session_id.clone(),
operation_type: input.operation_type,
status: input.operation_status,
phase_label: input.phase_label.clone(),
phase_detail: input.phase_detail.clone(),
progress: input.operation_progress,
error_message: input.error_message.clone(),
created_at: timestamp,
updated_at: timestamp,
})
ctx.db
.custom_world_agent_operation()
.insert(CustomWorldAgentOperation {
operation_id: input.operation_id.clone(),
session_id: input.session_id.clone(),
operation_type: input.operation_type,
status: input.operation_status,
phase_label: input.phase_label.clone(),
phase_detail: input.phase_detail.clone(),
progress: input.operation_progress,
error_message: input.error_message.clone(),
created_at: timestamp,
updated_at: timestamp,
})
};
Ok(build_custom_world_agent_operation_snapshot(&operation))

View File

@@ -6,11 +6,12 @@ use module_puzzle::{
PuzzleGeneratedImagesSaveInput, PuzzlePublicationStatus, PuzzlePublishInput, PuzzleResultDraft,
PuzzleRunDragInput, PuzzleRunGetInput, PuzzleRunNextLevelInput, PuzzleRunProcedureResult,
PuzzleRunSnapshot, PuzzleRunStartInput, PuzzleRunSwapInput, PuzzleRuntimeLevelStatus,
PuzzleSelectCoverImageInput, PuzzleWorkGetInput, PuzzleWorkProcedureResult, PuzzleWorkProfile,
PuzzleWorkUpsertInput, PuzzleWorksListInput, PuzzleWorksProcedureResult,
apply_publish_overrides_to_draft, apply_selected_candidate, build_result_preview,
compile_result_draft, create_work_profile, infer_anchor_pack, normalize_theme_tags,
publish_work_profile, resolve_puzzle_grid_size, select_next_profile, start_run, swap_pieces,
PuzzleSelectCoverImageInput, PuzzleWorkDeleteInput, PuzzleWorkGetInput,
PuzzleWorkProcedureResult, PuzzleWorkProfile, PuzzleWorkUpsertInput, PuzzleWorksListInput,
PuzzleWorksProcedureResult, apply_publish_overrides_to_draft, apply_selected_candidate,
build_result_preview, compile_result_draft, create_work_profile, infer_anchor_pack,
normalize_theme_tags, publish_work_profile, resolve_puzzle_grid_size, select_next_profile,
start_run, swap_pieces,
};
use serde_json::from_str as json_from_str;
use serde_json::to_string as json_to_string;
@@ -310,6 +311,25 @@ pub fn update_puzzle_work(
}
}
#[spacetimedb::procedure]
pub fn delete_puzzle_work(
ctx: &mut ProcedureContext,
input: PuzzleWorkDeleteInput,
) -> PuzzleWorksProcedureResult {
match ctx.try_with_tx(|tx| delete_puzzle_work_tx(tx, input.clone())) {
Ok(items) => PuzzleWorksProcedureResult {
ok: true,
items_json: Some(serialize_json(&items)),
error_message: None,
},
Err(message) => PuzzleWorksProcedureResult {
ok: false,
items_json: None,
error_message: Some(message),
},
}
}
#[spacetimedb::procedure]
pub fn list_puzzle_gallery(ctx: &mut ProcedureContext) -> PuzzleWorksProcedureResult {
match ctx.try_with_tx(|tx| list_puzzle_gallery_tx(tx)) {
@@ -890,6 +910,65 @@ fn update_puzzle_work_tx(
)
}
fn delete_puzzle_work_tx(
ctx: &TxContext,
input: PuzzleWorkDeleteInput,
) -> Result<Vec<PuzzleWorkProfile>, String> {
let row = ctx
.db
.puzzle_work_profile()
.profile_id()
.find(&input.profile_id)
.ok_or_else(|| "拼图作品不存在".to_string())?;
if row.owner_user_id != input.owner_user_id {
return Err("无权删除该拼图作品".to_string());
}
// 删除作品时同步清理来源 Agent 会话和运行快照,保持创作页列表与运行态数据一致。
ctx.db
.puzzle_work_profile()
.profile_id()
.delete(&row.profile_id);
if let Some(session_id) = row.source_session_id.as_ref() {
if let Some(session) = ctx.db.puzzle_agent_session().session_id().find(session_id) {
ctx.db
.puzzle_agent_session()
.session_id()
.delete(&session.session_id);
}
for message in ctx
.db
.puzzle_agent_message()
.iter()
.filter(|message| message.session_id == *session_id)
.collect::<Vec<_>>()
{
ctx.db
.puzzle_agent_message()
.message_id()
.delete(&message.message_id);
}
}
for run in ctx
.db
.puzzle_runtime_run()
.iter()
.filter(|run| {
run.owner_user_id == input.owner_user_id && run.entry_profile_id == input.profile_id
})
.collect::<Vec<_>>()
{
ctx.db.puzzle_runtime_run().run_id().delete(&run.run_id);
}
list_puzzle_works_tx(
ctx,
PuzzleWorksListInput {
owner_user_id: input.owner_user_id,
},
)
}
fn list_puzzle_gallery_tx(ctx: &TxContext) -> Result<Vec<PuzzleWorkProfile>, String> {
let mut items = ctx
.db