Compare commits

...

2 Commits

55 changed files with 2373 additions and 435 deletions

View File

@@ -0,0 +1,20 @@
# 创作页作品删除入口设计 2026-04-24
## 背景
创作页作品卡曾把删除作为底部大按钮展示,并且只对带 `profileId` 的 RPG 作品传入删除回调,导致大鱼、拼图、以及部分草稿作品没有删除入口。用户预期是:删除不是主操作,放在卡片右上角的小 icon 即可;任何作品都应该能删除。
## 落地规则
- 作品卡右上角固定展示删除 icon底部主操作区只保留继续创作、查看详情、体验等正向操作。
- 删除入口不按发布状态隐藏:草稿、已发布作品均可删除。
- 删除入口不按玩法类型隐藏RPG、大鱼吃小鱼、拼图作品均应在创作页可删除。
- 点击删除前保留浏览器确认弹窗,避免误触;删除中仅禁用当前作品卡的删除 icon。
- 删除成功后刷新或替换对应玩法的作品列表,确保卡片立即消失。
## 工程边界
- 前端只负责表现和触发删除,实际删除由 `server-rs` API 与 SpacetimeDB 模块过程完成。
- 大鱼作品按 `sourceSessionId` 删除创作 session并同步清理消息、素材槽和运行快照。
- 拼图作品按 `profileId` 删除作品 profile并同步清理来源 Agent session、消息和入口运行快照。
- RPG 已发布/持久草稿按 `profileId` 走既有自定义世界删除链路;纯 Agent session 草稿按 `sessionId` 走 owner-only session 删除过程,并清理消息、操作与草稿卡。

View File

@@ -0,0 +1,27 @@
# PC 端世界生成与草稿页布局优化说明 2026-04-24
## 目标
在移动端现有布局不变的前提下,只优化 PC 端世界生成页与世界草稿页的信息组织,让页面更紧凑、更有层次,并保留全部已有功能入口。
## 范围
- 世界生成页:`src/components/CustomWorldGenerationView.tsx`
- 世界草稿页 / 作品页:`src/components/custom-world-home/CustomWorldCreationHub.tsx`
- 新建作品入口:`src/components/custom-world-home/CustomWorldCreationStartCard.tsx`
- 作品卡片:`src/components/custom-world-home/CustomWorldWorkCard.tsx`
- 筛选标签:`src/components/custom-world-home/CustomWorldWorkTabs.tsx`
## PC 端落地规则
1. 移动端默认类名保持原布局语义,只通过 `lg:` / `xl:` 断点追加 PC 布局。
2. 世界生成页在 PC 端改为左右双栏:左侧突出进度与阶段,右侧承载玩家设定 / 结构化锚点,减少纵向滚动。
3. 世界草稿页在 PC 端将“新建作品”和“作品列表”分区强化:顶部入口更紧凑,作品卡片网格密度提升。
4. 不新增规则说明文案,不改变按钮、筛选、删除、体验、进入等功能行为。
5. 中文文本只做必要保留,不因为布局调整改写已有中文内容。
## 视觉策略
- PC 端使用更明确的 `xl:grid`、固定信息侧栏和更小间距,让主内容首屏承载更多信息。
- 卡片在 PC 端降低无效高度,操作按钮与状态信息尽量同行展示。
- 保留现有 `platform-*` 视觉体系,避免引入新的 UI 系统。

View File

@@ -0,0 +1,17 @@
# PC 世界档案草稿编辑页布局修正 2026-04-24
## 背景
用户反馈 PC 端世界草稿页没有明显变化。复核截图后确认实际页面是世界档案草稿编辑页中的实体目录,而不是创作首页作品列表。
## 本次修正范围
- `src/components/CustomWorldEntityCatalog.tsx`
## 落地要求
1. 移动端仍保持原来的单列滚动、顶部标签与搜索结构。
2. PC 端把超宽单列实体列表改成卡片网格,减少横向空白,提高信息密度。
3. PC 端顶部世界标题、标签、搜索和操作按钮更紧凑,避免首屏被空白标题区占用。
4. 功能不变:搜索、切换标签、新增、批量删除、选择、编辑、发布入口均保持原有行为。
5. 不新增说明类文案,不改写已有中文内容。

View File

@@ -0,0 +1,30 @@
# 世界草稿发布面板与测试入口设计 2026-04-24
## 目标
世界草稿页底部不再只有“发布并进入世界”一个动作,而是拆成两个明确入口:
1. 作品测试:跳过发布阻断项检查,直接进入当前草稿游戏体验。
2. 发布:打开发布面板,在面板内集中处理发布阻断项与封面设置,满足条件后发布到广场。
## 页面范围
- `src/components/rpg-creation-result/RpgCreationResultActionBar.tsx`
- `src/components/rpg-creation-result/RpgCreationResultViewImpl.tsx`
- `src/components/CustomWorldEntityCatalog.tsx`
- `src/components/rpg-entry/useRpgCreationEnterWorld.ts`
- `src/components/platform-entry/PlatformEntryFlowShellImpl.tsx`
## 交互规则
1. 世界草稿页右下角显示“作品测试”和“发布”两个按钮。
2. “作品测试”只同步当前草稿结果并进入游戏,不触发发布阻断项检查,也不执行发布动作。
3. “发布”打开独立发布面板,不在当前页面下方展开内容。
4. 发布面板显示当前阻断项;没有阻断项时允许执行发布。
5. 发布面板显示封面预览与封面状态,并提供“设置封面”入口。
6. 封面生成、封面上传与封面预览从世界档案页迁移到发布面板;世界档案页不再展示作品封面模块。
7. 移动端仍使用底部弹层式面板PC 端使用居中 modal。
## 发布后行为
发布成功后刷新本地结果档案,并进入正式世界;作品由既有 `publish_world` 流程同步到作品库 / 广场。

View File

@@ -96,6 +96,7 @@
7. 不把“点击配置”实现成在当前卡片下面继续展开大段内容。
8. 不重写现有高好感委托链路,只在本次规则下明确它什么时候还能触发。
9. 不在草稿生成阶段默认补动作、待机、攻击、跑动或技能动作素材。
10. RPG 世界草稿的可扮演角色、场景角色与场景列表项不再直接展示“生成资产 / 生成场景图”按钮;资产生成由草稿生成链路或后续专门工坊入口承接,列表卡片只保留浏览、编辑、选择和删除等核心操作。
---

View File

@@ -0,0 +1,54 @@
# 自定义世界资产 Prompt 与默认描述配置说明2026-04-24
## 1. 目标
本说明记录生成世界草稿时,角色形象图像、角色动作视频、每一幕场景背景图像三类资产的默认描述与正式模型 prompt 的配置位置,避免后续继续误改旧 `server-node` 链路。
## 2. 世界草稿默认描述字段
生成世界草稿时,后端会要求模型在角色与幕级剧情结构阶段直接产出资产默认描述字段:
- 角色:`visualDescription`,用于打开角色形象图像生成面板时默认填入角色形象描述框。
- 角色:`actionDescription`,用于打开角色动作视频生成面板时默认填入各动作描述框;当前每个动作会从同一角色默认动作描述起步,用户切换动作后可分别编辑并缓存。
- 每一幕:`sceneChapterBlueprints[*].acts[*].backgroundPromptText`,用于打开该幕背景图像生成面板时默认填入场景描述框。
- 场景:`visualDescription` 只作为旧场景图或没有幕级描述时的兜底,不再从角色 AI 形象生成面板维护场景背景描述。
草稿生成契约位置:
- `server-rs/crates/api-server/src/custom_world_foundation_draft.rs`
- `build_custom_world_role_outline_batch_prompt`
- `build_custom_world_landmark_seed_batch_prompt`
- `build_foundation_draft_user_prompt`
- `normalize_scene_act_blueprint`
前端默认框映射位置:
- `src/prompts/customWorldRolePromptDefaults.ts`
- `visualPromptText` 优先取 `role.visualDescription`
- `animationPromptText` 优先取 `role.actionDescription`
- `src/components/rpg-creation-asset-studio/RpgCreationRoleAssetStudioModalImpl.tsx`
- 角色形象与动作工坊初始化默认文本。
- `animationPromptTextByKey` 负责分动作保存动作描述。
- 当角色本身已有 `visualDescription/actionDescription` 时,必须优先使用这批世界草稿新生成字段,不能让旧 workflow cache 覆盖当前草稿默认文本。
- `src/components/rpg-creation-editor/RpgCreationEntityEditorShared.tsx`
- 幕背景图像生成弹窗优先使用 `act.backgroundPromptText`
- 普通场景图像生成弹窗仍可使用 `landmark.visualDescription` 兜底。
## 3. 正式模型 Prompt 配置
正式生成图片或视频时,不直接使用默认描述字段作为完整 prompt而是在 `server-rs` 继续编译:
- 角色主图:`server-rs/crates/api-server/src/custom_world_asset_prompts.rs`
- `build_character_visual_prompt`
- 内部使用 `build_master_prompt`
- 角色动作视频:`server-rs/crates/api-server/src/custom_world_asset_prompts.rs`
- `build_character_animation_prompt`
- 图生视频分支使用 `build_video_action_prompt`
- 场景背景图:`server-rs/crates/api-server/src/custom_world_ai.rs`
- `build_custom_world_scene_image_prompt`
## 4. 当前约束
- 不再把 `server-node/src/prompts/characterAssetPrompts.ts` 作为主链修改目标。
- 默认描述字段必须由世界草稿生成阶段写入,前端只负责把字段填入输入框并允许用户编辑。
- UI 不默认展示规则解释文案,正式约束只进入后端 prompt。

View File

@@ -38,3 +38,16 @@ LLM 扩展提示词为了草稿卡片简洁,只要求返回角色的 `publicMa
- `generate_landmarks` 的 payload 中新增场景必须包含非空 `description`,并能把 `characterIds` 落为 `sceneNpcIds`
- 结果页继续只消费 `resultPreview.preview`,不新增前端本地编译分支。
- 结果页点击新增实体后,如果服务端没有回传新增内容,必须展示错误提示。
## 2026-04-24 追加:可扮演角色结果页空刷新修复
新增可扮演角色报“生成请求已完成,但结果页未收到新增内容”的根因是:`api-server` 已经把 LLM 生成结果注入 `generatedCharacters`,但 `spacetime-module` 缺少 `generate_characters / generate_landmarks` 的真实落库 executoraction 会进入分派却无法把新增内容写入 `draft_profile_json``result_preview_json`
本次补齐 SpacetimeDB module executor
1. `generate_characters(roleType=playable)` 写入 `draftProfile.playableNpcs`
2. `generate_characters(roleType=story)` 写入 `draftProfile.storyNpcs`
3. `generate_landmarks` 写入 `draftProfile.landmarks`
4. 每个新增实体同步生成 draft card并刷新 `publish_gate_json / result_preview_json / checkpoints_json / operation / message`
结果页仍只消费服务端 `resultPreview.preview`;前端不会本地伪造新增角色。

View File

@@ -13,6 +13,18 @@
目标是把旧 Node 写入本地 `public/generated-character-drafts/*/workflow-cache.json` 的临时缓存,切到 OSS JSON 草稿对象,继续保持前端当前可消费 contract。
## 1.1 2026-04-24 修订:按作品隔离缓存
世界草稿生成后的首个场景角色经常使用 `hero / npc-1` 等稳定角色 ID。若缓存对象键只包含 `characterId`,不同作品打开同名角色时会复用上一部作品的 `visualPromptText / imageSrc / visualDrafts`导致“AI 角色生成的形象描述默认文本”和当前剧本无关。
本修订冻结以下口径:
1. 前端在角色资产工坊读写缓存时必须传入 `cacheScopeId`,默认取当前作品 `profile.id`
2. Rust 保存接口接受可选 `cacheScopeId`,并写回缓存 JSON便于读取时校验归属。
3. 新缓存对象键改为 `generated-character-drafts/{cacheScopeSegment}/{characterSegment}/workflow-cache/workflow-cache.json`
4. 未带 `cacheScopeId` 的旧请求仍走历史对象键,避免破坏旧工具或历史兼容入口。
5. 新前端不会回退读取旧无作品维度缓存,避免把跨作品旧缓存再次带入新世界草稿。
## 2. 当前前提
当前仓库已经具备以下能力:
@@ -63,15 +75,16 @@
请求结构继续保持前端当前字段:
1. `characterId`
2. `visualPromptText`
3. `animationPromptText`
4. `visualDrafts`
5. `selectedVisualDraftId`
6. `selectedAnimation`
7. `imageSrc`
8. `generatedVisualAssetId`
9. `generatedAnimationSetId`
10. `animationMap`
2. `cacheScopeId`:可选;当前自定义世界作品写入 `profile.id`
3. `visualPromptText`
4. `animationPromptText`
5. `visualDrafts`
6. `selectedVisualDraftId`
7. `selectedAnimation`
8. `imageSrc`
9. `generatedVisualAssetId`
10. `generatedAnimationSetId`
11. `animationMap`
返回结构继续保持:
@@ -83,28 +96,36 @@
缓存 JSON 固定写入:
新前端写入:
`generated-character-drafts/{cacheScopeSegment}/{characterSegment}/workflow-cache/workflow-cache.json`
旧兼容入口未提供 `cacheScopeId` 时继续写入:
`generated-character-drafts/{characterSegment}/workflow-cache/workflow-cache.json`
其中:
1. `characterSegment` 来自 `characterId` 的安全路径片段
2. 文件名固定为 `workflow-cache.json`
3. content type 固定为 `application/json; charset=utf-8`
1. `cacheScopeSegment` 来自 `cacheScopeId` 的安全路径片段,通常等于作品 `profile.id`
2. `characterSegment` 来自 `characterId` 的安全路径片段
3. 文件名固定为 `workflow-cache.json`
4. content type 固定为 `application/json; charset=utf-8`
## 6. 字段归一化规则
保存接口固定执行以下归一化:
1. `characterId` 必填trim 后不能为空
2. `visualPromptText` 最长保留 280 字
3. `animationPromptText` 最长保留 280 字
4. `visualDrafts` 只保留有 `imageSrc` 的候选
5. `visualDrafts[].width` 默认 `1024`
6. `visualDrafts[].height` 默认 `1536`
7. `selectedAnimation` 默认 `idle`
8. `imageSrc / generatedVisualAssetId / generatedAnimationSetId` 不序列化
9. 非对象 `animationMap` 归一化为 `null`
10. `updatedAt` 由 Rust 服务端生成 UTC 时间
2. `cacheScopeId` trim 后为空则视为旧兼容缓存,不写新作品目录
3. `visualPromptText` 最长保留 280 字
4. `animationPromptText` 最长保留 280 字
5. `visualDrafts` 只保留有 `imageSrc` 的候选
6. `visualDrafts[].width` 默认 `1024`
7. `visualDrafts[].height` 默认 `1536`
8. `selectedAnimation` 默认 `idle`
9. `imageSrc / generatedVisualAssetId / generatedAnimationSetId` 不序列化
10. 非对象 `animationMap` 归一化为 `null`
11. `updatedAt` 由 Rust 服务端生成 UTC 时间
## 7. 元数据规范
@@ -115,6 +136,7 @@
3. `entity_kind = character`
4. `entity_id = characterId`
5. `slot = workflow_cache`
6. `cache_scope_id = cacheScopeId`,仅新作品维度缓存写入
说明:

View File

@@ -103,6 +103,8 @@ src/services/creation-agent/
3. 补全剩余设定话术。
4. 生成结果页 action`draft_foundation`
`draft_foundation` 的生成进度必须展示并真实执行后端后台任务阶段:世界骨架、角色/场景结构、底稿编译、角色主形象生成、幕背景图生成、草稿卡编译、结果页写回。角色主形象与幕背景图不能只作为 UI 进度占位;后台任务必须调用素材生成链路,将角色 `imageSrc / generatedVisualAssetId` 与场景幕 `backgroundImageSrc / backgroundAssetId` 写回 `draftProfile` 后,才能继续进入草稿卡编译与结果页写回。
### 4.2 大鱼吃小鱼
保留差异:

View File

@@ -4,7 +4,7 @@ use axum::{
extract::Extension,
http::Request,
middleware,
routing::{get, post},
routing::{delete, get, post},
};
use tower_http::{
classify::ServerErrorsFailureClass,
@@ -33,9 +33,9 @@ use crate::{
auth_public_user::{get_public_user_by_code, get_public_user_by_id},
auth_sessions::auth_sessions,
big_fish::{
create_big_fish_session, execute_big_fish_action, get_big_fish_run, get_big_fish_session,
get_big_fish_works, start_big_fish_run, stream_big_fish_message, submit_big_fish_input,
submit_big_fish_message,
create_big_fish_session, delete_big_fish_work, execute_big_fish_action, get_big_fish_run,
get_big_fish_session, get_big_fish_works, start_big_fish_run, stream_big_fish_message,
submit_big_fish_input, submit_big_fish_message,
},
character_animation_assets::{
generate_character_animation, get_character_animation_job, get_character_workflow_cache,
@@ -46,8 +46,9 @@ use crate::{
generate_character_visual, get_character_visual_job, publish_character_visual,
},
custom_world::{
create_custom_world_agent_session, delete_custom_world_library_profile,
execute_custom_world_agent_action, get_custom_world_agent_card_detail,
create_custom_world_agent_session, delete_custom_world_agent_session,
delete_custom_world_library_profile, execute_custom_world_agent_action,
get_custom_world_agent_card_detail,
get_custom_world_agent_operation, get_custom_world_agent_session,
get_custom_world_gallery_detail, get_custom_world_gallery_detail_by_code,
get_custom_world_library, get_custom_world_library_detail, get_custom_world_works,
@@ -76,10 +77,10 @@ use crate::{
password_management::{change_password, reset_password},
phone_auth::{phone_login, send_phone_code},
puzzle::{
advance_puzzle_next_level, create_puzzle_agent_session, drag_puzzle_piece_or_group,
execute_puzzle_agent_action, get_puzzle_agent_session, get_puzzle_gallery_detail,
get_puzzle_run, get_puzzle_work_detail, get_puzzle_works, list_puzzle_gallery,
put_puzzle_work, start_puzzle_run, stream_puzzle_agent_message,
advance_puzzle_next_level, create_puzzle_agent_session, delete_puzzle_work,
drag_puzzle_piece_or_group, execute_puzzle_agent_action, get_puzzle_agent_session,
get_puzzle_gallery_detail, get_puzzle_run, get_puzzle_work_detail, get_puzzle_works,
list_puzzle_gallery, put_puzzle_work, start_puzzle_run, stream_puzzle_agent_message,
submit_puzzle_agent_message, swap_puzzle_pieces,
},
refresh_session::refresh_session,
@@ -442,10 +443,12 @@ pub fn build_router(state: AppState) -> Router {
)
.route(
"/api/runtime/custom-world/agent/sessions/{session_id}",
get(get_custom_world_agent_session).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
get(get_custom_world_agent_session)
.delete(delete_custom_world_agent_session)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/custom-world/works",
@@ -531,6 +534,13 @@ pub fn build_router(state: AppState) -> Router {
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/works/{session_id}",
delete(delete_big_fish_work).route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,
)),
)
.route(
"/api/runtime/big-fish/sessions/{session_id}/runs",
post(start_big_fish_run).route_layer(middleware::from_fn_with_state(
@@ -598,6 +608,7 @@ pub fn build_router(state: AppState) -> Router {
"/api/runtime/puzzle/works/{profile_id}",
get(get_puzzle_work_detail)
.put(put_puzzle_work)
.delete(delete_puzzle_work)
.route_layer(middleware::from_fn_with_state(
state.clone(),
require_bearer_auth,

View File

@@ -134,6 +134,33 @@ pub async fn get_big_fish_works(
))
}
pub async fn delete_big_fish_work(
State(state): State<AppState>,
Path(session_id): Path<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &session_id, "sessionId")?;
let items = state
.spacetime_client()
.delete_big_fish_work(session_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
big_fish_error_response(&request_context, map_big_fish_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
BigFishWorksResponse {
items: items
.into_iter()
.map(map_big_fish_work_summary_response)
.collect(),
},
))
}
pub async fn submit_big_fish_message(
State(state): State<AppState>,
Path(session_id): Path<String>,

View File

@@ -9,7 +9,7 @@ use std::{
use axum::{
Json,
extract::{Extension, Path as AxumPath, State, rejection::JsonRejection},
extract::{Extension, Path as AxumPath, Query, State, rejection::JsonRejection},
http::StatusCode,
response::Response,
};
@@ -29,6 +29,7 @@ use platform_oss::{
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
OssSignedGetObjectUrlRequest,
};
use serde::Deserialize;
use serde_json::{Value, json};
use shared_contracts::assets::{
CharacterAnimationDraftPayload, CharacterAnimationGenerateRequest,
@@ -58,6 +59,13 @@ const CHARACTER_ANIMATION_ASSET_KIND: &str = "character_animation";
const CHARACTER_ANIMATION_REFERENCE_ASSET_KIND: &str = "character_animation_reference_video";
const CHARACTER_WORKFLOW_CACHE_ASSET_KIND: &str = "character_workflow_cache";
const CHARACTER_ANIMATION_ENTITY_KIND: &str = "character";
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CharacterWorkflowCacheQuery {
#[serde(default)]
pub cache_scope_id: Option<String>,
}
const CHARACTER_ANIMATION_SLOT: &str = "animation_set";
const CHARACTER_ANIMATION_REFERENCE_SLOT: &str = "animation_reference_video";
const CHARACTER_WORKFLOW_CACHE_SLOT: &str = "workflow_cache";
@@ -573,6 +581,7 @@ pub async fn get_character_workflow_cache(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
AxumPath(character_id): AxumPath<String>,
Query(query): Query<CharacterWorkflowCacheQuery>,
) -> Result<Json<Value>, Response> {
let character_id = normalize_required_text(character_id.as_str(), "");
if character_id.is_empty() {
@@ -585,7 +594,8 @@ pub async fn get_character_workflow_cache(
));
}
let cache = load_workflow_cache(&state, character_id.as_str())
let cache_scope_id = trim_optional_text(query.cache_scope_id.as_deref());
let cache = load_workflow_cache(&state, character_id.as_str(), cache_scope_id.as_deref())
.await
.map_err(|error| character_animation_error_response(&request_context, error))?;
@@ -1530,9 +1540,10 @@ async fn put_imported_video_object(
async fn load_workflow_cache(
state: &AppState,
character_id: &str,
cache_scope_id: Option<&str>,
) -> Result<Option<CharacterWorkflowCachePayload>, AppError> {
let oss_client = require_oss_client(state)?;
let object_key = workflow_cache_object_key(character_id);
let object_key = workflow_cache_object_key(character_id, cache_scope_id);
let signed = match oss_client.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key,
expire_seconds: Some(60),
@@ -1571,7 +1582,7 @@ async fn load_workflow_cache(
}))
})?;
if cache.character_id == character_id {
if cache.character_id == character_id && cache.cache_scope_id.as_deref() == cache_scope_id {
Ok(Some(cache))
} else {
Ok(None)
@@ -1595,14 +1606,15 @@ async fn save_workflow_cache(
&reqwest::Client::new(),
OssPutObjectRequest {
prefix: LegacyAssetPrefix::CharacterDrafts,
path_segments: vec![
sanitize_storage_segment(cache.character_id.as_str(), "character"),
"workflow-cache".to_string(),
],
path_segments: workflow_cache_path_segments(&cache),
file_name: "workflow-cache.json".to_string(),
content_type: Some("application/json; charset=utf-8".to_string()),
access: OssObjectAccess::Private,
metadata: build_workflow_cache_metadata("asset-tool", cache.character_id.as_str()),
metadata: build_workflow_cache_metadata(
"asset-tool",
cache.character_id.as_str(),
cache.cache_scope_id.as_deref(),
),
body,
},
)
@@ -1616,8 +1628,10 @@ fn normalize_workflow_cache_payload(
updated_at: String,
) -> CharacterWorkflowCachePayload {
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
let cache_scope_id = trim_optional_text(payload.cache_scope_id.as_deref());
CharacterWorkflowCachePayload {
character_id: character_id.clone(),
cache_scope_id,
visual_prompt_text: clamp_prompt_seed_text(payload.visual_prompt_text.as_deref()),
animation_prompt_text: clamp_prompt_seed_text(payload.animation_prompt_text.as_deref()),
visual_drafts: normalize_visual_drafts(character_id.as_str(), payload.visual_drafts),
@@ -1661,11 +1675,32 @@ fn normalize_visual_drafts(
.collect()
}
fn workflow_cache_object_key(character_id: &str) -> String {
format!(
"generated-character-drafts/{}/workflow-cache/workflow-cache.json",
sanitize_storage_segment(character_id, "character")
)
fn workflow_cache_path_segments(cache: &CharacterWorkflowCachePayload) -> Vec<String> {
let character_segment = sanitize_storage_segment(cache.character_id.as_str(), "character");
if let Some(cache_scope_id) = cache.cache_scope_id.as_deref() {
vec![
sanitize_storage_segment(cache_scope_id, "world"),
character_segment,
"workflow-cache".to_string(),
]
} else {
vec![character_segment, "workflow-cache".to_string()]
}
}
fn workflow_cache_object_key(character_id: &str, cache_scope_id: Option<&str>) -> String {
if let Some(cache_scope_id) = cache_scope_id.and_then(|value| trim_optional_text(Some(value))) {
format!(
"generated-character-drafts/{}/{}/workflow-cache/workflow-cache.json",
sanitize_storage_segment(cache_scope_id.as_str(), "world"),
sanitize_storage_segment(character_id, "character")
)
} else {
format!(
"generated-character-drafts/{}/workflow-cache/workflow-cache.json",
sanitize_storage_segment(character_id, "character")
)
}
}
fn build_character_animation_job_payload(task: AiTaskSnapshot) -> CharacterAssetJobStatusPayload {
@@ -2460,8 +2495,9 @@ fn build_asset_metadata(
fn build_workflow_cache_metadata(
owner_user_id: &str,
character_id: &str,
cache_scope_id: Option<&str>,
) -> BTreeMap<String, String> {
BTreeMap::from([
let mut metadata = BTreeMap::from([
(
"asset_kind".to_string(),
CHARACTER_WORKFLOW_CACHE_ASSET_KIND.to_string(),
@@ -2476,7 +2512,11 @@ fn build_workflow_cache_metadata(
"slot".to_string(),
CHARACTER_WORKFLOW_CACHE_SLOT.to_string(),
),
])
]);
if let Some(cache_scope_id) = cache_scope_id.and_then(|value| trim_optional_text(Some(value))) {
metadata.insert("cache_scope_id".to_string(), cache_scope_id);
}
metadata
}
fn require_oss_client(state: &AppState) -> Result<&platform_oss::OssClient, AppError> {
@@ -3311,6 +3351,7 @@ mod tests {
let cache = normalize_workflow_cache_payload(
CharacterWorkflowCacheSaveRequest {
character_id: "hero".to_string(),
cache_scope_id: None,
visual_prompt_text: Some("主形象".to_string()),
animation_prompt_text: Some("待机".to_string()),
visual_drafts: vec![CharacterVisualDraftPayload {
@@ -3341,11 +3382,19 @@ mod tests {
#[test]
fn workflow_cache_object_key_uses_character_drafts_prefix() {
assert_eq!(
workflow_cache_object_key("Hero 01"),
workflow_cache_object_key("Hero 01", None),
"generated-character-drafts/hero-01/workflow-cache/workflow-cache.json"
);
}
#[test]
fn workflow_cache_object_key_can_scope_by_world() {
assert_eq!(
workflow_cache_object_key("Hero 01", Some("World 99")),
"generated-character-drafts/world-99/hero-01/workflow-cache/workflow-cache.json"
);
}
#[test]
fn build_animation_generate_result_payload_keeps_image_sequence_shape() {
let payload = build_animation_generate_result_payload(&CharacterAnimationGeneratedDraft {

View File

@@ -48,6 +48,12 @@ const CHARACTER_VISUAL_ENTITY_KIND: &str = "character";
const CHARACTER_VISUAL_SLOT: &str = "primary_visual";
const CHARACTER_VISUAL_TASK_POLL_INTERVAL_MS: u64 = 2_500;
#[derive(Clone, Debug)]
pub(crate) struct GeneratedCharacterPrimaryVisual {
pub image_src: String,
pub asset_id: String,
}
pub async fn generate_character_visual(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
@@ -277,6 +283,90 @@ pub async fn generate_character_visual(
))
}
pub(crate) async fn generate_character_primary_visual_for_profile(
state: &AppState,
owner_user_id: &str,
character_id: &str,
prompt_text: &str,
character_brief_text: Option<&str>,
) -> Result<GeneratedCharacterPrimaryVisual, AppError> {
let payload = CharacterVisualGenerateRequest {
character_id: character_id.to_string(),
source_mode: shared_contracts::assets::CharacterVisualSourceMode::TextToImage,
prompt_text: prompt_text.to_string(),
character_brief_text: character_brief_text.map(ToOwned::to_owned),
reference_image_data_urls: Vec::new(),
candidate_count: 1,
image_model: CHARACTER_VISUAL_MODEL.to_string(),
size: "1024*1024".to_string(),
};
let task_id = generate_ai_task_id(current_utc_micros());
let prompt = build_character_visual_prompt(
payload.prompt_text.as_str(),
payload.character_brief_text.as_deref(),
);
let character_id = normalize_required_text(payload.character_id.as_str(), "character");
let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL);
let size = normalize_required_text(payload.size.as_str(), "1024*1024");
create_visual_task(
state,
&task_id,
owner_user_id,
&character_id,
&model,
&prompt,
)?;
let settings = require_dashscope_settings(state)?;
let http_client = build_dashscope_http_client(&settings)?;
state
.ai_task_service()
.start_task(task_id.as_str(), current_utc_micros())
.map_err(map_ai_task_error)?;
let generated = create_character_visual_generation(
&http_client,
&settings,
model.as_str(),
prompt.as_str(),
size.as_str(),
1,
&[],
)
.await?;
let drafts = persist_visual_drafts(
state,
owner_user_id,
&character_id,
&task_id,
generated.images,
size.as_str(),
)
.await?;
let draft = drafts.into_iter().next().ok_or_else(|| {
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
"provider": "character-visual",
"message": "角色主形象生成没有返回候选图。",
}))
})?;
let asset_id = format!("visual-{character_id}-{task_id}");
let image_src = persist_published_visual(
state,
owner_user_id,
&character_id,
asset_id.as_str(),
draft.image_src.as_str(),
Some(prompt.as_str()),
)
.await?;
state
.ai_task_service()
.complete_task(task_id.as_str(), current_utc_micros())
.map_err(map_ai_task_error)?;
Ok(GeneratedCharacterPrimaryVisual {
image_src,
asset_id,
})
}
pub async fn get_character_visual_job(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,

View File

@@ -43,11 +43,13 @@ use tracing::info;
use crate::{
api_response::json_success_body,
auth::AuthenticatedAccessToken,
character_visual_assets::generate_character_primary_visual_for_profile,
custom_world_agent_entities::generate_custom_world_agent_entities,
custom_world_agent_turn::{
CustomWorldAgentTurnRequest, build_failed_finalize_record_input,
build_finalize_record_input, run_custom_world_agent_turn,
},
custom_world_ai::generate_custom_world_scene_image_for_profile,
custom_world_foundation_draft::{
DraftFoundationPayloadError, build_draft_foundation_action_payload_json,
generate_custom_world_foundation_draft,
@@ -504,6 +506,36 @@ pub async fn get_custom_world_works(
))
}
pub async fn delete_custom_world_agent_session(
State(state): State<AppState>,
AxumPath(session_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(&request_context, &session_id, "sessionId")?;
let items = state
.spacetime_client()
.delete_custom_world_agent_session(
session_id,
authenticated.claims().user_id().to_string(),
)
.await
.map_err(|error| {
custom_world_error_response(&request_context, map_custom_world_client_error(error))
})?;
Ok(json_success_body(
Some(&request_context),
CustomWorldWorksResponse {
items: items
.into_iter()
.map(map_custom_world_work_summary_response)
.collect(),
},
))
}
pub async fn get_custom_world_agent_card_detail(
State(state): State<AppState>,
Path((session_id, card_id)): Path<(String, String)>,
@@ -1096,6 +1128,111 @@ fn spawn_custom_world_draft_foundation_job(
}
};
let mut draft_profile_json = draft_result.draft_profile_json;
let mut draft_profile_value = match serde_json::from_str::<Value>(&draft_profile_json) {
Ok(Value::Object(object)) => Value::Object(object),
Ok(_) => {
let message = "foundation draft JSON 必须是 object".to_string();
let _ = upsert_custom_world_draft_foundation_progress(
&state,
&session.session_id,
&owner_user_id,
&operation_id,
"failed",
"底稿素材生成失败",
message.as_str(),
100,
Some(message),
)
.await;
return;
}
Err(error) => {
let message = format!("foundation draft JSON 非法:{error}");
let _ = upsert_custom_world_draft_foundation_progress(
&state,
&session.session_id,
&owner_user_id,
&operation_id,
"failed",
"底稿素材生成失败",
message.as_str(),
100,
Some(message),
)
.await;
return;
}
};
if let Err(message) = generate_draft_foundation_role_visuals(
&state,
&session,
&owner_user_id,
&operation_id,
&mut draft_profile_value,
)
.await
{
let _ = upsert_custom_world_draft_foundation_progress(
&state,
&session.session_id,
&owner_user_id,
&operation_id,
"failed",
"生成角色主形象失败",
message.as_str(),
100,
Some(message),
)
.await;
return;
}
if let Err(message) = generate_draft_foundation_act_backgrounds(
&state,
&session,
&owner_user_id,
&operation_id,
&mut draft_profile_value,
)
.await
{
let _ = upsert_custom_world_draft_foundation_progress(
&state,
&session.session_id,
&owner_user_id,
&operation_id,
"failed",
"生成幕背景图失败",
message.as_str(),
100,
Some(message),
)
.await;
return;
}
draft_profile_json = match serde_json::to_string(&draft_profile_value) {
Ok(value) => value,
Err(error) => {
let message = format!("带素材的 foundation draft JSON 序列化失败:{error}");
let _ = upsert_custom_world_draft_foundation_progress(
&state,
&session.session_id,
&owner_user_id,
&operation_id,
"failed",
"底稿素材写回失败",
message.as_str(),
100,
Some(message),
)
.await;
return;
}
};
let _ = upsert_custom_world_draft_foundation_progress(
&state,
&session.session_id,
@@ -1109,34 +1246,32 @@ fn spawn_custom_world_draft_foundation_job(
)
.await;
let payload_json = match build_draft_foundation_action_payload_json(
&payload,
&draft_result.draft_profile_json,
) {
Ok(value) => value,
Err(error) => {
let message = match error {
DraftFoundationPayloadError::SerializePayload(message) => message,
DraftFoundationPayloadError::InvalidPayloadShape => {
"action payload 必须是 object".to_string()
}
DraftFoundationPayloadError::InvalidGeneratedDraft(message) => message,
};
let _ = upsert_custom_world_draft_foundation_progress(
&state,
&session.session_id,
&owner_user_id,
&operation_id,
"failed",
"底稿写入失败",
message.clone().as_str(),
100,
Some(message),
)
.await;
return;
}
};
let payload_json =
match build_draft_foundation_action_payload_json(&payload, &draft_profile_json) {
Ok(value) => value,
Err(error) => {
let message = match error {
DraftFoundationPayloadError::SerializePayload(message) => message,
DraftFoundationPayloadError::InvalidPayloadShape => {
"action payload 必须是 object".to_string()
}
DraftFoundationPayloadError::InvalidGeneratedDraft(message) => message,
};
let _ = upsert_custom_world_draft_foundation_progress(
&state,
&session.session_id,
&owner_user_id,
&operation_id,
"failed",
"底稿写入失败",
message.clone().as_str(),
100,
Some(message),
)
.await;
return;
}
};
if let Err(error) = state
.spacetime_client()
@@ -1167,6 +1302,201 @@ fn spawn_custom_world_draft_foundation_job(
});
}
async fn generate_draft_foundation_role_visuals(
state: &AppState,
session: &CustomWorldAgentSessionRecord,
owner_user_id: &str,
operation_id: &str,
draft_profile: &mut Value,
) -> Result<(), String> {
let Some(profile_object) = draft_profile.as_object_mut() else {
return Err("foundation draft JSON 必须是 object".to_string());
};
let mut role_refs = Vec::new();
for key in ["playableNpcs", "storyNpcs"] {
if let Some(roles) = profile_object.get(key).and_then(Value::as_array) {
for index in 0..roles.len() {
role_refs.push((key.to_string(), index));
}
}
}
let total = role_refs.len().max(1);
for (completed, (key, index)) in role_refs.into_iter().enumerate() {
let role = profile_object
.get(key.as_str())
.and_then(Value::as_array)
.and_then(|roles| roles.get(index))
.cloned()
.unwrap_or(Value::Null);
let name =
json_text_from_value(&role, "name").unwrap_or_else(|| format!("角色{}", index + 1));
let role_id = json_text_from_value(&role, "id").unwrap_or_else(|| format!("{key}-{index}"));
let visual_prompt = json_text_from_value(&role, "visualDescription")
.or_else(|| json_text_from_value(&role, "description"))
.unwrap_or_else(|| name.clone());
upsert_custom_world_draft_foundation_progress(
state,
&session.session_id,
owner_user_id,
operation_id,
"running",
"生成角色主形象",
format!("正在生成角色主形象 {}/{}{}", completed + 1, total, name).as_str(),
97 + ((completed as u32).min(1)),
None,
)
.await
.map_err(|error| error.to_string())?;
let generated = generate_character_primary_visual_for_profile(
state,
owner_user_id,
role_id.as_str(),
visual_prompt.as_str(),
Some(name.as_str()),
)
.await
.map_err(|error| error.message().to_string())?;
if let Some(role_object) = profile_object
.get_mut(key.as_str())
.and_then(Value::as_array_mut)
.and_then(|roles| roles.get_mut(index))
.and_then(Value::as_object_mut)
{
role_object.insert("imageSrc".to_string(), Value::String(generated.image_src));
role_object.insert(
"generatedVisualAssetId".to_string(),
Value::String(generated.asset_id),
);
}
}
Ok(())
}
async fn generate_draft_foundation_act_backgrounds(
state: &AppState,
session: &CustomWorldAgentSessionRecord,
owner_user_id: &str,
operation_id: &str,
draft_profile: &mut Value,
) -> Result<(), String> {
let world_name =
json_text_from_value(draft_profile, "name").unwrap_or_else(|| "未命名世界".to_string());
let profile_id = json_text_from_value(draft_profile, "id");
let act_refs = collect_scene_act_refs(draft_profile);
let total = act_refs.len().max(1);
for (completed, act_ref) in act_refs.into_iter().enumerate() {
upsert_custom_world_draft_foundation_progress(
state,
&session.session_id,
owner_user_id,
operation_id,
"running",
"生成幕背景图",
format!(
"正在生成幕背景图 {}/{}{}",
completed + 1,
total,
act_ref.title
)
.as_str(),
98,
None,
)
.await
.map_err(|error| error.to_string())?;
let generated = generate_custom_world_scene_image_for_profile(
state,
owner_user_id,
profile_id.as_deref(),
world_name.as_str(),
act_ref.scene_id.as_str(),
act_ref.title.as_str(),
act_ref.summary.as_str(),
act_ref.prompt.as_str(),
)
.await
.map_err(|error| error.message().to_string())?;
if let Some(act_object) = draft_profile
.get_mut("sceneChapterBlueprints")
.and_then(Value::as_array_mut)
.and_then(|chapters| chapters.get_mut(act_ref.chapter_index))
.and_then(|chapter| chapter.get_mut("acts"))
.and_then(Value::as_array_mut)
.and_then(|acts| acts.get_mut(act_ref.act_index))
.and_then(Value::as_object_mut)
{
act_object.insert(
"backgroundImageSrc".to_string(),
Value::String(generated.image_src),
);
act_object.insert(
"backgroundAssetId".to_string(),
Value::String(generated.asset_id),
);
act_object.insert(
"generatedScenePrompt".to_string(),
Value::String(generated.prompt),
);
act_object.insert(
"generatedSceneModel".to_string(),
Value::String(generated.model),
);
}
}
Ok(())
}
struct SceneActGenerationRef {
chapter_index: usize,
act_index: usize,
scene_id: String,
title: String,
summary: String,
prompt: String,
}
fn collect_scene_act_refs(draft_profile: &Value) -> Vec<SceneActGenerationRef> {
draft_profile
.get("sceneChapterBlueprints")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
.flat_map(|(chapter_index, chapter)| {
let chapter_scene_id = json_text_from_value(chapter, "sceneId")
.or_else(|| json_text_from_value(chapter, "id"))
.unwrap_or_else(|| format!("chapter-{chapter_index}"));
chapter
.get("acts")
.and_then(Value::as_array)
.into_iter()
.flatten()
.enumerate()
.map(move |(act_index, act)| SceneActGenerationRef {
chapter_index,
act_index,
scene_id: json_text_from_value(act, "sceneId")
.unwrap_or_else(|| chapter_scene_id.clone()),
title: json_text_from_value(act, "title")
.unwrap_or_else(|| format!("{}", act_index + 1)),
summary: json_text_from_value(act, "summary").unwrap_or_default(),
prompt: json_text_from_value(act, "backgroundPromptText")
.or_else(|| json_text_from_value(act, "summary"))
.unwrap_or_else(|| "场景幕背景图,突出探索空间与局势氛围。".to_string()),
})
})
.collect()
}
fn json_text_from_value(value: &Value, key: &str) -> Option<String> {
value
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
async fn upsert_custom_world_draft_foundation_progress(
state: &AppState,
session_id: &str,

View File

@@ -122,6 +122,14 @@ struct GeneratedAssetResponse {
actual_prompt: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct GeneratedCustomWorldSceneImage {
pub image_src: String,
pub asset_id: String,
pub prompt: String,
pub model: String,
}
struct PreparedAssetUpload {
prefix: LegacyAssetPrefix,
path_segments: Vec<String>,
@@ -537,6 +545,114 @@ pub async fn generate_custom_world_scene_image(
Ok(json_success_body(Some(&request_context), asset))
}
pub(crate) async fn generate_custom_world_scene_image_for_profile(
state: &AppState,
owner_user_id: &str,
profile_id: Option<&str>,
world_name: &str,
scene_id: &str,
scene_name: &str,
scene_description: &str,
prompt_text: &str,
) -> Result<GeneratedCustomWorldSceneImage, AppError> {
let payload = CustomWorldSceneImageRequest {
profile_id: profile_id.map(ToOwned::to_owned),
world_name: Some(world_name.to_string()),
landmark_id: Some(scene_id.to_string()),
landmark_name: Some(scene_name.to_string()),
prompt: Some(prompt_text.to_string()),
size: Some("1600*900".to_string()),
negative_prompt: None,
reference_image_src: None,
user_prompt: Some(prompt_text.to_string()),
profile: Some(SceneImageProfileInput {
id: profile_id.map(ToOwned::to_owned),
name: Some(world_name.to_string()),
subtitle: None,
summary: None,
tone: None,
player_goal: None,
setting_text: None,
}),
landmark: Some(SceneImageLandmarkInput {
id: Some(scene_id.to_string()),
name: Some(scene_name.to_string()),
description: Some(scene_description.to_string()),
danger_level: None,
}),
};
let normalized = normalize_scene_image_request(payload)?;
let settings = require_dashscope_settings(state)?;
let http_client = build_dashscope_http_client(&settings)?;
let generated = create_text_to_image_generation(
&http_client,
&settings,
TEXT_TO_IMAGE_SCENE_MODEL,
normalized.prompt.as_str(),
Some(normalized.negative_prompt.as_str()),
normalized.size.as_str(),
"创建场景图片生成任务失败",
"查询场景图片任务失败",
"场景图片生成任务失败",
"场景图片生成超时或未返回图片地址",
)
.await?;
let downloaded = download_remote_image(
&http_client,
generated.image_url.as_str(),
"下载生成图片失败",
)
.await?;
let asset_id = format!("custom-scene-{}", current_utc_millis());
let upload = PreparedAssetUpload {
prefix: LegacyAssetPrefix::CustomWorldScenes,
path_segments: vec![
sanitize_storage_segment(
normalized
.profile_id
.as_deref()
.unwrap_or(normalized.world_name.as_str()),
"world",
),
sanitize_storage_segment(normalized.entity_id.as_str(), "scene"),
asset_id.clone(),
],
file_name: format!("scene.{}", downloaded.extension),
content_type: downloaded.mime_type,
body: downloaded.bytes,
asset_kind: "scene_image",
entity_kind: "custom_world_landmark",
entity_id: normalized.entity_id.clone(),
profile_id: normalized.profile_id.clone(),
slot: "scene_image",
source_job_id: Some(generated.task_id.clone()),
};
let model = normalized.model.clone();
let prompt = normalized.prompt.clone();
let asset = persist_custom_world_asset(
state,
owner_user_id,
upload,
GeneratedAssetResponse {
image_src: String::new(),
asset_id: asset_id.clone(),
source_type: "generated".to_string(),
model: Some(model.clone()),
size: Some(normalized.size),
task_id: Some(generated.task_id),
prompt: Some(prompt.clone()),
actual_prompt: generated.actual_prompt,
},
)
.await?;
Ok(GeneratedCustomWorldSceneImage {
image_src: asset.image_src,
asset_id,
prompt,
model,
})
}
pub async fn generate_custom_world_cover_image(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,

View File

@@ -976,7 +976,8 @@ fn build_foundation_draft_user_prompt(session: &CustomWorldAgentSessionRecord) -
"3. coreConflicts 必须至少 1 条。".to_string(),
"4. chapters 或 sceneChapterBlueprints 必须体现主线第一幕。".to_string(),
"5. sceneChapterBlueprints[0].acts 至少 1 条。".to_string(),
"6. summary 要像结果页摘要,不要只是原始 seed 重复".to_string(),
"6. sceneChapterBlueprints[*].acts[*].backgroundPromptText 必须逐幕生成,作为每一幕生成背景图时默认填入的场景画面描述,不要只生成一个全局场景背景提示词".to_string(),
"7. summary 要像结果页摘要,不要只是原始 seed 重复。".to_string(),
]
.join("\n\n")
}
@@ -1452,10 +1453,58 @@ fn normalize_scene_chapter_blueprint(chapter: JsonValue) -> JsonValue {
"acts".to_string(),
JsonValue::Array(vec![build_fallback_scene_act()]),
);
} else {
object.insert(
"acts".to_string(),
JsonValue::Array(
acts.into_iter()
.enumerate()
.map(|(index, act)| normalize_scene_act_blueprint(act, index))
.collect(),
),
);
}
JsonValue::Object(object)
}
fn normalize_scene_act_blueprint(act: JsonValue, index: usize) -> JsonValue {
let mut object = act.as_object().cloned().unwrap_or_default();
let fallback_act = build_fallback_scene_act_with_index(index);
let fallback_prompt = fallback_act
.get("backgroundPromptText")
.and_then(JsonValue::as_str)
.unwrap_or("当前幕场景背景,突出可探索空间、站位地面和局势氛围。")
.to_string();
let title = object
.get("title")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("{}", index + 1));
let summary = object
.get("summary")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| "当前幕推动场景内的主线压力。".to_string());
object.insert("title".to_string(), JsonValue::String(title.clone()));
object.insert("summary".to_string(), JsonValue::String(summary.clone()));
let background_prompt = object
.get("backgroundPromptText")
.and_then(JsonValue::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("{title}{summary}{fallback_prompt}"));
object.insert(
"backgroundPromptText".to_string(),
JsonValue::String(background_prompt),
);
JsonValue::Object(object)
}
fn build_fallback_scene_chapter_blueprint() -> JsonValue {
json!({
"id": "chapter-act-1",
@@ -1466,10 +1515,15 @@ fn build_fallback_scene_chapter_blueprint() -> JsonValue {
}
fn build_fallback_scene_act() -> JsonValue {
build_fallback_scene_act_with_index(0)
}
fn build_fallback_scene_act_with_index(index: usize) -> JsonValue {
json!({
"id": "scene-act-1",
"title": "开场场景幕",
"id": format!("scene-act-{}", index + 1),
"title": if index == 0 { "开场场景幕".to_string() } else { format!("{}", index + 1) },
"summary": "玩家被推入第一波局势,必须先确认站位、威胁和下一步追查方向。",
"backgroundPromptText": "第一幕场景背景,突出玩家初入现场时的空间轮廓、可站立地面、远近景层次和第一波威胁氛围。",
})
}

View File

@@ -715,6 +715,42 @@ pub async fn put_puzzle_work(
))
}
pub async fn delete_puzzle_work(
State(state): State<AppState>,
AxumPath(profile_id): AxumPath<String>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
) -> Result<Json<Value>, Response> {
ensure_non_empty(
&request_context,
PUZZLE_WORKS_PROVIDER,
&profile_id,
"profileId",
)?;
let items = state
.spacetime_client()
.delete_puzzle_work(profile_id, authenticated.claims().user_id().to_string())
.await
.map_err(|error| {
puzzle_error_response(
&request_context,
PUZZLE_WORKS_PROVIDER,
map_puzzle_client_error(error),
)
})?;
Ok(json_success_body(
Some(&request_context),
PuzzleWorksResponse {
items: items
.into_iter()
.map(map_puzzle_work_summary_response)
.collect(),
},
))
}
pub async fn list_puzzle_gallery(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,

View File

@@ -276,6 +276,13 @@ pub struct BigFishWorksListInput {
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorkDeleteInput {
pub session_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BigFishWorksProcedureResult {

View File

@@ -372,6 +372,13 @@ pub struct PuzzleWorkGetInput {
pub profile_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleWorkDeleteInput {
pub profile_id: String,
pub owner_user_id: String,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PuzzleWorkUpsertInput {

View File

@@ -320,6 +320,8 @@ pub struct CharacterAnimationPublishResponse {
#[serde(rename_all = "camelCase")]
pub struct CharacterWorkflowCachePayload {
pub character_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub cache_scope_id: Option<String>,
pub visual_prompt_text: String,
pub animation_prompt_text: String,
pub visual_drafts: Vec<CharacterVisualDraftPayload>,
@@ -342,6 +344,8 @@ pub struct CharacterWorkflowCachePayload {
pub struct CharacterWorkflowCacheSaveRequest {
pub character_id: String,
#[serde(default)]
pub cache_scope_id: Option<String>,
#[serde(default)]
pub visual_prompt_text: Option<String>,
#[serde(default)]
pub animation_prompt_text: Option<String>,
@@ -734,6 +738,7 @@ mod tests {
ok: true,
cache: CharacterWorkflowCachePayload {
character_id: "hero".to_string(),
cache_scope_id: Some("world-01".to_string()),
visual_prompt_text: "主形象".to_string(),
animation_prompt_text: "待机".to_string(),
visual_drafts: vec![CharacterVisualDraftPayload {
@@ -758,6 +763,7 @@ mod tests {
assert_eq!(payload["ok"], json!(true));
assert_eq!(payload["cache"]["characterId"], json!("hero"));
assert_eq!(payload["cache"]["cacheScopeId"], json!("world-01"));
assert_eq!(
payload["cache"]["visualDrafts"][0]["imageSrc"],
json!("/generated-character-drafts/hero/visual/job/candidate.svg")

View File

@@ -1,5 +1,6 @@
use super::*;
use crate::mapper::*;
use crate::module_bindings::delete_big_fish_work_procedure::delete_big_fish_work;
impl SpacetimeClient {
pub async fn create_big_fish_session(
@@ -71,6 +72,29 @@ impl SpacetimeClient {
.await
}
pub async fn delete_big_fish_work(
&self,
session_id: String,
owner_user_id: String,
) -> Result<Vec<BigFishWorkSummaryRecord>, SpacetimeClientError> {
let procedure_input = BigFishWorkDeleteInput {
session_id,
owner_user_id,
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.delete_big_fish_work_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_big_fish_works_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn submit_big_fish_message(
&self,
input: BigFishMessageSubmitRecordInput,

View File

@@ -1,5 +1,6 @@
use super::*;
use crate::mapper::*;
use crate::module_bindings::delete_custom_world_agent_session_procedure::delete_custom_world_agent_session;
impl SpacetimeClient {
pub async fn list_custom_world_profiles(
@@ -310,6 +311,29 @@ impl SpacetimeClient {
.await
}
pub async fn delete_custom_world_agent_session(
&self,
session_id: String,
owner_user_id: String,
) -> Result<Vec<CustomWorldWorkSummaryRecord>, SpacetimeClientError> {
let procedure_input = CustomWorldAgentSessionGetInput {
session_id,
owner_user_id,
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.delete_custom_world_agent_session_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_custom_world_works_list_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn get_custom_world_agent_card_detail(
&self,
session_id: String,

View File

@@ -4,7 +4,40 @@ pub mod module_bindings;
mod mapper;
pub(crate) use mapper::*;
pub use mapper::{BattleStateRecord, ResolveCombatActionRecord, CustomWorldLibraryEntryRecord, CustomWorldGalleryEntryRecord, CustomWorldLibraryMutationRecord, CustomWorldPublishedProfileCompileRecord, CustomWorldPublishWorldRecord, CustomWorldAgentMessageRecord, CustomWorldAgentOperationRecord, CustomWorldDraftCardRecord, CustomWorldSupportedActionRecord, CustomWorldCheckpointRecord, CustomWorldAgentCheckpointRecord, CustomWorldResultPreviewBlockerRecord, CustomWorldPublishGateRecord, CustomWorldWorkSummaryRecord, CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardDetailRecord, CustomWorldAgentSessionRecord, CustomWorldProfileUpsertRecordInput, CustomWorldPublishWorldRecordInput, CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentActionExecuteRecord, PuzzleAgentSessionCreateRecordInput, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentMessageFinalizeRecordInput, PuzzleGeneratedImagesSaveRecordInput, PuzzleSelectCoverImageRecordInput, PuzzlePublishRecordInput, PuzzleWorkUpsertRecordInput, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord, PuzzleCreatorIntentRecord, PuzzleGeneratedImageCandidateRecord, PuzzleResultDraftRecord, PuzzleAgentMessageRecord, PuzzleAgentSuggestedActionRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord, PuzzleResultPreviewRecord, PuzzleAgentSessionRecord, PuzzleWorkProfileRecord, PuzzleCellPositionRecord, PuzzlePieceStateRecord, PuzzleMergedGroupRecord, PuzzleBoardRecord, PuzzleRuntimeLevelRecord, PuzzleRunRecord, BigFishSessionCreateRecordInput, BigFishMessageSubmitRecordInput, BigFishMessageFinalizeRecordInput, BigFishAssetGenerateRecordInput, BigFishRunStartRecordInput, BigFishRunInputSubmitRecordInput, BigFishAnchorItemRecord, BigFishAnchorPackRecord, BigFishLevelBlueprintRecord, BigFishBackgroundBlueprintRecord, BigFishRuntimeParamsRecord, BigFishGameDraftRecord, BigFishAgentMessageRecord, BigFishAssetSlotRecord, BigFishAssetCoverageRecord, BigFishSessionRecord, BigFishWorkSummaryRecord, BigFishVector2Record, BigFishRuntimeEntityRecord, BigFishRuntimeRecord, ResolveNpcBattleInteractionInput, AiTaskStageRecord, AiResultReferenceRecord, AiTextChunkRecord, AiTaskRecord, AiTaskMutationRecord, NpcStateRecord, NpcInteractionRecord, NpcBattleInteractionRecord};
pub use mapper::{
AiResultReferenceRecord, AiTaskMutationRecord, AiTaskRecord, AiTaskStageRecord,
AiTextChunkRecord, BattleStateRecord, BigFishAgentMessageRecord, BigFishAnchorItemRecord,
BigFishAnchorPackRecord, BigFishAssetCoverageRecord, BigFishAssetGenerateRecordInput,
BigFishAssetSlotRecord, BigFishBackgroundBlueprintRecord, BigFishGameDraftRecord,
BigFishLevelBlueprintRecord, BigFishMessageFinalizeRecordInput,
BigFishMessageSubmitRecordInput, BigFishRunInputSubmitRecordInput, BigFishRunStartRecordInput,
BigFishRuntimeEntityRecord, BigFishRuntimeParamsRecord, BigFishRuntimeRecord,
BigFishSessionCreateRecordInput, BigFishSessionRecord, BigFishVector2Record,
BigFishWorkSummaryRecord, CustomWorldAgentActionExecuteRecord,
CustomWorldAgentActionExecuteRecordInput, CustomWorldAgentCheckpointRecord,
CustomWorldAgentMessageFinalizeRecordInput, CustomWorldAgentMessageRecord,
CustomWorldAgentMessageSubmitRecordInput, CustomWorldAgentOperationRecord,
CustomWorldAgentSessionCreateRecordInput, CustomWorldAgentSessionRecord,
CustomWorldCheckpointRecord, CustomWorldDraftCardDetailRecord,
CustomWorldDraftCardDetailSectionRecord, CustomWorldDraftCardRecord,
CustomWorldGalleryEntryRecord, CustomWorldLibraryEntryRecord, CustomWorldLibraryMutationRecord,
CustomWorldProfileUpsertRecordInput, CustomWorldPublishGateRecord,
CustomWorldPublishWorldRecord, CustomWorldPublishWorldRecordInput,
CustomWorldPublishedProfileCompileRecord, CustomWorldResultPreviewBlockerRecord,
CustomWorldSupportedActionRecord, CustomWorldWorkSummaryRecord, NpcBattleInteractionRecord,
NpcInteractionRecord, NpcStateRecord, PuzzleAgentMessageFinalizeRecordInput,
PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput,
PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord,
PuzzleAgentSuggestedActionRecord, PuzzleAnchorItemRecord, PuzzleAnchorPackRecord,
PuzzleBoardRecord, PuzzleCellPositionRecord, PuzzleCreatorIntentRecord,
PuzzleGeneratedImageCandidateRecord, PuzzleGeneratedImagesSaveRecordInput,
PuzzleMergedGroupRecord, PuzzlePieceStateRecord, PuzzlePublishRecordInput,
PuzzleResultDraftRecord, PuzzleResultPreviewBlockerRecord, PuzzleResultPreviewFindingRecord,
PuzzleResultPreviewRecord, PuzzleRunDragRecordInput, PuzzleRunNextLevelRecordInput,
PuzzleRunRecord, PuzzleRunStartRecordInput, PuzzleRunSwapRecordInput, PuzzleRuntimeLevelRecord,
PuzzleSelectCoverImageRecordInput, PuzzleWorkProfileRecord, PuzzleWorkUpsertRecordInput,
ResolveCombatActionRecord, ResolveNpcBattleInteractionInput,
};
pub mod ai;
pub mod assets;

View File

@@ -0,0 +1,23 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct BigFishWorkDeleteInput {
pub session_id: String,
pub owner_user_id: String,
}
impl __sdk::InModule for BigFishWorkDeleteInput {
type Module = super::RemoteModule;
}

View File

@@ -0,0 +1,57 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::big_fish_work_delete_input_type::BigFishWorkDeleteInput;
use super::big_fish_works_procedure_result_type::BigFishWorksProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct DeleteBigFishWorkArgs {
pub input: BigFishWorkDeleteInput,
}
impl __sdk::InModule for DeleteBigFishWorkArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `delete_big_fish_work`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait delete_big_fish_work {
fn delete_big_fish_work(&self, input: BigFishWorkDeleteInput,
) {
self.delete_big_fish_work_then(input, |_, _| {});
}
fn delete_big_fish_work_then(
&self,
input: BigFishWorkDeleteInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<BigFishWorksProcedureResult, __sdk::InternalError>) + Send + 'static,
);
}
impl delete_big_fish_work for super::RemoteProcedures {
fn delete_big_fish_work_then(
&self,
input: BigFishWorkDeleteInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<BigFishWorksProcedureResult, __sdk::InternalError>) + Send + 'static,
) {
self.imp.invoke_procedure_with_callback::<_, BigFishWorksProcedureResult>(
"delete_big_fish_work",
DeleteBigFishWorkArgs { input, },
__callback,
);
}
}

View File

@@ -0,0 +1,57 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::custom_world_agent_session_get_input_type::CustomWorldAgentSessionGetInput;
use super::custom_world_works_list_result_type::CustomWorldWorksListResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct DeleteCustomWorldAgentSessionArgs {
pub input: CustomWorldAgentSessionGetInput,
}
impl __sdk::InModule for DeleteCustomWorldAgentSessionArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `delete_custom_world_agent_session`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait delete_custom_world_agent_session {
fn delete_custom_world_agent_session(&self, input: CustomWorldAgentSessionGetInput,
) {
self.delete_custom_world_agent_session_then(input, |_, _| {});
}
fn delete_custom_world_agent_session_then(
&self,
input: CustomWorldAgentSessionGetInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<CustomWorldWorksListResult, __sdk::InternalError>) + Send + 'static,
);
}
impl delete_custom_world_agent_session for super::RemoteProcedures {
fn delete_custom_world_agent_session_then(
&self,
input: CustomWorldAgentSessionGetInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<CustomWorldWorksListResult, __sdk::InternalError>) + Send + 'static,
) {
self.imp.invoke_procedure_with_callback::<_, CustomWorldWorksListResult>(
"delete_custom_world_agent_session",
DeleteCustomWorldAgentSessionArgs { input, },
__callback,
);
}
}

View File

@@ -0,0 +1,57 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
use super::puzzle_work_delete_input_type::PuzzleWorkDeleteInput;
use super::puzzle_works_procedure_result_type::PuzzleWorksProcedureResult;
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
struct DeletePuzzleWorkArgs {
pub input: PuzzleWorkDeleteInput,
}
impl __sdk::InModule for DeletePuzzleWorkArgs {
type Module = super::RemoteModule;
}
#[allow(non_camel_case_types)]
/// Extension trait for access to the procedure `delete_puzzle_work`.
///
/// Implemented for [`super::RemoteProcedures`].
pub trait delete_puzzle_work {
fn delete_puzzle_work(&self, input: PuzzleWorkDeleteInput,
) {
self.delete_puzzle_work_then(input, |_, _| {});
}
fn delete_puzzle_work_then(
&self,
input: PuzzleWorkDeleteInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<PuzzleWorksProcedureResult, __sdk::InternalError>) + Send + 'static,
);
}
impl delete_puzzle_work for super::RemoteProcedures {
fn delete_puzzle_work_then(
&self,
input: PuzzleWorkDeleteInput,
__callback: impl FnOnce(&super::ProcedureEventContext, Result<PuzzleWorksProcedureResult, __sdk::InternalError>) + Send + 'static,
) {
self.imp.invoke_procedure_with_callback::<_, PuzzleWorksProcedureResult>(
"delete_puzzle_work",
DeletePuzzleWorkArgs { input, },
__callback,
);
}
}

View File

@@ -94,6 +94,7 @@ pub mod big_fish_session_get_input_type;
pub mod big_fish_session_procedure_result_type;
pub mod big_fish_session_snapshot_type;
pub mod big_fish_vector_2_type;
pub mod big_fish_work_delete_input_type;
pub mod big_fish_works_list_input_type;
pub mod big_fish_works_procedure_result_type;
pub mod chapter_pace_band_type;
@@ -212,6 +213,7 @@ pub mod puzzle_run_start_input_type;
pub mod puzzle_run_swap_input_type;
pub mod puzzle_runtime_run_row_type;
pub mod puzzle_select_cover_image_input_type;
pub mod puzzle_work_delete_input_type;
pub mod puzzle_work_get_input_type;
pub mod puzzle_work_procedure_result_type;
pub mod puzzle_work_profile_row_type;
@@ -404,7 +406,10 @@ pub mod create_battle_state_and_return_procedure;
pub mod create_big_fish_session_procedure;
pub mod create_custom_world_agent_session_procedure;
pub mod create_puzzle_agent_session_procedure;
pub mod delete_big_fish_work_procedure;
pub mod delete_custom_world_agent_session_procedure;
pub mod delete_custom_world_profile_and_return_procedure;
pub mod delete_puzzle_work_procedure;
pub mod delete_runtime_snapshot_and_return_procedure;
pub mod drag_puzzle_piece_or_group_procedure;
pub mod execute_custom_world_agent_action_procedure;
@@ -559,6 +564,7 @@ pub use big_fish_session_get_input_type::BigFishSessionGetInput;
pub use big_fish_session_procedure_result_type::BigFishSessionProcedureResult;
pub use big_fish_session_snapshot_type::BigFishSessionSnapshot;
pub use big_fish_vector_2_type::BigFishVector2;
pub use big_fish_work_delete_input_type::BigFishWorkDeleteInput;
pub use big_fish_works_list_input_type::BigFishWorksListInput;
pub use big_fish_works_procedure_result_type::BigFishWorksProcedureResult;
pub use chapter_pace_band_type::ChapterPaceBand;
@@ -677,6 +683,7 @@ pub use puzzle_run_start_input_type::PuzzleRunStartInput;
pub use puzzle_run_swap_input_type::PuzzleRunSwapInput;
pub use puzzle_runtime_run_row_type::PuzzleRuntimeRunRow;
pub use puzzle_select_cover_image_input_type::PuzzleSelectCoverImageInput;
pub use puzzle_work_delete_input_type::PuzzleWorkDeleteInput;
pub use puzzle_work_get_input_type::PuzzleWorkGetInput;
pub use puzzle_work_procedure_result_type::PuzzleWorkProcedureResult;
pub use puzzle_work_profile_row_type::PuzzleWorkProfileRow;

View File

@@ -0,0 +1,23 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
#![allow(unused, clippy::all)]
use spacetimedb_sdk::__codegen::{
self as __sdk,
__lib,
__sats,
__ws,
};
#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)]
#[sats(crate = __lib)]
pub struct PuzzleWorkDeleteInput {
pub profile_id: String,
pub owner_user_id: String,
}
impl __sdk::InModule for PuzzleWorkDeleteInput {
type Module = super::RemoteModule;
}

View File

@@ -1,5 +1,6 @@
use super::*;
use crate::mapper::*;
use crate::module_bindings::delete_puzzle_work_procedure::delete_puzzle_work;
impl SpacetimeClient {
pub async fn create_puzzle_agent_session(
@@ -280,6 +281,29 @@ impl SpacetimeClient {
.await
}
pub async fn delete_puzzle_work(
&self,
profile_id: String,
owner_user_id: String,
) -> Result<Vec<PuzzleWorkProfileRecord>, SpacetimeClientError> {
let procedure_input = PuzzleWorkDeleteInput {
profile_id,
owner_user_id,
};
self.call_after_connect(move |connection, sender| {
connection
.procedures()
.delete_puzzle_work_then(procedure_input, move |_, result| {
let mapped = result
.map_err(|error| SpacetimeClientError::Procedure(error.to_string()))
.and_then(map_puzzle_works_procedure_result);
send_once(&sender, mapped);
});
})
.await
}
pub async fn list_puzzle_gallery(
&self,
) -> Result<Vec<PuzzleWorkProfileRecord>, SpacetimeClientError> {

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

View File

@@ -18,7 +18,6 @@ import {
resolveCustomWorldLandmarkImageMap,
} from '../data/customWorldVisuals';
import { resolveCustomWorldCampScene } from '../services/customWorldCamp';
import { resolveCustomWorldCoverPresentation } from '../services/customWorldCover';
import { normalizeCustomWorldCreatorIntent } from '../services/customWorldCreatorIntent';
import {
AnimationState,
@@ -28,7 +27,6 @@ import {
type SceneChapterBlueprint,
} from '../types';
import { CharacterAnimator } from './CharacterAnimator';
import { CustomWorldCoverArtwork } from './CustomWorldCoverArtwork';
import type { RpgCreationEditorTarget } from './rpg-creation-editor/RpgCreationEntityEditorModal';
import { CustomWorldNpcPortrait } from './CustomWorldNpcVisualEditor';
import { ResolvedAssetImage } from './ResolvedAssetImage';
@@ -54,8 +52,6 @@ interface CustomWorldEntityCatalogProps {
onProfileChange: (profile: CustomWorldProfile) => void;
onDeleteStoryNpcs?: (ids: string[]) => void;
onDeleteLandmarks?: (ids: string[]) => void;
onGenerateRoleAssets?: (roleId: string) => void;
onGenerateSceneAssets?: (sceneId: string, sceneKind: 'camp' | 'landmark') => void;
createActionLabel?: string;
onCreateAction?: () => void;
createActionDisabled?: boolean;
@@ -389,21 +385,21 @@ function CatalogCard({
tabIndex={disabled ? -1 : 0}
onClick={disabled ? undefined : onClick}
aria-disabled={disabled}
className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors ${
className={`w-full rounded-[1.3rem] border p-2.5 text-left transition-colors xl:p-3 ${
isSelected
? 'border-rose-300/35 bg-rose-500/10'
: 'platform-subpanel'
}`}
>
<div className="flex items-start gap-3">
<div className="flex items-start gap-3 xl:gap-3.5">
<div
className={`platform-subpanel shrink-0 overflow-hidden rounded-[1rem] ${mediaClassName ?? 'h-[4.75rem] w-[4.75rem]'}`}
>
{media}
</div>
<div className="min-w-0 flex-1">
<div className="min-w-0 flex-1 xl:min-h-[5.6rem]">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 text-[15px] font-semibold leading-5 text-white">
<div className="min-w-0 text-[15px] font-semibold leading-5 text-white xl:line-clamp-1">
{title}
</div>
<div className="flex items-center gap-2">
@@ -411,7 +407,7 @@ function CatalogCard({
{selectionBadge}
</div>
</div>
<div className="mt-1.5 text-sm leading-5 text-zinc-300">
<div className="mt-1.5 text-sm leading-5 text-zinc-300 xl:line-clamp-2">
{description || '暂无描述'}
</div>
{actions ? <div className="mt-2 flex flex-wrap gap-2">{actions}</div> : null}
@@ -891,8 +887,6 @@ export function CustomWorldEntityCatalog({
onProfileChange,
onDeleteStoryNpcs,
onDeleteLandmarks,
onGenerateRoleAssets,
onGenerateSceneAssets,
createActionLabel,
onCreateAction,
createActionDisabled = false,
@@ -1104,11 +1098,6 @@ export function CustomWorldEntityCatalog({
1 +
(pendingGeneratedEntity?.kind === 'landmark' ? 1 : 0),
} satisfies Record<ResultTab, number>;
const coverPresentation = useMemo(
() => resolveCustomWorldCoverPresentation(profile),
[profile],
);
const bulkDeleteTab: BulkDeleteTab | null =
activeTab === 'story' || activeTab === 'landmarks' ? activeTab : null;
const isBulkDeleteMode =
@@ -1185,28 +1174,28 @@ export function CustomWorldEntityCatalog({
return (
<div
ref={scrollContainerRef}
className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide"
className="h-full min-h-0 space-y-3 overflow-y-auto overscroll-contain pr-1 scrollbar-hide xl:space-y-4 xl:pr-2"
>
<div className="px-1 pb-1 text-center">
<div className="px-1 pb-1 text-center xl:rounded-[2rem] xl:border xl:border-[var(--platform-subpanel-border)] xl:bg-white/55 xl:px-6 xl:py-4 xl:text-left xl:shadow-[0_18px_70px_rgba(255,79,139,0.08)] xl:backdrop-blur-sm">
<div className="text-[11px] font-bold tracking-[0.28em] text-zinc-500">
</div>
<div className="mt-2 text-3xl font-black text-[var(--platform-text-strong)] sm:text-[2.2rem]">
<div className="mt-2 text-3xl font-black text-[var(--platform-text-strong)] sm:text-[2.2rem] xl:mt-1 xl:text-[2rem]">
{profile.name}
</div>
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-400">
<div className="mt-2 text-sm tracking-[0.18em] text-zinc-400 xl:mt-1 xl:text-xs">
{profile.subtitle}
</div>
</div>
<div className="platform-sticky-fade sticky top-0 z-10 -mx-1 space-y-3 px-1 pb-3 pt-1 backdrop-blur-sm">
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide">
<div className="platform-sticky-fade sticky top-0 z-10 -mx-1 space-y-3 px-1 pb-3 pt-1 backdrop-blur-sm xl:rounded-[1.75rem] xl:border xl:border-[var(--platform-subpanel-border)] xl:bg-white/70 xl:px-4 xl:py-3 xl:shadow-[0_16px_48px_rgba(255,79,139,0.08)]">
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-hide xl:pb-0">
{RESULT_TABS.map((tab) => (
<div key={tab.id}>
<button
type="button"
onClick={() => onActiveTabChange(tab.id)}
className={`platform-tab px-3 py-2 text-left text-sm ${activeTab === tab.id ? 'platform-tab--active' : ''}`}
className={`platform-tab px-3 py-2 text-left text-sm xl:min-w-[5.25rem] xl:px-4 xl:py-2 ${activeTab === tab.id ? 'platform-tab--active' : ''}`}
>
<div className="font-semibold">{tab.label}</div>
<div className="mt-1 text-[10px] tracking-[0.16em] text-[var(--platform-text-soft)]">
@@ -1218,7 +1207,7 @@ export function CustomWorldEntityCatalog({
</div>
{activeTab !== 'world' ? (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center xl:gap-3">
<div className="min-w-0 flex-1">
<SearchBox
value={searchDraft}
@@ -1267,7 +1256,7 @@ export function CustomWorldEntityCatalog({
</div>
{activeTab === 'world' ? (
<>
<div className="space-y-3 xl:grid xl:grid-cols-[0.8fr_1.2fr] xl:items-start xl:gap-3 xl:space-y-0">
<Section title="档案规模">
<div className="grid grid-cols-3 gap-2 text-center text-[11px] text-zinc-300">
<div className="platform-subpanel rounded-xl px-2 py-3">
@@ -1291,40 +1280,6 @@ export function CustomWorldEntityCatalog({
</div>
</Section>
<Section
title="作品封面"
badge={
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
{coverPresentation.sourceType === 'uploaded'
? '上传封面'
: coverPresentation.sourceType === 'generated'
? 'AI封面'
: '默认封面'}
</span>
}
actions={
!readOnly ? (
<SmallButton
onClick={() => onEditTarget({ kind: 'cover' })}
tone="sky"
>
</SmallButton>
) : null
}
>
<div className="space-y-3">
<CustomWorldCoverArtwork
imageSrc={coverPresentation.imageSrc}
title={profile.name}
fallbackLabel={profile.name.slice(0, 4) || '封面'}
renderMode={coverPresentation.renderMode}
characterImageSrcs={coverPresentation.characterImageSrcs}
className="aspect-[16/9] rounded-[1.4rem] border border-[var(--platform-subpanel-border)]"
/>
</div>
</Section>
<Section
title="世界概述"
actions={
@@ -1394,11 +1349,11 @@ export function CustomWorldEntityCatalog({
</div>
</div>
</Section>
</>
</div>
) : null}
{activeTab === 'playable' ? (
<div className="space-y-3">
<div className="space-y-3 xl:grid xl:grid-cols-2 xl:gap-3 xl:space-y-0 2xl:grid-cols-3">
{pendingGeneratedEntity?.kind === 'playable' ? (
<PendingEntityCard
title={pendingGeneratedEntity.title}
@@ -1433,7 +1388,7 @@ export function CustomWorldEntityCatalog({
isSelectionMode={false}
isSelected={false}
layout="compact"
mediaClassName="h-[4.75rem] w-[4.75rem] sm:h-[5.25rem] sm:w-[5.25rem]"
mediaClassName="h-[4.75rem] w-[4.75rem] sm:h-[5.25rem] sm:w-[5.25rem] xl:h-[5.75rem] xl:w-[5.75rem]"
onClick={() =>
onEditTarget({
kind: 'playable',
@@ -1441,19 +1396,6 @@ export function CustomWorldEntityCatalog({
id: role.id,
})
}
actions={
!readOnly && onGenerateRoleAssets ? (
<SmallButton
onClick={(event) => {
event?.stopPropagation();
onGenerateRoleAssets(role.id);
}}
tone="sky"
>
</SmallButton>
) : null
}
media={
role.imageSrc?.trim() ? (
<ResolvedAssetImage
@@ -1522,7 +1464,7 @@ export function CustomWorldEntityCatalog({
) : null}
{activeTab === 'story' ? (
<div className="space-y-3">
<div className="space-y-3 xl:grid xl:grid-cols-2 xl:gap-3 xl:space-y-0 2xl:grid-cols-3">
{pendingGeneratedEntity?.kind === 'story' ? (
<PendingEntityCard
title={pendingGeneratedEntity.title}
@@ -1547,7 +1489,7 @@ export function CustomWorldEntityCatalog({
isSelectionMode={isBulkDeleteMode}
isSelected={selectedBulkIds.includes(npc.id)}
layout="compact"
mediaClassName="h-[4.75rem] w-[4.75rem] sm:h-[5.25rem] sm:w-[5.25rem]"
mediaClassName="h-[4.75rem] w-[4.75rem] sm:h-[5.25rem] sm:w-[5.25rem] xl:h-[5.75rem] xl:w-[5.75rem]"
onClick={() =>
isBulkDeleteMode
? toggleBulkSelected(npc.id)
@@ -1563,19 +1505,6 @@ export function CustomWorldEntityCatalog({
id: npc.id,
})
}
actions={
!readOnly && !isBulkDeleteMode && onGenerateRoleAssets ? (
<SmallButton
onClick={(event) => {
event?.stopPropagation();
onGenerateRoleAssets(npc.id);
}}
tone="sky"
>
</SmallButton>
) : null
}
media={
<CustomWorldNpcPortrait
npc={npc}
@@ -1595,7 +1524,7 @@ export function CustomWorldEntityCatalog({
) : null}
{activeTab === 'landmarks' ? (
<div className="space-y-3">
<div className="space-y-3 xl:grid xl:grid-cols-2 xl:gap-3 xl:space-y-0 2xl:grid-cols-3">
{pendingGeneratedEntity?.kind === 'landmark' ? (
<PendingEntityCard
title={pendingGeneratedEntity.title}
@@ -1639,20 +1568,6 @@ export function CustomWorldEntityCatalog({
id: scene.id,
})
}
actions={
!readOnly && !isBulkDeleteMode && onGenerateSceneAssets ? (
<SmallButton
onClick={(event) => {
event?.stopPropagation();
onGenerateSceneAssets(scene.id, scene.kind);
}}
tone="sky"
disabled={scene.kind === 'camp' && isBulkDeleteMode}
>
</SmallButton>
) : null
}
media={
<ImageFrame
src={scene.imageSrc}

View File

@@ -117,14 +117,14 @@ export function CustomWorldGenerationView({
</div>
</div>
<div className="flex flex-none flex-col gap-4 xl:min-h-0 xl:flex-1">
<section className="platform-surface platform-surface--soft flex flex-col px-4 py-3.5 xl:min-h-0 xl:flex-1">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-none flex-col gap-4 xl:grid xl:min-h-0 xl:flex-1 xl:grid-cols-[minmax(0,1.35fr)_minmax(22rem,0.65fr)] xl:items-stretch">
<section className="platform-surface platform-surface--soft flex flex-col px-4 py-3.5 xl:min-h-0 xl:flex-1 xl:px-5 xl:py-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between xl:gap-6">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-zinc-400">
{progressTitle}
</div>
<div className="mt-1 text-xl font-black leading-tight text-[var(--platform-text-strong)] sm:text-[2rem]">
<div className="mt-1 text-xl font-black leading-tight text-[var(--platform-text-strong)] sm:text-[2rem] xl:text-[2.4rem]">
{progress?.phaseLabel ?? '正在启动世界生成'}
</div>
<div className="mt-2 max-w-[36rem] text-sm leading-6 text-zinc-300">
@@ -141,7 +141,7 @@ export function CustomWorldGenerationView({
</div>
</div>
<div className="platform-progress-track mt-4 h-4 overflow-hidden rounded-full">
<div className="platform-progress-track mt-4 h-4 overflow-hidden rounded-full xl:mt-5 xl:h-5">
<motion.div
className="h-full bg-[linear-gradient(90deg,#ff4f8b_0%,#ff8a73_52%,#ffd2a6_100%)]"
animate={{ width: `${progressValue}%` }}
@@ -149,7 +149,7 @@ export function CustomWorldGenerationView({
/>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-3">
<div className="mt-4 grid gap-2 sm:grid-cols-3 xl:gap-3">
<div className="platform-subpanel rounded-2xl px-4 py-3">
<div className="text-[11px] tracking-[0.16em] text-zinc-500">
@@ -176,7 +176,7 @@ export function CustomWorldGenerationView({
</div>
</div>
<div className="mt-4 space-y-2 xl:min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
<div className="mt-4 space-y-2 xl:grid xl:min-h-0 xl:flex-1 xl:grid-cols-2 xl:content-start xl:gap-2 xl:space-y-0 xl:overflow-y-auto xl:pr-1">
{steps.map((step, index) => (
<div
key={buildFallbackRenderKey(step.id, `progress-step-${index}`)}
@@ -241,8 +241,8 @@ export function CustomWorldGenerationView({
</div>
</section>
<section className="platform-surface platform-surface--soft overflow-hidden px-4 py-3.5">
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<section className="platform-surface platform-surface--soft overflow-hidden px-4 py-3.5 xl:flex xl:min-h-0 xl:flex-col xl:px-5 xl:py-4">
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between xl:flex-col xl:items-start xl:gap-2">
<div className="min-w-0">
<div className="text-[11px] font-bold tracking-[0.2em] text-[var(--platform-cool-text)]">
{settingTitle}
@@ -265,14 +265,14 @@ export function CustomWorldGenerationView({
) : null}
</div>
{hasStructuredAnchors ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:min-h-0 xl:flex-1 xl:grid-cols-1 xl:overflow-y-auto xl:pr-1">
{anchorEntries.map((entry, index) => (
<div
key={buildFallbackRenderKey(
entry.id,
`anchor-entry-${index}`,
)}
className="platform-subpanel rounded-2xl px-4 py-4"
className="platform-subpanel rounded-2xl px-4 py-4 xl:py-3"
>
<div className="text-[11px] font-bold tracking-[0.18em] text-zinc-500">
{entry.label}
@@ -284,7 +284,7 @@ export function CustomWorldGenerationView({
))}
</div>
) : (
<div className="platform-subpanel whitespace-pre-line rounded-2xl px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto">
<div className="platform-subpanel whitespace-pre-line rounded-2xl px-4 py-4 text-sm leading-7 text-zinc-200 md:max-h-[16rem] md:overflow-y-auto xl:max-h-none xl:min-h-0 xl:flex-1">
{settingText || structuredEmptyText}
</div>
)}

View File

@@ -47,6 +47,7 @@ export type CharacterVisualDraft = {
export type CharacterAssetWorkflowCache = {
characterId: string;
cacheScopeId?: string;
visualPromptText: string;
animationPromptText: string;
animationPromptTextByKey?: Record<string, string>;
@@ -154,12 +155,19 @@ export async function generateCharacterVisualCandidates(
}>(CHARACTER_VISUAL_GENERATE_API_PATH, payload, '生成角色主形象失败');
}
export async function fetchCharacterWorkflowCache(characterId: string) {
export async function fetchCharacterWorkflowCache(
characterId: string,
cacheScopeId?: string,
) {
return fetchJson<{
ok: true;
cache: CharacterAssetWorkflowCache | null;
}>(
`${CHARACTER_WORKFLOW_CACHE_API_PATH}/${encodeURIComponent(characterId)}`,
`${CHARACTER_WORKFLOW_CACHE_API_PATH}/${encodeURIComponent(characterId)}${
cacheScopeId
? `?cacheScopeId=${encodeURIComponent(cacheScopeId)}`
: ''
}`,
'读取角色形象生成缓存失败',
);
}

View File

@@ -1,4 +1,4 @@
/* @vitest-environment jsdom */
/* @vitest-environment jsdom */
import { render, screen } from '@testing-library/react';
import { expect, test } from 'vitest';
@@ -127,5 +127,5 @@ test('creation hub shows delete action for persisted rpg drafts', () => {
/>,
);
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
});

View File

@@ -30,9 +30,11 @@ type CustomWorldCreationHubProps = {
bigFishItems?: BigFishWorkSummary[];
onOpenBigFishDetail?: (item: BigFishWorkSummary) => void;
onExperienceBigFish?: ((item: BigFishWorkSummary) => void) | null;
onDeleteBigFish?: ((item: BigFishWorkSummary) => void) | null;
puzzleItems?: PuzzleWorkSummary[];
onOpenPuzzleDetail?: (item: PuzzleWorkSummary) => void;
onExperiencePuzzle?: ((profileId: string) => void) | null;
onDeletePuzzle?: ((item: PuzzleWorkSummary) => void) | null;
};
function EmptyState({ title }: { title: string }) {
@@ -61,9 +63,11 @@ export function CustomWorldCreationHub({
bigFishItems = [],
onOpenBigFishDetail,
onExperienceBigFish = null,
onDeleteBigFish = null,
puzzleItems = [],
onOpenPuzzleDetail,
onExperiencePuzzle = null,
onDeletePuzzle = null,
}: CustomWorldCreationHubProps) {
const [activeFilter, setActiveFilter] =
useState<CustomWorldWorkFilter>('all');
@@ -104,8 +108,8 @@ export function CustomWorldCreationHub({
);
return (
<div className="platform-page-stage platform-remap-surface space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4">
<div className="space-y-4">
<div className="platform-page-stage platform-remap-surface space-y-4 px-3 pb-4 pt-3 sm:px-4 sm:pt-4 xl:px-5 xl:pb-5 xl:pt-5">
<div className="space-y-4 xl:space-y-3">
<CustomWorldCreationStartCard
busy={createBusy}
error={createError}
@@ -133,7 +137,7 @@ export function CustomWorldCreationHub({
) : null}
{loading ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{Array.from({ length: 3 }).map((_, index) => (
<div
key={`skeleton-${index}`}
@@ -151,7 +155,7 @@ export function CustomWorldCreationHub({
))}
</div>
) : filteredItems.length > 0 ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{filteredItems.map((item) => (
<CustomWorldWorkCard
key={`${item.kind}-${item.item.workId}`}
@@ -199,11 +203,17 @@ export function CustomWorldCreationHub({
: null
}
onDelete={
item.kind === 'rpg' && item.item.profileId
item.kind === 'puzzle'
? () => {
onDeletePublished?.(item.item);
onDeletePuzzle?.(item.item);
}
: null
: item.kind === 'big-fish'
? () => {
onDeleteBigFish?.(item.item);
}
: () => {
onDeletePublished?.(item.item);
}
}
deleteBusy={deletingWorkId === item.item.workId}
/>

View File

@@ -18,14 +18,14 @@ export function CustomWorldCreationStartCard({
}: CustomWorldCreationStartCardProps) {
return (
// 移动端限制模块高度,模板入口改为横向滚动,避免挤占作品列表首屏空间。
<div className="platform-surface platform-surface--hero relative max-h-[33svh] overflow-hidden px-3 py-3 sm:max-h-none sm:px-5 sm:py-5">
<div className="platform-surface platform-surface--hero relative max-h-[33svh] overflow-hidden px-3 py-3 sm:max-h-none sm:px-5 sm:py-5 xl:px-5 xl:py-4">
<div className="absolute inset-0 bg-[var(--platform-hero-overlay-strong)]" />
<div className="relative z-10 space-y-2.5 sm:space-y-4">
<div className="flex items-center justify-between gap-3">
<div className="text-xl font-black leading-none text-white sm:text-3xl">
<div className="relative z-10 space-y-2.5 sm:space-y-4 xl:space-y-3">
<div className="flex items-center justify-between gap-3 xl:items-end">
<div className="text-xl font-black leading-none text-white sm:text-3xl xl:text-2xl">
</div>
<div className="hidden text-sm leading-6 text-zinc-200/88 sm:block">
<div className="hidden text-sm leading-6 text-zinc-200/88 sm:block xl:text-xs xl:leading-5">
</div>
<span className="platform-pill platform-pill--neutral shrink-0 border-white/25 bg-white/14 px-2.5 text-xs text-white sm:hidden">
@@ -33,7 +33,7 @@ export function CustomWorldCreationStartCard({
</span>
</div>
<div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-5">
<div className="-mx-1 flex snap-x gap-2 overflow-x-auto px-1 pb-1 sm:mx-0 sm:grid sm:gap-3 sm:overflow-visible sm:px-0 sm:pb-0 sm:grid-cols-2 xl:grid-cols-5 xl:gap-2.5">
{PLATFORM_CREATION_TYPES.map((item) => {
const disabled = item.locked || busy;
@@ -45,7 +45,7 @@ export function CustomWorldCreationStartCard({
onClick={() => {
onCreateType(item.id);
}}
className={`platform-interactive-card relative min-h-[4rem] w-[11.25rem] shrink-0 snap-start overflow-hidden rounded-[1.15rem] border px-3 py-2.5 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] sm:px-4 sm:py-4 ${
className={`platform-interactive-card relative min-h-[4rem] w-[11.25rem] shrink-0 snap-start overflow-hidden rounded-[1.15rem] border px-3 py-2.5 text-left transition sm:min-h-[8.5rem] sm:w-auto sm:rounded-[1.5rem] sm:px-4 sm:py-4 xl:min-h-[6.4rem] xl:px-3.5 xl:py-3 ${
item.locked
? 'cursor-not-allowed border-white/10 bg-white/8 text-zinc-300/70'
: 'border-white/18 bg-[radial-gradient(circle_at_top_left,rgba(255,255,255,0.24),transparent_36%),linear-gradient(135deg,rgba(255,255,255,0.18),rgba(255,255,255,0.08))] text-white'
@@ -68,11 +68,11 @@ export function CustomWorldCreationStartCard({
)}
</div>
<div className="mt-2.5 truncate text-base font-black leading-tight text-inherit sm:mt-7 sm:text-lg">
<div className="mt-2.5 truncate text-base font-black leading-tight text-inherit sm:mt-7 sm:text-lg xl:mt-4 xl:text-base">
{item.title}
</div>
<div
className={`mt-1 truncate text-xs sm:mt-2 sm:text-sm ${
className={`mt-1 truncate text-xs sm:mt-2 sm:text-sm xl:mt-1 xl:text-xs ${
item.locked ? 'text-zinc-400' : 'text-zinc-200/82'
}`}
>

View File

@@ -76,7 +76,7 @@ export function CustomWorldWorkCard({
item.kind === 'rpg' ? item.item.coverCharacterImageSrcs : [];
return (
<div className="platform-surface platform-interactive-card relative min-h-[13.5rem] overflow-hidden px-4 py-4 sm:min-h-[14rem]">
<div className="platform-surface platform-interactive-card relative min-h-[13.5rem] overflow-hidden px-4 py-4 sm:min-h-[14rem] xl:min-h-[12.25rem] xl:px-4 xl:py-3.5">
<CustomWorldCoverArtwork
imageSrc={coverImageSrc}
title={title}
@@ -86,7 +86,7 @@ export function CustomWorldWorkCard({
className="platform-cover-artwork absolute inset-0"
/>
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
<div className="relative z-10 flex h-full min-h-[12rem] flex-col">
<div className="relative z-10 flex h-full min-h-[12rem] flex-col xl:min-h-[10.75rem]">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-wrap gap-2">
<span
@@ -117,24 +117,57 @@ export function CustomWorldWorkCard({
))
: null}
</div>
<div className="shrink-0 text-[11px] text-[var(--platform-text-soft)]">
{formatUpdatedAt(updatedAt)}
<div className="flex shrink-0 items-center gap-2">
<span className="text-[11px] text-[var(--platform-text-soft)]">
{formatUpdatedAt(updatedAt)}
</span>
{onDelete ? (
<button
type="button"
onClick={onDelete}
disabled={deleteBusy}
aria-label={deleteBusy ? '删除中' : `删除作品《${title}`}
title={deleteBusy ? '删除中' : '删除作品'}
className="grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-danger)] disabled:cursor-not-allowed disabled:opacity-55"
>
{deleteBusy ? (
<span className="text-xs leading-none"></span>
) : (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
>
<path d="M3 6h18" />
<path d="M8 6V4h8v2" />
<path d="M19 6l-1 14H6L5 6" />
<path d="M10 11v5" />
<path d="M14 11v5" />
</svg>
)}
</button>
) : null}
</div>
</div>
<div className="mt-4">
<div className="text-2xl font-black text-[var(--platform-text-strong)]">
<div className="mt-4 xl:mt-3">
<div className="text-2xl font-black text-[var(--platform-text-strong)] xl:text-xl">
{title}
</div>
<div className="mt-1 text-xs tracking-[0.12em] text-[var(--platform-text-soft)]">
{subtitle}
</div>
<div className="mt-3 line-clamp-3 text-sm leading-7 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)]">
<div className="mt-3 line-clamp-3 text-sm leading-7 text-[color:color-mix(in_srgb,var(--platform-text-base)_92%,transparent)] xl:mt-2 xl:line-clamp-2 xl:leading-6">
{summary}
</div>
</div>
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-center sm:justify-between">
<div className="mt-auto flex flex-col gap-3 pt-4 sm:flex-row sm:items-center sm:justify-between xl:gap-2 xl:pt-3">
<div className="flex flex-wrap gap-2">
{isPuzzle ? (
<>
@@ -188,11 +221,11 @@ export function CustomWorldWorkCard({
</>
)}
</div>
<div className="flex flex-wrap gap-2 sm:justify-end">
<div className="flex flex-wrap gap-2 sm:justify-end xl:gap-1.5">
<button
type="button"
onClick={onOpen}
className="platform-button platform-button--primary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm"
className="platform-button platform-button--primary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm xl:px-3.5 xl:py-1.5 xl:text-xs"
>
{openActionLabel}
</button>
@@ -200,21 +233,11 @@ export function CustomWorldWorkCard({
<button
type="button"
onClick={onExperience}
className="platform-button platform-button--secondary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm"
className="platform-button platform-button--secondary min-h-0 shrink-0 rounded-full px-4 py-2 text-sm xl:px-3.5 xl:py-1.5 xl:text-xs"
>
</button>
) : null}
{onDelete ? (
<button
type="button"
onClick={onDelete}
disabled={deleteBusy}
className="platform-button platform-button--danger min-h-0 shrink-0 rounded-full px-4 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-55"
>
{deleteBusy ? '删除中...' : '删除'}
</button>
) : null}
</div>
</div>
</div>

View File

@@ -23,7 +23,7 @@ export function CustomWorldWorkTabs({
onChange,
}: CustomWorldWorkTabsProps) {
return (
<div className="platform-remap-surface flex items-center gap-2 overflow-x-auto pb-1 scrollbar-hide">
<div className="platform-remap-surface flex items-center gap-2 overflow-x-auto pb-1 scrollbar-hide xl:pb-0">
{FILTER_OPTIONS.map((option) => {
const count =
option.id === 'draft'
@@ -37,7 +37,7 @@ export function CustomWorldWorkTabs({
key={option.id}
type="button"
onClick={() => onChange(option.id)}
className={`platform-tab shrink-0 px-4 py-2 text-sm ${
className={`platform-tab shrink-0 px-4 py-2 text-sm xl:px-4 xl:py-1.5 xl:text-xs ${
activeFilter === option.id ? 'platform-tab--active' : ''
}`}
>

View File

@@ -1,4 +1,4 @@
import { AnimatePresence, motion } from 'motion/react';
import { AnimatePresence, motion } from 'motion/react';
import {
lazy,
Suspense,
@@ -43,7 +43,10 @@ import {
getBigFishCreationSession,
streamBigFishCreationMessage,
} from '../../services/big-fish-creation';
import { listBigFishWorks } from '../../services/big-fish-works';
import {
deleteBigFishWork,
listBigFishWorks,
} from '../../services/big-fish-works';
import {
startBigFishRuntimeRun,
submitBigFishRuntimeInput,
@@ -63,9 +66,10 @@ import {
startPuzzleRun,
swapPuzzlePieces,
} from '../../services/puzzle-runtime';
import { listPuzzleWorks } from '../../services/puzzle-works';
import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works';
import { deleteRpgEntryWorldProfile } from '../../services/rpg-entry';
import { getRpgEntryWorldGalleryDetailByCode } from '../../services/rpg-entry/rpgEntryLibraryClient';
import { deleteRpgCreationAgentSession } from '../../services/rpg-creation';
import { rpgCreationPreviewAdapter } from '../../services/rpg-creation/rpgCreationPreviewAdapter';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
@@ -1254,7 +1258,7 @@ export function PlatformEntryFlowShellImpl({
const handleDeletePublishedWork = useCallback(
(work: (typeof creationHubItems)[number]) => {
if (!work.profileId || deletingCreationWorkId) {
if (deletingCreationWorkId) {
return;
}
@@ -1265,18 +1269,22 @@ export function PlatformEntryFlowShellImpl({
if (!confirmed) {
return;
}
if (!work.profileId) {
platformBootstrap.setPlatformError('当前作品缺少 profileId暂时无法删除。');
return;
}
setDeletingCreationWorkId(work.workId);
platformBootstrap.setPlatformError(null);
void deleteRpgEntryWorldProfile(work.profileId)
.then(async (entries) => {
platformBootstrap.setSavedCustomWorldEntries(entries);
await platformBootstrap.refreshCustomWorldWorks().catch(() => []);
const deleteTask = work.profileId
? deleteRpgEntryWorldProfile(work.profileId).then(async (entries) => {
platformBootstrap.setSavedCustomWorldEntries(entries);
await platformBootstrap.refreshCustomWorldWorks().catch(() => []);
})
: work.sessionId
? deleteRpgCreationAgentSession(work.sessionId).then((items) => {
platformBootstrap.setCustomWorldWorkEntries(items);
})
: Promise.reject(new Error('当前 RPG 作品缺少可删除 ID。'));
void deleteTask
.then(async () => {
await platformBootstrap.refreshPublishedGallery().catch(() => []);
})
.catch((error) => {
@@ -1292,6 +1300,72 @@ export function PlatformEntryFlowShellImpl({
[deletingCreationWorkId, platformBootstrap, runProtectedAction],
);
const handleDeleteBigFishWork = useCallback(
(work: BigFishWorkSummary) => {
if (deletingCreationWorkId) {
return;
}
runProtectedAction(() => {
const confirmed = window.confirm(
`确认删除作品《${work.title}》吗?删除后会从你的作品列表中移除。`,
);
if (!confirmed) {
return;
}
setDeletingCreationWorkId(work.workId);
setBigFishError(null);
void deleteBigFishWork(work.sourceSessionId)
.then((response) => {
setBigFishWorks(response.items);
})
.catch((error) => {
setBigFishError(
resolveBigFishErrorMessage(error, '删除大鱼吃小鱼作品失败。'),
);
})
.finally(() => {
setDeletingCreationWorkId(null);
});
});
},
[deletingCreationWorkId, resolveBigFishErrorMessage, runProtectedAction],
);
const handleDeletePuzzleWork = useCallback(
(work: PuzzleWorkSummary) => {
if (deletingCreationWorkId) {
return;
}
runProtectedAction(() => {
const confirmed = window.confirm(
`确认删除作品《${work.levelName}》吗?删除后会从你的作品列表和公开广场中移除。`,
);
if (!confirmed) {
return;
}
setDeletingCreationWorkId(work.workId);
setPuzzleError(null);
void deletePuzzleWork(work.profileId)
.then((response) => {
setPuzzleWorks(response.items);
})
.catch((error) => {
setPuzzleError(resolvePuzzleErrorMessage(error, '删除拼图作品失败。'));
})
.finally(() => {
setDeletingCreationWorkId(null);
});
});
},
[deletingCreationWorkId, resolvePuzzleErrorMessage, runProtectedAction],
);
const openPuzzleDetail = useCallback(
async (profileId: string) => {
setIsPuzzleBusy(true);
@@ -1520,6 +1594,9 @@ export function PlatformEntryFlowShellImpl({
void startBigFishRunFromWork(item);
});
}}
onDeleteBigFish={(item) => {
handleDeleteBigFishWork(item);
}}
puzzleItems={puzzleWorks}
onOpenPuzzleDetail={(item) => {
runProtectedAction(() => {
@@ -1535,6 +1612,9 @@ export function PlatformEntryFlowShellImpl({
void startPuzzleRunFromProfile(profileId);
});
}}
onDeletePuzzle={(item) => {
handleDeletePuzzleWork(item);
}}
/>
);
@@ -2007,6 +2087,23 @@ export function PlatformEntryFlowShellImpl({
});
});
}}
onTestWorld={() => {
runProtectedAction(() => {
void enterWorldCoordinator
.enterWorldForTestFromCurrentResult()
.catch((error) => {
sessionController.setCustomWorldError(
resolveRpgCreationErrorMessage(
error,
'进入作品测试失败。',
),
);
});
});
}}
onPublishWorld={async () => {
await enterWorldCoordinator.publishCurrentResult();
}}
onGenerateEntity={
sessionController.isAgentDraftResultView
? async (kind) => {
@@ -2061,49 +2158,6 @@ export function PlatformEntryFlowShellImpl({
}
: undefined
}
onGenerateRoleAssets={
sessionController.isAgentDraftResultView
? async (roleId) => {
const latestSession =
await autosaveCoordinator.executeAgentActionAndWait({
action: 'generate_role_assets',
roleIds: [roleId],
});
const latestProfile = latestSession
? rpgCreationPreviewAdapter.buildPreviewFromSession(
latestSession,
)
: null;
if (latestProfile) {
sessionController.setGeneratedCustomWorldProfile(
latestProfile,
);
}
}
: undefined
}
onGenerateSceneAssets={
sessionController.isAgentDraftResultView
? async (sceneId, sceneKind) => {
const latestSession =
await autosaveCoordinator.executeAgentActionAndWait({
action: 'generate_scene_assets',
sceneIds: [sceneId],
sceneKind,
});
const latestProfile = latestSession
? rpgCreationPreviewAdapter.buildPreviewFromSession(
latestSession,
)
: null;
if (latestProfile) {
sessionController.setGeneratedCustomWorldProfile(
latestProfile,
);
}
}
: undefined
}
readOnly={false}
compactAgentResultMode={
sessionController.isAgentDraftResultView

View File

@@ -64,6 +64,7 @@ function buildDefaultAnimationPromptTextByKey(defaultText: string) {
function pickCachedAnimationPromptTextByKey(
cache: CharacterAssetWorkflowCache,
fallbackText: string,
preferFreshRoleText: boolean,
) {
const fromCache = cache.animationPromptTextByKey ?? {};
@@ -73,8 +74,9 @@ function pickCachedAnimationPromptTextByKey(
const legacyText = cache.animationPromptText?.trim();
return {
...result,
[action.animation]:
cachedText && !isLegacyGeneratedActionDescription(cachedText)
[action.animation]: preferFreshRoleText
? fallbackText
: cachedText && !isLegacyGeneratedActionDescription(cachedText)
? cachedText
: legacyText && !isLegacyGeneratedActionDescription(legacyText)
? legacyText
@@ -487,6 +489,7 @@ function buildAnimationPreviewCharacter(params: {
export interface RpgCreationRoleAssetStudioModalProps {
role: EditableCustomWorldRole;
roleKind: 'playable' | 'story';
cacheScopeId?: string;
onApply?: (nextRole: EditableCustomWorldRole) => void;
onPublishSuccess?: (
payload: {
@@ -509,6 +512,7 @@ export interface RpgCreationRoleAssetStudioModalProps {
export function RpgCreationRoleAssetStudioModal({
role,
roleKind,
cacheScopeId,
onApply,
onPublishSuccess,
onClose,
@@ -746,13 +750,16 @@ export function RpgCreationRoleAssetStudioModal({
setSaveStatus(null);
setIsHydratingCache(true);
void fetchCharacterWorkflowCache(baseRole.id)
void fetchCharacterWorkflowCache(baseRole.id, cacheScopeId)
.then((result) => {
if (cancelled || !result.cache) {
return;
}
const cache = result.cache;
if (cacheScopeId && cache.cacheScopeId !== cacheScopeId) {
return;
}
const nextRole = mergeRole(baseRole, {
imageSrc: cache.imageSrc ?? baseRole.imageSrc,
generatedVisualAssetId:
@@ -765,7 +772,8 @@ export function RpgCreationRoleAssetStudioModal({
});
setWorkingRole(nextRole);
setVisualPromptText(
cache.visualPromptText &&
!baseRole.visualDescription?.trim() &&
cache.visualPromptText &&
!isLegacyGeneratedVisualDescription(cache.visualPromptText)
? cache.visualPromptText
: initialPromptBundle.visualPromptText,
@@ -774,6 +782,7 @@ export function RpgCreationRoleAssetStudioModal({
pickCachedAnimationPromptTextByKey(
cache,
initialPromptBundle.animationPromptText,
Boolean(baseRole.actionDescription?.trim()),
),
);
setVisualDrafts(cache.visualDrafts ?? []);
@@ -798,7 +807,7 @@ export function RpgCreationRoleAssetStudioModal({
return () => {
cancelled = true;
};
}, [baseRole, initialPromptBundle, roleSnapshotKey]);
}, [baseRole, cacheScopeId, initialPromptBundle, roleSnapshotKey]);
useEffect(() => {
if (isHydratingCache) {
@@ -808,8 +817,10 @@ export function RpgCreationRoleAssetStudioModal({
const timer = window.setTimeout(() => {
const payload: CharacterAssetWorkflowCache = {
characterId: workingRole.id,
cacheScopeId,
visualPromptText,
animationPromptText,
animationPromptTextByKey,
visualDrafts,
selectedVisualDraftId,
selectedAnimation,
@@ -829,9 +840,11 @@ export function RpgCreationRoleAssetStudioModal({
};
}, [
animationPromptText,
animationPromptTextByKey,
isHydratingCache,
selectedAnimation,
selectedVisualDraftId,
cacheScopeId,
visualDrafts,
visualPromptText,
workingRole.animationMap,
@@ -1137,7 +1150,12 @@ export function RpgCreationRoleAssetStudioModal({
workingRoleGeneratedVisualAssetId={workingRole.generatedVisualAssetId}
workingRoleImageSrc={workingRole.imageSrc}
workingRoleName={workingRole.name}
onAnimationPromptChange={setAnimationPromptText}
onAnimationPromptChange={(value) => {
setAnimationPromptTextByKey((current) => ({
...current,
[selectedAnimation]: value,
}));
}}
onGenerateAnimation={() => {
void handleGenerateAnimation();
}}

View File

@@ -202,6 +202,10 @@ function dedupeTextValues(values: Array<string | null | undefined>) {
];
}
function compactTextList(values: Array<string | null | undefined>) {
return values.map((value) => value?.trim() ?? '').filter(Boolean);
}
function moveArrayItem<T>(values: T[], fromIndex: number, toIndex: number) {
if (
fromIndex < 0 ||
@@ -326,19 +330,26 @@ function buildDefaultSceneActBlueprint(params: {
const encounterNpcIds = dedupeTextValues(params.encounterNpcIds).slice(0, 1);
const actTitle = buildDefaultSceneActTitle(params.index);
const sceneLabel = params.sceneName.trim() || '当前场景';
const sceneSummary = params.sceneSummary.trim();
const stageCoverage = buildSceneActStageCoverage(params.index, params.actCount);
const actSummary =
params.index === 0
? `玩家会在${sceneLabel}接住这一章的开场入口。`
: params.index >= params.actCount - 1
? `${sceneLabel}这一章会在这里把下一步方向抛给玩家。`
: `${sceneLabel}的主要压力会在这一幕继续加深。`;
return {
id: `${params.sceneId}-act-${params.index + 1}`,
sceneId: params.sceneId,
title: actTitle,
summary:
params.index === 0
? `玩家会在${sceneLabel}接住这一章的开场入口。`
: params.index >= params.actCount - 1
? `${sceneLabel}这一章会在这里把下一步方向抛给玩家。`
: `${sceneLabel}的主要压力会在这一幕继续加深。`,
summary: actSummary,
stageCoverage,
backgroundPromptText: compactTextList([
`${sceneLabel}${actTitle}背景`,
sceneSummary,
actSummary,
]).join(''),
backgroundImageSrc: params.backgroundImageSrc || undefined,
encounterNpcIds,
primaryNpcId: encounterNpcIds[0] ?? '',
@@ -461,6 +472,9 @@ function sanitizeSceneChapterBlueprint(params: {
title: currentAct?.title?.trim() || fallbackAct.title,
summary: currentAct?.summary?.trim() || fallbackAct.summary,
stageCoverage: buildSceneActStageCoverage(index, targetActCount),
backgroundPromptText:
currentAct?.backgroundPromptText?.trim() ||
fallbackAct.backgroundPromptText,
backgroundImageSrc:
currentAct?.backgroundImageSrc?.trim() ||
params.fallbackImageSrc ||
@@ -2391,15 +2405,18 @@ const FIXED_SCENE_IMAGE_SIZE = '1280*720';
function SceneImageGenerationModal({
profile,
landmark,
initialPromptText,
onApply,
onClose,
}: {
profile: CustomWorldProfile;
landmark: CustomWorldLandmark;
initialPromptText?: string;
onApply: (result: CustomWorldSceneImageResult) => void;
onClose: () => void;
}) {
const [userPrompt, setUserPrompt] = useDraft(
initialPromptText?.trim() ||
landmark.visualDescription?.trim() ||
landmark.description.trim() ||
landmark.name.trim(),
@@ -2504,12 +2521,12 @@ function SceneImageGenerationModal({
<ModalShell
title={`智能生成:${landmark.name || '当前场景'}`}
onClose={handleRequestClose}
panelClassName="sm:max-w-5xl"
panelClassName="sm:max-w-4xl"
overlayClassName="z-[99]"
disableClose={isGenerating}
usePixelFont
>
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.1fr)_minmax(20rem,0.9fr)]">
<div className="grid gap-5 lg:grid-cols-[minmax(0,1.15fr)_minmax(17rem,0.85fr)]">
<div className="space-y-4">
<Field label="画面内容描述">
<TextArea
@@ -2640,6 +2657,7 @@ function SceneImageGenerationModal({
function SceneActBackgroundModal({
profile,
landmark,
act,
actLabel,
currentImageSrc,
fallbackImageSrc,
@@ -2648,6 +2666,7 @@ function SceneActBackgroundModal({
}: {
profile: CustomWorldProfile;
landmark: CustomWorldLandmark;
act: SceneActBlueprint;
actLabel: string;
currentImageSrc?: string | null;
fallbackImageSrc?: string | null;
@@ -2738,6 +2757,10 @@ function SceneActBackgroundModal({
<SceneImageGenerationModal
profile={profile}
landmark={landmark}
initialPromptText={
act.backgroundPromptText?.trim() ||
compactTextList([act.title, act.summary, act.actGoal]).join('')
}
onApply={(result) => {
setDraftImageSrc(result.imageSrc);
}}
@@ -3027,7 +3050,7 @@ function CoverImageGenerationModal({
<TextArea
value={userPrompt}
onChange={(value) => setUserPrompt(value)}
rows={7}
rows={5}
placeholder="例如:海雾压进旧码头,主角站在残灯与潮水之间,整体像一张正式 RPG 作品封面。"
/>
</Field>
@@ -3077,7 +3100,7 @@ function CoverImageGenerationModal({
</div>
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
<div className="rounded-2xl border border-white/8 bg-black/18 p-2.5">
<CustomWorldCoverArtwork
imageSrc={previewImageSrc}
title={profile.name}
@@ -3088,7 +3111,7 @@ function CoverImageGenerationModal({
characterImageSrcs={
latestResult ? [] : initialPresentation.characterImageSrcs
}
className="aspect-[16/9] rounded-2xl"
className="aspect-[16/9] max-h-[14rem] rounded-2xl"
/>
</div>
@@ -3249,77 +3272,83 @@ export function WorldCoverEditor({
return (
<>
<ModalShell title="编辑作品封面" onClose={onClose}>
<div className="space-y-4">
<div className="rounded-2xl border border-white/8 bg-black/18 p-3">
<ModalShell
title="编辑作品封面"
onClose={onClose}
panelClassName="sm:max-w-3xl"
>
<div className="grid gap-4 md:grid-cols-[minmax(0,0.95fr)_minmax(17rem,1.05fr)]">
<div className="rounded-2xl border border-white/8 bg-black/18 p-2.5">
<CustomWorldCoverArtwork
imageSrc={previewPresentation.imageSrc}
title={profile.name}
fallbackLabel={profile.name.slice(0, 4) || '封面'}
renderMode={previewPresentation.renderMode}
characterImageSrcs={previewPresentation.characterImageSrcs}
className="aspect-[16/9] rounded-2xl"
className="aspect-[16/9] max-h-[13rem] rounded-2xl"
/>
</div>
<div className="flex flex-wrap gap-3">
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-200">
{draftCover.sourceType === 'uploaded'
? '当前为上传封面'
: draftCover.sourceType === 'generated'
? '当前为 AI 封面'
: '当前为默认封面'}
</span>
</div>
<div className="space-y-4">
<div className="flex flex-wrap gap-3">
<span className="rounded-full border border-white/10 bg-black/24 px-3 py-1 text-[11px] text-zinc-200">
{draftCover.sourceType === 'uploaded'
? '当前为上传封面'
: draftCover.sourceType === 'generated'
? '当前为 AI 封面'
: '当前为默认封面'}
</span>
</div>
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
<div className="mb-3 text-[11px] font-bold tracking-[0.14em] text-zinc-300">
<label className="block rounded-2xl border border-dashed border-white/12 bg-black/20 px-4 py-4">
<div className="mb-3 text-[11px] font-bold tracking-[0.14em] text-zinc-300">
</div>
<div className="mb-3 text-xs leading-5 text-zinc-400">
pngjpgwebp 16:9
</div>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => {
void handleUploadCover(event);
}}
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
/>
</label>
<div className="flex flex-wrap gap-3">
<ActionButton
label="AI 生成"
onClick={() => setIsGenerating(true)}
tone="sky"
/>
<ActionButton
label="重置为默认"
onClick={() =>
setDraftCover(buildDefaultCustomWorldCoverProfile(profile))
}
disabled={draftCover.sourceType === 'default'}
/>
</div>
<div className="mb-3 text-xs leading-5 text-zinc-400">
pngjpgwebp 16:9
</div>
<input
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={(event) => {
void handleUploadCover(event);
{uploadError ? (
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{uploadError}
</div>
) : null}
<SaveBar
onClose={onClose}
onSave={() => {
onSaveProfile({
...profile,
cover: draftCover,
});
onClose();
}}
className="w-full text-xs text-zinc-300 file:mr-3 file:rounded-lg file:border-0 file:bg-sky-500 file:px-3 file:py-1.5 file:text-xs file:font-medium file:text-black"
/>
</label>
<div className="flex flex-wrap gap-3">
<ActionButton
label="AI 生成"
onClick={() => setIsGenerating(true)}
tone="sky"
/>
<ActionButton
label="重置为默认"
onClick={() =>
setDraftCover(buildDefaultCustomWorldCoverProfile(profile))
}
disabled={draftCover.sourceType === 'default'}
/>
</div>
{uploadError ? (
<div className="rounded-2xl border border-rose-400/18 bg-rose-500/10 px-4 py-3 text-sm leading-6 text-rose-100">
{uploadError}
</div>
) : null}
<SaveBar
onClose={onClose}
onSave={() => {
onSaveProfile({
...profile,
cover: draftCover,
});
onClose();
}}
/>
</div>
</ModalShell>
@@ -4787,6 +4816,7 @@ export function PlayableNpcEditor({
<RpgCreationRoleAssetStudioModal
role={draft}
roleKind="playable"
cacheScopeId={profile.id}
onApply={(nextRole) =>
setDraft((current) => ({
...current,
@@ -5083,6 +5113,7 @@ export function StoryNpcEditor({
<RpgCreationRoleAssetStudioModal
role={draft}
roleKind="story"
cacheScopeId={profile.id}
onApply={(nextRole) =>
setDraft((current) => ({
...current,
@@ -5781,6 +5812,7 @@ export function LandmarkEditor({
activeSceneActBackgroundDraft.title.trim() ||
buildDefaultSceneActTitle(activeSceneActBackgroundIndex)
}
act={activeSceneActBackgroundDraft}
currentImageSrc={activeSceneActBackgroundDraft.backgroundImageSrc}
fallbackImageSrc={resolvedDraftImageSrc}
onApply={(imageSrc) =>

View File

@@ -2,8 +2,10 @@ import { X } from 'lucide-react';
import { type ReactNode, useState } from 'react';
import { createPortal } from 'react-dom';
import { resolveCustomWorldCoverPresentation } from '../../services/customWorldCover';
import type { CustomWorldProfile } from '../../types';
import { useAuthUi } from '../auth/AuthUiContext';
import { CustomWorldCoverArtwork } from '../CustomWorldCoverArtwork';
function SmallButton({
children,
@@ -32,14 +34,25 @@ function SmallButton({
);
}
function PublishBlockersDialog({
function PublishPanelDialog({
blockers,
profile,
publishReady,
isPublishing,
onClose,
onEditCover,
onPublish,
}: {
blockers: string[];
profile: CustomWorldProfile;
publishReady: boolean;
isPublishing: boolean;
onClose: () => void;
onEditCover: () => void;
onPublish: () => void;
}) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const coverPresentation = resolveCustomWorldCoverPresentation(profile);
if (typeof document === 'undefined') {
return null;
@@ -57,17 +70,17 @@ function PublishBlockersDialog({
<div
role="dialog"
aria-modal="true"
aria-label="发布前检查"
className="platform-modal-shell platform-remap-surface flex max-h-[min(88vh,42rem)] w-full max-w-lg flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
aria-label="发布作品"
className="platform-modal-shell platform-remap-surface flex max-h-[min(90vh,46rem)] w-full max-w-4xl flex-col overflow-hidden rounded-t-[1.75rem] shadow-[0_24px_80px_rgba(0,0,0,0.55)] sm:rounded-[1.75rem]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-center justify-between gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
<div>
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
</div>
<div className="mt-1 text-sm leading-6 text-[var(--platform-text-soft)]">
{blockers.length}
</div>
</div>
<button
@@ -80,29 +93,72 @@ function PublishBlockersDialog({
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="space-y-2">
{blockers.map((blocker, index) => (
<div
key={`publish-blocker-${index}-${blocker}`}
className="platform-banner platform-banner--warning text-sm leading-6"
>
<div className="text-xs font-semibold tracking-[0.14em] text-[var(--platform-warm-text)] opacity-80">
{index + 1}
</div>
<div className="mt-1 text-[var(--platform-text-strong)]">
{blocker}
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_minmax(18rem,0.78fr)]">
<div className="space-y-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
))}
{blockers.length > 0 ? (
<div className="space-y-2">
{blockers.map((blocker, index) => (
<div
key={`publish-blocker-${index}-${blocker}`}
className="platform-banner platform-banner--warning text-sm leading-6"
>
<div className="text-xs font-semibold tracking-[0.14em] text-[var(--platform-warm-text)] opacity-80">
{index + 1}
</div>
<div className="mt-1 text-[var(--platform-text-strong)]">
{blocker}
</div>
</div>
))}
</div>
) : (
<div className="platform-banner platform-banner--success text-sm leading-6">
</div>
)}
</div>
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-bold tracking-[0.18em] text-[var(--platform-text-soft)]">
</div>
<span className="platform-pill platform-pill--neutral px-2.5 py-1 text-[10px]">
{coverPresentation.sourceType === 'uploaded'
? '上传封面'
: coverPresentation.sourceType === 'generated'
? 'AI封面'
: '默认封面'}
</span>
</div>
<div className="platform-subpanel rounded-[1.25rem] p-2">
<CustomWorldCoverArtwork
imageSrc={coverPresentation.imageSrc}
title={profile.name}
fallbackLabel={profile.name.slice(0, 4) || '封面'}
renderMode={coverPresentation.renderMode}
characterImageSrcs={coverPresentation.characterImageSrcs}
className="aspect-[16/9] max-h-[15rem] rounded-[1rem]"
/>
</div>
<SmallButton onClick={onEditCover} tone="sky">
</SmallButton>
</div>
</div>
</div>
<div className="flex justify-end border-t border-[var(--platform-subpanel-border)] px-5 py-4">
<div className="flex flex-col-reverse gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4 sm:flex-row sm:justify-end">
<SmallButton onClick={onClose}></SmallButton>
<button
type="button"
onClick={onClose}
className="platform-button platform-button--primary"
onClick={onPublish}
disabled={!publishReady || isPublishing}
className={`platform-button platform-button--primary ${!publishReady || isPublishing ? 'cursor-not-allowed opacity-55' : ''}`}
>
{isPublishing ? '发布中...' : '发布到广场'}
</button>
</div>
</div>
@@ -118,6 +174,9 @@ interface RpgCreationResultActionBarProps {
onContinueExpand?: () => void;
onEditSetting?: () => void;
onEnterWorld?: () => void;
onOpenCoverEditor?: () => void;
onPublishWorld?: () => Promise<void> | void;
onTestWorld?: () => void;
onRegenerate?: () => void;
profile: CustomWorldProfile;
regenerateActionLabel: string;
@@ -132,6 +191,9 @@ export function RpgCreationResultActionBar({
onContinueExpand,
onEditSetting,
onEnterWorld,
onOpenCoverEditor,
onPublishWorld,
onTestWorld,
onRegenerate,
profile,
regenerateActionLabel,
@@ -140,6 +202,7 @@ export function RpgCreationResultActionBar({
}: RpgCreationResultActionBarProps) {
const [showPublishBlockersDialog, setShowPublishBlockersDialog] =
useState(false);
const [isPublishing, setIsPublishing] = useState(false);
// 结果页只在用户点击发布动作时展示阻断项,不做吸底常驻提示。
const handleEnterWorld = () => {
@@ -151,6 +214,20 @@ export function RpgCreationResultActionBar({
onEnterWorld?.();
};
const handlePublish = async () => {
if (!publishReady || isPublishing || !onPublishWorld) {
return;
}
setIsPublishing(true);
try {
await onPublishWorld();
setShowPublishBlockersDialog(false);
} finally {
setIsPublishing(false);
}
};
return (
<div className="mt-4 flex flex-col gap-3">
{profile.generationStatus === 'key_only' ? (
@@ -176,7 +253,26 @@ export function RpgCreationResultActionBar({
</SmallButton>
) : null}
{onEnterWorld ? (
{onTestWorld ? (
<button
type="button"
onClick={onTestWorld}
disabled={isGenerating}
className={`platform-button platform-button--secondary ${isGenerating ? 'opacity-55' : ''}`}
>
</button>
) : null}
{onPublishWorld ? (
<button
type="button"
onClick={() => setShowPublishBlockersDialog(true)}
disabled={isGenerating}
className={`platform-button platform-button--primary ${isGenerating ? 'opacity-55' : ''}`}
>
</button>
) : onEnterWorld ? (
<button
type="button"
onClick={handleEnterWorld}
@@ -188,13 +284,18 @@ export function RpgCreationResultActionBar({
) : null}
</div>
{showPublishBlockersDialog ? (
<PublishBlockersDialog
blockers={
publishBlockers.length > 0
? publishBlockers
: ['当前草稿还没有通过发布门槛,请先补齐必要内容。']
}
<PublishPanelDialog
blockers={publishBlockers}
profile={profile}
publishReady={publishReady}
isPublishing={isPublishing}
onClose={() => setShowPublishBlockersDialog(false)}
onEditCover={() => {
onOpenCoverEditor?.();
}}
onPublish={() => {
void handlePublish();
}}
/>
) : null}
</div>

View File

@@ -26,12 +26,13 @@ export interface RpgCreationResultViewProps {
onRegenerate?: () => void;
onContinueExpand?: () => void;
onEnterWorld?: () => void;
onOpenCoverEditor?: () => void;
onPublishWorld?: () => Promise<void> | void;
onTestWorld?: () => void;
onDeleteEntities?: (kind: 'story' | 'landmark', ids: string[]) => Promise<void> | void;
onGenerateEntity?:
| ((kind: EntityGenerationKind) => Promise<{ profile?: CustomWorldProfile | null } | void> | { profile?: CustomWorldProfile | null } | void)
| undefined;
onGenerateRoleAssets?: (roleId: string) => Promise<void> | void;
onGenerateSceneAssets?: (sceneId: string, sceneKind: 'camp' | 'landmark') => Promise<void> | void;
onProfileChange: (profile: CustomWorldProfile) => void;
readOnly?: boolean;
backLabel?: string;
@@ -63,11 +64,12 @@ export function RpgCreationResultView({
onEditSetting,
onRegenerate: triggerRegenerate,
onContinueExpand,
onOpenCoverEditor,
onPublishWorld,
onTestWorld,
onDeleteEntities,
onEnterWorld,
onGenerateEntity,
onGenerateRoleAssets,
onGenerateSceneAssets,
onProfileChange,
readOnly = false,
backLabel = '返回',
@@ -142,8 +144,6 @@ export function RpgCreationResultView({
onProfileChange={onProfileChange}
onDeleteStoryNpcs={deleteStoryNpcs}
onDeleteLandmarks={deleteLandmarks}
onGenerateRoleAssets={onGenerateRoleAssets ? (roleId) => { void onGenerateRoleAssets(roleId); } : undefined}
onGenerateSceneAssets={onGenerateSceneAssets ? (sceneId, sceneKind) => { void onGenerateSceneAssets(sceneId, sceneKind); } : undefined}
createActionLabel={
readOnly || (compactAgentResultMode && !onGenerateEntity)
? undefined
@@ -223,6 +223,11 @@ export function RpgCreationResultView({
onContinueExpand={onContinueExpand}
onEditSetting={onEditSetting}
onEnterWorld={onEnterWorld}
onOpenCoverEditor={
onOpenCoverEditor ?? (() => setEditorTarget({ kind: 'cover' }))
}
onPublishWorld={onPublishWorld}
onTestWorld={onTestWorld}
onRegenerate={triggerRegenerate ? handleRegenerate : undefined}
profile={profile}
regenerateActionLabel={regenerateActionLabel}

View File

@@ -40,7 +40,7 @@ export function useRpgCreationEnterWorld(
setGeneratedCustomWorldProfile,
} = params;
const enterWorldFromCurrentResult = useCallback(async () => {
const enterWorldForTestFromCurrentResult = useCallback(async () => {
if (!generatedCustomWorldProfile) {
return;
}
@@ -50,6 +50,32 @@ export function useRpgCreationEnterWorld(
return;
}
const latestResult = await syncAgentDraftResultProfile(
generatedCustomWorldProfile,
);
const latestProfile =
latestResult.profile ?? agentSessionProfile ?? generatedCustomWorldProfile;
setGeneratedCustomWorldProfile(latestProfile);
handleCustomWorldSelect(latestProfile);
}, [
activeAgentSessionId,
agentSessionProfile,
generatedCustomWorldProfile,
handleCustomWorldSelect,
isAgentDraftResultView,
setGeneratedCustomWorldProfile,
syncAgentDraftResultProfile,
]);
const publishCurrentResult = useCallback(async () => {
if (!generatedCustomWorldProfile) {
return null;
}
if (!isAgentDraftResultView || !activeAgentSessionId) {
return generatedCustomWorldProfile;
}
const latestResult = await syncAgentDraftResultProfile(
generatedCustomWorldProfile,
);
@@ -63,8 +89,7 @@ export function useRpgCreationEnterWorld(
latestSession.resultPreview?.canEnterWorld;
if (canEnterPublishedWorld) {
handleCustomWorldSelect(latestProfile);
return;
return latestProfile;
}
const publishedSession = await executePublishWorld();
@@ -73,7 +98,7 @@ export function useRpgCreationEnterWorld(
latestProfile;
setGeneratedCustomWorldProfile(publishedProfile);
handleCustomWorldSelect(publishedProfile);
return publishedProfile;
}, [
activeAgentSessionId,
agentSession,
@@ -86,7 +111,16 @@ export function useRpgCreationEnterWorld(
syncAgentDraftResultProfile,
]);
const enterWorldFromCurrentResult = useCallback(async () => {
const publishedProfile = await publishCurrentResult();
if (publishedProfile) {
handleCustomWorldSelect(publishedProfile);
}
}, [handleCustomWorldSelect, publishCurrentResult]);
return {
enterWorldFromCurrentResult,
enterWorldForTestFromCurrentResult,
publishCurrentResult,
};
}

View File

@@ -7,6 +7,12 @@ const BIG_FISH_WORKS_READ_RETRY: ApiRetryOptions = {
baseDelayMs: 120,
maxDelayMs: 360,
};
const BIG_FISH_WORKS_WRITE_RETRY: ApiRetryOptions = {
maxRetries: 1,
baseDelayMs: 120,
maxDelayMs: 360,
retryUnsafeMethods: true,
};
/**
* 读取当前用户的大鱼吃小鱼创作作品列表。
@@ -24,6 +30,23 @@ export async function listBigFishWorks() {
);
}
/**
* 删除当前用户的大鱼吃小鱼作品,并返回删除后的作品列表。
*/
export async function deleteBigFishWork(sessionId: string) {
return requestJson<BigFishWorksResponse>(
`${BIG_FISH_WORKS_API_BASE}/${encodeURIComponent(sessionId)}`,
{
method: 'DELETE',
},
'删除大鱼吃小鱼作品失败',
{
retry: BIG_FISH_WORKS_WRITE_RETRY,
},
);
}
export const bigFishWorksClient = {
delete: deleteBigFishWork,
list: listBigFishWorks,
};

View File

@@ -1 +1,5 @@
export { bigFishWorksClient, listBigFishWorks } from './bigFishWorksClient';
export {
bigFishWorksClient,
deleteBigFishWork,
listBigFishWorks,
} from './bigFishWorksClient';

View File

@@ -1,5 +1,6 @@
export {
getPuzzleWorkDetail,
deletePuzzleWork,
listPuzzleWorks,
puzzleWorksClient,
updatePuzzleWork,

View File

@@ -78,7 +78,24 @@ export async function updatePuzzleWork(
);
}
/**
* 删除当前用户的拼图作品,并返回删除后的作品列表。
*/
export async function deletePuzzleWork(profileId: string) {
return requestJson<PuzzleWorksResponse>(
`${PUZZLE_WORKS_API_BASE}/${encodeURIComponent(profileId)}`,
{
method: 'DELETE',
},
'删除拼图作品失败',
{
retry: PUZZLE_WORKS_WRITE_RETRY,
},
);
}
export const puzzleWorksClient = {
delete: deletePuzzleWork,
getDetail: getPuzzleWorkDetail,
list: listPuzzleWorks,
update: updatePuzzleWork,

View File

@@ -43,6 +43,7 @@ export {
rpgCreationPreviewAdapter,
} from './rpgCreationPreviewAdapter';
export {
deleteRpgCreationAgentSession,
listRpgCreationWorks,
rpgCreationWorkClient,
} from './rpgCreationWorkClient';

View File

@@ -11,9 +11,20 @@ export async function listRpgCreationWorks() {
return Array.isArray(response?.items) ? response.items : [];
}
export async function deleteRpgCreationAgentSession(sessionId: string) {
const response = await requestRpgCreationRuntimeJson<ListRpgCreationWorksResponse>(
`/custom-world/agent/sessions/${encodeURIComponent(sessionId)}`,
{ method: 'DELETE' },
'删除 RPG 草稿失败',
);
return Array.isArray(response?.items) ? response.items : [];
}
/**
* 工作包 D 把作品列表请求正式迁入 RPG 创作域 client。
*/
export const rpgCreationWorkClient = {
deleteAgentSession: deleteRpgCreationAgentSession,
listWorks: listRpgCreationWorks,
};

View File

@@ -344,6 +344,7 @@ export interface SceneActBlueprint {
title: string;
summary: string;
stageCoverage: SceneActStage[];
backgroundPromptText?: string | null;
backgroundImageSrc?: string | null;
backgroundAssetId?: string | null;
encounterNpcIds: string[];