diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index 5ad694034..6b79cb2fa 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -34,10 +34,12 @@ "typecheck": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json --noEmit && node scripts/check-config.mjs" }, "dependencies": { + "@cubone/react-file-manager": "^1.35.0", "@lexical/react": "^0.47.0", "@lexical/utils": "^0.47.0", "@tauri-apps/api": "^2.11.1", "@tauri-apps/plugin-clipboard-manager": "2.3.2", + "@tauri-apps/plugin-dialog": "^2.7.2", "@tauri-apps/plugin-http": "^2.5.9", "@tauri-apps/plugin-opener": "~2", "@vitejs/plugin-react": "^5.0.4", diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/developer.json b/apps/ai-game-creator-shell/src-tauri/capabilities/developer.json new file mode 100644 index 000000000..ca9fdae42 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/developer.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "developer", + "description": "开发窗口允许打开本地素材选择对话框。", + "windows": ["developer"], + "permissions": ["dialog:allow-open"] +} diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json index 50d312f47..93815ab46 100644 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json @@ -13,6 +13,7 @@ "identifier": "http:default", "allow": [{ "url": "https://dev.genarrative.world/api/*" }] }, - "opener:default" + "opener:default", + "dialog:allow-open" ] } diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 498616bd4..74e1b09dd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1377,6 +1377,137 @@ pub(crate) async fn sync_canvas_project_assets( sync_canvas_project_assets_at(root, canvas_project_id.trim(), api_base_url, api_key).await } +#[tauri::command] +pub(crate) fn import_ui_editor_local_files( + project_path: String, + source_paths: Vec, + max_file_size: u64, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.upload")?; + let _lock = acquire_project_write_lock(root, "asset.upload")?; + advance_agent_runtime_project_revision_locked(root)?; + let mut inputs = Vec::new(); + for source in source_paths { + let path = Path::new(source.trim()); + let metadata = fs::symlink_metadata(path).map_err(|e| format!("读取本地图片失败:{e}"))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("只能导入普通图片文件".to_string()); + } + if metadata.len() > max_file_size { + return Err(format!("图片超过 {} 字节限制", max_file_size)); + } + let bytes = fs::read(path).map_err(|e| format!("读取本地图片失败:{e}"))?; + let media_type = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + "image/png" + } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + "image/jpeg" + } else if bytes.starts_with(b"RIFF") && bytes.len() > 12 && &bytes[8..12] == b"WEBP" { + "image/webp" + } else { + return Err("本地文件不是受支持的 PNG/JPEG/WEBP 图片".to_string()); + }; + let name = path + .file_name() + .and_then(|v| v.to_str()) + .unwrap_or("image") + .to_string(); + inputs.push((name, media_type.to_string(), bytes)); + } + let assets = inputs + .into_iter() + .map(|(name, media, bytes)| upload_local_asset_at(root, &name, &media, &bytes)) + .collect::, _>>()? + .into_iter() + .map(|asset| ImportedAsset { + id: asset.id, + local_path: asset.local_path, + asset_kind: None, + }) + .collect(); + Ok(LocalImportResult { assets }) +} + +#[tauri::command] +pub(crate) async fn import_ui_editor_remote_assets( + project_path: String, + assets: Vec, + max_file_size: u64, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "canvas.asset_import")?; + let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; + advance_agent_runtime_project_revision_locked(root)?; + let mut downloads = Vec::new(); + for asset in assets { + let asset_id = json_string_field(&asset, "assetId") + .or_else(|| json_string_field(&asset, "objectKey")) + .ok_or_else(|| "平台素材缺少稳定 assetId/objectKey,拒绝导入".to_string())?; + let bytes = asset + .get("bytes") + .cloned() + .ok_or_else(|| format!("平台素材 {asset_id} 缺少图片内容")) + .and_then(|value| { + serde_json::from_value::>(value) + .map_err(|_| format!("平台素材 {asset_id} 图片内容无效")) + })?; + if bytes.len() as u64 > max_file_size { + return Err(format!("图片超过 {} 字节限制", max_file_size)); + } + let media_type = if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + "image/png" + } else if bytes.starts_with(&[0xff, 0xd8, 0xff]) { + "image/jpeg" + } else if bytes.starts_with(b"RIFF") && bytes.len() > 12 && &bytes[8..12] == b"WEBP" { + "image/webp" + } else { + return Err("平台素材不是受支持的 PNG/JPEG/WEBP 图片".to_string()); + }; + downloads.push((asset, asset_id, media_type.to_string(), bytes)); + } + let mut imported = Vec::new(); + for (asset, asset_id, media_type, bytes) in downloads { + let extension = infer_file_extension( + json_string_field(&asset, "objectKey") + .or_else(|| json_string_field(&asset, "imageSrc")) + .as_deref(), + &media_type, + ); + let local_name = sanitize_file_name(&asset_id); + let local_path = format!("assets/imported/remote-{local_name}.{extension}"); + let target = resolve_local_project_path(root, &local_path)?; + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|e| format!("创建导入目录失败:{e}"))?; + } + fs::write(&target, &bytes).map_err(|e| format!("写入平台素材失败:{e}"))?; + let registered = register_local_asset_entry( + root, + &local_path, + "ui", + &media_type, + "canvas", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: None, + resource_id: json_string_field(&asset, "assetId"), + asset_object_id: json_string_field(&asset, "assetObjectId"), + task_id: None, + prompt: None, + model: None, + generation_route: Some("editor.asset-library.import".to_string()), + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + )?; + imported.push(ImportedAsset { + id: registered.id, + local_path: registered.local_path, + asset_kind: json_string_field(&asset, "assetKind"), + }); + } + Ok(RemoteImportResult { assets: imported }) +} + #[tauri::command] pub(crate) async fn generate_platform_art_asset( project_path: String, diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index ec376e62e..bb7edfb66 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -921,6 +921,22 @@ struct SyncCanvasProjectAssetsResult { assets: Vec, } +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalImportResult { + assets: Vec, +} + +type RemoteImportResult = LocalImportResult; + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ImportedAsset { + id: String, + local_path: String, + asset_kind: Option, +} + #[derive(Debug, Eq, PartialEq)] struct GeneratedPlatformArtAssetSlice { name: String, @@ -2226,6 +2242,8 @@ fn main() { import_canvas_asset, import_canvas_export, sync_canvas_project_assets, + import_ui_editor_local_files, + import_ui_editor_remote_assets, generate_platform_art_asset, open_canvas_project, get_game_creation_agent_capabilities, diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx new file mode 100644 index 000000000..f98b6048e --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/index.tsx @@ -0,0 +1,438 @@ +import '@cubone/react-file-manager/dist/style.css'; + +import { FileManager } from '@cubone/react-file-manager'; +import { invoke } from '@tauri-apps/api/core'; +import { open as openNativeFileDialog } from '@tauri-apps/plugin-dialog'; +import { X } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + loadEditorAssetLibrary, + readClientAssetBytes, + resolveClientAssetReadUrl, +} from '../../services/clientApi'; +import { ThemedModal } from '../modal/ThemedModal'; +import { + buildProjectFiles, + buildRemoteFolderFiles, + buildRootFiles, + type ImportedAsset, + type ManagerFile, + type ManifestAsset, + PROJECT_ASSETS_PATH, + REMOTE_ASSETS_PATH, +} from './utils'; + +export type { ImportedAsset } from './utils'; + +const ROOT_PATH = '/'; + +export type UiEditorImageImporterProps = { + open: boolean; + onClose: () => void; + onImport: (assets: ImportedAsset[]) => void; + projectPath: string; + maxItems: number; + maxFileSize: number; + acceptedMediaTypes: readonly string[]; +}; + +type LocalProjectFile = { path: string }; + +type LocalManifestResponse = { + assets?: ManifestAsset[]; + manifest?: { assets?: ManifestAsset[] }; +}; + +type LocalProjectFilesResponse = { files?: LocalProjectFile[] }; + +type ImagePreviewResponse = { dataUrl: string }; + +type ImportAssetResponse = { + id: string; + localPath: string; + assetKind?: string | null; +}; + +export function AssetImporter({ + open, + onClose, + onImport, + projectPath, + maxItems, + maxFileSize, + acceptedMediaTypes, +}: UiEditorImageImporterProps) { + const [files, setFiles] = useState([]); + const [selected, setSelected] = useState([]); + const [preview, setPreview] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [currentPath, setCurrentPath] = useState(ROOT_PATH); + const currentPathRef = useRef(ROOT_PATH); + const remoteLoadedRef = useRef(false); + + const loadLocal = useCallback(async () => { + setError(null); + await invoke('init_local_game_project', { + projectPath, + projectId: 'ui-editor-test', + name: 'UI Editor Test', + }); + const [manifest, listed] = await Promise.all([ + invoke('get_local_game_manifest', { + projectPath, + commandId: 'asset.list', + }), + invoke('list_local_project_files', { + projectPath, + }), + ]); + const registered = (manifest.assets ?? + manifest.manifest?.assets ?? + []) as ManifestAsset[]; + const registeredPaths = new Set(registered.map((asset) => asset.localPath)); + const filesFromDisk = (listed.files ?? []).filter((file) => + registeredPaths.has(file.path), + ); + const byPath = new Map(registered.map((asset) => [asset.localPath, asset])); + return buildProjectFiles( + filesFromDisk + .map((file) => byPath.get(file.path)) + .filter(Boolean) as ManifestAsset[], + ); + }, [projectPath]); + + const replaceProjectFiles = useCallback((projectFiles: ManagerFile[]) => { + setFiles((current) => [ + ...buildRootFiles(), + ...projectFiles, + ...current.filter((file) => file.source === 'remote'), + ]); + }, []); + + const replaceRemoteFiles = useCallback((remoteFiles: ManagerFile[]) => { + setFiles((current) => [ + ...buildRootFiles(), + ...current.filter((file) => file.source === 'local'), + ...remoteFiles, + ]); + remoteLoadedRef.current = true; + }, []); + + const loadRemote = useCallback(async () => { + const library = await loadEditorAssetLibrary(); + const remoteFiles = buildRemoteFolderFiles(library); + replaceRemoteFiles(remoteFiles); + return remoteFiles; + }, [replaceRemoteFiles]); + + const refresh = useCallback( + async (path = currentPathRef.current) => { + setLoading(true); + setError(null); + try { + const shouldRefreshProject = + path === ROOT_PATH || path.startsWith(PROJECT_ASSETS_PATH); + const shouldRefreshRemote = + path === ROOT_PATH || path.startsWith(REMOTE_ASSETS_PATH); + const projectResult = shouldRefreshProject + ? await Promise.allSettled([loadLocal()]).then(([result]) => result) + : null; + if (projectResult?.status === 'fulfilled' && projectResult.value) { + replaceProjectFiles(projectResult.value); + } + let remoteResult: PromiseSettledResult | null = null; + if ( + shouldRefreshRemote && + (path !== ROOT_PATH || remoteLoadedRef.current) + ) { + remoteResult = await Promise.allSettled([loadRemote()]).then( + ([result]) => result, + ); + } + if ( + projectResult?.status === 'rejected' || + remoteResult?.status === 'rejected' + ) { + const cause = + projectResult?.status === 'rejected' + ? projectResult.reason + : remoteResult?.status === 'rejected' + ? remoteResult.reason + : null; + setError( + cause instanceof Error + ? cause.message + : '部分素材来源加载失败,仍可继续使用其他来源。', + ); + } + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setLoading(false); + } + }, + [loadLocal, loadRemote, replaceProjectFiles], + ); + + useEffect(() => { + if (!open) return; + currentPathRef.current = ROOT_PATH; + remoteLoadedRef.current = false; + setCurrentPath(ROOT_PATH); + setFiles(buildRootFiles()); + setSelected([]); + setPreview(null); + void refresh(ROOT_PATH); + }, [open, refresh]); + + useEffect(() => { + const item = selected.at(-1); + if (!item || item.isDirectory) { + setPreview(null); + return; + } + if (item.previewUrl) { + setPreview(item.previewUrl); + return; + } + if (item.source === 'remote' && item.remoteObjectKey) { + void resolveClientAssetReadUrl(item.remoteObjectKey) + .then(setPreview) + .catch(() => setPreview(null)); + return; + } + if ( + item.source === 'remote' && + /^https?:\/\//iu.test(item.asset?.localPath ?? '') + ) { + setPreview(item.asset?.localPath ?? null); + return; + } + void invoke('read_local_project_image_preview', { + projectPath, + relativePath: item.asset?.localPath, + }) + .then((value) => setPreview(value.dataUrl)) + .catch(() => setPreview(null)); + }, [projectPath, selected]); + + const pickLocal = async () => { + setError(null); + setLoading(true); + try { + const paths = await openNativeFileDialog({ + title: '选择图片素材', + multiple: true, + directory: false, + filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'webp'] }], + }); + const sourcePaths = Array.isArray(paths) ? paths : paths ? [paths] : []; + if (!sourcePaths.length) return; + if (sourcePaths.length > maxItems) + throw new Error(`最多选择 ${maxItems} 张图片`); + const result = await invoke<{ assets: ImportAssetResponse[] }>( + 'import_ui_editor_local_files', + { projectPath, sourcePaths, maxFileSize }, + ); + onImport( + result.assets.map((asset) => ({ + id: asset.id, + localPath: asset.localPath, + assetKind: asset.assetKind ?? null, + })), + ); + onClose(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setLoading(false); + } + }; + const confirm = async () => { + const chosen = selected.filter((item) => !item.isDirectory && item.asset); + if (!chosen.length || chosen.length > maxItems) return; + try { + setLoading(true); + const remoteChosen = chosen.filter((entry) => entry.source === 'remote'); + const localChosen = chosen.filter((entry) => entry.source !== 'remote'); + const imported: ImportedAsset[] = localChosen.map( + (entry) => entry.asset!, + ); + if (remoteChosen.length) { + const remoteResult = await invoke<{ assets: ImportAssetResponse[] }>( + 'import_ui_editor_remote_assets', + { + projectPath, + assets: await Promise.all( + remoteChosen.map(async (item) => { + const asset = item.asset!; + const response = asset.localPath.startsWith('http') + ? await fetch(asset.localPath) + : await readClientAssetBytes(asset.localPath); + if (!response.ok) + throw new Error( + `读取平台素材失败(HTTP ${response.status})`, + ); + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > maxFileSize) + throw new Error(`图片超过 ${maxFileSize} 字节限制`); + return { + assetId: asset.id, + objectKey: asset.localPath, + bytes: Array.from(bytes), + }; + }), + ), + maxFileSize, + }, + ); + imported.push( + ...remoteResult.assets.map((asset, index) => ({ + id: asset.id, + localPath: asset.localPath, + assetKind: + asset.assetKind ?? remoteChosen[index]?.asset?.assetKind ?? null, + })), + ); + } + onImport(imported); + onClose(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setLoading(false); + } + }; + const handleComputerImport = () => { + void pickLocal(); + }; + const handleFolderChange = (path: string) => { + const nextPath = path || ROOT_PATH; + currentPathRef.current = nextPath; + setCurrentPath(nextPath); + if (nextPath.startsWith(REMOTE_ASSETS_PATH) && !remoteLoadedRef.current) { + setLoading(true); + setError(null); + void loadRemote() + .catch((cause) => + setError(cause instanceof Error ? cause.message : String(cause)), + ) + .finally(() => setLoading(false)); + } + }; + + return ( + +
+

+ 导入图片素材 +

+ +
+
+
+ {error ? ( +
+ {error} +
+ ) : null} +
+ `.${value.split('/').at(-1)}`) + .join(',')} + onSelectionChange={(items: ManagerFile[]) => { + const assetItems = items + .filter((item) => !item.isDirectory) + .slice(-maxItems); + setSelected(assetItems); + }} + onFolderChange={handleFolderChange} + onRefresh={() => void refresh()} + /> +
+
+ +
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/react-file-manager.d.ts b/apps/ai-game-creator-shell/src/components/AssetImporter/react-file-manager.d.ts new file mode 100644 index 000000000..0cbb9f777 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/react-file-manager.d.ts @@ -0,0 +1,35 @@ +declare module '@cubone/react-file-manager' { + import type { CSSProperties, ReactElement, ReactNode } from 'react'; + + export type FileManagerFile = { + name: string; + isDirectory: boolean; + path: string; + updatedAt?: string; + size?: number; + }; + + export type FileManagerProps< + TFile extends FileManagerFile = FileManagerFile, + > = { + files: TFile[]; + className?: string; + layout?: 'list' | 'grid'; + initialPath?: string; + enableFilePreview?: boolean; + acceptedFileTypes?: string; + permissions?: Record; + onSelectionChange?: (files: TFile[]) => void; + filePreviewComponent?: (file: TFile) => ReactNode; + onFileOpen?: (file: TFile) => void; + onFolderChange?: (path: string) => void; + onRefresh?: () => void; + height?: string | number; + width?: string | number; + style?: CSSProperties; + }; + + export function FileManager( + props: FileManagerProps, + ): ReactElement; +} diff --git a/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts b/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts new file mode 100644 index 000000000..d8b1d4d71 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/AssetImporter/utils.ts @@ -0,0 +1,141 @@ +import type { FileManagerFile } from '@cubone/react-file-manager'; + +export type ImportedAsset = { + id: string; + localPath: string; + assetKind: string | null; +}; + +export type ManagerFile = FileManagerFile & { + previewUrl?: string; + source?: 'local' | 'remote'; + remoteObjectKey?: string; + asset?: ImportedAsset; +}; + +export type ManifestAsset = { + id: string; + localPath: string; + mediaType: string; + source?: { kind?: string }; +}; + +type RemoteFolder = { folderId?: string; label?: unknown }; +type RemoteAsset = { + folderId?: string; + assetKind?: string | null; + label?: unknown; + assetId?: string; + objectKey?: string; + imageSrc?: string; + size?: number; + previewUrl?: string; +}; +type RemoteLibrary = { folders?: RemoteFolder[]; assets?: RemoteAsset[] }; + +export const PROJECT_ASSETS_PATH = '/本地项目素材'; +export const REMOTE_ASSETS_PATH = '/云端素材库'; + +function isImageAssetKind(assetKind: unknown) { + const kind = typeof assetKind === 'string' ? assetKind.trim().toLowerCase() : ''; + if (!kind) return true; + return !( + kind === 'video' || kind === 'audio' || kind === 'sound-effect' || + kind === 'background-music' || kind === 'character-animation' || + kind.startsWith('video-') || kind.startsWith('audio-') || + kind.startsWith('character-animation') + ); +} + +function imagePath(path: string) { + return path.replace(/^\/+/, '').replaceAll('\\', '/'); +} + +function managerPathFromLocalPath(localPath: string) { + const path = imagePath(localPath); + return `${PROJECT_ASSETS_PATH}/${path.split('/').slice(1).join('/')}`; +} + +function safeManagerName(value: unknown, fallback: string) { + const normalized = typeof value === 'string' ? value.trim() : ''; + return (normalized || fallback).replaceAll('/', '/').replaceAll('\\', '\'); +} + +export function buildProjectFiles(assets: ManifestAsset[]): ManagerFile[] { + const result: ManagerFile[] = []; + const seen = new Set(); + for (const asset of assets) { + const path = imagePath(asset.localPath); + if (!path.startsWith('assets/') || !/^assets\/(.*\.(png|jpe?g|webp))$/i.test(path)) continue; + const parts = path.split('/'); + for (let index = 1; index < parts.length - 1; index += 1) { + const folderPath = `${PROJECT_ASSETS_PATH}/${parts.slice(1, index + 1).join('/')}`; + if (seen.has(folderPath)) continue; + seen.add(folderPath); + result.push({ name: parts[index] ?? folderPath, isDirectory: true, path: folderPath, source: 'local' }); + } + result.push({ + name: parts.at(-1) ?? path, + isDirectory: false, + path: managerPathFromLocalPath(asset.localPath), + source: 'local', + asset: { id: asset.id, localPath: asset.localPath, assetKind: null }, + }); + } + return result; +} + +export function buildRemoteFolderFiles(payload: unknown): ManagerFile[] { + const root = payload && typeof payload === 'object' ? (payload as Record) : {}; + const candidate = root.library ?? root.data ?? payload; + const library: RemoteLibrary = candidate && typeof candidate === 'object' ? (candidate as RemoteLibrary) : {}; + const folders = Array.isArray(library.folders) ? library.folders : []; + const assets = Array.isArray(library.assets) ? library.assets : []; + const result: ManagerFile[] = []; + const folderIds = new Set(folders.map((folder) => folder.folderId)); + const normalizedFolders = [...folders]; + if (assets.some((asset) => !folderIds.has(asset.folderId))) normalizedFolders.push({ folderId: '__uncategorized__', label: '未分类' }); + const usedFolderNames = new Set(); + for (const folder of normalizedFolders) { + const baseFolderName = safeManagerName(folder.label, folder.folderId || '未分类'); + const folderName = usedFolderNames.has(baseFolderName) + ? `${baseFolderName} (${safeManagerName(folder.folderId, String(usedFolderNames.size + 1))})` + : baseFolderName; + usedFolderNames.add(folderName); + const folderPath = `${REMOTE_ASSETS_PATH}/${folderName}`; + result.push({ name: folderName, isDirectory: true, path: folderPath, source: 'remote' }); + const folderAssets = assets.filter((item) => folder.folderId === '__uncategorized__' ? !folderIds.has(item.folderId) : item.folderId === folder.folderId); + const usedNames = new Set(); + for (const asset of folderAssets) { + if (!isImageAssetKind(asset.assetKind)) continue; + const baseName = safeManagerName(asset.label, asset.assetId || '图片素材'); + const fileName = usedNames.has(baseName) + ? `${baseName} (${safeManagerName(asset.assetId, String(usedNames.size + 1))})` + : baseName; + usedNames.add(fileName); + const localPath = asset.objectKey || asset.imageSrc || asset.assetId || fileName; + result.push({ + name: fileName, + isDirectory: false, + path: `${folderPath}/${fileName}`, + size: asset.size, + previewUrl: asset.previewUrl, + source: 'remote', + remoteObjectKey: asset.objectKey, + asset: { + id: asset.assetId || asset.objectKey || asset.imageSrc || 'platform-image', + localPath, + assetKind: asset.assetKind ?? null, + }, + }); + } + } + return result; +} + +export function buildRootFiles(): ManagerFile[] { + return [ + { name: '本地项目素材', isDirectory: true, path: PROJECT_ASSETS_PATH }, + { name: '云端素材库', isDirectory: true, path: REMOTE_ASSETS_PATH }, + ]; +} diff --git a/apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx b/apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx new file mode 100644 index 000000000..ee2feee63 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/modal/ThemedModal.tsx @@ -0,0 +1,103 @@ +import { + type CSSProperties, + type ReactNode, + useEffect, + useRef, +} from 'react'; +import { createPortal } from 'react-dom'; + +type ThemedModalTheme = 'light' | 'dark'; + +export type ThemedModalProps = { + open: boolean; + ariaLabel: string; + children: ReactNode; + onClose: () => void; + theme?: ThemedModalTheme; + closeOnBackdrop?: boolean; + closeOnEscape?: boolean; + overlayClassName?: string; + panelClassName?: string; + panelStyle?: CSSProperties; +}; + +function joinClassNames( + ...classNames: Array +) { + return classNames.filter(Boolean).join(' '); +} + +export function ThemedModal({ + open, + ariaLabel, + children, + onClose, + theme = 'light', + closeOnBackdrop = true, + closeOnEscape = true, + overlayClassName, + panelClassName, + panelStyle, +}: ThemedModalProps) { + const backdropPointerSequenceRef = useRef(null); + + useEffect(() => { + if (!open || !closeOnEscape) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [closeOnEscape, onClose, open]); + + if (!open || typeof document === 'undefined') return null; + + return createPortal( +
{ + backdropPointerSequenceRef.current = event.target === event.currentTarget; + }} + onPointerUpCapture={(event) => { + backdropPointerSequenceRef.current = + backdropPointerSequenceRef.current === true && + event.target === event.currentTarget; + }} + onPointerCancelCapture={() => { + backdropPointerSequenceRef.current = false; + }} + onClick={(event) => { + const stayedOnBackdrop = backdropPointerSequenceRef.current !== false; + backdropPointerSequenceRef.current = null; + if ( + closeOnBackdrop && + stayedOnBackdrop && + event.target === event.currentTarget + ) { + onClose(); + } + }} + > +
event.stopPropagation()} + > + {children} +
+
, + document.body, + ); +} diff --git a/apps/ai-game-creator-shell/src/services/clientApi.ts b/apps/ai-game-creator-shell/src/services/clientApi.ts index 5b16aa4f6..a82648ae6 100644 --- a/apps/ai-game-creator-shell/src/services/clientApi.ts +++ b/apps/ai-game-creator-shell/src/services/clientApi.ts @@ -93,6 +93,104 @@ export async function requestClientApi( return text ? unwrapApiResponse(JSON.parse(text) as T) : (null as T); } +/** + * Authenticated binary request for internal APIs (for example asset bytes). + * This intentionally shares the same token and URL resolution as requestClientApi + * instead of using the external editor API key flow. + */ +export async function requestClientApiBytes( + url: string, + fallbackMessage: string, + init: RequestInit = {}, +) { + const headers = new Headers(init.headers); + headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION); + const token = getStoredAuthAccessToken(); + if (token) { + headers.set('Authorization', `Bearer ${token}`); + } + let response: Response; + try { + response = await fetch(resolveClientApiUrl(url), { + ...init, + credentials: 'same-origin', + headers, + }); + } catch { + throw new ClientAuthRequestError( + '无法连接登录服务,请确认配套后端或 API 代理已启动后重试', + { networkError: true }, + ); + } + if (!response.ok) { + throw new ClientAuthRequestError( + await readApiErrorMessage(response, fallbackMessage), + { status: response.status }, + ); + } + return response; +} + +export type ClientAssetReadUrlResponse = { + read?: { signedUrl?: string; objectKey?: string; expiresAt?: string }; + signedUrl?: string; + objectKey?: string; + expiresAt?: string; +}; + +export type ClientEditorAssetLibrary = { + folders: Array<{ + folderId: string; + label: string; + sortOrder?: number; + collapsed?: boolean; + systemDefault?: boolean; + }>; + assets: Array<{ + assetId: string; + folderId: string; + label: string; + imageSrc?: string; + thumbnailSrc?: string | null; + objectKey?: string | null; + assetObjectId?: string | null; + width?: number; + height?: number; + assetKind?: string | null; + [key: string]: unknown; + }>; +}; + +export function loadEditorAssetLibrary() { + return requestClientApi<{ library: ClientEditorAssetLibrary }>( + '/api/editor/assets/library', + { method: 'GET' }, + '读取平台素材库失败', + ).then((response) => response.library); +} + +export async function resolveClientAssetReadUrl(objectKey: string) { + const params = new URLSearchParams({ objectKey: objectKey.replace(/^\/+/, '') }); + const payload = await requestClientApi( + `/api/assets/read-url?${params.toString()}`, + { method: 'GET' }, + '读取平台素材预览地址失败', + ); + const signedUrl = payload?.read?.signedUrl ?? payload?.signedUrl; + if (!signedUrl?.trim()) { + throw new Error('平台素材预览地址缺失'); + } + return signedUrl; +} + +export function readClientAssetBytes(objectKey: string) { + const params = new URLSearchParams({ objectKey: objectKey.replace(/^\/+/, '') }); + return requestClientApiBytes( + `/api/assets/read-bytes?${params.toString()}`, + '读取平台素材内容失败', + ); +} + export function getClientProfileDashboard() { return requestClientApi( '/api/profile/dashboard',