Merge branch 'feat/agc-version-switch' into feat/agc-canvas-resource-workbench-v3

This commit is contained in:
2026-09-10 10:42:20 +08:00
15 changed files with 1030 additions and 73 deletions
@@ -1946,6 +1946,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] #[tauri::command]
pub(crate) async fn derive_local_project_resource( pub(crate) async fn derive_local_project_resource(
input: DeriveLocalProjectResourceInput, input: DeriveLocalProjectResourceInput,
@@ -26,8 +26,9 @@ use serde::{Deserialize, Serialize};
use sha2::Digest; use sha2::Digest;
use shared_contracts::error_reports::ErrorReportLogInput; use shared_contracts::error_reports::ErrorReportLogInput;
use shared_contracts::game_creation_app::{ use shared_contracts::game_creation_app::{
game_creation_app_asset_category_for_kind, new_game_creation_app_manifest, game_creation_app_asset_category_for_kind, game_creation_app_asset_category_from_str,
new_game_creation_app_seed_tasks, validate_game_iteration_versions, new_game_creation_app_manifest, new_game_creation_app_seed_tasks,
normalize_game_creation_app_asset_tags, validate_game_iteration_versions,
GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor, GameCreationAgentArtifactTrace, GameCreationAgentCapabilityDescriptor,
GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep, GameCreationAgentPassPlanTrace, GameCreationAgentRepairRouteTrace, GameCreationAgentRunStep,
GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace, GameCreationAgentRunTaskGraphTrace, GameCreationAgentRunTrace, GameCreationAgentToolCallTrace,
@@ -2613,6 +2614,7 @@ fn main() {
upload_local_asset, upload_local_asset,
register_local_asset, register_local_asset,
create_ui_design_resource, create_ui_design_resource,
update_local_project_resource_classification,
derive_local_project_resource, derive_local_project_resource,
list_pending_local_project_resource_edits, list_pending_local_project_resource_edits,
resume_local_project_resource_edit, resume_local_project_resource_edit,
@@ -1113,6 +1113,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( pub(crate) fn create_manifest_task_at(
root: &Path, root: &Path,
task_id: &str, task_id: &str,
@@ -1598,6 +1677,8 @@ pub(crate) fn trim_optional_string(value: Option<String>) -> Option<String> {
.filter(|value| !value.is_empty()) .filter(|value| !value.is_empty())
} }
#[cfg(test)]
mod classification_tests;
#[cfg(test)] #[cfg(test)]
mod import_tests; mod import_tests;
#[cfg(test)] #[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());
}
@@ -49,6 +49,7 @@ import {
} from 'react'; } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp'; import type { GameCreationAppAssetManifestEntry } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import { resolveTauriInvoke } from '../../app/tauri'; import { resolveTauriInvoke } from '../../app/tauri';
import { import {
@@ -67,9 +68,10 @@ import {
dedupeChatReferences, dedupeChatReferences,
RESOURCE_REFERENCE_FILTERS, RESOURCE_REFERENCE_FILTERS,
type ResourceReference, type ResourceReference,
resourceReferenceCategoryLabel,
type ResourceReferenceFilter, type ResourceReferenceFilter,
resourceReferenceFilterKind,
resourceReferenceFromAsset, resourceReferenceFromAsset,
resourceReferenceMatchesCategoryFilter,
resourceReferenceMatchesQuery, resourceReferenceMatchesQuery,
} from './resourceReferences'; } from './resourceReferences';
@@ -438,8 +440,7 @@ function ResourceReferenceEditor({
const pickerReferences = useMemo(() => { const pickerReferences = useMemo(() => {
return assetReferences.filter((reference) => { return assetReferences.filter((reference) => {
if (!resourceReferenceMatchesQuery(reference, pickerQuery)) return false; if (!resourceReferenceMatchesQuery(reference, pickerQuery)) return false;
if (pickerFilter === 'all') return true; return resourceReferenceMatchesCategoryFilter(reference, pickerFilter);
return resourceReferenceFilterKind(reference) === pickerFilter;
}); });
}, [assetReferences, pickerFilter, pickerQuery]); }, [assetReferences, pickerFilter, pickerQuery]);
@@ -564,18 +565,17 @@ function ResourceReferenceEditor({
} }
/> />
</div> </div>
<div className="resource-reference-picker-filters" role="group"> <PlatformSegmentedTabs
{RESOURCE_REFERENCE_FILTERS.map((filter) => ( items={RESOURCE_REFERENCE_FILTERS}
<button activeId={pickerFilter}
type="button" onChange={setPickerFilter}
key={filter.id} layout="scroll"
aria-pressed={pickerFilter === filter.id} gap="sm"
onClick={() => setPickerFilter(filter.id)} frame="bare"
> surface="transparent"
{filter.label} size="sm"
</button> className="platform-theme platform-theme--light resource-reference-picker-filters"
))} />
</div>
<div className="resource-reference-picker-list" role="listbox"> <div className="resource-reference-picker-list" role="listbox">
{visiblePickerReferences.length === 0 ? ( {visiblePickerReferences.length === 0 ? (
<p className="resource-reference-picker-empty"> <p className="resource-reference-picker-empty">
@@ -615,7 +615,8 @@ function ResourceReferenceEditor({
<span> <span>
<strong>{reference.label}</strong> <strong>{reference.label}</strong>
<small> <small>
{reference.kind} · {reference.mediaType} {resourceReferenceCategoryLabel(reference.category)}{' '}
· {reference.kind} · {reference.mediaType}
</small> </small>
</span> </span>
{selected ? ( {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 = export type ResourceReferenceSource =
| 'asset-picker' | 'asset-picker'
@@ -12,6 +17,8 @@ export type ResourceReference = {
kind: string; kind: string;
mediaType: string; mediaType: string;
label: string; label: string;
category: GameCreationAppAssetCategory;
tags: string[];
source: ResourceReferenceSource; source: ResourceReferenceSource;
}; };
@@ -72,6 +79,8 @@ export function resourceReferenceFromAsset(
kind: asset.kind, kind: asset.kind,
mediaType: asset.mediaType, mediaType: asset.mediaType,
label: resourceDisplayName(asset), label: resourceDisplayName(asset),
category: gameCreationAppAssetCategory(asset),
tags: gameCreationAppAssetTags(asset),
source, source,
}; };
} }
@@ -85,6 +94,8 @@ export function sameResourceReference(
left.kind === right.kind && left.kind === right.kind &&
left.mediaType === right.mediaType && left.mediaType === right.mediaType &&
left.label === right.label && left.label === right.label &&
left.category === right.category &&
left.tags.join('\u0000') === right.tags.join('\u0000') &&
left.source === right.source left.source === right.source
); );
} }
@@ -102,37 +113,45 @@ export function resourceReferenceMatchesQuery(
); );
} }
export function resourceReferenceFilterKind(reference: ResourceReference) { /**
const mediaType = reference.mediaType.toLowerCase(); * 资源筛选唯一权威口径:manifest 资产的功能分类(`category`)。
const kind = reference.kind.toLowerCase(); * `@` 面板与资源画布都用它筛选,不按 mediaType 另建第二套分类。
if (mediaType.startsWith('image/')) return 'image'; */
if (mediaType.startsWith('video/')) return 'video'; export function resourceReferenceCategory(
if (mediaType.startsWith('audio/')) return 'audio'; reference: ResourceReference,
if (mediaType === 'application/json' || kind === 'ui') return 'ui'; ): GameCreationAppAssetCategory {
if ( return reference.category;
mediaType.startsWith('text/') ||
mediaType === 'application/json' ||
kind === 'document' ||
kind === 'code'
) {
return 'document';
}
return 'other';
} }
export const RESOURCE_REFERENCE_FILTERS = [ export const RESOURCE_REFERENCE_FILTERS = [
{ id: 'all', label: '全部' }, { id: 'all', label: '全部' },
{ id: 'image', label: '图片' }, { id: 'ui-interaction', label: 'UI 交互' },
{ id: 'video', label: '视频' }, { id: 'character', label: '角色与对象' },
{ id: 'scene', label: '场景与环境' },
{ id: 'audio', label: '音频' }, { id: 'audio', label: '音频' },
{ id: 'ui', label: 'UI' },
{ id: 'document', label: '文档' }, { id: 'document', label: '文档' },
{ id: 'other', label: '其它' }, { id: 'unclassified', label: '待归类' },
] as const; ] as const;
export type ResourceReferenceFilter = export type ResourceReferenceFilter =
(typeof RESOURCE_REFERENCE_FILTERS)[number]['id']; (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) { function chatReferenceKey(reference: ChatReference) {
if (reference.type === 'resource') { if (reference.type === 'resource') {
return `resource:${reference.resourceId}:${reference.source}`; return `resource:${reference.resourceId}:${reference.source}`;
+19 -20
View File
@@ -4657,29 +4657,9 @@ h2 {
} }
.resource-reference-picker-filters { .resource-reference-picker-filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 0 12px 8px; 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 { .resource-reference-picker-list {
display: grid; display: grid;
gap: 4px; gap: 4px;
@@ -6136,6 +6116,25 @@ iframe.preview-frame {
font-size: 12px; 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 { .game-resource-live-notice {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -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 ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import { PlatformSegmentedTabs } from '../../../../../packages/shared/src/components/PlatformSegmentedTabs';
import type { import type {
GameCreationAppAgentGroup, GameCreationAppAgentGroup,
GameCreationAppAssetManifestEntry, GameCreationAppAssetManifestEntry,
@@ -56,7 +57,11 @@ import {
type LocalGamePreviewInspectSelection, type LocalGamePreviewInspectSelection,
resolveEmbeddedPreviewUrl, resolveEmbeddedPreviewUrl,
} from '../../features/project-workspace/LocalGamePreviewFrame'; } 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 { ensureUiDesignResourceForPrototype } from '../../features/ui-editor/uiDesignResourceBridge';
import { import {
currentPlatformSessionGeneration, currentPlatformSessionGeneration,
@@ -121,6 +126,7 @@ import {
type ProjectResourceCardPreviewState, type ProjectResourceCardPreviewState,
summarizeProjectResourceDocument, summarizeProjectResourceDocument,
} from './resourceCardPreviewModel'; } from './resourceCardPreviewModel';
import { ResourceClassificationPanel } from './ResourceClassificationPanel';
import { import {
EMPTY_PROJECT_RESOURCE_GRAPH, EMPTY_PROJECT_RESOURCE_GRAPH,
normalizeProjectResourceGraph, normalizeProjectResourceGraph,
@@ -146,6 +152,7 @@ import {
type ProjectAgentResultSummary, type ProjectAgentResultSummary,
type ProjectAttachmentResult, type ProjectAttachmentResult,
type ProjectResource, type ProjectResource,
projectResourceAssetCategory,
type ProjectResourceCategory, type ProjectResourceCategory,
projectResourcesFromReadModels, projectResourcesFromReadModels,
projectResourceTypeLabel, projectResourceTypeLabel,
@@ -304,6 +311,15 @@ type ResourceCanvasViewportBySortMode = Record<
ResourceSortMode, ResourceSortMode,
ResourceCanvasViewportByCategory ResourceCanvasViewportByCategory
>; >;
/** 功能分类筛选与画布视口一样按「按依赖 / 按类型」分别保存,不新造第二套状态。 */
type ResourceCanvasCategoryFilterBySortMode = Record<
ResourceSortMode,
ResourceReferenceFilter
>;
function defaultResourceCanvasCategoryFilters(): ResourceCanvasCategoryFilterBySortMode {
return { dependency: 'all', type: 'all' };
}
function defaultResourceCanvasViewports(): ResourceCanvasViewportBySortMode { function defaultResourceCanvasViewports(): ResourceCanvasViewportBySortMode {
const byCategory = (): ResourceCanvasViewportByCategory => ({ const byCategory = (): ResourceCanvasViewportByCategory => ({
@@ -851,6 +867,8 @@ const ResourceCard = memo(function ResourceCard({
kind: resource.subtype || resource.category, kind: resource.subtype || resource.category,
mediaType: resource.mediaType, mediaType: resource.mediaType,
label: resource.label, label: resource.label,
category: projectResourceAssetCategory(resource),
tags: resource.assetTags ?? [],
source: 'resource-card', source: 'resource-card',
}); });
}} }}
@@ -1175,6 +1193,10 @@ export default function ProjectDevelopmentView({
}); });
const [sortMode, setSortMode] = useState<ResourceSortMode>('dependency'); const [sortMode, setSortMode] = useState<ResourceSortMode>('dependency');
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [resourceCanvasCategoryFilters, setResourceCanvasCategoryFilters] =
useState<ResourceCanvasCategoryFilterBySortMode>(
defaultResourceCanvasCategoryFilters,
);
const [resourceCanvasViewports, setResourceCanvasViewports] = const [resourceCanvasViewports, setResourceCanvasViewports] =
useState<ResourceCanvasViewportBySortMode>(defaultResourceCanvasViewports); useState<ResourceCanvasViewportBySortMode>(defaultResourceCanvasViewports);
const resourceCanvasViewportTargetsRef = useRef( const resourceCanvasViewportTargetsRef = useRef(
@@ -1216,6 +1238,8 @@ export default function ProjectDevelopmentView({
); );
const [resourceRecoveryPanelOpen, setResourceRecoveryPanelOpen] = const [resourceRecoveryPanelOpen, setResourceRecoveryPanelOpen] =
useState(false); useState(false);
const [resourceClassificationAssetId, setResourceClassificationAssetId] =
useState<string | null>(null);
const [pendingResourceEditActionIds, setPendingResourceEditActionIds] = const [pendingResourceEditActionIds, setPendingResourceEditActionIds] =
useState<Set<string>>(() => new Set()); useState<Set<string>>(() => new Set());
const [pendingResourceEditActionErrors, setPendingResourceEditActionErrors] = const [pendingResourceEditActionErrors, setPendingResourceEditActionErrors] =
@@ -1728,19 +1752,26 @@ export default function ProjectDevelopmentView({
); );
}, [resources, selectedResourceId]); }, [resources, selectedResourceId]);
const normalizedSearch = searchText.trim().toLowerCase(); const normalizedSearch = searchText.trim().toLowerCase();
const resourceCategoryFilter = resourceCanvasCategoryFilters[sortMode];
const visibleResources = useMemo( const visibleResources = useMemo(
() => () =>
canvasResources.filter((resource) => canvasResources.filter((resource) => {
normalizedSearch if (
resourceCategoryFilter !== 'all' &&
projectResourceAssetCategory(resource) !== resourceCategoryFilter
) {
return false;
}
return normalizedSearch
? [ ? [
resource.label, resource.label,
resource.path, resource.path,
resource.mediaType, resource.mediaType,
resource.taskTitle ?? '', resource.taskTitle ?? '',
].some((value) => value.toLowerCase().includes(normalizedSearch)) ].some((value) => value.toLowerCase().includes(normalizedSearch))
: true, : true;
), }),
[canvasResources, normalizedSearch], [canvasResources, normalizedSearch, resourceCategoryFilter],
); );
const visibleResourceIds = useMemo( const visibleResourceIds = useMemo(
() => new Set(visibleResources.map((resource) => resource.id)), () => new Set(visibleResources.map((resource) => resource.id)),
@@ -2153,6 +2184,43 @@ export default function ProjectDevelopmentView({
const focusedResource = const focusedResource =
canvasResources.find((resource) => resource.id === focusedResourceId) ?? canvasResources.find((resource) => resource.id === focusedResourceId) ??
null; 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(() => { const focusedResourceDependencyDetails = useMemo(() => {
if (!focusedResource) { if (!focusedResource) {
return { return {
@@ -3805,6 +3873,18 @@ export default function ProjectDevelopmentView({
setSortMode(nextMode); setSortMode(nextMode);
} }
function showResourceCategoryFilter(nextFilter: ResourceReferenceFilter) {
if (nextFilter === resourceCategoryFilter) {
return;
}
advanceFocusGeneration();
setHiddenCommittedResourceId(null);
setResourceCanvasCategoryFilters((current) => ({
...current,
[sortMode]: nextFilter,
}));
}
const openUiDesignEditor = useCallback( const openUiDesignEditor = useCallback(
async (resource: ProjectResource) => { async (resource: ProjectResource) => {
const prototypeAssetId = resource.manifestAssetId; const prototypeAssetId = resource.manifestAssetId;
@@ -4898,6 +4978,19 @@ export default function ProjectDevelopmentView({
> >
</button> </button>
{focusedResource.manifestAssetId ? (
<button
type="button"
className="game-resource-focus-action"
onClick={() =>
setResourceClassificationAssetId(
focusedResource.manifestAssetId,
)
}
>
</button>
) : null}
<button <button
type="button" type="button"
className="game-resource-focus-close" className="game-resource-focus-close"
@@ -5250,6 +5343,17 @@ export default function ProjectDevelopmentView({
}} }}
/> />
</label> </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 ? ( {resourceWorkbenchNotice ? (
<div className="game-resource-live-notice" role="status"> <div className="game-resource-live-notice" role="status">
<span>{resourceWorkbenchNotice}</span> <span>{resourceWorkbenchNotice}</span>
@@ -5687,6 +5791,16 @@ export default function ProjectDevelopmentView({
</footer> </footer>
) : null} ) : null}
{resourceClassificationAsset ? (
<ResourceClassificationPanel
key={resourceClassificationAsset.id}
projectPath={projectPath}
projectId={manifest.projectId}
asset={resourceClassificationAsset}
onClose={() => setResourceClassificationAssetId(null)}
onSaved={(result) => void handleResourceClassificationSaved(result)}
/>
) : null}
{resourceRecoveryPanelOpen ? ( {resourceRecoveryPanelOpen ? (
<div <div
className="game-approval-backdrop" className="game-approval-backdrop"
@@ -1,7 +1,10 @@
import type { import {
GameCreationAppManifest, type GameCreationAppAssetCategory,
GameIterationVersion, gameCreationAppAssetCategory,
ProjectResourceCanvasSection, gameCreationAppAssetTags,
type GameCreationAppManifest,
type GameIterationVersion,
type ProjectResourceCanvasSection,
} from '../../../../../packages/shared/src/contracts/gameCreationApp'; } from '../../../../../packages/shared/src/contracts/gameCreationApp';
export type ProjectResourceCategory = ProjectResourceCanvasSection; export type ProjectResourceCategory = ProjectResourceCanvasSection;
@@ -45,6 +48,12 @@ export type ProjectResource = {
dependencyDepth: number; dependencyDepth: number;
content?: string; content?: string;
version?: ProjectVersionResourceSummary; version?: ProjectVersionResourceSummary;
/**
* manifest 资产条目的功能分类与自定义标签投影。只有登记在 manifest 的资产才有该口径;
* 任务产物、附件、Agent 回执与项目版本不是 manifest 资产,按筛选口径归入 `待归类`。
*/
assetCategory?: GameCreationAppAssetCategory;
assetTags?: string[];
imageSequenceFrames?: Array<{ imageSequenceFrames?: Array<{
imageSrc: string; imageSrc: string;
objectKey?: string | null; objectKey?: string | null;
@@ -177,6 +186,16 @@ export function projectResourceTypeLabel(
return '未知'; return '未知';
} }
/**
* 资源画布筛选口径:只有 manifest 资产携带功能分类,其余投影资源(任务产物、附件、
* Agent 回执与项目版本)没有 `category` 事实源,统一归 `待归类`,不按扩展名另造分类。
*/
export function projectResourceAssetCategory(
resource: Pick<ProjectResource, 'assetCategory'>,
): GameCreationAppAssetCategory {
return resource.assetCategory ?? 'unclassified';
}
function resourcePriority(resource: ProjectResource) { function resourcePriority(resource: ProjectResource) {
if (resource.manifestAssetId) { if (resource.manifestAssetId) {
return 4; return 4;
@@ -269,6 +288,8 @@ export function projectResourcesFromReadModels(
referenceResourceIds: asset.source.referenceResourceIds ?? [], referenceResourceIds: asset.source.referenceResourceIds ?? [],
dependencies: [], dependencies: [],
dependencyDepth: 0, dependencyDepth: 0,
assetCategory: gameCreationAppAssetCategory(asset),
assetTags: gameCreationAppAssetTags(asset),
imageSequenceFrames: asset.imageSequenceFrames ?? null, imageSequenceFrames: asset.imageSequenceFrames ?? null,
imageSequenceDurationMs: asset.imageSequenceDurationMs ?? null, imageSequenceDurationMs: asset.imageSequenceDurationMs ?? null,
}); });
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import { import {
projectResourceAssetCategory,
projectResourcesFromReadModels, projectResourcesFromReadModels,
projectResourceTypeLabel, projectResourceTypeLabel,
} from '../src/view/project-development/resourceProjectionModel'; } from '../src/view/project-development/resourceProjectionModel';
@@ -193,6 +194,61 @@ describe('项目资源投影', () => {
expect(first.map(({ id }) => id)).toEqual(second.map(({ id }) => id)); 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 投影版本并保留直接父子关系', () => { it('只从 manifest 投影版本并保留直接父子关系', () => {
const manifest = createGameCreationAppManifest( const manifest = createGameCreationAppManifest(
'version-projection', '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 ChatComposerDraft,
type ChatReference, type ChatReference,
dispatchResourceReferenceInsert, dispatchResourceReferenceInsert,
RESOURCE_REFERENCE_FILTERS,
RESOURCE_REFERENCE_INSERT_EVENT, RESOURCE_REFERENCE_INSERT_EVENT,
resourceReferenceFilterKind, resourceReferenceCategory,
resourceReferenceFromAsset, resourceReferenceFromAsset,
resourceReferenceMatchesCategoryFilter,
resourceReferenceMatchesQuery, resourceReferenceMatchesQuery,
} from '../src/features/project-workspace/resourceReferences'; } from '../src/features/project-workspace/resourceReferences';
@@ -122,14 +124,60 @@ describe('ResourceReferenceInput', () => {
).__TAURI__; ).__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 hero = resourceReferenceFromAsset(assets[0]!, 'asset-picker');
const theme = resourceReferenceFromAsset(assets[2]!, 'asset-picker'); const theme = resourceReferenceFromAsset(assets[2]!, 'asset-picker');
expect(resourceReferenceMatchesQuery(hero, 'her')).toBe(true); expect(resourceReferenceMatchesQuery(hero, 'her')).toBe(true);
expect(resourceReferenceMatchesQuery(hero, 'character')).toBe(true); expect(resourceReferenceMatchesQuery(hero, 'character')).toBe(true);
expect(resourceReferenceMatchesQuery(hero, 'missing')).toBe(false); expect(resourceReferenceMatchesQuery(hero, 'missing')).toBe(false);
expect(resourceReferenceFilterKind(hero)).toBe('image'); expect(resourceReferenceCategory(hero)).toBe('character');
expect(resourceReferenceFilterKind(theme)).toBe('audio'); 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', () => { 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 和当前问题;自由文本回答同样绑定问题,已完成回合保留幂等重放。此身份匹配服务于用户提交,不增加恢复门禁或模型输出要求。 - 问题回答携带被回答卡片的 questionId,在已有回合锁内核对 Session 和当前问题;自由文本回答同样绑定问题,已完成回合保留幂等重放。此身份匹配服务于用户提交,不增加恢复门禁或模型输出要求。
- hydrate 结果(包括空结果)写入前端状态前同时核对请求序列和当前项目路径;过期结果直接丢弃,不重试、不阻塞正常 run。 - 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 资源功能分类与自定义标签只做数据层 ## 2026-09-09 manifest 资源功能分类与自定义标签只做数据层
- 本地 manifest 资产条目末尾新增 `category`(单值,6 类,默认 `unclassified`)与 `tags`(字符串数组,默认空数组),字段始终序列化;不进 api-server、外部 OpenAPI 或 SpacetimeDB schema,也不新增 Tauri 写命令。 - 本地 manifest 资产条目末尾新增 `category`(单值,6 类,默认 `unclassified`)与 `tags`(字符串数组,默认空数组),字段始终序列化;不进 api-server、外部 OpenAPI 或 SpacetimeDB schema,也不新增 Tauri 写命令。
@@ -14,6 +14,12 @@
保存配置复用写入前读取的高优先级本地覆盖内容:常用设置同步覆盖文件中已有的对应配置项,并保留当前模型 ID 和默认模型标记;模型选择仅同步 `selectedModelId``selectedModelIsDefault`;无冲突时不写覆盖文件。所有内容先完成序列化,多文件写入前保存原始内容,任一写入失败时逆序恢复已变更文件,回滚失败须明确报告。各文件沿用现有原子写入,不提供断电或进程崩溃下的多文件事务保证。全部成功后直接返回规范化配置,不执行保存后回读或外部诊断;单文件保存保持原路径。 保存配置复用写入前读取的高优先级本地覆盖内容:常用设置同步覆盖文件中已有的对应配置项,并保留当前模型 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 资源功能分类与自定义标签(数据层) ## 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` 本地 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`