diff --git a/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md b/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md index 6e3bd413..1738becb 100644 --- a/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md +++ b/docs/design/PLATFORM_CREATE_TAB_CREATIVE_AGENT_HOME_2026-05-05.md @@ -44,3 +44,15 @@ 3. 创作 Tab 默认显示拼图创作表单内容,且不显示旧“Hi, 朋友”、输入框或智能创作快捷按钮。 4. 隐藏的智能创作类型不出现在模板 Tab、旧选择弹层和创作 Hub 卡片中。 5. 草稿页返回创作页后仍回到同一模板入口,并可保留拼图表单草稿内容。 + +## 5. 嵌入式表单 UI 细节 + +2026-05-10 补充:抓大鹅与视觉小说作为创作 Tab 内嵌表单时,风格类横滑选择器应统一使用浅底卡片、柔和玫瑰色选中态和小圆点确认标记。不要使用大面积黑色渐变、黑底胶囊标签或高饱和红色外框,以免在输入框下方误读为错误提示。 + +嵌入式表单控件保持以下口径: + +1. 大文本输入框使用白底、低饱和边框和轻量 focus ring。 +2. 风格选择区作为独立浅色分组承载横滑卡片,移动端只横向滚动,不挤压生成按钮。 +3. 风格卡标签使用浅底胶囊,保证图片仍是主体。 +4. 难度等分段选项可以使用主品牌色,但选中态需要降低阴影和饱和度。 +5. UI 中不补充玩法规则说明文案,保持创作入口清爽。 diff --git a/docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md b/docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md index f8fe9268..1e894d92 100644 --- a/docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md +++ b/docs/technical/MATCH3D_DRAFT_ASSET_GENERATION_PIPELINE_2026-05-10.md @@ -19,7 +19,7 @@ 生成页步骤固定为: ```text -生成物品名称 -> 生成素材图 -> 切割独立图片 -> 生成 3D 模型 -> 上传图片与模型资产 -> 写入草稿页 +生成游戏名称 -> 生成物品名称 -> 生成素材图 -> 切割独立图片 -> 上传图片资产 -> 写入草稿页 ``` 生成页只展示题材和物品数量,不展示玩法规则说明。 @@ -32,15 +32,17 @@ 1. 读取 session config。 2. 将本次 MVP 的 `clearCount` 固定为 `3`,并同步用于草稿编译。 -3. 调用文本模型生成 `3` 个题材下的短物品名称。 -4. 调用项目当前图片链路 VectorEngine `gpt-image-2-all` 生成一张 `1:1` 素材图,提示词必须合入入口页选择的 `assetStylePrompt`。历史 `nanobanana2` 图片选项当前按项目统一决策回落到 VectorEngine,不重新接入 APIMart 图片网关。 -5. 将素材图按 `n*n` 网格切割成独立图片。当前 `3` 件物品使用 `2*2` 网格,取前 `3` 格。 -6. 将素材图和每张独立图片上传到 OSS,其中独立图片作为 Rodin 图生模型参考图。 -7. 对每张独立图片调用 Hyper3D Rodin Gen-2 图生模型,等待任务完成,读取 GLB 下载文件并转存到 `generated-match3d-assets`。 -8. 调用现有 SpacetimeDB compile procedure 写入草稿,并把本次生成的独立物品图片与模型文件引用序列化写入 `match3d_work_profile.generated_item_assets_json`。这一步对标拼图的 `save_puzzle_generated_images`:生成资产不能只挂在本次 HTTP response 上,否则退出结果页后从草稿架读取 `getMatch3DWorkDetail` 会丢失素材列表。 -9. 在 HTTP 返回的 draft/profile DTO 中附带本次生成的素材资产预览信息,模型生成成功时状态为 `model_ready`;后续重进草稿页时从 work profile 的持久化 `generatedItemAssets` 恢复同一批素材。 +3. 基于入口页题材设定文本调用文本模型生成作品元信息。模型固定请求 `gpt-4o`,只返回 JSON,其中 `gameName` 为 4 到 12 个中文字符的游戏名称,`tags` 为 3 到 6 个中文短标签;`summary` 首版必须保持空字符串,结果页 `作品描述` 默认留给用户填写。 +4. 调用文本模型生成 `3` 个题材下的短物品名称。 +5. 调用项目当前图片链路 VectorEngine `gpt-image-2-all` 生成一张 `1:1` 素材图,提示词必须合入入口页选择的 `assetStylePrompt`。历史 `nanobanana2` 图片选项当前按项目统一决策回落到 VectorEngine,不重新接入 APIMart 图片网关。 +6. 将素材图按 `n*n` 网格切割成独立图片。当前 `3` 件物品使用 `2*2` 网格,取前 `3` 格。 +7. 将素材图和每张独立图片上传到 OSS,其中独立图片作为草稿页素材预览和后续 Rodin 图生模型参考图。 +8. 调用现有 SpacetimeDB compile procedure 写入草稿,并把本次生成的独立物品图片列表序列化写入 `match3d_work_profile.generated_item_assets_json`。这一步对标拼图的 `save_puzzle_generated_images`:生成资产不能只挂在本次 HTTP response 上,否则退出结果页后从草稿架读取 `getMatch3DWorkDetail` 会丢失素材列表。 +9. 在 HTTP 返回的 draft/profile DTO 中附带本次生成的素材资产预览信息,独立图片状态为 `image_ready`,模型字段保持为空;后续重进草稿页时从 work profile 的持久化 `generatedItemAssets` 恢复同一批素材。 -草稿生成阶段会调用 Hyper3D Rodin,并等待 GLB 模型文件可下载;不得把上游下载 URL 直接写入 Match3D profile,必须先转存 OSS,再保存 `/generated-match3d-assets/...` 引用。结果页 `3D素材` Tab 的重新生成按钮仍可手动触发 Rodin 任务,但正式持久化仍以后端草稿生成链路写入的模型文件为准。 +若文本模型不可用或返回无法解析,后端必须降级为 `{themeText}抓大鹅` 与本地标签兜底,不阻断素材生成;但描述仍保持空字符串。 + +草稿生成阶段不调用 Hyper3D Rodin,不等待 `subscriptionKey`,也不下载模型文件;Rodin 生成只在结果页 `3D素材` Tab 由用户手动触发。手动生成得到的上游下载 URL 仍不得直接写入 Match3D profile,后续正式资产绑定以独立技术方案为准。 ## 4. 图片提示词 @@ -82,12 +84,11 @@ generated-match3d-assets ```text generated-match3d-assets/{sessionId}/{profileId}/material-sheet/{taskId}/sheet.png generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/image.png -generated-match3d-assets/{sessionId}/{profileId}/items/{itemSlug}/model/{taskUuid}/model.glb ``` `itemSlug` 必须带 `itemId` 前缀,例如 `match3d-item-1-item`。中文物品名清洗后可能都退回 `item`,不能只用物品名做路径,否则多张切割图会写到同一个 object key,导致草稿页预览图全部一致。 -HTTP DTO 同时返回 `imageSrc`、`imageObjectKey`、`modelSrc`、`modelObjectKey`、`modelFileName`、`taskUuid`、`subscriptionKey` 和 `status = model_ready`。前端模型预览不得直接请求裸 `/generated-match3d-assets/...` 路径;需要通过 `/api/assets/read-bytes` 读取模型字节,转成 Blob URL 后交给 Three.js GLTFLoader 加载,以绕开私有 bucket CORS。 +HTTP DTO 同时返回 `imageSrc`、`imageObjectKey`、空的 `modelSrc`、空的 `modelObjectKey` 和 `status = image_ready`。前端预览图片继续走 `ResolvedAssetImage` 换签;后续手动生成的模型文件也必须通过 `useResolvedAssetReadUrl` / `/api/assets/read-url` 换签后打开,不直接请求裸 `/generated-match3d-assets/...` 路径。 ## 6. 自动保存与草稿恢复 @@ -101,6 +102,13 @@ HTTP DTO 同时返回 `imageSrc`、`imageObjectKey`、`modelSrc`、`modelObjectK 因此 `map_match3d_work_summary_response` / `map_match3d_work_profile_response` 需要从 work profile snapshot 反序列化 `generated_item_assets_json` 并输出 `generatedItemAssets`。前端 `Match3DResultView` 仍保持现有优先级:本次生成流程内有 `draft.generatedItemAssets` 时用 draft;从草稿架重进没有 draft 时,用 `profile.generatedItemAssets`;两者都没有才回退到默认 3D 素材占位。 +结果页 `作品信息` Tab 字段命名对齐拼图草稿: + +1. `作品名称` 对应 Match3D `gameName`。 +2. `作品描述` 对应 Match3D `summary`,草稿生成默认空。 +3. `作品标签` 对应 Match3D `tags`,可由 AI 首次生成并允许用户继续编辑。 +4. 封面图与作品名称不再拆成左右两个大模块;封面只作为同一 Tab 内的可选上传入口,避免和作品基础信息割裂。 + `3D素材` 详情页只保留: 1. 模型预览区:优先加载 `modelSrc` 对应 GLB,支持拖动旋转;没有模型时展示空预览。 @@ -127,4 +135,4 @@ cargo check -p spacetime-client --manifest-path server-rs\Cargo.toml cargo check -p spacetime-module --manifest-path server-rs\Cargo.toml ``` -真实草稿生成需要本地私密环境配置 `VECTOR_ENGINE_API_KEY`、`HYPER3D_API_KEY` 和 OSS 访问变量。后端改动后使用 `npm run api-server` 启动,并检查 `/healthz`。 +真实草稿生成需要本地私密环境配置 `VECTOR_ENGINE_API_KEY` 和 OSS 访问变量;`HYPER3D_API_KEY` 只在结果页手动生成 3D 模型时需要。后端改动后使用 `npm run api-server` 启动,并检查 `/healthz`。 diff --git a/server-rs/crates/api-server/src/match3d.rs b/server-rs/crates/api-server/src/match3d.rs index 30df6286..4b643b41 100644 --- a/server-rs/crates/api-server/src/match3d.rs +++ b/server-rs/crates/api-server/src/match3d.rs @@ -1805,6 +1805,220 @@ async fn generate_match3d_material_sheet( }) } +#[allow(clippy::too_many_arguments)] +async fn generate_match3d_rodin_model_asset( + state: &AppState, + owner_user_id: &str, + session_id: &str, + profile_id: &str, + item_slug: &str, + item_name: &str, + config: &Match3DConfigJson, + image_bytes: Vec, + generated_at_micros: i64, +) -> Result { + let image_data_url = build_match3d_png_data_url(&image_bytes); + let submit_response = submit_image_to_model( + state, + hyper3d_contract::Hyper3dImageToModelRequest { + image_data_urls: vec![image_data_url], + image_urls: Vec::new(), + prompt: Some(build_match3d_rodin_model_prompt(config, item_name)), + condition_mode: Some("concat".to_string()), + seed: None, + geometry_file_format: Some("glb".to_string()), + material: Some("PBR".to_string()), + quality: Some("medium".to_string()), + mesh_mode: Some("Quad".to_string()), + addons: Vec::new(), + bbox_condition: None, + preview_render: Some(true), + }, + ) + .await?; + + wait_for_match3d_rodin_model( + state, + submit_response.subscription_key.as_str(), + item_name, + ) + .await?; + let download_response = query_downloads( + state, + hyper3d_contract::Hyper3dDownloadRequest { + task_uuid: submit_response.task_uuid.clone(), + }, + ) + .await?; + let model_file = select_match3d_glb_download( + &download_response.files, + submit_response.task_uuid.as_str(), + item_name, + )?; + let downloaded_model = download_match3d_rodin_model(model_file).await?; + let uploaded_model = persist_match3d_generated_bytes( + state, + owner_user_id, + session_id, + profile_id, + &[ + "items", + item_slug, + "model", + submit_response.task_uuid.as_str(), + ], + downloaded_model.file_name.as_str(), + downloaded_model.content_type.as_str(), + downloaded_model.bytes, + "match3d_item_model", + Some(submit_response.task_uuid.as_str()), + generated_at_micros, + ) + .await?; + + Ok(Match3DRodinModelAsset { + task_uuid: submit_response.task_uuid, + subscription_key: submit_response.subscription_key, + model_file_name: downloaded_model.file_name, + upload: uploaded_model, + }) +} + +fn build_match3d_png_data_url(image_bytes: &[u8]) -> String { + format!( + "data:image/png;base64,{}", + BASE64_STANDARD.encode(image_bytes) + ) +} + +fn build_match3d_rodin_model_prompt(config: &Match3DConfigJson, item_name: &str) -> String { + let style_clause = resolve_match3d_asset_style_prompt(config) + .map(|prompt| format!("画风遵循:{prompt}。")) + .unwrap_or_default(); + format!( + "{theme}题材抓大鹅游戏物件:{item_name}。{style_clause}生成单个完整游戏 3D 模型,主体清晰,低面数,PBR 材质,适合移动端实时渲染,不要文字、底座、场景和额外物体。", + theme = config.theme_text, + style_clause = style_clause, + ) +} + +async fn wait_for_match3d_rodin_model( + state: &AppState, + subscription_key: &str, + item_name: &str, +) -> Result<(), AppError> { + for attempt in 0..MATCH3D_RODIN_STATUS_MAX_ATTEMPTS { + let status_response = query_task_status( + state, + hyper3d_contract::Hyper3dTaskStatusRequest { + subscription_key: subscription_key.to_string(), + }, + ) + .await?; + match status_response.status.as_str() { + "done" => return Ok(()), + "failed" => { + let message = status_response + .jobs + .iter() + .filter_map(|job| job.message.as_deref()) + .map(str::trim) + .find(|value| !value.is_empty()) + .unwrap_or("Rodin 模型生成失败"); + return Err(match3d_bad_gateway(format!( + "{item_name} 3D 模型生成失败:{message}" + ))); + } + _ => {} + } + + if attempt + 1 < MATCH3D_RODIN_STATUS_MAX_ATTEMPTS { + tokio::time::sleep(Duration::from_millis( + MATCH3D_RODIN_STATUS_POLL_INTERVAL_MS, + )) + .await; + } + } + + Err(match3d_bad_gateway(format!( + "{item_name} 3D 模型生成超时,请稍后重试" + ))) +} + +fn select_match3d_glb_download<'a>( + files: &'a [hyper3d_contract::Hyper3dDownloadFilePayload], + task_uuid: &str, + item_name: &str, +) -> Result<&'a hyper3d_contract::Hyper3dDownloadFilePayload, AppError> { + files + .iter() + .find(|file| { + file.name.to_ascii_lowercase().ends_with(".glb") + || file.url.to_ascii_lowercase().split('?').next().unwrap_or("").ends_with(".glb") + }) + .or_else(|| files.first()) + .ok_or_else(|| { + match3d_bad_gateway(format!( + "{item_name} 3D 模型已完成但未返回可下载模型文件:{task_uuid}" + )) + }) +} + +async fn download_match3d_rodin_model( + file: &hyper3d_contract::Hyper3dDownloadFilePayload, +) -> Result { + let response = reqwest::Client::new() + .get(file.url.as_str()) + .send() + .await + .map_err(|error| match3d_bad_gateway(format!("下载 Rodin 模型失败:{error}")))?; + let status = response.status(); + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("model/gltf-binary") + .to_string(); + let bytes = response + .bytes() + .await + .map_err(|error| match3d_bad_gateway(format!("读取 Rodin 模型内容失败:{error}")))?; + if !status.is_success() { + return Err(match3d_bad_gateway(format!( + "下载 Rodin 模型失败:HTTP {}", + status.as_u16() + ))); + } + if bytes.is_empty() || bytes.len() > MATCH3D_RODIN_MAX_MODEL_BYTES { + return Err(match3d_bad_gateway("Rodin 模型内容为空或超过大小上限")); + } + + Ok(Match3DDownloadedModel { + bytes: bytes.to_vec(), + file_name: normalize_match3d_model_file_name(file.name.as_str()), + content_type: normalize_match3d_model_content_type(content_type.as_str()), + }) +} + +fn normalize_match3d_model_file_name(raw: &str) -> String { + let trimmed = raw.trim().rsplit('/').next().unwrap_or(raw).trim(); + let without_query = trimmed.split('?').next().unwrap_or(trimmed).trim(); + let sanitized = sanitize_match3d_asset_segment(without_query, "model"); + if sanitized.to_ascii_lowercase().ends_with(".glb") { + sanitized + } else { + "model.glb".to_string() + } +} + +fn normalize_match3d_model_content_type(raw: &str) -> String { + let normalized = raw.split(';').next().unwrap_or(raw).trim().to_lowercase(); + if normalized == "model/gltf-binary" || normalized == "application/octet-stream" { + return normalized; + } + "model/gltf-binary".to_string() +} + fn build_match3d_material_sheet_prompt( config: &Match3DConfigJson, item_names: &[String], diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index b290d456..1fa0f702 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.2.0 (commit eb11e2f5c41dce6979715ad407996270d61329f6). +// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/profile_invite_code_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/profile_invite_code_type.rs index 1ace70e7..401b6157 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/profile_invite_code_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/profile_invite_code_type.rs @@ -14,6 +14,7 @@ pub struct ProfileInviteCode { pub updated_at: __sdk::Timestamp, pub starts_at: Option<__sdk::Timestamp>, pub expires_at: Option<__sdk::Timestamp>, + pub granted_user_tags: Vec, } impl __sdk::InModule for ProfileInviteCode { @@ -31,6 +32,7 @@ pub struct ProfileInviteCodeCols { pub updated_at: __sdk::__query_builder::Col, pub starts_at: __sdk::__query_builder::Col>, pub expires_at: __sdk::__query_builder::Col>, + pub granted_user_tags: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for ProfileInviteCode { @@ -44,6 +46,7 @@ impl __sdk::__query_builder::HasCols for ProfileInviteCode { updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), starts_at: __sdk::__query_builder::Col::new(table_name, "starts_at"), expires_at: __sdk::__query_builder::Col::new(table_name, "expires_at"), + granted_user_tags: __sdk::__query_builder::Col::new(table_name, "granted_user_tags"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/record_tracking_event_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/record_tracking_event_and_return_procedure.rs index 01361ec7..c09132c0 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/record_tracking_event_and_return_procedure.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/record_tracking_event_and_return_procedure.rs @@ -31,10 +31,10 @@ pub trait record_tracking_event_and_return { input: RuntimeTrackingEventInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ); } @@ -44,10 +44,10 @@ impl record_tracking_event_and_return for super::RemoteProcedures { input: RuntimeTrackingEventInput, __callback: impl FnOnce( - &super::ProcedureEventContext, - Result, - ) + Send - + 'static, + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, ) { self.imp .invoke_procedure_with_callback::<_, RuntimeTrackingEventProcedureResult>( diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs index 77c8079a..66216638 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_admin_upsert_input_type.rs @@ -10,6 +10,7 @@ pub struct RuntimeProfileInviteCodeAdminUpsertInput { pub admin_user_id: String, pub invite_code: String, pub metadata_json: String, + pub granted_user_tags: Vec, pub starts_at_micros: Option, pub expires_at_micros: Option, pub updated_at_micros: i64, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs index da1ac766..51cf26c6 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/runtime_profile_invite_code_snapshot_type.rs @@ -10,6 +10,7 @@ pub struct RuntimeProfileInviteCodeSnapshot { pub user_id: String, pub invite_code: String, pub metadata_json: String, + pub granted_user_tags: Vec, pub starts_at_micros: Option, pub expires_at_micros: Option, pub created_at_micros: i64, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/user_account_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/user_account_type.rs index 50dd5520..116ead66 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/user_account_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/user_account_type.rs @@ -20,6 +20,7 @@ pub struct UserAccount { pub password_hash: String, pub password_login_enabled: bool, pub token_version: u64, + pub user_tags: Vec, } impl __sdk::InModule for UserAccount { @@ -43,6 +44,7 @@ pub struct UserAccountCols { pub password_hash: __sdk::__query_builder::Col, pub password_login_enabled: __sdk::__query_builder::Col, pub token_version: __sdk::__query_builder::Col, + pub user_tags: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for UserAccount { @@ -68,6 +70,7 @@ impl __sdk::__query_builder::HasCols for UserAccount { "password_login_enabled", ), token_version: __sdk::__query_builder::Col::new(table_name, "token_version"), + user_tags: __sdk::__query_builder::Col::new(table_name, "user_tags"), } } } diff --git a/server-rs/crates/spacetime-module/src/auth/tables.rs b/server-rs/crates/spacetime-module/src/auth/tables.rs index a82cd40e..50e95572 100644 --- a/server-rs/crates/spacetime-module/src/auth/tables.rs +++ b/server-rs/crates/spacetime-module/src/auth/tables.rs @@ -28,7 +28,6 @@ pub struct UserAccount { pub(crate) password_hash: String, pub(crate) password_login_enabled: bool, pub(crate) token_version: u64, - #[default(Vec::::new())] pub(crate) user_tags: Vec, } diff --git a/server-rs/crates/spacetime-module/src/puzzle.rs b/server-rs/crates/spacetime-module/src/puzzle.rs index 887d9f37..1e635a27 100644 --- a/server-rs/crates/spacetime-module/src/puzzle.rs +++ b/server-rs/crates/spacetime-module/src/puzzle.rs @@ -26,11 +26,14 @@ use module_puzzle::{ tag_similarity_score, }; use module_runtime::RuntimeProfileWalletLedgerSourceType; +use module_runtime::visible_runtime_profile_user_tags; use serde_json::from_str as json_from_str; use serde_json::json; use serde_json::to_string as json_to_string; use spacetimedb::{ProcedureContext, SpacetimeType, Table, Timestamp, TxContext}; +use crate::auth::user_account; + const PUZZLE_POINT_INCENTIVE_DEFAULT_U64: u64 = 0; /// 拼图 Agent session 真相表。 diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index ee8389c9..76e6b623 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -191,7 +191,6 @@ pub struct ProfileInviteCode { pub(crate) starts_at: Option, #[default(None::)] pub(crate) expires_at: Option, - #[default(Vec::::new())] pub(crate) granted_user_tags: Vec, } diff --git a/src/components/match3d-creation/Match3DAgentWorkspace.tsx b/src/components/match3d-creation/Match3DAgentWorkspace.tsx index a4a43e75..ba188a5e 100644 --- a/src/components/match3d-creation/Match3DAgentWorkspace.tsx +++ b/src/components/match3d-creation/Match3DAgentWorkspace.tsx @@ -1,4 +1,4 @@ -import { Loader2, Sparkles, WandSparkles, X } from 'lucide-react'; +import { Loader2, Plus, Sparkles, WandSparkles, X } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import type { @@ -325,7 +325,7 @@ export function Match3DAgentWorkspace({
-
+
3D素材风格
{MATCH3D_ASSET_STYLE_OPTIONS.map((option) => { @@ -374,10 +374,10 @@ export function Match3DAgentWorkspace({ assetStyleId: option.id, })); }} - className={`relative h-[4.45rem] w-[5.2rem] shrink-0 snap-start overflow-hidden rounded-[0.9rem] border p-0 text-left transition sm:h-[5.2rem] sm:w-[6.1rem] ${ + className={`group relative h-[4.9rem] w-[5.85rem] shrink-0 snap-start overflow-hidden rounded-[0.95rem] border p-0 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 sm:h-[5.45rem] sm:w-[6.4rem] ${ selected - ? 'border-[#ff4056] ring-1 ring-inset ring-[#ff4056]' - : 'border-[var(--platform-subpanel-border)]' + ? 'border-rose-300 bg-white shadow-[0_8px_18px_rgba(190,18,60,0.10)] ring-2 ring-rose-100' + : 'border-[var(--platform-subpanel-border)] bg-white/70 hover:border-rose-200 hover:bg-white/95' } ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`} aria-pressed={selected} aria-label={option.label} @@ -386,19 +386,30 @@ export function Match3DAgentWorkspace({ ) : ( - + )} - + + {selected ? ( + + ) : null} {isCustom ? ( - - + + + + + ) : null} - + {option.label} @@ -407,7 +418,7 @@ export function Match3DAgentWorkspace({
-
+
难度
@@ -426,10 +437,10 @@ export function Match3DAgentWorkspace({ difficultyOptionId: option.id, })) } - className={`min-h-10 rounded-[0.85rem] border px-2 text-sm font-black transition sm:min-h-11 ${ + className={`min-h-10 rounded-[0.85rem] border px-2 text-sm font-black transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 sm:min-h-11 ${ selected - ? 'border-[#ff4056] bg-[#ff4056] text-white shadow-[0_8px_18px_rgba(255,64,86,0.18)]' - : 'border-[var(--platform-subpanel-border)] bg-white/88 text-[var(--platform-text-strong)]' + ? 'border-[#ff7890] bg-[linear-gradient(180deg,#ff7890_0%,#ff4f6a_100%)] text-white shadow-[0_8px_18px_rgba(244,63,94,0.16)]' + : 'border-[var(--platform-subpanel-border)] bg-white/76 text-[var(--platform-text-strong)] hover:border-rose-200 hover:bg-white' } ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`} aria-pressed={selected} > @@ -502,7 +513,7 @@ export function Match3DAgentWorkspace({ value={draftCustomStylePrompt} onChange={(event) => setDraftCustomStylePrompt(event.target.value)} rows={4} - className="mt-4 h-[7.5rem] w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none" + className="mt-4 h-[7.5rem] w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 text-base leading-6 text-[var(--platform-text-strong)] outline-none transition focus:border-rose-200 focus:bg-white focus:ring-2 focus:ring-rose-100" aria-label="自定义3D素材风格描述" />
diff --git a/src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx b/src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx index 854bbca6..2a7a9cac 100644 --- a/src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx +++ b/src/components/visual-novel-creation/VisualNovelAgentWorkspace.tsx @@ -151,16 +151,24 @@ function VisualNovelStyleButton({ aria-pressed={active} aria-label={label} onClick={onClick} - className={`relative h-[4.45rem] w-[5.2rem] shrink-0 snap-start overflow-hidden rounded-[0.9rem] border p-0 text-left transition sm:h-[5.2rem] sm:w-[6.1rem] ${ + className={`group relative h-[4.9rem] w-[5.85rem] shrink-0 snap-start overflow-hidden rounded-[0.95rem] border p-0 text-left transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200 sm:h-[5.45rem] sm:w-[6.4rem] ${ active - ? 'border-[#ff4056] ring-1 ring-inset ring-[#ff4056]' - : 'border-[var(--platform-subpanel-border)]' + ? 'border-rose-300 bg-white shadow-[0_8px_18px_rgba(190,18,60,0.10)] ring-2 ring-rose-100' + : 'border-[var(--platform-subpanel-border)] bg-white/70 hover:border-rose-200 hover:bg-white/95' } ${disabled ? 'cursor-not-allowed opacity-55' : ''}`} > - - - - + + + {active ? ( + + ) : null} + {label} @@ -255,7 +263,7 @@ export function VisualNovelAgentWorkspace({
-
+
视觉画风
{VISUAL_NOVEL_STYLE_OPTIONS.map((option) => (