资源功能分类与自定义标签:筛选口径收敛为 category 并接入写入 UI
- 删除 resourceReferences.ts 中 mediaType 优先的 7 桶筛选口径与 resourceReferenceFilterKind,改由 resourceReferenceCategory / resourceReferenceMatchesCategoryFilter 以 manifest 资产 category 为唯一权威筛选口径 - RESOURCE_REFERENCE_FILTERS 改为 6 类 + 全部(全部 / UI 交互 / 角色与对象 / 场景与环境 / 音频 / 文档 / 待归类),并新增 resourceReferenceCategoryLabel 让中文名只定义一处 - ResourceReference 新增 category / tags,由 resourceReferenceFromAsset 经 gameCreationAppAssetCategory / gameCreationAppAssetTags 从 manifest 资产投影;资源画布 ProjectResource 同步新增 assetCategory / assetTags 投影与 projectResourceAssetCategory 待归类兜底 - 新增 Tauri 命令 update_local_project_resource_classification 与 UpdateLocalProjectResourceClassificationInput/Result,实现落在 project/manifest.rs 的 update_manifest_asset_classification_at,复用既有 mutate_manifest_at manifest 写锁与原子写入 - 命令持项目写锁后按 expectedProjectId / expectedProjectRevision 做 CAS,冲突分别返回 project-identity-conflict 与 project-revision-conflict;category 非 6 个合法值即报错且不回退到 kind 派生;tags 走 normalize_game_creation_app_asset_tags;只改目标条目的 category / tags 并在成功后推进一次项目 revision - 新增 Rust 定向测试 project/manifest/classification_tests.rs:更新成功、非法分类被拒、tags 归一化、未引用资产更新不影响其它字段、revision/CAS 与既有写入一致、输入拒绝未知字段 - 前端新增 ResourceClassificationPanel(ThemedModal + PlatformSegmentedTabs + PlatformTextField),资源详情面板以「分类与标签」按钮打开独立面板,保存后重读 manifest 并经 onManifestChange 反映到筛选与投影 - @ 面板与资源画布共用 packages/shared 的 PlatformSegmentedTabs 渲染同一组功能分类标签;画布筛选状态与画布视口一样按「按依赖 / 按类型」分别保存,复用既有 sortMode 状态形状 - 更新 resourceReferenceInput.test.tsx 与 projectResourceProjectionModel.test.ts 覆盖新口径,新增 resourceClassificationPanel.test.tsx 覆盖写入命令的输入归一化与失败不回退 - 同步技术方案与 decision-log:记录按 mediaType 分类到按 category 分类的语义变化(图片 / 视频桶消失,image / video / code / publication-material 归待归类)与新写入命令契约
This commit is contained in:
@@ -1928,6 +1928,22 @@ pub(crate) fn create_ui_design_resource(
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn update_local_project_resource_classification(
|
||||
input: UpdateLocalProjectResourceClassificationInput,
|
||||
) -> Result<UpdateLocalProjectResourceClassificationResult, String> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
) -> Result<UpdateLocalProjectResourceClassificationResult, String> {
|
||||
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<String>) -> Option<String> {
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod classification_tests;
|
||||
#[cfg(test)]
|
||||
mod import_tests;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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::<UpdateLocalProjectResourceClassificationInput>(
|
||||
serde_json::json!({
|
||||
"projectPath": "C:/project",
|
||||
"expectedProjectId": "project",
|
||||
"expectedProjectRevision": 1,
|
||||
"assetId": "asset-1",
|
||||
"category": "scene",
|
||||
"unexpected": true,
|
||||
}),
|
||||
);
|
||||
assert!(input.is_err());
|
||||
}
|
||||
+17
-16
@@ -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({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="resource-reference-picker-filters" role="group">
|
||||
{RESOURCE_REFERENCE_FILTERS.map((filter) => (
|
||||
<button
|
||||
type="button"
|
||||
key={filter.id}
|
||||
aria-pressed={pickerFilter === filter.id}
|
||||
onClick={() => setPickerFilter(filter.id)}
|
||||
>
|
||||
{filter.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<PlatformSegmentedTabs
|
||||
items={RESOURCE_REFERENCE_FILTERS}
|
||||
activeId={pickerFilter}
|
||||
onChange={setPickerFilter}
|
||||
layout="scroll"
|
||||
gap="sm"
|
||||
frame="bare"
|
||||
surface="transparent"
|
||||
size="sm"
|
||||
className="platform-theme platform-theme--light resource-reference-picker-filters"
|
||||
/>
|
||||
<div className="resource-reference-picker-list" role="listbox">
|
||||
{visiblePickerReferences.length === 0 ? (
|
||||
<p className="resource-reference-picker-empty">
|
||||
@@ -615,7 +615,8 @@ function ResourceReferenceEditor({
|
||||
<span>
|
||||
<strong>{reference.label}</strong>
|
||||
<small>
|
||||
{reference.kind} · {reference.mediaType}
|
||||
{resourceReferenceCategoryLabel(reference.category)}{' '}
|
||||
· {reference.kind} · {reference.mediaType}
|
||||
</small>
|
||||
</span>
|
||||
{selected ? (
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
+155
@@ -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>(() =>
|
||||
gameCreationAppAssetCategory(asset),
|
||||
);
|
||||
const [tagsDraft, setTagsDraft] = useState(() =>
|
||||
gameCreationAppAssetTags(asset).join('、'),
|
||||
);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<UpdateLocalProjectResourceClassificationResult>(
|
||||
'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 (
|
||||
<ThemedModal
|
||||
open
|
||||
ariaLabel="资源分类与标签"
|
||||
onClose={onClose}
|
||||
panelClassName="game-approval-dialog game-resource-classification-dialog"
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<h2>资源分类与标签</h2>
|
||||
<p>{asset.localPath}</p>
|
||||
</div>
|
||||
<button type="button" aria-label="关闭资源分类与标签" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="game-resource-classification-body">
|
||||
<PlatformSegmentedTabs
|
||||
items={RESOURCE_CLASSIFICATION_CATEGORY_OPTIONS}
|
||||
activeId={category}
|
||||
onChange={setCategory}
|
||||
columns="threeToSix"
|
||||
gap="sm"
|
||||
size="compact"
|
||||
/>
|
||||
<PlatformTextField
|
||||
aria-label="资源标签"
|
||||
placeholder="用、或逗号分隔标签"
|
||||
value={tagsDraft}
|
||||
onChange={(event) => setTagsDraft(event.currentTarget.value)}
|
||||
/>
|
||||
{error ? (
|
||||
<p className="game-resource-classification-error" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<footer>
|
||||
<PlatformActionButton
|
||||
tone="secondary"
|
||||
onClick={onClose}
|
||||
disabled={saving}
|
||||
>
|
||||
取消
|
||||
</PlatformActionButton>
|
||||
<PlatformActionButton
|
||||
onClick={() => void saveResourceClassification()}
|
||||
disabled={saving}
|
||||
>
|
||||
保存
|
||||
</PlatformActionButton>
|
||||
</footer>
|
||||
</ThemedModal>
|
||||
);
|
||||
}
|
||||
@@ -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<ResourceSortMode>('dependency');
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [resourceCanvasCategoryFilters, setResourceCanvasCategoryFilters] =
|
||||
useState<ResourceCanvasCategoryFilterBySortMode>(
|
||||
defaultResourceCanvasCategoryFilters,
|
||||
);
|
||||
const [resourceCanvasViewports, setResourceCanvasViewports] =
|
||||
useState<ResourceCanvasViewportBySortMode>(defaultResourceCanvasViewports);
|
||||
const resourceCanvasViewportTargetsRef = useRef(
|
||||
@@ -1216,6 +1238,8 @@ export default function ProjectDevelopmentView({
|
||||
);
|
||||
const [resourceRecoveryPanelOpen, setResourceRecoveryPanelOpen] =
|
||||
useState(false);
|
||||
const [resourceClassificationAssetId, setResourceClassificationAssetId] =
|
||||
useState<string | null>(null);
|
||||
const [pendingResourceEditActionIds, setPendingResourceEditActionIds] =
|
||||
useState<Set<string>>(() => 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<GameCreationAppManifest>(
|
||||
'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({
|
||||
>
|
||||
编辑资源
|
||||
</button>
|
||||
{focusedResource.manifestAssetId ? (
|
||||
<button
|
||||
type="button"
|
||||
className="game-resource-focus-action"
|
||||
onClick={() =>
|
||||
setResourceClassificationAssetId(
|
||||
focusedResource.manifestAssetId,
|
||||
)
|
||||
}
|
||||
>
|
||||
分类与标签
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="game-resource-focus-close"
|
||||
@@ -5250,6 +5343,17 @@ export default function ProjectDevelopmentView({
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<PlatformSegmentedTabs
|
||||
items={RESOURCE_REFERENCE_FILTERS}
|
||||
activeId={resourceCategoryFilter}
|
||||
onChange={showResourceCategoryFilter}
|
||||
layout="scroll"
|
||||
gap="sm"
|
||||
frame="bare"
|
||||
surface="transparent"
|
||||
size="sm"
|
||||
className="platform-theme platform-theme--light game-resource-category-filter"
|
||||
/>
|
||||
{resourceWorkbenchNotice ? (
|
||||
<div className="game-resource-live-notice" role="status">
|
||||
<span>{resourceWorkbenchNotice}</span>
|
||||
@@ -5687,6 +5791,16 @@ export default function ProjectDevelopmentView({
|
||||
</footer>
|
||||
) : null}
|
||||
|
||||
{resourceClassificationAsset ? (
|
||||
<ResourceClassificationPanel
|
||||
key={resourceClassificationAsset.id}
|
||||
projectPath={projectPath}
|
||||
projectId={manifest.projectId}
|
||||
asset={resourceClassificationAsset}
|
||||
onClose={() => setResourceClassificationAssetId(null)}
|
||||
onSaved={(result) => void handleResourceClassificationSaved(result)}
|
||||
/>
|
||||
) : null}
|
||||
{resourceRecoveryPanelOpen ? (
|
||||
<div
|
||||
className="game-approval-backdrop"
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import type {
|
||||
GameCreationAppManifest,
|
||||
GameIterationVersion,
|
||||
ProjectResourceCanvasSection,
|
||||
import {
|
||||
type GameCreationAppAssetCategory,
|
||||
gameCreationAppAssetCategory,
|
||||
gameCreationAppAssetTags,
|
||||
type GameCreationAppManifest,
|
||||
type GameIterationVersion,
|
||||
type ProjectResourceCanvasSection,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
|
||||
export type ProjectResourceCategory = ProjectResourceCanvasSection;
|
||||
@@ -45,6 +48,12 @@ export type ProjectResource = {
|
||||
dependencyDepth: number;
|
||||
content?: string;
|
||||
version?: ProjectVersionResourceSummary;
|
||||
/**
|
||||
* manifest 资产条目的功能分类与自定义标签投影。只有登记在 manifest 的资产才有该口径;
|
||||
* 任务产物、附件、Agent 回执与项目版本不是 manifest 资产,按筛选口径归入 `待归类`。
|
||||
*/
|
||||
assetCategory?: GameCreationAppAssetCategory;
|
||||
assetTags?: string[];
|
||||
imageSequenceFrames?: Array<{
|
||||
imageSrc: string;
|
||||
objectKey?: string | null;
|
||||
@@ -177,6 +186,16 @@ export function projectResourceTypeLabel(
|
||||
return '未知';
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源画布筛选口径:只有 manifest 资产携带功能分类,其余投影资源(任务产物、附件、
|
||||
* Agent 回执与项目版本)没有 `category` 事实源,统一归 `待归类`,不按扩展名另造分类。
|
||||
*/
|
||||
export function projectResourceAssetCategory(
|
||||
resource: Pick<ProjectResource, 'assetCategory'>,
|
||||
): GameCreationAppAssetCategory {
|
||||
return resource.assetCategory ?? 'unclassified';
|
||||
}
|
||||
|
||||
function resourcePriority(resource: ProjectResource) {
|
||||
if (resource.manifestAssetId) {
|
||||
return 4;
|
||||
@@ -269,6 +288,8 @@ export function projectResourcesFromReadModels(
|
||||
referenceResourceIds: asset.source.referenceResourceIds ?? [],
|
||||
dependencies: [],
|
||||
dependencyDepth: 0,
|
||||
assetCategory: gameCreationAppAssetCategory(asset),
|
||||
assetTags: gameCreationAppAssetTags(asset),
|
||||
imageSequenceFrames: asset.imageSequenceFrames ?? null,
|
||||
imageSequenceDurationMs: asset.imageSequenceDurationMs ?? null,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
projectResourceAssetCategory,
|
||||
projectResourcesFromReadModels,
|
||||
projectResourceTypeLabel,
|
||||
} from '../src/view/project-development/resourceProjectionModel';
|
||||
@@ -193,6 +194,61 @@ describe('项目资源投影', () => {
|
||||
expect(first.map(({ id }) => id)).toEqual(second.map(({ id }) => id));
|
||||
});
|
||||
|
||||
it('按 manifest 资产投影功能分类与标签,非资产资源归待归类', () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'asset-classification-projection',
|
||||
'资源分类投影测试',
|
||||
);
|
||||
manifest.assets = [
|
||||
{
|
||||
id: 'classified-scene',
|
||||
kind: 'scene',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/scene.png',
|
||||
source: { kind: 'generated' },
|
||||
category: 'ui-interaction',
|
||||
tags: ['主界面', '入口'],
|
||||
},
|
||||
{
|
||||
id: 'legacy-character',
|
||||
kind: 'character',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/hero.png',
|
||||
source: { kind: 'generated' },
|
||||
},
|
||||
];
|
||||
|
||||
const resources = projectResourcesFromReadModels(
|
||||
manifest,
|
||||
[
|
||||
{
|
||||
fileName: 'rules.yaml',
|
||||
mediaType: 'application/yaml',
|
||||
localPath: 'imports/rules.yaml',
|
||||
status: 'imported',
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const classified = resources.find(
|
||||
(resource) => resource.id === 'asset:classified-scene',
|
||||
);
|
||||
const legacy = resources.find(
|
||||
(resource) => resource.id === 'asset:legacy-character',
|
||||
);
|
||||
const attachment = resources.find(
|
||||
(resource) => resource.id === 'attachment:imports/rules.yaml',
|
||||
);
|
||||
|
||||
expect(classified?.assetCategory).toBe('ui-interaction');
|
||||
expect(classified?.assetTags).toEqual(['主界面', '入口']);
|
||||
expect(legacy?.assetCategory).toBe('character');
|
||||
expect(legacy?.assetTags).toEqual([]);
|
||||
expect(projectResourceAssetCategory(classified!)).toBe('ui-interaction');
|
||||
expect(projectResourceAssetCategory(attachment!)).toBe('unclassified');
|
||||
});
|
||||
|
||||
it('只从 manifest 投影版本并保留直接父子关系', () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'version-projection',
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import type { GameCreationAppAssetManifestEntry } from '../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { ResourceClassificationPanel } from '../src/view/project-development/ResourceClassificationPanel';
|
||||
|
||||
const asset: GameCreationAppAssetManifestEntry = {
|
||||
id: 'asset-hero',
|
||||
kind: 'character',
|
||||
mediaType: 'image/png',
|
||||
localPath: 'assets/hero.png',
|
||||
source: { kind: 'uploaded' },
|
||||
category: 'character',
|
||||
tags: ['主角'],
|
||||
};
|
||||
|
||||
function installInvoke(
|
||||
implementation: (command: string, args?: unknown) => Promise<unknown>,
|
||||
) {
|
||||
const invoke = vi.fn(implementation);
|
||||
(
|
||||
window as unknown as {
|
||||
__TAURI__?: { core?: { invoke?: typeof invoke } };
|
||||
}
|
||||
).__TAURI__ = { core: { invoke } };
|
||||
return invoke;
|
||||
}
|
||||
|
||||
function removeInvoke() {
|
||||
delete (
|
||||
window as unknown as {
|
||||
__TAURI__?: { core?: { invoke?: unknown } };
|
||||
}
|
||||
).__TAURI__;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
removeInvoke();
|
||||
});
|
||||
|
||||
describe('ResourceClassificationPanel', () => {
|
||||
test('writes the category and normalized tags through the controlled native command', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSaved = vi.fn();
|
||||
const invoke = installInvoke(async (command) => {
|
||||
if (command === 'get_local_game_project_revision') {
|
||||
return { revision: 7 };
|
||||
}
|
||||
if (command === 'update_local_project_resource_classification') {
|
||||
return {
|
||||
asset: { ...asset, category: 'scene', tags: ['主舞台', '日夜'] },
|
||||
committedProjectRevision: 8,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
});
|
||||
|
||||
render(
|
||||
<ResourceClassificationPanel
|
||||
projectPath="C:/project"
|
||||
projectId="project-1"
|
||||
asset={asset}
|
||||
onClose={vi.fn()}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '场景与环境' }));
|
||||
const tagsField = screen.getByLabelText('资源标签');
|
||||
await user.clear(tagsField);
|
||||
await user.type(tagsField, ' 主舞台 ,日夜,主舞台、');
|
||||
await user.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'update_local_project_resource_classification',
|
||||
expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
projectPath: 'C:/project',
|
||||
expectedProjectId: 'project-1',
|
||||
expectedProjectRevision: 7,
|
||||
assetId: 'asset-hero',
|
||||
category: 'scene',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const [, args] = invoke.mock.calls.find(
|
||||
([command]) => command === 'update_local_project_resource_classification',
|
||||
)!;
|
||||
expect((args as { input: { tags: string[] } }).input.tags).toEqual([
|
||||
'主舞台',
|
||||
'日夜',
|
||||
]);
|
||||
expect(onSaved.mock.calls[0]?.[0]).toEqual({
|
||||
asset: { ...asset, category: 'scene', tags: ['主舞台', '日夜'] },
|
||||
committedProjectRevision: 8,
|
||||
});
|
||||
});
|
||||
|
||||
test('surfaces the native rejection without reporting a save', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSaved = vi.fn();
|
||||
installInvoke(async (command) => {
|
||||
if (command === 'get_local_game_project_revision') {
|
||||
return { revision: 7 };
|
||||
}
|
||||
throw '非法资源分类:future-category';
|
||||
});
|
||||
|
||||
render(
|
||||
<ResourceClassificationPanel
|
||||
projectPath="C:/project"
|
||||
projectId="project-1"
|
||||
asset={asset}
|
||||
onClose={vi.fn()}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
await screen.findByRole('alert');
|
||||
expect(screen.getByRole('alert').textContent).toContain('非法资源分类');
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not attempt a write outside the native client', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSaved = vi.fn();
|
||||
render(
|
||||
<ResourceClassificationPanel
|
||||
projectPath="C:/project"
|
||||
projectId="project-1"
|
||||
asset={asset}
|
||||
onClose={vi.fn()}
|
||||
onSaved={onSaved}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '保存' }));
|
||||
|
||||
await screen.findByRole('alert');
|
||||
expect(screen.getByRole('alert').textContent).toContain('客户端');
|
||||
expect(onSaved).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
type ChatComposerDraft,
|
||||
type ChatReference,
|
||||
dispatchResourceReferenceInsert,
|
||||
RESOURCE_REFERENCE_FILTERS,
|
||||
RESOURCE_REFERENCE_INSERT_EVENT,
|
||||
resourceReferenceFilterKind,
|
||||
resourceReferenceCategory,
|
||||
resourceReferenceFromAsset,
|
||||
resourceReferenceMatchesCategoryFilter,
|
||||
resourceReferenceMatchesQuery,
|
||||
} from '../src/features/project-workspace/resourceReferences';
|
||||
|
||||
@@ -122,14 +124,60 @@ describe('ResourceReferenceInput', () => {
|
||||
).__TAURI__;
|
||||
});
|
||||
|
||||
test('filters candidate references by name, id, kind, and media category', () => {
|
||||
test('filters candidate references by name, id, kind, and functional category', () => {
|
||||
const hero = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
|
||||
const theme = resourceReferenceFromAsset(assets[2]!, 'asset-picker');
|
||||
expect(resourceReferenceMatchesQuery(hero, 'her')).toBe(true);
|
||||
expect(resourceReferenceMatchesQuery(hero, 'character')).toBe(true);
|
||||
expect(resourceReferenceMatchesQuery(hero, 'missing')).toBe(false);
|
||||
expect(resourceReferenceFilterKind(hero)).toBe('image');
|
||||
expect(resourceReferenceFilterKind(theme)).toBe('audio');
|
||||
expect(resourceReferenceCategory(hero)).toBe('character');
|
||||
expect(resourceReferenceCategory(theme)).toBe('audio');
|
||||
expect(hero.tags).toEqual([]);
|
||||
});
|
||||
|
||||
test('exposes the six functional categories plus all as the single filter source', () => {
|
||||
expect(RESOURCE_REFERENCE_FILTERS).toEqual([
|
||||
{ id: 'all', label: '全部' },
|
||||
{ id: 'ui-interaction', label: 'UI 交互' },
|
||||
{ id: 'character', label: '角色与对象' },
|
||||
{ id: 'scene', label: '场景与环境' },
|
||||
{ id: 'audio', label: '音频' },
|
||||
{ id: 'document', label: '文档' },
|
||||
{ id: 'unclassified', label: '待归类' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('projects explicit category and manifest tags onto the reference', () => {
|
||||
const hero = resourceReferenceFromAsset(
|
||||
{
|
||||
...assets[0]!,
|
||||
category: 'scene',
|
||||
tags: ['主舞台', '日夜'],
|
||||
},
|
||||
'asset-picker',
|
||||
);
|
||||
expect(hero.category).toBe('scene');
|
||||
expect(hero.tags).toEqual(['主舞台', '日夜']);
|
||||
expect(resourceReferenceMatchesCategoryFilter(hero, 'scene')).toBe(true);
|
||||
expect(resourceReferenceMatchesCategoryFilter(hero, 'character')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(resourceReferenceMatchesCategoryFilter(hero, 'all')).toBe(true);
|
||||
});
|
||||
|
||||
test('falls back to the kind-derived category when the manifest asset omits it', () => {
|
||||
const hero = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
|
||||
const unclassified = resourceReferenceFromAsset(
|
||||
{
|
||||
...assets[0]!,
|
||||
kind: 'image',
|
||||
},
|
||||
'asset-picker',
|
||||
);
|
||||
expect(resourceReferenceMatchesCategoryFilter(hero, 'character')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(resourceReferenceCategory(unclassified)).toBe('unclassified');
|
||||
});
|
||||
|
||||
test('resource-card insertion uses the shared structured reference event', () => {
|
||||
|
||||
@@ -8189,6 +8189,15 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
|
||||
- 问题回答携带被回答卡片的 questionId,在已有回合锁内核对 Session 和当前问题;自由文本回答同样绑定问题,已完成回合保留幂等重放。此身份匹配服务于用户提交,不增加恢复门禁或模型输出要求。
|
||||
- hydrate 结果(包括空结果)写入前端状态前同时核对请求序列和当前项目路径;过期结果直接丢弃,不重试、不阻塞正常 run。
|
||||
|
||||
## 2026-09-09 manifest 资源功能分类与自定义标签接入 UI 与写入
|
||||
|
||||
- 决策:资源筛选口径收敛为单一权威 `category`。删除 `resourceReferences.ts` 里 mediaType 优先的 7 桶口径与 `resourceReferenceFilterKind`,`@` 面板与资源画布统一改用 `RESOURCE_REFERENCE_FILTERS`(`全部 / UI 交互 / 角色与对象 / 场景与环境 / 音频 / 文档 / 待归类`,即 `all` + 6 个合法分类);筛选与标签文案的中文名只在该常量定义一次。
|
||||
- 语义变化(必须记录):旧「图片」「视频」两个桶在 category 口径下不存在独立对应项。`icon / icon-spritesheet / icon-spec / ui-design` 进 `UI 交互`,`character / character-animation` 进 `角色与对象`,`scene` 进 `场景与环境`,`sound-effect / background-music / audio` 进 `音频`,`spec / document` 进 `文档`,`image / video / code / publication-material` 进 `待归类`。默认筛选项是 `全部`,不筛时行为不变。
|
||||
- 决策:`ResourceReference` 携带 `category` / `tags`,从 manifest 资产投影(`resourceReferenceFromAsset`);资源画布 `ProjectResource` 增加 `assetCategory` / `assetTags` 投影。任务产物、附件、Agent 回执与项目版本不是 manifest 资产、没有 `category` 事实源,统一按 `unclassified` 参与筛选,不按扩展名另造第二套分类。
|
||||
- 决策:唯一写入命令 `update_local_project_resource_classification`(`project/manifest.rs::update_manifest_asset_classification_at`),走 `mutate_manifest_at` 既有 manifest 写锁与原子写入,命令包装层只做 `asset.register` 权限校验;持项目写锁后按 `expectedProjectId` / `expectedProjectRevision` 做 CAS,冲突分别返回 `project-identity-conflict` 与 `project-revision-conflict`。`category` 非法即失败且不回退到 `kind` 派生;`tags` 走既有归一化语义;只改目标条目的 `category` / `tags`,不动 `GameCreationAppAssetManifestEntry` 字段构成与顺序。
|
||||
- 决策:画布筛选状态与画布视口一样按「按依赖 / 按类型」分别保存(`ResourceCanvasCategoryFilterBySortMode`),不新造第二套状态;`@` 面板与画布共用 `packages/shared` 的 `PlatformSegmentedTabs`,分类/标签编辑入口是资源详情面板里的独立面板(`ThemedModal`),保存后重读 manifest 并经 `onManifestChange` 反映到筛选与投影。
|
||||
- 边界:本轮不做标签管理面板、标签库聚合、`@` 面板按标签筛选、数据迁移脚本、Agent 检索工具与 `agc_list_registered_assets` 投影扩展。
|
||||
|
||||
## 2026-09-09 manifest 资源功能分类与自定义标签只做数据层
|
||||
|
||||
- 本地 manifest 资产条目末尾新增 `category`(单值,6 类,默认 `unclassified`)与 `tags`(字符串数组,默认空数组),字段始终序列化;不进 api-server、外部 OpenAPI 或 SpacetimeDB schema,也不新增 Tauri 写命令。
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
|
||||
保存配置复用写入前读取的高优先级本地覆盖内容:常用设置同步覆盖文件中已有的对应配置项,并保留当前模型 ID 和默认模型标记;模型选择仅同步 `selectedModelId` 与 `selectedModelIsDefault`;无冲突时不写覆盖文件。所有内容先完成序列化,多文件写入前保存原始内容,任一写入失败时逆序恢复已变更文件,回滚失败须明确报告。各文件沿用现有原子写入,不提供断电或进程崩溃下的多文件事务保证。全部成功后直接返回规范化配置,不执行保存后回读或外部诊断;单文件保存保持原路径。
|
||||
|
||||
## 2026-09-09 manifest 资源功能分类与自定义标签(UI 与写入)
|
||||
|
||||
资源筛选口径收敛为单一权威:`category`。`apps/ai-game-creator-shell/src/features/project-workspace/resourceReferences.ts` 删除按 mediaType 优先的 7 桶口径(`image / video / audio / ui / document / other` + `all`)与 `resourceReferenceFilterKind`,改为导出 `resourceReferenceCategory`、`resourceReferenceMatchesCategoryFilter`、`RESOURCE_REFERENCE_FILTERS`(`全部 / UI 交互 / 角色与对象 / 场景与环境 / 音频 / 文档 / 待归类`,对应 `all` + 6 个合法分类)与 `resourceReferenceCategoryLabel`。`ResourceReference` 新增 `category` 与 `tags`,由 `resourceReferenceFromAsset` 经 `gameCreationAppAssetCategory` / `gameCreationAppAssetTags` 从 manifest 资产投影;资源画布的 `ProjectResource` 同步投影 `assetCategory` / `assetTags`,非 manifest 资产(任务产物、附件、Agent 回执、项目版本)没有 `category` 事实源,统一按 `unclassified` 参与筛选,不按扩展名另造分类。语义变化:旧「图片」「视频」桶在 category 口径下不再独立存在,`image` / `video` / `code` / `publication-material` 归 `待归类`,`icon / icon-spritesheet / icon-spec / ui-design` 归 `UI 交互`,`character / character-animation` 归 `角色与对象`,`scene` 归 `场景与环境`。
|
||||
|
||||
分类与标签的唯一写入命令是 `update_local_project_resource_classification`(输入 `projectPath`、`expectedProjectId`、`expectedProjectRevision`、`assetId`、`category`、`tags`),实现落在 `project/manifest.rs` 的 `update_manifest_asset_classification_at`:走 `mutate_manifest_at` 既有 manifest 写锁与原子写入,命令包装层只做 `asset.register` 权限校验;持项目写锁后按 `expectedProjectId` 与当前项目 revision 做 CAS,不匹配分别返回 `project-identity-conflict` 与 `project-revision-conflict`。`category` 必须是 6 个合法值之一,非法值报错失败且不回退到 `kind` 派生;`tags` 走 `normalize_game_creation_app_asset_tags`(trim、去空、去重)。写入只改目标条目的 `category` / `tags`,保持 `GameCreationAppAssetManifestEntry` 字段构成与顺序不变,成功后项目 revision 前进一次。UI 上 `@` 面板与资源画布共用 `packages/shared` 的 `PlatformSegmentedTabs` 渲染同一组筛选标签,画布筛选状态与画布视口一样按「按依赖 / 按类型」分别保存;资源详情面板提供「分类与标签」独立面板(`ThemedModal` + `PlatformSegmentedTabs` + `PlatformTextField`)做最小编辑,保存后重读 manifest 并经 `onManifestChange` 反映到筛选与投影。
|
||||
|
||||
## 2026-09-09 manifest 资源功能分类与自定义标签(数据层)
|
||||
|
||||
本地 manifest 资产条目末尾新增 `category`(单值,6 类 `ui-interaction / character / scene / audio / document / unclassified`,默认 `unclassified`)与 `tags`(字符串数组,默认空数组);字段始终序列化,不进 api-server、外部 OpenAPI 或 SpacetimeDB schema。默认分类由 `GAME_CREATION_APP_ASSET_CATEGORY_BY_KIND` 按 canonical kind 穷举映射:`icon / icon-spritesheet / icon-spec / ui-design → ui-interaction`,`character / character-animation → character`,`scene → scene`,`audio / sound-effect / background-music → audio`,`document / spec → document`,`image / video / code / publication-material → unclassified`;映射不到 canonical kind 的原始 kind(例如 `font`、`UI`、`test`)经既有 `canonicalGameCreationAppAssetKind` 落 `image` 后同样归 `unclassified`。
|
||||
|
||||
Reference in New Issue
Block a user