Merge pull request '修复 AGC 栏目画布上传素材落「待归类」(#359)' (#596) from fix/agc-asset-upload-category into master
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled

Reviewed-on: #596
This commit was merged in pull request #596.
This commit is contained in:
2026-10-03 17:58:02 +08:00
12 changed files with 194 additions and 14 deletions
@@ -567,6 +567,23 @@ pub(crate) fn upload_local_asset_at(
file_name: &str,
media_type: &str,
bytes: &[u8],
) -> Result<UploadLocalAssetResult, String> {
upload_local_asset_at_with_category(root, file_name, media_type, bytes, None)
}
/// 带**显式入口栏目**的上传:只给 GUI 工具栏上传用(前端 `targetCategory`)。
///
/// 上传的 manifest `kind` 只由内容证据推导(图片 / 视频 / 代码 → `unclassified`),
/// 与栏目不是同一套词汇:在栏目画布里上传素材时,用户选定的栏目才是归属真相,
/// 否则素材会落进「待归类」、在上传它的那一栏里看不见。
/// 取值校验与失败关闭沿用 [`register_local_asset_entry_with_category`]:
/// 非法值报错、绝不回退到 kind 派生;其它调用方继续走 [`upload_local_asset_at`]。
pub(crate) fn upload_local_asset_at_with_category(
root: &Path,
file_name: &str,
media_type: &str,
bytes: &[u8],
target_category: Option<&str>,
) -> Result<UploadLocalAssetResult, String> {
if bytes.is_empty() {
return Err("上传文件不能为空".to_string());
@@ -584,7 +601,7 @@ pub(crate) fn upload_local_asset_at(
}
crate::write_game_creator_private_file(&absolute_path, bytes, "上传文件")?;
register_local_asset_entry(
register_local_asset_entry_with_category(
root,
&relative_path,
uploaded_asset_kind(file_name, media_type),
@@ -602,6 +619,7 @@ pub(crate) fn upload_local_asset_at(
generation_kind: None,
reference_resource_ids: Vec::new(),
},
target_category,
)
}
@@ -2375,6 +2393,70 @@ mod tests {
);
}
/// Issue 359:工具栏上传带上入口栏目,素材才不会落进「待归类」。
///
/// 上传的 manifest `kind` 只由内容证据推导,图片 / 视频 / 代码都派生成 `unclassified`;
/// 在栏目画布(如「角色与对象」)里上传时栏目才是归属真相。不传时保持既有行为,
/// 非法值失败关闭且不留下半成品登记。
#[test]
fn upload_registers_into_the_explicit_entry_category() {
fn upload_category(root: &Path, asset_id: &str) -> GameCreationAppAssetCategory {
read_existing_manifest_for_project(root)
.expect("read manifest")
.assets
.into_iter()
.find(|asset| asset.id == asset_id)
.expect("uploaded asset is registered")
.category
}
let temporary = tempfile::tempdir().expect("tempdir");
let root = temporary.path();
crate::project::init_local_game_project_at(root, "upload-category-test", "上传入口栏目")
.expect("init project");
let uploaded = upload_local_asset_at_with_category(
root,
"hero.png",
"image/png",
b"png-bytes",
Some("character"),
)
.expect("upload with an entry category");
assert_eq!(
upload_category(root, &uploaded.id),
GameCreationAppAssetCategory::Character
);
let plain = upload_local_asset_at(root, "plain.png", "image/png", b"png-bytes")
.expect("upload without an entry category");
assert_eq!(
upload_category(root, &plain.id),
GameCreationAppAssetCategory::Unclassified
);
// 非法入口栏目(栏目侧伪值 `version` 不在枚举里)失败关闭:不新增登记。
let assets_before = read_existing_manifest_for_project(root)
.expect("read manifest")
.assets
.len();
assert!(upload_local_asset_at_with_category(
root,
"bad.png",
"image/png",
b"png-bytes",
Some("version"),
)
.is_err());
assert_eq!(
read_existing_manifest_for_project(root)
.expect("read manifest")
.assets
.len(),
assets_before
);
}
/// 画板导出推断出的 kind 必须已经是 canonical 值。
#[test]
fn canvas_export_asset_kind_is_always_canonical() {
@@ -493,12 +493,19 @@ pub(crate) fn upload_local_asset(
file_name: String,
media_type: String,
bytes: Vec<u8>,
target_category: Option<String>,
) -> Result<UploadLocalAssetResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "asset.upload")?;
let _lock = acquire_project_write_lock(root, "asset.upload")?;
advance_agent_runtime_project_revision_locked(root)?;
upload_local_asset_at(root, file_name.trim(), media_type.trim(), &bytes)
upload_local_asset_at_with_category(
root,
file_name.trim(),
media_type.trim(),
&bytes,
target_category.as_deref(),
)
}
#[tauri::command]
@@ -99,7 +99,13 @@ export type ResourceCanvasAudioToolAction =
audioKind: ResourceCanvasGenerationKind;
};
/** 上传入口:复用既有 `upload_local_asset`。 */
/**
* 上传入口:复用既有 `upload_local_asset`。
*
* 调用方按当前栏目带 `targetCategory`(见 `uploadResourceCanvasToolbarFiles`)——上传的
* manifest `kind` 只由内容证据推导,图片 / 视频 / 代码都会派生成 `unclassified`,
* 不带入口栏目素材就落「待归类」、在上传它的那一栏里看不见。
*/
export type ResourceCanvasUploadToolAction =
ResourceCanvasBottomToolActionBase & {
route: 'upload';
@@ -3348,8 +3348,9 @@ export default function ProjectDevelopmentView({
* 两条判据缺一不可:
* 1. 这张卡已经进了布局(`resourcePositionById`),否则 reconcile 还没补位、写坐标是空操作;
* 2. 这张卡已经有正式归类(投影里的 `category`)——`commitPosition` 的 `section` 必须与该资源
* 在 sidecar 里的 section 一致,否则会被静默跳过。生成结果的实际归类与入口栏目本来就可能不同
* (普通图片落「待归类」、规范落「文档」),所以这里按**正式归类**写,而不是入口栏目。
* 在 sidecar 里的 section 一致,否则会被静默跳过。生成结果的实际归类与入口栏目**不一定相同**
* (Agent / Direct 路径不传 `targetCategory`,普通图片仍落「待归类」),所以这里按**正式归类**写,
* 而不是入口栏目。
*
* 写完撤掉占位:结果已经接管了它的位置。占位被用户先删掉时同样清掉这条意图(没有位置可接管)。
*/
@@ -9062,9 +9063,15 @@ export default function ProjectDevelopmentView({
[selectResourceCanvasPage],
);
/** 工具栏「上传」:与资源面板上传同一条「上传 + 配对读清单」链路。 */
/**
* 工具栏「上传」:与资源面板上传同一条「上传 + 配对读清单」链路。
*
* 入口栏目随上传一起交给原生(`targetCategory`):上传的 kind 只由内容证据推导
* (图片 / 视频 / 代码 → `unclassified`),不带上它,素材会落进「待归类」、
* 在上传它的那一栏里看不见。取值就是工具栏自己的栏目,与生成入口同一口径。
*/
const uploadResourceCanvasToolbarFiles = useCallback(
async (files: readonly File[]) => {
async (files: readonly File[], targetCategory: ResourceCategory | null) => {
const invoke = window.__TAURI__?.core?.invoke;
if (!invoke) {
setResourceWorkbenchNotice('上传素材需要在客户端内打开');
@@ -9088,6 +9095,7 @@ export default function ProjectDevelopmentView({
bytes: Array.from(new Uint8Array(await file.arrayBuffer())),
})),
),
targetCategory,
invoke,
});
setResourceWorkbenchNotice(`已上传 ${uploadable.length} 个素材`);
@@ -10548,7 +10556,10 @@ export default function ProjectDevelopmentView({
}
onSelectAction={handleResourceBottomToolAction}
onUploadFiles={(files) => {
void uploadResourceCanvasToolbarFiles(files);
void uploadResourceCanvasToolbarFiles(
files,
resourceCanvasBottomToolbarCategory,
);
}}
uploading={resourceBottomToolbarUploading}
/>
@@ -1,4 +1,7 @@
import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
import type {
GameCreationAppManifest,
ProjectResourceCanvasCategory,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
export type ProjectManifestSnapshotSource =
| 'initial'
@@ -285,6 +288,14 @@ export async function uploadProjectAssetFilesAndReadSnapshot(input: {
mediaType: string;
bytes: number[];
}[];
/**
* 入口栏目:栏目画布里上传时由调用方显式给出当前栏目,原生按它登记归类。
*
* 上传的 manifest `kind` 只由内容证据推导(图片 / 视频 / 代码 → `unclassified`),
* 不传这一项时素材会落进「待归类」、在上传它的那一栏里看不见。
* 判定与失败关闭都在原生:非法值报错,前端不做伪分类。
*/
targetCategory?: ProjectResourceCanvasCategory | null;
invoke<T>(command: string, args: Record<string, unknown>): Promise<T>;
}): Promise<ProjectManifestSnapshot | null> {
for (const file of input.files) {
@@ -293,6 +304,7 @@ export async function uploadProjectAssetFilesAndReadSnapshot(input: {
fileName: file.fileName,
mediaType: file.mediaType,
bytes: file.bytes,
...(input.targetCategory ? { targetCategory: input.targetCategory } : {}),
});
}
const fresh = await rereadAuthoritativeProjectManifestSnapshot({
@@ -8839,6 +8839,52 @@ export function registerProjectAgentStatusTests() {
);
}, 20_000);
it('uploads toolbar files into the entry column so they stay visible where they were uploaded', async () => {
// Issue 359:图片只由内容证据派生成 `unclassified`,在「角色与对象」栏目里上传后
// 会落进「待归类」、在上传它的那一栏里看不见。工具栏上传必须把当前栏目交给原生。
const manifest = createGameCreationAppManifest(
'workbench-bottom-toolbar-upload-category',
'底部工具栏上传归类项目',
);
const { calls } = installResourceBookBottomToolbarInvoke(manifest);
render(
React.createElement(ProjectDevelopmentView, {
projectName: manifest.name,
projectPath: '/tmp/workbench-bottom-toolbar-upload-category',
manifest,
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
chat: React.createElement('div', null, '项目总控'),
onHomeOpen: vi.fn(),
onProjectsOpen: vi.fn(),
}),
);
await openResourceBookCategory('角色与对象');
const uploadInput = screen.getByLabelText(
'上传素材文件',
) as HTMLInputElement;
const file = new File(['png'], 'hero.png', { type: 'image/png' });
if (typeof file.arrayBuffer !== 'function') {
Object.defineProperty(file, 'arrayBuffer', {
value: async () => new Uint8Array([112, 110, 103]).buffer,
});
}
fireEvent.change(uploadInput, { target: { files: [file] } });
await waitFor(() =>
expect(uploadCall(calls, 'hero.png')).toMatchObject({
projectPath: '/tmp/workbench-bottom-toolbar-upload-category',
fileName: 'hero.png',
mediaType: 'image/png',
targetCategory: 'character',
bytes: [112, 110, 103],
}),
);
}, 20_000);
it('keeps the bottom toolbar clear of the zoom dock and above the book scene', () => {
const styles = readFileSync(
repoPath('apps/ai-game-creator-shell/src/styles.css'),