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 fdb27d0e7..49815fc97 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1928,6 +1928,22 @@ pub(crate) fn create_ui_design_resource( }) } +#[tauri::command] +pub(crate) fn update_local_project_resource_classification( + input: UpdateLocalProjectResourceClassificationInput, +) -> Result { + let root = Path::new(input.project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + update_manifest_asset_classification_at( + root, + &input.expected_project_id, + input.expected_project_revision, + &input.asset_id, + &input.category, + input.tags, + ) +} + #[tauri::command] pub(crate) async fn derive_local_project_resource( input: DeriveLocalProjectResourceInput, 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 dab175a08..ee9d719bd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -26,8 +26,9 @@ use serde::{Deserialize, Serialize}; use sha2::Digest; use shared_contracts::error_reports::ErrorReportLogInput; use shared_contracts::game_creation_app::{ - game_creation_app_asset_category_for_kind, new_game_creation_app_manifest, - new_game_creation_app_seed_tasks, validate_game_iteration_versions, + game_creation_app_asset_category_for_kind, game_creation_app_asset_category_from_str, + new_game_creation_app_manifest, new_game_creation_app_seed_tasks, + normalize_game_creation_app_asset_tags, validate_game_iteration_versions, GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor, GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep, GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace, @@ -2613,6 +2614,7 @@ fn main() { upload_local_asset, register_local_asset, create_ui_design_resource, + update_local_project_resource_classification, derive_local_project_resource, list_pending_local_project_resource_edits, resume_local_project_resource_edit, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index c9e73140c..0af61613b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -1082,6 +1082,85 @@ pub(crate) fn update_manifest_task_status_at( }) } +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub(crate) struct UpdateLocalProjectResourceClassificationInput { + pub(crate) project_path: String, + pub(crate) expected_project_id: String, + pub(crate) expected_project_revision: u64, + pub(crate) asset_id: String, + pub(crate) category: String, + #[serde(default)] + pub(crate) tags: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateLocalProjectResourceClassificationResult { + pub(crate) asset: GameCreationAppAssetManifestEntry, + pub(crate) committed_project_revision: u64, +} + +/// 只更新单个 manifest 资产条目的功能分类与自定义标签,保持条目字段构成与顺序不变。 +/// `category` 必须是 6 个合法值之一,非法值直接失败、不回退到 `kind` 派生; +/// `tags` 按既有归一化语义 trim、去空、去重。 +pub(crate) fn update_manifest_asset_classification_at( + root: &Path, + expected_project_id: &str, + expected_project_revision: u64, + asset_id: &str, + category: &str, + tags: Vec, +) -> Result { + if expected_project_revision + > shared_contracts::game_creation_app::GAME_CREATION_RESOURCE_LAYOUT_MAX_SAFE_REVISION + { + return Err("expectedProjectRevision 超出 JavaScript 安全整数范围".to_string()); + } + let expected_project_id = expected_project_id.trim(); + if expected_project_id.is_empty() { + return Err("资源分类 expectedProjectId 不能为空".to_string()); + } + let asset_id = asset_id.trim(); + if asset_id.is_empty() { + return Err("资源分类 assetId 不能为空".to_string()); + } + let category = game_creation_app_asset_category_from_str(category) + .ok_or_else(|| format!("非法资源分类:{}", category.trim()))?; + let tags = normalize_game_creation_app_asset_tags(&tags); + + if read_existing_manifest_for_project(root)?.project_id != expected_project_id { + return Err("project-identity-conflict".to_string()); + } + let _lock = acquire_project_write_lock(root, "asset.register")?; + if read_existing_manifest_for_project(root)?.project_id != expected_project_id { + return Err("project-identity-conflict".to_string()); + } + if read_game_creator_agent_runtime_project_revision(root)?.revision != expected_project_revision + { + return Err("project-revision-conflict".to_string()); + } + + let asset = mutate_manifest_at(root, |manifest| { + let Some(asset) = manifest + .assets + .iter_mut() + .find(|asset| asset.id == asset_id) + else { + return Err(format!("项目资源不存在:{asset_id}")); + }; + asset.category = category; + asset.tags = tags; + Ok(asset.clone()) + })?; + let committed_project_revision = advance_agent_runtime_project_revision_locked(root) + .map_err(|error| format!("资源分类已写入,但项目 revision 未能推进:{error}"))?; + Ok(UpdateLocalProjectResourceClassificationResult { + asset, + committed_project_revision, + }) +} + pub(crate) fn create_manifest_task_at( root: &Path, task_id: &str, @@ -1567,6 +1646,8 @@ pub(crate) fn trim_optional_string(value: Option) -> Option { .filter(|value| !value.is_empty()) } +#[cfg(test)] +mod classification_tests; #[cfg(test)] mod import_tests; #[cfg(test)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs new file mode 100644 index 000000000..dfda43e64 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/classification_tests.rs @@ -0,0 +1,279 @@ +use super::*; +use shared_contracts::game_creation_app::GameCreationAppAssetCategory; + +fn classification_test_root(test_name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "genarrative-manifest-classification-{test_name}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )) +} + +fn project_with_two_assets(test_name: &str) -> (PathBuf, String) { + let root = classification_test_root(test_name); + init_local_game_project_at(&root, "classification-project", "资源分类测试") + .expect("initialize classification project"); + let first = register_local_asset_entry( + &root, + "assets/hero.png", + "character", + "image/png", + "asset", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) + .expect("register first asset"); + register_local_asset_entry( + &root, + "assets/theme.mp3", + "background-music", + "audio/mpeg", + "asset", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Uploaded, + canvas_project_id: None, + resource_id: None, + asset_object_id: None, + task_id: None, + prompt: None, + model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), + }, + ) + .expect("register second asset"); + (root, first.id) +} + +#[test] +fn asset_classification_update_writes_category_and_normalized_tags() { + let (root, asset_id) = project_with_two_assets("update-success"); + let project_id = read_existing_manifest_for_project(&root) + .expect("read manifest") + .project_id; + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before") + .revision; + + let result = update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + &asset_id, + "scene", + vec![ + " 场景 ".to_string(), + String::new(), + " ".to_string(), + "场景".to_string(), + "主舞台".to_string(), + ], + ) + .expect("update classification"); + + assert_eq!(result.asset.id, asset_id); + assert_eq!(result.asset.category, GameCreationAppAssetCategory::Scene); + assert_eq!(result.asset.tags, vec!["场景", "主舞台"]); + assert_eq!(result.committed_project_revision, revision_before + 1); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision after") + .revision, + revision_before + 1 + ); + + let manifest = read_existing_manifest_for_project(&root).expect("read manifest after"); + let persisted = manifest + .assets + .iter() + .find(|asset| asset.id == asset_id) + .expect("persisted asset"); + assert_eq!(persisted.category, GameCreationAppAssetCategory::Scene); + assert_eq!(persisted.tags, vec!["场景", "主舞台"]); + assert_eq!(persisted.kind, "character"); + assert_eq!(persisted.local_path, "assets/hero.png"); +} + +#[test] +fn asset_classification_rejects_invalid_category_without_falling_back() { + let (root, asset_id) = project_with_two_assets("invalid-category"); + let project_id = read_existing_manifest_for_project(&root) + .expect("read manifest") + .project_id; + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before") + .revision; + let before = read_existing_manifest_for_project(&root).expect("read manifest before"); + + for invalid in ["", " props ", "image", "UI-INTERACTION", "future-category"] { + let error = update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + &asset_id, + invalid, + Vec::new(), + ) + .expect_err("invalid category must fail"); + assert!( + error.contains("非法资源分类"), + "unexpected error for {invalid:?}: {error}" + ); + } + + assert_eq!( + read_existing_manifest_for_project(&root).expect("read manifest after"), + before + ); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision after") + .revision, + revision_before + ); +} + +#[test] +fn asset_classification_update_leaves_other_assets_and_fields_untouched() { + let (root, asset_id) = project_with_two_assets("isolated-write"); + let project_id = read_existing_manifest_for_project(&root) + .expect("read manifest") + .project_id; + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before") + .revision; + let before = read_existing_manifest_for_project(&root).expect("read manifest before"); + + update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + &asset_id, + "ui-interaction", + vec!["入口".to_string()], + ) + .expect("update classification"); + + let after = read_existing_manifest_for_project(&root).expect("read manifest after"); + let mut expected = before.clone(); + let mut expected_assets = expected.assets.clone(); + let target = expected_assets + .iter_mut() + .find(|asset| asset.id == asset_id) + .expect("expected target asset"); + target.category = GameCreationAppAssetCategory::UiInteraction; + target.tags = vec!["入口".to_string()]; + expected.assets = expected_assets; + + assert_eq!(after, expected); + assert_eq!(after.versions, before.versions); + assert_eq!(after.tasks, before.tasks); + assert_eq!(after.name, before.name); + assert_eq!(after.project_id, before.project_id); +} + +#[test] +fn asset_classification_update_keeps_revision_cas_semantics() { + let (root, asset_id) = project_with_two_assets("revision-cas"); + let project_id = read_existing_manifest_for_project(&root) + .expect("read manifest") + .project_id; + let revision_before = read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision before") + .revision; + + let stale = update_manifest_asset_classification_at( + &root, + &project_id, + revision_before + 1, + &asset_id, + "scene", + Vec::new(), + ) + .expect_err("stale revision must fail"); + assert_eq!(stale, "project-revision-conflict"); + + let identity = update_manifest_asset_classification_at( + &root, + "other-project", + revision_before, + &asset_id, + "scene", + Vec::new(), + ) + .expect_err("foreign project identity must fail"); + assert_eq!(identity, "project-identity-conflict"); + + let missing = update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + "missing-asset", + "scene", + Vec::new(), + ) + .expect_err("missing asset must fail"); + assert!(missing.contains("项目资源不存在"), "unexpected: {missing}"); + + let before_failed = read_existing_manifest_for_project(&root).expect("read manifest"); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision") + .revision, + revision_before + ); + + update_manifest_asset_classification_at( + &root, + &project_id, + revision_before, + &asset_id, + "document", + vec!["说明".to_string()], + ) + .expect("valid update after rejected attempts"); + assert_eq!( + read_game_creator_agent_runtime_project_revision(&root) + .expect("read revision after") + .revision, + revision_before + 1 + ); + assert_eq!( + before_failed + .assets + .iter() + .find(|asset| asset.id == asset_id) + .expect("asset before") + .category, + GameCreationAppAssetCategory::Character + ); +} + +#[test] +fn asset_classification_input_rejects_unknown_fields() { + let input = serde_json::from_value::( + serde_json::json!({ + "projectPath": "C:/project", + "expectedProjectId": "project", + "expectedProjectRevision": 1, + "assetId": "asset-1", + "category": "scene", + "unexpected": true, + }), + ); + assert!(input.is_err()); +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx index 6fcb4a241..fa45e9745 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ResourceReferenceInput.tsx @@ -49,6 +49,7 @@ import { } from 'react'; import { createPortal } from 'react-dom'; +import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import { resolveTauriInvoke } from '../../app/tauri'; import { @@ -67,9 +68,10 @@ import { dedupeChatReferences, RESOURCE_REFERENCE_FILTERS, type ResourceReference, + resourceReferenceCategoryLabel, type ResourceReferenceFilter, - resourceReferenceFilterKind, resourceReferenceFromAsset, + resourceReferenceMatchesCategoryFilter, resourceReferenceMatchesQuery, } from './resourceReferences'; @@ -438,8 +440,7 @@ function ResourceReferenceEditor({ const pickerReferences = useMemo(() => { return assetReferences.filter((reference) => { if (!resourceReferenceMatchesQuery(reference, pickerQuery)) return false; - if (pickerFilter === 'all') return true; - return resourceReferenceFilterKind(reference) === pickerFilter; + return resourceReferenceMatchesCategoryFilter(reference, pickerFilter); }); }, [assetReferences, pickerFilter, pickerQuery]); @@ -564,18 +565,17 @@ function ResourceReferenceEditor({ } /> -
- {RESOURCE_REFERENCE_FILTERS.map((filter) => ( - - ))} -
+
{visiblePickerReferences.length === 0 ? (

@@ -615,7 +615,8 @@ function ResourceReferenceEditor({ {reference.label} - {reference.kind} · {reference.mediaType} + {resourceReferenceCategoryLabel(reference.category)}{' '} + · {reference.kind} · {reference.mediaType} {selected ? ( diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts index b397c6a75..5c921501f 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts @@ -1,4 +1,9 @@ -import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { + type GameCreationAppAssetCategory, + gameCreationAppAssetCategory, + type GameCreationAppAssetManifestEntry, + gameCreationAppAssetTags, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; export type ResourceReferenceSource = | 'asset-picker' @@ -12,6 +17,8 @@ export type ResourceReference = { kind: string; mediaType: string; label: string; + category: GameCreationAppAssetCategory; + tags: string[]; source: ResourceReferenceSource; }; @@ -72,6 +79,8 @@ export function resourceReferenceFromAsset( kind: asset.kind, mediaType: asset.mediaType, label: resourceDisplayName(asset), + category: gameCreationAppAssetCategory(asset), + tags: gameCreationAppAssetTags(asset), source, }; } @@ -85,6 +94,8 @@ export function sameResourceReference( left.kind === right.kind && left.mediaType === right.mediaType && left.label === right.label && + left.category === right.category && + left.tags.join('\u0000') === right.tags.join('\u0000') && left.source === right.source ); } @@ -102,37 +113,45 @@ export function resourceReferenceMatchesQuery( ); } -export function resourceReferenceFilterKind(reference: ResourceReference) { - const mediaType = reference.mediaType.toLowerCase(); - const kind = reference.kind.toLowerCase(); - if (mediaType.startsWith('image/')) return 'image'; - if (mediaType.startsWith('video/')) return 'video'; - if (mediaType.startsWith('audio/')) return 'audio'; - if (mediaType === 'application/json' || kind === 'ui') return 'ui'; - if ( - mediaType.startsWith('text/') || - mediaType === 'application/json' || - kind === 'document' || - kind === 'code' - ) { - return 'document'; - } - return 'other'; +/** + * 资源筛选唯一权威口径:manifest 资产的功能分类(`category`)。 + * `@` 面板与资源画布都用它筛选,不按 mediaType 另建第二套分类。 + */ +export function resourceReferenceCategory( + reference: ResourceReference, +): GameCreationAppAssetCategory { + return reference.category; } export const RESOURCE_REFERENCE_FILTERS = [ { id: 'all', label: '全部' }, - { id: 'image', label: '图片' }, - { id: 'video', label: '视频' }, + { id: 'ui-interaction', label: 'UI 交互' }, + { id: 'character', label: '角色与对象' }, + { id: 'scene', label: '场景与环境' }, { id: 'audio', label: '音频' }, - { id: 'ui', label: 'UI' }, { id: 'document', label: '文档' }, - { id: 'other', label: '其它' }, + { id: 'unclassified', label: '待归类' }, ] as const; export type ResourceReferenceFilter = (typeof RESOURCE_REFERENCE_FILTERS)[number]['id']; +export function resourceReferenceCategoryLabel( + category: GameCreationAppAssetCategory, +) { + return ( + RESOURCE_REFERENCE_FILTERS.find((filter) => filter.id === category) + ?.label ?? category + ); +} + +export function resourceReferenceMatchesCategoryFilter( + reference: ResourceReference, + filter: ResourceReferenceFilter, +) { + return filter === 'all' || resourceReferenceCategory(reference) === filter; +} + function chatReferenceKey(reference: ChatReference) { if (reference.type === 'resource') { return `resource:${reference.resourceId}:${reference.source}`; diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index f59614598..9660713d2 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -4657,29 +4657,9 @@ h2 { } .resource-reference-picker-filters { - display: flex; - flex-wrap: wrap; - gap: 6px; padding: 0 12px 8px; } -.resource-reference-picker-filters button { - min-height: 26px; - padding: 0 10px; - border: 1px solid var(--platform-surface-border, #dbe3ef); - border-radius: 999px; - background: transparent; - color: inherit; - font-size: 12px; - cursor: pointer; -} - -.resource-reference-picker-filters button[aria-pressed='true'] { - border-color: var(--platform-button-primary-border, #2563eb); - background: var(--platform-button-primary-fill, #2563eb); - color: var(--platform-button-primary-text, #fff); -} - .resource-reference-picker-list { display: grid; gap: 4px; @@ -6136,6 +6116,25 @@ iframe.preview-frame { font-size: 12px; } +.game-resource-category-filter { + margin: 8px 12px 0; +} + +.game-resource-classification-dialog { + width: min(480px, 100%); +} + +.game-resource-classification-body { + display: grid; + gap: 10px; +} + +.game-resource-classification-error { + margin: 0; + color: #b3261e; + font-size: 11px; +} + .game-resource-live-notice { display: flex; align-items: center; diff --git a/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx b/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx new file mode 100644 index 000000000..d8878127b --- /dev/null +++ b/apps/ai-game-creator-shell/src/view/project-development/ResourceClassificationPanel.tsx @@ -0,0 +1,155 @@ +import { useState } from 'react'; + +import { PlatformActionButton } from '../../../../../packages/shared/src/components/PlatformActionButton'; +import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; +import { PlatformTextField } from '../../../../../packages/shared/src/components/PlatformTextField'; +import { + GAME_CREATION_APP_ASSET_CATEGORIES, + type GameCreationAppAssetCategory, + gameCreationAppAssetCategory, + type GameCreationAppAssetManifestEntry, + gameCreationAppAssetTags, + normalizeGameCreationAppAssetTags, +} from '../../../../../packages/shared/src/contracts/gameCreationApp'; +import { ThemedModal } from '../../components/modal/ThemedModal'; +import { resourceReferenceCategoryLabel } from '../../features/project-workspace/resourceReferences'; + +type UpdateLocalProjectResourceClassificationResult = { + asset: GameCreationAppAssetManifestEntry; + committedProjectRevision: number; +}; + +const RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS = + GAME_CREATION_APP_ASSET_CATEGORIES.map((category) => ({ + id: category, + label: resourceReferenceCategoryLabel(category), + })); + +/** 标签草稿沿用写入路径的归一化边界,只按中英文逗号、顿号与换行切分。 */ +function splitResourceClassificationTagsDraft(value: string) { + return normalizeGameCreationAppAssetTags(value.split(/[,,、\n]/u)); +} + +function resourceClassificationErrorMessage(error: unknown) { + if (typeof error === 'string' && error.trim()) return error; + if (error instanceof Error && error.message) return error.message; + return '保存资源分类与标签失败'; +} + +type ResourceClassificationPanelProps = { + projectPath: string; + projectId: string; + asset: GameCreationAppAssetManifestEntry; + onClose: () => void; + onSaved: (result: UpdateLocalProjectResourceClassificationResult) => void; +}; + +export function ResourceClassificationPanel({ + projectPath, + projectId, + asset, + onClose, + onSaved, +}: ResourceClassificationPanelProps) { + const [category, setCategory] = useState(() => + gameCreationAppAssetCategory(asset), + ); + const [tagsDraft, setTagsDraft] = useState(() => + gameCreationAppAssetTags(asset).join('、'), + ); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + async function saveResourceClassification() { + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke) { + setError('资源分类与标签需要在客户端内保存'); + return; + } + setSaving(true); + setError(null); + try { + const status = await invoke<{ revision: number }>( + 'get_local_game_project_revision', + { projectPath }, + ); + if (!Number.isSafeInteger(status.revision) || status.revision < 0) { + throw new Error('项目 revision 无效'); + } + const result = + await invoke( + 'update_local_project_resource_classification', + { + input: { + projectPath, + expectedProjectId: projectId, + expectedProjectRevision: status.revision, + assetId: asset.id, + category, + tags: splitResourceClassificationTagsDraft(tagsDraft), + }, + }, + ); + onSaved(result); + } catch (saveError) { + setError(resourceClassificationErrorMessage(saveError)); + } finally { + setSaving(false); + } + } + + return ( + +

+
+

资源分类与标签

+

{asset.localPath}

+
+ +
+
+ + setTagsDraft(event.currentTarget.value)} + /> + {error ? ( +

+ {error} +

+ ) : null} +
+
+ + 取消 + + void saveResourceClassification()} + disabled={saving} + > + 保存 + +
+ + ); +} diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index cc7c5919d..d4518facc 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -43,6 +43,7 @@ import { import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs'; import type { GameCreationAppAgentGroup, GameCreationAppAssetManifestEntry, @@ -56,7 +57,11 @@ import { type LocalGamePreviewInspectSelection, resolveEmbeddedPreviewUrl, } from '../../features/project-workspace/LocalGamePreviewFrame'; -import { dispatchResourceReferenceInsert } from '../../features/project-workspace/resourceReferences'; +import { + dispatchResourceReferenceInsert, + RESOURCE_REFERENCE_FILTERS, + type ResourceReferenceFilter, +} from '../../features/project-workspace/resourceReferences'; import { ensureUiDesignResourceForPrototype } from '../../features/ui-editor/uiDesignResourceBridge'; import { currentPlatformSessionGeneration, @@ -121,6 +126,7 @@ import { type ProjectResourceCardPreviewState, summarizeProjectResourceDocument, } from './resourceCardPreviewModel'; +import { ResourceClassificationPanel } from './ResourceClassificationPanel'; import { EMPTY_PROJECT_RESOURCE_GRAPH, normalizeProjectResourceGraph, @@ -146,6 +152,7 @@ import { type ProjectAgentResultSummary, type ProjectAttachmentResult, type ProjectResource, + projectResourceAssetCategory, type ProjectResourceCategory, projectResourcesFromReadModels, projectResourceTypeLabel, @@ -304,6 +311,15 @@ type ResourceCanvasViewportBySortMode = Record< ResourceSortMode, ResourceCanvasViewportByCategory >; +/** 功能分类筛选与画布视口一样按「按依赖 / 按类型」分别保存,不新造第二套状态。 */ +type ResourceCanvasCategoryFilterBySortMode = Record< + ResourceSortMode, + ResourceReferenceFilter +>; + +function defaultResourceCanvasCategoryFilters(): ResourceCanvasCategoryFilterBySortMode { + return { dependency: 'all', type: 'all' }; +} function defaultResourceCanvasViewports(): ResourceCanvasViewportBySortMode { const byCategory = (): ResourceCanvasViewportByCategory => ({ @@ -851,6 +867,8 @@ const ResourceCard = memo(function ResourceCard({ kind: resource.subtype || resource.category, mediaType: resource.mediaType, label: resource.label, + category: projectResourceAssetCategory(resource), + tags: resource.assetTags ?? [], source: 'resource-card', }); }} @@ -1175,6 +1193,10 @@ export default function ProjectDevelopmentView({ }); const [sortMode, setSortMode] = useState('dependency'); const [searchText, setSearchText] = useState(''); + const [resourceCanvasCategoryFilters, setResourceCanvasCategoryFilters] = + useState( + defaultResourceCanvasCategoryFilters, + ); const [resourceCanvasViewports, setResourceCanvasViewports] = useState(defaultResourceCanvasViewports); const resourceCanvasViewportTargetsRef = useRef( @@ -1216,6 +1238,8 @@ export default function ProjectDevelopmentView({ ); const [resourceRecoveryPanelOpen, setResourceRecoveryPanelOpen] = useState(false); + const [resourceClassificationAssetId, setResourceClassificationAssetId] = + useState(null); const [pendingResourceEditActionIds, setPendingResourceEditActionIds] = useState>(() => new Set()); const [pendingResourceEditActionErrors, setPendingResourceEditActionErrors] = @@ -1728,19 +1752,26 @@ export default function ProjectDevelopmentView({ ); }, [resources, selectedResourceId]); const normalizedSearch = searchText.trim().toLowerCase(); + const resourceCategoryFilter = resourceCanvasCategoryFilters[sortMode]; const visibleResources = useMemo( () => - canvasResources.filter((resource) => - normalizedSearch + canvasResources.filter((resource) => { + if ( + resourceCategoryFilter !== 'all' && + projectResourceAssetCategory(resource) !== resourceCategoryFilter + ) { + return false; + } + return normalizedSearch ? [ resource.label, resource.path, resource.mediaType, resource.taskTitle ?? '', ].some((value) => value.toLowerCase().includes(normalizedSearch)) - : true, - ), - [canvasResources, normalizedSearch], + : true; + }), + [canvasResources, normalizedSearch, resourceCategoryFilter], ); const visibleResourceIds = useMemo( () => new Set(visibleResources.map((resource) => resource.id)), @@ -2153,6 +2184,43 @@ export default function ProjectDevelopmentView({ const focusedResource = canvasResources.find((resource) => resource.id === focusedResourceId) ?? null; + const resourceClassificationAsset = useMemo( + () => + resourceClassificationAssetId + ? (manifest.assets.find( + (asset) => asset.id === resourceClassificationAssetId, + ) ?? null) + : null, + [manifest.assets, resourceClassificationAssetId], + ); + const handleResourceClassificationSaved = useCallback( + async (result: { + asset: GameCreationAppAssetManifestEntry; + committedProjectRevision: number; + }) => { + setResourceClassificationAssetId(null); + const invoke = window.__TAURI__?.core?.invoke; + if (!invoke || !onManifestChange) return; + try { + const next = await invoke( + 'get_local_game_manifest', + { projectPath, commandId: 'asset.list' }, + ); + if (next.projectId !== manifest.projectId) return; + onManifestChange(projectPath, next, { + projectId: next.projectId, + revision: result.committedProjectRevision, + source: 'asset-command', + commitId: `asset-classification:${result.asset.id}`, + }); + } catch (error) { + setResourceWorkbenchNotice( + error instanceof Error ? error.message : String(error), + ); + } + }, + [manifest.projectId, onManifestChange, projectPath], + ); const focusedResourceDependencyDetails = useMemo(() => { if (!focusedResource) { return { @@ -3805,6 +3873,18 @@ export default function ProjectDevelopmentView({ setSortMode(nextMode); } + function showResourceCategoryFilter(nextFilter: ResourceReferenceFilter) { + if (nextFilter === resourceCategoryFilter) { + return; + } + advanceFocusGeneration(); + setHiddenCommittedResourceId(null); + setResourceCanvasCategoryFilters((current) => ({ + ...current, + [sortMode]: nextFilter, + })); + } + const openUiDesignEditor = useCallback( async (resource: ProjectResource) => { const prototypeAssetId = resource.manifestAssetId; @@ -4898,6 +4978,19 @@ export default function ProjectDevelopmentView({ > 编辑资源 + {focusedResource.manifestAssetId ? ( + + ) : null}