refactor(api-server): narrow puzzle state surface
This commit is contained in:
@@ -56,8 +56,9 @@
|
||||
|
||||
- 背景:`server-rs/crates/api-server/src/puzzle.rs` 已膨胀为数千行大文件,混合 Axum handler、草稿编译、图片生成、VectorEngine / OSS 持久化、DTO mapper、标签生成和测试;继续在单文件内迭代会降低定位和评审效率。
|
||||
- 决策:原超大 `puzzle.rs` 改为同名入口 `server-rs/crates/api-server/src/puzzle.rs` 加 `server-rs/crates/api-server/src/puzzle/` 子模块目录。`puzzle.rs` 只保留聚合入口和 handler re-export;`handlers.rs` 放 HTTP handler;`draft.rs` 放表单草稿 / 编译 / snapshot helper;`generation.rs` 放图片与 UI 背景生成编排;`vector_engine.rs` 放 VectorEngine、下载、OSS、asset object / binding 和错误归一;`mappers.rs` / `tags.rs` 保留映射和标签 / 错误 helper;`tests.rs` 承接原 puzzle 单测。
|
||||
- 2026-05-21 追加决策:拼图 HTTP/BFF handler 不再直接提取完整 `AppState`,统一通过 `PuzzleApiState` 暴露拼图能力需要的 SpacetimeDB facade、gallery cache、OSS、作者查询、LLM 和少量配置快照。`modules/puzzle.rs` 仍接收全局 `AppState` 以挂接鉴权和回到全局路由树,但内部路由先 `.with_state(PuzzleApiState::from_ref(&state))`,handler 使用 `State<PuzzleApiState>`。确需复用计费、外部失败审计等仍要求 `AppState` 的横切 helper 时,先经 `PuzzleApiState::root_state()` 显式过渡,后续再继续收窄。
|
||||
- 边界:本次只改变 `api-server` 内部文件组织,不改变 `/api/runtime/puzzle/*` 路由、DTO、error envelope、SpacetimeDB schema、公开 gallery cache 语义或计费语义。领域规则后续仍应逐步沉到 `module-puzzle`,SpacetimeDB 表、reducer、procedure 和 row shape 仍留在 `spacetime-module`。
|
||||
- 影响范围:`server-rs/crates/api-server/src/puzzle/`、`server-rs/crates/api-server/src/modules/puzzle.rs` 的 handler 引用、后端架构文档。
|
||||
- 影响范围:`server-rs/crates/api-server/src/state.rs`、`server-rs/crates/api-server/src/puzzle/`、`server-rs/crates/api-server/src/modules/puzzle.rs` 的 handler 引用、后端架构文档。
|
||||
- 验证方式:执行 `cargo check -p api-server --manifest-path server-rs\Cargo.toml`;后续若改动 puzzle API 行为,再按对应路由补充定向测试和 `npm run dev:api-server` `/healthz` smoke。
|
||||
- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
|
||||
@@ -77,13 +77,16 @@ npm run check:server-rs-ddd
|
||||
|
||||
1. 每个能力 Module 只暴露 `router(state) -> Router<AppState>`,由 `app.rs` 统一 `.merge(...)`。
|
||||
2. `app.rs` 只保留全局 middleware、TraceLayer、request context、tracking middleware、入口开关和少量顶层 glue。
|
||||
3. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现。
|
||||
4. 大 handler 拆分时优先按 `router.rs`、`handlers.rs`、`application.rs`、`assets.rs`、`mapper.rs`、`errors.rs` 分层。`handlers.rs` 只做 Axum extract、鉴权和 request/response,业务规则继续下沉到 `module-*`。
|
||||
5. 手写 Rust 模块入口统一使用同名 `.rs` 文件,例如 `puzzle.rs` + `puzzle/*.rs`、`match3d.rs` + `match3d/*.rs`;不要再新增 `mod.rs` 入口。生成的 SpacetimeDB Rust bindings 也由生成脚本同步为 `module_bindings.rs` + `module_bindings/*.rs` 布局。
|
||||
3. 能力 Module 可在路由内部用 `FromRef<AppState>` 派生自己的 Feature State,例如 `PuzzleApiState`。全局 `AppState` 仍作为进程组合根、鉴权层和全局中间件状态,但业务 handler 优先只提取对应 Feature State,不直接暴露完整 `AppState`。
|
||||
4. Feature State 只暴露该能力实际需要的 facade / adapter / 配置快照;若必须复用仍要求 `AppState` 的横切 helper(例如计费、外部失败审计或通用 tracking),应通过 Feature State 的窄方法或显式 `root_state()` 过渡,并在后续继续收窄。
|
||||
5. 路由迁移和业务重构分阶段处理;先移动路由装配,再拆 handler 内部实现,再收窄 handler 可见状态。
|
||||
6. 大 handler 拆分时优先按 `router.rs`、`handlers.rs`、`application.rs`、`assets.rs`、`mapper.rs`、`errors.rs` 分层。`handlers.rs` 只做 Axum extract、鉴权和 request/response,业务规则继续下沉到 `module-*`。
|
||||
7. 手写 Rust 模块入口统一使用同名 `.rs` 文件,例如 `puzzle.rs` + `puzzle/*.rs`、`match3d.rs` + `match3d/*.rs`;不要再新增 `mod.rs` 入口。生成的 SpacetimeDB Rust bindings 也由生成脚本同步为 `module_bindings.rs` + `module_bindings/*.rs` 布局。
|
||||
|
||||
拼图 `api-server` 内部拆分:
|
||||
|
||||
- `server-rs/crates/api-server/src/modules/puzzle.rs` 只负责路由装配、鉴权层和参考图 body limit;对外继续引用同一批 handler 名称。
|
||||
- `server-rs/crates/api-server/src/state.rs` 中的 `PuzzleApiState` 是拼图 HTTP/BFF 的 Feature State,集中暴露 `SpacetimeClient`、`PuzzleGalleryCache`、OSS client、作者查询所需认证服务、拼图 LLM client 和少量 VectorEngine / Agent 配置快照。拼图 handler 只提取 `State<PuzzleApiState>`,不得重新改回 `State<AppState>`。
|
||||
- `server-rs/crates/api-server/src/puzzle.rs` 只作为聚合入口,保留共享 import / 常量、内部模块声明和 handler re-export,不继续承载大段实现。
|
||||
- `server-rs/crates/api-server/src/puzzle/handlers.rs` 承接 Axum handler,负责 extract、鉴权上下文、调用 SpacetimeDB facade / 编排 helper,并返回 HTTP/SSE 响应。
|
||||
- `server-rs/crates/api-server/src/puzzle/draft.rs` 承接表单草稿保存、草稿编译、首关命名、UI 背景 prompt、降级 snapshot 和初始资产就绪校验。
|
||||
|
||||
@@ -52,6 +52,8 @@ export type PuzzleAgentActionRequest =
|
||||
pictureDescription?: string;
|
||||
referenceImageSrc?: string | null;
|
||||
referenceImageSrcs?: string[];
|
||||
referenceImageAssetObjectId?: string | null;
|
||||
referenceImageAssetObjectIds?: string[];
|
||||
imageModel?: string | null;
|
||||
aiRedraw?: boolean;
|
||||
}
|
||||
@@ -63,6 +65,8 @@ export type PuzzleAgentActionRequest =
|
||||
pictureDescription?: string;
|
||||
referenceImageSrc?: string | null;
|
||||
referenceImageSrcs?: string[];
|
||||
referenceImageAssetObjectId?: string | null;
|
||||
referenceImageAssetObjectIds?: string[];
|
||||
imageModel?: string | null;
|
||||
aiRedraw?: boolean;
|
||||
candidateCount?: number;
|
||||
@@ -73,6 +77,8 @@ export type PuzzleAgentActionRequest =
|
||||
promptText?: string | null;
|
||||
referenceImageSrc?: string | null;
|
||||
referenceImageSrcs?: string[];
|
||||
referenceImageAssetObjectId?: string | null;
|
||||
referenceImageAssetObjectIds?: string[];
|
||||
imageModel?: string | null;
|
||||
aiRedraw?: boolean;
|
||||
candidateCount?: number;
|
||||
|
||||
@@ -51,6 +51,8 @@ export interface CreatePuzzleAgentSessionRequest {
|
||||
pictureDescription?: string;
|
||||
referenceImageSrc?: string | null;
|
||||
referenceImageSrcs?: string[];
|
||||
referenceImageAssetObjectId?: string | null;
|
||||
referenceImageAssetObjectIds?: string[];
|
||||
imageModel?: string | null;
|
||||
aiRedraw?: boolean;
|
||||
}
|
||||
|
||||
@@ -394,9 +394,13 @@ pub async fn confirm_asset_object(
|
||||
let result = state
|
||||
.spacetime_client()
|
||||
.confirm_asset_object(
|
||||
build_confirm_asset_object_upsert_input(oss_client, payload)
|
||||
.await
|
||||
.map_err(map_confirm_asset_object_prepare_error)?,
|
||||
build_confirm_asset_object_upsert_input(
|
||||
oss_client,
|
||||
payload,
|
||||
authenticated.claims().user_id(),
|
||||
)
|
||||
.await
|
||||
.map_err(map_confirm_asset_object_prepare_error)?,
|
||||
)
|
||||
.await
|
||||
.map_err(map_confirm_asset_object_error)?;
|
||||
@@ -592,6 +596,7 @@ fn supported_asset_history_kind_message() -> String {
|
||||
async fn build_confirm_asset_object_upsert_input(
|
||||
oss_client: &platform_oss::OssClient,
|
||||
payload: ConfirmAssetObjectRequest,
|
||||
authenticated_owner_user_id: &str,
|
||||
) -> Result<module_assets::AssetObjectUpsertInput, ConfirmAssetObjectPrepareError> {
|
||||
let configured_bucket = oss_client.config_bucket().to_string();
|
||||
let resolved_bucket = payload
|
||||
@@ -629,6 +634,14 @@ async fn build_confirm_asset_object_upsert_input(
|
||||
{
|
||||
return Err(ConfirmAssetObjectPrepareError::ContentLengthMismatch);
|
||||
}
|
||||
let owner_user_id = normalize_optional_value(payload.owner_user_id).or_else(|| {
|
||||
let owner = authenticated_owner_user_id.trim();
|
||||
if owner.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(owner.to_string())
|
||||
}
|
||||
});
|
||||
|
||||
let now_micros = current_utc_micros();
|
||||
build_asset_object_upsert_input(
|
||||
@@ -645,7 +658,7 @@ async fn build_confirm_asset_object_upsert_input(
|
||||
normalize_optional_value(payload.content_hash),
|
||||
payload.asset_kind,
|
||||
payload.source_job_id,
|
||||
payload.owner_user_id,
|
||||
owner_user_id,
|
||||
payload.profile_id,
|
||||
payload.entity_id,
|
||||
now_micros,
|
||||
|
||||
@@ -109,9 +109,8 @@ const MATCH3D_WORK_METADATA_LLM_MODEL: &str = "gpt-4o";
|
||||
const MATCH3D_ITEM_SIZE_LARGE: &str = "大";
|
||||
const MATCH3D_ITEM_SIZE_MEDIUM: &str = "中";
|
||||
const MATCH3D_ITEM_SIZE_SMALL: &str = "小";
|
||||
const MATCH3D_CONTAINER_REFERENCE_IMAGE_BYTES: &[u8] = include_bytes!(
|
||||
"../../../../public/match3d-background-references/pot-fused-reference.png"
|
||||
);
|
||||
const MATCH3D_CONTAINER_REFERENCE_IMAGE_BYTES: &[u8] =
|
||||
include_bytes!("../../../../public/match3d-background-references/pot-fused-reference.png");
|
||||
const MATCH3D_PUBLIC_REFERENCE_IMAGE_PATH_PREFIX: &str = "public/";
|
||||
const MATCH3D_QUESTION_THEME: &str = "你想创作什么题材";
|
||||
const MATCH3D_QUESTION_CLEAR_COUNT: &str = "需要消除多少次才能通关";
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
use super::*;
|
||||
#[cfg(test)]
|
||||
use crate::generated_asset_sheets::crop_generated_asset_sheet_view_edge_matte;
|
||||
use crate::generated_asset_sheets::{
|
||||
GeneratedAssetSheetPersistInput, GeneratedAssetSheetPersistPrompt,
|
||||
GeneratedAssetSheetPromptInput, GeneratedAssetSheetSliceImage,
|
||||
build_generated_asset_sheet_prompt, persist_generated_asset_sheet_bytes,
|
||||
slice_generated_asset_sheet,
|
||||
};
|
||||
#[cfg(test)]
|
||||
use crate::generated_asset_sheets::crop_generated_asset_sheet_view_edge_matte;
|
||||
|
||||
pub(super) async fn generate_match3d_item_assets(
|
||||
state: &AppState,
|
||||
|
||||
@@ -1164,7 +1164,9 @@ fn match3d_container_reference_image_is_embedded_for_api_only_deploy() {
|
||||
assert_eq!(reference.mime_type, "image/png");
|
||||
assert_eq!(reference.file_name, "match3d-container-reference.png");
|
||||
assert!(
|
||||
reference.bytes.starts_with(&[137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
reference
|
||||
.bytes
|
||||
.starts_with(&[137, 80, 78, 71, 13, 10, 26, 10]),
|
||||
"container reference image should be PNG bytes"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
Router,
|
||||
extract::DefaultBodyLimit,
|
||||
extract::{DefaultBodyLimit, FromRef},
|
||||
middleware,
|
||||
routing::{get, post},
|
||||
};
|
||||
@@ -17,12 +17,13 @@ use crate::{
|
||||
submit_puzzle_agent_message, submit_puzzle_leaderboard, swap_puzzle_pieces,
|
||||
update_puzzle_run_pause, use_puzzle_runtime_prop,
|
||||
},
|
||||
state::AppState,
|
||||
state::{AppState, PuzzleApiState},
|
||||
};
|
||||
|
||||
const PUZZLE_REFERENCE_IMAGE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024;
|
||||
|
||||
pub fn router(state: AppState) -> Router<AppState> {
|
||||
// 中文注释:拼图 handler 只接收 PuzzleApiState,鉴权层仍使用全局 AppState。
|
||||
Router::new()
|
||||
.route(
|
||||
"/api/runtime/puzzle/agent/sessions",
|
||||
@@ -181,4 +182,6 @@ pub fn router(state: AppState) -> Router<AppState> {
|
||||
require_bearer_auth,
|
||||
)),
|
||||
)
|
||||
.with_state(PuzzleApiState::from_ref(&state))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
@@ -21,10 +21,8 @@ use module_assets::{
|
||||
};
|
||||
use module_puzzle::{PuzzleGeneratedImageCandidate, PuzzleRuntimeLevelStatus};
|
||||
use platform_llm::{LlmMessage, LlmMessageContentPart, LlmTextRequest};
|
||||
use platform_oss::{
|
||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
||||
OssSignedGetObjectUrlRequest,
|
||||
};
|
||||
use platform_oss::{LegacyAssetPrefix, OssSignedGetObjectUrlRequest};
|
||||
use platform_oss::{OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest};
|
||||
use serde_json::{Map, Value, json};
|
||||
use shared_contracts::{
|
||||
creation_audio::CreationAudioAsset,
|
||||
@@ -105,12 +103,9 @@ use crate::{
|
||||
},
|
||||
puzzle_gallery_cache::{build_puzzle_gallery_window_response, puzzle_gallery_cached_json},
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
vector_engine_audio_generation::{
|
||||
GeneratedCreationAudioTarget, generate_background_music_asset_for_creation,
|
||||
},
|
||||
work_author::resolve_work_author_by_user_id,
|
||||
work_play_tracking::{WorkPlayTrackingDraft, record_work_play_start_after_success},
|
||||
state::PuzzleApiState,
|
||||
work_author::resolve_puzzle_work_author_by_user_id,
|
||||
work_play_tracking::{WorkPlayTrackingDraft, record_puzzle_work_play_start_after_success},
|
||||
};
|
||||
|
||||
const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent";
|
||||
@@ -121,8 +116,6 @@ const PUZZLE_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2";
|
||||
const PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW: &str = "gemini-3.1-flash-image-preview";
|
||||
const PUZZLE_IMAGE_GENERATION_POINTS_COST: u64 = 2;
|
||||
const PUZZLE_ENTITY_KIND: &str = "puzzle_work";
|
||||
const PUZZLE_BACKGROUND_MUSIC_ASSET_KIND: &str = "puzzle_background_music";
|
||||
const PUZZLE_BACKGROUND_MUSIC_SLOT: &str = "background_music";
|
||||
#[cfg(test)]
|
||||
const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024";
|
||||
const PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE: &str = "1024x1024";
|
||||
|
||||
@@ -24,7 +24,7 @@ pub(crate) fn build_puzzle_form_seed_text_from_parts(
|
||||
}
|
||||
|
||||
pub(crate) async fn save_puzzle_form_payload_before_compile(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
@@ -76,7 +76,7 @@ pub(crate) async fn save_puzzle_form_payload_before_compile(
|
||||
}
|
||||
|
||||
pub(crate) async fn create_seeded_puzzle_session_when_form_save_missing(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
@@ -209,7 +209,7 @@ pub(crate) fn parse_puzzle_level_records_from_module_json(
|
||||
}
|
||||
|
||||
pub(crate) async fn get_puzzle_session_for_image_generation(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
payload: &ExecutePuzzleAgentActionRequest,
|
||||
@@ -469,7 +469,7 @@ impl PuzzleLevelNaming {
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_first_level_name(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
picture_description: &str,
|
||||
) -> PuzzleLevelNaming {
|
||||
if let Some(llm_client) = state.llm_client() {
|
||||
@@ -511,7 +511,7 @@ pub(crate) async fn generate_puzzle_first_level_name(
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_first_level_name_from_image(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
picture_description: &str,
|
||||
image: &PuzzleDownloadedImage,
|
||||
) -> Option<PuzzleLevelNaming> {
|
||||
@@ -1033,42 +1033,8 @@ pub(crate) fn attach_puzzle_level_ui_background(
|
||||
levels[index].ui_background_image_object_key = Some(generated.object_key);
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_background_music_required(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
profile_id: &str,
|
||||
title: &str,
|
||||
) -> Result<CreationAudioAsset, AppError> {
|
||||
let normalized_title = title.trim();
|
||||
if normalized_title.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"message": "拼图草稿背景音乐名称为空,无法完成背景音乐生成",
|
||||
})),
|
||||
);
|
||||
}
|
||||
generate_background_music_asset_for_creation(
|
||||
state,
|
||||
owner_user_id,
|
||||
String::new(),
|
||||
normalized_title.to_string(),
|
||||
Some("轻快, 拼图, 循环, instrumental".to_string()),
|
||||
None,
|
||||
GeneratedCreationAudioTarget {
|
||||
entity_kind: PUZZLE_ENTITY_KIND.to_string(),
|
||||
entity_id: profile_id.to_string(),
|
||||
slot: PUZZLE_BACKGROUND_MUSIC_SLOT.to_string(),
|
||||
asset_kind: PUZZLE_BACKGROUND_MUSIC_ASSET_KIND.to_string(),
|
||||
profile_id: Some(profile_id.to_string()),
|
||||
storage_prefix: LegacyAssetPrefix::PuzzleAssets,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_initial_ui_background_required(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
draft: &PuzzleResultDraftRecord,
|
||||
@@ -1128,7 +1094,7 @@ pub(crate) fn find_puzzle_level_for_initial_asset_check<'a>(
|
||||
}
|
||||
|
||||
pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
prompt_text: Option<&str>,
|
||||
@@ -1398,7 +1364,7 @@ pub(crate) async fn compile_puzzle_draft_with_initial_cover(
|
||||
}
|
||||
|
||||
pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
prompt_text: Option<&str>,
|
||||
@@ -1417,7 +1383,12 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
})?;
|
||||
let http_client = reqwest::Client::new();
|
||||
let uploaded_downloaded_image =
|
||||
resolve_puzzle_reference_image_as_data_url(state, &http_client, uploaded_image_src)
|
||||
resolve_puzzle_reference_image(
|
||||
state,
|
||||
&http_client,
|
||||
uploaded_image_src,
|
||||
Some(owner_user_id.as_str()),
|
||||
)
|
||||
.await
|
||||
.map(PuzzleDownloadedImage::from_resolved_reference_image)
|
||||
.map_err(|error| {
|
||||
@@ -1425,7 +1396,7 @@ pub(crate) async fn compile_puzzle_draft_with_uploaded_cover(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
"field": "referenceImageSrc",
|
||||
"message": "关闭 AI 重绘时上传图必须是图片 Data URL 或历史生成图片路径。",
|
||||
"message": "关闭 AI 重绘时上传图必须是拼图图片 assetObjectId、图片 Data URL 或历史生成图片路径。",
|
||||
}))
|
||||
} else {
|
||||
error
|
||||
|
||||
@@ -18,7 +18,7 @@ pub(crate) fn should_fallback_puzzle_reference_edit_to_generation(error: &AppErr
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_image_candidates(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
@@ -58,8 +58,13 @@ pub(crate) async fn generate_puzzle_image_candidates(
|
||||
.filter(|_| should_use_reference_image_edit)
|
||||
{
|
||||
Some(source) => {
|
||||
let resolved =
|
||||
resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?;
|
||||
let resolved = resolve_puzzle_reference_image(
|
||||
state,
|
||||
&http_client,
|
||||
source,
|
||||
Some(owner_user_id),
|
||||
)
|
||||
.await?;
|
||||
tracing::info!(
|
||||
provider = resolved_model.provider_name(),
|
||||
image_model = resolved_model.request_model_name(),
|
||||
@@ -219,13 +224,13 @@ pub(crate) async fn generate_puzzle_image_candidates(
|
||||
}
|
||||
|
||||
pub(crate) async fn generate_puzzle_ui_background_image(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
prompt: &str,
|
||||
) -> Result<GeneratedPuzzleUiBackgroundResponse, AppError> {
|
||||
let settings = require_openai_image_settings(state)?;
|
||||
let settings = require_openai_image_settings(state.root_state())?;
|
||||
let http_client = build_openai_image_http_client(&settings)?;
|
||||
let generated = create_openai_image_generation(
|
||||
&http_client,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
pub async fn create_puzzle_agent_session(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<CreatePuzzleAgentSessionRequest>, JsonRejection>,
|
||||
@@ -46,7 +46,7 @@ pub async fn create_puzzle_agent_session(
|
||||
}
|
||||
|
||||
pub async fn generate_puzzle_onboarding_work(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
payload: Result<Json<PuzzleOnboardingGenerateRequest>, JsonRejection>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
@@ -161,7 +161,7 @@ pub async fn generate_puzzle_onboarding_work(
|
||||
}
|
||||
|
||||
pub async fn save_puzzle_onboarding_work(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<PuzzleOnboardingSaveRequest>, JsonRejection>,
|
||||
@@ -270,7 +270,7 @@ pub async fn save_puzzle_onboarding_work(
|
||||
}
|
||||
|
||||
pub async fn get_puzzle_agent_session(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(session_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -303,7 +303,7 @@ pub async fn get_puzzle_agent_session(
|
||||
}
|
||||
|
||||
pub async fn submit_puzzle_agent_message(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(session_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -359,7 +359,7 @@ pub async fn submit_puzzle_agent_message(
|
||||
llm_client: state.llm_client(),
|
||||
session: &submitted_session,
|
||||
quick_fill_requested: payload.quick_fill_requested.unwrap_or(false),
|
||||
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
|
||||
enable_web_search: state.creation_agent_llm_web_search_enabled(),
|
||||
},
|
||||
|_| {},
|
||||
)
|
||||
@@ -401,7 +401,7 @@ pub async fn submit_puzzle_agent_message(
|
||||
}
|
||||
|
||||
pub async fn stream_puzzle_agent_message(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(session_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -464,7 +464,7 @@ pub async fn stream_puzzle_agent_message(
|
||||
llm_client: state.llm_client(),
|
||||
session: &session,
|
||||
quick_fill_requested,
|
||||
enable_web_search: state.config.creation_agent_llm_web_search_enabled,
|
||||
enable_web_search: state.creation_agent_llm_web_search_enabled(),
|
||||
},
|
||||
move |text| {
|
||||
let _ = reply_tx.send(text.to_string());
|
||||
@@ -554,7 +554,7 @@ pub async fn stream_puzzle_agent_message(
|
||||
}
|
||||
|
||||
pub async fn execute_puzzle_agent_action(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(session_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -595,6 +595,8 @@ pub async fn execute_puzzle_agent_action(
|
||||
has_reference_image = has_puzzle_reference_images(
|
||||
payload.reference_image_src.as_deref(),
|
||||
payload.reference_image_srcs.as_slice(),
|
||||
payload.reference_image_asset_object_id.as_deref(),
|
||||
payload.reference_image_asset_object_ids.as_slice(),
|
||||
),
|
||||
"拼图 Agent action 开始执行"
|
||||
);
|
||||
@@ -604,6 +606,8 @@ pub async fn execute_puzzle_agent_action(
|
||||
let reference_image_sources = collect_puzzle_reference_image_sources(
|
||||
payload.reference_image_src.as_deref(),
|
||||
payload.reference_image_srcs.as_slice(),
|
||||
payload.reference_image_asset_object_id.as_deref(),
|
||||
payload.reference_image_asset_object_ids.as_slice(),
|
||||
);
|
||||
let primary_reference_image_src = reference_image_sources.first().map(String::as_str);
|
||||
let prompt_text = payload
|
||||
@@ -627,7 +631,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
};
|
||||
let session = if ai_redraw {
|
||||
execute_billable_asset_operation_with_cost(
|
||||
&state,
|
||||
state.root_state(),
|
||||
&owner_user_id,
|
||||
"puzzle_initial_image",
|
||||
&billing_asset_id,
|
||||
@@ -652,7 +656,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
compile_session_id.clone(),
|
||||
owner_user_id.clone(),
|
||||
prompt_text,
|
||||
payload.reference_image_src.as_deref(),
|
||||
primary_reference_image_src,
|
||||
now,
|
||||
)
|
||||
.await
|
||||
@@ -737,7 +741,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
}))
|
||||
});
|
||||
let session = execute_billable_asset_operation_with_cost(
|
||||
&state,
|
||||
state.root_state(),
|
||||
&owner_user_id,
|
||||
"puzzle_generated_image",
|
||||
&billing_asset_id,
|
||||
@@ -787,6 +791,8 @@ pub async fn execute_puzzle_agent_action(
|
||||
let reference_image_sources = collect_puzzle_reference_image_sources(
|
||||
payload.reference_image_src.as_deref(),
|
||||
payload.reference_image_srcs.as_slice(),
|
||||
payload.reference_image_asset_object_id.as_deref(),
|
||||
payload.reference_image_asset_object_ids.as_slice(),
|
||||
);
|
||||
let primary_reference_image_src =
|
||||
reference_image_sources.first().map(String::as_str);
|
||||
@@ -942,7 +948,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
}))
|
||||
});
|
||||
let session = execute_billable_asset_operation_with_cost(
|
||||
&state,
|
||||
state.root_state(),
|
||||
&owner_user_id,
|
||||
"puzzle_ui_background_image",
|
||||
&billing_asset_id,
|
||||
@@ -1147,7 +1153,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
let (work_id, profile_id) = build_stable_puzzle_work_ids(&session_id);
|
||||
let author_display_name = resolve_author_display_name(&state, &authenticated);
|
||||
let profile = execute_billable_asset_operation(
|
||||
&state,
|
||||
state.root_state(),
|
||||
&owner_user_id,
|
||||
"puzzle_publish_work",
|
||||
&work_id,
|
||||
@@ -1235,7 +1241,7 @@ pub async fn execute_puzzle_agent_action(
|
||||
}
|
||||
|
||||
pub async fn get_puzzle_works(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
@@ -1263,7 +1269,7 @@ pub async fn get_puzzle_works(
|
||||
}
|
||||
|
||||
pub async fn get_puzzle_work_detail(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(profile_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(_authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1296,7 +1302,7 @@ pub async fn get_puzzle_work_detail(
|
||||
}
|
||||
|
||||
pub async fn put_puzzle_work(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(profile_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1355,7 +1361,7 @@ pub async fn put_puzzle_work(
|
||||
}
|
||||
|
||||
pub async fn delete_puzzle_work(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(profile_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1391,7 +1397,7 @@ pub async fn delete_puzzle_work(
|
||||
}
|
||||
|
||||
pub async fn claim_puzzle_work_point_incentive(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(profile_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1428,7 +1434,7 @@ pub async fn claim_puzzle_work_point_incentive(
|
||||
}
|
||||
|
||||
pub async fn list_puzzle_gallery(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Result<Response, Response> {
|
||||
if let Some(response) = state.puzzle_gallery_cache().read_fresh_response().await {
|
||||
@@ -1487,7 +1493,7 @@ pub async fn list_puzzle_gallery(
|
||||
}
|
||||
|
||||
pub async fn get_puzzle_gallery_detail(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(profile_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
) -> Result<Json<Value>, Response> {
|
||||
@@ -1519,7 +1525,7 @@ pub async fn get_puzzle_gallery_detail(
|
||||
}
|
||||
|
||||
pub async fn record_puzzle_gallery_like(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(profile_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1556,7 +1562,7 @@ pub async fn record_puzzle_gallery_like(
|
||||
}
|
||||
|
||||
pub async fn remix_puzzle_gallery_work(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(profile_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1599,7 +1605,7 @@ pub async fn remix_puzzle_gallery_work(
|
||||
}
|
||||
|
||||
pub async fn start_puzzle_run(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
payload: Result<Json<StartPuzzleRunRequest>, JsonRejection>,
|
||||
@@ -1639,7 +1645,7 @@ pub async fn start_puzzle_run(
|
||||
)
|
||||
})?;
|
||||
|
||||
record_work_play_start_after_success(
|
||||
record_puzzle_work_play_start_after_success(
|
||||
&state,
|
||||
&request_context,
|
||||
WorkPlayTrackingDraft::new(
|
||||
@@ -1665,7 +1671,7 @@ pub async fn start_puzzle_run(
|
||||
}
|
||||
|
||||
pub async fn get_puzzle_run(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(run_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1693,7 +1699,7 @@ pub async fn get_puzzle_run(
|
||||
}
|
||||
|
||||
pub async fn swap_puzzle_pieces(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(run_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1750,7 +1756,7 @@ pub async fn swap_puzzle_pieces(
|
||||
}
|
||||
|
||||
pub async fn drag_puzzle_piece_or_group(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(run_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1802,7 +1808,7 @@ pub async fn drag_puzzle_piece_or_group(
|
||||
}
|
||||
|
||||
pub async fn advance_puzzle_next_level(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(run_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1854,7 +1860,7 @@ pub async fn advance_puzzle_next_level(
|
||||
}
|
||||
|
||||
pub async fn update_puzzle_run_pause(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(run_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1898,7 +1904,7 @@ pub async fn update_puzzle_run_pause(
|
||||
}
|
||||
|
||||
pub async fn use_puzzle_runtime_prop(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(run_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
@@ -1944,7 +1950,7 @@ pub async fn use_puzzle_runtime_prop(
|
||||
let fallback_run_id = run_id.clone();
|
||||
let fallback_owner_user_id = owner_user_id.clone();
|
||||
let run_result = execute_billable_asset_operation(
|
||||
&state,
|
||||
state.root_state(),
|
||||
&owner_user_id,
|
||||
billing_asset_kind,
|
||||
billing_asset_id.as_str(),
|
||||
@@ -1996,7 +2002,7 @@ pub async fn use_puzzle_runtime_prop(
|
||||
}
|
||||
|
||||
pub async fn submit_puzzle_leaderboard(
|
||||
State(state): State<AppState>,
|
||||
State(state): State<PuzzleApiState>,
|
||||
AxumPath(run_id): AxumPath<String>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
Extension(authenticated): Extension<AuthenticatedAccessToken>,
|
||||
|
||||
@@ -343,11 +343,11 @@ fn has_puzzle_level_image(level: &PuzzleDraftLevelRecord) -> bool {
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_work_summary_response(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
item: PuzzleWorkProfileRecord,
|
||||
) -> PuzzleWorkSummaryResponse {
|
||||
let generation_status = resolve_puzzle_work_generation_status(&item);
|
||||
let author = resolve_work_author_by_user_id(
|
||||
let author = resolve_puzzle_work_author_by_user_id(
|
||||
state,
|
||||
&item.owner_user_id,
|
||||
Some(&item.author_display_name),
|
||||
@@ -391,10 +391,10 @@ pub(super) fn map_puzzle_work_summary_response(
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_gallery_card_response(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
item: PuzzleGalleryCardRecord,
|
||||
) -> PuzzleWorkSummaryResponse {
|
||||
let author = resolve_work_author_by_user_id(
|
||||
let author = resolve_puzzle_work_author_by_user_id(
|
||||
state,
|
||||
&item.owner_user_id,
|
||||
Some(&item.author_display_name),
|
||||
@@ -434,7 +434,7 @@ pub(super) fn map_puzzle_gallery_card_response(
|
||||
}
|
||||
|
||||
pub(super) fn map_puzzle_work_profile_response(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
item: PuzzleWorkProfileRecord,
|
||||
) -> PuzzleWorkProfileResponse {
|
||||
let mut summary = map_puzzle_work_summary_response(state, item.clone());
|
||||
@@ -491,7 +491,7 @@ pub(super) fn map_puzzle_recommended_next_work_response(
|
||||
}
|
||||
|
||||
pub(super) async fn enrich_puzzle_run_author_name(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
mut run: PuzzleRunRecord,
|
||||
) -> PuzzleRunRecord {
|
||||
if let Some(level) = run.current_level.as_mut() {
|
||||
@@ -500,7 +500,7 @@ pub(super) async fn enrich_puzzle_run_author_name(
|
||||
.get_puzzle_gallery_detail(level.profile_id.clone())
|
||||
.await
|
||||
{
|
||||
level.author_display_name = resolve_work_author_by_user_id(
|
||||
level.author_display_name = resolve_puzzle_work_author_by_user_id(
|
||||
state,
|
||||
&profile.owner_user_id,
|
||||
Some(&profile.author_display_name),
|
||||
@@ -632,7 +632,7 @@ pub(super) fn map_puzzle_board_response(
|
||||
}
|
||||
|
||||
pub(super) fn resolve_author_display_name(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
authenticated: &AuthenticatedAccessToken,
|
||||
) -> String {
|
||||
state
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) async fn generate_puzzle_work_tags(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
work_title: &str,
|
||||
work_description: &str,
|
||||
) -> Vec<String> {
|
||||
@@ -143,7 +143,7 @@ pub(super) fn build_fallback_puzzle_tags(
|
||||
}
|
||||
|
||||
pub(super) async fn save_generated_puzzle_tags_to_session(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
payload: &ExecutePuzzleAgentActionRequest,
|
||||
|
||||
@@ -41,6 +41,7 @@ fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: cursor.get_ref().len(),
|
||||
bytes: cursor.into_inner(),
|
||||
signed_read_url: None,
|
||||
};
|
||||
|
||||
let body = build_puzzle_vector_engine_image_request_body(
|
||||
@@ -64,6 +65,33 @@ fn puzzle_vector_engine_generation_fallback_includes_reference_image() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_generation_prefers_signed_reference_url() {
|
||||
let reference_image = PuzzleResolvedReferenceImage {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: 4,
|
||||
bytes: b"test".to_vec(),
|
||||
signed_read_url: Some(
|
||||
"https://oss.example/generated-puzzle-assets/reference.png?x-oss-signature=abc"
|
||||
.to_string(),
|
||||
),
|
||||
};
|
||||
|
||||
let body = build_puzzle_vector_engine_image_request_body(
|
||||
PuzzleImageModel::GptImage2,
|
||||
"参考图里的小猫做成拼图主图。",
|
||||
PUZZLE_DEFAULT_NEGATIVE_PROMPT,
|
||||
PUZZLE_VECTOR_ENGINE_GENERATED_IMAGE_SIZE,
|
||||
1,
|
||||
Some(&reference_image),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
body["image"][0],
|
||||
"https://oss.example/generated-puzzle-assets/reference.png?x-oss-signature=abc"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_edit_url_uses_images_edits_endpoint() {
|
||||
let settings = PuzzleVectorEngineSettings {
|
||||
@@ -131,6 +159,8 @@ fn puzzle_reference_image_sources_are_deduped_and_limited() {
|
||||
"data:image/png;base64,e".to_string(),
|
||||
"data:image/png;base64,f".to_string(),
|
||||
],
|
||||
None,
|
||||
&[],
|
||||
);
|
||||
|
||||
assert_eq!(sources.len(), 5);
|
||||
@@ -139,6 +169,62 @@ fn puzzle_reference_image_sources_are_deduped_and_limited() {
|
||||
assert!(!sources.contains(&"data:image/png;base64,f".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_reference_image_sources_prefer_asset_object_ids() {
|
||||
let sources = collect_puzzle_reference_image_sources(
|
||||
Some("data:image/png;base64,legacy"),
|
||||
&["/generated-puzzle-assets/legacy.png".to_string()],
|
||||
Some("asset-main-1"),
|
||||
&[
|
||||
"asset-main-1".to_string(),
|
||||
"asset-prompt-1".to_string(),
|
||||
"asset-prompt-2".to_string(),
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
sources,
|
||||
vec![
|
||||
"asset-object:asset-main-1".to_string(),
|
||||
"asset-object:asset-prompt-1".to_string(),
|
||||
"asset-object:asset-prompt-2".to_string(),
|
||||
"data:image/png;base64,legacy".to_string(),
|
||||
"/generated-puzzle-assets/legacy.png".to_string(),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_asset_object_reference_requires_matching_owner() {
|
||||
let asset_object = module_assets::AssetObjectRecord {
|
||||
asset_object_id: "assetobj_reference_1".to_string(),
|
||||
bucket: "genarrative-assets".to_string(),
|
||||
object_key: "generated-puzzle-assets/reference/image.png".to_string(),
|
||||
access_policy: module_assets::AssetObjectAccessPolicy::Private,
|
||||
content_type: Some("image/png".to_string()),
|
||||
content_length: 1024,
|
||||
content_hash: None,
|
||||
version: 1,
|
||||
source_job_id: None,
|
||||
owner_user_id: Some("user-other".to_string()),
|
||||
profile_id: None,
|
||||
entity_id: None,
|
||||
asset_kind: "puzzle_cover_image".to_string(),
|
||||
created_at: "2026-05-21T00:00:00Z".to_string(),
|
||||
updated_at: "2026-05-21T00:00:00Z".to_string(),
|
||||
};
|
||||
|
||||
let error = validate_puzzle_reference_asset_object(
|
||||
&asset_object,
|
||||
Some("user-current"),
|
||||
"genarrative-assets",
|
||||
)
|
||||
.expect_err("其他账号的参考图资产应被拒绝");
|
||||
|
||||
assert_eq!(error.status_code(), StatusCode::FORBIDDEN);
|
||||
assert!(error.body_text().contains("不属于当前账号"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_vector_engine_timeout_maps_to_gateway_timeout() {
|
||||
let error = map_puzzle_vector_engine_request_error(
|
||||
@@ -250,6 +336,8 @@ fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() {
|
||||
prompt_text: None,
|
||||
reference_image_src: None,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_image_asset_object_id: None,
|
||||
reference_image_asset_object_ids: Vec::new(),
|
||||
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||
ai_redraw: None,
|
||||
candidate_count: Some(1),
|
||||
@@ -383,6 +471,7 @@ fn puzzle_uploaded_cover_can_reuse_resolved_history_image() {
|
||||
mime_type: "image/png".to_string(),
|
||||
bytes_len: 8,
|
||||
bytes: b"pngbytes".to_vec(),
|
||||
signed_read_url: None,
|
||||
};
|
||||
|
||||
let downloaded = PuzzleDownloadedImage::from_resolved_reference_image(resolved);
|
||||
@@ -410,6 +499,8 @@ fn puzzle_first_level_name_snapshot_defaults_work_title() {
|
||||
prompt_text: None,
|
||||
reference_image_src: None,
|
||||
reference_image_srcs: Vec::new(),
|
||||
reference_image_asset_object_id: None,
|
||||
reference_image_asset_object_ids: Vec::new(),
|
||||
image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()),
|
||||
ai_redraw: None,
|
||||
candidate_count: Some(1),
|
||||
@@ -614,7 +705,9 @@ fn puzzle_ui_background_fields_roundtrip_between_response_and_module_json() {
|
||||
|
||||
#[test]
|
||||
fn puzzle_work_summary_response_keeps_levels_for_shelf_cover() {
|
||||
let state = AppState::new(crate::config::AppConfig::default()).expect("state should build");
|
||||
let app_state = crate::state::AppState::new(crate::config::AppConfig::default())
|
||||
.expect("state should build");
|
||||
let state: PuzzleApiState = axum::extract::FromRef::from_ref(&app_state);
|
||||
let level = PuzzleDraftLevelRecord {
|
||||
level_id: "puzzle-level-1".to_string(),
|
||||
level_name: "雨夜猫街".to_string(),
|
||||
|
||||
@@ -37,6 +37,7 @@ pub(crate) struct PuzzleResolvedReferenceImage {
|
||||
pub(crate) mime_type: String,
|
||||
pub(crate) bytes_len: usize,
|
||||
pub(crate) bytes: Vec<u8>,
|
||||
pub(crate) signed_read_url: Option<String>,
|
||||
}
|
||||
|
||||
pub(crate) struct GeneratedPuzzleImageCandidate {
|
||||
@@ -109,13 +110,9 @@ pub(crate) fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageMode
|
||||
}
|
||||
|
||||
pub(crate) fn require_puzzle_vector_engine_settings(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
) -> Result<PuzzleVectorEngineSettings, AppError> {
|
||||
let base_url = state
|
||||
.config
|
||||
.vector_engine_base_url
|
||||
.trim()
|
||||
.trim_end_matches('/');
|
||||
let base_url = state.vector_engine_base_url().trim().trim_end_matches('/');
|
||||
if base_url.is_empty() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
@@ -127,9 +124,7 @@ pub(crate) fn require_puzzle_vector_engine_settings(
|
||||
}
|
||||
|
||||
let api_key = state
|
||||
.config
|
||||
.vector_engine_api_key
|
||||
.as_deref()
|
||||
.vector_engine_api_key()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| {
|
||||
@@ -147,11 +142,11 @@ pub(crate) fn require_puzzle_vector_engine_settings(
|
||||
}
|
||||
|
||||
pub(crate) fn build_puzzle_image_http_client(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
image_model: PuzzleImageModel,
|
||||
) -> Result<reqwest::Client, AppError> {
|
||||
let provider = image_model.provider_name();
|
||||
let request_timeout_ms = state.config.vector_engine_image_request_timeout_ms;
|
||||
let request_timeout_ms = state.vector_engine_image_request_timeout_ms();
|
||||
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(request_timeout_ms.max(1)))
|
||||
@@ -397,11 +392,19 @@ pub(crate) fn build_puzzle_vector_engine_image_request_body(
|
||||
("n".to_string(), json!(candidate_count.clamp(1, 1))),
|
||||
("size".to_string(), Value::String(size.to_string())),
|
||||
]);
|
||||
if let Some(reference_image) = reference_image
|
||||
&& let Some(reference_data_url) =
|
||||
if let Some(reference_image) = reference_image {
|
||||
if let Some(signed_read_url) = reference_image
|
||||
.signed_read_url
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
body.insert("image".to_string(), json!([signed_read_url]));
|
||||
} else if let Some(reference_data_url) =
|
||||
build_puzzle_generation_reference_image_data_url(reference_image)
|
||||
{
|
||||
body.insert("image".to_string(), json!([reference_data_url]));
|
||||
{
|
||||
body.insert("image".to_string(), json!([reference_data_url]));
|
||||
}
|
||||
}
|
||||
|
||||
Value::Object(body)
|
||||
@@ -462,6 +465,48 @@ pub(crate) fn has_puzzle_reference_image(reference_image_src: Option<&str>) -> b
|
||||
pub(crate) fn collect_puzzle_reference_image_sources(
|
||||
legacy_reference_image_src: Option<&str>,
|
||||
reference_image_srcs: &[String],
|
||||
reference_image_asset_object_id: Option<&str>,
|
||||
reference_image_asset_object_ids: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut sources = Vec::new();
|
||||
for source in reference_image_asset_object_id
|
||||
.into_iter()
|
||||
.chain(reference_image_asset_object_ids.iter().map(String::as_str))
|
||||
.map(|asset_object_id| {
|
||||
asset_object_id
|
||||
.trim()
|
||||
.strip_prefix("asset-object:")
|
||||
.unwrap_or_else(|| asset_object_id.trim())
|
||||
})
|
||||
.filter(|asset_object_id| !asset_object_id.is_empty())
|
||||
.map(|asset_object_id| format!("asset-object:{asset_object_id}"))
|
||||
.chain(
|
||||
legacy_reference_image_src
|
||||
.into_iter()
|
||||
.chain(reference_image_srcs.iter().map(String::as_str))
|
||||
.map(str::to_string),
|
||||
)
|
||||
{
|
||||
let normalized = source.trim();
|
||||
if normalized.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !sources
|
||||
.iter()
|
||||
.any(|existing: &String| existing == normalized)
|
||||
{
|
||||
sources.push(normalized.to_string());
|
||||
}
|
||||
if sources.len() >= PUZZLE_REFERENCE_IMAGE_SOURCE_LIMIT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
sources
|
||||
}
|
||||
|
||||
pub(crate) fn collect_legacy_puzzle_reference_image_sources(
|
||||
legacy_reference_image_src: Option<&str>,
|
||||
reference_image_srcs: &[String],
|
||||
) -> Vec<String> {
|
||||
let mut sources = Vec::new();
|
||||
for source in legacy_reference_image_src
|
||||
@@ -488,9 +533,16 @@ pub(crate) fn collect_puzzle_reference_image_sources(
|
||||
pub(crate) fn has_puzzle_reference_images(
|
||||
legacy_reference_image_src: Option<&str>,
|
||||
reference_image_srcs: &[String],
|
||||
reference_image_asset_object_id: Option<&str>,
|
||||
reference_image_asset_object_ids: &[String],
|
||||
) -> bool {
|
||||
!collect_puzzle_reference_image_sources(legacy_reference_image_src, reference_image_srcs)
|
||||
.is_empty()
|
||||
!collect_puzzle_reference_image_sources(
|
||||
legacy_reference_image_src,
|
||||
reference_image_srcs,
|
||||
reference_image_asset_object_id,
|
||||
reference_image_asset_object_ids,
|
||||
)
|
||||
.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn should_use_puzzle_reference_image_edit(
|
||||
@@ -546,10 +598,19 @@ pub(crate) async fn download_puzzle_images_from_urls(
|
||||
Ok(PuzzleGeneratedImages { task_id, images })
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
state: &AppState,
|
||||
pub(crate) fn parse_puzzle_asset_object_reference(source: &str) -> Option<&str> {
|
||||
source
|
||||
.trim()
|
||||
.strip_prefix("asset-object:")
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_puzzle_reference_image(
|
||||
state: &PuzzleApiState,
|
||||
http_client: &reqwest::Client,
|
||||
source: &str,
|
||||
owner_user_id: Option<&str>,
|
||||
) -> Result<PuzzleResolvedReferenceImage, AppError> {
|
||||
let trimmed = source.trim();
|
||||
if trimmed.is_empty() {
|
||||
@@ -562,6 +623,16 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(asset_object_id) = parse_puzzle_asset_object_reference(trimmed) {
|
||||
return resolve_puzzle_reference_asset_object(
|
||||
state,
|
||||
http_client,
|
||||
asset_object_id,
|
||||
owner_user_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if let Some(parsed) = parse_puzzle_image_data_url(trimmed) {
|
||||
let bytes_len = parsed.bytes.len();
|
||||
if bytes_len > PUZZLE_REFERENCE_IMAGE_MAX_BYTES {
|
||||
@@ -579,6 +650,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
mime_type: parsed.mime_type,
|
||||
bytes_len,
|
||||
bytes: parsed.bytes,
|
||||
signed_read_url: None,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -587,7 +659,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "puzzle",
|
||||
"field": "referenceImageSrc",
|
||||
"message": "参考图必须是 Data URL 或 /generated-* 旧路径。",
|
||||
"message": "参考图必须是 assetObjectId、Data URL 或 /generated-* 旧路径。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -598,7 +670,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "puzzle",
|
||||
"field": "referenceImageSrc",
|
||||
"message": "参考图当前只支持 /generated-* 旧路径。",
|
||||
"message": "参考图当前只支持 assetObjectId 或 /generated-* 旧路径。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -615,8 +687,159 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
expire_seconds: Some(60),
|
||||
})
|
||||
.map_err(map_puzzle_asset_oss_error)?;
|
||||
let signed_read_url = signed.signed_url;
|
||||
download_signed_puzzle_reference_image(
|
||||
http_client,
|
||||
signed_read_url,
|
||||
object_key,
|
||||
None,
|
||||
"referenceImageSrc",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
state: &PuzzleApiState,
|
||||
http_client: &reqwest::Client,
|
||||
source: &str,
|
||||
) -> Result<PuzzleResolvedReferenceImage, AppError> {
|
||||
resolve_puzzle_reference_image(state, http_client, source, None).await
|
||||
}
|
||||
|
||||
async fn resolve_puzzle_reference_asset_object(
|
||||
state: &PuzzleApiState,
|
||||
http_client: &reqwest::Client,
|
||||
asset_object_id: &str,
|
||||
owner_user_id: Option<&str>,
|
||||
) -> Result<PuzzleResolvedReferenceImage, AppError> {
|
||||
let asset_object = state
|
||||
.spacetime_client()
|
||||
.get_asset_object(asset_object_id.to_string())
|
||||
.await
|
||||
.map_err(map_puzzle_client_error)?
|
||||
.ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object_id,
|
||||
"message": "参考图资产不存在或当前账号不可见。",
|
||||
}))
|
||||
})?;
|
||||
let oss_client = state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
"reason": "OSS 未完成环境变量配置",
|
||||
}))
|
||||
})?;
|
||||
validate_puzzle_reference_asset_object(
|
||||
&asset_object,
|
||||
owner_user_id,
|
||||
oss_client.config_bucket(),
|
||||
)?;
|
||||
let signed = oss_client
|
||||
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||||
object_key: asset_object.object_key.clone(),
|
||||
expire_seconds: Some(60),
|
||||
})
|
||||
.map_err(map_puzzle_asset_oss_error)?;
|
||||
let content_type = asset_object.content_type.clone();
|
||||
download_signed_puzzle_reference_image(
|
||||
http_client,
|
||||
signed.signed_url,
|
||||
asset_object.object_key.as_str(),
|
||||
content_type.as_deref(),
|
||||
"referenceImageAssetObjectId",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn validate_puzzle_reference_asset_object(
|
||||
asset_object: &module_assets::AssetObjectRecord,
|
||||
owner_user_id: Option<&str>,
|
||||
oss_bucket: &str,
|
||||
) -> Result<(), AppError> {
|
||||
if asset_object.bucket.trim() != oss_bucket.trim() {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": "参考图资产 bucket 与当前服务 OSS 配置不一致。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
if asset_object.asset_kind.trim() != "puzzle_cover_image" {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": "参考图资产类型不属于拼图图片。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
let content_type = asset_object
|
||||
.content_type
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.unwrap_or_default();
|
||||
if !content_type.starts_with("image/") {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": "参考图资产不是图片类型。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
if asset_object.content_length == 0
|
||||
|| asset_object.content_length > PUZZLE_REFERENCE_IMAGE_MAX_BYTES as u64
|
||||
{
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": "参考图资产大小不符合拼图生成要求。",
|
||||
"maxBytes": PUZZLE_REFERENCE_IMAGE_MAX_BYTES,
|
||||
"actualBytes": asset_object.content_length,
|
||||
})),
|
||||
);
|
||||
}
|
||||
if let Some(expected_owner_user_id) = owner_user_id
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
let actual_owner_user_id = asset_object
|
||||
.owner_user_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if actual_owner_user_id != Some(expected_owner_user_id) {
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::FORBIDDEN).with_details(json!({
|
||||
"provider": "asset-object",
|
||||
"field": "referenceImageAssetObjectId",
|
||||
"assetObjectId": asset_object.asset_object_id,
|
||||
"message": "参考图资产不属于当前账号。",
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_signed_puzzle_reference_image(
|
||||
http_client: &reqwest::Client,
|
||||
signed_read_url: String,
|
||||
object_key: &str,
|
||||
fallback_content_type: Option<&str>,
|
||||
field: &str,
|
||||
) -> Result<PuzzleResolvedReferenceImage, AppError> {
|
||||
let response = http_client
|
||||
.get(signed.signed_url)
|
||||
.get(signed_read_url.as_str())
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| map_puzzle_image_request_error(format!("读取拼图参考图失败:{error}")))?;
|
||||
@@ -625,6 +848,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.or(fallback_content_type)
|
||||
.unwrap_or("image/png")
|
||||
.to_string();
|
||||
let body = response.bytes().await.map_err(|error| {
|
||||
@@ -636,6 +860,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
"provider": "aliyun-oss",
|
||||
"message": format!("读取参考图失败,状态码:{status}"),
|
||||
"objectKey": object_key,
|
||||
"field": field,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -645,6 +870,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
"provider": "aliyun-oss",
|
||||
"message": "读取参考图失败:对象内容为空",
|
||||
"objectKey": object_key,
|
||||
"field": field,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -655,6 +881,7 @@ pub(crate) async fn resolve_puzzle_reference_image_as_data_url(
|
||||
mime_type,
|
||||
bytes_len,
|
||||
bytes: body.to_vec(),
|
||||
signed_read_url: Some(signed_read_url),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -693,7 +920,7 @@ pub(crate) async fn download_puzzle_remote_image(
|
||||
}
|
||||
|
||||
pub(crate) async fn persist_puzzle_generated_asset(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
@@ -805,7 +1032,7 @@ pub(crate) async fn persist_puzzle_generated_asset(
|
||||
}
|
||||
|
||||
pub(crate) async fn persist_puzzle_ui_background_image(
|
||||
state: &AppState,
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
session_id: &str,
|
||||
level_name: &str,
|
||||
|
||||
@@ -141,6 +141,86 @@ impl FromRef<AppState> for BackpressureState {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PuzzleApiState {
|
||||
root_state: AppState,
|
||||
spacetime_client: SpacetimeClient,
|
||||
puzzle_gallery_cache: PuzzleGalleryCache,
|
||||
oss_client: Option<OssClient>,
|
||||
auth_user_service: AuthUserService,
|
||||
llm_client: Option<LlmClient>,
|
||||
creative_agent_gpt5_client: Option<LlmClient>,
|
||||
creation_agent_llm_web_search_enabled: bool,
|
||||
vector_engine_image_request_timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl PuzzleApiState {
|
||||
pub fn root_state(&self) -> &AppState {
|
||||
&self.root_state
|
||||
}
|
||||
|
||||
pub fn spacetime_client(&self) -> &SpacetimeClient {
|
||||
&self.spacetime_client
|
||||
}
|
||||
|
||||
pub fn puzzle_gallery_cache(&self) -> &PuzzleGalleryCache {
|
||||
&self.puzzle_gallery_cache
|
||||
}
|
||||
|
||||
pub fn oss_client(&self) -> Option<&OssClient> {
|
||||
self.oss_client.as_ref()
|
||||
}
|
||||
|
||||
pub fn auth_user_service(&self) -> &AuthUserService {
|
||||
&self.auth_user_service
|
||||
}
|
||||
|
||||
pub fn llm_client(&self) -> Option<&LlmClient> {
|
||||
self.llm_client.as_ref()
|
||||
}
|
||||
|
||||
pub fn creative_agent_gpt5_client(&self) -> Option<&LlmClient> {
|
||||
self.creative_agent_gpt5_client.as_ref()
|
||||
}
|
||||
|
||||
pub fn creation_agent_llm_web_search_enabled(&self) -> bool {
|
||||
self.creation_agent_llm_web_search_enabled
|
||||
}
|
||||
|
||||
pub fn vector_engine_image_request_timeout_ms(&self) -> u64 {
|
||||
self.vector_engine_image_request_timeout_ms
|
||||
}
|
||||
|
||||
pub fn vector_engine_base_url(&self) -> &str {
|
||||
self.root_state.config.vector_engine_base_url.as_str()
|
||||
}
|
||||
|
||||
pub fn vector_engine_api_key(&self) -> Option<&str> {
|
||||
self.root_state.config.vector_engine_api_key.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromRef<AppState> for PuzzleApiState {
|
||||
fn from_ref(state: &AppState) -> Self {
|
||||
// 中文注释:拼图路由只暴露本能力需要的依赖快照,避免 handler 直接看见完整 AppState。
|
||||
Self {
|
||||
root_state: state.clone(),
|
||||
spacetime_client: state.spacetime_client.clone(),
|
||||
puzzle_gallery_cache: state.puzzle_gallery_cache.clone(),
|
||||
oss_client: state.oss_client.clone(),
|
||||
auth_user_service: state.auth_user_service.clone(),
|
||||
llm_client: state.llm_client.clone(),
|
||||
creative_agent_gpt5_client: state.creative_agent_gpt5_client.clone(),
|
||||
creation_agent_llm_web_search_enabled: state
|
||||
.config
|
||||
.creation_agent_llm_web_search_enabled,
|
||||
vector_engine_image_request_timeout_ms: state
|
||||
.config
|
||||
.vector_engine_image_request_timeout_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Axum/Hyper 会在路由树和连接 service 上频繁 clone state;AppState 外层必须保持浅拷贝。
|
||||
#[derive(Debug)]
|
||||
pub struct AppStateInner {
|
||||
@@ -1319,4 +1399,23 @@ mod tests {
|
||||
);
|
||||
assert!(client.config().official_fallback());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn puzzle_api_state_exposes_puzzle_dependency_snapshot() {
|
||||
let mut config = AppConfig::default();
|
||||
config.creation_agent_llm_web_search_enabled = false;
|
||||
config.vector_engine_image_request_timeout_ms = 987_654;
|
||||
|
||||
let state = AppState::new(config).expect("state should build");
|
||||
let puzzle_state: PuzzleApiState = FromRef::from_ref(&state);
|
||||
|
||||
assert!(!puzzle_state.creation_agent_llm_web_search_enabled());
|
||||
assert_eq!(
|
||||
puzzle_state.vector_engine_image_request_timeout_ms(),
|
||||
987_654
|
||||
);
|
||||
assert!(puzzle_state.llm_client().is_none());
|
||||
assert!(puzzle_state.creative_agent_gpt5_client().is_none());
|
||||
assert!(puzzle_state.oss_client().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use module_auth::AuthUser;
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::state::{AppState, PuzzleApiState};
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct WorkAuthorSummary {
|
||||
@@ -14,6 +14,34 @@ pub fn resolve_work_author_by_user_id(
|
||||
owner_user_id: &str,
|
||||
fallback_display_name: Option<&str>,
|
||||
fallback_public_user_code: Option<&str>,
|
||||
) -> WorkAuthorSummary {
|
||||
resolve_work_author_by_user_id_with_service(
|
||||
state.auth_user_service(),
|
||||
owner_user_id,
|
||||
fallback_display_name,
|
||||
fallback_public_user_code,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resolve_puzzle_work_author_by_user_id(
|
||||
state: &PuzzleApiState,
|
||||
owner_user_id: &str,
|
||||
fallback_display_name: Option<&str>,
|
||||
fallback_public_user_code: Option<&str>,
|
||||
) -> WorkAuthorSummary {
|
||||
resolve_work_author_by_user_id_with_service(
|
||||
state.auth_user_service(),
|
||||
owner_user_id,
|
||||
fallback_display_name,
|
||||
fallback_public_user_code,
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_work_author_by_user_id_with_service(
|
||||
auth_user_service: &module_auth::AuthUserService,
|
||||
owner_user_id: &str,
|
||||
fallback_display_name: Option<&str>,
|
||||
fallback_public_user_code: Option<&str>,
|
||||
) -> WorkAuthorSummary {
|
||||
let fallback_display_name =
|
||||
normalize_optional_text(fallback_display_name).unwrap_or_else(|| "玩家".to_string());
|
||||
@@ -26,7 +54,7 @@ pub fn resolve_work_author_by_user_id(
|
||||
};
|
||||
};
|
||||
|
||||
match state.auth_user_service().get_user_by_id(&owner_user_id) {
|
||||
match auth_user_service.get_user_by_id(&owner_user_id) {
|
||||
Ok(Some(user)) => map_auth_user_to_work_author_summary(user, fallback_display_name),
|
||||
Ok(None) | Err(_) => WorkAuthorSummary {
|
||||
display_name: fallback_display_name,
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde_json::{Value, json};
|
||||
use crate::{
|
||||
auth::AuthenticatedAccessToken,
|
||||
request_context::RequestContext,
|
||||
state::AppState,
|
||||
state::{AppState, PuzzleApiState},
|
||||
tracking::{TrackingEventDraft, record_tracking_event_after_success},
|
||||
};
|
||||
|
||||
@@ -68,6 +68,22 @@ pub(crate) async fn record_work_play_start_after_success(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
draft: WorkPlayTrackingDraft,
|
||||
) {
|
||||
record_work_play_start_input_after_success(state, request_context, draft).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn record_puzzle_work_play_start_after_success(
|
||||
state: &PuzzleApiState,
|
||||
request_context: &RequestContext,
|
||||
draft: WorkPlayTrackingDraft,
|
||||
) {
|
||||
record_work_play_start_input_after_success(state.root_state(), request_context, draft).await;
|
||||
}
|
||||
|
||||
async fn record_work_play_start_input_after_success(
|
||||
state: &AppState,
|
||||
request_context: &RequestContext,
|
||||
draft: WorkPlayTrackingDraft,
|
||||
) {
|
||||
let mut metadata = json!({
|
||||
"operation": WORK_PLAY_START_EVENT_KEY,
|
||||
|
||||
@@ -18,6 +18,10 @@ pub struct CreatePuzzleAgentSessionRequest {
|
||||
#[serde(default)]
|
||||
pub reference_image_srcs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_asset_object_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_asset_object_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub image_model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ai_redraw: Option<bool>,
|
||||
@@ -43,6 +47,10 @@ pub struct ExecutePuzzleAgentActionRequest {
|
||||
#[serde(default)]
|
||||
pub reference_image_srcs: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_asset_object_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reference_image_asset_object_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub image_model: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ai_redraw: Option<bool>,
|
||||
|
||||
@@ -46,6 +46,21 @@ impl SpacetimeClient {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_asset_object(
|
||||
&self,
|
||||
asset_object_id: String,
|
||||
) -> Result<Option<AssetObjectRecord>, SpacetimeClientError> {
|
||||
self.read_after_connect("get_asset_object", move |connection| {
|
||||
Ok(connection
|
||||
.db()
|
||||
.asset_object()
|
||||
.asset_object_id()
|
||||
.find(&asset_object_id)
|
||||
.map(map_asset_object_row))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn bind_asset_object_to_entity(
|
||||
&self,
|
||||
input: module_assets::AssetEntityBindingInput,
|
||||
|
||||
@@ -585,6 +585,7 @@ impl SpacetimeClient {
|
||||
"SELECT * FROM public_work_play_daily_stat WHERE source_type = 'big-fish'",
|
||||
"SELECT * FROM creation_entry_config",
|
||||
"SELECT * FROM creation_entry_type_config",
|
||||
"SELECT * FROM asset_object",
|
||||
] {
|
||||
if let Ok(subscription) = self
|
||||
.subscribe_cached_read_model_query(connection, broken.clone(), query, false)
|
||||
|
||||
@@ -120,7 +120,9 @@ pub use self::runtime_profile::{
|
||||
pub use self::story::{VisualNovelRuntimeEventRecord, VisualNovelRuntimeEventRecordInput};
|
||||
|
||||
pub(crate) use self::ai::map_ai_task_procedure_result;
|
||||
pub(crate) use self::assets::{map_entity_binding_procedure_result, map_procedure_result};
|
||||
pub(crate) use self::assets::{
|
||||
map_asset_object_row, map_entity_binding_procedure_result, map_procedure_result,
|
||||
};
|
||||
pub(crate) use self::auth::{
|
||||
map_auth_store_snapshot_import_procedure_result, map_auth_store_snapshot_procedure_result,
|
||||
};
|
||||
|
||||
@@ -115,6 +115,26 @@ pub(crate) fn map_snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn map_asset_object_row(row: AssetObject) -> AssetObjectRecord {
|
||||
build_asset_object_record(module_assets::AssetObjectUpsertSnapshot {
|
||||
asset_object_id: row.asset_object_id,
|
||||
bucket: row.bucket,
|
||||
object_key: row.object_key,
|
||||
access_policy: map_access_policy_back(row.access_policy),
|
||||
content_type: row.content_type,
|
||||
content_length: row.content_length,
|
||||
content_hash: row.content_hash,
|
||||
version: row.version,
|
||||
source_job_id: row.source_job_id,
|
||||
owner_user_id: row.owner_user_id,
|
||||
profile_id: row.profile_id,
|
||||
entity_id: row.entity_id,
|
||||
asset_kind: row.asset_kind,
|
||||
created_at_micros: row.created_at.to_micros_since_unix_epoch(),
|
||||
updated_at_micros: row.updated_at.to_micros_since_unix_epoch(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn map_access_policy(
|
||||
value: AssetObjectAccessPolicy,
|
||||
) -> crate::module_bindings::AssetObjectAccessPolicy {
|
||||
|
||||
@@ -14,6 +14,7 @@ export type CreativeImageInputReferenceImage = {
|
||||
id: string;
|
||||
label: string;
|
||||
imageSrc: string;
|
||||
assetObjectId?: string | null;
|
||||
};
|
||||
|
||||
export type CreativeImageInputPanelLabels = {
|
||||
|
||||
@@ -1956,6 +1956,8 @@ function buildPuzzleCompileActionFromFormPayload(
|
||||
...(pictureDescription ? { pictureDescription } : {}),
|
||||
referenceImageSrc: payload?.referenceImageSrc || null,
|
||||
referenceImageSrcs: payload?.referenceImageSrcs ?? [],
|
||||
referenceImageAssetObjectId: payload?.referenceImageAssetObjectId ?? null,
|
||||
referenceImageAssetObjectIds: payload?.referenceImageAssetObjectIds ?? [],
|
||||
imageModel: payload?.imageModel ?? null,
|
||||
aiRedraw: payload?.aiRedraw ?? true,
|
||||
candidateCount: 1,
|
||||
@@ -1978,6 +1980,8 @@ function buildPuzzleFormPayloadFromSession(
|
||||
pictureDescription,
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: null,
|
||||
aiRedraw: true,
|
||||
};
|
||||
@@ -2008,6 +2012,8 @@ function buildPuzzleFormPayloadFromAction(
|
||||
? (payload.referenceImageSrc ?? null)
|
||||
: (payload.referenceImageSrc ?? null),
|
||||
referenceImageSrcs: payload.referenceImageSrcs ?? [],
|
||||
referenceImageAssetObjectId: payload.referenceImageAssetObjectId ?? null,
|
||||
referenceImageAssetObjectIds: payload.referenceImageAssetObjectIds ?? [],
|
||||
imageModel:
|
||||
payload.action === 'compile_puzzle_draft'
|
||||
? (payload.imageModel ?? null)
|
||||
@@ -5542,6 +5548,10 @@ export function PlatformEntryFlowShellImpl({
|
||||
pictureDescription: payload.pictureDescription ?? '',
|
||||
referenceImageSrc: payload.referenceImageSrc ?? null,
|
||||
referenceImageSrcs: payload.referenceImageSrcs ?? [],
|
||||
referenceImageAssetObjectId:
|
||||
payload.referenceImageAssetObjectId ?? null,
|
||||
referenceImageAssetObjectIds:
|
||||
payload.referenceImageAssetObjectIds ?? [],
|
||||
imageModel: payload.imageModel ?? null,
|
||||
aiRedraw: payload.aiRedraw ?? true,
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ vi.mock('../ResolvedAssetImage', () => ({
|
||||
vi.mock('../../services/puzzle-works/puzzleAssetClient', () => ({
|
||||
puzzleAssetClient: {
|
||||
listHistoryAssets: vi.fn(),
|
||||
uploadReferenceImage: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -90,6 +91,19 @@ beforeEach(() => {
|
||||
if (!Element.prototype.scrollIntoView) {
|
||||
Element.prototype.scrollIntoView = () => {};
|
||||
}
|
||||
vi.mocked(puzzleAssetClient.uploadReferenceImage).mockImplementation(
|
||||
async ({ file }) => ({
|
||||
assetObjectId: `asset-reference-${file.name}`,
|
||||
assetKind: 'puzzle_cover_image',
|
||||
objectKey: `generated-puzzle-assets/reference/${file.name}`,
|
||||
imageSrc: `/generated-puzzle-assets/reference/${file.name}`,
|
||||
ownerUserId: 'user-1',
|
||||
profileId: null,
|
||||
entityId: null,
|
||||
createdAt: '1713686400.000000Z',
|
||||
updatedAt: '1713686400.000000Z',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -190,6 +204,8 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
|
||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
});
|
||||
@@ -325,8 +341,10 @@ test('puzzle workspace selects a history image from the upload card', async () =
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: '保留历史图里的主体,改成晴天花园。',
|
||||
pictureDescription: '保留历史图里的主体,改成晴天花园。',
|
||||
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: 'asset-history-1',
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
});
|
||||
@@ -384,6 +402,8 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
|
||||
promptText: '潮雾中的灯塔与断桥',
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
candidateCount: 1,
|
||||
@@ -484,6 +504,8 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
|
||||
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
});
|
||||
@@ -528,8 +550,10 @@ test('puzzle workspace hides prompt and cost when AI redraw is off', async () =>
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: 'first-level.png',
|
||||
pictureDescription: 'first-level.png',
|
||||
referenceImageSrc: uploadedDataUrl,
|
||||
referenceImageSrc: '/generated-puzzle-assets/reference/first-level.png',
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: 'asset-reference-first-level.png',
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: false,
|
||||
});
|
||||
@@ -584,6 +608,8 @@ test('puzzle workspace submits history image when AI redraw is off', async () =>
|
||||
pictureDescription: '历史素材 · image.png',
|
||||
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: 'asset-history-1',
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: false,
|
||||
});
|
||||
@@ -593,6 +619,17 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
|
||||
const onCreateFromForm = vi.fn();
|
||||
const uploadedDataUrl = 'data:image/png;base64,uploaded-square';
|
||||
stubReferenceImageUpload(uploadedDataUrl);
|
||||
vi.mocked(puzzleAssetClient.uploadReferenceImage).mockResolvedValue({
|
||||
assetObjectId: 'asset-reference-main-1',
|
||||
assetKind: 'puzzle_cover_image',
|
||||
objectKey: 'generated-puzzle-assets/reference/main-1.png',
|
||||
imageSrc: '/generated-puzzle-assets/reference/main-1.png',
|
||||
ownerUserId: 'user-1',
|
||||
profileId: null,
|
||||
entityId: null,
|
||||
createdAt: '1713686400.000000Z',
|
||||
updatedAt: '1713686400.000000Z',
|
||||
});
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
@@ -612,6 +649,9 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
|
||||
await waitFor(() => {
|
||||
expect(screen.getByAltText('拼图图片')).toBeTruthy();
|
||||
});
|
||||
expect(puzzleAssetClient.uploadReferenceImage).toHaveBeenCalledWith({
|
||||
file: expect.any(File),
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('画面AI重绘要求(提示词)'), {
|
||||
target: { value: '保留上传画面的主体和构图,改成雨夜灯街。' },
|
||||
});
|
||||
@@ -621,8 +661,101 @@ test('puzzle workspace submits uploaded reference image when AI redraw is on', a
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: '保留上传画面的主体和构图,改成雨夜灯街。',
|
||||
pictureDescription: '保留上传画面的主体和构图,改成雨夜灯街。',
|
||||
referenceImageSrc: uploadedDataUrl,
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: 'asset-reference-main-1',
|
||||
referenceImageAssetObjectIds: [],
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('puzzle workspace uploads prompt references as asset object ids', async () => {
|
||||
const onCreateFromForm = vi.fn();
|
||||
const uploadedSources = [
|
||||
'data:image/png;base64,reference-1',
|
||||
'data:image/png;base64,reference-2',
|
||||
];
|
||||
let readIndex = 0;
|
||||
stubReferenceImageUpload(uploadedSources[0] ?? 'data:image/png;base64,reference-1');
|
||||
class MockFileReader {
|
||||
result: string | null = null;
|
||||
onload: null | (() => void) = null;
|
||||
onerror: null | (() => void) = null;
|
||||
|
||||
readAsDataURL() {
|
||||
this.result = uploadedSources[readIndex] ?? uploadedSources[0] ?? '';
|
||||
readIndex += 1;
|
||||
this.onload?.();
|
||||
}
|
||||
}
|
||||
vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader);
|
||||
vi.mocked(puzzleAssetClient.uploadReferenceImage)
|
||||
.mockResolvedValueOnce({
|
||||
assetObjectId: 'asset-reference-prompt-1',
|
||||
assetKind: 'puzzle_cover_image',
|
||||
objectKey: 'generated-puzzle-assets/reference/prompt-1.png',
|
||||
imageSrc: '/generated-puzzle-assets/reference/prompt-1.png',
|
||||
ownerUserId: 'user-1',
|
||||
profileId: null,
|
||||
entityId: null,
|
||||
createdAt: '1713686400.000000Z',
|
||||
updatedAt: '1713686400.000000Z',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
assetObjectId: 'asset-reference-prompt-2',
|
||||
assetKind: 'puzzle_cover_image',
|
||||
objectKey: 'generated-puzzle-assets/reference/prompt-2.png',
|
||||
imageSrc: '/generated-puzzle-assets/reference/prompt-2.png',
|
||||
ownerUserId: 'user-1',
|
||||
profileId: null,
|
||||
entityId: null,
|
||||
createdAt: '1713686400.000000Z',
|
||||
updatedAt: '1713686400.000000Z',
|
||||
});
|
||||
|
||||
render(
|
||||
<PuzzleAgentWorkspace
|
||||
session={null}
|
||||
onBack={() => {}}
|
||||
onSubmitMessage={() => {}}
|
||||
onExecuteAction={() => {}}
|
||||
onCreateFromForm={onCreateFromForm}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText('画面描述'), {
|
||||
target: { value: '一只猫在雨夜灯牌下回头。' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('上传参考图', { selector: 'input' }), {
|
||||
target: {
|
||||
files: uploadedSources.map(
|
||||
(_source, index) =>
|
||||
new File(['x'], `reference-${index + 1}.png`, {
|
||||
type: 'image/png',
|
||||
}),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByRole('button', { name: /预览参考图/u })).toHaveLength(
|
||||
2,
|
||||
);
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成拼图游戏草稿/u }));
|
||||
confirmPuzzlePointCost();
|
||||
|
||||
expect(onCreateFromForm).toHaveBeenCalledWith({
|
||||
seedText: '一只猫在雨夜灯牌下回头。',
|
||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [
|
||||
'asset-reference-prompt-1',
|
||||
'asset-reference-prompt-2',
|
||||
],
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
});
|
||||
@@ -705,7 +838,15 @@ test('puzzle workspace uploads prompt reference images from the description box'
|
||||
seedText: '一只猫在雨夜灯牌下回头。',
|
||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: null,
|
||||
referenceImageSrcs: uploadedSources.slice(0, 5),
|
||||
referenceImageSrcs: [],
|
||||
referenceImageAssetObjectId: null,
|
||||
referenceImageAssetObjectIds: [
|
||||
'asset-reference-reference-1.png',
|
||||
'asset-reference-reference-2.png',
|
||||
'asset-reference-reference-3.png',
|
||||
'asset-reference-reference-4.png',
|
||||
'asset-reference-reference-5.png',
|
||||
],
|
||||
imageModel: 'gpt-image-2',
|
||||
aiRedraw: true,
|
||||
});
|
||||
|
||||
@@ -16,9 +16,11 @@ import { getPuzzleHistoryAssetReferenceLabel } from '../../services/puzzle-works
|
||||
import {
|
||||
cropPuzzleReferenceImageDataUrl,
|
||||
isPuzzleReferenceImageSquare,
|
||||
puzzleReferenceImageDataUrlToFile,
|
||||
readPuzzleReferenceImageAsDataUrl,
|
||||
readPuzzleReferenceImageForUpload,
|
||||
} from '../../services/puzzleReferenceImage';
|
||||
import { puzzleAssetClient } from '../../services/puzzle-works/puzzleAssetClient';
|
||||
import {
|
||||
CreativeImageInputPanel,
|
||||
type CreativeImageInputReferenceImage,
|
||||
@@ -54,6 +56,7 @@ type PuzzleAgentWorkspaceProps = {
|
||||
type PuzzleFormState = {
|
||||
pictureDescription: string;
|
||||
referenceImageSrc: string;
|
||||
referenceImageAssetObjectId: string;
|
||||
referenceImageLabel: string;
|
||||
referenceImageSrcs: CreativeImageInputReferenceImage[];
|
||||
imageModel: PuzzleImageModelId;
|
||||
@@ -63,6 +66,7 @@ type PuzzleFormState = {
|
||||
const EMPTY_FORM_STATE: PuzzleFormState = {
|
||||
pictureDescription: '',
|
||||
referenceImageSrc: '',
|
||||
referenceImageAssetObjectId: '',
|
||||
referenceImageLabel: '',
|
||||
referenceImageSrcs: [],
|
||||
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
@@ -74,6 +78,7 @@ const PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT = 5;
|
||||
type PuzzleImageCropState = {
|
||||
source: string;
|
||||
label: string;
|
||||
fileName: string;
|
||||
imageSize: { width: number; height: number };
|
||||
cropRect: SquareImageCropRect;
|
||||
error: string | null;
|
||||
@@ -97,11 +102,14 @@ function resolveInitialFormState(
|
||||
return {
|
||||
pictureDescription: formDraft.pictureDescription ?? '',
|
||||
referenceImageSrc: initialFormPayload?.referenceImageSrc ?? '',
|
||||
referenceImageAssetObjectId:
|
||||
initialFormPayload?.referenceImageAssetObjectId ?? '',
|
||||
referenceImageLabel: initialFormPayload?.referenceImageSrc
|
||||
? '已选择拼图图片'
|
||||
: '',
|
||||
referenceImageSrcs: createPuzzlePromptReferenceImagesFromSources(
|
||||
initialFormPayload?.referenceImageSrcs,
|
||||
initialFormPayload?.referenceImageAssetObjectIds,
|
||||
),
|
||||
imageModel: normalizePuzzleImageModel(initialFormPayload?.imageModel),
|
||||
aiRedraw: initialFormPayload?.aiRedraw ?? true,
|
||||
@@ -115,11 +123,14 @@ function resolveInitialFormState(
|
||||
initialFormPayload.seedText ??
|
||||
'',
|
||||
referenceImageSrc: initialFormPayload.referenceImageSrc ?? '',
|
||||
referenceImageAssetObjectId:
|
||||
initialFormPayload.referenceImageAssetObjectId ?? '',
|
||||
referenceImageLabel: initialFormPayload.referenceImageSrc
|
||||
? '已选择拼图图片'
|
||||
: '',
|
||||
referenceImageSrcs: createPuzzlePromptReferenceImagesFromSources(
|
||||
initialFormPayload.referenceImageSrcs,
|
||||
initialFormPayload.referenceImageAssetObjectIds,
|
||||
),
|
||||
imageModel: normalizePuzzleImageModel(initialFormPayload.imageModel),
|
||||
aiRedraw: initialFormPayload.aiRedraw ?? true,
|
||||
@@ -138,6 +149,7 @@ function resolveInitialFormState(
|
||||
session.seedText ||
|
||||
'',
|
||||
referenceImageSrc: '',
|
||||
referenceImageAssetObjectId: '',
|
||||
referenceImageLabel: '',
|
||||
referenceImageSrcs: [],
|
||||
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
@@ -166,14 +178,46 @@ function normalizePuzzlePromptReferenceSources(
|
||||
|
||||
function createPuzzlePromptReferenceImagesFromSources(
|
||||
sources: readonly string[] | null | undefined,
|
||||
assetObjectIds: readonly string[] | null | undefined = [],
|
||||
): CreativeImageInputReferenceImage[] {
|
||||
return normalizePuzzlePromptReferenceSources(sources).map(
|
||||
const assetIds = normalizePuzzleAssetObjectIds(assetObjectIds);
|
||||
const sourceImages = normalizePuzzlePromptReferenceSources(sources).map(
|
||||
(imageSrc, index) => ({
|
||||
id: `restored:${index}:${imageSrc}`,
|
||||
label: `参考图 ${index + 1}`,
|
||||
imageSrc,
|
||||
assetObjectId: assetIds[index] ?? null,
|
||||
}),
|
||||
);
|
||||
if (sourceImages.length > 0) {
|
||||
return sourceImages;
|
||||
}
|
||||
|
||||
return assetIds.map((assetObjectId, index) => ({
|
||||
id: `restored-asset:${index}:${assetObjectId}`,
|
||||
label: `参考图 ${index + 1}`,
|
||||
imageSrc: '',
|
||||
assetObjectId,
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizePuzzleAssetObjectIds(
|
||||
assetObjectIds: readonly (string | null | undefined)[] | null | undefined,
|
||||
) {
|
||||
const normalizedIds: string[] = [];
|
||||
for (const assetObjectId of assetObjectIds ?? []) {
|
||||
const normalized = assetObjectId?.trim() ?? '';
|
||||
if (
|
||||
normalized &&
|
||||
!normalizedIds.some((current) => current === normalized)
|
||||
) {
|
||||
normalizedIds.push(normalized);
|
||||
}
|
||||
if (normalizedIds.length >= PUZZLE_PROMPT_REFERENCE_IMAGE_LIMIT) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return normalizedIds;
|
||||
}
|
||||
|
||||
function addPuzzlePromptReferenceImage(
|
||||
@@ -256,6 +300,21 @@ export function PuzzleAgentWorkspace({
|
||||
),
|
||||
[formState.referenceImageSrc, formState.referenceImageSrcs],
|
||||
);
|
||||
const promptReferenceAssetObjectIds = useMemo(
|
||||
() =>
|
||||
formState.referenceImageSrc
|
||||
? []
|
||||
: normalizePuzzleAssetObjectIds(
|
||||
formState.referenceImageSrcs.map((image) => image.assetObjectId),
|
||||
),
|
||||
[formState.referenceImageSrc, formState.referenceImageSrcs],
|
||||
);
|
||||
const mainReferenceImageSrcForPayload =
|
||||
formState.referenceImageAssetObjectId && formState.aiRedraw
|
||||
? null
|
||||
: formState.referenceImageSrc || null;
|
||||
const promptReferenceImageSrcsForPayload =
|
||||
promptReferenceAssetObjectIds.length > 0 ? [] : promptReferenceImageSrcs;
|
||||
const canSubmit = formState.aiRedraw
|
||||
? Boolean(pictureDescription) && !isBusy
|
||||
: Boolean(formState.referenceImageSrc) && !isBusy;
|
||||
@@ -263,16 +322,21 @@ export function PuzzleAgentWorkspace({
|
||||
() => ({
|
||||
seedText: pictureDescription,
|
||||
pictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
referenceImageSrcs: promptReferenceImageSrcs,
|
||||
referenceImageSrc: mainReferenceImageSrcForPayload,
|
||||
referenceImageSrcs: promptReferenceImageSrcsForPayload,
|
||||
referenceImageAssetObjectId:
|
||||
formState.referenceImageAssetObjectId || null,
|
||||
referenceImageAssetObjectIds: promptReferenceAssetObjectIds,
|
||||
imageModel: formState.imageModel,
|
||||
aiRedraw: formState.aiRedraw,
|
||||
}),
|
||||
[
|
||||
formState.aiRedraw,
|
||||
formState.referenceImageSrc,
|
||||
formState.referenceImageAssetObjectId,
|
||||
formState.imageModel,
|
||||
promptReferenceImageSrcs,
|
||||
mainReferenceImageSrcForPayload,
|
||||
promptReferenceAssetObjectIds,
|
||||
promptReferenceImageSrcsForPayload,
|
||||
pictureDescription,
|
||||
],
|
||||
);
|
||||
@@ -280,6 +344,8 @@ export function PuzzleAgentWorkspace({
|
||||
autosavePayload.pictureDescription,
|
||||
autosavePayload.referenceImageSrc,
|
||||
autosavePayload.referenceImageSrcs,
|
||||
autosavePayload.referenceImageAssetObjectId,
|
||||
autosavePayload.referenceImageAssetObjectIds,
|
||||
autosavePayload.aiRedraw,
|
||||
autosavePayload.imageModel,
|
||||
]);
|
||||
@@ -333,6 +399,7 @@ export function PuzzleAgentWorkspace({
|
||||
setCropState({
|
||||
source: uploadImage.dataUrl,
|
||||
label: file.name.trim() || '本地拼图图片',
|
||||
fileName: file.name.trim() || 'puzzle-reference.jpg',
|
||||
imageSize,
|
||||
cropRect: buildCenteredSquareImageCropRect(imageSize),
|
||||
error: null,
|
||||
@@ -342,9 +409,11 @@ export function PuzzleAgentWorkspace({
|
||||
return;
|
||||
}
|
||||
|
||||
const asset = await puzzleAssetClient.uploadReferenceImage({ file });
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
referenceImageSrc: uploadImage.dataUrl,
|
||||
referenceImageSrc: asset.imageSrc || uploadImage.dataUrl,
|
||||
referenceImageAssetObjectId: asset.assetObjectId,
|
||||
referenceImageLabel: file.name.trim() || '本地拼图图片',
|
||||
}));
|
||||
setReferenceImageError(null);
|
||||
@@ -372,11 +441,18 @@ export function PuzzleAgentWorkspace({
|
||||
|
||||
try {
|
||||
const images = await Promise.all(
|
||||
files.slice(0, remainingSlots).map(async (file, index) => ({
|
||||
id: `prompt-upload:${Date.now()}:${index}:${file.name}`,
|
||||
label: file.name.trim() || `参考图 ${index + 1}`,
|
||||
imageSrc: await readPuzzleReferenceImageAsDataUrl(file),
|
||||
})),
|
||||
files.slice(0, remainingSlots).map(async (file, index) => {
|
||||
const [imageSrc, asset] = await Promise.all([
|
||||
readPuzzleReferenceImageAsDataUrl(file),
|
||||
puzzleAssetClient.uploadReferenceImage({ file }),
|
||||
]);
|
||||
return {
|
||||
id: `prompt-upload:${Date.now()}:${index}:${file.name}`,
|
||||
label: file.name.trim() || `参考图 ${index + 1}`,
|
||||
imageSrc: asset.imageSrc || imageSrc,
|
||||
assetObjectId: asset.assetObjectId,
|
||||
};
|
||||
}),
|
||||
);
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
@@ -439,9 +515,15 @@ export function PuzzleAgentWorkspace({
|
||||
cropY: currentCropState.cropRect.y,
|
||||
cropSize: currentCropState.cropRect.size,
|
||||
});
|
||||
const file = puzzleReferenceImageDataUrlToFile(
|
||||
dataUrl,
|
||||
currentCropState.fileName,
|
||||
);
|
||||
const asset = await puzzleAssetClient.uploadReferenceImage({ file });
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
referenceImageSrc: dataUrl,
|
||||
referenceImageSrc: asset.imageSrc || dataUrl,
|
||||
referenceImageAssetObjectId: asset.assetObjectId,
|
||||
referenceImageLabel: currentCropState.label,
|
||||
}));
|
||||
setCropState(null);
|
||||
@@ -482,8 +564,11 @@ export function PuzzleAgentWorkspace({
|
||||
const payload = {
|
||||
seedText: payloadPictureDescription,
|
||||
pictureDescription: payloadPictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
referenceImageSrcs: promptReferenceImageSrcs,
|
||||
referenceImageSrc: mainReferenceImageSrcForPayload,
|
||||
referenceImageSrcs: promptReferenceImageSrcsForPayload,
|
||||
referenceImageAssetObjectId:
|
||||
formState.referenceImageAssetObjectId || null,
|
||||
referenceImageAssetObjectIds: promptReferenceAssetObjectIds,
|
||||
imageModel: formState.imageModel,
|
||||
aiRedraw: formState.aiRedraw,
|
||||
};
|
||||
@@ -499,8 +584,11 @@ export function PuzzleAgentWorkspace({
|
||||
action: 'compile_puzzle_draft',
|
||||
promptText: payloadPictureDescription,
|
||||
pictureDescription: payloadPictureDescription,
|
||||
referenceImageSrc: formState.referenceImageSrc || null,
|
||||
referenceImageSrcs: promptReferenceImageSrcs,
|
||||
referenceImageSrc: mainReferenceImageSrcForPayload,
|
||||
referenceImageSrcs: promptReferenceImageSrcsForPayload,
|
||||
referenceImageAssetObjectId:
|
||||
formState.referenceImageAssetObjectId || null,
|
||||
referenceImageAssetObjectIds: promptReferenceAssetObjectIds,
|
||||
imageModel: formState.imageModel,
|
||||
aiRedraw: formState.aiRedraw,
|
||||
candidateCount: 1,
|
||||
@@ -510,6 +598,7 @@ export function PuzzleAgentWorkspace({
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
referenceImageSrc: '',
|
||||
referenceImageAssetObjectId: '',
|
||||
referenceImageLabel: '',
|
||||
aiRedraw: true,
|
||||
}));
|
||||
@@ -645,6 +734,7 @@ export function PuzzleAgentWorkspace({
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
referenceImageSrc: asset.imageSrc,
|
||||
referenceImageAssetObjectId: asset.assetObjectId,
|
||||
referenceImageLabel: getPuzzleHistoryAssetReferenceLabel(
|
||||
asset.imageSrc,
|
||||
),
|
||||
|
||||
@@ -13,6 +13,153 @@ export type PuzzleHistoryAsset = {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type PuzzleReferenceAsset = PuzzleHistoryAsset & {
|
||||
objectKey: string;
|
||||
};
|
||||
|
||||
type DirectUploadTicketResponse = {
|
||||
upload: {
|
||||
bucket: string;
|
||||
host: string;
|
||||
objectKey: string;
|
||||
legacyPublicPath: string;
|
||||
formFields: Record<string, string | null | undefined>;
|
||||
};
|
||||
};
|
||||
|
||||
type ConfirmAssetObjectResponse = {
|
||||
assetObject: {
|
||||
assetObjectId: string;
|
||||
objectKey: string;
|
||||
assetKind: 'puzzle_cover_image';
|
||||
ownerUserId?: string | null;
|
||||
profileId?: string | null;
|
||||
entityId?: string | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
|
||||
|
||||
const MIME_BY_EXTENSION: Record<string, string> = {
|
||||
jpeg: 'image/jpeg',
|
||||
jpg: 'image/jpeg',
|
||||
png: 'image/png',
|
||||
webp: 'image/webp',
|
||||
};
|
||||
|
||||
function resolvePuzzleImageContentType(file: File) {
|
||||
if (file.type.trim()) {
|
||||
return file.type.trim();
|
||||
}
|
||||
|
||||
const extension = file.name.split('.').pop()?.trim().toLowerCase() ?? '';
|
||||
return MIME_BY_EXTENSION[extension] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
function validatePuzzleReferenceImageFile(file: File) {
|
||||
const contentType = resolvePuzzleImageContentType(file);
|
||||
if (file.size <= 0) {
|
||||
throw new Error('参考图文件为空,请重新选择。');
|
||||
}
|
||||
if (file.size > PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES) {
|
||||
throw new Error('参考图过大,请压缩后再上传。');
|
||||
}
|
||||
if (!contentType.startsWith('image/')) {
|
||||
throw new Error('参考图必须是图片文件。');
|
||||
}
|
||||
}
|
||||
|
||||
async function postDirectUploadFile(
|
||||
upload: DirectUploadTicketResponse['upload'],
|
||||
file: File,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
Object.entries(upload.formFields).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
formData.append(key, value);
|
||||
}
|
||||
});
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(upload.host, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('上传拼图参考图失败。');
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadPuzzleReferenceImage(payload: {
|
||||
file: File;
|
||||
}): Promise<PuzzleReferenceAsset> {
|
||||
validatePuzzleReferenceImageFile(payload.file);
|
||||
const contentType = resolvePuzzleImageContentType(payload.file);
|
||||
const uploadedAt = Date.now();
|
||||
const ticket = await requestJson<DirectUploadTicketResponse>(
|
||||
'/api/assets/direct-upload-tickets',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
legacyPrefix: 'generated-puzzle-assets',
|
||||
pathSegments: ['puzzle-reference', 'draft', `${uploadedAt}`],
|
||||
fileName: payload.file.name,
|
||||
contentType,
|
||||
access: 'private',
|
||||
maxSizeBytes: PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
|
||||
metadata: {
|
||||
asset_kind: 'puzzle_cover_image',
|
||||
puzzle_slot: 'reference_image',
|
||||
},
|
||||
}),
|
||||
},
|
||||
'创建拼图参考图上传凭证失败',
|
||||
);
|
||||
|
||||
await postDirectUploadFile(ticket.upload, payload.file);
|
||||
|
||||
const confirmed = await requestJson<ConfirmAssetObjectResponse>(
|
||||
'/api/assets/objects/confirm',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
bucket: ticket.upload.bucket,
|
||||
objectKey: ticket.upload.objectKey,
|
||||
contentType,
|
||||
contentLength: payload.file.size,
|
||||
assetKind: 'puzzle_cover_image',
|
||||
accessPolicy: 'private',
|
||||
}),
|
||||
},
|
||||
'确认拼图参考图失败',
|
||||
);
|
||||
|
||||
return {
|
||||
assetObjectId: confirmed.assetObject.assetObjectId,
|
||||
assetKind: confirmed.assetObject.assetKind,
|
||||
objectKey: confirmed.assetObject.objectKey,
|
||||
imageSrc: ticket.upload.legacyPublicPath,
|
||||
ownerUserId: confirmed.assetObject.ownerUserId,
|
||||
ownerLabel: confirmed.assetObject.ownerUserId
|
||||
? `账号 ${confirmed.assetObject.ownerUserId}`
|
||||
: '当前账号',
|
||||
profileId: confirmed.assetObject.profileId,
|
||||
entityId: confirmed.assetObject.entityId,
|
||||
createdAt: confirmed.assetObject.createdAt ?? '',
|
||||
updatedAt: confirmed.assetObject.updatedAt ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export const puzzleReferenceAssetTestUtils = {
|
||||
maxUploadBytes: PUZZLE_REFERENCE_IMAGE_MAX_UPLOAD_BYTES,
|
||||
validateFile: validatePuzzleReferenceImageFile,
|
||||
};
|
||||
|
||||
/**
|
||||
* 读取历史拼图图片素材。结果页只把它们作为参考图来源,
|
||||
* 不直接替换当前正式图,正式图仍由后端单图生成链路写回。
|
||||
@@ -34,4 +181,5 @@ export async function listPuzzleHistoryAssets(payload: { limit?: number }) {
|
||||
|
||||
export const puzzleAssetClient = {
|
||||
listHistoryAssets: listPuzzleHistoryAssets,
|
||||
uploadReferenceImage: uploadPuzzleReferenceImage,
|
||||
};
|
||||
|
||||
@@ -238,3 +238,18 @@ export async function cropPuzzleReferenceImageDataUrl({
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function puzzleReferenceImageDataUrlToFile(
|
||||
dataUrl: string,
|
||||
fileName = 'puzzle-reference.jpg',
|
||||
) {
|
||||
const [metadata = '', encoded = ''] = dataUrl.split(',', 2);
|
||||
const mimeType =
|
||||
metadata.match(/^data:([^;]+);base64$/u)?.[1] ?? 'image/jpeg';
|
||||
const binary = atob(encoded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return new File([bytes], fileName, { type: mimeType });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user